mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-25 02:09:54 +00:00
213 lines
9.2 KiB
TypeScript
213 lines
9.2 KiB
TypeScript
import { NodeFileSystem, NodeServices } from "@effect/platform-node"
|
|
import { Global } from "@opencode/util/global"
|
|
import { OPENCODE_VERSION } from "../src/version"
|
|
import { expect, test } from "bun:test"
|
|
import { Console, Effect, FileSystem, Option, Scope } from "effect"
|
|
import { Command } from "effect/cli"
|
|
import fs from "node:fs/promises"
|
|
import os from "node:os"
|
|
import path from "node:path"
|
|
import { Commands, PrintLogs, Server, Standalone } from "../src/commands/commands"
|
|
import { Spec } from "../src/framework/spec"
|
|
import { ServerConnection } from "../src/services/server-connection"
|
|
import { ServiceConfig } from "../src/services/service-config"
|
|
import { isolatedEnv } from "./fixture/environment"
|
|
|
|
test("resolution groups Effect-native lifecycle operations only for the managed service", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-server-resolution-"))
|
|
const id = "server-resolution-test"
|
|
const server = Bun.serve({
|
|
port: 0,
|
|
fetch() {
|
|
return Response.json({
|
|
version: OPENCODE_VERSION,
|
|
pid: process.pid,
|
|
urls: [],
|
|
})
|
|
},
|
|
})
|
|
const registration = path.join(root, "state", ServiceConfig.filename())
|
|
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>) =>
|
|
Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer), Effect.scoped))
|
|
|
|
try {
|
|
await fs.mkdir(path.dirname(registration), { recursive: true })
|
|
await fs.writeFile(
|
|
registration,
|
|
JSON.stringify({
|
|
id,
|
|
version: OPENCODE_VERSION,
|
|
url: server.url.toString(),
|
|
pid: process.pid,
|
|
}),
|
|
)
|
|
const resolved = await runPromise(ServerConnection.resolve())
|
|
|
|
expect(resolved.endpoint.url).toBe(server.url.toString())
|
|
expect(resolved.service).toBeDefined()
|
|
if (!resolved.service) throw new Error("Expected managed service capabilities")
|
|
expect(Effect.isEffect(resolved.service.reconnect())).toBe(true)
|
|
expect(Effect.isEffect(resolved.service.restart())).toBe(true)
|
|
expect(await runPromise(resolved.service.reconnect())).toEqual(resolved.endpoint)
|
|
|
|
const explicit = await runPromise(ServerConnection.resolve({ server: server.url.toString() }))
|
|
expect(explicit.endpoint.url).toBe(server.url.toString())
|
|
expect(explicit.service).toBeUndefined()
|
|
} finally {
|
|
await server.stop(true)
|
|
await fs.rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("service options only require a matching version when requested", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-options-"))
|
|
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>) =>
|
|
Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer), Effect.scoped))
|
|
|
|
try {
|
|
expect((await runPromise(ServiceConfig.options())).version).toBeUndefined()
|
|
expect((await runPromise(ServiceConfig.options({ checkVersion: true }))).version).toBe(OPENCODE_VERSION)
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("global --server and --standalone flags parse in any position for server-connecting commands and reject on local-only commands", async () => {
|
|
const captured: Array<{
|
|
readonly path: string
|
|
readonly server: string | undefined
|
|
readonly standalone: boolean
|
|
}> = []
|
|
|
|
function bindTree(
|
|
node: Spec.Any,
|
|
prefix: ReadonlyArray<string> = [],
|
|
): Command.Command<string, unknown, unknown, unknown, never> {
|
|
const currentPath = [...prefix, node.name].join(" ")
|
|
const withHandler = node.spec.pipe(
|
|
Command.withHandler((input: Record<string, unknown>) =>
|
|
Effect.gen(function* () {
|
|
const serverFlag = yield* Effect.serviceOption(Server).pipe(Effect.map(Option.flatten))
|
|
const standaloneFlag = yield* Effect.serviceOption(Standalone)
|
|
const inputServer = Option.isOption(input.server)
|
|
? Option.getOrUndefined(input.server as Option.Option<string>)
|
|
: undefined
|
|
const inputStandalone = typeof input.standalone === "boolean" ? input.standalone : undefined
|
|
captured.push({
|
|
path: currentPath,
|
|
server: inputServer ?? Option.getOrUndefined(serverFlag),
|
|
standalone: inputStandalone ?? Option.getOrElse(standaloneFlag, () => false),
|
|
})
|
|
}),
|
|
),
|
|
) as Command.Command<string, unknown, unknown, unknown, never>
|
|
const children = Object.values(node.commands)
|
|
if (children.length === 0) return withHandler
|
|
return withHandler.pipe(
|
|
Command.withSubcommands(children.map((child) => bindTree(child, [...prefix, node.name]))),
|
|
) as Command.Command<string, unknown, unknown, unknown, never>
|
|
}
|
|
|
|
const silentConsole: Console.Console = Object.assign(Object.create(console), {
|
|
log: () => {},
|
|
error: () => {},
|
|
})
|
|
|
|
const runArgs = (args: ReadonlyArray<string>) =>
|
|
Effect.runPromise(
|
|
Command.runWith(bindTree(Commands).pipe(Command.withGlobalFlags([PrintLogs])), {
|
|
version: OPENCODE_VERSION,
|
|
renderErrors: false,
|
|
})(args).pipe(Effect.provideService(Console.Console, silentConsole), Effect.provide(NodeServices.layer)),
|
|
)
|
|
|
|
const url = "http://127.0.0.1:43210"
|
|
const serverCases: ReadonlyArray<readonly [ReadonlyArray<string>, string]> = [
|
|
[["--server", url, "session", "list"], "opencode session list"],
|
|
[["session", "--server", url, "list"], "opencode session list"],
|
|
[["session", "list", "--server", url], "opencode session list"],
|
|
[["session", "--server", url, "delete", "ses_123"], "opencode session delete"],
|
|
[["--server", url, "plugin", "list"], "opencode plugin list"],
|
|
[["plugin", "--server", url, "list"], "opencode plugin list"],
|
|
[["plugin", "list", "--server", url], "opencode plugin list"],
|
|
[["plugin", "--server", url, "check"], "opencode plugin check"],
|
|
[["plugin", "--server", url, "update"], "opencode plugin update"],
|
|
[["--server", url, "mcp", "list"], "opencode mcp list"],
|
|
[["mcp", "--server", url, "list"], "opencode mcp list"],
|
|
[["mcp", "list", "--server", url], "opencode mcp list"],
|
|
[["mcp", "--server", url, "auth", "linear"], "opencode mcp auth"],
|
|
[["mcp", "--server", url, "logout", "linear"], "opencode mcp logout"],
|
|
[["auth", "--server", url, "list"], "opencode auth list"],
|
|
[["debug", "--server", url, "config"], "opencode debug config"],
|
|
[["debug", "--server", url, "agents"], "opencode debug agents"],
|
|
[["debug", "--server", url, "heap-dump"], "opencode debug heap-dump"],
|
|
[["--server", url, "models"], "opencode models"],
|
|
[["models", "--server", url], "opencode models"],
|
|
[["--server", url, "run", "hello"], "opencode run"],
|
|
[["run", "hello", "--server", url], "opencode run"],
|
|
]
|
|
|
|
for (const [args, expectedPath] of serverCases) {
|
|
captured.length = 0
|
|
await runArgs(args)
|
|
expect(captured).toEqual([{ path: expectedPath, server: url, standalone: false }])
|
|
}
|
|
|
|
captured.length = 0
|
|
await runArgs(["session", "--standalone", "list"])
|
|
expect(captured).toEqual([{ path: "opencode session list", server: undefined, standalone: true }])
|
|
|
|
await expect(runArgs(["plugin", "add", "some-plugin", "--server", url])).rejects.toThrow()
|
|
await expect(runArgs(["plugin", "remove", "some-plugin", "--server", url])).rejects.toThrow()
|
|
await expect(runArgs(["mcp", "add", "my-mcp", "--url", "https://example.com/mcp", "--server", url])).rejects.toThrow()
|
|
await expect(runArgs(["serve", "--server", url])).rejects.toThrow()
|
|
await expect(runArgs(["--server", url, "serve"])).rejects.toThrow()
|
|
await expect(runArgs(["service", "status", "--server", url])).rejects.toThrow()
|
|
await expect(runArgs(["service", "--server", url, "start"])).rejects.toThrow()
|
|
})
|
|
|
|
test("local config commands do not start or require a server", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-local-config-cmd-"))
|
|
try {
|
|
const env = isolatedEnv(root)
|
|
const child = Bun.spawn(
|
|
[
|
|
process.execPath,
|
|
"run",
|
|
path.join(import.meta.dir, "../src/index.ts"),
|
|
"mcp",
|
|
"add",
|
|
"demo",
|
|
"--global",
|
|
"--url",
|
|
"https://example.com/mcp",
|
|
],
|
|
{
|
|
cwd: path.join(import.meta.dir, ".."),
|
|
env,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
},
|
|
)
|
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
new Response(child.stdout).text(),
|
|
new Response(child.stderr).text(),
|
|
child.exited,
|
|
])
|
|
expect({ exitCode, stderr }).toEqual({ exitCode: 0, stderr: "" })
|
|
expect(stdout).toContain('MCP server "demo" added')
|
|
const config = await Bun.file(path.join(root, "config", "opencode.json")).json()
|
|
expect(config).toMatchObject({
|
|
mcp: {
|
|
servers: {
|
|
demo: { type: "remote", url: "https://example.com/mcp" },
|
|
},
|
|
},
|
|
})
|
|
expect(await Bun.file(path.join(root, "state", "opencode", ServiceConfig.filename())).exists()).toBe(false)
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|