From 1b83c08b8afb30486fa4b84719b070f606423351 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 30 Jun 2026 00:17:54 -0400 Subject: [PATCH] Update service configuration CLI --- packages/cli/src/commands/commands.ts | 18 ++- packages/cli/src/commands/handlers/serve.ts | 17 ++- .../handlers/service/{password.ts => get.ts} | 8 +- .../cli/src/commands/handlers/service/set.ts | 11 ++ .../src/commands/handlers/service/unset.ts | 11 ++ packages/cli/src/index.ts | 4 +- packages/cli/src/services/daemon.ts | 121 +++++++++++++++--- 7 files changed, 157 insertions(+), 33 deletions(-) rename packages/cli/src/commands/handlers/service/{password.ts => get.ts} (54%) create mode 100644 packages/cli/src/commands/handlers/service/set.ts create mode 100644 packages/cli/src/commands/handlers/service/unset.ts diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 18db4006a8..e644c51be0 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -44,18 +44,26 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO 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("get", { + description: "Get service configuration", + params: { key: Argument.string("key").pipe(Argument.optional) }, + }), + Spec.make("set", { + description: "Set service configuration", + params: { key: Argument.string("key"), value: Argument.string("value") }, + }), + Spec.make("unset", { + description: "Unset service configuration", + params: { key: Argument.string("key") }, }), ], }), Spec.make("serve", { description: "Start the v2 API server", params: { - hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")), + hostname: Flag.string("hostname").pipe(Flag.optional), port: Flag.integer("port").pipe(Flag.optional), - register: Flag.boolean("register").pipe(Flag.withDefault(false)), + service: Flag.boolean("service").pipe(Flag.withDefault(false)), stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)), }, }), diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index 1357e7631c..3cbeded098 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -12,6 +12,7 @@ import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Daemon } from "../../services/daemon" import { Updater } from "../../services/updater" +import { randomBytes } from "crypto" export default Runtime.handler( Commands.commands.serve, @@ -21,18 +22,28 @@ export default Runtime.handler( const daemon = yield* Daemon.Service const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD - const password = input.stdio ? standalonePassword : yield* daemon.password() + const config = input.service ? yield* daemon.config() : {} + const password = input.service + ? yield* daemon.password() + : standalonePassword || randomBytes(32).toString("base64url") if (!password) return yield* Effect.fail(new Error("Missing server password")) - const address = yield* listen(input.hostname, input.port, password) + const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1" + const port = Option.isSome(input.port) + ? input.port + : config.port === undefined + ? Option.none() + : Option.some(config.port) + const address = yield* listen(hostname, port, password) yield* Effect.tryPromise(() => createOpencodeClient({ baseUrl: HttpServer.formatAddress(address), headers: ServerAuth.headers({ password }), }).v2.location.get(undefined, { throwOnError: true }), ) - if (input.register) yield* daemon.register(address) + if (input.service) yield* daemon.register(address) const url = HttpServer.formatAddress(address) console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`) + if (!input.service && !input.stdio && !standalonePassword) console.log(`server password ${password}`) const updater = yield* Updater.Service yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped) return yield* (input.stdio ? waitForStdinClose() : Effect.never) diff --git a/packages/cli/src/commands/handlers/service/password.ts b/packages/cli/src/commands/handlers/service/get.ts similarity index 54% rename from packages/cli/src/commands/handlers/service/password.ts rename to packages/cli/src/commands/handlers/service/get.ts index 6bf49d50d0..aaaebf14a1 100644 --- a/packages/cli/src/commands/handlers/service/password.ts +++ b/packages/cli/src/commands/handlers/service/get.ts @@ -6,11 +6,9 @@ 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) { + Commands.commands.service.commands.get, + Effect.fn("cli.service.get")(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) + process.stdout.write((yield* daemon.get(Option.getOrUndefined(input.key))) + EOL) }), ) diff --git a/packages/cli/src/commands/handlers/service/set.ts b/packages/cli/src/commands/handlers/service/set.ts new file mode 100644 index 0000000000..d1181ef14a --- /dev/null +++ b/packages/cli/src/commands/handlers/service/set.ts @@ -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.set, + Effect.fn("cli.service.set")(function* (input) { + yield* (yield* Daemon.Service).set(input.key, input.value) + }), +) diff --git a/packages/cli/src/commands/handlers/service/unset.ts b/packages/cli/src/commands/handlers/service/unset.ts new file mode 100644 index 0000000000..f16bbe32cc --- /dev/null +++ b/packages/cli/src/commands/handlers/service/unset.ts @@ -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.unset, + Effect.fn("cli.service.unset")(function* (input) { + yield* (yield* Daemon.Service).unset(input.key) + }), +) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f8a95977df..e013152adb 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -30,7 +30,9 @@ const Handlers = Runtime.handlers(Commands, { restart: () => import("./commands/handlers/service/restart"), status: () => import("./commands/handlers/service/status"), stop: () => import("./commands/handlers/service/stop"), - password: () => import("./commands/handlers/service/password"), + get: () => import("./commands/handlers/service/get"), + set: () => import("./commands/handlers/service/set"), + unset: () => import("./commands/handlers/service/unset"), }, serve: () => import("./commands/handlers/serve"), }) diff --git a/packages/cli/src/services/daemon.ts b/packages/cli/src/services/daemon.ts index 964822d1d2..98c1540388 100644 --- a/packages/cli/src/services/daemon.ts +++ b/packages/cli/src/services/daemon.ts @@ -15,6 +15,10 @@ export interface Interface { readonly status: () => Effect.Effect readonly stop: () => Effect.Effect readonly password: (value?: string) => Effect.Effect + readonly config: () => Effect.Effect + readonly get: (key?: string) => Effect.Effect + readonly set: (key: string, value: string) => Effect.Effect + readonly unset: (key: string) => Effect.Effect readonly register: (address: HttpServer.Address) => Effect.Effect } @@ -28,9 +32,20 @@ const Registration = Schema.Struct({ }) type Registration = typeof Registration.Type -const Config = Schema.Struct({ +const ServiceConfig = Schema.Struct({ + hostname: Schema.optional(Schema.String), + port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))), password: Schema.optional(Schema.String), }) +export type ServiceConfig = typeof ServiceConfig.Type + +const serviceConfigKeys = ["hostname", "port", "password"] as const +type ServiceConfigKey = (typeof serviceConfigKeys)[number] + +function serviceConfigKey(key: string): ServiceConfigKey { + if (serviceConfigKeys.includes(key as ServiceConfigKey)) return key as ServiceConfigKey + throw new Error(`Unknown service config key: ${key}`) +} function sameRegistration(left: Registration, right: Registration) { return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid @@ -41,32 +56,100 @@ export const layer = Layer.effect( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const directory = Global.Path.state - const file = path.join(directory, InstallationChannel === "local" ? "server-local.json" : "server.json") + const file = path.join(directory, InstallationChannel === "local" ? "service-local.json" : "service.json") const configFile = path.join(Global.Path.config, "service.json") - const legacyPasswordFile = path.join(directory, "password") const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration)) - const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(Config)) + const decodeServiceConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(ServiceConfig)) + + const config = Effect.fn("cli.daemon.config")(function* () { + return yield* fs.readFileString(configFile).pipe( + Effect.flatMap(decodeServiceConfig), + Effect.catch(() => Effect.succeed({} as ServiceConfig)), + ) + }) + + const writeConfig = Effect.fn("cli.daemon.writeConfig")(function* (value: ServiceConfig) { + const temp = configFile + ".tmp" + yield* fs.makeDirectory(path.dirname(configFile), { recursive: true }) + yield* fs.writeFileString(temp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }) + yield* fs.rename(temp, configFile) + }) const password = Effect.fn("cli.daemon.password")(function* (value?: string) { - const config = yield* fs - .readFileString(configFile) - .pipe(Effect.flatMap(decodeConfig), Effect.catch(() => Effect.succeed(undefined))) - if (value === undefined && config?.password) return config.password - - const legacy = yield* fs - .readFileString(legacyPasswordFile) - .pipe(Effect.catch(() => Effect.succeed(undefined))) - const next = value ?? legacy ?? randomBytes(32).toString("base64url") + const existing = yield* config() + if (value === undefined && existing.password) return existing.password + const next = value ?? randomBytes(32).toString("base64url") // Keep one private credential across server restarts so discovered clients // can reconnect without exposing a password flag or environment variable. - const temp = configFile + ".tmp" - yield* fs.writeFileString(temp, JSON.stringify({ password: next }, null, 2) + "\n", { mode: 0o600 }) - yield* fs.rename(temp, configFile) - if (legacy) yield* fs.remove(legacyPasswordFile).pipe(Effect.ignore) + yield* writeConfig({ ...existing, password: next }) return next }) + const get = Effect.fn("cli.daemon.get")(function* (key?: string) { + if (key === undefined) { + const { password: _password, ...safe } = yield* config() + return JSON.stringify(safe, null, 2) + } + switch (serviceConfigKey(key)) { + case "hostname": { + return (yield* config()).hostname ?? "" + } + case "port": { + const port = (yield* config()).port + return port === undefined ? "" : String(port) + } + case "password": { + return yield* password() + } + } + }) + + const set = Effect.fn("cli.daemon.set")(function* (key: string, value: string) { + switch (serviceConfigKey(key)) { + case "hostname": { + yield* stop() + yield* writeConfig({ ...(yield* config()), hostname: value }) + return + } + case "port": { + const port = Number(value) + if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error("Port must be between 1 and 65535") + yield* stop() + yield* writeConfig({ ...(yield* config()), port }) + return + } + case "password": { + yield* stop() + yield* password(value) + return + } + } + }) + + const unset = Effect.fn("cli.daemon.unset")(function* (key: string) { + switch (serviceConfigKey(key)) { + case "hostname": { + yield* stop() + const { hostname: _hostname, ...next } = yield* config() + yield* writeConfig(next) + return + } + case "port": { + yield* stop() + const { port: _port, ...next } = yield* config() + yield* writeConfig(next) + return + } + case "password": { + yield* stop() + const { password: _password, ...next } = yield* config() + yield* writeConfig(next) + return + } + } + }) + const registration = Effect.fnUntraced(function* () { return yield* fs.readFileString(file).pipe(Effect.flatMap(decodeRegistration)) }) @@ -131,7 +214,7 @@ export const layer = Layer.effect( return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint")) yield* Effect.try({ try: () => { - spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--register"], { + spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--service"], { detached: true, stdio: "ignore", }).unref() @@ -197,7 +280,7 @@ export const layer = Layer.effect( ) }) - return Service.of({ client, transport, start, status, stop, password, register }) + return Service.of({ client, transport, start, status, stop, password, config, get, set, unset, register }) }), )