Skip to content

Commit bf8050f

Browse files
committed
merge upstream dev
2 parents f99e525 + b1f0765 commit bf8050f

18 files changed

Lines changed: 296 additions & 307 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: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
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+
export const layer = Layer.effect(
67+
Service,
68+
Effect.gen(function* () {
69+
const afs = yield* AppFileSystem.Service
70+
const global = yield* Global.Service
71+
const fs = yield* FileSystem.FileSystem
72+
const flock = yield* EffectFlock.Service
73+
const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
74+
const reify = (input: { dir: string; add?: string[] }) =>
75+
Effect.gen(function* () {
76+
yield* flock.acquire(`npm-install:${input.dir}`)
77+
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
78+
const arborist = new Arborist({
79+
path: input.dir,
80+
binLinks: true,
81+
progress: false,
82+
savePrefix: "",
83+
ignoreScripts: true,
84+
})
85+
return yield* Effect.tryPromise({
86+
try: () =>
87+
arborist.reify({
88+
add: input?.add || [],
89+
save: true,
90+
saveType: "prod",
91+
}),
92+
catch: (cause) =>
93+
new InstallFailedError({
94+
cause,
95+
add: input?.add,
96+
dir: input.dir,
97+
}),
98+
}) as Effect.Effect<ArboristTree, InstallFailedError>
99+
}).pipe(
100+
Effect.withSpan("Npm.reify", {
101+
attributes: input,
102+
}),
103+
)
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+
134+
const tree = yield* reify({ dir, add: [pkg] })
135+
const first = tree.edgesOut.values().next().value?.to
136+
if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
137+
return resolveEntryPoint(first.name, first.path)
138+
}, Effect.scoped)
139+
140+
const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) {
141+
const canWrite = yield* afs.access(dir, { writable: true }).pipe(
142+
Effect.as(true),
143+
Effect.orElseSucceed(() => false),
144+
)
145+
if (!canWrite) return
146+
147+
yield* Effect.gen(function* () {
148+
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
149+
if (!nodeModulesExists) {
150+
yield* reify({ add: input?.add, dir })
151+
return
152+
}
153+
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
154+
155+
yield* Effect.gen(function* () {
156+
const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
157+
const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))
158+
159+
const pkgAny = pkg as any
160+
const lockAny = lock as any
161+
const declared = new Set([
162+
...Object.keys(pkgAny?.dependencies || {}),
163+
...Object.keys(pkgAny?.devDependencies || {}),
164+
...Object.keys(pkgAny?.peerDependencies || {}),
165+
...Object.keys(pkgAny?.optionalDependencies || {}),
166+
...(input?.add || []),
167+
])
168+
169+
const root = lockAny?.packages?.[""] || {}
170+
const locked = new Set([
171+
...Object.keys(root?.dependencies || {}),
172+
...Object.keys(root?.devDependencies || {}),
173+
...Object.keys(root?.peerDependencies || {}),
174+
...Object.keys(root?.optionalDependencies || {}),
175+
])
176+
177+
for (const name of declared) {
178+
if (!locked.has(name)) {
179+
yield* reify({ dir, add: input?.add })
180+
return
181+
}
182+
}
183+
}).pipe(Effect.withSpan("Npm.checkDirty"))
184+
185+
return
186+
}, Effect.scoped)
187+
188+
const which = Effect.fn("Npm.which")(function* (pkg: string) {
189+
const dir = directory(pkg)
190+
const binDir = path.join(dir, "node_modules", ".bin")
191+
192+
const pick = Effect.fnUntraced(function* () {
193+
const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
194+
195+
if (files.length === 0) return Option.none<string>()
196+
if (files.length === 1) return Option.some(files[0])
197+
198+
const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
199+
200+
if (Option.isSome(pkgJson)) {
201+
const parsed = pkgJson.value as { bin?: string | Record<string, string> }
202+
if (parsed?.bin) {
203+
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
204+
const bin = parsed.bin
205+
if (typeof bin === "string") return Option.some(unscoped)
206+
const keys = Object.keys(bin)
207+
if (keys.length === 1) return Option.some(keys[0])
208+
return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
209+
}
210+
}
211+
212+
return Option.some(files[0])
213+
})
214+
215+
return yield* Effect.gen(function* () {
216+
const bin = yield* pick()
217+
if (Option.isSome(bin)) {
218+
return Option.some(path.join(binDir, bin.value))
219+
}
220+
221+
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
222+
223+
yield* add(pkg)
224+
225+
const resolved = yield* pick()
226+
if (Option.isNone(resolved)) return Option.none<string>()
227+
return Option.some(path.join(binDir, resolved.value))
228+
}).pipe(
229+
Effect.scoped,
230+
Effect.orElseSucceed(() => Option.none<string>()),
231+
)
232+
})
233+
234+
return Service.of({
235+
add,
236+
install,
237+
outdated,
238+
which,
239+
})
240+
}),
241+
)
242+
243+
export const defaultLayer = layer.pipe(
244+
Layer.provide(EffectFlock.layer),
245+
Layer.provide(AppFileSystem.layer),
246+
Layer.provide(Global.layer),
247+
Layer.provide(NodeFileSystem.layer),
248+
)
249+
250+
const { runPromise } = makeRuntime(Service, defaultLayer)
251+
252+
export async function install(...args: Parameters<Interface["install"]>) {
253+
return runPromise((svc) => svc.install(...args))
254+
}
255+
256+
export async function add(...args: Parameters<Interface["add"]>) {
257+
return runPromise((svc) => svc.add(...args))
258+
}

0 commit comments

Comments
 (0)