progress
This commit is contained in:
parent
64dc81cee0
commit
a0f7731ef4
10 changed files with 948 additions and 252 deletions
2
bun.lock
2
bun.lock
|
|
@ -203,6 +203,7 @@
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"mime-types": "3.0.2",
|
"mime-types": "3.0.2",
|
||||||
"minimatch": "10.2.5",
|
"minimatch": "10.2.5",
|
||||||
|
"npm-package-arg": "13.0.2",
|
||||||
"semver": "catalog:",
|
"semver": "catalog:",
|
||||||
"xdg-basedir": "5.1.0",
|
"xdg-basedir": "5.1.0",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
|
|
@ -210,6 +211,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
|
"@types/npm-package-arg": "6.1.4",
|
||||||
"@types/npmcli__arborist": "6.3.3",
|
"@types/npmcli__arborist": "6.3.3",
|
||||||
"@types/semver": "catalog:",
|
"@types/semver": "catalog:",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/semver": "catalog:",
|
"@types/semver": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
|
"@types/npm-package-arg": "6.1.4",
|
||||||
"@types/npmcli__arborist": "6.3.3"
|
"@types/npmcli__arborist": "6.3.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -29,6 +30,7 @@
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"mime-types": "3.0.2",
|
"mime-types": "3.0.2",
|
||||||
"minimatch": "10.2.5",
|
"minimatch": "10.2.5",
|
||||||
|
"npm-package-arg": "13.0.2",
|
||||||
"semver": "catalog:",
|
"semver": "catalog:",
|
||||||
"xdg-basedir": "5.1.0",
|
"xdg-basedir": "5.1.0",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
|
|
|
||||||
2
packages/core/src/effect/memo-map.ts
Normal file
2
packages/core/src/effect/memo-map.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
import { Layer } from "effect"
|
||||||
|
export const memoMap = Layer.makeMemoMapUnsafe()
|
||||||
107
packages/core/src/effect/observability.ts
Normal file
107
packages/core/src/effect/observability.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { Effect, Layer, Logger } from "effect"
|
||||||
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
|
import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
|
||||||
|
import * as EffectLogger from "./logger"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { InstallationChannel, InstallationVersion } from "@/installation/version"
|
||||||
|
import { ensureProcessMetadata } from "@/util/opencode-process"
|
||||||
|
|
||||||
|
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
|
||||||
|
export const enabled = !!base
|
||||||
|
const processID = crypto.randomUUID()
|
||||||
|
|
||||||
|
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
|
||||||
|
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
|
||||||
|
(acc, x) => {
|
||||||
|
const [key, ...value] = x.split("=")
|
||||||
|
acc[key] = value.join("=")
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, string>,
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
export function resource(): { serviceName: string; serviceVersion: string; attributes: Record<string, string> } {
|
||||||
|
const processMetadata = ensureProcessMetadata("main")
|
||||||
|
const attributes: Record<string, string> = (() => {
|
||||||
|
const value = process.env.OTEL_RESOURCE_ATTRIBUTES
|
||||||
|
if (!value) return {}
|
||||||
|
try {
|
||||||
|
return Object.fromEntries(
|
||||||
|
value.split(",").map((entry) => {
|
||||||
|
const index = entry.indexOf("=")
|
||||||
|
if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry")
|
||||||
|
return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return {
|
||||||
|
serviceName: "opencode",
|
||||||
|
serviceVersion: InstallationVersion,
|
||||||
|
attributes: {
|
||||||
|
...attributes,
|
||||||
|
"deployment.environment.name": InstallationChannel,
|
||||||
|
"opencode.client": Flag.OPENCODE_CLIENT,
|
||||||
|
"opencode.process_role": processMetadata.processRole,
|
||||||
|
"opencode.run_id": processMetadata.runID,
|
||||||
|
"service.instance.id": processID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logs() {
|
||||||
|
return Logger.layer(
|
||||||
|
[
|
||||||
|
EffectLogger.logger,
|
||||||
|
OtlpLogger.make({
|
||||||
|
url: `${base}/v1/logs`,
|
||||||
|
resource: resource(),
|
||||||
|
headers,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{ mergeWithExisting: false },
|
||||||
|
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
|
||||||
|
}
|
||||||
|
|
||||||
|
const traces = async () => {
|
||||||
|
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
|
||||||
|
const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
|
||||||
|
const SdkBase = await import("@opentelemetry/sdk-trace-base")
|
||||||
|
|
||||||
|
// @effect/opentelemetry creates a NodeTracerProvider but never calls
|
||||||
|
// register(), so the global @opentelemetry/api context manager stays
|
||||||
|
// as the no-op default. Non-Effect code (like the AI SDK) that calls
|
||||||
|
// tracer.startActiveSpan() relies on context.active() to find the
|
||||||
|
// parent span — without a real context manager every span starts a
|
||||||
|
// new trace. Registering AsyncLocalStorageContextManager fixes this.
|
||||||
|
const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
|
||||||
|
const { context } = await import("@opentelemetry/api")
|
||||||
|
const mgr = new AsyncLocalStorageContextManager()
|
||||||
|
mgr.enable()
|
||||||
|
context.setGlobalContextManager(mgr)
|
||||||
|
|
||||||
|
return NodeSdk.layer(() => ({
|
||||||
|
resource: resource(),
|
||||||
|
spanProcessor: new SdkBase.BatchSpanProcessor(
|
||||||
|
new OTLP.OTLPTraceExporter({
|
||||||
|
url: `${base}/v1/traces`,
|
||||||
|
headers,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const layer = !base
|
||||||
|
? EffectLogger.layer
|
||||||
|
: Layer.unwrap(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const trace = yield* Effect.promise(traces)
|
||||||
|
return Layer.mergeAll(trace, logs())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Observability = { enabled, layer }
|
||||||
19
packages/core/src/effect/runtime.ts
Normal file
19
packages/core/src/effect/runtime.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { Observability } from "./observability"
|
||||||
|
import { Layer, type Context, ManagedRuntime, type Effect } from "effect"
|
||||||
|
import { memoMap } from "./memo-map"
|
||||||
|
|
||||||
|
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
|
||||||
|
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
||||||
|
const getRuntime = () =>
|
||||||
|
(rt ??= ManagedRuntime.make(Layer.provideMerge(layer, Observability.layer) as Layer.Layer<I, E>, { memoMap }))
|
||||||
|
|
||||||
|
return {
|
||||||
|
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
|
||||||
|
runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
||||||
|
getRuntime().runPromiseExit(service.use(fn), options),
|
||||||
|
runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
||||||
|
getRuntime().runPromise(service.use(fn), options),
|
||||||
|
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
|
||||||
|
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
export * as AppFileSystem from "./filesystem.js"
|
||||||
|
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { dirname, join, relative, resolve as pathResolve } from "path"
|
import { dirname, join, relative, resolve as pathResolve } from "path"
|
||||||
import { realpathSync } from "fs"
|
import { realpathSync } from "fs"
|
||||||
|
|
@ -5,232 +7,231 @@ import * as NFS from "fs/promises"
|
||||||
import { lookup } from "mime-types"
|
import { lookup } from "mime-types"
|
||||||
import { Effect, FileSystem, Layer, Schema, Context } from "effect"
|
import { Effect, FileSystem, Layer, Schema, Context } from "effect"
|
||||||
import type { PlatformError } from "effect/PlatformError"
|
import type { PlatformError } from "effect/PlatformError"
|
||||||
import { Glob } from "./util/glob"
|
|
||||||
|
|
||||||
export namespace AppFileSystem {
|
import { Glob } from "./util/glob.js"
|
||||||
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
|
|
||||||
method: Schema.String,
|
|
||||||
cause: Schema.optional(Schema.Defect),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export type Error = PlatformError | FileSystemError
|
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
|
||||||
|
method: Schema.String,
|
||||||
|
cause: Schema.optional(Schema.Defect),
|
||||||
|
}) {}
|
||||||
|
|
||||||
export interface DirEntry {
|
export type Error = PlatformError | FileSystemError
|
||||||
readonly name: string
|
|
||||||
readonly type: "file" | "directory" | "symlink" | "other"
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface extends FileSystem.FileSystem {
|
export interface DirEntry {
|
||||||
readonly isDir: (path: string) => Effect.Effect<boolean>
|
readonly name: string
|
||||||
readonly isFile: (path: string) => Effect.Effect<boolean>
|
readonly type: "file" | "directory" | "symlink" | "other"
|
||||||
readonly existsSafe: (path: string) => Effect.Effect<boolean>
|
}
|
||||||
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
|
|
||||||
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
|
|
||||||
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
|
|
||||||
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
|
|
||||||
readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
|
|
||||||
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
|
||||||
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
|
|
||||||
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
|
||||||
readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
|
|
||||||
readonly globMatch: (pattern: string, filepath: string) => boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
|
export interface Interface extends FileSystem.FileSystem {
|
||||||
|
readonly isDir: (path: string) => Effect.Effect<boolean>
|
||||||
|
readonly isFile: (path: string) => Effect.Effect<boolean>
|
||||||
|
readonly existsSafe: (path: string) => Effect.Effect<boolean>
|
||||||
|
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
|
||||||
|
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
|
||||||
|
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
|
||||||
|
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
|
||||||
|
readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
|
||||||
|
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
||||||
|
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
|
||||||
|
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
||||||
|
readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
|
||||||
|
readonly globMatch: (pattern: string, filepath: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const fs = yield* FileSystem.FileSystem
|
|
||||||
|
|
||||||
const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
|
export const layer = Layer.effect(
|
||||||
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
|
||||||
|
const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
|
||||||
|
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
|
||||||
|
})
|
||||||
|
|
||||||
|
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
|
||||||
|
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
||||||
|
return info?.type === "Directory"
|
||||||
|
})
|
||||||
|
|
||||||
|
const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
|
||||||
|
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
||||||
|
return info?.type === "File"
|
||||||
|
})
|
||||||
|
|
||||||
|
const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
|
||||||
|
return yield* Effect.tryPromise({
|
||||||
|
try: async () => {
|
||||||
|
const entries = await NFS.readdir(dirPath, { withFileTypes: true })
|
||||||
|
return entries.map(
|
||||||
|
(e): DirEntry => ({
|
||||||
|
name: e.name,
|
||||||
|
type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }),
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
|
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
|
||||||
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
const text = yield* fs.readFileString(path)
|
||||||
return info?.type === "Directory"
|
return JSON.parse(text)
|
||||||
|
})
|
||||||
|
|
||||||
|
const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
|
||||||
|
const content = JSON.stringify(data, null, 2)
|
||||||
|
yield* fs.writeFileString(path, content)
|
||||||
|
if (mode) yield* fs.chmod(path, mode)
|
||||||
|
})
|
||||||
|
|
||||||
|
const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
|
||||||
|
yield* fs.makeDirectory(path, { recursive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
|
||||||
|
path: string,
|
||||||
|
content: string | Uint8Array,
|
||||||
|
mode?: number,
|
||||||
|
) {
|
||||||
|
const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
|
||||||
|
|
||||||
|
yield* write.pipe(
|
||||||
|
Effect.catchIf(
|
||||||
|
(e) => e.reason._tag === "NotFound",
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* fs.makeDirectory(dirname(path), { recursive: true })
|
||||||
|
yield* write
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (mode) yield* fs.chmod(path, mode)
|
||||||
|
})
|
||||||
|
|
||||||
|
const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
|
||||||
|
return yield* Effect.tryPromise({
|
||||||
|
try: () => Glob.scan(pattern, options),
|
||||||
|
catch: (cause) => new FileSystemError({ method: "glob", cause }),
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
|
const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
|
||||||
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
const result: string[] = []
|
||||||
return info?.type === "File"
|
let current = start
|
||||||
})
|
while (true) {
|
||||||
|
const search = join(current, target)
|
||||||
|
if (yield* fs.exists(search)) result.push(search)
|
||||||
|
if (stop === current) break
|
||||||
|
const parent = dirname(current)
|
||||||
|
if (parent === current) break
|
||||||
|
current = parent
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
|
const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
|
||||||
return yield* Effect.tryPromise({
|
const result: string[] = []
|
||||||
try: async () => {
|
let current = options.start
|
||||||
const entries = await NFS.readdir(dirPath, { withFileTypes: true })
|
while (true) {
|
||||||
return entries.map(
|
for (const target of options.targets) {
|
||||||
(e): DirEntry => ({
|
|
||||||
name: e.name,
|
|
||||||
type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
|
|
||||||
const text = yield* fs.readFileString(path)
|
|
||||||
return JSON.parse(text)
|
|
||||||
})
|
|
||||||
|
|
||||||
const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
|
|
||||||
const content = JSON.stringify(data, null, 2)
|
|
||||||
yield* fs.writeFileString(path, content)
|
|
||||||
if (mode) yield* fs.chmod(path, mode)
|
|
||||||
})
|
|
||||||
|
|
||||||
const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
|
|
||||||
yield* fs.makeDirectory(path, { recursive: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
|
|
||||||
path: string,
|
|
||||||
content: string | Uint8Array,
|
|
||||||
mode?: number,
|
|
||||||
) {
|
|
||||||
const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
|
|
||||||
|
|
||||||
yield* write.pipe(
|
|
||||||
Effect.catchIf(
|
|
||||||
(e) => e.reason._tag === "NotFound",
|
|
||||||
() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* fs.makeDirectory(dirname(path), { recursive: true })
|
|
||||||
yield* write
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (mode) yield* fs.chmod(path, mode)
|
|
||||||
})
|
|
||||||
|
|
||||||
const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
|
|
||||||
return yield* Effect.tryPromise({
|
|
||||||
try: () => Glob.scan(pattern, options),
|
|
||||||
catch: (cause) => new FileSystemError({ method: "glob", cause }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
|
|
||||||
const result: string[] = []
|
|
||||||
let current = start
|
|
||||||
while (true) {
|
|
||||||
const search = join(current, target)
|
const search = join(current, target)
|
||||||
if (yield* fs.exists(search)) result.push(search)
|
if (yield* fs.exists(search)) result.push(search)
|
||||||
if (stop === current) break
|
|
||||||
const parent = dirname(current)
|
|
||||||
if (parent === current) break
|
|
||||||
current = parent
|
|
||||||
}
|
}
|
||||||
return result
|
if (options.stop === current) break
|
||||||
})
|
const parent = dirname(current)
|
||||||
|
if (parent === current) break
|
||||||
|
current = parent
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
|
const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
|
||||||
const result: string[] = []
|
const result: string[] = []
|
||||||
let current = options.start
|
let current = start
|
||||||
while (true) {
|
while (true) {
|
||||||
for (const target of options.targets) {
|
const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
|
||||||
const search = join(current, target)
|
Effect.catch(() => Effect.succeed([] as string[])),
|
||||||
if (yield* fs.exists(search)) result.push(search)
|
)
|
||||||
}
|
result.push(...matches)
|
||||||
if (options.stop === current) break
|
if (stop === current) break
|
||||||
const parent = dirname(current)
|
const parent = dirname(current)
|
||||||
if (parent === current) break
|
if (parent === current) break
|
||||||
current = parent
|
current = parent
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
|
return Service.of({
|
||||||
const result: string[] = []
|
...fs,
|
||||||
let current = start
|
existsSafe,
|
||||||
while (true) {
|
isDir,
|
||||||
const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
|
isFile,
|
||||||
Effect.catch(() => Effect.succeed([] as string[])),
|
readDirectoryEntries,
|
||||||
)
|
readJson,
|
||||||
result.push(...matches)
|
writeJson,
|
||||||
if (stop === current) break
|
ensureDir,
|
||||||
const parent = dirname(current)
|
writeWithDirs,
|
||||||
if (parent === current) break
|
findUp,
|
||||||
current = parent
|
up,
|
||||||
}
|
globUp,
|
||||||
return result
|
glob,
|
||||||
})
|
globMatch: Glob.match,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
return Service.of({
|
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
|
||||||
...fs,
|
|
||||||
existsSafe,
|
|
||||||
isDir,
|
|
||||||
isFile,
|
|
||||||
readDirectoryEntries,
|
|
||||||
readJson,
|
|
||||||
writeJson,
|
|
||||||
ensureDir,
|
|
||||||
writeWithDirs,
|
|
||||||
findUp,
|
|
||||||
up,
|
|
||||||
globUp,
|
|
||||||
glob,
|
|
||||||
globMatch: Glob.match,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
|
// Pure helpers that don't need Effect (path manipulation, sync operations)
|
||||||
|
export function mimeType(p: string): string {
|
||||||
|
return lookup(p) || "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
// Pure helpers that don't need Effect (path manipulation, sync operations)
|
export function normalizePath(p: string): string {
|
||||||
export function mimeType(p: string): string {
|
if (process.platform !== "win32") return p
|
||||||
return lookup(p) || "application/octet-stream"
|
const resolved = pathResolve(windowsPath(p))
|
||||||
}
|
try {
|
||||||
|
return realpathSync.native(resolved)
|
||||||
export function normalizePath(p: string): string {
|
} catch {
|
||||||
if (process.platform !== "win32") return p
|
return resolved
|
||||||
const resolved = pathResolve(windowsPath(p))
|
|
||||||
try {
|
|
||||||
return realpathSync.native(resolved)
|
|
||||||
} catch {
|
|
||||||
return resolved
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizePathPattern(p: string): string {
|
|
||||||
if (process.platform !== "win32") return p
|
|
||||||
if (p === "*") return p
|
|
||||||
const match = p.match(/^(.*)[\\/]\*$/)
|
|
||||||
if (!match) return normalizePath(p)
|
|
||||||
const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
|
|
||||||
return join(normalizePath(dir), "*")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolve(p: string): string {
|
|
||||||
const resolved = pathResolve(windowsPath(p))
|
|
||||||
try {
|
|
||||||
return normalizePath(realpathSync(resolved))
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.code === "ENOENT") return normalizePath(resolved)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function windowsPath(p: string): string {
|
|
||||||
if (process.platform !== "win32") return p
|
|
||||||
return p
|
|
||||||
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
|
||||||
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
|
||||||
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
|
||||||
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function overlaps(a: string, b: string) {
|
|
||||||
const relA = relative(a, b)
|
|
||||||
const relB = relative(b, a)
|
|
||||||
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function contains(parent: string, child: string) {
|
|
||||||
return !relative(parent, child).startsWith("..")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizePathPattern(p: string): string {
|
||||||
|
if (process.platform !== "win32") return p
|
||||||
|
if (p === "*") return p
|
||||||
|
const match = p.match(/^(.*)[\\/]\*$/)
|
||||||
|
if (!match) return normalizePath(p)
|
||||||
|
const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
|
||||||
|
return join(normalizePath(dir), "*")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolve(p: string): string {
|
||||||
|
const resolved = pathResolve(windowsPath(p))
|
||||||
|
try {
|
||||||
|
return normalizePath(realpathSync(resolved))
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.code === "ENOENT") return normalizePath(resolved)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function windowsPath(p: string): string {
|
||||||
|
if (process.platform !== "win32") return p
|
||||||
|
return p
|
||||||
|
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||||
|
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||||
|
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||||
|
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overlaps(a: string, b: string) {
|
||||||
|
const relA = relative(a, b)
|
||||||
|
const relB = relative(b, a)
|
||||||
|
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contains(parent: string, child: string) {
|
||||||
|
return !relative(parent, child).startsWith("..")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,42 @@
|
||||||
|
export * as Global from "./global.js"
|
||||||
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
|
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
|
||||||
export namespace Global {
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Global") {}
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Global") {}
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly home: string
|
readonly home: string
|
||||||
readonly data: string
|
readonly data: string
|
||||||
readonly cache: string
|
readonly cache: string
|
||||||
readonly config: string
|
readonly config: string
|
||||||
readonly state: string
|
readonly state: string
|
||||||
readonly bin: string
|
readonly bin: string
|
||||||
readonly log: string
|
readonly log: string
|
||||||
}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const app = "opencode"
|
|
||||||
const home = process.env.OPENCODE_TEST_HOME ?? os.homedir()
|
|
||||||
const data = path.join(xdgData!, app)
|
|
||||||
const cache = path.join(xdgCache!, app)
|
|
||||||
const cfg = path.join(xdgConfig!, app)
|
|
||||||
const state = path.join(xdgState!, app)
|
|
||||||
const bin = path.join(cache, "bin")
|
|
||||||
const log = path.join(data, "log")
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
home,
|
|
||||||
data,
|
|
||||||
cache,
|
|
||||||
config: cfg,
|
|
||||||
state,
|
|
||||||
bin,
|
|
||||||
log,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const app = "opencode"
|
||||||
|
const home = process.env.OPENCODE_TEST_HOME ?? os.homedir()
|
||||||
|
const data = path.join(xdgData!, app)
|
||||||
|
const cache = path.join(xdgCache!, app)
|
||||||
|
const cfg = path.join(xdgConfig!, app)
|
||||||
|
const state = path.join(xdgState!, app)
|
||||||
|
const bin = path.join(cache, "bin")
|
||||||
|
const log = path.join(data, "log")
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
home,
|
||||||
|
data,
|
||||||
|
cache,
|
||||||
|
config: cfg,
|
||||||
|
state,
|
||||||
|
bin,
|
||||||
|
log,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
|
||||||
294
packages/core/src/npm.ts
Normal file
294
packages/core/src/npm.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
export * as Npm from "./npm.js"
|
||||||
|
|
||||||
|
import path from "path"
|
||||||
|
import npa from "npm-package-arg"
|
||||||
|
import semver from "semver"
|
||||||
|
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
|
||||||
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
|
|
||||||
|
import { AppFileSystem } from "./filesystem.js"
|
||||||
|
import { Global } from "./global.js"
|
||||||
|
import { EffectFlock } from "./util/effect-flock.js"
|
||||||
|
|
||||||
|
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: {
|
||||||
|
name: string
|
||||||
|
version?: 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 name = (() => {
|
||||||
|
try {
|
||||||
|
return npa(pkg).name ?? pkg
|
||||||
|
} catch {
|
||||||
|
return pkg
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (yield* afs.existsSafe(dir)) {
|
||||||
|
return resolveEntryPoint(name, path.join(dir, "node_modules", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 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) {
|
||||||
|
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"]>) {
|
||||||
|
const entry = await runPromise((svc) => svc.add(...args))
|
||||||
|
return {
|
||||||
|
directory: entry.directory,
|
||||||
|
entrypoint: Option.getOrUndefined(entry.entrypoint),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
268
packages/core/src/npm/index.ts
Normal file
268
packages/core/src/npm/index.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
import path from "path"
|
||||||
|
import npa from "npm-package-arg"
|
||||||
|
import semver from "semver"
|
||||||
|
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
|
||||||
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
|
import { AppFileSystem } from "../filesystem"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { EffectFlock } from "../util/effect-flock"
|
||||||
|
|
||||||
|
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: {
|
||||||
|
name: string
|
||||||
|
version?: 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 name = (() => {
|
||||||
|
try {
|
||||||
|
return npa(pkg).name ?? pkg
|
||||||
|
} catch {
|
||||||
|
return pkg
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (yield* afs.existsSafe(dir)) {
|
||||||
|
return resolveEntryPoint(name, path.join(dir, "node_modules", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 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) {
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
export * as Npm from "."
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/tsconfig",
|
"$schema": "https://www.schemastore.org/tsconfig",
|
||||||
"extends": "@tsconfig/bun/tsconfig.json",
|
"_version": "24.0.0",
|
||||||
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"lib": ["es2024", "ESNext.Array", "ESNext.Collection", "ESNext.Error", "ESNext.Iterator", "ESNext.Promise"],
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"target": "es2024",
|
||||||
"noUncheckedIndexedAccess": false,
|
"noUncheckedIndexedAccess": false,
|
||||||
"plugins": [
|
"strict": true,
|
||||||
{
|
"esModuleInterop": true,
|
||||||
"name": "@effect/language-service",
|
"skipLibCheck": true
|
||||||
"transform": "@effect/language-service/transform",
|
|
||||||
"namespaceImportPackages": ["effect", "@effect/*"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue