Skip to content

Commit 68f4aa2

Browse files
authored
fix(plugin): parse package specifiers with npm-package-arg and sanitize win32 cache paths (#21135)
1 parent 3a0e00d commit 68f4aa2

File tree

6 files changed

+139
-7
lines changed

6 files changed

+139
-7
lines changed

bun.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/opencode/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"@types/bun": "catalog:",
5555
"@types/cross-spawn": "catalog:",
5656
"@types/mime-types": "3.0.1",
57+
"@types/npm-package-arg": "6.1.4",
5758
"@types/npmcli__arborist": "6.3.3",
5859
"@types/semver": "^7.5.8",
5960
"@types/turndown": "5.0.5",
@@ -135,6 +136,7 @@
135136
"jsonc-parser": "3.3.1",
136137
"mime-types": "3.0.2",
137138
"minimatch": "10.0.3",
139+
"npm-package-arg": "13.0.2",
138140
"open": "10.1.2",
139141
"opencode-gitlab-auth": "2.0.1",
140142
"opencode-poe-auth": "0.0.1",

packages/opencode/src/npm/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Arborist } from "@npmcli/arborist"
1111

1212
export namespace Npm {
1313
const log = Log.create({ service: "npm" })
14+
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
1415

1516
export const InstallFailedError = NamedError.create(
1617
"NpmInstallFailedError",
@@ -19,8 +20,13 @@ export namespace Npm {
1920
}),
2021
)
2122

23+
export function sanitize(pkg: string) {
24+
if (!illegal) return pkg
25+
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
26+
}
27+
2228
function directory(pkg: string) {
23-
return path.join(Global.Path.cache, "packages", pkg)
29+
return path.join(Global.Path.cache, "packages", sanitize(pkg))
2430
}
2531

2632
function resolveEntryPoint(name: string, dir: string) {

packages/opencode/src/plugin/shared.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import path from "path"
22
import { fileURLToPath, pathToFileURL } from "url"
3+
import npa from "npm-package-arg"
34
import semver from "semver"
45
import { Npm } from "@/npm"
56
import { Filesystem } from "@/util/filesystem"
@@ -12,11 +13,24 @@ export function isDeprecatedPlugin(spec: string) {
1213
return DEPRECATED_PLUGIN_PACKAGES.some((pkg) => spec.includes(pkg))
1314
}
1415

16+
function parse(spec: string) {
17+
try {
18+
return npa(spec)
19+
} catch {}
20+
}
21+
1522
export function parsePluginSpecifier(spec: string) {
16-
const lastAt = spec.lastIndexOf("@")
17-
const pkg = lastAt > 0 ? spec.substring(0, lastAt) : spec
18-
const version = lastAt > 0 ? spec.substring(lastAt + 1) : "latest"
19-
return { pkg, version }
23+
const hit = parse(spec)
24+
if (hit?.type === "alias" && !hit.name) {
25+
const sub = (hit as npa.AliasResult).subSpec
26+
if (sub?.name) {
27+
const version = !sub.rawSpec || sub.rawSpec === "*" ? "latest" : sub.rawSpec
28+
return { pkg: sub.name, version }
29+
}
30+
}
31+
if (!hit?.name) return { pkg: spec, version: "" }
32+
if (hit.raw === hit.name) return { pkg: hit.name, version: "latest" }
33+
return { pkg: hit.name, version: hit.rawSpec }
2034
}
2135

2236
export type PluginSource = "file" | "npm"
@@ -190,9 +204,11 @@ export async function checkPluginCompatibility(target: string, opencodeVersion:
190204
}
191205
}
192206

193-
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
207+
export async function resolvePluginTarget(spec: string) {
194208
if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec)
195-
const result = await Npm.add(parsed.pkg + "@" + parsed.version)
209+
const hit = parse(spec)
210+
const pkg = hit?.name && hit.raw === hit.name ? `${hit.name}@latest` : spec
211+
const result = await Npm.add(pkg)
196212
return result.directory
197213
}
198214

packages/opencode/test/npm.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { Npm } from "../src/npm"
3+
4+
const win = process.platform === "win32"
5+
6+
describe("Npm.sanitize", () => {
7+
test("keeps normal scoped package specs unchanged", () => {
8+
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
9+
expect(Npm.sanitize("@opencode/acme@1.0.0")).toBe("@opencode/acme@1.0.0")
10+
expect(Npm.sanitize("prettier")).toBe("prettier")
11+
})
12+
13+
test("handles git https specs", () => {
14+
const spec = "acme@git+https://github.com/opencode/acme.git"
15+
const expected = win ? "acme@git+https_//github.com/opencode/acme.git" : spec
16+
expect(Npm.sanitize(spec)).toBe(expected)
17+
})
18+
})
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { parsePluginSpecifier } from "../../src/plugin/shared"
3+
4+
describe("parsePluginSpecifier", () => {
5+
test("parses standard npm package without version", () => {
6+
expect(parsePluginSpecifier("acme")).toEqual({
7+
pkg: "acme",
8+
version: "latest",
9+
})
10+
})
11+
12+
test("parses standard npm package with version", () => {
13+
expect(parsePluginSpecifier("acme@1.0.0")).toEqual({
14+
pkg: "acme",
15+
version: "1.0.0",
16+
})
17+
})
18+
19+
test("parses scoped npm package without version", () => {
20+
expect(parsePluginSpecifier("@opencode/acme")).toEqual({
21+
pkg: "@opencode/acme",
22+
version: "latest",
23+
})
24+
})
25+
26+
test("parses scoped npm package with version", () => {
27+
expect(parsePluginSpecifier("@opencode/acme@1.0.0")).toEqual({
28+
pkg: "@opencode/acme",
29+
version: "1.0.0",
30+
})
31+
})
32+
33+
test("parses package with git+https url", () => {
34+
expect(parsePluginSpecifier("acme@git+https://github.com/opencode/acme.git")).toEqual({
35+
pkg: "acme",
36+
version: "git+https://github.com/opencode/acme.git",
37+
})
38+
})
39+
40+
test("parses scoped package with git+https url", () => {
41+
expect(parsePluginSpecifier("@opencode/acme@git+https://github.com/opencode/acme.git")).toEqual({
42+
pkg: "@opencode/acme",
43+
version: "git+https://github.com/opencode/acme.git",
44+
})
45+
})
46+
47+
test("parses package with git+ssh url containing another @", () => {
48+
expect(parsePluginSpecifier("acme@git+ssh://git@github.com/opencode/acme.git")).toEqual({
49+
pkg: "acme",
50+
version: "git+ssh://git@github.com/opencode/acme.git",
51+
})
52+
})
53+
54+
test("parses scoped package with git+ssh url containing another @", () => {
55+
expect(parsePluginSpecifier("@opencode/acme@git+ssh://git@github.com/opencode/acme.git")).toEqual({
56+
pkg: "@opencode/acme",
57+
version: "git+ssh://git@github.com/opencode/acme.git",
58+
})
59+
})
60+
61+
test("parses unaliased git+ssh url", () => {
62+
expect(parsePluginSpecifier("git+ssh://git@github.com/opencode/acme.git")).toEqual({
63+
pkg: "git+ssh://git@github.com/opencode/acme.git",
64+
version: "",
65+
})
66+
})
67+
68+
test("parses npm alias using the alias name", () => {
69+
expect(parsePluginSpecifier("acme@npm:@opencode/acme@1.0.0")).toEqual({
70+
pkg: "acme",
71+
version: "npm:@opencode/acme@1.0.0",
72+
})
73+
})
74+
75+
test("parses bare npm protocol specifier using the target package", () => {
76+
expect(parsePluginSpecifier("npm:@opencode/acme@1.0.0")).toEqual({
77+
pkg: "@opencode/acme",
78+
version: "1.0.0",
79+
})
80+
})
81+
82+
test("parses unversioned npm protocol specifier", () => {
83+
expect(parsePluginSpecifier("npm:@opencode/acme")).toEqual({
84+
pkg: "@opencode/acme",
85+
version: "latest",
86+
})
87+
})
88+
})

0 commit comments

Comments
 (0)