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 empty = { agents: { "fast-lo": "", "fast-hi": "", "good-lo": "", "good-hi": "", "deep-lo": "", "deep-hi": "", }, }; 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 = { agents: { ...empty.agents, "fast-lo": "openai/gpt-6-luna#low" }, }; 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: empty, }, { 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: empty, }, { 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: empty, }, { package: "./plugins/stanp-opencode/multi-work" }, { package: "./plugins/stanp-opencode/quota" }, "after", ]); }); } test("installer writes empty option skeletons, fills only missing keys, and is idempotent", async () => { const f = await fixture(); await install(f.source, f.config, "agents,multi-work,quota,optchat"); const fresh = await contents(f.config); expect(fresh.plugins.slice(1)).toEqual([ { package: "./plugins/stanp-opencode/agents", options: empty }, { package: "./plugins/stanp-opencode/multi-work" }, { package: "./plugins/stanp-opencode/quota" }, { package: "./plugins/stanp-opencode/optchat", options: { compactionModel: "", dataDir: "" }, }, ]); fresh.plugins[1].options = { agents: { "fast-lo": "openai/gpt-6-luna#low" }, extra: 1 }; fresh.plugins[4].options = { compactionModel: "acme/cheap" }; await writeFile(join(f.config, "opencode.json"), JSON.stringify(fresh)); await install(f.source, f.config); const filled = await contents(f.config); expect(filled.plugins[1].options).toEqual({ agents: { ...empty.agents, "fast-lo": "openai/gpt-6-luna#low" }, extra: 1, }); expect(filled.plugins[4].options).toEqual({ compactionModel: "acme/cheap", dataDir: "" }); expect(filled).toMatchObject(unrelated); await install(f.source, f.config); expect(await contents(f.config)).toEqual(filled); }); test("agents installs set experimental.subagent_depth to at least 2", async () => { const f = await fixture(); await install(f.source, f.config, "quota"); expect((await contents(f.config)).experimental).toBeUndefined(); await install(f.source, f.config, "agents"); const fresh = await contents(f.config); expect(fresh.experimental).toEqual({ subagent_depth: 2 }); expect(fresh).toMatchObject(unrelated); for (const [depth, expected] of [ [1, 2], [3, 3], ]) { await writeFile( join(f.config, "opencode.json"), JSON.stringify({ ...fresh, experimental: { subagent_depth: depth, other: true } }), ); await install(f.source, f.config); const result = await contents(f.config); expect(result.experimental).toEqual({ subagent_depth: expected, other: true }); await install(f.source, f.config); expect(await contents(f.config)).toEqual(result); } }); 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: { agents: { "fast-lo": "openai/gpt-6-luna#low" } }, }, { 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.agents["fast-lo"]).toBe("openai/gpt-6-luna#low"); 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); } });