Skip to content

Commit 2e78fde

Browse files
authored
ensure pinned plugin versions and do not run package scripts on install (#20248)
1 parent 1fcb920 commit 2e78fde

5 files changed

Lines changed: 93 additions & 5 deletions

File tree

packages/opencode/specs/tui-plugins.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,11 @@ npm plugins can declare a version compatibility range in `package.json` using th
148148
- `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
149149
- `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`.
150150
- `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced.
151+
- npm plugin package installs are executed with `--ignore-scripts`, so package `install` / `postinstall` lifecycle scripts are not run.
151152
- Without `--force`, an already-configured npm package name is a no-op.
152153
- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
154+
- 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.
155+
- Bare npm specs (`pkg`) are treated as `latest` and can refresh when the cached version is stale.
153156
- Tuple targets in `oc-plugin` provide default options written into config.
154157
- A package can target `server`, `tui`, or both.
155158
- If a package targets both, each target must still resolve to a separate target-only module. Do not export `{ server, tui }` from one module.

packages/opencode/src/bun/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export namespace BunProc {
5050
}),
5151
)
5252

53-
export async function install(pkg: string, version = "latest") {
53+
export async function install(pkg: string, version = "latest", opts?: { ignoreScripts?: boolean }) {
5454
// Use lock to ensure only one install at a time
5555
using _ = await Lock.write("bun-install")
5656

@@ -82,6 +82,7 @@ export namespace BunProc {
8282
"add",
8383
"--force",
8484
"--exact",
85+
...(opts?.ignoreScripts ? ["--ignore-scripts"] : []),
8586
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
8687
...(proxied() || process.env.CI ? ["--no-cache"] : []),
8788
"--cwd",

packages/opencode/src/plugin/shared.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ export async function checkPluginCompatibility(target: string, opencodeVersion:
189189

190190
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
191191
if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec)
192-
return BunProc.install(parsed.pkg, parsed.version)
192+
return BunProc.install(parsed.pkg, parsed.version, { ignoreScripts: true })
193193
}
194194

195195
export async function readPluginPackage(target: string): Promise<PluginPackage> {

packages/opencode/test/bun.test.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
import { describe, expect, test } from "bun:test"
1+
import { describe, expect, spyOn, test } from "bun:test"
22
import fs from "fs/promises"
33
import path from "path"
4+
import { BunProc } from "../src/bun"
5+
import { PackageRegistry } from "../src/bun/registry"
6+
import { Global } from "../src/global"
7+
import { Process } from "../src/util/process"
48

59
describe("BunProc registry configuration", () => {
610
test("should not contain hardcoded registry parameters", async () => {
@@ -51,3 +55,83 @@ describe("BunProc registry configuration", () => {
5155
}
5256
})
5357
})
58+
59+
describe("BunProc install pinning", () => {
60+
test("uses pinned cache without touching registry", async () => {
61+
const pkg = `pin-test-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
62+
const ver = "1.2.3"
63+
const mod = path.join(Global.Path.cache, "node_modules", pkg)
64+
const data = path.join(Global.Path.cache, "package.json")
65+
66+
await fs.mkdir(mod, { recursive: true })
67+
await Bun.write(path.join(mod, "package.json"), JSON.stringify({ name: pkg, version: ver }, null, 2))
68+
69+
const src = await fs.readFile(data, "utf8").catch(() => "")
70+
const json = src ? ((JSON.parse(src) as { dependencies?: Record<string, string> }) ?? {}) : {}
71+
const deps = json.dependencies ?? {}
72+
deps[pkg] = ver
73+
await Bun.write(data, JSON.stringify({ ...json, dependencies: deps }, null, 2))
74+
75+
const stale = spyOn(PackageRegistry, "isOutdated").mockImplementation(async () => {
76+
throw new Error("unexpected registry check")
77+
})
78+
const run = spyOn(Process, "run").mockImplementation(async () => {
79+
throw new Error("unexpected process.run")
80+
})
81+
82+
try {
83+
const out = await BunProc.install(pkg, ver)
84+
expect(out).toBe(mod)
85+
expect(stale).not.toHaveBeenCalled()
86+
expect(run).not.toHaveBeenCalled()
87+
} finally {
88+
stale.mockRestore()
89+
run.mockRestore()
90+
91+
await fs.rm(mod, { recursive: true, force: true })
92+
const end = await fs
93+
.readFile(data, "utf8")
94+
.then((item) => JSON.parse(item) as { dependencies?: Record<string, string> })
95+
.catch(() => undefined)
96+
if (end?.dependencies) {
97+
delete end.dependencies[pkg]
98+
await Bun.write(data, JSON.stringify(end, null, 2))
99+
}
100+
}
101+
})
102+
103+
test("passes --ignore-scripts when requested", async () => {
104+
const pkg = `ignore-test-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
105+
const ver = "4.5.6"
106+
const mod = path.join(Global.Path.cache, "node_modules", pkg)
107+
const data = path.join(Global.Path.cache, "package.json")
108+
109+
const run = spyOn(Process, "run").mockImplementation(async () => ({
110+
code: 0,
111+
stdout: Buffer.alloc(0),
112+
stderr: Buffer.alloc(0),
113+
}))
114+
115+
try {
116+
await fs.rm(mod, { recursive: true, force: true })
117+
await BunProc.install(pkg, ver, { ignoreScripts: true })
118+
119+
expect(run).toHaveBeenCalled()
120+
const call = run.mock.calls[0]?.[0]
121+
expect(call).toContain("--ignore-scripts")
122+
expect(call).toContain(`${pkg}@${ver}`)
123+
} finally {
124+
run.mockRestore()
125+
await fs.rm(mod, { recursive: true, force: true })
126+
127+
const end = await fs
128+
.readFile(data, "utf8")
129+
.then((item) => JSON.parse(item) as { dependencies?: Record<string, string> })
130+
.catch(() => undefined)
131+
if (end?.dependencies) {
132+
delete end.dependencies[pkg]
133+
await Bun.write(data, JSON.stringify(end, null, 2))
134+
}
135+
}
136+
})
137+
})

packages/opencode/test/plugin/loader-shared.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,8 +266,8 @@ describe("plugin.loader.shared", () => {
266266
try {
267267
await load(tmp.path)
268268

269-
expect(install.mock.calls).toContainEqual(["acme-plugin", "latest"])
270-
expect(install.mock.calls).toContainEqual(["scope-plugin", "2.3.4"])
269+
expect(install.mock.calls).toContainEqual(["acme-plugin", "latest", { ignoreScripts: true }])
270+
expect(install.mock.calls).toContainEqual(["scope-plugin", "2.3.4", { ignoreScripts: true }])
271271
} finally {
272272
install.mockRestore()
273273
}

0 commit comments

Comments
 (0)