390 lines
15 KiB
TypeScript
390 lines
15 KiB
TypeScript
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", "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 ["quota", "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.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,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: "" },
|
|
},
|
|
]);
|
|
expect(fresh.experimental).toEqual({ subagent_depth: 2 });
|
|
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);
|
|
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",
|
|
// 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("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);
|
|
}
|
|
});
|