Compare commits

..

22 Commits

Author SHA1 Message Date
Dax Raad 6bedcad47b docs: add Sol model discount pricing 2026-08-18 15:20:46 -04:00
opencode-agent[bot] 02f3f3cb3e test(core): remove flaky webfetch checks (#43278)
Co-authored-by: nexxeln <nexxeln@users.noreply.github.com>
2026-08-18 20:26:26 +02:00
Dax b0c3a16ead feat(plugin): expose server plugin status 2026-08-18 14:22:32 -04:00
Filip 594c395576 fix(tui): restore MCP sign-in flow (#43274) 2026-08-18 20:02:10 +02:00
Dax b7402c264d fix(core): reduce noisy tracing spans
Reduce low-value tracing emitted during parallel tool execution and suppress per-statement SQL spans.
2026-08-18 13:42:50 -04:00
Dax 3f79699bce feat(core): persist websearch provider selection (#43268) 2026-08-18 17:32:08 +00:00
Dax 67fe76057e feat(cli): configure service environment (#43269) 2026-08-18 13:27:44 -04:00
opencode-agent[bot] bac474aaa0 fix(tui): scope session picker to active location (#43264)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-18 17:01:15 +00:00
Aiden Cline 98c717cb5b fix(core): migrate standalone small model (#43260) 2026-08-18 11:14:36 -05:00
Major Hayden 5c8d46ab4b fix(core): make Google Vertex models work with ADC credentials (#43077)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-08-18 10:47:13 -05:00
Dax Raad d5e83fefda fix(core): reuse prompt cache for forks 2026-08-18 11:38:11 -04:00
Dax Raad 46378dda50 refactor(core): standardize builtin plugin ids 2026-08-18 11:07:11 -04:00
Dax b38d9d812f refactor(client): move service shutdown client-side (#43252) 2026-08-18 15:02:37 +00:00
Dax Raad 8df039d261 fix(cli): limit source maps to development channels 2026-08-18 10:53:42 -04:00
Dax Raad c92fb2d41b fix(cli): improve process failure logging 2026-08-18 10:49:52 -04:00
Shoubhit Dash 958308c913 fix(core): ignore malformed model costs (#43251) 2026-08-18 19:59:47 +05:30
Dax Raad 16390ca47d fix(cli): log background service startup 2026-08-18 10:15:36 -04:00
Dax Raad 643eed300d fix(cli): log managed service lease loss 2026-08-18 10:06:59 -04:00
Dax Raad c3a6721de2 refactor(tui): standardize builtin plugin ids 2026-08-18 10:06:59 -04:00
Shoubhit Dash 47af7462ea feat(cli): improve session export flow (#43229) 2026-08-18 18:00:12 +05:30
Simon Klee a76ab93673 tui: update OpenTUI to 0.5.4 (#43220) 2026-08-18 13:56:34 +02:00
Shoubhit Dash dfb60e37a3 feat(cli): add interactive auth commands (#43127) 2026-08-18 16:37:56 +05:30
170 changed files with 3020 additions and 2579 deletions
+18 -17
View File
@@ -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
View File
@@ -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",
@@ -7,6 +7,7 @@ import { useMcpToggle } from "@/context/mcp"
import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK } from "@/context/server-sdk"
import { useData } from "@/context/server"
import { pluginLabel } from "@/utils/plugin"
import { ExternalLink } from "./external-link"
type SkillItem = {
@@ -101,10 +102,10 @@ export const ProjectSettingsExtensions: Component = () => {
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map((item) => item.id))
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map(pluginLabel))
const projectPlugins = createMemo(() => {
const shared = new Set(globalPlugins())
return (projectPluginList.latest ?? []).map((item) => item.id).filter((name) => !shared.has(name))
return (projectPluginList.latest ?? []).map(pluginLabel).filter((name) => !shared.has(name))
})
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
@@ -6,6 +6,7 @@ import { useLanguage } from "@/context/language"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useMcpToggle } from "@/context/mcp"
import { pluginLabel } from "@/utils/plugin"
import { ExternalLink } from "../external-link"
import { InlineServerSelect } from "./parts/server-select"
import "./settings-v2.css"
@@ -44,7 +45,9 @@ export const SettingsExtensionsV2: Component = () => {
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.plugin.list().then((result) => result.data),
)
const plugins = createMemo<PluginRowItem[]>(() => (pluginList.latest ?? []).map((item) => ({ name: item.id })))
const plugins = createMemo<PluginRowItem[]>(() =>
(pluginList.latest ?? []).map((item) => ({ name: pluginLabel(item) })),
)
createEffect(() => {
if (serverSdk.connection.status() !== "connected") return
@@ -6,6 +6,7 @@ import { useMcpToggle } from "@/context/mcp"
import { useWorkspaceLocation } from "@/context/location"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { pluginLabel } from "@/utils/plugin"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -38,7 +39,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
() => (props.shown ? sdk().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
const plugins = createMemo(() => (pluginList.latest ?? []).map(pluginLabel))
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
+8
View File
@@ -0,0 +1,8 @@
import type { PluginInfo } from "@opencode-ai/client"
export function pluginLabel(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
+1
View File
@@ -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:*",
+1 -1
View File
@@ -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,
+7 -12
View File
@@ -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")
+54 -12
View File
@@ -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),
@@ -254,15 +275,36 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
Spec.make("stop", { description: "Stop the background server" }),
Spec.make("get", {
description: "Get service configuration",
params: { key: Argument.string("key").pipe(Argument.optional) },
params: {
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env"), Argument.optional),
name: Argument.string("name").pipe(
Argument.withDescription("Environment variable name"),
Argument.optional,
),
},
}),
Spec.make("set", {
description: "Set service configuration",
params: { key: Argument.string("key"), value: Argument.string("value") },
params: {
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
value: Argument.string("value").pipe(
Argument.withDescription("Setting value or environment variable name"),
),
nestedValue: Argument.string("env-value").pipe(
Argument.withDescription("Environment variable value"),
Argument.optional,
),
},
}),
Spec.make("unset", {
description: "Unset service configuration",
params: { key: Argument.string("key") },
params: {
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
name: Argument.string("name").pipe(
Argument.withDescription("Environment variable name"),
Argument.optional,
),
},
}),
],
}),
@@ -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,
)
})
+253 -38
View File
@@ -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}` : ""}`),
)
})
+11 -1
View File
@@ -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"
+78 -130
View File
@@ -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
}
@@ -1,6 +1,6 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import { OpenCode } from "@opencode-ai/client"
import { OpenCode, type PluginInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
@@ -14,11 +14,18 @@ export default Runtime.handler(
const endpoint = found ?? (yield* Service.ensure(options))
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
const plugins = response.data.toSorted((a, b) => a.id.localeCompare(b.id))
const plugins = response.data.toSorted((a, b) => name(a).localeCompare(name(b)))
if (plugins.length === 0) {
process.stdout.write("No plugins loaded" + EOL)
return
}
process.stdout.write(plugins.map((plugin) => plugin.id).join(EOL) + EOL)
process.stdout.write(plugins.map(name).join(EOL) + EOL)
}),
)
function name(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
@@ -7,6 +7,8 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.service.commands.get,
Effect.fn("cli.service.get")(function* (input) {
process.stdout.write((yield* ServiceConfig.get(Option.getOrUndefined(input.key))) + EOL)
process.stdout.write(
(yield* ServiceConfig.get(Option.getOrUndefined(input.key), Option.getOrUndefined(input.name))) + EOL,
)
}),
)
@@ -1,4 +1,4 @@
import { Effect } from "effect"
import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
@@ -6,6 +6,6 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.service.commands.set,
Effect.fn("cli.service.set")(function* (input) {
yield* ServiceConfig.set(input.key, input.value)
yield* ServiceConfig.set(input.key, input.value, Option.getOrUndefined(input.nestedValue))
}),
)
@@ -1,4 +1,4 @@
import { Effect } from "effect"
import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
@@ -6,6 +6,6 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.service.commands.unset,
Effect.fn("cli.service.unset")(function* (input) {
yield* ServiceConfig.unset(input.key)
yield* ServiceConfig.unset(input.key, Option.getOrUndefined(input.name))
}),
)
+23
View File
@@ -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),
+24 -7
View File
@@ -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,
+37 -8
View File
@@ -15,10 +15,11 @@ export const Info = Schema.Struct({
hostname: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
password: Schema.optional(Schema.String),
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
})
export type Info = typeof Info.Type
const keys = ["hostname", "port", "password"] as const
const keys = ["hostname", "port", "password", "env"] as const
type Key = (typeof keys)[number]
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
@@ -76,7 +77,7 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file:
})
function configKey(key: string): Key {
if (key === "hostname" || key === "port" || key === "password") return key
if (key === "hostname" || key === "port" || key === "password" || key === "env") return key
throw new Error(`Unknown service config key: ${key}`)
}
@@ -104,6 +105,7 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
return {
file,
version: input.checkVersion ? OPENCODE_VERSION : undefined,
env: (yield* read()).env,
command: [
...selfCommand(),
"serve",
@@ -141,12 +143,14 @@ export const password = Effect.fn("cli.service-config.password")(function* (valu
return next
})
export const get = Effect.fn("cli.service-config.get")(function* (key?: string) {
export const get = Effect.fn("cli.service-config.get")(function* (key?: string, name?: string) {
if (key === undefined) {
const { password: _password, ...safe } = yield* read()
return JSON.stringify(safe, null, 2)
}
switch (configKey(key)) {
const selected = configKey(key)
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service get ${selected}`)
switch (selected) {
case "hostname": {
return (yield* read()).hostname ?? ""
}
@@ -157,12 +161,19 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string)
case "password": {
return yield* password()
}
case "env": {
const env = (yield* read()).env ?? {}
return name === undefined ? JSON.stringify(env, null, 2) : (env[name] ?? "")
}
}
throw new Error(`Unknown service config key: ${key}`)
})
export const set = Effect.fn("cli.service-config.set")(function* (key: string, value: string) {
switch (configKey(key)) {
export const set = Effect.fn("cli.service-config.set")(function* (key: string, value: string, nestedValue?: string) {
const selected = configKey(key)
if (selected !== "env" && nestedValue !== undefined)
throw new Error(`Usage: opencode service set ${selected} <value>`)
switch (selected) {
case "hostname": {
yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), hostname: value })
@@ -180,11 +191,20 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
yield* password(value)
return
}
case "env": {
if (nestedValue === undefined) throw new Error("Usage: opencode service set env <key> <value>")
yield* Service.stop(yield* options())
const existing = yield* read()
yield* write({ ...existing, env: { ...existing.env, [value]: nestedValue } })
return
}
}
})
export const unset = Effect.fn("cli.service-config.unset")(function* (key: string) {
switch (configKey(key)) {
export const unset = Effect.fn("cli.service-config.unset")(function* (key: string, name?: string) {
const selected = configKey(key)
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service unset ${selected}`)
switch (selected) {
case "hostname": {
yield* Service.stop(yield* options())
const { hostname: _hostname, ...next } = yield* read()
@@ -203,6 +223,15 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin
yield* write(next)
return
}
case "env": {
if (name === undefined) throw new Error("Usage: opencode service unset env <key>")
yield* Service.stop(yield* options())
const existing = yield* read()
const { [name]: _removed, ...env } = existing.env ?? {}
const { env: _existingEnv, ...rest } = existing
yield* write(Object.keys(env).length === 0 ? rest : { ...rest, env })
return
}
}
})
+48
View File
@@ -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)
}
+252 -5
View File
@@ -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, ".."),
+26 -12
View File
@@ -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)
}
})
+38
View File
@@ -60,6 +60,44 @@ test("local channel stores service config with the local service filename", asyn
}
})
test("service config manages environment variables", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-env-"))
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
try {
await Effect.runPromise(
ServiceConfig.set("env", "OPENCODE_SERVICE_ENV_TEST", "configured").pipe(
Effect.provide(layer),
Effect.provide(NodeFileSystem.layer),
),
)
expect(
await Effect.runPromise(
ServiceConfig.get("env", "OPENCODE_SERVICE_ENV_TEST").pipe(
Effect.provide(layer),
Effect.provide(NodeFileSystem.layer),
),
),
).toBe("configured")
expect(
(
await Effect.runPromise(
ServiceConfig.options().pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer)),
)
).env,
).toEqual({ OPENCODE_SERVICE_ENV_TEST: "configured" })
await Effect.runPromise(
ServiceConfig.unset("env", "OPENCODE_SERVICE_ENV_TEST").pipe(
Effect.provide(layer),
Effect.provide(NodeFileSystem.layer),
),
)
expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({})
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
test("service filenames share release channels and identify preview channels", () => {
expect(ServiceConfig.filename("latest")).toBe("service.json")
expect(ServiceConfig.filename("dev")).toBe("service.json")
-5
View File
@@ -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)))
+14 -58
View File
@@ -71,7 +71,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
return yield* Effect.try({
try: () => {
return spawnServiceContender(command, args)
return spawnServiceContender(command, args, options.env)
},
catch: (cause) => new Error("Failed to start server", { cause }),
})
@@ -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 }
@@ -12,7 +10,11 @@ export type AgentColor = string
export type PermissionEffect = "allow" | "deny" | "ask"
export type PluginInfo = { id: string }
export type PluginSource =
| { type: "builtin" }
| { type: "package"; package: string }
| { type: "local"; path: string }
| { type: "sdk" }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
@@ -198,6 +200,10 @@ export type ProviderRequest = {
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
export type PluginInfo =
| { id: string; source: PluginSource; status: "active"; tui: boolean }
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
export type TokenUsageInfo = {
input: number
output: number
@@ -2273,10 +2279,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 = {
+15 -47
View File
@@ -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"
@@ -51,7 +51,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
if (command === undefined) throw new Error("Missing service command")
try {
return spawnServiceContender(command, args)
return spawnServiceContender(command, args, options.env)
} catch (cause) {
throw new Error("Failed to start server", { cause })
}
@@ -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) {
+10 -2
View File
@@ -10,8 +10,16 @@ export type ServiceContender = {
const stderrLimit = 8 * 1024
export function spawnServiceContender(command: string, args: ReadonlyArray<string>): ServiceContender {
const child = spawn(command, args, { detached: true, stdio: ["ignore", "ignore", "pipe"] })
export function spawnServiceContender(
command: string,
args: ReadonlyArray<string>,
env?: Readonly<Record<string, string>>,
): ServiceContender {
const child = spawn(command, args, {
detached: true,
stdio: ["ignore", "ignore", "pipe"],
env: { ...process.env, ...env },
})
let error: Error | undefined
let closed = false
let stderr = Buffer.alloc(0)
+2
View File
@@ -28,6 +28,8 @@ export type EnsureReason = "missing" | "version-mismatch"
export type EnsureOptions = DiscoverOptions & {
/** Service command and arguments. Defaults to `opencode serve --service`. */
readonly command?: ReadonlyArray<string>
/** Environment variables added to the inherited service process environment. */
readonly env?: Readonly<Record<string, string>>
/** Called once before spawning a new service process. */
readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void
}
+8 -16
View File
@@ -11,6 +11,8 @@ if (mode === "record-start") {
await writeFile(registration + ".started", "")
process.exit(1)
}
if (mode === "environment")
await writeFile(registration + ".environment", process.env.OPENCODE_SERVICE_ENV_TEST ?? "")
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" || mode === "coordinated-failed-loser") {
@@ -28,7 +30,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 +38,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 +54,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 +72,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"))
+23 -3
View File
@@ -59,6 +59,26 @@ test("ensures a missing service with native promises", async () => {
}
})
test("adds configured environment variables with native promises", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "environment"],
env: { OPENCODE_SERVICE_ENV_TEST: "configured" },
})
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
expect(await Bun.file(registration + ".environment").text()).toBe("configured")
} finally {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
})
test("waits for a live contender when another native contender fails", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
@@ -126,13 +146,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) {
-16
View File
@@ -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({
+36 -29
View File
@@ -68,6 +68,28 @@ test("reuses a compatible registered service", async () => {
expect(existing.exitCode).toBe(null)
})
test("adds configured environment variables when starting a service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await run(
ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "environment"],
env: { OPENCODE_SERVICE_ENV_TEST: "configured" },
}),
)
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
expect(await Bun.file(registration + ".environment").text()).toBe("configured")
} finally {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
})
test("replaces an incompatible registered service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
@@ -143,40 +165,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 +362,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())
}
+1 -1
View File
@@ -109,7 +109,7 @@ const layer = Layer.effect(
get: Effect.fn("Agent.get")(function* (id) {
return state.get().agents.get(id)
}),
resolve: Effect.fn("Agent.resolve")(function* (id) {
resolve: Effect.fnUntraced(function* (id) {
if (id !== undefined) return state.get().agents.get(ID.make(id))
return selectedDefault()
}),
+1 -1
View File
@@ -426,7 +426,7 @@ export const layer = (options?: Options) =>
)
return Service.of({
entries: Effect.fn("Config.entries")(function* () {
entries: Effect.fnUntraced(function* () {
return configs
}),
update,
+1 -1
View File
@@ -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,
)
@@ -120,9 +120,13 @@ export class EffectSQLiteSession<TRelations extends AnyRelations> extends SQLite
private execute(query: Query, params: unknown[], method: SQLiteExecuteMethod | "values") {
const statement = this.client.unsafe(query.sql, params)
if (method === "values") return statement.values
if (method === "get") return statement.withoutTransform.pipe(Effect.map((rows) => rows[0]))
return statement.withoutTransform
if (method === "values") return statement.values.pipe(Effect.withTracerEnabled(false))
if (method === "get")
return statement.withoutTransform.pipe(
Effect.map((rows) => rows[0]),
Effect.withTracerEnabled(false),
)
return statement.withoutTransform.pipe(Effect.withTracerEnabled(false))
}
private isInTransaction() {
+5 -5
View File
@@ -138,7 +138,7 @@ export const make = Effect.gen(function* () {
scope: yield* Scope.Scope,
}
const settle = Effect.fn("Job.settle")(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
const job = jobs.get(id)
@@ -170,7 +170,7 @@ export const make = Effect.gen(function* () {
return result.info
})
const fork = Effect.fn("Job.fork")(function* (
const fork = Effect.fnUntraced(function* (
scope: Scope.Scope,
id: string,
token: object,
@@ -192,7 +192,7 @@ export const make = Effect.gen(function* () {
return snapshot(job)
})
const start: Interface["start"] = Effect.fn("Job.start")(function* (input) {
const start: Interface["start"] = Effect.fnUntraced(function* (input) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const id = input.id ?? Identifier.ascending("job")
@@ -247,7 +247,7 @@ export const make = Effect.gen(function* () {
return { info: snapshot(job), timedOut: true }
})
const removeBlock = Effect.fn("Job.removeBlock")(function* (input: BlockInput) {
const removeBlock = Effect.fnUntraced(function* (input: BlockInput) {
yield* SynchronizedRef.update(state.jobs, (jobs) => {
const job = jobs.get(input.id)
if (!job || job.info.status !== "running" || job.isBackgrounded) return jobs
@@ -258,7 +258,7 @@ export const make = Effect.gen(function* () {
})
})
const block: Interface["block"] = Effect.fn("Job.block")(function* (input) {
const block: Interface["block"] = Effect.fnUntraced(function* (input) {
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map<string, Active>] => {
const job = jobs.get(input.id)
if (!job) return [{ type: "missing" }, jobs]
+1 -1
View File
@@ -65,7 +65,7 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
const absolute = path.resolve(location.directory, input.path)
if (FSUtil.contains(location.directory, absolute)) {
return {
+2 -2
View File
@@ -152,14 +152,14 @@ const layer = Layer.effect(
)
})
const configured = Effect.fn("Permission.configured")(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) {
const configured = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) {
const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
const agent = yield* agents.resolve(agentID ?? session.agent)
return agent?.permissions ?? missingAgentPermissions
})
const allowsAll = Effect.fn("Permission.allowsAll")(function* (input: {
const allowsAll = Effect.fnUntraced(function* (input: {
readonly sessionID: SessionSchema.ID
readonly action: string
readonly agent?: Agent.ID
+1 -1
View File
@@ -39,7 +39,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const { db } = yield* Database.Service
const list = Effect.fn("PermissionSaved.list")(function* (input?: ListInput) {
const list = Effect.fnUntraced(function* (input?: ListInput) {
const rows = yield* db
.select()
.from(PermissionTable)
+47 -13
View File
@@ -1,10 +1,10 @@
export * as Plugin from "./plugin.js"
export { Event, ID, Info } from "@opencode-ai/schema/plugin"
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "./app.js"
import { Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
import { Cause, Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
import { Agent } from "./agent.js"
import { AISDK } from "./aisdk.js"
import { Catalog } from "./catalog.js"
@@ -23,11 +23,17 @@ import { Tool } from "./tool.js"
import { PluginHooks } from "./plugin/hooks.js"
export interface Interface {
readonly activate: (plugins: readonly Versioned[]) => Effect.Effect<void>
readonly activate: (
plugins: readonly Versioned[],
failures?: readonly Extract<Plugin.Info, { readonly status: "failed" }>[],
) => Effect.Effect<void>
readonly list: () => Effect.Effect<Plugin.Info[]>
}
export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & { readonly version: string }
export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & {
readonly version: string
readonly source?: Plugin.Source
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
@@ -38,6 +44,7 @@ const layer = Layer.effect(
const scope = yield* Scope.make()
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
const lock = Semaphore.makeUnsafe(1)
let inventory: Plugin.Info[] = []
let host: Parameters<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>[0]
const load = Effect.fnUntraced(function* (plugin: Versioned) {
@@ -56,15 +63,18 @@ const layer = Layer.effect(
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
Effect.exit,
)
if (Exit.isSuccess(loaded)) return child
if (Exit.isSuccess(loaded)) return { scope: child } as const
yield* Effect.logWarning("failed to load plugin", {
"plugin.id": plugin.id,
cause: loaded.cause,
})
return undefined
return { error: Cause.pretty(loaded.cause) } as const
})
const activate = Effect.fn("Plugin.activate")(function* (plugins: readonly Versioned[]) {
const activate = Effect.fn("Plugin.activate")(function* (
plugins: readonly Versioned[],
failures: readonly Extract<Plugin.Info, { readonly status: "failed" }>[] = [],
) {
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
const ids = new Set<Plugin.ID>()
for (const definition of definitions) {
@@ -85,26 +95,40 @@ const layer = Layer.effect(
const candidate = next[index]
return definition.id === candidate?.id && definition.version === candidate.version
})
)
) {
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
return
}
yield* State.batch(
Effect.gen(function* () {
const nextInventory: Plugin.Info[] = []
for (const definition of definitions) {
const previous = active.get(definition.id)
active.delete(definition.id)
if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore)
const loaded = yield* load(definition)
if (loaded) {
active.set(definition.id, { plugin: definition, scope: loaded })
if (loaded.scope !== undefined) {
active.set(definition.id, { plugin: definition, scope: loaded.scope })
nextInventory.push(activeInfo(definition))
continue
}
nextInventory.push({
id: definition.id,
source: definition.source ?? { type: "builtin" },
status: "failed",
error: loaded.error,
tui: definition.tui ?? false,
})
if (!previous) continue
const restored = yield* load(previous.plugin)
if (restored) {
active.set(definition.id, { plugin: previous.plugin, scope: restored })
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
@@ -119,6 +143,7 @@ const layer = Layer.effect(
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), {
discard: true,
})
inventory = [...nextInventory, ...failures]
}),
)
yield* bus.publish(Plugin.Event.Updated, {})
@@ -136,7 +161,7 @@ const layer = Layer.effect(
const service = Service.of({
activate,
list: Effect.fn("Plugin.list")(function* () {
return Array.from(active.keys()).map((id) => ({ id }))
return inventory
}),
})
host = yield* PluginHost.make(service)
@@ -144,6 +169,15 @@ const layer = Layer.effect(
}),
)
function activeInfo(plugin: Versioned): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
status: "active",
tui: plugin.tui ?? false,
}
}
export const node = makeLocationNode({
service: Service,
layer,
+1 -1
View File
@@ -68,7 +68,7 @@ const layer = Layer.effect(
return { dispose }
})
const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) {
const trigger: Interface["trigger"] = Effect.fnUntraced(function* (domain, name, event) {
for (const callback of callbacks.get(key(domain, name)) ?? []) {
const result: Effect.Effect<void, Failures[typeof domain][typeof name]> = Reflect.apply(callback, undefined, [
event,
@@ -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()) {
+8 -3
View File
@@ -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",
+1 -1
View File
@@ -35,7 +35,7 @@ export const layer = Layer.effect(
return Service.of({
register: (plugin) =>
Effect.sync(() => {
plugins.set(plugin.id, { ...plugin, version: String(++revision) })
plugins.set(plugin.id, { ...plugin, version: String(++revision), source: { type: "sdk" } })
}).pipe(Effect.andThen(bus.publish(Updated, {})), Effect.asVoid),
all: () => [...plugins.values()],
})
+45 -13
View File
@@ -2,7 +2,7 @@ export * as PluginSupervisor from "./supervisor.js"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Event } from "@opencode-ai/schema/config"
import { Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
import { Cause, Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import { ConfigPluginSource } from "../config/plugin/source.js"
@@ -19,12 +19,14 @@ const PluginModule = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
effect: Schema.declare<PluginDefinition["effect"]>(
(input): input is PluginDefinition["effect"] => typeof input === "function",
),
}),
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
),
@@ -42,10 +44,12 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
const definitions = [...pre, ...post]
const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, Plugin.Versioned>()
const failures = new Map<string, Extract<Plugin.Info, { readonly status: "failed" }>>()
const plugins = () => [...definitions, ...packages.values()]
for (const operation of operations) {
if (operation.type === "remove") {
if (operation.target === "*") failures.clear()
plugins()
.filter((plugin) => matches(operation.target, plugin.id))
.forEach((plugin) => enabled.delete(plugin.id))
@@ -65,21 +69,35 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
const plugin = yield* load(operation).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(Effect.as(undefined)),
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Cause.pretty(cause) }),
),
),
)
if (!plugin) continue
if ("error" in plugin) {
failures.set(operation.target, {
source: pluginSource(operation.target),
status: "failed",
error: plugin.error,
tui: false,
})
continue
}
failures.delete(operation.target)
const previous = packages.get(operation.target)
if (previous) enabled.delete(previous.id)
packages.set(operation.target, plugin)
enabled.add(plugin.id)
}
return [
...pre.filter((plugin) => enabled.has(plugin.id)),
...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)),
]
return {
plugins: [
...pre.filter((plugin) => enabled.has(plugin.id)),
...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.values()],
}
})
const load = Effect.fn("PluginSupervisor.load")(function* (
@@ -89,7 +107,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
if (!entrypoint) return
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const source =
operation.mtime === undefined
@@ -103,7 +121,9 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
id: plugin.id,
tui: plugin.tui,
version: JSON.stringify(operation),
source: pluginSource(operation.target),
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Plugin.Versioned
})
@@ -129,13 +149,20 @@ export const layer = Layer.effect(
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed SDK plugins in boot order.
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
const pre = [
...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })),
...sdk.all(),
]
const post = internal.post.map((plugin) => ({
...plugin,
version: "internal",
source: { type: "builtin" as const },
}))
const operations = yield* sources.operations()
// Apply config operations and load enabled package plugins into one ordered generation.
const plugins = yield* resolve(pre, post, operations)
const resolved = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(plugins)
yield* registry.activate(resolved.plugins, resolved.failures)
})
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst.
@@ -172,4 +199,9 @@ const nodeDeps = [
PluginInternal.requirements,
] as const
function pluginSource(target: string): Plugin.Source {
if (path.isAbsolute(target)) return { type: "local", path: target }
return { type: "package", package: target }
}
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
+1 -1
View File
@@ -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* () {
+11
View File
@@ -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")],
+2 -1
View File
@@ -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 })),
+1 -1
View File
@@ -54,7 +54,7 @@ const layer = Layer.effect(
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
return Service.of({
get: Effect.fn("SessionStore.get")(function* (sessionID) {
get: Effect.fnUntraced(function* (sessionID) {
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
return row ? fromRow(row) : undefined
}),
+6 -5
View File
@@ -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,
)
}
+2 -2
View File
@@ -101,7 +101,7 @@ export const layer = (options?: ShellSelect.Options) =>
}),
)
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
const require = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
@@ -153,7 +153,7 @@ export const layer = (options?: ShellSelect.Options) =>
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
+2 -2
View File
@@ -153,7 +153,7 @@ const ARITY: Record<string, number> = {
"yarn run": 3,
}
export const scan = Effect.fn("ShellParse.scan")(function* (
export const scan = Effect.fnUntraced(function* (
command: string,
shell: string,
cwd: string,
@@ -163,7 +163,7 @@ export const scan = Effect.fn("ShellParse.scan")(function* (
return yield* scanLegacy(command, shell, cwd)
})
const scanLegacy = Effect.fn("ShellParse.scanLegacy")(function* (command: string, shell: string, cwd: string) {
const scanLegacy = Effect.fnUntraced(function* (command: string, shell: string, cwd: string) {
const parsers = yield* Effect.promise(load)
const powershell = ShellSelect.ps(shell)
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
+1 -4
View File
@@ -97,10 +97,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
const materialize = Effect.fnUntraced(function* () {
const next = options.initial()
const api = options.draft(next)
for (const transform of transforms)
yield* apply(transform.run, api).pipe(
Effect.withSpan("State.reload.update", { attributes: { state: options.name ?? "anonymous" } }),
)
for (const transform of transforms) yield* apply(transform.run, api)
yield* commit(next)
})
+1 -1
View File
@@ -51,7 +51,7 @@ const layer = Layer.effect(
const global = yield* Global.Service
const directory = path.join(global.data, DIRECTORY)
const truncate = Effect.fn("ToolOutput.truncate")(function* (result: Result) {
const truncate = Effect.fnUntraced(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 ?? [])
+1 -1
View File
@@ -50,7 +50,7 @@ const layer = Layer.effect(
const image = yield* Image.Service
type NormalizedItem = Tool.Content | "decode" | "size"
const normalizeImages = Effect.fn("Tool.normalizeImages")(function* (content: ReadonlyArray<Tool.Content>) {
const normalizeImages = Effect.fnUntraced(function* (content: ReadonlyArray<Tool.Content>) {
const normalized = yield* Effect.forEach(content, (item): Effect.Effect<NormalizedItem> => {
if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item)
const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1]
+2 -2
View File
@@ -208,7 +208,7 @@ export const Plugin = {
)
yield* context.progress({ shellID: info.id })
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
const captureShell = Effect.fnUntraced(function* () {
const configured = Config.latest(yield* config.entries(), "tool_output")
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
@@ -228,7 +228,7 @@ export const Plugin = {
}
})
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const settleShell = Effect.fnUntraced(function* () {
const final = yield* shell.wait(info.id)
const capture = yield* captureShell()
+6 -11
View File
@@ -4,7 +4,6 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema, Semaphore } from "effect"
import { HttpClientError } from "effect/unstable/http"
import { Config } from "../../config.js"
import { Form } from "../../form.js"
import { Permission } from "../../permission.js"
import { WebSearch } from "../../websearch.js"
@@ -30,7 +29,6 @@ export const Plugin = {
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
const permission = yield* Permission.Service
const forms = yield* Form.Service
const config = yield* Config.Service
const websearch = yield* WebSearch.Service
yield* ctx.tool
@@ -97,9 +95,7 @@ export const Plugin = {
if (response.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") {
yield* config.update((draft) => {
draft.websearch = false
})
yield* websearch.select(false)
return yield* new WebSearch.DisabledError()
}
const selection =
@@ -131,11 +127,7 @@ export const Plugin = {
(providerID !== "random" && !providers.some((provider) => provider.id === providerID))
)
return yield* new WebSearch.ProviderRequiredError()
yield* config.update((draft) => {
draft.websearch = {
provider: providerID === "random" ? "random" : WebSearch.ID.make(providerID),
}
})
yield* websearch.select(providerID === "random" ? "random" : WebSearch.ID.make(providerID))
if (providerID !== "random") return WebSearch.ID.make(providerID)
return providers[Math.floor(Math.random() * providers.length)]?.id
}),
@@ -206,7 +198,10 @@ export const Plugin = {
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
const disabled = Config.latest(yield* config.entries(), "websearch") === false
const disabled = yield* websearch.default().pipe(
Effect.as(false),
Effect.catchTag("WebSearch.Disabled", () => Effect.succeed(true)),
)
if (disabled) delete event.tools[name]
}),
)
+22 -8
View File
@@ -1,9 +1,10 @@
export * as WebSearch from "./websearch.js"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Context, Effect, Layer, Schema } from "effect"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus.js"
import { KV } from "./kv.js"
import { State } from "./state.js"
export const ID = WebSearch.ID
@@ -24,6 +25,10 @@ export type Result = WebSearch.Result
export const Response = WebSearch.Response
export type Response = WebSearch.Response
export const ProviderKey = "websearch:provider"
export const Selection = Schema.Union([ID, Schema.Literal("random"), Schema.Literal(false)])
export type Selection = typeof Selection.Type
export interface ProviderImplementation extends Provider {
readonly execute: (input: ProviderInput) => Effect.Effect<readonly Result[], unknown>
}
@@ -49,6 +54,7 @@ export type Error = ProviderRequiredError | ProviderNotFoundError | DisabledErro
export interface Interface extends State.Transformable<Draft> {
readonly providers: () => Effect.Effect<readonly Provider[]>
readonly default: () => Effect.Effect<Provider | undefined, DisabledError>
readonly select: (selection: Selection) => Effect.Effect<void>
readonly query: (input: Input) => Effect.Effect<Response, Error>
}
@@ -56,14 +62,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/We
type Data = {
readonly providers: Map<ID, ProviderImplementation>
selection?: ID | "random" | false
selection?: Selection
}
export type Draft = {
add: (provider: ProviderImplementation) => void
default: {
get: () => ID | "random" | false | undefined
set: (selection: ID | "random" | false) => void
get: () => Selection | undefined
set: (selection: Selection) => void
}
}
@@ -71,6 +77,7 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const kv = yield* KV.Service
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
const state = State.create<Data, Draft>({
initial: () => ({ providers: new Map() }),
@@ -91,12 +98,16 @@ const layer = Layer.effect(
const defaultProvider = Effect.fn("WebSearch.default")(function* () {
const data = state.get()
if (data.selection === false) return yield* new DisabledError()
if (data.selection === "random") {
const stored = data.selection === undefined ? yield* kv.get(ProviderKey) : undefined
const decoded = Schema.decodeUnknownOption(Selection)(stored)
if (stored !== undefined && Option.isNone(decoded)) yield* kv.remove(ProviderKey)
const selection = data.selection ?? Option.getOrUndefined(decoded)
if (selection === false) return yield* new DisabledError()
if (selection === "random") {
const providers = Array.from(data.providers.values())
return providers[Math.floor(Math.random() * providers.length)]
}
return data.selection ? data.providers.get(data.selection) : undefined
return selection ? data.providers.get(selection) : undefined
})
const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) {
@@ -120,6 +131,9 @@ const layer = Layer.effect(
const provider = yield* defaultProvider()
return provider && { id: provider.id, name: provider.name }
}),
select: Effect.fn("WebSearch.select")(function* (selection) {
yield* kv.set(ProviderKey, selection)
}),
query: Effect.fn("WebSearch.query")(function* (input) {
const provider = yield* resolve(input)
const results = yield* provider.execute({ query: input.query }).pipe(
@@ -135,5 +149,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node],
deps: [Bus.node, KV.node],
})
@@ -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" } },
+23 -2
View File
@@ -44,7 +44,9 @@ describe("PluginSupervisor config", () => {
const plugins = yield* Plugin.Service
yield* ready()
expect(
(yield* plugins.list()).map((plugin) => plugin.id).filter((id) => id.startsWith("opencode.provider.")),
(yield* plugins.list())
.flatMap((plugin) => (plugin.id ? [plugin.id] : []))
.filter((id) => id.startsWith("opencode.provider.")),
).toEqual([Plugin.ID.make("opencode.provider.openai")])
}),
),
@@ -64,10 +66,20 @@ describe("PluginSupervisor config", () => {
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
const plugins = yield* Plugin.Service
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
description: "Loaded from config",
mode: "subagent",
})
expect((yield* plugins.list()).find((plugin) => plugin.id === "config-promise-plugin")).toEqual({
id: Plugin.ID.make("config-promise-plugin"),
source: {
type: "local",
path: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
},
status: "active",
tui: true,
})
}),
),
)
@@ -143,6 +155,7 @@ describe("PluginSupervisor config", () => {
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
const plugins = yield* Plugin.Service
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
description: "Loaded after invalid plugins",
})
@@ -150,6 +163,12 @@ describe("PluginSupervisor config", () => {
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
])
expect(
(yield* plugins.list()).filter((plugin) => plugin.status === "failed").map((plugin) => plugin.source),
).toEqual([
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts") },
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts") },
])
}),
).pipe(Effect.provide(Logger.layer([logger])))
})
@@ -246,10 +265,12 @@ describe("PluginSupervisor config", () => {
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
const inventory = yield* plugins.list()
const ids = inventory.map((plugin) => String(plugin.id))
expect(ids).toContain("opencode.agent")
expect(ids).toContain("static-sdk")
expect(ids).not.toContain("config-promise-plugin")
expect(inventory.find((plugin) => plugin.id === "static-sdk")?.source).toEqual({ type: "sdk" })
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
+26 -1
View File
@@ -6,7 +6,7 @@ import { expect, test } from "bun:test"
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { eq, sql } from "drizzle-orm"
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Effect } from "effect"
import { Effect, Tracer } from "effect"
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
import { isSqlError } from "effect/unstable/sql/SqlError"
import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
@@ -49,6 +49,31 @@ test("selects rows through Effect-yieldable query builders", async () => {
)
})
test("suppresses statement spans", async () => {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
await Effect.runPromise(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.transaction((tx) => tx.insert(users).values({ name: "Grace" }))
yield* db.select().from(users)
}).pipe(
Effect.provideService(Tracer.Tracer, tracer),
Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
Effect.scoped,
),
)
expect(spans.map((span) => span.name)).not.toContain("sql.execute")
})
test("commits successful transactions", async () => {
await run(
Effect.gen(function* () {
+10 -2
View File
@@ -428,10 +428,18 @@ describe("LocationServiceMap", () => {
),
)
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* registry.list()).length === 0) break
if ((yield* registry.list()).some((plugin) => plugin.status === "failed")) break
yield* Effect.sleep("20 millis")
}
expect(yield* registry.list()).toEqual([])
expect(yield* registry.list()).toEqual([
{
id: Plugin.ID.make("failing-plugin"),
source: { type: "local", path: path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts") },
status: "failed",
error: expect.stringContaining("plugin failed"),
tui: false,
},
])
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
for (let attempt = 0; attempt < 100; attempt++) {
+48 -13
View File
@@ -89,13 +89,7 @@ describe("Plugin", () => {
yield* host.mcp.connect({ location, server: "routed" }).pipe(Effect.orDie)
yield* host.mcp.disconnect({ location, server: "routed" }).pipe(Effect.orDie)
expect((yield* host.mcp.list({ location }).pipe(Effect.orDie)).location.directory).toBe(target)
expect(routed).toEqual([
"add:/target",
"remove:/target",
"connect:/target",
"disconnect:/target",
"list:/target",
])
expect(routed).toEqual(["add:/target", "remove:/target", "connect:/target", "disconnect:/target", "list:/target"])
}),
)
@@ -138,9 +132,22 @@ describe("Plugin", () => {
expect(updates).toBe(2)
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
yield* plugins.activate(
[versioned(managed(), "2")],
[
{
source: { type: "package", package: "broken" },
status: "failed",
error: "failed to resolve",
tui: false,
},
],
)
expect(updates).toBe(3)
yield* plugins.activate([])
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
expect(updates).toBe(3)
expect(updates).toBe(4)
yield* unsubscribe
}),
)
@@ -160,7 +167,7 @@ describe("Plugin", () => {
.pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
expect(yield* plugins.list()).toEqual([{ id: active }])
expect(yield* plugins.list()).toEqual([{ id: active, source: { type: "builtin" }, status: "active", tui: false }])
}),
)
@@ -189,12 +196,24 @@ describe("Plugin", () => {
})
yield* plugins.activate([versioned(good), versioned(bad)])
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }])
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
{
id: Plugin.ID.make("bad"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("materialization failed"),
tui: false,
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
fail = false
yield* plugins.activate([versioned(good), versioned(bad, "2")])
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }, { id: Plugin.ID.make("bad") }])
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
{ id: Plugin.ID.make("bad"), source: { type: "builtin" }, status: "active", tui: false },
])
}),
)
@@ -229,7 +248,15 @@ describe("Plugin", () => {
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("managed") }])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: false,
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
}),
)
@@ -261,7 +288,15 @@ describe("Plugin", () => {
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
expect(yield* plugins.list()).toEqual([])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: false,
},
])
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
}),
)
@@ -2,6 +2,7 @@ import { Plugin } from "@opencode-ai/plugin"
export default Plugin.define({
id: "config-promise-plugin",
tui: true,
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("configured", (agent) => {
+42 -2
View File
@@ -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",
])
})
+20
View File
@@ -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()
}
})
})
+23
View File
@@ -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(
-22
View File
@@ -186,28 +186,6 @@ describe("WebFetchTool helpers", () => {
)
})
test("parses malformed tag prefixes in linear time without a regex prepass", () => {
const small = "<a".repeat(250_000)
const large = "<a".repeat(1_000_000)
const start = Bun.nanoseconds()
WebFetchTool.convertHTMLToMarkdown(small)
const smallDuration = Bun.nanoseconds() - start
const next = Bun.nanoseconds()
WebFetchTool.convertHTMLToMarkdown(large)
const largeDuration = Bun.nanoseconds() - next
expect(largeDuration).toBeLessThan(smallDuration * 10)
})
test("caps escaped prose and backtick-heavy pre output at the webfetch response ceiling", () => {
const prose = `<p>${"*".repeat(WebFetchTool.MAX_RESPONSE_BYTES)}</p>`
const code = `<pre>${"`".repeat(WebFetchTool.MAX_RESPONSE_BYTES - 11)}</pre>`
const proseOutput = WebFetchTool.convertHTMLToMarkdown(prose)
const codeOutput = WebFetchTool.convertHTMLToMarkdown(code)
expect(Buffer.byteLength(proseOutput)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
expect(Buffer.byteLength(codeOutput)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
expect(codeOutput.startsWith("~~~\n")).toBe(true)
})
test("does not confuse source NUL text with buffered code", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<p>before \u00000\u0000 after</p><pre>code</pre>`)).toBe(
`before \u00000\u0000 after\n\n\`\`\`\ncode\n\`\`\``,
+2 -1
View File
@@ -29,7 +29,7 @@ const webSearchToolNode = makeLocationNode({
yield* registerToolPlugin(WebSearchTool.Plugin, { websearch: webSearchHost(websearch) })
}),
),
deps: [Tool.node, Permission.node, WebSearch.node, Form.node, Config.node],
deps: [Tool.node, Permission.node, WebSearch.node, Form.node],
})
const sessionID = Session.ID.make("ses_websearch_test")
@@ -93,6 +93,7 @@ const websearch = Layer.succeed(
if (selection === false) return yield* new WebSearch.DisabledError()
return selection ? providers.find((provider) => provider.id === selection) : undefined
}),
select: (next) => Effect.sync(() => (selection = next)),
query: (input) =>
Effect.gen(function* () {
queries.push(input)
+27 -1
View File
@@ -3,10 +3,11 @@ import { Effect, Exit, Scope } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { KV } from "@opencode-ai/core/kv"
import { WebSearch } from "@opencode-ai/core/websearch"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node])))
const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node])))
const register = (id: string) =>
Effect.gen(function* () {
@@ -80,6 +81,31 @@ describe("WebSearch", () => {
}),
)
it.effect("persists the selected provider in KV", () =>
Effect.gen(function* () {
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
const kv = yield* KV.Service
yield* websearch.select(parallel.providerID)
expect(yield* kv.get(WebSearch.ProviderKey)).toBe(parallel.providerID)
expect((yield* websearch.query({ query: "remembered" })).providerID).toBe(parallel.providerID)
}),
)
it.effect("keeps config transforms above the persisted selection", () =>
Effect.gen(function* () {
const exa = yield* register("exa")
const parallel = yield* register("parallel")
const websearch = yield* WebSearch.Service
yield* websearch.select(parallel.providerID)
yield* websearch.transform((draft) => draft.default.set(exa.providerID))
expect((yield* websearch.query({ query: "configured" })).providerID).toBe(exa.providerID)
}),
)
it.effect("chooses a registered provider for random selection", () =>
Effect.gen(function* () {
yield* register("exa")
+5 -8
View File
@@ -2,7 +2,7 @@ import { app } from "electron"
import { Deferred, Effect, Fiber } from "effect"
import type { ServerReadyData } from "../shared/ipc-contract"
import { checkAppExists, resolveAppPath } from "./files/apps"
import { registerIpcHandlers } from "./ipc"
import { registerIpcHandlers, registerUpdaterIpcHandlers, registerWslIpcHandlers } from "./ipc"
import {
acquireApplicationLock,
configureApplication,
@@ -21,7 +21,6 @@ import { getDefaultServerUrl, setDefaultServerUrl } from "./service/server-setti
import { createUpdaterIpc, setupAutoUpdater, showUpdaterDialog, startAutoUpdater } from "./updater"
import { getLastFocusedWindow, setBackgroundColor } from "./windows"
import { startWsl } from "./wsl/start"
import { createDeferredWslIpc } from "./wsl/ipc"
const main = Effect.gen(function* () {
const logger = configureApplication()
@@ -34,8 +33,6 @@ const main = Effect.gen(function* () {
yield* prepareDesktop(logger)
const updater = setupAutoUpdater(lifecycle.prepareToRestart)
const updaterIpc = createUpdaterIpc(updater)
const wslIpc = createDeferredWslIpc()
const menu = {
trigger: (id: string) => {
const win = getLastFocusedWindow()
@@ -44,7 +41,7 @@ const main = Effect.gen(function* () {
checkForUpdates: () => void showUpdaterDialog(updater),
relaunch: lifecycle.relaunch,
}
const ipcDeps: Parameters<typeof registerIpcHandlers>[0] = {
registerIpcHandlers({
relaunch: lifecycle.relaunch,
awaitInitialization: Effect.fnUntraced(
function* () {
@@ -69,8 +66,8 @@ const main = Effect.gen(function* () {
setNativeTranslations: (bundle) => {
if (setNativeTranslations(bundle)) createMenu(menu)
},
}
yield* Effect.promise(() => registerIpcHandlers(ipcDeps, updaterIpc, wslIpc.ipc))
})
registerUpdaterIpcHandlers(createUpdaterIpc(updater))
startAutoUpdater(updater)
yield* Effect.promise(() => startNetworkLogging())
@@ -79,7 +76,7 @@ const main = Effect.gen(function* () {
logger.log("starting v2 background service")
const background = yield* Effect.promise(() => startBackgroundCli(logger))
const wsl = yield* Effect.promise(() => startWsl(background, logger))
wslIpc.set(wsl.ipc)
registerWslIpcHandlers(wsl.ipc)
wsl.start()
lifecycle.setWslShutdown(wsl.stop)
yield* Deferred.succeed(serverReady, {
-28
View File
@@ -1,28 +0,0 @@
import type { WebContents } from "electron"
import { Effect, Queue, Stream } from "effect"
import type { DesktopEvent } from "../shared/ipc-rpc/events"
const queues = new Map<number, Queue.Queue<DesktopEvent>>()
export function bindIpcEvents(senderId: number) {
const queue = Effect.runSync(Queue.unbounded<DesktopEvent>())
queues.set(senderId, queue)
return () => {
if (queues.get(senderId) === queue) queues.delete(senderId)
}
}
export function ipcEventStream(senderId: number) {
return Stream.unwrap(
Effect.gen(function* () {
const queue = queues.get(senderId) ?? (yield* Queue.unbounded<DesktopEvent>())
if (!queues.has(senderId)) queues.set(senderId, queue)
return Stream.fromQueue(queue)
}),
)
}
export function emitIpcEvent(sender: WebContents, event: DesktopEvent) {
const queue = queues.get(sender.id)
if (queue) Queue.offerUnsafe(queue, event)
}
@@ -1,64 +0,0 @@
import { BrowserWindow } from "electron"
import { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
import { Effect } from "effect"
import type { FatalRendererError, ServerReadyData } from "../../shared/ipc-contract"
import { AppRpcs } from "../../shared/ipc-rpc"
import { setForceFocus } from "../native/debug"
import { IpcPortHandoff } from "../ipc-transport"
import { sender } from "./context"
export type AppHandlerDeps = {
relaunch: () => void
awaitInitialization: () => Promise<ServerReadyData>
consumeInitialDeepLinks: () => Promise<string[]> | string[]
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
isFirstLaunchOnboardingPending: () => Promise<boolean> | boolean
finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null> | string | null
checkAppExists: (appName: string) => Promise<boolean> | boolean
resolveAppPath: (appName: string) => Promise<string | null>
setBackgroundColor: (color: string) => void
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
setNativeTranslations: (bundle: DesktopNativeBundle) => void
}
export function appHandlers(deps: AppHandlerDeps) {
return AppRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return AppRpcs.of({
AppAwaitInitialization: () => Effect.promise(() => deps.awaitInitialization()),
AppConsumeInitialDeepLinks: () => promise(deps.consumeInitialDeepLinks),
AppGetDefaultServerUrl: () => promise(deps.getDefaultServerUrl),
AppSetDefaultServerUrl: ({ url }) => promise(() => deps.setDefaultServerUrl(url)),
AppIsFirstLaunchOnboardingPending: () => promise(deps.isFirstLaunchOnboardingPending),
AppFinishFirstLaunchOnboarding: ({ createDefaultProject }) =>
promise(() => deps.finishFirstLaunchOnboarding(createDefaultProject)),
AppCheckAppExists: ({ appName }) => promise(() => deps.checkAppExists(appName)),
AppResolveAppPath: ({ appName }) => Effect.promise(() => deps.resolveAppPath(appName)),
AppSetBackgroundColor: ({ color }) => Effect.sync(() => deps.setBackgroundColor(color)),
AppExportDebugLogs: () => Effect.promise(() => deps.exportDebugLogs()),
AppSetForceFocus: ({ enabled }, context) =>
Effect.promise(() => setForceFocus(sender(handoff, context), enabled)),
AppRecordFatalRendererError: ({ error }) => promise(() => deps.recordFatalRendererError(error)),
AppSetNativeTranslations: ({ value }, context) =>
Effect.sync(() => {
const contents = sender(handoff, context)
const win = BrowserWindow.fromWebContents(contents)
if (!win || win.isDestroyed() || win.webContents !== contents) {
throw new Error("Invalid native translation sender")
}
const bundle = parseDesktopNativeBundle(value)
if (!bundle) throw new Error("Invalid native translation bundle")
deps.setNativeTranslations(bundle)
}),
AppRelaunch: () => Effect.sync(deps.relaunch),
})
}),
)
}
function promise<A>(evaluate: () => A | Promise<A>) {
return Effect.promise(async () => evaluate())
}
@@ -1,9 +0,0 @@
import type { IpcPortHandoff } from "../ipc-transport"
export type RpcContext = { readonly client: { readonly id: number } }
export function sender(handoff: IpcPortHandoff["Service"], context: RpcContext) {
const contents = handoff.sender(context.client.id)
if (!contents || contents.isDestroyed()) throw new Error("Renderer connection not found")
return contents
}
@@ -1,14 +0,0 @@
import { Effect } from "effect"
import { EventRpcs } from "../../shared/ipc-rpc"
import { ipcEventStream } from "../ipc-events"
import { IpcPortHandoff } from "../ipc-transport"
import { sender } from "./context"
export const eventHandlers = EventRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return EventRpcs.of({
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
})
}),
)
@@ -1,40 +0,0 @@
import { Effect } from "effect"
import { FileRpcs } from "../../shared/ipc-rpc"
import { createFileCapabilities, openExternalURL, openLocalFileURL } from "../files"
import { IpcPortHandoff } from "../ipc-transport"
import { sender } from "./context"
export function fileHandlers(files: ReturnType<typeof createFileCapabilities>) {
return FileRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return FileRpcs.of({
FilesOpenDirectoryPicker: ({ options }) => Effect.promise(() => files.openDirectoryPicker(options)),
FilesOpenFilePicker: ({ options }, context) =>
Effect.promise(() =>
files.openFilePicker(
sender(handoff, context).id,
options ? { ...options, extensions: options.extensions && [...options.extensions] } : undefined,
),
),
FilesReadPickedFile: ({ token, path }, context) =>
Effect.promise(
async () => new Uint8Array(await files.readPickedFile(sender(handoff, context).id, token, path)),
),
FilesReleasePickedFiles: ({ token }, context) =>
Effect.sync(() => files.releasePickedFiles(sender(handoff, context).id, token)),
FilesSaveFilePicker: ({ options }) => Effect.promise(() => files.saveFilePicker(options)),
FilesOpenExternal: ({ url }) => Effect.sync(() => openExternalURL(url)),
FilesOpenLocalFile: ({ url }) => Effect.sync(() => openLocalFileURL(url)),
FilesOpenPath: ({ path, application }) =>
Effect.promise(async () => (await files.openPath(path, application)) ?? null),
FilesRevealPath: ({ path }) => Effect.promise(() => files.revealPath(path)),
FilesReadClipboardImage: () =>
Effect.sync(() => {
const image = files.readClipboardImage()
return image ? { ...image, buffer: new Uint8Array(image.buffer) } : null
}),
})
}),
)
}
@@ -1,26 +0,0 @@
import { BrowserWindow } from "electron"
import { Effect } from "effect"
import { MenuRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import { runDesktopMenuAction } from "../native/menu-actions"
import { sender } from "./context"
export function menuHandlers(deps: {
readonly showUpdater: () => Promise<void> | void
readonly relaunch: () => void
}) {
return MenuRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return MenuRpcs.of({
MenuRunAction: ({ action }, context) =>
Effect.sync(() =>
runDesktopMenuAction(BrowserWindow.fromWebContents(sender(handoff, context)), action, {
checkForUpdates: () => void deps.showUpdater(),
relaunch: deps.relaunch,
}),
),
})
}),
)
}
@@ -1,32 +0,0 @@
import { Effect } from "effect"
import { StorageRpcs } from "../../shared/ipc-rpc"
import type { createDesktopStorage } from "../storage"
export function storageHandlers(storage: ReturnType<typeof createDesktopStorage>) {
return StorageRpcs.toLayer(
Effect.succeed(
StorageRpcs.of({
StorageGet: ({ name, key }) => Effect.sync(() => storage.get(name, key)),
StorageSet: ({ name, key, value }) => Effect.sync(() => storage.set(name, key, value)),
StorageDelete: ({ name, key }) => Effect.sync(() => storage.deleteValue(name, key)),
StorageClear: ({ name }) => Effect.sync(() => storage.clear(name)),
StorageKeys: ({ name }) => Effect.sync(() => storage.keys(name)),
StorageLength: ({ name }) => Effect.sync(() => storage.length(name)),
DraftsGet: ({ key }) => Effect.sync(() => storage.drafts.get(key)),
DraftsSet: ({ key, value }) => Effect.sync(() => storage.drafts.set(key, value)),
DraftsDelete: ({ key }) => Effect.sync(() => storage.drafts.set(key, null)),
DraftsPutBlob: ({ data }) =>
Effect.sync(() =>
storage.drafts.putBlob(
data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer,
),
),
DraftsGetBlob: ({ id }) =>
Effect.sync(() => {
const data = storage.drafts.getBlob(id)
return data ? new Uint8Array(data) : null
}),
}),
),
)
}
@@ -1,19 +0,0 @@
import { Effect } from "effect"
import { UpdaterRpcs } from "../../shared/ipc-rpc"
import type { UpdaterIpc } from "../updater"
import { IpcPortHandoff } from "../ipc-transport"
import { sender } from "./context"
export function updaterHandlers(updater: UpdaterIpc) {
return UpdaterRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return UpdaterRpcs.of({
UpdaterSubscribe: (_args, context) => Effect.sync(() => updater.subscribe(sender(handoff, context))),
UpdaterUnsubscribe: (_args, context) => Effect.sync(() => updater.unsubscribe(sender(handoff, context).id)),
UpdaterCheck: () => Effect.promise(() => updater.check()),
UpdaterInstall: () => Effect.promise(() => updater.install()),
})
}),
)
}
@@ -1,45 +0,0 @@
import { BrowserWindow } from "electron"
import { Effect } from "effect"
import { WindowRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "../windows"
import { sender } from "./context"
export const windowHandlers = WindowRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return WindowRpcs.of({
WindowGetId: (_args, context) =>
Effect.sync(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
if (!win) throw new Error("Window not found")
const id = getWindowID(win)
if (!id) throw new Error("Window ID not found")
return id
}),
WindowGetFocused: (_args, context) =>
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.isFocused() ?? false),
WindowGetFullscreen: (_args, context) =>
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.isFullScreen() ?? false),
WindowSetFocus: (_args, context) =>
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.focus()),
WindowShow: (_args, context) =>
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.show()),
WindowGetZoomFactor: (_args, context) => Effect.sync(() => sender(handoff, context).getZoomFactor()),
WindowSetZoomFactor: ({ factor }, context) =>
Effect.sync(() => {
const contents = sender(handoff, context)
contents.setZoomFactor(factor)
const win = BrowserWindow.fromWebContents(contents)
if (win) updateTitlebar(win)
}),
WindowGetPinchZoomEnabled: () => Effect.sync(getPinchZoomEnabled),
WindowSetPinchZoomEnabled: ({ enabled }) => Effect.sync(() => setPinchZoomEnabled(enabled)),
WindowSetTitlebar: ({ theme }, context) =>
Effect.sync(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
if (win) setTitlebar(win, theme)
}),
})
}),
)

Some files were not shown because too many files have changed in this diff Show More