mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 07:48:24 -04:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 802534891c | |||
| 3431dfb8b8 | |||
| fefe8b500a | |||
| 5b8573e804 | |||
| 40123cbe2d | |||
| 016c641860 | |||
| b41b0e3d2d | |||
| 3d3e50ebf0 | |||
| 0abc0d541a | |||
| 3f3989e694 | |||
| b4667d9b0d | |||
| 3c68d75776 | |||
| 23ed876835 | |||
| 0e86466f99 | |||
| 32548bcb4a | |||
| 86c54c5acc | |||
| ae584332b3 | |||
| 1694c5bfe1 | |||
| cdfbb26c00 | |||
| 610c036ef1 | |||
| 2638e2acfa | |||
| 49bbea5aed | |||
| 5fccdc9fc7 | |||
| 664b2c36e8 | |||
| 964474a1b1 | |||
| ab15fc1575 | |||
| 99d392a4fb | |||
| ae9a696607 | |||
| bd51a0d35b | |||
| 8c191b10c2 | |||
| cb6a9253fe | |||
| 23f97ac49d | |||
| 021ab50fb1 | |||
| 3fe906f517 | |||
| a8d8a35cd3 | |||
| 9b77430d0d | |||
| 1045a43603 | |||
| 26af77cd1e | |||
| 25a9de301a |
@@ -213,7 +213,7 @@ for (const item of targets) {
|
|||||||
},
|
},
|
||||||
files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {},
|
files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {},
|
||||||
entrypoints: [
|
entrypoints: [
|
||||||
"./src/index.ts",
|
"./src/temporary.ts",
|
||||||
parserWorker,
|
parserWorker,
|
||||||
workerPath,
|
workerPath,
|
||||||
rgPath,
|
rgPath,
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ export const layer = Layer.effect(
|
|||||||
const decode = Schema.decodeUnknownOption(Info)
|
const decode = Schema.decodeUnknownOption(Info)
|
||||||
|
|
||||||
const all = Effect.fn("Auth.all")(function* () {
|
const all = Effect.fn("Auth.all")(function* () {
|
||||||
|
if (process.env.OPENCODE_AUTH_CONTENT) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT)
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
|
const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
|
||||||
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import type { ZodType } from "zod"
|
import type { ZodType } from "zod"
|
||||||
|
|
||||||
export namespace BusEvent {
|
export type Definition = ReturnType<typeof define>
|
||||||
export type Definition = ReturnType<typeof define>
|
|
||||||
|
|
||||||
const registry = new Map<string, Definition>()
|
const registry = new Map<string, Definition>()
|
||||||
|
|
||||||
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
|
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
|
||||||
const result = {
|
const result = {
|
||||||
type,
|
type,
|
||||||
properties,
|
properties,
|
||||||
}
|
|
||||||
registry.set(type, result)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function payloads() {
|
|
||||||
return registry
|
|
||||||
.entries()
|
|
||||||
.map(([type, def]) => {
|
|
||||||
return z
|
|
||||||
.object({
|
|
||||||
type: z.literal(type),
|
|
||||||
properties: def.properties,
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: `Event.${def.type}`,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.toArray()
|
|
||||||
}
|
}
|
||||||
|
registry.set(type, result)
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function payloads() {
|
||||||
|
return registry
|
||||||
|
.entries()
|
||||||
|
.map(([type, def]) => {
|
||||||
|
return z
|
||||||
|
.object({
|
||||||
|
type: z.literal(type),
|
||||||
|
properties: def.properties,
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: `Event.${def.type}`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.toArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as BusEvent from "./bus-event"
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
import z from "zod"
|
|
||||||
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
|
|
||||||
import { EffectBridge } from "@/effect"
|
|
||||||
import { Log } from "../util"
|
|
||||||
import { BusEvent } from "./bus-event"
|
|
||||||
import { GlobalBus } from "./global"
|
|
||||||
import { InstanceState } from "@/effect"
|
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "bus" })
|
|
||||||
|
|
||||||
export const InstanceDisposed = BusEvent.define(
|
|
||||||
"server.instance.disposed",
|
|
||||||
z.object({
|
|
||||||
directory: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
|
|
||||||
type: D["type"]
|
|
||||||
properties: z.infer<D["properties"]>
|
|
||||||
}
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
wildcard: PubSub.PubSub<Payload>
|
|
||||||
typed: Map<string, PubSub.PubSub<Payload>>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly publish: <D extends BusEvent.Definition>(
|
|
||||||
def: D,
|
|
||||||
properties: z.output<D["properties"]>,
|
|
||||||
) => Effect.Effect<void>
|
|
||||||
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
|
|
||||||
readonly subscribeAll: () => Stream.Stream<Payload>
|
|
||||||
readonly subscribeCallback: <D extends BusEvent.Definition>(
|
|
||||||
def: D,
|
|
||||||
callback: (event: Payload<D>) => unknown,
|
|
||||||
) => Effect.Effect<() => void>
|
|
||||||
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const state = yield* InstanceState.make<State>(
|
|
||||||
Effect.fn("Bus.state")(function* (ctx) {
|
|
||||||
const wildcard = yield* PubSub.unbounded<Payload>()
|
|
||||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
// Publish InstanceDisposed before shutting down so subscribers see it
|
|
||||||
yield* PubSub.publish(wildcard, {
|
|
||||||
type: InstanceDisposed.type,
|
|
||||||
properties: { directory: ctx.directory },
|
|
||||||
})
|
|
||||||
yield* PubSub.shutdown(wildcard)
|
|
||||||
for (const ps of typed.values()) {
|
|
||||||
yield* PubSub.shutdown(ps)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return { wildcard, typed }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
let ps = state.typed.get(def.type)
|
|
||||||
if (!ps) {
|
|
||||||
ps = yield* PubSub.unbounded<Payload>()
|
|
||||||
state.typed.set(def.type, ps)
|
|
||||||
}
|
|
||||||
return ps as unknown as PubSub.PubSub<Payload<D>>
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
const payload: Payload = { type: def.type, properties }
|
|
||||||
log.info("publishing", { type: def.type })
|
|
||||||
|
|
||||||
const ps = s.typed.get(def.type)
|
|
||||||
if (ps) yield* PubSub.publish(ps, payload)
|
|
||||||
yield* PubSub.publish(s.wildcard, payload)
|
|
||||||
|
|
||||||
const dir = yield* InstanceState.directory
|
|
||||||
const context = yield* InstanceState.context
|
|
||||||
const workspace = yield* InstanceState.workspaceID
|
|
||||||
|
|
||||||
GlobalBus.emit("event", {
|
|
||||||
directory: dir,
|
|
||||||
project: context.project.id,
|
|
||||||
workspace,
|
|
||||||
payload,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
|
|
||||||
log.info("subscribing", { type: def.type })
|
|
||||||
return Stream.unwrap(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
const ps = yield* getOrCreate(s, def)
|
|
||||||
return Stream.fromPubSub(ps)
|
|
||||||
}),
|
|
||||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
|
|
||||||
}
|
|
||||||
|
|
||||||
function subscribeAll(): Stream.Stream<Payload> {
|
|
||||||
log.info("subscribing", { type: "*" })
|
|
||||||
return Stream.unwrap(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
return Stream.fromPubSub(s.wildcard)
|
|
||||||
}),
|
|
||||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
|
|
||||||
}
|
|
||||||
|
|
||||||
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
log.info("subscribing", { type })
|
|
||||||
const bridge = yield* EffectBridge.make()
|
|
||||||
const scope = yield* Scope.make()
|
|
||||||
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
|
|
||||||
|
|
||||||
yield* Scope.provide(scope)(
|
|
||||||
Stream.fromSubscription(subscription).pipe(
|
|
||||||
Stream.runForEach((msg) =>
|
|
||||||
Effect.tryPromise({
|
|
||||||
try: () => Promise.resolve().then(() => callback(msg)),
|
|
||||||
catch: (cause) => {
|
|
||||||
log.error("subscriber failed", { type, cause })
|
|
||||||
},
|
|
||||||
}).pipe(Effect.ignore),
|
|
||||||
),
|
|
||||||
Effect.forkScoped,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
log.info("unsubscribing", { type })
|
|
||||||
bridge.fork(Scope.close(scope, Exit.void))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
|
|
||||||
def: D,
|
|
||||||
callback: (event: Payload<D>) => unknown,
|
|
||||||
) {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
const ps = yield* getOrCreate(s, def)
|
|
||||||
return yield* on(ps, def.type, callback)
|
|
||||||
})
|
|
||||||
|
|
||||||
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
return yield* on(s.wildcard, "*", callback)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer
|
|
||||||
|
|
||||||
const { runPromise, runSync } = makeRuntime(Service, layer)
|
|
||||||
|
|
||||||
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
|
|
||||||
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
|
|
||||||
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
|
||||||
return runPromise((svc) => svc.publish(def, properties))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function subscribe<D extends BusEvent.Definition>(
|
|
||||||
def: D,
|
|
||||||
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
|
|
||||||
) {
|
|
||||||
return runSync((svc) => svc.subscribeCallback(def, callback))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function subscribeAll(callback: (event: any) => unknown) {
|
|
||||||
return runSync((svc) => svc.subscribeAllCallback(callback))
|
|
||||||
}
|
|
||||||
@@ -1 +1,193 @@
|
|||||||
export * as Bus from "./bus"
|
import z from "zod"
|
||||||
|
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
|
||||||
|
import { EffectBridge } from "@/effect"
|
||||||
|
import { Log } from "../util"
|
||||||
|
import { BusEvent } from "./bus-event"
|
||||||
|
import { GlobalBus } from "./global"
|
||||||
|
import { InstanceState } from "@/effect"
|
||||||
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "bus" })
|
||||||
|
|
||||||
|
export const InstanceDisposed = BusEvent.define(
|
||||||
|
"server.instance.disposed",
|
||||||
|
z.object({
|
||||||
|
directory: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
|
||||||
|
type: D["type"]
|
||||||
|
properties: z.infer<D["properties"]>
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
wildcard: PubSub.PubSub<Payload>
|
||||||
|
typed: Map<string, PubSub.PubSub<Payload>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly publish: <D extends BusEvent.Definition>(
|
||||||
|
def: D,
|
||||||
|
properties: z.output<D["properties"]>,
|
||||||
|
) => Effect.Effect<void>
|
||||||
|
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
|
||||||
|
readonly subscribeAll: () => Stream.Stream<Payload>
|
||||||
|
readonly subscribeCallback: <D extends BusEvent.Definition>(
|
||||||
|
def: D,
|
||||||
|
callback: (event: Payload<D>) => unknown,
|
||||||
|
) => Effect.Effect<() => void>
|
||||||
|
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("Bus.state")(function* (ctx) {
|
||||||
|
const wildcard = yield* PubSub.unbounded<Payload>()
|
||||||
|
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
||||||
|
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
// Publish InstanceDisposed before shutting down so subscribers see it
|
||||||
|
yield* PubSub.publish(wildcard, {
|
||||||
|
type: InstanceDisposed.type,
|
||||||
|
properties: { directory: ctx.directory },
|
||||||
|
})
|
||||||
|
yield* PubSub.shutdown(wildcard)
|
||||||
|
for (const ps of typed.values()) {
|
||||||
|
yield* PubSub.shutdown(ps)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { wildcard, typed }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
let ps = state.typed.get(def.type)
|
||||||
|
if (!ps) {
|
||||||
|
ps = yield* PubSub.unbounded<Payload>()
|
||||||
|
state.typed.set(def.type, ps)
|
||||||
|
}
|
||||||
|
return ps as unknown as PubSub.PubSub<Payload<D>>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
const payload: Payload = { type: def.type, properties }
|
||||||
|
log.info("publishing", { type: def.type })
|
||||||
|
|
||||||
|
const ps = s.typed.get(def.type)
|
||||||
|
if (ps) yield* PubSub.publish(ps, payload)
|
||||||
|
yield* PubSub.publish(s.wildcard, payload)
|
||||||
|
|
||||||
|
const dir = yield* InstanceState.directory
|
||||||
|
const context = yield* InstanceState.context
|
||||||
|
const workspace = yield* InstanceState.workspaceID
|
||||||
|
|
||||||
|
GlobalBus.emit("event", {
|
||||||
|
directory: dir,
|
||||||
|
project: context.project.id,
|
||||||
|
workspace,
|
||||||
|
payload,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
|
||||||
|
log.info("subscribing", { type: def.type })
|
||||||
|
return Stream.unwrap(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
const ps = yield* getOrCreate(s, def)
|
||||||
|
return Stream.fromPubSub(ps)
|
||||||
|
}),
|
||||||
|
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeAll(): Stream.Stream<Payload> {
|
||||||
|
log.info("subscribing", { type: "*" })
|
||||||
|
return Stream.unwrap(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
return Stream.fromPubSub(s.wildcard)
|
||||||
|
}),
|
||||||
|
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
|
||||||
|
}
|
||||||
|
|
||||||
|
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
log.info("subscribing", { type })
|
||||||
|
const bridge = yield* EffectBridge.make()
|
||||||
|
const scope = yield* Scope.make()
|
||||||
|
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
|
||||||
|
|
||||||
|
yield* Scope.provide(scope)(
|
||||||
|
Stream.fromSubscription(subscription).pipe(
|
||||||
|
Stream.runForEach((msg) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: () => Promise.resolve().then(() => callback(msg)),
|
||||||
|
catch: (cause) => {
|
||||||
|
log.error("subscriber failed", { type, cause })
|
||||||
|
},
|
||||||
|
}).pipe(Effect.ignore),
|
||||||
|
),
|
||||||
|
Effect.forkScoped,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
log.info("unsubscribing", { type })
|
||||||
|
bridge.fork(Scope.close(scope, Exit.void))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
|
||||||
|
def: D,
|
||||||
|
callback: (event: Payload<D>) => unknown,
|
||||||
|
) {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
const ps = yield* getOrCreate(s, def)
|
||||||
|
return yield* on(ps, def.type, callback)
|
||||||
|
})
|
||||||
|
|
||||||
|
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
return yield* on(s.wildcard, "*", callback)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer
|
||||||
|
|
||||||
|
const { runPromise, runSync } = makeRuntime(Service, layer)
|
||||||
|
|
||||||
|
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
|
||||||
|
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
|
||||||
|
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||||
|
return runPromise((svc) => svc.publish(def, properties))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe<D extends BusEvent.Definition>(
|
||||||
|
def: D,
|
||||||
|
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
|
||||||
|
) {
|
||||||
|
return runSync((svc) => svc.subscribeCallback(def, callback))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeAll(callback: (event: any) => unknown) {
|
||||||
|
return runSync((svc) => svc.subscribeAllCallback(callback))
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as Bus from "."
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { useEvent } from "@tui/context/event"
|
|||||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||||
import { StartupLoading } from "@tui/component/startup-loading"
|
import { StartupLoading } from "@tui/component/startup-loading"
|
||||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
import { SyncProvider, useSync } from "@tui/context/sync"
|
||||||
import { LocalProvider, useLocal } from "@tui/context/local"
|
import { LocalProvider, parseModel, useLocal } from "@tui/context/local"
|
||||||
import { DialogModel, useConnected } from "@tui/component/dialog-model"
|
import { DialogModel, useConnected } from "@tui/component/dialog-model"
|
||||||
import { DialogMcp } from "@tui/component/dialog-mcp"
|
import { DialogMcp } from "@tui/component/dialog-mcp"
|
||||||
import { DialogStatus } from "@tui/component/dialog-status"
|
import { DialogStatus } from "@tui/component/dialog-status"
|
||||||
@@ -49,10 +49,8 @@ import { DialogAlert } from "./ui/dialog-alert"
|
|||||||
import { DialogConfirm } from "./ui/dialog-confirm"
|
import { DialogConfirm } from "./ui/dialog-confirm"
|
||||||
import { ToastProvider, useToast } from "./ui/toast"
|
import { ToastProvider, useToast } from "./ui/toast"
|
||||||
import { ExitProvider, useExit } from "./context/exit"
|
import { ExitProvider, useExit } from "./context/exit"
|
||||||
import { Session as SessionApi } from "@/session"
|
|
||||||
import { TuiEvent } from "./event"
|
import { TuiEvent } from "./event"
|
||||||
import { KVProvider, useKV } from "./context/kv"
|
import { KVProvider, useKV } from "./context/kv"
|
||||||
import { Provider } from "@/provider"
|
|
||||||
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||||
@@ -304,7 +302,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
|
|
||||||
if (route.data.type === "session") {
|
if (route.data.type === "session") {
|
||||||
const session = sync.session.get(route.data.sessionID)
|
const session = sync.session.get(route.data.sessionID)
|
||||||
if (!session || SessionApi.isDefaultTitle(session.title)) {
|
if (!session) {
|
||||||
renderer.setTerminalTitle("OpenCode")
|
renderer.setTerminalTitle("OpenCode")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -324,7 +322,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
batch(() => {
|
batch(() => {
|
||||||
if (args.agent) local.agent.set(args.agent)
|
if (args.agent) local.agent.set(args.agent)
|
||||||
if (args.model) {
|
if (args.model) {
|
||||||
const { providerID, modelID } = Provider.parseModel(args.model)
|
const { providerID, modelID } = parseModel(args.model)
|
||||||
if (!providerID || !modelID)
|
if (!providerID || !modelID)
|
||||||
return toast.show({
|
return toast.show({
|
||||||
variant: "warning",
|
variant: "warning",
|
||||||
|
|||||||
@@ -41,9 +41,7 @@ export async function openWorkspaceSession(input: {
|
|||||||
workspaceID: input.workspaceID,
|
workspaceID: input.workspaceID,
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log("opening!")
|
|
||||||
while (true) {
|
while (true) {
|
||||||
console.log("creating")
|
|
||||||
const result = await client.session.create({ workspace: input.workspaceID }).catch((err) => {
|
const result = await client.session.create({ workspace: input.workspaceID }).catch((err) => {
|
||||||
log.error("workspace session create request failed", {
|
log.error("workspace session create request failed", {
|
||||||
workspaceID: input.workspaceID,
|
workspaceID: input.workspaceID,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
|||||||
const [store, setStore] = createStore<Record<string, any>>()
|
const [store, setStore] = createStore<Record<string, any>>()
|
||||||
const filePath = path.join(Global.Path.state, "kv.json")
|
const filePath = path.join(Global.Path.state, "kv.json")
|
||||||
|
|
||||||
Filesystem.readJson(filePath)
|
Filesystem.readJson<Record<string, any>>(filePath)
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
setStore(x)
|
setStore(x)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -492,7 +492,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
return last.time.completed ? "idle" : "working"
|
return last.time.completed ? "idle" : "working"
|
||||||
},
|
},
|
||||||
async sync(sessionID: string) {
|
async sync(sessionID: string) {
|
||||||
console.log("YO", sessionID, fullSyncedSessions.has(sessionID))
|
|
||||||
if (fullSyncedSessions.has(sessionID)) return
|
if (fullSyncedSessions.has(sessionID)) return
|
||||||
const [session, messages, todo, diff] = await Promise.all([
|
const [session, messages, todo, diff] = await Promise.all([
|
||||||
sdk.client.session.get({ sessionID }, { throwOnError: true }),
|
sdk.client.session.get({ sessionID }, { throwOnError: true }),
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { SessionID } from "@/session/schema"
|
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
export const TuiEvent = {
|
export const TuiEvent = {
|
||||||
@@ -42,7 +41,7 @@ export const TuiEvent = {
|
|||||||
SessionSelect: BusEvent.define(
|
SessionSelect: BusEvent.define(
|
||||||
"tui.session.select",
|
"tui.session.select",
|
||||||
z.object({
|
z.object({
|
||||||
sessionID: SessionID.zod.describe("Session ID to navigate to"),
|
sessionID: z.string().describe("Session ID to navigate to"),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function View(props: { api: TuiPluginApi }) {
|
|||||||
const [open, setOpen] = createSignal(true)
|
const [open, setOpen] = createSignal(true)
|
||||||
const theme = () => props.api.theme.current
|
const theme = () => props.api.theme.current
|
||||||
const list = createMemo(() => props.api.state.lsp())
|
const list = createMemo(() => props.api.state.lsp())
|
||||||
const off = createMemo(() => props.api.state.config.lsp === false)
|
const off = createMemo(() => props.api.state.config.lsp)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box>
|
<box>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// import "@opentui/solid/runtime-plugin-support"
|
import "@opentui/solid/runtime-plugin-support"
|
||||||
import {
|
import {
|
||||||
type TuiDispose,
|
type TuiDispose,
|
||||||
type TuiPlugin,
|
type TuiPlugin,
|
||||||
@@ -16,7 +16,6 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
|||||||
import { Log } from "@/util"
|
import { Log } from "@/util"
|
||||||
import { errorData, errorMessage } from "@/util/error"
|
import { errorData, errorMessage } from "@/util/error"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import {
|
import {
|
||||||
readPackageThemes,
|
readPackageThemes,
|
||||||
readPluginId,
|
readPluginId,
|
||||||
@@ -790,10 +789,7 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
|
|||||||
state.pending.delete(spec)
|
state.pending.delete(spec)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const ready = await Instance.provide({
|
const ready = await resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()).catch((error) => {
|
||||||
directory: state.directory,
|
|
||||||
fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
|
|
||||||
}).catch((error) => {
|
|
||||||
fail("failed to add tui plugin", { path: next, error })
|
fail("failed to add tui plugin", { path: next, error })
|
||||||
return [] as PluginLoad[]
|
return [] as PluginLoad[]
|
||||||
})
|
})
|
||||||
@@ -987,42 +983,37 @@ export namespace TuiPluginRuntime {
|
|||||||
}
|
}
|
||||||
runtime = next
|
runtime = next
|
||||||
try {
|
try {
|
||||||
await Instance.provide({
|
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
|
||||||
directory: cwd,
|
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
|
||||||
fn: async () => {
|
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
|
||||||
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
|
}
|
||||||
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
|
|
||||||
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of INTERNAL_TUI_PLUGINS) {
|
for (const item of INTERNAL_TUI_PLUGINS) {
|
||||||
log.info("loading internal tui plugin", { id: item.id })
|
log.info("loading internal tui plugin", { id: item.id })
|
||||||
const entry = loadInternalPlugin(item)
|
const entry = loadInternalPlugin(item)
|
||||||
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
|
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
|
||||||
addPluginEntry(next, {
|
addPluginEntry(next, {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
load: entry,
|
load: entry,
|
||||||
meta,
|
meta,
|
||||||
themes: {},
|
themes: {},
|
||||||
plugin: entry.module.tui,
|
plugin: entry.module.tui,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
|
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
|
||||||
await addExternalPluginEntries(next, ready)
|
await addExternalPluginEntries(next, ready)
|
||||||
|
|
||||||
applyInitialPluginEnabledState(next, config)
|
applyInitialPluginEnabledState(next, config)
|
||||||
for (const plugin of next.plugins) {
|
for (const plugin of next.plugins) {
|
||||||
if (!plugin.enabled) continue
|
if (!plugin.enabled) continue
|
||||||
// Keep plugin execution sequential for deterministic side effects:
|
// Keep plugin execution sequential for deterministic side effects:
|
||||||
// command registration order affects keybind/command precedence,
|
// command registration order affects keybind/command precedence,
|
||||||
// route registration is last-wins when ids collide,
|
// route registration is last-wins when ids collide,
|
||||||
// and hook chains rely on stable plugin ordering.
|
// and hook chains rely on stable plugin ordering.
|
||||||
await activatePluginEntry(next, plugin, false)
|
await activatePluginEntry(next, plugin, false)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail("failed to load tui plugins", { directory: cwd, error })
|
fail("failed to load tui plugins", { directory: cwd, error })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ export function FormatError(input: unknown) {
|
|||||||
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
|
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
|
||||||
if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
|
if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
|
||||||
const data = (input as ErrorLike).data
|
const data = (input as ErrorLike).data
|
||||||
const suggestions = data?.suggestions as string[] | undefined
|
const suggestions: string[] = Array.isArray(data?.suggestions) ? data.suggestions : []
|
||||||
return [
|
return [
|
||||||
`Model not found: ${data?.providerID}/${data?.modelID}`,
|
`Model not found: ${data?.providerID}/${data?.modelID}`,
|
||||||
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
|
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
|
||||||
`Try: \`opencode models\` to list available models`,
|
`Try: \`opencode models\` to list available models`,
|
||||||
`Or check your config (opencode.json) provider/model names`,
|
`Or check your config (opencode.json) provider/model names`,
|
||||||
].join("\n")
|
].join("\n")
|
||||||
@@ -64,10 +64,10 @@ export function FormatError(input: unknown) {
|
|||||||
const data = (input as ErrorLike).data
|
const data = (input as ErrorLike).data
|
||||||
const path = data?.path
|
const path = data?.path
|
||||||
const message = data?.message
|
const message = data?.message
|
||||||
const issues = data?.issues as Array<{ message: string; path: string[] }> | undefined
|
const issues: Array<{ message: string; path: string[] }> = Array.isArray(data?.issues) ? data.issues : []
|
||||||
return [
|
return [
|
||||||
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
|
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
|
||||||
...(issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
|
...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")),
|
||||||
].join("\n")
|
].join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
|
||||||
import { InstanceState } from "@/effect"
|
|
||||||
import { EffectBridge } from "@/effect"
|
|
||||||
import type { InstanceContext } from "@/project/instance"
|
|
||||||
import { SessionID, MessageID } from "@/session/schema"
|
|
||||||
import { Effect, Layer, Context } from "effect"
|
|
||||||
import z from "zod"
|
|
||||||
import { Config } from "../config"
|
|
||||||
import { MCP } from "../mcp"
|
|
||||||
import { Skill } from "../skill"
|
|
||||||
import PROMPT_INITIALIZE from "./template/initialize.txt"
|
|
||||||
import PROMPT_REVIEW from "./template/review.txt"
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
commands: Record<string, Info>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
Executed: BusEvent.define(
|
|
||||||
"command.executed",
|
|
||||||
z.object({
|
|
||||||
name: z.string(),
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
arguments: z.string(),
|
|
||||||
messageID: MessageID.zod,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Info = z
|
|
||||||
.object({
|
|
||||||
name: z.string(),
|
|
||||||
description: z.string().optional(),
|
|
||||||
agent: z.string().optional(),
|
|
||||||
model: z.string().optional(),
|
|
||||||
source: z.enum(["command", "mcp", "skill"]).optional(),
|
|
||||||
// workaround for zod not supporting async functions natively so we use getters
|
|
||||||
// https://zod.dev/v4/changelog?id=zfunction
|
|
||||||
template: z.promise(z.string()).or(z.string()),
|
|
||||||
subtask: z.boolean().optional(),
|
|
||||||
hints: z.array(z.string()),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "Command",
|
|
||||||
})
|
|
||||||
|
|
||||||
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
|
|
||||||
export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
|
|
||||||
|
|
||||||
export function hints(template: string) {
|
|
||||||
const result: string[] = []
|
|
||||||
const numbered = template.match(/\$\d+/g)
|
|
||||||
if (numbered) {
|
|
||||||
for (const match of [...new Set(numbered)].sort()) result.push(match)
|
|
||||||
}
|
|
||||||
if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Default = {
|
|
||||||
INIT: "init",
|
|
||||||
REVIEW: "review",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
|
||||||
readonly list: () => Effect.Effect<Info[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const config = yield* Config.Service
|
|
||||||
const mcp = yield* MCP.Service
|
|
||||||
const skill = yield* Skill.Service
|
|
||||||
|
|
||||||
const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) {
|
|
||||||
const cfg = yield* config.get()
|
|
||||||
const bridge = yield* EffectBridge.make()
|
|
||||||
const commands: Record<string, Info> = {}
|
|
||||||
|
|
||||||
commands[Default.INIT] = {
|
|
||||||
name: Default.INIT,
|
|
||||||
description: "guided AGENTS.md setup",
|
|
||||||
source: "command",
|
|
||||||
get template() {
|
|
||||||
return PROMPT_INITIALIZE.replace("${path}", ctx.worktree)
|
|
||||||
},
|
|
||||||
hints: hints(PROMPT_INITIALIZE),
|
|
||||||
}
|
|
||||||
commands[Default.REVIEW] = {
|
|
||||||
name: Default.REVIEW,
|
|
||||||
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
|
||||||
source: "command",
|
|
||||||
get template() {
|
|
||||||
return PROMPT_REVIEW.replace("${path}", ctx.worktree)
|
|
||||||
},
|
|
||||||
subtask: true,
|
|
||||||
hints: hints(PROMPT_REVIEW),
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, command] of Object.entries(cfg.command ?? {})) {
|
|
||||||
commands[name] = {
|
|
||||||
name,
|
|
||||||
agent: command.agent,
|
|
||||||
model: command.model,
|
|
||||||
description: command.description,
|
|
||||||
source: "command",
|
|
||||||
get template() {
|
|
||||||
return command.template
|
|
||||||
},
|
|
||||||
subtask: command.subtask,
|
|
||||||
hints: hints(command.template),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, prompt] of Object.entries(yield* mcp.prompts())) {
|
|
||||||
commands[name] = {
|
|
||||||
name,
|
|
||||||
source: "mcp",
|
|
||||||
description: prompt.description,
|
|
||||||
get template() {
|
|
||||||
return bridge.promise(
|
|
||||||
mcp
|
|
||||||
.getPrompt(
|
|
||||||
prompt.client,
|
|
||||||
prompt.name,
|
|
||||||
prompt.arguments
|
|
||||||
? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`]))
|
|
||||||
: {},
|
|
||||||
)
|
|
||||||
.pipe(
|
|
||||||
Effect.map(
|
|
||||||
(template) =>
|
|
||||||
template?.messages
|
|
||||||
.map((message) => (message.content.type === "text" ? message.content.text : ""))
|
|
||||||
.join("\n") || "",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of yield* skill.all()) {
|
|
||||||
if (commands[item.name]) continue
|
|
||||||
commands[item.name] = {
|
|
||||||
name: item.name,
|
|
||||||
description: item.description,
|
|
||||||
source: "skill",
|
|
||||||
get template() {
|
|
||||||
return item.content
|
|
||||||
},
|
|
||||||
hints: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
commands,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>((ctx) => init(ctx))
|
|
||||||
|
|
||||||
const get = Effect.fn("Command.get")(function* (name: string) {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
return s.commands[name]
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("Command.list")(function* () {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
return Object.values(s.commands)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ get, list })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
|
||||||
Layer.provide(Config.defaultLayer),
|
|
||||||
Layer.provide(MCP.defaultLayer),
|
|
||||||
Layer.provide(Skill.defaultLayer),
|
|
||||||
)
|
|
||||||
@@ -1 +1,188 @@
|
|||||||
export * as Command from "./command"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { InstanceState } from "@/effect"
|
||||||
|
import { EffectBridge } from "@/effect"
|
||||||
|
import type { InstanceContext } from "@/project/instance"
|
||||||
|
import { SessionID, MessageID } from "@/session/schema"
|
||||||
|
import { Effect, Layer, Context } from "effect"
|
||||||
|
import z from "zod"
|
||||||
|
import { Config } from "../config"
|
||||||
|
import { MCP } from "../mcp"
|
||||||
|
import { Skill } from "../skill"
|
||||||
|
import PROMPT_INITIALIZE from "./template/initialize.txt"
|
||||||
|
import PROMPT_REVIEW from "./template/review.txt"
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
commands: Record<string, Info>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Executed: BusEvent.define(
|
||||||
|
"command.executed",
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
arguments: z.string(),
|
||||||
|
messageID: MessageID.zod,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Info = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
agent: z.string().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
source: z.enum(["command", "mcp", "skill"]).optional(),
|
||||||
|
// workaround for zod not supporting async functions natively so we use getters
|
||||||
|
// https://zod.dev/v4/changelog?id=zfunction
|
||||||
|
template: z.promise(z.string()).or(z.string()),
|
||||||
|
subtask: z.boolean().optional(),
|
||||||
|
hints: z.array(z.string()),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "Command",
|
||||||
|
})
|
||||||
|
|
||||||
|
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
|
||||||
|
export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
|
||||||
|
|
||||||
|
export function hints(template: string) {
|
||||||
|
const result: string[] = []
|
||||||
|
const numbered = template.match(/\$\d+/g)
|
||||||
|
if (numbered) {
|
||||||
|
for (const match of [...new Set(numbered)].sort()) result.push(match)
|
||||||
|
}
|
||||||
|
if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Default = {
|
||||||
|
INIT: "init",
|
||||||
|
REVIEW: "review",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||||
|
readonly list: () => Effect.Effect<Info[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const mcp = yield* MCP.Service
|
||||||
|
const skill = yield* Skill.Service
|
||||||
|
|
||||||
|
const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) {
|
||||||
|
const cfg = yield* config.get()
|
||||||
|
const bridge = yield* EffectBridge.make()
|
||||||
|
const commands: Record<string, Info> = {}
|
||||||
|
|
||||||
|
commands[Default.INIT] = {
|
||||||
|
name: Default.INIT,
|
||||||
|
description: "guided AGENTS.md setup",
|
||||||
|
source: "command",
|
||||||
|
get template() {
|
||||||
|
return PROMPT_INITIALIZE.replace("${path}", ctx.worktree)
|
||||||
|
},
|
||||||
|
hints: hints(PROMPT_INITIALIZE),
|
||||||
|
}
|
||||||
|
commands[Default.REVIEW] = {
|
||||||
|
name: Default.REVIEW,
|
||||||
|
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
||||||
|
source: "command",
|
||||||
|
get template() {
|
||||||
|
return PROMPT_REVIEW.replace("${path}", ctx.worktree)
|
||||||
|
},
|
||||||
|
subtask: true,
|
||||||
|
hints: hints(PROMPT_REVIEW),
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, command] of Object.entries(cfg.command ?? {})) {
|
||||||
|
commands[name] = {
|
||||||
|
name,
|
||||||
|
agent: command.agent,
|
||||||
|
model: command.model,
|
||||||
|
description: command.description,
|
||||||
|
source: "command",
|
||||||
|
get template() {
|
||||||
|
return command.template
|
||||||
|
},
|
||||||
|
subtask: command.subtask,
|
||||||
|
hints: hints(command.template),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, prompt] of Object.entries(yield* mcp.prompts())) {
|
||||||
|
commands[name] = {
|
||||||
|
name,
|
||||||
|
source: "mcp",
|
||||||
|
description: prompt.description,
|
||||||
|
get template() {
|
||||||
|
return bridge.promise(
|
||||||
|
mcp
|
||||||
|
.getPrompt(
|
||||||
|
prompt.client,
|
||||||
|
prompt.name,
|
||||||
|
prompt.arguments
|
||||||
|
? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`]))
|
||||||
|
: {},
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
Effect.map(
|
||||||
|
(template) =>
|
||||||
|
template?.messages
|
||||||
|
.map((message) => (message.content.type === "text" ? message.content.text : ""))
|
||||||
|
.join("\n") || "",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of yield* skill.all()) {
|
||||||
|
if (commands[item.name]) continue
|
||||||
|
commands[item.name] = {
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
source: "skill",
|
||||||
|
get template() {
|
||||||
|
return item.content
|
||||||
|
},
|
||||||
|
hints: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
commands,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const state = yield* InstanceState.make<State>((ctx) => init(ctx))
|
||||||
|
|
||||||
|
const get = Effect.fn("Command.get")(function* (name: string) {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
return s.commands[name]
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("Command.list")(function* () {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
return Object.values(s.commands)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ get, list })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(MCP.defaultLayer),
|
||||||
|
Layer.provide(Skill.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
|
export * as Command from "."
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ export const Info = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
lsp: z
|
lsp: z
|
||||||
.union([
|
.union([
|
||||||
z.literal(false),
|
z.literal(true),
|
||||||
z.record(
|
z.record(
|
||||||
z.string(),
|
z.string(),
|
||||||
z.union([
|
z.union([
|
||||||
|
|||||||
@@ -4,81 +4,81 @@ import { pathToFileURL } from "url"
|
|||||||
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
|
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
||||||
export namespace ConfigPlugin {
|
const Options = z.record(z.string(), z.unknown())
|
||||||
const Options = z.record(z.string(), z.unknown())
|
export type Options = z.infer<typeof Options>
|
||||||
export type Options = z.infer<typeof Options>
|
|
||||||
|
|
||||||
// Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options.
|
// Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options.
|
||||||
// It answers "what should we load?" but says nothing about where that value came from.
|
// It answers "what should we load?" but says nothing about where that value came from.
|
||||||
export const Spec = z.union([z.string(), z.tuple([z.string(), Options])])
|
export const Spec = z.union([z.string(), z.tuple([z.string(), Options])])
|
||||||
export type Spec = z.infer<typeof Spec>
|
export type Spec = z.infer<typeof Spec>
|
||||||
|
|
||||||
export type Scope = "global" | "local"
|
export type Scope = "global" | "local"
|
||||||
|
|
||||||
// Origin keeps the original config provenance attached to a spec.
|
// Origin keeps the original config provenance attached to a spec.
|
||||||
// After multiple config files are merged, callers still need to know which file declared the plugin
|
// After multiple config files are merged, callers still need to know which file declared the plugin
|
||||||
// and whether it should behave like a global or project-local plugin.
|
// and whether it should behave like a global or project-local plugin.
|
||||||
export type Origin = {
|
export type Origin = {
|
||||||
spec: Spec
|
spec: Spec
|
||||||
source: string
|
source: string
|
||||||
scope: Scope
|
scope: Scope
|
||||||
}
|
|
||||||
|
|
||||||
export async function load(dir: string) {
|
|
||||||
const plugins: ConfigPlugin.Spec[] = []
|
|
||||||
|
|
||||||
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
|
|
||||||
cwd: dir,
|
|
||||||
absolute: true,
|
|
||||||
dot: true,
|
|
||||||
symlink: true,
|
|
||||||
})) {
|
|
||||||
plugins.push(pathToFileURL(item).href)
|
|
||||||
}
|
|
||||||
return plugins
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pluginSpecifier(plugin: Spec): string {
|
|
||||||
return Array.isArray(plugin) ? plugin[0] : plugin
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pluginOptions(plugin: Spec): Options | undefined {
|
|
||||||
return Array.isArray(plugin) ? plugin[1] : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// Path-like specs are resolved relative to the config file that declared them so merges later on do not
|
|
||||||
// accidentally reinterpret `./plugin.ts` relative to some other directory.
|
|
||||||
export async function resolvePluginSpec(plugin: Spec, configFilepath: string): Promise<Spec> {
|
|
||||||
const spec = pluginSpecifier(plugin)
|
|
||||||
if (!isPathPluginSpec(spec)) return plugin
|
|
||||||
|
|
||||||
const base = path.dirname(configFilepath)
|
|
||||||
const file = (() => {
|
|
||||||
if (spec.startsWith("file://")) return spec
|
|
||||||
if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return pathToFileURL(spec).href
|
|
||||||
return pathToFileURL(path.resolve(base, spec)).href
|
|
||||||
})()
|
|
||||||
|
|
||||||
const resolved = await resolvePathPluginTarget(file).catch(() => file)
|
|
||||||
|
|
||||||
if (Array.isArray(plugin)) return [resolved, plugin[1]]
|
|
||||||
return resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dedupe on the load identity (package name for npm specs, exact file URL for local specs), but keep the
|
|
||||||
// full Origin so downstream code still knows which config file won and where follow-up writes should go.
|
|
||||||
export function deduplicatePluginOrigins(plugins: Origin[]): Origin[] {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const list: Origin[] = []
|
|
||||||
|
|
||||||
for (const plugin of plugins.toReversed()) {
|
|
||||||
const spec = pluginSpecifier(plugin.spec)
|
|
||||||
const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
|
|
||||||
if (seen.has(name)) continue
|
|
||||||
seen.add(name)
|
|
||||||
list.push(plugin)
|
|
||||||
}
|
|
||||||
|
|
||||||
return list.toReversed()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function load(dir: string) {
|
||||||
|
const plugins: Spec[] = []
|
||||||
|
|
||||||
|
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
|
||||||
|
cwd: dir,
|
||||||
|
absolute: true,
|
||||||
|
dot: true,
|
||||||
|
symlink: true,
|
||||||
|
})) {
|
||||||
|
plugins.push(pathToFileURL(item).href)
|
||||||
|
}
|
||||||
|
return plugins
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pluginSpecifier(plugin: Spec): string {
|
||||||
|
return Array.isArray(plugin) ? plugin[0] : plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pluginOptions(plugin: Spec): Options | undefined {
|
||||||
|
return Array.isArray(plugin) ? plugin[1] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path-like specs are resolved relative to the config file that declared them so merges later on do not
|
||||||
|
// accidentally reinterpret `./plugin.ts` relative to some other directory.
|
||||||
|
export async function resolvePluginSpec(plugin: Spec, configFilepath: string): Promise<Spec> {
|
||||||
|
const spec = pluginSpecifier(plugin)
|
||||||
|
if (!isPathPluginSpec(spec)) return plugin
|
||||||
|
|
||||||
|
const base = path.dirname(configFilepath)
|
||||||
|
const file = (() => {
|
||||||
|
if (spec.startsWith("file://")) return spec
|
||||||
|
if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return pathToFileURL(spec).href
|
||||||
|
return pathToFileURL(path.resolve(base, spec)).href
|
||||||
|
})()
|
||||||
|
|
||||||
|
const resolved = await resolvePathPluginTarget(file).catch(() => file)
|
||||||
|
|
||||||
|
if (Array.isArray(plugin)) return [resolved, plugin[1]]
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe on the load identity (package name for npm specs, exact file URL for local specs), but keep the
|
||||||
|
// full Origin so downstream code still knows which config file won and where follow-up writes should go.
|
||||||
|
export function deduplicatePluginOrigins(plugins: Origin[]): Origin[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const list: Origin[] = []
|
||||||
|
|
||||||
|
for (const plugin of plugins.toReversed()) {
|
||||||
|
const spec = pluginSpecifier(plugin.spec)
|
||||||
|
const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
|
||||||
|
if (seen.has(name)) continue
|
||||||
|
seen.add(name)
|
||||||
|
list.push(plugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.toReversed()
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as ConfigPlugin from "./plugin"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import os from "os"
|
|||||||
import { Filesystem } from "@/util"
|
import { Filesystem } from "@/util"
|
||||||
import { InvalidError } from "./error"
|
import { InvalidError } from "./error"
|
||||||
|
|
||||||
type ParseSource =
|
export type ParseSource =
|
||||||
| {
|
| {
|
||||||
type: "path"
|
type: "path"
|
||||||
path: string
|
path: string
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export type WorkspaceAdaptor = {
|
|||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
||||||
create(info: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
|
create(info: WorkspaceInfo, env: Record<string, string>, from?: WorkspaceInfo): Promise<void>
|
||||||
remove(info: WorkspaceInfo): Promise<void>
|
remove(info: WorkspaceInfo): Promise<void>
|
||||||
target(info: WorkspaceInfo): Target | Promise<Target>
|
target(info: WorkspaceInfo): Target | Promise<Target>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Database, asc, eq, inArray } from "@/storage"
|
|||||||
import { Project } from "@/project"
|
import { Project } from "@/project"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
|
import { Auth } from "@/auth"
|
||||||
import { SyncEvent } from "@/sync"
|
import { SyncEvent } from "@/sync"
|
||||||
import { EventTable } from "@/sync/event.sql"
|
import { EventTable } from "@/sync/event.sql"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
@@ -112,7 +113,12 @@ export namespace Workspace {
|
|||||||
.run()
|
.run()
|
||||||
})
|
})
|
||||||
|
|
||||||
await adaptor.create(config)
|
const env = {
|
||||||
|
OPENCODE_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))),
|
||||||
|
OPENCODE_WORKSPACE_ID: config.id,
|
||||||
|
OPENCODE_EXPERIMENTAL_WORKSPACES: "true",
|
||||||
|
}
|
||||||
|
await adaptor.create(config, env)
|
||||||
|
|
||||||
startSync(info)
|
startSync(info)
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import { File } from "@/file"
|
|||||||
import { Vcs } from "@/project"
|
import { Vcs } from "@/project"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
|
import { Config } from "@/config"
|
||||||
import * as Observability from "./observability"
|
import * as Observability from "./observability"
|
||||||
|
|
||||||
export const BootstrapLayer = Layer.mergeAll(
|
export const BootstrapLayer = Layer.mergeAll(
|
||||||
|
Config.defaultLayer,
|
||||||
Plugin.defaultLayer,
|
Plugin.defaultLayer,
|
||||||
ShareNext.defaultLayer,
|
ShareNext.defaultLayer,
|
||||||
Format.defaultLayer,
|
Format.defaultLayer,
|
||||||
|
|||||||
Vendored
-35
@@ -1,35 +0,0 @@
|
|||||||
import { Context, Effect, Layer } from "effect"
|
|
||||||
import { InstanceState } from "@/effect"
|
|
||||||
|
|
||||||
type State = Record<string, string | undefined>
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly get: (key: string) => Effect.Effect<string | undefined>
|
|
||||||
readonly all: () => Effect.Effect<State>
|
|
||||||
readonly set: (key: string, value: string) => Effect.Effect<void>
|
|
||||||
readonly remove: (key: string) => Effect.Effect<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Env") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const state = yield* InstanceState.make<State>(Effect.fn("Env.state")(() => Effect.succeed({ ...process.env })))
|
|
||||||
|
|
||||||
const get = Effect.fn("Env.get")((key: string) => InstanceState.use(state, (env) => env[key]))
|
|
||||||
const all = Effect.fn("Env.all")(() => InstanceState.get(state))
|
|
||||||
const set = Effect.fn("Env.set")(function* (key: string, value: string) {
|
|
||||||
const env = yield* InstanceState.get(state)
|
|
||||||
env[key] = value
|
|
||||||
})
|
|
||||||
const remove = Effect.fn("Env.remove")(function* (key: string) {
|
|
||||||
const env = yield* InstanceState.get(state)
|
|
||||||
delete env[key]
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ get, all, set, remove })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer
|
|
||||||
Vendored
+37
-1
@@ -1 +1,37 @@
|
|||||||
export * as Env from "./env"
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
import { InstanceState } from "@/effect"
|
||||||
|
|
||||||
|
type State = Record<string, string | undefined>
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly get: (key: string) => Effect.Effect<string | undefined>
|
||||||
|
readonly all: () => Effect.Effect<State>
|
||||||
|
readonly set: (key: string, value: string) => Effect.Effect<void>
|
||||||
|
readonly remove: (key: string) => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Env") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make<State>(Effect.fn("Env.state")(() => Effect.succeed({ ...process.env })))
|
||||||
|
|
||||||
|
const get = Effect.fn("Env.get")((key: string) => InstanceState.use(state, (env) => env[key]))
|
||||||
|
const all = Effect.fn("Env.all")(() => InstanceState.get(state))
|
||||||
|
const set = Effect.fn("Env.set")(function* (key: string, value: string) {
|
||||||
|
const env = yield* InstanceState.get(state)
|
||||||
|
env[key] = value
|
||||||
|
})
|
||||||
|
const remove = Effect.fn("Env.remove")(function* (key: string) {
|
||||||
|
const env = yield* InstanceState.get(state)
|
||||||
|
delete env[key]
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ get, all, set, remove })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer
|
||||||
|
|
||||||
|
export * as Env from "."
|
||||||
|
|||||||
@@ -1,654 +0,0 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
|
||||||
import { InstanceState } from "@/effect"
|
|
||||||
|
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
|
||||||
import { Git } from "@/git"
|
|
||||||
import { Effect, Layer, Context, Scope } from "effect"
|
|
||||||
import * as Stream from "effect/Stream"
|
|
||||||
import { formatPatch, structuredPatch } from "diff"
|
|
||||||
import fuzzysort from "fuzzysort"
|
|
||||||
import ignore from "ignore"
|
|
||||||
import path from "path"
|
|
||||||
import z from "zod"
|
|
||||||
import { Global } from "../global"
|
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { Log } from "../util"
|
|
||||||
import { Protected } from "./protected"
|
|
||||||
import { Ripgrep } from "./ripgrep"
|
|
||||||
|
|
||||||
export const Info = z
|
|
||||||
.object({
|
|
||||||
path: z.string(),
|
|
||||||
added: z.number().int(),
|
|
||||||
removed: z.number().int(),
|
|
||||||
status: z.enum(["added", "deleted", "modified"]),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "File",
|
|
||||||
})
|
|
||||||
|
|
||||||
export type Info = z.infer<typeof Info>
|
|
||||||
|
|
||||||
export const Node = z
|
|
||||||
.object({
|
|
||||||
name: z.string(),
|
|
||||||
path: z.string(),
|
|
||||||
absolute: z.string(),
|
|
||||||
type: z.enum(["file", "directory"]),
|
|
||||||
ignored: z.boolean(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "FileNode",
|
|
||||||
})
|
|
||||||
export type Node = z.infer<typeof Node>
|
|
||||||
|
|
||||||
export const Content = z
|
|
||||||
.object({
|
|
||||||
type: z.enum(["text", "binary"]),
|
|
||||||
content: z.string(),
|
|
||||||
diff: z.string().optional(),
|
|
||||||
patch: z
|
|
||||||
.object({
|
|
||||||
oldFileName: z.string(),
|
|
||||||
newFileName: z.string(),
|
|
||||||
oldHeader: z.string().optional(),
|
|
||||||
newHeader: z.string().optional(),
|
|
||||||
hunks: z.array(
|
|
||||||
z.object({
|
|
||||||
oldStart: z.number(),
|
|
||||||
oldLines: z.number(),
|
|
||||||
newStart: z.number(),
|
|
||||||
newLines: z.number(),
|
|
||||||
lines: z.array(z.string()),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
index: z.string().optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
encoding: z.literal("base64").optional(),
|
|
||||||
mimeType: z.string().optional(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "FileContent",
|
|
||||||
})
|
|
||||||
export type Content = z.infer<typeof Content>
|
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
Edited: BusEvent.define(
|
|
||||||
"file.edited",
|
|
||||||
z.object({
|
|
||||||
file: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
const log = Log.create({ service: "file" })
|
|
||||||
|
|
||||||
const binary = new Set([
|
|
||||||
"exe",
|
|
||||||
"dll",
|
|
||||||
"pdb",
|
|
||||||
"bin",
|
|
||||||
"so",
|
|
||||||
"dylib",
|
|
||||||
"o",
|
|
||||||
"a",
|
|
||||||
"lib",
|
|
||||||
"wav",
|
|
||||||
"mp3",
|
|
||||||
"ogg",
|
|
||||||
"oga",
|
|
||||||
"ogv",
|
|
||||||
"ogx",
|
|
||||||
"flac",
|
|
||||||
"aac",
|
|
||||||
"wma",
|
|
||||||
"m4a",
|
|
||||||
"weba",
|
|
||||||
"mp4",
|
|
||||||
"avi",
|
|
||||||
"mov",
|
|
||||||
"wmv",
|
|
||||||
"flv",
|
|
||||||
"webm",
|
|
||||||
"mkv",
|
|
||||||
"zip",
|
|
||||||
"tar",
|
|
||||||
"gz",
|
|
||||||
"gzip",
|
|
||||||
"bz",
|
|
||||||
"bz2",
|
|
||||||
"bzip",
|
|
||||||
"bzip2",
|
|
||||||
"7z",
|
|
||||||
"rar",
|
|
||||||
"xz",
|
|
||||||
"lz",
|
|
||||||
"z",
|
|
||||||
"pdf",
|
|
||||||
"doc",
|
|
||||||
"docx",
|
|
||||||
"ppt",
|
|
||||||
"pptx",
|
|
||||||
"xls",
|
|
||||||
"xlsx",
|
|
||||||
"dmg",
|
|
||||||
"iso",
|
|
||||||
"img",
|
|
||||||
"vmdk",
|
|
||||||
"ttf",
|
|
||||||
"otf",
|
|
||||||
"woff",
|
|
||||||
"woff2",
|
|
||||||
"eot",
|
|
||||||
"sqlite",
|
|
||||||
"db",
|
|
||||||
"mdb",
|
|
||||||
"apk",
|
|
||||||
"ipa",
|
|
||||||
"aab",
|
|
||||||
"xapk",
|
|
||||||
"app",
|
|
||||||
"pkg",
|
|
||||||
"deb",
|
|
||||||
"rpm",
|
|
||||||
"snap",
|
|
||||||
"flatpak",
|
|
||||||
"appimage",
|
|
||||||
"msi",
|
|
||||||
"msp",
|
|
||||||
"jar",
|
|
||||||
"war",
|
|
||||||
"ear",
|
|
||||||
"class",
|
|
||||||
"kotlin_module",
|
|
||||||
"dex",
|
|
||||||
"vdex",
|
|
||||||
"odex",
|
|
||||||
"oat",
|
|
||||||
"art",
|
|
||||||
"wasm",
|
|
||||||
"wat",
|
|
||||||
"bc",
|
|
||||||
"ll",
|
|
||||||
"s",
|
|
||||||
"ko",
|
|
||||||
"sys",
|
|
||||||
"drv",
|
|
||||||
"efi",
|
|
||||||
"rom",
|
|
||||||
"com",
|
|
||||||
])
|
|
||||||
|
|
||||||
const image = new Set([
|
|
||||||
"png",
|
|
||||||
"jpg",
|
|
||||||
"jpeg",
|
|
||||||
"gif",
|
|
||||||
"bmp",
|
|
||||||
"webp",
|
|
||||||
"ico",
|
|
||||||
"tif",
|
|
||||||
"tiff",
|
|
||||||
"svg",
|
|
||||||
"svgz",
|
|
||||||
"avif",
|
|
||||||
"apng",
|
|
||||||
"jxl",
|
|
||||||
"heic",
|
|
||||||
"heif",
|
|
||||||
"raw",
|
|
||||||
"cr2",
|
|
||||||
"nef",
|
|
||||||
"arw",
|
|
||||||
"dng",
|
|
||||||
"orf",
|
|
||||||
"raf",
|
|
||||||
"pef",
|
|
||||||
"x3f",
|
|
||||||
])
|
|
||||||
|
|
||||||
const text = new Set([
|
|
||||||
"ts",
|
|
||||||
"tsx",
|
|
||||||
"mts",
|
|
||||||
"cts",
|
|
||||||
"mtsx",
|
|
||||||
"ctsx",
|
|
||||||
"js",
|
|
||||||
"jsx",
|
|
||||||
"mjs",
|
|
||||||
"cjs",
|
|
||||||
"sh",
|
|
||||||
"bash",
|
|
||||||
"zsh",
|
|
||||||
"fish",
|
|
||||||
"ps1",
|
|
||||||
"psm1",
|
|
||||||
"cmd",
|
|
||||||
"bat",
|
|
||||||
"json",
|
|
||||||
"jsonc",
|
|
||||||
"json5",
|
|
||||||
"yaml",
|
|
||||||
"yml",
|
|
||||||
"toml",
|
|
||||||
"md",
|
|
||||||
"mdx",
|
|
||||||
"txt",
|
|
||||||
"xml",
|
|
||||||
"html",
|
|
||||||
"htm",
|
|
||||||
"css",
|
|
||||||
"scss",
|
|
||||||
"sass",
|
|
||||||
"less",
|
|
||||||
"graphql",
|
|
||||||
"gql",
|
|
||||||
"sql",
|
|
||||||
"ini",
|
|
||||||
"cfg",
|
|
||||||
"conf",
|
|
||||||
"env",
|
|
||||||
])
|
|
||||||
|
|
||||||
const textName = new Set([
|
|
||||||
"dockerfile",
|
|
||||||
"makefile",
|
|
||||||
".gitignore",
|
|
||||||
".gitattributes",
|
|
||||||
".editorconfig",
|
|
||||||
".npmrc",
|
|
||||||
".nvmrc",
|
|
||||||
".prettierrc",
|
|
||||||
".eslintrc",
|
|
||||||
])
|
|
||||||
|
|
||||||
const mime: Record<string, string> = {
|
|
||||||
png: "image/png",
|
|
||||||
jpg: "image/jpeg",
|
|
||||||
jpeg: "image/jpeg",
|
|
||||||
gif: "image/gif",
|
|
||||||
bmp: "image/bmp",
|
|
||||||
webp: "image/webp",
|
|
||||||
ico: "image/x-icon",
|
|
||||||
tif: "image/tiff",
|
|
||||||
tiff: "image/tiff",
|
|
||||||
svg: "image/svg+xml",
|
|
||||||
svgz: "image/svg+xml",
|
|
||||||
avif: "image/avif",
|
|
||||||
apng: "image/apng",
|
|
||||||
jxl: "image/jxl",
|
|
||||||
heic: "image/heic",
|
|
||||||
heif: "image/heif",
|
|
||||||
}
|
|
||||||
|
|
||||||
type Entry = { files: string[]; dirs: string[] }
|
|
||||||
|
|
||||||
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
|
|
||||||
const name = (file: string) => path.basename(file).toLowerCase()
|
|
||||||
const isImageByExtension = (file: string) => image.has(ext(file))
|
|
||||||
const isTextByExtension = (file: string) => text.has(ext(file))
|
|
||||||
const isTextByName = (file: string) => textName.has(name(file))
|
|
||||||
const isBinaryByExtension = (file: string) => binary.has(ext(file))
|
|
||||||
const isImage = (mimeType: string) => mimeType.startsWith("image/")
|
|
||||||
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
|
|
||||||
|
|
||||||
function shouldEncode(mimeType: string) {
|
|
||||||
const type = mimeType.toLowerCase()
|
|
||||||
log.debug("shouldEncode", { type })
|
|
||||||
if (!type) return false
|
|
||||||
if (type.startsWith("text/")) return false
|
|
||||||
if (type.includes("charset=")) return false
|
|
||||||
const top = type.split("/", 2)[0]
|
|
||||||
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hidden = (item: string) => {
|
|
||||||
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
|
|
||||||
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortHiddenLast = (items: string[], prefer: boolean) => {
|
|
||||||
if (prefer) return items
|
|
||||||
const visible: string[] = []
|
|
||||||
const hiddenItems: string[] = []
|
|
||||||
for (const item of items) {
|
|
||||||
if (hidden(item)) hiddenItems.push(item)
|
|
||||||
else visible.push(item)
|
|
||||||
}
|
|
||||||
return [...visible, ...hiddenItems]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
cache: Entry
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly init: () => Effect.Effect<void>
|
|
||||||
readonly status: () => Effect.Effect<Info[]>
|
|
||||||
readonly read: (file: string) => Effect.Effect<Content>
|
|
||||||
readonly list: (dir?: string) => Effect.Effect<Node[]>
|
|
||||||
readonly search: (input: {
|
|
||||||
query: string
|
|
||||||
limit?: number
|
|
||||||
dirs?: boolean
|
|
||||||
type?: "file" | "directory"
|
|
||||||
}) => Effect.Effect<string[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/File") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const appFs = yield* AppFileSystem.Service
|
|
||||||
const rg = yield* Ripgrep.Service
|
|
||||||
const git = yield* Git.Service
|
|
||||||
const scope = yield* Scope.Scope
|
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
|
||||||
Effect.fn("File.state")(() =>
|
|
||||||
Effect.succeed({
|
|
||||||
cache: { files: [], dirs: [] } as Entry,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const scan = Effect.fn("File.scan")(function* () {
|
|
||||||
if (Instance.directory === path.parse(Instance.directory).root) return
|
|
||||||
const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
|
|
||||||
const next: Entry = { files: [], dirs: [] }
|
|
||||||
|
|
||||||
if (isGlobalHome) {
|
|
||||||
const dirs = new Set<string>()
|
|
||||||
const protectedNames = Protected.names()
|
|
||||||
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
|
||||||
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
|
||||||
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
|
||||||
const top = yield* appFs.readDirectoryEntries(Instance.directory).pipe(Effect.orElseSucceed(() => []))
|
|
||||||
|
|
||||||
for (const entry of top) {
|
|
||||||
if (entry.type !== "directory") continue
|
|
||||||
if (shouldIgnoreName(entry.name)) continue
|
|
||||||
dirs.add(entry.name + "/")
|
|
||||||
|
|
||||||
const base = path.join(Instance.directory, entry.name)
|
|
||||||
const children = yield* appFs.readDirectoryEntries(base).pipe(Effect.orElseSucceed(() => []))
|
|
||||||
for (const child of children) {
|
|
||||||
if (child.type !== "directory") continue
|
|
||||||
if (shouldIgnoreNested(child.name)) continue
|
|
||||||
dirs.add(entry.name + "/" + child.name + "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
next.dirs = Array.from(dirs).toSorted()
|
|
||||||
} else {
|
|
||||||
const files = yield* rg.files({ cwd: Instance.directory }).pipe(
|
|
||||||
Stream.runCollect,
|
|
||||||
Effect.map((chunk) => [...chunk]),
|
|
||||||
)
|
|
||||||
const seen = new Set<string>()
|
|
||||||
for (const file of files) {
|
|
||||||
next.files.push(file)
|
|
||||||
let current = file
|
|
||||||
while (true) {
|
|
||||||
const dir = path.dirname(current)
|
|
||||||
if (dir === ".") break
|
|
||||||
if (dir === current) break
|
|
||||||
current = dir
|
|
||||||
if (seen.has(dir)) continue
|
|
||||||
seen.add(dir)
|
|
||||||
next.dirs.push(dir + "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
s.cache = next
|
|
||||||
})
|
|
||||||
|
|
||||||
let cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
|
|
||||||
|
|
||||||
const ensure = Effect.fn("File.ensure")(function* () {
|
|
||||||
yield* cachedScan
|
|
||||||
cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
|
|
||||||
})
|
|
||||||
|
|
||||||
const gitText = Effect.fnUntraced(function* (args: string[]) {
|
|
||||||
return (yield* git.run(args, { cwd: Instance.directory })).text()
|
|
||||||
})
|
|
||||||
|
|
||||||
const init = Effect.fn("File.init")(function* () {
|
|
||||||
yield* ensure().pipe(Effect.forkIn(scope))
|
|
||||||
})
|
|
||||||
|
|
||||||
const status = Effect.fn("File.status")(function* () {
|
|
||||||
if (Instance.project.vcs !== "git") return []
|
|
||||||
|
|
||||||
const diffOutput = yield* gitText([
|
|
||||||
"-c",
|
|
||||||
"core.fsmonitor=false",
|
|
||||||
"-c",
|
|
||||||
"core.quotepath=false",
|
|
||||||
"diff",
|
|
||||||
"--numstat",
|
|
||||||
"HEAD",
|
|
||||||
])
|
|
||||||
|
|
||||||
const changed: Info[] = []
|
|
||||||
|
|
||||||
if (diffOutput.trim()) {
|
|
||||||
for (const line of diffOutput.trim().split("\n")) {
|
|
||||||
const [added, removed, file] = line.split("\t")
|
|
||||||
changed.push({
|
|
||||||
path: file,
|
|
||||||
added: added === "-" ? 0 : parseInt(added, 10),
|
|
||||||
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
|
||||||
status: "modified",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const untrackedOutput = yield* gitText([
|
|
||||||
"-c",
|
|
||||||
"core.fsmonitor=false",
|
|
||||||
"-c",
|
|
||||||
"core.quotepath=false",
|
|
||||||
"ls-files",
|
|
||||||
"--others",
|
|
||||||
"--exclude-standard",
|
|
||||||
])
|
|
||||||
|
|
||||||
if (untrackedOutput.trim()) {
|
|
||||||
for (const file of untrackedOutput.trim().split("\n")) {
|
|
||||||
const content = yield* appFs
|
|
||||||
.readFileString(path.join(Instance.directory, file))
|
|
||||||
.pipe(Effect.catch(() => Effect.succeed<string | undefined>(undefined)))
|
|
||||||
if (content === undefined) continue
|
|
||||||
changed.push({
|
|
||||||
path: file,
|
|
||||||
added: content.split("\n").length,
|
|
||||||
removed: 0,
|
|
||||||
status: "added",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletedOutput = yield* gitText([
|
|
||||||
"-c",
|
|
||||||
"core.fsmonitor=false",
|
|
||||||
"-c",
|
|
||||||
"core.quotepath=false",
|
|
||||||
"diff",
|
|
||||||
"--name-only",
|
|
||||||
"--diff-filter=D",
|
|
||||||
"HEAD",
|
|
||||||
])
|
|
||||||
|
|
||||||
if (deletedOutput.trim()) {
|
|
||||||
for (const file of deletedOutput.trim().split("\n")) {
|
|
||||||
changed.push({
|
|
||||||
path: file,
|
|
||||||
added: 0,
|
|
||||||
removed: 0,
|
|
||||||
status: "deleted",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return changed.map((item) => {
|
|
||||||
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
path: path.relative(Instance.directory, full),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
|
|
||||||
using _ = log.time("read", { file })
|
|
||||||
const full = path.join(Instance.directory, file)
|
|
||||||
|
|
||||||
if (!Instance.containsPath(full)) throw new Error("Access denied: path escapes project directory")
|
|
||||||
|
|
||||||
if (isImageByExtension(file)) {
|
|
||||||
const exists = yield* appFs.existsSafe(full)
|
|
||||||
if (exists) {
|
|
||||||
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
|
||||||
return {
|
|
||||||
type: "text" as const,
|
|
||||||
content: Buffer.from(bytes).toString("base64"),
|
|
||||||
mimeType: getImageMimeType(file),
|
|
||||||
encoding: "base64" as const,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { type: "text" as const, content: "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const knownText = isTextByExtension(file) || isTextByName(file)
|
|
||||||
|
|
||||||
if (isBinaryByExtension(file) && !knownText) return { type: "binary" as const, content: "" }
|
|
||||||
|
|
||||||
const exists = yield* appFs.existsSafe(full)
|
|
||||||
if (!exists) return { type: "text" as const, content: "" }
|
|
||||||
|
|
||||||
const mimeType = AppFileSystem.mimeType(full)
|
|
||||||
const encode = knownText ? false : shouldEncode(mimeType)
|
|
||||||
|
|
||||||
if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType }
|
|
||||||
|
|
||||||
if (encode) {
|
|
||||||
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
|
||||||
return {
|
|
||||||
type: "text" as const,
|
|
||||||
content: Buffer.from(bytes).toString("base64"),
|
|
||||||
mimeType,
|
|
||||||
encoding: "base64" as const,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = yield* appFs.readFileString(full).pipe(
|
|
||||||
Effect.map((s) => s.trim()),
|
|
||||||
Effect.catch(() => Effect.succeed("")),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (Instance.project.vcs === "git") {
|
|
||||||
let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file])
|
|
||||||
if (!diff.trim()) {
|
|
||||||
diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
|
|
||||||
}
|
|
||||||
if (diff.trim()) {
|
|
||||||
const original = yield* git.show(Instance.directory, "HEAD", file)
|
|
||||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
|
||||||
context: Infinity,
|
|
||||||
ignoreWhitespace: true,
|
|
||||||
})
|
|
||||||
return { type: "text" as const, content, patch, diff: formatPatch(patch) }
|
|
||||||
}
|
|
||||||
return { type: "text" as const, content }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { type: "text" as const, content }
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("File.list")(function* (dir?: string) {
|
|
||||||
const exclude = [".git", ".DS_Store"]
|
|
||||||
let ignored = (_: string) => false
|
|
||||||
if (Instance.project.vcs === "git") {
|
|
||||||
const ig = ignore()
|
|
||||||
const gitignore = path.join(Instance.project.worktree, ".gitignore")
|
|
||||||
const gitignoreText = yield* appFs.readFileString(gitignore).pipe(Effect.catch(() => Effect.succeed("")))
|
|
||||||
if (gitignoreText) ig.add(gitignoreText)
|
|
||||||
const ignoreFile = path.join(Instance.project.worktree, ".ignore")
|
|
||||||
const ignoreText = yield* appFs.readFileString(ignoreFile).pipe(Effect.catch(() => Effect.succeed("")))
|
|
||||||
if (ignoreText) ig.add(ignoreText)
|
|
||||||
ignored = ig.ignores.bind(ig)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
|
|
||||||
if (!Instance.containsPath(resolved)) throw new Error("Access denied: path escapes project directory")
|
|
||||||
|
|
||||||
const entries = yield* appFs.readDirectoryEntries(resolved).pipe(Effect.orElseSucceed(() => []))
|
|
||||||
|
|
||||||
const nodes: Node[] = []
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (exclude.includes(entry.name)) continue
|
|
||||||
const absolute = path.join(resolved, entry.name)
|
|
||||||
const file = path.relative(Instance.directory, absolute)
|
|
||||||
const type = entry.type === "directory" ? "directory" : "file"
|
|
||||||
nodes.push({
|
|
||||||
name: entry.name,
|
|
||||||
path: file,
|
|
||||||
absolute,
|
|
||||||
type,
|
|
||||||
ignored: ignored(type === "directory" ? file + "/" : file),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return nodes.sort((a, b) => {
|
|
||||||
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
|
||||||
return a.name.localeCompare(b.name)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const search = Effect.fn("File.search")(function* (input: {
|
|
||||||
query: string
|
|
||||||
limit?: number
|
|
||||||
dirs?: boolean
|
|
||||||
type?: "file" | "directory"
|
|
||||||
}) {
|
|
||||||
yield* ensure()
|
|
||||||
const { cache } = yield* InstanceState.get(state)
|
|
||||||
|
|
||||||
const query = input.query.trim()
|
|
||||||
const limit = input.limit ?? 100
|
|
||||||
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
|
||||||
log.info("search", { query, kind })
|
|
||||||
|
|
||||||
const preferHidden = query.startsWith(".") || query.includes("/.")
|
|
||||||
|
|
||||||
if (!query) {
|
|
||||||
if (kind === "file") return cache.files.slice(0, limit)
|
|
||||||
return sortHiddenLast(cache.dirs.toSorted(), preferHidden).slice(0, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = kind === "file" ? cache.files : kind === "directory" ? cache.dirs : [...cache.files, ...cache.dirs]
|
|
||||||
|
|
||||||
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
|
||||||
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
|
||||||
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
|
||||||
|
|
||||||
log.info("search", { query, kind, results: output.length })
|
|
||||||
return output
|
|
||||||
})
|
|
||||||
|
|
||||||
log.info("init")
|
|
||||||
return Service.of({ init, status, read, list, search })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
|
||||||
Layer.provide(Ripgrep.defaultLayer),
|
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
|
||||||
Layer.provide(Git.defaultLayer),
|
|
||||||
)
|
|
||||||
@@ -1 +1,656 @@
|
|||||||
export * as File from "./file"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { InstanceState } from "@/effect"
|
||||||
|
|
||||||
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
|
import { Git } from "@/git"
|
||||||
|
import { Effect, Layer, Context, Scope } from "effect"
|
||||||
|
import * as Stream from "effect/Stream"
|
||||||
|
import { formatPatch, structuredPatch } from "diff"
|
||||||
|
import fuzzysort from "fuzzysort"
|
||||||
|
import ignore from "ignore"
|
||||||
|
import path from "path"
|
||||||
|
import z from "zod"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { Instance } from "../project/instance"
|
||||||
|
import { Log } from "../util"
|
||||||
|
import { Protected } from "./protected"
|
||||||
|
import { Ripgrep } from "./ripgrep"
|
||||||
|
|
||||||
|
export const Info = z
|
||||||
|
.object({
|
||||||
|
path: z.string(),
|
||||||
|
added: z.number().int(),
|
||||||
|
removed: z.number().int(),
|
||||||
|
status: z.enum(["added", "deleted", "modified"]),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "File",
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
|
export const Node = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
absolute: z.string(),
|
||||||
|
type: z.enum(["file", "directory"]),
|
||||||
|
ignored: z.boolean(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "FileNode",
|
||||||
|
})
|
||||||
|
export type Node = z.infer<typeof Node>
|
||||||
|
|
||||||
|
export const Content = z
|
||||||
|
.object({
|
||||||
|
type: z.enum(["text", "binary"]),
|
||||||
|
content: z.string(),
|
||||||
|
diff: z.string().optional(),
|
||||||
|
patch: z
|
||||||
|
.object({
|
||||||
|
oldFileName: z.string(),
|
||||||
|
newFileName: z.string(),
|
||||||
|
oldHeader: z.string().optional(),
|
||||||
|
newHeader: z.string().optional(),
|
||||||
|
hunks: z.array(
|
||||||
|
z.object({
|
||||||
|
oldStart: z.number(),
|
||||||
|
oldLines: z.number(),
|
||||||
|
newStart: z.number(),
|
||||||
|
newLines: z.number(),
|
||||||
|
lines: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
index: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
encoding: z.literal("base64").optional(),
|
||||||
|
mimeType: z.string().optional(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "FileContent",
|
||||||
|
})
|
||||||
|
export type Content = z.infer<typeof Content>
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Edited: BusEvent.define(
|
||||||
|
"file.edited",
|
||||||
|
z.object({
|
||||||
|
file: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = Log.create({ service: "file" })
|
||||||
|
|
||||||
|
const binary = new Set([
|
||||||
|
"exe",
|
||||||
|
"dll",
|
||||||
|
"pdb",
|
||||||
|
"bin",
|
||||||
|
"so",
|
||||||
|
"dylib",
|
||||||
|
"o",
|
||||||
|
"a",
|
||||||
|
"lib",
|
||||||
|
"wav",
|
||||||
|
"mp3",
|
||||||
|
"ogg",
|
||||||
|
"oga",
|
||||||
|
"ogv",
|
||||||
|
"ogx",
|
||||||
|
"flac",
|
||||||
|
"aac",
|
||||||
|
"wma",
|
||||||
|
"m4a",
|
||||||
|
"weba",
|
||||||
|
"mp4",
|
||||||
|
"avi",
|
||||||
|
"mov",
|
||||||
|
"wmv",
|
||||||
|
"flv",
|
||||||
|
"webm",
|
||||||
|
"mkv",
|
||||||
|
"zip",
|
||||||
|
"tar",
|
||||||
|
"gz",
|
||||||
|
"gzip",
|
||||||
|
"bz",
|
||||||
|
"bz2",
|
||||||
|
"bzip",
|
||||||
|
"bzip2",
|
||||||
|
"7z",
|
||||||
|
"rar",
|
||||||
|
"xz",
|
||||||
|
"lz",
|
||||||
|
"z",
|
||||||
|
"pdf",
|
||||||
|
"doc",
|
||||||
|
"docx",
|
||||||
|
"ppt",
|
||||||
|
"pptx",
|
||||||
|
"xls",
|
||||||
|
"xlsx",
|
||||||
|
"dmg",
|
||||||
|
"iso",
|
||||||
|
"img",
|
||||||
|
"vmdk",
|
||||||
|
"ttf",
|
||||||
|
"otf",
|
||||||
|
"woff",
|
||||||
|
"woff2",
|
||||||
|
"eot",
|
||||||
|
"sqlite",
|
||||||
|
"db",
|
||||||
|
"mdb",
|
||||||
|
"apk",
|
||||||
|
"ipa",
|
||||||
|
"aab",
|
||||||
|
"xapk",
|
||||||
|
"app",
|
||||||
|
"pkg",
|
||||||
|
"deb",
|
||||||
|
"rpm",
|
||||||
|
"snap",
|
||||||
|
"flatpak",
|
||||||
|
"appimage",
|
||||||
|
"msi",
|
||||||
|
"msp",
|
||||||
|
"jar",
|
||||||
|
"war",
|
||||||
|
"ear",
|
||||||
|
"class",
|
||||||
|
"kotlin_module",
|
||||||
|
"dex",
|
||||||
|
"vdex",
|
||||||
|
"odex",
|
||||||
|
"oat",
|
||||||
|
"art",
|
||||||
|
"wasm",
|
||||||
|
"wat",
|
||||||
|
"bc",
|
||||||
|
"ll",
|
||||||
|
"s",
|
||||||
|
"ko",
|
||||||
|
"sys",
|
||||||
|
"drv",
|
||||||
|
"efi",
|
||||||
|
"rom",
|
||||||
|
"com",
|
||||||
|
])
|
||||||
|
|
||||||
|
const image = new Set([
|
||||||
|
"png",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"gif",
|
||||||
|
"bmp",
|
||||||
|
"webp",
|
||||||
|
"ico",
|
||||||
|
"tif",
|
||||||
|
"tiff",
|
||||||
|
"svg",
|
||||||
|
"svgz",
|
||||||
|
"avif",
|
||||||
|
"apng",
|
||||||
|
"jxl",
|
||||||
|
"heic",
|
||||||
|
"heif",
|
||||||
|
"raw",
|
||||||
|
"cr2",
|
||||||
|
"nef",
|
||||||
|
"arw",
|
||||||
|
"dng",
|
||||||
|
"orf",
|
||||||
|
"raf",
|
||||||
|
"pef",
|
||||||
|
"x3f",
|
||||||
|
])
|
||||||
|
|
||||||
|
const text = new Set([
|
||||||
|
"ts",
|
||||||
|
"tsx",
|
||||||
|
"mts",
|
||||||
|
"cts",
|
||||||
|
"mtsx",
|
||||||
|
"ctsx",
|
||||||
|
"js",
|
||||||
|
"jsx",
|
||||||
|
"mjs",
|
||||||
|
"cjs",
|
||||||
|
"sh",
|
||||||
|
"bash",
|
||||||
|
"zsh",
|
||||||
|
"fish",
|
||||||
|
"ps1",
|
||||||
|
"psm1",
|
||||||
|
"cmd",
|
||||||
|
"bat",
|
||||||
|
"json",
|
||||||
|
"jsonc",
|
||||||
|
"json5",
|
||||||
|
"yaml",
|
||||||
|
"yml",
|
||||||
|
"toml",
|
||||||
|
"md",
|
||||||
|
"mdx",
|
||||||
|
"txt",
|
||||||
|
"xml",
|
||||||
|
"html",
|
||||||
|
"htm",
|
||||||
|
"css",
|
||||||
|
"scss",
|
||||||
|
"sass",
|
||||||
|
"less",
|
||||||
|
"graphql",
|
||||||
|
"gql",
|
||||||
|
"sql",
|
||||||
|
"ini",
|
||||||
|
"cfg",
|
||||||
|
"conf",
|
||||||
|
"env",
|
||||||
|
])
|
||||||
|
|
||||||
|
const textName = new Set([
|
||||||
|
"dockerfile",
|
||||||
|
"makefile",
|
||||||
|
".gitignore",
|
||||||
|
".gitattributes",
|
||||||
|
".editorconfig",
|
||||||
|
".npmrc",
|
||||||
|
".nvmrc",
|
||||||
|
".prettierrc",
|
||||||
|
".eslintrc",
|
||||||
|
])
|
||||||
|
|
||||||
|
const mime: Record<string, string> = {
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
gif: "image/gif",
|
||||||
|
bmp: "image/bmp",
|
||||||
|
webp: "image/webp",
|
||||||
|
ico: "image/x-icon",
|
||||||
|
tif: "image/tiff",
|
||||||
|
tiff: "image/tiff",
|
||||||
|
svg: "image/svg+xml",
|
||||||
|
svgz: "image/svg+xml",
|
||||||
|
avif: "image/avif",
|
||||||
|
apng: "image/apng",
|
||||||
|
jxl: "image/jxl",
|
||||||
|
heic: "image/heic",
|
||||||
|
heif: "image/heif",
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entry = { files: string[]; dirs: string[] }
|
||||||
|
|
||||||
|
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
|
||||||
|
const name = (file: string) => path.basename(file).toLowerCase()
|
||||||
|
const isImageByExtension = (file: string) => image.has(ext(file))
|
||||||
|
const isTextByExtension = (file: string) => text.has(ext(file))
|
||||||
|
const isTextByName = (file: string) => textName.has(name(file))
|
||||||
|
const isBinaryByExtension = (file: string) => binary.has(ext(file))
|
||||||
|
const isImage = (mimeType: string) => mimeType.startsWith("image/")
|
||||||
|
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
|
||||||
|
|
||||||
|
function shouldEncode(mimeType: string) {
|
||||||
|
const type = mimeType.toLowerCase()
|
||||||
|
log.debug("shouldEncode", { type })
|
||||||
|
if (!type) return false
|
||||||
|
if (type.startsWith("text/")) return false
|
||||||
|
if (type.includes("charset=")) return false
|
||||||
|
const top = type.split("/", 2)[0]
|
||||||
|
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hidden = (item: string) => {
|
||||||
|
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
|
||||||
|
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortHiddenLast = (items: string[], prefer: boolean) => {
|
||||||
|
if (prefer) return items
|
||||||
|
const visible: string[] = []
|
||||||
|
const hiddenItems: string[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (hidden(item)) hiddenItems.push(item)
|
||||||
|
else visible.push(item)
|
||||||
|
}
|
||||||
|
return [...visible, ...hiddenItems]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
cache: Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly init: () => Effect.Effect<void>
|
||||||
|
readonly status: () => Effect.Effect<Info[]>
|
||||||
|
readonly read: (file: string) => Effect.Effect<Content>
|
||||||
|
readonly list: (dir?: string) => Effect.Effect<Node[]>
|
||||||
|
readonly search: (input: {
|
||||||
|
query: string
|
||||||
|
limit?: number
|
||||||
|
dirs?: boolean
|
||||||
|
type?: "file" | "directory"
|
||||||
|
}) => Effect.Effect<string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/File") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const appFs = yield* AppFileSystem.Service
|
||||||
|
const rg = yield* Ripgrep.Service
|
||||||
|
const git = yield* Git.Service
|
||||||
|
const scope = yield* Scope.Scope
|
||||||
|
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("File.state")(() =>
|
||||||
|
Effect.succeed({
|
||||||
|
cache: { files: [], dirs: [] } as Entry,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const scan = Effect.fn("File.scan")(function* () {
|
||||||
|
if (Instance.directory === path.parse(Instance.directory).root) return
|
||||||
|
const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
|
||||||
|
const next: Entry = { files: [], dirs: [] }
|
||||||
|
|
||||||
|
if (isGlobalHome) {
|
||||||
|
const dirs = new Set<string>()
|
||||||
|
const protectedNames = Protected.names()
|
||||||
|
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
||||||
|
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
||||||
|
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
||||||
|
const top = yield* appFs.readDirectoryEntries(Instance.directory).pipe(Effect.orElseSucceed(() => []))
|
||||||
|
|
||||||
|
for (const entry of top) {
|
||||||
|
if (entry.type !== "directory") continue
|
||||||
|
if (shouldIgnoreName(entry.name)) continue
|
||||||
|
dirs.add(entry.name + "/")
|
||||||
|
|
||||||
|
const base = path.join(Instance.directory, entry.name)
|
||||||
|
const children = yield* appFs.readDirectoryEntries(base).pipe(Effect.orElseSucceed(() => []))
|
||||||
|
for (const child of children) {
|
||||||
|
if (child.type !== "directory") continue
|
||||||
|
if (shouldIgnoreNested(child.name)) continue
|
||||||
|
dirs.add(entry.name + "/" + child.name + "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next.dirs = Array.from(dirs).toSorted()
|
||||||
|
} else {
|
||||||
|
const files = yield* rg.files({ cwd: Instance.directory }).pipe(
|
||||||
|
Stream.runCollect,
|
||||||
|
Effect.map((chunk) => [...chunk]),
|
||||||
|
)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const file of files) {
|
||||||
|
next.files.push(file)
|
||||||
|
let current = file
|
||||||
|
while (true) {
|
||||||
|
const dir = path.dirname(current)
|
||||||
|
if (dir === ".") break
|
||||||
|
if (dir === current) break
|
||||||
|
current = dir
|
||||||
|
if (seen.has(dir)) continue
|
||||||
|
seen.add(dir)
|
||||||
|
next.dirs.push(dir + "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
s.cache = next
|
||||||
|
})
|
||||||
|
|
||||||
|
let cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
|
||||||
|
|
||||||
|
const ensure = Effect.fn("File.ensure")(function* () {
|
||||||
|
yield* cachedScan
|
||||||
|
cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
|
||||||
|
})
|
||||||
|
|
||||||
|
const gitText = Effect.fnUntraced(function* (args: string[]) {
|
||||||
|
return (yield* git.run(args, { cwd: Instance.directory })).text()
|
||||||
|
})
|
||||||
|
|
||||||
|
const init = Effect.fn("File.init")(function* () {
|
||||||
|
yield* ensure().pipe(Effect.forkIn(scope))
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = Effect.fn("File.status")(function* () {
|
||||||
|
if (Instance.project.vcs !== "git") return []
|
||||||
|
|
||||||
|
const diffOutput = yield* gitText([
|
||||||
|
"-c",
|
||||||
|
"core.fsmonitor=false",
|
||||||
|
"-c",
|
||||||
|
"core.quotepath=false",
|
||||||
|
"diff",
|
||||||
|
"--numstat",
|
||||||
|
"HEAD",
|
||||||
|
])
|
||||||
|
|
||||||
|
const changed: Info[] = []
|
||||||
|
|
||||||
|
if (diffOutput.trim()) {
|
||||||
|
for (const line of diffOutput.trim().split("\n")) {
|
||||||
|
const [added, removed, file] = line.split("\t")
|
||||||
|
changed.push({
|
||||||
|
path: file,
|
||||||
|
added: added === "-" ? 0 : parseInt(added, 10),
|
||||||
|
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||||
|
status: "modified",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const untrackedOutput = yield* gitText([
|
||||||
|
"-c",
|
||||||
|
"core.fsmonitor=false",
|
||||||
|
"-c",
|
||||||
|
"core.quotepath=false",
|
||||||
|
"ls-files",
|
||||||
|
"--others",
|
||||||
|
"--exclude-standard",
|
||||||
|
])
|
||||||
|
|
||||||
|
if (untrackedOutput.trim()) {
|
||||||
|
for (const file of untrackedOutput.trim().split("\n")) {
|
||||||
|
const content = yield* appFs
|
||||||
|
.readFileString(path.join(Instance.directory, file))
|
||||||
|
.pipe(Effect.catch(() => Effect.succeed<string | undefined>(undefined)))
|
||||||
|
if (content === undefined) continue
|
||||||
|
changed.push({
|
||||||
|
path: file,
|
||||||
|
added: content.split("\n").length,
|
||||||
|
removed: 0,
|
||||||
|
status: "added",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedOutput = yield* gitText([
|
||||||
|
"-c",
|
||||||
|
"core.fsmonitor=false",
|
||||||
|
"-c",
|
||||||
|
"core.quotepath=false",
|
||||||
|
"diff",
|
||||||
|
"--name-only",
|
||||||
|
"--diff-filter=D",
|
||||||
|
"HEAD",
|
||||||
|
])
|
||||||
|
|
||||||
|
if (deletedOutput.trim()) {
|
||||||
|
for (const file of deletedOutput.trim().split("\n")) {
|
||||||
|
changed.push({
|
||||||
|
path: file,
|
||||||
|
added: 0,
|
||||||
|
removed: 0,
|
||||||
|
status: "deleted",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed.map((item) => {
|
||||||
|
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
path: path.relative(Instance.directory, full),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
|
||||||
|
using _ = log.time("read", { file })
|
||||||
|
const full = path.join(Instance.directory, file)
|
||||||
|
|
||||||
|
if (!Instance.containsPath(full)) throw new Error("Access denied: path escapes project directory")
|
||||||
|
|
||||||
|
if (isImageByExtension(file)) {
|
||||||
|
const exists = yield* appFs.existsSafe(full)
|
||||||
|
if (exists) {
|
||||||
|
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
||||||
|
return {
|
||||||
|
type: "text" as const,
|
||||||
|
content: Buffer.from(bytes).toString("base64"),
|
||||||
|
mimeType: getImageMimeType(file),
|
||||||
|
encoding: "base64" as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { type: "text" as const, content: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownText = isTextByExtension(file) || isTextByName(file)
|
||||||
|
|
||||||
|
if (isBinaryByExtension(file) && !knownText) return { type: "binary" as const, content: "" }
|
||||||
|
|
||||||
|
const exists = yield* appFs.existsSafe(full)
|
||||||
|
if (!exists) return { type: "text" as const, content: "" }
|
||||||
|
|
||||||
|
const mimeType = AppFileSystem.mimeType(full)
|
||||||
|
const encode = knownText ? false : shouldEncode(mimeType)
|
||||||
|
|
||||||
|
if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType }
|
||||||
|
|
||||||
|
if (encode) {
|
||||||
|
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
||||||
|
return {
|
||||||
|
type: "text" as const,
|
||||||
|
content: Buffer.from(bytes).toString("base64"),
|
||||||
|
mimeType,
|
||||||
|
encoding: "base64" as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = yield* appFs.readFileString(full).pipe(
|
||||||
|
Effect.map((s) => s.trim()),
|
||||||
|
Effect.catch(() => Effect.succeed("")),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (Instance.project.vcs === "git") {
|
||||||
|
let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file])
|
||||||
|
if (!diff.trim()) {
|
||||||
|
diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
|
||||||
|
}
|
||||||
|
if (diff.trim()) {
|
||||||
|
const original = yield* git.show(Instance.directory, "HEAD", file)
|
||||||
|
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||||
|
context: Infinity,
|
||||||
|
ignoreWhitespace: true,
|
||||||
|
})
|
||||||
|
return { type: "text" as const, content, patch, diff: formatPatch(patch) }
|
||||||
|
}
|
||||||
|
return { type: "text" as const, content }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "text" as const, content }
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("File.list")(function* (dir?: string) {
|
||||||
|
const exclude = [".git", ".DS_Store"]
|
||||||
|
let ignored = (_: string) => false
|
||||||
|
if (Instance.project.vcs === "git") {
|
||||||
|
const ig = ignore()
|
||||||
|
const gitignore = path.join(Instance.project.worktree, ".gitignore")
|
||||||
|
const gitignoreText = yield* appFs.readFileString(gitignore).pipe(Effect.catch(() => Effect.succeed("")))
|
||||||
|
if (gitignoreText) ig.add(gitignoreText)
|
||||||
|
const ignoreFile = path.join(Instance.project.worktree, ".ignore")
|
||||||
|
const ignoreText = yield* appFs.readFileString(ignoreFile).pipe(Effect.catch(() => Effect.succeed("")))
|
||||||
|
if (ignoreText) ig.add(ignoreText)
|
||||||
|
ignored = ig.ignores.bind(ig)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
|
||||||
|
if (!Instance.containsPath(resolved)) throw new Error("Access denied: path escapes project directory")
|
||||||
|
|
||||||
|
const entries = yield* appFs.readDirectoryEntries(resolved).pipe(Effect.orElseSucceed(() => []))
|
||||||
|
|
||||||
|
const nodes: Node[] = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (exclude.includes(entry.name)) continue
|
||||||
|
const absolute = path.join(resolved, entry.name)
|
||||||
|
const file = path.relative(Instance.directory, absolute)
|
||||||
|
const type = entry.type === "directory" ? "directory" : "file"
|
||||||
|
nodes.push({
|
||||||
|
name: entry.name,
|
||||||
|
path: file,
|
||||||
|
absolute,
|
||||||
|
type,
|
||||||
|
ignored: ignored(type === "directory" ? file + "/" : file),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nodes.sort((a, b) => {
|
||||||
|
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const search = Effect.fn("File.search")(function* (input: {
|
||||||
|
query: string
|
||||||
|
limit?: number
|
||||||
|
dirs?: boolean
|
||||||
|
type?: "file" | "directory"
|
||||||
|
}) {
|
||||||
|
yield* ensure()
|
||||||
|
const { cache } = yield* InstanceState.get(state)
|
||||||
|
|
||||||
|
const query = input.query.trim()
|
||||||
|
const limit = input.limit ?? 100
|
||||||
|
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
||||||
|
log.info("search", { query, kind })
|
||||||
|
|
||||||
|
const preferHidden = query.startsWith(".") || query.includes("/.")
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
if (kind === "file") return cache.files.slice(0, limit)
|
||||||
|
return sortHiddenLast(cache.dirs.toSorted(), preferHidden).slice(0, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = kind === "file" ? cache.files : kind === "directory" ? cache.dirs : [...cache.files, ...cache.dirs]
|
||||||
|
|
||||||
|
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
||||||
|
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
||||||
|
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
||||||
|
|
||||||
|
log.info("search", { query, kind, results: output.length })
|
||||||
|
return output
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info("init")
|
||||||
|
return Service.of({ init, status, read, list, search })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
Layer.provide(Git.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
|
export * as File from "."
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+104
-104
@@ -5,109 +5,109 @@ import { Flag } from "@/flag/flag"
|
|||||||
import type { SessionID } from "@/session/schema"
|
import type { SessionID } from "@/session/schema"
|
||||||
import { Log } from "../util"
|
import { Log } from "../util"
|
||||||
|
|
||||||
export namespace FileTime {
|
const log = Log.create({ service: "file.time" })
|
||||||
const log = Log.create({ service: "file.time" })
|
|
||||||
|
|
||||||
export type Stamp = {
|
export type Stamp = {
|
||||||
readonly read: Date
|
readonly read: Date
|
||||||
readonly mtime: number | undefined
|
readonly mtime: number | undefined
|
||||||
readonly size: number | undefined
|
readonly size: number | undefined
|
||||||
}
|
|
||||||
|
|
||||||
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
|
|
||||||
const value = reads.get(sessionID)
|
|
||||||
if (value) return value
|
|
||||||
|
|
||||||
const next = new Map<string, Stamp>()
|
|
||||||
reads.set(sessionID, next)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
reads: Map<SessionID, Map<string, Stamp>>
|
|
||||||
locks: Map<string, Semaphore.Semaphore>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
|
||||||
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
|
||||||
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
|
|
||||||
readonly withLock: <T>(filepath: string, fn: () => Effect.Effect<T>) => Effect.Effect<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileTime") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const fsys = yield* AppFileSystem.Service
|
|
||||||
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
|
||||||
|
|
||||||
const stamp = Effect.fnUntraced(function* (file: string) {
|
|
||||||
const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.void))
|
|
||||||
return {
|
|
||||||
read: yield* DateTime.nowAsDate,
|
|
||||||
mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,
|
|
||||||
size: info ? Number(info.size) : undefined,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const state = yield* InstanceState.make<State>(
|
|
||||||
Effect.fn("FileTime.state")(() =>
|
|
||||||
Effect.succeed({
|
|
||||||
reads: new Map<SessionID, Map<string, Stamp>>(),
|
|
||||||
locks: new Map<string, Semaphore.Semaphore>(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
|
|
||||||
filepath = AppFileSystem.normalizePath(filepath)
|
|
||||||
const locks = (yield* InstanceState.get(state)).locks
|
|
||||||
const lock = locks.get(filepath)
|
|
||||||
if (lock) return lock
|
|
||||||
|
|
||||||
const next = Semaphore.makeUnsafe(1)
|
|
||||||
locks.set(filepath, next)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
|
|
||||||
file = AppFileSystem.normalizePath(file)
|
|
||||||
const reads = (yield* InstanceState.get(state)).reads
|
|
||||||
log.info("read", { sessionID, file })
|
|
||||||
session(reads, sessionID).set(file, yield* stamp(file))
|
|
||||||
})
|
|
||||||
|
|
||||||
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
|
|
||||||
file = AppFileSystem.normalizePath(file)
|
|
||||||
const reads = (yield* InstanceState.get(state)).reads
|
|
||||||
return reads.get(sessionID)?.get(file)?.read
|
|
||||||
})
|
|
||||||
|
|
||||||
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
|
|
||||||
if (disableCheck) return
|
|
||||||
filepath = AppFileSystem.normalizePath(filepath)
|
|
||||||
|
|
||||||
const reads = (yield* InstanceState.get(state)).reads
|
|
||||||
const time = reads.get(sessionID)?.get(filepath)
|
|
||||||
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
|
||||||
|
|
||||||
const next = yield* stamp(filepath)
|
|
||||||
const changed = next.mtime !== time.mtime || next.size !== time.size
|
|
||||||
if (!changed) return
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Effect.Effect<T>) {
|
|
||||||
return yield* fn().pipe((yield* getLock(filepath)).withPermits(1))
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ read, get, assert, withLock })
|
|
||||||
}),
|
|
||||||
).pipe(Layer.orDie)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
|
||||||
|
const value = reads.get(sessionID)
|
||||||
|
if (value) return value
|
||||||
|
|
||||||
|
const next = new Map<string, Stamp>()
|
||||||
|
reads.set(sessionID, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
reads: Map<SessionID, Map<string, Stamp>>
|
||||||
|
locks: Map<string, Semaphore.Semaphore>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
||||||
|
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
||||||
|
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
|
||||||
|
readonly withLock: <T>(filepath: string, fn: () => Effect.Effect<T>) => Effect.Effect<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/FileTime") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fsys = yield* AppFileSystem.Service
|
||||||
|
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
||||||
|
|
||||||
|
const stamp = Effect.fnUntraced(function* (file: string) {
|
||||||
|
const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.void))
|
||||||
|
return {
|
||||||
|
read: yield* DateTime.nowAsDate,
|
||||||
|
mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,
|
||||||
|
size: info ? Number(info.size) : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("FileTime.state")(() =>
|
||||||
|
Effect.succeed({
|
||||||
|
reads: new Map<SessionID, Map<string, Stamp>>(),
|
||||||
|
locks: new Map<string, Semaphore.Semaphore>(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
|
||||||
|
filepath = AppFileSystem.normalizePath(filepath)
|
||||||
|
const locks = (yield* InstanceState.get(state)).locks
|
||||||
|
const lock = locks.get(filepath)
|
||||||
|
if (lock) return lock
|
||||||
|
|
||||||
|
const next = Semaphore.makeUnsafe(1)
|
||||||
|
locks.set(filepath, next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
|
||||||
|
file = AppFileSystem.normalizePath(file)
|
||||||
|
const reads = (yield* InstanceState.get(state)).reads
|
||||||
|
log.info("read", { sessionID, file })
|
||||||
|
session(reads, sessionID).set(file, yield* stamp(file))
|
||||||
|
})
|
||||||
|
|
||||||
|
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
|
||||||
|
file = AppFileSystem.normalizePath(file)
|
||||||
|
const reads = (yield* InstanceState.get(state)).reads
|
||||||
|
return reads.get(sessionID)?.get(file)?.read
|
||||||
|
})
|
||||||
|
|
||||||
|
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
|
||||||
|
if (disableCheck) return
|
||||||
|
filepath = AppFileSystem.normalizePath(filepath)
|
||||||
|
|
||||||
|
const reads = (yield* InstanceState.get(state)).reads
|
||||||
|
const time = reads.get(sessionID)?.get(filepath)
|
||||||
|
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
||||||
|
|
||||||
|
const next = yield* stamp(filepath)
|
||||||
|
const changed = next.mtime !== time.mtime || next.size !== time.size
|
||||||
|
if (!changed) return
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Effect.Effect<T>) {
|
||||||
|
return yield* fn().pipe((yield* getLock(filepath)).withPermits(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ read, get, assert, withLock })
|
||||||
|
}),
|
||||||
|
).pipe(Layer.orDie)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||||
|
|
||||||
|
export * as FileTime from "./time"
|
||||||
|
|||||||
@@ -19,145 +19,145 @@ import { Log } from "../util"
|
|||||||
|
|
||||||
declare const OPENCODE_LIBC: string | undefined
|
declare const OPENCODE_LIBC: string | undefined
|
||||||
|
|
||||||
export namespace FileWatcher {
|
const log = Log.create({ service: "file.watcher" })
|
||||||
const log = Log.create({ service: "file.watcher" })
|
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||||
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
Updated: BusEvent.define(
|
Updated: BusEvent.define(
|
||||||
"file.watcher.updated",
|
"file.watcher.updated",
|
||||||
z.object({
|
z.object({
|
||||||
file: z.string(),
|
file: z.string(),
|
||||||
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
|
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
|
||||||
try {
|
|
||||||
const binding = require(
|
|
||||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
|
|
||||||
)
|
|
||||||
return createWrapper(binding) as typeof import("@parcel/watcher")
|
|
||||||
} catch (error) {
|
|
||||||
log.error("failed to load watcher binding", { error })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function getBackend() {
|
|
||||||
if (process.platform === "win32") return "windows"
|
|
||||||
if (process.platform === "darwin") return "fs-events"
|
|
||||||
if (process.platform === "linux") return "inotify"
|
|
||||||
}
|
|
||||||
|
|
||||||
function protecteds(dir: string) {
|
|
||||||
return Protected.paths().filter((item) => {
|
|
||||||
const rel = path.relative(dir, item)
|
|
||||||
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const hasNativeBinding = () => !!watcher()
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly init: () => Effect.Effect<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileWatcher") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const config = yield* Config.Service
|
|
||||||
const git = yield* Git.Service
|
|
||||||
|
|
||||||
const state = yield* InstanceState.make(
|
|
||||||
Effect.fn("FileWatcher.state")(
|
|
||||||
function* () {
|
|
||||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
|
|
||||||
|
|
||||||
log.info("init", { directory: Instance.directory })
|
|
||||||
|
|
||||||
const backend = getBackend()
|
|
||||||
if (!backend) {
|
|
||||||
log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const w = watcher()
|
|
||||||
if (!w) return
|
|
||||||
|
|
||||||
log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
|
|
||||||
|
|
||||||
const subs: ParcelWatcher.AsyncSubscription[] = []
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
|
|
||||||
)
|
|
||||||
|
|
||||||
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
|
|
||||||
if (err) return
|
|
||||||
for (const evt of evts) {
|
|
||||||
if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
|
||||||
if (evt.type === "update") void Bus.publish(Event.Updated, { file: evt.path, event: "change" })
|
|
||||||
if (evt.type === "delete") void Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const subscribe = (dir: string, ignore: string[]) => {
|
|
||||||
const pending = w.subscribe(dir, cb, { ignore, backend })
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
const sub = yield* Effect.promise(() => pending)
|
|
||||||
subs.push(sub)
|
|
||||||
}).pipe(
|
|
||||||
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
|
||||||
Effect.catchCause((cause) => {
|
|
||||||
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
|
||||||
pending.then((s) => s.unsubscribe()).catch(() => {})
|
|
||||||
return Effect.void
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cfg = yield* config.get()
|
|
||||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
|
||||||
|
|
||||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
|
||||||
yield* subscribe(Instance.directory, [
|
|
||||||
...FileIgnore.PATTERNS,
|
|
||||||
...cfgIgnores,
|
|
||||||
...protecteds(Instance.directory),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Instance.project.vcs === "git") {
|
|
||||||
const result = yield* git.run(["rev-parse", "--git-dir"], {
|
|
||||||
cwd: Instance.project.worktree,
|
|
||||||
})
|
|
||||||
const vcsDir =
|
|
||||||
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
|
|
||||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
|
||||||
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
|
||||||
(entry) => entry !== "HEAD",
|
|
||||||
)
|
|
||||||
yield* subscribe(vcsDir, ignore)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Effect.catchCause((cause) => {
|
|
||||||
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
|
||||||
return Effect.void
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
init: Effect.fn("FileWatcher.init")(function* () {
|
|
||||||
yield* InstanceState.get(state)
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
),
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||||
|
try {
|
||||||
|
const binding = require(
|
||||||
|
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
|
||||||
|
)
|
||||||
|
return createWrapper(binding) as typeof import("@parcel/watcher")
|
||||||
|
} catch (error) {
|
||||||
|
log.error("failed to load watcher binding", { error })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function getBackend() {
|
||||||
|
if (process.platform === "win32") return "windows"
|
||||||
|
if (process.platform === "darwin") return "fs-events"
|
||||||
|
if (process.platform === "linux") return "inotify"
|
||||||
|
}
|
||||||
|
|
||||||
|
function protecteds(dir: string) {
|
||||||
|
return Protected.paths().filter((item) => {
|
||||||
|
const rel = path.relative(dir, item)
|
||||||
|
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasNativeBinding = () => !!watcher()
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly init: () => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/FileWatcher") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const git = yield* Git.Service
|
||||||
|
|
||||||
|
const state = yield* InstanceState.make(
|
||||||
|
Effect.fn("FileWatcher.state")(
|
||||||
|
function* () {
|
||||||
|
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
|
||||||
|
|
||||||
|
log.info("init", { directory: Instance.directory })
|
||||||
|
|
||||||
|
const backend = getBackend()
|
||||||
|
if (!backend) {
|
||||||
|
log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = watcher()
|
||||||
|
if (!w) return
|
||||||
|
|
||||||
|
log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
|
||||||
|
|
||||||
|
const subs: ParcelWatcher.AsyncSubscription[] = []
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
|
||||||
|
)
|
||||||
|
|
||||||
|
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
|
||||||
|
if (err) return
|
||||||
|
for (const evt of evts) {
|
||||||
|
if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
||||||
|
if (evt.type === "update") void Bus.publish(Event.Updated, { file: evt.path, event: "change" })
|
||||||
|
if (evt.type === "delete") void Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const subscribe = (dir: string, ignore: string[]) => {
|
||||||
|
const pending = w.subscribe(dir, cb, { ignore, backend })
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const sub = yield* Effect.promise(() => pending)
|
||||||
|
subs.push(sub)
|
||||||
|
}).pipe(
|
||||||
|
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
||||||
|
Effect.catchCause((cause) => {
|
||||||
|
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
||||||
|
pending.then((s) => s.unsubscribe()).catch(() => {})
|
||||||
|
return Effect.void
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = yield* config.get()
|
||||||
|
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||||
|
|
||||||
|
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||||
|
yield* subscribe(Instance.directory, [
|
||||||
|
...FileIgnore.PATTERNS,
|
||||||
|
...cfgIgnores,
|
||||||
|
...protecteds(Instance.directory),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Instance.project.vcs === "git") {
|
||||||
|
const result = yield* git.run(["rev-parse", "--git-dir"], {
|
||||||
|
cwd: Instance.project.worktree,
|
||||||
|
})
|
||||||
|
const vcsDir =
|
||||||
|
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
|
||||||
|
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||||
|
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
||||||
|
(entry) => entry !== "HEAD",
|
||||||
|
)
|
||||||
|
yield* subscribe(vcsDir, ignore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Effect.catchCause((cause) => {
|
||||||
|
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
||||||
|
return Effect.void
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
init: Effect.fn("FileWatcher.init")(function* () {
|
||||||
|
yield* InstanceState.get(state)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||||
|
|
||||||
|
export * as FileWatcher from "./watcher"
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
import { Effect, Layer, Context } from "effect"
|
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
|
||||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
|
||||||
import { InstanceState } from "@/effect"
|
|
||||||
import path from "path"
|
|
||||||
import { mergeDeep } from "remeda"
|
|
||||||
import z from "zod"
|
|
||||||
import { Config } from "../config"
|
|
||||||
import { Log } from "../util"
|
|
||||||
import * as Formatter from "./formatter"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "format" })
|
|
||||||
|
|
||||||
export const Status = z
|
|
||||||
.object({
|
|
||||||
name: z.string(),
|
|
||||||
extensions: z.string().array(),
|
|
||||||
enabled: z.boolean(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "FormatterStatus",
|
|
||||||
})
|
|
||||||
export type Status = z.infer<typeof Status>
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly init: () => Effect.Effect<void>
|
|
||||||
readonly status: () => Effect.Effect<Status[]>
|
|
||||||
readonly file: (filepath: string) => Effect.Effect<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Format") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const config = yield* Config.Service
|
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
||||||
|
|
||||||
const state = yield* InstanceState.make(
|
|
||||||
Effect.fn("Format.state")(function* (_ctx) {
|
|
||||||
const commands: Record<string, string[] | false> = {}
|
|
||||||
const formatters: Record<string, Formatter.Info> = {}
|
|
||||||
|
|
||||||
const cfg = yield* config.get()
|
|
||||||
|
|
||||||
if (cfg.formatter !== false) {
|
|
||||||
for (const item of Object.values(Formatter)) {
|
|
||||||
formatters[item.name] = item
|
|
||||||
}
|
|
||||||
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
|
|
||||||
// Ruff and uv are both the same formatter, so disabling either should disable both.
|
|
||||||
if (["ruff", "uv"].includes(name) && (cfg.formatter?.ruff?.disabled || cfg.formatter?.uv?.disabled)) {
|
|
||||||
// TODO combine formatters so shared backends like Ruff/uv don't need linked disable handling here.
|
|
||||||
delete formatters.ruff
|
|
||||||
delete formatters.uv
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (item.disabled) {
|
|
||||||
delete formatters[name]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const info = mergeDeep(formatters[name] ?? {}, {
|
|
||||||
extensions: [],
|
|
||||||
...item,
|
|
||||||
})
|
|
||||||
|
|
||||||
formatters[name] = {
|
|
||||||
...info,
|
|
||||||
name,
|
|
||||||
enabled: async () => info.command ?? false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.info("all formatters are disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCommand(item: Formatter.Info) {
|
|
||||||
let cmd = commands[item.name]
|
|
||||||
if (cmd === false || cmd === undefined) {
|
|
||||||
cmd = await item.enabled()
|
|
||||||
commands[item.name] = cmd
|
|
||||||
}
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isEnabled(item: Formatter.Info) {
|
|
||||||
const cmd = await getCommand(item)
|
|
||||||
return cmd !== false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getFormatter(ext: string) {
|
|
||||||
const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext))
|
|
||||||
const checks = await Promise.all(
|
|
||||||
matching.map(async (item) => {
|
|
||||||
log.info("checking", { name: item.name, ext })
|
|
||||||
const cmd = await getCommand(item)
|
|
||||||
if (cmd) {
|
|
||||||
log.info("enabled", { name: item.name, ext })
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
item,
|
|
||||||
cmd,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return checks.filter((x) => x.cmd).map((x) => ({ item: x.item, cmd: x.cmd! }))
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFile(filepath: string) {
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
log.info("formatting", { file: filepath })
|
|
||||||
const ext = path.extname(filepath)
|
|
||||||
|
|
||||||
for (const { item, cmd } of yield* Effect.promise(() => getFormatter(ext))) {
|
|
||||||
if (cmd === false) continue
|
|
||||||
log.info("running", { command: cmd })
|
|
||||||
const replaced = cmd.map((x) => x.replace("$FILE", filepath))
|
|
||||||
const dir = yield* InstanceState.directory
|
|
||||||
const code = yield* spawner
|
|
||||||
.spawn(
|
|
||||||
ChildProcess.make(replaced[0]!, replaced.slice(1), {
|
|
||||||
cwd: dir,
|
|
||||||
env: item.environment,
|
|
||||||
extendEnv: true,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.pipe(
|
|
||||||
Effect.flatMap((handle) => handle.exitCode),
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.catch(() =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
log.error("failed to format file", {
|
|
||||||
error: "spawn failed",
|
|
||||||
command: cmd,
|
|
||||||
...item.environment,
|
|
||||||
file: filepath,
|
|
||||||
})
|
|
||||||
return ChildProcessSpawner.ExitCode(1)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (code !== 0) {
|
|
||||||
log.error("failed", {
|
|
||||||
command: cmd,
|
|
||||||
...item.environment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("init")
|
|
||||||
|
|
||||||
return {
|
|
||||||
formatters,
|
|
||||||
isEnabled,
|
|
||||||
formatFile,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const init = Effect.fn("Format.init")(function* () {
|
|
||||||
yield* InstanceState.get(state)
|
|
||||||
})
|
|
||||||
|
|
||||||
const status = Effect.fn("Format.status")(function* () {
|
|
||||||
const { formatters, isEnabled } = yield* InstanceState.get(state)
|
|
||||||
const result: Status[] = []
|
|
||||||
for (const formatter of Object.values(formatters)) {
|
|
||||||
const isOn = yield* Effect.promise(() => isEnabled(formatter))
|
|
||||||
result.push({
|
|
||||||
name: formatter.name,
|
|
||||||
extensions: formatter.extensions,
|
|
||||||
enabled: isOn,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const file = Effect.fn("Format.file")(function* (filepath: string) {
|
|
||||||
const { formatFile } = yield* InstanceState.get(state)
|
|
||||||
yield* formatFile(filepath)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ init, status, file })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
|
||||||
Layer.provide(Config.defaultLayer),
|
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
|
||||||
)
|
|
||||||
@@ -1 +1,194 @@
|
|||||||
export * as Format from "./format"
|
import { Effect, Layer, Context } from "effect"
|
||||||
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
|
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||||
|
import { InstanceState } from "@/effect"
|
||||||
|
import path from "path"
|
||||||
|
import { mergeDeep } from "remeda"
|
||||||
|
import z from "zod"
|
||||||
|
import { Config } from "../config"
|
||||||
|
import { Log } from "../util"
|
||||||
|
import * as Formatter from "./formatter"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "format" })
|
||||||
|
|
||||||
|
export const Status = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
extensions: z.string().array(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "FormatterStatus",
|
||||||
|
})
|
||||||
|
export type Status = z.infer<typeof Status>
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly init: () => Effect.Effect<void>
|
||||||
|
readonly status: () => Effect.Effect<Status[]>
|
||||||
|
readonly file: (filepath: string) => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Format") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
|
|
||||||
|
const state = yield* InstanceState.make(
|
||||||
|
Effect.fn("Format.state")(function* (_ctx) {
|
||||||
|
const commands: Record<string, string[] | false> = {}
|
||||||
|
const formatters: Record<string, Formatter.Info> = {}
|
||||||
|
|
||||||
|
const cfg = yield* config.get()
|
||||||
|
|
||||||
|
if (cfg.formatter !== false) {
|
||||||
|
for (const item of Object.values(Formatter)) {
|
||||||
|
formatters[item.name] = item
|
||||||
|
}
|
||||||
|
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
|
||||||
|
// Ruff and uv are both the same formatter, so disabling either should disable both.
|
||||||
|
if (["ruff", "uv"].includes(name) && (cfg.formatter?.ruff?.disabled || cfg.formatter?.uv?.disabled)) {
|
||||||
|
// TODO combine formatters so shared backends like Ruff/uv don't need linked disable handling here.
|
||||||
|
delete formatters.ruff
|
||||||
|
delete formatters.uv
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (item.disabled) {
|
||||||
|
delete formatters[name]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const info = mergeDeep(formatters[name] ?? {}, {
|
||||||
|
extensions: [],
|
||||||
|
...item,
|
||||||
|
})
|
||||||
|
|
||||||
|
formatters[name] = {
|
||||||
|
...info,
|
||||||
|
name,
|
||||||
|
enabled: async () => info.command ?? false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.info("all formatters are disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCommand(item: Formatter.Info) {
|
||||||
|
let cmd = commands[item.name]
|
||||||
|
if (cmd === false || cmd === undefined) {
|
||||||
|
cmd = await item.enabled()
|
||||||
|
commands[item.name] = cmd
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isEnabled(item: Formatter.Info) {
|
||||||
|
const cmd = await getCommand(item)
|
||||||
|
return cmd !== false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFormatter(ext: string) {
|
||||||
|
const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext))
|
||||||
|
const checks = await Promise.all(
|
||||||
|
matching.map(async (item) => {
|
||||||
|
log.info("checking", { name: item.name, ext })
|
||||||
|
const cmd = await getCommand(item)
|
||||||
|
if (cmd) {
|
||||||
|
log.info("enabled", { name: item.name, ext })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
cmd,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return checks.filter((x) => x.cmd).map((x) => ({ item: x.item, cmd: x.cmd! }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFile(filepath: string) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
log.info("formatting", { file: filepath })
|
||||||
|
const ext = path.extname(filepath)
|
||||||
|
|
||||||
|
for (const { item, cmd } of yield* Effect.promise(() => getFormatter(ext))) {
|
||||||
|
if (cmd === false) continue
|
||||||
|
log.info("running", { command: cmd })
|
||||||
|
const replaced = cmd.map((x) => x.replace("$FILE", filepath))
|
||||||
|
const dir = yield* InstanceState.directory
|
||||||
|
const code = yield* spawner
|
||||||
|
.spawn(
|
||||||
|
ChildProcess.make(replaced[0]!, replaced.slice(1), {
|
||||||
|
cwd: dir,
|
||||||
|
env: item.environment,
|
||||||
|
extendEnv: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
Effect.flatMap((handle) => handle.exitCode),
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.catch(() =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
log.error("failed to format file", {
|
||||||
|
error: "spawn failed",
|
||||||
|
command: cmd,
|
||||||
|
...item.environment,
|
||||||
|
file: filepath,
|
||||||
|
})
|
||||||
|
return ChildProcessSpawner.ExitCode(1)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (code !== 0) {
|
||||||
|
log.error("failed", {
|
||||||
|
command: cmd,
|
||||||
|
...item.environment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("init")
|
||||||
|
|
||||||
|
return {
|
||||||
|
formatters,
|
||||||
|
isEnabled,
|
||||||
|
formatFile,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const init = Effect.fn("Format.init")(function* () {
|
||||||
|
yield* InstanceState.get(state)
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = Effect.fn("Format.status")(function* () {
|
||||||
|
const { formatters, isEnabled } = yield* InstanceState.get(state)
|
||||||
|
const result: Status[] = []
|
||||||
|
for (const formatter of Object.values(formatters)) {
|
||||||
|
const isOn = yield* Effect.promise(() => isEnabled(formatter))
|
||||||
|
result.push({
|
||||||
|
name: formatter.name,
|
||||||
|
extensions: formatter.extensions,
|
||||||
|
enabled: isOn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const file = Effect.fn("Format.file")(function* (filepath: string) {
|
||||||
|
const { formatFile } = yield* InstanceState.get(state)
|
||||||
|
yield* formatFile(filepath)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ init, status, file })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
|
export * as Format from "."
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
|
||||||
import { Effect, Layer, Context, Stream } from "effect"
|
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
|
||||||
|
|
||||||
const cfg = [
|
|
||||||
"--no-optional-locks",
|
|
||||||
"-c",
|
|
||||||
"core.autocrlf=false",
|
|
||||||
"-c",
|
|
||||||
"core.fsmonitor=false",
|
|
||||||
"-c",
|
|
||||||
"core.longpaths=true",
|
|
||||||
"-c",
|
|
||||||
"core.symlinks=true",
|
|
||||||
"-c",
|
|
||||||
"core.quotepath=false",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const out = (result: { text(): string }) => result.text().trim()
|
|
||||||
const nuls = (text: string) => text.split("\0").filter(Boolean)
|
|
||||||
const fail = (err: unknown) =>
|
|
||||||
({
|
|
||||||
exitCode: 1,
|
|
||||||
text: () => "",
|
|
||||||
stdout: Buffer.alloc(0),
|
|
||||||
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
|
|
||||||
}) satisfies Result
|
|
||||||
|
|
||||||
export type Kind = "added" | "deleted" | "modified"
|
|
||||||
|
|
||||||
export type Base = {
|
|
||||||
readonly name: string
|
|
||||||
readonly ref: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Item = {
|
|
||||||
readonly file: string
|
|
||||||
readonly code: string
|
|
||||||
readonly status: Kind
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Stat = {
|
|
||||||
readonly file: string
|
|
||||||
readonly additions: number
|
|
||||||
readonly deletions: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Result {
|
|
||||||
readonly exitCode: number
|
|
||||||
readonly text: () => string
|
|
||||||
readonly stdout: Buffer
|
|
||||||
readonly stderr: Buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Options {
|
|
||||||
readonly cwd: string
|
|
||||||
readonly env?: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly run: (args: string[], opts: Options) => Effect.Effect<Result>
|
|
||||||
readonly branch: (cwd: string) => Effect.Effect<string | undefined>
|
|
||||||
readonly prefix: (cwd: string) => Effect.Effect<string>
|
|
||||||
readonly defaultBranch: (cwd: string) => Effect.Effect<Base | undefined>
|
|
||||||
readonly hasHead: (cwd: string) => Effect.Effect<boolean>
|
|
||||||
readonly mergeBase: (cwd: string, base: string, head?: string) => Effect.Effect<string | undefined>
|
|
||||||
readonly show: (cwd: string, ref: string, file: string, prefix?: string) => Effect.Effect<string>
|
|
||||||
readonly status: (cwd: string) => Effect.Effect<Item[]>
|
|
||||||
readonly diff: (cwd: string, ref: string) => Effect.Effect<Item[]>
|
|
||||||
readonly stats: (cwd: string, ref: string) => Effect.Effect<Stat[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
const kind = (code: string): Kind => {
|
|
||||||
if (code === "??") return "added"
|
|
||||||
if (code.includes("U")) return "modified"
|
|
||||||
if (code.includes("A") && !code.includes("D")) return "added"
|
|
||||||
if (code.includes("D") && !code.includes("A")) return "deleted"
|
|
||||||
return "modified"
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Git") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
||||||
|
|
||||||
const run = Effect.fn("Git.run")(
|
|
||||||
function* (args: string[], opts: Options) {
|
|
||||||
const proc = ChildProcess.make("git", [...cfg, ...args], {
|
|
||||||
cwd: opts.cwd,
|
|
||||||
env: opts.env,
|
|
||||||
extendEnv: true,
|
|
||||||
stdin: "ignore",
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
})
|
|
||||||
const handle = yield* spawner.spawn(proc)
|
|
||||||
const [stdout, stderr] = yield* Effect.all(
|
|
||||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
exitCode: yield* handle.exitCode,
|
|
||||||
text: () => stdout,
|
|
||||||
stdout: Buffer.from(stdout),
|
|
||||||
stderr: Buffer.from(stderr),
|
|
||||||
} satisfies Result
|
|
||||||
},
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.catch((err) => Effect.succeed(fail(err))),
|
|
||||||
)
|
|
||||||
|
|
||||||
const text = Effect.fn("Git.text")(function* (args: string[], opts: Options) {
|
|
||||||
return (yield* run(args, opts)).text()
|
|
||||||
})
|
|
||||||
|
|
||||||
const lines = Effect.fn("Git.lines")(function* (args: string[], opts: Options) {
|
|
||||||
return (yield* text(args, opts))
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
})
|
|
||||||
|
|
||||||
const refs = Effect.fnUntraced(function* (cwd: string) {
|
|
||||||
return yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd })
|
|
||||||
})
|
|
||||||
|
|
||||||
const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) {
|
|
||||||
const result = yield* run(["config", "init.defaultBranch"], { cwd })
|
|
||||||
const name = out(result)
|
|
||||||
if (!name || !list.includes(name)) return
|
|
||||||
return { name, ref: name } satisfies Base
|
|
||||||
})
|
|
||||||
|
|
||||||
const primary = Effect.fnUntraced(function* (cwd: string) {
|
|
||||||
const list = yield* lines(["remote"], { cwd })
|
|
||||||
if (list.includes("origin")) return "origin"
|
|
||||||
if (list.length === 1) return list[0]
|
|
||||||
if (list.includes("upstream")) return "upstream"
|
|
||||||
return list[0]
|
|
||||||
})
|
|
||||||
|
|
||||||
const branch = Effect.fn("Git.branch")(function* (cwd: string) {
|
|
||||||
const result = yield* run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd })
|
|
||||||
if (result.exitCode !== 0) return
|
|
||||||
const text = out(result)
|
|
||||||
return text || undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
const prefix = Effect.fn("Git.prefix")(function* (cwd: string) {
|
|
||||||
const result = yield* run(["rev-parse", "--show-prefix"], { cwd })
|
|
||||||
if (result.exitCode !== 0) return ""
|
|
||||||
return out(result)
|
|
||||||
})
|
|
||||||
|
|
||||||
const defaultBranch = Effect.fn("Git.defaultBranch")(function* (cwd: string) {
|
|
||||||
const remote = yield* primary(cwd)
|
|
||||||
if (remote) {
|
|
||||||
const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd })
|
|
||||||
if (head.exitCode === 0) {
|
|
||||||
const ref = out(head).replace(/^refs\/remotes\//, "")
|
|
||||||
const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
|
|
||||||
if (name) return { name, ref } satisfies Base
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = yield* refs(cwd)
|
|
||||||
const next = yield* configured(cwd, list)
|
|
||||||
if (next) return next
|
|
||||||
if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base
|
|
||||||
if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasHead = Effect.fn("Git.hasHead")(function* (cwd: string) {
|
|
||||||
const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd })
|
|
||||||
return result.exitCode === 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const mergeBase = Effect.fn("Git.mergeBase")(function* (cwd: string, base: string, head = "HEAD") {
|
|
||||||
const result = yield* run(["merge-base", base, head], { cwd })
|
|
||||||
if (result.exitCode !== 0) return
|
|
||||||
const text = out(result)
|
|
||||||
return text || undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
const show = Effect.fn("Git.show")(function* (cwd: string, ref: string, file: string, prefix = "") {
|
|
||||||
const target = prefix ? `${prefix}${file}` : file
|
|
||||||
const result = yield* run(["show", `${ref}:${target}`], { cwd })
|
|
||||||
if (result.exitCode !== 0) return ""
|
|
||||||
if (result.stdout.includes(0)) return ""
|
|
||||||
return result.text()
|
|
||||||
})
|
|
||||||
|
|
||||||
const status = Effect.fn("Git.status")(function* (cwd: string) {
|
|
||||||
return nuls(
|
|
||||||
yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], {
|
|
||||||
cwd,
|
|
||||||
}),
|
|
||||||
).flatMap((item) => {
|
|
||||||
const file = item.slice(3)
|
|
||||||
if (!file) return []
|
|
||||||
const code = item.slice(0, 2)
|
|
||||||
return [{ file, code, status: kind(code) } satisfies Item]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const diff = Effect.fn("Git.diff")(function* (cwd: string, ref: string) {
|
|
||||||
const list = nuls(
|
|
||||||
yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }),
|
|
||||||
)
|
|
||||||
return list.flatMap((code, idx) => {
|
|
||||||
if (idx % 2 !== 0) return []
|
|
||||||
const file = list[idx + 1]
|
|
||||||
if (!code || !file) return []
|
|
||||||
return [{ file, code, status: kind(code) } satisfies Item]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const stats = Effect.fn("Git.stats")(function* (cwd: string, ref: string) {
|
|
||||||
return nuls(
|
|
||||||
yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }),
|
|
||||||
).flatMap((item) => {
|
|
||||||
const a = item.indexOf("\t")
|
|
||||||
const b = item.indexOf("\t", a + 1)
|
|
||||||
if (a === -1 || b === -1) return []
|
|
||||||
const file = item.slice(b + 1)
|
|
||||||
if (!file) return []
|
|
||||||
const adds = item.slice(0, a)
|
|
||||||
const dels = item.slice(a + 1, b)
|
|
||||||
const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10)
|
|
||||||
const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
file,
|
|
||||||
additions: Number.isFinite(additions) ? additions : 0,
|
|
||||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
|
||||||
} satisfies Stat,
|
|
||||||
]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
run,
|
|
||||||
branch,
|
|
||||||
prefix,
|
|
||||||
defaultBranch,
|
|
||||||
hasHead,
|
|
||||||
mergeBase,
|
|
||||||
show,
|
|
||||||
status,
|
|
||||||
diff,
|
|
||||||
stats,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
|
|
||||||
@@ -1 +1,260 @@
|
|||||||
export * as Git from "./git"
|
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||||
|
import { Effect, Layer, Context, Stream } from "effect"
|
||||||
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
|
|
||||||
|
const cfg = [
|
||||||
|
"--no-optional-locks",
|
||||||
|
"-c",
|
||||||
|
"core.autocrlf=false",
|
||||||
|
"-c",
|
||||||
|
"core.fsmonitor=false",
|
||||||
|
"-c",
|
||||||
|
"core.longpaths=true",
|
||||||
|
"-c",
|
||||||
|
"core.symlinks=true",
|
||||||
|
"-c",
|
||||||
|
"core.quotepath=false",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const out = (result: { text(): string }) => result.text().trim()
|
||||||
|
const nuls = (text: string) => text.split("\0").filter(Boolean)
|
||||||
|
const fail = (err: unknown) =>
|
||||||
|
({
|
||||||
|
exitCode: 1,
|
||||||
|
text: () => "",
|
||||||
|
stdout: Buffer.alloc(0),
|
||||||
|
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
|
||||||
|
}) satisfies Result
|
||||||
|
|
||||||
|
export type Kind = "added" | "deleted" | "modified"
|
||||||
|
|
||||||
|
export type Base = {
|
||||||
|
readonly name: string
|
||||||
|
readonly ref: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Item = {
|
||||||
|
readonly file: string
|
||||||
|
readonly code: string
|
||||||
|
readonly status: Kind
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Stat = {
|
||||||
|
readonly file: string
|
||||||
|
readonly additions: number
|
||||||
|
readonly deletions: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Result {
|
||||||
|
readonly exitCode: number
|
||||||
|
readonly text: () => string
|
||||||
|
readonly stdout: Buffer
|
||||||
|
readonly stderr: Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
readonly cwd: string
|
||||||
|
readonly env?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly run: (args: string[], opts: Options) => Effect.Effect<Result>
|
||||||
|
readonly branch: (cwd: string) => Effect.Effect<string | undefined>
|
||||||
|
readonly prefix: (cwd: string) => Effect.Effect<string>
|
||||||
|
readonly defaultBranch: (cwd: string) => Effect.Effect<Base | undefined>
|
||||||
|
readonly hasHead: (cwd: string) => Effect.Effect<boolean>
|
||||||
|
readonly mergeBase: (cwd: string, base: string, head?: string) => Effect.Effect<string | undefined>
|
||||||
|
readonly show: (cwd: string, ref: string, file: string, prefix?: string) => Effect.Effect<string>
|
||||||
|
readonly status: (cwd: string) => Effect.Effect<Item[]>
|
||||||
|
readonly diff: (cwd: string, ref: string) => Effect.Effect<Item[]>
|
||||||
|
readonly stats: (cwd: string, ref: string) => Effect.Effect<Stat[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = (code: string): Kind => {
|
||||||
|
if (code === "??") return "added"
|
||||||
|
if (code.includes("U")) return "modified"
|
||||||
|
if (code.includes("A") && !code.includes("D")) return "added"
|
||||||
|
if (code.includes("D") && !code.includes("A")) return "deleted"
|
||||||
|
return "modified"
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Git") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
|
|
||||||
|
const run = Effect.fn("Git.run")(
|
||||||
|
function* (args: string[], opts: Options) {
|
||||||
|
const proc = ChildProcess.make("git", [...cfg, ...args], {
|
||||||
|
cwd: opts.cwd,
|
||||||
|
env: opts.env,
|
||||||
|
extendEnv: true,
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
const handle = yield* spawner.spawn(proc)
|
||||||
|
const [stdout, stderr] = yield* Effect.all(
|
||||||
|
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||||
|
{ concurrency: 2 },
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
exitCode: yield* handle.exitCode,
|
||||||
|
text: () => stdout,
|
||||||
|
stdout: Buffer.from(stdout),
|
||||||
|
stderr: Buffer.from(stderr),
|
||||||
|
} satisfies Result
|
||||||
|
},
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.catch((err) => Effect.succeed(fail(err))),
|
||||||
|
)
|
||||||
|
|
||||||
|
const text = Effect.fn("Git.text")(function* (args: string[], opts: Options) {
|
||||||
|
return (yield* run(args, opts)).text()
|
||||||
|
})
|
||||||
|
|
||||||
|
const lines = Effect.fn("Git.lines")(function* (args: string[], opts: Options) {
|
||||||
|
return (yield* text(args, opts))
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
})
|
||||||
|
|
||||||
|
const refs = Effect.fnUntraced(function* (cwd: string) {
|
||||||
|
return yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd })
|
||||||
|
})
|
||||||
|
|
||||||
|
const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) {
|
||||||
|
const result = yield* run(["config", "init.defaultBranch"], { cwd })
|
||||||
|
const name = out(result)
|
||||||
|
if (!name || !list.includes(name)) return
|
||||||
|
return { name, ref: name } satisfies Base
|
||||||
|
})
|
||||||
|
|
||||||
|
const primary = Effect.fnUntraced(function* (cwd: string) {
|
||||||
|
const list = yield* lines(["remote"], { cwd })
|
||||||
|
if (list.includes("origin")) return "origin"
|
||||||
|
if (list.length === 1) return list[0]
|
||||||
|
if (list.includes("upstream")) return "upstream"
|
||||||
|
return list[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
const branch = Effect.fn("Git.branch")(function* (cwd: string) {
|
||||||
|
const result = yield* run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd })
|
||||||
|
if (result.exitCode !== 0) return
|
||||||
|
const text = out(result)
|
||||||
|
return text || undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const prefix = Effect.fn("Git.prefix")(function* (cwd: string) {
|
||||||
|
const result = yield* run(["rev-parse", "--show-prefix"], { cwd })
|
||||||
|
if (result.exitCode !== 0) return ""
|
||||||
|
return out(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
const defaultBranch = Effect.fn("Git.defaultBranch")(function* (cwd: string) {
|
||||||
|
const remote = yield* primary(cwd)
|
||||||
|
if (remote) {
|
||||||
|
const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd })
|
||||||
|
if (head.exitCode === 0) {
|
||||||
|
const ref = out(head).replace(/^refs\/remotes\//, "")
|
||||||
|
const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
|
||||||
|
if (name) return { name, ref } satisfies Base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = yield* refs(cwd)
|
||||||
|
const next = yield* configured(cwd, list)
|
||||||
|
if (next) return next
|
||||||
|
if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base
|
||||||
|
if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasHead = Effect.fn("Git.hasHead")(function* (cwd: string) {
|
||||||
|
const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd })
|
||||||
|
return result.exitCode === 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const mergeBase = Effect.fn("Git.mergeBase")(function* (cwd: string, base: string, head = "HEAD") {
|
||||||
|
const result = yield* run(["merge-base", base, head], { cwd })
|
||||||
|
if (result.exitCode !== 0) return
|
||||||
|
const text = out(result)
|
||||||
|
return text || undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const show = Effect.fn("Git.show")(function* (cwd: string, ref: string, file: string, prefix = "") {
|
||||||
|
const target = prefix ? `${prefix}${file}` : file
|
||||||
|
const result = yield* run(["show", `${ref}:${target}`], { cwd })
|
||||||
|
if (result.exitCode !== 0) return ""
|
||||||
|
if (result.stdout.includes(0)) return ""
|
||||||
|
return result.text()
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = Effect.fn("Git.status")(function* (cwd: string) {
|
||||||
|
return nuls(
|
||||||
|
yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], {
|
||||||
|
cwd,
|
||||||
|
}),
|
||||||
|
).flatMap((item) => {
|
||||||
|
const file = item.slice(3)
|
||||||
|
if (!file) return []
|
||||||
|
const code = item.slice(0, 2)
|
||||||
|
return [{ file, code, status: kind(code) } satisfies Item]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const diff = Effect.fn("Git.diff")(function* (cwd: string, ref: string) {
|
||||||
|
const list = nuls(
|
||||||
|
yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }),
|
||||||
|
)
|
||||||
|
return list.flatMap((code, idx) => {
|
||||||
|
if (idx % 2 !== 0) return []
|
||||||
|
const file = list[idx + 1]
|
||||||
|
if (!code || !file) return []
|
||||||
|
return [{ file, code, status: kind(code) } satisfies Item]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const stats = Effect.fn("Git.stats")(function* (cwd: string, ref: string) {
|
||||||
|
return nuls(
|
||||||
|
yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }),
|
||||||
|
).flatMap((item) => {
|
||||||
|
const a = item.indexOf("\t")
|
||||||
|
const b = item.indexOf("\t", a + 1)
|
||||||
|
if (a === -1 || b === -1) return []
|
||||||
|
const file = item.slice(b + 1)
|
||||||
|
if (!file) return []
|
||||||
|
const adds = item.slice(0, a)
|
||||||
|
const dels = item.slice(a + 1, b)
|
||||||
|
const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10)
|
||||||
|
const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
file,
|
||||||
|
additions: Number.isFinite(additions) ? additions : 0,
|
||||||
|
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||||
|
} satisfies Stat,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
run,
|
||||||
|
branch,
|
||||||
|
prefix,
|
||||||
|
defaultBranch,
|
||||||
|
hasHead,
|
||||||
|
mergeBase,
|
||||||
|
show,
|
||||||
|
status,
|
||||||
|
diff,
|
||||||
|
stats,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
|
||||||
|
|
||||||
|
export * as Git from "."
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import fs from "fs/promises"
|
|
||||||
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
|
|
||||||
import path from "path"
|
|
||||||
import os from "os"
|
|
||||||
import { Filesystem } from "../util"
|
|
||||||
import { Flock } from "@opencode-ai/shared/util/flock"
|
|
||||||
|
|
||||||
const app = "opencode"
|
|
||||||
|
|
||||||
const data = path.join(xdgData!, app)
|
|
||||||
const cache = path.join(xdgCache!, app)
|
|
||||||
const config = path.join(xdgConfig!, app)
|
|
||||||
const state = path.join(xdgState!, app)
|
|
||||||
|
|
||||||
export const Path = {
|
|
||||||
// Allow override via OPENCODE_TEST_HOME for test isolation
|
|
||||||
get home() {
|
|
||||||
return process.env.OPENCODE_TEST_HOME || os.homedir()
|
|
||||||
},
|
|
||||||
data,
|
|
||||||
bin: path.join(cache, "bin"),
|
|
||||||
log: path.join(data, "log"),
|
|
||||||
cache,
|
|
||||||
config,
|
|
||||||
state,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize Flock with global state path
|
|
||||||
Flock.setGlobal({ state })
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
fs.mkdir(Path.data, { recursive: true }),
|
|
||||||
fs.mkdir(Path.config, { recursive: true }),
|
|
||||||
fs.mkdir(Path.state, { recursive: true }),
|
|
||||||
fs.mkdir(Path.log, { recursive: true }),
|
|
||||||
fs.mkdir(Path.bin, { recursive: true }),
|
|
||||||
])
|
|
||||||
|
|
||||||
const CACHE_VERSION = "21"
|
|
||||||
|
|
||||||
const version = await Filesystem.readText(path.join(Path.cache, "version")).catch(() => "0")
|
|
||||||
|
|
||||||
if (version !== CACHE_VERSION) {
|
|
||||||
try {
|
|
||||||
const contents = await fs.readdir(Path.cache)
|
|
||||||
await Promise.all(
|
|
||||||
contents.map((item) =>
|
|
||||||
fs.rm(path.join(Path.cache, item), {
|
|
||||||
recursive: true,
|
|
||||||
force: true,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} catch {}
|
|
||||||
await Filesystem.write(path.join(Path.cache, "version"), CACHE_VERSION)
|
|
||||||
}
|
|
||||||
@@ -1 +1,58 @@
|
|||||||
export * as Global from "./global"
|
import fs from "fs/promises"
|
||||||
|
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
|
||||||
|
import path from "path"
|
||||||
|
import os from "os"
|
||||||
|
import { Filesystem } from "../util"
|
||||||
|
import { Flock } from "@opencode-ai/shared/util/flock"
|
||||||
|
|
||||||
|
const app = "opencode"
|
||||||
|
|
||||||
|
const data = path.join(xdgData!, app)
|
||||||
|
const cache = path.join(xdgCache!, app)
|
||||||
|
const config = path.join(xdgConfig!, app)
|
||||||
|
const state = path.join(xdgState!, app)
|
||||||
|
|
||||||
|
export const Path = {
|
||||||
|
// Allow override via OPENCODE_TEST_HOME for test isolation
|
||||||
|
get home() {
|
||||||
|
return process.env.OPENCODE_TEST_HOME || os.homedir()
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
bin: path.join(cache, "bin"),
|
||||||
|
log: path.join(data, "log"),
|
||||||
|
cache,
|
||||||
|
config,
|
||||||
|
state,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Flock with global state path
|
||||||
|
Flock.setGlobal({ state })
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
fs.mkdir(Path.data, { recursive: true }),
|
||||||
|
fs.mkdir(Path.config, { recursive: true }),
|
||||||
|
fs.mkdir(Path.state, { recursive: true }),
|
||||||
|
fs.mkdir(Path.log, { recursive: true }),
|
||||||
|
fs.mkdir(Path.bin, { recursive: true }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const CACHE_VERSION = "21"
|
||||||
|
|
||||||
|
const version = await Filesystem.readText(path.join(Path.cache, "version")).catch(() => "0")
|
||||||
|
|
||||||
|
if (version !== CACHE_VERSION) {
|
||||||
|
try {
|
||||||
|
const contents = await fs.readdir(Path.cache)
|
||||||
|
await Promise.all(
|
||||||
|
contents.map((item) =>
|
||||||
|
fs.rm(path.join(Path.cache, item), {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch {}
|
||||||
|
await Filesystem.write(path.join(Path.cache, "version"), CACHE_VERSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as Global from "."
|
||||||
|
|||||||
@@ -1,86 +1,86 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { randomBytes } from "crypto"
|
import { randomBytes } from "crypto"
|
||||||
|
|
||||||
export namespace Identifier {
|
const prefixes = {
|
||||||
const prefixes = {
|
event: "evt",
|
||||||
event: "evt",
|
session: "ses",
|
||||||
session: "ses",
|
message: "msg",
|
||||||
message: "msg",
|
permission: "per",
|
||||||
permission: "per",
|
question: "que",
|
||||||
question: "que",
|
user: "usr",
|
||||||
user: "usr",
|
part: "prt",
|
||||||
part: "prt",
|
pty: "pty",
|
||||||
pty: "pty",
|
tool: "tool",
|
||||||
tool: "tool",
|
workspace: "wrk",
|
||||||
workspace: "wrk",
|
entry: "ent",
|
||||||
entry: "ent",
|
} as const
|
||||||
} as const
|
|
||||||
|
|
||||||
export function schema(prefix: keyof typeof prefixes) {
|
export function schema(prefix: keyof typeof prefixes) {
|
||||||
return z.string().startsWith(prefixes[prefix])
|
return z.string().startsWith(prefixes[prefix])
|
||||||
}
|
|
||||||
|
|
||||||
const LENGTH = 26
|
|
||||||
|
|
||||||
// State for monotonic ID generation
|
|
||||||
let lastTimestamp = 0
|
|
||||||
let counter = 0
|
|
||||||
|
|
||||||
export function ascending(prefix: keyof typeof prefixes, given?: string) {
|
|
||||||
return generateID(prefix, "ascending", given)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function descending(prefix: keyof typeof prefixes, given?: string) {
|
|
||||||
return generateID(prefix, "descending", given)
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string {
|
|
||||||
if (!given) {
|
|
||||||
return create(prefixes[prefix], direction)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!given.startsWith(prefixes[prefix])) {
|
|
||||||
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
|
|
||||||
}
|
|
||||||
return given
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomBase62(length: number): string {
|
|
||||||
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
||||||
let result = ""
|
|
||||||
const bytes = randomBytes(length)
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
result += chars[bytes[i] % 62]
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
|
|
||||||
const currentTimestamp = timestamp ?? Date.now()
|
|
||||||
|
|
||||||
if (currentTimestamp !== lastTimestamp) {
|
|
||||||
lastTimestamp = currentTimestamp
|
|
||||||
counter = 0
|
|
||||||
}
|
|
||||||
counter++
|
|
||||||
|
|
||||||
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
|
|
||||||
|
|
||||||
now = direction === "descending" ? ~now : now
|
|
||||||
|
|
||||||
const timeBytes = Buffer.alloc(6)
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
|
|
||||||
}
|
|
||||||
|
|
||||||
return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
|
|
||||||
export function timestamp(id: string): number {
|
|
||||||
const prefix = id.split("_")[0]
|
|
||||||
const hex = id.slice(prefix.length + 1, prefix.length + 13)
|
|
||||||
const encoded = BigInt("0x" + hex)
|
|
||||||
return Number(encoded / BigInt(0x1000))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LENGTH = 26
|
||||||
|
|
||||||
|
// State for monotonic ID generation
|
||||||
|
let lastTimestamp = 0
|
||||||
|
let counter = 0
|
||||||
|
|
||||||
|
export function ascending(prefix: keyof typeof prefixes, given?: string) {
|
||||||
|
return generateID(prefix, "ascending", given)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function descending(prefix: keyof typeof prefixes, given?: string) {
|
||||||
|
return generateID(prefix, "descending", given)
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string {
|
||||||
|
if (!given) {
|
||||||
|
return create(prefixes[prefix], direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!given.startsWith(prefixes[prefix])) {
|
||||||
|
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
|
||||||
|
}
|
||||||
|
return given
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomBase62(length: number): string {
|
||||||
|
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
|
let result = ""
|
||||||
|
const bytes = randomBytes(length)
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars[bytes[i] % 62]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
|
||||||
|
const currentTimestamp = timestamp ?? Date.now()
|
||||||
|
|
||||||
|
if (currentTimestamp !== lastTimestamp) {
|
||||||
|
lastTimestamp = currentTimestamp
|
||||||
|
counter = 0
|
||||||
|
}
|
||||||
|
counter++
|
||||||
|
|
||||||
|
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
|
||||||
|
|
||||||
|
now = direction === "descending" ? ~now : now
|
||||||
|
|
||||||
|
const timeBytes = Buffer.alloc(6)
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
|
||||||
|
export function timestamp(id: string): number {
|
||||||
|
const prefix = id.split("_")[0]
|
||||||
|
const hex = id.slice(prefix.length + 1, prefix.length + 13)
|
||||||
|
const encoded = BigInt("0x" + hex)
|
||||||
|
return Number(encoded / BigInt(0x1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as Identifier from "./id"
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
|
||||||
import z from "zod"
|
|
||||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
|
||||||
import { Log } from "../util"
|
|
||||||
import { Process } from "@/util"
|
|
||||||
|
|
||||||
const SUPPORTED_IDES = [
|
|
||||||
{ name: "Windsurf" as const, cmd: "windsurf" },
|
|
||||||
{ name: "Visual Studio Code - Insiders" as const, cmd: "code-insiders" },
|
|
||||||
{ name: "Visual Studio Code" as const, cmd: "code" },
|
|
||||||
{ name: "Cursor" as const, cmd: "cursor" },
|
|
||||||
{ name: "VSCodium" as const, cmd: "codium" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const log = Log.create({ service: "ide" })
|
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
Installed: BusEvent.define(
|
|
||||||
"ide.installed",
|
|
||||||
z.object({
|
|
||||||
ide: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", z.object({}))
|
|
||||||
|
|
||||||
export const InstallFailedError = NamedError.create(
|
|
||||||
"InstallFailedError",
|
|
||||||
z.object({
|
|
||||||
stderr: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export function ide() {
|
|
||||||
if (process.env["TERM_PROGRAM"] === "vscode") {
|
|
||||||
const v = process.env["GIT_ASKPASS"]
|
|
||||||
for (const ide of SUPPORTED_IDES) {
|
|
||||||
if (v?.includes(ide.name)) return ide.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function alreadyInstalled() {
|
|
||||||
return process.env["OPENCODE_CALLER"] === "vscode" || process.env["OPENCODE_CALLER"] === "vscode-insiders"
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function install(ide: (typeof SUPPORTED_IDES)[number]["name"]) {
|
|
||||||
const cmd = SUPPORTED_IDES.find((i) => i.name === ide)?.cmd
|
|
||||||
if (!cmd) throw new Error(`Unknown IDE: ${ide}`)
|
|
||||||
|
|
||||||
const p = await Process.run([cmd, "--install-extension", "sst-dev.opencode"], {
|
|
||||||
nothrow: true,
|
|
||||||
})
|
|
||||||
const stdout = p.stdout.toString()
|
|
||||||
const stderr = p.stderr.toString()
|
|
||||||
|
|
||||||
log.info("installed", {
|
|
||||||
ide,
|
|
||||||
stdout,
|
|
||||||
stderr,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (p.code !== 0) {
|
|
||||||
throw new InstallFailedError({ stderr })
|
|
||||||
}
|
|
||||||
if (stdout.includes("already installed")) {
|
|
||||||
throw new AlreadyInstalledError({})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +1,73 @@
|
|||||||
export * as Ide from "./ide"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import z from "zod"
|
||||||
|
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||||
|
import { Log } from "../util"
|
||||||
|
import { Process } from "@/util"
|
||||||
|
|
||||||
|
const SUPPORTED_IDES = [
|
||||||
|
{ name: "Windsurf" as const, cmd: "windsurf" },
|
||||||
|
{ name: "Visual Studio Code - Insiders" as const, cmd: "code-insiders" },
|
||||||
|
{ name: "Visual Studio Code" as const, cmd: "code" },
|
||||||
|
{ name: "Cursor" as const, cmd: "cursor" },
|
||||||
|
{ name: "VSCodium" as const, cmd: "codium" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const log = Log.create({ service: "ide" })
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Installed: BusEvent.define(
|
||||||
|
"ide.installed",
|
||||||
|
z.object({
|
||||||
|
ide: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", z.object({}))
|
||||||
|
|
||||||
|
export const InstallFailedError = NamedError.create(
|
||||||
|
"InstallFailedError",
|
||||||
|
z.object({
|
||||||
|
stderr: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export function ide() {
|
||||||
|
if (process.env["TERM_PROGRAM"] === "vscode") {
|
||||||
|
const v = process.env["GIT_ASKPASS"]
|
||||||
|
for (const ide of SUPPORTED_IDES) {
|
||||||
|
if (v?.includes(ide.name)) return ide.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alreadyInstalled() {
|
||||||
|
return process.env["OPENCODE_CALLER"] === "vscode" || process.env["OPENCODE_CALLER"] === "vscode-insiders"
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function install(ide: (typeof SUPPORTED_IDES)[number]["name"]) {
|
||||||
|
const cmd = SUPPORTED_IDES.find((i) => i.name === ide)?.cmd
|
||||||
|
if (!cmd) throw new Error(`Unknown IDE: ${ide}`)
|
||||||
|
|
||||||
|
const p = await Process.run([cmd, "--install-extension", "sst-dev.opencode"], {
|
||||||
|
nothrow: true,
|
||||||
|
})
|
||||||
|
const stdout = p.stdout.toString()
|
||||||
|
const stderr = p.stderr.toString()
|
||||||
|
|
||||||
|
log.info("installed", {
|
||||||
|
ide,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (p.code !== 0) {
|
||||||
|
throw new InstallFailedError({ stderr })
|
||||||
|
}
|
||||||
|
if (stdout.includes("already installed")) {
|
||||||
|
throw new AlreadyInstalledError({})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as Ide from "."
|
||||||
|
|||||||
@@ -1 +1,338 @@
|
|||||||
export * as Installation from "./installation"
|
import { Effect, Layer, Schema, Context, Stream } from "effect"
|
||||||
|
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||||
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
|
import path from "path"
|
||||||
|
import z from "zod"
|
||||||
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { Flag } from "../flag/flag"
|
||||||
|
import { Log } from "../util"
|
||||||
|
|
||||||
|
import semver from "semver"
|
||||||
|
import { InstallationChannel, InstallationVersion } from "./version"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "installation" })
|
||||||
|
|
||||||
|
export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
|
||||||
|
|
||||||
|
export type ReleaseType = "patch" | "minor" | "major"
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Updated: BusEvent.define(
|
||||||
|
"installation.updated",
|
||||||
|
z.object({
|
||||||
|
version: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
UpdateAvailable: BusEvent.define(
|
||||||
|
"installation.update-available",
|
||||||
|
z.object({
|
||||||
|
version: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReleaseType(current: string, latest: string): ReleaseType {
|
||||||
|
const currMajor = semver.major(current)
|
||||||
|
const currMinor = semver.minor(current)
|
||||||
|
const newMajor = semver.major(latest)
|
||||||
|
const newMinor = semver.minor(latest)
|
||||||
|
|
||||||
|
if (newMajor > currMajor) return "major"
|
||||||
|
if (newMinor > currMinor) return "minor"
|
||||||
|
return "patch"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Info = z
|
||||||
|
.object({
|
||||||
|
version: z.string(),
|
||||||
|
latest: z.string(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "InstallationInfo",
|
||||||
|
})
|
||||||
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
|
export const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
|
||||||
|
|
||||||
|
export function isPreview() {
|
||||||
|
return InstallationChannel !== "latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocal() {
|
||||||
|
return InstallationChannel === "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
|
||||||
|
stderr: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
// Response schemas for external version APIs
|
||||||
|
const GitHubRelease = Schema.Struct({ tag_name: Schema.String })
|
||||||
|
const NpmPackage = Schema.Struct({ version: Schema.String })
|
||||||
|
const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })
|
||||||
|
const BrewInfoV2 = Schema.Struct({
|
||||||
|
formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })),
|
||||||
|
})
|
||||||
|
const ChocoPackage = Schema.Struct({
|
||||||
|
d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }),
|
||||||
|
})
|
||||||
|
const ScoopManifest = NpmPackage
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly info: () => Effect.Effect<Info>
|
||||||
|
readonly method: () => Effect.Effect<Method>
|
||||||
|
readonly latest: (method?: Method) => Effect.Effect<string>
|
||||||
|
readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Installation") {}
|
||||||
|
|
||||||
|
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner> =
|
||||||
|
Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const http = yield* HttpClient.HttpClient
|
||||||
|
const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http))
|
||||||
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
|
|
||||||
|
const text = Effect.fnUntraced(
|
||||||
|
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
||||||
|
const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
|
||||||
|
cwd: opts?.cwd,
|
||||||
|
env: opts?.env,
|
||||||
|
extendEnv: true,
|
||||||
|
})
|
||||||
|
const handle = yield* spawner.spawn(proc)
|
||||||
|
const out = yield* Stream.mkString(Stream.decodeText(handle.stdout))
|
||||||
|
yield* handle.exitCode
|
||||||
|
return out
|
||||||
|
},
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.catch(() => Effect.succeed("")),
|
||||||
|
)
|
||||||
|
|
||||||
|
const run = Effect.fnUntraced(
|
||||||
|
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
||||||
|
const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
|
||||||
|
cwd: opts?.cwd,
|
||||||
|
env: opts?.env,
|
||||||
|
extendEnv: true,
|
||||||
|
})
|
||||||
|
const handle = yield* spawner.spawn(proc)
|
||||||
|
const [stdout, stderr] = yield* Effect.all(
|
||||||
|
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||||
|
{ concurrency: 2 },
|
||||||
|
)
|
||||||
|
const code = yield* handle.exitCode
|
||||||
|
return { code, stdout, stderr }
|
||||||
|
},
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const getBrewFormula = Effect.fnUntraced(function* () {
|
||||||
|
const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
|
||||||
|
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
|
||||||
|
const coreFormula = yield* text(["brew", "list", "--formula", "opencode"])
|
||||||
|
if (coreFormula.includes("opencode")) return "opencode"
|
||||||
|
return "opencode"
|
||||||
|
})
|
||||||
|
|
||||||
|
const upgradeCurl = Effect.fnUntraced(
|
||||||
|
function* (target: string) {
|
||||||
|
const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install"))
|
||||||
|
const body = yield* response.text
|
||||||
|
const bodyBytes = new TextEncoder().encode(body)
|
||||||
|
const proc = ChildProcess.make("bash", [], {
|
||||||
|
stdin: Stream.make(bodyBytes),
|
||||||
|
env: { VERSION: target },
|
||||||
|
extendEnv: true,
|
||||||
|
})
|
||||||
|
const handle = yield* spawner.spawn(proc)
|
||||||
|
const [stdout, stderr] = yield* Effect.all(
|
||||||
|
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||||
|
{ concurrency: 2 },
|
||||||
|
)
|
||||||
|
const code = yield* handle.exitCode
|
||||||
|
return { code, stdout, stderr }
|
||||||
|
},
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.orDie,
|
||||||
|
)
|
||||||
|
|
||||||
|
const methodImpl = Effect.fn("Installation.method")(function* () {
|
||||||
|
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method
|
||||||
|
if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method
|
||||||
|
const exec = process.execPath.toLowerCase()
|
||||||
|
|
||||||
|
const checks: Array<{ name: Method; command: () => Effect.Effect<string> }> = [
|
||||||
|
{ name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) },
|
||||||
|
{ name: "yarn", command: () => text(["yarn", "global", "list"]) },
|
||||||
|
{ name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) },
|
||||||
|
{ name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) },
|
||||||
|
{ name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) },
|
||||||
|
{ name: "scoop", command: () => text(["scoop", "list", "opencode"]) },
|
||||||
|
{ name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) },
|
||||||
|
]
|
||||||
|
|
||||||
|
checks.sort((a, b) => {
|
||||||
|
const aMatches = exec.includes(a.name)
|
||||||
|
const bMatches = exec.includes(b.name)
|
||||||
|
if (aMatches && !bMatches) return -1
|
||||||
|
if (!aMatches && bMatches) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const check of checks) {
|
||||||
|
const output = yield* check.command()
|
||||||
|
const installedName =
|
||||||
|
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
|
||||||
|
if (output.includes(installedName)) {
|
||||||
|
return check.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown" as Method
|
||||||
|
})
|
||||||
|
|
||||||
|
const latestImpl = Effect.fn("Installation.latest")(function* (installMethod?: Method) {
|
||||||
|
const detectedMethod = installMethod || (yield* methodImpl())
|
||||||
|
|
||||||
|
if (detectedMethod === "brew") {
|
||||||
|
const formula = yield* getBrewFormula()
|
||||||
|
if (formula.includes("/")) {
|
||||||
|
const infoJson = yield* text(["brew", "info", "--json=v2", formula])
|
||||||
|
const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson)
|
||||||
|
return info.formulae[0].versions.stable
|
||||||
|
}
|
||||||
|
const response = yield* httpOk.execute(
|
||||||
|
HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response)
|
||||||
|
return data.versions.stable
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
|
||||||
|
const r = (yield* text(["npm", "config", "get", "registry"])).trim()
|
||||||
|
const reg = r || "https://registry.npmjs.org"
|
||||||
|
const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg
|
||||||
|
const channel = InstallationChannel
|
||||||
|
const response = yield* httpOk.execute(
|
||||||
|
HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson),
|
||||||
|
)
|
||||||
|
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
|
||||||
|
return data.version
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detectedMethod === "choco") {
|
||||||
|
const response = yield* httpOk.execute(
|
||||||
|
HttpClientRequest.get(
|
||||||
|
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
|
||||||
|
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })),
|
||||||
|
)
|
||||||
|
const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response)
|
||||||
|
return data.d.results[0].Version
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detectedMethod === "scoop") {
|
||||||
|
const response = yield* httpOk.execute(
|
||||||
|
HttpClientRequest.get(
|
||||||
|
"https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json",
|
||||||
|
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })),
|
||||||
|
)
|
||||||
|
const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response)
|
||||||
|
return data.version
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = yield* httpOk.execute(
|
||||||
|
HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response)
|
||||||
|
return data.tag_name.replace(/^v/, "")
|
||||||
|
}, Effect.orDie)
|
||||||
|
|
||||||
|
const upgradeImpl = Effect.fn("Installation.upgrade")(function* (m: Method, target: string) {
|
||||||
|
let result: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined
|
||||||
|
switch (m) {
|
||||||
|
case "curl":
|
||||||
|
result = yield* upgradeCurl(target)
|
||||||
|
break
|
||||||
|
case "npm":
|
||||||
|
result = yield* run(["npm", "install", "-g", `opencode-ai@${target}`])
|
||||||
|
break
|
||||||
|
case "pnpm":
|
||||||
|
result = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`])
|
||||||
|
break
|
||||||
|
case "bun":
|
||||||
|
result = yield* run(["bun", "install", "-g", `opencode-ai@${target}`])
|
||||||
|
break
|
||||||
|
case "brew": {
|
||||||
|
const formula = yield* getBrewFormula()
|
||||||
|
const env = { HOMEBREW_NO_AUTO_UPDATE: "1" }
|
||||||
|
if (formula.includes("/")) {
|
||||||
|
const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env })
|
||||||
|
if (tap.code !== 0) {
|
||||||
|
result = tap
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const repo = yield* text(["brew", "--repo", "anomalyco/tap"])
|
||||||
|
const dir = repo.trim()
|
||||||
|
if (dir) {
|
||||||
|
const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env })
|
||||||
|
if (pull.code !== 0) {
|
||||||
|
result = pull
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = yield* run(["brew", "upgrade", formula], { env })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "choco":
|
||||||
|
result = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"])
|
||||||
|
break
|
||||||
|
case "scoop":
|
||||||
|
result = yield* run(["scoop", "install", `opencode@${target}`])
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
|
||||||
|
}
|
||||||
|
if (!result || result.code !== 0) {
|
||||||
|
const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
|
||||||
|
return yield* new UpgradeFailedError({ stderr })
|
||||||
|
}
|
||||||
|
log.info("upgraded", {
|
||||||
|
method: m,
|
||||||
|
target,
|
||||||
|
stdout: result.stdout,
|
||||||
|
stderr: result.stderr,
|
||||||
|
})
|
||||||
|
yield* text([process.execPath, "--version"])
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
info: Effect.fn("Installation.info")(function* () {
|
||||||
|
return {
|
||||||
|
version: InstallationVersion,
|
||||||
|
latest: yield* latestImpl(),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
method: methodImpl,
|
||||||
|
latest: latestImpl,
|
||||||
|
upgrade: upgradeImpl,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(FetchHttpClient.layer),
|
||||||
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
|
export * as Installation from "."
|
||||||
|
|||||||
@@ -1,336 +0,0 @@
|
|||||||
import { Effect, Layer, Schema, Context, Stream } from "effect"
|
|
||||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
|
||||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
|
||||||
import path from "path"
|
|
||||||
import z from "zod"
|
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
|
||||||
import { Flag } from "../flag/flag"
|
|
||||||
import { Log } from "../util"
|
|
||||||
|
|
||||||
import semver from "semver"
|
|
||||||
import { InstallationChannel, InstallationVersion } from "./version"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "installation" })
|
|
||||||
|
|
||||||
export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
|
|
||||||
|
|
||||||
export type ReleaseType = "patch" | "minor" | "major"
|
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
Updated: BusEvent.define(
|
|
||||||
"installation.updated",
|
|
||||||
z.object({
|
|
||||||
version: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
UpdateAvailable: BusEvent.define(
|
|
||||||
"installation.update-available",
|
|
||||||
z.object({
|
|
||||||
version: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getReleaseType(current: string, latest: string): ReleaseType {
|
|
||||||
const currMajor = semver.major(current)
|
|
||||||
const currMinor = semver.minor(current)
|
|
||||||
const newMajor = semver.major(latest)
|
|
||||||
const newMinor = semver.minor(latest)
|
|
||||||
|
|
||||||
if (newMajor > currMajor) return "major"
|
|
||||||
if (newMinor > currMinor) return "minor"
|
|
||||||
return "patch"
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Info = z
|
|
||||||
.object({
|
|
||||||
version: z.string(),
|
|
||||||
latest: z.string(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "InstallationInfo",
|
|
||||||
})
|
|
||||||
export type Info = z.infer<typeof Info>
|
|
||||||
|
|
||||||
export const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
|
|
||||||
|
|
||||||
export function isPreview() {
|
|
||||||
return InstallationChannel !== "latest"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isLocal() {
|
|
||||||
return InstallationChannel === "local"
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
|
|
||||||
stderr: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
// Response schemas for external version APIs
|
|
||||||
const GitHubRelease = Schema.Struct({ tag_name: Schema.String })
|
|
||||||
const NpmPackage = Schema.Struct({ version: Schema.String })
|
|
||||||
const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })
|
|
||||||
const BrewInfoV2 = Schema.Struct({
|
|
||||||
formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })),
|
|
||||||
})
|
|
||||||
const ChocoPackage = Schema.Struct({
|
|
||||||
d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }),
|
|
||||||
})
|
|
||||||
const ScoopManifest = NpmPackage
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly info: () => Effect.Effect<Info>
|
|
||||||
readonly method: () => Effect.Effect<Method>
|
|
||||||
readonly latest: (method?: Method) => Effect.Effect<string>
|
|
||||||
readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Installation") {}
|
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner> =
|
|
||||||
Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const http = yield* HttpClient.HttpClient
|
|
||||||
const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http))
|
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
||||||
|
|
||||||
const text = Effect.fnUntraced(
|
|
||||||
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
|
||||||
const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
|
|
||||||
cwd: opts?.cwd,
|
|
||||||
env: opts?.env,
|
|
||||||
extendEnv: true,
|
|
||||||
})
|
|
||||||
const handle = yield* spawner.spawn(proc)
|
|
||||||
const out = yield* Stream.mkString(Stream.decodeText(handle.stdout))
|
|
||||||
yield* handle.exitCode
|
|
||||||
return out
|
|
||||||
},
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.catch(() => Effect.succeed("")),
|
|
||||||
)
|
|
||||||
|
|
||||||
const run = Effect.fnUntraced(
|
|
||||||
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
|
||||||
const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
|
|
||||||
cwd: opts?.cwd,
|
|
||||||
env: opts?.env,
|
|
||||||
extendEnv: true,
|
|
||||||
})
|
|
||||||
const handle = yield* spawner.spawn(proc)
|
|
||||||
const [stdout, stderr] = yield* Effect.all(
|
|
||||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
const code = yield* handle.exitCode
|
|
||||||
return { code, stdout, stderr }
|
|
||||||
},
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
|
|
||||||
)
|
|
||||||
|
|
||||||
const getBrewFormula = Effect.fnUntraced(function* () {
|
|
||||||
const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
|
|
||||||
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
|
|
||||||
const coreFormula = yield* text(["brew", "list", "--formula", "opencode"])
|
|
||||||
if (coreFormula.includes("opencode")) return "opencode"
|
|
||||||
return "opencode"
|
|
||||||
})
|
|
||||||
|
|
||||||
const upgradeCurl = Effect.fnUntraced(
|
|
||||||
function* (target: string) {
|
|
||||||
const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install"))
|
|
||||||
const body = yield* response.text
|
|
||||||
const bodyBytes = new TextEncoder().encode(body)
|
|
||||||
const proc = ChildProcess.make("bash", [], {
|
|
||||||
stdin: Stream.make(bodyBytes),
|
|
||||||
env: { VERSION: target },
|
|
||||||
extendEnv: true,
|
|
||||||
})
|
|
||||||
const handle = yield* spawner.spawn(proc)
|
|
||||||
const [stdout, stderr] = yield* Effect.all(
|
|
||||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
const code = yield* handle.exitCode
|
|
||||||
return { code, stdout, stderr }
|
|
||||||
},
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.orDie,
|
|
||||||
)
|
|
||||||
|
|
||||||
const methodImpl = Effect.fn("Installation.method")(function* () {
|
|
||||||
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method
|
|
||||||
if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method
|
|
||||||
const exec = process.execPath.toLowerCase()
|
|
||||||
|
|
||||||
const checks: Array<{ name: Method; command: () => Effect.Effect<string> }> = [
|
|
||||||
{ name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) },
|
|
||||||
{ name: "yarn", command: () => text(["yarn", "global", "list"]) },
|
|
||||||
{ name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) },
|
|
||||||
{ name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) },
|
|
||||||
{ name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) },
|
|
||||||
{ name: "scoop", command: () => text(["scoop", "list", "opencode"]) },
|
|
||||||
{ name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) },
|
|
||||||
]
|
|
||||||
|
|
||||||
checks.sort((a, b) => {
|
|
||||||
const aMatches = exec.includes(a.name)
|
|
||||||
const bMatches = exec.includes(b.name)
|
|
||||||
if (aMatches && !bMatches) return -1
|
|
||||||
if (!aMatches && bMatches) return 1
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const check of checks) {
|
|
||||||
const output = yield* check.command()
|
|
||||||
const installedName =
|
|
||||||
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
|
|
||||||
if (output.includes(installedName)) {
|
|
||||||
return check.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "unknown" as Method
|
|
||||||
})
|
|
||||||
|
|
||||||
const latestImpl = Effect.fn("Installation.latest")(function* (installMethod?: Method) {
|
|
||||||
const detectedMethod = installMethod || (yield* methodImpl())
|
|
||||||
|
|
||||||
if (detectedMethod === "brew") {
|
|
||||||
const formula = yield* getBrewFormula()
|
|
||||||
if (formula.includes("/")) {
|
|
||||||
const infoJson = yield* text(["brew", "info", "--json=v2", formula])
|
|
||||||
const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson)
|
|
||||||
return info.formulae[0].versions.stable
|
|
||||||
}
|
|
||||||
const response = yield* httpOk.execute(
|
|
||||||
HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response)
|
|
||||||
return data.versions.stable
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
|
|
||||||
const r = (yield* text(["npm", "config", "get", "registry"])).trim()
|
|
||||||
const reg = r || "https://registry.npmjs.org"
|
|
||||||
const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg
|
|
||||||
const channel = InstallationChannel
|
|
||||||
const response = yield* httpOk.execute(
|
|
||||||
HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson),
|
|
||||||
)
|
|
||||||
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
|
|
||||||
return data.version
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detectedMethod === "choco") {
|
|
||||||
const response = yield* httpOk.execute(
|
|
||||||
HttpClientRequest.get(
|
|
||||||
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
|
|
||||||
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })),
|
|
||||||
)
|
|
||||||
const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response)
|
|
||||||
return data.d.results[0].Version
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detectedMethod === "scoop") {
|
|
||||||
const response = yield* httpOk.execute(
|
|
||||||
HttpClientRequest.get(
|
|
||||||
"https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json",
|
|
||||||
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })),
|
|
||||||
)
|
|
||||||
const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response)
|
|
||||||
return data.version
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = yield* httpOk.execute(
|
|
||||||
HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response)
|
|
||||||
return data.tag_name.replace(/^v/, "")
|
|
||||||
}, Effect.orDie)
|
|
||||||
|
|
||||||
const upgradeImpl = Effect.fn("Installation.upgrade")(function* (m: Method, target: string) {
|
|
||||||
let result: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined
|
|
||||||
switch (m) {
|
|
||||||
case "curl":
|
|
||||||
result = yield* upgradeCurl(target)
|
|
||||||
break
|
|
||||||
case "npm":
|
|
||||||
result = yield* run(["npm", "install", "-g", `opencode-ai@${target}`])
|
|
||||||
break
|
|
||||||
case "pnpm":
|
|
||||||
result = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`])
|
|
||||||
break
|
|
||||||
case "bun":
|
|
||||||
result = yield* run(["bun", "install", "-g", `opencode-ai@${target}`])
|
|
||||||
break
|
|
||||||
case "brew": {
|
|
||||||
const formula = yield* getBrewFormula()
|
|
||||||
const env = { HOMEBREW_NO_AUTO_UPDATE: "1" }
|
|
||||||
if (formula.includes("/")) {
|
|
||||||
const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env })
|
|
||||||
if (tap.code !== 0) {
|
|
||||||
result = tap
|
|
||||||
break
|
|
||||||
}
|
|
||||||
const repo = yield* text(["brew", "--repo", "anomalyco/tap"])
|
|
||||||
const dir = repo.trim()
|
|
||||||
if (dir) {
|
|
||||||
const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env })
|
|
||||||
if (pull.code !== 0) {
|
|
||||||
result = pull
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result = yield* run(["brew", "upgrade", formula], { env })
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "choco":
|
|
||||||
result = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"])
|
|
||||||
break
|
|
||||||
case "scoop":
|
|
||||||
result = yield* run(["scoop", "install", `opencode@${target}`])
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
|
|
||||||
}
|
|
||||||
if (!result || result.code !== 0) {
|
|
||||||
const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
|
|
||||||
return yield* new UpgradeFailedError({ stderr })
|
|
||||||
}
|
|
||||||
log.info("upgraded", {
|
|
||||||
method: m,
|
|
||||||
target,
|
|
||||||
stdout: result.stdout,
|
|
||||||
stderr: result.stderr,
|
|
||||||
})
|
|
||||||
yield* text([process.execPath, "--version"])
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
info: Effect.fn("Installation.info")(function* () {
|
|
||||||
return {
|
|
||||||
version: InstallationVersion,
|
|
||||||
latest: yield* latestImpl(),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
method: methodImpl,
|
|
||||||
latest: latestImpl,
|
|
||||||
upgrade: upgradeImpl,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
|
||||||
Layer.provide(FetchHttpClient.layer),
|
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
|
||||||
)
|
|
||||||
@@ -5,4 +5,4 @@ declare global {
|
|||||||
|
|
||||||
export const InstallationVersion = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
export const InstallationVersion = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
||||||
export const InstallationChannel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
export const InstallationChannel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
||||||
export const InstallationLocal = InstallationVersion === "local"
|
export const InstallationLocal = InstallationChannel === "local"
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
const servers: Record<string, LSPServer.Info> = {}
|
const servers: Record<string, LSPServer.Info> = {}
|
||||||
|
|
||||||
if (cfg.lsp === false) {
|
if (!cfg.lsp) {
|
||||||
log.info("all LSPs are disabled")
|
log.info("all LSPs are disabled")
|
||||||
} else {
|
} else {
|
||||||
for (const server of Object.values(LSPServer)) {
|
for (const server of Object.values(LSPServer)) {
|
||||||
@@ -440,12 +440,11 @@ export const layer = Layer.effect(
|
|||||||
const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) {
|
const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) {
|
||||||
const results = yield* runAll((client) =>
|
const results = yield* runAll((client) =>
|
||||||
client.connection
|
client.connection
|
||||||
.sendRequest("workspace/symbol", { query })
|
.sendRequest<Symbol[]>("workspace/symbol", { query })
|
||||||
.then((result: any) => result.filter((x: Symbol) => kinds.includes(x.kind)))
|
.then((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10))
|
||||||
.then((result: any) => result.slice(0, 10))
|
.catch(() => [] as Symbol[]),
|
||||||
.catch(() => []),
|
|
||||||
)
|
)
|
||||||
return results.flat() as Symbol[]
|
return results.flat()
|
||||||
})
|
})
|
||||||
|
|
||||||
const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {
|
const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {
|
||||||
|
|||||||
@@ -1 +1,933 @@
|
|||||||
export * as MCP from "./mcp"
|
import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai"
|
||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||||
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||||
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
||||||
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||||
|
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||||
|
import {
|
||||||
|
CallToolResultSchema,
|
||||||
|
type Tool as MCPToolDef,
|
||||||
|
ToolListChangedNotificationSchema,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js"
|
||||||
|
import { Config } from "../config"
|
||||||
|
import { ConfigMCP } from "../config/mcp"
|
||||||
|
import { Log } from "../util"
|
||||||
|
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||||
|
import z from "zod/v4"
|
||||||
|
import { Instance } from "../project/instance"
|
||||||
|
import { Installation } from "../installation"
|
||||||
|
import { InstallationVersion } from "../installation/version"
|
||||||
|
import { withTimeout } from "@/util/timeout"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
|
import { McpOAuthProvider } from "./oauth-provider"
|
||||||
|
import { McpOAuthCallback } from "./oauth-callback"
|
||||||
|
import { McpAuth } from "./auth"
|
||||||
|
import { BusEvent } from "../bus/bus-event"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||||
|
import open from "open"
|
||||||
|
import { Effect, Exit, Layer, Option, Context, Stream } from "effect"
|
||||||
|
import { EffectBridge } from "@/effect"
|
||||||
|
import { InstanceState } from "@/effect"
|
||||||
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
|
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "mcp" })
|
||||||
|
const DEFAULT_TIMEOUT = 30_000
|
||||||
|
|
||||||
|
export const Resource = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
uri: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
mimeType: z.string().optional(),
|
||||||
|
client: z.string(),
|
||||||
|
})
|
||||||
|
.meta({ ref: "McpResource" })
|
||||||
|
export type Resource = z.infer<typeof Resource>
|
||||||
|
|
||||||
|
export const ToolsChanged = BusEvent.define(
|
||||||
|
"mcp.tools.changed",
|
||||||
|
z.object({
|
||||||
|
server: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const BrowserOpenFailed = BusEvent.define(
|
||||||
|
"mcp.browser.open.failed",
|
||||||
|
z.object({
|
||||||
|
mcpName: z.string(),
|
||||||
|
url: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Failed = NamedError.create(
|
||||||
|
"MCPFailed",
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
type MCPClient = Client
|
||||||
|
|
||||||
|
export const Status = z
|
||||||
|
.discriminatedUnion("status", [
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
status: z.literal("connected"),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "MCPStatusConnected",
|
||||||
|
}),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
status: z.literal("disabled"),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "MCPStatusDisabled",
|
||||||
|
}),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
status: z.literal("failed"),
|
||||||
|
error: z.string(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "MCPStatusFailed",
|
||||||
|
}),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
status: z.literal("needs_auth"),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "MCPStatusNeedsAuth",
|
||||||
|
}),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
status: z.literal("needs_client_registration"),
|
||||||
|
error: z.string(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "MCPStatusNeedsClientRegistration",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
.meta({
|
||||||
|
ref: "MCPStatus",
|
||||||
|
})
|
||||||
|
export type Status = z.infer<typeof Status>
|
||||||
|
|
||||||
|
// Store transports for OAuth servers to allow finishing auth
|
||||||
|
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
|
||||||
|
const pendingOAuthTransports = new Map<string, TransportWithAuth>()
|
||||||
|
|
||||||
|
// Prompt cache types
|
||||||
|
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
|
||||||
|
type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"][number]
|
||||||
|
type McpEntry = NonNullable<Config.Info["mcp"]>[string]
|
||||||
|
|
||||||
|
function isMcpConfigured(entry: McpEntry): entry is ConfigMCP.Info {
|
||||||
|
return typeof entry === "object" && entry !== null && "type" in entry
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||||
|
|
||||||
|
// Convert MCP tool definition to AI SDK Tool type
|
||||||
|
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
|
||||||
|
const inputSchema = mcpTool.inputSchema
|
||||||
|
|
||||||
|
// Spread first, then override type to ensure it's always "object"
|
||||||
|
const schema: JSONSchema7 = {
|
||||||
|
...(inputSchema as JSONSchema7),
|
||||||
|
type: "object",
|
||||||
|
properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"],
|
||||||
|
additionalProperties: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
return dynamicTool({
|
||||||
|
description: mcpTool.description ?? "",
|
||||||
|
inputSchema: jsonSchema(schema),
|
||||||
|
execute: async (args: unknown) => {
|
||||||
|
return client.callTool(
|
||||||
|
{
|
||||||
|
name: mcpTool.name,
|
||||||
|
arguments: (args || {}) as Record<string, unknown>,
|
||||||
|
},
|
||||||
|
CallToolResultSchema,
|
||||||
|
{
|
||||||
|
resetTimeoutOnProgress: true,
|
||||||
|
timeout,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function defs(key: string, client: MCPClient, timeout?: number) {
|
||||||
|
return Effect.tryPromise({
|
||||||
|
try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT),
|
||||||
|
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
|
||||||
|
}).pipe(
|
||||||
|
Effect.map((result) => result.tools),
|
||||||
|
Effect.catch((err) => {
|
||||||
|
log.error("failed to get tools from client", { key, error: err })
|
||||||
|
return Effect.succeed(undefined)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchFromClient<T extends { name: string }>(
|
||||||
|
clientName: string,
|
||||||
|
client: Client,
|
||||||
|
listFn: (c: Client) => Promise<T[]>,
|
||||||
|
label: string,
|
||||||
|
) {
|
||||||
|
return Effect.tryPromise({
|
||||||
|
try: () => listFn(client),
|
||||||
|
catch: (e: any) => {
|
||||||
|
log.error(`failed to get ${label}`, { clientName, error: e.message })
|
||||||
|
return e
|
||||||
|
},
|
||||||
|
}).pipe(
|
||||||
|
Effect.map((items) => {
|
||||||
|
const out: Record<string, T & { client: string }> = {}
|
||||||
|
const sanitizedClient = sanitize(clientName)
|
||||||
|
for (const item of items) {
|
||||||
|
out[sanitizedClient + ":" + sanitize(item.name)] = { ...item, client: clientName }
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}),
|
||||||
|
Effect.orElseSucceed(() => undefined),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateResult {
|
||||||
|
mcpClient?: MCPClient
|
||||||
|
status: Status
|
||||||
|
defs?: MCPToolDef[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthResult {
|
||||||
|
authorizationUrl: string
|
||||||
|
oauthState: string
|
||||||
|
client?: MCPClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Effect Service ---
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
status: Record<string, Status>
|
||||||
|
clients: Record<string, MCPClient>
|
||||||
|
defs: Record<string, MCPToolDef[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly status: () => Effect.Effect<Record<string, Status>>
|
||||||
|
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
|
||||||
|
readonly tools: () => Effect.Effect<Record<string, Tool>>
|
||||||
|
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
|
||||||
|
readonly resources: () => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
|
||||||
|
readonly add: (name: string, mcp: ConfigMCP.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
|
||||||
|
readonly connect: (name: string) => Effect.Effect<void>
|
||||||
|
readonly disconnect: (name: string) => Effect.Effect<void>
|
||||||
|
readonly getPrompt: (
|
||||||
|
clientName: string,
|
||||||
|
name: string,
|
||||||
|
args?: Record<string, string>,
|
||||||
|
) => Effect.Effect<Awaited<ReturnType<MCPClient["getPrompt"]>> | undefined>
|
||||||
|
readonly readResource: (
|
||||||
|
clientName: string,
|
||||||
|
resourceUri: string,
|
||||||
|
) => Effect.Effect<Awaited<ReturnType<MCPClient["readResource"]>> | undefined>
|
||||||
|
readonly startAuth: (mcpName: string) => Effect.Effect<{ authorizationUrl: string; oauthState: string }>
|
||||||
|
readonly authenticate: (mcpName: string) => Effect.Effect<Status>
|
||||||
|
readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect<Status>
|
||||||
|
readonly removeAuth: (mcpName: string) => Effect.Effect<void>
|
||||||
|
readonly supportsOAuth: (mcpName: string) => Effect.Effect<boolean>
|
||||||
|
readonly hasStoredTokens: (mcpName: string) => Effect.Effect<boolean>
|
||||||
|
readonly getAuthStatus: (mcpName: string) => Effect.Effect<AuthStatus>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/MCP") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
|
const auth = yield* McpAuth.Service
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
|
||||||
|
type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect a client via the given transport with resource safety:
|
||||||
|
* on failure the transport is closed; on success the caller owns it.
|
||||||
|
*/
|
||||||
|
const connectTransport = (transport: Transport, timeout: number) =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.succeed(transport),
|
||||||
|
(t) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: () => {
|
||||||
|
const client = new Client({ name: "opencode", version: InstallationVersion })
|
||||||
|
return withTimeout(client.connect(t), timeout).then(() => client)
|
||||||
|
},
|
||||||
|
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
|
||||||
|
}),
|
||||||
|
(t, exit) => (Exit.isFailure(exit) ? Effect.tryPromise(() => t.close()).pipe(Effect.ignore) : Effect.void),
|
||||||
|
)
|
||||||
|
|
||||||
|
const DISABLED_RESULT: CreateResult = { status: { status: "disabled" } }
|
||||||
|
|
||||||
|
const connectRemote = Effect.fn("MCP.connectRemote")(function* (
|
||||||
|
key: string,
|
||||||
|
mcp: ConfigMCP.Info & { type: "remote" },
|
||||||
|
) {
|
||||||
|
const oauthDisabled = mcp.oauth === false
|
||||||
|
const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined
|
||||||
|
let authProvider: McpOAuthProvider | undefined
|
||||||
|
|
||||||
|
if (!oauthDisabled) {
|
||||||
|
authProvider = new McpOAuthProvider(
|
||||||
|
key,
|
||||||
|
mcp.url,
|
||||||
|
{
|
||||||
|
clientId: oauthConfig?.clientId,
|
||||||
|
clientSecret: oauthConfig?.clientSecret,
|
||||||
|
scope: oauthConfig?.scope,
|
||||||
|
redirectUri: oauthConfig?.redirectUri,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onRedirect: async (url) => {
|
||||||
|
log.info("oauth redirect requested", { key, url: url.toString() })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const transports: Array<{ name: string; transport: TransportWithAuth }> = [
|
||||||
|
{
|
||||||
|
name: "StreamableHTTP",
|
||||||
|
transport: new StreamableHTTPClientTransport(new URL(mcp.url), {
|
||||||
|
authProvider,
|
||||||
|
requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SSE",
|
||||||
|
transport: new SSEClientTransport(new URL(mcp.url), {
|
||||||
|
authProvider,
|
||||||
|
requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
|
||||||
|
let lastStatus: Status | undefined
|
||||||
|
|
||||||
|
for (const { name, transport } of transports) {
|
||||||
|
const result = yield* connectTransport(transport, connectTimeout).pipe(
|
||||||
|
Effect.map((client) => ({ client, transportName: name })),
|
||||||
|
Effect.catch((error) => {
|
||||||
|
const lastError = error instanceof Error ? error : new Error(String(error))
|
||||||
|
const isAuthError =
|
||||||
|
error instanceof UnauthorizedError || (authProvider && lastError.message.includes("OAuth"))
|
||||||
|
|
||||||
|
if (isAuthError) {
|
||||||
|
log.info("mcp server requires authentication", { key, transport: name })
|
||||||
|
|
||||||
|
if (lastError.message.includes("registration") || lastError.message.includes("client_id")) {
|
||||||
|
lastStatus = {
|
||||||
|
status: "needs_client_registration" as const,
|
||||||
|
error: "Server does not support dynamic client registration. Please provide clientId in config.",
|
||||||
|
}
|
||||||
|
return bus
|
||||||
|
.publish(TuiEvent.ToastShow, {
|
||||||
|
title: "MCP Authentication Required",
|
||||||
|
message: `Server "${key}" requires a pre-registered client ID. Add clientId to your config.`,
|
||||||
|
variant: "warning",
|
||||||
|
duration: 8000,
|
||||||
|
})
|
||||||
|
.pipe(Effect.ignore, Effect.as(undefined))
|
||||||
|
} else {
|
||||||
|
pendingOAuthTransports.set(key, transport)
|
||||||
|
lastStatus = { status: "needs_auth" as const }
|
||||||
|
return bus
|
||||||
|
.publish(TuiEvent.ToastShow, {
|
||||||
|
title: "MCP Authentication Required",
|
||||||
|
message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`,
|
||||||
|
variant: "warning",
|
||||||
|
duration: 8000,
|
||||||
|
})
|
||||||
|
.pipe(Effect.ignore, Effect.as(undefined))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("transport connection failed", {
|
||||||
|
key,
|
||||||
|
transport: name,
|
||||||
|
url: mcp.url,
|
||||||
|
error: lastError.message,
|
||||||
|
})
|
||||||
|
lastStatus = { status: "failed" as const, error: lastError.message }
|
||||||
|
return Effect.succeed(undefined)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (result) {
|
||||||
|
log.info("connected", { key, transport: result.transportName })
|
||||||
|
return { client: result.client as MCPClient | undefined, status: { status: "connected" } as Status }
|
||||||
|
}
|
||||||
|
// If this was an auth error, stop trying other transports
|
||||||
|
if (lastStatus?.status === "needs_auth" || lastStatus?.status === "needs_client_registration") break
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
client: undefined as MCPClient | undefined,
|
||||||
|
status: (lastStatus ?? { status: "failed", error: "Unknown error" }) as Status,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const connectLocal = Effect.fn("MCP.connectLocal")(function* (
|
||||||
|
key: string,
|
||||||
|
mcp: ConfigMCP.Info & { type: "local" },
|
||||||
|
) {
|
||||||
|
const [cmd, ...args] = mcp.command
|
||||||
|
const cwd = Instance.directory
|
||||||
|
const transport = new StdioClientTransport({
|
||||||
|
stderr: "pipe",
|
||||||
|
command: cmd,
|
||||||
|
args,
|
||||||
|
cwd,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
|
||||||
|
...mcp.environment,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
transport.stderr?.on("data", (chunk: Buffer) => {
|
||||||
|
log.info(`mcp stderr: ${chunk.toString()}`, { key })
|
||||||
|
})
|
||||||
|
|
||||||
|
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
|
||||||
|
return yield* connectTransport(transport, connectTimeout).pipe(
|
||||||
|
Effect.map((client): { client: MCPClient | undefined; status: Status } => ({
|
||||||
|
client,
|
||||||
|
status: { status: "connected" },
|
||||||
|
})),
|
||||||
|
Effect.catch((error): Effect.Effect<{ client: MCPClient | undefined; status: Status }> => {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error)
|
||||||
|
log.error("local mcp startup failed", { key, command: mcp.command, cwd, error: msg })
|
||||||
|
return Effect.succeed({ client: undefined, status: { status: "failed", error: msg } })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const create = Effect.fn("MCP.create")(function* (key: string, mcp: ConfigMCP.Info) {
|
||||||
|
if (mcp.enabled === false) {
|
||||||
|
log.info("mcp server disabled", { key })
|
||||||
|
return DISABLED_RESULT
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("found", { key, type: mcp.type })
|
||||||
|
|
||||||
|
const { client: mcpClient, status } =
|
||||||
|
mcp.type === "remote"
|
||||||
|
? yield* connectRemote(key, mcp as ConfigMCP.Info & { type: "remote" })
|
||||||
|
: yield* connectLocal(key, mcp as ConfigMCP.Info & { type: "local" })
|
||||||
|
|
||||||
|
if (!mcpClient) {
|
||||||
|
return { status } satisfies CreateResult
|
||||||
|
}
|
||||||
|
|
||||||
|
const listed = yield* defs(key, mcpClient, mcp.timeout)
|
||||||
|
if (!listed) {
|
||||||
|
yield* Effect.tryPromise(() => mcpClient.close()).pipe(Effect.ignore)
|
||||||
|
return { status: { status: "failed", error: "Failed to get tools" } } satisfies CreateResult
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("create() successfully created client", { key, toolCount: listed.length })
|
||||||
|
return { mcpClient, status, defs: listed } satisfies CreateResult
|
||||||
|
})
|
||||||
|
const cfgSvc = yield* Config.Service
|
||||||
|
|
||||||
|
const descendants = Effect.fnUntraced(
|
||||||
|
function* (pid: number) {
|
||||||
|
if (process.platform === "win32") return [] as number[]
|
||||||
|
const pids: number[] = []
|
||||||
|
const queue = [pid]
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift()!
|
||||||
|
const handle = yield* spawner.spawn(ChildProcess.make("pgrep", ["-P", String(current)], { stdin: "ignore" }))
|
||||||
|
const text = yield* Stream.mkString(Stream.decodeText(handle.stdout))
|
||||||
|
yield* handle.exitCode
|
||||||
|
for (const tok of text.split("\n")) {
|
||||||
|
const cpid = parseInt(tok, 10)
|
||||||
|
if (!isNaN(cpid) && !pids.includes(cpid)) {
|
||||||
|
pids.push(cpid)
|
||||||
|
queue.push(cpid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pids
|
||||||
|
},
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.catch(() => Effect.succeed([] as number[])),
|
||||||
|
)
|
||||||
|
|
||||||
|
function watch(s: State, name: string, client: MCPClient, bridge: EffectBridge.Shape, timeout?: number) {
|
||||||
|
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
||||||
|
log.info("tools list changed notification received", { server: name })
|
||||||
|
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
||||||
|
|
||||||
|
const listed = await bridge.promise(defs(name, client, timeout))
|
||||||
|
if (!listed) return
|
||||||
|
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
||||||
|
|
||||||
|
s.defs[name] = listed
|
||||||
|
await bridge.promise(bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("MCP.state")(function* () {
|
||||||
|
const cfg = yield* cfgSvc.get()
|
||||||
|
const bridge = yield* EffectBridge.make()
|
||||||
|
const config = cfg.mcp ?? {}
|
||||||
|
const s: State = {
|
||||||
|
status: {},
|
||||||
|
clients: {},
|
||||||
|
defs: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Effect.forEach(
|
||||||
|
Object.entries(config),
|
||||||
|
([key, mcp]) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (!isMcpConfigured(mcp)) {
|
||||||
|
log.error("Ignoring MCP config entry without type", { key })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mcp.enabled === false) {
|
||||||
|
s.status[key] = { status: "disabled" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = yield* create(key, mcp).pipe(Effect.catch(() => Effect.void))
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
|
s.status[key] = result.status
|
||||||
|
if (result.mcpClient) {
|
||||||
|
s.clients[key] = result.mcpClient
|
||||||
|
s.defs[key] = result.defs!
|
||||||
|
watch(s, key, result.mcpClient, bridge, mcp.timeout)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.forEach(
|
||||||
|
Object.values(s.clients),
|
||||||
|
(client) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pid = client.transport instanceof StdioClientTransport ? client.transport.pid : null
|
||||||
|
if (typeof pid === "number") {
|
||||||
|
const pids = yield* descendants(pid)
|
||||||
|
for (const dpid of pids) {
|
||||||
|
try {
|
||||||
|
process.kill(dpid, "SIGTERM")
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield* Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
|
||||||
|
}),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
pendingOAuthTransports.clear()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return s
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function closeClient(s: State, name: string) {
|
||||||
|
const client = s.clients[name]
|
||||||
|
delete s.defs[name]
|
||||||
|
if (!client) return Effect.void
|
||||||
|
return Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeClient = Effect.fnUntraced(function* (
|
||||||
|
s: State,
|
||||||
|
name: string,
|
||||||
|
client: MCPClient,
|
||||||
|
listed: MCPToolDef[],
|
||||||
|
timeout?: number,
|
||||||
|
) {
|
||||||
|
const bridge = yield* EffectBridge.make()
|
||||||
|
yield* closeClient(s, name)
|
||||||
|
s.status[name] = { status: "connected" }
|
||||||
|
s.clients[name] = client
|
||||||
|
s.defs[name] = listed
|
||||||
|
watch(s, name, client, bridge, timeout)
|
||||||
|
return s.status[name]
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = Effect.fn("MCP.status")(function* () {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
|
||||||
|
const cfg = yield* cfgSvc.get()
|
||||||
|
const config = cfg.mcp ?? {}
|
||||||
|
const result: Record<string, Status> = {}
|
||||||
|
|
||||||
|
for (const [key, mcp] of Object.entries(config)) {
|
||||||
|
if (!isMcpConfigured(mcp)) continue
|
||||||
|
result[key] = s.status[key] ?? { status: "disabled" }
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const clients = Effect.fn("MCP.clients")(function* () {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
return s.clients
|
||||||
|
})
|
||||||
|
|
||||||
|
const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: ConfigMCP.Info) {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
const result = yield* create(name, mcp)
|
||||||
|
|
||||||
|
s.status[name] = result.status
|
||||||
|
if (!result.mcpClient) {
|
||||||
|
yield* closeClient(s, name)
|
||||||
|
delete s.clients[name]
|
||||||
|
return result.status
|
||||||
|
}
|
||||||
|
|
||||||
|
return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout)
|
||||||
|
})
|
||||||
|
|
||||||
|
const add = Effect.fn("MCP.add")(function* (name: string, mcp: ConfigMCP.Info) {
|
||||||
|
yield* createAndStore(name, mcp)
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
return { status: s.status }
|
||||||
|
})
|
||||||
|
|
||||||
|
const connect = Effect.fn("MCP.connect")(function* (name: string) {
|
||||||
|
const mcp = yield* getMcpConfig(name)
|
||||||
|
if (!mcp) {
|
||||||
|
log.error("MCP config not found or invalid", { name })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
yield* createAndStore(name, { ...mcp, enabled: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
yield* closeClient(s, name)
|
||||||
|
delete s.clients[name]
|
||||||
|
s.status[name] = { status: "disabled" }
|
||||||
|
})
|
||||||
|
|
||||||
|
const tools = Effect.fn("MCP.tools")(function* () {
|
||||||
|
const result: Record<string, Tool> = {}
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
|
||||||
|
const cfg = yield* cfgSvc.get()
|
||||||
|
const config = cfg.mcp ?? {}
|
||||||
|
const defaultTimeout = cfg.experimental?.mcp_timeout
|
||||||
|
|
||||||
|
const connectedClients = Object.entries(s.clients).filter(
|
||||||
|
([clientName]) => s.status[clientName]?.status === "connected",
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* Effect.forEach(
|
||||||
|
connectedClients,
|
||||||
|
([clientName, client]) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const mcpConfig = config[clientName]
|
||||||
|
const entry = mcpConfig && isMcpConfigured(mcpConfig) ? mcpConfig : undefined
|
||||||
|
|
||||||
|
const listed = s.defs[clientName]
|
||||||
|
if (!listed) {
|
||||||
|
log.warn("missing cached tools for connected server", { clientName })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = entry?.timeout ?? defaultTimeout
|
||||||
|
for (const mcpTool of listed) {
|
||||||
|
result[sanitize(clientName) + "_" + sanitize(mcpTool.name)] = convertMcpTool(mcpTool, client, timeout)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
function collectFromConnected<T extends { name: string }>(
|
||||||
|
s: State,
|
||||||
|
listFn: (c: Client) => Promise<T[]>,
|
||||||
|
label: string,
|
||||||
|
) {
|
||||||
|
return Effect.forEach(
|
||||||
|
Object.entries(s.clients).filter(([name]) => s.status[name]?.status === "connected"),
|
||||||
|
([clientName, client]) =>
|
||||||
|
fetchFromClient(clientName, client, listFn, label).pipe(Effect.map((items) => Object.entries(items ?? {}))),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
).pipe(Effect.map((results) => Object.fromEntries<T & { client: string }>(results.flat())))
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompts = Effect.fn("MCP.prompts")(function* () {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
return yield* collectFromConnected(s, (c) => c.listPrompts().then((r) => r.prompts), "prompts")
|
||||||
|
})
|
||||||
|
|
||||||
|
const resources = Effect.fn("MCP.resources")(function* () {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
return yield* collectFromConnected(s, (c) => c.listResources().then((r) => r.resources), "resources")
|
||||||
|
})
|
||||||
|
|
||||||
|
const withClient = Effect.fnUntraced(function* <A>(
|
||||||
|
clientName: string,
|
||||||
|
fn: (client: MCPClient) => Promise<A>,
|
||||||
|
label: string,
|
||||||
|
meta?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
const client = s.clients[clientName]
|
||||||
|
if (!client) {
|
||||||
|
log.warn(`client not found for ${label}`, { clientName })
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return yield* Effect.tryPromise({
|
||||||
|
try: () => fn(client),
|
||||||
|
catch: (e: any) => {
|
||||||
|
log.error(`failed to ${label}`, { clientName, ...meta, error: e?.message })
|
||||||
|
return e
|
||||||
|
},
|
||||||
|
}).pipe(Effect.orElseSucceed(() => undefined))
|
||||||
|
})
|
||||||
|
|
||||||
|
const getPrompt = Effect.fn("MCP.getPrompt")(function* (
|
||||||
|
clientName: string,
|
||||||
|
name: string,
|
||||||
|
args?: Record<string, string>,
|
||||||
|
) {
|
||||||
|
return yield* withClient(clientName, (client) => client.getPrompt({ name, arguments: args }), "getPrompt", {
|
||||||
|
promptName: name,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const readResource = Effect.fn("MCP.readResource")(function* (clientName: string, resourceUri: string) {
|
||||||
|
return yield* withClient(clientName, (client) => client.readResource({ uri: resourceUri }), "readResource", {
|
||||||
|
resourceUri,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
|
||||||
|
const cfg = yield* cfgSvc.get()
|
||||||
|
const mcpConfig = cfg.mcp?.[mcpName]
|
||||||
|
if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined
|
||||||
|
return mcpConfig
|
||||||
|
})
|
||||||
|
|
||||||
|
const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string) {
|
||||||
|
const mcpConfig = yield* getMcpConfig(mcpName)
|
||||||
|
if (!mcpConfig) throw new Error(`MCP server ${mcpName} not found or disabled`)
|
||||||
|
if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
|
||||||
|
if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
|
||||||
|
|
||||||
|
// OAuth config is optional - if not provided, we'll use auto-discovery
|
||||||
|
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
|
||||||
|
|
||||||
|
// Start the callback server with custom redirectUri if configured
|
||||||
|
yield* Effect.promise(() => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri))
|
||||||
|
|
||||||
|
const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("")
|
||||||
|
yield* auth.updateOAuthState(mcpName, oauthState)
|
||||||
|
let capturedUrl: URL | undefined
|
||||||
|
const authProvider = new McpOAuthProvider(
|
||||||
|
mcpName,
|
||||||
|
mcpConfig.url,
|
||||||
|
{
|
||||||
|
clientId: oauthConfig?.clientId,
|
||||||
|
clientSecret: oauthConfig?.clientSecret,
|
||||||
|
scope: oauthConfig?.scope,
|
||||||
|
redirectUri: oauthConfig?.redirectUri,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onRedirect: async (url) => {
|
||||||
|
capturedUrl = url
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth,
|
||||||
|
)
|
||||||
|
|
||||||
|
const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider })
|
||||||
|
|
||||||
|
return yield* Effect.tryPromise({
|
||||||
|
try: () => {
|
||||||
|
const client = new Client({ name: "opencode", version: InstallationVersion })
|
||||||
|
return client
|
||||||
|
.connect(transport)
|
||||||
|
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
|
||||||
|
},
|
||||||
|
catch: (error) => error,
|
||||||
|
}).pipe(
|
||||||
|
Effect.catch((error) => {
|
||||||
|
if (error instanceof UnauthorizedError && capturedUrl) {
|
||||||
|
pendingOAuthTransports.set(mcpName, transport)
|
||||||
|
return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState } satisfies AuthResult)
|
||||||
|
}
|
||||||
|
return Effect.die(error)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const authenticate = Effect.fn("MCP.authenticate")(function* (mcpName: string) {
|
||||||
|
const result = yield* startAuth(mcpName)
|
||||||
|
if (!result.authorizationUrl) {
|
||||||
|
const client = "client" in result ? result.client : undefined
|
||||||
|
const mcpConfig = yield* getMcpConfig(mcpName)
|
||||||
|
if (!mcpConfig) {
|
||||||
|
yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)
|
||||||
|
return { status: "failed", error: "MCP config not found after auth" } as Status
|
||||||
|
}
|
||||||
|
|
||||||
|
const listed = client ? yield* defs(mcpName, client, mcpConfig.timeout) : undefined
|
||||||
|
if (!client || !listed) {
|
||||||
|
yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)
|
||||||
|
return { status: "failed", error: "Failed to get tools" } as Status
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
yield* auth.clearOAuthState(mcpName)
|
||||||
|
return yield* storeClient(s, mcpName, client, listed, mcpConfig.timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("opening browser for oauth", { mcpName, url: result.authorizationUrl, state: result.oauthState })
|
||||||
|
|
||||||
|
const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName)
|
||||||
|
|
||||||
|
yield* Effect.tryPromise(() => open(result.authorizationUrl)).pipe(
|
||||||
|
Effect.flatMap((subprocess) =>
|
||||||
|
Effect.callback<void, Error>((resume) => {
|
||||||
|
const timer = setTimeout(() => resume(Effect.void), 500)
|
||||||
|
subprocess.on("error", (err) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
resume(Effect.fail(err))
|
||||||
|
})
|
||||||
|
subprocess.on("exit", (code) => {
|
||||||
|
if (code !== null && code !== 0) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
resume(Effect.fail(new Error(`Browser open failed with exit code ${code}`)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.catch(() => {
|
||||||
|
log.warn("failed to open browser, user must open URL manually", { mcpName })
|
||||||
|
return bus.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const code = yield* Effect.promise(() => callbackPromise)
|
||||||
|
|
||||||
|
const storedState = yield* auth.getOAuthState(mcpName)
|
||||||
|
if (storedState !== result.oauthState) {
|
||||||
|
yield* auth.clearOAuthState(mcpName)
|
||||||
|
throw new Error("OAuth state mismatch - potential CSRF attack")
|
||||||
|
}
|
||||||
|
yield* auth.clearOAuthState(mcpName)
|
||||||
|
return yield* finishAuth(mcpName, code)
|
||||||
|
})
|
||||||
|
|
||||||
|
const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
|
||||||
|
const transport = pendingOAuthTransports.get(mcpName)
|
||||||
|
if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
|
||||||
|
|
||||||
|
const result = yield* Effect.tryPromise({
|
||||||
|
try: () => transport.finishAuth(authorizationCode).then(() => true as const),
|
||||||
|
catch: (error) => {
|
||||||
|
log.error("failed to finish oauth", { mcpName, error })
|
||||||
|
return error
|
||||||
|
},
|
||||||
|
}).pipe(Effect.option)
|
||||||
|
|
||||||
|
if (Option.isNone(result)) {
|
||||||
|
return { status: "failed", error: "OAuth completion failed" } as Status
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* auth.clearCodeVerifier(mcpName)
|
||||||
|
pendingOAuthTransports.delete(mcpName)
|
||||||
|
|
||||||
|
const mcpConfig = yield* getMcpConfig(mcpName)
|
||||||
|
if (!mcpConfig) return { status: "failed", error: "MCP config not found after auth" } as Status
|
||||||
|
|
||||||
|
return yield* createAndStore(mcpName, mcpConfig)
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeAuth = Effect.fn("MCP.removeAuth")(function* (mcpName: string) {
|
||||||
|
yield* auth.remove(mcpName)
|
||||||
|
McpOAuthCallback.cancelPending(mcpName)
|
||||||
|
pendingOAuthTransports.delete(mcpName)
|
||||||
|
log.info("removed oauth credentials", { mcpName })
|
||||||
|
})
|
||||||
|
|
||||||
|
const supportsOAuth = Effect.fn("MCP.supportsOAuth")(function* (mcpName: string) {
|
||||||
|
const mcpConfig = yield* getMcpConfig(mcpName)
|
||||||
|
if (!mcpConfig) return false
|
||||||
|
return mcpConfig.type === "remote" && mcpConfig.oauth !== false
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasStoredTokens = Effect.fn("MCP.hasStoredTokens")(function* (mcpName: string) {
|
||||||
|
const entry = yield* auth.get(mcpName)
|
||||||
|
return !!entry?.tokens
|
||||||
|
})
|
||||||
|
|
||||||
|
const getAuthStatus = Effect.fn("MCP.getAuthStatus")(function* (mcpName: string) {
|
||||||
|
const entry = yield* auth.get(mcpName)
|
||||||
|
if (!entry?.tokens) return "not_authenticated" as AuthStatus
|
||||||
|
const expired = yield* auth.isTokenExpired(mcpName)
|
||||||
|
return (expired ? "expired" : "authenticated") as AuthStatus
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
status,
|
||||||
|
clients,
|
||||||
|
tools,
|
||||||
|
prompts,
|
||||||
|
resources,
|
||||||
|
add,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
getPrompt,
|
||||||
|
readResource,
|
||||||
|
startAuth,
|
||||||
|
authenticate,
|
||||||
|
finishAuth,
|
||||||
|
removeAuth,
|
||||||
|
supportsOAuth,
|
||||||
|
hasStoredTokens,
|
||||||
|
getAuthStatus,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
|
||||||
|
|
||||||
|
// --- Per-service runtime ---
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(McpAuth.layer),
|
||||||
|
Layer.provide(Bus.layer),
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
|
export * as MCP from "."
|
||||||
|
|||||||
@@ -1,931 +0,0 @@
|
|||||||
import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai"
|
|
||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
|
||||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
|
||||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
|
||||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
||||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
|
||||||
import {
|
|
||||||
CallToolResultSchema,
|
|
||||||
type Tool as MCPToolDef,
|
|
||||||
ToolListChangedNotificationSchema,
|
|
||||||
} from "@modelcontextprotocol/sdk/types.js"
|
|
||||||
import { Config } from "../config"
|
|
||||||
import { ConfigMCP } from "../config/mcp"
|
|
||||||
import { Log } from "../util"
|
|
||||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
|
||||||
import z from "zod/v4"
|
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { Installation } from "../installation"
|
|
||||||
import { InstallationVersion } from "../installation/version"
|
|
||||||
import { withTimeout } from "@/util/timeout"
|
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
|
||||||
import { McpOAuthProvider } from "./oauth-provider"
|
|
||||||
import { McpOAuthCallback } from "./oauth-callback"
|
|
||||||
import { McpAuth } from "./auth"
|
|
||||||
import { BusEvent } from "../bus/bus-event"
|
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
|
||||||
import open from "open"
|
|
||||||
import { Effect, Exit, Layer, Option, Context, Stream } from "effect"
|
|
||||||
import { EffectBridge } from "@/effect"
|
|
||||||
import { InstanceState } from "@/effect"
|
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
|
||||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "mcp" })
|
|
||||||
const DEFAULT_TIMEOUT = 30_000
|
|
||||||
|
|
||||||
export const Resource = z
|
|
||||||
.object({
|
|
||||||
name: z.string(),
|
|
||||||
uri: z.string(),
|
|
||||||
description: z.string().optional(),
|
|
||||||
mimeType: z.string().optional(),
|
|
||||||
client: z.string(),
|
|
||||||
})
|
|
||||||
.meta({ ref: "McpResource" })
|
|
||||||
export type Resource = z.infer<typeof Resource>
|
|
||||||
|
|
||||||
export const ToolsChanged = BusEvent.define(
|
|
||||||
"mcp.tools.changed",
|
|
||||||
z.object({
|
|
||||||
server: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const BrowserOpenFailed = BusEvent.define(
|
|
||||||
"mcp.browser.open.failed",
|
|
||||||
z.object({
|
|
||||||
mcpName: z.string(),
|
|
||||||
url: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const Failed = NamedError.create(
|
|
||||||
"MCPFailed",
|
|
||||||
z.object({
|
|
||||||
name: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
type MCPClient = Client
|
|
||||||
|
|
||||||
export const Status = z
|
|
||||||
.discriminatedUnion("status", [
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
status: z.literal("connected"),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "MCPStatusConnected",
|
|
||||||
}),
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
status: z.literal("disabled"),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "MCPStatusDisabled",
|
|
||||||
}),
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
status: z.literal("failed"),
|
|
||||||
error: z.string(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "MCPStatusFailed",
|
|
||||||
}),
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
status: z.literal("needs_auth"),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "MCPStatusNeedsAuth",
|
|
||||||
}),
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
status: z.literal("needs_client_registration"),
|
|
||||||
error: z.string(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "MCPStatusNeedsClientRegistration",
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
.meta({
|
|
||||||
ref: "MCPStatus",
|
|
||||||
})
|
|
||||||
export type Status = z.infer<typeof Status>
|
|
||||||
|
|
||||||
// Store transports for OAuth servers to allow finishing auth
|
|
||||||
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
|
|
||||||
const pendingOAuthTransports = new Map<string, TransportWithAuth>()
|
|
||||||
|
|
||||||
// Prompt cache types
|
|
||||||
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
|
|
||||||
type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"][number]
|
|
||||||
type McpEntry = NonNullable<Config.Info["mcp"]>[string]
|
|
||||||
|
|
||||||
function isMcpConfigured(entry: McpEntry): entry is ConfigMCP.Info {
|
|
||||||
return typeof entry === "object" && entry !== null && "type" in entry
|
|
||||||
}
|
|
||||||
|
|
||||||
const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_")
|
|
||||||
|
|
||||||
// Convert MCP tool definition to AI SDK Tool type
|
|
||||||
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
|
|
||||||
const inputSchema = mcpTool.inputSchema
|
|
||||||
|
|
||||||
// Spread first, then override type to ensure it's always "object"
|
|
||||||
const schema: JSONSchema7 = {
|
|
||||||
...(inputSchema as JSONSchema7),
|
|
||||||
type: "object",
|
|
||||||
properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"],
|
|
||||||
additionalProperties: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
return dynamicTool({
|
|
||||||
description: mcpTool.description ?? "",
|
|
||||||
inputSchema: jsonSchema(schema),
|
|
||||||
execute: async (args: unknown) => {
|
|
||||||
return client.callTool(
|
|
||||||
{
|
|
||||||
name: mcpTool.name,
|
|
||||||
arguments: (args || {}) as Record<string, unknown>,
|
|
||||||
},
|
|
||||||
CallToolResultSchema,
|
|
||||||
{
|
|
||||||
resetTimeoutOnProgress: true,
|
|
||||||
timeout,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function defs(key: string, client: MCPClient, timeout?: number) {
|
|
||||||
return Effect.tryPromise({
|
|
||||||
try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT),
|
|
||||||
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
|
|
||||||
}).pipe(
|
|
||||||
Effect.map((result) => result.tools),
|
|
||||||
Effect.catch((err) => {
|
|
||||||
log.error("failed to get tools from client", { key, error: err })
|
|
||||||
return Effect.succeed(undefined)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchFromClient<T extends { name: string }>(
|
|
||||||
clientName: string,
|
|
||||||
client: Client,
|
|
||||||
listFn: (c: Client) => Promise<T[]>,
|
|
||||||
label: string,
|
|
||||||
) {
|
|
||||||
return Effect.tryPromise({
|
|
||||||
try: () => listFn(client),
|
|
||||||
catch: (e: any) => {
|
|
||||||
log.error(`failed to get ${label}`, { clientName, error: e.message })
|
|
||||||
return e
|
|
||||||
},
|
|
||||||
}).pipe(
|
|
||||||
Effect.map((items) => {
|
|
||||||
const out: Record<string, T & { client: string }> = {}
|
|
||||||
const sanitizedClient = sanitize(clientName)
|
|
||||||
for (const item of items) {
|
|
||||||
out[sanitizedClient + ":" + sanitize(item.name)] = { ...item, client: clientName }
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}),
|
|
||||||
Effect.orElseSucceed(() => undefined),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreateResult {
|
|
||||||
mcpClient?: MCPClient
|
|
||||||
status: Status
|
|
||||||
defs?: MCPToolDef[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthResult {
|
|
||||||
authorizationUrl: string
|
|
||||||
oauthState: string
|
|
||||||
client?: MCPClient
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Effect Service ---
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
status: Record<string, Status>
|
|
||||||
clients: Record<string, MCPClient>
|
|
||||||
defs: Record<string, MCPToolDef[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly status: () => Effect.Effect<Record<string, Status>>
|
|
||||||
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
|
|
||||||
readonly tools: () => Effect.Effect<Record<string, Tool>>
|
|
||||||
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
|
|
||||||
readonly resources: () => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
|
|
||||||
readonly add: (name: string, mcp: ConfigMCP.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
|
|
||||||
readonly connect: (name: string) => Effect.Effect<void>
|
|
||||||
readonly disconnect: (name: string) => Effect.Effect<void>
|
|
||||||
readonly getPrompt: (
|
|
||||||
clientName: string,
|
|
||||||
name: string,
|
|
||||||
args?: Record<string, string>,
|
|
||||||
) => Effect.Effect<Awaited<ReturnType<MCPClient["getPrompt"]>> | undefined>
|
|
||||||
readonly readResource: (
|
|
||||||
clientName: string,
|
|
||||||
resourceUri: string,
|
|
||||||
) => Effect.Effect<Awaited<ReturnType<MCPClient["readResource"]>> | undefined>
|
|
||||||
readonly startAuth: (mcpName: string) => Effect.Effect<{ authorizationUrl: string; oauthState: string }>
|
|
||||||
readonly authenticate: (mcpName: string) => Effect.Effect<Status>
|
|
||||||
readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect<Status>
|
|
||||||
readonly removeAuth: (mcpName: string) => Effect.Effect<void>
|
|
||||||
readonly supportsOAuth: (mcpName: string) => Effect.Effect<boolean>
|
|
||||||
readonly hasStoredTokens: (mcpName: string) => Effect.Effect<boolean>
|
|
||||||
readonly getAuthStatus: (mcpName: string) => Effect.Effect<AuthStatus>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/MCP") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
||||||
const auth = yield* McpAuth.Service
|
|
||||||
const bus = yield* Bus.Service
|
|
||||||
|
|
||||||
type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect a client via the given transport with resource safety:
|
|
||||||
* on failure the transport is closed; on success the caller owns it.
|
|
||||||
*/
|
|
||||||
const connectTransport = (transport: Transport, timeout: number) =>
|
|
||||||
Effect.acquireUseRelease(
|
|
||||||
Effect.succeed(transport),
|
|
||||||
(t) =>
|
|
||||||
Effect.tryPromise({
|
|
||||||
try: () => {
|
|
||||||
const client = new Client({ name: "opencode", version: InstallationVersion })
|
|
||||||
return withTimeout(client.connect(t), timeout).then(() => client)
|
|
||||||
},
|
|
||||||
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
|
|
||||||
}),
|
|
||||||
(t, exit) => (Exit.isFailure(exit) ? Effect.tryPromise(() => t.close()).pipe(Effect.ignore) : Effect.void),
|
|
||||||
)
|
|
||||||
|
|
||||||
const DISABLED_RESULT: CreateResult = { status: { status: "disabled" } }
|
|
||||||
|
|
||||||
const connectRemote = Effect.fn("MCP.connectRemote")(function* (
|
|
||||||
key: string,
|
|
||||||
mcp: ConfigMCP.Info & { type: "remote" },
|
|
||||||
) {
|
|
||||||
const oauthDisabled = mcp.oauth === false
|
|
||||||
const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined
|
|
||||||
let authProvider: McpOAuthProvider | undefined
|
|
||||||
|
|
||||||
if (!oauthDisabled) {
|
|
||||||
authProvider = new McpOAuthProvider(
|
|
||||||
key,
|
|
||||||
mcp.url,
|
|
||||||
{
|
|
||||||
clientId: oauthConfig?.clientId,
|
|
||||||
clientSecret: oauthConfig?.clientSecret,
|
|
||||||
scope: oauthConfig?.scope,
|
|
||||||
redirectUri: oauthConfig?.redirectUri,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onRedirect: async (url) => {
|
|
||||||
log.info("oauth redirect requested", { key, url: url.toString() })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
auth,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const transports: Array<{ name: string; transport: TransportWithAuth }> = [
|
|
||||||
{
|
|
||||||
name: "StreamableHTTP",
|
|
||||||
transport: new StreamableHTTPClientTransport(new URL(mcp.url), {
|
|
||||||
authProvider,
|
|
||||||
requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "SSE",
|
|
||||||
transport: new SSEClientTransport(new URL(mcp.url), {
|
|
||||||
authProvider,
|
|
||||||
requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
|
|
||||||
let lastStatus: Status | undefined
|
|
||||||
|
|
||||||
for (const { name, transport } of transports) {
|
|
||||||
const result = yield* connectTransport(transport, connectTimeout).pipe(
|
|
||||||
Effect.map((client) => ({ client, transportName: name })),
|
|
||||||
Effect.catch((error) => {
|
|
||||||
const lastError = error instanceof Error ? error : new Error(String(error))
|
|
||||||
const isAuthError =
|
|
||||||
error instanceof UnauthorizedError || (authProvider && lastError.message.includes("OAuth"))
|
|
||||||
|
|
||||||
if (isAuthError) {
|
|
||||||
log.info("mcp server requires authentication", { key, transport: name })
|
|
||||||
|
|
||||||
if (lastError.message.includes("registration") || lastError.message.includes("client_id")) {
|
|
||||||
lastStatus = {
|
|
||||||
status: "needs_client_registration" as const,
|
|
||||||
error: "Server does not support dynamic client registration. Please provide clientId in config.",
|
|
||||||
}
|
|
||||||
return bus
|
|
||||||
.publish(TuiEvent.ToastShow, {
|
|
||||||
title: "MCP Authentication Required",
|
|
||||||
message: `Server "${key}" requires a pre-registered client ID. Add clientId to your config.`,
|
|
||||||
variant: "warning",
|
|
||||||
duration: 8000,
|
|
||||||
})
|
|
||||||
.pipe(Effect.ignore, Effect.as(undefined))
|
|
||||||
} else {
|
|
||||||
pendingOAuthTransports.set(key, transport)
|
|
||||||
lastStatus = { status: "needs_auth" as const }
|
|
||||||
return bus
|
|
||||||
.publish(TuiEvent.ToastShow, {
|
|
||||||
title: "MCP Authentication Required",
|
|
||||||
message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`,
|
|
||||||
variant: "warning",
|
|
||||||
duration: 8000,
|
|
||||||
})
|
|
||||||
.pipe(Effect.ignore, Effect.as(undefined))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug("transport connection failed", {
|
|
||||||
key,
|
|
||||||
transport: name,
|
|
||||||
url: mcp.url,
|
|
||||||
error: lastError.message,
|
|
||||||
})
|
|
||||||
lastStatus = { status: "failed" as const, error: lastError.message }
|
|
||||||
return Effect.succeed(undefined)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if (result) {
|
|
||||||
log.info("connected", { key, transport: result.transportName })
|
|
||||||
return { client: result.client as MCPClient | undefined, status: { status: "connected" } as Status }
|
|
||||||
}
|
|
||||||
// If this was an auth error, stop trying other transports
|
|
||||||
if (lastStatus?.status === "needs_auth" || lastStatus?.status === "needs_client_registration") break
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
client: undefined as MCPClient | undefined,
|
|
||||||
status: (lastStatus ?? { status: "failed", error: "Unknown error" }) as Status,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const connectLocal = Effect.fn("MCP.connectLocal")(function* (
|
|
||||||
key: string,
|
|
||||||
mcp: ConfigMCP.Info & { type: "local" },
|
|
||||||
) {
|
|
||||||
const [cmd, ...args] = mcp.command
|
|
||||||
const cwd = Instance.directory
|
|
||||||
const transport = new StdioClientTransport({
|
|
||||||
stderr: "pipe",
|
|
||||||
command: cmd,
|
|
||||||
args,
|
|
||||||
cwd,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
|
|
||||||
...mcp.environment,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
transport.stderr?.on("data", (chunk: Buffer) => {
|
|
||||||
log.info(`mcp stderr: ${chunk.toString()}`, { key })
|
|
||||||
})
|
|
||||||
|
|
||||||
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
|
|
||||||
return yield* connectTransport(transport, connectTimeout).pipe(
|
|
||||||
Effect.map((client): { client: MCPClient | undefined; status: Status } => ({
|
|
||||||
client,
|
|
||||||
status: { status: "connected" },
|
|
||||||
})),
|
|
||||||
Effect.catch((error): Effect.Effect<{ client: MCPClient | undefined; status: Status }> => {
|
|
||||||
const msg = error instanceof Error ? error.message : String(error)
|
|
||||||
log.error("local mcp startup failed", { key, command: mcp.command, cwd, error: msg })
|
|
||||||
return Effect.succeed({ client: undefined, status: { status: "failed", error: msg } })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const create = Effect.fn("MCP.create")(function* (key: string, mcp: ConfigMCP.Info) {
|
|
||||||
if (mcp.enabled === false) {
|
|
||||||
log.info("mcp server disabled", { key })
|
|
||||||
return DISABLED_RESULT
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("found", { key, type: mcp.type })
|
|
||||||
|
|
||||||
const { client: mcpClient, status } =
|
|
||||||
mcp.type === "remote"
|
|
||||||
? yield* connectRemote(key, mcp as ConfigMCP.Info & { type: "remote" })
|
|
||||||
: yield* connectLocal(key, mcp as ConfigMCP.Info & { type: "local" })
|
|
||||||
|
|
||||||
if (!mcpClient) {
|
|
||||||
return { status } satisfies CreateResult
|
|
||||||
}
|
|
||||||
|
|
||||||
const listed = yield* defs(key, mcpClient, mcp.timeout)
|
|
||||||
if (!listed) {
|
|
||||||
yield* Effect.tryPromise(() => mcpClient.close()).pipe(Effect.ignore)
|
|
||||||
return { status: { status: "failed", error: "Failed to get tools" } } satisfies CreateResult
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("create() successfully created client", { key, toolCount: listed.length })
|
|
||||||
return { mcpClient, status, defs: listed } satisfies CreateResult
|
|
||||||
})
|
|
||||||
const cfgSvc = yield* Config.Service
|
|
||||||
|
|
||||||
const descendants = Effect.fnUntraced(
|
|
||||||
function* (pid: number) {
|
|
||||||
if (process.platform === "win32") return [] as number[]
|
|
||||||
const pids: number[] = []
|
|
||||||
const queue = [pid]
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const current = queue.shift()!
|
|
||||||
const handle = yield* spawner.spawn(ChildProcess.make("pgrep", ["-P", String(current)], { stdin: "ignore" }))
|
|
||||||
const text = yield* Stream.mkString(Stream.decodeText(handle.stdout))
|
|
||||||
yield* handle.exitCode
|
|
||||||
for (const tok of text.split("\n")) {
|
|
||||||
const cpid = parseInt(tok, 10)
|
|
||||||
if (!isNaN(cpid) && !pids.includes(cpid)) {
|
|
||||||
pids.push(cpid)
|
|
||||||
queue.push(cpid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pids
|
|
||||||
},
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.catch(() => Effect.succeed([] as number[])),
|
|
||||||
)
|
|
||||||
|
|
||||||
function watch(s: State, name: string, client: MCPClient, bridge: EffectBridge.Shape, timeout?: number) {
|
|
||||||
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
|
||||||
log.info("tools list changed notification received", { server: name })
|
|
||||||
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
|
||||||
|
|
||||||
const listed = await bridge.promise(defs(name, client, timeout))
|
|
||||||
if (!listed) return
|
|
||||||
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
|
||||||
|
|
||||||
s.defs[name] = listed
|
|
||||||
await bridge.promise(bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
|
||||||
Effect.fn("MCP.state")(function* () {
|
|
||||||
const cfg = yield* cfgSvc.get()
|
|
||||||
const bridge = yield* EffectBridge.make()
|
|
||||||
const config = cfg.mcp ?? {}
|
|
||||||
const s: State = {
|
|
||||||
status: {},
|
|
||||||
clients: {},
|
|
||||||
defs: {},
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* Effect.forEach(
|
|
||||||
Object.entries(config),
|
|
||||||
([key, mcp]) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (!isMcpConfigured(mcp)) {
|
|
||||||
log.error("Ignoring MCP config entry without type", { key })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mcp.enabled === false) {
|
|
||||||
s.status[key] = { status: "disabled" }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = yield* create(key, mcp).pipe(Effect.catch(() => Effect.void))
|
|
||||||
if (!result) return
|
|
||||||
|
|
||||||
s.status[key] = result.status
|
|
||||||
if (result.mcpClient) {
|
|
||||||
s.clients[key] = result.mcpClient
|
|
||||||
s.defs[key] = result.defs!
|
|
||||||
watch(s, key, result.mcpClient, bridge, mcp.timeout)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* Effect.forEach(
|
|
||||||
Object.values(s.clients),
|
|
||||||
(client) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const pid = client.transport instanceof StdioClientTransport ? client.transport.pid : null
|
|
||||||
if (typeof pid === "number") {
|
|
||||||
const pids = yield* descendants(pid)
|
|
||||||
for (const dpid of pids) {
|
|
||||||
try {
|
|
||||||
process.kill(dpid, "SIGTERM")
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
yield* Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
|
|
||||||
}),
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
)
|
|
||||||
pendingOAuthTransports.clear()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return s
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
function closeClient(s: State, name: string) {
|
|
||||||
const client = s.clients[name]
|
|
||||||
delete s.defs[name]
|
|
||||||
if (!client) return Effect.void
|
|
||||||
return Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
|
|
||||||
}
|
|
||||||
|
|
||||||
const storeClient = Effect.fnUntraced(function* (
|
|
||||||
s: State,
|
|
||||||
name: string,
|
|
||||||
client: MCPClient,
|
|
||||||
listed: MCPToolDef[],
|
|
||||||
timeout?: number,
|
|
||||||
) {
|
|
||||||
const bridge = yield* EffectBridge.make()
|
|
||||||
yield* closeClient(s, name)
|
|
||||||
s.status[name] = { status: "connected" }
|
|
||||||
s.clients[name] = client
|
|
||||||
s.defs[name] = listed
|
|
||||||
watch(s, name, client, bridge, timeout)
|
|
||||||
return s.status[name]
|
|
||||||
})
|
|
||||||
|
|
||||||
const status = Effect.fn("MCP.status")(function* () {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
|
|
||||||
const cfg = yield* cfgSvc.get()
|
|
||||||
const config = cfg.mcp ?? {}
|
|
||||||
const result: Record<string, Status> = {}
|
|
||||||
|
|
||||||
for (const [key, mcp] of Object.entries(config)) {
|
|
||||||
if (!isMcpConfigured(mcp)) continue
|
|
||||||
result[key] = s.status[key] ?? { status: "disabled" }
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const clients = Effect.fn("MCP.clients")(function* () {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
return s.clients
|
|
||||||
})
|
|
||||||
|
|
||||||
const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: ConfigMCP.Info) {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
const result = yield* create(name, mcp)
|
|
||||||
|
|
||||||
s.status[name] = result.status
|
|
||||||
if (!result.mcpClient) {
|
|
||||||
yield* closeClient(s, name)
|
|
||||||
delete s.clients[name]
|
|
||||||
return result.status
|
|
||||||
}
|
|
||||||
|
|
||||||
return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout)
|
|
||||||
})
|
|
||||||
|
|
||||||
const add = Effect.fn("MCP.add")(function* (name: string, mcp: ConfigMCP.Info) {
|
|
||||||
yield* createAndStore(name, mcp)
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
return { status: s.status }
|
|
||||||
})
|
|
||||||
|
|
||||||
const connect = Effect.fn("MCP.connect")(function* (name: string) {
|
|
||||||
const mcp = yield* getMcpConfig(name)
|
|
||||||
if (!mcp) {
|
|
||||||
log.error("MCP config not found or invalid", { name })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
yield* createAndStore(name, { ...mcp, enabled: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
yield* closeClient(s, name)
|
|
||||||
delete s.clients[name]
|
|
||||||
s.status[name] = { status: "disabled" }
|
|
||||||
})
|
|
||||||
|
|
||||||
const tools = Effect.fn("MCP.tools")(function* () {
|
|
||||||
const result: Record<string, Tool> = {}
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
|
|
||||||
const cfg = yield* cfgSvc.get()
|
|
||||||
const config = cfg.mcp ?? {}
|
|
||||||
const defaultTimeout = cfg.experimental?.mcp_timeout
|
|
||||||
|
|
||||||
const connectedClients = Object.entries(s.clients).filter(
|
|
||||||
([clientName]) => s.status[clientName]?.status === "connected",
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* Effect.forEach(
|
|
||||||
connectedClients,
|
|
||||||
([clientName, client]) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const mcpConfig = config[clientName]
|
|
||||||
const entry = mcpConfig && isMcpConfigured(mcpConfig) ? mcpConfig : undefined
|
|
||||||
|
|
||||||
const listed = s.defs[clientName]
|
|
||||||
if (!listed) {
|
|
||||||
log.warn("missing cached tools for connected server", { clientName })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeout = entry?.timeout ?? defaultTimeout
|
|
||||||
for (const mcpTool of listed) {
|
|
||||||
result[sanitize(clientName) + "_" + sanitize(mcpTool.name)] = convertMcpTool(mcpTool, client, timeout)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
function collectFromConnected<T extends { name: string }>(
|
|
||||||
s: State,
|
|
||||||
listFn: (c: Client) => Promise<T[]>,
|
|
||||||
label: string,
|
|
||||||
) {
|
|
||||||
return Effect.forEach(
|
|
||||||
Object.entries(s.clients).filter(([name]) => s.status[name]?.status === "connected"),
|
|
||||||
([clientName, client]) =>
|
|
||||||
fetchFromClient(clientName, client, listFn, label).pipe(Effect.map((items) => Object.entries(items ?? {}))),
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
).pipe(Effect.map((results) => Object.fromEntries<T & { client: string }>(results.flat())))
|
|
||||||
}
|
|
||||||
|
|
||||||
const prompts = Effect.fn("MCP.prompts")(function* () {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
return yield* collectFromConnected(s, (c) => c.listPrompts().then((r) => r.prompts), "prompts")
|
|
||||||
})
|
|
||||||
|
|
||||||
const resources = Effect.fn("MCP.resources")(function* () {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
return yield* collectFromConnected(s, (c) => c.listResources().then((r) => r.resources), "resources")
|
|
||||||
})
|
|
||||||
|
|
||||||
const withClient = Effect.fnUntraced(function* <A>(
|
|
||||||
clientName: string,
|
|
||||||
fn: (client: MCPClient) => Promise<A>,
|
|
||||||
label: string,
|
|
||||||
meta?: Record<string, unknown>,
|
|
||||||
) {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
const client = s.clients[clientName]
|
|
||||||
if (!client) {
|
|
||||||
log.warn(`client not found for ${label}`, { clientName })
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
return yield* Effect.tryPromise({
|
|
||||||
try: () => fn(client),
|
|
||||||
catch: (e: any) => {
|
|
||||||
log.error(`failed to ${label}`, { clientName, ...meta, error: e?.message })
|
|
||||||
return e
|
|
||||||
},
|
|
||||||
}).pipe(Effect.orElseSucceed(() => undefined))
|
|
||||||
})
|
|
||||||
|
|
||||||
const getPrompt = Effect.fn("MCP.getPrompt")(function* (
|
|
||||||
clientName: string,
|
|
||||||
name: string,
|
|
||||||
args?: Record<string, string>,
|
|
||||||
) {
|
|
||||||
return yield* withClient(clientName, (client) => client.getPrompt({ name, arguments: args }), "getPrompt", {
|
|
||||||
promptName: name,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const readResource = Effect.fn("MCP.readResource")(function* (clientName: string, resourceUri: string) {
|
|
||||||
return yield* withClient(clientName, (client) => client.readResource({ uri: resourceUri }), "readResource", {
|
|
||||||
resourceUri,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
|
|
||||||
const cfg = yield* cfgSvc.get()
|
|
||||||
const mcpConfig = cfg.mcp?.[mcpName]
|
|
||||||
if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined
|
|
||||||
return mcpConfig
|
|
||||||
})
|
|
||||||
|
|
||||||
const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string) {
|
|
||||||
const mcpConfig = yield* getMcpConfig(mcpName)
|
|
||||||
if (!mcpConfig) throw new Error(`MCP server ${mcpName} not found or disabled`)
|
|
||||||
if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
|
|
||||||
if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
|
|
||||||
|
|
||||||
// OAuth config is optional - if not provided, we'll use auto-discovery
|
|
||||||
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
|
|
||||||
|
|
||||||
// Start the callback server with custom redirectUri if configured
|
|
||||||
yield* Effect.promise(() => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri))
|
|
||||||
|
|
||||||
const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
|
||||||
.map((b) => b.toString(16).padStart(2, "0"))
|
|
||||||
.join("")
|
|
||||||
yield* auth.updateOAuthState(mcpName, oauthState)
|
|
||||||
let capturedUrl: URL | undefined
|
|
||||||
const authProvider = new McpOAuthProvider(
|
|
||||||
mcpName,
|
|
||||||
mcpConfig.url,
|
|
||||||
{
|
|
||||||
clientId: oauthConfig?.clientId,
|
|
||||||
clientSecret: oauthConfig?.clientSecret,
|
|
||||||
scope: oauthConfig?.scope,
|
|
||||||
redirectUri: oauthConfig?.redirectUri,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onRedirect: async (url) => {
|
|
||||||
capturedUrl = url
|
|
||||||
},
|
|
||||||
},
|
|
||||||
auth,
|
|
||||||
)
|
|
||||||
|
|
||||||
const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider })
|
|
||||||
|
|
||||||
return yield* Effect.tryPromise({
|
|
||||||
try: () => {
|
|
||||||
const client = new Client({ name: "opencode", version: InstallationVersion })
|
|
||||||
return client
|
|
||||||
.connect(transport)
|
|
||||||
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
|
|
||||||
},
|
|
||||||
catch: (error) => error,
|
|
||||||
}).pipe(
|
|
||||||
Effect.catch((error) => {
|
|
||||||
if (error instanceof UnauthorizedError && capturedUrl) {
|
|
||||||
pendingOAuthTransports.set(mcpName, transport)
|
|
||||||
return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState } satisfies AuthResult)
|
|
||||||
}
|
|
||||||
return Effect.die(error)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const authenticate = Effect.fn("MCP.authenticate")(function* (mcpName: string) {
|
|
||||||
const result = yield* startAuth(mcpName)
|
|
||||||
if (!result.authorizationUrl) {
|
|
||||||
const client = "client" in result ? result.client : undefined
|
|
||||||
const mcpConfig = yield* getMcpConfig(mcpName)
|
|
||||||
if (!mcpConfig) {
|
|
||||||
yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)
|
|
||||||
return { status: "failed", error: "MCP config not found after auth" } as Status
|
|
||||||
}
|
|
||||||
|
|
||||||
const listed = client ? yield* defs(mcpName, client, mcpConfig.timeout) : undefined
|
|
||||||
if (!client || !listed) {
|
|
||||||
yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)
|
|
||||||
return { status: "failed", error: "Failed to get tools" } as Status
|
|
||||||
}
|
|
||||||
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
yield* auth.clearOAuthState(mcpName)
|
|
||||||
return yield* storeClient(s, mcpName, client, listed, mcpConfig.timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("opening browser for oauth", { mcpName, url: result.authorizationUrl, state: result.oauthState })
|
|
||||||
|
|
||||||
const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName)
|
|
||||||
|
|
||||||
yield* Effect.tryPromise(() => open(result.authorizationUrl)).pipe(
|
|
||||||
Effect.flatMap((subprocess) =>
|
|
||||||
Effect.callback<void, Error>((resume) => {
|
|
||||||
const timer = setTimeout(() => resume(Effect.void), 500)
|
|
||||||
subprocess.on("error", (err) => {
|
|
||||||
clearTimeout(timer)
|
|
||||||
resume(Effect.fail(err))
|
|
||||||
})
|
|
||||||
subprocess.on("exit", (code) => {
|
|
||||||
if (code !== null && code !== 0) {
|
|
||||||
clearTimeout(timer)
|
|
||||||
resume(Effect.fail(new Error(`Browser open failed with exit code ${code}`)))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Effect.catch(() => {
|
|
||||||
log.warn("failed to open browser, user must open URL manually", { mcpName })
|
|
||||||
return bus.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const code = yield* Effect.promise(() => callbackPromise)
|
|
||||||
|
|
||||||
const storedState = yield* auth.getOAuthState(mcpName)
|
|
||||||
if (storedState !== result.oauthState) {
|
|
||||||
yield* auth.clearOAuthState(mcpName)
|
|
||||||
throw new Error("OAuth state mismatch - potential CSRF attack")
|
|
||||||
}
|
|
||||||
yield* auth.clearOAuthState(mcpName)
|
|
||||||
return yield* finishAuth(mcpName, code)
|
|
||||||
})
|
|
||||||
|
|
||||||
const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
|
|
||||||
const transport = pendingOAuthTransports.get(mcpName)
|
|
||||||
if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
|
|
||||||
|
|
||||||
const result = yield* Effect.tryPromise({
|
|
||||||
try: () => transport.finishAuth(authorizationCode).then(() => true as const),
|
|
||||||
catch: (error) => {
|
|
||||||
log.error("failed to finish oauth", { mcpName, error })
|
|
||||||
return error
|
|
||||||
},
|
|
||||||
}).pipe(Effect.option)
|
|
||||||
|
|
||||||
if (Option.isNone(result)) {
|
|
||||||
return { status: "failed", error: "OAuth completion failed" } as Status
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* auth.clearCodeVerifier(mcpName)
|
|
||||||
pendingOAuthTransports.delete(mcpName)
|
|
||||||
|
|
||||||
const mcpConfig = yield* getMcpConfig(mcpName)
|
|
||||||
if (!mcpConfig) return { status: "failed", error: "MCP config not found after auth" } as Status
|
|
||||||
|
|
||||||
return yield* createAndStore(mcpName, mcpConfig)
|
|
||||||
})
|
|
||||||
|
|
||||||
const removeAuth = Effect.fn("MCP.removeAuth")(function* (mcpName: string) {
|
|
||||||
yield* auth.remove(mcpName)
|
|
||||||
McpOAuthCallback.cancelPending(mcpName)
|
|
||||||
pendingOAuthTransports.delete(mcpName)
|
|
||||||
log.info("removed oauth credentials", { mcpName })
|
|
||||||
})
|
|
||||||
|
|
||||||
const supportsOAuth = Effect.fn("MCP.supportsOAuth")(function* (mcpName: string) {
|
|
||||||
const mcpConfig = yield* getMcpConfig(mcpName)
|
|
||||||
if (!mcpConfig) return false
|
|
||||||
return mcpConfig.type === "remote" && mcpConfig.oauth !== false
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasStoredTokens = Effect.fn("MCP.hasStoredTokens")(function* (mcpName: string) {
|
|
||||||
const entry = yield* auth.get(mcpName)
|
|
||||||
return !!entry?.tokens
|
|
||||||
})
|
|
||||||
|
|
||||||
const getAuthStatus = Effect.fn("MCP.getAuthStatus")(function* (mcpName: string) {
|
|
||||||
const entry = yield* auth.get(mcpName)
|
|
||||||
if (!entry?.tokens) return "not_authenticated" as AuthStatus
|
|
||||||
const expired = yield* auth.isTokenExpired(mcpName)
|
|
||||||
return (expired ? "expired" : "authenticated") as AuthStatus
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
status,
|
|
||||||
clients,
|
|
||||||
tools,
|
|
||||||
prompts,
|
|
||||||
resources,
|
|
||||||
add,
|
|
||||||
connect,
|
|
||||||
disconnect,
|
|
||||||
getPrompt,
|
|
||||||
readResource,
|
|
||||||
startAuth,
|
|
||||||
authenticate,
|
|
||||||
finishAuth,
|
|
||||||
removeAuth,
|
|
||||||
supportsOAuth,
|
|
||||||
hasStoredTokens,
|
|
||||||
getAuthStatus,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
|
|
||||||
|
|
||||||
// --- Per-service runtime ---
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
|
||||||
Layer.provide(McpAuth.layer),
|
|
||||||
Layer.provide(Bus.layer),
|
|
||||||
Layer.provide(Config.defaultLayer),
|
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
|
||||||
)
|
|
||||||
@@ -1 +1,198 @@
|
|||||||
export * as Npm from "./npm"
|
import semver from "semver"
|
||||||
|
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 { readdir, rm } from "fs/promises"
|
||||||
|
import { Filesystem } from "@/util"
|
||||||
|
import { Flock } from "@opencode-ai/shared/util/flock"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "npm" })
|
||||||
|
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
|
||||||
|
|
||||||
|
export const InstallFailedError = NamedError.create(
|
||||||
|
"NpmInstallFailedError",
|
||||||
|
z.object({
|
||||||
|
pkg: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export function sanitize(pkg: string) {
|
||||||
|
if (!illegal) return pkg
|
||||||
|
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function directory(pkg: string) {
|
||||||
|
return path.join(Global.Path.cache, "packages", sanitize(pkg))
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEntryPoint(name: string, dir: string) {
|
||||||
|
let entrypoint: string | undefined
|
||||||
|
try {
|
||||||
|
entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
|
||||||
|
} catch {}
|
||||||
|
const result = {
|
||||||
|
directory: dir,
|
||||||
|
entrypoint,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
|
||||||
|
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
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) {
|
||||||
|
const { Arborist } = await import("@npmcli/arborist")
|
||||||
|
const dir = directory(pkg)
|
||||||
|
await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
|
||||||
|
log.info("installing package", {
|
||||||
|
pkg,
|
||||||
|
})
|
||||||
|
|
||||||
|
const arborist = new Arborist({
|
||||||
|
path: dir,
|
||||||
|
binLinks: true,
|
||||||
|
progress: false,
|
||||||
|
savePrefix: "",
|
||||||
|
ignoreScripts: true,
|
||||||
|
})
|
||||||
|
const tree = await arborist.loadVirtual().catch(() => {})
|
||||||
|
if (tree) {
|
||||||
|
const first = tree.edgesOut.values().next().value?.to
|
||||||
|
if (first) {
|
||||||
|
return resolveEntryPoint(first.name, first.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await arborist
|
||||||
|
.reify({
|
||||||
|
add: [pkg],
|
||||||
|
save: true,
|
||||||
|
saveType: "prod",
|
||||||
|
})
|
||||||
|
.catch((cause) => {
|
||||||
|
throw new InstallFailedError(
|
||||||
|
{ pkg },
|
||||||
|
{
|
||||||
|
cause,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = result.edgesOut.values().next().value?.to
|
||||||
|
if (!first) throw new InstallFailedError({ pkg })
|
||||||
|
return resolveEntryPoint(first.name, first.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")))) {
|
||||||
|
log.info("node_modules missing, reifying")
|
||||||
|
await reify()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type PackageDeps = Record<string, string>
|
||||||
|
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([
|
||||||
|
...Object.keys(pkg.dependencies || {}),
|
||||||
|
...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) {
|
||||||
|
const dir = directory(pkg)
|
||||||
|
const binDir = path.join(dir, "node_modules", ".bin")
|
||||||
|
|
||||||
|
const pick = async () => {
|
||||||
|
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 "."
|
||||||
|
|||||||
@@ -1,187 +0,0 @@
|
|||||||
import semver from "semver"
|
|
||||||
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 { readdir, rm } from "fs/promises"
|
|
||||||
import { Filesystem } from "@/util"
|
|
||||||
import { Flock } from "@opencode-ai/shared/util/flock"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "npm" })
|
|
||||||
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
|
|
||||||
|
|
||||||
export const InstallFailedError = NamedError.create(
|
|
||||||
"NpmInstallFailedError",
|
|
||||||
z.object({
|
|
||||||
pkg: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export function sanitize(pkg: string) {
|
|
||||||
if (!illegal) return pkg
|
|
||||||
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
|
|
||||||
}
|
|
||||||
|
|
||||||
function directory(pkg: string) {
|
|
||||||
return path.join(Global.Path.cache, "packages", sanitize(pkg))
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveEntryPoint(name: string, dir: string) {
|
|
||||||
let entrypoint: string | undefined
|
|
||||||
try {
|
|
||||||
entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
|
|
||||||
} catch {}
|
|
||||||
const result = {
|
|
||||||
directory: dir,
|
|
||||||
entrypoint,
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
|
|
||||||
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
|
|
||||||
if (!response.ok) {
|
|
||||||
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) {
|
|
||||||
const { Arborist } = await import("@npmcli/arborist")
|
|
||||||
const dir = directory(pkg)
|
|
||||||
await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
|
|
||||||
log.info("installing package", {
|
|
||||||
pkg,
|
|
||||||
})
|
|
||||||
|
|
||||||
const arborist = new Arborist({
|
|
||||||
path: dir,
|
|
||||||
binLinks: true,
|
|
||||||
progress: false,
|
|
||||||
savePrefix: "",
|
|
||||||
ignoreScripts: true,
|
|
||||||
})
|
|
||||||
const tree = await arborist.loadVirtual().catch(() => {})
|
|
||||||
if (tree) {
|
|
||||||
const first = tree.edgesOut.values().next().value?.to
|
|
||||||
if (first) {
|
|
||||||
return resolveEntryPoint(first.name, first.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await arborist
|
|
||||||
.reify({
|
|
||||||
add: [pkg],
|
|
||||||
save: true,
|
|
||||||
saveType: "prod",
|
|
||||||
})
|
|
||||||
.catch((cause) => {
|
|
||||||
throw new InstallFailedError(
|
|
||||||
{ pkg },
|
|
||||||
{
|
|
||||||
cause,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const first = result.edgesOut.values().next().value?.to
|
|
||||||
if (!first) throw new InstallFailedError({ pkg })
|
|
||||||
return resolveEntryPoint(first.name, first.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
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")))) {
|
|
||||||
log.info("node_modules missing, reifying")
|
|
||||||
await reify()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
|
|
||||||
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
|
|
||||||
|
|
||||||
const declared = new Set([
|
|
||||||
...Object.keys(pkg.dependencies || {}),
|
|
||||||
...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) {
|
|
||||||
const dir = directory(pkg)
|
|
||||||
const binDir = path.join(dir, "node_modules", ".bin")
|
|
||||||
|
|
||||||
const pick = async () => {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1 +1,325 @@
|
|||||||
export * as Permission from "./permission"
|
import { Bus } from "@/bus"
|
||||||
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { ConfigPermission } from "@/config/permission"
|
||||||
|
import { InstanceState } from "@/effect"
|
||||||
|
import { ProjectID } from "@/project/schema"
|
||||||
|
import { MessageID, SessionID } from "@/session/schema"
|
||||||
|
import { PermissionTable } from "@/session/session.sql"
|
||||||
|
import { Database, eq } from "@/storage"
|
||||||
|
import { zod } from "@/util/effect-zod"
|
||||||
|
import { Log } from "@/util"
|
||||||
|
import { withStatics } from "@/util/schema"
|
||||||
|
import { Wildcard } from "@/util"
|
||||||
|
import { Deferred, Effect, Layer, Schema, Context } from "effect"
|
||||||
|
import os from "os"
|
||||||
|
import { evaluate as evalRule } from "./evaluate"
|
||||||
|
import { PermissionID } from "./schema"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "permission" })
|
||||||
|
|
||||||
|
export const Action = Schema.Literals(["allow", "deny", "ask"])
|
||||||
|
.annotate({ identifier: "PermissionAction" })
|
||||||
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type Action = Schema.Schema.Type<typeof Action>
|
||||||
|
|
||||||
|
export class Rule extends Schema.Class<Rule>("PermissionRule")({
|
||||||
|
permission: Schema.String,
|
||||||
|
pattern: Schema.String,
|
||||||
|
action: Action,
|
||||||
|
}) {
|
||||||
|
static readonly zod = zod(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Ruleset = Schema.mutable(Schema.Array(Rule))
|
||||||
|
.annotate({ identifier: "PermissionRuleset" })
|
||||||
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type Ruleset = Schema.Schema.Type<typeof Ruleset>
|
||||||
|
|
||||||
|
export class Request extends Schema.Class<Request>("PermissionRequest")({
|
||||||
|
id: PermissionID,
|
||||||
|
sessionID: SessionID,
|
||||||
|
permission: Schema.String,
|
||||||
|
patterns: Schema.Array(Schema.String),
|
||||||
|
metadata: Schema.Record(Schema.String, Schema.Unknown),
|
||||||
|
always: Schema.Array(Schema.String),
|
||||||
|
tool: Schema.optional(
|
||||||
|
Schema.Struct({
|
||||||
|
messageID: MessageID,
|
||||||
|
callID: Schema.String,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}) {
|
||||||
|
static readonly zod = zod(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Reply = Schema.Literals(["once", "always", "reject"]).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type Reply = Schema.Schema.Type<typeof Reply>
|
||||||
|
|
||||||
|
const reply = {
|
||||||
|
reply: Reply,
|
||||||
|
message: Schema.optional(Schema.String),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReplyBody = Schema.Struct(reply)
|
||||||
|
.annotate({ identifier: "PermissionReplyBody" })
|
||||||
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type ReplyBody = Schema.Schema.Type<typeof ReplyBody>
|
||||||
|
|
||||||
|
export class Approval extends Schema.Class<Approval>("PermissionApproval")({
|
||||||
|
projectID: ProjectID,
|
||||||
|
patterns: Schema.Array(Schema.String),
|
||||||
|
}) {
|
||||||
|
static readonly zod = zod(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Asked: BusEvent.define("permission.asked", Request.zod),
|
||||||
|
Replied: BusEvent.define(
|
||||||
|
"permission.replied",
|
||||||
|
zod(
|
||||||
|
Schema.Struct({
|
||||||
|
sessionID: SessionID,
|
||||||
|
requestID: PermissionID,
|
||||||
|
reply: Reply,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
||||||
|
override get message() {
|
||||||
|
return "The user rejected permission to use this specific tool call."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
||||||
|
feedback: Schema.String,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
||||||
|
ruleset: Schema.Any,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Error = DeniedError | RejectedError | CorrectedError
|
||||||
|
|
||||||
|
export const AskInput = Schema.Struct({
|
||||||
|
...Request.fields,
|
||||||
|
id: Schema.optional(PermissionID),
|
||||||
|
ruleset: Ruleset,
|
||||||
|
})
|
||||||
|
.annotate({ identifier: "PermissionAskInput" })
|
||||||
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type AskInput = Schema.Schema.Type<typeof AskInput>
|
||||||
|
|
||||||
|
export const ReplyInput = Schema.Struct({
|
||||||
|
requestID: PermissionID,
|
||||||
|
...reply,
|
||||||
|
})
|
||||||
|
.annotate({ identifier: "PermissionReplyInput" })
|
||||||
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type ReplyInput = Schema.Schema.Type<typeof ReplyInput>
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly ask: (input: AskInput) => Effect.Effect<void, Error>
|
||||||
|
readonly reply: (input: ReplyInput) => Effect.Effect<void>
|
||||||
|
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingEntry {
|
||||||
|
info: Request
|
||||||
|
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
pending: Map<PermissionID, PendingEntry>
|
||||||
|
approved: Ruleset
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||||
|
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
|
||||||
|
return evalRule(permission, pattern, ...rulesets)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("Permission.state")(function* (ctx) {
|
||||||
|
const row = Database.use((db) =>
|
||||||
|
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
|
||||||
|
)
|
||||||
|
const state = {
|
||||||
|
pending: new Map<PermissionID, PendingEntry>(),
|
||||||
|
approved: row?.data ?? [],
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
for (const item of state.pending.values()) {
|
||||||
|
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||||
|
}
|
||||||
|
state.pending.clear()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return state
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ask = Effect.fn("Permission.ask")(function* (input: AskInput) {
|
||||||
|
const { approved, pending } = yield* InstanceState.get(state)
|
||||||
|
const { ruleset, ...request } = input
|
||||||
|
let needsAsk = false
|
||||||
|
|
||||||
|
for (const pattern of request.patterns) {
|
||||||
|
const rule = evaluate(request.permission, pattern, ruleset, approved)
|
||||||
|
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
||||||
|
if (rule.action === "deny") {
|
||||||
|
return yield* new DeniedError({
|
||||||
|
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (rule.action === "allow") continue
|
||||||
|
needsAsk = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!needsAsk) return
|
||||||
|
|
||||||
|
const id = request.id ?? PermissionID.ascending()
|
||||||
|
const info = Schema.decodeUnknownSync(Request)({
|
||||||
|
id,
|
||||||
|
...request,
|
||||||
|
})
|
||||||
|
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
||||||
|
|
||||||
|
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||||
|
pending.set(id, { info, deferred })
|
||||||
|
yield* bus.publish(Event.Asked, info)
|
||||||
|
return yield* Effect.ensuring(
|
||||||
|
Deferred.await(deferred),
|
||||||
|
Effect.sync(() => {
|
||||||
|
pending.delete(id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) {
|
||||||
|
const { approved, pending } = yield* InstanceState.get(state)
|
||||||
|
const existing = pending.get(input.requestID)
|
||||||
|
if (!existing) return
|
||||||
|
|
||||||
|
pending.delete(input.requestID)
|
||||||
|
yield* bus.publish(Event.Replied, {
|
||||||
|
sessionID: existing.info.sessionID,
|
||||||
|
requestID: existing.info.id,
|
||||||
|
reply: input.reply,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (input.reply === "reject") {
|
||||||
|
yield* Deferred.fail(
|
||||||
|
existing.deferred,
|
||||||
|
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const [id, item] of pending.entries()) {
|
||||||
|
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||||
|
pending.delete(id)
|
||||||
|
yield* bus.publish(Event.Replied, {
|
||||||
|
sessionID: item.info.sessionID,
|
||||||
|
requestID: item.info.id,
|
||||||
|
reply: "reject",
|
||||||
|
})
|
||||||
|
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Deferred.succeed(existing.deferred, undefined)
|
||||||
|
if (input.reply === "once") return
|
||||||
|
|
||||||
|
for (const pattern of existing.info.always) {
|
||||||
|
approved.push({
|
||||||
|
permission: existing.info.permission,
|
||||||
|
pattern,
|
||||||
|
action: "allow",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, item] of pending.entries()) {
|
||||||
|
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||||
|
const ok = item.info.patterns.every(
|
||||||
|
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
|
||||||
|
)
|
||||||
|
if (!ok) continue
|
||||||
|
pending.delete(id)
|
||||||
|
yield* bus.publish(Event.Replied, {
|
||||||
|
sessionID: item.info.sessionID,
|
||||||
|
requestID: item.info.id,
|
||||||
|
reply: "always",
|
||||||
|
})
|
||||||
|
yield* Deferred.succeed(item.deferred, undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("Permission.list")(function* () {
|
||||||
|
const pending = (yield* InstanceState.get(state)).pending
|
||||||
|
return Array.from(pending.values(), (item) => item.info)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ ask, reply, list })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function expand(pattern: string): string {
|
||||||
|
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
|
||||||
|
if (pattern === "~") return os.homedir()
|
||||||
|
if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
|
||||||
|
if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
|
||||||
|
return pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromConfig(permission: ConfigPermission.Info) {
|
||||||
|
const ruleset: Ruleset = []
|
||||||
|
for (const [key, value] of Object.entries(permission)) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
ruleset.push({ permission: key, action: value, pattern: "*" })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ruleset.push(
|
||||||
|
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ruleset
|
||||||
|
}
|
||||||
|
|
||||||
|
export function merge(...rulesets: Ruleset[]): Ruleset {
|
||||||
|
return rulesets.flat()
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
|
||||||
|
|
||||||
|
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
||||||
|
const result = new Set<string>()
|
||||||
|
for (const tool of tools) {
|
||||||
|
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
||||||
|
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
|
||||||
|
if (!rule) continue
|
||||||
|
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||||
|
|
||||||
|
export * as Permission from "."
|
||||||
|
|||||||
@@ -1,323 +0,0 @@
|
|||||||
import { Bus } from "@/bus"
|
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
|
||||||
import { ConfigPermission } from "@/config/permission"
|
|
||||||
import { InstanceState } from "@/effect"
|
|
||||||
import { ProjectID } from "@/project/schema"
|
|
||||||
import { MessageID, SessionID } from "@/session/schema"
|
|
||||||
import { PermissionTable } from "@/session/session.sql"
|
|
||||||
import { Database, eq } from "@/storage"
|
|
||||||
import { zod } from "@/util/effect-zod"
|
|
||||||
import { Log } from "@/util"
|
|
||||||
import { withStatics } from "@/util/schema"
|
|
||||||
import { Wildcard } from "@/util"
|
|
||||||
import { Deferred, Effect, Layer, Schema, Context } from "effect"
|
|
||||||
import os from "os"
|
|
||||||
import { evaluate as evalRule } from "./evaluate"
|
|
||||||
import { PermissionID } from "./schema"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "permission" })
|
|
||||||
|
|
||||||
export const Action = Schema.Literals(["allow", "deny", "ask"])
|
|
||||||
.annotate({ identifier: "PermissionAction" })
|
|
||||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
|
||||||
export type Action = Schema.Schema.Type<typeof Action>
|
|
||||||
|
|
||||||
export class Rule extends Schema.Class<Rule>("PermissionRule")({
|
|
||||||
permission: Schema.String,
|
|
||||||
pattern: Schema.String,
|
|
||||||
action: Action,
|
|
||||||
}) {
|
|
||||||
static readonly zod = zod(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Ruleset = Schema.mutable(Schema.Array(Rule))
|
|
||||||
.annotate({ identifier: "PermissionRuleset" })
|
|
||||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
|
||||||
export type Ruleset = Schema.Schema.Type<typeof Ruleset>
|
|
||||||
|
|
||||||
export class Request extends Schema.Class<Request>("PermissionRequest")({
|
|
||||||
id: PermissionID,
|
|
||||||
sessionID: SessionID,
|
|
||||||
permission: Schema.String,
|
|
||||||
patterns: Schema.Array(Schema.String),
|
|
||||||
metadata: Schema.Record(Schema.String, Schema.Unknown),
|
|
||||||
always: Schema.Array(Schema.String),
|
|
||||||
tool: Schema.optional(
|
|
||||||
Schema.Struct({
|
|
||||||
messageID: MessageID,
|
|
||||||
callID: Schema.String,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}) {
|
|
||||||
static readonly zod = zod(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Reply = Schema.Literals(["once", "always", "reject"]).pipe(withStatics((s) => ({ zod: zod(s) })))
|
|
||||||
export type Reply = Schema.Schema.Type<typeof Reply>
|
|
||||||
|
|
||||||
const reply = {
|
|
||||||
reply: Reply,
|
|
||||||
message: Schema.optional(Schema.String),
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ReplyBody = Schema.Struct(reply)
|
|
||||||
.annotate({ identifier: "PermissionReplyBody" })
|
|
||||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
|
||||||
export type ReplyBody = Schema.Schema.Type<typeof ReplyBody>
|
|
||||||
|
|
||||||
export class Approval extends Schema.Class<Approval>("PermissionApproval")({
|
|
||||||
projectID: ProjectID,
|
|
||||||
patterns: Schema.Array(Schema.String),
|
|
||||||
}) {
|
|
||||||
static readonly zod = zod(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
Asked: BusEvent.define("permission.asked", Request.zod),
|
|
||||||
Replied: BusEvent.define(
|
|
||||||
"permission.replied",
|
|
||||||
zod(
|
|
||||||
Schema.Struct({
|
|
||||||
sessionID: SessionID,
|
|
||||||
requestID: PermissionID,
|
|
||||||
reply: Reply,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
|
||||||
override get message() {
|
|
||||||
return "The user rejected permission to use this specific tool call."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
|
||||||
feedback: Schema.String,
|
|
||||||
}) {
|
|
||||||
override get message() {
|
|
||||||
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
|
||||||
ruleset: Schema.Any,
|
|
||||||
}) {
|
|
||||||
override get message() {
|
|
||||||
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Error = DeniedError | RejectedError | CorrectedError
|
|
||||||
|
|
||||||
export const AskInput = Schema.Struct({
|
|
||||||
...Request.fields,
|
|
||||||
id: Schema.optional(PermissionID),
|
|
||||||
ruleset: Ruleset,
|
|
||||||
})
|
|
||||||
.annotate({ identifier: "PermissionAskInput" })
|
|
||||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
|
||||||
export type AskInput = Schema.Schema.Type<typeof AskInput>
|
|
||||||
|
|
||||||
export const ReplyInput = Schema.Struct({
|
|
||||||
requestID: PermissionID,
|
|
||||||
...reply,
|
|
||||||
})
|
|
||||||
.annotate({ identifier: "PermissionReplyInput" })
|
|
||||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
|
||||||
export type ReplyInput = Schema.Schema.Type<typeof ReplyInput>
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly ask: (input: AskInput) => Effect.Effect<void, Error>
|
|
||||||
readonly reply: (input: ReplyInput) => Effect.Effect<void>
|
|
||||||
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PendingEntry {
|
|
||||||
info: Request
|
|
||||||
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
pending: Map<PermissionID, PendingEntry>
|
|
||||||
approved: Ruleset
|
|
||||||
}
|
|
||||||
|
|
||||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
|
||||||
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
|
|
||||||
return evalRule(permission, pattern, ...rulesets)
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const bus = yield* Bus.Service
|
|
||||||
const state = yield* InstanceState.make<State>(
|
|
||||||
Effect.fn("Permission.state")(function* (ctx) {
|
|
||||||
const row = Database.use((db) =>
|
|
||||||
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
|
|
||||||
)
|
|
||||||
const state = {
|
|
||||||
pending: new Map<PermissionID, PendingEntry>(),
|
|
||||||
approved: row?.data ?? [],
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
for (const item of state.pending.values()) {
|
|
||||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
|
||||||
}
|
|
||||||
state.pending.clear()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return state
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const ask = Effect.fn("Permission.ask")(function* (input: AskInput) {
|
|
||||||
const { approved, pending } = yield* InstanceState.get(state)
|
|
||||||
const { ruleset, ...request } = input
|
|
||||||
let needsAsk = false
|
|
||||||
|
|
||||||
for (const pattern of request.patterns) {
|
|
||||||
const rule = evaluate(request.permission, pattern, ruleset, approved)
|
|
||||||
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
|
||||||
if (rule.action === "deny") {
|
|
||||||
return yield* new DeniedError({
|
|
||||||
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (rule.action === "allow") continue
|
|
||||||
needsAsk = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!needsAsk) return
|
|
||||||
|
|
||||||
const id = request.id ?? PermissionID.ascending()
|
|
||||||
const info = Schema.decodeUnknownSync(Request)({
|
|
||||||
id,
|
|
||||||
...request,
|
|
||||||
})
|
|
||||||
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
|
||||||
|
|
||||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
|
||||||
pending.set(id, { info, deferred })
|
|
||||||
yield* bus.publish(Event.Asked, info)
|
|
||||||
return yield* Effect.ensuring(
|
|
||||||
Deferred.await(deferred),
|
|
||||||
Effect.sync(() => {
|
|
||||||
pending.delete(id)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) {
|
|
||||||
const { approved, pending } = yield* InstanceState.get(state)
|
|
||||||
const existing = pending.get(input.requestID)
|
|
||||||
if (!existing) return
|
|
||||||
|
|
||||||
pending.delete(input.requestID)
|
|
||||||
yield* bus.publish(Event.Replied, {
|
|
||||||
sessionID: existing.info.sessionID,
|
|
||||||
requestID: existing.info.id,
|
|
||||||
reply: input.reply,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (input.reply === "reject") {
|
|
||||||
yield* Deferred.fail(
|
|
||||||
existing.deferred,
|
|
||||||
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const [id, item] of pending.entries()) {
|
|
||||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
|
||||||
pending.delete(id)
|
|
||||||
yield* bus.publish(Event.Replied, {
|
|
||||||
sessionID: item.info.sessionID,
|
|
||||||
requestID: item.info.id,
|
|
||||||
reply: "reject",
|
|
||||||
})
|
|
||||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* Deferred.succeed(existing.deferred, undefined)
|
|
||||||
if (input.reply === "once") return
|
|
||||||
|
|
||||||
for (const pattern of existing.info.always) {
|
|
||||||
approved.push({
|
|
||||||
permission: existing.info.permission,
|
|
||||||
pattern,
|
|
||||||
action: "allow",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, item] of pending.entries()) {
|
|
||||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
|
||||||
const ok = item.info.patterns.every(
|
|
||||||
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
|
|
||||||
)
|
|
||||||
if (!ok) continue
|
|
||||||
pending.delete(id)
|
|
||||||
yield* bus.publish(Event.Replied, {
|
|
||||||
sessionID: item.info.sessionID,
|
|
||||||
requestID: item.info.id,
|
|
||||||
reply: "always",
|
|
||||||
})
|
|
||||||
yield* Deferred.succeed(item.deferred, undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("Permission.list")(function* () {
|
|
||||||
const pending = (yield* InstanceState.get(state)).pending
|
|
||||||
return Array.from(pending.values(), (item) => item.info)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ ask, reply, list })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
function expand(pattern: string): string {
|
|
||||||
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
|
|
||||||
if (pattern === "~") return os.homedir()
|
|
||||||
if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
|
|
||||||
if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
|
|
||||||
return pattern
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fromConfig(permission: ConfigPermission.Info) {
|
|
||||||
const ruleset: Ruleset = []
|
|
||||||
for (const [key, value] of Object.entries(permission)) {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
ruleset.push({ permission: key, action: value, pattern: "*" })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ruleset.push(
|
|
||||||
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return ruleset
|
|
||||||
}
|
|
||||||
|
|
||||||
export function merge(...rulesets: Ruleset[]): Ruleset {
|
|
||||||
return rulesets.flat()
|
|
||||||
}
|
|
||||||
|
|
||||||
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
|
|
||||||
|
|
||||||
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
|
||||||
const result = new Set<string>()
|
|
||||||
for (const tool of tools) {
|
|
||||||
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
|
||||||
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
|
|
||||||
if (!rule) continue
|
|
||||||
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||||
import type { Model } from "@opencode-ai/sdk/v2"
|
import type { Model } from "@opencode-ai/sdk/v2"
|
||||||
import { Installation } from "@/installation"
|
|
||||||
import { InstallationVersion } from "@/installation/version"
|
import { InstallationVersion } from "@/installation/version"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { Log } from "../../util"
|
import { Log } from "../../util"
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export namespace CopilotModels {
|
|||||||
// every version looks like: `{model.id}-YYYY-MM-DD`
|
// every version looks like: `{model.id}-YYYY-MM-DD`
|
||||||
version: z.string(),
|
version: z.string(),
|
||||||
supported_endpoints: z.array(z.string()).optional(),
|
supported_endpoints: z.array(z.string()).optional(),
|
||||||
|
policy: z
|
||||||
|
.object({
|
||||||
|
state: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
capabilities: z.object({
|
capabilities: z.object({
|
||||||
family: z.string(),
|
family: z.string(),
|
||||||
limits: z.object({
|
limits: z.object({
|
||||||
@@ -123,7 +128,9 @@ export namespace CopilotModels {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const result = { ...existing }
|
const result = { ...existing }
|
||||||
const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const))
|
const remote = new Map(
|
||||||
|
data.data.filter((m) => m.model_picker_enabled && m.policy?.state !== "disabled").map((m) => [m.id, m] as const),
|
||||||
|
)
|
||||||
|
|
||||||
// prune existing models whose api.id isn't in the endpoint response
|
// prune existing models whose api.id isn't in the endpoint response
|
||||||
for (const [key, model] of Object.entries(result)) {
|
for (const [key, model] of Object.entries(result)) {
|
||||||
|
|||||||
@@ -1 +1,289 @@
|
|||||||
export * as Plugin from "./plugin"
|
import type {
|
||||||
|
Hooks,
|
||||||
|
PluginInput,
|
||||||
|
Plugin as PluginInstance,
|
||||||
|
PluginModule,
|
||||||
|
WorkspaceAdaptor as PluginWorkspaceAdaptor,
|
||||||
|
} from "@opencode-ai/plugin"
|
||||||
|
import { Config } from "../config"
|
||||||
|
import { Bus } from "../bus"
|
||||||
|
import { Log } from "../util"
|
||||||
|
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
|
import { Flag } from "../flag/flag"
|
||||||
|
import { CodexAuthPlugin } from "./codex"
|
||||||
|
import { Session } from "../session"
|
||||||
|
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||||
|
import { CopilotAuthPlugin } from "./github-copilot/copilot"
|
||||||
|
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
||||||
|
import { PoeAuthPlugin } from "opencode-poe-auth"
|
||||||
|
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
|
||||||
|
import { Effect, Layer, Context, Stream } from "effect"
|
||||||
|
import { EffectBridge } from "@/effect"
|
||||||
|
import { InstanceState } from "@/effect"
|
||||||
|
import { errorMessage } from "@/util/error"
|
||||||
|
import { PluginLoader } from "./loader"
|
||||||
|
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
|
||||||
|
import { registerAdaptor } from "@/control-plane/adaptors"
|
||||||
|
import type { WorkspaceAdaptor } from "@/control-plane/types"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "plugin" })
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
hooks: Hooks[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook names that follow the (input, output) => Promise<void> trigger pattern
|
||||||
|
type TriggerName = {
|
||||||
|
[K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
|
||||||
|
}[keyof Hooks]
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly trigger: <
|
||||||
|
Name extends TriggerName,
|
||||||
|
Input = Parameters<Required<Hooks>[Name]>[0],
|
||||||
|
Output = Parameters<Required<Hooks>[Name]>[1],
|
||||||
|
>(
|
||||||
|
name: Name,
|
||||||
|
input: Input,
|
||||||
|
output: Output,
|
||||||
|
) => Effect.Effect<Output>
|
||||||
|
readonly list: () => Effect.Effect<Hooks[]>
|
||||||
|
readonly init: () => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
|
||||||
|
|
||||||
|
// Built-in plugins that are directly imported (not installed from npm)
|
||||||
|
const INTERNAL_PLUGINS: PluginInstance[] = [
|
||||||
|
CodexAuthPlugin,
|
||||||
|
CopilotAuthPlugin,
|
||||||
|
GitlabAuthPlugin,
|
||||||
|
PoeAuthPlugin,
|
||||||
|
CloudflareWorkersAuthPlugin,
|
||||||
|
CloudflareAIGatewayAuthPlugin,
|
||||||
|
]
|
||||||
|
|
||||||
|
function isServerPlugin(value: unknown): value is PluginInstance {
|
||||||
|
return typeof value === "function"
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerPlugin(value: unknown) {
|
||||||
|
if (isServerPlugin(value)) return value
|
||||||
|
if (!value || typeof value !== "object" || !("server" in value)) return
|
||||||
|
if (!isServerPlugin(value.server)) return
|
||||||
|
return value.server
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLegacyPlugins(mod: Record<string, unknown>) {
|
||||||
|
const seen = new Set<unknown>()
|
||||||
|
const result: PluginInstance[] = []
|
||||||
|
|
||||||
|
for (const entry of Object.values(mod)) {
|
||||||
|
if (seen.has(entry)) continue
|
||||||
|
seen.add(entry)
|
||||||
|
const plugin = getServerPlugin(entry)
|
||||||
|
if (!plugin) throw new TypeError("Plugin export is not a function")
|
||||||
|
result.push(plugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) {
|
||||||
|
const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
|
||||||
|
if (plugin) {
|
||||||
|
await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg)
|
||||||
|
hooks.push(await (plugin as PluginModule).server(input, load.options))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const server of getLegacyPlugins(load.mod)) {
|
||||||
|
hooks.push(await server(input, load.options))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
const config = yield* Config.Service
|
||||||
|
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("Plugin.state")(function* (ctx) {
|
||||||
|
const hooks: Hooks[] = []
|
||||||
|
const bridge = yield* EffectBridge.make()
|
||||||
|
|
||||||
|
function publishPluginError(message: string) {
|
||||||
|
bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Server } = yield* Effect.promise(() => import("../server/server"))
|
||||||
|
|
||||||
|
const client = createOpencodeClient({
|
||||||
|
baseUrl: "http://localhost:4096",
|
||||||
|
directory: ctx.directory,
|
||||||
|
headers: Flag.OPENCODE_SERVER_PASSWORD
|
||||||
|
? {
|
||||||
|
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
fetch: async (...args) => (await Server.Default()).app.fetch(...args),
|
||||||
|
})
|
||||||
|
const cfg = yield* config.get()
|
||||||
|
const input: PluginInput = {
|
||||||
|
client,
|
||||||
|
project: ctx.project,
|
||||||
|
worktree: ctx.worktree,
|
||||||
|
directory: ctx.directory,
|
||||||
|
experimental_workspace: {
|
||||||
|
register(type: string, adaptor: PluginWorkspaceAdaptor) {
|
||||||
|
registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
get serverUrl(): URL {
|
||||||
|
return Server.url ?? new URL("http://localhost:4096")
|
||||||
|
},
|
||||||
|
// @ts-expect-error
|
||||||
|
$: typeof Bun === "undefined" ? undefined : Bun.$,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const plugin of INTERNAL_PLUGINS) {
|
||||||
|
log.info("loading internal plugin", { name: plugin.name })
|
||||||
|
const init = yield* Effect.tryPromise({
|
||||||
|
try: () => plugin(input),
|
||||||
|
catch: (err) => {
|
||||||
|
log.error("failed to load internal plugin", { name: plugin.name, error: err })
|
||||||
|
},
|
||||||
|
}).pipe(Effect.option)
|
||||||
|
if (init._tag === "Some") hooks.push(init.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin_origins ?? [])
|
||||||
|
if (Flag.OPENCODE_PURE && cfg.plugin_origins?.length) {
|
||||||
|
log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length })
|
||||||
|
}
|
||||||
|
if (plugins.length) yield* config.waitForDependencies()
|
||||||
|
|
||||||
|
const loaded = yield* Effect.promise(() =>
|
||||||
|
PluginLoader.loadExternal({
|
||||||
|
items: plugins,
|
||||||
|
kind: "server",
|
||||||
|
report: {
|
||||||
|
start(candidate) {
|
||||||
|
log.info("loading plugin", { path: candidate.plan.spec })
|
||||||
|
},
|
||||||
|
missing(candidate, _retry, message) {
|
||||||
|
log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message })
|
||||||
|
},
|
||||||
|
error(candidate, _retry, stage, error, resolved) {
|
||||||
|
const spec = candidate.plan.spec
|
||||||
|
const cause = error instanceof Error ? (error.cause ?? error) : error
|
||||||
|
const message = stage === "load" ? errorMessage(error) : errorMessage(cause)
|
||||||
|
|
||||||
|
if (stage === "install") {
|
||||||
|
const parsed = parsePluginSpecifier(spec)
|
||||||
|
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message })
|
||||||
|
publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage === "compatibility") {
|
||||||
|
log.warn("plugin incompatible", { path: spec, error: message })
|
||||||
|
publishPluginError(`Plugin ${spec} skipped: ${message}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage === "entry") {
|
||||||
|
log.error("failed to resolve plugin server entry", { path: spec, error: message })
|
||||||
|
publishPluginError(`Failed to load plugin ${spec}: ${message}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message })
|
||||||
|
publishPluginError(`Failed to load plugin ${spec}: ${message}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
for (const load of loaded) {
|
||||||
|
if (!load) continue
|
||||||
|
|
||||||
|
// Keep plugin execution sequential so hook registration and execution
|
||||||
|
// order remains deterministic across plugin runs.
|
||||||
|
yield* Effect.tryPromise({
|
||||||
|
try: () => applyPlugin(load, input, hooks),
|
||||||
|
catch: (err) => {
|
||||||
|
const message = errorMessage(err)
|
||||||
|
log.error("failed to load plugin", { path: load.spec, error: message })
|
||||||
|
return message
|
||||||
|
},
|
||||||
|
}).pipe(
|
||||||
|
Effect.catch(() => {
|
||||||
|
// TODO: make proper events for this
|
||||||
|
// bus.publish(Session.Event.Error, {
|
||||||
|
// error: new NamedError.Unknown({
|
||||||
|
// message: `Failed to load plugin ${load.spec}: ${message}`,
|
||||||
|
// }).toObject(),
|
||||||
|
// })
|
||||||
|
return Effect.void
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify plugins of current config
|
||||||
|
for (const hook of hooks) {
|
||||||
|
yield* Effect.tryPromise({
|
||||||
|
try: () => Promise.resolve((hook as any).config?.(cfg)),
|
||||||
|
catch: (err) => {
|
||||||
|
log.error("plugin config hook failed", { error: err })
|
||||||
|
},
|
||||||
|
}).pipe(Effect.ignore)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to bus events, fiber interrupted when scope closes
|
||||||
|
yield* bus.subscribeAll().pipe(
|
||||||
|
Stream.runForEach((input) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
for (const hook of hooks) {
|
||||||
|
void hook["event"]?.({ event: input as any })
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
|
|
||||||
|
return { hooks }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const trigger = Effect.fn("Plugin.trigger")(function* <
|
||||||
|
Name extends TriggerName,
|
||||||
|
Input = Parameters<Required<Hooks>[Name]>[0],
|
||||||
|
Output = Parameters<Required<Hooks>[Name]>[1],
|
||||||
|
>(name: Name, input: Input, output: Output) {
|
||||||
|
if (!name) return output
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
for (const hook of s.hooks) {
|
||||||
|
const fn = hook[name] as any
|
||||||
|
if (!fn) continue
|
||||||
|
yield* Effect.promise(async () => fn(input, output))
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("Plugin.list")(function* () {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
return s.hooks
|
||||||
|
})
|
||||||
|
|
||||||
|
const init = Effect.fn("Plugin.init")(function* () {
|
||||||
|
yield* InstanceState.get(state)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ trigger, list, init })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer))
|
||||||
|
|
||||||
|
export * as Plugin from "."
|
||||||
|
|||||||
@@ -1,287 +0,0 @@
|
|||||||
import type {
|
|
||||||
Hooks,
|
|
||||||
PluginInput,
|
|
||||||
Plugin as PluginInstance,
|
|
||||||
PluginModule,
|
|
||||||
WorkspaceAdaptor as PluginWorkspaceAdaptor,
|
|
||||||
} from "@opencode-ai/plugin"
|
|
||||||
import { Config } from "../config"
|
|
||||||
import { Bus } from "../bus"
|
|
||||||
import { Log } from "../util"
|
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
|
||||||
import { Flag } from "../flag/flag"
|
|
||||||
import { CodexAuthPlugin } from "./codex"
|
|
||||||
import { Session } from "../session"
|
|
||||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
|
||||||
import { CopilotAuthPlugin } from "./github-copilot/copilot"
|
|
||||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
|
||||||
import { PoeAuthPlugin } from "opencode-poe-auth"
|
|
||||||
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
|
|
||||||
import { Effect, Layer, Context, Stream } from "effect"
|
|
||||||
import { EffectBridge } from "@/effect"
|
|
||||||
import { InstanceState } from "@/effect"
|
|
||||||
import { errorMessage } from "@/util/error"
|
|
||||||
import { PluginLoader } from "./loader"
|
|
||||||
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
|
|
||||||
import { registerAdaptor } from "@/control-plane/adaptors"
|
|
||||||
import type { WorkspaceAdaptor } from "@/control-plane/types"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "plugin" })
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
hooks: Hooks[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook names that follow the (input, output) => Promise<void> trigger pattern
|
|
||||||
type TriggerName = {
|
|
||||||
[K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
|
|
||||||
}[keyof Hooks]
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly trigger: <
|
|
||||||
Name extends TriggerName,
|
|
||||||
Input = Parameters<Required<Hooks>[Name]>[0],
|
|
||||||
Output = Parameters<Required<Hooks>[Name]>[1],
|
|
||||||
>(
|
|
||||||
name: Name,
|
|
||||||
input: Input,
|
|
||||||
output: Output,
|
|
||||||
) => Effect.Effect<Output>
|
|
||||||
readonly list: () => Effect.Effect<Hooks[]>
|
|
||||||
readonly init: () => Effect.Effect<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
|
|
||||||
|
|
||||||
// Built-in plugins that are directly imported (not installed from npm)
|
|
||||||
const INTERNAL_PLUGINS: PluginInstance[] = [
|
|
||||||
CodexAuthPlugin,
|
|
||||||
CopilotAuthPlugin,
|
|
||||||
GitlabAuthPlugin,
|
|
||||||
PoeAuthPlugin,
|
|
||||||
CloudflareWorkersAuthPlugin,
|
|
||||||
CloudflareAIGatewayAuthPlugin,
|
|
||||||
]
|
|
||||||
|
|
||||||
function isServerPlugin(value: unknown): value is PluginInstance {
|
|
||||||
return typeof value === "function"
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServerPlugin(value: unknown) {
|
|
||||||
if (isServerPlugin(value)) return value
|
|
||||||
if (!value || typeof value !== "object" || !("server" in value)) return
|
|
||||||
if (!isServerPlugin(value.server)) return
|
|
||||||
return value.server
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLegacyPlugins(mod: Record<string, unknown>) {
|
|
||||||
const seen = new Set<unknown>()
|
|
||||||
const result: PluginInstance[] = []
|
|
||||||
|
|
||||||
for (const entry of Object.values(mod)) {
|
|
||||||
if (seen.has(entry)) continue
|
|
||||||
seen.add(entry)
|
|
||||||
const plugin = getServerPlugin(entry)
|
|
||||||
if (!plugin) throw new TypeError("Plugin export is not a function")
|
|
||||||
result.push(plugin)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) {
|
|
||||||
const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
|
|
||||||
if (plugin) {
|
|
||||||
await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg)
|
|
||||||
hooks.push(await (plugin as PluginModule).server(input, load.options))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const server of getLegacyPlugins(load.mod)) {
|
|
||||||
hooks.push(await server(input, load.options))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const bus = yield* Bus.Service
|
|
||||||
const config = yield* Config.Service
|
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
|
||||||
Effect.fn("Plugin.state")(function* (ctx) {
|
|
||||||
const hooks: Hooks[] = []
|
|
||||||
const bridge = yield* EffectBridge.make()
|
|
||||||
|
|
||||||
function publishPluginError(message: string) {
|
|
||||||
bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const { Server } = yield* Effect.promise(() => import("../server/server"))
|
|
||||||
|
|
||||||
const client = createOpencodeClient({
|
|
||||||
baseUrl: "http://localhost:4096",
|
|
||||||
directory: ctx.directory,
|
|
||||||
headers: Flag.OPENCODE_SERVER_PASSWORD
|
|
||||||
? {
|
|
||||||
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
fetch: async (...args) => (await Server.Default()).app.fetch(...args),
|
|
||||||
})
|
|
||||||
const cfg = yield* config.get()
|
|
||||||
const input: PluginInput = {
|
|
||||||
client,
|
|
||||||
project: ctx.project,
|
|
||||||
worktree: ctx.worktree,
|
|
||||||
directory: ctx.directory,
|
|
||||||
experimental_workspace: {
|
|
||||||
register(type: string, adaptor: PluginWorkspaceAdaptor) {
|
|
||||||
registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
get serverUrl(): URL {
|
|
||||||
return Server.url ?? new URL("http://localhost:4096")
|
|
||||||
},
|
|
||||||
// @ts-expect-error
|
|
||||||
$: typeof Bun === "undefined" ? undefined : Bun.$,
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const plugin of INTERNAL_PLUGINS) {
|
|
||||||
log.info("loading internal plugin", { name: plugin.name })
|
|
||||||
const init = yield* Effect.tryPromise({
|
|
||||||
try: () => plugin(input),
|
|
||||||
catch: (err) => {
|
|
||||||
log.error("failed to load internal plugin", { name: plugin.name, error: err })
|
|
||||||
},
|
|
||||||
}).pipe(Effect.option)
|
|
||||||
if (init._tag === "Some") hooks.push(init.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin_origins ?? [])
|
|
||||||
if (Flag.OPENCODE_PURE && cfg.plugin_origins?.length) {
|
|
||||||
log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length })
|
|
||||||
}
|
|
||||||
if (plugins.length) yield* config.waitForDependencies()
|
|
||||||
|
|
||||||
const loaded = yield* Effect.promise(() =>
|
|
||||||
PluginLoader.loadExternal({
|
|
||||||
items: plugins,
|
|
||||||
kind: "server",
|
|
||||||
report: {
|
|
||||||
start(candidate) {
|
|
||||||
log.info("loading plugin", { path: candidate.plan.spec })
|
|
||||||
},
|
|
||||||
missing(candidate, _retry, message) {
|
|
||||||
log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message })
|
|
||||||
},
|
|
||||||
error(candidate, _retry, stage, error, resolved) {
|
|
||||||
const spec = candidate.plan.spec
|
|
||||||
const cause = error instanceof Error ? (error.cause ?? error) : error
|
|
||||||
const message = stage === "load" ? errorMessage(error) : errorMessage(cause)
|
|
||||||
|
|
||||||
if (stage === "install") {
|
|
||||||
const parsed = parsePluginSpecifier(spec)
|
|
||||||
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message })
|
|
||||||
publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stage === "compatibility") {
|
|
||||||
log.warn("plugin incompatible", { path: spec, error: message })
|
|
||||||
publishPluginError(`Plugin ${spec} skipped: ${message}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stage === "entry") {
|
|
||||||
log.error("failed to resolve plugin server entry", { path: spec, error: message })
|
|
||||||
publishPluginError(`Failed to load plugin ${spec}: ${message}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message })
|
|
||||||
publishPluginError(`Failed to load plugin ${spec}: ${message}`)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
for (const load of loaded) {
|
|
||||||
if (!load) continue
|
|
||||||
|
|
||||||
// Keep plugin execution sequential so hook registration and execution
|
|
||||||
// order remains deterministic across plugin runs.
|
|
||||||
yield* Effect.tryPromise({
|
|
||||||
try: () => applyPlugin(load, input, hooks),
|
|
||||||
catch: (err) => {
|
|
||||||
const message = errorMessage(err)
|
|
||||||
log.error("failed to load plugin", { path: load.spec, error: message })
|
|
||||||
return message
|
|
||||||
},
|
|
||||||
}).pipe(
|
|
||||||
Effect.catch(() => {
|
|
||||||
// TODO: make proper events for this
|
|
||||||
// bus.publish(Session.Event.Error, {
|
|
||||||
// error: new NamedError.Unknown({
|
|
||||||
// message: `Failed to load plugin ${load.spec}: ${message}`,
|
|
||||||
// }).toObject(),
|
|
||||||
// })
|
|
||||||
return Effect.void
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify plugins of current config
|
|
||||||
for (const hook of hooks) {
|
|
||||||
yield* Effect.tryPromise({
|
|
||||||
try: () => Promise.resolve((hook as any).config?.(cfg)),
|
|
||||||
catch: (err) => {
|
|
||||||
log.error("plugin config hook failed", { error: err })
|
|
||||||
},
|
|
||||||
}).pipe(Effect.ignore)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to bus events, fiber interrupted when scope closes
|
|
||||||
yield* bus.subscribeAll().pipe(
|
|
||||||
Stream.runForEach((input) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
for (const hook of hooks) {
|
|
||||||
void hook["event"]?.({ event: input as any })
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Effect.forkScoped,
|
|
||||||
)
|
|
||||||
|
|
||||||
return { hooks }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const trigger = Effect.fn("Plugin.trigger")(function* <
|
|
||||||
Name extends TriggerName,
|
|
||||||
Input = Parameters<Required<Hooks>[Name]>[0],
|
|
||||||
Output = Parameters<Required<Hooks>[Name]>[1],
|
|
||||||
>(name: Name, input: Input, output: Output) {
|
|
||||||
if (!name) return output
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
for (const hook of s.hooks) {
|
|
||||||
const fn = hook[name] as any
|
|
||||||
if (!fn) continue
|
|
||||||
yield* Effect.promise(async () => fn(input, output))
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("Plugin.list")(function* () {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
return s.hooks
|
|
||||||
})
|
|
||||||
|
|
||||||
const init = Effect.fn("Plugin.init")(function* () {
|
|
||||||
yield* InstanceState.get(state)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ trigger, list, init })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer))
|
|
||||||
@@ -12,9 +12,13 @@ import { Log } from "@/util"
|
|||||||
import { FileWatcher } from "@/file/watcher"
|
import { FileWatcher } from "@/file/watcher"
|
||||||
import { ShareNext } from "@/share"
|
import { ShareNext } from "@/share"
|
||||||
import * as Effect from "effect/Effect"
|
import * as Effect from "effect/Effect"
|
||||||
|
import { Config } from "@/config"
|
||||||
|
|
||||||
export const InstanceBootstrap = Effect.gen(function* () {
|
export const InstanceBootstrap = Effect.gen(function* () {
|
||||||
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
||||||
|
// everything depends on config so eager load it for nice traces
|
||||||
|
yield* Config.Service.use((svc) => svc.get())
|
||||||
|
// Plugin can mutate config so it has to be initialized before anything else.
|
||||||
yield* Plugin.Service.use((svc) => svc.init())
|
yield* Plugin.Service.use((svc) => svc.init())
|
||||||
yield* Effect.all(
|
yield* Effect.all(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -547,12 +547,14 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
|||||||
},
|
},
|
||||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||||
if (modelID.startsWith("duo-workflow-")) {
|
if (modelID.startsWith("duo-workflow-")) {
|
||||||
const workflowRef = options?.workflowRef as string | undefined
|
const workflowRef = typeof options?.workflowRef === "string" ? options.workflowRef : undefined
|
||||||
// Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef
|
// Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef
|
||||||
const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
|
const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
|
||||||
|
const workflowDefinition =
|
||||||
|
typeof options?.workflowDefinition === "string" ? options.workflowDefinition : undefined
|
||||||
const model = sdk.workflowChat(sdkModelID, {
|
const model = sdk.workflowChat(sdkModelID, {
|
||||||
featureFlags,
|
featureFlags,
|
||||||
workflowDefinition: options?.workflowDefinition as string | undefined,
|
workflowDefinition,
|
||||||
})
|
})
|
||||||
if (workflowRef) {
|
if (workflowRef) {
|
||||||
model.selectedModelRef = workflowRef
|
model.selectedModelRef = workflowRef
|
||||||
|
|||||||
@@ -923,7 +923,7 @@ export function smallOptions(model: Provider.Model) {
|
|||||||
model.api.npm === "@ai-sdk/github-copilot"
|
model.api.npm === "@ai-sdk/github-copilot"
|
||||||
) {
|
) {
|
||||||
if (model.api.id.includes("gpt-5")) {
|
if (model.api.id.includes("gpt-5")) {
|
||||||
if (model.api.id.includes("5.")) {
|
if (model.api.id.includes("5.") || model.api.id.includes("5-mini")) {
|
||||||
return { store: false, reasoningEffort: "low" }
|
return { store: false, reasoningEffort: "low" }
|
||||||
}
|
}
|
||||||
return { store: false, reasoningEffort: "minimal" }
|
return { store: false, reasoningEffort: "minimal" }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { AppRuntime } from "@/effect/app-runtime"
|
|||||||
import { AsyncQueue } from "../../util/queue"
|
import { AsyncQueue } from "../../util/queue"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
|
import { SessionID } from "@/session/schema"
|
||||||
|
|
||||||
const TuiRequest = z.object({
|
const TuiRequest = z.object({
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
@@ -371,7 +372,7 @@ export const TuiRoutes = lazy(() =>
|
|||||||
validator("json", TuiEvent.SessionSelect.properties),
|
validator("json", TuiEvent.SessionSelect.properties),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { sessionID } = c.req.valid("json")
|
const { sessionID } = c.req.valid("json")
|
||||||
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
|
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(SessionID.make(sessionID))))
|
||||||
await Bus.publish(TuiEvent.SessionSelect, { sessionID })
|
await Bus.publish(TuiEvent.SessionSelect, { sessionID })
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,58 +3,58 @@ import { Bonjour } from "bonjour-service"
|
|||||||
|
|
||||||
const log = Log.create({ service: "mdns" })
|
const log = Log.create({ service: "mdns" })
|
||||||
|
|
||||||
export namespace MDNS {
|
let bonjour: Bonjour | undefined
|
||||||
let bonjour: Bonjour | undefined
|
let currentPort: number | undefined
|
||||||
let currentPort: number | undefined
|
|
||||||
|
|
||||||
export function publish(port: number, domain?: string) {
|
export function publish(port: number, domain?: string) {
|
||||||
if (currentPort === port) return
|
if (currentPort === port) return
|
||||||
if (bonjour) unpublish()
|
if (bonjour) unpublish()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const host = domain ?? "opencode.local"
|
const host = domain ?? "opencode.local"
|
||||||
const name = `opencode-${port}`
|
const name = `opencode-${port}`
|
||||||
bonjour = new Bonjour()
|
bonjour = new Bonjour()
|
||||||
const service = bonjour.publish({
|
const service = bonjour.publish({
|
||||||
name,
|
name,
|
||||||
type: "http",
|
type: "http",
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
txt: { path: "/" },
|
txt: { path: "/" },
|
||||||
})
|
})
|
||||||
|
|
||||||
service.on("up", () => {
|
service.on("up", () => {
|
||||||
log.info("mDNS service published", { name, port })
|
log.info("mDNS service published", { name, port })
|
||||||
})
|
})
|
||||||
|
|
||||||
service.on("error", (err) => {
|
service.on("error", (err) => {
|
||||||
log.error("mDNS service error", { error: err })
|
log.error("mDNS service error", { error: err })
|
||||||
})
|
})
|
||||||
|
|
||||||
currentPort = port
|
currentPort = port
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error("mDNS publish failed", { error: err })
|
log.error("mDNS publish failed", { error: err })
|
||||||
if (bonjour) {
|
|
||||||
try {
|
|
||||||
bonjour.destroy()
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
bonjour = undefined
|
|
||||||
currentPort = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unpublish() {
|
|
||||||
if (bonjour) {
|
if (bonjour) {
|
||||||
try {
|
try {
|
||||||
bonjour.unpublishAll()
|
|
||||||
bonjour.destroy()
|
bonjour.destroy()
|
||||||
} catch (err) {
|
} catch {}
|
||||||
log.error("mDNS unpublish failed", { error: err })
|
|
||||||
}
|
|
||||||
bonjour = undefined
|
|
||||||
currentPort = undefined
|
|
||||||
log.info("mDNS service unpublished")
|
|
||||||
}
|
}
|
||||||
|
bonjour = undefined
|
||||||
|
currentPort = undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function unpublish() {
|
||||||
|
if (bonjour) {
|
||||||
|
try {
|
||||||
|
bonjour.unpublishAll()
|
||||||
|
bonjour.destroy()
|
||||||
|
} catch (err) {
|
||||||
|
log.error("mDNS unpublish failed", { error: err })
|
||||||
|
}
|
||||||
|
bonjour = undefined
|
||||||
|
currentPort = undefined
|
||||||
|
log.info("mDNS service unpublished")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as MDNS from "./mdns"
|
||||||
|
|||||||
@@ -101,89 +101,83 @@ const app = (upgrade: UpgradeWebSocket) =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export namespace ServerProxy {
|
const log = Log.Default.clone().tag("service", "server-proxy")
|
||||||
const log = Log.Default.clone().tag("service", "server-proxy")
|
|
||||||
|
|
||||||
export async function http(
|
export async function http(
|
||||||
url: string | URL,
|
url: string | URL,
|
||||||
extra: HeadersInit | undefined,
|
extra: HeadersInit | undefined,
|
||||||
req: Request,
|
req: Request,
|
||||||
workspaceID: WorkspaceID,
|
workspaceID: WorkspaceID,
|
||||||
) {
|
) {
|
||||||
console.log("proxy http request", {
|
if (!Workspace.isSyncing(workspaceID)) {
|
||||||
|
return new Response(`broken sync connection for workspace: ${workspaceID}`, {
|
||||||
|
status: 503,
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/plain; charset=utf-8",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(
|
||||||
|
new Request(url, {
|
||||||
method: req.method,
|
method: req.method,
|
||||||
request: req.url,
|
headers: headers(req, extra),
|
||||||
url: String(url),
|
body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,
|
||||||
})
|
redirect: "manual",
|
||||||
|
signal: req.signal,
|
||||||
|
}),
|
||||||
|
).then((res) => {
|
||||||
|
const sync = Fence.parse(res.headers)
|
||||||
|
const next = new Headers(res.headers)
|
||||||
|
next.delete("content-encoding")
|
||||||
|
next.delete("content-length")
|
||||||
|
|
||||||
if (!Workspace.isSyncing(workspaceID)) {
|
const done = sync ? Fence.wait(workspaceID, sync, req.signal) : Promise.resolve()
|
||||||
return new Response(`broken sync connection for workspace: ${workspaceID}`, {
|
|
||||||
status: 503,
|
|
||||||
headers: {
|
|
||||||
"content-type": "text/plain; charset=utf-8",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetch(
|
return done.then(async () => {
|
||||||
new Request(url, {
|
console.log("proxy http response", {
|
||||||
method: req.method,
|
method: req.method,
|
||||||
headers: headers(req, extra),
|
request: req.url,
|
||||||
body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,
|
url: String(url),
|
||||||
redirect: "manual",
|
status: res.status,
|
||||||
signal: req.signal,
|
statusText: res.statusText,
|
||||||
}),
|
|
||||||
).then((res) => {
|
|
||||||
const sync = Fence.parse(res.headers)
|
|
||||||
const next = new Headers(res.headers)
|
|
||||||
next.delete("content-encoding")
|
|
||||||
next.delete("content-length")
|
|
||||||
|
|
||||||
const done = sync ? Fence.wait(workspaceID, sync, req.signal) : Promise.resolve()
|
|
||||||
|
|
||||||
return done.then(async () => {
|
|
||||||
console.log("proxy http response", {
|
|
||||||
method: req.method,
|
|
||||||
request: req.url,
|
|
||||||
url: String(url),
|
|
||||||
status: res.status,
|
|
||||||
statusText: res.statusText,
|
|
||||||
})
|
|
||||||
return new Response(res.body, {
|
|
||||||
status: res.status,
|
|
||||||
statusText: res.statusText,
|
|
||||||
headers: next,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
return new Response(res.body, {
|
||||||
}
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
export function websocket(
|
|
||||||
upgrade: UpgradeWebSocket,
|
|
||||||
target: string | URL,
|
|
||||||
extra: HeadersInit | undefined,
|
|
||||||
req: Request,
|
|
||||||
env: unknown,
|
|
||||||
) {
|
|
||||||
const proxy = new URL(req.url)
|
|
||||||
proxy.pathname = "/__workspace_ws"
|
|
||||||
proxy.search = ""
|
|
||||||
const next = new Headers(req.headers)
|
|
||||||
next.set("x-opencode-proxy-url", socket(target))
|
|
||||||
for (const [key, value] of new Headers(extra).entries()) {
|
|
||||||
next.set(key, value)
|
|
||||||
}
|
|
||||||
log.info("proxy websocket", {
|
|
||||||
request: req.url,
|
|
||||||
target: String(target),
|
|
||||||
})
|
|
||||||
return app(upgrade).fetch(
|
|
||||||
new Request(proxy, {
|
|
||||||
method: req.method,
|
|
||||||
headers: next,
|
headers: next,
|
||||||
signal: req.signal,
|
})
|
||||||
}),
|
})
|
||||||
env as never,
|
})
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function websocket(
|
||||||
|
upgrade: UpgradeWebSocket,
|
||||||
|
target: string | URL,
|
||||||
|
extra: HeadersInit | undefined,
|
||||||
|
req: Request,
|
||||||
|
env: unknown,
|
||||||
|
) {
|
||||||
|
const proxy = new URL(req.url)
|
||||||
|
proxy.pathname = "/__workspace_ws"
|
||||||
|
proxy.search = ""
|
||||||
|
const next = new Headers(req.headers)
|
||||||
|
next.set("x-opencode-proxy-url", socket(target))
|
||||||
|
for (const [key, value] of new Headers(extra).entries()) {
|
||||||
|
next.set(key, value)
|
||||||
|
}
|
||||||
|
log.info("proxy websocket", {
|
||||||
|
request: req.url,
|
||||||
|
target: String(target),
|
||||||
|
})
|
||||||
|
return app(upgrade).fetch(
|
||||||
|
new Request(proxy, {
|
||||||
|
method: req.method,
|
||||||
|
headers: next,
|
||||||
|
signal: req.signal,
|
||||||
|
}),
|
||||||
|
env as never,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as ServerProxy from "./proxy"
|
||||||
|
|||||||
@@ -17,37 +17,22 @@ globalThis.AI_SDK_LOG_WARNINGS = false
|
|||||||
|
|
||||||
initProjectors()
|
initProjectors()
|
||||||
|
|
||||||
export namespace Server {
|
const log = Log.create({ service: "server" })
|
||||||
const log = Log.create({ service: "server" })
|
|
||||||
|
|
||||||
export type Listener = {
|
export type Listener = {
|
||||||
hostname: string
|
hostname: string
|
||||||
port: number
|
port: number
|
||||||
url: URL
|
url: URL
|
||||||
stop: (close?: boolean) => Promise<void>
|
stop: (close?: boolean) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Default = lazy(() => create({}))
|
export const Default = lazy(() => create({}))
|
||||||
|
|
||||||
function create(opts: { cors?: string[] }) {
|
function create(opts: { cors?: string[] }) {
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
const runtime = adapter.create(app)
|
const runtime = adapter.create(app)
|
||||||
|
|
||||||
if (Flag.OPENCODE_WORKSPACE_ID) {
|
|
||||||
return {
|
|
||||||
app: app
|
|
||||||
.onError(ErrorMiddleware)
|
|
||||||
.use(AuthMiddleware)
|
|
||||||
.use(LoggerMiddleware)
|
|
||||||
.use(CompressionMiddleware)
|
|
||||||
.use(CorsMiddleware(opts))
|
|
||||||
.use(FenceMiddleware)
|
|
||||||
.route("/", ControlPlaneRoutes())
|
|
||||||
.route("/", InstanceRoutes(runtime.upgradeWebSocket)),
|
|
||||||
runtime,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (Flag.OPENCODE_WORKSPACE_ID) {
|
||||||
return {
|
return {
|
||||||
app: app
|
app: app
|
||||||
.onError(ErrorMiddleware)
|
.onError(ErrorMiddleware)
|
||||||
@@ -55,73 +40,88 @@ export namespace Server {
|
|||||||
.use(LoggerMiddleware)
|
.use(LoggerMiddleware)
|
||||||
.use(CompressionMiddleware)
|
.use(CompressionMiddleware)
|
||||||
.use(CorsMiddleware(opts))
|
.use(CorsMiddleware(opts))
|
||||||
|
.use(FenceMiddleware)
|
||||||
.route("/", ControlPlaneRoutes())
|
.route("/", ControlPlaneRoutes())
|
||||||
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
|
.route("/", InstanceRoutes(runtime.upgradeWebSocket)),
|
||||||
.route("/", UIRoutes()),
|
|
||||||
runtime,
|
runtime,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openapi() {
|
return {
|
||||||
// Build a fresh app with all routes registered directly so
|
app: app
|
||||||
// hono-openapi can see describeRoute metadata (`.route()` wraps
|
.onError(ErrorMiddleware)
|
||||||
// handlers when the sub-app has a custom errorHandler, which
|
.use(AuthMiddleware)
|
||||||
// strips the metadata symbol).
|
.use(LoggerMiddleware)
|
||||||
const { app } = create({})
|
.use(CompressionMiddleware)
|
||||||
const result = await generateSpecs(app, {
|
.use(CorsMiddleware(opts))
|
||||||
documentation: {
|
.route("/", ControlPlaneRoutes())
|
||||||
info: {
|
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
|
||||||
title: "opencode",
|
.route("/", UIRoutes()),
|
||||||
version: "1.0.0",
|
runtime,
|
||||||
description: "opencode api",
|
|
||||||
},
|
|
||||||
openapi: "3.1.1",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export let url: URL
|
|
||||||
|
|
||||||
export async function listen(opts: {
|
|
||||||
port: number
|
|
||||||
hostname: string
|
|
||||||
mdns?: boolean
|
|
||||||
mdnsDomain?: string
|
|
||||||
cors?: string[]
|
|
||||||
}): Promise<Listener> {
|
|
||||||
const built = create(opts)
|
|
||||||
const server = await built.runtime.listen(opts)
|
|
||||||
|
|
||||||
const next = new URL("http://localhost")
|
|
||||||
next.hostname = opts.hostname
|
|
||||||
next.port = String(server.port)
|
|
||||||
url = next
|
|
||||||
|
|
||||||
const mdns =
|
|
||||||
opts.mdns &&
|
|
||||||
server.port &&
|
|
||||||
opts.hostname !== "127.0.0.1" &&
|
|
||||||
opts.hostname !== "localhost" &&
|
|
||||||
opts.hostname !== "::1"
|
|
||||||
if (mdns) {
|
|
||||||
MDNS.publish(server.port, opts.mdnsDomain)
|
|
||||||
} else if (opts.mdns) {
|
|
||||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
|
||||||
}
|
|
||||||
|
|
||||||
let closing: Promise<void> | undefined
|
|
||||||
return {
|
|
||||||
hostname: opts.hostname,
|
|
||||||
port: server.port,
|
|
||||||
url: next,
|
|
||||||
stop(close?: boolean) {
|
|
||||||
closing ??= (async () => {
|
|
||||||
if (mdns) MDNS.unpublish()
|
|
||||||
await server.stop(close)
|
|
||||||
})()
|
|
||||||
return closing
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function openapi() {
|
||||||
|
// Build a fresh app with all routes registered directly so
|
||||||
|
// hono-openapi can see describeRoute metadata (`.route()` wraps
|
||||||
|
// handlers when the sub-app has a custom errorHandler, which
|
||||||
|
// strips the metadata symbol).
|
||||||
|
const { app } = create({})
|
||||||
|
const result = await generateSpecs(app, {
|
||||||
|
documentation: {
|
||||||
|
info: {
|
||||||
|
title: "opencode",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "opencode api",
|
||||||
|
},
|
||||||
|
openapi: "3.1.1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export let url: URL
|
||||||
|
|
||||||
|
export async function listen(opts: {
|
||||||
|
port: number
|
||||||
|
hostname: string
|
||||||
|
mdns?: boolean
|
||||||
|
mdnsDomain?: string
|
||||||
|
cors?: string[]
|
||||||
|
}): Promise<Listener> {
|
||||||
|
const built = create(opts)
|
||||||
|
const server = await built.runtime.listen(opts)
|
||||||
|
|
||||||
|
const next = new URL("http://localhost")
|
||||||
|
next.hostname = opts.hostname
|
||||||
|
next.port = String(server.port)
|
||||||
|
url = next
|
||||||
|
|
||||||
|
const mdns =
|
||||||
|
opts.mdns &&
|
||||||
|
server.port &&
|
||||||
|
opts.hostname !== "127.0.0.1" &&
|
||||||
|
opts.hostname !== "localhost" &&
|
||||||
|
opts.hostname !== "::1"
|
||||||
|
if (mdns) {
|
||||||
|
MDNS.publish(server.port, opts.mdnsDomain)
|
||||||
|
} else if (opts.mdns) {
|
||||||
|
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||||
|
}
|
||||||
|
|
||||||
|
let closing: Promise<void> | undefined
|
||||||
|
return {
|
||||||
|
hostname: opts.hostname,
|
||||||
|
port: server.port,
|
||||||
|
url: next,
|
||||||
|
stop(close?: boolean) {
|
||||||
|
closing ??= (async () => {
|
||||||
|
if (mdns) MDNS.unpublish()
|
||||||
|
await server.stop(close)
|
||||||
|
})()
|
||||||
|
return closing
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as Server from "./server"
|
||||||
|
|||||||
@@ -272,16 +272,18 @@ export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsa
|
|||||||
input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0,
|
input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0,
|
||||||
)
|
)
|
||||||
const cacheWriteInputTokens = safe(
|
const cacheWriteInputTokens = safe(
|
||||||
(input.usage.inputTokenDetails?.cacheWriteTokens ??
|
Number(
|
||||||
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
input.usage.inputTokenDetails?.cacheWriteTokens ??
|
||||||
// google-vertex-anthropic returns metadata under "vertex" key
|
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
||||||
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
|
// google-vertex-anthropic returns metadata under "vertex" key
|
||||||
input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ??
|
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
|
||||||
// @ts-expect-error
|
input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ??
|
||||||
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
|
// @ts-expect-error
|
||||||
// @ts-expect-error
|
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
|
||||||
input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ??
|
// @ts-expect-error
|
||||||
0) as number,
|
input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ??
|
||||||
|
0,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// AI SDK v6 normalized inputTokens to include cached tokens across all providers
|
// AI SDK v6 normalized inputTokens to include cached tokens across all providers
|
||||||
|
|||||||
@@ -6,111 +6,111 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
|||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Log } from "../util"
|
import { Log } from "../util"
|
||||||
|
|
||||||
export namespace Discovery {
|
const skillConcurrency = 4
|
||||||
const skillConcurrency = 4
|
const fileConcurrency = 8
|
||||||
const fileConcurrency = 8
|
|
||||||
|
|
||||||
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
|
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
files: Schema.Array(Schema.String),
|
files: Schema.Array(Schema.String),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
class Index extends Schema.Class<Index>("Index")({
|
class Index extends Schema.Class<Index>("Index")({
|
||||||
skills: Schema.Array(IndexSkill),
|
skills: Schema.Array(IndexSkill),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly pull: (url: string) => Effect.Effect<string[]>
|
readonly pull: (url: string) => Effect.Effect<string[]>
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SkillDiscovery") {}
|
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Path.Path | HttpClient.HttpClient> =
|
|
||||||
Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const log = Log.create({ service: "skill-discovery" })
|
|
||||||
const fs = yield* AppFileSystem.Service
|
|
||||||
const path = yield* Path.Path
|
|
||||||
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
|
||||||
const cache = path.join(Global.Path.cache, "skills")
|
|
||||||
|
|
||||||
const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) {
|
|
||||||
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
|
|
||||||
|
|
||||||
return yield* HttpClientRequest.get(url).pipe(
|
|
||||||
http.execute,
|
|
||||||
Effect.flatMap((res) => res.arrayBuffer),
|
|
||||||
Effect.flatMap((body) => fs.writeWithDirs(dest, new Uint8Array(body))),
|
|
||||||
Effect.as(true),
|
|
||||||
Effect.catch((err) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
log.error("failed to download", { url, err })
|
|
||||||
return false
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const pull = Effect.fn("Discovery.pull")(function* (url: string) {
|
|
||||||
const base = url.endsWith("/") ? url : `${url}/`
|
|
||||||
const index = new URL("index.json", base).href
|
|
||||||
const host = base.slice(0, -1)
|
|
||||||
|
|
||||||
log.info("fetching index", { url: index })
|
|
||||||
|
|
||||||
const data = yield* HttpClientRequest.get(index).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
http.execute,
|
|
||||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
|
|
||||||
Effect.catch((err) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
log.error("failed to fetch index", { url: index, err })
|
|
||||||
return null
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!data) return []
|
|
||||||
|
|
||||||
const list = data.skills.filter((skill) => {
|
|
||||||
if (!skill.files.includes("SKILL.md")) {
|
|
||||||
log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
const dirs = yield* Effect.forEach(
|
|
||||||
list,
|
|
||||||
(skill) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const root = path.join(cache, skill.name)
|
|
||||||
|
|
||||||
yield* Effect.forEach(
|
|
||||||
skill.files,
|
|
||||||
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
|
|
||||||
{
|
|
||||||
concurrency: fileConcurrency,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const md = path.join(root, "SKILL.md")
|
|
||||||
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
|
|
||||||
}),
|
|
||||||
{ concurrency: skillConcurrency },
|
|
||||||
)
|
|
||||||
|
|
||||||
return dirs.filter((dir): dir is string => dir !== null)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ pull })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
|
||||||
Layer.provide(FetchHttpClient.layer),
|
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
|
||||||
Layer.provide(NodePath.layer),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/SkillDiscovery") {}
|
||||||
|
|
||||||
|
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Path.Path | HttpClient.HttpClient> =
|
||||||
|
Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const log = Log.create({ service: "skill-discovery" })
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const path = yield* Path.Path
|
||||||
|
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||||
|
const cache = path.join(Global.Path.cache, "skills")
|
||||||
|
|
||||||
|
const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) {
|
||||||
|
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
|
||||||
|
|
||||||
|
return yield* HttpClientRequest.get(url).pipe(
|
||||||
|
http.execute,
|
||||||
|
Effect.flatMap((res) => res.arrayBuffer),
|
||||||
|
Effect.flatMap((body) => fs.writeWithDirs(dest, new Uint8Array(body))),
|
||||||
|
Effect.as(true),
|
||||||
|
Effect.catch((err) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
log.error("failed to download", { url, err })
|
||||||
|
return false
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const pull = Effect.fn("Discovery.pull")(function* (url: string) {
|
||||||
|
const base = url.endsWith("/") ? url : `${url}/`
|
||||||
|
const index = new URL("index.json", base).href
|
||||||
|
const host = base.slice(0, -1)
|
||||||
|
|
||||||
|
log.info("fetching index", { url: index })
|
||||||
|
|
||||||
|
const data = yield* HttpClientRequest.get(index).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
http.execute,
|
||||||
|
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
|
||||||
|
Effect.catch((err) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
log.error("failed to fetch index", { url: index, err })
|
||||||
|
return null
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data) return []
|
||||||
|
|
||||||
|
const list = data.skills.filter((skill) => {
|
||||||
|
if (!skill.files.includes("SKILL.md")) {
|
||||||
|
log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const dirs = yield* Effect.forEach(
|
||||||
|
list,
|
||||||
|
(skill) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const root = path.join(cache, skill.name)
|
||||||
|
|
||||||
|
yield* Effect.forEach(
|
||||||
|
skill.files,
|
||||||
|
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
|
||||||
|
{
|
||||||
|
concurrency: fileConcurrency,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const md = path.join(root, "SKILL.md")
|
||||||
|
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
|
||||||
|
}),
|
||||||
|
{ concurrency: skillConcurrency },
|
||||||
|
)
|
||||||
|
|
||||||
|
return dirs.filter((dir): dir is string => dir !== null)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ pull })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||||
|
Layer.provide(FetchHttpClient.layer),
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
Layer.provide(NodePath.layer),
|
||||||
|
)
|
||||||
|
|
||||||
|
export * as Discovery from "./discovery"
|
||||||
|
|||||||
@@ -1 +1,264 @@
|
|||||||
export * as Skill from "./skill"
|
import os from "os"
|
||||||
|
import path from "path"
|
||||||
|
import { pathToFileURL } from "url"
|
||||||
|
import z from "zod"
|
||||||
|
import { Effect, Layer, Context } from "effect"
|
||||||
|
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||||
|
import type { Agent } from "@/agent/agent"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { InstanceState } from "@/effect"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { Global } from "@/global"
|
||||||
|
import { Permission } from "@/permission"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
|
import { Config } from "../config"
|
||||||
|
import { ConfigMarkdown } from "../config"
|
||||||
|
import { Glob } from "@opencode-ai/shared/util/glob"
|
||||||
|
import { Log } from "../util"
|
||||||
|
import { Discovery } from "./discovery"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "skill" })
|
||||||
|
const EXTERNAL_DIRS = [".claude", ".agents"]
|
||||||
|
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
||||||
|
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
||||||
|
const SKILL_PATTERN = "**/SKILL.md"
|
||||||
|
|
||||||
|
export const Info = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
location: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
})
|
||||||
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
|
export const InvalidError = NamedError.create(
|
||||||
|
"SkillInvalidError",
|
||||||
|
z.object({
|
||||||
|
path: z.string(),
|
||||||
|
message: z.string().optional(),
|
||||||
|
issues: z.custom<z.core.$ZodIssue[]>().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const NameMismatchError = NamedError.create(
|
||||||
|
"SkillNameMismatchError",
|
||||||
|
z.object({
|
||||||
|
path: z.string(),
|
||||||
|
expected: z.string(),
|
||||||
|
actual: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
skills: Record<string, Info>
|
||||||
|
dirs: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||||
|
readonly all: () => Effect.Effect<Info[]>
|
||||||
|
readonly dirs: () => Effect.Effect<string[]>
|
||||||
|
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.Interface) {
|
||||||
|
const md = yield* Effect.tryPromise({
|
||||||
|
try: () => ConfigMarkdown.parse(match),
|
||||||
|
catch: (err) => err,
|
||||||
|
}).pipe(
|
||||||
|
Effect.catch(
|
||||||
|
Effect.fnUntraced(function* (err) {
|
||||||
|
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||||
|
? err.data.message
|
||||||
|
: `Failed to parse skill ${match}`
|
||||||
|
const { Session } = yield* Effect.promise(() => import("@/session"))
|
||||||
|
yield* bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||||
|
log.error("failed to load skill", { skill: match, err })
|
||||||
|
return undefined
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!md) return
|
||||||
|
|
||||||
|
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
||||||
|
if (!parsed.success) return
|
||||||
|
|
||||||
|
if (state.skills[parsed.data.name]) {
|
||||||
|
log.warn("duplicate skill name", {
|
||||||
|
name: parsed.data.name,
|
||||||
|
existing: state.skills[parsed.data.name].location,
|
||||||
|
duplicate: match,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
state.dirs.add(path.dirname(match))
|
||||||
|
state.skills[parsed.data.name] = {
|
||||||
|
name: parsed.data.name,
|
||||||
|
description: parsed.data.description,
|
||||||
|
location: match,
|
||||||
|
content: md.content,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const scan = Effect.fnUntraced(function* (
|
||||||
|
state: State,
|
||||||
|
bus: Bus.Interface,
|
||||||
|
root: string,
|
||||||
|
pattern: string,
|
||||||
|
opts?: { dot?: boolean; scope?: string },
|
||||||
|
) {
|
||||||
|
const matches = yield* Effect.tryPromise({
|
||||||
|
try: () =>
|
||||||
|
Glob.scan(pattern, {
|
||||||
|
cwd: root,
|
||||||
|
absolute: true,
|
||||||
|
include: "file",
|
||||||
|
symlink: true,
|
||||||
|
dot: opts?.dot,
|
||||||
|
}),
|
||||||
|
catch: (error) => error,
|
||||||
|
}).pipe(
|
||||||
|
Effect.catch((error) => {
|
||||||
|
if (!opts?.scope) return Effect.die(error)
|
||||||
|
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
|
||||||
|
return Effect.succeed([] as string[])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* Effect.forEach(matches, (match) => add(state, match, bus), {
|
||||||
|
concurrency: "unbounded",
|
||||||
|
discard: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadSkills = Effect.fnUntraced(function* (
|
||||||
|
state: State,
|
||||||
|
config: Config.Interface,
|
||||||
|
discovery: Discovery.Interface,
|
||||||
|
bus: Bus.Interface,
|
||||||
|
fsys: AppFileSystem.Interface,
|
||||||
|
directory: string,
|
||||||
|
worktree: string,
|
||||||
|
) {
|
||||||
|
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||||
|
for (const dir of EXTERNAL_DIRS) {
|
||||||
|
const root = path.join(Global.Path.home, dir)
|
||||||
|
if (!(yield* fsys.isDir(root))) continue
|
||||||
|
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const upDirs = yield* fsys
|
||||||
|
.up({ targets: EXTERNAL_DIRS, start: directory, stop: worktree })
|
||||||
|
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||||
|
|
||||||
|
for (const root of upDirs) {
|
||||||
|
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const configDirs = yield* config.directories()
|
||||||
|
for (const dir of configDirs) {
|
||||||
|
yield* scan(state, bus, dir, OPENCODE_SKILL_PATTERN)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = yield* config.get()
|
||||||
|
for (const item of cfg.skills?.paths ?? []) {
|
||||||
|
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
|
||||||
|
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
|
||||||
|
if (!(yield* fsys.isDir(dir))) {
|
||||||
|
log.warn("skill path not found", { path: dir })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* scan(state, bus, dir, SKILL_PATTERN)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const url of cfg.skills?.urls ?? []) {
|
||||||
|
const pulledDirs = yield* discovery.pull(url)
|
||||||
|
for (const dir of pulledDirs) {
|
||||||
|
state.dirs.add(dir)
|
||||||
|
yield* scan(state, bus, dir, SKILL_PATTERN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("init", { count: Object.keys(state.skills).length })
|
||||||
|
})
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Skill") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const discovery = yield* Discovery.Service
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
const fsys = yield* AppFileSystem.Service
|
||||||
|
const state = yield* InstanceState.make(
|
||||||
|
Effect.fn("Skill.state")(function* (ctx) {
|
||||||
|
const s: State = { skills: {}, dirs: new Set() }
|
||||||
|
yield* loadSkills(s, config, discovery, bus, fsys, ctx.directory, ctx.worktree)
|
||||||
|
return s
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const get = Effect.fn("Skill.get")(function* (name: string) {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
return s.skills[name]
|
||||||
|
})
|
||||||
|
|
||||||
|
const all = Effect.fn("Skill.all")(function* () {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
return Object.values(s.skills)
|
||||||
|
})
|
||||||
|
|
||||||
|
const dirs = Effect.fn("Skill.dirs")(function* () {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
return Array.from(s.dirs)
|
||||||
|
})
|
||||||
|
|
||||||
|
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
const list = Object.values(s.skills).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
|
if (!agent) return list
|
||||||
|
return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ get, all, dirs, available })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(Discovery.defaultLayer),
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(Bus.layer),
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
|
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
||||||
|
if (list.length === 0) return "No skills are currently available."
|
||||||
|
if (opts.verbose) {
|
||||||
|
return [
|
||||||
|
"<available_skills>",
|
||||||
|
...list
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.flatMap((skill) => [
|
||||||
|
" <skill>",
|
||||||
|
` <name>${skill.name}</name>`,
|
||||||
|
` <description>${skill.description}</description>`,
|
||||||
|
` <location>${pathToFileURL(skill.location).href}</location>`,
|
||||||
|
" </skill>",
|
||||||
|
]),
|
||||||
|
"</available_skills>",
|
||||||
|
].join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
"## Available Skills",
|
||||||
|
...list
|
||||||
|
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.map((skill) => `- **${skill.name}**: ${skill.description}`),
|
||||||
|
].join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as Skill from "."
|
||||||
|
|||||||
@@ -1,262 +0,0 @@
|
|||||||
import os from "os"
|
|
||||||
import path from "path"
|
|
||||||
import { pathToFileURL } from "url"
|
|
||||||
import z from "zod"
|
|
||||||
import { Effect, Layer, Context } from "effect"
|
|
||||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
|
||||||
import type { Agent } from "@/agent/agent"
|
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { InstanceState } from "@/effect"
|
|
||||||
import { Flag } from "@/flag/flag"
|
|
||||||
import { Global } from "@/global"
|
|
||||||
import { Permission } from "@/permission"
|
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
|
||||||
import { Config } from "../config"
|
|
||||||
import { ConfigMarkdown } from "../config"
|
|
||||||
import { Glob } from "@opencode-ai/shared/util/glob"
|
|
||||||
import { Log } from "../util"
|
|
||||||
import { Discovery } from "./discovery"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "skill" })
|
|
||||||
const EXTERNAL_DIRS = [".claude", ".agents"]
|
|
||||||
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
|
||||||
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
|
||||||
const SKILL_PATTERN = "**/SKILL.md"
|
|
||||||
|
|
||||||
export const Info = z.object({
|
|
||||||
name: z.string(),
|
|
||||||
description: z.string(),
|
|
||||||
location: z.string(),
|
|
||||||
content: z.string(),
|
|
||||||
})
|
|
||||||
export type Info = z.infer<typeof Info>
|
|
||||||
|
|
||||||
export const InvalidError = NamedError.create(
|
|
||||||
"SkillInvalidError",
|
|
||||||
z.object({
|
|
||||||
path: z.string(),
|
|
||||||
message: z.string().optional(),
|
|
||||||
issues: z.custom<z.core.$ZodIssue[]>().optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const NameMismatchError = NamedError.create(
|
|
||||||
"SkillNameMismatchError",
|
|
||||||
z.object({
|
|
||||||
path: z.string(),
|
|
||||||
expected: z.string(),
|
|
||||||
actual: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
skills: Record<string, Info>
|
|
||||||
dirs: Set<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
|
||||||
readonly all: () => Effect.Effect<Info[]>
|
|
||||||
readonly dirs: () => Effect.Effect<string[]>
|
|
||||||
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.Interface) {
|
|
||||||
const md = yield* Effect.tryPromise({
|
|
||||||
try: () => ConfigMarkdown.parse(match),
|
|
||||||
catch: (err) => err,
|
|
||||||
}).pipe(
|
|
||||||
Effect.catch(
|
|
||||||
Effect.fnUntraced(function* (err) {
|
|
||||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
|
||||||
? err.data.message
|
|
||||||
: `Failed to parse skill ${match}`
|
|
||||||
const { Session } = yield* Effect.promise(() => import("@/session"))
|
|
||||||
yield* bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
|
||||||
log.error("failed to load skill", { skill: match, err })
|
|
||||||
return undefined
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!md) return
|
|
||||||
|
|
||||||
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
|
||||||
if (!parsed.success) return
|
|
||||||
|
|
||||||
if (state.skills[parsed.data.name]) {
|
|
||||||
log.warn("duplicate skill name", {
|
|
||||||
name: parsed.data.name,
|
|
||||||
existing: state.skills[parsed.data.name].location,
|
|
||||||
duplicate: match,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
state.dirs.add(path.dirname(match))
|
|
||||||
state.skills[parsed.data.name] = {
|
|
||||||
name: parsed.data.name,
|
|
||||||
description: parsed.data.description,
|
|
||||||
location: match,
|
|
||||||
content: md.content,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const scan = Effect.fnUntraced(function* (
|
|
||||||
state: State,
|
|
||||||
bus: Bus.Interface,
|
|
||||||
root: string,
|
|
||||||
pattern: string,
|
|
||||||
opts?: { dot?: boolean; scope?: string },
|
|
||||||
) {
|
|
||||||
const matches = yield* Effect.tryPromise({
|
|
||||||
try: () =>
|
|
||||||
Glob.scan(pattern, {
|
|
||||||
cwd: root,
|
|
||||||
absolute: true,
|
|
||||||
include: "file",
|
|
||||||
symlink: true,
|
|
||||||
dot: opts?.dot,
|
|
||||||
}),
|
|
||||||
catch: (error) => error,
|
|
||||||
}).pipe(
|
|
||||||
Effect.catch((error) => {
|
|
||||||
if (!opts?.scope) return Effect.die(error)
|
|
||||||
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
|
|
||||||
return Effect.succeed([] as string[])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* Effect.forEach(matches, (match) => add(state, match, bus), {
|
|
||||||
concurrency: "unbounded",
|
|
||||||
discard: true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadSkills = Effect.fnUntraced(function* (
|
|
||||||
state: State,
|
|
||||||
config: Config.Interface,
|
|
||||||
discovery: Discovery.Interface,
|
|
||||||
bus: Bus.Interface,
|
|
||||||
fsys: AppFileSystem.Interface,
|
|
||||||
directory: string,
|
|
||||||
worktree: string,
|
|
||||||
) {
|
|
||||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
|
||||||
for (const dir of EXTERNAL_DIRS) {
|
|
||||||
const root = path.join(Global.Path.home, dir)
|
|
||||||
if (!(yield* fsys.isDir(root))) continue
|
|
||||||
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const upDirs = yield* fsys
|
|
||||||
.up({ targets: EXTERNAL_DIRS, start: directory, stop: worktree })
|
|
||||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
|
||||||
|
|
||||||
for (const root of upDirs) {
|
|
||||||
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const configDirs = yield* config.directories()
|
|
||||||
for (const dir of configDirs) {
|
|
||||||
yield* scan(state, bus, dir, OPENCODE_SKILL_PATTERN)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cfg = yield* config.get()
|
|
||||||
for (const item of cfg.skills?.paths ?? []) {
|
|
||||||
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
|
|
||||||
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
|
|
||||||
if (!(yield* fsys.isDir(dir))) {
|
|
||||||
log.warn("skill path not found", { path: dir })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* scan(state, bus, dir, SKILL_PATTERN)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const url of cfg.skills?.urls ?? []) {
|
|
||||||
const pulledDirs = yield* discovery.pull(url)
|
|
||||||
for (const dir of pulledDirs) {
|
|
||||||
state.dirs.add(dir)
|
|
||||||
yield* scan(state, bus, dir, SKILL_PATTERN)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("init", { count: Object.keys(state.skills).length })
|
|
||||||
})
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Skill") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const discovery = yield* Discovery.Service
|
|
||||||
const config = yield* Config.Service
|
|
||||||
const bus = yield* Bus.Service
|
|
||||||
const fsys = yield* AppFileSystem.Service
|
|
||||||
const state = yield* InstanceState.make(
|
|
||||||
Effect.fn("Skill.state")(function* (ctx) {
|
|
||||||
const s: State = { skills: {}, dirs: new Set() }
|
|
||||||
yield* loadSkills(s, config, discovery, bus, fsys, ctx.directory, ctx.worktree)
|
|
||||||
return s
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const get = Effect.fn("Skill.get")(function* (name: string) {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
return s.skills[name]
|
|
||||||
})
|
|
||||||
|
|
||||||
const all = Effect.fn("Skill.all")(function* () {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
return Object.values(s.skills)
|
|
||||||
})
|
|
||||||
|
|
||||||
const dirs = Effect.fn("Skill.dirs")(function* () {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
return Array.from(s.dirs)
|
|
||||||
})
|
|
||||||
|
|
||||||
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
|
|
||||||
const s = yield* InstanceState.get(state)
|
|
||||||
const list = Object.values(s.skills).toSorted((a, b) => a.name.localeCompare(b.name))
|
|
||||||
if (!agent) return list
|
|
||||||
return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ get, all, dirs, available })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
|
||||||
Layer.provide(Discovery.defaultLayer),
|
|
||||||
Layer.provide(Config.defaultLayer),
|
|
||||||
Layer.provide(Bus.layer),
|
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
|
||||||
)
|
|
||||||
|
|
||||||
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
|
||||||
if (list.length === 0) return "No skills are currently available."
|
|
||||||
if (opts.verbose) {
|
|
||||||
return [
|
|
||||||
"<available_skills>",
|
|
||||||
...list
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
.flatMap((skill) => [
|
|
||||||
" <skill>",
|
|
||||||
` <name>${skill.name}</name>`,
|
|
||||||
` <description>${skill.description}</description>`,
|
|
||||||
` <location>${pathToFileURL(skill.location).href}</location>`,
|
|
||||||
" </skill>",
|
|
||||||
]),
|
|
||||||
"</available_skills>",
|
|
||||||
].join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
"## Available Skills",
|
|
||||||
...list
|
|
||||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
|
||||||
.map((skill) => `- **${skill.name}**: ${skill.description}`),
|
|
||||||
].join("\n")
|
|
||||||
}
|
|
||||||
@@ -1 +1,777 @@
|
|||||||
export * as Snapshot from "./snapshot"
|
import { Cause, Duration, Effect, Layer, Schedule, Semaphore, Context, Stream } from "effect"
|
||||||
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
|
import { formatPatch, structuredPatch } from "diff"
|
||||||
|
import path from "path"
|
||||||
|
import z from "zod"
|
||||||
|
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||||
|
import { InstanceState } from "@/effect"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
|
import { Hash } from "@opencode-ai/shared/util/hash"
|
||||||
|
import { Config } from "../config"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { Log } from "../util"
|
||||||
|
|
||||||
|
export const Patch = z.object({
|
||||||
|
hash: z.string(),
|
||||||
|
files: z.string().array(),
|
||||||
|
})
|
||||||
|
export type Patch = z.infer<typeof Patch>
|
||||||
|
|
||||||
|
export const FileDiff = z
|
||||||
|
.object({
|
||||||
|
file: z.string(),
|
||||||
|
patch: z.string(),
|
||||||
|
additions: z.number(),
|
||||||
|
deletions: z.number(),
|
||||||
|
status: z.enum(["added", "deleted", "modified"]).optional(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "SnapshotFileDiff",
|
||||||
|
})
|
||||||
|
export type FileDiff = z.infer<typeof FileDiff>
|
||||||
|
|
||||||
|
const log = Log.create({ service: "snapshot" })
|
||||||
|
const prune = "7.days"
|
||||||
|
const limit = 2 * 1024 * 1024
|
||||||
|
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
|
||||||
|
const cfg = ["-c", "core.autocrlf=false", ...core]
|
||||||
|
const quote = [...cfg, "-c", "core.quotepath=false"]
|
||||||
|
interface GitResult {
|
||||||
|
readonly code: ChildProcessSpawner.ExitCode
|
||||||
|
readonly text: string
|
||||||
|
readonly stderr: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = Omit<Interface, "init">
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly init: () => Effect.Effect<void>
|
||||||
|
readonly cleanup: () => Effect.Effect<void>
|
||||||
|
readonly track: () => Effect.Effect<string | undefined>
|
||||||
|
readonly patch: (hash: string) => Effect.Effect<Patch>
|
||||||
|
readonly restore: (snapshot: string) => Effect.Effect<void>
|
||||||
|
readonly revert: (patches: Patch[]) => Effect.Effect<void>
|
||||||
|
readonly diff: (hash: string) => Effect.Effect<string>
|
||||||
|
readonly diffFull: (from: string, to: string) => Effect.Effect<FileDiff[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Snapshot") {}
|
||||||
|
|
||||||
|
export const layer: Layer.Layer<
|
||||||
|
Service,
|
||||||
|
never,
|
||||||
|
AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | Config.Service
|
||||||
|
> = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const locks = new Map<string, Semaphore.Semaphore>()
|
||||||
|
|
||||||
|
const lock = (key: string) => {
|
||||||
|
const hit = locks.get(key)
|
||||||
|
if (hit) return hit
|
||||||
|
|
||||||
|
const next = Semaphore.makeUnsafe(1)
|
||||||
|
locks.set(key, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("Snapshot.state")(function* (ctx) {
|
||||||
|
const state = {
|
||||||
|
directory: ctx.directory,
|
||||||
|
worktree: ctx.worktree,
|
||||||
|
gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)),
|
||||||
|
vcs: ctx.project.vcs,
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
|
||||||
|
|
||||||
|
const enc = new TextEncoder()
|
||||||
|
const feed = (list: string[]) => Stream.make(enc.encode(list.join("\0") + "\0"))
|
||||||
|
|
||||||
|
const git = Effect.fnUntraced(
|
||||||
|
function* (
|
||||||
|
cmd: string[],
|
||||||
|
opts?: { cwd?: string; env?: Record<string, string>; stdin?: ChildProcess.CommandInput },
|
||||||
|
) {
|
||||||
|
const proc = ChildProcess.make("git", cmd, {
|
||||||
|
cwd: opts?.cwd,
|
||||||
|
env: opts?.env,
|
||||||
|
extendEnv: true,
|
||||||
|
stdin: opts?.stdin,
|
||||||
|
})
|
||||||
|
const handle = yield* spawner.spawn(proc)
|
||||||
|
const [text, stderr] = yield* Effect.all(
|
||||||
|
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||||
|
{ concurrency: 2 },
|
||||||
|
)
|
||||||
|
const code = yield* handle.exitCode
|
||||||
|
return { code, text, stderr } satisfies GitResult
|
||||||
|
},
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.catch((err) =>
|
||||||
|
Effect.succeed({
|
||||||
|
code: ChildProcessSpawner.ExitCode(1),
|
||||||
|
text: "",
|
||||||
|
stderr: err instanceof Error ? err.message : String(err),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ignore = Effect.fnUntraced(function* (files: string[]) {
|
||||||
|
if (!files.length) return new Set<string>()
|
||||||
|
const check = yield* git(
|
||||||
|
[
|
||||||
|
...quote,
|
||||||
|
"--git-dir",
|
||||||
|
path.join(state.worktree, ".git"),
|
||||||
|
"--work-tree",
|
||||||
|
state.worktree,
|
||||||
|
"check-ignore",
|
||||||
|
"--no-index",
|
||||||
|
"--stdin",
|
||||||
|
"-z",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: state.directory,
|
||||||
|
stdin: feed(files),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (check.code !== 0 && check.code !== 1) return new Set<string>()
|
||||||
|
return new Set(check.text.split("\0").filter(Boolean))
|
||||||
|
})
|
||||||
|
|
||||||
|
const drop = Effect.fnUntraced(function* (files: string[]) {
|
||||||
|
if (!files.length) return
|
||||||
|
yield* git(
|
||||||
|
[
|
||||||
|
...cfg,
|
||||||
|
...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: state.directory,
|
||||||
|
stdin: feed(files),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const stage = Effect.fnUntraced(function* (files: string[]) {
|
||||||
|
if (!files.length) return
|
||||||
|
const result = yield* git(
|
||||||
|
[...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])],
|
||||||
|
{
|
||||||
|
cwd: state.directory,
|
||||||
|
stdin: feed(files),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (result.code === 0) return
|
||||||
|
log.warn("failed to add snapshot files", {
|
||||||
|
exitCode: result.code,
|
||||||
|
stderr: result.stderr,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
||||||
|
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
||||||
|
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
||||||
|
const locked = <A, E, R>(fx: Effect.Effect<A, E, R>) => lock(state.gitdir).withPermits(1)(fx)
|
||||||
|
|
||||||
|
const enabled = Effect.fnUntraced(function* () {
|
||||||
|
if (state.vcs !== "git") return false
|
||||||
|
return (yield* config.get()).snapshot !== false
|
||||||
|
})
|
||||||
|
|
||||||
|
const excludes = Effect.fnUntraced(function* () {
|
||||||
|
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
||||||
|
cwd: state.worktree,
|
||||||
|
})
|
||||||
|
const file = result.text.trim()
|
||||||
|
if (!file) return
|
||||||
|
if (!(yield* exists(file))) return
|
||||||
|
return file
|
||||||
|
})
|
||||||
|
|
||||||
|
const sync = Effect.fnUntraced(function* (list: string[] = []) {
|
||||||
|
const file = yield* excludes()
|
||||||
|
const target = path.join(state.gitdir, "info", "exclude")
|
||||||
|
const text = [
|
||||||
|
file ? (yield* read(file)).trimEnd() : "",
|
||||||
|
...list.map((item) => `/${item.replaceAll("\\", "/")}`),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
|
||||||
|
yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
|
||||||
|
})
|
||||||
|
|
||||||
|
const add = Effect.fnUntraced(function* () {
|
||||||
|
yield* sync()
|
||||||
|
const [diff, other] = yield* Effect.all(
|
||||||
|
[
|
||||||
|
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
|
||||||
|
cwd: state.directory,
|
||||||
|
}),
|
||||||
|
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
|
||||||
|
cwd: state.directory,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{ concurrency: 2 },
|
||||||
|
)
|
||||||
|
if (diff.code !== 0 || other.code !== 0) {
|
||||||
|
log.warn("failed to list snapshot files", {
|
||||||
|
diffCode: diff.code,
|
||||||
|
diffStderr: diff.stderr,
|
||||||
|
otherCode: other.code,
|
||||||
|
otherStderr: other.stderr,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracked = diff.text.split("\0").filter(Boolean)
|
||||||
|
const untracked = other.text.split("\0").filter(Boolean)
|
||||||
|
const all = Array.from(new Set([...tracked, ...untracked]))
|
||||||
|
if (!all.length) return
|
||||||
|
|
||||||
|
// Resolve source-repo ignore rules against the exact candidate set.
|
||||||
|
// --no-index keeps this pattern-based even when a path is already tracked.
|
||||||
|
const ignored = yield* ignore(all)
|
||||||
|
|
||||||
|
// Remove newly-ignored files from snapshot index to prevent re-adding
|
||||||
|
if (ignored.size > 0) {
|
||||||
|
const ignoredFiles = Array.from(ignored)
|
||||||
|
log.info("removing gitignored files from snapshot", { count: ignoredFiles.length })
|
||||||
|
yield* drop(ignoredFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allow = all.filter((item) => !ignored.has(item))
|
||||||
|
if (!allow.length) return
|
||||||
|
|
||||||
|
const large = new Set(
|
||||||
|
(yield* Effect.all(
|
||||||
|
allow.map((item) =>
|
||||||
|
fs
|
||||||
|
.stat(path.join(state.directory, item))
|
||||||
|
.pipe(Effect.catch(() => Effect.void))
|
||||||
|
.pipe(
|
||||||
|
Effect.map((stat) => {
|
||||||
|
if (!stat || stat.type !== "File") return
|
||||||
|
const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size
|
||||||
|
return size > limit ? item : undefined
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
{ concurrency: 8 },
|
||||||
|
)).filter((item): item is string => Boolean(item)),
|
||||||
|
)
|
||||||
|
const block = new Set(untracked.filter((item) => large.has(item)))
|
||||||
|
yield* sync(Array.from(block))
|
||||||
|
// Stage only the allowed candidate paths so snapshot updates stay scoped.
|
||||||
|
yield* stage(allow.filter((item) => !block.has(item)))
|
||||||
|
})
|
||||||
|
|
||||||
|
const cleanup = Effect.fnUntraced(function* () {
|
||||||
|
return yield* locked(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (!(yield* enabled())) return
|
||||||
|
if (!(yield* exists(state.gitdir))) return
|
||||||
|
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
|
||||||
|
if (result.code !== 0) {
|
||||||
|
log.warn("cleanup failed", {
|
||||||
|
exitCode: result.code,
|
||||||
|
stderr: result.stderr,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.info("cleanup", { prune })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const track = Effect.fnUntraced(function* () {
|
||||||
|
return yield* locked(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (!(yield* enabled())) return
|
||||||
|
const existed = yield* exists(state.gitdir)
|
||||||
|
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
|
||||||
|
if (!existed) {
|
||||||
|
yield* git(["init"], {
|
||||||
|
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
|
||||||
|
})
|
||||||
|
yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
|
||||||
|
yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
|
||||||
|
yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
|
||||||
|
yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
|
||||||
|
log.info("initialized")
|
||||||
|
}
|
||||||
|
yield* add()
|
||||||
|
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
|
||||||
|
const hash = result.text.trim()
|
||||||
|
log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
|
||||||
|
return hash
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const patch = Effect.fnUntraced(function* (hash: string) {
|
||||||
|
return yield* locked(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* add()
|
||||||
|
const result = yield* git(
|
||||||
|
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
||||||
|
{
|
||||||
|
cwd: state.directory,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (result.code !== 0) {
|
||||||
|
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||||
|
return { hash, files: [] }
|
||||||
|
}
|
||||||
|
const files = result.text
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
// Hide ignored-file removals from the user-facing patch output.
|
||||||
|
const ignored = yield* ignore(files)
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash,
|
||||||
|
files: files
|
||||||
|
.filter((item) => !ignored.has(item))
|
||||||
|
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const restore = Effect.fnUntraced(function* (snapshot: string) {
|
||||||
|
return yield* locked(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
log.info("restore", { commit: snapshot })
|
||||||
|
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
|
||||||
|
if (result.code === 0) {
|
||||||
|
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
|
||||||
|
cwd: state.worktree,
|
||||||
|
})
|
||||||
|
if (checkout.code === 0) return
|
||||||
|
log.error("failed to restore snapshot", {
|
||||||
|
snapshot,
|
||||||
|
exitCode: checkout.code,
|
||||||
|
stderr: checkout.stderr,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.error("failed to restore snapshot", {
|
||||||
|
snapshot,
|
||||||
|
exitCode: result.code,
|
||||||
|
stderr: result.stderr,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const revert = Effect.fnUntraced(function* (patches: Patch[]) {
|
||||||
|
return yield* locked(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const ops: { hash: string; file: string; rel: string }[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const item of patches) {
|
||||||
|
for (const file of item.files) {
|
||||||
|
if (seen.has(file)) continue
|
||||||
|
seen.add(file)
|
||||||
|
ops.push({
|
||||||
|
hash: item.hash,
|
||||||
|
file,
|
||||||
|
rel: path.relative(state.worktree, file).replaceAll("\\", "/"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const single = Effect.fnUntraced(function* (op: (typeof ops)[number]) {
|
||||||
|
log.info("reverting", { file: op.file, hash: op.hash })
|
||||||
|
const result = yield* git([...core, ...args(["checkout", op.hash, "--", op.file])], {
|
||||||
|
cwd: state.worktree,
|
||||||
|
})
|
||||||
|
if (result.code === 0) return
|
||||||
|
const tree = yield* git([...core, ...args(["ls-tree", op.hash, "--", op.rel])], {
|
||||||
|
cwd: state.worktree,
|
||||||
|
})
|
||||||
|
if (tree.code === 0 && tree.text.trim()) {
|
||||||
|
log.info("file existed in snapshot but checkout failed, keeping", { file: op.file, hash: op.hash })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash })
|
||||||
|
yield* remove(op.file)
|
||||||
|
})
|
||||||
|
|
||||||
|
const clash = (a: string, b: string) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)
|
||||||
|
|
||||||
|
for (let i = 0; i < ops.length; ) {
|
||||||
|
const first = ops[i]!
|
||||||
|
const run = [first]
|
||||||
|
let j = i + 1
|
||||||
|
// Only batch adjacent files when their paths cannot affect each other.
|
||||||
|
while (j < ops.length && run.length < 100) {
|
||||||
|
const next = ops[j]!
|
||||||
|
if (next.hash !== first.hash) break
|
||||||
|
if (run.some((item) => clash(item.rel, next.rel))) break
|
||||||
|
run.push(next)
|
||||||
|
j += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.length === 1) {
|
||||||
|
yield* single(first)
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = yield* git(
|
||||||
|
[...core, ...args(["ls-tree", "--name-only", first.hash, "--", ...run.map((item) => item.rel)])],
|
||||||
|
{
|
||||||
|
cwd: state.worktree,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (tree.code !== 0) {
|
||||||
|
log.info("batched ls-tree failed, falling back to single-file revert", {
|
||||||
|
hash: first.hash,
|
||||||
|
files: run.length,
|
||||||
|
})
|
||||||
|
for (const op of run) {
|
||||||
|
yield* single(op)
|
||||||
|
}
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const have = new Set(
|
||||||
|
tree.text
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
const list = run.filter((item) => have.has(item.rel))
|
||||||
|
if (list.length) {
|
||||||
|
log.info("reverting", { hash: first.hash, files: list.length })
|
||||||
|
const result = yield* git(
|
||||||
|
[...core, ...args(["checkout", first.hash, "--", ...list.map((item) => item.file)])],
|
||||||
|
{
|
||||||
|
cwd: state.worktree,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (result.code !== 0) {
|
||||||
|
log.info("batched checkout failed, falling back to single-file revert", {
|
||||||
|
hash: first.hash,
|
||||||
|
files: list.length,
|
||||||
|
})
|
||||||
|
for (const op of run) {
|
||||||
|
yield* single(op)
|
||||||
|
}
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const op of run) {
|
||||||
|
if (have.has(op.rel)) continue
|
||||||
|
log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash })
|
||||||
|
yield* remove(op.file)
|
||||||
|
}
|
||||||
|
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const diff = Effect.fnUntraced(function* (hash: string) {
|
||||||
|
return yield* locked(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* add()
|
||||||
|
const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
|
||||||
|
cwd: state.worktree,
|
||||||
|
})
|
||||||
|
if (result.code !== 0) {
|
||||||
|
log.warn("failed to get diff", {
|
||||||
|
hash,
|
||||||
|
exitCode: result.code,
|
||||||
|
stderr: result.stderr,
|
||||||
|
})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return result.text.trim()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
|
||||||
|
return yield* locked(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
type Row = {
|
||||||
|
file: string
|
||||||
|
status: "added" | "deleted" | "modified"
|
||||||
|
binary: boolean
|
||||||
|
additions: number
|
||||||
|
deletions: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ref = {
|
||||||
|
file: string
|
||||||
|
side: "before" | "after"
|
||||||
|
ref: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const show = Effect.fnUntraced(function* (row: Row) {
|
||||||
|
if (row.binary) return ["", ""]
|
||||||
|
if (row.status === "added") {
|
||||||
|
return [
|
||||||
|
"",
|
||||||
|
yield* git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (row.status === "deleted") {
|
||||||
|
return [
|
||||||
|
yield* git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(
|
||||||
|
Effect.map((item) => item.text),
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return yield* Effect.all(
|
||||||
|
[
|
||||||
|
git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
|
||||||
|
git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
|
||||||
|
],
|
||||||
|
{ concurrency: 2 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const load = Effect.fnUntraced(
|
||||||
|
function* (rows: Row[]) {
|
||||||
|
const refs = rows.flatMap((row) => {
|
||||||
|
if (row.binary) return []
|
||||||
|
if (row.status === "added")
|
||||||
|
return [{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref]
|
||||||
|
if (row.status === "deleted") {
|
||||||
|
return [{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref,
|
||||||
|
{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
if (!refs.length) return new Map<string, { before: string; after: string }>()
|
||||||
|
|
||||||
|
const proc = ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], {
|
||||||
|
cwd: state.directory,
|
||||||
|
extendEnv: true,
|
||||||
|
stdin: Stream.make(new TextEncoder().encode(refs.map((item) => item.ref).join("\n") + "\n")),
|
||||||
|
})
|
||||||
|
const handle = yield* spawner.spawn(proc)
|
||||||
|
const [out, err] = yield* Effect.all(
|
||||||
|
[Stream.mkUint8Array(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||||
|
{ concurrency: 2 },
|
||||||
|
)
|
||||||
|
const code = yield* handle.exitCode
|
||||||
|
if (code !== 0) {
|
||||||
|
log.info("git cat-file --batch failed during snapshot diff, falling back to per-file git show", {
|
||||||
|
stderr: err,
|
||||||
|
refs: refs.length,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fail = (msg: string, extra?: Record<string, string>) => {
|
||||||
|
log.info(msg, { ...extra, refs: refs.length })
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = new Map<string, { before: string; after: string }>()
|
||||||
|
const dec = new TextDecoder()
|
||||||
|
let i = 0
|
||||||
|
for (const ref of refs) {
|
||||||
|
let end = i
|
||||||
|
while (end < out.length && out[end] !== 10) end += 1
|
||||||
|
if (end >= out.length) {
|
||||||
|
return fail(
|
||||||
|
"git cat-file --batch returned a truncated header during snapshot diff, falling back to per-file git show",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const head = dec.decode(out.slice(i, end))
|
||||||
|
i = end + 1
|
||||||
|
const hit = map.get(ref.file) ?? { before: "", after: "" }
|
||||||
|
if (head.endsWith(" missing")) {
|
||||||
|
map.set(ref.file, hit)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = head.match(/^[0-9a-f]+ blob (\d+)$/)
|
||||||
|
if (!match) {
|
||||||
|
return fail(
|
||||||
|
"git cat-file --batch returned an unexpected header during snapshot diff, falling back to per-file git show",
|
||||||
|
{ head },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = Number(match[1])
|
||||||
|
if (!Number.isInteger(size) || size < 0 || i + size >= out.length || out[i + size] !== 10) {
|
||||||
|
return fail(
|
||||||
|
"git cat-file --batch returned truncated content during snapshot diff, falling back to per-file git show",
|
||||||
|
{ head },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = dec.decode(out.slice(i, i + size))
|
||||||
|
if (ref.side === "before") hit.before = text
|
||||||
|
if (ref.side === "after") hit.after = text
|
||||||
|
map.set(ref.file, hit)
|
||||||
|
i += size + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i !== out.length) {
|
||||||
|
return fail(
|
||||||
|
"git cat-file --batch returned trailing data during snapshot diff, falling back to per-file git show",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map
|
||||||
|
},
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.catch(() =>
|
||||||
|
Effect.succeed<Map<string, { before: string; after: string }> | undefined>(undefined),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result: FileDiff[] = []
|
||||||
|
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||||
|
|
||||||
|
const statuses = yield* git(
|
||||||
|
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
|
||||||
|
{ cwd: state.directory },
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const line of statuses.text.trim().split("\n")) {
|
||||||
|
if (!line) continue
|
||||||
|
const [code, file] = line.split("\t")
|
||||||
|
if (!code || !file) continue
|
||||||
|
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
||||||
|
}
|
||||||
|
|
||||||
|
const numstat = yield* git(
|
||||||
|
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
||||||
|
{
|
||||||
|
cwd: state.directory,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const rows = numstat.text
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.flatMap((line) => {
|
||||||
|
const [adds, dels, file] = line.split("\t")
|
||||||
|
if (!file) return []
|
||||||
|
const binary = adds === "-" && dels === "-"
|
||||||
|
const additions = binary ? 0 : parseInt(adds)
|
||||||
|
const deletions = binary ? 0 : parseInt(dels)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
file,
|
||||||
|
status: status.get(file) ?? "modified",
|
||||||
|
binary,
|
||||||
|
additions: Number.isFinite(additions) ? additions : 0,
|
||||||
|
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||||
|
} satisfies Row,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Hide ignored-file removals from the user-facing diff output.
|
||||||
|
const ignored = yield* ignore(rows.map((r) => r.file))
|
||||||
|
if (ignored.size > 0) {
|
||||||
|
const filtered = rows.filter((r) => !ignored.has(r.file))
|
||||||
|
rows.length = 0
|
||||||
|
rows.push(...filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = 100
|
||||||
|
const patch = (file: string, before: string, after: string) =>
|
||||||
|
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
|
||||||
|
|
||||||
|
for (let i = 0; i < rows.length; i += step) {
|
||||||
|
const run = rows.slice(i, i + step)
|
||||||
|
const text = yield* load(run)
|
||||||
|
|
||||||
|
for (const row of run) {
|
||||||
|
const hit = text?.get(row.file) ?? { before: "", after: "" }
|
||||||
|
const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row)
|
||||||
|
result.push({
|
||||||
|
file: row.file,
|
||||||
|
patch: row.binary ? "" : patch(row.file, before, after),
|
||||||
|
additions: row.additions,
|
||||||
|
deletions: row.deletions,
|
||||||
|
status: row.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* cleanup().pipe(
|
||||||
|
Effect.catchCause((cause) => {
|
||||||
|
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
|
||||||
|
return Effect.void
|
||||||
|
}),
|
||||||
|
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||||
|
Effect.delay(Duration.minutes(1)),
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
|
|
||||||
|
return { cleanup, track, patch, restore, revert, diff, diffFull }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
init: Effect.fn("Snapshot.init")(function* () {
|
||||||
|
yield* InstanceState.get(state)
|
||||||
|
}),
|
||||||
|
cleanup: Effect.fn("Snapshot.cleanup")(function* () {
|
||||||
|
return yield* InstanceState.useEffect(state, (s) => s.cleanup())
|
||||||
|
}),
|
||||||
|
track: Effect.fn("Snapshot.track")(function* () {
|
||||||
|
return yield* InstanceState.useEffect(state, (s) => s.track())
|
||||||
|
}),
|
||||||
|
patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
|
||||||
|
return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
|
||||||
|
}),
|
||||||
|
restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
|
||||||
|
return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
|
||||||
|
}),
|
||||||
|
revert: Effect.fn("Snapshot.revert")(function* (patches: Patch[]) {
|
||||||
|
return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
|
||||||
|
}),
|
||||||
|
diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
|
||||||
|
return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
|
||||||
|
}),
|
||||||
|
diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
|
||||||
|
return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
|
export * as Snapshot from "."
|
||||||
|
|||||||
@@ -1,775 +0,0 @@
|
|||||||
import { Cause, Duration, Effect, Layer, Schedule, Semaphore, Context, Stream } from "effect"
|
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
|
||||||
import { formatPatch, structuredPatch } from "diff"
|
|
||||||
import path from "path"
|
|
||||||
import z from "zod"
|
|
||||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
|
||||||
import { InstanceState } from "@/effect"
|
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
|
||||||
import { Hash } from "@opencode-ai/shared/util/hash"
|
|
||||||
import { Config } from "../config"
|
|
||||||
import { Global } from "../global"
|
|
||||||
import { Log } from "../util"
|
|
||||||
|
|
||||||
export const Patch = z.object({
|
|
||||||
hash: z.string(),
|
|
||||||
files: z.string().array(),
|
|
||||||
})
|
|
||||||
export type Patch = z.infer<typeof Patch>
|
|
||||||
|
|
||||||
export const FileDiff = z
|
|
||||||
.object({
|
|
||||||
file: z.string(),
|
|
||||||
patch: z.string(),
|
|
||||||
additions: z.number(),
|
|
||||||
deletions: z.number(),
|
|
||||||
status: z.enum(["added", "deleted", "modified"]).optional(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "SnapshotFileDiff",
|
|
||||||
})
|
|
||||||
export type FileDiff = z.infer<typeof FileDiff>
|
|
||||||
|
|
||||||
const log = Log.create({ service: "snapshot" })
|
|
||||||
const prune = "7.days"
|
|
||||||
const limit = 2 * 1024 * 1024
|
|
||||||
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
|
|
||||||
const cfg = ["-c", "core.autocrlf=false", ...core]
|
|
||||||
const quote = [...cfg, "-c", "core.quotepath=false"]
|
|
||||||
interface GitResult {
|
|
||||||
readonly code: ChildProcessSpawner.ExitCode
|
|
||||||
readonly text: string
|
|
||||||
readonly stderr: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type State = Omit<Interface, "init">
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly init: () => Effect.Effect<void>
|
|
||||||
readonly cleanup: () => Effect.Effect<void>
|
|
||||||
readonly track: () => Effect.Effect<string | undefined>
|
|
||||||
readonly patch: (hash: string) => Effect.Effect<Patch>
|
|
||||||
readonly restore: (snapshot: string) => Effect.Effect<void>
|
|
||||||
readonly revert: (patches: Patch[]) => Effect.Effect<void>
|
|
||||||
readonly diff: (hash: string) => Effect.Effect<string>
|
|
||||||
readonly diffFull: (from: string, to: string) => Effect.Effect<FileDiff[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Snapshot") {}
|
|
||||||
|
|
||||||
export const layer: Layer.Layer<
|
|
||||||
Service,
|
|
||||||
never,
|
|
||||||
AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | Config.Service
|
|
||||||
> = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const fs = yield* AppFileSystem.Service
|
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
||||||
const config = yield* Config.Service
|
|
||||||
const locks = new Map<string, Semaphore.Semaphore>()
|
|
||||||
|
|
||||||
const lock = (key: string) => {
|
|
||||||
const hit = locks.get(key)
|
|
||||||
if (hit) return hit
|
|
||||||
|
|
||||||
const next = Semaphore.makeUnsafe(1)
|
|
||||||
locks.set(key, next)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
|
||||||
Effect.fn("Snapshot.state")(function* (ctx) {
|
|
||||||
const state = {
|
|
||||||
directory: ctx.directory,
|
|
||||||
worktree: ctx.worktree,
|
|
||||||
gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)),
|
|
||||||
vcs: ctx.project.vcs,
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
|
|
||||||
|
|
||||||
const enc = new TextEncoder()
|
|
||||||
const feed = (list: string[]) => Stream.make(enc.encode(list.join("\0") + "\0"))
|
|
||||||
|
|
||||||
const git = Effect.fnUntraced(
|
|
||||||
function* (
|
|
||||||
cmd: string[],
|
|
||||||
opts?: { cwd?: string; env?: Record<string, string>; stdin?: ChildProcess.CommandInput },
|
|
||||||
) {
|
|
||||||
const proc = ChildProcess.make("git", cmd, {
|
|
||||||
cwd: opts?.cwd,
|
|
||||||
env: opts?.env,
|
|
||||||
extendEnv: true,
|
|
||||||
stdin: opts?.stdin,
|
|
||||||
})
|
|
||||||
const handle = yield* spawner.spawn(proc)
|
|
||||||
const [text, stderr] = yield* Effect.all(
|
|
||||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
const code = yield* handle.exitCode
|
|
||||||
return { code, text, stderr } satisfies GitResult
|
|
||||||
},
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.catch((err) =>
|
|
||||||
Effect.succeed({
|
|
||||||
code: ChildProcessSpawner.ExitCode(1),
|
|
||||||
text: "",
|
|
||||||
stderr: err instanceof Error ? err.message : String(err),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const ignore = Effect.fnUntraced(function* (files: string[]) {
|
|
||||||
if (!files.length) return new Set<string>()
|
|
||||||
const check = yield* git(
|
|
||||||
[
|
|
||||||
...quote,
|
|
||||||
"--git-dir",
|
|
||||||
path.join(state.worktree, ".git"),
|
|
||||||
"--work-tree",
|
|
||||||
state.worktree,
|
|
||||||
"check-ignore",
|
|
||||||
"--no-index",
|
|
||||||
"--stdin",
|
|
||||||
"-z",
|
|
||||||
],
|
|
||||||
{
|
|
||||||
cwd: state.directory,
|
|
||||||
stdin: feed(files),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (check.code !== 0 && check.code !== 1) return new Set<string>()
|
|
||||||
return new Set(check.text.split("\0").filter(Boolean))
|
|
||||||
})
|
|
||||||
|
|
||||||
const drop = Effect.fnUntraced(function* (files: string[]) {
|
|
||||||
if (!files.length) return
|
|
||||||
yield* git(
|
|
||||||
[
|
|
||||||
...cfg,
|
|
||||||
...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]),
|
|
||||||
],
|
|
||||||
{
|
|
||||||
cwd: state.directory,
|
|
||||||
stdin: feed(files),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const stage = Effect.fnUntraced(function* (files: string[]) {
|
|
||||||
if (!files.length) return
|
|
||||||
const result = yield* git(
|
|
||||||
[...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])],
|
|
||||||
{
|
|
||||||
cwd: state.directory,
|
|
||||||
stdin: feed(files),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (result.code === 0) return
|
|
||||||
log.warn("failed to add snapshot files", {
|
|
||||||
exitCode: result.code,
|
|
||||||
stderr: result.stderr,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
|
||||||
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
|
||||||
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
|
||||||
const locked = <A, E, R>(fx: Effect.Effect<A, E, R>) => lock(state.gitdir).withPermits(1)(fx)
|
|
||||||
|
|
||||||
const enabled = Effect.fnUntraced(function* () {
|
|
||||||
if (state.vcs !== "git") return false
|
|
||||||
return (yield* config.get()).snapshot !== false
|
|
||||||
})
|
|
||||||
|
|
||||||
const excludes = Effect.fnUntraced(function* () {
|
|
||||||
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
|
||||||
cwd: state.worktree,
|
|
||||||
})
|
|
||||||
const file = result.text.trim()
|
|
||||||
if (!file) return
|
|
||||||
if (!(yield* exists(file))) return
|
|
||||||
return file
|
|
||||||
})
|
|
||||||
|
|
||||||
const sync = Effect.fnUntraced(function* (list: string[] = []) {
|
|
||||||
const file = yield* excludes()
|
|
||||||
const target = path.join(state.gitdir, "info", "exclude")
|
|
||||||
const text = [
|
|
||||||
file ? (yield* read(file)).trimEnd() : "",
|
|
||||||
...list.map((item) => `/${item.replaceAll("\\", "/")}`),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n")
|
|
||||||
yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
|
|
||||||
yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
|
|
||||||
})
|
|
||||||
|
|
||||||
const add = Effect.fnUntraced(function* () {
|
|
||||||
yield* sync()
|
|
||||||
const [diff, other] = yield* Effect.all(
|
|
||||||
[
|
|
||||||
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
|
|
||||||
cwd: state.directory,
|
|
||||||
}),
|
|
||||||
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
|
|
||||||
cwd: state.directory,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
if (diff.code !== 0 || other.code !== 0) {
|
|
||||||
log.warn("failed to list snapshot files", {
|
|
||||||
diffCode: diff.code,
|
|
||||||
diffStderr: diff.stderr,
|
|
||||||
otherCode: other.code,
|
|
||||||
otherStderr: other.stderr,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const tracked = diff.text.split("\0").filter(Boolean)
|
|
||||||
const untracked = other.text.split("\0").filter(Boolean)
|
|
||||||
const all = Array.from(new Set([...tracked, ...untracked]))
|
|
||||||
if (!all.length) return
|
|
||||||
|
|
||||||
// Resolve source-repo ignore rules against the exact candidate set.
|
|
||||||
// --no-index keeps this pattern-based even when a path is already tracked.
|
|
||||||
const ignored = yield* ignore(all)
|
|
||||||
|
|
||||||
// Remove newly-ignored files from snapshot index to prevent re-adding
|
|
||||||
if (ignored.size > 0) {
|
|
||||||
const ignoredFiles = Array.from(ignored)
|
|
||||||
log.info("removing gitignored files from snapshot", { count: ignoredFiles.length })
|
|
||||||
yield* drop(ignoredFiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
const allow = all.filter((item) => !ignored.has(item))
|
|
||||||
if (!allow.length) return
|
|
||||||
|
|
||||||
const large = new Set(
|
|
||||||
(yield* Effect.all(
|
|
||||||
allow.map((item) =>
|
|
||||||
fs
|
|
||||||
.stat(path.join(state.directory, item))
|
|
||||||
.pipe(Effect.catch(() => Effect.void))
|
|
||||||
.pipe(
|
|
||||||
Effect.map((stat) => {
|
|
||||||
if (!stat || stat.type !== "File") return
|
|
||||||
const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size
|
|
||||||
return size > limit ? item : undefined
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
{ concurrency: 8 },
|
|
||||||
)).filter((item): item is string => Boolean(item)),
|
|
||||||
)
|
|
||||||
const block = new Set(untracked.filter((item) => large.has(item)))
|
|
||||||
yield* sync(Array.from(block))
|
|
||||||
// Stage only the allowed candidate paths so snapshot updates stay scoped.
|
|
||||||
yield* stage(allow.filter((item) => !block.has(item)))
|
|
||||||
})
|
|
||||||
|
|
||||||
const cleanup = Effect.fnUntraced(function* () {
|
|
||||||
return yield* locked(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (!(yield* enabled())) return
|
|
||||||
if (!(yield* exists(state.gitdir))) return
|
|
||||||
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
|
|
||||||
if (result.code !== 0) {
|
|
||||||
log.warn("cleanup failed", {
|
|
||||||
exitCode: result.code,
|
|
||||||
stderr: result.stderr,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.info("cleanup", { prune })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const track = Effect.fnUntraced(function* () {
|
|
||||||
return yield* locked(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (!(yield* enabled())) return
|
|
||||||
const existed = yield* exists(state.gitdir)
|
|
||||||
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
|
|
||||||
if (!existed) {
|
|
||||||
yield* git(["init"], {
|
|
||||||
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
|
|
||||||
})
|
|
||||||
yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
|
|
||||||
yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
|
|
||||||
yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
|
|
||||||
yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
|
|
||||||
log.info("initialized")
|
|
||||||
}
|
|
||||||
yield* add()
|
|
||||||
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
|
|
||||||
const hash = result.text.trim()
|
|
||||||
log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
|
|
||||||
return hash
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const patch = Effect.fnUntraced(function* (hash: string) {
|
|
||||||
return yield* locked(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* add()
|
|
||||||
const result = yield* git(
|
|
||||||
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
|
||||||
{
|
|
||||||
cwd: state.directory,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (result.code !== 0) {
|
|
||||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
|
||||||
return { hash, files: [] }
|
|
||||||
}
|
|
||||||
const files = result.text
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.map((x) => x.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
|
|
||||||
// Hide ignored-file removals from the user-facing patch output.
|
|
||||||
const ignored = yield* ignore(files)
|
|
||||||
|
|
||||||
return {
|
|
||||||
hash,
|
|
||||||
files: files
|
|
||||||
.filter((item) => !ignored.has(item))
|
|
||||||
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const restore = Effect.fnUntraced(function* (snapshot: string) {
|
|
||||||
return yield* locked(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
log.info("restore", { commit: snapshot })
|
|
||||||
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
|
|
||||||
if (result.code === 0) {
|
|
||||||
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
|
|
||||||
cwd: state.worktree,
|
|
||||||
})
|
|
||||||
if (checkout.code === 0) return
|
|
||||||
log.error("failed to restore snapshot", {
|
|
||||||
snapshot,
|
|
||||||
exitCode: checkout.code,
|
|
||||||
stderr: checkout.stderr,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.error("failed to restore snapshot", {
|
|
||||||
snapshot,
|
|
||||||
exitCode: result.code,
|
|
||||||
stderr: result.stderr,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const revert = Effect.fnUntraced(function* (patches: Patch[]) {
|
|
||||||
return yield* locked(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const ops: { hash: string; file: string; rel: string }[] = []
|
|
||||||
const seen = new Set<string>()
|
|
||||||
for (const item of patches) {
|
|
||||||
for (const file of item.files) {
|
|
||||||
if (seen.has(file)) continue
|
|
||||||
seen.add(file)
|
|
||||||
ops.push({
|
|
||||||
hash: item.hash,
|
|
||||||
file,
|
|
||||||
rel: path.relative(state.worktree, file).replaceAll("\\", "/"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const single = Effect.fnUntraced(function* (op: (typeof ops)[number]) {
|
|
||||||
log.info("reverting", { file: op.file, hash: op.hash })
|
|
||||||
const result = yield* git([...core, ...args(["checkout", op.hash, "--", op.file])], {
|
|
||||||
cwd: state.worktree,
|
|
||||||
})
|
|
||||||
if (result.code === 0) return
|
|
||||||
const tree = yield* git([...core, ...args(["ls-tree", op.hash, "--", op.rel])], {
|
|
||||||
cwd: state.worktree,
|
|
||||||
})
|
|
||||||
if (tree.code === 0 && tree.text.trim()) {
|
|
||||||
log.info("file existed in snapshot but checkout failed, keeping", { file: op.file, hash: op.hash })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash })
|
|
||||||
yield* remove(op.file)
|
|
||||||
})
|
|
||||||
|
|
||||||
const clash = (a: string, b: string) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)
|
|
||||||
|
|
||||||
for (let i = 0; i < ops.length; ) {
|
|
||||||
const first = ops[i]!
|
|
||||||
const run = [first]
|
|
||||||
let j = i + 1
|
|
||||||
// Only batch adjacent files when their paths cannot affect each other.
|
|
||||||
while (j < ops.length && run.length < 100) {
|
|
||||||
const next = ops[j]!
|
|
||||||
if (next.hash !== first.hash) break
|
|
||||||
if (run.some((item) => clash(item.rel, next.rel))) break
|
|
||||||
run.push(next)
|
|
||||||
j += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (run.length === 1) {
|
|
||||||
yield* single(first)
|
|
||||||
i = j
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const tree = yield* git(
|
|
||||||
[...core, ...args(["ls-tree", "--name-only", first.hash, "--", ...run.map((item) => item.rel)])],
|
|
||||||
{
|
|
||||||
cwd: state.worktree,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if (tree.code !== 0) {
|
|
||||||
log.info("batched ls-tree failed, falling back to single-file revert", {
|
|
||||||
hash: first.hash,
|
|
||||||
files: run.length,
|
|
||||||
})
|
|
||||||
for (const op of run) {
|
|
||||||
yield* single(op)
|
|
||||||
}
|
|
||||||
i = j
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const have = new Set(
|
|
||||||
tree.text
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
)
|
|
||||||
const list = run.filter((item) => have.has(item.rel))
|
|
||||||
if (list.length) {
|
|
||||||
log.info("reverting", { hash: first.hash, files: list.length })
|
|
||||||
const result = yield* git(
|
|
||||||
[...core, ...args(["checkout", first.hash, "--", ...list.map((item) => item.file)])],
|
|
||||||
{
|
|
||||||
cwd: state.worktree,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (result.code !== 0) {
|
|
||||||
log.info("batched checkout failed, falling back to single-file revert", {
|
|
||||||
hash: first.hash,
|
|
||||||
files: list.length,
|
|
||||||
})
|
|
||||||
for (const op of run) {
|
|
||||||
yield* single(op)
|
|
||||||
}
|
|
||||||
i = j
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const op of run) {
|
|
||||||
if (have.has(op.rel)) continue
|
|
||||||
log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash })
|
|
||||||
yield* remove(op.file)
|
|
||||||
}
|
|
||||||
|
|
||||||
i = j
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const diff = Effect.fnUntraced(function* (hash: string) {
|
|
||||||
return yield* locked(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* add()
|
|
||||||
const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
|
|
||||||
cwd: state.worktree,
|
|
||||||
})
|
|
||||||
if (result.code !== 0) {
|
|
||||||
log.warn("failed to get diff", {
|
|
||||||
hash,
|
|
||||||
exitCode: result.code,
|
|
||||||
stderr: result.stderr,
|
|
||||||
})
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return result.text.trim()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
|
|
||||||
return yield* locked(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
type Row = {
|
|
||||||
file: string
|
|
||||||
status: "added" | "deleted" | "modified"
|
|
||||||
binary: boolean
|
|
||||||
additions: number
|
|
||||||
deletions: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type Ref = {
|
|
||||||
file: string
|
|
||||||
side: "before" | "after"
|
|
||||||
ref: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const show = Effect.fnUntraced(function* (row: Row) {
|
|
||||||
if (row.binary) return ["", ""]
|
|
||||||
if (row.status === "added") {
|
|
||||||
return [
|
|
||||||
"",
|
|
||||||
yield* git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
if (row.status === "deleted") {
|
|
||||||
return [
|
|
||||||
yield* git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(
|
|
||||||
Effect.map((item) => item.text),
|
|
||||||
),
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return yield* Effect.all(
|
|
||||||
[
|
|
||||||
git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
|
|
||||||
git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
|
|
||||||
],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const load = Effect.fnUntraced(
|
|
||||||
function* (rows: Row[]) {
|
|
||||||
const refs = rows.flatMap((row) => {
|
|
||||||
if (row.binary) return []
|
|
||||||
if (row.status === "added")
|
|
||||||
return [{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref]
|
|
||||||
if (row.status === "deleted") {
|
|
||||||
return [{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref]
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref,
|
|
||||||
{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref,
|
|
||||||
]
|
|
||||||
})
|
|
||||||
if (!refs.length) return new Map<string, { before: string; after: string }>()
|
|
||||||
|
|
||||||
const proc = ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], {
|
|
||||||
cwd: state.directory,
|
|
||||||
extendEnv: true,
|
|
||||||
stdin: Stream.make(new TextEncoder().encode(refs.map((item) => item.ref).join("\n") + "\n")),
|
|
||||||
})
|
|
||||||
const handle = yield* spawner.spawn(proc)
|
|
||||||
const [out, err] = yield* Effect.all(
|
|
||||||
[Stream.mkUint8Array(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
const code = yield* handle.exitCode
|
|
||||||
if (code !== 0) {
|
|
||||||
log.info("git cat-file --batch failed during snapshot diff, falling back to per-file git show", {
|
|
||||||
stderr: err,
|
|
||||||
refs: refs.length,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const fail = (msg: string, extra?: Record<string, string>) => {
|
|
||||||
log.info(msg, { ...extra, refs: refs.length })
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const map = new Map<string, { before: string; after: string }>()
|
|
||||||
const dec = new TextDecoder()
|
|
||||||
let i = 0
|
|
||||||
for (const ref of refs) {
|
|
||||||
let end = i
|
|
||||||
while (end < out.length && out[end] !== 10) end += 1
|
|
||||||
if (end >= out.length) {
|
|
||||||
return fail(
|
|
||||||
"git cat-file --batch returned a truncated header during snapshot diff, falling back to per-file git show",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const head = dec.decode(out.slice(i, end))
|
|
||||||
i = end + 1
|
|
||||||
const hit = map.get(ref.file) ?? { before: "", after: "" }
|
|
||||||
if (head.endsWith(" missing")) {
|
|
||||||
map.set(ref.file, hit)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = head.match(/^[0-9a-f]+ blob (\d+)$/)
|
|
||||||
if (!match) {
|
|
||||||
return fail(
|
|
||||||
"git cat-file --batch returned an unexpected header during snapshot diff, falling back to per-file git show",
|
|
||||||
{ head },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const size = Number(match[1])
|
|
||||||
if (!Number.isInteger(size) || size < 0 || i + size >= out.length || out[i + size] !== 10) {
|
|
||||||
return fail(
|
|
||||||
"git cat-file --batch returned truncated content during snapshot diff, falling back to per-file git show",
|
|
||||||
{ head },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = dec.decode(out.slice(i, i + size))
|
|
||||||
if (ref.side === "before") hit.before = text
|
|
||||||
if (ref.side === "after") hit.after = text
|
|
||||||
map.set(ref.file, hit)
|
|
||||||
i += size + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (i !== out.length) {
|
|
||||||
return fail(
|
|
||||||
"git cat-file --batch returned trailing data during snapshot diff, falling back to per-file git show",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return map
|
|
||||||
},
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.catch(() =>
|
|
||||||
Effect.succeed<Map<string, { before: string; after: string }> | undefined>(undefined),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const result: FileDiff[] = []
|
|
||||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
|
||||||
|
|
||||||
const statuses = yield* git(
|
|
||||||
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
|
|
||||||
{ cwd: state.directory },
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const line of statuses.text.trim().split("\n")) {
|
|
||||||
if (!line) continue
|
|
||||||
const [code, file] = line.split("\t")
|
|
||||||
if (!code || !file) continue
|
|
||||||
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
|
||||||
}
|
|
||||||
|
|
||||||
const numstat = yield* git(
|
|
||||||
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
|
||||||
{
|
|
||||||
cwd: state.directory,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const rows = numstat.text
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.filter(Boolean)
|
|
||||||
.flatMap((line) => {
|
|
||||||
const [adds, dels, file] = line.split("\t")
|
|
||||||
if (!file) return []
|
|
||||||
const binary = adds === "-" && dels === "-"
|
|
||||||
const additions = binary ? 0 : parseInt(adds)
|
|
||||||
const deletions = binary ? 0 : parseInt(dels)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
file,
|
|
||||||
status: status.get(file) ?? "modified",
|
|
||||||
binary,
|
|
||||||
additions: Number.isFinite(additions) ? additions : 0,
|
|
||||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
|
||||||
} satisfies Row,
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
// Hide ignored-file removals from the user-facing diff output.
|
|
||||||
const ignored = yield* ignore(rows.map((r) => r.file))
|
|
||||||
if (ignored.size > 0) {
|
|
||||||
const filtered = rows.filter((r) => !ignored.has(r.file))
|
|
||||||
rows.length = 0
|
|
||||||
rows.push(...filtered)
|
|
||||||
}
|
|
||||||
|
|
||||||
const step = 100
|
|
||||||
const patch = (file: string, before: string, after: string) =>
|
|
||||||
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
|
|
||||||
|
|
||||||
for (let i = 0; i < rows.length; i += step) {
|
|
||||||
const run = rows.slice(i, i + step)
|
|
||||||
const text = yield* load(run)
|
|
||||||
|
|
||||||
for (const row of run) {
|
|
||||||
const hit = text?.get(row.file) ?? { before: "", after: "" }
|
|
||||||
const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row)
|
|
||||||
result.push({
|
|
||||||
file: row.file,
|
|
||||||
patch: row.binary ? "" : patch(row.file, before, after),
|
|
||||||
additions: row.additions,
|
|
||||||
deletions: row.deletions,
|
|
||||||
status: row.status,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* cleanup().pipe(
|
|
||||||
Effect.catchCause((cause) => {
|
|
||||||
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
|
|
||||||
return Effect.void
|
|
||||||
}),
|
|
||||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
|
||||||
Effect.delay(Duration.minutes(1)),
|
|
||||||
Effect.forkScoped,
|
|
||||||
)
|
|
||||||
|
|
||||||
return { cleanup, track, patch, restore, revert, diff, diffFull }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
init: Effect.fn("Snapshot.init")(function* () {
|
|
||||||
yield* InstanceState.get(state)
|
|
||||||
}),
|
|
||||||
cleanup: Effect.fn("Snapshot.cleanup")(function* () {
|
|
||||||
return yield* InstanceState.useEffect(state, (s) => s.cleanup())
|
|
||||||
}),
|
|
||||||
track: Effect.fn("Snapshot.track")(function* () {
|
|
||||||
return yield* InstanceState.useEffect(state, (s) => s.track())
|
|
||||||
}),
|
|
||||||
patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
|
|
||||||
return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
|
|
||||||
}),
|
|
||||||
restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
|
|
||||||
return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
|
|
||||||
}),
|
|
||||||
revert: Effect.fn("Snapshot.revert")(function* (patches: Patch[]) {
|
|
||||||
return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
|
|
||||||
}),
|
|
||||||
diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
|
|
||||||
return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
|
|
||||||
}),
|
|
||||||
diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
|
|
||||||
return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
|
||||||
Layer.provide(Config.defaultLayer),
|
|
||||||
)
|
|
||||||
@@ -1 +1,278 @@
|
|||||||
export * as SyncEvent from "./sync-event"
|
import z from "zod"
|
||||||
|
import type { ZodObject } from "zod"
|
||||||
|
import { Database, eq } from "@/storage"
|
||||||
|
import { GlobalBus } from "@/bus/global"
|
||||||
|
import { Bus as ProjectBus } from "@/bus"
|
||||||
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
import { EventSequenceTable, EventTable } from "./event.sql"
|
||||||
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
|
import { EventID } from "./schema"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
|
||||||
|
export type Definition = {
|
||||||
|
type: string
|
||||||
|
version: number
|
||||||
|
aggregate: string
|
||||||
|
schema: z.ZodObject
|
||||||
|
|
||||||
|
// This is temporary and only exists for compatibility with bus
|
||||||
|
// event definitions
|
||||||
|
properties: z.ZodObject
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Event<Def extends Definition = Definition> = {
|
||||||
|
id: string
|
||||||
|
seq: number
|
||||||
|
aggregateID: string
|
||||||
|
data: z.infer<Def["schema"]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SerializedEvent<Def extends Definition = Definition> = Event<Def> & { type: string }
|
||||||
|
|
||||||
|
type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void
|
||||||
|
|
||||||
|
export const registry = new Map<string, Definition>()
|
||||||
|
let projectors: Map<Definition, ProjectorFunc> | undefined
|
||||||
|
const versions = new Map<string, number>()
|
||||||
|
let frozen = false
|
||||||
|
let convertEvent: (type: string, event: Event["data"]) => Promise<Record<string, unknown>> | Record<string, unknown>
|
||||||
|
|
||||||
|
export function reset() {
|
||||||
|
frozen = false
|
||||||
|
projectors = undefined
|
||||||
|
convertEvent = (_, data) => data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: typeof convertEvent }) {
|
||||||
|
projectors = new Map(input.projectors)
|
||||||
|
|
||||||
|
// Install all the latest event defs to the bus. We only ever emit
|
||||||
|
// latest versions from code, and keep around old versions for
|
||||||
|
// replaying. Replaying does not go through the bus, and it
|
||||||
|
// simplifies the bus to only use unversioned latest events
|
||||||
|
for (let [type, version] of versions.entries()) {
|
||||||
|
let def = registry.get(versionedType(type, version))!
|
||||||
|
|
||||||
|
BusEvent.define(def.type, def.properties || def.schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Freeze the system so it clearly errors if events are defined
|
||||||
|
// after `init` which would cause bugs
|
||||||
|
frozen = true
|
||||||
|
convertEvent = input.convertEvent || ((_, data) => data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function versionedType<A extends string>(type: A): A
|
||||||
|
export function versionedType<A extends string, B extends number>(type: A, version: B): `${A}/${B}`
|
||||||
|
export function versionedType(type: string, version?: number) {
|
||||||
|
return version ? `${type}.${version}` : type
|
||||||
|
}
|
||||||
|
|
||||||
|
export function define<
|
||||||
|
Type extends string,
|
||||||
|
Agg extends string,
|
||||||
|
Schema extends ZodObject<Record<Agg, z.ZodType<string>>>,
|
||||||
|
BusSchema extends ZodObject = Schema,
|
||||||
|
>(input: { type: Type; version: number; aggregate: Agg; schema: Schema; busSchema?: BusSchema }) {
|
||||||
|
if (frozen) {
|
||||||
|
throw new Error("Error defining sync event: sync system has been frozen")
|
||||||
|
}
|
||||||
|
|
||||||
|
const def = {
|
||||||
|
type: input.type,
|
||||||
|
version: input.version,
|
||||||
|
aggregate: input.aggregate,
|
||||||
|
schema: input.schema,
|
||||||
|
properties: input.busSchema ? input.busSchema : input.schema,
|
||||||
|
}
|
||||||
|
|
||||||
|
versions.set(def.type, Math.max(def.version, versions.get(def.type) || 0))
|
||||||
|
|
||||||
|
registry.set(versionedType(def.type, def.version), def)
|
||||||
|
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
export function project<Def extends Definition>(
|
||||||
|
def: Def,
|
||||||
|
func: (db: Database.TxOrDb, data: Event<Def>["data"]) => void,
|
||||||
|
): [Definition, ProjectorFunc] {
|
||||||
|
return [def, func as ProjectorFunc]
|
||||||
|
}
|
||||||
|
|
||||||
|
function process<Def extends Definition>(def: Def, event: Event<Def>, options: { publish: boolean }) {
|
||||||
|
if (projectors == null) {
|
||||||
|
throw new Error("No projectors available. Call `SyncEvent.init` to install projectors")
|
||||||
|
}
|
||||||
|
|
||||||
|
const projector = projectors.get(def)
|
||||||
|
if (!projector) {
|
||||||
|
throw new Error(`Projector not found for event: ${def.type}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// idempotent: need to ignore any events already logged
|
||||||
|
|
||||||
|
Database.transaction((tx) => {
|
||||||
|
projector(tx, event.data)
|
||||||
|
|
||||||
|
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||||
|
tx.insert(EventSequenceTable)
|
||||||
|
.values({
|
||||||
|
aggregate_id: event.aggregateID,
|
||||||
|
seq: event.seq,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: EventSequenceTable.aggregate_id,
|
||||||
|
set: { seq: event.seq },
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
tx.insert(EventTable)
|
||||||
|
.values({
|
||||||
|
id: event.id,
|
||||||
|
seq: event.seq,
|
||||||
|
aggregate_id: event.aggregateID,
|
||||||
|
type: versionedType(def.type, def.version),
|
||||||
|
data: event.data as Record<string, unknown>,
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
Database.effect(() => {
|
||||||
|
if (options?.publish) {
|
||||||
|
const result = convertEvent(def.type, event.data)
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
void result.then((data) => {
|
||||||
|
void ProjectBus.publish({ type: def.type, properties: def.schema }, data)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
void ProjectBus.publish({ type: def.type, properties: def.schema }, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
GlobalBus.emit("event", {
|
||||||
|
directory: Instance.directory,
|
||||||
|
project: Instance.project.id,
|
||||||
|
workspace: WorkspaceContext.workspaceID,
|
||||||
|
payload: {
|
||||||
|
type: "sync",
|
||||||
|
syncEvent: {
|
||||||
|
type: versionedType(def.type, def.version),
|
||||||
|
...event,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replay(event: SerializedEvent, options?: { publish: boolean }) {
|
||||||
|
const def = registry.get(event.type)
|
||||||
|
if (!def) {
|
||||||
|
throw new Error(`Unknown event type: ${event.type}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = Database.use((db) =>
|
||||||
|
db
|
||||||
|
.select({ seq: EventSequenceTable.seq })
|
||||||
|
.from(EventSequenceTable)
|
||||||
|
.where(eq(EventSequenceTable.aggregate_id, event.aggregateID))
|
||||||
|
.get(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const latest = row?.seq ?? -1
|
||||||
|
if (event.seq <= latest) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = latest + 1
|
||||||
|
if (event.seq !== expected) {
|
||||||
|
throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
process(def, event, { publish: !!options?.publish })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) {
|
||||||
|
const source = events[0]?.aggregateID
|
||||||
|
if (!source) return
|
||||||
|
if (events.some((item) => item.aggregateID !== source)) {
|
||||||
|
throw new Error("Replay events must belong to the same session")
|
||||||
|
}
|
||||||
|
const start = events[0].seq
|
||||||
|
for (const [i, item] of events.entries()) {
|
||||||
|
const seq = start + i
|
||||||
|
if (item.seq !== seq) {
|
||||||
|
throw new Error(`Replay sequence mismatch at index ${i}: expected ${seq}, got ${item.seq}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const item of events) {
|
||||||
|
replay(item, options)
|
||||||
|
}
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
export function run<Def extends Definition>(def: Def, data: Event<Def>["data"], options?: { publish?: boolean }) {
|
||||||
|
const agg = (data as Record<string, string>)[def.aggregate]
|
||||||
|
// This should never happen: we've enforced it via typescript in
|
||||||
|
// the definition
|
||||||
|
if (agg == null) {
|
||||||
|
throw new Error(`SyncEvent.run: "${def.aggregate}" required but not found: ${JSON.stringify(data)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (def.version !== versions.get(def.type)) {
|
||||||
|
throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { publish = true } = options || {}
|
||||||
|
|
||||||
|
// Note that this is an "immediate" transaction which is critical.
|
||||||
|
// We need to make sure we can safely read and write with nothing
|
||||||
|
// else changing the data from under us
|
||||||
|
Database.transaction(
|
||||||
|
(tx) => {
|
||||||
|
const id = EventID.ascending()
|
||||||
|
const row = tx
|
||||||
|
.select({ seq: EventSequenceTable.seq })
|
||||||
|
.from(EventSequenceTable)
|
||||||
|
.where(eq(EventSequenceTable.aggregate_id, agg))
|
||||||
|
.get()
|
||||||
|
const seq = row?.seq != null ? row.seq + 1 : 0
|
||||||
|
|
||||||
|
const event = { id, seq, aggregateID: agg, data }
|
||||||
|
process(def, event, { publish })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
behavior: "immediate",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remove(aggregateID: string) {
|
||||||
|
Database.transaction((tx) => {
|
||||||
|
tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
|
||||||
|
tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function payloads() {
|
||||||
|
return registry
|
||||||
|
.entries()
|
||||||
|
.map(([type, def]) => {
|
||||||
|
return z
|
||||||
|
.object({
|
||||||
|
type: z.literal("sync"),
|
||||||
|
name: z.literal(type),
|
||||||
|
id: z.string(),
|
||||||
|
seq: z.number(),
|
||||||
|
aggregateID: z.literal(def.aggregate),
|
||||||
|
data: def.schema,
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: `SyncEvent.${def.type}`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.toArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as SyncEvent from "."
|
||||||
|
|||||||
@@ -1,276 +0,0 @@
|
|||||||
import z from "zod"
|
|
||||||
import type { ZodObject } from "zod"
|
|
||||||
import { Database, eq } from "@/storage"
|
|
||||||
import { GlobalBus } from "@/bus/global"
|
|
||||||
import { Bus as ProjectBus } from "@/bus"
|
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import { EventSequenceTable, EventTable } from "./event.sql"
|
|
||||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
|
||||||
import { EventID } from "./schema"
|
|
||||||
import { Flag } from "@/flag/flag"
|
|
||||||
|
|
||||||
export type Definition = {
|
|
||||||
type: string
|
|
||||||
version: number
|
|
||||||
aggregate: string
|
|
||||||
schema: z.ZodObject
|
|
||||||
|
|
||||||
// This is temporary and only exists for compatibility with bus
|
|
||||||
// event definitions
|
|
||||||
properties: z.ZodObject
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Event<Def extends Definition = Definition> = {
|
|
||||||
id: string
|
|
||||||
seq: number
|
|
||||||
aggregateID: string
|
|
||||||
data: z.infer<Def["schema"]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SerializedEvent<Def extends Definition = Definition> = Event<Def> & { type: string }
|
|
||||||
|
|
||||||
type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void
|
|
||||||
|
|
||||||
export const registry = new Map<string, Definition>()
|
|
||||||
let projectors: Map<Definition, ProjectorFunc> | undefined
|
|
||||||
const versions = new Map<string, number>()
|
|
||||||
let frozen = false
|
|
||||||
let convertEvent: (type: string, event: Event["data"]) => Promise<Record<string, unknown>> | Record<string, unknown>
|
|
||||||
|
|
||||||
export function reset() {
|
|
||||||
frozen = false
|
|
||||||
projectors = undefined
|
|
||||||
convertEvent = (_, data) => data
|
|
||||||
}
|
|
||||||
|
|
||||||
export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: typeof convertEvent }) {
|
|
||||||
projectors = new Map(input.projectors)
|
|
||||||
|
|
||||||
// Install all the latest event defs to the bus. We only ever emit
|
|
||||||
// latest versions from code, and keep around old versions for
|
|
||||||
// replaying. Replaying does not go through the bus, and it
|
|
||||||
// simplifies the bus to only use unversioned latest events
|
|
||||||
for (let [type, version] of versions.entries()) {
|
|
||||||
let def = registry.get(versionedType(type, version))!
|
|
||||||
|
|
||||||
BusEvent.define(def.type, def.properties || def.schema)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Freeze the system so it clearly errors if events are defined
|
|
||||||
// after `init` which would cause bugs
|
|
||||||
frozen = true
|
|
||||||
convertEvent = input.convertEvent || ((_, data) => data)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function versionedType<A extends string>(type: A): A
|
|
||||||
export function versionedType<A extends string, B extends number>(type: A, version: B): `${A}/${B}`
|
|
||||||
export function versionedType(type: string, version?: number) {
|
|
||||||
return version ? `${type}.${version}` : type
|
|
||||||
}
|
|
||||||
|
|
||||||
export function define<
|
|
||||||
Type extends string,
|
|
||||||
Agg extends string,
|
|
||||||
Schema extends ZodObject<Record<Agg, z.ZodType<string>>>,
|
|
||||||
BusSchema extends ZodObject = Schema,
|
|
||||||
>(input: { type: Type; version: number; aggregate: Agg; schema: Schema; busSchema?: BusSchema }) {
|
|
||||||
if (frozen) {
|
|
||||||
throw new Error("Error defining sync event: sync system has been frozen")
|
|
||||||
}
|
|
||||||
|
|
||||||
const def = {
|
|
||||||
type: input.type,
|
|
||||||
version: input.version,
|
|
||||||
aggregate: input.aggregate,
|
|
||||||
schema: input.schema,
|
|
||||||
properties: input.busSchema ? input.busSchema : input.schema,
|
|
||||||
}
|
|
||||||
|
|
||||||
versions.set(def.type, Math.max(def.version, versions.get(def.type) || 0))
|
|
||||||
|
|
||||||
registry.set(versionedType(def.type, def.version), def)
|
|
||||||
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
export function project<Def extends Definition>(
|
|
||||||
def: Def,
|
|
||||||
func: (db: Database.TxOrDb, data: Event<Def>["data"]) => void,
|
|
||||||
): [Definition, ProjectorFunc] {
|
|
||||||
return [def, func as ProjectorFunc]
|
|
||||||
}
|
|
||||||
|
|
||||||
function process<Def extends Definition>(def: Def, event: Event<Def>, options: { publish: boolean }) {
|
|
||||||
if (projectors == null) {
|
|
||||||
throw new Error("No projectors available. Call `SyncEvent.init` to install projectors")
|
|
||||||
}
|
|
||||||
|
|
||||||
const projector = projectors.get(def)
|
|
||||||
if (!projector) {
|
|
||||||
throw new Error(`Projector not found for event: ${def.type}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// idempotent: need to ignore any events already logged
|
|
||||||
|
|
||||||
Database.transaction((tx) => {
|
|
||||||
projector(tx, event.data)
|
|
||||||
|
|
||||||
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
|
||||||
tx.insert(EventSequenceTable)
|
|
||||||
.values({
|
|
||||||
aggregate_id: event.aggregateID,
|
|
||||||
seq: event.seq,
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: EventSequenceTable.aggregate_id,
|
|
||||||
set: { seq: event.seq },
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
tx.insert(EventTable)
|
|
||||||
.values({
|
|
||||||
id: event.id,
|
|
||||||
seq: event.seq,
|
|
||||||
aggregate_id: event.aggregateID,
|
|
||||||
type: versionedType(def.type, def.version),
|
|
||||||
data: event.data as Record<string, unknown>,
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
}
|
|
||||||
|
|
||||||
Database.effect(() => {
|
|
||||||
if (options?.publish) {
|
|
||||||
const result = convertEvent(def.type, event.data)
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
void result.then((data) => {
|
|
||||||
void ProjectBus.publish({ type: def.type, properties: def.schema }, data)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
void ProjectBus.publish({ type: def.type, properties: def.schema }, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
GlobalBus.emit("event", {
|
|
||||||
directory: Instance.directory,
|
|
||||||
project: Instance.project.id,
|
|
||||||
workspace: WorkspaceContext.workspaceID,
|
|
||||||
payload: {
|
|
||||||
type: "sync",
|
|
||||||
syncEvent: {
|
|
||||||
type: versionedType(def.type, def.version),
|
|
||||||
...event,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function replay(event: SerializedEvent, options?: { publish: boolean }) {
|
|
||||||
const def = registry.get(event.type)
|
|
||||||
if (!def) {
|
|
||||||
throw new Error(`Unknown event type: ${event.type}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const row = Database.use((db) =>
|
|
||||||
db
|
|
||||||
.select({ seq: EventSequenceTable.seq })
|
|
||||||
.from(EventSequenceTable)
|
|
||||||
.where(eq(EventSequenceTable.aggregate_id, event.aggregateID))
|
|
||||||
.get(),
|
|
||||||
)
|
|
||||||
|
|
||||||
const latest = row?.seq ?? -1
|
|
||||||
if (event.seq <= latest) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const expected = latest + 1
|
|
||||||
if (event.seq !== expected) {
|
|
||||||
throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
process(def, event, { publish: !!options?.publish })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) {
|
|
||||||
const source = events[0]?.aggregateID
|
|
||||||
if (!source) return
|
|
||||||
if (events.some((item) => item.aggregateID !== source)) {
|
|
||||||
throw new Error("Replay events must belong to the same session")
|
|
||||||
}
|
|
||||||
const start = events[0].seq
|
|
||||||
for (const [i, item] of events.entries()) {
|
|
||||||
const seq = start + i
|
|
||||||
if (item.seq !== seq) {
|
|
||||||
throw new Error(`Replay sequence mismatch at index ${i}: expected ${seq}, got ${item.seq}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const item of events) {
|
|
||||||
replay(item, options)
|
|
||||||
}
|
|
||||||
return source
|
|
||||||
}
|
|
||||||
|
|
||||||
export function run<Def extends Definition>(def: Def, data: Event<Def>["data"], options?: { publish?: boolean }) {
|
|
||||||
const agg = (data as Record<string, string>)[def.aggregate]
|
|
||||||
// This should never happen: we've enforced it via typescript in
|
|
||||||
// the definition
|
|
||||||
if (agg == null) {
|
|
||||||
throw new Error(`SyncEvent.run: "${def.aggregate}" required but not found: ${JSON.stringify(data)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (def.version !== versions.get(def.type)) {
|
|
||||||
throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { publish = true } = options || {}
|
|
||||||
|
|
||||||
// Note that this is an "immediate" transaction which is critical.
|
|
||||||
// We need to make sure we can safely read and write with nothing
|
|
||||||
// else changing the data from under us
|
|
||||||
Database.transaction(
|
|
||||||
(tx) => {
|
|
||||||
const id = EventID.ascending()
|
|
||||||
const row = tx
|
|
||||||
.select({ seq: EventSequenceTable.seq })
|
|
||||||
.from(EventSequenceTable)
|
|
||||||
.where(eq(EventSequenceTable.aggregate_id, agg))
|
|
||||||
.get()
|
|
||||||
const seq = row?.seq != null ? row.seq + 1 : 0
|
|
||||||
|
|
||||||
const event = { id, seq, aggregateID: agg, data }
|
|
||||||
process(def, event, { publish })
|
|
||||||
},
|
|
||||||
{
|
|
||||||
behavior: "immediate",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function remove(aggregateID: string) {
|
|
||||||
Database.transaction((tx) => {
|
|
||||||
tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
|
|
||||||
tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function payloads() {
|
|
||||||
return registry
|
|
||||||
.entries()
|
|
||||||
.map(([type, def]) => {
|
|
||||||
return z
|
|
||||||
.object({
|
|
||||||
type: z.literal("sync"),
|
|
||||||
name: z.literal(type),
|
|
||||||
id: z.string(),
|
|
||||||
seq: z.number(),
|
|
||||||
aggregateID: z.literal(def.aggregate),
|
|
||||||
data: def.schema,
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: `SyncEvent.${def.type}`,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.toArray()
|
|
||||||
}
|
|
||||||
@@ -1,33 +1,10 @@
|
|||||||
import yargs from "yargs"
|
|
||||||
import { TuiThreadCommand } from "./cli/cmd/tui/thread"
|
import { TuiThreadCommand } from "./cli/cmd/tui/thread"
|
||||||
import { InstallationVersion } from "./installation/version"
|
|
||||||
import { hideBin } from "yargs/helpers"
|
|
||||||
import { Log } from "./node"
|
import { Log } from "./node"
|
||||||
|
|
||||||
Log.init({
|
Log.init({
|
||||||
print: false,
|
print: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const cli = yargs(hideBin(process.argv))
|
console.log(TuiThreadCommand)
|
||||||
.parserConfiguration({ "populate--": true })
|
|
||||||
.scriptName("opencode")
|
console.log(performance.now())
|
||||||
.wrap(100)
|
|
||||||
.help("help", "show help")
|
|
||||||
.alias("help", "h")
|
|
||||||
.version("version", "show version number", InstallationVersion)
|
|
||||||
.alias("version", "v")
|
|
||||||
.option("print-logs", {
|
|
||||||
describe: "print logs to stderr",
|
|
||||||
type: "boolean",
|
|
||||||
})
|
|
||||||
.option("log-level", {
|
|
||||||
describe: "log level",
|
|
||||||
type: "string",
|
|
||||||
choices: ["DEBUG", "INFO", "WARN", "ERROR"],
|
|
||||||
})
|
|
||||||
.option("pure", {
|
|
||||||
describe: "run without external plugins",
|
|
||||||
type: "boolean",
|
|
||||||
})
|
|
||||||
.command(TuiThreadCommand)
|
|
||||||
.parse()
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export type Context<M extends Metadata = Metadata> = {
|
|||||||
agent: string
|
agent: string
|
||||||
abort: AbortSignal
|
abort: AbortSignal
|
||||||
callID?: string
|
callID?: string
|
||||||
extra?: { [key: string]: any }
|
extra?: { [key: string]: unknown }
|
||||||
messages: MessageV2.WithParts[]
|
messages: MessageV2.WithParts[]
|
||||||
metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>
|
metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>
|
||||||
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
|
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export async function readText(p: string): Promise<string> {
|
|||||||
return readFile(p, "utf-8")
|
return readFile(p, "utf-8")
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readJson<T = any>(p: string): Promise<T> {
|
export async function readJson<T = unknown>(p: string): Promise<T> {
|
||||||
return JSON.parse(await readFile(p, "utf-8"))
|
return JSON.parse(await readFile(p, "utf-8"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,597 @@
|
|||||||
export * as Worktree from "./worktree"
|
import z from "zod"
|
||||||
|
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { Instance } from "../project/instance"
|
||||||
|
import { InstanceBootstrap } from "../project/bootstrap"
|
||||||
|
import { Project } from "../project"
|
||||||
|
import { Database, eq } from "../storage"
|
||||||
|
import { ProjectTable } from "../project/project.sql"
|
||||||
|
import type { ProjectID } from "../project/schema"
|
||||||
|
import { Log } from "../util"
|
||||||
|
import { Slug } from "@opencode-ai/shared/util/slug"
|
||||||
|
import { errorMessage } from "../util/error"
|
||||||
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { GlobalBus } from "@/bus/global"
|
||||||
|
import { Git } from "@/git"
|
||||||
|
import { Effect, Layer, Path, Scope, Context, Stream } from "effect"
|
||||||
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
|
import { NodePath } from "@effect/platform-node"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
|
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
|
||||||
|
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||||
|
import { InstanceState } from "@/effect"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "worktree" })
|
||||||
|
|
||||||
|
export const Event = {
|
||||||
|
Ready: BusEvent.define(
|
||||||
|
"worktree.ready",
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
branch: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Failed: BusEvent.define(
|
||||||
|
"worktree.failed",
|
||||||
|
z.object({
|
||||||
|
message: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Info = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
branch: z.string(),
|
||||||
|
directory: z.string(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "Worktree",
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
|
export const CreateInput = z
|
||||||
|
.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
startCommand: z.string().optional().describe("Additional startup script to run after the project's start command"),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "WorktreeCreateInput",
|
||||||
|
})
|
||||||
|
|
||||||
|
export type CreateInput = z.infer<typeof CreateInput>
|
||||||
|
|
||||||
|
export const RemoveInput = z
|
||||||
|
.object({
|
||||||
|
directory: z.string(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "WorktreeRemoveInput",
|
||||||
|
})
|
||||||
|
|
||||||
|
export type RemoveInput = z.infer<typeof RemoveInput>
|
||||||
|
|
||||||
|
export const ResetInput = z
|
||||||
|
.object({
|
||||||
|
directory: z.string(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "WorktreeResetInput",
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ResetInput = z.infer<typeof ResetInput>
|
||||||
|
|
||||||
|
export const NotGitError = NamedError.create(
|
||||||
|
"WorktreeNotGitError",
|
||||||
|
z.object({
|
||||||
|
message: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const NameGenerationFailedError = NamedError.create(
|
||||||
|
"WorktreeNameGenerationFailedError",
|
||||||
|
z.object({
|
||||||
|
message: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const CreateFailedError = NamedError.create(
|
||||||
|
"WorktreeCreateFailedError",
|
||||||
|
z.object({
|
||||||
|
message: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const StartCommandFailedError = NamedError.create(
|
||||||
|
"WorktreeStartCommandFailedError",
|
||||||
|
z.object({
|
||||||
|
message: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const RemoveFailedError = NamedError.create(
|
||||||
|
"WorktreeRemoveFailedError",
|
||||||
|
z.object({
|
||||||
|
message: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const ResetFailedError = NamedError.create(
|
||||||
|
"WorktreeResetFailedError",
|
||||||
|
z.object({
|
||||||
|
message: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function slugify(input: string) {
|
||||||
|
return input
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+/, "")
|
||||||
|
.replace(/-+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function failedRemoves(...chunks: string[]) {
|
||||||
|
return chunks.filter(Boolean).flatMap((chunk) =>
|
||||||
|
chunk
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.flatMap((line) => {
|
||||||
|
const match = line.match(/^warning:\s+failed to remove\s+(.+):\s+/i)
|
||||||
|
if (!match) return []
|
||||||
|
const value = match[1]?.trim().replace(/^['"]|['"]$/g, "")
|
||||||
|
if (!value) return []
|
||||||
|
return [value]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Effect service
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly makeWorktreeInfo: (name?: string) => Effect.Effect<Info>
|
||||||
|
readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
|
||||||
|
readonly create: (input?: CreateInput) => Effect.Effect<Info>
|
||||||
|
readonly remove: (input: RemoveInput) => Effect.Effect<boolean>
|
||||||
|
readonly reset: (input: ResetInput) => Effect.Effect<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Worktree") {}
|
||||||
|
|
||||||
|
type GitResult = { code: number; text: string; stderr: string }
|
||||||
|
|
||||||
|
export const layer: Layer.Layer<
|
||||||
|
Service,
|
||||||
|
never,
|
||||||
|
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Git.Service | Project.Service
|
||||||
|
> = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const scope = yield* Scope.Scope
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const pathSvc = yield* Path.Path
|
||||||
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
|
const gitSvc = yield* Git.Service
|
||||||
|
const project = yield* Project.Service
|
||||||
|
|
||||||
|
const git = Effect.fnUntraced(
|
||||||
|
function* (args: string[], opts?: { cwd?: string }) {
|
||||||
|
const handle = yield* spawner.spawn(
|
||||||
|
ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
|
||||||
|
)
|
||||||
|
const [text, stderr] = yield* Effect.all(
|
||||||
|
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||||
|
{ concurrency: 2 },
|
||||||
|
)
|
||||||
|
const code = yield* handle.exitCode
|
||||||
|
return { code, text, stderr } satisfies GitResult
|
||||||
|
},
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.catch((e) =>
|
||||||
|
Effect.succeed({ code: 1, text: "", stderr: e instanceof Error ? e.message : String(e) } satisfies GitResult),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const MAX_NAME_ATTEMPTS = 26
|
||||||
|
const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) {
|
||||||
|
const ctx = yield* InstanceState.context
|
||||||
|
for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) {
|
||||||
|
const name = base ? (attempt === 0 ? base : `${base}-${Slug.create()}`) : Slug.create()
|
||||||
|
const branch = `opencode/${name}`
|
||||||
|
const directory = pathSvc.join(root, name)
|
||||||
|
|
||||||
|
if (yield* fs.exists(directory).pipe(Effect.orDie)) continue
|
||||||
|
|
||||||
|
const ref = `refs/heads/${branch}`
|
||||||
|
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree })
|
||||||
|
if (branchCheck.code === 0) continue
|
||||||
|
|
||||||
|
return Info.parse({ name, branch, directory })
|
||||||
|
}
|
||||||
|
throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
|
||||||
|
})
|
||||||
|
|
||||||
|
const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (name?: string) {
|
||||||
|
const ctx = yield* InstanceState.context
|
||||||
|
if (ctx.project.vcs !== "git") {
|
||||||
|
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = pathSvc.join(Global.Path.data, "worktree", ctx.project.id)
|
||||||
|
yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
|
||||||
|
|
||||||
|
const base = name ? slugify(name) : ""
|
||||||
|
return yield* candidate(root, base || undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
const setup = Effect.fnUntraced(function* (info: Info) {
|
||||||
|
const ctx = yield* InstanceState.context
|
||||||
|
const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
|
||||||
|
cwd: ctx.worktree,
|
||||||
|
})
|
||||||
|
if (created.code !== 0) {
|
||||||
|
throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* project.addSandbox(ctx.project.id, info.directory).pipe(Effect.catch(() => Effect.void))
|
||||||
|
})
|
||||||
|
|
||||||
|
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
|
||||||
|
const ctx = yield* InstanceState.context
|
||||||
|
const workspaceID = yield* InstanceState.workspaceID
|
||||||
|
const projectID = ctx.project.id
|
||||||
|
const extra = startCommand?.trim()
|
||||||
|
|
||||||
|
const populated = yield* git(["reset", "--hard"], { cwd: info.directory })
|
||||||
|
if (populated.code !== 0) {
|
||||||
|
const message = populated.stderr || populated.text || "Failed to populate worktree"
|
||||||
|
log.error("worktree checkout failed", { directory: info.directory, message })
|
||||||
|
GlobalBus.emit("event", {
|
||||||
|
directory: info.directory,
|
||||||
|
project: ctx.project.id,
|
||||||
|
workspace: workspaceID,
|
||||||
|
payload: { type: Event.Failed.type, properties: { message } },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const booted = yield* Effect.promise(() =>
|
||||||
|
Instance.provide({
|
||||||
|
directory: info.directory,
|
||||||
|
init: () => BootstrapRuntime.runPromise(InstanceBootstrap),
|
||||||
|
fn: () => undefined,
|
||||||
|
})
|
||||||
|
.then(() => true)
|
||||||
|
.catch((error) => {
|
||||||
|
const message = errorMessage(error)
|
||||||
|
log.error("worktree bootstrap failed", { directory: info.directory, message })
|
||||||
|
GlobalBus.emit("event", {
|
||||||
|
directory: info.directory,
|
||||||
|
project: ctx.project.id,
|
||||||
|
workspace: workspaceID,
|
||||||
|
payload: { type: Event.Failed.type, properties: { message } },
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (!booted) return
|
||||||
|
|
||||||
|
GlobalBus.emit("event", {
|
||||||
|
directory: info.directory,
|
||||||
|
project: ctx.project.id,
|
||||||
|
workspace: workspaceID,
|
||||||
|
payload: {
|
||||||
|
type: Event.Ready.type,
|
||||||
|
properties: { name: info.name, branch: info.branch },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* runStartScripts(info.directory, { projectID, extra })
|
||||||
|
})
|
||||||
|
|
||||||
|
const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) {
|
||||||
|
yield* setup(info)
|
||||||
|
yield* boot(info, startCommand)
|
||||||
|
})
|
||||||
|
|
||||||
|
const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) {
|
||||||
|
const info = yield* makeWorktreeInfo(input?.name)
|
||||||
|
yield* setup(info)
|
||||||
|
yield* boot(info, input?.startCommand).pipe(
|
||||||
|
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))),
|
||||||
|
Effect.forkIn(scope),
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
})
|
||||||
|
|
||||||
|
const canonical = Effect.fnUntraced(function* (input: string) {
|
||||||
|
const abs = pathSvc.resolve(input)
|
||||||
|
const real = yield* fs.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs)))
|
||||||
|
const normalized = pathSvc.normalize(real)
|
||||||
|
return process.platform === "win32" ? normalized.toLowerCase() : normalized
|
||||||
|
})
|
||||||
|
|
||||||
|
function parseWorktreeList(text: string) {
|
||||||
|
return text
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
|
||||||
|
if (!line) return acc
|
||||||
|
if (line.startsWith("worktree ")) {
|
||||||
|
acc.push({ path: line.slice("worktree ".length).trim() })
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
const current = acc[acc.length - 1]
|
||||||
|
if (!current) return acc
|
||||||
|
if (line.startsWith("branch ")) {
|
||||||
|
current.branch = line.slice("branch ".length).trim()
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
const locateWorktree = Effect.fnUntraced(function* (
|
||||||
|
entries: { path?: string; branch?: string }[],
|
||||||
|
directory: string,
|
||||||
|
) {
|
||||||
|
for (const item of entries) {
|
||||||
|
if (!item.path) continue
|
||||||
|
const key = yield* canonical(item.path)
|
||||||
|
if (key === directory) return item
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
function stopFsmonitor(target: string) {
|
||||||
|
return fs.exists(target).pipe(
|
||||||
|
Effect.orDie,
|
||||||
|
Effect.flatMap((exists) => (exists ? git(["fsmonitor--daemon", "stop"], { cwd: target }) : Effect.void)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanDirectory(target: string) {
|
||||||
|
return Effect.promise(() =>
|
||||||
|
import("fs/promises")
|
||||||
|
.then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }))
|
||||||
|
.catch((error) => {
|
||||||
|
const message = errorMessage(error)
|
||||||
|
throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
|
||||||
|
if (Instance.project.vcs !== "git") {
|
||||||
|
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = yield* canonical(input.directory)
|
||||||
|
|
||||||
|
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||||
|
if (list.code !== 0) {
|
||||||
|
throw new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = parseWorktreeList(list.text)
|
||||||
|
const entry = yield* locateWorktree(entries, directory)
|
||||||
|
|
||||||
|
if (!entry?.path) {
|
||||||
|
const directoryExists = yield* fs.exists(directory).pipe(Effect.orDie)
|
||||||
|
if (directoryExists) {
|
||||||
|
yield* stopFsmonitor(directory)
|
||||||
|
yield* cleanDirectory(directory)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* stopFsmonitor(entry.path)
|
||||||
|
const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: Instance.worktree })
|
||||||
|
if (removed.code !== 0) {
|
||||||
|
const next = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||||
|
if (next.code !== 0) {
|
||||||
|
throw new RemoveFailedError({
|
||||||
|
message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const stale = yield* locateWorktree(parseWorktreeList(next.text), directory)
|
||||||
|
if (stale?.path) {
|
||||||
|
throw new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* cleanDirectory(entry.path)
|
||||||
|
|
||||||
|
const branch = entry.branch?.replace(/^refs\/heads\//, "")
|
||||||
|
if (branch) {
|
||||||
|
const deleted = yield* git(["branch", "-D", branch], { cwd: Instance.worktree })
|
||||||
|
if (deleted.code !== 0) {
|
||||||
|
throw new RemoveFailedError({
|
||||||
|
message: deleted.stderr || deleted.text || "Failed to delete worktree branch",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const gitExpect = Effect.fnUntraced(function* (
|
||||||
|
args: string[],
|
||||||
|
opts: { cwd: string },
|
||||||
|
error: (r: GitResult) => Error,
|
||||||
|
) {
|
||||||
|
const result = yield* git(args, opts)
|
||||||
|
if (result.code !== 0) throw error(result)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const runStartCommand = Effect.fnUntraced(
|
||||||
|
function* (directory: string, cmd: string) {
|
||||||
|
const [shell, args] = process.platform === "win32" ? ["cmd", ["/c", cmd]] : ["bash", ["-lc", cmd]]
|
||||||
|
const handle = yield* spawner.spawn(
|
||||||
|
ChildProcess.make(shell, args, { cwd: directory, extendEnv: true, stdin: "ignore" }),
|
||||||
|
)
|
||||||
|
// Drain stdout, capture stderr for error reporting
|
||||||
|
const [, stderr] = yield* Effect.all(
|
||||||
|
[Stream.runDrain(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||||
|
{ concurrency: 2 },
|
||||||
|
).pipe(Effect.orDie)
|
||||||
|
const code = yield* handle.exitCode
|
||||||
|
return { code, stderr }
|
||||||
|
},
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.catch(() => Effect.succeed({ code: 1, stderr: "" })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const runStartScript = Effect.fnUntraced(function* (directory: string, cmd: string, kind: string) {
|
||||||
|
const text = cmd.trim()
|
||||||
|
if (!text) return true
|
||||||
|
const result = yield* runStartCommand(directory, text)
|
||||||
|
if (result.code === 0) return true
|
||||||
|
log.error("worktree start command failed", { kind, directory, message: result.stderr })
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
const runStartScripts = Effect.fnUntraced(function* (
|
||||||
|
directory: string,
|
||||||
|
input: { projectID: ProjectID; extra?: string },
|
||||||
|
) {
|
||||||
|
const row = yield* Effect.sync(() =>
|
||||||
|
Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get()),
|
||||||
|
)
|
||||||
|
const project = row ? Project.fromRow(row) : undefined
|
||||||
|
const startup = project?.commands?.start?.trim() ?? ""
|
||||||
|
const ok = yield* runStartScript(directory, startup, "project")
|
||||||
|
if (!ok) return false
|
||||||
|
yield* runStartScript(directory, input.extra ?? "", "worktree")
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const prune = Effect.fnUntraced(function* (root: string, entries: string[]) {
|
||||||
|
const base = yield* canonical(root)
|
||||||
|
yield* Effect.forEach(
|
||||||
|
entries,
|
||||||
|
(entry) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const target = yield* canonical(pathSvc.resolve(root, entry))
|
||||||
|
if (target === base) return
|
||||||
|
if (!target.startsWith(`${base}${pathSvc.sep}`)) return
|
||||||
|
yield* fs.remove(target, { recursive: true }).pipe(Effect.ignore)
|
||||||
|
}),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const sweep = Effect.fnUntraced(function* (root: string) {
|
||||||
|
const first = yield* git(["clean", "-ffdx"], { cwd: root })
|
||||||
|
if (first.code === 0) return first
|
||||||
|
|
||||||
|
const entries = failedRemoves(first.stderr, first.text)
|
||||||
|
if (!entries.length) return first
|
||||||
|
|
||||||
|
yield* prune(root, entries)
|
||||||
|
return yield* git(["clean", "-ffdx"], { cwd: root })
|
||||||
|
})
|
||||||
|
|
||||||
|
const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) {
|
||||||
|
if (Instance.project.vcs !== "git") {
|
||||||
|
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = yield* canonical(input.directory)
|
||||||
|
const primary = yield* canonical(Instance.worktree)
|
||||||
|
if (directory === primary) {
|
||||||
|
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||||
|
if (list.code !== 0) {
|
||||||
|
throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = yield* locateWorktree(parseWorktreeList(list.text), directory)
|
||||||
|
if (!entry?.path) {
|
||||||
|
throw new ResetFailedError({ message: "Worktree not found" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const worktreePath = entry.path
|
||||||
|
|
||||||
|
const base = yield* gitSvc.defaultBranch(Instance.worktree)
|
||||||
|
if (!base) {
|
||||||
|
throw new ResetFailedError({ message: "Default branch not found" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const sep = base.ref.indexOf("/")
|
||||||
|
if (base.ref !== base.name && sep > 0) {
|
||||||
|
const remote = base.ref.slice(0, sep)
|
||||||
|
const branch = base.ref.slice(sep + 1)
|
||||||
|
yield* gitExpect(
|
||||||
|
["fetch", remote, branch],
|
||||||
|
{ cwd: Instance.worktree },
|
||||||
|
(r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${base.ref}` }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* gitExpect(
|
||||||
|
["reset", "--hard", base.ref],
|
||||||
|
{ cwd: worktreePath },
|
||||||
|
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset worktree to target" }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const cleanResult = yield* sweep(worktreePath)
|
||||||
|
if (cleanResult.code !== 0) {
|
||||||
|
throw new ResetFailedError({ message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree" })
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* gitExpect(
|
||||||
|
["submodule", "update", "--init", "--recursive", "--force"],
|
||||||
|
{ cwd: worktreePath },
|
||||||
|
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to update submodules" }),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* gitExpect(
|
||||||
|
["submodule", "foreach", "--recursive", "git", "reset", "--hard"],
|
||||||
|
{ cwd: worktreePath },
|
||||||
|
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset submodules" }),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* gitExpect(
|
||||||
|
["submodule", "foreach", "--recursive", "git", "clean", "-fdx"],
|
||||||
|
{ cwd: worktreePath },
|
||||||
|
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to clean submodules" }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const status = yield* git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
|
||||||
|
if (status.code !== 0) {
|
||||||
|
throw new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.text.trim()) {
|
||||||
|
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* runStartScripts(worktreePath, { projectID: Instance.project.id }).pipe(
|
||||||
|
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree start task failed", { cause }))),
|
||||||
|
Effect.forkIn(scope),
|
||||||
|
)
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ makeWorktreeInfo, createFromInfo, create, remove, reset })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(Git.defaultLayer),
|
||||||
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
|
Layer.provide(Project.defaultLayer),
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
Layer.provide(NodePath.layer),
|
||||||
|
)
|
||||||
|
|
||||||
|
export * as Worktree from "."
|
||||||
|
|||||||
@@ -1,595 +0,0 @@
|
|||||||
import z from "zod"
|
|
||||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
|
||||||
import { Global } from "../global"
|
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { InstanceBootstrap } from "../project/bootstrap"
|
|
||||||
import { Project } from "../project"
|
|
||||||
import { Database, eq } from "../storage"
|
|
||||||
import { ProjectTable } from "../project/project.sql"
|
|
||||||
import type { ProjectID } from "../project/schema"
|
|
||||||
import { Log } from "../util"
|
|
||||||
import { Slug } from "@opencode-ai/shared/util/slug"
|
|
||||||
import { errorMessage } from "../util/error"
|
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
|
||||||
import { GlobalBus } from "@/bus/global"
|
|
||||||
import { Git } from "@/git"
|
|
||||||
import { Effect, Layer, Path, Scope, Context, Stream } from "effect"
|
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
|
||||||
import { NodePath } from "@effect/platform-node"
|
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
|
||||||
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
|
|
||||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
|
||||||
import { InstanceState } from "@/effect"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "worktree" })
|
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
Ready: BusEvent.define(
|
|
||||||
"worktree.ready",
|
|
||||||
z.object({
|
|
||||||
name: z.string(),
|
|
||||||
branch: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Failed: BusEvent.define(
|
|
||||||
"worktree.failed",
|
|
||||||
z.object({
|
|
||||||
message: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Info = z
|
|
||||||
.object({
|
|
||||||
name: z.string(),
|
|
||||||
branch: z.string(),
|
|
||||||
directory: z.string(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "Worktree",
|
|
||||||
})
|
|
||||||
|
|
||||||
export type Info = z.infer<typeof Info>
|
|
||||||
|
|
||||||
export const CreateInput = z
|
|
||||||
.object({
|
|
||||||
name: z.string().optional(),
|
|
||||||
startCommand: z.string().optional().describe("Additional startup script to run after the project's start command"),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "WorktreeCreateInput",
|
|
||||||
})
|
|
||||||
|
|
||||||
export type CreateInput = z.infer<typeof CreateInput>
|
|
||||||
|
|
||||||
export const RemoveInput = z
|
|
||||||
.object({
|
|
||||||
directory: z.string(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "WorktreeRemoveInput",
|
|
||||||
})
|
|
||||||
|
|
||||||
export type RemoveInput = z.infer<typeof RemoveInput>
|
|
||||||
|
|
||||||
export const ResetInput = z
|
|
||||||
.object({
|
|
||||||
directory: z.string(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "WorktreeResetInput",
|
|
||||||
})
|
|
||||||
|
|
||||||
export type ResetInput = z.infer<typeof ResetInput>
|
|
||||||
|
|
||||||
export const NotGitError = NamedError.create(
|
|
||||||
"WorktreeNotGitError",
|
|
||||||
z.object({
|
|
||||||
message: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const NameGenerationFailedError = NamedError.create(
|
|
||||||
"WorktreeNameGenerationFailedError",
|
|
||||||
z.object({
|
|
||||||
message: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const CreateFailedError = NamedError.create(
|
|
||||||
"WorktreeCreateFailedError",
|
|
||||||
z.object({
|
|
||||||
message: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const StartCommandFailedError = NamedError.create(
|
|
||||||
"WorktreeStartCommandFailedError",
|
|
||||||
z.object({
|
|
||||||
message: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const RemoveFailedError = NamedError.create(
|
|
||||||
"WorktreeRemoveFailedError",
|
|
||||||
z.object({
|
|
||||||
message: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const ResetFailedError = NamedError.create(
|
|
||||||
"WorktreeResetFailedError",
|
|
||||||
z.object({
|
|
||||||
message: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
function slugify(input: string) {
|
|
||||||
return input
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-+/, "")
|
|
||||||
.replace(/-+$/, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function failedRemoves(...chunks: string[]) {
|
|
||||||
return chunks.filter(Boolean).flatMap((chunk) =>
|
|
||||||
chunk
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.flatMap((line) => {
|
|
||||||
const match = line.match(/^warning:\s+failed to remove\s+(.+):\s+/i)
|
|
||||||
if (!match) return []
|
|
||||||
const value = match[1]?.trim().replace(/^['"]|['"]$/g, "")
|
|
||||||
if (!value) return []
|
|
||||||
return [value]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Effect service
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly makeWorktreeInfo: (name?: string) => Effect.Effect<Info>
|
|
||||||
readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
|
|
||||||
readonly create: (input?: CreateInput) => Effect.Effect<Info>
|
|
||||||
readonly remove: (input: RemoveInput) => Effect.Effect<boolean>
|
|
||||||
readonly reset: (input: ResetInput) => Effect.Effect<boolean>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Worktree") {}
|
|
||||||
|
|
||||||
type GitResult = { code: number; text: string; stderr: string }
|
|
||||||
|
|
||||||
export const layer: Layer.Layer<
|
|
||||||
Service,
|
|
||||||
never,
|
|
||||||
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Git.Service | Project.Service
|
|
||||||
> = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const scope = yield* Scope.Scope
|
|
||||||
const fs = yield* AppFileSystem.Service
|
|
||||||
const pathSvc = yield* Path.Path
|
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
||||||
const gitSvc = yield* Git.Service
|
|
||||||
const project = yield* Project.Service
|
|
||||||
|
|
||||||
const git = Effect.fnUntraced(
|
|
||||||
function* (args: string[], opts?: { cwd?: string }) {
|
|
||||||
const handle = yield* spawner.spawn(
|
|
||||||
ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
|
|
||||||
)
|
|
||||||
const [text, stderr] = yield* Effect.all(
|
|
||||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
const code = yield* handle.exitCode
|
|
||||||
return { code, text, stderr } satisfies GitResult
|
|
||||||
},
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.catch((e) =>
|
|
||||||
Effect.succeed({ code: 1, text: "", stderr: e instanceof Error ? e.message : String(e) } satisfies GitResult),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const MAX_NAME_ATTEMPTS = 26
|
|
||||||
const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) {
|
|
||||||
const ctx = yield* InstanceState.context
|
|
||||||
for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) {
|
|
||||||
const name = base ? (attempt === 0 ? base : `${base}-${Slug.create()}`) : Slug.create()
|
|
||||||
const branch = `opencode/${name}`
|
|
||||||
const directory = pathSvc.join(root, name)
|
|
||||||
|
|
||||||
if (yield* fs.exists(directory).pipe(Effect.orDie)) continue
|
|
||||||
|
|
||||||
const ref = `refs/heads/${branch}`
|
|
||||||
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree })
|
|
||||||
if (branchCheck.code === 0) continue
|
|
||||||
|
|
||||||
return Info.parse({ name, branch, directory })
|
|
||||||
}
|
|
||||||
throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
|
|
||||||
})
|
|
||||||
|
|
||||||
const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (name?: string) {
|
|
||||||
const ctx = yield* InstanceState.context
|
|
||||||
if (ctx.project.vcs !== "git") {
|
|
||||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = pathSvc.join(Global.Path.data, "worktree", ctx.project.id)
|
|
||||||
yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
|
|
||||||
|
|
||||||
const base = name ? slugify(name) : ""
|
|
||||||
return yield* candidate(root, base || undefined)
|
|
||||||
})
|
|
||||||
|
|
||||||
const setup = Effect.fnUntraced(function* (info: Info) {
|
|
||||||
const ctx = yield* InstanceState.context
|
|
||||||
const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
|
|
||||||
cwd: ctx.worktree,
|
|
||||||
})
|
|
||||||
if (created.code !== 0) {
|
|
||||||
throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* project.addSandbox(ctx.project.id, info.directory).pipe(Effect.catch(() => Effect.void))
|
|
||||||
})
|
|
||||||
|
|
||||||
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
|
|
||||||
const ctx = yield* InstanceState.context
|
|
||||||
const workspaceID = yield* InstanceState.workspaceID
|
|
||||||
const projectID = ctx.project.id
|
|
||||||
const extra = startCommand?.trim()
|
|
||||||
|
|
||||||
const populated = yield* git(["reset", "--hard"], { cwd: info.directory })
|
|
||||||
if (populated.code !== 0) {
|
|
||||||
const message = populated.stderr || populated.text || "Failed to populate worktree"
|
|
||||||
log.error("worktree checkout failed", { directory: info.directory, message })
|
|
||||||
GlobalBus.emit("event", {
|
|
||||||
directory: info.directory,
|
|
||||||
project: ctx.project.id,
|
|
||||||
workspace: workspaceID,
|
|
||||||
payload: { type: Event.Failed.type, properties: { message } },
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const booted = yield* Effect.promise(() =>
|
|
||||||
Instance.provide({
|
|
||||||
directory: info.directory,
|
|
||||||
init: () => BootstrapRuntime.runPromise(InstanceBootstrap),
|
|
||||||
fn: () => undefined,
|
|
||||||
})
|
|
||||||
.then(() => true)
|
|
||||||
.catch((error) => {
|
|
||||||
const message = errorMessage(error)
|
|
||||||
log.error("worktree bootstrap failed", { directory: info.directory, message })
|
|
||||||
GlobalBus.emit("event", {
|
|
||||||
directory: info.directory,
|
|
||||||
project: ctx.project.id,
|
|
||||||
workspace: workspaceID,
|
|
||||||
payload: { type: Event.Failed.type, properties: { message } },
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if (!booted) return
|
|
||||||
|
|
||||||
GlobalBus.emit("event", {
|
|
||||||
directory: info.directory,
|
|
||||||
project: ctx.project.id,
|
|
||||||
workspace: workspaceID,
|
|
||||||
payload: {
|
|
||||||
type: Event.Ready.type,
|
|
||||||
properties: { name: info.name, branch: info.branch },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* runStartScripts(info.directory, { projectID, extra })
|
|
||||||
})
|
|
||||||
|
|
||||||
const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) {
|
|
||||||
yield* setup(info)
|
|
||||||
yield* boot(info, startCommand)
|
|
||||||
})
|
|
||||||
|
|
||||||
const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) {
|
|
||||||
const info = yield* makeWorktreeInfo(input?.name)
|
|
||||||
yield* setup(info)
|
|
||||||
yield* boot(info, input?.startCommand).pipe(
|
|
||||||
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))),
|
|
||||||
Effect.forkIn(scope),
|
|
||||||
)
|
|
||||||
return info
|
|
||||||
})
|
|
||||||
|
|
||||||
const canonical = Effect.fnUntraced(function* (input: string) {
|
|
||||||
const abs = pathSvc.resolve(input)
|
|
||||||
const real = yield* fs.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs)))
|
|
||||||
const normalized = pathSvc.normalize(real)
|
|
||||||
return process.platform === "win32" ? normalized.toLowerCase() : normalized
|
|
||||||
})
|
|
||||||
|
|
||||||
function parseWorktreeList(text: string) {
|
|
||||||
return text
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
|
|
||||||
if (!line) return acc
|
|
||||||
if (line.startsWith("worktree ")) {
|
|
||||||
acc.push({ path: line.slice("worktree ".length).trim() })
|
|
||||||
return acc
|
|
||||||
}
|
|
||||||
const current = acc[acc.length - 1]
|
|
||||||
if (!current) return acc
|
|
||||||
if (line.startsWith("branch ")) {
|
|
||||||
current.branch = line.slice("branch ".length).trim()
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
}, [])
|
|
||||||
}
|
|
||||||
|
|
||||||
const locateWorktree = Effect.fnUntraced(function* (
|
|
||||||
entries: { path?: string; branch?: string }[],
|
|
||||||
directory: string,
|
|
||||||
) {
|
|
||||||
for (const item of entries) {
|
|
||||||
if (!item.path) continue
|
|
||||||
const key = yield* canonical(item.path)
|
|
||||||
if (key === directory) return item
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
function stopFsmonitor(target: string) {
|
|
||||||
return fs.exists(target).pipe(
|
|
||||||
Effect.orDie,
|
|
||||||
Effect.flatMap((exists) => (exists ? git(["fsmonitor--daemon", "stop"], { cwd: target }) : Effect.void)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanDirectory(target: string) {
|
|
||||||
return Effect.promise(() =>
|
|
||||||
import("fs/promises")
|
|
||||||
.then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }))
|
|
||||||
.catch((error) => {
|
|
||||||
const message = errorMessage(error)
|
|
||||||
throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
|
|
||||||
if (Instance.project.vcs !== "git") {
|
|
||||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const directory = yield* canonical(input.directory)
|
|
||||||
|
|
||||||
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
|
||||||
if (list.code !== 0) {
|
|
||||||
throw new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = parseWorktreeList(list.text)
|
|
||||||
const entry = yield* locateWorktree(entries, directory)
|
|
||||||
|
|
||||||
if (!entry?.path) {
|
|
||||||
const directoryExists = yield* fs.exists(directory).pipe(Effect.orDie)
|
|
||||||
if (directoryExists) {
|
|
||||||
yield* stopFsmonitor(directory)
|
|
||||||
yield* cleanDirectory(directory)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* stopFsmonitor(entry.path)
|
|
||||||
const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: Instance.worktree })
|
|
||||||
if (removed.code !== 0) {
|
|
||||||
const next = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
|
||||||
if (next.code !== 0) {
|
|
||||||
throw new RemoveFailedError({
|
|
||||||
message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const stale = yield* locateWorktree(parseWorktreeList(next.text), directory)
|
|
||||||
if (stale?.path) {
|
|
||||||
throw new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* cleanDirectory(entry.path)
|
|
||||||
|
|
||||||
const branch = entry.branch?.replace(/^refs\/heads\//, "")
|
|
||||||
if (branch) {
|
|
||||||
const deleted = yield* git(["branch", "-D", branch], { cwd: Instance.worktree })
|
|
||||||
if (deleted.code !== 0) {
|
|
||||||
throw new RemoveFailedError({
|
|
||||||
message: deleted.stderr || deleted.text || "Failed to delete worktree branch",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
const gitExpect = Effect.fnUntraced(function* (
|
|
||||||
args: string[],
|
|
||||||
opts: { cwd: string },
|
|
||||||
error: (r: GitResult) => Error,
|
|
||||||
) {
|
|
||||||
const result = yield* git(args, opts)
|
|
||||||
if (result.code !== 0) throw error(result)
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const runStartCommand = Effect.fnUntraced(
|
|
||||||
function* (directory: string, cmd: string) {
|
|
||||||
const [shell, args] = process.platform === "win32" ? ["cmd", ["/c", cmd]] : ["bash", ["-lc", cmd]]
|
|
||||||
const handle = yield* spawner.spawn(
|
|
||||||
ChildProcess.make(shell, args, { cwd: directory, extendEnv: true, stdin: "ignore" }),
|
|
||||||
)
|
|
||||||
// Drain stdout, capture stderr for error reporting
|
|
||||||
const [, stderr] = yield* Effect.all(
|
|
||||||
[Stream.runDrain(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
).pipe(Effect.orDie)
|
|
||||||
const code = yield* handle.exitCode
|
|
||||||
return { code, stderr }
|
|
||||||
},
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.catch(() => Effect.succeed({ code: 1, stderr: "" })),
|
|
||||||
)
|
|
||||||
|
|
||||||
const runStartScript = Effect.fnUntraced(function* (directory: string, cmd: string, kind: string) {
|
|
||||||
const text = cmd.trim()
|
|
||||||
if (!text) return true
|
|
||||||
const result = yield* runStartCommand(directory, text)
|
|
||||||
if (result.code === 0) return true
|
|
||||||
log.error("worktree start command failed", { kind, directory, message: result.stderr })
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
const runStartScripts = Effect.fnUntraced(function* (
|
|
||||||
directory: string,
|
|
||||||
input: { projectID: ProjectID; extra?: string },
|
|
||||||
) {
|
|
||||||
const row = yield* Effect.sync(() =>
|
|
||||||
Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get()),
|
|
||||||
)
|
|
||||||
const project = row ? Project.fromRow(row) : undefined
|
|
||||||
const startup = project?.commands?.start?.trim() ?? ""
|
|
||||||
const ok = yield* runStartScript(directory, startup, "project")
|
|
||||||
if (!ok) return false
|
|
||||||
yield* runStartScript(directory, input.extra ?? "", "worktree")
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
const prune = Effect.fnUntraced(function* (root: string, entries: string[]) {
|
|
||||||
const base = yield* canonical(root)
|
|
||||||
yield* Effect.forEach(
|
|
||||||
entries,
|
|
||||||
(entry) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const target = yield* canonical(pathSvc.resolve(root, entry))
|
|
||||||
if (target === base) return
|
|
||||||
if (!target.startsWith(`${base}${pathSvc.sep}`)) return
|
|
||||||
yield* fs.remove(target, { recursive: true }).pipe(Effect.ignore)
|
|
||||||
}),
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const sweep = Effect.fnUntraced(function* (root: string) {
|
|
||||||
const first = yield* git(["clean", "-ffdx"], { cwd: root })
|
|
||||||
if (first.code === 0) return first
|
|
||||||
|
|
||||||
const entries = failedRemoves(first.stderr, first.text)
|
|
||||||
if (!entries.length) return first
|
|
||||||
|
|
||||||
yield* prune(root, entries)
|
|
||||||
return yield* git(["clean", "-ffdx"], { cwd: root })
|
|
||||||
})
|
|
||||||
|
|
||||||
const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) {
|
|
||||||
if (Instance.project.vcs !== "git") {
|
|
||||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const directory = yield* canonical(input.directory)
|
|
||||||
const primary = yield* canonical(Instance.worktree)
|
|
||||||
if (directory === primary) {
|
|
||||||
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
|
||||||
if (list.code !== 0) {
|
|
||||||
throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = yield* locateWorktree(parseWorktreeList(list.text), directory)
|
|
||||||
if (!entry?.path) {
|
|
||||||
throw new ResetFailedError({ message: "Worktree not found" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const worktreePath = entry.path
|
|
||||||
|
|
||||||
const base = yield* gitSvc.defaultBranch(Instance.worktree)
|
|
||||||
if (!base) {
|
|
||||||
throw new ResetFailedError({ message: "Default branch not found" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const sep = base.ref.indexOf("/")
|
|
||||||
if (base.ref !== base.name && sep > 0) {
|
|
||||||
const remote = base.ref.slice(0, sep)
|
|
||||||
const branch = base.ref.slice(sep + 1)
|
|
||||||
yield* gitExpect(
|
|
||||||
["fetch", remote, branch],
|
|
||||||
{ cwd: Instance.worktree },
|
|
||||||
(r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${base.ref}` }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* gitExpect(
|
|
||||||
["reset", "--hard", base.ref],
|
|
||||||
{ cwd: worktreePath },
|
|
||||||
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset worktree to target" }),
|
|
||||||
)
|
|
||||||
|
|
||||||
const cleanResult = yield* sweep(worktreePath)
|
|
||||||
if (cleanResult.code !== 0) {
|
|
||||||
throw new ResetFailedError({ message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree" })
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* gitExpect(
|
|
||||||
["submodule", "update", "--init", "--recursive", "--force"],
|
|
||||||
{ cwd: worktreePath },
|
|
||||||
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to update submodules" }),
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* gitExpect(
|
|
||||||
["submodule", "foreach", "--recursive", "git", "reset", "--hard"],
|
|
||||||
{ cwd: worktreePath },
|
|
||||||
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset submodules" }),
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* gitExpect(
|
|
||||||
["submodule", "foreach", "--recursive", "git", "clean", "-fdx"],
|
|
||||||
{ cwd: worktreePath },
|
|
||||||
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to clean submodules" }),
|
|
||||||
)
|
|
||||||
|
|
||||||
const status = yield* git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
|
|
||||||
if (status.code !== 0) {
|
|
||||||
throw new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.text.trim()) {
|
|
||||||
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` })
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* runStartScripts(worktreePath, { projectID: Instance.project.id }).pipe(
|
|
||||||
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree start task failed", { cause }))),
|
|
||||||
Effect.forkIn(scope),
|
|
||||||
)
|
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ makeWorktreeInfo, createFromInfo, create, remove, reset })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
|
||||||
Layer.provide(Git.defaultLayer),
|
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
|
||||||
Layer.provide(Project.defaultLayer),
|
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
|
||||||
Layer.provide(NodePath.layer),
|
|
||||||
)
|
|
||||||
@@ -757,7 +757,7 @@ test("updates config and writes to file", async () => {
|
|||||||
const newConfig = { model: "updated/model" }
|
const newConfig = { model: "updated/model" }
|
||||||
await save(newConfig as any)
|
await save(newConfig as any)
|
||||||
|
|
||||||
const writtenConfig = await Filesystem.readJson(path.join(tmp.path, "config.json"))
|
const writtenConfig = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, "config.json"))
|
||||||
expect(writtenConfig.model).toBe("updated/model")
|
expect(writtenConfig.model).toBe("updated/model")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ describe("plugin.auth-override", () => {
|
|||||||
}, 30000) // Increased timeout for plugin installation
|
}, 30000) // Increased timeout for plugin installation
|
||||||
})
|
})
|
||||||
|
|
||||||
const file = path.join(import.meta.dir, "../../src/plugin/plugin.ts")
|
const file = path.join(import.meta.dir, "../../src/plugin/index.ts")
|
||||||
|
|
||||||
describe("plugin.config-hook-error-isolation", () => {
|
describe("plugin.config-hook-error-isolation", () => {
|
||||||
test("config hooks are individually error-isolated in the layer factory", async () => {
|
test("config hooks are individually error-isolated in the layer factory", async () => {
|
||||||
|
|||||||
@@ -1601,7 +1601,7 @@ export type Config = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
lsp?:
|
lsp?:
|
||||||
| false
|
| true
|
||||||
| {
|
| {
|
||||||
[key: string]:
|
[key: string]:
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -6938,8 +6938,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"sessionID": {
|
"sessionID": {
|
||||||
"description": "Session ID to navigate to",
|
"description": "Session ID to navigate to",
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"pattern": "^ses.*"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["sessionID"]
|
"required": ["sessionID"]
|
||||||
@@ -8511,8 +8510,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"sessionID": {
|
"sessionID": {
|
||||||
"description": "Session ID to navigate to",
|
"description": "Session ID to navigate to",
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"pattern": "^ses.*"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["sessionID"]
|
"required": ["sessionID"]
|
||||||
@@ -11761,7 +11759,7 @@
|
|||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"const": false
|
"const": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
+54
-72
@@ -8,7 +8,8 @@ import { EffectFlock } from "./util/effect-flock"
|
|||||||
|
|
||||||
export namespace Npm {
|
export namespace Npm {
|
||||||
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
|
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
|
||||||
pkg: Schema.String,
|
add: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||||
|
dir: Schema.String,
|
||||||
cause: Schema.optional(Schema.Defect),
|
cause: Schema.optional(Schema.Defect),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
@@ -19,7 +20,10 @@ export namespace Npm {
|
|||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
||||||
readonly install: (dir: string, input?: { add: string[] }) => Effect.Effect<void, 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 outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
|
||||||
readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
|
readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
|
||||||
}
|
}
|
||||||
@@ -55,6 +59,37 @@ export namespace Npm {
|
|||||||
interface ArboristTree {
|
interface ArboristTree {
|
||||||
edgesOut: Map<string, { to?: ArboristNode }>
|
edgesOut: Map<string, { to?: ArboristNode }>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reify = (input: { dir: string; add?: string[] }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -91,45 +126,12 @@ export namespace Npm {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const add = Effect.fn("Npm.add")(function* (pkg: string) {
|
const add = Effect.fn("Npm.add")(function* (pkg: string) {
|
||||||
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
|
|
||||||
const dir = directory(pkg)
|
const dir = directory(pkg)
|
||||||
yield* flock.acquire(`npm-install:${dir}`)
|
yield* flock.acquire(`npm-install:${dir}`)
|
||||||
|
|
||||||
const arborist = new Arborist({
|
const tree = yield* reify({ dir, add: [pkg] })
|
||||||
path: dir,
|
const first = tree.edgesOut.values().next().value?.to
|
||||||
binLinks: true,
|
if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
|
||||||
progress: false,
|
|
||||||
savePrefix: "",
|
|
||||||
ignoreScripts: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const tree = yield* Effect.tryPromise({
|
|
||||||
try: () => arborist.loadVirtual().catch(() => undefined),
|
|
||||||
catch: () => undefined,
|
|
||||||
}).pipe(Effect.orElseSucceed(() => undefined)) as Effect.Effect<ArboristTree | undefined>
|
|
||||||
|
|
||||||
if (tree) {
|
|
||||||
const first = tree.edgesOut.values().next().value?.to
|
|
||||||
if (first) {
|
|
||||||
return resolveEntryPoint(first.name, first.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = yield* Effect.tryPromise({
|
|
||||||
try: () =>
|
|
||||||
arborist.reify({
|
|
||||||
add: [pkg],
|
|
||||||
save: true,
|
|
||||||
saveType: "prod",
|
|
||||||
}),
|
|
||||||
catch: (cause) => new InstallFailedError({ pkg, cause }),
|
|
||||||
}) as Effect.Effect<ArboristTree, InstallFailedError>
|
|
||||||
|
|
||||||
const first = result.edgesOut.values().next().value?.to
|
|
||||||
if (!first) {
|
|
||||||
return yield* new InstallFailedError({ pkg })
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolveEntryPoint(first.name, first.path)
|
return resolveEntryPoint(first.name, first.path)
|
||||||
}, Effect.scoped)
|
}, Effect.scoped)
|
||||||
|
|
||||||
@@ -142,41 +144,20 @@ export namespace Npm {
|
|||||||
|
|
||||||
yield* flock.acquire(`npm-install:${dir}`)
|
yield* flock.acquire(`npm-install:${dir}`)
|
||||||
|
|
||||||
const reify = Effect.fn("Npm.reify")(function* () {
|
yield* Effect.gen(function* () {
|
||||||
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
|
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
|
||||||
const arb = new Arborist({
|
if (!nodeModulesExists) {
|
||||||
path: dir,
|
yield* reify({ add: input?.add, dir })
|
||||||
binLinks: true,
|
return
|
||||||
progress: false,
|
}
|
||||||
savePrefix: "",
|
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
|
||||||
ignoreScripts: true,
|
|
||||||
})
|
|
||||||
yield* Effect.tryPromise({
|
|
||||||
try: () =>
|
|
||||||
arb
|
|
||||||
.reify({
|
|
||||||
add: input?.add || [],
|
|
||||||
save: true,
|
|
||||||
saveType: "prod",
|
|
||||||
})
|
|
||||||
.catch(() => {}),
|
|
||||||
catch: () => {},
|
|
||||||
}).pipe(Effect.orElseSucceed(() => {}))
|
|
||||||
})
|
|
||||||
|
|
||||||
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
|
|
||||||
if (!nodeModulesExists) {
|
|
||||||
yield* reify()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
yield* Effect.gen(function* () {
|
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([
|
const declared = new Set([
|
||||||
...Object.keys(pkgAny?.dependencies || {}),
|
...Object.keys(pkgAny?.dependencies || {}),
|
||||||
...Object.keys(pkgAny?.devDependencies || {}),
|
...Object.keys(pkgAny?.devDependencies || {}),
|
||||||
@@ -195,11 +176,12 @@ export namespace Npm {
|
|||||||
|
|
||||||
for (const name of declared) {
|
for (const name of declared) {
|
||||||
if (!locked.has(name)) {
|
if (!locked.has(name)) {
|
||||||
yield* reify()
|
yield* reify({ dir, add: input?.add })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).pipe(Effect.withSpan("Npm.checkDirty"))
|
}).pipe(Effect.withSpan("Npm.checkDirty"))
|
||||||
|
|
||||||
return
|
return
|
||||||
}, Effect.scoped)
|
}, Effect.scoped)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user