diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index d5fe48629911..f979b1a103db 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -148,8 +148,11 @@ npm plugins can declare a version compatibility range in `package.json` using th - `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors. - `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`. - `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced. +- npm plugin package installs are executed with `--ignore-scripts`, so package `install` / `postinstall` lifecycle scripts are not run. - Without `--force`, an already-configured npm package name is a no-op. - With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept. +- Explicit npm specs with a version suffix (for example `pkg@1.2.3`) are pinned. Runtime install requests that exact version and does not run stale/latest checks for newer registry versions. +- Bare npm specs (`pkg`) are treated as `latest` and can refresh when the cached version is stale. - Tuple targets in `oc-plugin` provide default options written into config. - A package can target `server`, `tui`, or both. - If a package targets both, each target must still resolve to a separate target-only module. Do not export `{ server, tui }` from one module. diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index dbdf5a2bc421..589414a0256c 100644 --- a/packages/opencode/src/bun/index.ts +++ b/packages/opencode/src/bun/index.ts @@ -50,7 +50,7 @@ export namespace BunProc { }), ) - export async function install(pkg: string, version = "latest") { + export async function install(pkg: string, version = "latest", opts?: { ignoreScripts?: boolean }) { // Use lock to ensure only one install at a time using _ = await Lock.write("bun-install") @@ -82,6 +82,7 @@ export namespace BunProc { "add", "--force", "--exact", + ...(opts?.ignoreScripts ? ["--ignore-scripts"] : []), // TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936) ...(proxied() || process.env.CI ? ["--no-cache"] : []), "--cwd", diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts index 116519143620..2c9edfb0a933 100644 --- a/packages/opencode/src/plugin/shared.ts +++ b/packages/opencode/src/plugin/shared.ts @@ -189,7 +189,7 @@ export async function checkPluginCompatibility(target: string, opencodeVersion: export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) { if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec) - return BunProc.install(parsed.pkg, parsed.version) + return BunProc.install(parsed.pkg, parsed.version, { ignoreScripts: true }) } export async function readPluginPackage(target: string): Promise { diff --git a/packages/opencode/test/bun.test.ts b/packages/opencode/test/bun.test.ts index d607ae47820f..db3fa2a28cc9 100644 --- a/packages/opencode/test/bun.test.ts +++ b/packages/opencode/test/bun.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" +import { BunProc } from "../src/bun" +import { PackageRegistry } from "../src/bun/registry" +import { Global } from "../src/global" +import { Process } from "../src/util/process" describe("BunProc registry configuration", () => { test("should not contain hardcoded registry parameters", async () => { @@ -51,3 +55,83 @@ describe("BunProc registry configuration", () => { } }) }) + +describe("BunProc install pinning", () => { + test("uses pinned cache without touching registry", async () => { + const pkg = `pin-test-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` + const ver = "1.2.3" + const mod = path.join(Global.Path.cache, "node_modules", pkg) + const data = path.join(Global.Path.cache, "package.json") + + await fs.mkdir(mod, { recursive: true }) + await Bun.write(path.join(mod, "package.json"), JSON.stringify({ name: pkg, version: ver }, null, 2)) + + const src = await fs.readFile(data, "utf8").catch(() => "") + const json = src ? ((JSON.parse(src) as { dependencies?: Record }) ?? {}) : {} + const deps = json.dependencies ?? {} + deps[pkg] = ver + await Bun.write(data, JSON.stringify({ ...json, dependencies: deps }, null, 2)) + + const stale = spyOn(PackageRegistry, "isOutdated").mockImplementation(async () => { + throw new Error("unexpected registry check") + }) + const run = spyOn(Process, "run").mockImplementation(async () => { + throw new Error("unexpected process.run") + }) + + try { + const out = await BunProc.install(pkg, ver) + expect(out).toBe(mod) + expect(stale).not.toHaveBeenCalled() + expect(run).not.toHaveBeenCalled() + } finally { + stale.mockRestore() + run.mockRestore() + + await fs.rm(mod, { recursive: true, force: true }) + const end = await fs + .readFile(data, "utf8") + .then((item) => JSON.parse(item) as { dependencies?: Record }) + .catch(() => undefined) + if (end?.dependencies) { + delete end.dependencies[pkg] + await Bun.write(data, JSON.stringify(end, null, 2)) + } + } + }) + + test("passes --ignore-scripts when requested", async () => { + const pkg = `ignore-test-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` + const ver = "4.5.6" + const mod = path.join(Global.Path.cache, "node_modules", pkg) + const data = path.join(Global.Path.cache, "package.json") + + const run = spyOn(Process, "run").mockImplementation(async () => ({ + code: 0, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + })) + + try { + await fs.rm(mod, { recursive: true, force: true }) + await BunProc.install(pkg, ver, { ignoreScripts: true }) + + expect(run).toHaveBeenCalled() + const call = run.mock.calls[0]?.[0] + expect(call).toContain("--ignore-scripts") + expect(call).toContain(`${pkg}@${ver}`) + } finally { + run.mockRestore() + await fs.rm(mod, { recursive: true, force: true }) + + const end = await fs + .readFile(data, "utf8") + .then((item) => JSON.parse(item) as { dependencies?: Record }) + .catch(() => undefined) + if (end?.dependencies) { + delete end.dependencies[pkg] + await Bun.write(data, JSON.stringify(end, null, 2)) + } + } + }) +}) diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index ebc8daa24ed8..b547979231ac 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -266,8 +266,8 @@ describe("plugin.loader.shared", () => { try { await load(tmp.path) - expect(install.mock.calls).toContainEqual(["acme-plugin", "latest"]) - expect(install.mock.calls).toContainEqual(["scope-plugin", "2.3.4"]) + expect(install.mock.calls).toContainEqual(["acme-plugin", "latest", { ignoreScripts: true }]) + expect(install.mock.calls).toContainEqual(["scope-plugin", "2.3.4", { ignoreScripts: true }]) } finally { install.mockRestore() }