summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorSebastian <[email protected]>2026-03-31 16:59:43 +0200
committerGitHub <[email protected]>2026-03-31 16:59:43 +0200
commit2e78fdec43ecf98123813b4b1f1c45125004f73f (patch)
treee0cd91aacb325b7f0cb5f2613328844b7ee22ed8
parent1fcb920eb42f71548c0bfc4259b0464e3f40e1b7 (diff)
downloadopencode-2e78fdec43ecf98123813b4b1f1c45125004f73f.tar.gz
opencode-2e78fdec43ecf98123813b4b1f1c45125004f73f.zip
ensure pinned plugin versions and do not run package scripts on install (#20248)
-rw-r--r--packages/opencode/specs/tui-plugins.md3
-rw-r--r--packages/opencode/src/bun/index.ts3
-rw-r--r--packages/opencode/src/plugin/shared.ts2
-rw-r--r--packages/opencode/test/bun.test.ts86
-rw-r--r--packages/opencode/test/plugin/loader-shared.test.ts4
5 files changed, 93 insertions, 5 deletions
diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md
index d5fe48629..f979b1a10 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 `[email protected]`) 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 dbdf5a2bc..589414a02 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 116519143..2c9edfb0a 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<PluginPackage> {
diff --git a/packages/opencode/test/bun.test.ts b/packages/opencode/test/bun.test.ts
index d607ae478..db3fa2a28 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<string, string> }) ?? {}) : {}
+ 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<string, string> })
+ .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<string, string> })
+ .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 ebc8daa24..b54797923 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()
}