summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorLuke Parker <[email protected]>2026-04-06 10:26:40 +1000
committerGitHub <[email protected]>2026-04-06 00:26:40 +0000
commit68f4aa220ea68c539bd1e316a4e96fdd76b93560 (patch)
tree49a9a030c50e23f32cf90fc4d12df36af7615df6 /packages
parent3a0e00dd7f9192730f6d0eeee37ae0a5fb023927 (diff)
downloadopencode-68f4aa220ea68c539bd1e316a4e96fdd76b93560.tar.gz
opencode-68f4aa220ea68c539bd1e316a4e96fdd76b93560.zip
fix(plugin): parse package specifiers with npm-package-arg and sanitize win32 cache paths (#21135)
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/package.json2
-rw-r--r--packages/opencode/src/npm/index.ts8
-rw-r--r--packages/opencode/src/plugin/shared.ts28
-rw-r--r--packages/opencode/test/npm.test.ts18
-rw-r--r--packages/opencode/test/plugin/shared.test.ts88
5 files changed, 137 insertions, 7 deletions
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index d7f12549c..40a0fed2f 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 69bb2ca52..3568ff20e 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 {
}),
)
+ export 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 f92520d05..6cda49786 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,11 +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) {
- const lastAt = spec.lastIndexOf("@")
- 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)
+ 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" }
+ return { pkg: hit.name, version: hit.rawSpec }
}
export type PluginSource = "file" | "npm"
@@ -190,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
new file mode 100644
index 000000000..61e3ca6dd
--- /dev/null
+++ b/packages/opencode/test/npm.test.ts
@@ -0,0 +1,18 @@
+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")
+ expect(Npm.sanitize("@opencode/[email protected]")).toBe("@opencode/[email protected]")
+ expect(Npm.sanitize("prettier")).toBe("prettier")
+ })
+
+ 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)
+ })
+})
diff --git a/packages/opencode/test/plugin/shared.test.ts b/packages/opencode/test/plugin/shared.test.ts
new file mode 100644
index 000000000..98475b02f
--- /dev/null
+++ b/packages/opencode/test/plugin/shared.test.ts
@@ -0,0 +1,88 @@
+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("[email protected]")).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/[email protected]")).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://[email protected]/opencode/acme.git")).toEqual({
+ pkg: "acme",
+ version: "git+ssh://[email protected]/opencode/acme.git",
+ })
+ })
+
+ test("parses scoped package with git+ssh url containing another @", () => {
+ expect(parsePluginSpecifier("@opencode/acme@git+ssh://[email protected]/opencode/acme.git")).toEqual({
+ pkg: "@opencode/acme",
+ version: "git+ssh://[email protected]/opencode/acme.git",
+ })
+ })
+
+ test("parses unaliased git+ssh url", () => {
+ expect(parsePluginSpecifier("git+ssh://[email protected]/opencode/acme.git")).toEqual({
+ pkg: "git+ssh://[email protected]/opencode/acme.git",
+ version: "",
+ })
+ })
+
+ test("parses npm alias using the alias name", () => {
+ expect(parsePluginSpecifier("acme@npm:@opencode/[email protected]")).toEqual({
+ pkg: "acme",
+ version: "npm:@opencode/[email protected]",
+ })
+ })
+
+ test("parses bare npm protocol specifier using the target package", () => {
+ expect(parsePluginSpecifier("npm:@opencode/[email protected]")).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",
+ })
+ })
+})