487 lines
19 KiB
TypeScript
487 lines
19 KiB
TypeScript
import { afterEach, expect, test } from "bun:test";
|
|
import {
|
|
chmod,
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
readdir,
|
|
rm,
|
|
stat,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { createHash } from "node:crypto";
|
|
import { install } from "../scripts/install.js";
|
|
|
|
const directories: string[] = [];
|
|
afterEach(async () => {
|
|
for (const path of directories.splice(0)) await rm(path, { recursive: true, force: true });
|
|
});
|
|
const unrelated = {
|
|
theme: "dark",
|
|
permissions: { shell: "ask" },
|
|
mcp: {
|
|
servers: {
|
|
codegraph: { type: "local", command: ["user-codegraph"], codemode: true },
|
|
browser: { type: "local", command: ["user-browser"] },
|
|
},
|
|
},
|
|
};
|
|
const names = ["agents", "multi-work", "quota", "optchat"];
|
|
const helper = new URL("../scripts/install.ts", import.meta.url);
|
|
async function run(
|
|
command: string[],
|
|
cwd: string,
|
|
env: NodeJS.ProcessEnv = Bun.env,
|
|
input?: string,
|
|
) {
|
|
const process = Bun.spawn(command, {
|
|
cwd,
|
|
env,
|
|
stdin: input === undefined ? "ignore" : "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
timeout: 30_000,
|
|
});
|
|
if (input !== undefined && process.stdin) {
|
|
process.stdin.write(input);
|
|
process.stdin.end();
|
|
}
|
|
const [exitCode, stdout, stderr] = await Promise.all([
|
|
process.exited,
|
|
new Response(process.stdout).text(),
|
|
new Response(process.stderr).text(),
|
|
]);
|
|
return { exitCode, stdout, stderr };
|
|
}
|
|
async function fixture() {
|
|
const directory = await mkdtemp(join(tmpdir(), "plugin-install-"));
|
|
directories.push(directory);
|
|
const source = join(directory, "package with spaces");
|
|
const config = join(directory, "config with spaces");
|
|
await mkdir(join(source, "dist"), { recursive: true });
|
|
await mkdir(join(source, "scripts"));
|
|
await mkdir(config);
|
|
for (const name of names)
|
|
await writeFile(
|
|
join(source, "dist", `${name === "quota" ? "quota-plugin" : name}.js`),
|
|
`export default { id: "stanp-opencode.${name}", setup() {} };`,
|
|
);
|
|
await writeFile(
|
|
join(source, "dist", "tui.js"),
|
|
'export default { id: "stanp-opencode.quota-tui", setup() {} };',
|
|
);
|
|
const built = await Bun.build({ entrypoints: [helper.pathname], target: "bun", format: "esm" });
|
|
expect(built.success).toBe(true);
|
|
await Bun.write(join(source, "dist", "install.js"), built.outputs[0]!);
|
|
await writeFile(join(source, "package.json"), '{"type":"module"}');
|
|
await writeFile(
|
|
join(config, "opencode.json"),
|
|
JSON.stringify({ ...unrelated, plugins: [{ package: "before", options: { keep: true } }] }),
|
|
);
|
|
await writeFile(join(config, "opencode.jsonc"), "// untouched user config\n{}\n");
|
|
return { directory, source, config };
|
|
}
|
|
async function contents(config: string) {
|
|
return JSON.parse(await readFile(join(config, "opencode.json"), "utf8"));
|
|
}
|
|
|
|
for (const selection of [
|
|
"agents",
|
|
"quota",
|
|
"agents,quota",
|
|
"agents,multi-work",
|
|
"agents,multi-work,quota",
|
|
"optchat",
|
|
"agents,multi-work,quota,optchat",
|
|
]) {
|
|
test(`installs valid subset ${selection}, preserving unrelated config and MCP`, async () => {
|
|
const f = await fixture();
|
|
await install(f.source, f.config, selection);
|
|
const result = await contents(f.config);
|
|
expect(result).toMatchObject(unrelated);
|
|
expect(result.plugins.map((entry: { package: string }) => entry.package)).toEqual([
|
|
"before",
|
|
...selection.split(",").map((name) => `./plugins/stanp-opencode/${name}`),
|
|
]);
|
|
const target = join(f.config, "plugins", "stanp-opencode");
|
|
expect((await readdir(target)).sort()).toEqual(selection.split(",").sort());
|
|
for (const name of selection.split(",")) {
|
|
const manifest = await Bun.file(join(target, name, "package.json")).json();
|
|
expect(manifest.exports["./tui"] !== undefined).toBe(name === "quota");
|
|
expect(await Bun.file(join(target, name, "tui.js")).exists()).toBe(name === "quota");
|
|
}
|
|
expect(await readFile(join(f.config, "opencode.jsonc"), "utf8")).toBe(
|
|
"// untouched user config\n{}\n",
|
|
);
|
|
});
|
|
}
|
|
|
|
test("upgrades retain selection and options; explicit selection replaces only owned registrations", async () => {
|
|
const f = await fixture();
|
|
await install(f.source, f.config, "agents,quota");
|
|
const current = await contents(f.config);
|
|
current.plugins[1].options = { mode: "gpt", provider: "openai", lunaPriority: false };
|
|
current.plugins.push({ package: "after", options: { untouched: 1 } });
|
|
await writeFile(join(f.config, "opencode.json"), JSON.stringify(current));
|
|
await install(f.source, f.config);
|
|
expect((await contents(f.config)).plugins).toEqual(current.plugins);
|
|
await install(f.source, f.config, "quota");
|
|
expect((await contents(f.config)).plugins).toEqual([
|
|
current.plugins[0],
|
|
{ package: "./plugins/stanp-opencode/quota" },
|
|
current.plugins[3],
|
|
]);
|
|
});
|
|
|
|
test("mixed string/object registrations upgrade without flags and preserve unrelated entries verbatim", async () => {
|
|
const f = await fixture();
|
|
const before = "unrelated-plugin";
|
|
const after = { package: "unrelated-object", options: { keep: [1, true, "value"] } };
|
|
await writeFile(
|
|
join(f.config, "opencode.json"),
|
|
JSON.stringify({
|
|
...unrelated,
|
|
plugins: [before, { package: "./plugins/stanp-opencode/quota" }, after],
|
|
}),
|
|
);
|
|
await install(f.source, f.config);
|
|
expect((await contents(f.config)).plugins).toEqual([
|
|
before,
|
|
{ package: "./plugins/stanp-opencode/quota" },
|
|
after,
|
|
]);
|
|
await install(f.source, f.config, "agents,quota");
|
|
const updated = await contents(f.config);
|
|
expect(updated.plugins).toEqual([
|
|
before,
|
|
{
|
|
package: "./plugins/stanp-opencode/agents",
|
|
options: { mode: "gpt", provider: "openai", lunaPriority: true },
|
|
},
|
|
{ package: "./plugins/stanp-opencode/quota" },
|
|
after,
|
|
]);
|
|
expect(updated).toMatchObject(unrelated);
|
|
});
|
|
|
|
test("owned string registrations retain their selection and explicit selection removes mixed duplicates", async () => {
|
|
const f = await fixture();
|
|
await writeFile(
|
|
join(f.config, "opencode.json"),
|
|
JSON.stringify({
|
|
plugins: [
|
|
"before",
|
|
"./plugins/stanp-opencode/agents",
|
|
"./plugins/stanp-opencode/multi-work",
|
|
"./plugins/stanp-opencode/quota",
|
|
{ package: "stanp-opencode/quota" },
|
|
"after",
|
|
],
|
|
}),
|
|
);
|
|
await install(f.source, f.config);
|
|
const retained = await contents(f.config);
|
|
expect(retained.plugins).toEqual([
|
|
"before",
|
|
{
|
|
package: "./plugins/stanp-opencode/agents",
|
|
options: { mode: "gpt", provider: "openai", lunaPriority: true },
|
|
},
|
|
{ package: "./plugins/stanp-opencode/multi-work" },
|
|
{ package: "./plugins/stanp-opencode/quota" },
|
|
"after",
|
|
]);
|
|
await install(f.source, f.config, "quota");
|
|
expect((await contents(f.config)).plugins).toEqual([
|
|
"before",
|
|
{ package: "./plugins/stanp-opencode/quota" },
|
|
"after",
|
|
]);
|
|
});
|
|
|
|
for (const target of ["./plugins/stanp-opencode", "stanp-opencode@1.0.0", "absolute"]) {
|
|
test(`owned monolith string ${target} upgrades without a selection`, async () => {
|
|
const f = await fixture();
|
|
const packageName =
|
|
target === "absolute" ? join(f.config, "plugins", "stanp-opencode") : target;
|
|
await writeFile(
|
|
join(f.config, "opencode.json"),
|
|
JSON.stringify({ plugins: ["before", packageName, "after"] }),
|
|
);
|
|
await install(f.source, f.config);
|
|
expect((await contents(f.config)).plugins).toEqual([
|
|
"before",
|
|
{
|
|
package: "./plugins/stanp-opencode/agents",
|
|
options: { mode: "gpt", provider: "openai", lunaPriority: true },
|
|
},
|
|
{ package: "./plugins/stanp-opencode/multi-work" },
|
|
{ package: "./plugins/stanp-opencode/quota" },
|
|
"after",
|
|
]);
|
|
});
|
|
}
|
|
|
|
test("local installer retains executable permissions and supports direct invocation", async () => {
|
|
const f = await fixture();
|
|
const path = new URL("../install-local.sh", import.meta.url).pathname;
|
|
expect((await stat(path)).mode & 0o777).toBe(0o755);
|
|
const result = await run([path, "--plugins", "multi-work"], f.directory);
|
|
expect(result.exitCode).toBe(2);
|
|
expect(result.stderr).toContain("Multi-work requires Agents");
|
|
});
|
|
|
|
test("replaces the legacy monolith without duplicates and retains Agents options", async () => {
|
|
const f = await fixture();
|
|
await writeFile(
|
|
join(f.config, "opencode.json"),
|
|
JSON.stringify({
|
|
...unrelated,
|
|
plugins: [
|
|
{ package: "before" },
|
|
{
|
|
package: "./plugins/stanp-opencode",
|
|
options: { mode: "gpt", provider: "openai", lunaPriority: false },
|
|
},
|
|
{ package: "stanp-opencode" },
|
|
{ package: "after" },
|
|
],
|
|
}),
|
|
);
|
|
await mkdir(join(f.config, "plugins"));
|
|
await writeFile(join(f.config, "plugins", "stanp-opencode.js"), "legacy");
|
|
await install(f.source, f.config);
|
|
const result = await contents(f.config);
|
|
expect(result.plugins.map((entry: { package: string }) => entry.package)).toEqual([
|
|
"before",
|
|
// The monolith never had Optchat, so upgrading it does not add Optchat.
|
|
...names
|
|
.filter((name) => name !== "optchat")
|
|
.map((name) => `./plugins/stanp-opencode/${name}`),
|
|
"after",
|
|
]);
|
|
expect(result.plugins[1].options.lunaPriority).toBe(false);
|
|
expect(await Bun.file(join(f.config, "plugins", "stanp-opencode.js")).exists()).toBe(false);
|
|
expect(result).toMatchObject(unrelated);
|
|
});
|
|
|
|
test("noninteractive fresh installs require a selection; invalid selections leave config unchanged", async () => {
|
|
const f = await fixture();
|
|
const original = await readFile(join(f.config, "opencode.json"), "utf8");
|
|
await expect(install(f.source, f.config)).rejects.toThrow("requires --plugins");
|
|
for (const selection of [
|
|
"multi-work",
|
|
"multi-work,quota",
|
|
"",
|
|
"agents,agents",
|
|
"personal",
|
|
"agents,",
|
|
])
|
|
await expect(install(f.source, f.config, selection)).rejects.toThrow();
|
|
expect(await readFile(join(f.config, "opencode.json"), "utf8")).toBe(original);
|
|
});
|
|
|
|
test("interactive Multi-work-only selection offers Agents", async () => {
|
|
const f = await fixture();
|
|
const command = `${process.execPath} '${helper.pathname}' '${f.source}' '${f.config}'`;
|
|
const child = Bun.spawn(["script", "-qec", command, "/dev/null"], {
|
|
cwd: f.directory,
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
timeout: 10_000,
|
|
});
|
|
let output = "";
|
|
let selected = false;
|
|
let confirmed = false;
|
|
const reader = child.stdout.getReader();
|
|
while (true) {
|
|
const chunk = await reader.read();
|
|
if (chunk.done) break;
|
|
output += new TextDecoder().decode(chunk.value);
|
|
if (!selected && output.includes("Plugins (")) {
|
|
child.stdin.write("multi-work\n");
|
|
selected = true;
|
|
}
|
|
if (!confirmed && output.includes("Add Agents?")) {
|
|
child.stdin.write("y\n");
|
|
confirmed = true;
|
|
}
|
|
}
|
|
expect(await child.exited, output).toBe(0);
|
|
child.stdin.end();
|
|
expect(output).toContain("Add Agents?");
|
|
expect(
|
|
(await contents(f.config)).plugins
|
|
.slice(1)
|
|
.map((entry: { package: string }) => entry.package),
|
|
).toEqual(["./plugins/stanp-opencode/agents", "./plugins/stanp-opencode/multi-work"]);
|
|
});
|
|
|
|
test("artifact validation failure leaves prior installation and config intact", async () => {
|
|
const f = await fixture();
|
|
await install(f.source, f.config, "quota");
|
|
const original = await readFile(join(f.config, "opencode.json"), "utf8");
|
|
await writeFile(
|
|
join(f.source, "dist", "agents.js"),
|
|
'export default { id: "wrong", server() {} };',
|
|
);
|
|
// Import in a new process to avoid the runtime's module cache from prior fixture validation.
|
|
const result = await run(
|
|
[process.execPath, helper.pathname, f.source, f.config, "agents"],
|
|
f.directory,
|
|
);
|
|
expect(result.exitCode).not.toBe(0);
|
|
expect(await readFile(join(f.config, "opencode.json"), "utf8")).toBe(original);
|
|
expect(
|
|
await Bun.file(join(f.config, "plugins", "stanp-opencode", "quota", "index.js")).exists(),
|
|
).toBe(true);
|
|
expect(await readdir(join(f.config, "plugins"))).toEqual(["stanp-opencode"]);
|
|
});
|
|
|
|
test("replaces an owned installation symlink without touching its target or unrelated plugin directories", async () => {
|
|
const f = await fixture();
|
|
const external = join(f.directory, "old installation");
|
|
const sibling = join(f.config, "plugins", "unrelated");
|
|
await mkdir(external);
|
|
await mkdir(sibling, { recursive: true });
|
|
await writeFile(join(external, "keep.txt"), "old work is untouched");
|
|
await writeFile(join(sibling, "index.js"), "unrelated plugin");
|
|
await symlink(external, join(f.config, "plugins", "stanp-opencode"));
|
|
await install(f.source, f.config, "quota");
|
|
expect(await readFile(join(external, "keep.txt"), "utf8")).toBe("old work is untouched");
|
|
expect(await readFile(join(sibling, "index.js"), "utf8")).toBe("unrelated plugin");
|
|
expect(
|
|
await Bun.file(join(f.config, "plugins", "stanp-opencode", "quota", "index.js")).exists(),
|
|
).toBe(true);
|
|
});
|
|
|
|
test("both shell installers reject explicit Multi-work-only selection before doing work", async () => {
|
|
const f = await fixture();
|
|
for (const file of ["install.sh", "install-local.sh"]) {
|
|
const result = await run(
|
|
["bash", new URL(`../${file}`, import.meta.url).pathname, "--plugins", "multi-work"],
|
|
f.directory,
|
|
);
|
|
expect(result.exitCode).toBe(2);
|
|
expect(result.stderr).toContain("Multi-work requires Agents");
|
|
}
|
|
expect((await contents(f.config)).plugins).toHaveLength(1);
|
|
});
|
|
|
|
test("local shell installer builds its checkout and upgrades mixed entries without flags", async () => {
|
|
const f = await fixture();
|
|
const after = { package: "unrelated-object", options: { keep: true } };
|
|
await writeFile(
|
|
join(f.config, "opencode.json"),
|
|
JSON.stringify({
|
|
plugins: ["unrelated-plugin", { package: "./plugins/stanp-opencode/quota" }, after],
|
|
}),
|
|
);
|
|
await Bun.write(
|
|
join(f.source, "install-local.sh"),
|
|
Bun.file(new URL("../install-local.sh", import.meta.url)),
|
|
);
|
|
const bin = join(f.directory, "bin");
|
|
await mkdir(bin);
|
|
await writeFile(
|
|
join(bin, "bun"),
|
|
`#!/usr/bin/env bash\nset -euo pipefail\nif [[ "$1" == install || "$1" == run ]]; then printf '%s\\n' "$PWD:$*" >> "$CALLS"; exit; fi\nexec "$REAL_BUN" "$@"\n`,
|
|
);
|
|
await chmod(join(bin, "bun"), 0o755);
|
|
const env = {
|
|
...Bun.env,
|
|
PATH: `${bin}:${dirname(process.execPath)}:${Bun.env.PATH}`,
|
|
REAL_BUN: process.execPath,
|
|
CALLS: join(f.directory, "calls"),
|
|
OPENCODE_CONFIG_DIR: f.config,
|
|
};
|
|
const result = await run(["bash", join(f.source, "install-local.sh")], f.directory, env);
|
|
expect(result, result.stderr).toMatchObject({ exitCode: 0 });
|
|
expect(await readFile(env.CALLS, "utf8")).toBe(
|
|
`${f.source}:install --frozen-lockfile\n${f.source}:run build\n`,
|
|
);
|
|
expect((await contents(f.config)).plugins).toEqual([
|
|
"unrelated-plugin",
|
|
{ package: "./plugins/stanp-opencode/quota" },
|
|
after,
|
|
]);
|
|
});
|
|
|
|
for (const validChecksum of [true, false])
|
|
test(`release shell installer verifies packaged checksum (${validChecksum})`, async () => {
|
|
const f = await fixture();
|
|
if (validChecksum)
|
|
await writeFile(
|
|
join(f.config, "opencode.json"),
|
|
JSON.stringify({ plugins: ["unrelated-plugin", "./plugins/stanp-opencode/quota"] }),
|
|
);
|
|
const archiveRoot = join(f.directory, "archive");
|
|
await mkdir(archiveRoot);
|
|
const packageRoot = join(archiveRoot, "package");
|
|
await mkdir(packageRoot);
|
|
// Build the release fixture under the actual archive package prefix.
|
|
await run(["cp", "-R", `${f.source}/.`, packageRoot], f.directory);
|
|
const archive = join(f.directory, "stanp-opencode.tgz");
|
|
expect(
|
|
(await run(["tar", "-czf", archive, "-C", archiveRoot, "package"], f.directory))
|
|
.exitCode,
|
|
).toBe(0);
|
|
const hash = createHash("sha256")
|
|
.update(new Uint8Array(await Bun.file(archive).arrayBuffer()))
|
|
.digest("hex");
|
|
const checksum = join(f.directory, "checksum");
|
|
await writeFile(checksum, `${validChecksum ? hash : "0".repeat(64)} stanp-opencode.tgz\n`);
|
|
const release = join(f.directory, "release.json");
|
|
await writeFile(
|
|
release,
|
|
JSON.stringify({
|
|
tag_name: "v2",
|
|
assets: [
|
|
{
|
|
id: 1,
|
|
name: "stanp-opencode.tgz",
|
|
browser_download_url: "https://fixture/archive",
|
|
},
|
|
{
|
|
id: 2,
|
|
name: "stanp-opencode.tgz.sha256",
|
|
browser_download_url: "https://fixture/checksum",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const bin = join(f.directory, "bin");
|
|
await mkdir(bin);
|
|
await writeFile(
|
|
join(bin, "curl"),
|
|
`#!/usr/bin/env bash\nset -euo pipefail\nfor ((i=1;i<=$#;i++)); do if [[ "\${!i}" == -o ]]; then j=$((i+1)); output="\${!j}"; fi; done\ncase "$*" in *https://fixture/archive*) cp "$ARCHIVE" "$output";; *https://fixture/checksum*) cp "$CHECKSUM" "$output";; *) cp "$RELEASE" "$output";; esac\n`,
|
|
);
|
|
await chmod(join(bin, "curl"), 0o755);
|
|
const result = await run(
|
|
["bash", new URL("../install.sh", import.meta.url).pathname],
|
|
f.directory,
|
|
{
|
|
...Bun.env,
|
|
PATH: `${bin}:${dirname(process.execPath)}:${Bun.env.PATH}`,
|
|
ARCHIVE: archive,
|
|
CHECKSUM: checksum,
|
|
RELEASE: release,
|
|
OPENCODE_CONFIG_DIR: f.config,
|
|
},
|
|
);
|
|
expect(result.exitCode, result.stderr).toBe(validChecksum ? 0 : 1);
|
|
if (validChecksum)
|
|
expect((await contents(f.config)).plugins).toEqual([
|
|
"unrelated-plugin",
|
|
{ package: "./plugins/stanp-opencode/quota" },
|
|
]);
|
|
else {
|
|
expect(result.stderr).toContain("checksum mismatch");
|
|
expect((await contents(f.config)).plugins).toHaveLength(1);
|
|
}
|
|
});
|