mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e22664485 |
@@ -339,6 +339,7 @@
|
|||||||
"@lydell/node-pty": "1.2.0-beta.10",
|
"@lydell/node-pty": "1.2.0-beta.10",
|
||||||
"@modelcontextprotocol/sdk": "1.27.1",
|
"@modelcontextprotocol/sdk": "1.27.1",
|
||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
|
"@npmcli/config": "10.8.0",
|
||||||
"@octokit/graphql": "9.0.2",
|
"@octokit/graphql": "9.0.2",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
"@openauthjs/openauth": "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/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/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=="],
|
"@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",
|
"@lydell/node-pty": "1.2.0-beta.10",
|
||||||
"@modelcontextprotocol/sdk": "1.27.1",
|
"@modelcontextprotocol/sdk": "1.27.1",
|
||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
|
"@npmcli/config": "10.8.0",
|
||||||
"@octokit/graphql": "9.0.2",
|
"@octokit/graphql": "9.0.2",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
"@openauthjs/openauth": "catalog:",
|
"@openauthjs/openauth": "catalog:",
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+233
-136
@@ -1,13 +1,17 @@
|
|||||||
|
import path from "path"
|
||||||
import semver from "semver"
|
import semver from "semver"
|
||||||
import z from "zod"
|
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 { NamedError } from "@opencode-ai/util/error"
|
||||||
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Log } from "../util/log"
|
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 { Flock } from "@/util/flock"
|
||||||
import { Arborist } from "@npmcli/arborist"
|
import { Arborist } from "@npmcli/arborist"
|
||||||
|
import { NpmConfig } from "./config"
|
||||||
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
|
|
||||||
export namespace Npm {
|
export namespace Npm {
|
||||||
const log = Log.create({ service: "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) {
|
export function sanitize(pkg: string) {
|
||||||
if (!illegal) return pkg
|
if (!illegal) return pkg
|
||||||
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
|
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
|
||||||
@@ -34,155 +69,217 @@ export namespace Npm {
|
|||||||
try {
|
try {
|
||||||
entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
|
entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
|
||||||
} catch {}
|
} catch {}
|
||||||
const result = {
|
return {
|
||||||
directory: dir,
|
directory: dir,
|
||||||
entrypoint,
|
entrypoint,
|
||||||
}
|
}
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
|
export const layer = Layer.effect(
|
||||||
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
|
Service,
|
||||||
if (!response.ok) {
|
Effect.gen(function* () {
|
||||||
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
|
const fs = yield* AppFileSystem.Service
|
||||||
return false
|
const cfg = yield* NpmConfig.Service
|
||||||
}
|
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||||
|
|
||||||
const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
|
const create = Effect.fnUntraced(function* (dir: string) {
|
||||||
const latestVersion = data?.["dist-tags"]?.latest
|
return new Arborist({
|
||||||
if (!latestVersion) {
|
path: dir,
|
||||||
log.warn("No latest version found, using cached", { pkg, cachedVersion })
|
binLinks: true,
|
||||||
return false
|
progress: false,
|
||||||
}
|
savePrefix: "",
|
||||||
|
ignoreScripts: true,
|
||||||
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
|
...(yield* cfg.config(dir)),
|
||||||
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",
|
|
||||||
})
|
})
|
||||||
.catch((cause) => {
|
|
||||||
throw new InstallFailedError(
|
const lock = <A, E>(key: string, body: Effect.Effect<A, E>) =>
|
||||||
{ pkg },
|
Effect.scoped(
|
||||||
{
|
Effect.gen(function* () {
|
||||||
cause,
|
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
|
const install = Effect.fn("Npm.install")(function* (dir: string) {
|
||||||
if (!first) throw new InstallFailedError({ pkg })
|
const key = `npm-install:${dir}`
|
||||||
return resolveEntryPoint(first.name, first.path)
|
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) {
|
export async function install(dir: string) {
|
||||||
await using _ = await Flock.acquire(`npm-install:${dir}`)
|
return runPromise((svc) => svc.install(dir))
|
||||||
log.info("checking dependencies", { dir })
|
}
|
||||||
|
|
||||||
const reify = async () => {
|
export async function outdated(pkg: string, cachedVersion: string) {
|
||||||
const arb = new Arborist({
|
return runPromise((svc) => svc.outdated(pkg, cachedVersion))
|
||||||
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 which(pkg: string) {
|
export async function which(pkg: string) {
|
||||||
const dir = directory(pkg)
|
return runPromise((svc) => svc.which(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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
@@ -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>
|
||||||
|
}
|
||||||
@@ -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