mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 13:49:25 -04:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7374576ee6 | |||
| 98c717cb5b | |||
| 5c8d46ab4b | |||
| d5e83fefda | |||
| 46378dda50 | |||
| b38d9d812f | |||
| 8df039d261 | |||
| c92fb2d41b | |||
| 958308c913 | |||
| 16390ca47d | |||
| 643eed300d | |||
| c3a6721de2 | |||
| 47af7462ea | |||
| a76ab93673 | |||
| dfb60e37a3 |
@@ -119,6 +119,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -589,9 +590,9 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.3",
|
||||
"@opentui/keymap": ">=0.5.3",
|
||||
"@opentui/solid": ">=0.5.3",
|
||||
"@opentui/core": ">=0.5.4",
|
||||
"@opentui/keymap": ">=0.5.4",
|
||||
"@opentui/solid": ">=0.5.4",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -1087,9 +1088,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.5.3",
|
||||
"@opentui/keymap": "0.5.3",
|
||||
"@opentui/solid": "0.5.3",
|
||||
"@opentui/core": "0.5.4",
|
||||
"@opentui/keymap": "0.5.4",
|
||||
"@opentui/solid": "0.5.4",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -2111,27 +2112,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.5.3", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.3", "@opentui/core-darwin-x64": "0.5.3", "@opentui/core-linux-arm64": "0.5.3", "@opentui/core-linux-arm64-musl": "0.5.3", "@opentui/core-linux-x64": "0.5.3", "@opentui/core-linux-x64-musl": "0.5.3", "@opentui/core-win32-arm64": "0.5.3", "@opentui/core-win32-x64": "0.5.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-K8EQu44cx0rhnn3v3baCQW18Bpci3GltZayOwVpGGsbiAGL1WUYqwQjuaWsmS0c4dCa9rQ5xCEoHB1C4936nDg=="],
|
||||
"@opentui/core": ["@opentui/core@0.5.4", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.4", "@opentui/core-darwin-x64": "0.5.4", "@opentui/core-linux-arm64": "0.5.4", "@opentui/core-linux-arm64-musl": "0.5.4", "@opentui/core-linux-x64": "0.5.4", "@opentui/core-linux-x64-musl": "0.5.4", "@opentui/core-win32-arm64": "0.5.4", "@opentui/core-win32-x64": "0.5.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-czcJKQ72QhTWvu1eWfKg4EPN1GLLND6cP9MOhDyqYzCdIZgxQSJVWYzz6c4/CQGOu/qQLRyce2y1efVu4lgQ0w=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R39YeUqaMb/rH1h6G4MkB4MLVKIrRaUaXLfVqorZM4xgU5BxnfPetRk1vWR9vuLCvDwskg+kQ589kULw0o6AWA=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oETbn6tg/0g+mOvGXy3iot+1Zv7CGr65U1lRaPJ7kpsKGnOxftCpDD1qA0I6eQwfPJa9k7h9D/9yGB3IbweGcg=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-1pmUas/chTVFGeiN19kaOx+5Xbte/DLhcgKyACwWO0M3+xE3z1v/6QGSyX6CoP5HBpmDroiX+JHv1ic/JlGd/g=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-TIeqCNfAV8xvNAv6oVBYsoGBz/p8CxcYm668OQIeBPUO+irqbQ72vJJa/SwFZrUEAHFmsDELaB6y80oHU5Jm6g=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nMo9Q9VIaQVdw2SNKlwIEWMmf3z+cI4jRdCkh36e2RU1FO7LrIBAEmV1ZuRp1CIFVGPkqCXIizCeckZHTr4yQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-UrOsX3D5BOO9TI30WvRwEK/lyPPhY75+LwSqeRRe8SV2ON+Ez5QqY4oryTyYC6+yNahTJufz34n0YgwnQaxTNA=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-QOYAxbbWrhYo27Cd6m0ATzpEx9YCAKAq82LgfUn0xu+VKXLNu+Q3hMNSVbG0SepUQQZil5rz228R9o9Cs8995w=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-8vFWd1dsPZj9fHQKlsGHue3ukp7MRjTSpmIrdneIruOcoK2etccHfd6bqIo4FcNr+HA0eI2FP4ZIL9xhE3r9/w=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-hdAYLriLpTj3lvpMyL25GPBzvM2w/n2KCSbIwTmgS2F/dPZYCKJxHEETj2lCvtStSp7KuY8tkg3Xl5RAq1v7gA=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-1RXzSl6d347O7mUXviXFWlFFyILr7qsVt4jwD3k0UiKtENeEDNi+glM1bKHbXwCdQjfA835QgFA2mbILybK+aQ=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-BkVIiPQ1TOf5/FfmIpf7DQU5rT/FO6ASW5R/o/wonI5Pdul7XiDCu86gzGyk1x5k9Sbh6GLeq1fe8/tPmI7IaA=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-YYREqUB3v5K0qWij3A5YWzJkkVXp/EqIiuHZKsAjrUAY/0+Bp8Hn0baLvvvkuklpG9whW+GfwIeUsmp4fxqmCA=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-AjObTyZPU0xsK3Yk8GmhkboK6OcMoHBbydqYAybeHD4+v6axScSuZ3OEI9J05JJ9T7H2nZNky75tsdnjsvZJmg=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-F9sB6suPJmwkLF2MwKEC+OtwP6cn5QspIm4MCh2hmu3mVqSz9GDpYID1X3sAh9/V79EVocqiOSqI3KeCTRouKQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-e3nRlF2nSkLKCUPBF32OL9EDgtQDIh2pBo7tjhumpTyJ3qoNOa3us7DsM290Vw4xnakM6jpk9r9NzRf72CuMVg=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-2/6dPPPJ9xL/bWz9jh+lZeV2g/tbxZ9N4FBIqwhnqSfIYy5jOnscsPZpcN4X6PstfJAo2yf0Ebk/tLTOzOS6TQ=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.3", "", { "dependencies": { "@opentui/core": "0.5.3" }, "peerDependencies": { "@opentui/react": "0.5.3", "@opentui/solid": "0.5.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-VdvUabUDWnmgolzp1qiO55wz1zv0mMu5Z6tv+UQk9JqZRfKt5Q8XTDi0uQzhBb6qeLsHvXtRFOsnn6EFAcLixA=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.4", "", { "dependencies": { "@opentui/core": "0.5.4" }, "peerDependencies": { "@opentui/react": "0.5.4", "@opentui/solid": "0.5.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-WAB6EH6C7SicmK/ycvx+mUu00sQ755gToZqBx8vgLhS7qZ07s/WLi88Ys0D6AA0i8lOiSIPA4uqei1jkV8UnBQ=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.5.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-IB6YC3ajucQeo06BMSNYRD7LHf8ndm6G5ETcYsS+51Mli5yW0ioyUr+6+qgqVtaQyOcJvaD1KWmK23pXLhsiwg=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.5.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-1injDvieTyYXD3RLC2EclaJK8XZ2+qyJ7wGeQPSzpcYEEcanb7G79KVWFlN7YUzqVAhTIictZDoQ6SRvIwL4Ww=="],
|
||||
|
||||
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
|
||||
|
||||
|
||||
+3
-3
@@ -47,9 +47,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.3",
|
||||
"@opentui/keymap": "0.5.3",
|
||||
"@opentui/solid": "0.5.3",
|
||||
"@opentui/core": "0.5.4",
|
||||
"@opentui/keymap": "0.5.4",
|
||||
"@opentui/solid": "0.5.4",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
|
||||
@@ -109,7 +109,7 @@ for (const item of targets) {
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
sourcemap: "inline",
|
||||
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none",
|
||||
splitting: true,
|
||||
compile: {
|
||||
autoloadBunfig: false,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
||||
import { Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
@@ -63,28 +64,22 @@ try {
|
||||
})
|
||||
if (unauthorizedOpenApi.status !== 401)
|
||||
throw new Error("Compiled service exposed application routes without authentication")
|
||||
const unauthorizedStop = await fetch(new URL("/api/service/stop", info.url), {
|
||||
const stopRoute = await fetch(new URL("/api/service/stop", info.url), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ instanceID: info.id }),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (unauthorizedStop.status !== 401) throw new Error("Compiled service accepted unauthenticated stop")
|
||||
if (stopRoute.status !== 404) throw new Error("Compiled service exposed the removed HTTP stop route")
|
||||
|
||||
const winner = processes.find((process) => process.pid === info.pid)
|
||||
const loser = processes.find((process) => process.pid !== info.pid)
|
||||
if (!winner || !loser) throw new Error("Compiled contenders did not elect one registered owner")
|
||||
if (!(await exitsWithin(loser, 10_000))) throw new Error("Losing compiled contender did not exit")
|
||||
|
||||
const stopped = await Schema.decodeUnknownPromise(ServiceStatus.StopResponse)(
|
||||
await fetch(new URL("/api/service/stop", info.url), {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ instanceID: info.id }),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
}).then((response) => response.json()),
|
||||
await Effect.runPromise(
|
||||
Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
if (!stopped.accepted) throw new Error("Compiled service rejected exact-instance stop")
|
||||
if (!(await exitsWithin(winner, 10_000))) throw new Error("Compiled service did not stop")
|
||||
for (let attempt = 0; attempt < 200 && (await Bun.file(registration).exists()); attempt++) await Bun.sleep(25)
|
||||
if (await Bun.file(registration).exists()) throw new Error("Compiled service registration was not removed")
|
||||
|
||||
@@ -86,12 +86,37 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
],
|
||||
}),
|
||||
Spec.make("auth", {
|
||||
description: "Manage authentication",
|
||||
description: "manage AI providers and credentials",
|
||||
commands: [
|
||||
Spec.make("login", {
|
||||
description: "Log in to a well-known authentication provider",
|
||||
Spec.make("list", {
|
||||
description: "list providers and credentials",
|
||||
params: {
|
||||
url: Argument.string("url").pipe(Argument.withDescription("Well-known provider URL")),
|
||||
...ServerParams,
|
||||
format: Flag.choice("format", ["default", "json"]).pipe(
|
||||
Flag.withDescription("Output format"),
|
||||
Flag.withDefault("default"),
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("login", {
|
||||
description: "log in to a provider",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Integration ID, name, or well-known provider URL"),
|
||||
Argument.optional,
|
||||
),
|
||||
method: Flag.string("method").pipe(Flag.withDescription("Authentication method ID"), Flag.optional),
|
||||
},
|
||||
}),
|
||||
Spec.make("logout", {
|
||||
description: "log out from a configured provider",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Integration ID or name"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
],
|
||||
@@ -145,11 +170,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "Export session data as JSON",
|
||||
params: {
|
||||
...ServerParams,
|
||||
session: Flag.string("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to export to stdout"),
|
||||
Flag.optional,
|
||||
),
|
||||
session: Argument.string("session").pipe(Argument.withDescription("Session ID to export"), Argument.optional),
|
||||
sanitize: Flag.boolean("sanitize").pipe(
|
||||
Flag.withDescription("Redact sensitive transcript and file data"),
|
||||
Flag.withDefault(false),
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { confirm, log, multiselect, password, select, text, type Option } from "@clack/prompts"
|
||||
import { Effect } from "effect"
|
||||
import type { FormAnswer, FormField, FormFields } from "@opencode-ai/client"
|
||||
import { openUrl, prompt, requireInteractive } from "../../../ui/prompt"
|
||||
|
||||
const skip = Symbol("skip")
|
||||
const custom = Symbol("custom")
|
||||
|
||||
export const answerForm = Effect.fn("cli.auth.form")(function* (fields: FormFields | undefined) {
|
||||
if (!fields) return undefined
|
||||
yield* requireInteractive("Authentication form input requires an interactive terminal")
|
||||
const answer: FormAnswer = {}
|
||||
for (const field of fields) {
|
||||
if (!active(field, answer)) continue
|
||||
const value = yield* answerField(field)
|
||||
if (value !== undefined) answer[field.key] = value
|
||||
}
|
||||
return answer
|
||||
})
|
||||
|
||||
export const secret = Effect.fn("cli.auth.secret")(function* (message: string) {
|
||||
yield* requireInteractive("API key input requires an interactive terminal")
|
||||
return yield* prompt<string>(() => password({ message, validate: (value) => (!value ? "Required" : undefined) }))
|
||||
})
|
||||
|
||||
const answerField = Effect.fn("cli.auth.form.field")(function* (field: FormField) {
|
||||
const message = field.title ?? field.key
|
||||
if (field.description) log.info(field.description)
|
||||
if (field.type === "external") {
|
||||
log.info(field.url)
|
||||
yield* openUrl(field.url)
|
||||
const acknowledged = yield* prompt<boolean>(() =>
|
||||
confirm({ message: message || "Continue after completing this step?", initialValue: true }),
|
||||
)
|
||||
if (!acknowledged) return yield* Effect.fail(new Error(`${message || "External step"} is required`))
|
||||
return true
|
||||
}
|
||||
if (field.type === "boolean") {
|
||||
if (field.required) return yield* prompt<boolean>(() => confirm({ message, initialValue: field.default ?? true }))
|
||||
const options: Array<Option<boolean | typeof skip>> = [
|
||||
{ value: true, label: "Yes" },
|
||||
{ value: false, label: "No" },
|
||||
{ value: skip, label: "Skip" },
|
||||
]
|
||||
const value = yield* prompt<boolean | typeof skip>(() =>
|
||||
select<boolean | typeof skip>({
|
||||
message,
|
||||
options,
|
||||
initialValue: field.default ?? skip,
|
||||
}),
|
||||
)
|
||||
if (value === skip) return undefined
|
||||
return value
|
||||
}
|
||||
if (field.type === "multiselect") {
|
||||
const options: Array<Option<string | typeof custom>> = field.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
hint: option.description,
|
||||
}))
|
||||
if (field.custom) options.push({ value: custom, label: "Type another value" })
|
||||
const values = yield* prompt<Array<string | typeof custom>>(() =>
|
||||
multiselect<string | typeof custom>({
|
||||
message,
|
||||
options,
|
||||
initialValues: field.default,
|
||||
required: field.required || (field.minItems ?? 0) > 0,
|
||||
}),
|
||||
)
|
||||
const selected = values.filter((value): value is string => value !== custom)
|
||||
if (values.includes(custom)) {
|
||||
selected.push(yield* prompt<string>(() => text({ message: "Enter value", validate: required })))
|
||||
}
|
||||
if (field.minItems !== undefined && selected.length < field.minItems) {
|
||||
return yield* Effect.fail(new Error(`Select at least ${field.minItems}`))
|
||||
}
|
||||
if (field.maxItems !== undefined && selected.length > field.maxItems) {
|
||||
return yield* Effect.fail(new Error(`Select at most ${field.maxItems}`))
|
||||
}
|
||||
return selected
|
||||
}
|
||||
if (field.type === "string" && field.options) {
|
||||
const options: Array<Option<string | typeof custom | typeof skip>> = field.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
hint: option.description,
|
||||
}))
|
||||
if (field.custom) options.push({ value: custom, label: "Type your own answer" })
|
||||
if (!field.required) options.push({ value: skip, label: "Skip" })
|
||||
const value = yield* prompt<string | typeof custom | typeof skip>(() =>
|
||||
select<string | typeof custom | typeof skip>({ message, options, initialValue: field.default }),
|
||||
)
|
||||
if (value === skip) return undefined
|
||||
if (value !== custom) return value
|
||||
}
|
||||
const value = yield* prompt<string>(() =>
|
||||
text({
|
||||
message,
|
||||
placeholder: field.type === "string" ? field.placeholder : undefined,
|
||||
initialValue: field.default === undefined ? undefined : String(field.default),
|
||||
validate: (input) => validateText(field, input),
|
||||
}),
|
||||
)
|
||||
if (!value && !field.required) return undefined
|
||||
if (field.type === "string") return value
|
||||
return Number(value)
|
||||
})
|
||||
|
||||
function active(field: FormField, answer: FormAnswer) {
|
||||
if (field.type === "external" || !field.when) return true
|
||||
return field.when.every((condition) => {
|
||||
const value = answer[condition.key]
|
||||
if (value === undefined) return false
|
||||
const matches = Array.isArray(value) ? value.includes(String(condition.value)) : value === condition.value
|
||||
return condition.op === "eq" ? matches : !matches
|
||||
})
|
||||
}
|
||||
|
||||
function required(value: string | undefined) {
|
||||
return value ? undefined : "Required"
|
||||
}
|
||||
|
||||
function validateText(field: Exclude<FormField, { type: "boolean" | "external" | "multiselect" }>, value?: string) {
|
||||
if (!value) return field.required ? "Required" : undefined
|
||||
if (field.type === "number" || field.type === "integer") {
|
||||
const number = Number(value)
|
||||
if (!Number.isFinite(number)) return "Expected a number"
|
||||
if (field.type === "integer" && !Number.isInteger(number)) return "Expected an integer"
|
||||
if (typeof field.minimum === "number" && number < field.minimum) return `Must be at least ${field.minimum}`
|
||||
if (typeof field.maximum === "number" && number > field.maximum) return `Must be at most ${field.maximum}`
|
||||
return undefined
|
||||
}
|
||||
if (field.minLength !== undefined && value.length < field.minLength)
|
||||
return `Must be at least ${field.minLength} characters`
|
||||
if (field.maxLength !== undefined && value.length > field.maxLength)
|
||||
return `Must be at most ${field.maxLength} characters`
|
||||
if (field.pattern) {
|
||||
try {
|
||||
if (!new RegExp(field.pattern).test(value)) return "Invalid format"
|
||||
} catch {
|
||||
return "Invalid format"
|
||||
}
|
||||
}
|
||||
if (field.format === "uri" && !URL.canParse(value)) return "Expected a URL"
|
||||
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "Expected an email address"
|
||||
if (field.format === "date") {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return "Expected a date"
|
||||
const date = new Date(`${value}T00:00:00.000Z`)
|
||||
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) return "Expected a date"
|
||||
}
|
||||
if (field.format === "date-time" && Number.isNaN(Date.parse(value))) return "Expected a date and time"
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { createClient, loadIntegrations } from "./shared"
|
||||
import { errorMessage } from "../../../ui/prompt"
|
||||
|
||||
export default Runtime.handler(Commands.commands.auth.commands.list, (input) =>
|
||||
list(input).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
process.stderr.write(errorMessage(error) + EOL)
|
||||
process.exitCode = 1
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const list = Effect.fn("cli.auth.list")(function* (input) {
|
||||
const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone })
|
||||
const integrations = (yield* loadIntegrations(client)).filter((integration) => integration.connections.length > 0)
|
||||
if (input.format === "json") {
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
integrations.map((integration) => ({
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
connections: integration.connections,
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
) + EOL,
|
||||
)
|
||||
return
|
||||
}
|
||||
const rows = integrations.flatMap((integration) =>
|
||||
integration.connections.map((connection) => ({
|
||||
integration: integration.name,
|
||||
source: connection.type === "credential" ? connection.label : connection.name,
|
||||
type: connection.type === "credential" ? "stored" : "environment",
|
||||
})),
|
||||
)
|
||||
if (rows.length === 0) {
|
||||
process.stdout.write("No authenticated integrations" + EOL)
|
||||
return
|
||||
}
|
||||
const width = Math.max(...rows.map((row) => row.integration.length)) + 2
|
||||
process.stdout.write(
|
||||
rows.map((row) => row.integration.padEnd(width) + row.source.padEnd(28) + row.type).join(EOL) + EOL,
|
||||
)
|
||||
})
|
||||
@@ -1,52 +1,267 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { OpenCode, type IntegrationCommandStatusOutput, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import { autocomplete, intro, log, outro, select, spinner, text } from "@clack/prompts"
|
||||
import { Effect, Option } from "effect"
|
||||
import type { FormAnswer, IntegrationInfo, OpenCodeClient } from "@opencode-ai/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { handlePromptErrors, openUrl, prompt, requireInteractive } from "../../../ui/prompt"
|
||||
import { answerForm, secret } from "./form"
|
||||
import {
|
||||
createClient,
|
||||
connectMethods,
|
||||
loadIntegrations,
|
||||
location,
|
||||
request,
|
||||
resolveIntegration,
|
||||
resolveMethod,
|
||||
type ConnectMethod,
|
||||
} from "./shared"
|
||||
|
||||
const location = { directory: process.cwd() }
|
||||
const integrationPriority = new Map([
|
||||
["opencode", 0],
|
||||
["opencode-go", 1],
|
||||
["openai", 2],
|
||||
["github-copilot", 3],
|
||||
["google", 4],
|
||||
["anthropic", 5],
|
||||
["openrouter", 6],
|
||||
["vercel", 7],
|
||||
])
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.auth.commands.login,
|
||||
Effect.fn("cli.auth.login")(function* (input) {
|
||||
process.stdout.write("Logging in..." + EOL + EOL)
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
yield* request(() => client.integration.wellknown.add({ url: input.url, location }))
|
||||
const integrationID = input.url.replace(/\/+$/, "")
|
||||
const started = yield* request(() =>
|
||||
client.integration.command.connect({ integrationID, methodID: "login", location }),
|
||||
)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
request(() =>
|
||||
client.integration.command.cancel({ integrationID, attemptID: started.data.attemptID, location }),
|
||||
).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const status = yield* wait(client, integrationID, started.data.attemptID)
|
||||
if (status.status === "failed") return yield* Effect.fail(new Error(status.message))
|
||||
if (status.status === "expired") return yield* Effect.fail(new Error("Authentication expired"))
|
||||
process.stdout.write("Logged in" + EOL)
|
||||
}),
|
||||
Effect.fn("cli.auth.login")((input) =>
|
||||
login({
|
||||
target: Option.getOrUndefined(input.target),
|
||||
method: Option.getOrUndefined(input.method),
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
}).pipe(handlePromptErrors),
|
||||
),
|
||||
)
|
||||
|
||||
const wait = (
|
||||
const login = Effect.fn("cli.auth.login.run")(function* (input: {
|
||||
target?: string
|
||||
method?: string
|
||||
server?: string
|
||||
standalone: boolean
|
||||
}) {
|
||||
if (!input.target)
|
||||
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
|
||||
intro("Connect an integration")
|
||||
const client = yield* createClient({ server: input.server, standalone: input.standalone })
|
||||
const integration = yield* findIntegration(client, input.target)
|
||||
const methods = connectMethods(integration)
|
||||
if (methods.length === 0) yield* Effect.fail(new Error(`${integration.name} has no interactive login methods`))
|
||||
const method = yield* chooseMethod(methods, input.method)
|
||||
const answer = method.type === "command" ? undefined : yield* answerForm(method.form)
|
||||
yield* authenticate(client, integration, method, answer)
|
||||
outro("Done")
|
||||
})
|
||||
|
||||
const findIntegration = Effect.fn("cli.auth.login.integration")(function* (client: OpenCodeClient, target?: string) {
|
||||
if (target && URL.canParse(target)) {
|
||||
const protocol = new URL(target).protocol
|
||||
if (protocol === "http:" || protocol === "https:") {
|
||||
const progress = spinner()
|
||||
progress.start("Discovering authentication provider...")
|
||||
yield* request((signal) => client.integration.wellknown.add({ url: target, location }, { signal })).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop("Authentication provider discovered"))),
|
||||
Effect.tapCause(() => Effect.sync(() => progress.stop("Discovery failed", 1))),
|
||||
)
|
||||
}
|
||||
}
|
||||
const integrations = yield* loadIntegrations(client)
|
||||
if (target) return yield* resolveIntegration(integrations, target)
|
||||
const available = integrations
|
||||
.filter((integration) => connectMethods(integration).length > 0)
|
||||
.toSorted(
|
||||
(a, b) =>
|
||||
(integrationPriority.get(a.id) ?? integrationPriority.size) -
|
||||
(integrationPriority.get(b.id) ?? integrationPriority.size) ||
|
||||
a.name.localeCompare(b.name) ||
|
||||
a.id.localeCompare(b.id),
|
||||
)
|
||||
if (available.length === 0) return yield* Effect.fail(new Error("No authentication integrations are available"))
|
||||
const id = yield* prompt<string>(() =>
|
||||
autocomplete({
|
||||
message: "Select integration",
|
||||
maxItems: 8,
|
||||
options: available.map((integration) => {
|
||||
const option = { value: integration.id, label: integration.name, hint: integration.id }
|
||||
if (integration.connections.length > 0) return { ...option, hint: "connected" }
|
||||
if (integration.id === "opencode") return { ...option, hint: "recommended" }
|
||||
return option
|
||||
}),
|
||||
}),
|
||||
)
|
||||
return yield* resolveIntegration(available, id)
|
||||
})
|
||||
|
||||
const chooseMethod = Effect.fn("cli.auth.login.method")(function* (methods: ConnectMethod[], target?: string) {
|
||||
if (target) return yield* resolveMethod(methods, target)
|
||||
if (methods.length === 1) return methods[0]
|
||||
yield* requireInteractive("Pass --method when running without an interactive terminal")
|
||||
const id = yield* prompt<string>(() =>
|
||||
select({
|
||||
message: "Select login method",
|
||||
options: methods.map((method) => {
|
||||
if (method.type === "key") return { value: "key", label: method.label ?? "API key" }
|
||||
return { value: method.id, label: method.label }
|
||||
}),
|
||||
}),
|
||||
)
|
||||
return yield* resolveMethod(methods, id)
|
||||
})
|
||||
|
||||
const authenticate = Effect.fn("cli.auth.login.authenticate")(function* (
|
||||
client: OpenCodeClient,
|
||||
integration: IntegrationInfo,
|
||||
method: ConnectMethod,
|
||||
answer?: FormAnswer,
|
||||
) {
|
||||
if (method.type === "key") return yield* keyLogin(client, integration, method, answer)
|
||||
if (method.type === "command") return yield* commandLogin(client, integration, method)
|
||||
return yield* oauthLogin(client, integration, method, answer)
|
||||
})
|
||||
|
||||
const keyLogin = Effect.fn("cli.auth.login.key")(function* (
|
||||
client: OpenCodeClient,
|
||||
integration: IntegrationInfo,
|
||||
method: Extract<ConnectMethod, { type: "key" }>,
|
||||
answer?: FormAnswer,
|
||||
) {
|
||||
const key = yield* secret(method.label ?? `Enter your ${integration.name} API key`)
|
||||
const progress = spinner()
|
||||
progress.start("Saving credential...")
|
||||
yield* request((signal) =>
|
||||
client.integration.connect.key({ integrationID: integration.id, key, answer, location }, { signal }),
|
||||
).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop(`Connected to ${integration.name}`))),
|
||||
Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))),
|
||||
)
|
||||
})
|
||||
|
||||
const oauthLogin = Effect.fn("cli.auth.login.oauth")(function* (
|
||||
client: OpenCodeClient,
|
||||
integration: IntegrationInfo,
|
||||
method: Extract<ConnectMethod, { type: "oauth" }>,
|
||||
answer?: FormAnswer,
|
||||
) {
|
||||
const progress = spinner()
|
||||
progress.start("Starting authorization...")
|
||||
const started = yield* request((signal) =>
|
||||
client.integration.oauth.connect(
|
||||
{ integrationID: integration.id, methodID: method.id, answer, location },
|
||||
{ signal },
|
||||
),
|
||||
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
|
||||
const attempt = started.data
|
||||
yield* Effect.addFinalizer(() =>
|
||||
request(() =>
|
||||
client.integration.oauth.cancel(
|
||||
{ integrationID: integration.id, attemptID: attempt.attemptID, location },
|
||||
{ signal: AbortSignal.timeout(5_000) },
|
||||
),
|
||||
).pipe(Effect.ignore),
|
||||
)
|
||||
progress.stop("Authorization started")
|
||||
log.info(attempt.instructions)
|
||||
log.info(attempt.url)
|
||||
if (process.stdin.isTTY && process.stdout.isTTY) yield* openUrl(attempt.url)
|
||||
|
||||
if (attempt.mode === "code") {
|
||||
yield* requireInteractive("This login requires an interactive terminal to enter the authorization code")
|
||||
const code = yield* prompt<string>(() =>
|
||||
text({ message: "Paste the authorization code", validate: (value) => (!value ? "Required" : undefined) }),
|
||||
)
|
||||
const completing = spinner()
|
||||
completing.start("Completing authorization...")
|
||||
yield* request((signal) =>
|
||||
client.integration.oauth.complete(
|
||||
{ integrationID: integration.id, attemptID: attempt.attemptID, code, location },
|
||||
{ signal },
|
||||
),
|
||||
).pipe(
|
||||
Effect.tap(() => Effect.sync(() => completing.stop(`Connected to ${integration.name}`))),
|
||||
Effect.tapCause(() => Effect.sync(() => completing.stop("Authentication failed", 1))),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const waiting = spinner()
|
||||
waiting.start("Waiting for authorization...")
|
||||
const status = yield* waitForOAuth(client, integration.id, attempt.attemptID).pipe(
|
||||
Effect.tapCause(() => Effect.sync(() => waiting.stop("Authentication failed", 1))),
|
||||
)
|
||||
if (status.status === "complete") {
|
||||
waiting.stop(`Connected to ${integration.name}`)
|
||||
return
|
||||
}
|
||||
waiting.stop("Authentication failed", 1)
|
||||
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
|
||||
yield* Effect.fail(new Error("Authorization expired"))
|
||||
})
|
||||
|
||||
const commandLogin = Effect.fn("cli.auth.login.command")(function* (
|
||||
client: OpenCodeClient,
|
||||
integration: IntegrationInfo,
|
||||
method: Extract<ConnectMethod, { type: "command" }>,
|
||||
) {
|
||||
const progress = spinner()
|
||||
progress.start("Starting authentication command...")
|
||||
const started = yield* request((signal) =>
|
||||
client.integration.command.connect({ integrationID: integration.id, methodID: method.id, location }, { signal }),
|
||||
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
request(() =>
|
||||
client.integration.command.cancel(
|
||||
{
|
||||
integrationID: integration.id,
|
||||
attemptID: started.data.attemptID,
|
||||
location,
|
||||
},
|
||||
{ signal: AbortSignal.timeout(5_000) },
|
||||
),
|
||||
).pipe(Effect.ignore),
|
||||
)
|
||||
const status = yield* waitForCommand(client, integration.id, started.data.attemptID, (message) =>
|
||||
progress.message(message.trim() || "Waiting for authentication command..."),
|
||||
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
|
||||
if (status.status === "complete") {
|
||||
progress.stop(`Connected to ${integration.name}`)
|
||||
return
|
||||
}
|
||||
progress.stop("Authentication failed", 1)
|
||||
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
|
||||
yield* Effect.fail(new Error("Authentication expired"))
|
||||
})
|
||||
|
||||
const waitForOAuth = Effect.fn("cli.auth.login.oauth.wait")(function* (
|
||||
client: OpenCodeClient,
|
||||
integrationID: string,
|
||||
attemptID: string,
|
||||
shown = false,
|
||||
): Effect.Effect<Exclude<IntegrationCommandStatusOutput["data"], { status: "pending" }>, unknown> =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* request(() => client.integration.command.status({ integrationID, attemptID, location }))
|
||||
) {
|
||||
while (true) {
|
||||
const response = yield* request((signal) =>
|
||||
client.integration.oauth.status({ integrationID, attemptID, location }, { signal }),
|
||||
)
|
||||
if (response.data.status !== "pending") return response.data
|
||||
const output = response.data.message?.trim()
|
||||
if (!shown && output) process.stdout.write(output + EOL + EOL)
|
||||
yield* Effect.sleep(500)
|
||||
return yield* wait(client, integrationID, attemptID, shown || !!output)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function request<A>(task: () => Promise<A>) {
|
||||
return Effect.tryPromise({ try: task, catch: (cause) => cause })
|
||||
}
|
||||
const waitForCommand = Effect.fn("cli.auth.login.command.wait")(function* (
|
||||
client: OpenCodeClient,
|
||||
integrationID: string,
|
||||
attemptID: string,
|
||||
update: (message: string) => void,
|
||||
) {
|
||||
while (true) {
|
||||
const response = yield* request((signal) =>
|
||||
client.integration.command.status({ integrationID, attemptID, location }, { signal }),
|
||||
)
|
||||
if (response.data.status !== "pending") return response.data
|
||||
if (response.data.message) update(response.data.message)
|
||||
yield* Effect.sleep(500)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { autocomplete, intro, outro, spinner } from "@clack/prompts"
|
||||
import { Effect, Option } from "effect"
|
||||
import type { IntegrationInfo } from "@opencode-ai/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt"
|
||||
import { createClient, loadIntegrations, location, request, resolveIntegration } from "./shared"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.auth.commands.logout,
|
||||
Effect.fn("cli.auth.logout")((input) =>
|
||||
logout({
|
||||
target: Option.getOrUndefined(input.target),
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
}).pipe(handlePromptErrors),
|
||||
),
|
||||
)
|
||||
|
||||
const logout = Effect.fn("cli.auth.logout.run")(function* (input: {
|
||||
target?: string
|
||||
server?: string
|
||||
standalone: boolean
|
||||
}) {
|
||||
if (!input.target)
|
||||
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
|
||||
intro("Remove credential")
|
||||
const client = yield* createClient({ server: input.server, standalone: input.standalone })
|
||||
const integrations = yield* loadIntegrations(client)
|
||||
const integration = yield* chooseIntegration(integrations, input.target)
|
||||
const credentials = integration.connections.filter((connection) => connection.type === "credential")
|
||||
if (credentials.length === 0) {
|
||||
const environment = integration.connections
|
||||
.filter((connection) => connection.type === "env")
|
||||
.map((connection) => connection.name)
|
||||
if (environment.length) {
|
||||
yield* Effect.fail(
|
||||
new Error(
|
||||
`${integration.name} is authenticated through ${environment.join(", ")}; unset the environment variable to disconnect`,
|
||||
),
|
||||
)
|
||||
}
|
||||
yield* Effect.fail(new Error(`No stored credentials for ${integration.name}`))
|
||||
}
|
||||
const progress = spinner()
|
||||
progress.start("Removing credential...")
|
||||
yield* Effect.forEach(
|
||||
credentials,
|
||||
(connection) =>
|
||||
request((signal) => client.credential.remove({ credentialID: connection.id, location }, { signal })),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop(`Disconnected from ${integration.name}`))),
|
||||
Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to remove credential", 1))),
|
||||
)
|
||||
outro("Done")
|
||||
})
|
||||
|
||||
const chooseIntegration = Effect.fn("cli.auth.logout.integration")(function* (
|
||||
integrations: IntegrationInfo[],
|
||||
target?: string,
|
||||
) {
|
||||
if (target) return yield* resolveIntegration(integrations, target)
|
||||
const configured = integrations.filter((integration) =>
|
||||
integration.connections.some((connection) => connection.type === "credential"),
|
||||
)
|
||||
if (configured.length === 0) return yield* Effect.fail(new Error("No stored credentials found"))
|
||||
const id = yield* prompt<string>(() =>
|
||||
autocomplete({
|
||||
message: "Select integration",
|
||||
maxItems: 8,
|
||||
options: configured.map((integration) => ({
|
||||
value: integration.id,
|
||||
label: integration.name,
|
||||
hint: integration.connections
|
||||
.filter((connection) => connection.type === "credential")
|
||||
.map((connection) => connection.label)
|
||||
.join(", "),
|
||||
})),
|
||||
}),
|
||||
)
|
||||
return yield* resolveIntegration(configured, id)
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Effect } from "effect"
|
||||
import { OpenCode, type IntegrationInfo, type IntegrationMethod, type OpenCodeClient } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export const location = { directory: process.cwd() }
|
||||
|
||||
export const createClient = Effect.fn("cli.auth.client")(function* (input: ServerConnection.Args) {
|
||||
const server = yield* ServerConnection.resolve(input)
|
||||
return OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) })
|
||||
})
|
||||
|
||||
export function request<A>(run: (signal: AbortSignal) => Promise<A>) {
|
||||
return Effect.tryPromise({ try: run, catch: (cause) => cause })
|
||||
}
|
||||
|
||||
export const loadIntegrations = Effect.fn("cli.auth.integrations")(function* (client: OpenCodeClient) {
|
||||
// The model endpoint is the existing public readiness boundary for the initial plugin generation.
|
||||
yield* request((signal) => client.model.default({ location }, { signal }))
|
||||
return yield* request((signal) => client.integration.list({ location }, { signal })).pipe(
|
||||
Effect.map((response) => response.data),
|
||||
)
|
||||
})
|
||||
|
||||
export const resolveIntegration = Effect.fn("cli.auth.resolve-integration")(function* (
|
||||
integrations: IntegrationInfo[],
|
||||
target: string,
|
||||
) {
|
||||
const normalized = target.replace(/\/+$/, "")
|
||||
const byID = integrations.find((integration) => integration.id === normalized)
|
||||
if (byID) return byID
|
||||
const matches = integrations.filter((integration) => integration.name.toLowerCase() === normalized.toLowerCase())
|
||||
if (matches.length === 1) return matches[0]
|
||||
if (matches.length > 1) {
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
`Integration name "${target}" is ambiguous: ${matches.map((integration) => integration.id).join(", ")}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
return yield* Effect.fail(new Error(`Integration not found: ${target}`))
|
||||
})
|
||||
|
||||
export type ConnectMethod = Exclude<IntegrationMethod, { type: "env" }>
|
||||
|
||||
export function connectMethods(integration: IntegrationInfo) {
|
||||
return integration.methods
|
||||
.filter((method): method is ConnectMethod => method.type !== "env")
|
||||
.toSorted((a, b) => Number(a.type === "key") - Number(b.type === "key"))
|
||||
}
|
||||
|
||||
export const resolveMethod = Effect.fn("cli.auth.resolve-method")(function* (methods: ConnectMethod[], target: string) {
|
||||
const normalized = target.toLowerCase()
|
||||
const matches = methods.filter((method) => {
|
||||
if (method.type === "key") return normalized === "key" || method.label?.toLowerCase() === normalized
|
||||
return method.id === target || method.label.toLowerCase() === normalized
|
||||
})
|
||||
if (matches.length === 1) return matches[0]
|
||||
if (matches.length > 1) return yield* Effect.fail(new Error(`Authentication method "${target}" is ambiguous`))
|
||||
const available = methods.map((method) => (method.type === "key" ? "key" : method.id)).join(", ")
|
||||
return yield* Effect.fail(
|
||||
new Error(`Authentication method not found: ${target}${available ? `. Available: ${available}` : ""}`),
|
||||
)
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Config } from "../../config"
|
||||
import { Context, Effect, FileSystem, Option } from "effect"
|
||||
import { Context, Effect, FileSystem, Option, Queue } from "effect"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { UpdatePreflight } from "../../services/update-preflight"
|
||||
@@ -19,11 +19,21 @@ export default Runtime.handler(Commands, (input) =>
|
||||
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
|
||||
const preflight = UpdatePreflight.make()
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
||||
const serviceStarts = yield* Queue.unbounded<{
|
||||
readonly reason: "missing" | "version-mismatch"
|
||||
readonly previousVersion?: string
|
||||
}>()
|
||||
yield* Queue.take(serviceStarts).pipe(
|
||||
Effect.flatMap((event) => Effect.logInfo("background service starting", event)),
|
||||
Effect.forever,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: requestedServer,
|
||||
standalone: input.standalone,
|
||||
mismatch: "replace",
|
||||
onStart: (reason, previousVersion) => {
|
||||
Queue.offerUnsafe(serviceStarts, { reason, previousVersion })
|
||||
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
|
||||
process.stderr.write(
|
||||
reason === "version-mismatch"
|
||||
|
||||
@@ -1,140 +1,88 @@
|
||||
import { OpenCode, type SessionInfo } from "@opencode-ai/client"
|
||||
import { autocomplete, cancel, intro, isCancel, log, outro } from "@clack/prompts"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Effect, Option } from "effect"
|
||||
import { EOL, tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { emitKeypressEvents, type Key } from "node:readline"
|
||||
import { EOL } from "node:os"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { errorMessage } from "../../ui/prompt"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.export,
|
||||
Effect.fn("cli.export")(function* (input) {
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
})
|
||||
const client = OpenCode.make({
|
||||
baseUrl: server.endpoint.url,
|
||||
headers: Service.headers(server.endpoint),
|
||||
})
|
||||
const requested = Option.getOrUndefined(input.session)
|
||||
const selected = requested
|
||||
? undefined
|
||||
: yield* Effect.promise(async () => {
|
||||
const location = await client.location.get({ location: { directory: process.cwd() } })
|
||||
const page = await client.session.list({
|
||||
directory: location.directory,
|
||||
workspace: location.workspaceID,
|
||||
parentID: null,
|
||||
order: "desc",
|
||||
limit: 50,
|
||||
Effect.fn("cli.export")((input) =>
|
||||
Effect.gen(function* () {
|
||||
const requested = Option.getOrUndefined(input.session)
|
||||
if (!requested && !process.stdin.isTTY) {
|
||||
yield* Effect.fail(new Error("Pass a session ID when running without an interactive terminal"))
|
||||
}
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
})
|
||||
const client = OpenCode.make({
|
||||
baseUrl: server.endpoint.url,
|
||||
headers: Service.headers(server.endpoint),
|
||||
})
|
||||
const sessionID = requested
|
||||
? requested
|
||||
: yield* Effect.gen(function* () {
|
||||
intro("Export session", { output: process.stderr })
|
||||
const location = yield* Effect.tryPromise({
|
||||
try: () => client.location.get({ location: { directory: process.cwd() } }),
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
const page = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
client.session.list({
|
||||
directory: location.directory,
|
||||
workspace: location.workspaceID,
|
||||
parentID: null,
|
||||
order: "desc",
|
||||
limit: 50,
|
||||
}),
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
if (page.data.length === 0) {
|
||||
log.error("No sessions found", { output: process.stderr })
|
||||
outro("Done", { output: process.stderr })
|
||||
return undefined
|
||||
}
|
||||
const selected = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
autocomplete({
|
||||
message: "Select session to export",
|
||||
maxItems: 10,
|
||||
options: page.data.map((session) => ({
|
||||
label: session.title,
|
||||
value: session.id,
|
||||
hint: `${new Date(session.time.updated).toLocaleString()} - ${session.id.slice(-8)}`,
|
||||
})),
|
||||
output: process.stderr,
|
||||
}),
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
if (isCancel(selected)) {
|
||||
cancel("Cancelled", { output: process.stderr })
|
||||
process.exitCode = 130
|
||||
return undefined
|
||||
}
|
||||
outro("Exporting session...", { output: process.stderr })
|
||||
return selected
|
||||
})
|
||||
if (page.data.length === 0) {
|
||||
process.stderr.write(`No sessions found${EOL}`)
|
||||
return undefined
|
||||
}
|
||||
return selectSession(page.data, input.sanitize)
|
||||
})
|
||||
const sessionID = requested ?? selected?.session.id
|
||||
if (!sessionID) return
|
||||
const data = yield* Effect.promise(() =>
|
||||
client.session.export({ sessionID, sanitize: selected?.sanitize ?? input.sanitize }),
|
||||
)
|
||||
process.stdout.write(yield* Effect.promise(() => writeExport(data, sessionID, requested !== undefined)))
|
||||
}),
|
||||
if (!sessionID) return
|
||||
const data = yield* Effect.tryPromise({
|
||||
try: () => client.session.export({ sessionID, sanitize: input.sanitize }),
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
process.stdout.write(JSON.stringify(data, null, 2) + EOL)
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
process.stderr.write(errorMessage(error) + EOL)
|
||||
process.exitCode = 1
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
type Selection = { session: SessionInfo; sanitize: boolean }
|
||||
|
||||
function selectSession(sessions: SessionInfo[], initialSanitize: boolean) {
|
||||
if (!process.stdin.isTTY) return Promise.reject(new Error("Session ID is required when stdin is not interactive"))
|
||||
const input = process.stdin
|
||||
const output = process.stderr
|
||||
const wasRaw = input.isRaw
|
||||
const wasPaused = input.isPaused()
|
||||
const date = new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})
|
||||
const columns = output.columns ?? 100
|
||||
const titleWidth = Math.max(8, Math.min(48, columns - 34))
|
||||
let selected = 0
|
||||
let offset = 0
|
||||
let sanitize = initialSanitize
|
||||
let height = 0
|
||||
|
||||
const render = () => {
|
||||
const visible = sessions.slice(offset, offset + 10)
|
||||
const lines = [" \x1b[36mExport session\x1b[0m", ""]
|
||||
lines.push(
|
||||
...visible.map((session) => {
|
||||
const index = sessions.indexOf(session)
|
||||
const title = (session.title ?? "Untitled session").slice(0, titleWidth).padEnd(titleWidth)
|
||||
const updated = date.format(session.time.updated).slice(0, 18).padEnd(18)
|
||||
const row = `${index === selected ? ">" : " "} ${title} ${updated} ${session.id.slice(-8)}`
|
||||
return index === selected ? `\x1b[1m${row}\x1b[0m` : row
|
||||
}),
|
||||
"",
|
||||
` [${sanitize ? "x" : " "}] sanitize sensitive data`,
|
||||
"",
|
||||
" navigate \x1b[2mup/down\x1b[0m sanitize \x1b[2mspace\x1b[0m export \x1b[2menter\x1b[0m cancel \x1b[2mesc\x1b[0m",
|
||||
)
|
||||
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
|
||||
output.write(lines.join(EOL) + EOL)
|
||||
height = lines.length
|
||||
}
|
||||
const clear = () => {
|
||||
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
|
||||
output.write("\x1b[?25h")
|
||||
input.removeListener("keypress", onKeypress)
|
||||
input.setRawMode(wasRaw ?? false)
|
||||
if (wasPaused) input.pause()
|
||||
}
|
||||
const onKeypress = (value: string | undefined, key: Key) => {
|
||||
if (key.name === "up") {
|
||||
selected = (selected - 1 + sessions.length) % sessions.length
|
||||
if (selected === sessions.length - 1) offset = Math.max(0, sessions.length - 10)
|
||||
if (selected < offset) offset = selected
|
||||
}
|
||||
if (key.name === "down") {
|
||||
selected = (selected + 1) % sessions.length
|
||||
if (selected === 0) offset = 0
|
||||
if (selected >= offset + 10) offset = selected - 9
|
||||
}
|
||||
if (key.name === "space" || value === " ") sanitize = !sanitize
|
||||
if (key.name === "return") return finish(sessions[selected])
|
||||
if (key.name === "escape" || (key.ctrl && key.name === "c")) return cancel()
|
||||
render()
|
||||
}
|
||||
const finish = (session: SessionInfo) => {
|
||||
clear()
|
||||
resolveSelection?.({ session, sanitize })
|
||||
}
|
||||
const cancel = () => {
|
||||
clear()
|
||||
resolveSelection?.()
|
||||
}
|
||||
let resolveSelection: ((selection?: Selection) => void) | undefined
|
||||
|
||||
emitKeypressEvents(input)
|
||||
input.setRawMode(true)
|
||||
input.resume()
|
||||
input.on("keypress", onKeypress)
|
||||
output.write("\x1b[?25l")
|
||||
render()
|
||||
return new Promise<Selection | undefined>((resolve) => {
|
||||
resolveSelection = resolve
|
||||
})
|
||||
}
|
||||
|
||||
export async function writeExport(data: unknown, sessionID: string, stdout: boolean) {
|
||||
const json = JSON.stringify(data, null, 2) + EOL
|
||||
if (stdout) return json
|
||||
const file = path.join(tmpdir(), `opencode-session-${sessionID}-${crypto.randomUUID().slice(0, 8)}.json`)
|
||||
await Bun.write(file, json)
|
||||
return file + EOL
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
acp: () => import("./commands/handlers/acp"),
|
||||
api: () => import("./commands/handlers/api"),
|
||||
auth: {
|
||||
list: () => import("./commands/handlers/auth/list"),
|
||||
login: () => import("./commands/handlers/auth/login"),
|
||||
logout: () => import("./commands/handlers/auth/logout"),
|
||||
},
|
||||
debug: {
|
||||
agents: () => import("./commands/handlers/debug/agents"),
|
||||
@@ -57,6 +59,21 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
|
||||
Effect.gen(function* () {
|
||||
yield* Heap.listen
|
||||
const runFork = Effect.runForkWith(yield* Effect.context<never>())
|
||||
const uncaughtException = (cause: Error, origin: "uncaughtException" | "unhandledRejection") => {
|
||||
runFork(Effect.logError("uncaught exception", { cause, origin }))
|
||||
}
|
||||
const unhandledRejection = (cause: unknown) => {
|
||||
runFork(Effect.logError("unhandled rejection", { cause }))
|
||||
}
|
||||
process.on("uncaughtException", uncaughtException)
|
||||
process.on("unhandledRejection", unhandledRejection)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
process.off("uncaughtException", uncaughtException)
|
||||
process.off("unhandledRejection", unhandledRejection)
|
||||
}),
|
||||
)
|
||||
yield* Effect.logInfo("cli starting", {
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
@@ -65,6 +82,12 @@ Effect.gen(function* () {
|
||||
})
|
||||
return yield* Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logError("cli process failed", {
|
||||
cause,
|
||||
args: process.argv.slice(2),
|
||||
}).pipe(Effect.andThen(Effect.failCause(cause))),
|
||||
),
|
||||
Effect.annotateLogs({ role: "cli" }),
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Updater.layer),
|
||||
|
||||
@@ -117,7 +117,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
serviceOptions === undefined
|
||||
? undefined
|
||||
: {
|
||||
instanceID,
|
||||
onListen: (address, shutdown) =>
|
||||
Effect.gen(function* () {
|
||||
if (!config.password) yield* ServiceConfig.password(password)
|
||||
@@ -180,18 +179,36 @@ const register = Effect.fnUntraced(function* (
|
||||
password,
|
||||
}
|
||||
const encoded = yield* encodeInfo(info)
|
||||
const current = fs.readFileString(file).pipe(
|
||||
Effect.flatMap(decodeInfo),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
const owns = (found: Info | undefined) =>
|
||||
found?.id === info.id &&
|
||||
const current = fs.readFileString(file).pipe(Effect.flatMap(decodeInfo))
|
||||
const owns = (found: Info) =>
|
||||
found.id === info.id &&
|
||||
found.version === info.version &&
|
||||
found.url === info.url &&
|
||||
found.pid === info.pid &&
|
||||
found.password === info.password
|
||||
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
|
||||
yield* current.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("managed service registration check failed; shutting down", {
|
||||
cause,
|
||||
serviceID: id,
|
||||
servicePID: process.pid,
|
||||
registration: file,
|
||||
}).pipe(Effect.andThen(Effect.failCause(cause))),
|
||||
),
|
||||
Effect.tap((found) =>
|
||||
owns(found)
|
||||
? Effect.void
|
||||
: Effect.logWarning("managed service registration replaced; shutting down", {
|
||||
serviceID: id,
|
||||
servicePID: process.pid,
|
||||
registration: file,
|
||||
observedServiceID: found.id,
|
||||
observedServicePID: found.pid,
|
||||
observedVersion: found.version,
|
||||
observedURL: found.url,
|
||||
}),
|
||||
),
|
||||
Effect.filterOrFail(owns),
|
||||
Effect.repeat(Schedule.spaced("5 seconds")),
|
||||
Effect.ignore,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { cancel, isCancel, log, outro } from "@clack/prompts"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const cancelled = Symbol("cancelled")
|
||||
|
||||
export function prompt<A>(run: () => Promise<A | symbol>) {
|
||||
return Effect.tryPromise({ try: run, catch: (cause) => cause }).pipe(
|
||||
Effect.flatMap((value) => (isCancel(value) ? Effect.fail(cancelled) : Effect.succeed(value))),
|
||||
)
|
||||
}
|
||||
|
||||
export function requireInteractive(message: string) {
|
||||
if (process.stdin.isTTY && process.stdout.isTTY) return Effect.void
|
||||
return Effect.fail(new Error(message))
|
||||
}
|
||||
|
||||
export const openUrl = Effect.fn("cli.prompt.open-url")(function* (url: string) {
|
||||
const { default: open } = yield* Effect.promise(() => import("open"))
|
||||
yield* Effect.promise(() => open(url)).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
export function handlePromptErrors<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
return effect.pipe(
|
||||
Effect.catchIf(
|
||||
(error) => error === cancelled,
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
cancel("Cancelled")
|
||||
process.exitCode = 130
|
||||
}),
|
||||
),
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
log.error(errorMessage(error))
|
||||
outro("Failed")
|
||||
process.exitCode = 1
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
@@ -1,20 +1,267 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
|
||||
describe("auth command", () => {
|
||||
test("registers login", async () => {
|
||||
const [auth, login] = await Promise.all([cli(["auth", "--help"]), cli(["auth", "login", "--help"])])
|
||||
test("registers authentication commands", async () => {
|
||||
const [auth, list, login, logout] = await Promise.all([
|
||||
cli(["auth", "--help"]),
|
||||
cli(["auth", "list", "--help"]),
|
||||
cli(["auth", "login", "--help"]),
|
||||
cli(["auth", "logout", "--help"]),
|
||||
])
|
||||
|
||||
expect(auth.exitCode).toBe(0)
|
||||
expect(auth.stdout).toContain("list")
|
||||
expect(auth.stdout).toContain("login")
|
||||
expect(auth.stdout).toContain("Log in to a well-known authentication provider")
|
||||
expect(auth.stdout).toContain("logout")
|
||||
expect(auth.stdout).toContain("manage AI providers and credentials")
|
||||
expect(auth.stdout).toContain("list providers and credentials")
|
||||
expect(auth.stdout).toContain("log in to a provider")
|
||||
expect(auth.stdout).toContain("log out from a configured provider")
|
||||
expect(auth.stdout).not.toContain("connect")
|
||||
expect(list.exitCode).toBe(0)
|
||||
expect(list.stdout).toContain("opencode auth list [flags]")
|
||||
expect(list.stdout).toContain("--format")
|
||||
expect(login.exitCode).toBe(0)
|
||||
expect(login.stdout).toContain("opencode auth login [flags] <url>")
|
||||
expect(login.stdout).toContain("Well-known provider URL")
|
||||
expect(login.stdout).toContain("opencode auth login [flags] [<target>]")
|
||||
expect(login.stdout).toContain("Integration ID, name, or well-known provider URL")
|
||||
expect(login.stdout).toContain("--method")
|
||||
expect(logout.exitCode).toBe(0)
|
||||
expect(logout.stdout).toContain("opencode auth logout [flags] [<target>]")
|
||||
})
|
||||
|
||||
test("lists stored and environment connections", async () => {
|
||||
const requests: string[] = []
|
||||
using server = authServer((request, url) => {
|
||||
if (url.pathname === "/api/integration") {
|
||||
return Response.json(
|
||||
located([
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
methods: [],
|
||||
connections: [
|
||||
{ type: "credential", id: "cred_test", label: "default" },
|
||||
{ type: "env", name: "ANTHROPIC_API_KEY" },
|
||||
],
|
||||
},
|
||||
{ id: "openai", name: "OpenAI", methods: [], connections: [] },
|
||||
]),
|
||||
)
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
}, requests)
|
||||
|
||||
const result = await cli(["auth", "list", "--format", "json", "--server", server.url.toString()])
|
||||
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
||||
expect(JSON.parse(result.stdout)).toEqual([
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
connections: [
|
||||
{ type: "credential", id: "cred_test", label: "default" },
|
||||
{ type: "env", name: "ANTHROPIC_API_KEY" },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(requests.indexOf("/api/model/default")).toBeLessThan(requests.indexOf("/api/integration"))
|
||||
})
|
||||
|
||||
test("runs command authentication without interactive input", async () => {
|
||||
const requests: Array<{ method: string; path: string }> = []
|
||||
using server = authServer((request, url) => {
|
||||
requests.push({ method: request.method, path: url.pathname })
|
||||
if (url.pathname === "/api/integration") {
|
||||
return Response.json(
|
||||
located([
|
||||
{
|
||||
id: "company",
|
||||
name: "Company",
|
||||
methods: [{ id: "login", type: "command", label: "Company login", command: ["company", "login"] }],
|
||||
connections: [],
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/integration/company/connect/command" && request.method === "POST") {
|
||||
return Response.json(located({ attemptID: "con_test", time: { created: 1, expires: 2 } }))
|
||||
}
|
||||
if (url.pathname === "/api/integration/company/connect/command/con_test" && request.method === "GET") {
|
||||
return Response.json(located({ status: "complete", time: { created: 1, expires: 2 } }))
|
||||
}
|
||||
if (url.pathname === "/api/integration/company/connect/command/con_test" && request.method === "DELETE") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
})
|
||||
|
||||
const result = await cli(["auth", "login", "company", "--server", server.url.toString()])
|
||||
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
||||
expect(result.stdout).toContain("Connected to Company")
|
||||
expect(requests).toContainEqual({ method: "POST", path: "/api/integration/company/connect/command" })
|
||||
expect(requests).toContainEqual({ method: "GET", path: "/api/integration/company/connect/command/con_test" })
|
||||
expect(requests).toContainEqual({ method: "DELETE", path: "/api/integration/company/connect/command/con_test" })
|
||||
})
|
||||
|
||||
test("completes automatic OAuth authentication", async () => {
|
||||
const requests: Array<{ method: string; path: string }> = []
|
||||
using server = authServer((request, url) => {
|
||||
requests.push({ method: request.method, path: url.pathname })
|
||||
if (url.pathname === "/api/integration") {
|
||||
return Response.json(
|
||||
located([
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
methods: [{ id: "browser", type: "oauth", label: "Browser" }],
|
||||
connections: [],
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/integration/openai/connect/oauth" && request.method === "POST") {
|
||||
return Response.json(
|
||||
located({
|
||||
attemptID: "con_oauth",
|
||||
url: "https://example.com/authorize",
|
||||
instructions: "Authorize OpenAI",
|
||||
mode: "auto",
|
||||
time: { created: 1, expires: 2 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "GET") {
|
||||
return Response.json(located({ status: "complete", time: { created: 1, expires: 2 } }))
|
||||
}
|
||||
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
})
|
||||
|
||||
const result = await cli(["auth", "login", "openai", "--server", server.url.toString()])
|
||||
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
||||
expect(result.stdout).toContain("https://example.com/authorize")
|
||||
expect(result.stdout).toContain("Connected to OpenAI")
|
||||
expect(requests).toContainEqual({ method: "POST", path: "/api/integration/openai/connect/oauth" })
|
||||
expect(requests).toContainEqual({ method: "GET", path: "/api/integration/openai/connect/oauth/con_oauth" })
|
||||
expect(requests).toContainEqual({ method: "DELETE", path: "/api/integration/openai/connect/oauth/con_oauth" })
|
||||
})
|
||||
|
||||
test("settles the OAuth spinner when status polling fails", async () => {
|
||||
using server = authServer((request, url) => {
|
||||
if (url.pathname === "/api/integration") {
|
||||
return Response.json(
|
||||
located([
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
methods: [{ id: "browser", type: "oauth", label: "Browser" }],
|
||||
connections: [],
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/integration/openai/connect/oauth" && request.method === "POST") {
|
||||
return Response.json(
|
||||
located({
|
||||
attemptID: "con_oauth",
|
||||
url: "https://example.com/authorize",
|
||||
instructions: "Authorize OpenAI",
|
||||
mode: "auto",
|
||||
time: { created: 1, expires: 2 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "GET") {
|
||||
return new Response("Unavailable", { status: 500 })
|
||||
}
|
||||
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
})
|
||||
|
||||
const result = await cli(["auth", "login", "openai", "--server", server.url.toString()])
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toContain("Authentication failed")
|
||||
expect(result.stdout).toContain("Failed")
|
||||
expect(result.stdout).not.toContain("\n at ")
|
||||
})
|
||||
|
||||
test("removes stored credentials", async () => {
|
||||
const removed: string[] = []
|
||||
using server = authServer((request, url) => {
|
||||
if (url.pathname === "/api/integration") {
|
||||
return Response.json(
|
||||
located([
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
methods: [{ type: "key" }],
|
||||
connections: [{ type: "credential", id: "cred_test", label: "default" }],
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/credential/cred_test" && request.method === "DELETE") {
|
||||
removed.push("cred_test")
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
})
|
||||
|
||||
const result = await cli(["auth", "logout", "anthropic", "--server", server.url.toString()])
|
||||
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
||||
expect(result.stdout).toContain("Disconnected from Anthropic")
|
||||
expect(removed).toEqual(["cred_test"])
|
||||
})
|
||||
|
||||
test("requires a target outside an interactive terminal", async () => {
|
||||
const result = await cli(["auth", "login"])
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toContain("Pass an integration ID or name")
|
||||
expect(result.stdout).not.toContain("Background service failed to start")
|
||||
})
|
||||
|
||||
test("reports list connection failures without a stack trace", async () => {
|
||||
using server = Bun.serve({ port: 0, fetch: () => new Response("Unavailable", { status: 503 }) })
|
||||
const result = await cli(["auth", "list", "--server", server.url.toString()])
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toBe("")
|
||||
expect(result.stderr).toContain("did not provide a compatible V2 health response")
|
||||
expect(result.stderr).not.toContain("\n at ")
|
||||
})
|
||||
})
|
||||
|
||||
function authServer(fetch: (request: Request, url: URL) => Response | Promise<Response>, requests?: string[]) {
|
||||
return Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
requests?.push(url.pathname)
|
||||
if (url.pathname === "/api/health") return health()
|
||||
if (url.pathname === "/api/model/default") return Response.json(located(null))
|
||||
return fetch(request, url)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function health() {
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
}
|
||||
|
||||
function located<T>(data: T) {
|
||||
return {
|
||||
location: {
|
||||
directory: process.cwd(),
|
||||
project: { id: "project", directory: process.cwd(), canonical: process.cwd() },
|
||||
},
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
async function cli(args: string[]) {
|
||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||
cwd: path.join(import.meta.dir, ".."),
|
||||
|
||||
@@ -3,7 +3,6 @@ import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
import { writeExport } from "../src/commands/handlers/export"
|
||||
|
||||
const info = {
|
||||
id: "ses_export_test",
|
||||
@@ -72,7 +71,7 @@ test("export is raw by default and supports explicit sanitization", async () =>
|
||||
})
|
||||
|
||||
try {
|
||||
const [stdout, , exitCode] = await run(["export", "-s", info.id, "--server", server.url.toString()])
|
||||
const [stdout, , exitCode] = await run(["export", info.id, "--server", server.url.toString()])
|
||||
const exported = JSON.parse(stdout)
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
@@ -80,7 +79,6 @@ test("export is raw by default and supports explicit sanitization", async () =>
|
||||
|
||||
const [sanitized, , sanitizedExitCode] = await run([
|
||||
"export",
|
||||
"-s",
|
||||
info.id,
|
||||
"--sanitize",
|
||||
"--server",
|
||||
@@ -94,7 +92,7 @@ test("export is raw by default and supports explicit sanitization", async () =>
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
test("export reports an empty session list without a stack trace", async () => {
|
||||
test("export requires a session outside an interactive terminal", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
@@ -114,23 +112,39 @@ test("export reports an empty session list without a stack trace", async () => {
|
||||
try {
|
||||
const [stdout, stderr, exitCode] = await run(["export", "--server", server.url.toString()])
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(exitCode).toBe(1)
|
||||
expect(stdout).toBe("")
|
||||
expect(stderr).toBe(`No sessions found${os.EOL}`)
|
||||
expect(stderr).toBe(`Pass a session ID when running without an interactive terminal${os.EOL}`)
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("interactive export writes a temporary JSON file", async () => {
|
||||
const output = await writeExport(transfer, info.id, false)
|
||||
const file = output.trim()
|
||||
test("export reports a missing session without a stack trace", async () => {
|
||||
const sessionID = "ses_missing"
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/health") return health()
|
||||
if (url.pathname === `/api/session/${sessionID}/export`) {
|
||||
return Response.json(
|
||||
{ _tag: "SessionNotFoundError", sessionID, message: `Session not found: ${sessionID}` },
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
expect(path.dirname(file)).toBe(os.tmpdir())
|
||||
expect(await Bun.file(file).json()).toEqual(transfer)
|
||||
const [stdout, stderr, exitCode] = await run(["export", sessionID, "--server", server.url.toString()])
|
||||
|
||||
expect(exitCode).toBe(1)
|
||||
expect(stdout).toBe("")
|
||||
expect(stderr).toBe(`Session not found: ${sessionID}${os.EOL}`)
|
||||
} finally {
|
||||
await fs.rm(file, { force: true })
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -41,13 +41,8 @@ import type { Config } from "@opencode-ai/schema/config"
|
||||
export type Endpoint0_0Output = { readonly healthy: true; readonly version: string; readonly pid: number }
|
||||
export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>
|
||||
|
||||
export type Endpoint0_1Input = { readonly instanceID: string }
|
||||
export type Endpoint0_1Output = { readonly accepted: boolean }
|
||||
export type HealthStopOperation<E = never> = (input: Endpoint0_1Input) => Effect.Effect<Endpoint0_1Output, E>
|
||||
|
||||
export interface HealthApi<E = never> {
|
||||
readonly get: HealthGetOperation<E>
|
||||
readonly stop: HealthStopOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint1_0Output = { readonly urls: ReadonlyArray<string> }
|
||||
|
||||
@@ -6,8 +6,6 @@ import { HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { ClientApi } from "../../contract"
|
||||
import type {
|
||||
Endpoint0_0Output,
|
||||
Endpoint0_1Input,
|
||||
Endpoint0_1Output,
|
||||
Endpoint1_0Output,
|
||||
Endpoint2_0Input,
|
||||
Endpoint2_0Output,
|
||||
@@ -248,12 +246,7 @@ const preserveStream =
|
||||
const Endpoint0_0 = (raw: RawClient["server.health"]) => () =>
|
||||
preserveEffect<Endpoint0_0Output>()(raw["health.get"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const Endpoint0_1 = (raw: RawClient["server.health"]) => (input: Endpoint0_1Input) =>
|
||||
preserveEffect<Endpoint0_1Output>()(
|
||||
raw["health.stop"]({ payload: { instanceID: input["instanceID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw), stop: Endpoint0_1(raw) })
|
||||
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw) })
|
||||
|
||||
const Endpoint1_0 = (raw: RawClient["server.server"]) => () =>
|
||||
preserveEffect<Endpoint1_0Output>()(raw["server.get"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
@@ -87,7 +87,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
yield* announce("missing")
|
||||
yield* evict(info, options, timing)
|
||||
yield* terminate(info, options, timing)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
return yield* Effect.fail(new Error("Background service failed to start"))
|
||||
if (compatible) return Option.none<LocalService>()
|
||||
yield* announce("version-mismatch", service.version)
|
||||
yield* kill(service, options, timing).pipe(Effect.ignore)
|
||||
yield* terminate(service.info, options, timing).pipe(Effect.ignore)
|
||||
lastSpawn = 0
|
||||
return Option.none<LocalService>()
|
||||
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
|
||||
@@ -133,8 +133,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
|
||||
const existing = yield* find(options)
|
||||
if (existing !== undefined) yield* kill(existing, options, defaultEnsureTiming)
|
||||
const info = yield* read(options.file)
|
||||
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
|
||||
})
|
||||
|
||||
function fallback() {
|
||||
@@ -243,12 +243,6 @@ const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = fal
|
||||
return { info, ...(yield* probeResult(info, allowLegacy, timeout)) }
|
||||
})
|
||||
|
||||
// Health-checked lookup without the version gate: lifecycle operations must be
|
||||
// able to see (and replace or stop) a server from a different version.
|
||||
const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
|
||||
return (yield* registered(options.file, true)).service
|
||||
})
|
||||
|
||||
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
|
||||
// discovery window.
|
||||
const poll = (timing: EnsureTiming) =>
|
||||
@@ -269,59 +263,21 @@ function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const terminate = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const current = yield* read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
yield* signal(info.pid, "SIGTERM")
|
||||
const done = yield* stopped(info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
|
||||
if (Option.isSome(done)) return
|
||||
|
||||
if (Option.isNone(done)) {
|
||||
const latest = yield* read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
yield* signal(info.pid, "SIGKILL")
|
||||
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
|
||||
}
|
||||
const latest = yield* read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
yield* signal(info.pid, "SIGKILL")
|
||||
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
|
||||
})
|
||||
|
||||
const kill = Effect.fnUntraced(function* (
|
||||
service: LocalService,
|
||||
options: { readonly file?: string },
|
||||
timing: EnsureTiming,
|
||||
) {
|
||||
const requested = yield* requestStop(service, timing.requestTimeout)
|
||||
if (requested === "rejected") return
|
||||
if (requested === "unsupported") {
|
||||
// A stale registration may point at a reused PID. Authenticate again
|
||||
// immediately before the legacy signal fallback.
|
||||
const current = yield* find(options)
|
||||
if (current === undefined || !same(current.info, service.info)) return
|
||||
yield* signal(service.info.pid, "SIGTERM")
|
||||
}
|
||||
const done = yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
|
||||
if (Option.isSome(done)) return
|
||||
|
||||
const latest = yield* find(options)
|
||||
if (latest === undefined || !same(latest.info, service.info)) return
|
||||
yield* signal(service.info.pid, "SIGKILL")
|
||||
yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)))
|
||||
})
|
||||
|
||||
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse)
|
||||
|
||||
const requestStop = Effect.fnUntraced(function* (service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
if (service.info.id === undefined || service.legacy) return "unsupported" as const
|
||||
const response = yield* Effect.tryPromise(() =>
|
||||
fetch(new URL("/api/service/stop", service.info.url), {
|
||||
method: "POST",
|
||||
headers: { ...headers(service.endpoint), "content-type": "application/json" },
|
||||
body: JSON.stringify({ instanceID: service.info.id }),
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
}),
|
||||
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
|
||||
const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
const decoded = decodeStopResponse(body)
|
||||
if (!response.ok || Option.isNone(decoded) || !decoded.value.accepted) return "rejected" as const
|
||||
return "accepted" as const
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
/** Effect-based local service lifecycle operations. */
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type {
|
||||
HealthGetOutput,
|
||||
HealthStopInput,
|
||||
HealthStopOutput,
|
||||
ServerGetOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
@@ -367,18 +365,6 @@ export function make(options: ClientOptions) {
|
||||
{ method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
stop: (input: HealthStopInput, requestOptions?: RequestOptions) =>
|
||||
request<HealthStopOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/service/stop`,
|
||||
body: { instanceID: input["instanceID"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
server: {
|
||||
get: (requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -2,8 +2,6 @@ export type JsonValue = null | boolean | number | string | Array<JsonValue> | {
|
||||
|
||||
export type ServiceHealth = { healthy: true; version: string; pid: number }
|
||||
|
||||
export type ServiceStopResponse = { accepted: boolean }
|
||||
|
||||
export type ModelRef = { id: string; providerID: string; variant?: string }
|
||||
|
||||
export type ProviderSettings = { [x: string]: any }
|
||||
@@ -2273,10 +2271,6 @@ export const isWorktreeError = (value: unknown): value is WorktreeError =>
|
||||
|
||||
export type HealthGetOutput = ServiceHealth
|
||||
|
||||
export type HealthStopInput = { readonly instanceID: { readonly instanceID: string }["instanceID"] }
|
||||
|
||||
export type HealthStopOutput = ServiceStopResponse
|
||||
|
||||
export type ServerGetOutput = { urls: Array<string> }
|
||||
|
||||
export type LocationGetInput = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { readFile, rm } from "node:fs/promises"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "../service-contender.js"
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
|
||||
import type { ServiceHealth } from "./generated/types.js"
|
||||
|
||||
export * from "../service.js"
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
announce("missing")
|
||||
await evict(registration.info, options, timing)
|
||||
await terminate(registration.info, options, timing)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
|
||||
if (!compatible) {
|
||||
announce("version-mismatch", service.version)
|
||||
await kill(service, options, timing).catch(() => undefined)
|
||||
await terminate(service.info, options, timing).catch(() => undefined)
|
||||
lastSpawn = 0
|
||||
}
|
||||
} else {
|
||||
@@ -110,8 +110,8 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export async function stop(options: StopOptions = {}) {
|
||||
const existing = await find(options)
|
||||
if (existing !== undefined) await kill(existing, options, defaultEnsureTiming)
|
||||
const info = await read(options.file)
|
||||
if (info !== undefined) await terminate(info, options, defaultEnsureTiming)
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
@@ -199,10 +199,6 @@ async function registered(file?: string, allowLegacy = false, timeout?: number)
|
||||
return { info, ...(await probeResult(info, allowLegacy, timeout)) }
|
||||
}
|
||||
|
||||
async function find(options: { readonly file?: string }) {
|
||||
return (await registered(options.file, true)).service
|
||||
}
|
||||
|
||||
function signal(pid: number, name: NodeJS.Signals) {
|
||||
try {
|
||||
process.kill(pid, name)
|
||||
@@ -230,47 +226,19 @@ function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
async function evict(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
async function terminate(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const current = await read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
signal(info.pid, "SIGTERM")
|
||||
if (await waitUntilStopped(info.pid, timing)) return
|
||||
|
||||
if (!(await waitUntilStopped(info.pid, timing))) {
|
||||
const latest = await read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
signal(info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
|
||||
}
|
||||
const latest = await read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
signal(info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
|
||||
}
|
||||
|
||||
async function kill(service: LocalService, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const requested = await requestStop(service, timing.requestTimeout)
|
||||
if (requested === "rejected") return
|
||||
if (requested === "unsupported") {
|
||||
const current = await find(options)
|
||||
if (current === undefined || !same(current.info, service.info)) return
|
||||
signal(service.info.pid, "SIGTERM")
|
||||
}
|
||||
if (await waitUntilStopped(service.info.pid, timing)) return
|
||||
|
||||
const latest = await find(options)
|
||||
if (latest === undefined || !same(latest.info, service.info)) return
|
||||
signal(service.info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(service.info.pid, timing)))
|
||||
throw new Error(`Server process ${service.info.pid} is still running`)
|
||||
}
|
||||
|
||||
async function requestStop(service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
if (service.info.id === undefined || service.legacy) return "unsupported" as const
|
||||
const response = await fetch(new URL("/api/service/stop", service.info.url), {
|
||||
method: "POST",
|
||||
headers: { ...headers(service.endpoint), "content-type": "application/json" },
|
||||
body: JSON.stringify({ instanceID: service.info.id }),
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
}).catch(() => undefined)
|
||||
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
|
||||
const body = (await response.json().catch(() => undefined)) as ServiceStopResponse | undefined
|
||||
if (!response.ok || body?.accepted !== true) return "rejected" as const
|
||||
return "accepted" as const
|
||||
await rm(options.file ?? fallback(), { force: true })
|
||||
}
|
||||
|
||||
function delay(milliseconds: number) {
|
||||
|
||||
@@ -28,7 +28,7 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
|
||||
|
||||
let requests = 0
|
||||
let version = "test"
|
||||
if (mode === "old" || mode === "reject-stop") version = "old"
|
||||
if (mode === "old") version = "old"
|
||||
if (mode === "incompatible") version = "1.9.0"
|
||||
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
|
||||
const id = crypto.randomUUID()
|
||||
@@ -36,17 +36,6 @@ const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const pathname = new URL(request.url).pathname
|
||||
if (pathname === "/api/service/stop" && mode === "reject-stop") {
|
||||
await appendFile(registration + ".stop-attempts", process.pid + "\n")
|
||||
return Response.json({ accepted: false })
|
||||
}
|
||||
if (pathname === "/api/service/stop" && mode === "graceful") {
|
||||
const body = await request.json()
|
||||
if (typeof body !== "object" || body === null || body.instanceID !== id) return Response.json({ accepted: false })
|
||||
await writeFile(registration + ".stop", JSON.stringify(body))
|
||||
setTimeout(shutdown, 25)
|
||||
return Response.json({ accepted: true })
|
||||
}
|
||||
if (pathname !== "/api/health") return new Response(null, { status: 404 })
|
||||
requests += 1
|
||||
if (mode === "starting") await writeFile(registration + ".health-request", "")
|
||||
@@ -63,7 +52,7 @@ const server = Bun.serve({
|
||||
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
|
||||
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
|
||||
if (mode === "failed-owner") return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
|
||||
if (mode === "starting" || mode === "graceful" || mode === "reject-stop")
|
||||
if (mode === "starting" || mode === "graceful")
|
||||
return Response.json({ healthy: true, version, pid: process.pid })
|
||||
return Response.json({ healthy: true, version, pid: process.pid })
|
||||
},
|
||||
@@ -81,9 +70,10 @@ await writeFile(
|
||||
)
|
||||
await rename(registration + ".tmp", registration)
|
||||
|
||||
function shutdown() {
|
||||
async function shutdown(signal?: NodeJS.Signals) {
|
||||
if (signal !== undefined) await writeFile(registration + ".signal", signal)
|
||||
server.stop(true)
|
||||
process.exit()
|
||||
}
|
||||
process.on("SIGTERM", shutdown)
|
||||
process.on("SIGINT", shutdown)
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"))
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"))
|
||||
|
||||
@@ -126,13 +126,13 @@ test("evicts an unresponsive registered service before starting its replacement"
|
||||
await waitForExit(replacement.pid)
|
||||
})
|
||||
|
||||
test("requests graceful stop of the exact service instance", async () => {
|
||||
test("signals the registered service process", async () => {
|
||||
const registration = await setup("graceful")
|
||||
const info = await Bun.file(registration).json()
|
||||
|
||||
await Service.stop({ file: registration })
|
||||
|
||||
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
|
||||
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
})
|
||||
|
||||
async function setup(mode: string) {
|
||||
|
||||
@@ -191,22 +191,6 @@ test("integration connections optionally submit a form answer", async () => {
|
||||
expect(await requests[3].json()).toEqual({ methodID: "device" })
|
||||
})
|
||||
|
||||
test("health.stop sends exact replacement identity", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json({ accepted: true })
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.health.stop({ instanceID: "instance" })).toEqual({ accepted: true })
|
||||
expect(request?.method).toBe("POST")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/service/stop")
|
||||
expect(await request?.json()).toEqual({ instanceID: "instance" })
|
||||
})
|
||||
|
||||
test("MCP resource catalog uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
|
||||
@@ -143,40 +143,36 @@ test("evicts an unresponsive registered service before starting its replacement"
|
||||
await waitForExit(replacement.pid)
|
||||
})
|
||||
|
||||
test("requests graceful stop of the exact service instance", async () => {
|
||||
test("signals an unresponsive registered service process", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const process = spawn(registration, "graceful")
|
||||
const process = spawn(registration, "hanging")
|
||||
await waitForFile(registration)
|
||||
const info = await Bun.file(registration).json()
|
||||
|
||||
await run(Service.stop({ file: registration }))
|
||||
await process.exited
|
||||
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
|
||||
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
})
|
||||
|
||||
test("does not spawn contenders while an incompatible service rejects replacement", async () => {
|
||||
test("signals an incompatible service before starting its replacement", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const contender = join(directory, "contender.json")
|
||||
const existing = spawn(registration, "reject-stop")
|
||||
const existing = spawn(registration, "old")
|
||||
await waitForFile(registration)
|
||||
const controller = new AbortController()
|
||||
const starting = Effect.runPromise(
|
||||
const endpoint = await run(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, contender, "record-start"],
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
{ signal: controller.signal },
|
||||
command: [process.execPath, fixture, registration, "delayed", "10"],
|
||||
}),
|
||||
)
|
||||
const replacement = await Bun.file(registration).json()
|
||||
|
||||
await waitForLines(registration + ".stop-attempts", 2)
|
||||
controller.abort()
|
||||
await starting.catch(() => undefined)
|
||||
|
||||
expect(await Bun.file(contender + ".started").exists()).toBe(false)
|
||||
expect(existing.exitCode).toBe(null)
|
||||
expect(await existing.exited).toBe(0)
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
process.kill(replacement.pid, "SIGTERM")
|
||||
await waitForExit(replacement.pid)
|
||||
})
|
||||
|
||||
test("a legacy health response is still replaced", async () => {
|
||||
@@ -344,17 +340,6 @@ async function waitForFile(file: string) {
|
||||
throw new Error(`Timed out waiting for ${file}`)
|
||||
}
|
||||
|
||||
async function waitForLines(file: string, count: number) {
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
const text = await Bun.file(file)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
if (text.trim().split("\n").length >= count) return
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${count} lines in ${file}`)
|
||||
}
|
||||
|
||||
async function health(url: string) {
|
||||
return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ export function normalize(input: unknown): Result {
|
||||
"agents",
|
||||
migratedAgents,
|
||||
nativeAgents,
|
||||
isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
|
||||
migratedSmallModel !== undefined || isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
|
||||
diagnostics,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
export * as ConfigFormatterPlugin from "./formatter.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Entry } from "@opencode-ai/schema/config"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Formatter } from "../../formatter.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.formatter",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const loaded = { entries: [] as Entry[] }
|
||||
yield* ctx.event
|
||||
.subscribe()
|
||||
.pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(formatter.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.entries = yield* config.entries()
|
||||
yield* formatter.transform((draft) => {
|
||||
const configured = Config.latest(loaded.entries, "formatter")
|
||||
if (configured === false) {
|
||||
draft.clear()
|
||||
return
|
||||
}
|
||||
if (configured === undefined) {
|
||||
for (const item of draft.list()) if (item.builtIn) draft.remove(item.name)
|
||||
return
|
||||
}
|
||||
if (configured === true) return
|
||||
for (const [name, entry] of Object.entries(configured)) {
|
||||
if (entry.disabled) {
|
||||
draft.remove(name)
|
||||
continue
|
||||
}
|
||||
const current = draft.get(name)
|
||||
draft.set(name, {
|
||||
name,
|
||||
extensions: entry.extensions ?? current?.extensions ?? [],
|
||||
environment: { ...current?.environment, ...entry.environment },
|
||||
enabled:
|
||||
current && !entry.command ? current.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
export * as ConfigImagePlugin from "./image.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Entry } from "@opencode-ai/schema/config"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Image } from "../../image.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.image",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const image = yield* Image.Service
|
||||
const loaded = { entries: [] as Entry[] }
|
||||
yield* ctx.event
|
||||
.subscribe()
|
||||
.pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(image.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.entries = yield* config.entries()
|
||||
yield* image.transform((draft) => {
|
||||
for (const entry of loaded.entries) {
|
||||
if (entry.type !== "document" || !entry.info.media?.image) continue
|
||||
draft.update((policy) => {
|
||||
const configured = entry.info.media?.image
|
||||
if (!configured) return
|
||||
if (configured.auto_resize !== undefined) policy.autoResize = configured.auto_resize
|
||||
if (configured.max_width !== undefined) policy.maxWidth = configured.max_width
|
||||
if (configured.max_height !== undefined) policy.maxHeight = configured.max_height
|
||||
if (configured.max_base64_bytes !== undefined) policy.maxBase64Bytes = configured.max_base64_bytes
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
export * as ConfigToolOutputPlugin from "./tool-output.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Entry } from "@opencode-ai/schema/config"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.tool-output",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const output = yield* ToolOutput.Service
|
||||
const loaded = { entries: [] as Entry[] }
|
||||
yield* ctx.event
|
||||
.subscribe()
|
||||
.pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(output.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.entries = yield* config.entries()
|
||||
yield* output.transform((draft) => {
|
||||
const configured = Config.latest(loaded.entries, "tool_output")
|
||||
if (!configured) return
|
||||
draft.update((policy) => {
|
||||
if (configured.max_lines !== undefined) policy.maxLines = configured.max_lines
|
||||
if (configured.max_bytes !== undefined) policy.maxBytes = configured.max_bytes
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -8,11 +8,23 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Config } from "./config.js"
|
||||
import { Location } from "./location.js"
|
||||
import { make, type Info } from "./formatter/builtins.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export interface Interface {
|
||||
type Data = {
|
||||
readonly formatters: Map<string, Info>
|
||||
}
|
||||
|
||||
export interface Draft {
|
||||
readonly list: () => readonly Info[]
|
||||
readonly get: (name: string) => Info | undefined
|
||||
readonly set: (name: string, formatter: Info) => void
|
||||
readonly remove: (name: string) => void
|
||||
readonly clear: () => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly file: (filepath: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
@@ -21,66 +33,48 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const global = yield* Global.Service
|
||||
const commands = new Map<string, string[] | false>()
|
||||
let formatters: Info[] = []
|
||||
|
||||
const load = yield* Effect.cached(
|
||||
Effect.gen(function* () {
|
||||
const configured = Config.latest(yield* config.entries(), "formatter")
|
||||
if (!configured) {
|
||||
yield* Effect.logInfo("all formatters are disabled")
|
||||
return
|
||||
}
|
||||
|
||||
const builtIns = make({
|
||||
directory: location.directory,
|
||||
worktree: location.project.directory,
|
||||
fs,
|
||||
npm,
|
||||
processes,
|
||||
bin: global.bin,
|
||||
})
|
||||
formatters = builtIns
|
||||
if (configured === true) return
|
||||
|
||||
for (const [name, entry] of Object.entries(configured)) {
|
||||
const index = formatters.findIndex((formatter) => formatter.name === name)
|
||||
if (entry.disabled) {
|
||||
if (index !== -1) formatters.splice(index, 1)
|
||||
continue
|
||||
}
|
||||
|
||||
const builtIn = builtIns.find((formatter) => formatter.name === name)
|
||||
const formatter: Info = {
|
||||
name,
|
||||
extensions: entry.extensions ?? builtIn?.extensions ?? [],
|
||||
environment: { ...builtIn?.environment, ...entry.environment },
|
||||
enabled:
|
||||
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
|
||||
}
|
||||
if (index === -1) formatters.push(formatter)
|
||||
else formatters[index] = formatter
|
||||
}
|
||||
}).pipe(Effect.withSpan("Formatter.load")),
|
||||
)
|
||||
const commands = new WeakMap<Info, string[] | false>()
|
||||
const builtIns = make({
|
||||
directory: location.directory,
|
||||
worktree: location.project.directory,
|
||||
fs,
|
||||
npm,
|
||||
processes,
|
||||
bin: global.bin,
|
||||
})
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "formatter",
|
||||
initial: () => ({
|
||||
formatters: new Map(builtIns.map((formatter) => [formatter.name, { ...formatter, builtIn: true }])),
|
||||
}),
|
||||
draft: (data) => ({
|
||||
list: () => Array.from(data.formatters.values()),
|
||||
get: (name) => data.formatters.get(name),
|
||||
set: (name, formatter) => data.formatters.set(name, { ...formatter, name }),
|
||||
remove: (name) => {
|
||||
data.formatters.delete(name)
|
||||
},
|
||||
clear: () => data.formatters.clear(),
|
||||
}),
|
||||
})
|
||||
|
||||
const command = Effect.fnUntraced(function* (formatter: Info) {
|
||||
const cached = commands.get(formatter.name)
|
||||
const cached = commands.get(formatter)
|
||||
if (cached !== undefined) return cached
|
||||
const result = yield* formatter.enabled
|
||||
if (result !== false) commands.set(formatter.name, result)
|
||||
if (result !== false) commands.set(formatter, result)
|
||||
return result
|
||||
})
|
||||
|
||||
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
|
||||
yield* load
|
||||
const matching = formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
|
||||
const matching = Array.from(state.get().formatters.values()).filter((formatter) =>
|
||||
formatter.extensions.includes(path.extname(filepath)),
|
||||
)
|
||||
|
||||
for (const formatter of matching) {
|
||||
const enabled = yield* command(formatter)
|
||||
@@ -118,12 +112,12 @@ const layer = Layer.effect(
|
||||
return false
|
||||
})
|
||||
|
||||
return Service.of({ file })
|
||||
return Service.of({ file, transform: state.transform, reload: state.reload })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
|
||||
deps: [FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { which } from "../util/which.js"
|
||||
|
||||
export interface Info {
|
||||
readonly name: string
|
||||
readonly builtIn?: boolean
|
||||
readonly environment?: Record<string, string>
|
||||
readonly extensions: readonly string[]
|
||||
readonly enabled: Effect.Effect<string[] | false>
|
||||
|
||||
+33
-15
@@ -2,8 +2,9 @@ export * as Image from "./image.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Config } from "./config.js"
|
||||
import { FileSystem } from "./filesystem.js"
|
||||
import type { DeepMutable } from "./schema.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export class ResizerUnavailableError extends Schema.TaggedError<ResizerUnavailableError>()(
|
||||
"Image.ResizerUnavailableError",
|
||||
@@ -32,7 +33,18 @@ export class SizeError extends Schema.TaggedError<SizeError>()("Image.SizeError"
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
export interface Policy {
|
||||
readonly autoResize: boolean
|
||||
readonly maxWidth: number
|
||||
readonly maxHeight: number
|
||||
readonly maxBase64Bytes: number
|
||||
}
|
||||
|
||||
export interface Draft {
|
||||
readonly update: (update: (policy: DeepMutable<Policy>) => void) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly normalize: (
|
||||
resource: string,
|
||||
content: FileSystem.Content & { readonly encoding: "base64" },
|
||||
@@ -47,7 +59,18 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Im
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const state = State.create<DeepMutable<Policy>, Draft>({
|
||||
name: "image",
|
||||
initial: () => ({
|
||||
autoResize: true,
|
||||
maxWidth: 2_000,
|
||||
maxHeight: 2_000,
|
||||
maxBase64Bytes: 5 * 1024 * 1024,
|
||||
}),
|
||||
draft: (policy) => ({
|
||||
update: (update) => update(policy),
|
||||
}),
|
||||
})
|
||||
const loadAdapter = yield* Effect.cached(
|
||||
Effect.tryPromise({
|
||||
try: () => import("./image/photon.js"),
|
||||
@@ -58,22 +81,17 @@ const layer = Layer.effect(
|
||||
resource: string,
|
||||
content: FileSystem.Content & { readonly encoding: "base64" },
|
||||
) {
|
||||
const image = Object.assign(
|
||||
{},
|
||||
...(yield* config.entries()).flatMap((entry) =>
|
||||
entry.type === "document" && entry.info.media?.image ? [entry.info.media.image] : [],
|
||||
),
|
||||
)
|
||||
const policy = state.get()
|
||||
const normalize = yield* loadAdapter
|
||||
return yield* normalize(resource, content, {
|
||||
autoResize: image.auto_resize ?? true,
|
||||
maxWidth: image.max_width ?? 2_000,
|
||||
maxHeight: image.max_height ?? 2_000,
|
||||
maxBase64Bytes: image.max_base64_bytes ?? 5 * 1024 * 1024,
|
||||
autoResize: policy.autoResize,
|
||||
maxWidth: policy.maxWidth,
|
||||
maxHeight: policy.maxHeight,
|
||||
maxBase64Bytes: policy.maxBase64Bytes,
|
||||
})
|
||||
})
|
||||
return Service.of({ normalize })
|
||||
return Service.of({ normalize, transform: state.transform, reload: state.reload })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Agent } from "./agent.js"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
import { Catalog } from "./catalog.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Formatter } from "./formatter.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { MCP } from "./mcp/index.js"
|
||||
@@ -154,6 +155,7 @@ export const node = makeLocationNode({
|
||||
AISDK.node,
|
||||
Catalog.node,
|
||||
Command.node,
|
||||
Formatter.node,
|
||||
Integration.node,
|
||||
MCP.node,
|
||||
Location.node,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AISDK } from "../aisdk.js"
|
||||
import { Catalog } from "../catalog.js"
|
||||
import { Command } from "../command.js"
|
||||
import { Credential } from "../credential.js"
|
||||
import { Formatter } from "../formatter.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Integration } from "../integration.js"
|
||||
import { Location } from "../location.js"
|
||||
@@ -36,6 +37,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
const commands = yield* Command.Service
|
||||
const bus = yield* Bus.Service
|
||||
const integration = yield* Integration.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const location = yield* Location.Service
|
||||
const reference = yield* Reference.Service
|
||||
@@ -185,6 +187,22 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
event: {
|
||||
subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)),
|
||||
},
|
||||
formatter: {
|
||||
reload: formatter.reload,
|
||||
transform: (callback) =>
|
||||
formatter.transform((draft) => {
|
||||
callback({
|
||||
add: (definition) =>
|
||||
draft.set(definition.name, {
|
||||
name: definition.name,
|
||||
extensions: [...definition.extensions],
|
||||
environment: definition.environment === undefined ? undefined : { ...definition.environment },
|
||||
enabled: Effect.succeed([...definition.command]),
|
||||
}),
|
||||
remove: draft.remove,
|
||||
})
|
||||
}),
|
||||
},
|
||||
integration: {
|
||||
list: () => response(integration.list()),
|
||||
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
|
||||
|
||||
@@ -12,6 +12,8 @@ import { Config } from "../config.js"
|
||||
import { Credential } from "../credential.js"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command.js"
|
||||
import { ConfigFormatterPlugin } from "../config/plugin/formatter.js"
|
||||
import { ConfigImagePlugin } from "../config/plugin/image.js"
|
||||
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
|
||||
import { ConfigMCPPlugin } from "../config/plugin/mcp.js"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
|
||||
@@ -20,6 +22,7 @@ import { ConfigReferencePlugin } from "../config/plugin/reference.js"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill.js"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
|
||||
import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Environment } from "../environment/index.js"
|
||||
import { FileMutation } from "../file-mutation.js"
|
||||
@@ -57,6 +60,7 @@ import { ShellTool } from "../tool/plugin/shell.js"
|
||||
import { SkillTool } from "../tool/plugin/skill.js"
|
||||
import { SubagentTool } from "../tool/plugin/subagent.js"
|
||||
import { Tool } from "../tool.js"
|
||||
import { ToolOutput } from "../tool-output.js"
|
||||
import { WebFetchTool } from "../tool/plugin/webfetch.js"
|
||||
import { WebSearchTool } from "../tool/plugin/websearch.js"
|
||||
import { WellKnown } from "../wellknown.js"
|
||||
@@ -111,6 +115,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const skillDiscovery = yield* SkillDiscovery.Service
|
||||
const tools = yield* Tool.Service
|
||||
const toolOutput = yield* ToolOutput.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const wellknown = yield* WellKnown.Service
|
||||
return Context.mergeAll(
|
||||
@@ -149,6 +154,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Skill.Service, skill),
|
||||
Context.make(SkillDiscovery.Service, skillDiscovery),
|
||||
Context.make(Tool.Service, tools),
|
||||
Context.make(ToolOutput.Service, toolOutput),
|
||||
Context.make(Watcher.Service, watcher),
|
||||
Context.make(WellKnown.Service, wellknown),
|
||||
)
|
||||
@@ -194,6 +200,7 @@ export const requirements = LayerNode.group([
|
||||
Skill.node,
|
||||
SkillDiscovery.node,
|
||||
Tool.node,
|
||||
ToolOutput.node,
|
||||
Watcher.node,
|
||||
WellKnown.node,
|
||||
])
|
||||
@@ -232,6 +239,9 @@ const post = [
|
||||
ConfigReferencePlugin.Plugin,
|
||||
ConfigAgentPlugin.Plugin,
|
||||
ConfigCommandPlugin.Plugin,
|
||||
ConfigFormatterPlugin.Plugin,
|
||||
ConfigImagePlugin.Plugin,
|
||||
ConfigToolOutputPlugin.Plugin,
|
||||
ConfigSkillPlugin.Plugin,
|
||||
ConfigProviderPlugin.Plugin,
|
||||
ConfigWebSearchPlugin.Plugin,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Effect } from "effect"
|
||||
const urls = [/^https:\/\/mcp\.cloudflare\.com\/mcp$/, /^https:\/\/executor\.sh\/[^/]+\/mcp$/]
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.mcp.codemode-exclusion",
|
||||
id: "opencode.mcp.codemode.exclusion",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.mcp.transform((draft) => {
|
||||
for (const [, server] of draft.list()) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ModelsDev } from "../models-dev.js"
|
||||
import { Provider } from "../provider.js"
|
||||
|
||||
export const ModelsDevPlugin = define({
|
||||
id: "opencode.models-dev",
|
||||
id: "opencode.models.dev",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const bus = yield* Bus.Service
|
||||
@@ -55,8 +55,13 @@ export const ModelsDevPlugin = define({
|
||||
})
|
||||
|
||||
function environmentNames(provider: ModelsDev.Snapshot) {
|
||||
if (provider.info.id !== Provider.ID.azure) return [...provider.environment]
|
||||
return [...provider.environment.filter((name) => name.endsWith("_API_KEY")), "AZURE_COGNITIVE_SERVICES_API_KEY"]
|
||||
if (provider.info.id === Provider.ID.azure)
|
||||
return [...provider.environment.filter((name) => name.endsWith("_API_KEY")), "AZURE_COGNITIVE_SERVICES_API_KEY"]
|
||||
// models.dev advertises project, location, and the ADC credentials file path for
|
||||
// Vertex. Those configure Google auth rather than carrying a key, so only the
|
||||
// Express Mode key may become a credential; GoogleVertexPlugin handles activation.
|
||||
if (provider.info.id === Provider.ID.googleVertex) return ["GOOGLE_VERTEX_API_KEY"]
|
||||
return [...provider.environment]
|
||||
}
|
||||
|
||||
function snapshots(data: readonly ModelsDev.Snapshot[]) {
|
||||
|
||||
@@ -60,7 +60,7 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
|
||||
}
|
||||
|
||||
export const AmazonBedrockPlugin = define({
|
||||
id: "opencode.provider.amazon-bedrock",
|
||||
id: "opencode.provider.amazon.bedrock",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { configuredSettings } from "./configured.js"
|
||||
const providerID = Provider.ID.make("cloudflare-ai-gateway")
|
||||
|
||||
export const CloudflareAIGatewayPlugin = define({
|
||||
id: "opencode.provider.cloudflare-ai-gateway",
|
||||
id: "opencode.provider.cloudflare.ai.gateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const configured = yield* configuredSettings(providerID)
|
||||
const form = iife(() => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { configuredSettings } from "./configured.js"
|
||||
const providerID = Provider.ID.make("cloudflare-workers-ai")
|
||||
|
||||
export const CloudflareWorkersAIPlugin = define({
|
||||
id: "opencode.provider.cloudflare-workers-ai",
|
||||
id: "opencode.provider.cloudflare.workers.ai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const configured = yield* configuredSettings(providerID)
|
||||
const form = iife(() => {
|
||||
|
||||
@@ -146,7 +146,7 @@ const oauth = (app: App.Info) =>
|
||||
}) satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
export const GithubCopilotPlugin = define({
|
||||
id: "opencode.provider.github-copilot",
|
||||
id: "opencode.provider.github.copilot",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const catalog = yield* Catalog.Service
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
@@ -55,7 +55,7 @@ function authFetch(fetchWithRuntimeOptions?: unknown) {
|
||||
}
|
||||
|
||||
export const GoogleVertexPlugin = define({
|
||||
id: "opencode.provider.google-vertex",
|
||||
id: "opencode.provider.google.vertex",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
@@ -71,6 +71,9 @@ export const GoogleVertexPlugin = define({
|
||||
const project = resolveProject(item.provider.settings ?? {})
|
||||
const location = String(resolveLocation(item.provider.settings ?? {}))
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
// Vertex authenticates through ADC rather than a key credential, so a
|
||||
// resolvable project is what makes the provider usable.
|
||||
if (project && provider.activation === "auto") provider.activation = "enabled"
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
...(project ? { project } : {}),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
|
||||
export const OpenAICompatiblePlugin = define({
|
||||
id: "opencode.provider.openai-compatible",
|
||||
id: "opencode.provider.openai.compatible",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Provider } from "../../provider.js"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
|
||||
export const SapAICorePlugin = define({
|
||||
id: "opencode.provider.sap-ai-core",
|
||||
id: "opencode.provider.sap.ai.core",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const npm = yield* Npm.Service
|
||||
yield* ctx.aisdk.hook(
|
||||
|
||||
@@ -65,7 +65,7 @@ export function cortexFetch(upstream: FetchLike = fetch) {
|
||||
}
|
||||
|
||||
export const SnowflakeCortexPlugin = define({
|
||||
id: "opencode.provider.snowflake-cortex",
|
||||
id: "opencode.provider.snowflake.cortex",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
|
||||
@@ -33,7 +33,7 @@ export const Plugins = [OpenAIPlugin, GooglePlugin, AnthropicPlugin, KimiPlugin,
|
||||
|
||||
function make(id: string, select: (modelID: string) => string | undefined) {
|
||||
return define({
|
||||
id: `opencode.system-prompt.${id}`,
|
||||
id: `opencode.prompt.${id}`,
|
||||
effect: Effect.fn(`SystemPromptPlugin.${id}`)(function* (ctx) {
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -48,6 +48,17 @@ const builtins = new Map<string, () => Promise<unknown>>([
|
||||
["@opencode-ai/ai/providers/azure/chat", () => import("@opencode-ai/ai/providers/azure/chat")],
|
||||
["@opencode-ai/ai/providers/azure/responses", () => import("@opencode-ai/ai/providers/azure/responses")],
|
||||
["@opencode-ai/ai/providers/google", () => import("@opencode-ai/ai/providers/google")],
|
||||
["@opencode-ai/ai/providers/google-vertex", () => import("@opencode-ai/ai/providers/google-vertex")],
|
||||
["@opencode-ai/ai/providers/google-vertex/gemini", () => import("@opencode-ai/ai/providers/google-vertex/gemini")],
|
||||
["@opencode-ai/ai/providers/google-vertex/chat", () => import("@opencode-ai/ai/providers/google-vertex/chat")],
|
||||
[
|
||||
"@opencode-ai/ai/providers/google-vertex/responses",
|
||||
() => import("@opencode-ai/ai/providers/google-vertex/responses"),
|
||||
],
|
||||
[
|
||||
"@opencode-ai/ai/providers/google-vertex/messages",
|
||||
() => import("@opencode-ai/ai/providers/google-vertex/messages"),
|
||||
],
|
||||
["@opencode-ai/ai/providers/openai", () => import("@opencode-ai/ai/providers/openai")],
|
||||
["@opencode-ai/ai/providers/openai/chat", () => import("@opencode-ai/ai/providers/openai/chat")],
|
||||
["@opencode-ai/ai/providers/openai/responses", () => import("@opencode-ai/ai/providers/openai/responses")],
|
||||
|
||||
@@ -231,7 +231,8 @@ export const layer = Layer.effect(
|
||||
http: {
|
||||
headers: SessionModelHeaders.make(session, app),
|
||||
},
|
||||
promptCacheKey: SessionPromptCacheKey.make(session.id),
|
||||
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
|
||||
promptCacheKey: SessionPromptCacheKey.make(session.fork?.sessionID ?? session.id),
|
||||
system: context.system,
|
||||
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
|
||||
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Money } from "@opencode-ai/schema/money"
|
||||
import type { TokenUsage } from "@opencode-ai/schema/token-usage"
|
||||
import type { Model } from "../model.js"
|
||||
|
||||
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
|
||||
const finite = (value: number) => (Number.isFinite(value) ? value : 0)
|
||||
const safe = (value: number | undefined) => Math.max(0, finite(value ?? 0))
|
||||
|
||||
export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({
|
||||
input: safe(usage?.nonCachedInputTokens),
|
||||
@@ -26,10 +27,10 @@ export function calculateCost(costs: Model.Info["cost"], usage: TokenUsage.Info)
|
||||
const cost = tier ?? costs.find((cost) => cost.tier === undefined)
|
||||
if (!cost) return Money.USD.zero
|
||||
return Money.USD.make(
|
||||
(usage.input * cost.input +
|
||||
(usage.output + usage.reasoning) * cost.output +
|
||||
usage.cache.read * cost.cache.read +
|
||||
usage.cache.write * cost.cache.write) /
|
||||
(usage.input * finite(cost.input) +
|
||||
(usage.output + usage.reasoning) * finite(cost.output) +
|
||||
usage.cache.read * finite(cost.cache.read) +
|
||||
usage.cache.write * finite(cost.cache.write)) /
|
||||
1_000_000,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ import { Context, Duration, Effect, Layer, Option, Schedule } from "effect"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Config } from "./config.js"
|
||||
import { Identifier } from "./id/id.js"
|
||||
import type { DeepMutable } from "./schema.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export const MAX_LINES = 2_000
|
||||
export const MAX_BYTES = 50 * 1024 // 50 KiB
|
||||
@@ -16,7 +17,16 @@ export const DIRECTORY = "tool-output"
|
||||
|
||||
type Result = Tool.Result
|
||||
|
||||
export interface Interface {
|
||||
export interface Policy {
|
||||
readonly maxLines: number
|
||||
readonly maxBytes: number
|
||||
}
|
||||
|
||||
export interface Draft {
|
||||
readonly update: (update: (policy: DeepMutable<Policy>) => void) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly truncate: (result: Result) => Effect.Effect<Result>
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
}
|
||||
@@ -46,19 +56,23 @@ const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface,
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.data, DIRECTORY)
|
||||
const state = State.create<DeepMutable<Policy>, Draft>({
|
||||
name: "tool-output",
|
||||
initial: () => ({ maxLines: MAX_LINES, maxBytes: MAX_BYTES }),
|
||||
draft: (policy) => ({
|
||||
update: (update) => update(policy),
|
||||
}),
|
||||
})
|
||||
|
||||
const truncate = Effect.fn("ToolOutput.truncate")(function* (result: Result) {
|
||||
if (result.metadata?.truncated !== undefined) return result
|
||||
const content =
|
||||
typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? [])
|
||||
const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n")
|
||||
const configured = Config.latest(yield* config.entries(), "tool_output")
|
||||
const maxLines = configured?.max_lines ?? MAX_LINES
|
||||
const maxBytes = configured?.max_bytes ?? MAX_BYTES
|
||||
const { maxLines, maxBytes } = state.get()
|
||||
const lines = text.split("\n")
|
||||
if (text.endsWith("\n")) lines.pop()
|
||||
const totalBytes = Buffer.byteLength(text, "utf-8")
|
||||
@@ -113,7 +127,12 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ truncate, cleanup: () => cleanup(fs, directory) })
|
||||
return Service.of({
|
||||
truncate,
|
||||
cleanup: () => cleanup(fs, directory),
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -137,5 +156,5 @@ const cleanupNode = makeGlobalNode({
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, FSUtil.node, Global.node, cleanupNode],
|
||||
deps: [FSUtil.node, Global.node, cleanupNode],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigImagePlugin } from "@opencode-ai/core/config/plugin/image"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigMedia } from "@opencode-ai/schema/config/media"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { host } from "../plugin/host"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
describe("ConfigImagePlugin.Plugin", () => {
|
||||
it.live("materializes image policy from config", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = {
|
||||
autoResize: true,
|
||||
maxWidth: 2_000,
|
||||
maxHeight: 2_000,
|
||||
maxBase64Bytes: 5 * 1024 * 1024,
|
||||
}
|
||||
const image = Image.Service.of({
|
||||
normalize: () => Effect.die("unused image.normalize"),
|
||||
reload: () => Effect.void,
|
||||
transform: (callback) =>
|
||||
Effect.sync(() => {
|
||||
callback({ update: (update) => update(policy) })
|
||||
return { dispose: Effect.void }
|
||||
}),
|
||||
})
|
||||
yield* ConfigImagePlugin.Plugin.effect(host()).pipe(
|
||||
Effect.provideService(Image.Service, image),
|
||||
Effect.provide(
|
||||
Config.testLayer([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
media: new ConfigMedia.Info({
|
||||
image: new ConfigMedia.Image({
|
||||
auto_resize: false,
|
||||
max_width: 1_000,
|
||||
max_height: 800,
|
||||
max_base64_bytes: 123_456,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
expect(policy).toEqual({
|
||||
autoResize: false,
|
||||
maxWidth: 1_000,
|
||||
maxHeight: 800,
|
||||
maxBase64Bytes: 123_456,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -150,6 +150,16 @@ describe("ConfigNormalize", () => {
|
||||
})
|
||||
|
||||
test("migrates the legacy small model to the title agent", () => {
|
||||
const result = normalized({ small_model: "anthropic/claude-haiku-4-5" })
|
||||
expect(result.encoded.agents).toEqual({
|
||||
title: {
|
||||
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
|
||||
},
|
||||
})
|
||||
expect(result.diagnostics).toEqual([])
|
||||
})
|
||||
|
||||
test("merges the legacy small model with the title agent", () => {
|
||||
const result = normalized({
|
||||
small_model: "anthropic/claude-haiku-4-5",
|
||||
agent: { title: { prompt: "Custom title prompt" } },
|
||||
|
||||
@@ -7,33 +7,32 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { Config } from "../src/config"
|
||||
import { ConfigFormatterPlugin } from "../src/config/plugin/formatter"
|
||||
import { Formatter } from "../src/formatter"
|
||||
import { Location } from "../src/location"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { host } from "./plugin/host"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
type ConfigInput = typeof Info.Encoded
|
||||
|
||||
function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
|
||||
const entries =
|
||||
configured === undefined
|
||||
? []
|
||||
: [
|
||||
new Document({
|
||||
type: "document",
|
||||
info: Schema.decodeUnknownSync(Info)({ formatter: configured }),
|
||||
}),
|
||||
]
|
||||
return AppNodeBuilder.build(Formatter.node, [
|
||||
[Config.node, Config.testLayer(entries)],
|
||||
const layer = AppNodeBuilder.build(Formatter.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
],
|
||||
[Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })],
|
||||
])
|
||||
const entries =
|
||||
configured === undefined
|
||||
? []
|
||||
: [new Document({ type: "document", info: Schema.decodeUnknownSync(Info)({ formatter: configured }) })]
|
||||
return Layer.effectDiscard(ConfigFormatterPlugin.Plugin.effect(host()).pipe(Effect.provide(Config.testLayer(entries)))).pipe(
|
||||
Layer.provideMerge(layer),
|
||||
)
|
||||
}
|
||||
|
||||
function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
@@ -162,4 +161,34 @@ describe("Formatter", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("resolves a replacement formatter command independently", () =>
|
||||
withTemp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const formatter = yield* Formatter.Service
|
||||
const file = path.join(directory, "test.replaced")
|
||||
const register = (content: string) =>
|
||||
formatter.transform((draft) => {
|
||||
draft.set("replacement", {
|
||||
name: "replacement",
|
||||
extensions: [".replaced"],
|
||||
enabled: Effect.succeed([
|
||||
process.execPath,
|
||||
"-e",
|
||||
`require('fs').writeFileSync(process.argv.at(-1), '${content}')`,
|
||||
"$FILE",
|
||||
]),
|
||||
})
|
||||
})
|
||||
|
||||
yield* register("first")
|
||||
expect(yield* formatter.file(file)).toBe(true)
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("first")
|
||||
|
||||
yield* register("second")
|
||||
expect(yield* formatter.file(file)).toBe(true)
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("second")
|
||||
}).pipe(Effect.provide(formatterLayer(directory, false))),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -4,4 +4,6 @@ import { Effect, Layer } from "effect"
|
||||
/** Passthrough resizer for tests that build Tool.node without a Location. */
|
||||
export const imagePassthrough = Layer.mock(Image.Service, {
|
||||
normalize: (_resource, content) => Effect.succeed(content),
|
||||
transform: () => Effect.die("unused image.transform"),
|
||||
reload: () => Effect.die("unused image.reload"),
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -16,6 +17,8 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
@@ -99,6 +102,34 @@ describe("Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers formatters through the plugin context", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const location = yield* Location.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const file = path.join(location.directory, "plugin.formatter-test")
|
||||
yield* Effect.promise(() => fs.writeFile(file, "before"))
|
||||
const registration = yield* host.formatter.transform((draft) => {
|
||||
draft.add({
|
||||
name: "plugin",
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
"const fs = require('fs'); fs.writeFileSync(process.argv.at(-1), 'after')",
|
||||
"$FILE",
|
||||
],
|
||||
extensions: [".formatter-test"],
|
||||
})
|
||||
})
|
||||
|
||||
expect(yield* formatter.file(file)).toBe(true)
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("after")
|
||||
yield* registration.dispose
|
||||
expect(yield* formatter.file(file)).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces plugins by ID and version", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Bus } from "@opencode-ai/core/bus"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
@@ -44,6 +45,7 @@ export const PluginTestLayer = LayerNode.compile(
|
||||
Credential.node,
|
||||
Bus.node,
|
||||
Form.node,
|
||||
Formatter.node,
|
||||
LayerNodePlatform.httpClient,
|
||||
Plugin.node,
|
||||
Agent.node,
|
||||
|
||||
@@ -48,6 +48,10 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
event: overrides.event ?? {
|
||||
subscribe: () => Stream.empty,
|
||||
},
|
||||
formatter: overrides.formatter ?? {
|
||||
transform: () => Effect.die("unused formatter.transform"),
|
||||
reload: () => Effect.die("unused formatter.reload"),
|
||||
},
|
||||
integration: overrides.integration ?? {
|
||||
list: () => Effect.die("unused integration.list"),
|
||||
get: () => Effect.die("unused integration.get"),
|
||||
|
||||
@@ -421,8 +421,48 @@ describe("ModelsDevPlugin", () => {
|
||||
expect(yield* integrations.get(Integration.ID.make("google-vertex"))).toBeDefined()
|
||||
expect(yield* integrations.get(Integration.ID.make("azure-cognitive-services"))).toBeUndefined()
|
||||
expect(yield* integrations.get(Integration.ID.make("google-vertex-anthropic"))).toBeUndefined()
|
||||
expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.azure-cognitive-services")
|
||||
expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.google-vertex-anthropic")
|
||||
expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.azure.cognitive.services")
|
||||
expect(ProviderPlugins.map((plugin) => plugin.id)).not.toContain("opencode.provider.google.vertex.anthropic")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("advertises only key-bearing Google Vertex environment variables", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
|
||||
yield* ModelsDevPlugin.effect(
|
||||
host({
|
||||
catalog: catalogHost(catalog),
|
||||
integration: integrationHost(integrations),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
ModelsDev.Service,
|
||||
ModelsDev.Service.of({
|
||||
get: () =>
|
||||
Effect.succeed([
|
||||
{
|
||||
info: {
|
||||
id: Provider.ID.make("google-vertex"),
|
||||
name: "Google Vertex",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/google-vertex"),
|
||||
},
|
||||
environment: ["GOOGLE_VERTEX_PROJECT", "GOOGLE_VERTEX_LOCATION", "GOOGLE_APPLICATION_CREDENTIALS"],
|
||||
models: [],
|
||||
},
|
||||
] satisfies readonly ModelsDev.Snapshot[]),
|
||||
refresh: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// Vertex authenticates through ADC; project, location, and the credentials
|
||||
// file path are configuration, not API keys.
|
||||
expect(yield* integrations.get(Integration.ID.make("google-vertex"))).toMatchObject({
|
||||
methods: [{ type: "key" }, { type: "env", names: ["GOOGLE_VERTEX_API_KEY"] }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -141,6 +141,52 @@ describe("GoogleVertexPlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("enables the provider when a project resolves and leaves it automatic otherwise", () =>
|
||||
withEnv(
|
||||
{
|
||||
GOOGLE_VERTEX_PROJECT: undefined,
|
||||
GOOGLE_CLOUD_PROJECT: undefined,
|
||||
GCP_PROJECT: undefined,
|
||||
GCLOUD_PROJECT: undefined,
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(Provider.ID.make("google-vertex"), (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/google-vertex")
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.make("google-vertex"))).activation).toBe("auto")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("enables the provider when a project resolves from env", () =>
|
||||
withEnv(
|
||||
{
|
||||
GOOGLE_VERTEX_PROJECT: undefined,
|
||||
GOOGLE_CLOUD_PROJECT: "adc-project",
|
||||
GCP_PROJECT: undefined,
|
||||
GCLOUD_PROJECT: undefined,
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
catalog.provider.update(Provider.ID.make("google-vertex"), (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/google-vertex")
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.make("google-vertex"))).activation).toBe("enabled")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("resolves the advertised GOOGLE_VERTEX_PROJECT env for provider updates and SDKs", () =>
|
||||
withEnv(
|
||||
{
|
||||
|
||||
@@ -43,10 +43,10 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
||||
describe("SnowflakeCortexPlugin", () => {
|
||||
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
|
||||
Effect.sync(() => {
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.snowflake-cortex")
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.snowflake.cortex")
|
||||
const ids = ProviderPlugins.map((p) => p.id)
|
||||
expect(ids.indexOf("opencode.provider.snowflake-cortex")).toBeLessThan(
|
||||
ids.indexOf("opencode.provider.openai-compatible"),
|
||||
expect(ids.indexOf("opencode.provider.snowflake.cortex")).toBeLessThan(
|
||||
ids.indexOf("opencode.provider.openai.compatible"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -48,12 +48,12 @@ describe("SystemPromptPlugin", () => {
|
||||
|
||||
test("uses granular IDs with a common prefix", () => {
|
||||
expect(SystemPromptPlugin.Plugins.map((plugin) => plugin.id)).toEqual([
|
||||
"opencode.system-prompt.openai",
|
||||
"opencode.system-prompt.google",
|
||||
"opencode.system-prompt.anthropic",
|
||||
"opencode.system-prompt.kimi",
|
||||
"opencode.system-prompt.arcee",
|
||||
"opencode.system-prompt.meta",
|
||||
"opencode.prompt.openai",
|
||||
"opencode.prompt.google",
|
||||
"opencode.prompt.anthropic",
|
||||
"opencode.prompt.kimi",
|
||||
"opencode.prompt.arcee",
|
||||
"opencode.prompt.meta",
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
|
||||
describe("Provider", () => {
|
||||
test("loads Vertex native provider entrypoints", async () => {
|
||||
const packages = [
|
||||
"@opencode-ai/ai/providers/google-vertex",
|
||||
"@opencode-ai/ai/providers/google-vertex/gemini",
|
||||
"@opencode-ai/ai/providers/google-vertex/chat",
|
||||
"@opencode-ai/ai/providers/google-vertex/responses",
|
||||
"@opencode-ai/ai/providers/google-vertex/messages",
|
||||
]
|
||||
|
||||
for (const specifier of packages) {
|
||||
const loaded = await Effect.runPromise(Provider.loadPackage(specifier))
|
||||
expect(loaded.model).toBeFunction()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -197,6 +197,29 @@ test("calculates step cost using the matching context tier", () => {
|
||||
).toBeCloseTo(0.0002926)
|
||||
})
|
||||
|
||||
test("ignores malformed model cost fields", () => {
|
||||
const costs = [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(3),
|
||||
output: Money.USDPerMillionTokens.make(15),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.make(0.3),
|
||||
write: Money.USDPerMillionTokens.make(3.75),
|
||||
},
|
||||
},
|
||||
]
|
||||
Object.assign(costs[0], { input: {} })
|
||||
|
||||
expect(
|
||||
SessionUsage.calculateCost(costs, {
|
||||
input: 1_000_000,
|
||||
output: 100_000,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
}),
|
||||
).toBe(Money.USD.make(1.5))
|
||||
})
|
||||
|
||||
test("does not apply an ineligible tier without base pricing", () => {
|
||||
expect(
|
||||
SessionUsage.calculateCost(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigToolOutputPlugin } from "@opencode-ai/core/config/plugin/tool-output"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -12,6 +13,7 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { Identifier } from "@opencode-ai/core/id/id"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
import { host } from "./plugin/host"
|
||||
|
||||
const withStore = <A, E, R>(
|
||||
body: (output: ToolOutput.Interface, fs: FSUtil.Interface, root: string) => Effect.Effect<A, E, R>,
|
||||
@@ -21,10 +23,12 @@ const withStore = <A, E, R>(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
const config = Config.testLayer([new Document({ type: "document", info })])
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [
|
||||
[Config.node, config],
|
||||
const base = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [
|
||||
[Global.node, Global.layerWith({ data: tmp.path })],
|
||||
])
|
||||
const layer = Layer.effectDiscard(
|
||||
ConfigToolOutputPlugin.Plugin.effect(host()).pipe(Effect.provide(config)),
|
||||
).pipe(Layer.provideMerge(base))
|
||||
return Effect.gen(function* () {
|
||||
return yield* body(yield* ToolOutput.Service, yield* FSUtil.Service, tmp.path)
|
||||
}).pipe(Effect.provide(layer))
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Exit, Layer, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigImagePlugin } from "@opencode-ai/core/config/plugin/image"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigMedia } from "@opencode-ai/schema/config/media"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -25,6 +26,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
import { host } from "./plugin/host"
|
||||
|
||||
const readToolNode = makeLocationNode({
|
||||
name: "test/read-tool-plugin",
|
||||
@@ -90,7 +92,8 @@ const permission = permissionLayer({
|
||||
),
|
||||
})
|
||||
const config = Config.testLayer()
|
||||
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
|
||||
const imageLayer = AppNodeBuilder.build(Image.node)
|
||||
const configureImage = ConfigImagePlugin.Plugin.effect(host())
|
||||
const testFileSystem = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.use((fs) =>
|
||||
@@ -132,11 +135,15 @@ const mutation = Layer.succeed(
|
||||
)
|
||||
const unavailableImage = Layer.succeed(
|
||||
Image.Service,
|
||||
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
|
||||
Image.Service.of({
|
||||
normalize: () => Effect.fail(new Image.ResizerUnavailableError()),
|
||||
transform: () => Effect.die("unused image.transform"),
|
||||
reload: () => Effect.die("unused image.reload"),
|
||||
}),
|
||||
)
|
||||
const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
|
||||
Layer.mergeAll(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, readToolNode]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, readToolNode, Image.node]), [
|
||||
[ReadToolFileSystem.node, reader],
|
||||
[Permission.node, permission],
|
||||
[Config.node, config],
|
||||
@@ -395,6 +402,7 @@ describe("ReadTool", () => {
|
||||
}),
|
||||
}),
|
||||
])
|
||||
yield* configureImage
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
@@ -436,6 +444,7 @@ describe("ReadTool", () => {
|
||||
}),
|
||||
}),
|
||||
])
|
||||
yield* configureImage
|
||||
const registry = yield* Tool.Service
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
@@ -477,6 +486,7 @@ describe("ReadTool", () => {
|
||||
}),
|
||||
}),
|
||||
])
|
||||
yield* configureImage
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
|
||||
@@ -32,9 +32,9 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.3",
|
||||
"@opentui/keymap": ">=0.5.3",
|
||||
"@opentui/solid": ">=0.5.3",
|
||||
"@opentui/core": ">=0.5.4",
|
||||
"@opentui/keymap": ">=0.5.4",
|
||||
"@opentui/solid": ">=0.5.4",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Effect } from "effect"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface FormatterDefinition {
|
||||
readonly name: string
|
||||
readonly command: readonly string[]
|
||||
readonly extensions: readonly string[]
|
||||
readonly environment?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export interface FormatterDraft {
|
||||
readonly add: (formatter: FormatterDefinition) => void
|
||||
readonly remove: (name: string) => void
|
||||
}
|
||||
|
||||
export interface FormatterDomain {
|
||||
readonly transform: Transform<FormatterDraft>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { AISDKDomain } from "./aisdk.js"
|
||||
import type { CatalogDomain } from "./catalog.js"
|
||||
import type { CommandDomain } from "./command.js"
|
||||
import type { EventDomain } from "./event.js"
|
||||
import type { FormatterDomain } from "./formatter.js"
|
||||
import type { IntegrationDomain } from "./integration.js"
|
||||
import type { MCPDomain } from "./mcp.js"
|
||||
import type { ReferenceDomain } from "./reference.js"
|
||||
@@ -24,6 +25,7 @@ export interface Context {
|
||||
readonly catalog: CatalogDomain
|
||||
readonly command: CommandDomain
|
||||
readonly event: EventDomain
|
||||
readonly formatter: FormatterDomain
|
||||
readonly integration: IntegrationDomain
|
||||
readonly mcp: MCPDomain
|
||||
readonly plugin: PluginApi<unknown>
|
||||
|
||||
@@ -158,6 +158,10 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
),
|
||||
},
|
||||
formatter: {
|
||||
transform: transform(host.formatter),
|
||||
reload: () => run(host.formatter.reload()),
|
||||
},
|
||||
integration: {
|
||||
list: adaptApiMethod(IntegrationEndpoints["integration.list"], host.integration.list),
|
||||
get: adaptApiMethod(IntegrationEndpoints["integration.get"], host.integration.get),
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface FormatterDefinition {
|
||||
readonly name: string
|
||||
readonly command: readonly string[]
|
||||
readonly extensions: readonly string[]
|
||||
readonly environment?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export interface FormatterDraft {
|
||||
readonly add: (formatter: FormatterDefinition) => void
|
||||
readonly remove: (name: string) => void
|
||||
}
|
||||
|
||||
export interface FormatterDomain {
|
||||
readonly transform: Transform<FormatterDraft>
|
||||
readonly reload: () => Promise<void>
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { AISDKDomain } from "./aisdk.js"
|
||||
import type { CatalogDomain } from "./catalog.js"
|
||||
import type { CommandDomain } from "./command.js"
|
||||
import type { EventDomain } from "./event.js"
|
||||
import type { FormatterDomain } from "./formatter.js"
|
||||
import type { IntegrationDomain } from "./integration.js"
|
||||
import type { MCPDomain } from "./mcp.js"
|
||||
import type { ReferenceDomain } from "./reference.js"
|
||||
@@ -23,6 +24,7 @@ export interface Context {
|
||||
readonly catalog: CatalogDomain
|
||||
readonly command: CommandDomain
|
||||
readonly event: EventDomain
|
||||
readonly formatter: FormatterDomain
|
||||
readonly integration: IntegrationDomain
|
||||
readonly mcp: MCPDomain
|
||||
readonly plugin: PluginApi
|
||||
|
||||
@@ -48,58 +48,6 @@
|
||||
"summary": "Check server health"
|
||||
}
|
||||
},
|
||||
"/api/service/stop": {
|
||||
"post": {
|
||||
"tags": ["health"],
|
||||
"operationId": "v2.health.stop",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ServiceStopResponse",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceStopResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Request graceful shutdown of one exact managed server instance.",
|
||||
"summary": "Stop the managed server",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceStopRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/server": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
@@ -9783,26 +9731,6 @@
|
||||
"required": ["_tag", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ServiceStopRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"instanceID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["instanceID"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ServiceStopResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["accepted"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Union_1": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -9,16 +9,6 @@ export namespace ServiceStatus {
|
||||
pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||
}).annotate({ identifier: "ServiceHealth" })
|
||||
export type Health = typeof Health.Type
|
||||
|
||||
export const StopRequest = Schema.Struct({
|
||||
instanceID: Schema.String,
|
||||
}).annotate({ identifier: "ServiceStopRequest" })
|
||||
export type StopRequest = typeof StopRequest.Type
|
||||
|
||||
export const StopResponse = Schema.Struct({
|
||||
accepted: Schema.Boolean,
|
||||
}).annotate({ identifier: "ServiceStopResponse" })
|
||||
export type StopResponse = typeof StopResponse.Type
|
||||
}
|
||||
|
||||
export const HealthGroup = HttpApiGroup.make("server.health")
|
||||
@@ -33,16 +23,4 @@ export const HealthGroup = HttpApiGroup.make("server.health")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("health.stop", "/api/service/stop", {
|
||||
payload: ServiceStatus.StopRequest,
|
||||
success: ServiceStatus.StopResponse,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.health.stop",
|
||||
summary: "Stop the managed server",
|
||||
description: "Request graceful shutdown of one exact managed server instance.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "health" }))
|
||||
|
||||
@@ -4,17 +4,15 @@ import { Api } from "../api"
|
||||
import { ServerInfo } from "../server-info"
|
||||
|
||||
export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) =>
|
||||
handlers
|
||||
.handle("health.get", () =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* ServerInfo.Service
|
||||
return {
|
||||
healthy: true as const,
|
||||
version: info.app.version ?? "unknown",
|
||||
// Runtimes without OS process identity (workerd) report 0.
|
||||
pid: process.pid ?? 0,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle("health.stop", () => Effect.succeed({ accepted: false })),
|
||||
handlers.handle("health.get", () =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* ServerInfo.Service
|
||||
return {
|
||||
healthy: true as const,
|
||||
version: info.app.version ?? "unknown",
|
||||
// Runtimes without OS process identity (workerd) report 0.
|
||||
pid: process.pid ?? 0,
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
export * as ServerProcess from "./process"
|
||||
|
||||
import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
||||
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
|
||||
import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Schema, Scope } from "effect"
|
||||
import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Scope } from "effect"
|
||||
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { createServer } from "node:http"
|
||||
import { ServerAuth } from "./auth"
|
||||
import { isAllowedCorsOrigin } from "./cors"
|
||||
@@ -18,7 +16,6 @@ import { Status } from "./service-status"
|
||||
import type { ServerOptions } from "./options"
|
||||
|
||||
export interface Lifecycle<E = never, R = never> {
|
||||
readonly instanceID: string
|
||||
readonly onListen: (
|
||||
address: HttpServer.Address,
|
||||
shutdown: Effect.Effect<void>,
|
||||
@@ -51,16 +48,13 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
const hostname = options.hostname ?? "127.0.0.1"
|
||||
const port = Option.fromNullishOr(options.port)
|
||||
const shutdown = yield* Deferred.make<void>()
|
||||
const status = yield* Status.make({
|
||||
instanceID: lifecycle?.instanceID ?? randomUUID(),
|
||||
managed: lifecycle !== undefined,
|
||||
})
|
||||
const status = yield* Status.make()
|
||||
const bound = yield* listen({ hostname, port })
|
||||
const application = yield* Ref.make(Option.none<App>())
|
||||
// Request fibers may continue inbound trace context, but must not inherit the server startup parent.
|
||||
yield* bound.http
|
||||
.serve(
|
||||
dispatch(password, status, application, shutdown, options.app?.version ?? "unknown").pipe(
|
||||
dispatch(password, status, application, options.app?.version ?? "unknown").pipe(
|
||||
HttpMiddleware.cors({ allowedOrigins: isAllowedCorsOrigin, maxAge: 86_400 }),
|
||||
),
|
||||
errorResponseLogger,
|
||||
@@ -163,22 +157,15 @@ function dispatch(
|
||||
password: string,
|
||||
status: Status.Interface,
|
||||
application: Ref.Ref<Option.Option<App>>,
|
||||
shutdown: Deferred.Deferred<void>,
|
||||
version: string,
|
||||
): App {
|
||||
const auth = ServerAuth.Config.of({ password: Option.some(password), username: "opencode" })
|
||||
return Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
const lifecycle =
|
||||
request.method === "GET" && url.pathname === "/api/health"
|
||||
? "health"
|
||||
: request.method === "POST" && url.pathname === "/api/service/stop"
|
||||
? "stop"
|
||||
: undefined
|
||||
if (lifecycle !== undefined) {
|
||||
if (request.method === "GET" && url.pathname === "/api/health") {
|
||||
if (!(yield* authorizedRequest(request, auth))) return unauthorized()
|
||||
return yield* control(request, lifecycle, status, () => Deferred.doneUnsafe(shutdown, Effect.void), version)
|
||||
return yield* healthResponse(status, version)
|
||||
}
|
||||
const state = yield* status.current
|
||||
const app = yield* Ref.get(application)
|
||||
@@ -196,33 +183,6 @@ function unauthorized() {
|
||||
})
|
||||
}
|
||||
|
||||
const control = Effect.fnUntraced(function* (
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
route: "health" | "stop",
|
||||
status: Status.Interface,
|
||||
stop: () => void,
|
||||
version: string,
|
||||
) {
|
||||
if (route === "health") return yield* healthResponse(status, version)
|
||||
const body = yield* request.json.pipe(Effect.option)
|
||||
const input = Option.isSome(body) ? Schema.decodeUnknownOption(ServiceStatus.StopRequest)(body.value) : Option.none()
|
||||
if (Option.isNone(input)) return HttpServerResponse.jsonUnsafe({ code: "invalid_request" }, { status: 400 })
|
||||
const accepted = yield* status.requestStop(input.value)
|
||||
if (accepted) {
|
||||
const response = NodeHttpServerRequest.toServerResponse(request)
|
||||
yield* Effect.sync(() => {
|
||||
const complete = () => {
|
||||
response.off("finish", complete)
|
||||
response.off("close", complete)
|
||||
stop()
|
||||
}
|
||||
response.once("finish", complete)
|
||||
response.once("close", complete)
|
||||
})
|
||||
}
|
||||
return HttpServerResponse.jsonUnsafe({ accepted })
|
||||
})
|
||||
|
||||
const healthResponse = Effect.fnUntraced(function* (status: Status.Interface, version: string) {
|
||||
const state = yield* status.current
|
||||
return HttpServerResponse.jsonUnsafe(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export * as Status from "./service-status"
|
||||
|
||||
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
||||
import { Effect, Ref } from "effect"
|
||||
|
||||
export type State =
|
||||
@@ -14,14 +13,9 @@ export interface Interface {
|
||||
readonly ready: Effect.Effect<void>
|
||||
readonly fail: Effect.Effect<void>
|
||||
readonly beginStopping: Effect.Effect<void>
|
||||
readonly requestStop: (request: ServiceStatus.StopRequest) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export const make = Effect.fnUntraced(function* (options: {
|
||||
readonly instanceID: string
|
||||
readonly managed: boolean
|
||||
readonly initial?: State
|
||||
}) {
|
||||
export const make = Effect.fnUntraced(function* (options: { readonly initial?: State } = {}) {
|
||||
const current = yield* Ref.make(options.initial ?? ({ type: "starting" } satisfies State))
|
||||
const beginStopping = Ref.update(current, (status) =>
|
||||
status.type === "stopping" ? status : ({ type: "stopping" } satisfies State),
|
||||
@@ -32,9 +26,5 @@ export const make = Effect.fnUntraced(function* (options: {
|
||||
ready: Ref.update(current, (status) => (status.type === "starting" ? ({ type: "ready" } satisfies State) : status)),
|
||||
fail: Ref.update(current, (status) => (status.type === "starting" ? ({ type: "failed" } satisfies State) : status)),
|
||||
beginStopping,
|
||||
requestStop: (request) => {
|
||||
if (!options.managed || request.instanceID !== options.instanceID) return Effect.succeed(false)
|
||||
return beginStopping.pipe(Effect.as(true))
|
||||
},
|
||||
} satisfies Interface
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Status } from "../src/service-status"
|
||||
|
||||
it.effect("moves from starting to ready", () =>
|
||||
Effect.gen(function* () {
|
||||
const status = yield* Status.make({ instanceID: "one", managed: false })
|
||||
const status = yield* Status.make()
|
||||
expect(yield* status.current).toEqual({ type: "starting" })
|
||||
yield* status.ready
|
||||
expect(yield* status.current).toEqual({ type: "ready" })
|
||||
@@ -14,7 +14,7 @@ it.effect("moves from starting to ready", () =>
|
||||
|
||||
it.effect("keeps a startup failure until shutdown", () =>
|
||||
Effect.gen(function* () {
|
||||
const status = yield* Status.make({ instanceID: "one", managed: true })
|
||||
const status = yield* Status.make()
|
||||
yield* status.fail
|
||||
yield* status.ready
|
||||
yield* status.fail
|
||||
@@ -22,24 +22,13 @@ it.effect("keeps a startup failure until shutdown", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stops only the addressed managed instance", () =>
|
||||
Effect.gen(function* () {
|
||||
const status = yield* Status.make({ instanceID: "one", managed: true })
|
||||
|
||||
expect(yield* status.requestStop({ instanceID: "other" })).toBe(false)
|
||||
expect(yield* status.current).toEqual({ type: "starting" })
|
||||
expect(yield* status.requestStop({ instanceID: "one" })).toBe(true)
|
||||
expect(yield* status.current).toEqual({ type: "stopping" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps stopping after shutdown begins", () =>
|
||||
Effect.gen(function* () {
|
||||
const status = yield* Status.make({ instanceID: "one", managed: true })
|
||||
const status = yield* Status.make()
|
||||
|
||||
yield* status.beginStopping
|
||||
expect(yield* status.current).toEqual({ type: "stopping" })
|
||||
expect(yield* status.requestStop({ instanceID: "one" })).toBe(true)
|
||||
yield* status.beginStopping
|
||||
expect(yield* status.current).toEqual({ type: "stopping" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -99,7 +99,7 @@ function View(props: { context: Plugin.Context }) {
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.home-footer",
|
||||
id: "opencode.home.footer",
|
||||
setup(context) {
|
||||
// Root takeover: an external plugin replacing home.footer wins (last-
|
||||
// enabled) and this builtin shows as suppressed, not silently gone.
|
||||
|
||||
@@ -83,7 +83,7 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.prompt-footer",
|
||||
id: "opencode.prompt.footer",
|
||||
setup(context) {
|
||||
context.ui.slot({
|
||||
append: "prompt.footer",
|
||||
|
||||
@@ -42,7 +42,7 @@ export function SidebarContext(props: { context: Plugin.Context; sessionID: stri
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "internal:sidebar-context",
|
||||
id: "opencode.sidebar.context",
|
||||
setup(context) {
|
||||
context.ui.slot({
|
||||
append: "sidebar.content",
|
||||
|
||||
@@ -40,7 +40,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.sidebar-footer",
|
||||
id: "opencode.sidebar.footer",
|
||||
setup(context) {
|
||||
// Append keeps the path open to additive plugin claims; an external
|
||||
// replace still takes the boundary over.
|
||||
|
||||
@@ -71,7 +71,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "internal:sidebar-mcp",
|
||||
id: "opencode.sidebar.mcp",
|
||||
setup(context) {
|
||||
context.ui.slot({
|
||||
append: "sidebar.content",
|
||||
|
||||
@@ -1079,7 +1079,7 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "diff-viewer",
|
||||
id: "opencode.diffs",
|
||||
setup(context) {
|
||||
context.ui.router.register({
|
||||
name: ROUTE,
|
||||
|
||||
@@ -29,7 +29,7 @@ test("closing the diff viewer returns to the route it opened from", async () =>
|
||||
try {
|
||||
expect(viewer.current()).toEqual({
|
||||
type: "plugin",
|
||||
id: "diff-viewer",
|
||||
id: "opencode.diffs",
|
||||
name: "diff",
|
||||
data: { mode: "working", sessionID: "session-1", returnRoute: startRoute },
|
||||
})
|
||||
@@ -207,7 +207,7 @@ async function renderDiffViewer(
|
||||
navigate(destination: Destination) {
|
||||
setCurrent(
|
||||
destination.type === "plugin" && !("id" in destination)
|
||||
? { ...destination, id: "diff-viewer" }
|
||||
? { ...destination, id: "opencode.diffs" }
|
||||
: destination,
|
||||
)
|
||||
},
|
||||
@@ -334,7 +334,7 @@ test("branch diff source requests branch VCS diff", async () => {
|
||||
const viewer = await renderDiffViewer([], {
|
||||
initialRoute: {
|
||||
type: "plugin",
|
||||
id: "diff-viewer",
|
||||
id: "opencode.diffs",
|
||||
name: "diff",
|
||||
data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
|
||||
},
|
||||
@@ -342,7 +342,7 @@ test("branch diff source requests branch VCS diff", async () => {
|
||||
try {
|
||||
expect(viewer.current()).toEqual({
|
||||
type: "plugin",
|
||||
id: "diff-viewer",
|
||||
id: "opencode.diffs",
|
||||
name: "diff",
|
||||
data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
|
||||
})
|
||||
|
||||
@@ -48,58 +48,6 @@
|
||||
"summary": "Check server health"
|
||||
}
|
||||
},
|
||||
"/api/service/stop": {
|
||||
"post": {
|
||||
"tags": ["health"],
|
||||
"operationId": "v2.health.stop",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ServiceStopResponse",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceStopResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Request graceful shutdown of one exact managed server instance.",
|
||||
"summary": "Stop the managed server",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceStopRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/server": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
@@ -9783,26 +9731,6 @@
|
||||
"required": ["_tag", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ServiceStopRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"instanceID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["instanceID"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ServiceStopResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["accepted"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Union_1": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -48,58 +48,6 @@
|
||||
"summary": "Check server health"
|
||||
}
|
||||
},
|
||||
"/api/service/stop": {
|
||||
"post": {
|
||||
"tags": ["health"],
|
||||
"operationId": "v2.health.stop",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ServiceStopResponse",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceStopResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Request graceful shutdown of one exact managed server instance.",
|
||||
"summary": "Stop the managed server",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceStopRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/server": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
@@ -9783,26 +9731,6 @@
|
||||
"required": ["_tag", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ServiceStopRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"instanceID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["instanceID"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ServiceStopResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accepted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["accepted"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Union_1": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user