This commit is contained in:
+38
-6
@@ -14,7 +14,18 @@ import type { ToolEditor } from "@opencode/plugin/effect/tool";
|
||||
import type { SkillEditor } from "@opencode/plugin/effect/skill";
|
||||
import type { SessionHooks } from "@opencode/plugin/effect/session";
|
||||
import { z } from "zod";
|
||||
import plugin from "../src/index.js";
|
||||
import agentsPlugin from "../src/agents.js";
|
||||
import multiWorkPlugin from "../src/multi-work.js";
|
||||
import { fromPromise } from "@opencode/plugin/promise/adapter";
|
||||
import { AgentsRpc } from "../src/agents-rpc.js";
|
||||
|
||||
const plugin = {
|
||||
effect: (context: Context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* fromPromise(agentsPlugin).effect(context);
|
||||
yield* multiWorkPlugin.effect(context);
|
||||
}),
|
||||
};
|
||||
import { command } from "../src/herdr.js";
|
||||
|
||||
// This is a composite plugin host fixture, not OpenCode's native executor/job runtime.
|
||||
@@ -127,6 +138,7 @@ function host(fixture: Awaited<ReturnType<typeof scenario>>, directory: string)
|
||||
PubSub.unbounded<Schema.Schema.Type<typeof Agent.Event.Updated>>(),
|
||||
);
|
||||
const registration = Effect.succeed({ dispose: Effect.void });
|
||||
let permissions: (() => Effect.Effect<Agent.Info["permissions"]>) | undefined;
|
||||
const editAgents: AgentEditor = {
|
||||
get: (id) => agents.get(id),
|
||||
list: () => [...agents.values()],
|
||||
@@ -234,7 +246,20 @@ function host(fixture: Awaited<ReturnType<typeof scenario>>, directory: string)
|
||||
generate: {},
|
||||
provider: {},
|
||||
permission: {},
|
||||
plugin: {},
|
||||
plugin: {
|
||||
list: () =>
|
||||
Effect.succeed({
|
||||
location,
|
||||
data: [
|
||||
{
|
||||
id: "stanp-opencode.multi-work",
|
||||
source: { type: "builtin" },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
reference: {},
|
||||
skill: {
|
||||
transform: (callback: (editor: SkillEditor) => void) =>
|
||||
@@ -270,10 +295,17 @@ function host(fixture: Awaited<ReturnType<typeof scenario>>, directory: string)
|
||||
resolve: () => Effect.succeed(undefined),
|
||||
},
|
||||
},
|
||||
rpc: {
|
||||
register: () =>
|
||||
Effect.succeed({ dispose: Effect.void, events: { emit: () => Effect.void } }),
|
||||
},
|
||||
rpc: Object.assign(() => ({ permissions: () => permissions!() }), {
|
||||
register: (
|
||||
definition: typeof AgentsRpc,
|
||||
handlers: { permissions: () => Effect.Effect<Agent.Info["permissions"]> },
|
||||
) =>
|
||||
Effect.sync(() => {
|
||||
expect(definition.id).toBe(AgentsRpc.id);
|
||||
permissions = handlers.permissions;
|
||||
return { dispose: Effect.void, events: { emit: () => Effect.void } };
|
||||
}),
|
||||
}),
|
||||
mcp: {
|
||||
transform: (callback: (editor: { get: () => undefined }) => void) =>
|
||||
Effect.sync(() => callback({ get: () => undefined })).pipe(
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { Plugin, Model, Provider } from "@opencode/plugin/effect";
|
||||
import { fromPromise } from "@opencode/plugin/promise/adapter";
|
||||
import { Effect } from "effect";
|
||||
import agents from "../../src/agents.js";
|
||||
|
||||
export default Plugin.define({
|
||||
id: agents.id,
|
||||
effect: (context) =>
|
||||
Effect.gen(function* () {
|
||||
const providerID = Provider.ID.make("fixture");
|
||||
yield* context.provider.transform((editor) =>
|
||||
editor.add({
|
||||
info: {
|
||||
...Provider.Info.empty(providerID),
|
||||
name: "Isolated fixture",
|
||||
activation: "enabled",
|
||||
package: "@opencode/ai/providers/openai-compatible",
|
||||
settings: { baseURL: process.env.FIXTURE_MODEL_URL! },
|
||||
},
|
||||
models: ["gpt-6-luna", "gpt-6.1-sol", "gpt-6-astra"].map((id) =>
|
||||
Model.Info.default(providerID, Model.ID.make(id)),
|
||||
),
|
||||
}),
|
||||
);
|
||||
yield* fromPromise(agents).effect({ ...context, options: { provider: "fixture" } });
|
||||
}),
|
||||
});
|
||||
Vendored
+4
-19
@@ -1,4 +1,4 @@
|
||||
import { Plugin, Agent, Model, Provider } from "@opencode/plugin/effect";
|
||||
import { Plugin, Agent } from "@opencode/plugin/effect";
|
||||
import { Rpc } from "@opencode/plugin/rpc";
|
||||
import { Session } from "@opencode/schema/session";
|
||||
import { SessionMessage } from "@opencode/schema/session-message";
|
||||
@@ -6,7 +6,7 @@ import { Tool } from "@opencode/schema/tool";
|
||||
import { Cause, Effect } from "effect";
|
||||
import { z } from "zod";
|
||||
import { appendFile } from "node:fs/promises";
|
||||
import plugin from "../../src/index.js";
|
||||
import multiWork from "../../src/multi-work.js";
|
||||
|
||||
const probe = Rpc.define({
|
||||
id: "multi-work-test",
|
||||
@@ -24,25 +24,10 @@ const probe = Rpc.define({
|
||||
});
|
||||
|
||||
export default Plugin.define({
|
||||
id: plugin.id,
|
||||
id: multiWork.id,
|
||||
effect: (context) =>
|
||||
Effect.gen(function* () {
|
||||
const providerID = Provider.ID.make("fixture");
|
||||
yield* context.provider.transform((editor) =>
|
||||
editor.add({
|
||||
info: {
|
||||
...Provider.Info.empty(providerID),
|
||||
name: "Isolated fixture",
|
||||
activation: "enabled",
|
||||
package: "@opencode/ai/providers/openai-compatible",
|
||||
settings: { baseURL: process.env.FIXTURE_MODEL_URL! },
|
||||
},
|
||||
models: ["gpt-6-luna", "gpt-6.1-sol", "gpt-6-astra"].map((id) =>
|
||||
Model.Info.default(providerID, Model.ID.make(id)),
|
||||
),
|
||||
}),
|
||||
);
|
||||
yield* plugin.effect({ ...context, options: { provider: "fixture" } });
|
||||
yield* multiWork.effect(context);
|
||||
let delegate: Tool.Info | undefined;
|
||||
yield* context.tool.transform((editor) => {
|
||||
delegate = editor.get("subagent");
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
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 { fileURLToPath } from "node:url";
|
||||
|
||||
const installer = fileURLToPath(new URL("../install-local.sh", import.meta.url));
|
||||
const directories: string[] = [];
|
||||
const jsonc = '{\n // This file must remain untouched.\n "theme": "dark"\n}\n';
|
||||
const oldPlugin = 'export default { id: "old", setup() { return "old"; } };\n';
|
||||
type FixtureConfig =
|
||||
| { permissions: { shell: string }; plugins: { package: string }[] }
|
||||
| {
|
||||
theme: string;
|
||||
plugins: {
|
||||
package: string;
|
||||
options: { mode: string; coordinate: string; lunaPriority: boolean };
|
||||
}[];
|
||||
}
|
||||
| { plugins: string };
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
directories.splice(0).map((path) => rm(path, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function run(command: string[], cwd: string, env: NodeJS.ProcessEnv) {
|
||||
const child = Bun.spawn(command, {
|
||||
cwd,
|
||||
env,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
timeout: 20_000,
|
||||
});
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
]);
|
||||
return { exitCode, stdout, stderr };
|
||||
}
|
||||
|
||||
async function fixture(configValue?: FixtureConfig) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "stanp-local-install-"));
|
||||
directories.push(directory);
|
||||
const checkout = join(directory, "checkout with spaces");
|
||||
const cwd = join(directory, "different cwd");
|
||||
const config = join(directory, "config with spaces");
|
||||
const bin = join(directory, "bin");
|
||||
const installed = join(config, "plugins", "stanp-opencode");
|
||||
await Promise.all([
|
||||
mkdir(checkout),
|
||||
mkdir(cwd),
|
||||
mkdir(bin),
|
||||
mkdir(installed, { recursive: true }),
|
||||
]);
|
||||
await Promise.all([
|
||||
Bun.write(join(checkout, "install-local.sh"), Bun.file(installer)),
|
||||
writeFile(
|
||||
join(checkout, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "stanp-opencode",
|
||||
version: "1.0.0",
|
||||
type: "module",
|
||||
exports: { ".": "./dist/index.js", "./tui": { import: "./dist/tui.js" } },
|
||||
}),
|
||||
),
|
||||
writeFile(join(checkout, "bun.lock"), ""),
|
||||
writeFile(join(config, "opencode.jsonc"), jsonc),
|
||||
writeFile(
|
||||
join(config, "opencode.json"),
|
||||
JSON.stringify(
|
||||
configValue ?? {
|
||||
permissions: { shell: "ask" },
|
||||
plugins: [{ package: "existing-plugin" }],
|
||||
},
|
||||
),
|
||||
),
|
||||
writeFile(join(installed, "index.js"), oldPlugin),
|
||||
writeFile(
|
||||
join(installed, "package.json"),
|
||||
'{"name":"stanp-opencode","type":"module","main":"./index.js"}\n',
|
||||
),
|
||||
writeFile(join(config, "plugins", "stanp-opencode.js"), "legacy\n"),
|
||||
writeFile(
|
||||
join(bin, "bun"),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "\${1:-}" == "install" ]]; then
|
||||
[[ "\${2:-}" == "--frozen-lockfile" ]]
|
||||
printf '%s\n' "$PWD" >> "$CALLS"
|
||||
[[ "$MODE" != "dependency" ]]
|
||||
exit
|
||||
fi
|
||||
if [[ "\${1:-}" == "run" && "\${2:-}" == "build" ]]; then
|
||||
printf '%s\n' "$PWD" >> "$CALLS"
|
||||
[[ "$MODE" != "build" ]]
|
||||
mkdir -p dist
|
||||
if [[ "$MODE" == "validation" ]]; then
|
||||
printf '%s\n' 'export default { id: "stanp-opencode", server() {} };' > dist/index.js
|
||||
else
|
||||
printf 'export default { id: "stanp-opencode", setup() { return "%s"; } };\n' "$GENERATION" > dist/index.js
|
||||
fi
|
||||
printf '%s\n' 'export default { id: "stanp-opencode.quota-tui", setup() {} };' > dist/tui.js
|
||||
exit
|
||||
fi
|
||||
exec "$REAL_BUN" "$@"
|
||||
`,
|
||||
),
|
||||
writeFile(
|
||||
join(bin, "curl"),
|
||||
`#!/usr/bin/env bash
|
||||
touch "$FETCH_MARKER"
|
||||
printf 'release fetching is forbidden\n' >&2
|
||||
exit 99
|
||||
`,
|
||||
),
|
||||
]);
|
||||
await Promise.all([chmod(join(bin, "bun"), 0o755), chmod(join(bin, "curl"), 0o755)]);
|
||||
const env = {
|
||||
...Bun.env,
|
||||
PATH: `${bin}:${dirname(process.execPath)}:${Bun.env.PATH ?? "/usr/bin:/bin"}`,
|
||||
HOME: join(directory, "home"),
|
||||
XDG_CONFIG_HOME: join(directory, "ignored-xdg"),
|
||||
OPENCODE_CONFIG_DIR: config,
|
||||
REAL_BUN: process.execPath,
|
||||
MODE: "success",
|
||||
GENERATION: "first",
|
||||
CALLS: join(directory, "calls"),
|
||||
FETCH_MARKER: join(directory, "release-fetch-attempted"),
|
||||
};
|
||||
return { directory, checkout, cwd, config, installed, env };
|
||||
}
|
||||
|
||||
test("builds from its checkout and installs defaults when invoked elsewhere", async () => {
|
||||
const setup = await fixture();
|
||||
const result = await run(
|
||||
["bash", join(setup.checkout, "install-local.sh")],
|
||||
setup.cwd,
|
||||
setup.env,
|
||||
);
|
||||
expect(result, result.stderr).toMatchObject({ exitCode: 0 });
|
||||
expect(await readFile(setup.env.CALLS, "utf8")).toBe(`${setup.checkout}\n${setup.checkout}\n`);
|
||||
expect(await Bun.file(setup.env.FETCH_MARKER).exists()).toBe(false);
|
||||
expect(await readFile(join(setup.config, "opencode.jsonc"), "utf8")).toBe(jsonc);
|
||||
expect(JSON.parse(await readFile(join(setup.config, "opencode.json"), "utf8"))).toEqual({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permissions: { shell: "ask" },
|
||||
plugins: [
|
||||
{ package: "existing-plugin" },
|
||||
{
|
||||
package: "./plugins/stanp-opencode",
|
||||
options: { mode: "gpt", provider: "openai", lunaPriority: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
const imported = await run(
|
||||
[
|
||||
process.execPath,
|
||||
"-e",
|
||||
"const { default: plugin } = await import(process.argv[1]); console.log(plugin.setup());",
|
||||
setup.installed,
|
||||
],
|
||||
setup.cwd,
|
||||
setup.env,
|
||||
);
|
||||
expect(imported, imported.stderr).toMatchObject({ exitCode: 0, stdout: "first\n" });
|
||||
expect((await readdir(setup.installed)).sort()).toEqual(["index.js", "package.json", "tui.js"]);
|
||||
expect(await Bun.file(join(setup.config, "plugins", "stanp-opencode.js")).exists()).toBe(false);
|
||||
});
|
||||
|
||||
test("reinstall replaces bundles while preserving existing settings and plugin options", async () => {
|
||||
const configured = {
|
||||
theme: "dark",
|
||||
plugins: [
|
||||
{
|
||||
package: "./plugins/stanp-opencode",
|
||||
options: { mode: "custom", coordinate: "provider/model", lunaPriority: false },
|
||||
},
|
||||
],
|
||||
};
|
||||
const setup = await fixture(configured);
|
||||
const first = await run(
|
||||
["bash", join(setup.checkout, "install-local.sh")],
|
||||
setup.cwd,
|
||||
setup.env,
|
||||
);
|
||||
expect(first, first.stderr).toMatchObject({ exitCode: 0 });
|
||||
const second = await run(["bash", join(setup.checkout, "install-local.sh")], setup.cwd, {
|
||||
...setup.env,
|
||||
GENERATION: "second",
|
||||
});
|
||||
expect(second, second.stderr).toMatchObject({ exitCode: 0 });
|
||||
expect(JSON.parse(await readFile(join(setup.config, "opencode.json"), "utf8"))).toEqual({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
...configured,
|
||||
});
|
||||
expect(await readFile(join(setup.installed, "index.js"), "utf8")).toContain('return "second"');
|
||||
});
|
||||
|
||||
test("enables Playwright only when requested", async () => {
|
||||
const setup = await fixture();
|
||||
const result = await run(
|
||||
["bash", join(setup.checkout, "install-local.sh"), "--playwright"],
|
||||
setup.cwd,
|
||||
setup.env,
|
||||
);
|
||||
expect(result, result.stderr).toMatchObject({ exitCode: 0 });
|
||||
const installed = JSON.parse(await readFile(join(setup.config, "opencode.json"), "utf8"));
|
||||
expect(installed.plugins.at(-1).options.playwright).toBe(true);
|
||||
});
|
||||
|
||||
for (const mode of ["dependency", "build", "validation", "config"] as const) {
|
||||
test(`${mode} failure leaves the previous installation and configuration untouched`, async () => {
|
||||
const invalidConfig = mode === "config" ? { plugins: "not-an-array" } : undefined;
|
||||
const setup = await fixture(invalidConfig);
|
||||
const beforeConfig = await readFile(join(setup.config, "opencode.json"), "utf8");
|
||||
const result = await run(["bash", join(setup.checkout, "install-local.sh")], setup.cwd, {
|
||||
...setup.env,
|
||||
MODE: mode === "config" ? "success" : mode,
|
||||
});
|
||||
expect(result.exitCode, result.stderr).not.toBe(0);
|
||||
expect(await readFile(join(setup.installed, "index.js"), "utf8")).toBe(oldPlugin);
|
||||
expect(await readFile(join(setup.config, "opencode.json"), "utf8")).toBe(beforeConfig);
|
||||
expect(await readFile(join(setup.config, "opencode.jsonc"), "utf8")).toBe(jsonc);
|
||||
expect(await readFile(join(setup.config, "plugins", "stanp-opencode.js"), "utf8")).toBe(
|
||||
"legacy\n",
|
||||
);
|
||||
expect((await readdir(join(setup.config, "plugins"))).sort()).toEqual([
|
||||
"stanp-opencode",
|
||||
"stanp-opencode.js",
|
||||
]);
|
||||
expect(await Bun.file(setup.env.FETCH_MARKER).exists()).toBe(false);
|
||||
});
|
||||
}
|
||||
+445
-261
@@ -1,296 +1,480 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
readlink,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
import { install } from "../scripts/install.js";
|
||||
|
||||
const installer = fileURLToPath(new URL("../install.sh", import.meta.url));
|
||||
const directories: string[] = [];
|
||||
const configContents = '{\n // Keep user settings and comments.\n "theme": "dark"\n}\n';
|
||||
const jsonConfig = { permissions: { shell: "ask" }, plugins: [{ package: "existing-plugin" }] };
|
||||
const previousPlugin = 'export default { id: "previous", setup() { return "preserved"; } };\n';
|
||||
const previousTarget = "../stanp-opencode/install.previous";
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
directories.splice(0).map((path) => rm(path, { recursive: true, force: true })),
|
||||
);
|
||||
for (const path of directories.splice(0)) await rm(path, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function run(command: string[], cwd: string, env: NodeJS.ProcessEnv) {
|
||||
const child = Bun.spawn(command, {
|
||||
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 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: 20_000,
|
||||
timeout: 30_000,
|
||||
});
|
||||
if (input !== undefined && process.stdin) {
|
||||
process.stdin.write(input);
|
||||
process.stdin.end();
|
||||
}
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
process.exited,
|
||||
new Response(process.stdout).text(),
|
||||
new Response(process.stderr).text(),
|
||||
]);
|
||||
return { exitCode, stdout, stderr };
|
||||
}
|
||||
|
||||
async function install(
|
||||
mode: "native" | "checksum" | "legacy",
|
||||
configured = false,
|
||||
playwright = false,
|
||||
) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "stanp-install-"));
|
||||
async function fixture() {
|
||||
const directory = await mkdtemp("/tmp/opencode/plugin-install-");
|
||||
directories.push(directory);
|
||||
const config =
|
||||
mode === "native"
|
||||
? join(directory, "xdg", "opencode")
|
||||
: join(directory, "custom config with spaces");
|
||||
const bin = join(directory, "bin");
|
||||
const fixture = join(directory, "package");
|
||||
await Promise.all([
|
||||
mkdir(bin),
|
||||
mkdir(join(fixture, "dist"), { recursive: true }),
|
||||
mkdir(join(config, "plugins"), { recursive: true }),
|
||||
mkdir(join(config, "stanp-opencode", "install.previous"), { recursive: true }),
|
||||
]);
|
||||
await Promise.all([
|
||||
writeFile(join(config, "opencode.jsonc"), configContents),
|
||||
writeFile(
|
||||
join(config, "opencode.json"),
|
||||
JSON.stringify(
|
||||
configured
|
||||
? {
|
||||
...jsonConfig,
|
||||
plugins: [
|
||||
...jsonConfig.plugins,
|
||||
{
|
||||
package: "./plugins/stanp-opencode",
|
||||
options: {
|
||||
mode: "gpt",
|
||||
provider: "existing-provider",
|
||||
lunaPriority: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: jsonConfig,
|
||||
),
|
||||
),
|
||||
symlink(previousTarget, join(config, "plugins", "stanp-opencode")),
|
||||
writeFile(join(config, "stanp-opencode", "install.previous", "index.js"), previousPlugin),
|
||||
writeFile(
|
||||
join(config, "stanp-opencode", "install.previous", "package.json"),
|
||||
'{"name":"stanp-opencode","type":"module","main":"./index.js"}\n',
|
||||
),
|
||||
writeFile(join(fixture, "index.js"), 'export { default } from "./dist/index.js";\n'),
|
||||
writeFile(
|
||||
join(fixture, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "stanp-opencode",
|
||||
version: "1.0.0",
|
||||
type: "module",
|
||||
main: "./dist/index.js",
|
||||
exports: { ".": "./dist/index.js", "./tui": "./dist/tui.js" },
|
||||
scripts: { postinstall: "bun ./postinstall.js" },
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
join(fixture, "postinstall.js"),
|
||||
'await Bun.write(process.env.POSTINSTALL_MARKER, "executed");\n',
|
||||
),
|
||||
writeFile(
|
||||
join(fixture, "dist", "index.js"),
|
||||
'await Bun.write(process.env.IMPORT_MARKER, "imported");\n' +
|
||||
(mode === "legacy"
|
||||
? 'export default { id: "stanp-opencode", async server() { return {}; } };\n'
|
||||
: 'export default { id: "stanp-opencode", effect() { return "native-ready"; } };\n'),
|
||||
),
|
||||
writeFile(
|
||||
join(fixture, "dist", "tui.js"),
|
||||
'export default { id: "stanp-opencode.quota-tui", setup() { return "tui-ready"; } };\n',
|
||||
),
|
||||
]);
|
||||
const env = {
|
||||
...Bun.env,
|
||||
PATH: `${bin}:${dirname(process.execPath)}:${Bun.env.PATH ?? "/usr/bin:/bin"}`,
|
||||
HOME: directory,
|
||||
XDG_CONFIG_HOME: join(directory, "xdg"),
|
||||
OPENCODE_CONFIG_DIR: mode === "native" ? "" : config,
|
||||
FIXTURE_DIR: directory,
|
||||
POSTINSTALL_MARKER: join(directory, "postinstall-ran"),
|
||||
IMPORT_MARKER: join(directory, "import-ran"),
|
||||
};
|
||||
const packed = await run(["tar", "-czf", "stanp-opencode.tgz", "package"], directory, env);
|
||||
expect(packed, packed.stderr).toMatchObject({ exitCode: 0 });
|
||||
const digest =
|
||||
mode === "checksum"
|
||||
? "0".repeat(64)
|
||||
: createHash("sha256")
|
||||
.update(await readFile(join(directory, "stanp-opencode.tgz")))
|
||||
.digest("hex");
|
||||
await Promise.all([
|
||||
writeFile(join(directory, "stanp-opencode.tgz.sha256"), `${digest} stanp-opencode.tgz\n`),
|
||||
writeFile(
|
||||
join(directory, "release.json"),
|
||||
JSON.stringify({
|
||||
tag_name: "v1.0.0",
|
||||
assets: ["stanp-opencode.tgz", "stanp-opencode.tgz.sha256"].map((name, id) => ({
|
||||
id: id + 1,
|
||||
name,
|
||||
browser_download_url: `https://fixtures.invalid/${name}`,
|
||||
})),
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
join(bin, "curl"),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
url=""
|
||||
output=""
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
-o|--output) output="$2"; shift 2 ;;
|
||||
https://*) url="$1"; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
case "$url" in
|
||||
https://git.krtss.de/api/v1/repos/StanPonomarev/stanp-opencode/releases/latest) source=release.json ;;
|
||||
https://fixtures.invalid/stanp-opencode.tgz?asset_id=1) source=stanp-opencode.tgz ;;
|
||||
https://fixtures.invalid/stanp-opencode.tgz.sha256?asset_id=2) source=stanp-opencode.tgz.sha256 ;;
|
||||
*) printf 'Unexpected fixture URL: %s\\n' "$url" >&2; exit 1 ;;
|
||||
esac
|
||||
if [[ -n "$output" ]]; then
|
||||
cp "$FIXTURE_DIR/$source" "$output"
|
||||
else
|
||||
cat "$FIXTURE_DIR/$source"
|
||||
fi
|
||||
`,
|
||||
),
|
||||
writeFile(
|
||||
join(bin, "sha256sum"),
|
||||
`#!/usr/bin/env bash
|
||||
if [[ "\${1:-}" == -* ]]; then
|
||||
printf 'usage: sha256sum [-bctwz] [files ...]\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
PATH="\${PATH#*:}"
|
||||
export PATH
|
||||
exec sha256sum "$@"
|
||||
`,
|
||||
),
|
||||
]);
|
||||
await Promise.all([chmod(join(bin, "curl"), 0o755), chmod(join(bin, "sha256sum"), 0o755)]);
|
||||
const result = await run(
|
||||
["bash", installer, ...(playwright ? ["--playwright"] : [])],
|
||||
directory,
|
||||
env,
|
||||
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() {} };',
|
||||
);
|
||||
return { directory, config, env, result };
|
||||
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"));
|
||||
}
|
||||
|
||||
test("installs an importable native plugin and default config under XDG without running scripts", async () => {
|
||||
const { directory, config, env, result } = await install("native");
|
||||
expect(result, result.stderr).toMatchObject({ exitCode: 0 });
|
||||
// The generated package path is only known at runtime; exercise its module-loading boundary.
|
||||
const imported = await run(
|
||||
[
|
||||
process.execPath,
|
||||
"-e",
|
||||
"const { default: plugin } = await import(process.argv[1]); console.log(JSON.stringify([plugin.id, plugin.effect()]));",
|
||||
join(config, "plugins", "stanp-opencode"),
|
||||
],
|
||||
directory,
|
||||
env,
|
||||
);
|
||||
expect(imported, imported.stderr).toMatchObject({
|
||||
exitCode: 0,
|
||||
stdout: '["stanp-opencode","native-ready"]\n',
|
||||
for (const selection of [
|
||||
"agents",
|
||||
"quota",
|
||||
"agents,quota",
|
||||
"agents,multi-work",
|
||||
"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.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",
|
||||
);
|
||||
});
|
||||
expect(await readFile(join(config, "opencode.jsonc"), "utf8")).toBe(configContents);
|
||||
expect(JSON.parse(await readFile(join(config, "opencode.json"), "utf8"))).toEqual({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
plugins: [
|
||||
{ package: "existing-plugin" },
|
||||
}
|
||||
|
||||
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",
|
||||
package: "./plugins/stanp-opencode/agents",
|
||||
options: { mode: "gpt", provider: "openai", lunaPriority: true },
|
||||
},
|
||||
],
|
||||
permissions: { shell: "ask" },
|
||||
{ package: "./plugins/stanp-opencode/multi-work" },
|
||||
{ package: "./plugins/stanp-opencode/quota" },
|
||||
"after",
|
||||
]);
|
||||
});
|
||||
expect(await Bun.file(join(config, "plugins", "stanp-opencode", "package.json")).exists()).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await Bun.file(join(config, "plugins", "stanp-opencode", "tui.js")).exists()).toBe(true);
|
||||
expect(
|
||||
await readFile(join(config, "stanp-opencode", "install.previous", "index.js"), "utf8"),
|
||||
).toBe(previousPlugin);
|
||||
expect(await Bun.file(env.POSTINSTALL_MARKER).exists()).toBe(false);
|
||||
}, 60_000);
|
||||
|
||||
test("preserves an existing stanp-opencode configuration", async () => {
|
||||
const { config, result } = await install("native", true);
|
||||
expect(result, result.stderr).toMatchObject({ exitCode: 0 });
|
||||
const installed = JSON.parse(await readFile(join(config, "opencode.json"), "utf8"));
|
||||
expect(installed.plugins).toEqual([
|
||||
{ package: "existing-plugin" },
|
||||
{
|
||||
package: "./plugins/stanp-opencode",
|
||||
options: {
|
||||
mode: "gpt",
|
||||
provider: "existing-provider",
|
||||
lunaPriority: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(installed.permissions).toEqual(jsonConfig.permissions);
|
||||
}, 60_000);
|
||||
|
||||
test("enables Playwright only when requested", async () => {
|
||||
const { config, result } = await install("native", false, true);
|
||||
expect(result, result.stderr).toMatchObject({ exitCode: 0 });
|
||||
const installed = JSON.parse(await readFile(join(config, "opencode.json"), "utf8"));
|
||||
expect(installed.plugins.at(-1).options.playwright).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
for (const mode of ["checksum", "legacy"] as const) {
|
||||
test(`${mode} failure preserves the installed plugin in a custom path containing spaces`, async () => {
|
||||
const { directory, config, env, result } = await install(mode);
|
||||
expect(result.exitCode, result.stderr).not.toBe(0);
|
||||
expect(await readlink(join(config, "plugins", "stanp-opencode"))).toBe(previousTarget);
|
||||
expect(await readFile(join(config, "opencode.jsonc"), "utf8")).toBe(configContents);
|
||||
expect(JSON.parse(await readFile(join(config, "opencode.json"), "utf8"))).toEqual(
|
||||
jsonConfig,
|
||||
);
|
||||
expect(
|
||||
await readFile(join(config, "stanp-opencode", "install.previous", "index.js"), "utf8"),
|
||||
).toBe(previousPlugin);
|
||||
expect(await readdir(join(config, "stanp-opencode"))).toEqual(["install.previous"]);
|
||||
expect(await readdir(join(config, "plugins"))).toEqual(["stanp-opencode"]);
|
||||
// Load the preserved package from this test's runtime-selected configuration directory.
|
||||
const imported = await run(
|
||||
[
|
||||
process.execPath,
|
||||
"-e",
|
||||
"const { default: plugin } = await import(process.argv[1]); console.log(plugin.setup());",
|
||||
join(config, "plugins", "stanp-opencode"),
|
||||
],
|
||||
directory,
|
||||
env,
|
||||
);
|
||||
expect(imported, imported.stderr).toMatchObject({ exitCode: 0, stdout: "preserved\n" });
|
||||
expect(await Bun.file(env.POSTINSTALL_MARKER).exists()).toBe(false);
|
||||
expect(await Bun.file(env.IMPORT_MARKER).exists()).toBe(mode === "legacy");
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
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",
|
||||
...names.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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -143,6 +143,7 @@ function fixture() {
|
||||
const toolHooks: ((event: ToolHooks["execute.before"]) => Effect.Effect<void, Tool.Error>)[] =
|
||||
[];
|
||||
const hooks: ((event: SessionContext) => Effect.Effect<void>)[] = [];
|
||||
let prompt!: (event: Pick<SessionContext, "sessionID">) => Effect.Effect<void>;
|
||||
let transform!: (editor: AgentEditor) => void;
|
||||
const registration = Effect.succeed({ dispose: Effect.void });
|
||||
const editAgents = (): AgentEditor => ({
|
||||
@@ -214,9 +215,13 @@ function fixture() {
|
||||
}
|
||||
entered.resolve();
|
||||
}),
|
||||
hook: (_name: string, callback: (event: SessionContext) => Effect.Effect<void>) =>
|
||||
hook: (name: string, callback: (event: SessionContext) => Effect.Effect<void>) =>
|
||||
Effect.sync(() => {
|
||||
hooks.push(callback);
|
||||
if (name === "context") hooks.push(callback);
|
||||
if (name === "prompt") {
|
||||
// SAFETY: The prompt admission hook reads only the native session ID.
|
||||
prompt = callback as typeof prompt;
|
||||
}
|
||||
}).pipe(Effect.andThen(registration)),
|
||||
},
|
||||
tool: {
|
||||
@@ -256,6 +261,7 @@ function fixture() {
|
||||
order,
|
||||
entered,
|
||||
hooks,
|
||||
prompt: (event: Pick<SessionContext, "sessionID">) => prompt(event),
|
||||
toolHooks,
|
||||
call,
|
||||
progress,
|
||||
@@ -676,6 +682,45 @@ test("rejects foreign and changed-agent resumes, non-primary multi-work and unbo
|
||||
);
|
||||
});
|
||||
|
||||
test("command-created children require ready owned bindings and cannot bypass placement guards", async () => {
|
||||
const f = fixture();
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
yield* f.invoke();
|
||||
const child = f.sessions.get("ses_child1")!;
|
||||
const binding = bindingSchema.parse(f.storage.get(bindingKey(child.id)));
|
||||
const admit = () => f.prompt({ sessionID: Session.ID.make(child.id) });
|
||||
yield* admit();
|
||||
f.storage.set(bindingKey(child.id), { ...binding, ready: false });
|
||||
expect((yield* Effect.exit(admit()))._tag).toBe("Failure");
|
||||
f.storage.set(bindingKey(child.id), binding);
|
||||
child.location.directory = "/relocated";
|
||||
expect((yield* Effect.exit(admit()))._tag).toBe("Failure");
|
||||
child.location.directory = binding.directory;
|
||||
child.projectID = "foreign";
|
||||
expect((yield* Effect.exit(admit()))._tag).toBe("Failure");
|
||||
child.projectID = binding.projectID;
|
||||
f.parent.location.directory = "/elsewhere";
|
||||
expect((yield* Effect.exit(admit()))._tag).toBe("Failure");
|
||||
f.parent.location.directory = binding.root.directory;
|
||||
f.parent.agent = "build";
|
||||
expect((yield* Effect.exit(admit()))._tag).toBe("Failure");
|
||||
f.parent.agent = "multi-work";
|
||||
child.agent = "multi-work";
|
||||
expect((yield* Effect.exit(admit()))._tag).toBe("Failure");
|
||||
child.agent = "fast-hi";
|
||||
expect((yield* Effect.exit(admit()))._tag).toBe("Failure");
|
||||
child.agent = "fast-lo";
|
||||
expect((yield* Effect.exit(admit()))._tag).toBe("Failure");
|
||||
child.location.directory = f.parent.location.directory;
|
||||
yield* admit();
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("ineligible roots do not register multi-work; multi-work cannot execute shell even with session overrides", async () => {
|
||||
const f = fixture();
|
||||
f.herdr.eligible = false;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Host } from "@opencode/plugin/host";
|
||||
import { build } from "../scripts/build.js";
|
||||
import { install } from "../scripts/install.js";
|
||||
|
||||
test("packed package has independent native plugins and quota-only TUI discovery", async () => {
|
||||
const directory = await mkdtemp("/tmp/opencode/plugin-package-");
|
||||
try {
|
||||
await build();
|
||||
const archive = join(directory, "stanp-opencode.tgz");
|
||||
const pack = Bun.spawn([process.execPath, "pm", "pack", "--filename", archive], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const output = new Response(pack.stderr).text();
|
||||
expect(await pack.exited, await output).toBe(0);
|
||||
const unpack = Bun.spawn(["tar", "-xzf", archive, "-C", directory], {
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
});
|
||||
expect(await unpack.exited).toBe(0);
|
||||
const source = join(directory, "package");
|
||||
const manifest = await Bun.file(join(source, "package.json")).json();
|
||||
expect(manifest.exports["."]).toBeUndefined();
|
||||
expect(await Bun.file(join(source, "index.js")).exists()).toBe(false);
|
||||
expect(await Bun.file(join(source, "dist", "index.js")).exists()).toBe(false);
|
||||
for (const name of ["agents", "multi-work", "quota"]) {
|
||||
const entrypoint = join(source, manifest.exports[`./${name}`].import);
|
||||
const { default: plugin } = await import(entrypoint);
|
||||
expect(plugin.id).toBe(`stanp-opencode.${name}`);
|
||||
}
|
||||
const config = join(directory, "config");
|
||||
await mkdir(config);
|
||||
await install(source, config, "agents,multi-work,quota");
|
||||
for (const name of ["agents", "multi-work", "quota"]) {
|
||||
const entries = Host.resolve({
|
||||
directory: join(config, "plugins", "stanp-opencode", name),
|
||||
});
|
||||
expect(entries.server).toBeDefined();
|
||||
expect(entries.tui !== undefined).toBe(name === "quota");
|
||||
}
|
||||
const { QuotaRpc } = await import(join(source, manifest.exports["./quota-rpc"].import));
|
||||
expect(QuotaRpc.id).toBe("stanp-opencode.quota");
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
+279
-967
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { Plugin } from "@opencode/plugin";
|
||||
import type { Context } from "@opencode/plugin/effect/plugin";
|
||||
import { Effect } from "effect";
|
||||
import quota from "../src/quota-plugin.js";
|
||||
import multiWork from "../src/multi-work.js";
|
||||
import { QuotaRpc } from "../src/quota-rpc.js";
|
||||
import { AgentsRpc } from "../src/agents-rpc.js";
|
||||
|
||||
test("Quota loads independently and retains its RPC ID and nullable status", async () => {
|
||||
let controller: ReadableStreamDefaultController<unknown>;
|
||||
const stream = new ReadableStream({
|
||||
start(value) {
|
||||
controller = value;
|
||||
},
|
||||
});
|
||||
let status: (() => Promise<null>) | undefined;
|
||||
let disposed = false;
|
||||
let modelHook = false;
|
||||
const context = {
|
||||
options: {},
|
||||
location: { directory: "/fixture" },
|
||||
integration: {
|
||||
connection: {
|
||||
async active() {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
event: {
|
||||
subscribe({ signal }: { signal: AbortSignal }) {
|
||||
signal.addEventListener("abort", () => controller.close(), { once: true });
|
||||
return stream;
|
||||
},
|
||||
},
|
||||
rpc: {
|
||||
async register(definition: typeof QuotaRpc, handlers: { status: () => Promise<null> }) {
|
||||
expect(definition.id).toBe("stanp-opencode.quota");
|
||||
status = handlers.status;
|
||||
return {
|
||||
async dispose() {
|
||||
disposed = true;
|
||||
},
|
||||
events: { async emit() {} },
|
||||
};
|
||||
},
|
||||
},
|
||||
session: {
|
||||
async hook(name: string) {
|
||||
expect(name).toBe("model.request");
|
||||
modelHook = true;
|
||||
return { async dispose() {} };
|
||||
},
|
||||
},
|
||||
};
|
||||
// SAFETY: Quota with no credentials uses only these native host services.
|
||||
// Absent agent/model/MCP APIs fail if independence regresses.
|
||||
const setup = quota.setup as (
|
||||
context: Pick<Plugin.Context, "options">,
|
||||
) => ReturnType<typeof quota.setup>;
|
||||
const cleanup = await setup(context);
|
||||
expect(quota.id).toBe("stanp-opencode.quota");
|
||||
expect(await status!()).toBeNull();
|
||||
expect(modelHook).toBe(true);
|
||||
if (cleanup) await cleanup();
|
||||
expect(disposed).toBe(true);
|
||||
});
|
||||
|
||||
test("Multi-work rejects missing Agents before any Herdr operations or registrations", async () => {
|
||||
const context = {
|
||||
rpc: (definition: typeof AgentsRpc) => {
|
||||
expect(definition.id).toBe("stanp-opencode.agents");
|
||||
return { permissions: () => Effect.fail(new Error("RPC not registered")) };
|
||||
},
|
||||
};
|
||||
// SAFETY: Missing Agents fails at the first RPC call; no other host service is accessed.
|
||||
const activate = multiWork.effect as (
|
||||
context: Pick<Context, "options">,
|
||||
) => ReturnType<typeof multiWork.effect>;
|
||||
const result = await Effect.runPromiseExit(
|
||||
Effect.scoped(activate({ ...context, options: {} })),
|
||||
);
|
||||
expect(String(result)).toContain("Multi-work requires Agents loaded before it");
|
||||
});
|
||||
+1
-26
@@ -247,7 +247,7 @@ async function accountFingerprint(id: string) {
|
||||
return Buffer.from(bytes).toString("hex");
|
||||
}
|
||||
|
||||
test("reads old cached snapshots without session estimates and removes excluded windows", async () => {
|
||||
test("reads cached snapshots and removes excluded windows", async () => {
|
||||
const cached = {
|
||||
account: await accountFingerprint("account-one"),
|
||||
latest: {
|
||||
@@ -284,30 +284,6 @@ test("reads old cached snapshots without session estimates and removes excluded
|
||||
},
|
||||
],
|
||||
},
|
||||
sessions: {
|
||||
root: {
|
||||
"codex:primary": {
|
||||
resetsAt: 2_000_000_000_000,
|
||||
lastUsed: 12.5,
|
||||
consumed: 2,
|
||||
},
|
||||
"CODEX-BENGALFOX:primary": {
|
||||
resetsAt: 2_000_000_000_000,
|
||||
lastUsed: 80,
|
||||
consumed: 40,
|
||||
},
|
||||
"codex_luna_reserve:primary": {
|
||||
resetsAt: 2_000_000_000_000,
|
||||
lastUsed: 60,
|
||||
consumed: 30,
|
||||
},
|
||||
"codex_luna:primary": {
|
||||
resetsAt: 2_000_000_000_000,
|
||||
lastUsed: 20,
|
||||
consumed: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = fixture(
|
||||
{
|
||||
@@ -328,7 +304,6 @@ test("reads old cached snapshots without session estimates and removes excluded
|
||||
"codex_luna:primary",
|
||||
]);
|
||||
expect(status?.windows[0]).not.toHaveProperty("sessionConsumed");
|
||||
expect(result.stored.get("chatgpt-quota")).not.toHaveProperty("sessions");
|
||||
expect(result.calls()).toBe(1);
|
||||
await result.tracker.dispose();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user