Skip to content

Commit c45208c

Browse files
kommanderRyu
authored andcommitted
resolve subpath only packages for plugins (anomalyco#20555)
1 parent 569e70b commit c45208c

File tree

2 files changed

+185
-1
lines changed

2 files changed

+185
-1
lines changed

packages/opencode/src/npm/index.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import semver from "semver"
2+
import z from "zod"
3+
import { NamedError } from "@opencode-ai/util/error"
4+
import { Global } from "../global"
5+
import { Lock } from "../util/lock"
6+
import { Log } from "../util/log"
7+
import path from "path"
8+
import { readdir, rm } from "fs/promises"
9+
import { Filesystem } from "@/util/filesystem"
10+
import { Flock } from "@/util/flock"
11+
import { Arborist } from "@npmcli/arborist"
12+
13+
export namespace Npm {
14+
const log = Log.create({ service: "npm" })
15+
16+
export const InstallFailedError = NamedError.create(
17+
"NpmInstallFailedError",
18+
z.object({
19+
pkg: z.string(),
20+
}),
21+
)
22+
23+
function directory(pkg: string) {
24+
return path.join(Global.Path.cache, "packages", pkg)
25+
}
26+
27+
function resolveEntryPoint(name: string, dir: string) {
28+
let entrypoint: string | undefined
29+
try {
30+
entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
31+
} catch {}
32+
const result = {
33+
directory: dir,
34+
entrypoint,
35+
}
36+
return result
37+
}
38+
39+
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
40+
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
41+
if (!response.ok) {
42+
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
43+
return false
44+
}
45+
46+
const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
47+
const latestVersion = data?.["dist-tags"]?.latest
48+
if (!latestVersion) {
49+
log.warn("No latest version found, using cached", { pkg, cachedVersion })
50+
return false
51+
}
52+
53+
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
54+
if (range) return !semver.satisfies(latestVersion, cachedVersion)
55+
56+
return semver.lt(cachedVersion, latestVersion)
57+
}
58+
59+
export async function add(pkg: string) {
60+
using _ = await Lock.write(`npm-install:${pkg}`)
61+
log.info("installing package", {
62+
pkg,
63+
})
64+
const dir = directory(pkg)
65+
66+
const arborist = new Arborist({
67+
path: dir,
68+
binLinks: true,
69+
progress: false,
70+
savePrefix: "",
71+
})
72+
const tree = await arborist.loadVirtual().catch(() => {})
73+
if (tree) {
74+
const first = tree.edgesOut.values().next().value?.to
75+
if (first) {
76+
return resolveEntryPoint(first.name, first.path)
77+
}
78+
}
79+
80+
const result = await arborist
81+
.reify({
82+
add: [pkg],
83+
save: true,
84+
saveType: "prod",
85+
})
86+
.catch((cause) => {
87+
throw new InstallFailedError(
88+
{ pkg },
89+
{
90+
cause,
91+
},
92+
)
93+
})
94+
95+
const first = result.edgesOut.values().next().value?.to
96+
if (!first) throw new InstallFailedError({ pkg })
97+
return resolveEntryPoint(first.name, first.path)
98+
}
99+
100+
export async function install(dir: string) {
101+
await using _ = await Flock.acquire(`npm-install:${dir}`)
102+
log.info("checking dependencies", { dir })
103+
104+
const reify = async () => {
105+
const arb = new Arborist({
106+
path: dir,
107+
binLinks: true,
108+
progress: false,
109+
savePrefix: "",
110+
})
111+
await arb.reify().catch(() => {})
112+
}
113+
114+
if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
115+
log.info("node_modules missing, reifying")
116+
await reify()
117+
return
118+
}
119+
120+
const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
121+
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
122+
123+
const declared = new Set([
124+
...Object.keys(pkg.dependencies || {}),
125+
...Object.keys(pkg.devDependencies || {}),
126+
...Object.keys(pkg.peerDependencies || {}),
127+
...Object.keys(pkg.optionalDependencies || {}),
128+
])
129+
130+
const root = lock.packages?.[""] || {}
131+
const locked = new Set([
132+
...Object.keys(root.dependencies || {}),
133+
...Object.keys(root.devDependencies || {}),
134+
...Object.keys(root.peerDependencies || {}),
135+
...Object.keys(root.optionalDependencies || {}),
136+
])
137+
138+
for (const name of declared) {
139+
if (!locked.has(name)) {
140+
log.info("dependency not in lock file, reifying", { name })
141+
await reify()
142+
return
143+
}
144+
}
145+
146+
log.info("dependencies in sync")
147+
}
148+
149+
export async function which(pkg: string) {
150+
const dir = directory(pkg)
151+
const binDir = path.join(dir, "node_modules", ".bin")
152+
153+
const pick = async () => {
154+
const files = await readdir(binDir).catch(() => [])
155+
if (files.length === 0) return undefined
156+
if (files.length === 1) return files[0]
157+
// Multiple binaries — resolve from package.json bin field like npx does
158+
const pkgJson = await Filesystem.readJson<{ bin?: string | Record<string, string> }>(
159+
path.join(dir, "node_modules", pkg, "package.json"),
160+
).catch(() => undefined)
161+
if (pkgJson?.bin) {
162+
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
163+
const bin = pkgJson.bin
164+
if (typeof bin === "string") return unscoped
165+
const keys = Object.keys(bin)
166+
if (keys.length === 1) return keys[0]
167+
return bin[unscoped] ? unscoped : keys[0]
168+
}
169+
return files[0]
170+
}
171+
172+
const bin = await pick()
173+
if (bin) return path.join(binDir, bin)
174+
175+
await rm(path.join(dir, "package-lock.json"), { force: true })
176+
await add(pkg)
177+
const resolved = await pick()
178+
if (!resolved) return
179+
return path.join(binDir, resolved)
180+
}
181+
}

packages/opencode/src/provider/provider.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
66
import { NoSuchModelError, type Provider as SDK } from "ai"
77
import { Log } from "../util/log"
88
import { BunProc } from "../bun"
9+
import { Npm } from "../npm"
910
import { Hash } from "../util/hash"
1011
import { Plugin } from "../plugin"
1112
import { NamedError } from "@opencode-ai/util/error"
@@ -1365,7 +1366,9 @@ export namespace Provider {
13651366

13661367
let installedPath: string
13671368
if (!model.api.npm.startsWith("file://")) {
1368-
installedPath = await BunProc.install(model.api.npm, "latest")
1369+
const item = await Npm.add(model.api.npm)
1370+
if (!item.entrypoint) throw new Error(`Package ${model.api.npm} has no import entrypoint`)
1371+
installedPath = item.entrypoint
13691372
} else {
13701373
log.info("loading local provider", { pkg: model.api.npm })
13711374
installedPath = model.api.npm

0 commit comments

Comments
 (0)