This commit is contained in:
+15
-1
@@ -1,9 +1,23 @@
|
||||
import solidPlugin from "@opentui/solid/bun-plugin";
|
||||
import { rm } from "node:fs/promises";
|
||||
|
||||
export async function build() {
|
||||
for (const file of ["index.js", "index.d.ts", "playwright.d.ts"])
|
||||
await rm(`dist/${file}`, { force: true });
|
||||
const results = await Promise.all([
|
||||
Bun.build({
|
||||
entrypoints: ["src/index.ts", "src/quota-rpc.ts"],
|
||||
entrypoints: [
|
||||
"src/agents.ts",
|
||||
"src/multi-work.ts",
|
||||
"src/quota-plugin.ts",
|
||||
"src/quota-rpc.ts",
|
||||
],
|
||||
outdir: "dist",
|
||||
target: "bun",
|
||||
format: "esm",
|
||||
}),
|
||||
Bun.build({
|
||||
entrypoints: ["scripts/install.ts"],
|
||||
outdir: "dist",
|
||||
target: "bun",
|
||||
format: "esm",
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { mkdir, rename, rm, copyFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { z } from "zod";
|
||||
|
||||
const names = ["agents", "multi-work", "quota"] as const;
|
||||
const base = "./plugins/stanp-opencode";
|
||||
const entrySchema = z
|
||||
.object({ package: z.string(), options: z.record(z.string(), z.json()).optional() })
|
||||
.catchall(z.json());
|
||||
type Entry = z.infer<typeof entrySchema>;
|
||||
const pluginSchema = z.union([z.string(), entrySchema]);
|
||||
const registrationSchema = z
|
||||
.union([z.string().transform((packageName) => ({ package: packageName })), entrySchema])
|
||||
.pipe(entrySchema);
|
||||
const configSchema = z
|
||||
.object({ plugins: z.array(pluginSchema).optional(), $schema: z.string().optional() })
|
||||
.catchall(z.json());
|
||||
const nativePlugin = z.union([
|
||||
z.object({ id: z.string(), setup: z.function() }),
|
||||
z.object({ id: z.string(), effect: z.function() }),
|
||||
]);
|
||||
type DirectoryExports = { ".": string; "./tui"?: string };
|
||||
|
||||
export async function install(source: string, config: string, selection?: string) {
|
||||
const absoluteBase = join(config, "plugins", "stanp-opencode");
|
||||
const monolith = (target: string) =>
|
||||
target === base || target === absoluteBase || /^stanp-opencode(?:@[^/]+)?$/.test(target);
|
||||
const owned = new Set(
|
||||
names.flatMap((name) => [
|
||||
`${base}/${name}`,
|
||||
`${absoluteBase}/${name}`,
|
||||
`stanp-opencode/${name}`,
|
||||
]),
|
||||
);
|
||||
const isOwned = (target: string) => monolith(target) || owned.has(target);
|
||||
const configFile = join(config, "opencode.json");
|
||||
const current = configSchema.parse(
|
||||
(await Bun.file(configFile).exists()) ? await Bun.file(configFile).json() : {},
|
||||
);
|
||||
const entries = current.plugins ?? [];
|
||||
// Normalize only the lookup view; unrelated entries retain their original representation.
|
||||
const configured = entries.map((entry) => registrationSchema.parse(entry));
|
||||
const previous = configured.filter((entry) => isOwned(entry.package));
|
||||
const oldMonolith = previous.some((entry) => monolith(entry.package));
|
||||
const retained = oldMonolith
|
||||
? names
|
||||
: names.filter((name) =>
|
||||
previous.some(
|
||||
(entry) =>
|
||||
entry.package === `${base}/${name}` ||
|
||||
entry.package === `${absoluteBase}/${name}` ||
|
||||
entry.package === `stanp-opencode/${name}`,
|
||||
),
|
||||
);
|
||||
let chosen: string[];
|
||||
if (selection !== undefined) chosen = selection.split(",");
|
||||
else if (process.stdin.isTTY) {
|
||||
const input = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
const defaults = retained.length ? retained : names;
|
||||
chosen = (
|
||||
(
|
||||
await input.question(
|
||||
`Plugins (agents,multi-work,quota) [${defaults.join(",")}]: `,
|
||||
)
|
||||
).trim() || defaults.join(",")
|
||||
).split(",");
|
||||
if (chosen.includes("multi-work") && !chosen.includes("agents")) {
|
||||
if (
|
||||
/^(y|yes)?$/i.test(
|
||||
(
|
||||
await input.question("Multi-work requires Agents. Add Agents? [Y/n]: ")
|
||||
).trim(),
|
||||
)
|
||||
)
|
||||
chosen.push("agents");
|
||||
}
|
||||
} finally {
|
||||
input.close();
|
||||
}
|
||||
} else {
|
||||
if (!retained.length)
|
||||
throw new Error(
|
||||
"Noninteractive installation requires --plugins agents,multi-work,quota (or a valid subset)",
|
||||
);
|
||||
chosen = [...retained];
|
||||
}
|
||||
if (
|
||||
!z.array(z.enum(names)).min(1).safeParse(chosen).success ||
|
||||
new Set(chosen).size !== chosen.length
|
||||
)
|
||||
throw new Error("Invalid --plugins selection; use unique names: agents,multi-work,quota");
|
||||
if (chosen.includes("multi-work") && !chosen.includes("agents"))
|
||||
throw new Error("Multi-work requires Agents; select agents,multi-work");
|
||||
const selected = names.filter((name) => chosen.includes(name));
|
||||
const registrations: Entry[] = selected.map((name) => {
|
||||
const prior = previous.find(
|
||||
(entry) =>
|
||||
entry.package === `${base}/${name}` ||
|
||||
entry.package === `${absoluteBase}/${name}` ||
|
||||
entry.package === `stanp-opencode/${name}` ||
|
||||
(name === "agents" && monolith(entry.package)),
|
||||
);
|
||||
const options: NonNullable<Entry["options"]> = {};
|
||||
if (name === "agents" && !prior?.options)
|
||||
Object.assign(options, { mode: "gpt", provider: "openai", lunaPriority: true });
|
||||
Object.assign(options, prior?.options);
|
||||
delete options.playwright;
|
||||
const entry = entrySchema.parse({ ...prior, package: `${base}/${name}` });
|
||||
delete entry.options;
|
||||
if (Object.keys(options).length) entry.options = options;
|
||||
return entry;
|
||||
});
|
||||
const first = configured.findIndex((entry) => isOwned(entry.package));
|
||||
const next = entries.filter((_entry, index) => !isOwned(configured[index]!.package));
|
||||
next.splice(first < 0 ? next.length : first, 0, ...registrations);
|
||||
const updated = {
|
||||
...current,
|
||||
$schema: current.$schema ?? "https://opencode.ai/config.json",
|
||||
plugins: next,
|
||||
};
|
||||
const parent = join(config, "plugins");
|
||||
await mkdir(parent, { recursive: true });
|
||||
const staged = join(parent, `.stanp-opencode.new.${process.pid}`);
|
||||
const backup = join(parent, `.stanp-opencode.old.${process.pid}`);
|
||||
const target = join(parent, "stanp-opencode");
|
||||
const configStage = join(config, `.stanp-opencode.config.${process.pid}`);
|
||||
let saved = false;
|
||||
let activated = false;
|
||||
try {
|
||||
await mkdir(staged);
|
||||
for (const name of selected) {
|
||||
const file = name === "quota" ? "quota-plugin" : name;
|
||||
const entrypoint = join(source, "dist", `${file}.js`);
|
||||
const { default: definition } = await import(entrypoint);
|
||||
const plugin = nativePlugin.parse(definition);
|
||||
if (plugin.id !== `stanp-opencode.${name}`)
|
||||
throw new Error(`Invalid native OpenCode plugin: ${name}`);
|
||||
const directory = join(staged, name);
|
||||
await mkdir(directory);
|
||||
await copyFile(entrypoint, join(directory, "index.js"));
|
||||
const exports: DirectoryExports = { ".": "./index.js" };
|
||||
if (name === "quota") {
|
||||
const tui = join(source, "dist", "tui.js");
|
||||
if (
|
||||
!new Bun.Transpiler({ loader: "js" })
|
||||
.scan(await Bun.file(tui).text())
|
||||
.exports.includes("default")
|
||||
)
|
||||
throw new Error("Quota lacks its TUI entrypoint");
|
||||
await copyFile(tui, join(directory, "tui.js"));
|
||||
exports["./tui"] = "./tui.js";
|
||||
}
|
||||
await Bun.write(
|
||||
join(directory, "package.json"),
|
||||
JSON.stringify({
|
||||
name: `stanp-opencode-${name}`,
|
||||
type: "module",
|
||||
main: "./index.js",
|
||||
exports,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Bun.write(configStage, JSON.stringify(updated, null, 4) + "\n");
|
||||
try {
|
||||
await rename(target, backup);
|
||||
saved = true;
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
|
||||
throw error;
|
||||
}
|
||||
await rename(staged, target);
|
||||
activated = true;
|
||||
await rename(configStage, configFile);
|
||||
} catch (error) {
|
||||
if (activated) await rm(target, { recursive: true, force: true });
|
||||
if (saved) await rename(backup, target);
|
||||
throw error;
|
||||
} finally {
|
||||
await rm(staged, { recursive: true, force: true });
|
||||
await rm(configStage, { force: true });
|
||||
}
|
||||
if (saved) await rm(backup, { recursive: true, force: true });
|
||||
await rm(join(parent, "stanp-opencode.js"), { force: true });
|
||||
console.log(`Installed ${selected.join(", ")}. Restart OpenCode.`);
|
||||
if (oldMonolith)
|
||||
console.log(
|
||||
"Clean break: quota tracking resets; old Multi-work bindings cannot resume. Existing worktrees are untouched.",
|
||||
);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const [source, config, selection] = process.argv.slice(2);
|
||||
if (!source || !config) throw new Error("Expected package directory and config directory");
|
||||
await install(source, config, selection);
|
||||
}
|
||||
@@ -58,18 +58,26 @@ await writeFile(
|
||||
`#!/bin/sh\ncase "$1 $2" in\n"worktree create") [ "$9" = "--label" ] && [ "\${10}" = "Fix native host" ] || exit 8; printf '%s\\n%s\\n' "$6" "\${10}" > '${herdrCreation}'; git -C '${root}' worktree add -b "$6" '${destination}' "$8" >&2 || exit 1; printf '{"result":{"workspace":{"workspace_id":"w2"},"worktree":{"path":"${destination}","branch":"%s","is_linked_worktree":true}}}' "$6"; exit ;;\nesac\ncase "$1 $2 $4" in\n"workspace list ") echo '{"result":{"workspaces":[{"workspace_id":"w1"}]}}' ;;\n"worktree list w1") echo '${inventory(root, "w1")}' ;;\n"worktree list w2") echo '${inventory(destination, "w2")}' ;;\n*) exit 9 ;;\nesac\n`,
|
||||
);
|
||||
await chmod(join(bin, "herdr"), 0o755);
|
||||
const built = await Bun.build({
|
||||
entrypoints: [resolve("tests/fixtures/native-host-plugin.ts")],
|
||||
outdir: join(temp, "plugin"),
|
||||
naming: "index.js",
|
||||
target: "bun",
|
||||
format: "esm",
|
||||
});
|
||||
if (!built.success) throw new Error(String(built.logs));
|
||||
for (const [name, fixture] of [
|
||||
["agents", "native-agents-plugin"],
|
||||
["multi-work", "native-host-plugin"],
|
||||
] as const) {
|
||||
const built = await Bun.build({
|
||||
entrypoints: [resolve(`tests/fixtures/${fixture}.ts`)],
|
||||
outdir: join(temp, "plugin", name),
|
||||
naming: "index.js",
|
||||
target: "bun",
|
||||
format: "esm",
|
||||
});
|
||||
if (!built.success) throw new Error(String(built.logs));
|
||||
}
|
||||
await writeFile(
|
||||
join(config, "opencode.json"),
|
||||
JSON.stringify({
|
||||
plugins: [join(temp, "plugin")],
|
||||
plugins: [
|
||||
{ package: join(temp, "plugin", "agents") },
|
||||
{ package: join(temp, "plugin", "multi-work") },
|
||||
],
|
||||
agents: {
|
||||
build: { permissions: [{ action: "herdr_worktree", resource: "*", effect: "deny" }] },
|
||||
},
|
||||
@@ -290,8 +298,83 @@ try {
|
||||
if (attempt === 99) throw new Error("Native cancellation did not interrupt the child");
|
||||
await Bun.sleep(50);
|
||||
}
|
||||
releaseModel.resolve();
|
||||
holdModel = false;
|
||||
for (const plugins of [
|
||||
[{ package: join(temp, "plugin", "multi-work") }],
|
||||
[
|
||||
{ package: join(temp, "plugin", "multi-work") },
|
||||
{ package: join(temp, "plugin", "agents") },
|
||||
],
|
||||
]) {
|
||||
server.kill();
|
||||
await server.exited;
|
||||
await writeFile(
|
||||
join(config, "opencode.json"),
|
||||
JSON.stringify({ plugins, update: "disable" }),
|
||||
);
|
||||
server = start();
|
||||
await ready();
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const { data } = await client.plugin.list({ location });
|
||||
const multi = data.find((plugin) => plugin.id === "stanp-opencode.multi-work");
|
||||
if (multi?.state.status === "failed") break;
|
||||
if (attempt === 99 || multi?.state.status === "active")
|
||||
throw new Error(
|
||||
`Missing or late Agents did not fail Multi-work activation: ${JSON.stringify(data)}`,
|
||||
);
|
||||
await Bun.sleep(100);
|
||||
}
|
||||
}
|
||||
server.kill();
|
||||
await server.exited;
|
||||
const quotaBuild = await Bun.build({
|
||||
entrypoints: [resolve("src/quota-plugin.ts")],
|
||||
outdir: join(temp, "plugin", "quota"),
|
||||
naming: "index.js",
|
||||
target: "bun",
|
||||
format: "esm",
|
||||
});
|
||||
if (!quotaBuild.success) throw new Error(String(quotaBuild.logs));
|
||||
await writeFile(
|
||||
join(config, "opencode.json"),
|
||||
JSON.stringify({
|
||||
plugins: [{ package: join(temp, "plugin", "quota") }],
|
||||
update: "disable",
|
||||
}),
|
||||
);
|
||||
server = start();
|
||||
await ready();
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const independent = await client.plugin.list({ location });
|
||||
if (
|
||||
independent.data.some(
|
||||
(plugin) =>
|
||||
plugin.id === "stanp-opencode.agents" ||
|
||||
plugin.id === "stanp-opencode.multi-work",
|
||||
)
|
||||
)
|
||||
throw new Error("Quota-only configuration loaded Agents or Multi-work");
|
||||
if (
|
||||
independent.data.some(
|
||||
(plugin) =>
|
||||
plugin.id === "stanp-opencode.quota" && plugin.state.status === "active",
|
||||
)
|
||||
)
|
||||
break;
|
||||
if (attempt === 99) throw new Error("Quota did not activate independently");
|
||||
await Bun.sleep(100);
|
||||
}
|
||||
const quotaStatus = await client.rpc.call({
|
||||
rpcID: "stanp-opencode.quota",
|
||||
method: "status",
|
||||
location,
|
||||
input: {},
|
||||
});
|
||||
if (quotaStatus.output !== null)
|
||||
throw new Error("Unauthenticated Quota RPC should return null");
|
||||
console.log(
|
||||
"PASS: configured build deny + session override, native foreground result, child move-before-prompt, persisted binding resume after host restart, background completion + parent notification, native foreground interruption",
|
||||
"PASS: configured build deny + session override, native foreground result, child move-before-prompt, persisted binding resume after host restart, background completion + parent notification, native foreground interruption, missing/late Agents fail closed, independent Quota and stable RPC",
|
||||
);
|
||||
} finally {
|
||||
releaseModel.resolve();
|
||||
|
||||
Reference in New Issue
Block a user