mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee66e11c27 |
@@ -12,6 +12,11 @@ const ServerParams = {
|
|||||||
Flag.withDescription("Connect to a server URL instead of the background service"),
|
Flag.withDescription("Connect to a server URL instead of the background service"),
|
||||||
Flag.optional,
|
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", {
|
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),
|
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", {
|
Spec.make("service", {
|
||||||
description: "Manage the background server",
|
description: "Manage the background server",
|
||||||
commands: [
|
commands: [
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default Runtime.handler(
|
|||||||
Effect.fn("cli.api")(function* (input) {
|
Effect.fn("cli.api")(function* (input) {
|
||||||
const server = yield* ServerConnection.resolve({
|
const server = yield* ServerConnection.resolve({
|
||||||
server: Option.getOrUndefined(input.server),
|
server: Option.getOrUndefined(input.server),
|
||||||
|
remote: Option.getOrUndefined(input.remote),
|
||||||
standalone: input.standalone,
|
standalone: input.standalone,
|
||||||
mismatch: "ignore",
|
mismatch: "ignore",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default Runtime.handler(Commands, (input) =>
|
|||||||
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
||||||
const server = yield* ServerConnection.resolve({
|
const server = yield* ServerConnection.resolve({
|
||||||
server: Option.getOrUndefined(input.server),
|
server: Option.getOrUndefined(input.server),
|
||||||
|
remote: Option.getOrUndefined(input.remote),
|
||||||
standalone: input.standalone,
|
standalone: input.standalone,
|
||||||
onStart: (reason, previousVersion) => {
|
onStart: (reason, previousVersion) => {
|
||||||
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
|
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
|||||||
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
|
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
|
||||||
yield* Effect.promise(async () => validateMiniTerminal())
|
yield* Effect.promise(async () => validateMiniTerminal())
|
||||||
const serverURL = Option.getOrUndefined(input.server)
|
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(() =>
|
yield* Effect.promise(() =>
|
||||||
runMini({
|
runMini({
|
||||||
server,
|
server,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export default Runtime.handler(Commands.commands.run, (input) =>
|
|||||||
const separator = process.argv.indexOf("--", 2)
|
const separator = process.argv.indexOf("--", 2)
|
||||||
const server = yield* ServerConnection.resolve({
|
const server = yield* ServerConnection.resolve({
|
||||||
server: Option.getOrUndefined(input.server),
|
server: Option.getOrUndefined(input.server),
|
||||||
|
remote: Option.getOrUndefined(input.remote),
|
||||||
standalone: input.standalone,
|
standalone: input.standalone,
|
||||||
})
|
})
|
||||||
yield* Effect.promise(() =>
|
yield* Effect.promise(() =>
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -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`)
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
import { Config } from "@opencode-ai/tui/config"
|
import { Config } from "@opencode-ai/tui/config"
|
||||||
import { Schema } from "effect"
|
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<typeof Server>
|
||||||
|
|
||||||
|
export const Info = Schema.Struct({
|
||||||
|
...Config.Info.fields,
|
||||||
|
servers: Schema.optional(Schema.Record(Schema.String, Server)),
|
||||||
|
})
|
||||||
export type Info = Schema.Schema.Type<typeof Info>
|
export type Info = Schema.Schema.Type<typeof Info>
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ const Handlers = Runtime.handlers(Commands, {
|
|||||||
mini: () => import("./commands/handlers/mini"),
|
mini: () => import("./commands/handlers/mini"),
|
||||||
run: () => import("./commands/handlers/run"),
|
run: () => import("./commands/handlers/run"),
|
||||||
pair: () => import("./commands/handlers/pair"),
|
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: {
|
service: {
|
||||||
start: () => import("./commands/handlers/service/start"),
|
start: () => import("./commands/handlers/service/start"),
|
||||||
restart: () => import("./commands/handlers/service/restart"),
|
restart: () => import("./commands/handlers/service/restart"),
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/
|
|||||||
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
|
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { Effect, Redacted } from "effect"
|
import { Effect, Redacted } from "effect"
|
||||||
|
import { Config } from "../config"
|
||||||
import { Env } from "../env"
|
import { Env } from "../env"
|
||||||
import { ServiceConfig } from "./service-config"
|
import { ServiceConfig } from "./service-config"
|
||||||
import { Standalone } from "./standalone"
|
import { Standalone } from "./standalone"
|
||||||
|
|
||||||
export type Args = {
|
export type Args = {
|
||||||
readonly server?: string
|
readonly server?: string
|
||||||
|
readonly remote?: string
|
||||||
readonly standalone?: boolean
|
readonly standalone?: boolean
|
||||||
readonly mismatch?: "replace" | "ignore" | "error"
|
readonly mismatch?: "replace" | "ignore" | "error"
|
||||||
readonly onStart?: EnsureOptions["onStart"]
|
readonly onStart?: EnsureOptions["onStart"]
|
||||||
@@ -19,13 +21,28 @@ export type Resolved = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) {
|
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) {
|
||||||
if (args.server !== undefined && args.standalone)
|
if (args.server !== undefined && args.remote !== undefined)
|
||||||
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
|
return yield* Effect.fail(new Error("--server and --remote cannot be combined"))
|
||||||
if (args.server !== undefined) {
|
if ((args.server !== undefined || args.remote !== undefined) && args.standalone)
|
||||||
const password = yield* Env.password
|
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 = {
|
const endpoint = {
|
||||||
url: args.server,
|
url,
|
||||||
auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined,
|
auth: password
|
||||||
|
? {
|
||||||
|
type: "basic" as const,
|
||||||
|
username: profile?.username ?? "opencode",
|
||||||
|
password,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
} satisfies Endpoint
|
} satisfies Endpoint
|
||||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||||
const health = yield* Effect.tryPromise({
|
const health = yield* Effect.tryPromise({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Effect, FileSystem, Scope } from "effect"
|
|||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import { Config } from "../src/config"
|
||||||
import { ServerConnection } from "../src/services/server-connection"
|
import { ServerConnection } from "../src/services/server-connection"
|
||||||
import { ServiceConfig } from "../src/services/service-config"
|
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 registration = path.join(root, "state", ServiceConfig.filename())
|
||||||
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
|
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
|
||||||
const runPromise = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem | Scope.Scope>) =>
|
const runPromise = <A, E>(
|
||||||
Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer), Effect.scoped))
|
effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem | Scope.Scope | Config.Service>,
|
||||||
|
) =>
|
||||||
|
Effect.runPromise(
|
||||||
|
effect.pipe(
|
||||||
|
Effect.provide(Config.layer),
|
||||||
|
Effect.provide(layer),
|
||||||
|
Effect.provide(NodeFileSystem.layer),
|
||||||
|
Effect.scoped,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(path.dirname(registration), { recursive: true })
|
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() }))
|
const explicit = await runPromise(ServerConnection.resolve({ server: server.url.toString() }))
|
||||||
expect(explicit.endpoint.url).toBe(server.url.toString())
|
expect(explicit.endpoint.url).toBe(server.url.toString())
|
||||||
expect(explicit.service).toBeUndefined()
|
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 {
|
} finally {
|
||||||
await server.stop(true)
|
await server.stop(true)
|
||||||
await fs.rm(root, { recursive: true, force: 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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -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<string, string> } = {},
|
||||||
|
) {
|
||||||
|
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")
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user