Просмотр исходного кода

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

Sebastian 3 недель назад
Родитель
Сommit
2e78fdec43

+ 3 - 0
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.

+ 2 - 1
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",

+ 1 - 1
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> {

+ 85 - 1
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))
+      }
+    }
+  })
+})

+ 2 - 2
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()
     }