refactor: extract shared util package (#37828)
This commit is contained in:
parent
7243bd9e12
commit
e0810753f2
272 changed files with 590 additions and 565 deletions
276
packages/util/src/npm.ts
Normal file
276
packages/util/src/npm.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
export * as Npm from "./npm"
|
||||
|
||||
import path from "path"
|
||||
import npa from "npm-package-arg"
|
||||
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
import { EffectFlock } from "./effect-flock"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { filesystem } from "./effect/app-node-platform"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeRuntime } from "./effect/runtime"
|
||||
import { NpmConfig } from "./npm-config"
|
||||
import { resolveModule } from "#runtime-import"
|
||||
|
||||
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
|
||||
add: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||
dir: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export interface EntryPoint {
|
||||
readonly directory: string
|
||||
readonly entrypoint?: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly add: (
|
||||
pkg: string,
|
||||
options?: { readonly subpaths?: readonly string[] },
|
||||
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
||||
readonly install: (
|
||||
dir: string,
|
||||
input?: {
|
||||
add: {
|
||||
name: string
|
||||
version?: string
|
||||
}[]
|
||||
},
|
||||
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
|
||||
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
|
||||
|
||||
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
|
||||
|
||||
export function sanitize(pkg: string) {
|
||||
if (!illegal) return pkg
|
||||
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
|
||||
}
|
||||
|
||||
const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[] = [""]): EntryPoint => {
|
||||
const entrypoint = subpaths
|
||||
.map((subpath) => {
|
||||
try {
|
||||
return resolveModule([name, subpath].filter(Boolean).join("/"), dir)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
.find((entrypoint) => entrypoint !== undefined)
|
||||
return {
|
||||
directory: dir,
|
||||
entrypoint,
|
||||
}
|
||||
}
|
||||
|
||||
interface ArboristNode {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
interface ArboristTree {
|
||||
edgesOut: Map<string, { to?: ArboristNode }>
|
||||
}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const afs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const flock = yield* EffectFlock.Service
|
||||
const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
|
||||
const reify = (input: { dir: string; add?: string[] }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* flock.acquire(`npm-install:${input.dir}`)
|
||||
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
|
||||
const add = input.add ?? []
|
||||
const npmOptions = yield* NpmConfig.load(input.dir)
|
||||
const arborist = new Arborist({
|
||||
...npmOptions,
|
||||
path: input.dir,
|
||||
binLinks: true,
|
||||
progress: false,
|
||||
savePrefix: "",
|
||||
ignoreScripts: true,
|
||||
})
|
||||
return yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
arborist.reify({
|
||||
...npmOptions,
|
||||
add,
|
||||
save: true,
|
||||
saveType: "prod",
|
||||
}),
|
||||
catch: (cause) =>
|
||||
new InstallFailedError({
|
||||
cause,
|
||||
add,
|
||||
dir: input.dir,
|
||||
}),
|
||||
}) as Effect.Effect<ArboristTree, InstallFailedError>
|
||||
}).pipe(
|
||||
Effect.withSpan("Npm.reify", {
|
||||
attributes: input,
|
||||
}),
|
||||
)
|
||||
|
||||
const add = Effect.fn("Npm.add")(function* (pkg: string, options?: { readonly subpaths?: readonly string[] }) {
|
||||
const dir = directory(pkg)
|
||||
const name = (() => {
|
||||
try {
|
||||
return npa(pkg).name ?? pkg
|
||||
} catch {
|
||||
return pkg
|
||||
}
|
||||
})()
|
||||
|
||||
if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) {
|
||||
return resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths)
|
||||
}
|
||||
|
||||
const tree = yield* reify({ dir, add: [pkg] })
|
||||
const first = tree.edgesOut.values().next().value?.to
|
||||
if (!first) {
|
||||
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths)
|
||||
if (result.entrypoint) return result
|
||||
return yield* new InstallFailedError({ add: [pkg], dir })
|
||||
}
|
||||
return resolveEntryPoint(first.name, first.path, options?.subpaths)
|
||||
}, Effect.scoped)
|
||||
|
||||
const install: Interface["install"] = Effect.fn("Npm.install")(function* (dir, input) {
|
||||
const canWrite = yield* afs.access(dir, { writable: true }).pipe(
|
||||
Effect.as(true),
|
||||
Effect.orElseSucceed(() => false),
|
||||
)
|
||||
if (!canWrite) return
|
||||
|
||||
const add = input?.add.map((pkg) => [pkg.name, pkg.version].filter(Boolean).join("@")) ?? []
|
||||
if (
|
||||
yield* Effect.gen(function* () {
|
||||
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
|
||||
if (!nodeModulesExists) {
|
||||
yield* reify({ add, dir })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
|
||||
)
|
||||
return
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
|
||||
const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))
|
||||
|
||||
const pkgAny = pkg as any
|
||||
const lockAny = lock as any
|
||||
const declared = new Set([
|
||||
...Object.keys(pkgAny?.dependencies || {}),
|
||||
...Object.keys(pkgAny?.devDependencies || {}),
|
||||
...Object.keys(pkgAny?.peerDependencies || {}),
|
||||
...Object.keys(pkgAny?.optionalDependencies || {}),
|
||||
...(input?.add || []).map((pkg) => pkg.name),
|
||||
])
|
||||
|
||||
const root = lockAny?.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)) {
|
||||
yield* reify({ dir, add })
|
||||
return
|
||||
}
|
||||
}
|
||||
}).pipe(Effect.withSpan("Npm.checkDirty"))
|
||||
|
||||
return
|
||||
}, Effect.scoped)
|
||||
|
||||
const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: 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 Option.none<string>()
|
||||
// Caller picked a specific bin (e.g. pyright exposes both `pyright` and
|
||||
// `pyright-langserver`); trust the hint if the package provides it.
|
||||
if (bin) return files.includes(bin) ? Option.some(bin) : Option.none<string>()
|
||||
if (files.length === 1) return Option.some(files[0])
|
||||
|
||||
const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
|
||||
|
||||
if (Option.isSome(pkgJson)) {
|
||||
const parsed = pkgJson.value as { bin?: string | Record<string, string> }
|
||||
if (parsed?.bin) {
|
||||
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
|
||||
const parsedBin = parsed.bin
|
||||
if (typeof parsedBin === "string") return Option.some(unscoped)
|
||||
const keys = Object.keys(parsedBin)
|
||||
if (keys.length === 1) return Option.some(keys[0])
|
||||
return parsedBin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
|
||||
}
|
||||
}
|
||||
|
||||
return Option.some(files[0])
|
||||
})
|
||||
|
||||
return Option.getOrUndefined(
|
||||
yield* Effect.gen(function* () {
|
||||
const bin = yield* pick()
|
||||
if (Option.isSome(bin)) {
|
||||
return Option.some(path.join(binDir, bin.value))
|
||||
}
|
||||
|
||||
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
|
||||
|
||||
yield* add(pkg)
|
||||
|
||||
const resolved = yield* pick()
|
||||
if (Option.isNone(resolved)) return Option.none<string>()
|
||||
return Option.some(path.join(binDir, resolved.value))
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.orElseSucceed(() => Option.none<string>()),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
add,
|
||||
install,
|
||||
which,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Global.node, filesystem, EffectFlock.node],
|
||||
})
|
||||
|
||||
const { runPromise } = makeRuntime(Service, LayerNode.compile(node))
|
||||
|
||||
export async function install(...args: Parameters<Interface["install"]>) {
|
||||
return runPromise((svc) => svc.install(...args))
|
||||
}
|
||||
|
||||
export async function add(...args: Parameters<Interface["add"]>) {
|
||||
return runPromise((svc) => svc.add(...args))
|
||||
}
|
||||
|
||||
export async function which(...args: Parameters<Interface["which"]>) {
|
||||
return runPromise((svc) => svc.which(...args))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue