From e8fd450eb69ab79a970dbbbdf2989ef5130b0444 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:53:19 +1000 Subject: [PATCH 1/4] Sanitize npm package dirs and fix plugin parsing Replace filesystem-illegal characters in package cache directory names on Windows by adding a sanitize() helper and using it in directory(). Fix parsing of plugin specifiers by splitting on the first "@" after position 0 so scoped names and git URLs are handled correctly. Add unit tests for parsePluginSpecifier covering plain, scoped, and git URL cases. --- packages/opencode/src/npm/index.ts | 8 ++- packages/opencode/src/plugin/shared.ts | 3 +- packages/opencode/test/plugin/shared.test.ts | 60 ++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/plugin/shared.test.ts diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index 69bb2ca5284e..a1d9ee2ba387 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -11,6 +11,7 @@ import { Arborist } from "@npmcli/arborist" export namespace Npm { const log = Log.create({ service: "npm" }) + const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined export const InstallFailedError = NamedError.create( "NpmInstallFailedError", @@ -19,8 +20,13 @@ export namespace Npm { }), ) + function sanitize(pkg: string) { + if (!illegal) return pkg + return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("") + } + function directory(pkg: string) { - return path.join(Global.Path.cache, "packages", pkg) + return path.join(Global.Path.cache, "packages", sanitize(pkg)) } function resolveEntryPoint(name: string, dir: string) { diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts index f92520d05dc2..e0adb707ae17 100644 --- a/packages/opencode/src/plugin/shared.ts +++ b/packages/opencode/src/plugin/shared.ts @@ -13,7 +13,8 @@ export function isDeprecatedPlugin(spec: string) { } export function parsePluginSpecifier(spec: string) { - const lastAt = spec.lastIndexOf("@") + // Split on the version separator while keeping scoped names and git URLs intact. + const lastAt = spec.indexOf("@", 1) const pkg = lastAt > 0 ? spec.substring(0, lastAt) : spec const version = lastAt > 0 ? spec.substring(lastAt + 1) : "latest" return { pkg, version } diff --git a/packages/opencode/test/plugin/shared.test.ts b/packages/opencode/test/plugin/shared.test.ts new file mode 100644 index 000000000000..7fac242cc545 --- /dev/null +++ b/packages/opencode/test/plugin/shared.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test" +import { parsePluginSpecifier } from "../../src/plugin/shared" + +describe("parsePluginSpecifier", () => { + test("parses standard npm package without version", () => { + expect(parsePluginSpecifier("acme")).toEqual({ + pkg: "acme", + version: "latest", + }) + }) + + test("parses standard npm package with version", () => { + expect(parsePluginSpecifier("acme@1.0.0")).toEqual({ + pkg: "acme", + version: "1.0.0", + }) + }) + + test("parses scoped npm package without version", () => { + expect(parsePluginSpecifier("@opencode/acme")).toEqual({ + pkg: "@opencode/acme", + version: "latest", + }) + }) + + test("parses scoped npm package with version", () => { + expect(parsePluginSpecifier("@opencode/acme@1.0.0")).toEqual({ + pkg: "@opencode/acme", + version: "1.0.0", + }) + }) + + test("parses package with git+https url", () => { + expect(parsePluginSpecifier("acme@git+https://github.com/opencode/acme.git")).toEqual({ + pkg: "acme", + version: "git+https://github.com/opencode/acme.git", + }) + }) + + test("parses scoped package with git+https url", () => { + expect(parsePluginSpecifier("@opencode/acme@git+https://github.com/opencode/acme.git")).toEqual({ + pkg: "@opencode/acme", + version: "git+https://github.com/opencode/acme.git", + }) + }) + + test("parses package with git+ssh url containing another @", () => { + expect(parsePluginSpecifier("acme@git+ssh://git@github.com/opencode/acme.git")).toEqual({ + pkg: "acme", + version: "git+ssh://git@github.com/opencode/acme.git", + }) + }) + + test("parses scoped package with git+ssh url containing another @", () => { + expect(parsePluginSpecifier("@opencode/acme@git+ssh://git@github.com/opencode/acme.git")).toEqual({ + pkg: "@opencode/acme", + version: "git+ssh://git@github.com/opencode/acme.git", + }) + }) +}) From 4a5a47468cfd49b76396d879266ae2d10171501e Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:05:27 +1000 Subject: [PATCH 2/4] Export sanitize and limit to Windows; add tests Always define the illegal-character set and export Npm.sanitize, but only perform character substitution on Windows (no-op on other platforms). This exposes sanitize for testing and avoids unnecessary replacements on non-Windows systems. Adds unit tests to verify scoped package handling and git+https spec sanitization on Windows. --- packages/opencode/src/npm/index.ts | 6 +++--- packages/opencode/test/npm.test.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/npm.test.ts diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index a1d9ee2ba387..152f47043a27 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -11,7 +11,7 @@ import { Arborist } from "@npmcli/arborist" export namespace Npm { const log = Log.create({ service: "npm" }) - const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined + const illegal = new Set(["<", ">", ":", '"', "|", "?", "*"]) export const InstallFailedError = NamedError.create( "NpmInstallFailedError", @@ -20,8 +20,8 @@ export namespace Npm { }), ) - function sanitize(pkg: string) { - if (!illegal) return pkg + export function sanitize(pkg: string) { + if (process.platform !== "win32") return pkg return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("") } diff --git a/packages/opencode/test/npm.test.ts b/packages/opencode/test/npm.test.ts new file mode 100644 index 000000000000..e9a930a38f3c --- /dev/null +++ b/packages/opencode/test/npm.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import { Npm } from "../src/npm" + +const win = process.platform === "win32" + +describe("Npm.sanitize", () => { + test("keeps normal scoped package specs unchanged", () => { + expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme") + }) + + test("handles git https specs", () => { + const spec = "acme@git+https://github.com/opencode/acme.git" + const expected = win ? "acme@git+https_//github.com/opencode/acme.git" : spec + expect(Npm.sanitize(spec)).toBe(expected) + }) +}) From f0689971e240ec931f8fcc802763cfe3c989e990 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:47:58 +1000 Subject: [PATCH 3/4] Use npm-package-arg to parse plugin specifiers Add npm-package-arg (and types) and switch plugin specifier parsing to use it. parsePluginSpecifier now relies on npa to correctly handle aliases, npm: protocol specs, git URLs and unversioned specs; resolvePluginTarget builds the install target from the parsed result. Npm.sanitize was simplified to only apply Windows-specific illegal-character replacement, and tests were updated/added to cover the new parsing and sanitize behaviors. Package.json and lockfile updated to include the new dependency. --- bun.lock | 2 ++ packages/opencode/package.json | 2 ++ packages/opencode/src/npm/index.ts | 4 +-- packages/opencode/src/plugin/shared.ts | 29 +++++++++++++++----- packages/opencode/test/npm.test.ts | 2 ++ packages/opencode/test/plugin/shared.test.ts | 28 +++++++++++++++++++ 6 files changed, 58 insertions(+), 9 deletions(-) diff --git a/bun.lock b/bun.lock index cdf44a5d84a9..d4159c2495fb 100644 --- a/bun.lock +++ b/bun.lock @@ -371,6 +371,7 @@ "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "minimatch": "10.0.3", + "npm-package-arg": "13.0.2", "open": "10.1.2", "opencode-gitlab-auth": "2.0.1", "opencode-poe-auth": "0.0.1", @@ -412,6 +413,7 @@ "@types/bun": "catalog:", "@types/cross-spawn": "catalog:", "@types/mime-types": "3.0.1", + "@types/npm-package-arg": "6.1.4", "@types/npmcli__arborist": "6.3.3", "@types/semver": "^7.5.8", "@types/turndown": "5.0.5", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index d7f12549c018..40a0fed2fa1b 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -54,6 +54,7 @@ "@types/bun": "catalog:", "@types/cross-spawn": "catalog:", "@types/mime-types": "3.0.1", + "@types/npm-package-arg": "6.1.4", "@types/npmcli__arborist": "6.3.3", "@types/semver": "^7.5.8", "@types/turndown": "5.0.5", @@ -135,6 +136,7 @@ "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "minimatch": "10.0.3", + "npm-package-arg": "13.0.2", "open": "10.1.2", "opencode-gitlab-auth": "2.0.1", "opencode-poe-auth": "0.0.1", diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index 152f47043a27..3568ff20e245 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -11,7 +11,7 @@ import { Arborist } from "@npmcli/arborist" export namespace Npm { const log = Log.create({ service: "npm" }) - const illegal = new Set(["<", ">", ":", '"', "|", "?", "*"]) + const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined export const InstallFailedError = NamedError.create( "NpmInstallFailedError", @@ -21,7 +21,7 @@ export namespace Npm { ) export function sanitize(pkg: string) { - if (process.platform !== "win32") return pkg + if (!illegal) return pkg return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("") } diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts index e0adb707ae17..c89757a26944 100644 --- a/packages/opencode/src/plugin/shared.ts +++ b/packages/opencode/src/plugin/shared.ts @@ -1,5 +1,6 @@ import path from "path" import { fileURLToPath, pathToFileURL } from "url" +import npa from "npm-package-arg" import semver from "semver" import { Npm } from "@/npm" import { Filesystem } from "@/util/filesystem" @@ -12,12 +13,24 @@ export function isDeprecatedPlugin(spec: string) { return DEPRECATED_PLUGIN_PACKAGES.some((pkg) => spec.includes(pkg)) } +function parse(spec: string) { + try { + return npa(spec) + } catch {} +} + export function parsePluginSpecifier(spec: string) { - // Split on the version separator while keeping scoped names and git URLs intact. - const lastAt = spec.indexOf("@", 1) - const pkg = lastAt > 0 ? spec.substring(0, lastAt) : spec - const version = lastAt > 0 ? spec.substring(lastAt + 1) : "latest" - return { pkg, version } + const hit = parse(spec) + const sub = + hit && "subSpec" in hit + ? (hit as typeof hit & { subSpec?: { name?: string; rawSpec?: string } }).subSpec + : undefined + if (hit?.type === "alias" && !hit.name && sub?.name) { + return { pkg: sub.name, version: !sub.rawSpec || sub.rawSpec === "*" ? "latest" : sub.rawSpec } + } + if (!hit?.name) return { pkg: spec, version: "" } + if (hit.raw === hit.name) return { pkg: hit.name, version: "latest" } + return { pkg: hit.name, version: hit.rawSpec } } export type PluginSource = "file" | "npm" @@ -191,9 +204,11 @@ export async function checkPluginCompatibility(target: string, opencodeVersion: } } -export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) { +export async function resolvePluginTarget(spec: string) { if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec) - const result = await Npm.add(parsed.pkg + "@" + parsed.version) + const hit = parse(spec) + const pkg = hit?.name && hit.raw === hit.name ? `${hit.name}@latest` : spec + const result = await Npm.add(pkg) return result.directory } diff --git a/packages/opencode/test/npm.test.ts b/packages/opencode/test/npm.test.ts index e9a930a38f3c..61e3ca6ddf02 100644 --- a/packages/opencode/test/npm.test.ts +++ b/packages/opencode/test/npm.test.ts @@ -6,6 +6,8 @@ const win = process.platform === "win32" describe("Npm.sanitize", () => { test("keeps normal scoped package specs unchanged", () => { expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme") + expect(Npm.sanitize("@opencode/acme@1.0.0")).toBe("@opencode/acme@1.0.0") + expect(Npm.sanitize("prettier")).toBe("prettier") }) test("handles git https specs", () => { diff --git a/packages/opencode/test/plugin/shared.test.ts b/packages/opencode/test/plugin/shared.test.ts index 7fac242cc545..98475b02f469 100644 --- a/packages/opencode/test/plugin/shared.test.ts +++ b/packages/opencode/test/plugin/shared.test.ts @@ -57,4 +57,32 @@ describe("parsePluginSpecifier", () => { version: "git+ssh://git@github.com/opencode/acme.git", }) }) + + test("parses unaliased git+ssh url", () => { + expect(parsePluginSpecifier("git+ssh://git@github.com/opencode/acme.git")).toEqual({ + pkg: "git+ssh://git@github.com/opencode/acme.git", + version: "", + }) + }) + + test("parses npm alias using the alias name", () => { + expect(parsePluginSpecifier("acme@npm:@opencode/acme@1.0.0")).toEqual({ + pkg: "acme", + version: "npm:@opencode/acme@1.0.0", + }) + }) + + test("parses bare npm protocol specifier using the target package", () => { + expect(parsePluginSpecifier("npm:@opencode/acme@1.0.0")).toEqual({ + pkg: "@opencode/acme", + version: "1.0.0", + }) + }) + + test("parses unversioned npm protocol specifier", () => { + expect(parsePluginSpecifier("npm:@opencode/acme")).toEqual({ + pkg: "@opencode/acme", + version: "latest", + }) + }) }) From f00c3046e75a39f8e1e38052877c5fc932f25a3f Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:19:46 +1000 Subject: [PATCH 4/4] Simplify plugin alias parsing --- packages/opencode/src/plugin/shared.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts index c89757a26944..6cda49786bc9 100644 --- a/packages/opencode/src/plugin/shared.ts +++ b/packages/opencode/src/plugin/shared.ts @@ -21,12 +21,12 @@ function parse(spec: string) { export function parsePluginSpecifier(spec: string) { const hit = parse(spec) - const sub = - hit && "subSpec" in hit - ? (hit as typeof hit & { subSpec?: { name?: string; rawSpec?: string } }).subSpec - : undefined - if (hit?.type === "alias" && !hit.name && sub?.name) { - return { pkg: sub.name, version: !sub.rawSpec || sub.rawSpec === "*" ? "latest" : sub.rawSpec } + if (hit?.type === "alias" && !hit.name) { + const sub = (hit as npa.AliasResult).subSpec + if (sub?.name) { + const version = !sub.rawSpec || sub.rawSpec === "*" ? "latest" : sub.rawSpec + return { pkg: sub.name, version } + } } if (!hit?.name) return { pkg: spec, version: "" } if (hit.raw === hit.name) return { pkg: hit.name, version: "latest" }