From ee66e11c27046059458eae520b2357cb8004b55e Mon Sep 17 00:00:00 2001 From: Ryan Vogel <89211796+R44VC0RP@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:15:58 +0000 Subject: [PATCH] feat(cli): add saved remote servers --- packages/cli/src/commands/commands.ts | 26 ++++++++ packages/cli/src/commands/handlers/api.ts | 1 + packages/cli/src/commands/handlers/default.ts | 1 + packages/cli/src/commands/handlers/mini.ts | 6 +- packages/cli/src/commands/handlers/run.ts | 1 + .../cli/src/commands/handlers/server/add.ts | 23 ++++++++ .../cli/src/commands/handlers/server/list.ts | 21 +++++++ .../src/commands/handlers/server/remove.ts | 19 ++++++ packages/cli/src/config/schema.ts | 12 +++- packages/cli/src/index.ts | 5 ++ .../cli/src/services/server-connection.ts | 29 +++++++-- packages/cli/test/server-connection.test.ts | 50 +++++++++++++++- packages/cli/test/server-profile.test.ts | 59 +++++++++++++++++++ 13 files changed, 243 insertions(+), 10 deletions(-) create mode 100644 packages/cli/src/commands/handlers/server/add.ts create mode 100644 packages/cli/src/commands/handlers/server/list.ts create mode 100644 packages/cli/src/commands/handlers/server/remove.ts create mode 100644 packages/cli/test/server-profile.test.ts diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 771f57f42a..e33db2defc 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -12,6 +12,11 @@ const ServerParams = { Flag.withDescription("Connect to a server URL instead of the background service"), Flag.optional, ), + remote: Flag.string("remote").pipe( + Flag.withAlias("r"), + Flag.withDescription("Connect to a saved remote server"), + Flag.optional, + ), } export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", { @@ -201,6 +206,27 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden), }, }), + Spec.make("server", { + description: "Manage saved server connections", + commands: [ + Spec.make("list", { description: "List saved server connections" }), + Spec.make("add", { + description: "Save a server connection (and OPENCODE_PASSWORD when set)", + params: { + name: Argument.string("name").pipe(Argument.withDescription("Name for the saved server")), + url: Argument.string("url").pipe(Argument.withDescription("Server URL")), + username: Flag.string("username").pipe( + Flag.withDescription("Basic authentication username"), + Flag.optional, + ), + }, + }), + Spec.make("remove", { + description: "Remove a saved server connection", + params: { name: Argument.string("name").pipe(Argument.withDescription("Saved server name")) }, + }), + ], + }), Spec.make("service", { description: "Manage the background server", commands: [ diff --git a/packages/cli/src/commands/handlers/api.ts b/packages/cli/src/commands/handlers/api.ts index aa612f67c1..18ecb99d33 100644 --- a/packages/cli/src/commands/handlers/api.ts +++ b/packages/cli/src/commands/handlers/api.ts @@ -20,6 +20,7 @@ export default Runtime.handler( Effect.fn("cli.api")(function* (input) { const server = yield* ServerConnection.resolve({ server: Option.getOrUndefined(input.server), + remote: Option.getOrUndefined(input.remote), standalone: input.standalone, mismatch: "ignore", }) diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 535003ed1a..aeb409ae3e 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -20,6 +20,7 @@ export default Runtime.handler(Commands, (input) => yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close())) const server = yield* ServerConnection.resolve({ server: Option.getOrUndefined(input.server), + remote: Option.getOrUndefined(input.remote), standalone: input.standalone, onStart: (reason, previousVersion) => { if (reason === "version-mismatch" && preflight.begin(previousVersion)) return diff --git a/packages/cli/src/commands/handlers/mini.ts b/packages/cli/src/commands/handlers/mini.ts index e44f7e0b2d..356647c36e 100644 --- a/packages/cli/src/commands/handlers/mini.ts +++ b/packages/cli/src/commands/handlers/mini.ts @@ -8,7 +8,11 @@ export default Runtime.handler(Commands.commands.mini, (input) => const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini")) yield* Effect.promise(async () => validateMiniTerminal()) const serverURL = Option.getOrUndefined(input.server) - const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone }) + const server = yield* ServerConnection.resolve({ + server: serverURL, + remote: Option.getOrUndefined(input.remote), + standalone: input.standalone, + }) yield* Effect.promise(() => runMini({ server, diff --git a/packages/cli/src/commands/handlers/run.ts b/packages/cli/src/commands/handlers/run.ts index 50fbaa4e2c..15b2d97dd7 100644 --- a/packages/cli/src/commands/handlers/run.ts +++ b/packages/cli/src/commands/handlers/run.ts @@ -9,6 +9,7 @@ export default Runtime.handler(Commands.commands.run, (input) => const separator = process.argv.indexOf("--", 2) const server = yield* ServerConnection.resolve({ server: Option.getOrUndefined(input.server), + remote: Option.getOrUndefined(input.remote), standalone: input.standalone, }) yield* Effect.promise(() => diff --git a/packages/cli/src/commands/handlers/server/add.ts b/packages/cli/src/commands/handlers/server/add.ts new file mode 100644 index 0000000000..1edca10735 --- /dev/null +++ b/packages/cli/src/commands/handlers/server/add.ts @@ -0,0 +1,23 @@ +import { Effect, Option, Redacted } from "effect" +import { Commands } from "../../commands" +import { Config } from "../../../config" +import { Env } from "../../../env" +import { Runtime } from "../../../framework/runtime" + +export default Runtime.handler( + Commands.commands.server.commands.add, + Effect.fn("cli.server.add")(function* (input) { + if (!URL.canParse(input.url)) return yield* Effect.fail(new Error(`Invalid server URL: ${input.url}`)) + const config = yield* Config.Service + const password = yield* Env.password + yield* config.update((draft) => { + draft.servers ??= {} + draft.servers[input.name] = { + url: input.url, + username: Option.getOrUndefined(input.username), + password: password ? Redacted.value(password) : undefined, + } + }) + process.stdout.write(`Saved server ${input.name}\n`) + }), +) diff --git a/packages/cli/src/commands/handlers/server/list.ts b/packages/cli/src/commands/handlers/server/list.ts new file mode 100644 index 0000000000..d1088a31be --- /dev/null +++ b/packages/cli/src/commands/handlers/server/list.ts @@ -0,0 +1,21 @@ +import { Effect } from "effect" +import { Commands } from "../../commands" +import { Config } from "../../../config" +import { Runtime } from "../../../framework/runtime" + +export default Runtime.handler( + Commands.commands.server.commands.list, + Effect.fn("cli.server.list")(function* () { + const config = yield* Config.Service + const servers = Object.entries((yield* config.get()).servers ?? {}) + if (!servers.length) { + process.stdout.write("No saved servers\n") + return + } + process.stdout.write( + servers + .map(([name, server]) => `${name}\t${server.url}${server.username ? `\t${server.username}` : ""}`) + .join("\n") + "\n", + ) + }), +) diff --git a/packages/cli/src/commands/handlers/server/remove.ts b/packages/cli/src/commands/handlers/server/remove.ts new file mode 100644 index 0000000000..b195ff4378 --- /dev/null +++ b/packages/cli/src/commands/handlers/server/remove.ts @@ -0,0 +1,19 @@ +import { Effect } from "effect" +import { Commands } from "../../commands" +import { Config } from "../../../config" +import { Runtime } from "../../../framework/runtime" + +export default Runtime.handler( + Commands.commands.server.commands.remove, + Effect.fn("cli.server.remove")(function* (input) { + const config = yield* Config.Service + if ((yield* config.get()).servers?.[input.name] === undefined) + return yield* Effect.fail(new Error(`Saved server "${input.name}" not found`)) + yield* config.update((draft) => { + if (!draft.servers) return + delete draft.servers[input.name] + if (!Object.keys(draft.servers).length) delete draft.servers + }) + process.stdout.write(`Removed server ${input.name}\n`) + }), +) diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index fc6b9724c6..37256de6bb 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -1,5 +1,15 @@ import { Config } from "@opencode-ai/tui/config" import { Schema } from "effect" -export const Info = Schema.Struct({ ...Config.Info.fields }) +export const Server = Schema.Struct({ + url: Schema.String, + username: Schema.optional(Schema.String), + password: Schema.optional(Schema.String), +}) +export type Server = Schema.Schema.Type + +export const Info = Schema.Struct({ + ...Config.Info.fields, + servers: Schema.optional(Schema.Record(Schema.String, Server)), +}) export type Info = Schema.Schema.Type diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7ee80846ed..b47b6890ae 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -38,6 +38,11 @@ const Handlers = Runtime.handlers(Commands, { mini: () => import("./commands/handlers/mini"), run: () => import("./commands/handlers/run"), pair: () => import("./commands/handlers/pair"), + server: { + list: () => import("./commands/handlers/server/list"), + add: () => import("./commands/handlers/server/add"), + remove: () => import("./commands/handlers/server/remove"), + }, service: { start: () => import("./commands/handlers/service/start"), restart: () => import("./commands/handlers/service/restart"), diff --git a/packages/cli/src/services/server-connection.ts b/packages/cli/src/services/server-connection.ts index 4eccc95bc9..5bcab2082d 100644 --- a/packages/cli/src/services/server-connection.ts +++ b/packages/cli/src/services/server-connection.ts @@ -2,12 +2,14 @@ import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/ import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Effect, Redacted } from "effect" +import { Config } from "../config" import { Env } from "../env" import { ServiceConfig } from "./service-config" import { Standalone } from "./standalone" export type Args = { readonly server?: string + readonly remote?: string readonly standalone?: boolean readonly mismatch?: "replace" | "ignore" | "error" readonly onStart?: EnsureOptions["onStart"] @@ -19,13 +21,28 @@ export type Resolved = { } export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) { - if (args.server !== undefined && args.standalone) - return yield* Effect.fail(new Error("--server and --standalone cannot be combined")) - if (args.server !== undefined) { - const password = yield* Env.password + if (args.server !== undefined && args.remote !== undefined) + return yield* Effect.fail(new Error("--server and --remote cannot be combined")) + if ((args.server !== undefined || args.remote !== undefined) && args.standalone) + return yield* Effect.fail(new Error("--server, --remote, and --standalone cannot be combined")) + if (args.server !== undefined || args.remote !== undefined) { + const config = yield* Config.Service + const profile = args.remote === undefined ? undefined : (yield* config.get()).servers?.[args.remote] + if (args.remote !== undefined && profile === undefined) + return yield* Effect.fail(new Error(`Saved server "${args.remote}" not found`)) + const environmentPassword = yield* Env.password + const password = environmentPassword ? Redacted.value(environmentPassword) : profile?.password + const url = profile?.url ?? args.server + if (url === undefined) return yield* Effect.fail(new Error("Missing server URL")) const endpoint = { - url: args.server, - auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined, + url, + auth: password + ? { + type: "basic" as const, + username: profile?.username ?? "opencode", + password, + } + : undefined, } satisfies Endpoint const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const health = yield* Effect.tryPromise({ diff --git a/packages/cli/test/server-connection.test.ts b/packages/cli/test/server-connection.test.ts index 41a061f57c..e73de98571 100644 --- a/packages/cli/test/server-connection.test.ts +++ b/packages/cli/test/server-connection.test.ts @@ -6,6 +6,7 @@ import { Effect, FileSystem, Scope } from "effect" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" +import { Config } from "../src/config" import { ServerConnection } from "../src/services/server-connection" import { ServiceConfig } from "../src/services/service-config" @@ -24,8 +25,17 @@ test("resolution groups Effect-native lifecycle operations only for the managed }) const registration = path.join(root, "state", ServiceConfig.filename()) const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") }) - const runPromise = (effect: Effect.Effect) => - Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer), Effect.scoped)) + const runPromise = ( + effect: Effect.Effect, + ) => + Effect.runPromise( + effect.pipe( + Effect.provide(Config.layer), + Effect.provide(layer), + Effect.provide(NodeFileSystem.layer), + Effect.scoped, + ), + ) try { await fs.mkdir(path.dirname(registration), { recursive: true }) @@ -50,8 +60,44 @@ test("resolution groups Effect-native lifecycle operations only for the managed const explicit = await runPromise(ServerConnection.resolve({ server: server.url.toString() })) expect(explicit.endpoint.url).toBe(server.url.toString()) expect(explicit.service).toBeUndefined() + + await runPromise( + Effect.gen(function* () { + const config = yield* Config.Service + yield* config.update((draft) => { + draft.servers = { + mac: { url: server.url.toString(), username: "ryan", password: "secret" }, + } + }) + }), + ) + const saved = await runPromise(ServerConnection.resolve({ remote: "mac" })) + expect(saved.endpoint).toEqual({ + url: server.url.toString(), + auth: { type: "basic", username: "ryan", password: "secret" }, + }) + expect(saved.service).toBeUndefined() } finally { await server.stop(true) await fs.rm(root, { recursive: true, force: true }) } }) + +test("reports an unknown saved server", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-server-profile-")) + const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") }) + + try { + const result = await Effect.runPromise( + Effect.flip(ServerConnection.resolve({ remote: "missing" })).pipe( + Effect.provide(Config.layer), + Effect.provide(layer), + Effect.provide(NodeFileSystem.layer), + Effect.scoped, + ), + ) + expect(result.message).toBe('Saved server "missing" not found') + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) diff --git a/packages/cli/test/server-profile.test.ts b/packages/cli/test/server-profile.test.ts new file mode 100644 index 0000000000..bd37a4cb3d --- /dev/null +++ b/packages/cli/test/server-profile.test.ts @@ -0,0 +1,59 @@ +import { afterEach, expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +const directories: string[] = [] + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true }))) +}) + +async function cli( + args: string[], + options: { directory?: string; environment?: Record } = {}, +) { + const directory = options.directory ?? (await fs.mkdtemp(path.join(os.tmpdir(), "opencode-server-profile-"))) + if (!options.directory) directories.push(directory) + const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], { + cwd: path.join(import.meta.dir, ".."), + env: { ...process.env, OPENCODE_CONFIG_DIR: directory, ...options.environment }, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]) + return { directory, stdout, stderr, exitCode } +} + +test("adds, lists, and removes a saved server", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-server-profile-shared-")) + directories.push(directory) + + const added = await cli(["server", "add", "mac", "http://127.0.0.1:4096", "--username", "ryan"], { + directory, + environment: { OPENCODE_PASSWORD: "secret" }, + }) + expect(added).toMatchObject({ exitCode: 0, stdout: "Saved server mac\n" }) + expect(await Bun.file(path.join(directory, "cli.json")).json()).toMatchObject({ + servers: { mac: { url: "http://127.0.0.1:4096", username: "ryan", password: "secret" } }, + }) + + const listed = await cli(["server", "list"], { directory }) + expect(listed).toMatchObject({ exitCode: 0, stdout: "mac\thttp://127.0.0.1:4096\tryan\n" }) + expect(listed.stdout).not.toContain("secret") + + const removed = await cli(["server", "remove", "mac"], { directory }) + expect(removed).toMatchObject({ exitCode: 0, stdout: "Removed server mac\n" }) + expect(await Bun.file(path.join(directory, "cli.json")).json()).not.toHaveProperty("servers") +}) + +test("exposes a remote shorthand without replacing the session shorthand", async () => { + const result = await cli(["--help"]) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain("--remote, -r string") + expect(result.stdout).toContain("--session, -s string") +})