From 71b748d0aa6890c028c322321cb8f4fa50d50fef Mon Sep 17 00:00:00 2001 From: Softcannon <325396068+softcannon-g@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:56:48 -0500 Subject: [PATCH 1/2] chore: add local plugin entrypoint for OpenCode discovery Add index.ts re-exporting the built dist/plugin.js so this checkout can be used directly as an OpenCode plugin directory. OpenCode discovers plugins by a directory's index entry. The package's real entrypoint (dist/plugin.js) exports both host APIs from one default export: V2 hosts use id/setup, V1 hosts use server. dist/ is generated by tsc + the web build and remains gitignored. --- index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 index.ts diff --git a/index.ts b/index.ts new file mode 100644 index 00000000..78d6cb22 --- /dev/null +++ b/index.ts @@ -0,0 +1,11 @@ +// Local OpenCode plugin entrypoint. +// +// OpenCode discovers a plugin directory by its `index.ts` entry. This package's +// real entrypoint is `dist/plugin.js`, which normalizes both host APIs from a +// single default export: +// - V2 hosts use `id` + `setup` (native V2 plugin API) +// - V1 hosts use `server` (legacy hook-map plugin API) +// +// `dist/` is generated by `tsc` (plus the web UI build) and is not committed. +// Run the build before using this directory as a plugin source. +export { default } from "./dist/plugin.js" From aa53aa61e983ec895f90fbdb573c7ad05d610c65 Mon Sep 17 00:00:00 2001 From: Softcannon <325396068+softcannon-g@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:04:38 -0500 Subject: [PATCH 2/2] chore: add bun-free local rebuild script Add scripts/rebuild-local.mjs to rebuild this checkout into a loadable OpenCode plugin using only Node.js and npm. The upstream build script invokes bun, which is not required to produce dist/. The script: - installs dependencies with --ignore-scripts (skips the husky/bun prepare) - runs tsc for server code and the web UI, plus vite for the UI assets - resolves CLI entrypoints from each package's own bin field, so it works without spawning Windows .cmd shims - verifies dist/plugin.js, dist/v2/plugin.js, dist/web, and index.ts, then asserts the default export exposes id, setup (V2), and server (V1) Flags: --skip-deps to reuse node_modules, --check for typecheck only. Exposed as npm run build:local. --- package.json | 1 + scripts/rebuild-local.mjs | 139 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 scripts/rebuild-local.mjs diff --git a/package.json b/package.json index 95d6632a..281483bd 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ }, "scripts": { "build": "rm -rf dist && bunx tsc && bun run web:build", + "build:local": "node scripts/rebuild-local.mjs", "web:dev": "cd web && bun run dev", "web:build": "cd web && bun run build", "dev": "tsc --watch", diff --git a/scripts/rebuild-local.mjs b/scripts/rebuild-local.mjs new file mode 100644 index 00000000..d6515496 --- /dev/null +++ b/scripts/rebuild-local.mjs @@ -0,0 +1,139 @@ +// Rebuild this checkout into a loadable local OpenCode plugin. +// +// The package's build script uses `bun`, which is not required here: this +// script drives the same TypeScript and Vite steps through npm so a checkout +// can be rebuilt with only Node.js available. +// +// node scripts/rebuild-local.mjs # full rebuild +// node scripts/rebuild-local.mjs --skip-deps # skip npm install +// node scripts/rebuild-local.mjs --check # typecheck only, no output +// +// Produces: +// dist/ compiled server code (includes dist/v2/plugin.js) +// dist/web/ built memory web UI +// +// dist/ is gitignored. index.ts at the repository root re-exports dist/plugin.js +// so the checkout itself is a valid OpenCode plugin directory. + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const args = new Set(process.argv.slice(2)); +const skipDeps = args.has("--skip-deps"); +const checkOnly = args.has("--check"); + +const isWindows = process.platform === "win32"; + +/** + * Resolve an npm-installed CLI to its JS entrypoint. + * + * The npm/npx launchers are `.cmd` shims on Windows, which Node refuses to spawn + * without a shell. Invoking the underlying JS avoids that entirely, so no shell + * is needed and arguments stay safely separated on every platform. + * + * The entry file name varies by package (typescript -> bin/tsc, vite -> + * bin/vite.js), so read each package's own `bin` field rather than guessing. + */ +function cliBin(pkgName, commandName, cwd = ROOT) { + const pkgDir = join(cwd, "node_modules", ...pkgName.split("/")); + const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8")); + const binField = pkg.bin; + const entry = + typeof binField === "string" + ? binField + : binField?.[commandName] ?? binField?.[pkgName.split("/").pop()]; + assert.ok(entry, `cannot resolve "${commandName}" entrypoint in ${pkgName}`); + return join(pkgDir, entry); +} + +/** Run the current Node binary against a script, failing on a non-zero exit. */ +function run(args, cwd, label = args.join(" ")) { + console.log(`\n$ ${label}`); + const result = spawnSync(process.execPath, args, { cwd, stdio: "inherit" }); + if (result.error) throw result.error; + assert.equal(result.status, 0, `command failed (${result.status}): ${label}`); +} + +const npmCli = process.env.npm_execpath && process.env.npm_execpath.endsWith(".js") + ? process.env.npm_execpath + : undefined; + +/** Run `npm ` via its JS entrypoint when known, otherwise the bare binary. */ +function runNpm(args, cwd) { + if (npmCli) return run([npmCli, ...args], cwd, `npm ${args.join(" ")}`); + console.log(`\n$ npm ${args.join(" ")}`); + const result = spawnSync(isWindows ? "npm.cmd" : "npm", args, { + cwd, + stdio: "inherit", + shell: isWindows, + }); + if (result.error) throw result.error; + assert.equal(result.status, 0, `npm failed (${result.status})`); +} + +function step(message) { + console.log(`\n=== ${message} ===`); +} + +// 1. Dependencies. --ignore-scripts skips the upstream `prepare` hook, which +// shells out to husky/bun and is irrelevant to a built artifact. +if (!skipDeps) { + step("Installing dependencies"); + if (!existsSync(join(ROOT, "node_modules"))) { + runNpm(["install", "--ignore-scripts", "--no-audit", "--no-fund"], ROOT); + } else { + console.log("root node_modules present, skipping (pass no --skip-deps to reinstall)"); + } + if (!existsSync(join(ROOT, "web", "node_modules"))) { + runNpm(["install", "--ignore-scripts", "--no-audit", "--no-fund"], join(ROOT, "web")); + } else { + console.log("web node_modules present, skipping"); + } +} + +// 2. Server code. `tsc` reads tsconfig.json and writes dist/. +step(checkOnly ? "Typechecking server code" : "Building server code"); +run( + [cliBin("typescript", "tsc"), checkOnly ? "--noEmit" : ""].filter(Boolean), + ROOT, + checkOnly ? "tsc --noEmit" : "tsc" +); + +if (checkOnly) { + console.log("\nTypecheck complete. No output written."); + process.exit(0); +} + +// 3. Web UI. Force a clean output so stale hashed asset names cannot linger. +step("Building web UI"); +const webDist = join(ROOT, "dist", "web"); +if (existsSync(webDist)) rmSync(webDist, { recursive: true, force: true }); +const web = join(ROOT, "web"); +run([cliBin("typescript", "tsc", web), "-b"], web, "tsc -b (web)"); +run([cliBin("vite", "vite", web), "build"], web, "vite build (web)"); + +// 4. Verify the artifacts a plugin host needs. +step("Verifying build output"); +const required = [ + join(ROOT, "dist", "plugin.js"), + join(ROOT, "dist", "v2", "plugin.js"), + join(ROOT, "dist", "web", "index.html"), + join(ROOT, "index.ts"), +]; +for (const path of required) { + assert.ok(existsSync(path), `missing expected artifact: ${path}`); + console.log(`ok ${path.slice(ROOT.length + 1)}`); +} + +const plugin = (await import(`file://${join(ROOT, "dist", "plugin.js")}`)).default; +assert.equal(plugin.id, "opencode-mem", "plugin id must be opencode-mem"); +assert.equal(typeof plugin.setup, "function", "V2 setup export must be callable"); +assert.equal(typeof plugin.server, "function", "V1 server export must be callable"); +console.log("ok default export exposes id, setup (V2), server (V1)"); + +console.log("\nRebuild complete. Reload OpenCode to pick up the change:"); +console.log(" opencode reload");