mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-09 23:44:52 +00:00
Compare commits
1 Commits
opencode-r
...
npmcli-con
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e22664485 |
3
bun.lock
3
bun.lock
@@ -339,6 +339,7 @@
|
||||
"@lydell/node-pty": "1.2.0-beta.10",
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@npmcli/config": "10.8.0",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
@@ -1423,6 +1424,8 @@
|
||||
|
||||
"@npmcli/arborist": ["@npmcli/arborist@9.4.0", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^5.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/map-workspaces": "^5.0.0", "@npmcli/metavuln-calculator": "^9.0.2", "@npmcli/name-from-folder": "^4.0.0", "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/query": "^5.0.0", "@npmcli/redact": "^4.0.0", "@npmcli/run-script": "^10.0.0", "bin-links": "^6.0.0", "cacache": "^20.0.1", "common-ancestor-path": "^2.0.0", "hosted-git-info": "^9.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^11.2.1", "minimatch": "^10.0.3", "nopt": "^9.0.0", "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.0", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "pacote": "^21.0.2", "parse-conflict-json": "^5.0.1", "proc-log": "^6.0.0", "proggy": "^4.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", "semver": "^7.3.7", "ssri": "^13.0.0", "treeverse": "^3.0.0", "walk-up-path": "^4.0.0" }, "bin": { "arborist": "bin/index.js" } }, "sha512-4Bm8hNixJG/sii1PMnag0V9i/sGOX9VRzFrUiZMSBJpGlLR38f+Btl85d07G9GL56xO0l0OZjvrGNYsDYp0xKA=="],
|
||||
|
||||
"@npmcli/config": ["@npmcli/config@10.8.0", "", { "dependencies": { "@npmcli/map-workspaces": "^5.0.0", "@npmcli/package-json": "^7.0.0", "ci-info": "^4.0.0", "ini": "^6.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "walk-up-path": "^4.0.0" } }, "sha512-YkhoXZQU7zxyGi3V7J0zdK2pghzF9YXHiRdpRX8QNhsefk/zAJZJjRsbbw1hD67hlMp2gSygUGgW4y7FlrUThw=="],
|
||||
|
||||
"@npmcli/fs": ["@npmcli/fs@5.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og=="],
|
||||
|
||||
"@npmcli/git": ["@npmcli/git@7.0.2", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/promise-spawn": "^9.0.0", "ini": "^6.0.0", "lru-cache": "^11.2.1", "npm-pick-manifest": "^11.0.1", "proc-log": "^6.0.0", "semver": "^7.3.5", "which": "^6.0.0" } }, "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg=="],
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
"@lydell/node-pty": "1.2.0-beta.10",
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@npmcli/config": "10.8.0",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
|
||||
91
packages/opencode/src/npm/config.ts
Normal file
91
packages/opencode/src/npm/config.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { createRequire } from "module"
|
||||
import path from "path"
|
||||
import Config from "@npmcli/config"
|
||||
import { definitions, flatten, shorthands } from "@npmcli/config/lib/definitions/index.js"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { Global } from "@/global"
|
||||
|
||||
export namespace NpmConfig {
|
||||
type Data = Record<string, unknown>
|
||||
type Where = "project" | "user" | "global"
|
||||
|
||||
export interface Interface {
|
||||
readonly config: (dir: string) => Effect.Effect<Data, Error>
|
||||
readonly paths: (dir: string) => Effect.Effect<string[], Error>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/NpmConfig") {}
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const npmPath = (() => {
|
||||
try {
|
||||
return path.dirname(require.resolve("npm/package.json"))
|
||||
} catch {
|
||||
return path.join(Global.Path.cache, "npm")
|
||||
}
|
||||
})()
|
||||
|
||||
function source(conf: Config, where: Where) {
|
||||
return conf.data.get(where)?.source
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
const load = Effect.fnUntraced(function* (dir: string) {
|
||||
const conf = new Config({
|
||||
argv: [],
|
||||
cwd: AppFileSystem.resolve(dir),
|
||||
definitions,
|
||||
env: { ...process.env },
|
||||
execPath: process.execPath,
|
||||
flatten,
|
||||
npmPath,
|
||||
platform: process.platform,
|
||||
shorthands,
|
||||
warn: false,
|
||||
})
|
||||
yield* Effect.tryPromise({
|
||||
try: () => conf.load(),
|
||||
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||
})
|
||||
return conf
|
||||
})
|
||||
|
||||
const config = Effect.fn("NpmConfig.config")(function* (dir: string) {
|
||||
return (yield* load(dir)).flat as Data
|
||||
})
|
||||
|
||||
const paths = Effect.fn("NpmConfig.paths")(function* (dir: string) {
|
||||
const conf = yield* load(dir)
|
||||
const list = yield* Effect.forEach(["project", "user", "global"] as const, (where) =>
|
||||
Effect.gen(function* () {
|
||||
const file = source(conf, where)
|
||||
if (!file || !path.isAbsolute(file)) return
|
||||
const resolved = AppFileSystem.resolve(file)
|
||||
if (!(yield* fs.existsSafe(resolved))) return
|
||||
return resolved
|
||||
}),
|
||||
)
|
||||
return list.filter((item): item is string => item !== undefined)
|
||||
})
|
||||
|
||||
return Service.of({ config, paths })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function config(dir: string) {
|
||||
return runPromise((svc) => svc.config(dir))
|
||||
}
|
||||
|
||||
export async function paths(dir: string) {
|
||||
return runPromise((svc) => svc.paths(dir))
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import path from "path"
|
||||
import semver from "semver"
|
||||
import z from "zod"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { readdir, rm } from "fs/promises"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Flock } from "@/util/flock"
|
||||
import { Arborist } from "@npmcli/arborist"
|
||||
import { NpmConfig } from "./config"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
|
||||
export namespace Npm {
|
||||
const log = Log.create({ service: "npm" })
|
||||
@@ -20,6 +24,37 @@ export namespace Npm {
|
||||
}),
|
||||
)
|
||||
|
||||
export interface Interface {
|
||||
readonly add: (
|
||||
pkg: string,
|
||||
) => Effect.Effect<
|
||||
{ directory: string; entrypoint: string | undefined },
|
||||
Error | AppFileSystem.Error | InstanceType<typeof InstallFailedError>
|
||||
>
|
||||
readonly install: (dir: string) => Effect.Effect<void, Error | AppFileSystem.Error>
|
||||
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
|
||||
readonly which: (
|
||||
pkg: string,
|
||||
) => Effect.Effect<string | undefined, Error | AppFileSystem.Error | InstanceType<typeof InstallFailedError>>
|
||||
}
|
||||
|
||||
type Pkg = {
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
optionalDependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
type Lock = {
|
||||
packages?: Record<string, Pkg>
|
||||
}
|
||||
|
||||
type Bin = {
|
||||
bin?: string | Record<string, string>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Npm") {}
|
||||
|
||||
export function sanitize(pkg: string) {
|
||||
if (!illegal) return pkg
|
||||
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
|
||||
@@ -34,155 +69,217 @@ export namespace Npm {
|
||||
try {
|
||||
entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
|
||||
} catch {}
|
||||
const result = {
|
||||
return {
|
||||
directory: dir,
|
||||
entrypoint,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
|
||||
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
|
||||
if (!response.ok) {
|
||||
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
|
||||
return false
|
||||
}
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const cfg = yield* NpmConfig.Service
|
||||
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||
|
||||
const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
|
||||
const latestVersion = data?.["dist-tags"]?.latest
|
||||
if (!latestVersion) {
|
||||
log.warn("No latest version found, using cached", { pkg, cachedVersion })
|
||||
return false
|
||||
}
|
||||
|
||||
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
|
||||
if (range) return !semver.satisfies(latestVersion, cachedVersion)
|
||||
|
||||
return semver.lt(cachedVersion, latestVersion)
|
||||
}
|
||||
|
||||
export async function add(pkg: string) {
|
||||
const dir = directory(pkg)
|
||||
await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
|
||||
log.info("installing package", {
|
||||
pkg,
|
||||
})
|
||||
|
||||
const arborist = new Arborist({
|
||||
path: dir,
|
||||
binLinks: true,
|
||||
progress: false,
|
||||
savePrefix: "",
|
||||
ignoreScripts: true,
|
||||
})
|
||||
const tree = await arborist.loadVirtual().catch(() => {})
|
||||
if (tree) {
|
||||
const first = tree.edgesOut.values().next().value?.to
|
||||
if (first) {
|
||||
return resolveEntryPoint(first.name, first.path)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await arborist
|
||||
.reify({
|
||||
add: [pkg],
|
||||
save: true,
|
||||
saveType: "prod",
|
||||
const create = Effect.fnUntraced(function* (dir: string) {
|
||||
return new Arborist({
|
||||
path: dir,
|
||||
binLinks: true,
|
||||
progress: false,
|
||||
savePrefix: "",
|
||||
ignoreScripts: true,
|
||||
...(yield* cfg.config(dir)),
|
||||
})
|
||||
})
|
||||
.catch((cause) => {
|
||||
throw new InstallFailedError(
|
||||
{ pkg },
|
||||
{
|
||||
cause,
|
||||
},
|
||||
|
||||
const lock = <A, E>(key: string, body: Effect.Effect<A, E>) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.acquireRelease(Effect.promise(() => Flock.acquire(key)).pipe(Effect.orDie), (lease) =>
|
||||
Effect.promise(() => lease.release()).pipe(Effect.orDie),
|
||||
)
|
||||
return yield* body
|
||||
}),
|
||||
)
|
||||
|
||||
const readPkg = <A>(file: string, fallback: A) =>
|
||||
fs.readJson(file).pipe(
|
||||
Effect.catch(() => Effect.succeed(fallback)),
|
||||
Effect.map((value) => value as A),
|
||||
)
|
||||
|
||||
const reify = Effect.fnUntraced(function* (dir: string) {
|
||||
const arb = yield* create(dir)
|
||||
yield* Effect.promise(() => arb.reify()).pipe(Effect.catch(() => Effect.void))
|
||||
})
|
||||
|
||||
const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
|
||||
const url = `https://registry.npmjs.org/${pkg}`
|
||||
const data = yield* HttpClientRequest.get(url).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
http.execute,
|
||||
Effect.flatMap((res) => res.json),
|
||||
Effect.catch(() =>
|
||||
Effect.sync(() => {
|
||||
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
|
||||
return undefined
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const latestVersion =
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
"dist-tags" in data &&
|
||||
data["dist-tags"] &&
|
||||
typeof data["dist-tags"] === "object"
|
||||
? (data["dist-tags"] as { latest?: string }).latest
|
||||
: undefined
|
||||
|
||||
if (!latestVersion) {
|
||||
log.warn("No latest version found, using cached", { pkg, cachedVersion })
|
||||
return false
|
||||
}
|
||||
|
||||
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
|
||||
if (range) return !semver.satisfies(latestVersion, cachedVersion)
|
||||
return semver.lt(cachedVersion, latestVersion)
|
||||
})
|
||||
|
||||
const add = Effect.fn("Npm.add")(function* (pkg: string) {
|
||||
const dir = directory(pkg)
|
||||
const key = `npm-install:${AppFileSystem.resolve(dir)}`
|
||||
|
||||
return yield* lock(
|
||||
key,
|
||||
Effect.gen(function* () {
|
||||
log.info("installing package", { pkg })
|
||||
const arb = yield* create(dir)
|
||||
const tree = yield* Effect.promise(() => arb.loadVirtual()).pipe(
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
const cached = tree?.edgesOut.values().next().value?.to
|
||||
if (cached) return resolveEntryPoint(cached.name, cached.path)
|
||||
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: () => arb.reify({ add: [pkg], save: true, saveType: "prod" }),
|
||||
catch: (cause) =>
|
||||
new InstallFailedError(
|
||||
{ pkg },
|
||||
{
|
||||
cause,
|
||||
},
|
||||
),
|
||||
})
|
||||
|
||||
const first = result.edgesOut.values().next().value?.to
|
||||
if (!first) return yield* Effect.fail(new InstallFailedError({ pkg }))
|
||||
return resolveEntryPoint(first.name, first.path)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const first = result.edgesOut.values().next().value?.to
|
||||
if (!first) throw new InstallFailedError({ pkg })
|
||||
return resolveEntryPoint(first.name, first.path)
|
||||
const install = Effect.fn("Npm.install")(function* (dir: string) {
|
||||
const key = `npm-install:${dir}`
|
||||
yield* lock(
|
||||
key,
|
||||
Effect.gen(function* () {
|
||||
log.info("checking dependencies", { dir })
|
||||
|
||||
if (!(yield* fs.existsSafe(path.join(dir, "node_modules")))) {
|
||||
log.info("node_modules missing, reifying")
|
||||
yield* reify(dir)
|
||||
return
|
||||
}
|
||||
|
||||
const pkg = yield* readPkg<Pkg>(path.join(dir, "package.json"), {})
|
||||
const lock = yield* readPkg<Lock>(path.join(dir, "package-lock.json"), {})
|
||||
const declared = new Set([
|
||||
...Object.keys(pkg.dependencies || {}),
|
||||
...Object.keys(pkg.devDependencies || {}),
|
||||
...Object.keys(pkg.peerDependencies || {}),
|
||||
...Object.keys(pkg.optionalDependencies || {}),
|
||||
])
|
||||
|
||||
const root = lock.packages?.[""] || {}
|
||||
const locked = new Set([
|
||||
...Object.keys(root.dependencies || {}),
|
||||
...Object.keys(root.devDependencies || {}),
|
||||
...Object.keys(root.peerDependencies || {}),
|
||||
...Object.keys(root.optionalDependencies || {}),
|
||||
])
|
||||
|
||||
for (const name of declared) {
|
||||
if (locked.has(name)) continue
|
||||
log.info("dependency not in lock file, reifying", { name })
|
||||
yield* reify(dir)
|
||||
return
|
||||
}
|
||||
|
||||
log.info("dependencies in sync")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const which = Effect.fn("Npm.which")(function* (pkg: string) {
|
||||
const dir = directory(pkg)
|
||||
const binDir = path.join(dir, "node_modules", ".bin")
|
||||
|
||||
const pick = Effect.fnUntraced(function* () {
|
||||
const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
if (files.length === 0) return undefined
|
||||
if (files.length === 1) return files[0]
|
||||
|
||||
const pkgJson = yield* readPkg<Bin | undefined>(
|
||||
path.join(dir, "node_modules", pkg, "package.json"),
|
||||
undefined,
|
||||
)
|
||||
if (!pkgJson?.bin) return files[0]
|
||||
|
||||
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
|
||||
if (typeof pkgJson.bin === "string") return unscoped
|
||||
|
||||
const keys = Object.keys(pkgJson.bin)
|
||||
if (keys.length === 1) return keys[0]
|
||||
return pkgJson.bin[unscoped] ? unscoped : keys[0]
|
||||
})
|
||||
|
||||
const bin = yield* pick()
|
||||
if (bin) return path.join(binDir, bin)
|
||||
|
||||
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.catch(() => Effect.void))
|
||||
yield* add(pkg)
|
||||
const resolved = yield* pick()
|
||||
if (!resolved) return
|
||||
return path.join(binDir, resolved)
|
||||
})
|
||||
|
||||
return Service.of({ add, install, outdated, which })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NpmConfig.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function add(pkg: string) {
|
||||
return runPromise((svc) => svc.add(pkg))
|
||||
}
|
||||
|
||||
export async function install(dir: string) {
|
||||
await using _ = await Flock.acquire(`npm-install:${dir}`)
|
||||
log.info("checking dependencies", { dir })
|
||||
return runPromise((svc) => svc.install(dir))
|
||||
}
|
||||
|
||||
const reify = async () => {
|
||||
const arb = new Arborist({
|
||||
path: dir,
|
||||
binLinks: true,
|
||||
progress: false,
|
||||
savePrefix: "",
|
||||
ignoreScripts: true,
|
||||
})
|
||||
await arb.reify().catch(() => {})
|
||||
}
|
||||
|
||||
if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
|
||||
log.info("node_modules missing, reifying")
|
||||
await reify()
|
||||
return
|
||||
}
|
||||
|
||||
const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
|
||||
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
|
||||
|
||||
const declared = new Set([
|
||||
...Object.keys(pkg.dependencies || {}),
|
||||
...Object.keys(pkg.devDependencies || {}),
|
||||
...Object.keys(pkg.peerDependencies || {}),
|
||||
...Object.keys(pkg.optionalDependencies || {}),
|
||||
])
|
||||
|
||||
const root = lock.packages?.[""] || {}
|
||||
const locked = new Set([
|
||||
...Object.keys(root.dependencies || {}),
|
||||
...Object.keys(root.devDependencies || {}),
|
||||
...Object.keys(root.peerDependencies || {}),
|
||||
...Object.keys(root.optionalDependencies || {}),
|
||||
])
|
||||
|
||||
for (const name of declared) {
|
||||
if (!locked.has(name)) {
|
||||
log.info("dependency not in lock file, reifying", { name })
|
||||
await reify()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.info("dependencies in sync")
|
||||
export async function outdated(pkg: string, cachedVersion: string) {
|
||||
return runPromise((svc) => svc.outdated(pkg, cachedVersion))
|
||||
}
|
||||
|
||||
export async function which(pkg: string) {
|
||||
const dir = directory(pkg)
|
||||
const binDir = path.join(dir, "node_modules", ".bin")
|
||||
|
||||
const pick = async () => {
|
||||
const files = await readdir(binDir).catch(() => [])
|
||||
if (files.length === 0) return undefined
|
||||
if (files.length === 1) return files[0]
|
||||
// Multiple binaries — resolve from package.json bin field like npx does
|
||||
const pkgJson = await Filesystem.readJson<{ bin?: string | Record<string, string> }>(
|
||||
path.join(dir, "node_modules", pkg, "package.json"),
|
||||
).catch(() => undefined)
|
||||
if (pkgJson?.bin) {
|
||||
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
|
||||
const bin = pkgJson.bin
|
||||
if (typeof bin === "string") return unscoped
|
||||
const keys = Object.keys(bin)
|
||||
if (keys.length === 1) return keys[0]
|
||||
return bin[unscoped] ? unscoped : keys[0]
|
||||
}
|
||||
return files[0]
|
||||
}
|
||||
|
||||
const bin = await pick()
|
||||
if (bin) return path.join(binDir, bin)
|
||||
|
||||
await rm(path.join(dir, "package-lock.json"), { force: true })
|
||||
await add(pkg)
|
||||
const resolved = await pick()
|
||||
if (!resolved) return
|
||||
return path.join(binDir, resolved)
|
||||
return runPromise((svc) => svc.which(pkg))
|
||||
}
|
||||
}
|
||||
|
||||
29
packages/opencode/src/npm/npmcli-config.d.ts
vendored
Normal file
29
packages/opencode/src/npm/npmcli-config.d.ts
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
declare module "@npmcli/config" {
|
||||
type Data = Record<string, unknown>
|
||||
type Where = "default" | "builtin" | "global" | "user" | "project" | "env" | "cli"
|
||||
|
||||
export default class Config {
|
||||
constructor(input: {
|
||||
argv: string[]
|
||||
cwd: string
|
||||
definitions: Data
|
||||
env: NodeJS.ProcessEnv
|
||||
execPath: string
|
||||
flatten: (input: Data, flat?: Data) => Data
|
||||
npmPath: string
|
||||
platform: NodeJS.Platform
|
||||
shorthands: Record<string, string[]>
|
||||
warn?: boolean
|
||||
})
|
||||
|
||||
readonly data: Map<Where, { source: string | null }>
|
||||
readonly flat: Data
|
||||
load(): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@npmcli/config/lib/definitions/index.js" {
|
||||
export const definitions: Record<string, unknown>
|
||||
export const shorthands: Record<string, string[]>
|
||||
export const flatten: (input: Record<string, unknown>, flat?: Record<string, unknown>) => Record<string, unknown>
|
||||
}
|
||||
115
packages/opencode/test/npm/config.test.ts
Normal file
115
packages/opencode/test/npm/config.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { NpmConfig } from "../../src/npm/config"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
function env(next: Record<string, string | undefined>) {
|
||||
const prev = Object.fromEntries(Object.keys(next).map((key) => [key, process.env[key]]))
|
||||
|
||||
for (const [key, value] of Object.entries(next)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const [key, value] of Object.entries(prev)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("NpmConfig", () => {
|
||||
test("returns selected config file paths in precedence order", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const global = path.join(tmp.path, "global.npmrc")
|
||||
const user = path.join(tmp.path, "user.npmrc")
|
||||
const root = path.join(tmp.path, ".npmrc")
|
||||
const child = path.join(tmp.path, "repo", ".npmrc")
|
||||
const pkg = path.join(tmp.path, "repo", "package.json")
|
||||
const dir = path.join(tmp.path, "repo", ".opencode")
|
||||
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await Bun.write(global, "registry=https://global.example/\n")
|
||||
await Bun.write(user, "registry=https://user.example/\n")
|
||||
await Bun.write(root, "registry=https://root.example/\n")
|
||||
await Bun.write(child, "registry=https://child.example/\n")
|
||||
await Bun.write(pkg, '{"name":"repo","version":"1.0.0"}\n')
|
||||
|
||||
const restore = env({
|
||||
npm_config_globalconfig: global,
|
||||
npm_config_userconfig: user,
|
||||
})
|
||||
|
||||
try {
|
||||
expect(await NpmConfig.paths(dir)).toEqual([child, user, global])
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
test("merges config relative to a directory with env last", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const global = path.join(tmp.path, "global.npmrc")
|
||||
const user = path.join(tmp.path, "user.npmrc")
|
||||
const root = path.join(tmp.path, ".npmrc")
|
||||
const child = path.join(tmp.path, "repo", ".npmrc")
|
||||
const pkg = path.join(tmp.path, "repo", "package.json")
|
||||
const dir = path.join(tmp.path, "repo", ".opencode")
|
||||
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await Bun.write(global, "registry=https://global.example/\nignore-scripts=false\n")
|
||||
await Bun.write(user, "registry=https://user.example/\nignore-scripts=false\n")
|
||||
await Bun.write(root, "registry=https://root.example/\nignore-scripts=false\n")
|
||||
await Bun.write(child, "ignore-scripts=true\nbin-links=false\n@scope:registry=https://scope.example/\n")
|
||||
await Bun.write(pkg, '{"name":"repo","version":"1.0.0"}\n')
|
||||
|
||||
const restore = env({
|
||||
npm_config_globalconfig: global,
|
||||
npm_config_userconfig: user,
|
||||
npm_config_ignore_scripts: "false",
|
||||
npm_config_registry: "https://env.example/",
|
||||
})
|
||||
|
||||
try {
|
||||
const cfg = await NpmConfig.config(dir)
|
||||
expect(cfg.registry).toBe("https://env.example/")
|
||||
expect(cfg.ignoreScripts).toBe(false)
|
||||
expect(cfg.binLinks).toBe(false)
|
||||
expect(cfg["@scope:registry"]).toBe("https://scope.example/")
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
test("reloads config on each call", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const global = path.join(tmp.path, "global.npmrc")
|
||||
const user = path.join(tmp.path, "user.npmrc")
|
||||
const local = path.join(tmp.path, "repo", ".npmrc")
|
||||
const pkg = path.join(tmp.path, "repo", "package.json")
|
||||
const dir = path.join(tmp.path, "repo", ".opencode")
|
||||
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await Bun.write(global, "registry=https://global.example/\n")
|
||||
await Bun.write(user, "registry=https://user.example/\n")
|
||||
await Bun.write(local, "ignore-scripts=true\n")
|
||||
await Bun.write(pkg, '{"name":"repo","version":"1.0.0"}\n')
|
||||
|
||||
const restore = env({
|
||||
npm_config_globalconfig: global,
|
||||
npm_config_userconfig: user,
|
||||
})
|
||||
|
||||
try {
|
||||
const first = await NpmConfig.config(dir)
|
||||
await Bun.write(local, "ignore-scripts=false\n")
|
||||
const second = await NpmConfig.config(dir)
|
||||
expect(first.ignoreScripts).toBe(true)
|
||||
expect(second.ignoreScripts).toBe(false)
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user