refactor: consolidate npm exports and trace flock acquisition (#23151)
This commit is contained in:
@@ -18,7 +18,7 @@ import { InstallationLocal, InstallationVersion } from "@/installation/version"
|
|||||||
import { makeRuntime } from "@/effect/runtime"
|
import { makeRuntime } from "@/effect/runtime"
|
||||||
import { Filesystem, Log } from "@/util"
|
import { Filesystem, Log } from "@/util"
|
||||||
import { ConfigVariable } from "@/config/variable"
|
import { ConfigVariable } from "@/config/variable"
|
||||||
import { Npm } from "@/npm/effect"
|
import { Npm } from "@/npm"
|
||||||
|
|
||||||
const log = Log.create({ service: "tui.config" })
|
const log = Log.create({ service: "tui.config" })
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Layer } from "effect"
|
import { Layer } from "effect"
|
||||||
import { TuiConfig } from "./config/tui"
|
import { TuiConfig } from "./config/tui"
|
||||||
import { Npm } from "@/npm/effect"
|
import { Npm } from "@/npm"
|
||||||
import { Observability } from "@/effect/observability"
|
import { Observability } from "@/effect/observability"
|
||||||
|
|
||||||
export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))
|
export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import { ConfigPaths } from "./paths"
|
|||||||
import { ConfigFormatter } from "./formatter"
|
import { ConfigFormatter } from "./formatter"
|
||||||
import { ConfigLSP } from "./lsp"
|
import { ConfigLSP } from "./lsp"
|
||||||
import { ConfigVariable } from "./variable"
|
import { ConfigVariable } from "./variable"
|
||||||
import { Npm } from "@/npm/effect"
|
import { Npm } from "@/npm"
|
||||||
|
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import { Pty } from "@/pty"
|
|||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { ShareNext } from "@/share"
|
import { ShareNext } from "@/share"
|
||||||
import { SessionShare } from "@/share"
|
import { SessionShare } from "@/share"
|
||||||
import { Npm } from "@/npm/effect"
|
import { Npm } from "@/npm"
|
||||||
import { memoMap } from "./memo-map"
|
import { memoMap } from "./memo-map"
|
||||||
|
|
||||||
export const AppLayer = Layer.mergeAll(
|
export const AppLayer = Layer.mergeAll(
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
export * as Npm from "./effect"
|
|
||||||
|
|
||||||
import path from "path"
|
|
||||||
import semver from "semver"
|
|
||||||
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
|
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
|
||||||
import { Global } from "@opencode-ai/shared/global"
|
|
||||||
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
|
|
||||||
|
|
||||||
import { makeRuntime } from "../effect/runtime"
|
|
||||||
|
|
||||||
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: Option.Option<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
|
||||||
readonly install: (
|
|
||||||
dir: string,
|
|
||||||
input?: { add: string[] },
|
|
||||||
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
|
|
||||||
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
|
|
||||||
readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
|
|
||||||
}
|
|
||||||
|
|
||||||
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): EntryPoint => {
|
|
||||||
let entrypoint: Option.Option<string>
|
|
||||||
try {
|
|
||||||
const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
|
|
||||||
entrypoint = Option.some(resolved)
|
|
||||||
} catch {
|
|
||||||
entrypoint = Option.none()
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
directory: dir,
|
|
||||||
entrypoint,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ArboristNode {
|
|
||||||
name: string
|
|
||||||
path: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ArboristTree {
|
|
||||||
edgesOut: Map<string, { to?: ArboristNode }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const afs = yield* AppFileSystem.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 arborist = new Arborist({
|
|
||||||
path: input.dir,
|
|
||||||
binLinks: true,
|
|
||||||
progress: false,
|
|
||||||
savePrefix: "",
|
|
||||||
ignoreScripts: true,
|
|
||||||
})
|
|
||||||
return yield* Effect.tryPromise({
|
|
||||||
try: () =>
|
|
||||||
arborist.reify({
|
|
||||||
add: input?.add || [],
|
|
||||||
save: true,
|
|
||||||
saveType: "prod",
|
|
||||||
}),
|
|
||||||
catch: (cause) =>
|
|
||||||
new InstallFailedError({
|
|
||||||
cause,
|
|
||||||
add: input?.add,
|
|
||||||
dir: input.dir,
|
|
||||||
}),
|
|
||||||
}) as Effect.Effect<ArboristTree, InstallFailedError>
|
|
||||||
}).pipe(
|
|
||||||
Effect.withSpan("Npm.reify", {
|
|
||||||
attributes: input,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
|
|
||||||
const response = yield* Effect.tryPromise({
|
|
||||||
try: () => fetch(`https://registry.npmjs.org/${pkg}`),
|
|
||||||
catch: () => undefined,
|
|
||||||
}).pipe(Effect.orElseSucceed(() => undefined))
|
|
||||||
|
|
||||||
if (!response || !response.ok) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = yield* Effect.tryPromise({
|
|
||||||
try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>,
|
|
||||||
catch: () => undefined,
|
|
||||||
}).pipe(Effect.orElseSucceed(() => undefined))
|
|
||||||
|
|
||||||
const latestVersion = data?.["dist-tags"]?.latest
|
|
||||||
if (!latestVersion) {
|
|
||||||
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 tree = yield* reify({ dir, add: [pkg] })
|
|
||||||
const first = tree.edgesOut.values().next().value?.to
|
|
||||||
if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
|
|
||||||
return resolveEntryPoint(first.name, first.path)
|
|
||||||
}, Effect.scoped)
|
|
||||||
|
|
||||||
const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) {
|
|
||||||
const canWrite = yield* afs.access(dir, { writable: true }).pipe(
|
|
||||||
Effect.as(true),
|
|
||||||
Effect.orElseSucceed(() => false),
|
|
||||||
)
|
|
||||||
if (!canWrite) return
|
|
||||||
|
|
||||||
yield* Effect.gen(function* () {
|
|
||||||
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
|
|
||||||
if (!nodeModulesExists) {
|
|
||||||
yield* reify({ add: input?.add, dir })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
|
|
||||||
|
|
||||||
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 || []),
|
|
||||||
])
|
|
||||||
|
|
||||||
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: input?.add })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).pipe(Effect.withSpan("Npm.checkDirty"))
|
|
||||||
|
|
||||||
return
|
|
||||||
}, Effect.scoped)
|
|
||||||
|
|
||||||
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 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 bin = parsed.bin
|
|
||||||
if (typeof bin === "string") return Option.some(unscoped)
|
|
||||||
const keys = Object.keys(bin)
|
|
||||||
if (keys.length === 1) return Option.some(keys[0])
|
|
||||||
return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Option.some(files[0])
|
|
||||||
})
|
|
||||||
|
|
||||||
return 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,
|
|
||||||
outdated,
|
|
||||||
which,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
|
||||||
Layer.provide(EffectFlock.layer),
|
|
||||||
Layer.provide(AppFileSystem.layer),
|
|
||||||
Layer.provide(Global.layer),
|
|
||||||
Layer.provide(NodeFileSystem.layer),
|
|
||||||
)
|
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
+239
-166
@@ -1,198 +1,271 @@
|
|||||||
import semver from "semver"
|
export * as Npm from "."
|
||||||
import z from "zod"
|
|
||||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
|
||||||
import { Global } from "../global"
|
|
||||||
import { Log } from "../util"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { readdir, rm } from "fs/promises"
|
import semver from "semver"
|
||||||
import { Filesystem } from "@/util"
|
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
|
||||||
import { Flock } from "@opencode-ai/shared/util/flock"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
|
import { Global } from "@opencode-ai/shared/global"
|
||||||
|
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
|
||||||
|
|
||||||
|
import { makeRuntime } from "../effect/runtime"
|
||||||
|
|
||||||
|
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: Option.Option<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
||||||
|
readonly install: (
|
||||||
|
dir: string,
|
||||||
|
input?: { add: string[] },
|
||||||
|
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
|
||||||
|
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
|
||||||
|
readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
|
||||||
|
|
||||||
const log = Log.create({ service: "npm" })
|
|
||||||
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
|
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
|
||||||
|
|
||||||
export const InstallFailedError = NamedError.create(
|
|
||||||
"NpmInstallFailedError",
|
|
||||||
z.object({
|
|
||||||
pkg: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
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("")
|
||||||
}
|
}
|
||||||
|
|
||||||
function directory(pkg: string) {
|
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
|
||||||
return path.join(Global.Path.cache, "packages", sanitize(pkg))
|
let entrypoint: Option.Option<string>
|
||||||
}
|
|
||||||
|
|
||||||
function resolveEntryPoint(name: string, dir: string) {
|
|
||||||
let entrypoint: string | undefined
|
|
||||||
try {
|
try {
|
||||||
entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
|
const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
|
||||||
} catch {}
|
entrypoint = Option.some(resolved)
|
||||||
const result = {
|
} catch {
|
||||||
|
entrypoint = Option.none()
|
||||||
|
}
|
||||||
|
return {
|
||||||
directory: dir,
|
directory: dir,
|
||||||
entrypoint,
|
entrypoint,
|
||||||
}
|
}
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
|
interface ArboristNode {
|
||||||
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
|
name: string
|
||||||
if (!response.ok) {
|
path: string
|
||||||
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
interface ArboristTree {
|
||||||
const { Arborist } = await import("@npmcli/arborist")
|
edgesOut: Map<string, { to?: ArboristNode }>
|
||||||
const dir = directory(pkg)
|
}
|
||||||
await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
|
|
||||||
log.info("installing package", {
|
|
||||||
pkg,
|
|
||||||
})
|
|
||||||
|
|
||||||
const arborist = new Arborist({
|
export const layer = Layer.effect(
|
||||||
path: dir,
|
Service,
|
||||||
binLinks: true,
|
Effect.gen(function* () {
|
||||||
progress: false,
|
const afs = yield* AppFileSystem.Service
|
||||||
savePrefix: "",
|
const global = yield* Global.Service
|
||||||
ignoreScripts: true,
|
const fs = yield* FileSystem.FileSystem
|
||||||
})
|
const flock = yield* EffectFlock.Service
|
||||||
const tree = await arborist.loadVirtual().catch(() => {})
|
const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
|
||||||
if (tree) {
|
const reify = (input: { dir: string; add?: string[] }) =>
|
||||||
const first = tree.edgesOut.values().next().value?.to
|
Effect.gen(function* () {
|
||||||
if (first) {
|
yield* flock.acquire(`npm-install:${input.dir}`)
|
||||||
return resolveEntryPoint(first.name, first.path)
|
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
|
||||||
}
|
const arborist = new Arborist({
|
||||||
}
|
path: input.dir,
|
||||||
|
binLinks: true,
|
||||||
|
progress: false,
|
||||||
|
savePrefix: "",
|
||||||
|
ignoreScripts: true,
|
||||||
|
})
|
||||||
|
return yield* Effect.tryPromise({
|
||||||
|
try: () =>
|
||||||
|
arborist.reify({
|
||||||
|
add: input?.add || [],
|
||||||
|
save: true,
|
||||||
|
saveType: "prod",
|
||||||
|
}),
|
||||||
|
catch: (cause) =>
|
||||||
|
new InstallFailedError({
|
||||||
|
cause,
|
||||||
|
add: input?.add,
|
||||||
|
dir: input.dir,
|
||||||
|
}),
|
||||||
|
}) as Effect.Effect<ArboristTree, InstallFailedError>
|
||||||
|
}).pipe(
|
||||||
|
Effect.withSpan("Npm.reify", {
|
||||||
|
attributes: input,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const result = await arborist
|
const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
|
||||||
.reify({
|
const response = yield* Effect.tryPromise({
|
||||||
add: [pkg],
|
try: () => fetch(`https://registry.npmjs.org/${pkg}`),
|
||||||
save: true,
|
catch: () => undefined,
|
||||||
saveType: "prod",
|
}).pipe(Effect.orElseSucceed(() => undefined))
|
||||||
|
|
||||||
|
if (!response || !response.ok) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = yield* Effect.tryPromise({
|
||||||
|
try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>,
|
||||||
|
catch: () => undefined,
|
||||||
|
}).pipe(Effect.orElseSucceed(() => undefined))
|
||||||
|
|
||||||
|
const latestVersion = data?.["dist-tags"]?.latest
|
||||||
|
if (!latestVersion) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
|
||||||
|
if (range) return !semver.satisfies(latestVersion, cachedVersion)
|
||||||
|
|
||||||
|
return semver.lt(cachedVersion, latestVersion)
|
||||||
})
|
})
|
||||||
.catch((cause) => {
|
|
||||||
throw new InstallFailedError(
|
const add = Effect.fn("Npm.add")(function* (pkg: string) {
|
||||||
{ pkg },
|
const dir = directory(pkg)
|
||||||
{
|
|
||||||
cause,
|
const tree = yield* reify({ dir, add: [pkg] })
|
||||||
},
|
const first = tree.edgesOut.values().next().value?.to
|
||||||
|
if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
|
||||||
|
return resolveEntryPoint(first.name, first.path)
|
||||||
|
}, Effect.scoped)
|
||||||
|
|
||||||
|
const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) {
|
||||||
|
const canWrite = yield* afs.access(dir, { writable: true }).pipe(
|
||||||
|
Effect.as(true),
|
||||||
|
Effect.orElseSucceed(() => false),
|
||||||
|
)
|
||||||
|
if (!canWrite) return
|
||||||
|
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
|
||||||
|
if (!nodeModulesExists) {
|
||||||
|
yield* reify({ add: input?.add, dir })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
|
||||||
|
|
||||||
|
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 || []),
|
||||||
|
])
|
||||||
|
|
||||||
|
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: input?.add })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).pipe(Effect.withSpan("Npm.checkDirty"))
|
||||||
|
|
||||||
|
return
|
||||||
|
}, Effect.scoped)
|
||||||
|
|
||||||
|
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 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 bin = parsed.bin
|
||||||
|
if (typeof bin === "string") return Option.some(unscoped)
|
||||||
|
const keys = Object.keys(bin)
|
||||||
|
if (keys.length === 1) return Option.some(keys[0])
|
||||||
|
return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Option.some(files[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
return 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>()),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const first = result.edgesOut.values().next().value?.to
|
return Service.of({
|
||||||
if (!first) throw new InstallFailedError({ pkg })
|
add,
|
||||||
return resolveEntryPoint(first.name, first.path)
|
install,
|
||||||
}
|
outdated,
|
||||||
|
which,
|
||||||
export async function install(dir: string) {
|
|
||||||
await using _ = await Flock.acquire(`npm-install:${dir}`)
|
|
||||||
log.info("checking dependencies", { dir })
|
|
||||||
|
|
||||||
const reify = async () => {
|
|
||||||
const { Arborist } = await import("@npmcli/arborist")
|
|
||||||
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")))) {
|
export const defaultLayer = layer.pipe(
|
||||||
log.info("node_modules missing, reifying")
|
Layer.provide(EffectFlock.layer),
|
||||||
await reify()
|
Layer.provide(AppFileSystem.layer),
|
||||||
return
|
Layer.provide(Global.layer),
|
||||||
}
|
Layer.provide(NodeFileSystem.layer),
|
||||||
|
)
|
||||||
|
|
||||||
type PackageDeps = Record<string, string>
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
type PackageJson = {
|
|
||||||
dependencies?: PackageDeps
|
|
||||||
devDependencies?: PackageDeps
|
|
||||||
peerDependencies?: PackageDeps
|
|
||||||
optionalDependencies?: PackageDeps
|
|
||||||
}
|
|
||||||
const pkg: PackageJson = await Filesystem.readJson<PackageJson>(path.join(dir, "package.json")).catch(() => ({}))
|
|
||||||
const lock: { packages?: Record<string, PackageJson> } = await Filesystem.readJson<{
|
|
||||||
packages?: Record<string, PackageJson>
|
|
||||||
}>(path.join(dir, "package-lock.json")).catch(() => ({}))
|
|
||||||
|
|
||||||
const declared = new Set([
|
export async function install(...args: Parameters<Interface["install"]>) {
|
||||||
...Object.keys(pkg.dependencies || {}),
|
return runPromise((svc) => svc.install(...args))
|
||||||
...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 add(...args: Parameters<Interface["add"]>) {
|
||||||
const dir = directory(pkg)
|
const entry = await runPromise((svc) => svc.add(...args))
|
||||||
const binDir = path.join(dir, "node_modules", ".bin")
|
return {
|
||||||
|
directory: entry.directory,
|
||||||
const pick = async () => {
|
entrypoint: Option.getOrUndefined(entry.entrypoint),
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export * as Npm from "."
|
export async function outdated(...args: Parameters<Interface["outdated"]>) {
|
||||||
|
return runPromise((svc) => svc.outdated(...args))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function which(...args: Parameters<Interface["which"]>) {
|
||||||
|
const resolved = await runPromise((svc) => svc.which(...args))
|
||||||
|
return Option.getOrUndefined(resolved)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import npa from "npm-package-arg"
|
|||||||
import semver from "semver"
|
import semver from "semver"
|
||||||
import { Filesystem } from "@/util"
|
import { Filesystem } from "@/util"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import { Npm } from "@/npm/effect"
|
import { Npm } from "@/npm"
|
||||||
|
|
||||||
// Old npm package names for plugins that are now built-in
|
// Old npm package names for plugins that are now built-in
|
||||||
export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
|
export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Option } from "effect"
|
|
||||||
import { expect, spyOn, test } from "bun:test"
|
import { expect, spyOn, test } from "bun:test"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
@@ -6,7 +5,7 @@ import { pathToFileURL } from "url"
|
|||||||
import { tmpdir } from "../../fixture/fixture"
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||||
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
||||||
import { Npm } from "../../../src/npm/effect"
|
import { Npm } from "../../../src/npm"
|
||||||
|
|
||||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||||
|
|
||||||
@@ -57,7 +56,7 @@ test("loads npm tui plugin from package ./tui export", async () => {
|
|||||||
}
|
}
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||||
@@ -118,7 +117,7 @@ test("does not use npm package exports dot for tui entry", async () => {
|
|||||||
}
|
}
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||||
@@ -180,7 +179,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
|
|||||||
}
|
}
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||||
@@ -242,7 +241,7 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
|
|||||||
}
|
}
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||||
@@ -300,7 +299,7 @@ test("does not use npm package main for tui entry", async () => {
|
|||||||
}
|
}
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||||
const warn = spyOn(console, "warn").mockImplementation(() => {})
|
const warn = spyOn(console, "warn").mockImplementation(() => {})
|
||||||
const error = spyOn(console, "error").mockImplementation(() => {})
|
const error = spyOn(console, "error").mockImplementation(() => {})
|
||||||
|
|
||||||
@@ -469,7 +468,7 @@ test("uses npm package name when tui plugin id is omitted", async () => {
|
|||||||
}
|
}
|
||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { Global } from "../../src/global"
|
|||||||
import { ProjectID } from "../../src/project/schema"
|
import { ProjectID } from "../../src/project/schema"
|
||||||
import { Filesystem } from "../../src/util"
|
import { Filesystem } from "../../src/util"
|
||||||
import { ConfigPlugin } from "@/config/plugin"
|
import { ConfigPlugin } from "@/config/plugin"
|
||||||
import { Npm } from "@/npm/effect"
|
import { Npm } from "@/npm"
|
||||||
|
|
||||||
const emptyAccount = Layer.mock(Account.Service)({
|
const emptyAccount = Layer.mock(Account.Service)({
|
||||||
active: () => Effect.succeed(Option.none()),
|
active: () => Effect.succeed(Option.none()),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test"
|
import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test"
|
||||||
import { Effect, Option } from "effect"
|
import { Effect } from "effect"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
@@ -13,7 +13,7 @@ const { Plugin } = await import("../../src/plugin/index")
|
|||||||
const { PluginLoader } = await import("../../src/plugin/loader")
|
const { PluginLoader } = await import("../../src/plugin/loader")
|
||||||
const { readPackageThemes } = await import("../../src/plugin/shared")
|
const { readPackageThemes } = await import("../../src/plugin/shared")
|
||||||
const { Instance } = await import("../../src/project/instance")
|
const { Instance } = await import("../../src/project/instance")
|
||||||
const { Npm } = await import("../../src/npm/effect")
|
const { Npm } = await import("../../src/npm")
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
if (disableDefault === undefined) {
|
if (disableDefault === undefined) {
|
||||||
@@ -239,8 +239,8 @@ describe("plugin.loader.shared", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const add = spyOn(Npm, "add").mockImplementation(async (pkg) => {
|
const add = spyOn(Npm, "add").mockImplementation(async (pkg) => {
|
||||||
if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: Option.none() }
|
if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: undefined }
|
||||||
return { directory: tmp.extra.scope, entrypoint: Option.none() }
|
return { directory: tmp.extra.scope, entrypoint: undefined }
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -301,7 +301,7 @@ describe("plugin.loader.shared", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await load(tmp.path)
|
await load(tmp.path)
|
||||||
@@ -358,7 +358,7 @@ describe("plugin.loader.shared", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await load(tmp.path)
|
await load(tmp.path)
|
||||||
@@ -410,7 +410,7 @@ describe("plugin.loader.shared", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await load(tmp.path)
|
await load(tmp.path)
|
||||||
@@ -455,7 +455,7 @@ describe("plugin.loader.shared", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await load(tmp.path)
|
await load(tmp.path)
|
||||||
@@ -518,7 +518,7 @@ describe("plugin.loader.shared", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await load(tmp.path)
|
await load(tmp.path)
|
||||||
@@ -548,7 +548,7 @@ describe("plugin.loader.shared", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: Option.none() })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: undefined })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await load(tmp.path)
|
await load(tmp.path)
|
||||||
@@ -927,7 +927,7 @@ export default {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||||
const missing: string[] = []
|
const missing: string[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -996,7 +996,7 @@ export default {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: Option.none() })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const loaded = await PluginLoader.loadExternal({
|
const loaded = await PluginLoader.loadExternal({
|
||||||
|
|||||||
@@ -165,55 +165,60 @@ export namespace EffectFlock {
|
|||||||
|
|
||||||
type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string }
|
type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string }
|
||||||
|
|
||||||
const tryAcquireLockDir = Effect.fn("EffectFlock.tryAcquire")(function* (lockDir: string) {
|
const tryAcquireLockDir = (lockDir: string, key: string) =>
|
||||||
const token = randomUUID()
|
Effect.gen(function* () {
|
||||||
const metaPath = path.join(lockDir, "meta.json")
|
const token = randomUUID()
|
||||||
const heartbeatPath = path.join(lockDir, "heartbeat")
|
const metaPath = path.join(lockDir, "meta.json")
|
||||||
|
const heartbeatPath = path.join(lockDir, "heartbeat")
|
||||||
|
|
||||||
// Atomic mkdir — the POSIX lock primitive
|
// Atomic mkdir — the POSIX lock primitive
|
||||||
const created = yield* atomicMkdir(lockDir)
|
const created = yield* atomicMkdir(lockDir)
|
||||||
|
|
||||||
if (!created) {
|
if (!created) {
|
||||||
if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired()
|
if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired()
|
||||||
|
|
||||||
// Stale — race for breaker ownership
|
// Stale — race for breaker ownership
|
||||||
const breakerPath = lockDir + ".breaker"
|
const breakerPath = lockDir + ".breaker"
|
||||||
|
|
||||||
const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe(
|
const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe(
|
||||||
Effect.as(true),
|
Effect.as(true),
|
||||||
Effect.catchIf(
|
Effect.catchIf(
|
||||||
(e) => e.reason._tag === "AlreadyExists",
|
(e) => e.reason._tag === "AlreadyExists",
|
||||||
() => cleanStaleBreaker(breakerPath),
|
() => cleanStaleBreaker(breakerPath),
|
||||||
),
|
),
|
||||||
Effect.catchIf(isPathGone, () => Effect.succeed(false)),
|
Effect.catchIf(isPathGone, () => Effect.succeed(false)),
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!claimed) return yield* new NotAcquired()
|
if (!claimed) return yield* new NotAcquired()
|
||||||
|
|
||||||
// We own the breaker — double-check staleness, nuke, recreate
|
// We own the breaker — double-check staleness, nuke, recreate
|
||||||
const recreated = yield* Effect.gen(function* () {
|
const recreated = yield* Effect.gen(function* () {
|
||||||
if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false
|
if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false
|
||||||
yield* forceRemove(lockDir)
|
yield* forceRemove(lockDir)
|
||||||
return yield* atomicMkdir(lockDir)
|
return yield* atomicMkdir(lockDir)
|
||||||
}).pipe(Effect.ensuring(forceRemove(breakerPath)))
|
}).pipe(Effect.ensuring(forceRemove(breakerPath)))
|
||||||
|
|
||||||
if (!recreated) return yield* new NotAcquired()
|
if (!recreated) return yield* new NotAcquired()
|
||||||
}
|
}
|
||||||
|
|
||||||
// We own the lock dir — write heartbeat + meta with exclusive create
|
// We own the lock dir — write heartbeat + meta with exclusive create
|
||||||
yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed")
|
yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed")
|
||||||
|
|
||||||
const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() })
|
const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() })
|
||||||
yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed")
|
yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed")
|
||||||
|
|
||||||
return { token, metaPath, heartbeatPath, lockDir } satisfies Handle
|
return { token, metaPath, heartbeatPath, lockDir } satisfies Handle
|
||||||
})
|
}).pipe(
|
||||||
|
Effect.withSpan("EffectFlock.tryAcquire", {
|
||||||
|
attributes: { key },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
// -- retry wrapper (preserves Handle type) --
|
// -- retry wrapper (preserves Handle type) --
|
||||||
|
|
||||||
const acquireHandle = (lockfile: string, key: string): Effect.Effect<Handle, LockError> =>
|
const acquireHandle = (lockfile: string, key: string): Effect.Effect<Handle, LockError> =>
|
||||||
tryAcquireLockDir(lockfile).pipe(
|
tryAcquireLockDir(lockfile, key).pipe(
|
||||||
Effect.retry({
|
Effect.retry({
|
||||||
while: (err) => err._tag === "NotAcquired",
|
while: (err) => err._tag === "NotAcquired",
|
||||||
schedule: retrySchedule,
|
schedule: retrySchedule,
|
||||||
|
|||||||
Reference in New Issue
Block a user