ensure pinned plugin versions and do not run package scripts on install (#20248)

pull/20284/head
Sebastian 2026-03-31 16:59:43 +02:00 committed by GitHub
parent 1fcb920eb4
commit 2e78fdec43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 93 additions and 5 deletions

View File

@ -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.

View File

@ -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",

View File

@ -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> {

View File

@ -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))
}
}
})
})

View File

@ -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()
}