feat(core): add command registry (#30624)
This commit is contained in:
parent
70bb710715
commit
1ff19103a2
150 changed files with 4642 additions and 2546 deletions
116
packages/cli/bin/lildax.cjs
Normal file
116
packages/cli/bin/lildax.cjs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const childProcess = require("child_process")
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const os = require("os")
|
||||
|
||||
const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP"]
|
||||
|
||||
function run(target) {
|
||||
const child = childProcess.spawn(target, process.argv.slice(2), { stdio: "inherit" })
|
||||
child.on("error", (error) => {
|
||||
console.error(error.message)
|
||||
process.exit(1)
|
||||
})
|
||||
const forwarders = {}
|
||||
for (const signal of forwardedSignals) {
|
||||
forwarders[signal] = () => {
|
||||
try {
|
||||
child.kill(signal)
|
||||
} catch {}
|
||||
}
|
||||
process.on(signal, forwarders[signal])
|
||||
}
|
||||
child.on("exit", (code, signal) => {
|
||||
for (const forwardedSignal of forwardedSignals) process.removeListener(forwardedSignal, forwarders[forwardedSignal])
|
||||
if (signal) return process.kill(process.pid, signal)
|
||||
process.exit(typeof code === "number" ? code : 0)
|
||||
})
|
||||
}
|
||||
|
||||
const envPath = process.env.OPENCODE_BIN_PATH
|
||||
const scriptDir = path.dirname(fs.realpathSync(__filename))
|
||||
const cached = path.join(scriptDir, ".lildax")
|
||||
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform()
|
||||
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
|
||||
const base = "@opencode-ai/lildax-" + platform + "-" + arch
|
||||
const binary = platform === "windows" ? "lildax.exe" : "lildax"
|
||||
|
||||
function supportsAvx2() {
|
||||
if (arch !== "x64") return false
|
||||
if (platform === "linux") {
|
||||
try {
|
||||
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (platform === "darwin") {
|
||||
try {
|
||||
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], { encoding: "utf8", timeout: 1500 })
|
||||
return result.status === 0 && (result.stdout || "").trim() === "1"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (platform === "windows") {
|
||||
const command =
|
||||
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
|
||||
for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
|
||||
try {
|
||||
const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", command], {
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
})
|
||||
if (result.status !== 0) continue
|
||||
const output = (result.stdout || "").trim().toLowerCase()
|
||||
if (output === "true" || output === "1") return true
|
||||
if (output === "false" || output === "0") return false
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const names = (() => {
|
||||
const baseline = arch === "x64" && !supportsAvx2()
|
||||
if (platform === "linux") {
|
||||
const musl = (() => {
|
||||
try {
|
||||
if (fs.existsSync("/etc/alpine-release")) return true
|
||||
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
|
||||
return ((result.stdout || "") + (result.stderr || "")).toLowerCase().includes("musl")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
if (musl) return arch === "x64" ? (baseline ? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base] : [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]) : [`${base}-musl`, base]
|
||||
return arch === "x64" ? (baseline ? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`] : [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]) : [base, `${base}-musl`]
|
||||
}
|
||||
return arch === "x64" ? (baseline ? [`${base}-baseline`, base] : [base, `${base}-baseline`]) : [base]
|
||||
})()
|
||||
|
||||
function findBinary(startDir) {
|
||||
let current = startDir
|
||||
for (;;) {
|
||||
const modules = path.join(current, "node_modules")
|
||||
if (fs.existsSync(modules)) for (const name of names) {
|
||||
const candidate = path.join(modules, name, "bin", binary)
|
||||
if (fs.existsSync(candidate)) return candidate
|
||||
}
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current) return
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
|
||||
if (!resolved) {
|
||||
console.error("It seems that your package manager failed to install the right lildax CLI package. Try manually installing " + names.map((name) => `"${name}"`).join(" or ") + " package")
|
||||
process.exit(1)
|
||||
}
|
||||
run(resolved)
|
||||
|
|
@ -2,12 +2,14 @@
|
|||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.15.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"opencode": "./src/index.ts"
|
||||
"lildax": "./bin/lildax.cjs"
|
||||
},
|
||||
"files": [
|
||||
"bin"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"dev": "bun run src/index.ts",
|
||||
|
|
@ -16,9 +18,13 @@
|
|||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
|
|
|
|||
103
packages/cli/script/build.ts
Normal file
103
packages/cli/script/build.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { rm } from "fs/promises"
|
||||
import path from "path"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { modelsData } from "./generate"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
const binary = "lildax"
|
||||
process.chdir(dir)
|
||||
|
||||
await rm("dist", { recursive: true, force: true })
|
||||
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const sourcemapsFlag = process.argv.includes("--sourcemaps")
|
||||
|
||||
const allTargets: {
|
||||
os: string
|
||||
arch: "arm64" | "x64"
|
||||
abi?: "musl"
|
||||
avx2?: false
|
||||
}[] = [
|
||||
{ os: "linux", arch: "arm64" },
|
||||
{ os: "linux", arch: "x64" },
|
||||
{ os: "linux", arch: "x64", avx2: false },
|
||||
{ os: "linux", arch: "arm64", abi: "musl" },
|
||||
{ os: "linux", arch: "x64", abi: "musl" },
|
||||
{ os: "linux", arch: "x64", abi: "musl", avx2: false },
|
||||
{ os: "darwin", arch: "arm64" },
|
||||
{ os: "darwin", arch: "x64" },
|
||||
{ os: "darwin", arch: "x64", avx2: false },
|
||||
{ os: "win32", arch: "arm64" },
|
||||
{ os: "win32", arch: "x64" },
|
||||
{ os: "win32", arch: "x64", avx2: false },
|
||||
]
|
||||
|
||||
const targets = singleFlag
|
||||
? allTargets.filter((item) => {
|
||||
if (item.os !== process.platform || item.arch !== process.arch) return false
|
||||
if (item.avx2 === false) return baselineFlag
|
||||
return item.abi === undefined
|
||||
})
|
||||
: allTargets
|
||||
|
||||
for (const item of targets) {
|
||||
const name = [
|
||||
binary,
|
||||
item.os === "win32" ? "windows" : item.os,
|
||||
item.arch,
|
||||
item.avx2 === false ? "baseline" : undefined,
|
||||
item.abi,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("-")
|
||||
console.log(`building ${name}`)
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
sourcemap: sourcemapsFlag ? "linked" : "none",
|
||||
splitting: true,
|
||||
compile: {
|
||||
autoloadBunfig: false,
|
||||
autoloadDotenv: false,
|
||||
autoloadTsconfig: true,
|
||||
autoloadPackageJson: true,
|
||||
target: name.replace(binary, "bun") as Bun.Build.CompileTarget,
|
||||
outfile: `./dist/${name}/bin/${binary}`,
|
||||
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--"],
|
||||
windows: {},
|
||||
},
|
||||
define: {
|
||||
OPENCODE_VERSION: `'${Script.version}'`,
|
||||
OPENCODE_CLI_NAME: `'${binary}'`,
|
||||
OPENCODE_MODELS_DEV: modelsData,
|
||||
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
||||
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
|
||||
},
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
for (const log of result.logs) console.error(log)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await Bun.write(
|
||||
`./dist/${name}/package.json`,
|
||||
JSON.stringify(
|
||||
{
|
||||
name: `@opencode-ai/${name}`,
|
||||
version: Script.version,
|
||||
license: "MIT",
|
||||
os: [item.os],
|
||||
cpu: [item.arch],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
7
packages/cli/script/generate.ts
Normal file
7
packages/cli/script/generate.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
|
||||
|
||||
export const modelsData = process.env.MODELS_DEV_API_JSON
|
||||
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
|
||||
: await fetch(`${modelsUrl}/api.json`).then((response) => response.text())
|
||||
|
||||
console.log("Loaded models.dev snapshot")
|
||||
48
packages/cli/script/publish.ts
Normal file
48
packages/cli/script/publish.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
#!/usr/bin/env bun
|
||||
import { $ } from "bun"
|
||||
import pkg from "../package.json"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const dir = fileURLToPath(new URL("..", import.meta.url))
|
||||
process.chdir(dir)
|
||||
|
||||
async function published(name: string, version: string) {
|
||||
return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0
|
||||
}
|
||||
|
||||
async function publish(dir: string, name: string, version: string) {
|
||||
if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir)
|
||||
if (await published(name, version)) return console.log(`already published ${name}@${version}`)
|
||||
await $`bun pm pack`.cwd(dir)
|
||||
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
|
||||
}
|
||||
|
||||
const binaries: Record<string, string> = {}
|
||||
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) {
|
||||
const item = await Bun.file(`./dist/${filepath}`).json()
|
||||
binaries[item.name] = item.version
|
||||
}
|
||||
console.log("binaries", binaries)
|
||||
const version = Object.values(binaries)[0]
|
||||
|
||||
await $`mkdir -p ./dist/${pkg.name}/bin`
|
||||
await $`cp ./bin/lildax.cjs ./dist/${pkg.name}/bin/lildax`
|
||||
await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: pkg.name,
|
||||
bin: { lildax: "./bin/lildax" },
|
||||
version,
|
||||
license: pkg.license,
|
||||
os: ["darwin", "linux", "win32"],
|
||||
cpu: ["arm64", "x64"],
|
||||
optionalDependencies: binaries,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
await Promise.all(Object.entries(binaries).map(([name, version]) => publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version)))
|
||||
await publish(`./dist/${pkg.name}`, pkg.name, version)
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { CliApi } from "./cli-api"
|
||||
|
||||
export const Api = CliApi.make("opencode", {
|
||||
description: "OpenCode command line interface",
|
||||
commands: [
|
||||
CliApi.make("debug", {
|
||||
description: "Debugging and troubleshooting tools",
|
||||
commands: [CliApi.make("agents", { description: "List all agents" })],
|
||||
}),
|
||||
CliApi.make("migrate", { description: "Migrate v1 data to v2" }),
|
||||
],
|
||||
})
|
||||
36
packages/cli/src/commands/commands.ts
Normal file
36
packages/cli/src/commands/commands.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Argument, Flag } from "effect/unstable/cli"
|
||||
import { Spec } from "../framework/spec"
|
||||
|
||||
declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
|
||||
description: "OpenCode 2.0 preview command line interface",
|
||||
commands: [
|
||||
Spec.make("debug", {
|
||||
description: "Debugging and troubleshooting tools",
|
||||
commands: [Spec.make("agents", { description: "List all agents" })],
|
||||
}),
|
||||
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
|
||||
Spec.make("service", {
|
||||
description: "Manage the background server",
|
||||
commands: [
|
||||
Spec.make("start", { description: "Start the background server" }),
|
||||
Spec.make("restart", { description: "Restart the background server" }),
|
||||
Spec.make("status", { description: "Show background server status" }),
|
||||
Spec.make("stop", { description: "Stop the background server" }),
|
||||
Spec.make("password", {
|
||||
description: "Get or set the server password",
|
||||
params: { value: Argument.string("value").pipe(Argument.optional) },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
Spec.make("serve", {
|
||||
description: "Start the v2 API server",
|
||||
params: {
|
||||
hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")),
|
||||
port: Flag.integer("port").pipe(Flag.optional),
|
||||
register: Flag.boolean("register").pipe(Flag.withDefault(false)),
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
21
packages/cli/src/commands/handlers/debug/agents.ts
Normal file
21
packages/cli/src/commands/handlers/debug/agents.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { EOL } from "os"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Daemon } from "../../../services/daemon"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.debug.commands.agents,
|
||||
Effect.fn("cli.debug.agents")(function* () {
|
||||
const daemon = yield* Daemon.Service
|
||||
const client = yield* daemon.client()
|
||||
const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } }))
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
response.data?.data.toSorted((a, b) => a.id.localeCompare(b.id)),
|
||||
null,
|
||||
2,
|
||||
) + EOL,
|
||||
)
|
||||
}),
|
||||
)
|
||||
5
packages/cli/src/commands/handlers/migrate.ts
Normal file
5
packages/cli/src/commands/handlers/migrate.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import * as Effect from "effect/Effect"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
|
||||
export default Runtime.handler(Commands.commands.migrate, (_input) => Effect.log("No migrations to run."))
|
||||
39
packages/cli/src/commands/handlers/serve.ts
Normal file
39
packages/cli/src/commands/handlers/serve.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Context, Layer, Option } from "effect"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { createServer } from "node:http"
|
||||
import { createRoutes } from "@opencode-ai/server/routes"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Daemon } from "../../services/daemon"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.serve,
|
||||
Effect.fn("cli.serve")(function* (input) {
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const daemon = yield* Daemon.Service
|
||||
const address = yield* listen(input.hostname, input.port, yield* daemon.password())
|
||||
if (input.register) yield* daemon.register(address)
|
||||
console.log(`server listening on ${HttpServer.formatAddress(address)}`)
|
||||
return yield* Effect.never
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
function listen(hostname: string, port: Option.Option<number>, password: string) {
|
||||
if (Option.isSome(port)) return bind(hostname, port.value, password)
|
||||
// Preserve the familiar default when available, but let the OS choose a free
|
||||
// port when another local server already owns 4096.
|
||||
return bind(hostname, 4096, password).pipe(Effect.catch(() => bind(hostname, 0, password)))
|
||||
}
|
||||
|
||||
function bind(hostname: string, port: number, password: string) {
|
||||
return Layer.build(
|
||||
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
|
||||
),
|
||||
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
|
||||
}
|
||||
16
packages/cli/src/commands/handlers/service/password.ts
Normal file
16
packages/cli/src/commands/handlers/service/password.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { EOL } from "os"
|
||||
import { Option } from "effect"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Daemon } from "../../../services/daemon"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.password,
|
||||
Effect.fn("cli.service.password")(function* (input) {
|
||||
const daemon = yield* Daemon.Service
|
||||
const value = Option.getOrUndefined(input.value)
|
||||
if (value !== undefined) yield* daemon.stop()
|
||||
process.stdout.write((yield* daemon.password(value)) + EOL)
|
||||
}),
|
||||
)
|
||||
14
packages/cli/src/commands/handlers/service/restart.ts
Normal file
14
packages/cli/src/commands/handlers/service/restart.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { EOL } from "os"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Daemon } from "../../../services/daemon"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.restart,
|
||||
Effect.fn("cli.service.restart")(function* () {
|
||||
const daemon = yield* Daemon.Service
|
||||
yield* daemon.stop()
|
||||
process.stdout.write((yield* daemon.start()) + EOL)
|
||||
}),
|
||||
)
|
||||
12
packages/cli/src/commands/handlers/service/start.ts
Normal file
12
packages/cli/src/commands/handlers/service/start.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { EOL } from "os"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Daemon } from "../../../services/daemon"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.start,
|
||||
Effect.fn("cli.service.start")(function* () {
|
||||
process.stdout.write((yield* (yield* Daemon.Service).start()) + EOL)
|
||||
}),
|
||||
)
|
||||
13
packages/cli/src/commands/handlers/service/status.ts
Normal file
13
packages/cli/src/commands/handlers/service/status.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { EOL } from "os"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Daemon } from "../../../services/daemon"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.status,
|
||||
Effect.fn("cli.service.status")(function* () {
|
||||
const url = yield* (yield* Daemon.Service).status()
|
||||
process.stdout.write((url ? `running ${url}` : "stopped") + EOL)
|
||||
}),
|
||||
)
|
||||
11
packages/cli/src/commands/handlers/service/stop.ts
Normal file
11
packages/cli/src/commands/handlers/service/stop.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import * as Effect from "effect/Effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Daemon } from "../../../services/daemon"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.stop,
|
||||
Effect.fn("cli.service.stop")(function* () {
|
||||
yield* (yield* Daemon.Service).stop()
|
||||
}),
|
||||
)
|
||||
|
|
@ -1,19 +1,20 @@
|
|||
import * as Effect from "effect/Effect"
|
||||
import * as Command from "effect/unstable/cli/Command"
|
||||
import { CliApi } from "./cli-api"
|
||||
import { Spec } from "./spec"
|
||||
import { Daemon } from "../services/daemon"
|
||||
|
||||
export type Input<Value> =
|
||||
Value extends CliApi.Node<infer _Name, infer Spec, infer _Commands>
|
||||
? Input<Spec>
|
||||
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
|
||||
? Input<Command>
|
||||
: Value extends Command.Command<infer _Name, infer Input, infer _Context, infer _Error, infer _Requirements>
|
||||
? Input
|
||||
: never
|
||||
|
||||
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown>
|
||||
type Loader<Node extends CliApi.Any> = () => Promise<{ default: (input: Input<Node>) => Effect.Effect<void, any> }>
|
||||
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, never>
|
||||
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service>
|
||||
type Loader<Node extends Spec.Any> = () => Promise<{ default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service> }>
|
||||
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service>
|
||||
|
||||
export type Handlers<Node extends CliApi.Any> = keyof Node["commands"] extends never
|
||||
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
|
||||
? Loader<Node>
|
||||
: { readonly $?: Loader<Node> } & { readonly [Key in keyof Node["commands"]]: Handlers<Node["commands"][Key]> }
|
||||
|
||||
|
|
@ -29,17 +30,17 @@ type RuntimeHandlers =
|
|||
readonly [key: string]: RuntimeHandlers | (() => Promise<{ default: RuntimeHandler }>) | undefined
|
||||
}
|
||||
|
||||
export function handler<const Node extends CliApi.Any, Error>(
|
||||
export function handler<const Node extends Spec.Any, Error, Requirements>(
|
||||
_node: Node,
|
||||
run: (input: Input<Node>) => Effect.Effect<void, Error>,
|
||||
run: (input: Input<Node>) => Effect.Effect<void, Error, Requirements>,
|
||||
) {
|
||||
return run
|
||||
}
|
||||
|
||||
export function handlers<const Root extends CliApi.Any>(root: Root, handlers: Handlers<Root>) {
|
||||
export function handlers<const Root extends Spec.Any>(root: Root, handlers: Handlers<Root>) {
|
||||
const result: LazyHandler[] = []
|
||||
|
||||
function add(node: CliApi.Any, value: RuntimeHandlers) {
|
||||
function add(node: Spec.Any, value: RuntimeHandlers) {
|
||||
if (typeof value === "function") {
|
||||
result.push({ spec: node.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
|
||||
return
|
||||
|
|
@ -52,11 +53,11 @@ export function handlers<const Root extends CliApi.Any>(root: Root, handlers: Ha
|
|||
return result
|
||||
}
|
||||
|
||||
export function run(api: CliApi.Any, handlers: ReadonlyArray<LazyHandler>, options: { readonly version: string }) {
|
||||
return Command.run(provide(api, handlers), options) as Effect.Effect<void, unknown, Command.Environment>
|
||||
export function run(commands: Spec.Any, handlers: ReadonlyArray<LazyHandler>, options: { readonly version: string }) {
|
||||
return Command.run(provide(commands, handlers), options) as Effect.Effect<void, unknown, Command.Environment>
|
||||
}
|
||||
|
||||
function provide(node: CliApi.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
|
||||
function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
|
||||
const spec: Command.Command.Any = Object.keys(node.commands).length
|
||||
? (node.spec as Command.Command<string, unknown>).pipe(
|
||||
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
|
||||
|
|
@ -65,8 +66,12 @@ function provide(node: CliApi.Any, handlers: ReadonlyArray<LazyHandler>): Provid
|
|||
const handler = handlers.find((handler) => handler.spec === node.spec)
|
||||
if (!handler) return spec as ProvidedCommand
|
||||
return spec.pipe(
|
||||
Command.withHandler((input) => Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))),
|
||||
Command.withHandler((input) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
|
||||
}),
|
||||
),
|
||||
) as ProvidedCommand
|
||||
}
|
||||
|
||||
export * as CliBuilder from "./cli-builder"
|
||||
export * as Runtime from "./runtime"
|
||||
|
|
@ -39,4 +39,4 @@ type ChildrenOf<Commands extends ReadonlyArray<Any>> = {
|
|||
readonly [Node in Commands[number] as Node["name"]]: Node
|
||||
}
|
||||
|
||||
export * as CliApi from "./cli-api"
|
||||
export * as Spec from "./spec"
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { EOL } from "os"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Api } from "../../api"
|
||||
import { CliBuilder } from "../../cli-builder"
|
||||
|
||||
export default CliBuilder.handler(
|
||||
Api.commands.debug.commands.agents,
|
||||
Effect.fn("cli.debug.agents")(
|
||||
function* () {
|
||||
const svc = {
|
||||
plugin: yield* PluginBoot.Service,
|
||||
agent: yield* AgentV2.Service,
|
||||
}
|
||||
yield* svc.plugin.wait()
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
(yield* svc.agent.all()).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
null,
|
||||
2,
|
||||
) + EOL,
|
||||
)
|
||||
},
|
||||
Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(process.cwd()) })),
|
||||
Effect.provide(LocationServiceMap.layer),
|
||||
),
|
||||
)
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import * as Effect from "effect/Effect"
|
||||
import { Api } from "../api"
|
||||
import { CliBuilder } from "../cli-builder"
|
||||
|
||||
export default CliBuilder.handler(Api.commands.migrate, (_input) => Effect.log("No migrations to run."))
|
||||
|
|
@ -3,17 +3,27 @@
|
|||
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
|
||||
import * as NodeServices from "@effect/platform-node/NodeServices"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Api } from "./api"
|
||||
import { CliBuilder } from "./cli-builder"
|
||||
import { Commands } from "./commands/commands"
|
||||
import { Runtime } from "./framework/runtime"
|
||||
import { Daemon } from "./services/daemon"
|
||||
|
||||
const Handlers = CliBuilder.handlers(Api, {
|
||||
const Handlers = Runtime.handlers(Commands, {
|
||||
debug: {
|
||||
agents: () => import("./handlers/debug/agents"),
|
||||
agents: () => import("./commands/handlers/debug/agents"),
|
||||
},
|
||||
migrate: () => import("./handlers/migrate"),
|
||||
migrate: () => import("./commands/handlers/migrate"),
|
||||
service: {
|
||||
start: () => import("./commands/handlers/service/start"),
|
||||
restart: () => import("./commands/handlers/service/restart"),
|
||||
status: () => import("./commands/handlers/service/status"),
|
||||
stop: () => import("./commands/handlers/service/stop"),
|
||||
password: () => import("./commands/handlers/service/password"),
|
||||
},
|
||||
serve: () => import("./commands/handlers/serve"),
|
||||
})
|
||||
|
||||
CliBuilder.run(Api, Handlers, { version: "local" }).pipe(
|
||||
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
|
||||
Effect.provide(Daemon.defaultLayer),
|
||||
Effect.provide(NodeServices.layer),
|
||||
Effect.scoped,
|
||||
NodeRuntime.runMain,
|
||||
|
|
|
|||
145
packages/cli/src/services/daemon.ts
Normal file
145
packages/cli/src/services/daemon.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { Global } from "@opencode-ai/core/global"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { ServerAuth } from "@opencode-ai/server/auth"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { randomBytes } from "crypto"
|
||||
import path from "path"
|
||||
|
||||
export interface Interface {
|
||||
readonly client: () => Effect.Effect<ReturnType<typeof createOpencodeClient>, unknown>
|
||||
readonly start: () => Effect.Effect<string, Error>
|
||||
readonly status: () => Effect.Effect<string | undefined>
|
||||
readonly stop: () => Effect.Effect<void, unknown>
|
||||
readonly password: (value?: string) => Effect.Effect<string, unknown>
|
||||
readonly register: (address: HttpServer.Address) => Effect.Effect<void, unknown, Scope.Scope>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Daemon") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const directory = Global.Path.state
|
||||
const file = path.join(directory, "server.json")
|
||||
const passwordFile = path.join(directory, "password")
|
||||
const decodeRegistration = Schema.decodeUnknownEffect(
|
||||
Schema.fromJsonString(Schema.Struct({ url: Schema.String, pid: Schema.Number })),
|
||||
)
|
||||
|
||||
const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
|
||||
const existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (value === undefined && existing) return existing
|
||||
|
||||
// Keep one private credential across server restarts so discovered clients
|
||||
// can reconnect without exposing a password flag or environment variable.
|
||||
const generated = value ?? randomBytes(32).toString("base64url")
|
||||
const temp = passwordFile + ".tmp"
|
||||
yield* fs.makeDirectory(directory, { recursive: true })
|
||||
yield* fs.writeFileString(temp, generated, { mode: 0o600 })
|
||||
yield* fs.rename(temp, passwordFile)
|
||||
return generated
|
||||
})
|
||||
|
||||
const registration = Effect.fnUntraced(function* () {
|
||||
return yield* fs.readFileString(file).pipe(Effect.flatMap(decodeRegistration))
|
||||
})
|
||||
|
||||
const createClient = Effect.fnUntraced(function* (url: string) {
|
||||
return createOpencodeClient({ baseUrl: url, headers: ServerAuth.headers({ password: yield* password() }) })
|
||||
})
|
||||
|
||||
const healthy = Effect.fnUntraced(function* () {
|
||||
const info = yield* registration()
|
||||
const client = yield* createClient(info.url)
|
||||
const response = yield* Effect.tryPromise(() => client.v2.health.get())
|
||||
if (response.data?.healthy === true) return info
|
||||
return yield* Effect.fail(new Error("Registered server is not healthy"))
|
||||
})
|
||||
|
||||
const start = Effect.fn("cli.daemon.start")(function* () {
|
||||
const existing = yield* healthy().pipe(Effect.option)
|
||||
const found = Option.getOrUndefined(existing)
|
||||
if (found) return found.url
|
||||
|
||||
yield* Effect.sync(() => {
|
||||
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
|
||||
Bun.spawn([process.execPath, ...(compiled ? [] : [Bun.main]), "serve", "--register"], {
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
}).unref()
|
||||
})
|
||||
|
||||
return yield* healthy().pipe(
|
||||
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
|
||||
Effect.map((info) => info.url),
|
||||
Effect.mapError(() => new Error("Failed to start server")),
|
||||
)
|
||||
})
|
||||
|
||||
const client = Effect.fn("cli.daemon.client")(function* () {
|
||||
return yield* createClient(yield* start())
|
||||
})
|
||||
|
||||
const status = Effect.fn("cli.daemon.status")(function* () {
|
||||
const existing = yield* healthy().pipe(Effect.option)
|
||||
const found = Option.getOrUndefined(existing)
|
||||
if (found) return found.url
|
||||
yield* fs.remove(file).pipe(Effect.ignore)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const signal = (pid: number, signal: NodeJS.Signals) =>
|
||||
Effect.try({ try: () => process.kill(pid, signal), catch: (cause) => cause }).pipe(Effect.ignore)
|
||||
|
||||
const awaitStopped = Effect.fnUntraced(function* (pid: number) {
|
||||
const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe(
|
||||
Effect.orElseSucceed(() => false),
|
||||
)
|
||||
if (!running) return true
|
||||
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
|
||||
})
|
||||
|
||||
const stop = Effect.fn("cli.daemon.stop")(function* () {
|
||||
const existing = yield* healthy().pipe(Effect.option)
|
||||
// A stale registration may point at a PID that has since been reused by
|
||||
// another process. Only signal the PID after authenticating the server.
|
||||
if (Option.isNone(existing)) return yield* fs.remove(file).pipe(Effect.ignore)
|
||||
const pid = existing.value.pid
|
||||
yield* signal(pid, "SIGTERM")
|
||||
const stopped = yield* awaitStopped(pid).pipe(
|
||||
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
|
||||
Effect.option,
|
||||
)
|
||||
if (Option.isNone(stopped)) {
|
||||
yield* signal(pid, "SIGKILL")
|
||||
yield* awaitStopped(pid).pipe(
|
||||
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
|
||||
)
|
||||
}
|
||||
yield* fs.remove(file).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
const register = Effect.fn("cli.daemon.register")(function* (address: HttpServer.Address) {
|
||||
const temp = file + ".tmp"
|
||||
yield* fs.makeDirectory(directory, { recursive: true })
|
||||
yield* fs.writeFileString(
|
||||
temp,
|
||||
JSON.stringify({ url: HttpServer.formatAddress(address), pid: process.pid }),
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
yield* fs.rename(temp, file)
|
||||
// The metadata file represents this live listener, not persistent config.
|
||||
// Scope shutdown removes it when the server exits normally.
|
||||
yield* Effect.addFinalizer(() => fs.remove(file).pipe(Effect.ignore))
|
||||
})
|
||||
|
||||
return Service.of({ client, start, status, stop, password, register })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export * as Daemon from "./daemon"
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue