diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index f27f4804f5..ba11cd7630 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -275,15 +275,36 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME Spec.make("stop", { description: "Stop the background server" }), Spec.make("get", { description: "Get service configuration", - params: { key: Argument.string("key").pipe(Argument.optional) }, + params: { + key: Argument.string("key").pipe(Argument.withDescription("Service setting or env"), Argument.optional), + name: Argument.string("name").pipe( + Argument.withDescription("Environment variable name"), + Argument.optional, + ), + }, }), Spec.make("set", { description: "Set service configuration", - params: { key: Argument.string("key"), value: Argument.string("value") }, + params: { + key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")), + value: Argument.string("value").pipe( + Argument.withDescription("Setting value or environment variable name"), + ), + nestedValue: Argument.string("env-value").pipe( + Argument.withDescription("Environment variable value"), + Argument.optional, + ), + }, }), Spec.make("unset", { description: "Unset service configuration", - params: { key: Argument.string("key") }, + params: { + key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")), + name: Argument.string("name").pipe( + Argument.withDescription("Environment variable name"), + Argument.optional, + ), + }, }), ], }), diff --git a/packages/cli/src/commands/handlers/service/get.ts b/packages/cli/src/commands/handlers/service/get.ts index fd4b9af384..8130e3e1db 100644 --- a/packages/cli/src/commands/handlers/service/get.ts +++ b/packages/cli/src/commands/handlers/service/get.ts @@ -7,6 +7,8 @@ import { ServiceConfig } from "../../../services/service-config" export default Runtime.handler( Commands.commands.service.commands.get, Effect.fn("cli.service.get")(function* (input) { - process.stdout.write((yield* ServiceConfig.get(Option.getOrUndefined(input.key))) + EOL) + process.stdout.write( + (yield* ServiceConfig.get(Option.getOrUndefined(input.key), Option.getOrUndefined(input.name))) + EOL, + ) }), ) diff --git a/packages/cli/src/commands/handlers/service/set.ts b/packages/cli/src/commands/handlers/service/set.ts index f761c02411..5e8e421efc 100644 --- a/packages/cli/src/commands/handlers/service/set.ts +++ b/packages/cli/src/commands/handlers/service/set.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect" +import { Effect, Option } from "effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { ServiceConfig } from "../../../services/service-config" @@ -6,6 +6,6 @@ import { ServiceConfig } from "../../../services/service-config" export default Runtime.handler( Commands.commands.service.commands.set, Effect.fn("cli.service.set")(function* (input) { - yield* ServiceConfig.set(input.key, input.value) + yield* ServiceConfig.set(input.key, input.value, Option.getOrUndefined(input.nestedValue)) }), ) diff --git a/packages/cli/src/commands/handlers/service/unset.ts b/packages/cli/src/commands/handlers/service/unset.ts index cc738125d3..cbcda88252 100644 --- a/packages/cli/src/commands/handlers/service/unset.ts +++ b/packages/cli/src/commands/handlers/service/unset.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect" +import { Effect, Option } from "effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { ServiceConfig } from "../../../services/service-config" @@ -6,6 +6,6 @@ import { ServiceConfig } from "../../../services/service-config" export default Runtime.handler( Commands.commands.service.commands.unset, Effect.fn("cli.service.unset")(function* (input) { - yield* ServiceConfig.unset(input.key) + yield* ServiceConfig.unset(input.key, Option.getOrUndefined(input.name)) }), ) diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index 53d9a0ccc1..9eeebfb164 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -15,10 +15,11 @@ export const Info = 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), + env: Schema.optional(Schema.Record(Schema.String, Schema.String)), }) export type Info = typeof Info.Type -const keys = ["hostname", "port", "password"] as const +const keys = ["hostname", "port", "password", "env"] as const type Key = (typeof keys)[number] const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info)) @@ -76,7 +77,7 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file: }) function configKey(key: string): Key { - if (key === "hostname" || key === "port" || key === "password") return key + if (key === "hostname" || key === "port" || key === "password" || key === "env") return key throw new Error(`Unknown service config key: ${key}`) } @@ -104,6 +105,7 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi return { file, version: input.checkVersion ? OPENCODE_VERSION : undefined, + env: (yield* read()).env, command: [ ...selfCommand(), "serve", @@ -141,12 +143,14 @@ export const password = Effect.fn("cli.service-config.password")(function* (valu return next }) -export const get = Effect.fn("cli.service-config.get")(function* (key?: string) { +export const get = Effect.fn("cli.service-config.get")(function* (key?: string, name?: string) { if (key === undefined) { const { password: _password, ...safe } = yield* read() return JSON.stringify(safe, null, 2) } - switch (configKey(key)) { + const selected = configKey(key) + if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service get ${selected}`) + switch (selected) { case "hostname": { return (yield* read()).hostname ?? "" } @@ -157,12 +161,19 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string) case "password": { return yield* password() } + case "env": { + const env = (yield* read()).env ?? {} + return name === undefined ? JSON.stringify(env, null, 2) : (env[name] ?? "") + } } throw new Error(`Unknown service config key: ${key}`) }) -export const set = Effect.fn("cli.service-config.set")(function* (key: string, value: string) { - switch (configKey(key)) { +export const set = Effect.fn("cli.service-config.set")(function* (key: string, value: string, nestedValue?: string) { + const selected = configKey(key) + if (selected !== "env" && nestedValue !== undefined) + throw new Error(`Usage: opencode service set ${selected} `) + switch (selected) { case "hostname": { yield* Service.stop(yield* options()) yield* write({ ...(yield* read()), hostname: value }) @@ -180,11 +191,20 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v yield* password(value) return } + case "env": { + if (nestedValue === undefined) throw new Error("Usage: opencode service set env ") + yield* Service.stop(yield* options()) + const existing = yield* read() + yield* write({ ...existing, env: { ...existing.env, [value]: nestedValue } }) + return + } } }) -export const unset = Effect.fn("cli.service-config.unset")(function* (key: string) { - switch (configKey(key)) { +export const unset = Effect.fn("cli.service-config.unset")(function* (key: string, name?: string) { + const selected = configKey(key) + if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service unset ${selected}`) + switch (selected) { case "hostname": { yield* Service.stop(yield* options()) const { hostname: _hostname, ...next } = yield* read() @@ -203,6 +223,15 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin yield* write(next) return } + case "env": { + if (name === undefined) throw new Error("Usage: opencode service unset env ") + yield* Service.stop(yield* options()) + const existing = yield* read() + const { [name]: _removed, ...env } = existing.env ?? {} + const { env: _existingEnv, ...rest } = existing + yield* write(Object.keys(env).length === 0 ? rest : { ...rest, env }) + return + } } }) diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index 704d8773b1..a669e1f5b3 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -60,6 +60,44 @@ test("local channel stores service config with the local service filename", asyn } }) +test("service config manages environment variables", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-env-")) + const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") }) + try { + await Effect.runPromise( + ServiceConfig.set("env", "OPENCODE_SERVICE_ENV_TEST", "configured").pipe( + Effect.provide(layer), + Effect.provide(NodeFileSystem.layer), + ), + ) + expect( + await Effect.runPromise( + ServiceConfig.get("env", "OPENCODE_SERVICE_ENV_TEST").pipe( + Effect.provide(layer), + Effect.provide(NodeFileSystem.layer), + ), + ), + ).toBe("configured") + expect( + ( + await Effect.runPromise( + ServiceConfig.options().pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer)), + ) + ).env, + ).toEqual({ OPENCODE_SERVICE_ENV_TEST: "configured" }) + + await Effect.runPromise( + ServiceConfig.unset("env", "OPENCODE_SERVICE_ENV_TEST").pipe( + Effect.provide(layer), + Effect.provide(NodeFileSystem.layer), + ), + ) + expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({}) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + test("service filenames share release channels and identify preview channels", () => { expect(ServiceConfig.filename("latest")).toBe("service.json") expect(ServiceConfig.filename("dev")).toBe("service.json") diff --git a/packages/client/src/effect/service.ts b/packages/client/src/effect/service.ts index f7e8450f8b..53ee19fd87 100644 --- a/packages/client/src/effect/service.ts +++ b/packages/client/src/effect/service.ts @@ -71,7 +71,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti if (command === undefined) return yield* Effect.fail(new Error("Missing service command")) return yield* Effect.try({ try: () => { - return spawnServiceContender(command, args) + return spawnServiceContender(command, args, options.env) }, catch: (cause) => new Error("Failed to start server", { cause }), }) diff --git a/packages/client/src/promise/service.ts b/packages/client/src/promise/service.ts index bc16f0ad4c..a0f2ad5176 100644 --- a/packages/client/src/promise/service.ts +++ b/packages/client/src/promise/service.ts @@ -51,7 +51,7 @@ export async function ensure(options: EnsureOptions = {}): Promise { const [command, ...args] = options.command ?? ["opencode", "serve", "--service"] if (command === undefined) throw new Error("Missing service command") try { - return spawnServiceContender(command, args) + return spawnServiceContender(command, args, options.env) } catch (cause) { throw new Error("Failed to start server", { cause }) } diff --git a/packages/client/src/service-contender.ts b/packages/client/src/service-contender.ts index f4c4de085b..5d60bf4d60 100644 --- a/packages/client/src/service-contender.ts +++ b/packages/client/src/service-contender.ts @@ -10,8 +10,16 @@ export type ServiceContender = { const stderrLimit = 8 * 1024 -export function spawnServiceContender(command: string, args: ReadonlyArray): ServiceContender { - const child = spawn(command, args, { detached: true, stdio: ["ignore", "ignore", "pipe"] }) +export function spawnServiceContender( + command: string, + args: ReadonlyArray, + env?: Readonly>, +): ServiceContender { + const child = spawn(command, args, { + detached: true, + stdio: ["ignore", "ignore", "pipe"], + env: { ...process.env, ...env }, + }) let error: Error | undefined let closed = false let stderr = Buffer.alloc(0) diff --git a/packages/client/src/service.ts b/packages/client/src/service.ts index 65c3226643..1db78885aa 100644 --- a/packages/client/src/service.ts +++ b/packages/client/src/service.ts @@ -28,6 +28,8 @@ export type EnsureReason = "missing" | "version-mismatch" export type EnsureOptions = DiscoverOptions & { /** Service command and arguments. Defaults to `opencode serve --service`. */ readonly command?: ReadonlyArray + /** Environment variables added to the inherited service process environment. */ + readonly env?: Readonly> /** Called once before spawning a new service process. */ readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void } diff --git a/packages/client/test/fixture/service.ts b/packages/client/test/fixture/service.ts index e4205d711f..3128f9f8a6 100644 --- a/packages/client/test/fixture/service.ts +++ b/packages/client/test/fixture/service.ts @@ -11,6 +11,8 @@ if (mode === "record-start") { await writeFile(registration + ".started", "") process.exit(1) } +if (mode === "environment") + await writeFile(registration + ".environment", process.env.OPENCODE_SERVICE_ENV_TEST ?? "") if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL") if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" || mode === "coordinated-failed-loser") { diff --git a/packages/client/test/promise-service.test.ts b/packages/client/test/promise-service.test.ts index 6d6a766014..2f344015c8 100644 --- a/packages/client/test/promise-service.test.ts +++ b/packages/client/test/promise-service.test.ts @@ -59,6 +59,26 @@ test("ensures a missing service with native promises", async () => { } }) +test("adds configured environment variables with native promises", async () => { + const directory = await temp() + const registration = join(directory, "service.json") + const endpoint = await ensure({ + file: registration, + version: "test", + command: [process.execPath, fixture, registration, "environment"], + env: { OPENCODE_SERVICE_ENV_TEST: "configured" }, + }) + const info = await Bun.file(registration).json() + + try { + expect(endpoint.url).toBe(info.url) + expect(await Bun.file(registration + ".environment").text()).toBe("configured") + } finally { + process.kill(info.pid, "SIGTERM") + await waitForExit(info.pid) + } +}) + test("waits for a live contender when another native contender fails", async () => { const directory = await temp() const registration = join(directory, "service.json") diff --git a/packages/client/test/service.test.ts b/packages/client/test/service.test.ts index dc99cec6ae..7f3da0e32c 100644 --- a/packages/client/test/service.test.ts +++ b/packages/client/test/service.test.ts @@ -68,6 +68,28 @@ test("reuses a compatible registered service", async () => { expect(existing.exitCode).toBe(null) }) +test("adds configured environment variables when starting a service", async () => { + const directory = await temp() + const registration = join(directory, "service.json") + const endpoint = await run( + ensure({ + file: registration, + version: "test", + command: [process.execPath, fixture, registration, "environment"], + env: { OPENCODE_SERVICE_ENV_TEST: "configured" }, + }), + ) + const info = await Bun.file(registration).json() + + try { + expect(endpoint.url).toBe(info.url) + expect(await Bun.file(registration + ".environment").text()).toBe("configured") + } finally { + process.kill(info.pid, "SIGTERM") + await waitForExit(info.pid) + } +}) + test("replaces an incompatible registered service", async () => { const directory = await temp() const registration = join(directory, "service.json")