import { afterEach, expect, test } from "bun:test"; import { chmod, mkdir, mkdtemp, readFile, readdir, rm, 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"]; 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 ["quota", "agents,multi-work,quota"]) { 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.experimental === undefined).toBe(selection === "quota"); 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("installer writes empty option skeletons and subagent_depth, fills only missing keys, and is idempotent", async () => { const f = await fixture(); await install(f.source, f.config, "agents,multi-work,quota"); 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" }, ]); expect(fresh.experimental).toEqual({ subagent_depth: 2 }); fresh.plugins[1].options = { agents: { "fast-lo": "openai/gpt-6-luna#low" }, extra: 1, }; 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).toMatchObject(unrelated); await install(f.source, f.config); expect(await contents(f.config)).toEqual(filled); for (const [depth, expected] of [ [1, 2], [3, 3], ]) { await writeFile( join(f.config, "opencode.json"), JSON.stringify({ ...filled, experimental: { subagent_depth: depth, other: true }, }), ); await install(f.source, f.config); expect((await contents(f.config)).experimental).toEqual({ subagent_depth: expected, other: true, }); } }); test("local installer supports direct invocation", async () => { const f = await fixture(); const path = new URL("../install-local.sh", import.meta.url).pathname; 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", ...names.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("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("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); } });