Skip to content

Commit 2f73e73

Browse files
committed
trace npm fully
1 parent 4c30a78 commit 2f73e73

16 files changed

Lines changed: 279 additions & 288 deletions

File tree

.opencode/opencode.jsonc

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
{
22
"$schema": "https://opencode.ai/config.json",
3-
"provider": {
4-
"opencode": {
5-
"options": {},
6-
},
7-
},
3+
"provider": {},
84
"permission": {
95
"edit": {
106
"packages/opencode/migration/*": "deny",

packages/opencode/src/cli/cmd/tui/config/tui.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ import { Flag } from "@/flag/flag"
1111
import { isRecord } from "@/util/record"
1212
import { Global } from "@/global"
1313
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
14-
import { Npm } from "@opencode-ai/shared/npm"
1514
import { CurrentWorkingDirectory } from "./cwd"
1615
import { ConfigPlugin } from "@/config/plugin"
1716
import { ConfigKeybinds } from "@/config/keybinds"
1817
import { InstallationLocal, InstallationVersion } from "@/installation/version"
19-
import { makeRuntime } from "@/cli/effect/runtime"
18+
import { makeRuntime } from "@/effect/runtime"
2019
import { Filesystem, Log } from "@/util"
2120
import { ConfigVariable } from "@/config/variable"
21+
import { Npm } from "@/npm/effect"
2222

2323
const log = Log.create({ service: "tui.config" })
2424

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Layer } from "effect"
22
import { TuiConfig } from "./config/tui"
3-
import { Npm } from "@opencode-ai/shared/npm"
3+
import { Npm } from "@/npm/effect"
44
import { Observability } from "@/effect/observability"
55

66
export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))

packages/opencode/src/config/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import { InstanceState } from "@/effect"
2424
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
2525
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
2626
import { InstanceRef } from "@/effect/instance-ref"
27-
import { Npm } from "@opencode-ai/shared/npm"
2827
import { ConfigAgent } from "./agent"
2928
import { ConfigMCP } from "./mcp"
3029
import { ConfigModelID } from "./model-id"
@@ -39,6 +38,7 @@ import { ConfigPaths } from "./paths"
3938
import { ConfigFormatter } from "./formatter"
4039
import { ConfigLSP } from "./lsp"
4140
import { ConfigVariable } from "./variable"
41+
import { Npm } from "@/npm/effect"
4242

4343
const log = Log.create({ service: "config" })
4444

packages/opencode/src/effect/app-runtime.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Layer, ManagedRuntime } from "effect"
2-
import { attach, memoMap } from "./run-service"
2+
import { attach } from "./run-service"
33
import * as Observability from "./observability"
44

55
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
@@ -46,7 +46,8 @@ import { Pty } from "@/pty"
4646
import { Installation } from "@/installation"
4747
import { ShareNext } from "@/share"
4848
import { SessionShare } from "@/share"
49-
import { Npm } from "@opencode-ai/shared/npm"
49+
import { Npm } from "@/npm/effect"
50+
import { memoMap } from "./memo-map"
5051

5152
export const AppLayer = Layer.mergeAll(
5253
Npm.defaultLayer,

packages/opencode/src/effect/bootstrap-runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Layer, ManagedRuntime } from "effect"
2-
import { memoMap } from "./run-service"
32

43
import { Plugin } from "@/plugin"
54
import { LSP } from "@/lsp"
@@ -12,6 +11,7 @@ import { Snapshot } from "@/snapshot"
1211
import { Bus } from "@/bus"
1312
import { Config } from "@/config"
1413
import * as Observability from "./observability"
14+
import { memoMap } from "./memo-map"
1515

1616
export const BootstrapLayer = Layer.mergeAll(
1717
Config.defaultLayer,
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { Layer } from "effect"
2+
3+
export const memoMap = Layer.makeMemoMapUnsafe()

packages/opencode/src/effect/run-service.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ import { InstanceRef, WorkspaceRef } from "./instance-ref"
66
import * as Observability from "./observability"
77
import { WorkspaceContext } from "@/control-plane/workspace-context"
88
import type { InstanceContext } from "@/project/instance"
9-
10-
export const memoMap = Layer.makeMemoMapUnsafe()
9+
import { memoMap } from "./memo-map"
1110

1211
type Refs = {
1312
instance?: InstanceContext

packages/opencode/src/cli/effect/runtime.ts renamed to packages/opencode/src/effect/runtime.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { Observability } from "@/effect/observability"
1+
import { Observability } from "./observability"
22
import { Layer, type Context, ManagedRuntime, type Effect } from "effect"
3-
4-
export const memoMap = Layer.makeMemoMapUnsafe()
3+
import { memoMap } from "./memo-map"
54

65
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
76
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
export * as Npm from "./effect"
2+
3+
import path from "path"
4+
import semver from "semver"
5+
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
6+
import { NodeFileSystem } from "@effect/platform-node"
7+
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
8+
import { Global } from "@opencode-ai/shared/global"
9+
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
10+
11+
import { makeRuntime } from "../effect/runtime"
12+
13+
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
14+
add: Schema.Array(Schema.String).pipe(Schema.optional),
15+
dir: Schema.String,
16+
cause: Schema.optional(Schema.Defect),
17+
}) {}
18+
19+
export interface EntryPoint {
20+
readonly directory: string
21+
readonly entrypoint: Option.Option<string>
22+
}
23+
24+
export interface Interface {
25+
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
26+
readonly install: (
27+
dir: string,
28+
input?: { add: string[] },
29+
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
30+
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
31+
readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
32+
}
33+
34+
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
35+
36+
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
37+
38+
export function sanitize(pkg: string) {
39+
if (!illegal) return pkg
40+
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
41+
}
42+
43+
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
44+
let entrypoint: Option.Option<string>
45+
try {
46+
const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
47+
entrypoint = Option.some(resolved)
48+
} catch {
49+
entrypoint = Option.none()
50+
}
51+
return {
52+
directory: dir,
53+
entrypoint,
54+
}
55+
}
56+
57+
interface ArboristNode {
58+
name: string
59+
path: string
60+
}
61+
62+
interface ArboristTree {
63+
edgesOut: Map<string, { to?: ArboristNode }>
64+
}
65+
66+
const reify = (input: { dir: string; add?: string[] }) =>
67+
Effect.gen(function* () {
68+
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
69+
const arborist = new Arborist({
70+
path: input.dir,
71+
binLinks: true,
72+
progress: false,
73+
savePrefix: "",
74+
ignoreScripts: true,
75+
})
76+
return yield* Effect.tryPromise({
77+
try: () =>
78+
arborist.reify({
79+
add: input?.add || [],
80+
save: true,
81+
saveType: "prod",
82+
}),
83+
catch: (cause) =>
84+
new InstallFailedError({
85+
cause,
86+
add: input?.add,
87+
dir: input.dir,
88+
}),
89+
}) as Effect.Effect<ArboristTree, InstallFailedError>
90+
}).pipe(
91+
Effect.withSpan("Npm.reify", {
92+
attributes: input,
93+
}),
94+
)
95+
96+
export const layer = Layer.effect(
97+
Service,
98+
Effect.gen(function* () {
99+
const afs = yield* AppFileSystem.Service
100+
const global = yield* Global.Service
101+
const fs = yield* FileSystem.FileSystem
102+
const flock = yield* EffectFlock.Service
103+
const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
104+
105+
const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
106+
const response = yield* Effect.tryPromise({
107+
try: () => fetch(`https://registry.npmjs.org/${pkg}`),
108+
catch: () => undefined,
109+
}).pipe(Effect.orElseSucceed(() => undefined))
110+
111+
if (!response || !response.ok) {
112+
return false
113+
}
114+
115+
const data = yield* Effect.tryPromise({
116+
try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>,
117+
catch: () => undefined,
118+
}).pipe(Effect.orElseSucceed(() => undefined))
119+
120+
const latestVersion = data?.["dist-tags"]?.latest
121+
if (!latestVersion) {
122+
return false
123+
}
124+
125+
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
126+
if (range) return !semver.satisfies(latestVersion, cachedVersion)
127+
128+
return semver.lt(cachedVersion, latestVersion)
129+
})
130+
131+
const add = Effect.fn("Npm.add")(function* (pkg: string) {
132+
const dir = directory(pkg)
133+
yield* flock.acquire(`npm-install:${dir}`)
134+
135+
const tree = yield* reify({ dir, add: [pkg] })
136+
const first = tree.edgesOut.values().next().value?.to
137+
if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
138+
return resolveEntryPoint(first.name, first.path)
139+
}, Effect.scoped)
140+
141+
const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) {
142+
const canWrite = yield* afs.access(dir, { writable: true }).pipe(
143+
Effect.as(true),
144+
Effect.orElseSucceed(() => false),
145+
)
146+
if (!canWrite) return
147+
148+
yield* flock.acquire(`npm-install:${dir}`)
149+
150+
yield* Effect.gen(function* () {
151+
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
152+
if (!nodeModulesExists) {
153+
yield* reify({ add: input?.add, dir })
154+
return
155+
}
156+
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
157+
158+
yield* Effect.gen(function* () {
159+
const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
160+
const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))
161+
162+
const pkgAny = pkg as any
163+
const lockAny = lock as any
164+
const declared = new Set([
165+
...Object.keys(pkgAny?.dependencies || {}),
166+
...Object.keys(pkgAny?.devDependencies || {}),
167+
...Object.keys(pkgAny?.peerDependencies || {}),
168+
...Object.keys(pkgAny?.optionalDependencies || {}),
169+
...(input?.add || []),
170+
])
171+
172+
const root = lockAny?.packages?.[""] || {}
173+
const locked = new Set([
174+
...Object.keys(root?.dependencies || {}),
175+
...Object.keys(root?.devDependencies || {}),
176+
...Object.keys(root?.peerDependencies || {}),
177+
...Object.keys(root?.optionalDependencies || {}),
178+
])
179+
180+
for (const name of declared) {
181+
if (!locked.has(name)) {
182+
yield* reify({ dir, add: input?.add })
183+
return
184+
}
185+
}
186+
}).pipe(Effect.withSpan("Npm.checkDirty"))
187+
188+
return
189+
}, Effect.scoped)
190+
191+
const which = Effect.fn("Npm.which")(function* (pkg: string) {
192+
const dir = directory(pkg)
193+
const binDir = path.join(dir, "node_modules", ".bin")
194+
195+
const pick = Effect.fnUntraced(function* () {
196+
const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
197+
198+
if (files.length === 0) return Option.none<string>()
199+
if (files.length === 1) return Option.some(files[0])
200+
201+
const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
202+
203+
if (Option.isSome(pkgJson)) {
204+
const parsed = pkgJson.value as { bin?: string | Record<string, string> }
205+
if (parsed?.bin) {
206+
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
207+
const bin = parsed.bin
208+
if (typeof bin === "string") return Option.some(unscoped)
209+
const keys = Object.keys(bin)
210+
if (keys.length === 1) return Option.some(keys[0])
211+
return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
212+
}
213+
}
214+
215+
return Option.some(files[0])
216+
})
217+
218+
return yield* Effect.gen(function* () {
219+
const bin = yield* pick()
220+
if (Option.isSome(bin)) {
221+
return Option.some(path.join(binDir, bin.value))
222+
}
223+
224+
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
225+
226+
yield* add(pkg)
227+
228+
const resolved = yield* pick()
229+
if (Option.isNone(resolved)) return Option.none<string>()
230+
return Option.some(path.join(binDir, resolved.value))
231+
}).pipe(
232+
Effect.scoped,
233+
Effect.orElseSucceed(() => Option.none<string>()),
234+
)
235+
})
236+
237+
return Service.of({
238+
add,
239+
install,
240+
outdated,
241+
which,
242+
})
243+
}),
244+
)
245+
246+
export const defaultLayer = layer.pipe(
247+
Layer.provide(EffectFlock.layer),
248+
Layer.provide(AppFileSystem.layer),
249+
Layer.provide(Global.layer),
250+
Layer.provide(NodeFileSystem.layer),
251+
)
252+
253+
const { runPromise } = makeRuntime(Service, defaultLayer)
254+
255+
export async function install(...args: Parameters<Interface["install"]>) {
256+
return runPromise((svc) => svc.install(...args))
257+
}
258+
259+
export async function add(...args: Parameters<Interface["add"]>) {
260+
return runPromise((svc) => svc.add(...args))
261+
}

0 commit comments

Comments
 (0)