Compare commits
261
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49fa822aa6 | ||
|
|
c933504d9c | ||
|
|
2d0d3d596e | ||
|
|
3bd98ea055 | ||
|
|
7e997cfba4 | ||
|
|
5d6f2a1524 | ||
|
|
b1cb71856e | ||
|
|
518264fcd9 | ||
|
|
7235c9c9b8 | ||
|
|
274033cd52 | ||
|
|
9b369ee815 | ||
|
|
721ff5121e | ||
|
|
cfbf5d1c6f | ||
|
|
02cb7e7b71 | ||
|
|
942630eb4a | ||
|
|
ce66b191d1 | ||
|
|
6f1f5944ce | ||
|
|
1c49b2ed67 | ||
|
|
3c6be604ec | ||
|
|
ddc02c2893 | ||
|
|
2703eff2e2 | ||
|
|
38e4540119 | ||
|
|
4100fcbd17 | ||
|
|
83cb0f60ec | ||
|
|
effd96755e | ||
|
|
5801cce1b5 | ||
|
|
77e6c0d329 | ||
|
|
fed716ada5 | ||
|
|
426d92e352 | ||
|
|
d0412a213b | ||
|
|
16aa67086b | ||
|
|
7cea32ee08 | ||
|
|
128d10d9e9 | ||
|
|
5ef72e1101 | ||
|
|
d8060dc9ad | ||
|
|
f71fb18d3d | ||
|
|
5654dd2aad | ||
|
|
64dde0cb15 | ||
|
|
a658e43eeb | ||
|
|
ca8a42c973 | ||
|
|
8f5f75db12 | ||
|
|
4ff0d07b1d | ||
|
|
2a571b3cee | ||
|
|
0fffcdfe46 | ||
|
|
2ba2ee52e8 | ||
|
|
56d818fc34 | ||
|
|
9c8da69196 | ||
|
|
a78018697c | ||
|
|
e45b6ef1de | ||
|
|
b616543ac2 | ||
|
|
2bd3d9a696 | ||
|
|
fa15dbc5ec | ||
|
|
312e5c7a7c | ||
|
|
049502fac6 | ||
|
|
cc2915be16 | ||
|
|
ce061bf661 | ||
|
|
3b8790e034 | ||
|
|
a4f3cedcdf | ||
|
|
1c9a2eb239 | ||
|
|
4fb417d3b5 | ||
|
|
11030c627b | ||
|
|
c104098a66 | ||
|
|
49ee3ba85a | ||
|
|
4fc538378d | ||
|
|
d28b5ad2f4 | ||
|
|
6589a66822 | ||
|
|
5cf9abe743 | ||
|
|
903d81819d | ||
|
|
472f9e64a6 | ||
|
|
c04fa9e253 | ||
|
|
3a78fb1f42 | ||
|
|
85ce6a5f95 | ||
|
|
5217e6c1af | ||
|
|
3a7f617098 | ||
|
|
d9150413cb | ||
|
|
bcbc1dba22 | ||
|
|
ce3235e115 | ||
|
|
a9a2a597d5 | ||
|
|
3753601f87 | ||
|
|
fb4bab8a66 | ||
|
|
b3526f6ce9 | ||
|
|
f220f02a2f | ||
|
|
235a86fb60 | ||
|
|
67b9c9c027 | ||
|
|
2f11c9f7ed | ||
|
|
e1c1193f3e | ||
|
|
29250a0efb | ||
|
|
c6e6bdf59f | ||
|
|
d80e1199ca | ||
|
|
10ea59066f | ||
|
|
79d6b10d7c | ||
|
|
6e78f36a0f | ||
|
|
16866e1180 | ||
|
|
6d130e5deb | ||
|
|
e30d8173c1 | ||
|
|
7a79f3a5ea | ||
|
|
b8ca71d309 | ||
|
|
6849f96825 | ||
|
|
00c3248295 | ||
|
|
818b56dbd0 | ||
|
|
29b5b24787 | ||
|
|
11363170ca | ||
|
|
ba9e4b67ed | ||
|
|
bd1029b19f | ||
|
|
43b51f09d0 | ||
|
|
c61ab51886 | ||
|
|
d373c562f2 | ||
|
|
5fa5d876fc | ||
|
|
805af011c9 | ||
|
|
480aa8b23b | ||
|
|
d62442bb5d | ||
|
|
8602937a37 | ||
|
|
77da433e0a | ||
|
|
ad79f3e0cf | ||
|
|
6c2dfd2f52 | ||
|
|
9a8b54fe62 | ||
|
|
dcdbdb218f | ||
|
|
5e49029e70 | ||
|
|
19abadaf27 | ||
|
|
6fea0178eb | ||
|
|
ebe9dcf27e | ||
|
|
57efec4429 | ||
|
|
e22144806f | ||
|
|
27fa297a42 | ||
|
|
b1cd25de3d | ||
|
|
780bbb0f3b | ||
|
|
817cb076a8 | ||
|
|
347526e9fd | ||
|
|
092bc674a5 | ||
|
|
b24a4e897e | ||
|
|
3afa622eab | ||
|
|
d01cb7f013 | ||
|
|
28b03595bf | ||
|
|
32684e70e6 | ||
|
|
b2baddcd37 | ||
|
|
dbd48d423d | ||
|
|
e7cc8259b5 | ||
|
|
a9ccb0804f | ||
|
|
ebe6087e8f | ||
|
|
dc978cb889 | ||
|
|
8cbc43fbb0 | ||
|
|
82359c4b1b | ||
|
|
7f3e51453b | ||
|
|
cbdb2d9825 | ||
|
|
96bde05f6e | ||
|
|
dcb8ed8eb0 | ||
|
|
aab82cc1a7 | ||
|
|
cd1d1e81a6 | ||
|
|
cff441909a | ||
|
|
3615d5aab1 | ||
|
|
11d9e82eaf | ||
|
|
eadda11ec9 | ||
|
|
f73a56c223 | ||
|
|
c0acf5c43f | ||
|
|
4d585464f3 | ||
|
|
f0cb17a812 | ||
|
|
357a74714a | ||
|
|
ffea6c7974 | ||
|
|
8e9550d90d | ||
|
|
9b7b6cb30f | ||
|
|
cc68afb2de | ||
|
|
11c33d52a5 | ||
|
|
2a487ea990 | ||
|
|
9c05d4e2fd | ||
|
|
0745162eab | ||
|
|
21d055be19 | ||
|
|
d19f7bc77c | ||
|
|
8694da9309 | ||
|
|
a0fc27e424 | ||
|
|
35deef6175 | ||
|
|
36f8b7e47d | ||
|
|
f4337dff3c | ||
|
|
ba8c920639 | ||
|
|
5bb7b23440 | ||
|
|
dc7d665e94 | ||
|
|
40d5ea1cf1 | ||
|
|
6e47ae769e | ||
|
|
9ca4be62bd | ||
|
|
fed221e0b0 | ||
|
|
75308ea47d | ||
|
|
daa3116f4b | ||
|
|
9e7f7bf8e4 | ||
|
|
4d43d584fe | ||
|
|
3052a79b32 | ||
|
|
13b3117ca9 | ||
|
|
83bb216486 | ||
|
|
fc46cef5fd | ||
|
|
799996db76 | ||
|
|
df75bfe07c | ||
|
|
c818c9dcb6 | ||
|
|
c36ab3f935 | ||
|
|
e3c983c21f | ||
|
|
a196569f51 | ||
|
|
19da27e1cb | ||
|
|
4a737493ac | ||
|
|
ec301f6a2c | ||
|
|
ac7a885ae9 | ||
|
|
7f2b5ee8c2 | ||
|
|
15784aa036 | ||
|
|
edbc02855d | ||
|
|
a43d3e0e1e | ||
|
|
ae25278eda | ||
|
|
6869186fc6 | ||
|
|
f8c6742e54 | ||
|
|
014dbd34c4 | ||
|
|
21ae91b4f2 | ||
|
|
bb3f14119b | ||
|
|
6f165e23de | ||
|
|
cef0c8ac84 | ||
|
|
dd8bb44d1d | ||
|
|
30868f52ea | ||
|
|
9c88235121 | ||
|
|
4e14f79511 | ||
|
|
e0396b809a | ||
|
|
319498e2fd | ||
|
|
2ba9aa2196 | ||
|
|
114eeb21dc | ||
|
|
1cf8123bc6 | ||
|
|
db6a038829 | ||
|
|
6ff833a22b | ||
|
|
5c401673b2 | ||
|
|
e8ce5df414 | ||
|
|
b8799be3c8 | ||
|
|
7ded0ec9e9 | ||
|
|
f5d0371efe | ||
|
|
22e64cac67 | ||
|
|
2a1305f231 | ||
|
|
e691e8f274 | ||
|
|
cc6dd5321c | ||
|
|
2c17e3a4db | ||
|
|
98f5e6e713 | ||
|
|
474e311f6f | ||
|
|
626a488fb8 | ||
|
|
a300a6cc7a | ||
|
|
fe594693a4 | ||
|
|
98e091796b | ||
|
|
3c4b4d5faf | ||
|
|
b6ff1b18c7 | ||
|
|
9c9bc09f52 | ||
|
|
d6e06c8950 | ||
|
|
844fb71938 | ||
|
|
fbb7b5b1bf | ||
|
|
95280ebec9 | ||
|
|
fea9a0bd4c | ||
|
|
30c4fcb1a5 | ||
|
|
193c169ca5 | ||
|
|
cee04f2924 | ||
|
|
1219691c11 | ||
|
|
b2cc40f09c | ||
|
|
0b2e65f16d | ||
|
|
1ea01fdad0 | ||
|
|
f8aa4a3be0 | ||
|
|
293bb422fa | ||
|
|
54a78c9224 | ||
|
|
9b30ee2db2 | ||
|
|
ba1ec62caf | ||
|
|
72ec05d0be | ||
|
|
0b702704ae | ||
|
|
3480cef52e | ||
|
|
dcfe4b0d51 | ||
|
|
b2e3dc87ea |
@@ -37,7 +37,6 @@ jobs:
|
||||
PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}
|
||||
STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }}
|
||||
HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }}
|
||||
INCIDENT_API_KEY: ${{ secrets.INCIDENT_API_KEY }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}
|
||||
|
||||
@@ -68,6 +68,11 @@ jobs:
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
- name: Run HttpApi exerciser gates
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/opencode
|
||||
run: bun run test:httpapi
|
||||
|
||||
- name: Publish unit reports
|
||||
if: always()
|
||||
uses: mikepenz/action-junit-report@v6
|
||||
|
||||
@@ -3,6 +3,7 @@ node_modules
|
||||
.worktrees
|
||||
.sst
|
||||
.env
|
||||
.env.local
|
||||
.idea
|
||||
.vscode
|
||||
.codex
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Fake secret-looking strings used by HTTP recorder redaction tests.
|
||||
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:69
|
||||
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:92
|
||||
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:146
|
||||
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:gcp-api-key:71
|
||||
@@ -3,7 +3,7 @@
|
||||
"provider": {},
|
||||
"permission": {
|
||||
"edit": {
|
||||
"packages/opencode/migration/*": "deny",
|
||||
"packages/opencode/migration/*": "ask",
|
||||
},
|
||||
},
|
||||
"mcp": {},
|
||||
|
||||
+367
-285
@@ -1,35 +1,62 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { RGBA, VignetteEffect } from "@opentui/core"
|
||||
import type {
|
||||
TuiKeybindSet,
|
||||
TuiPlugin,
|
||||
TuiPluginApi,
|
||||
TuiPluginMeta,
|
||||
TuiPluginModule,
|
||||
TuiSlotPlugin,
|
||||
} from "@opencode-ai/plugin/tui"
|
||||
import { useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { useBindings, useKeymapSelector } from "@opentui/keymap/solid"
|
||||
import { RGBA, VignetteEffect, type KeyEvent, type Renderable } from "@opentui/core"
|
||||
import { createBindingLookup, type BindingConfig } from "@opentui/keymap/extras"
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginMeta, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
|
||||
|
||||
const tabs = ["overview", "counter", "help"]
|
||||
const bind = {
|
||||
modal: "ctrl+shift+m",
|
||||
screen: "ctrl+shift+o",
|
||||
home: "escape,ctrl+h",
|
||||
left: "left,h",
|
||||
right: "right,l",
|
||||
up: "up,k",
|
||||
down: "down,j",
|
||||
alert: "a",
|
||||
confirm: "c",
|
||||
prompt: "p",
|
||||
select: "s",
|
||||
modal_accept: "enter,return",
|
||||
modal_close: "escape",
|
||||
dialog_close: "escape",
|
||||
local: "x",
|
||||
local_push: "enter,return",
|
||||
local_close: "q,backspace",
|
||||
host: "z",
|
||||
const command = {
|
||||
modal: "smoke_modal",
|
||||
screen: "smoke_screen",
|
||||
alert: "smoke_alert",
|
||||
confirm: "smoke_confirm",
|
||||
prompt: "smoke_prompt",
|
||||
select: "smoke_select",
|
||||
host: "smoke_host",
|
||||
home: "smoke_home",
|
||||
toast: "smoke_toast",
|
||||
dialog_close: "smoke_dialog_close",
|
||||
local_push: "smoke_local_push",
|
||||
local_pop: "smoke_local_pop",
|
||||
screen_home: "smoke_screen_home",
|
||||
screen_left: "smoke_screen_left",
|
||||
screen_right: "smoke_screen_right",
|
||||
screen_up: "smoke_screen_up",
|
||||
screen_down: "smoke_screen_down",
|
||||
screen_modal: "smoke_screen_modal",
|
||||
screen_local: "smoke_screen_local",
|
||||
screen_host: "smoke_screen_host",
|
||||
screen_alert: "smoke_screen_alert",
|
||||
screen_confirm: "smoke_screen_confirm",
|
||||
screen_prompt: "smoke_screen_prompt",
|
||||
screen_select: "smoke_screen_select",
|
||||
modal_accept: "smoke_modal_accept",
|
||||
modal_close: "smoke_modal_close",
|
||||
}
|
||||
|
||||
type SmokeBindings = BindingConfig<Renderable, KeyEvent>
|
||||
|
||||
const defaultKeymap = {
|
||||
[command.modal]: "ctrl+shift+m",
|
||||
[command.screen]: "ctrl+shift+o",
|
||||
[command.dialog_close]: "escape",
|
||||
[command.local_push]: "enter,return",
|
||||
[command.local_pop]: "escape,q,backspace",
|
||||
[command.screen_home]: "escape,ctrl+h",
|
||||
[command.screen_left]: "left,h",
|
||||
[command.screen_right]: "right,l",
|
||||
[command.screen_up]: "up,k",
|
||||
[command.screen_down]: "down,j",
|
||||
[command.screen_modal]: "ctrl+shift+m",
|
||||
[command.screen_local]: "x",
|
||||
[command.screen_host]: "z",
|
||||
[command.screen_alert]: "a",
|
||||
[command.screen_confirm]: "c",
|
||||
[command.screen_prompt]: "p",
|
||||
[command.screen_select]: "s",
|
||||
[command.modal_accept]: "enter,return",
|
||||
[command.modal_close]: "escape",
|
||||
}
|
||||
|
||||
const pick = (value: unknown, fallback: string) => {
|
||||
@@ -43,16 +70,14 @@ const num = (value: unknown, fallback: number) => {
|
||||
return value
|
||||
}
|
||||
|
||||
const rec = (value: unknown) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return
|
||||
return Object.fromEntries(Object.entries(value))
|
||||
}
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === "object" && !Array.isArray(value)
|
||||
|
||||
type Cfg = {
|
||||
label: string
|
||||
route: string
|
||||
vignette: number
|
||||
keybinds: Record<string, unknown> | undefined
|
||||
keybinds: SmokeBindings | undefined
|
||||
}
|
||||
|
||||
type Route = {
|
||||
@@ -74,7 +99,7 @@ const cfg = (options: Record<string, unknown> | undefined) => {
|
||||
label: pick(options?.label, "smoke"),
|
||||
route: pick(options?.route, "workspace-smoke"),
|
||||
vignette: Math.max(0, num(options?.vignette, 0.35)),
|
||||
keybinds: rec(options?.keybinds),
|
||||
keybinds: record(options?.keybinds) ? (options.keybinds as SmokeBindings) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +110,12 @@ const names = (input: Cfg) => {
|
||||
}
|
||||
}
|
||||
|
||||
type Keys = TuiKeybindSet
|
||||
function createKeys(input: SmokeBindings | undefined) {
|
||||
return createBindingLookup({ ...defaultKeymap, ...input })
|
||||
}
|
||||
|
||||
type Keys = ReturnType<typeof createKeys>
|
||||
|
||||
const ui = {
|
||||
panel: "#1d1d1d",
|
||||
border: "#4a4a4a",
|
||||
@@ -292,125 +322,174 @@ const Screen = (props: {
|
||||
}
|
||||
const pop = (base?: State) => {
|
||||
const next = base ?? current(props.api, props.route)
|
||||
const local = Math.max(0, next.local - 1)
|
||||
set(local, next)
|
||||
set(Math.max(0, next.local - 1), next)
|
||||
}
|
||||
const show = () => {
|
||||
setTimeout(() => {
|
||||
open()
|
||||
}, 0)
|
||||
}
|
||||
useKeyboard((evt) => {
|
||||
if (props.api.route.current.name !== props.route.screen) return
|
||||
const next = current(props.api, props.route)
|
||||
if (props.api.ui.dialog.open) {
|
||||
if (props.keys.match("dialog_close", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.ui.dialog.clear()
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
const screenActive = () => props.api.route.current.name === props.route.screen
|
||||
|
||||
if (next.local > 0) {
|
||||
if (evt.name === "escape" || props.keys.match("local_close", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
pop(next)
|
||||
return
|
||||
}
|
||||
useBindings(() => ({
|
||||
enabled: () => screenActive() && props.api.ui.dialog.open,
|
||||
commands: [
|
||||
{
|
||||
name: command.dialog_close,
|
||||
run() {
|
||||
props.api.ui.dialog.clear()
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: props.keys.gather("smoke.dialog", [command.dialog_close]),
|
||||
}))
|
||||
|
||||
if (props.keys.match("local_push", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
push(next)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
useBindings(() => ({
|
||||
enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local > 0,
|
||||
commands: [
|
||||
{
|
||||
name: command.local_push,
|
||||
run() {
|
||||
push(current(props.api, props.route))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: command.local_pop,
|
||||
run() {
|
||||
pop(current(props.api, props.route))
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: props.keys.gather("smoke.local", [command.local_push, command.local_pop]),
|
||||
}))
|
||||
|
||||
if (props.keys.match("home", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate("home")
|
||||
return
|
||||
}
|
||||
useBindings(() => ({
|
||||
enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local === 0,
|
||||
commands: [
|
||||
{
|
||||
name: command.screen_home,
|
||||
run() {
|
||||
props.api.route.navigate("home")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: command.screen_left,
|
||||
run() {
|
||||
const next = current(props.api, props.route)
|
||||
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
|
||||
},
|
||||
},
|
||||
{
|
||||
name: command.screen_right,
|
||||
run() {
|
||||
const next = current(props.api, props.route)
|
||||
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
|
||||
},
|
||||
},
|
||||
{
|
||||
name: command.screen_up,
|
||||
run() {
|
||||
const next = current(props.api, props.route)
|
||||
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
|
||||
},
|
||||
},
|
||||
{
|
||||
name: command.screen_down,
|
||||
run() {
|
||||
const next = current(props.api, props.route)
|
||||
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
|
||||
},
|
||||
},
|
||||
{
|
||||
name: command.screen_modal,
|
||||
run() {
|
||||
props.api.route.navigate(props.route.modal, current(props.api, props.route))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: command.screen_local,
|
||||
run() {
|
||||
open()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: command.screen_host,
|
||||
run() {
|
||||
host(props.api, props.input, skin)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: command.screen_alert,
|
||||
run() {
|
||||
warn(props.api, props.route, current(props.api, props.route))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: command.screen_confirm,
|
||||
run() {
|
||||
check(props.api, props.route, current(props.api, props.route))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: command.screen_prompt,
|
||||
run() {
|
||||
entry(props.api, props.route, current(props.api, props.route))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: command.screen_select,
|
||||
run() {
|
||||
picker(props.api, props.route, current(props.api, props.route))
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: props.keys.gather("smoke.screen", [
|
||||
command.screen_home,
|
||||
command.screen_left,
|
||||
command.screen_right,
|
||||
command.screen_up,
|
||||
command.screen_down,
|
||||
command.screen_modal,
|
||||
command.screen_local,
|
||||
command.screen_host,
|
||||
command.screen_alert,
|
||||
command.screen_confirm,
|
||||
command.screen_prompt,
|
||||
command.screen_select,
|
||||
]),
|
||||
}))
|
||||
const shortcuts = useKeymapSelector((keymap) => {
|
||||
const bindings = keymap.getCommandBindings({
|
||||
visibility: "registered",
|
||||
commands: [
|
||||
command.screen_home,
|
||||
command.screen_up,
|
||||
command.screen_down,
|
||||
command.screen_modal,
|
||||
command.screen_alert,
|
||||
command.screen_confirm,
|
||||
command.screen_prompt,
|
||||
command.screen_select,
|
||||
command.screen_local,
|
||||
command.screen_host,
|
||||
command.local_push,
|
||||
command.local_pop,
|
||||
],
|
||||
})
|
||||
|
||||
if (props.keys.match("left", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("right", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("up", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("down", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("modal", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.modal, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("local", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
open()
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("host", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
host(props.api, props.input, skin)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("alert", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
warn(props.api, props.route, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("confirm", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
check(props.api, props.route, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("prompt", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
entry(props.api, props.route, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("select", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
picker(props.api, props.route, next)
|
||||
return {
|
||||
screen_home: props.api.keys.formatBindings(bindings.get(command.screen_home)) ?? "",
|
||||
screen_up: props.api.keys.formatBindings(bindings.get(command.screen_up)) ?? "",
|
||||
screen_down: props.api.keys.formatBindings(bindings.get(command.screen_down)) ?? "",
|
||||
screen_modal: props.api.keys.formatBindings(bindings.get(command.screen_modal)) ?? "",
|
||||
screen_alert: props.api.keys.formatBindings(bindings.get(command.screen_alert)) ?? "",
|
||||
screen_confirm: props.api.keys.formatBindings(bindings.get(command.screen_confirm)) ?? "",
|
||||
screen_prompt: props.api.keys.formatBindings(bindings.get(command.screen_prompt)) ?? "",
|
||||
screen_select: props.api.keys.formatBindings(bindings.get(command.screen_select)) ?? "",
|
||||
screen_local: props.api.keys.formatBindings(bindings.get(command.screen_local)) ?? "",
|
||||
screen_host: props.api.keys.formatBindings(bindings.get(command.screen_host)) ?? "",
|
||||
local_push: props.api.keys.formatBindings(bindings.get(command.local_push)) ?? "",
|
||||
local_pop: props.api.keys.formatBindings(bindings.get(command.local_pop)) ?? "",
|
||||
}
|
||||
})
|
||||
|
||||
@@ -430,7 +509,7 @@ const Screen = (props: {
|
||||
<b>{props.input.label} screen</b>
|
||||
<span style={{ fg: skin.muted }}> plugin route</span>
|
||||
</text>
|
||||
<text fg={skin.muted}>{props.keys.print("home")} home</text>
|
||||
<text fg={skin.muted}>{shortcuts().screen_home} home</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} paddingBottom={1}>
|
||||
@@ -477,7 +556,7 @@ const Screen = (props: {
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={skin.text}>Counter: {value.count}</text>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("up")} / {props.keys.print("down")} change value
|
||||
{shortcuts().screen_up} / {shortcuts().screen_down} change value
|
||||
</text>
|
||||
</box>
|
||||
) : null}
|
||||
@@ -485,17 +564,16 @@ const Screen = (props: {
|
||||
{value.tab === 2 ? (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
|
||||
confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
|
||||
{shortcuts().screen_modal} modal | {shortcuts().screen_alert} alert | {shortcuts().screen_confirm}{" "}
|
||||
confirm | {shortcuts().screen_prompt} prompt | {shortcuts().screen_select} select
|
||||
</text>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("local")} local stack | {props.keys.print("host")} host stack
|
||||
{shortcuts().screen_local} local stack | {shortcuts().screen_host} host stack
|
||||
</text>
|
||||
<text fg={skin.muted}>
|
||||
local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
|
||||
close
|
||||
local open: {shortcuts().local_push} push nested · {shortcuts().local_pop} close
|
||||
</text>
|
||||
<text fg={skin.muted}>{props.keys.print("home")} returns home</text>
|
||||
<text fg={skin.muted}>{shortcuts().screen_home} returns home</text>
|
||||
</box>
|
||||
) : null}
|
||||
</box>
|
||||
@@ -548,7 +626,7 @@ const Screen = (props: {
|
||||
</text>
|
||||
<text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close
|
||||
{shortcuts().local_push} push nested · {shortcuts().local_pop} pop/close
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Btn txt="push" run={push} skin={skin} on />
|
||||
@@ -571,20 +649,35 @@ const Modal = (props: {
|
||||
const value = parse(props.params)
|
||||
const skin = tone(props.api)
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (props.api.route.current.name !== props.route.modal) return
|
||||
useBindings(() => ({
|
||||
enabled: () => props.api.route.current.name === props.route.modal,
|
||||
commands: [
|
||||
{
|
||||
name: command.modal_accept,
|
||||
run() {
|
||||
props.api.route.navigate(props.route.screen, { ...parse(props.params), source: "modal" })
|
||||
},
|
||||
},
|
||||
{
|
||||
name: command.modal_close,
|
||||
run() {
|
||||
props.api.route.navigate("home")
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: props.keys.gather("smoke.modal", [command.modal_accept, command.modal_close]),
|
||||
}))
|
||||
const shortcuts = useKeymapSelector((keymap) => {
|
||||
const bindings = keymap.getCommandBindings({
|
||||
visibility: "registered",
|
||||
commands: [command.modal, command.screen, command.modal_accept, command.modal_close],
|
||||
})
|
||||
|
||||
if (props.keys.match("modal_accept", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("modal_close", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate("home")
|
||||
return {
|
||||
modal: props.api.keys.formatBindings(bindings.get(command.modal)) ?? "",
|
||||
screen: props.api.keys.formatBindings(bindings.get(command.screen)) ?? "",
|
||||
modal_accept: props.api.keys.formatBindings(bindings.get(command.modal_accept)) ?? "",
|
||||
modal_close: props.api.keys.formatBindings(bindings.get(command.modal_close)) ?? "",
|
||||
}
|
||||
})
|
||||
|
||||
@@ -595,10 +688,10 @@ const Modal = (props: {
|
||||
<text fg={skin.text}>
|
||||
<b>{props.input.label} modal</b>
|
||||
</text>
|
||||
<text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
|
||||
<text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
|
||||
<text fg={skin.muted}>{shortcuts().modal} modal command</text>
|
||||
<text fg={skin.muted}>{shortcuts().screen} screen command</text>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
|
||||
{shortcuts().modal_accept} opens screen · {shortcuts().modal_close} closes
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Btn
|
||||
@@ -651,25 +744,8 @@ const home = (api: TuiPluginApi, input: Cfg) => ({
|
||||
},
|
||||
home_prompt(ctx, value) {
|
||||
const skin = look(ctx.theme.current)
|
||||
type Prompt = (props: {
|
||||
workspaceID?: string
|
||||
visible?: boolean
|
||||
disabled?: boolean
|
||||
onSubmit?: () => void
|
||||
hint?: JSX.Element
|
||||
right?: JSX.Element
|
||||
showPlaceholder?: boolean
|
||||
placeholders?: {
|
||||
normal?: string[]
|
||||
shell?: string[]
|
||||
}
|
||||
}) => JSX.Element
|
||||
type Slot = (
|
||||
props: { name: string; mode?: unknown; children?: JSX.Element } & Record<string, unknown>,
|
||||
) => JSX.Element | null
|
||||
const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot }
|
||||
const Prompt = ui.Prompt
|
||||
const Slot = ui.Slot
|
||||
const Prompt = api.ui.Prompt
|
||||
const Slot = api.ui.Slot
|
||||
const normal = [
|
||||
`[SMOKE] route check for ${input.label}`,
|
||||
"[SMOKE] confirm home_prompt slot override",
|
||||
@@ -791,109 +867,115 @@ const slot = (api: TuiPluginApi, input: Cfg): TuiSlotPlugin[] => [
|
||||
|
||||
const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
|
||||
const route = names(input)
|
||||
api.command.register(() => [
|
||||
{
|
||||
title: `${input.label} modal`,
|
||||
value: "plugin.smoke.modal",
|
||||
keybind: keys.get("modal"),
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke",
|
||||
api.keymap.registerLayer({
|
||||
commands: [
|
||||
{
|
||||
name: command.modal,
|
||||
title: `${input.label} modal`,
|
||||
category: "Plugin",
|
||||
namespace: "palette",
|
||||
slashName: "smoke",
|
||||
run() {
|
||||
api.route.navigate(route.modal, { source: "command" })
|
||||
},
|
||||
},
|
||||
onSelect: () => {
|
||||
api.route.navigate(route.modal, { source: "command" })
|
||||
{
|
||||
name: command.screen,
|
||||
title: `${input.label} screen`,
|
||||
category: "Plugin",
|
||||
namespace: "palette",
|
||||
slashName: "smoke-screen",
|
||||
run() {
|
||||
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} screen`,
|
||||
value: "plugin.smoke.screen",
|
||||
keybind: keys.get("screen"),
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-screen",
|
||||
{
|
||||
name: command.alert,
|
||||
title: `${input.label} alert dialog`,
|
||||
category: "Plugin",
|
||||
namespace: "palette",
|
||||
slashName: "smoke-alert",
|
||||
run() {
|
||||
warn(api, route, current(api, route))
|
||||
},
|
||||
},
|
||||
onSelect: () => {
|
||||
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
|
||||
{
|
||||
name: command.confirm,
|
||||
title: `${input.label} confirm dialog`,
|
||||
category: "Plugin",
|
||||
namespace: "palette",
|
||||
slashName: "smoke-confirm",
|
||||
run() {
|
||||
check(api, route, current(api, route))
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} alert dialog`,
|
||||
value: "plugin.smoke.alert",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-alert",
|
||||
{
|
||||
name: command.prompt,
|
||||
title: `${input.label} prompt dialog`,
|
||||
category: "Plugin",
|
||||
namespace: "palette",
|
||||
slashName: "smoke-prompt",
|
||||
run() {
|
||||
entry(api, route, current(api, route))
|
||||
},
|
||||
},
|
||||
onSelect: () => {
|
||||
warn(api, route, current(api, route))
|
||||
{
|
||||
name: command.select,
|
||||
title: `${input.label} select dialog`,
|
||||
category: "Plugin",
|
||||
namespace: "palette",
|
||||
slashName: "smoke-select",
|
||||
run() {
|
||||
picker(api, route, current(api, route))
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} confirm dialog`,
|
||||
value: "plugin.smoke.confirm",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-confirm",
|
||||
{
|
||||
name: command.host,
|
||||
title: `${input.label} host overlay`,
|
||||
category: "Plugin",
|
||||
namespace: "palette",
|
||||
slashName: "smoke-host",
|
||||
run() {
|
||||
host(api, input, tone(api))
|
||||
},
|
||||
},
|
||||
onSelect: () => {
|
||||
check(api, route, current(api, route))
|
||||
{
|
||||
name: command.home,
|
||||
title: `${input.label} go home`,
|
||||
category: "Plugin",
|
||||
namespace: "palette",
|
||||
enabled: () => api.route.current.name !== "home",
|
||||
run() {
|
||||
api.route.navigate("home")
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} prompt dialog`,
|
||||
value: "plugin.smoke.prompt",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-prompt",
|
||||
{
|
||||
name: command.toast,
|
||||
title: `${input.label} toast`,
|
||||
category: "Plugin",
|
||||
namespace: "palette",
|
||||
run() {
|
||||
api.ui.toast({
|
||||
variant: "info",
|
||||
title: "Smoke",
|
||||
message: "Plugin toast works",
|
||||
duration: 2000,
|
||||
})
|
||||
},
|
||||
},
|
||||
onSelect: () => {
|
||||
entry(api, route, current(api, route))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} select dialog`,
|
||||
value: "plugin.smoke.select",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-select",
|
||||
},
|
||||
onSelect: () => {
|
||||
picker(api, route, current(api, route))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} host overlay`,
|
||||
value: "plugin.smoke.host",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-host",
|
||||
},
|
||||
onSelect: () => {
|
||||
host(api, input, tone(api))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} go home`,
|
||||
value: "plugin.smoke.home",
|
||||
category: "Plugin",
|
||||
enabled: api.route.current.name !== "home",
|
||||
onSelect: () => {
|
||||
api.route.navigate("home")
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} toast`,
|
||||
value: "plugin.smoke.toast",
|
||||
category: "Plugin",
|
||||
onSelect: () => {
|
||||
api.ui.toast({
|
||||
variant: "info",
|
||||
title: "Smoke",
|
||||
message: "Plugin toast works",
|
||||
duration: 2000,
|
||||
})
|
||||
},
|
||||
},
|
||||
])
|
||||
],
|
||||
bindings: keys.gather("smoke.global", [
|
||||
command.modal,
|
||||
command.screen,
|
||||
command.alert,
|
||||
command.confirm,
|
||||
command.prompt,
|
||||
command.select,
|
||||
command.host,
|
||||
command.home,
|
||||
command.toast,
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api, options, meta) => {
|
||||
@@ -902,9 +984,9 @@ const tui: TuiPlugin = async (api, options, meta) => {
|
||||
await api.theme.install("./smoke-theme.json")
|
||||
api.theme.set("smoke-theme")
|
||||
|
||||
const value = cfg(options ?? undefined)
|
||||
const value = cfg(options)
|
||||
const route = names(value)
|
||||
const keys = api.keybind.create(bind, value.keybinds)
|
||||
const keys = createKeys(value.keybinds)
|
||||
const fx = new VignetteEffect(value.vignette)
|
||||
const post = fx.apply.bind(fx)
|
||||
api.renderer.addPostProcessFn(post)
|
||||
|
||||
+5
-4
@@ -7,10 +7,11 @@
|
||||
"enabled": false,
|
||||
"label": "workspace",
|
||||
"keybinds": {
|
||||
"modal": "ctrl+alt+m",
|
||||
"screen": "ctrl+alt+o",
|
||||
"home": "escape,ctrl+shift+h",
|
||||
"dialog_close": "escape,q"
|
||||
"smoke_modal": "ctrl+alt+m",
|
||||
"smoke_screen": "ctrl+alt+o",
|
||||
"smoke_screen_home": "escape,ctrl+shift+h",
|
||||
"smoke_screen_modal": "ctrl+alt+m",
|
||||
"smoke_dialog_close": "escape,q"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
### General Principles
|
||||
|
||||
- Keep things in one function unless composable or reusable
|
||||
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
|
||||
- Avoid `try`/`catch` where possible
|
||||
- Avoid using the `any` type
|
||||
- Use Bun APIs when possible, like `Bun.file()`
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -107,11 +107,11 @@
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "0.3.0",
|
||||
"solid-stripe": "0.8.1",
|
||||
"svix": "1.92.2",
|
||||
"vite": "catalog:",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@webgpu/types": "0.1.54",
|
||||
"typescript": "catalog:",
|
||||
@@ -120,7 +120,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -147,7 +147,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -171,7 +171,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -195,7 +195,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -229,7 +229,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -283,7 +283,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -303,6 +303,7 @@
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
@@ -312,7 +313,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -326,16 +327,47 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
"aws4fetch": "1.0.20",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/http-recorder": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.96",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
@@ -361,10 +393,6 @@
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/node-server": "1.19.11",
|
||||
"@hono/node-ws": "1.3.0",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
@@ -380,9 +408,11 @@
|
||||
"@opentelemetry/sdk-trace-base": "2.6.1",
|
||||
"@opentelemetry/sdk-trace-node": "2.6.1",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/scheduled": "1.5.2",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
@@ -404,8 +434,6 @@
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
"hono": "catalog:",
|
||||
"hono-openapi": "catalog:",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
@@ -432,7 +460,6 @@
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
"zod-to-json-schema": "3.24.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.4",
|
||||
@@ -465,12 +492,11 @@
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2",
|
||||
"zod-to-json-schema": "3.24.5",
|
||||
},
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -478,6 +504,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
@@ -485,11 +512,13 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.2",
|
||||
"@opentui/solid": ">=0.2.2",
|
||||
"@opentui/core": ">=0.2.6",
|
||||
"@opentui/keymap": ">=0.2.6",
|
||||
"@opentui/solid": ">=0.2.6",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
"@opentui/keymap",
|
||||
"@opentui/solid",
|
||||
],
|
||||
},
|
||||
@@ -505,7 +534,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -520,7 +549,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -555,7 +584,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -604,7 +633,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -649,6 +678,7 @@
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@types/bun": "catalog:",
|
||||
@@ -664,8 +694,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.2.2",
|
||||
"@opentui/solid": "0.2.2",
|
||||
"@opentui/core": "0.2.6",
|
||||
"@opentui/keymap": "0.2.6",
|
||||
"@opentui/solid": "0.2.6",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -728,7 +759,7 @@
|
||||
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
|
||||
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="],
|
||||
|
||||
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
|
||||
|
||||
@@ -1202,8 +1233,6 @@
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
|
||||
|
||||
"@hono/node-ws": ["@hono/node-ws@1.3.0", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="],
|
||||
|
||||
"@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="],
|
||||
|
||||
"@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="],
|
||||
@@ -1548,6 +1577,10 @@
|
||||
|
||||
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
|
||||
|
||||
"@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"],
|
||||
|
||||
"@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
||||
|
||||
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
|
||||
@@ -1590,21 +1623,23 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.2.2", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.2", "@opentui/core-darwin-x64": "0.2.2", "@opentui/core-linux-arm64": "0.2.2", "@opentui/core-linux-x64": "0.2.2", "@opentui/core-win32-arm64": "0.2.2", "@opentui/core-win32-x64": "0.2.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wxg1CD58SVrowu+WgbhZNi3UP/wWxPio2Kj2IeTjomoIE+6EXLxR8eCCxHYVuQUd9E4fknrKkY5HmiSsp6oPow=="],
|
||||
"@opentui/core": ["@opentui/core@0.2.6", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.6", "@opentui/core-darwin-x64": "0.2.6", "@opentui/core-linux-arm64": "0.2.6", "@opentui/core-linux-x64": "0.2.6", "@opentui/core-win32-arm64": "0.2.6", "@opentui/core-win32-x64": "0.2.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dBpMaWVM7wtW2/2TlGPrkPjg6gOL3MVU/5XXk+U1LDJB8L4q4NeYWVdzfAVNcEvgmuuCy/cVqdY2D4ei+e7MMg=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tY5n3ZRQx+b0kyhQJJLsyJMeZ+0w4FV37YZc/Qqv3qvOqE9kZPw/7adR77FYwWDm/7fax94mLMrR8Y5bKUkDmw=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hR5nsxNj+059utzenTCF0kealUlibON6fLuebFUCGM/5kJnqa+shIh0XbUDFm0+F47vqVUgZufBdUuieQZIbvQ=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-W/R7OnqY30FXcTG0tiP2JkQFmgtYbIte5afQ5PC12TliRoee1RqG3iCG6kY1jxW+3Vg6jge88uiSjUEDpeV2gA=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-pJ/bH4WC/mbBaakM1YdH6TVo67jhy0KPd61bCz97w0I/PJGr8fmNKvhmMt/AwyFgOQi3FYZiEKLMpGdvUcSsrQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1pzTYFEZauYuw6AGycw2TYGtAlZVGjuUtSdxH1fP51kBPS3oVWduUY2j7GKREz3SU5NulvO2Wc6HWsm3feMqwQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Pnd3kOxig8ii+/IqYheOPEgferylsQA0L6tKBnHQ9jRlCJOcu0Rv65Jepueh212vevdV9DzPURJnhejG06J6g=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ucVwUtUYeOYGVFPBLbPoxzbrPdhD0PDyKNQ2X4n1AJ9jlQX4gqBZRcXMEF8hiXDjFxsikZwef7De0ciCcWvAMg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-458Mx9tBzEPzfft8cSt5ZaIpEepoxBXBOL6AUVmDTKWaZ3uouraPcEKraGAyvOTDQp2XDI3R8c/2GdaR77FaUQ=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-MPhYdJNdxmC5Bqsq6sis/+VkjRgkEjm+bQ1Tl++NSKLuiTU32Re0ImcZlgHbe+LZtZoGMZHVSgZlkGd3oYXO2g=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-BDUrdrT1RCcVnQoHJmUut4y811jDBAEtc6GJFB4Gs265Be8SrTjVCus6p2fSQ7j9sZQ1OcjO+5+4NkheSZICDQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-19BroLfn2h0RDYfJS5o96Fc8kYCDhRBcseIXtHIkoKIsKMxx62KiDLo/byVye6rp+yQRRB7Xkd2uWqsbdiWo9w=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-SUYAzRJ9TSoD2Qt8kn6FJz6dbTrFEPVig5mScB4zFGgGQO/Bbod2/Q31vLS/IQrX+FDb67WaErD+kuMCnMPPLA=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.2.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.2", "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-ZBVfCoVAhcUGQWPAWOTdzuVldMaRkuPpCu4U1VZCqmIw9DtbCuiVr0WnDocDxKhJLbTu8bl3qEWtVCf6lTSi3w=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.6", "", { "dependencies": { "@opentui/core": "0.2.6" }, "peerDependencies": { "@opentui/react": "0.2.6", "@opentui/solid": "0.2.6", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-+6OYuedrFCKVo4ryGFNwws++2VOmPcXU3PwpY0mP47gYQY2nvQ+etWIs2Y7r5eMIqUfxVCldkKsrzcEcA4tb/A=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.2.6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.6", "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-2y225WlOGi/fCaajkxBmLyVW8Cr+OmhowHdvrYcz5w2kBD15sKbJLIYu1G9DxceirT1uIyasGy2TGzRRcVkTDg=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -2002,6 +2037,8 @@
|
||||
|
||||
"@sigstore/verify": ["@sigstore/verify@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag=="],
|
||||
|
||||
"@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
|
||||
|
||||
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
||||
|
||||
"@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="],
|
||||
@@ -2168,8 +2205,6 @@
|
||||
|
||||
"@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="],
|
||||
|
||||
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
|
||||
|
||||
"@standard-community/standard-json": ["@standard-community/standard-json@0.3.5", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@types/json-schema": "^7.0.15", "@valibot/to-json-schema": "^1.3.0", "arktype": "^2.1.20", "effect": "^3.16.8", "quansync": "^0.2.11", "sury": "^10.0.0", "typebox": "^1.0.17", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.5" }, "optionalPeers": ["@valibot/to-json-schema", "arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-to-json-schema"] }, "sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA=="],
|
||||
|
||||
"@standard-community/standard-openapi": ["@standard-community/standard-openapi@0.2.9", "", { "peerDependencies": { "@standard-community/standard-json": "^0.3.5", "@standard-schema/spec": "^1.0.0", "arktype": "^2.1.20", "effect": "^3.17.14", "openapi-types": "^12.1.3", "sury": "^10.0.0", "typebox": "^1.0.0", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-openapi": "^4" }, "optionalPeers": ["arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-openapi"] }, "sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg=="],
|
||||
@@ -3180,8 +3215,6 @@
|
||||
|
||||
"fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="],
|
||||
|
||||
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
|
||||
@@ -4656,8 +4689,6 @@
|
||||
|
||||
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
||||
|
||||
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
|
||||
|
||||
"stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
@@ -4726,8 +4757,6 @@
|
||||
|
||||
"sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="],
|
||||
|
||||
"svix": ["svix@1.92.2", "", { "dependencies": { "standardwebhooks": "1.0.0" } }, "sha512-ZmuA3UVvlnF9EgxlzmPtF7CKjQb64Z6OFlyfdDfU0sdcC7dJa+3aOYX5B9mA+RS6ch1AxBa4UP/l6KmqfGtWBQ=="],
|
||||
|
||||
"system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
|
||||
@@ -5568,6 +5597,10 @@
|
||||
|
||||
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||
|
||||
"@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
|
||||
|
||||
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
|
||||
|
||||
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||
|
||||
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||
@@ -6634,6 +6667,8 @@
|
||||
|
||||
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
|
||||
|
||||
"@opencode-ai/llm/@smithy/eventstream-codec/@smithy/types": ["@smithy/types@4.14.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg=="],
|
||||
|
||||
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
||||
|
||||
"@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||
|
||||
@@ -30,6 +30,7 @@ export const api = new sst.cloudflare.Worker("Api", {
|
||||
transform: {
|
||||
worker: (args) => {
|
||||
args.logpush = true
|
||||
if ($app.stage === "vimtor") return
|
||||
args.bindings = $resolve(args.bindings).apply((bindings) => [
|
||||
...bindings,
|
||||
{
|
||||
|
||||
+3
-3
@@ -1,5 +1,6 @@
|
||||
import { domain } from "./stage"
|
||||
import { EMAILOCTOPUS_API_KEY } from "./app"
|
||||
import { SECRET } from "./secret"
|
||||
|
||||
////////////////
|
||||
// DATABASE
|
||||
@@ -221,8 +222,6 @@ const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
||||
const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", {
|
||||
properties: { value: stripeWebhook.secret },
|
||||
})
|
||||
const INCIDENT_WEBHOOK_SIGNING_SECRET = new sst.Secret("INCIDENT_WEBHOOK_SIGNING_SECRET")
|
||||
const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL")
|
||||
|
||||
const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
|
||||
|
||||
@@ -233,6 +232,7 @@ const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
|
||||
const bucket = new sst.cloudflare.Bucket("ZenData")
|
||||
const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
|
||||
|
||||
const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL")
|
||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
||||
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||
|
||||
@@ -254,8 +254,8 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
database,
|
||||
AUTH_API_URL,
|
||||
STRIPE_WEBHOOK_SECRET,
|
||||
INCIDENT_WEBHOOK_SIGNING_SECRET,
|
||||
DISCORD_INCIDENT_WEBHOOK_URL,
|
||||
SECRET.HoneycombWebhookSecret,
|
||||
STRIPE_SECRET_KEY,
|
||||
EMAILOCTOPUS_API_KEY,
|
||||
AWS_SES_ACCESS_KEY_ID,
|
||||
|
||||
+169
-285
@@ -1,318 +1,202 @@
|
||||
const displayName = (s: string) =>
|
||||
s
|
||||
.split("-")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ")
|
||||
.replace(/(?<=\d) (?=\d)/g, ".")
|
||||
import { SECRET } from "./secret"
|
||||
import { domain } from "./stage"
|
||||
|
||||
const resourceName = (s: string) => displayName(s).replace(/[^a-zA-Z0-9]/g, "")
|
||||
|
||||
const varSpec = (label: string, name: string) =>
|
||||
$jsonStringify({
|
||||
content: [
|
||||
{
|
||||
content: [
|
||||
{
|
||||
attrs: {
|
||||
name,
|
||||
label,
|
||||
missing: false,
|
||||
},
|
||||
type: "varSpec",
|
||||
},
|
||||
],
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
type: "doc",
|
||||
})
|
||||
|
||||
const fields = {
|
||||
model: incident.getAlertAttributeOutput({ name: "Model" }),
|
||||
product: incident.getAlertAttributeOutput({ name: "Product" }),
|
||||
}
|
||||
|
||||
const alertSource = new incident.AlertSource("HoneycombAlertSource", {
|
||||
name: $app.stage === "production" ? "Honeycomb" : `Honeycomb (${$app.stage})`,
|
||||
sourceType: "honeycomb",
|
||||
template: {
|
||||
title: {
|
||||
literal: varSpec("Payload -> Title", "title"),
|
||||
},
|
||||
description: {
|
||||
literal: varSpec("Payload -> Description", "description"),
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
alertAttributeId: fields.model.id,
|
||||
binding: {
|
||||
value: {
|
||||
reference: 'expressions["model"]',
|
||||
},
|
||||
mergeStrategy: "first_wins",
|
||||
},
|
||||
},
|
||||
{
|
||||
alertAttributeId: fields.product.id,
|
||||
binding: {
|
||||
value: {
|
||||
reference: 'expressions["product"]',
|
||||
},
|
||||
mergeStrategy: "first_wins",
|
||||
},
|
||||
},
|
||||
],
|
||||
expressions: [
|
||||
{
|
||||
label: "Model",
|
||||
operations: [
|
||||
{
|
||||
operationType: "parse",
|
||||
parse: {
|
||||
returns: {
|
||||
array: false,
|
||||
type: fields.model.type,
|
||||
},
|
||||
source: "$['model']",
|
||||
},
|
||||
},
|
||||
],
|
||||
reference: "model",
|
||||
rootReference: "payload",
|
||||
},
|
||||
{
|
||||
label: "Product",
|
||||
operations: [
|
||||
{
|
||||
operationType: "parse",
|
||||
parse: {
|
||||
returns: {
|
||||
array: false,
|
||||
type: fields.product.type,
|
||||
},
|
||||
source: "$['product']",
|
||||
},
|
||||
},
|
||||
],
|
||||
reference: "product",
|
||||
rootReference: "payload",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const webhookRecipient = new honeycomb.WebhookRecipient(`IncidentWebhook`, {
|
||||
name: $app.stage === "production" ? "Incident.io" : `Incident.io (${$app.stage})`,
|
||||
url: alertSource.alertEventsUrl,
|
||||
secret: alertSource.secretToken,
|
||||
const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", {
|
||||
name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`,
|
||||
url: `https://${domain}/honeycomb/webhook`,
|
||||
secret: SECRET.HoneycombWebhookSecret.result,
|
||||
templates: [
|
||||
{
|
||||
type: "trigger",
|
||||
body: $jsonStringify({
|
||||
title: "{{ .Name }}",
|
||||
description: "{{ .Description }}",
|
||||
status: "{{ .Alert.Status }}",
|
||||
deduplication_key: "{{ .Alert.InstanceID }}",
|
||||
source_url: "{{ .Result.URL }}",
|
||||
model: "{{ .Vars.model }}",
|
||||
product: "{{ .Vars.product }}",
|
||||
}),
|
||||
body: `{
|
||||
"url": {{ .Result.URL | quote }},
|
||||
"type": {{ .Vars.type | quote }},
|
||||
"name": {{ .Name | quote }},
|
||||
"status": {{ .Alert.Status | quote }},
|
||||
"isTest": {{ .Alert.IsTest }},
|
||||
"groups": {{ .Result.GroupsTriggered | toJson }}
|
||||
}`,
|
||||
},
|
||||
],
|
||||
variables: [
|
||||
{
|
||||
name: "model",
|
||||
},
|
||||
{
|
||||
name: "product",
|
||||
name: "type",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
new incident.AlertRoute("HoneycombAlertRoute", {
|
||||
name: $app.stage === "production" ? "Honeycomb" : `Honeycomb (${$app.stage})`,
|
||||
enabled: true,
|
||||
isPrivate: false,
|
||||
alertSources: [
|
||||
{
|
||||
alertSourceId: alertSource.id,
|
||||
conditionGroups: [
|
||||
{
|
||||
conditions: [
|
||||
{
|
||||
subject: "alert.title",
|
||||
operation: "is_set",
|
||||
paramBindings: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
conditionGroups: [
|
||||
{
|
||||
conditions: [
|
||||
{
|
||||
subject: "alert.title",
|
||||
operation: "is_set",
|
||||
paramBindings: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
expressions: [],
|
||||
escalationConfig: {
|
||||
autoCancelEscalations: true,
|
||||
escalationTargets: [],
|
||||
},
|
||||
incidentConfig: {
|
||||
autoDeclineEnabled: true,
|
||||
enabled: true,
|
||||
conditionGroups: [],
|
||||
deferTimeSeconds: 0,
|
||||
groupingKeys: [
|
||||
const modelHttpErrorsQuery = (product: "go" | "zen") => {
|
||||
const filters = [
|
||||
{ column: "model", op: "exists" },
|
||||
{ column: "event_type", op: "=", value: "completions" },
|
||||
{ column: "user_agent", op: "contains", value: "opencode" },
|
||||
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
|
||||
]
|
||||
|
||||
return honeycomb.getQuerySpecificationOutput({
|
||||
breakdowns: ["model"],
|
||||
calculatedFields: [
|
||||
{
|
||||
reference: $interpolate`alert.attributes.${fields.model.id}`,
|
||||
},
|
||||
{
|
||||
reference: $interpolate`alert.attributes.${fields.product.id}`,
|
||||
name: "is_failed_http_status",
|
||||
expression:
|
||||
product === "go"
|
||||
? `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401")), NOT(EQUALS($status, "429"))), 1, 0)`
|
||||
: `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`,
|
||||
},
|
||||
],
|
||||
groupingWindowSeconds: 3600,
|
||||
},
|
||||
incidentTemplate: {
|
||||
name: {
|
||||
value: {
|
||||
literal: varSpec("Alert -> Title", "alert.title"),
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
value: {
|
||||
literal: varSpec("Alert -> Description", "alert.description"),
|
||||
},
|
||||
},
|
||||
startInTriage: {
|
||||
value: {
|
||||
literal: "true",
|
||||
},
|
||||
},
|
||||
severity: {
|
||||
mergeStrategy: "first-wins",
|
||||
},
|
||||
incidentMode: {
|
||||
value: {
|
||||
literal: $app.stage === "production" ? "standard" : "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
type Product = "go" | "zen"
|
||||
|
||||
type Trigger = (opts: { model: string; product: Product }) => {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
json: honeycomb.GetQuerySpecificationOutputArgs
|
||||
threshold: { op: ">=" | "<="; value: number }
|
||||
calculations: [
|
||||
{ op: "COUNT", name: "TOTAL", filterCombination: "AND", filters },
|
||||
{ op: "SUM", name: "FAILED", column: "is_failed_http_status", filterCombination: "AND", filters },
|
||||
],
|
||||
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 100), DIV($FAILED, $TOTAL), 0)" }],
|
||||
timeRange: 900,
|
||||
}).json
|
||||
}
|
||||
|
||||
type Model = { id: string; products: Product[]; triggers: Trigger[] }
|
||||
const providerHttpErrorsQuery = (product: "go" | "zen") => {
|
||||
const filters = [
|
||||
{ column: "provider", op: "exists" },
|
||||
{ column: "user_agent", op: "contains", value: "opencode" },
|
||||
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
|
||||
]
|
||||
|
||||
const httpErrors: Trigger = ({ model, product }) => ({
|
||||
id: "increased-http-errors",
|
||||
title: `Increased HTTP Errors for ${displayName(model)} on ${displayName(product)}`,
|
||||
description: `Detected increased rate of HTTP errors for ${displayName(model)} on OpenCode ${displayName(product)}`,
|
||||
json: {
|
||||
return honeycomb.getQuerySpecificationOutput({
|
||||
breakdowns: ["provider"],
|
||||
calculatedFields: [
|
||||
{
|
||||
name: "is_success_http_status",
|
||||
expression: `IF(AND(GTE($status, "200"), LT($status, "400")), 1, 0)`,
|
||||
},
|
||||
{
|
||||
name: "is_failed_provider_http_status",
|
||||
expression: `IF(GT($llm.error.code, "400"), 1, 0)`,
|
||||
},
|
||||
],
|
||||
calculations: [
|
||||
{
|
||||
op: "COUNT",
|
||||
name: "TOTAL",
|
||||
op: "SUM",
|
||||
name: "SUCCESS",
|
||||
column: "is_success_http_status",
|
||||
filterCombination: "AND",
|
||||
filters: [
|
||||
{ column: "model", op: "=", value: model },
|
||||
{ column: "event_type", op: "=", value: "completions" },
|
||||
{ column: "user_agent", op: "contains", value: "opencode" },
|
||||
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
|
||||
],
|
||||
filters: [...filters, { column: "event_type", op: "=", value: "completions" }],
|
||||
},
|
||||
{
|
||||
op: "COUNT",
|
||||
op: "SUM",
|
||||
name: "FAILED",
|
||||
column: "is_failed_provider_http_status",
|
||||
filterCombination: "AND",
|
||||
filters: [
|
||||
{ column: "model", op: "=", value: model },
|
||||
{ column: "event_type", op: "=", value: "completions" },
|
||||
{ column: "user_agent", op: "contains", value: "opencode" },
|
||||
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
|
||||
{ column: "status", op: ">=", value: "400" },
|
||||
{ column: "status", op: "!=", value: "401" },
|
||||
],
|
||||
filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }],
|
||||
},
|
||||
],
|
||||
formulas: [{ name: "ERROR", expression: "$FAILED / $TOTAL" }],
|
||||
formulas: [
|
||||
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 50), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
|
||||
],
|
||||
timeRange: 900,
|
||||
},
|
||||
threshold: { op: ">=", value: 0.8 },
|
||||
}).json
|
||||
}
|
||||
|
||||
const description = "Managed by SST (Don't edit in Honeycomb UI)"
|
||||
|
||||
new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
|
||||
name: "Increased Model HTTP Errors [Go]",
|
||||
description,
|
||||
queryJson: modelHttpErrorsQuery("go"),
|
||||
alertType: "on_change",
|
||||
frequency: 300,
|
||||
thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }],
|
||||
recipients: [
|
||||
{
|
||||
id: webhookRecipient.id,
|
||||
notificationDetails: [
|
||||
{
|
||||
variables: [{ name: "type", value: "model_http_errors" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const models: Model[] = [
|
||||
{ id: "kimi-k2.6", products: ["go", "zen"], triggers: [httpErrors] },
|
||||
{ id: "kimi-k2.5", products: ["go", "zen"], triggers: [httpErrors] },
|
||||
{ id: "deepseek-v4-flash", products: ["go", "zen"], triggers: [httpErrors] },
|
||||
{ id: "deepseek-v4-pro", products: ["go", "zen"], triggers: [httpErrors] },
|
||||
{ id: "glm-5.1", products: ["go", "zen"], triggers: [httpErrors] },
|
||||
// { id: "glm-5", products: ["go"], triggers: [httpErrors] },
|
||||
{ id: "qwen3.6-plus", products: ["go", "zen"], triggers: [httpErrors] },
|
||||
{ id: "qwen3.5-plus", products: ["go"], triggers: [httpErrors] },
|
||||
{ id: "minimax-m2.7", products: ["go", "zen"], triggers: [httpErrors] },
|
||||
// { id: "minimax-m2.5", products: ["go", "zen"], triggers: [httpErrors] },
|
||||
{ id: "mimo-v2.5-pro", products: ["go"], triggers: [httpErrors] },
|
||||
// { id: "mimo-v2.5", products: ["go"], triggers: [httpErrors] },
|
||||
// { id: "mimo-v2-omni", products: ["go"], triggers: [httpErrors] },
|
||||
// { id: "mimo-v2-pro", products: ["go"], triggers: [httpErrors] },
|
||||
{ id: "claude-opus-4-7", products: ["zen"], triggers: [httpErrors] },
|
||||
// { id: "claude-opus-4-6", products: ["zen"], triggers: [httpErrors] },
|
||||
// { id: "claude-sonnet-4-6", products: ["zen"], triggers: [httpErrors] },
|
||||
{ id: "gpt-5.5", products: ["zen"], triggers: [httpErrors] },
|
||||
{ id: "big-pickle", products: ["zen"], triggers: [httpErrors] },
|
||||
// { id: "minimax-m2.5-free", products: ["zen"], triggers: [httpErrors] },
|
||||
// { id: "hy3-preview-free", products: ["zen"], triggers: [httpErrors] },
|
||||
// { id: "nemotron-3-super-free", products: ["zen"], triggers: [httpErrors] },
|
||||
// { id: "trinity-large-preview-free", products: ["zen"], triggers: [httpErrors] },
|
||||
// { id: "ling-2.6-flash-free", products: ["zen"], triggers: [httpErrors] },
|
||||
]
|
||||
new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
|
||||
name: "Increased Model HTTP Errors [Zen]",
|
||||
description,
|
||||
queryJson: modelHttpErrorsQuery("zen"),
|
||||
alertType: "on_change",
|
||||
frequency: 300,
|
||||
thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }],
|
||||
recipients: [
|
||||
{
|
||||
id: webhookRecipient.id,
|
||||
notificationDetails: [
|
||||
{
|
||||
variables: [{ name: "type", value: "model_http_errors" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if ($app.stage !== "production") {
|
||||
models.splice(1)
|
||||
}
|
||||
new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", {
|
||||
name: "Increased Provider HTTP Errors [Go]",
|
||||
description,
|
||||
queryJson: providerHttpErrorsQuery("go"),
|
||||
alertType: "on_change",
|
||||
frequency: 300,
|
||||
thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }],
|
||||
recipients: [
|
||||
{
|
||||
id: webhookRecipient.id,
|
||||
notificationDetails: [
|
||||
{
|
||||
variables: [{ name: "type", value: "provider_http_errors" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
for (const model of models) {
|
||||
for (const product of model.products) {
|
||||
for (const trigger of model.triggers) {
|
||||
const spec = trigger({ model: model.id, product })
|
||||
new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", {
|
||||
name: "Increased Provider HTTP Errors [Zen]",
|
||||
description,
|
||||
queryJson: providerHttpErrorsQuery("zen"),
|
||||
alertType: "on_change",
|
||||
frequency: 300,
|
||||
thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }],
|
||||
recipients: [
|
||||
{
|
||||
id: webhookRecipient.id,
|
||||
notificationDetails: [
|
||||
{
|
||||
variables: [{ name: "type", value: "provider_http_errors" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
new honeycomb.Trigger(resourceName(`${spec.id}-${product}-${model.id}`), {
|
||||
name: spec.title,
|
||||
description: spec.description,
|
||||
queryJson: honeycomb.getQuerySpecificationOutput(spec.json).json,
|
||||
alertType: "on_change",
|
||||
frequency: 300,
|
||||
thresholds: [{ ...spec.threshold, exceededLimit: 1 }],
|
||||
recipients: [
|
||||
{
|
||||
id: webhookRecipient.id,
|
||||
notificationDetails: [
|
||||
{
|
||||
variables: [
|
||||
{ name: "model", value: model.id },
|
||||
{ name: "product", value: product },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
new honeycomb.Trigger("IncreasedFreeTierRequests", {
|
||||
name: "Increased Free Tier Requests",
|
||||
description,
|
||||
queryJson: honeycomb.getQuerySpecificationOutput({
|
||||
calculations: [{ op: "COUNT" }],
|
||||
filters: [
|
||||
{ column: "event_type", op: "=", value: "completions" },
|
||||
{ column: "user_agent", op: "contains", value: "opencode" },
|
||||
{ column: "isFreeTier", op: "=", value: "true" },
|
||||
],
|
||||
timeRange: 3600,
|
||||
}).json,
|
||||
alertType: "on_change",
|
||||
frequency: 900,
|
||||
thresholds: [{ op: ">=", value: 50, exceededLimit: 1 }],
|
||||
baselineDetails: [{ type: "percentage", offsetMinutes: 1440 }],
|
||||
recipients: [
|
||||
{
|
||||
id: webhookRecipient.id,
|
||||
notificationDetails: [
|
||||
{
|
||||
variables: [{ name: "type", value: "custom" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
sst.Linkable.wrap(random.RandomPassword, (resource) => ({
|
||||
properties: {
|
||||
value: resource.result,
|
||||
},
|
||||
}))
|
||||
|
||||
export const SECRET = {
|
||||
R2AccessKey: new sst.Secret("R2AccessKey", "unknown"),
|
||||
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
|
||||
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-ynZFX8eCamzBuVpauYLbju/Cqbt2260JNumMUj79PKA=",
|
||||
"aarch64-linux": "sha256-JCu7JZkdAAHTufWEJRV1gJErKvHFirq+qmVNIRPZ/0w=",
|
||||
"aarch64-darwin": "sha256-9Dkt/poYBpLdtqA6L9pLe6GS435zFGb5rOYWE5rEnjA=",
|
||||
"x86_64-darwin": "sha256-Nd5j28gAcM7+0ETBchjk9VojViHy3N/z2MkdU42YuCg="
|
||||
"x86_64-linux": "sha256-baGxh+hk/rPhg0xI/OdMDz6dPwncgercYNBdTPnLX9o=",
|
||||
"aarch64-linux": "sha256-VTWKq679B3Q4ZnAoQzC4VSCYA09wWecNJ+JajvjNB1U=",
|
||||
"aarch64-darwin": "sha256-orf2zIBMTiiQrt/6qCzE+o0oKhv6u8zXF9DH1Bo3lbo=",
|
||||
"x86_64-darwin": "sha256-1MZC1fadRoY4lhkmjlcUQTLYH9Q8pDI1bxd5f94f1xU="
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -13,6 +13,7 @@
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
|
||||
"prepare": "husky",
|
||||
"random": "echo 'Random script'",
|
||||
@@ -34,8 +35,9 @@
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.2.2",
|
||||
"@opentui/solid": "0.2.2",
|
||||
"@opentui/core": "0.2.6",
|
||||
"@opentui/keymap": "0.2.6",
|
||||
"@opentui/solid": "0.2.6",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
@@ -131,6 +133,7 @@
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -231,6 +231,7 @@ export function createChildStoreManager(input: {
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
})
|
||||
children[key] = child
|
||||
disposers.set(key, dispose)
|
||||
|
||||
@@ -81,6 +81,7 @@ const baseState = (input: Partial<State> = {}) =>
|
||||
limit: 10,
|
||||
message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
...input,
|
||||
}) as State
|
||||
|
||||
|
||||
@@ -211,6 +211,12 @@ export function applyDirectoryEvent(input: {
|
||||
const result = Binary.search(messages, props.messageID, (m) => m.id)
|
||||
if (result.found) messages.splice(result.index, 1)
|
||||
}
|
||||
const parts = draft.part[props.messageID]
|
||||
if (parts) {
|
||||
for (const part of parts) {
|
||||
delete draft.part_text_accum_delta[part.id]
|
||||
}
|
||||
}
|
||||
delete draft.part[props.messageID]
|
||||
}),
|
||||
)
|
||||
@@ -219,6 +225,11 @@ export function applyDirectoryEvent(input: {
|
||||
case "message.part.updated": {
|
||||
const part = (event.properties as { part: Part }).part
|
||||
if (SKIP_PARTS.has(part.type)) break
|
||||
input.setStore(
|
||||
produce((draft) => {
|
||||
delete draft.part_text_accum_delta[part.id]
|
||||
}),
|
||||
)
|
||||
const parts = input.store.part[part.messageID]
|
||||
if (!parts) {
|
||||
input.setStore("part", part.messageID, [part])
|
||||
@@ -240,6 +251,11 @@ export function applyDirectoryEvent(input: {
|
||||
}
|
||||
case "message.part.removed": {
|
||||
const props = event.properties as { messageID: string; partID: string }
|
||||
input.setStore(
|
||||
produce((draft) => {
|
||||
delete draft.part_text_accum_delta[props.partID]
|
||||
}),
|
||||
)
|
||||
const parts = input.store.part[props.messageID]
|
||||
if (!parts) break
|
||||
const result = Binary.search(parts, props.partID, (p) => p.id)
|
||||
@@ -263,6 +279,7 @@ export function applyDirectoryEvent(input: {
|
||||
if (!parts) break
|
||||
const result = Binary.search(parts, props.partID, (p) => p.id)
|
||||
if (!result.found) break
|
||||
input.setStore("part_text_accum_delta", props.partID, (existing) => (existing ?? "") + props.delta)
|
||||
input.setStore(
|
||||
"part",
|
||||
props.messageID,
|
||||
|
||||
@@ -39,6 +39,7 @@ describe("app session cache", () => {
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
} = {
|
||||
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
||||
session_diff: { ses_1: [] },
|
||||
@@ -47,12 +48,14 @@ describe("app session cache", () => {
|
||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||
permission: { ses_1: [] as PermissionRequest[] },
|
||||
question: { ses_1: [] as QuestionRequest[] },
|
||||
part_text_accum_delta: { prt_1: "streamed text" },
|
||||
}
|
||||
|
||||
dropSessionCaches(store, ["ses_1"])
|
||||
|
||||
expect(store.message.ses_1).toBeUndefined()
|
||||
expect(store.part.msg_1).toBeUndefined()
|
||||
expect(store.part_text_accum_delta.prt_1).toBeUndefined()
|
||||
expect(store.todo.ses_1).toBeUndefined()
|
||||
expect(store.session_diff.ses_1).toBeUndefined()
|
||||
expect(store.session_status.ses_1).toBeUndefined()
|
||||
@@ -70,6 +73,7 @@ describe("app session cache", () => {
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
} = {
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
@@ -78,6 +82,7 @@ describe("app session cache", () => {
|
||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||
permission: {},
|
||||
question: {},
|
||||
part_text_accum_delta: {},
|
||||
}
|
||||
|
||||
dropSessionCaches(store, ["ses_1"])
|
||||
|
||||
@@ -18,6 +18,7 @@ type SessionCache = {
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) {
|
||||
@@ -27,6 +28,9 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
|
||||
for (const key of Object.keys(store.part)) {
|
||||
const parts = store.part[key]
|
||||
if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue
|
||||
for (const part of parts) {
|
||||
delete store.part_text_accum_delta[part.id]
|
||||
}
|
||||
delete store.part[key]
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ export type State = {
|
||||
part: {
|
||||
[messageID: string]: Part[]
|
||||
}
|
||||
part_text_accum_delta: {
|
||||
[partID: string]: string
|
||||
}
|
||||
}
|
||||
|
||||
export type VcsCache = {
|
||||
|
||||
@@ -26,7 +26,12 @@ export function getProjectAvatarSource(id?: string, icon?: { color?: string; url
|
||||
return icon?.url
|
||||
}
|
||||
|
||||
export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
|
||||
export const ProjectIcon = (props: {
|
||||
project: LocalProject
|
||||
class?: string
|
||||
notify?: boolean
|
||||
working?: boolean
|
||||
}): JSX.Element => {
|
||||
const globalSync = useGlobalSync()
|
||||
const notification = useNotification()
|
||||
const permission = usePermission()
|
||||
@@ -65,6 +70,11 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={props.working}>
|
||||
<div class="absolute bottom-px right-px size-3 rounded-full bg-background-base z-10 flex items-center justify-center">
|
||||
<Spinner class="size-[9px]" />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ const ProjectTile = (props: {
|
||||
sidebarHovering: Accessor<boolean>
|
||||
selected: Accessor<boolean>
|
||||
active: Accessor<boolean>
|
||||
isWorking: Accessor<boolean>
|
||||
overlay: Accessor<boolean>
|
||||
suppressHover: Accessor<boolean>
|
||||
dirs: Accessor<string[]>
|
||||
@@ -143,7 +144,7 @@ const ProjectTile = (props: {
|
||||
}}
|
||||
onBlur={() => props.setOpen(false)}
|
||||
>
|
||||
<ProjectIcon project={props.project} notify />
|
||||
<ProjectIcon project={props.project} notify working={props.isWorking()} />
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
@@ -301,6 +302,12 @@ export const SortableProject = (props: {
|
||||
}
|
||||
|
||||
const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
|
||||
const isWorking = createMemo(() =>
|
||||
dirs().some((directory) => {
|
||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
||||
return Object.values(store.session_status).some((status) => status?.type === "busy" || status?.type === "retry")
|
||||
}),
|
||||
)
|
||||
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
|
||||
const workspaceSessions = (directory: string) => {
|
||||
const [data] = globalSync.child(directory, { bootstrap: false })
|
||||
@@ -313,6 +320,7 @@ export const SortableProject = (props: {
|
||||
sidebarHovering={props.ctx.sidebarHovering}
|
||||
selected={selected}
|
||||
active={active}
|
||||
isWorking={isWorking}
|
||||
overlay={overlay}
|
||||
suppressHover={() => state.suppressHover}
|
||||
dirs={dirs}
|
||||
|
||||
@@ -28,6 +28,12 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||
|
||||
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
|
||||
return typeof value.file === "string"
|
||||
}
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
canReview: () => boolean
|
||||
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
|
||||
@@ -70,7 +76,8 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
||||
|
||||
const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
|
||||
const diffs = createMemo(() => props.diffs().filter(renderDiff))
|
||||
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||
const kinds = createMemo(() => {
|
||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||
if (!a) return b
|
||||
@@ -81,7 +88,7 @@ export function SessionSidePanel(props: {
|
||||
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
|
||||
|
||||
const out = new Map<string, "add" | "del" | "mix">()
|
||||
for (const diff of props.diffs()) {
|
||||
for (const diff of diffs()) {
|
||||
const file = normalize(diff.file)
|
||||
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -31,11 +31,11 @@
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "0.3.0",
|
||||
"solid-stripe": "0.8.1",
|
||||
"svix": "1.92.2",
|
||||
"vite": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@webgpu/types": "0.1.54",
|
||||
"typescript": "catalog:",
|
||||
|
||||
@@ -355,8 +355,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "مفتاح API مفقود.",
|
||||
"zen.api.error.invalidApiKey": "مفتاح API غير صالح.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "تم تجاوز حصة الاشتراك. أعد المحاولة خلال {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"تم تجاوز حصة الاشتراك. يمكنك الاستمرار في استخدام النماذج المجانية.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"تم الوصول إلى حد الاستخدام لمدة 5 ساعات. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"تم الوصول إلى حد الاستخدام الأسبوعي. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"تم الوصول إلى حد الاستخدام الشهري. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "لا توجد طريقة دفع. أضف طريقة دفع هنا: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "رصيد غير كاف. إدارة فواتيرك هنا: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -363,8 +363,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "Chave de API ausente.",
|
||||
"zen.api.error.invalidApiKey": "Chave de API inválida.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Cota de assinatura excedida. Tente novamente em {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Cota de assinatura excedida. Você pode continuar usando modelos gratuitos.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Limite de uso de 5 horas atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Limite de uso semanal atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Limite de uso mensal atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Nenhuma forma de pagamento. Adicione uma forma de pagamento aqui: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Saldo insuficiente. Gerencie seu faturamento aqui: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -359,8 +359,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "Manglende API-nøgle.",
|
||||
"zen.api.error.invalidApiKey": "Ugyldig API-nøgle.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igen om {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Abonnementskvote overskredet. Du kan fortsætte med at bruge gratis modeller.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Forbrugsgrænsen for 5 timer er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Ugentlig forbrugsgrænse er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Månedlig forbrugsgrænse er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Ingen betalingsmetode. Tilføj en betalingsmetode her: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Utilstrækkelig saldo. Administrer din fakturering her: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -362,8 +362,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "Fehlender API-Key.",
|
||||
"zen.api.error.invalidApiKey": "Ungültiger API-Key.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Abonnement-Quote überschritten. Erneuter Versuch in {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Abonnement-Quote überschritten. Du kannst weiterhin kostenlose Modelle nutzen.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"5-Stunden-Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Wöchentliches Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Monatliches Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Keine Zahlungsmethode. Füge hier eine Zahlungsmethode hinzu: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Unzureichendes Guthaben. Verwalte deine Abrechnung hier: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -355,8 +355,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "Missing API key.",
|
||||
"zen.api.error.invalidApiKey": "Invalid API key.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Subscription quota exceeded. Retry in {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Subscription quota exceeded. You can continue using free models.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"5-hour usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Weekly usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Monthly usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "No payment method. Add a payment method here: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Insufficient balance. Manage your billing here: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -363,8 +363,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "Falta la clave API.",
|
||||
"zen.api.error.invalidApiKey": "Clave API inválida.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Cuota de suscripción excedida. Reintenta en {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Cuota de suscripción excedida. Puedes continuar usando modelos gratuitos.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Límite de uso de 5 horas alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Límite de uso semanal alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Límite de uso mensual alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Sin método de pago. Añade un método de pago aquí: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Saldo insuficiente. Gestiona tu facturación aquí: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -363,8 +363,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "Clé API manquante.",
|
||||
"zen.api.error.invalidApiKey": "Clé API invalide.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Quota d'abonnement dépassé. Réessayez dans {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Quota d'abonnement dépassé. Vous pouvez continuer à utiliser les modèles gratuits.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Limite d'utilisation sur 5 heures atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Limite d'utilisation hebdomadaire atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Limite d'utilisation mensuelle atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Aucune méthode de paiement. Ajoutez une méthode de paiement ici : {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Solde insuffisant. Gérez votre facturation ici : {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -359,8 +359,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "Chiave API mancante.",
|
||||
"zen.api.error.invalidApiKey": "Chiave API non valida.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Quota dell'abbonamento superata. Riprova tra {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Quota dell'abbonamento superata. Puoi continuare a utilizzare modelli gratuiti.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Limite di utilizzo di 5 ore raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Limite di utilizzo settimanale raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Limite di utilizzo mensile raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Nessun metodo di pagamento. Aggiungi un metodo di pagamento qui: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Saldo insufficiente. Gestisci la tua fatturazione qui: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -360,8 +360,12 @@ export const dict = {
|
||||
"zen.api.error.invalidApiKey": "無効なAPIキーです。",
|
||||
"zen.api.error.subscriptionQuotaExceeded":
|
||||
"サブスクリプションの制限を超えました。{{retryIn}} 後に再試行してください。",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"サブスクリプションの制限を超えました。無料モデルは引き続きご利用いただけます。",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"5時間の利用上限に達しました。{{retryIn}} 後にリセットされます。今すぐこのモデルの利用を続けるには、利用可能な残高からの利用を有効化してください: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"週間の利用上限に達しました。{{retryIn}} 後にリセットされます。今すぐこのモデルの利用を続けるには、利用可能な残高からの利用を有効化してください: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"月間の利用上限に達しました。{{retryIn}} 後にリセットされます。今すぐこのモデルの利用を続けるには、利用可能な残高からの利用を有効化してください: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "お支払い方法がありません。こちらからお支払い方法を追加してください: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "残高が不足しています。こちらから請求を管理してください: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -354,8 +354,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "API 키가 누락되었습니다.",
|
||||
"zen.api.error.invalidApiKey": "유효하지 않은 API 키입니다.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "구독 할당량을 초과했습니다. {{retryIn}} 후 다시 시도해 주세요.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"구독 할당량을 초과했습니다. 무료 모델은 계속 사용할 수 있습니다.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"5시간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"주간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"월간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "결제 수단이 없습니다. 결제 수단을 추가하세요: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "잔액이 부족합니다. 결제 관리를 여기서 하세요: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -360,8 +360,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "Mangler API-nøkkel.",
|
||||
"zen.api.error.invalidApiKey": "Ugyldig API-nøkkel.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igjen om {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Abonnementskvote overskredet. Du kan fortsette å bruke gratis modeller.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"5-timers bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Ukentlig bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Månedlig bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Ingen betalingsmetode. Legg til en betalingsmetode her: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Utilstrekkelig saldo. Administrer faktureringen din her: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -361,8 +361,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "Brak klucza API.",
|
||||
"zen.api.error.invalidApiKey": "Nieprawidłowy klucz API.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Przekroczono limit subskrypcji. Spróbuj ponownie za {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Przekroczono limit subskrypcji. Możesz kontynuować korzystanie z darmowych modeli.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Osiągnięto 5-godzinny limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Osiągnięto tygodniowy limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Osiągnięto miesięczny limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Brak metody płatności. Dodaj metodę płatności tutaj: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Niewystarczające saldo. Zarządzaj swoimi płatnościami tutaj: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -365,8 +365,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "Отсутствует API ключ.",
|
||||
"zen.api.error.invalidApiKey": "Неверный API ключ.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Квота подписки превышена. Повторите попытку через {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Квота подписки превышена. Вы можете продолжить использовать бесплатные модели.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Достигнут лимит использования за 5 часов. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Достигнут недельный лимит использования. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Достигнут месячный лимит использования. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Нет способа оплаты. Добавьте способ оплаты здесь: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Недостаточно средств. Управляйте оплатой здесь: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -356,8 +356,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "ไม่มี API key",
|
||||
"zen.api.error.invalidApiKey": "API key ไม่ถูกต้อง",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "โควต้าการสมัครสมาชิกเกินขีดจำกัด ลองใหม่ในอีก {{retryIn}}",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"โควต้าการสมัครสมาชิกเกินขีดจำกัด คุณสามารถดำเนินการต่อโดยใช้โมเดลฟรี",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"ถึงขีดจำกัดการใช้งานในรอบ 5 ชั่วโมงแล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"ถึงขีดจำกัดการใช้งานรายสัปดาห์แล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"ถึงขีดจำกัดการใช้งานรายเดือนแล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "ไม่มีวิธีการชำระเงิน เพิ่มวิธีการชำระเงินที่นี่: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "ยอดเงินคงเหลือไม่เพียงพอ จัดการการเรียกเก็บเงินของคุณที่นี่: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -363,8 +363,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "API anahtarı eksik.",
|
||||
"zen.api.error.invalidApiKey": "Geçersiz API anahtarı.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Abonelik kotası aşıldı. {{retryIn}} içinde tekrar deneyin.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Abonelik kotası aşıldı. Ücretsiz modelleri kullanmaya devam edebilirsiniz.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"5 saatlik kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Haftalık kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Aylık kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Ödeme yöntemi bulunamadı. Buradan bir ödeme yöntemi ekleyin: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Yetersiz bakiye. Faturalandırmanızı buradan yönetin: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -343,7 +343,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "缺少 API 密钥。",
|
||||
"zen.api.error.invalidApiKey": "无效的 API 密钥。",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "超出订阅配额。请在 {{retryIn}} 后重试。",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels": "超出订阅配额。您可以继续使用免费模型。",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"已达到 5 小时使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"已达到每周使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"已达到每月使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "没有付款方式。请在此处添加付款方式:{{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "余额不足。请在此处管理您的计费:{{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -343,7 +343,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "缺少 API 金鑰。",
|
||||
"zen.api.error.invalidApiKey": "無效的 API 金鑰。",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "超出訂閱配額。請在 {{retryIn}} 後重試。",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels": "超出訂閱配額。你可以繼續使用免費模型。",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"已達 5 小時使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"已達每週使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"已達每月使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "無付款方式。請在此處新增付款方式:{{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "餘額不足。請在此處管理你的帳務:{{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { json } from "@solidjs/router"
|
||||
import { Database } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||
|
||||
export async function GET(_evt: APIEvent) {
|
||||
return json({
|
||||
data: await Database.use(async (tx) => {
|
||||
const result = await tx.$count(UserTable)
|
||||
return result
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { z } from "zod"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
|
||||
|
||||
const DISCORD_ALERT_ROLE_ID = "1501447160175136838"
|
||||
|
||||
const basePayload = z.object({
|
||||
name: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
isTest: z.boolean().optional(),
|
||||
url: z.string(),
|
||||
})
|
||||
|
||||
const groups = z.object({ group: z.object({ key: z.string(), value: z.string() }).array() }).array()
|
||||
|
||||
const honeycombWebhookPayload = z.discriminatedUnion("type", [
|
||||
basePayload.extend({
|
||||
type: z.literal("model_http_errors"),
|
||||
groups,
|
||||
}),
|
||||
basePayload.extend({
|
||||
type: z.literal("provider_http_errors"),
|
||||
groups,
|
||||
}),
|
||||
basePayload.extend({
|
||||
type: z.literal("custom"),
|
||||
}),
|
||||
])
|
||||
|
||||
const postDiscordMessage = async (payload: z.infer<typeof honeycombWebhookPayload>) => {
|
||||
const group =
|
||||
payload.type === "model_http_errors" ? "model" : payload.type === "provider_http_errors" ? "provider" : undefined
|
||||
const names = payload.type === "custom" ? [] : payload.groups.flatMap((item) => item.group.map((g) => g.value))
|
||||
|
||||
const content = [
|
||||
`[**${payload.isTest ? "[TEST] " : ""}${payload.name ?? "Honeycomb alert"}**](${payload.url})`,
|
||||
group && names.length > 0 ? `Affected ${group}s:` : undefined,
|
||||
...names.map((name) => `- ${name}`),
|
||||
"",
|
||||
`<@&${DISCORD_ALERT_ROLE_ID}>`,
|
||||
]
|
||||
.filter((line) => line !== undefined)
|
||||
.join("\n")
|
||||
|
||||
return fetch(Resource.DISCORD_INCIDENT_WEBHOOK_URL.value, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
content,
|
||||
allowed_mentions: { roles: [DISCORD_ALERT_ROLE_ID] },
|
||||
flags: 4,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(input: APIEvent) {
|
||||
const token = input.request.headers.get("X-Honeycomb-Webhook-Token")
|
||||
if (!safeEqual(token ?? "", Resource.HoneycombWebhookSecret.value)) {
|
||||
console.debug("Invalid Honeycomb webhook token")
|
||||
return Response.json({ message: "invalid token" }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await input.request.json()
|
||||
console.log(body, JSON.stringify(body, null, 2))
|
||||
|
||||
const parsed = honeycombWebhookPayload.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error(parsed.error)
|
||||
return Response.json({ message: "invalid payload" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (parsed.data.status !== "TRIGGERED") {
|
||||
console.debug("Skipping resolved alert Honeycomb webhook")
|
||||
return Response.json({ message: "ignored" }, { status: 200 })
|
||||
}
|
||||
|
||||
const response = await postDiscordMessage(parsed.data)
|
||||
if (!response.ok) {
|
||||
return Response.json({ message: "discord webhook failed" }, { status: 502 })
|
||||
}
|
||||
|
||||
return Response.json({ message: "sent" }, { status: 200 })
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { Webhook } from "svix"
|
||||
|
||||
const DISCORD_INCIDENT_ROLE_ID = "1501447160175136838"
|
||||
|
||||
type Incident = {
|
||||
mode?: "test" | "standard"
|
||||
name?: string
|
||||
permalink?: string
|
||||
summary?: string
|
||||
}
|
||||
|
||||
type IncidentWebhookPayload = {
|
||||
event_type?: string
|
||||
"public_incident.incident_created_v2"?: Incident
|
||||
}
|
||||
|
||||
const verifyWebhook = async (request: Request) => {
|
||||
const body = await request.text()
|
||||
try {
|
||||
return new Webhook(Resource.INCIDENT_WEBHOOK_SIGNING_SECRET.value).verify(
|
||||
body,
|
||||
Object.fromEntries(request.headers.entries()),
|
||||
) as IncidentWebhookPayload
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const postDiscordMessage = async (incident: Incident) => {
|
||||
return fetch(Resource.DISCORD_INCIDENT_WEBHOOK_URL.value, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: [
|
||||
`**${incident.mode === "test" ? "[TEST] " : ""}${incident.name ?? "Incident has been created"}**`,
|
||||
incident.summary,
|
||||
"",
|
||||
`<@&${DISCORD_INCIDENT_ROLE_ID}>`,
|
||||
"",
|
||||
incident.permalink,
|
||||
]
|
||||
.filter((line) => line !== undefined)
|
||||
.join("\n"),
|
||||
allowed_mentions: {
|
||||
roles: [DISCORD_INCIDENT_ROLE_ID],
|
||||
},
|
||||
flags: 4,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(input: APIEvent) {
|
||||
const payload = await verifyWebhook(input.request)
|
||||
if (!payload) {
|
||||
return Response.json({ message: "invalid signature" }, { status: 401 })
|
||||
}
|
||||
|
||||
if (payload.event_type !== "public_incident.incident_created_v2") {
|
||||
return Response.json({ message: "ignored event" }, { status: 200 })
|
||||
}
|
||||
|
||||
const incident = payload["public_incident.incident_created_v2"]
|
||||
if (!incident) {
|
||||
return Response.json({ message: "missing incident" }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await postDiscordMessage(incident)
|
||||
if (!response.ok) {
|
||||
return Response.json({ message: "discord webhook failed" }, { status: 502 })
|
||||
}
|
||||
|
||||
return Response.json({ message: "sent" }, { status: 200 })
|
||||
}
|
||||
@@ -67,6 +67,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -79,6 +80,7 @@
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function UsageSection() {
|
||||
}
|
||||
|
||||
const calculateTotalOutputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
|
||||
return u.outputTokens + (u.reasoningTokens ?? 0)
|
||||
return u.outputTokens
|
||||
}
|
||||
|
||||
const goPrev = async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { handler } from "~/routes/zen/util/handler"
|
||||
import { parseOpenAiVariant } from "~/routes/zen/util/variant"
|
||||
|
||||
export function POST(input: APIEvent) {
|
||||
return handler(input, {
|
||||
@@ -7,6 +8,7 @@ export function POST(input: APIEvent) {
|
||||
modelList: "lite",
|
||||
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
|
||||
parseModel: (url: string, body: any) => body.model,
|
||||
parseVariant: (url: string, body: any) => parseOpenAiVariant(body),
|
||||
parseIsStream: (url: string, body: any) => !!body.stream,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { handler } from "~/routes/zen/util/handler"
|
||||
import { parseAnthropicVariant } from "~/routes/zen/util/variant"
|
||||
|
||||
export function POST(input: APIEvent) {
|
||||
return handler(input, {
|
||||
@@ -7,6 +8,7 @@ export function POST(input: APIEvent) {
|
||||
modelList: "lite",
|
||||
parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined,
|
||||
parseModel: (url: string, body: any) => body.model,
|
||||
parseVariant: (url: string, body: any) => parseAnthropicVariant(body),
|
||||
parseIsStream: (url: string, body: any) => !!body.stream,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,4 +13,15 @@ class LimitError extends Error {
|
||||
}
|
||||
export class RateLimitError extends LimitError {}
|
||||
export class FreeUsageLimitError extends LimitError {}
|
||||
export class SubscriptionUsageLimitError extends LimitError {}
|
||||
export class BlackUsageLimitError extends LimitError {}
|
||||
|
||||
type LimitName = "5 hour" | "weekly" | "monthly"
|
||||
export class GoUsageLimitError extends LimitError {
|
||||
workspace: string
|
||||
limitName: LimitName
|
||||
constructor(message: string, workspace: string, limitName: LimitName, retryAfter?: number) {
|
||||
super(message, retryAfter)
|
||||
this.workspace = workspace
|
||||
this.limitName = limitName
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
ModelError,
|
||||
RateLimitError,
|
||||
FreeUsageLimitError,
|
||||
SubscriptionUsageLimitError,
|
||||
GoUsageLimitError,
|
||||
BlackUsageLimitError,
|
||||
} from "./error"
|
||||
import {
|
||||
buildCostChunk,
|
||||
@@ -70,6 +71,7 @@ export async function handler(
|
||||
modelList: "lite" | "full"
|
||||
parseApiKey: (headers: Headers) => string | undefined
|
||||
parseModel: (url: string, body: any) => string
|
||||
parseVariant: (url: string, body: any) => string | undefined
|
||||
parseIsStream: (url: string, body: any) => boolean
|
||||
},
|
||||
) {
|
||||
@@ -92,6 +94,7 @@ export async function handler(
|
||||
const url = input.request.url
|
||||
const body = await input.request.json()
|
||||
const model = opts.parseModel(url, body)
|
||||
const variant = opts.parseVariant(url, body)
|
||||
const isStream = opts.parseIsStream(url, body)
|
||||
const rawIp = input.request.headers.get("x-real-ip") ?? ""
|
||||
const ip = rawIp.includes(":") ? rawIp.split(":").slice(0, 4).join(":") : rawIp
|
||||
@@ -108,6 +111,7 @@ export async function handler(
|
||||
request: requestId,
|
||||
client: ocClient,
|
||||
user_agent: userAgent,
|
||||
"model.variant": variant,
|
||||
})
|
||||
const zenData = ZenData.list(opts.modelList)
|
||||
const modelInfo = validateModel(zenData, model)
|
||||
@@ -161,6 +165,7 @@ export async function handler(
|
||||
if (typeof v === "string") {
|
||||
if (v === "$ip") return [[k, ip]]
|
||||
if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : []
|
||||
if (v === "$session") return sessionId ? [[k, sessionId]] : []
|
||||
if (v.startsWith("$header.")) {
|
||||
const headerValue = input.request.headers.get(v.slice(8))
|
||||
return headerValue ? [[k, headerValue]] : []
|
||||
@@ -395,7 +400,8 @@ export async function handler(
|
||||
if (
|
||||
error instanceof RateLimitError ||
|
||||
error instanceof FreeUsageLimitError ||
|
||||
error instanceof SubscriptionUsageLimitError
|
||||
error instanceof GoUsageLimitError ||
|
||||
error instanceof BlackUsageLimitError
|
||||
) {
|
||||
const headers = new Headers()
|
||||
if (error.retryAfter) {
|
||||
@@ -404,7 +410,17 @@ export async function handler(
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: { type: error.constructor.name, message: error.message },
|
||||
error: {
|
||||
type: error.constructor.name,
|
||||
message: error.message,
|
||||
},
|
||||
metadata:
|
||||
error instanceof GoUsageLimitError
|
||||
? {
|
||||
workspace: error.workspace,
|
||||
limitName: error.limitName,
|
||||
}
|
||||
: {},
|
||||
}),
|
||||
{ status: 429, headers },
|
||||
)
|
||||
@@ -693,7 +709,7 @@ export async function handler(
|
||||
timeUpdated: sub.timeFixedUpdated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionUsageLimitError(
|
||||
throw new BlackUsageLimitError(
|
||||
t("zen.api.error.subscriptionQuotaExceeded", {
|
||||
retryIn: formatRetryTime(result.resetInSec),
|
||||
}),
|
||||
@@ -711,7 +727,7 @@ export async function handler(
|
||||
timeUpdated: sub.timeRollingUpdated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionUsageLimitError(
|
||||
throw new BlackUsageLimitError(
|
||||
t("zen.api.error.subscriptionQuotaExceeded", {
|
||||
retryIn: formatRetryTime(result.resetInSec),
|
||||
}),
|
||||
@@ -728,6 +744,7 @@ export async function handler(
|
||||
// Validate lite subscription billing
|
||||
if (opts.modelList === "lite" && authInfo.billing.lite && authInfo.lite) {
|
||||
try {
|
||||
const consoleGoUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/go`
|
||||
const sub = authInfo.lite
|
||||
const liteData = LiteData.getLimits()
|
||||
|
||||
@@ -739,8 +756,13 @@ export async function handler(
|
||||
timeUpdated: sub.timeWeeklyUpdated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionUsageLimitError(
|
||||
t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
|
||||
throw new GoUsageLimitError(
|
||||
t("zen.api.error.goSubscriptionWeeklyLimitExceeded", {
|
||||
retryIn: formatRetryTime(result.resetInSec),
|
||||
consoleGoUrl,
|
||||
}),
|
||||
authInfo.workspaceID,
|
||||
"weekly",
|
||||
result.resetInSec,
|
||||
)
|
||||
}
|
||||
@@ -754,8 +776,13 @@ export async function handler(
|
||||
timeSubscribed: sub.timeCreated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionUsageLimitError(
|
||||
t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
|
||||
throw new GoUsageLimitError(
|
||||
t("zen.api.error.goSubscriptionMonthlyLimitExceeded", {
|
||||
retryIn: formatRetryTime(result.resetInSec),
|
||||
consoleGoUrl,
|
||||
}),
|
||||
authInfo.workspaceID,
|
||||
"monthly",
|
||||
result.resetInSec,
|
||||
)
|
||||
}
|
||||
@@ -769,8 +796,13 @@ export async function handler(
|
||||
timeUpdated: sub.timeRollingUpdated,
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new SubscriptionUsageLimitError(
|
||||
t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
|
||||
throw new GoUsageLimitError(
|
||||
t("zen.api.error.goSubscriptionRollingLimitExceeded", {
|
||||
retryIn: formatRetryTime(result.resetInSec),
|
||||
consoleGoUrl,
|
||||
}),
|
||||
authInfo.workspaceID,
|
||||
"5 hour",
|
||||
result.resetInSec,
|
||||
)
|
||||
}
|
||||
@@ -857,10 +889,6 @@ export async function handler(
|
||||
|
||||
const inputCost = modelCost.input * inputTokens * 100
|
||||
const outputCost = modelCost.output * outputTokens * 100
|
||||
const reasoningCost = (() => {
|
||||
if (!reasoningTokens) return undefined
|
||||
return modelCost.output * reasoningTokens * 100
|
||||
})()
|
||||
const cacheReadCost = (() => {
|
||||
if (!cacheReadTokens) return undefined
|
||||
if (!modelCost.cacheRead) return undefined
|
||||
@@ -877,17 +905,11 @@ export async function handler(
|
||||
return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
|
||||
})()
|
||||
const totalCostInCent =
|
||||
inputCost +
|
||||
outputCost +
|
||||
(reasoningCost ?? 0) +
|
||||
(cacheReadCost ?? 0) +
|
||||
(cacheWrite5mCost ?? 0) +
|
||||
(cacheWrite1hCost ?? 0)
|
||||
inputCost + outputCost + (cacheReadCost ?? 0) + (cacheWrite5mCost ?? 0) + (cacheWrite1hCost ?? 0)
|
||||
return {
|
||||
totalCostInCent,
|
||||
inputCost,
|
||||
outputCost,
|
||||
reasoningCost,
|
||||
cacheReadCost,
|
||||
cacheWrite5mCost,
|
||||
cacheWrite1hCost,
|
||||
@@ -909,8 +931,7 @@ export async function handler(
|
||||
) {
|
||||
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
|
||||
usageInfo
|
||||
const { totalCostInCent, inputCost, outputCost, reasoningCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } =
|
||||
costInfo
|
||||
const { totalCostInCent, inputCost, outputCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } = costInfo
|
||||
|
||||
logger.metric({
|
||||
"tokens.input": inputTokens,
|
||||
@@ -921,14 +942,12 @@ export async function handler(
|
||||
"tokens.cache_write_1h": cacheWrite1hTokens,
|
||||
"cost.input.microcents": centsToMicroCents(inputCost),
|
||||
"cost.output.microcents": centsToMicroCents(outputCost),
|
||||
"cost.reasoning.microcents": reasoningCost ? centsToMicroCents(reasoningCost) : undefined,
|
||||
"cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined,
|
||||
"cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined,
|
||||
"cost.total.microcents": centsToMicroCents(totalCostInCent),
|
||||
// deprecated - remove after May 20, 2026
|
||||
"cost.input": Math.round(inputCost),
|
||||
"cost.output": Math.round(outputCost),
|
||||
"cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
|
||||
"cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
|
||||
"cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
|
||||
"cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
|
||||
|
||||
@@ -13,7 +13,7 @@ export function createRateLimiter(
|
||||
if (!zenApiKey) return
|
||||
const dict = i18n(localeFromRequest(request))
|
||||
|
||||
const LIMIT = rateLimit ?? 300
|
||||
const LIMIT = rateLimit ?? 500
|
||||
const yyyyMMddHHmm = new Date(Date.now())
|
||||
.toISOString()
|
||||
.replace(/[^0-9]/g, "")
|
||||
|
||||
@@ -50,7 +50,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
|
||||
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
|
||||
return {
|
||||
inputTokens: inputTokens - (cacheReadTokens ?? 0),
|
||||
outputTokens: outputTokens - (reasoningTokens ?? 0),
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWrite5mTokens: undefined,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export function parseAnthropicVariant(body: any) {
|
||||
const effort = body.effort ?? body.output_config?.effort ?? body.outputConfig?.effort ?? body.thinking?.effort
|
||||
if (effort) return effort
|
||||
|
||||
const budget = body.thinking?.budget_tokens ?? body.thinking?.budgetTokens
|
||||
if (body.thinking?.type !== "enabled" || typeof budget !== "number") return undefined
|
||||
return budget > 16_000 ? "max" : "high"
|
||||
}
|
||||
|
||||
export function parseGoogleVariant(body: any) {
|
||||
const thinkingConfig = body.generationConfig?.thinkingConfig ?? body.thinkingConfig
|
||||
if (thinkingConfig?.thinkingLevel) return thinkingConfig.thinkingLevel
|
||||
|
||||
const budget = thinkingConfig?.thinkingBudget ?? thinkingConfig?.thinking_budget
|
||||
if (typeof budget !== "number" || budget <= 0) return undefined
|
||||
return budget > 16_000 ? "max" : "high"
|
||||
}
|
||||
|
||||
export function parseOpenAiVariant(body: any) {
|
||||
return body.reasoningEffort ?? body.reasoning_effort ?? body.reasoning?.effort
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { handler } from "~/routes/zen/util/handler"
|
||||
import { parseOpenAiVariant } from "~/routes/zen/util/variant"
|
||||
|
||||
export function POST(input: APIEvent) {
|
||||
return handler(input, {
|
||||
@@ -7,6 +8,7 @@ export function POST(input: APIEvent) {
|
||||
modelList: "full",
|
||||
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
|
||||
parseModel: (url: string, body: any) => body.model,
|
||||
parseVariant: (url: string, body: any) => parseOpenAiVariant(body),
|
||||
parseIsStream: (url: string, body: any) => !!body.stream,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { handler } from "~/routes/zen/util/handler"
|
||||
import { parseAnthropicVariant } from "~/routes/zen/util/variant"
|
||||
|
||||
export function POST(input: APIEvent) {
|
||||
return handler(input, {
|
||||
@@ -7,6 +8,7 @@ export function POST(input: APIEvent) {
|
||||
modelList: "full",
|
||||
parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined,
|
||||
parseModel: (url: string, body: any) => body.model,
|
||||
parseVariant: (url: string, body: any) => parseAnthropicVariant(body),
|
||||
parseIsStream: (url: string, body: any) => !!body.stream,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { handler } from "~/routes/zen/util/handler"
|
||||
import { parseGoogleVariant } from "~/routes/zen/util/variant"
|
||||
|
||||
export function POST(input: APIEvent) {
|
||||
return handler(input, {
|
||||
@@ -7,6 +8,7 @@ export function POST(input: APIEvent) {
|
||||
modelList: "full",
|
||||
parseApiKey: (headers: Headers) => headers.get("x-goog-api-key") ?? undefined,
|
||||
parseModel: (url: string, _body: any) => url.split("/").pop()?.split(":")?.[0] ?? "",
|
||||
parseVariant: (url: string, body: any) => parseGoogleVariant(body),
|
||||
parseIsStream: (url: string, _body: any) =>
|
||||
// ie. url: https://opencode.ai/zen/v1/models/gemini-3-pro:streamGenerateContent?alt=sse'
|
||||
url.split("/").pop()?.split(":")?.[1]?.startsWith("streamGenerateContent") ?? false,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { handler } from "~/routes/zen/util/handler"
|
||||
import { parseOpenAiVariant } from "~/routes/zen/util/variant"
|
||||
|
||||
export function POST(input: APIEvent) {
|
||||
return handler(input, {
|
||||
@@ -7,6 +8,7 @@ export function POST(input: APIEvent) {
|
||||
modelList: "full",
|
||||
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
|
||||
parseModel: (url: string, body: any) => body.model,
|
||||
parseVariant: (url: string, body: any) => parseOpenAiVariant(body),
|
||||
parseIsStream: (url: string, body: any) => !!body.stream,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client", "@webgpu/types"],
|
||||
"types": ["vite/client", "@webgpu/types", "bun"],
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { timingSafeEqual } from "node:crypto"
|
||||
|
||||
export function safeEqual(a: string, b: string): boolean {
|
||||
const encoder = new TextEncoder()
|
||||
const aBytes = encoder.encode(a)
|
||||
const bBytes = encoder.encode(b)
|
||||
return aBytes.length === bBytes.length && timingSafeEqual(aBytes, bBytes)
|
||||
}
|
||||
Vendored
+2
-2
@@ -91,8 +91,8 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"INCIDENT_WEBHOOK_SIGNING_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"HoneycombWebhookSecret": {
|
||||
"type": "random.index/randomPassword.RandomPassword"
|
||||
"value": string
|
||||
}
|
||||
"R2AccessKey": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -19,7 +19,7 @@ export default {
|
||||
url.pathname !== "/zen/go/v1/responses" &&
|
||||
!url.pathname.startsWith("/zen/go/v1/models/")
|
||||
)
|
||||
return
|
||||
continue
|
||||
|
||||
let data = {
|
||||
"cf.continent": event.event.request.cf?.continent,
|
||||
|
||||
+2
-2
@@ -91,8 +91,8 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"INCIDENT_WEBHOOK_SIGNING_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"HoneycombWebhookSecret": {
|
||||
"type": "random.index/randomPassword.RandomPassword"
|
||||
"value": string
|
||||
}
|
||||
"R2AccessKey": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
+2
-2
@@ -91,8 +91,8 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"INCIDENT_WEBHOOK_SIGNING_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"HoneycombWebhookSecret": {
|
||||
"type": "random.index/randomPassword.RandomPassword"
|
||||
"value": string
|
||||
}
|
||||
"R2AccessKey": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -36,7 +36,7 @@ export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Ty
|
||||
* mapped `.omit()` / `.extend()` surface triggers brand-intersection
|
||||
* explosions for branded primitives (`string & Brand<"SessionID">` extends
|
||||
* `object` via the brand and gets walked into the prototype by `DeepPartial`,
|
||||
* `updateSchema`, etc.), and zod's inference through `z.ZodType<T | undefined>`
|
||||
* mapped-schema helpers, and zod's inference through `z.ZodType<T | undefined>`
|
||||
* wrappers also can't reconstruct `T` cleanly. Consumers that care about the
|
||||
* post-`.omit()` shape should cast `c.req.valid(...)` to the expected type.
|
||||
*/
|
||||
@@ -11,9 +11,12 @@ function falsy(key: string) {
|
||||
return value === "false" || value === "0"
|
||||
}
|
||||
|
||||
// Channels that default to the new effect-httpapi server backend. The legacy
|
||||
// hono backend remains the default for stable (`prod`/`latest`) installs.
|
||||
const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"])
|
||||
// Channels where new experiments default to ON (unstable / internal users).
|
||||
// Stable channels (`prod`, `latest`) stay opt-in.
|
||||
const UNSTABLE_CHANNELS = new Set(["dev", "beta", "local"])
|
||||
function unstableDefault(key: string) {
|
||||
return truthy(key) || (!falsy(key) && UNSTABLE_CHANNELS.has(InstallationChannel))
|
||||
}
|
||||
|
||||
function number(key: string) {
|
||||
const value = process.env[key]
|
||||
@@ -53,6 +56,9 @@ export const Flag = {
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS,
|
||||
OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
|
||||
// Default-on for dev/beta/local; opt-in for stable. Set
|
||||
// OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL=false to force off, =true to force on.
|
||||
OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"),
|
||||
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
|
||||
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
|
||||
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
|
||||
@@ -76,7 +82,9 @@ export const Flag = {
|
||||
OPENCODE_EXPERIMENTAL_LSP_TY: truthy("OPENCODE_EXPERIMENTAL_LSP_TY"),
|
||||
OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
|
||||
OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
|
||||
OPENCODE_EXPERIMENTAL_SCOUT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SCOUT"),
|
||||
OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"),
|
||||
OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
|
||||
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
|
||||
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
|
||||
OPENCODE_DISABLE_EMBEDDED_WEB_UI: truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
|
||||
@@ -86,14 +94,6 @@ export const Flag = {
|
||||
OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"),
|
||||
|
||||
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
|
||||
// Defaults to true on dev/beta/local channels so internal users exercise the
|
||||
// new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay
|
||||
// on the legacy hono backend until the rollout is complete. An explicit env
|
||||
// var ("true"/"1" or "false"/"0") always wins, providing an opt-in for
|
||||
// stable users and an escape hatch for dev/beta users.
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI:
|
||||
truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") ||
|
||||
(!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)),
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ const paths = {
|
||||
data,
|
||||
bin: path.join(cache, "bin"),
|
||||
log: path.join(data, "log"),
|
||||
repos: path.join(data, "repos"),
|
||||
cache,
|
||||
config,
|
||||
state,
|
||||
@@ -37,6 +38,7 @@ await Promise.all([
|
||||
fs.mkdir(Path.tmp, { recursive: true }),
|
||||
fs.mkdir(Path.log, { recursive: true }),
|
||||
fs.mkdir(Path.bin, { recursive: true }),
|
||||
fs.mkdir(Path.repos, { recursive: true }),
|
||||
])
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Global") {}
|
||||
@@ -50,6 +52,7 @@ export interface Interface {
|
||||
readonly tmp: string
|
||||
readonly bin: string
|
||||
readonly log: string
|
||||
readonly repos: string
|
||||
}
|
||||
|
||||
export function make(input: Partial<Interface> = {}): Interface {
|
||||
@@ -62,6 +65,7 @@ export function make(input: Partial<Interface> = {}): Interface {
|
||||
tmp: Path.tmp,
|
||||
bin: Path.bin,
|
||||
log: Path.log,
|
||||
repos: Path.repos,
|
||||
...input,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,19 @@
|
||||
# OpenCode Desktop
|
||||
|
||||
Native OpenCode desktop app, built with Tauri v2.
|
||||
The OpenCode Desktop app, built with Electron.
|
||||
|
||||
## Development
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run --cwd packages/desktop tauri dev
|
||||
```
|
||||
|
||||
This starts the Vite dev server on http://localhost:1420 and opens the native window.
|
||||
|
||||
If you only want the web dev server (no native shell):
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/desktop dev
|
||||
bun dev
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
To create a production `dist/` and build the native app bundle:
|
||||
Run the `build` script to build the app's JS assets, then `package` to
|
||||
bundle the assets as an application. The resulting app will be in `dist/`.
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/desktop tauri build
|
||||
bun run build && bun run package
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions.
|
||||
|
||||
@@ -66,8 +66,8 @@ const getBase = (): Configuration => ({
|
||||
verifyUpdateCodeSignature: false,
|
||||
},
|
||||
nsis: {
|
||||
oneClick: false,
|
||||
allowToChangeInstallationDirectory: true,
|
||||
oneClick: true,
|
||||
perMachine: false,
|
||||
installerIcon: `resources/icons/icon.ico`,
|
||||
installerHeaderIcon: `resources/icons/icon.ico`,
|
||||
},
|
||||
|
||||
@@ -37,7 +37,7 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: { index: "src/main/index.ts" },
|
||||
input: { index: "src/main/index.ts", sidecar: "src/main/sidecar.ts" },
|
||||
},
|
||||
externalizeDeps: { include: [nodePtyPkg] },
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs"
|
||||
import { execFile, execFileSync } from "node:child_process"
|
||||
import { access, readFile, readdir } from "node:fs/promises"
|
||||
import { dirname, extname, join } from "node:path"
|
||||
import util from "node:util"
|
||||
|
||||
export function checkAppExists(appName: string): boolean {
|
||||
const execFilePromise = util.promisify(execFile)
|
||||
|
||||
const exists = (path: string) =>
|
||||
access(path)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
export function checkAppExists(appName: string) {
|
||||
if (process.platform === "win32") return true
|
||||
if (process.platform === "linux") return true
|
||||
return checkMacosApp(appName)
|
||||
}
|
||||
|
||||
export function resolveAppPath(appName: string): string | null {
|
||||
export function resolveAppPath(appName: string) {
|
||||
if (process.platform !== "win32") return appName
|
||||
return resolveWindowsAppPath(appName)
|
||||
}
|
||||
@@ -32,26 +40,25 @@ export function wslPath(path: string, mode: "windows" | "linux" | null): string
|
||||
}
|
||||
}
|
||||
|
||||
function checkMacosApp(appName: string) {
|
||||
async function checkMacosApp(appName: string) {
|
||||
const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`]
|
||||
|
||||
const home = process.env.HOME
|
||||
if (home) locations.push(`${home}/Applications/${appName}.app`)
|
||||
|
||||
if (locations.some((location) => existsSync(location))) return true
|
||||
|
||||
try {
|
||||
execFileSync("which", [appName])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
for (const location of locations) {
|
||||
if (await exists(location)) return true
|
||||
}
|
||||
|
||||
return execFilePromise("which", [appName])
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
function resolveWindowsAppPath(appName: string): string | null {
|
||||
async function resolveWindowsAppPath(appName: string): Promise<string | null> {
|
||||
let output: string
|
||||
try {
|
||||
output = execFileSync("where", [appName]).toString()
|
||||
output = execFilePromise("where", [appName]).toString()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -66,8 +73,8 @@ function resolveWindowsAppPath(appName: string): string | null {
|
||||
const exe = paths.find((path) => hasExt(path, "exe"))
|
||||
if (exe) return exe
|
||||
|
||||
const resolveCmd = (path: string) => {
|
||||
const content = readFileSync(path, "utf8")
|
||||
const resolveCmd = async (path: string) => {
|
||||
const content = await readFile(path, "utf8")
|
||||
for (const token of content.split('"').map((value: string) => value.trim())) {
|
||||
const lower = token.toLowerCase()
|
||||
if (!lower.includes(".exe")) continue
|
||||
@@ -85,10 +92,10 @@ function resolveWindowsAppPath(appName: string): string | null {
|
||||
return join(current, part)
|
||||
}, base)
|
||||
|
||||
if (existsSync(resolved)) return resolved
|
||||
if (await exists(resolved)) return resolved
|
||||
}
|
||||
|
||||
if (existsSync(token)) return token
|
||||
if (await exists(token)) return token
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -96,20 +103,20 @@ function resolveWindowsAppPath(appName: string): string | null {
|
||||
|
||||
for (const path of paths) {
|
||||
if (hasExt(path, "cmd") || hasExt(path, "bat")) {
|
||||
const resolved = resolveCmd(path)
|
||||
const resolved = await resolveCmd(path)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
|
||||
if (!extname(path)) {
|
||||
const cmd = `${path}.cmd`
|
||||
if (existsSync(cmd)) {
|
||||
const resolved = resolveCmd(cmd)
|
||||
if (await exists(cmd)) {
|
||||
const resolved = await resolveCmd(cmd)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
|
||||
const bat = `${path}.bat`
|
||||
if (existsSync(bat)) {
|
||||
const resolved = resolveCmd(bat)
|
||||
if (await exists(bat)) {
|
||||
const resolved = await resolveCmd(bat)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
}
|
||||
@@ -126,7 +133,7 @@ function resolveWindowsAppPath(appName: string): string | null {
|
||||
const dirs = [dirname(path), dirname(dirname(path)), dirname(dirname(dirname(path)))]
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
for (const entry of await readdir(dir)) {
|
||||
const candidate = join(dir, entry)
|
||||
if (!hasExt(candidate, "exe")) continue
|
||||
const stem = entry.replace(/\.exe$/i, "")
|
||||
|
||||
Vendored
+1
@@ -5,6 +5,7 @@ interface ImportMetaEnv {
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
declare module "virtual:opencode-server" {
|
||||
export namespace Server {
|
||||
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
|
||||
|
||||
+272
-403
@@ -7,18 +7,36 @@ import { homedir, tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { getCACertificates, setDefaultCACertificates } from "node:tls"
|
||||
import type { Event } from "electron"
|
||||
import { app, BrowserWindow, dialog } from "electron"
|
||||
import pkg from "electron-updater"
|
||||
import { app, BrowserWindow } from "electron"
|
||||
|
||||
import contextMenu from "electron-context-menu"
|
||||
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
|
||||
|
||||
// on macOS apps run in `/` which can cause issues with ripgrep
|
||||
try {
|
||||
process.chdir(homedir())
|
||||
} catch {}
|
||||
|
||||
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
|
||||
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
|
||||
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
|
||||
import { CHANNEL, UPDATER_ENABLED } from "./constants"
|
||||
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
|
||||
import { initLogging } from "./logging"
|
||||
import { parseMarkdown } from "./markdown"
|
||||
import { createMenu } from "./menu"
|
||||
import {
|
||||
getDefaultServerUrl,
|
||||
getWslConfig,
|
||||
preferAppEnv,
|
||||
setDefaultServerUrl,
|
||||
setWslConfig,
|
||||
spawnLocalServer,
|
||||
type SidecarListener,
|
||||
} from "./server"
|
||||
import {
|
||||
createLoadingWindow,
|
||||
createMainWindow,
|
||||
registerRendererProtocol,
|
||||
setBackgroundColor,
|
||||
setDockIcon,
|
||||
} from "./windows"
|
||||
import { migrate } from "./migrate"
|
||||
import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater"
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
|
||||
const APP_NAMES: Record<string, string> = {
|
||||
dev: "OpenCode Dev",
|
||||
@@ -31,130 +49,16 @@ const APP_IDS: Record<string, string> = {
|
||||
prod: "ai.opencode.desktop",
|
||||
}
|
||||
const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
|
||||
const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"
|
||||
const onboardingTestRoot = setupOnboardingTestEnv()
|
||||
app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev")
|
||||
app.setAppUserModelId(appId)
|
||||
app.setPath("userData", onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId))
|
||||
if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session"))
|
||||
const logger = initLogging()
|
||||
const { autoUpdater } = pkg
|
||||
|
||||
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
|
||||
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
|
||||
import { CHANNEL, UPDATER_ENABLED } from "./constants"
|
||||
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
|
||||
import { initLogging } from "./logging"
|
||||
import { parseMarkdown } from "./markdown"
|
||||
import { createMenu } from "./menu"
|
||||
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
|
||||
import {
|
||||
createLoadingWindow,
|
||||
createMainWindow,
|
||||
registerRendererProtocol,
|
||||
setBackgroundColor,
|
||||
setDockIcon,
|
||||
} from "./windows"
|
||||
import { drizzle } from "drizzle-orm/node-sqlite/driver"
|
||||
import type { Server } from "virtual:opencode-server"
|
||||
import { migrate } from "./migrate"
|
||||
let logger: ReturnType<typeof initLogging>
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let server: SidecarListener | null = null
|
||||
|
||||
const initEmitter = new EventEmitter()
|
||||
let initStep: InitStep = { phase: "server_waiting" }
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let server: Server.Listener | null = null
|
||||
const loadingComplete = defer<void>()
|
||||
|
||||
const pendingDeepLinks: string[] = []
|
||||
|
||||
const serverReady = defer<ServerReadyData>()
|
||||
|
||||
useSystemCertificates()
|
||||
|
||||
function setupOnboardingTestEnv() {
|
||||
if (!TEST_ONBOARDING) return
|
||||
|
||||
const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) =>
|
||||
mkdirSync(join(root, dir), { recursive: true }),
|
||||
)
|
||||
process.env.OPENCODE_DB = ":memory:"
|
||||
process.env.XDG_DATA_HOME = join(root, "data")
|
||||
process.env.XDG_CONFIG_HOME = join(root, "config")
|
||||
process.env.XDG_CACHE_HOME = join(root, "cache")
|
||||
process.env.XDG_STATE_HOME = join(root, "state")
|
||||
return root
|
||||
}
|
||||
|
||||
logger.log("app starting", {
|
||||
version: app.getVersion(),
|
||||
packaged: app.isPackaged,
|
||||
onboardingTest: Boolean(onboardingTestRoot),
|
||||
})
|
||||
|
||||
setupApp()
|
||||
|
||||
function setupApp() {
|
||||
ensureLoopbackNoProxy()
|
||||
useEnvProxy()
|
||||
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
|
||||
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
|
||||
app.on("second-instance", (_event: Event, argv: string[]) => {
|
||||
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
|
||||
if (urls.length) {
|
||||
logger.log("deep link received via second-instance", { urls })
|
||||
emitDeepLinks(urls)
|
||||
}
|
||||
focusMainWindow()
|
||||
})
|
||||
|
||||
app.on("open-url", (event: Event, url: string) => {
|
||||
event.preventDefault()
|
||||
logger.log("deep link received via open-url", { url })
|
||||
emitDeepLinks([url])
|
||||
})
|
||||
|
||||
app.on("before-quit", () => {
|
||||
killSidecar()
|
||||
})
|
||||
|
||||
app.on("will-quit", () => {
|
||||
killSidecar()
|
||||
})
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
killSidecar()
|
||||
app.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
void app.whenReady().then(async () => {
|
||||
if (!TEST_ONBOARDING) migrate()
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
registerRendererProtocol()
|
||||
setDockIcon()
|
||||
setupAutoUpdater()
|
||||
await initialize()
|
||||
})
|
||||
}
|
||||
|
||||
function useSystemCertificates() {
|
||||
try {
|
||||
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
|
||||
} catch (error) {
|
||||
logger.warn("failed to load system certificates", error)
|
||||
}
|
||||
}
|
||||
|
||||
function useEnvProxy() {
|
||||
try {
|
||||
// Electron 41.2 runs Node 24.14.1; latest @types/node@24 is 24.12.2.
|
||||
@@ -170,154 +74,17 @@ function emitDeepLinks(urls: string[]) {
|
||||
if (mainWindow) sendDeepLinks(mainWindow, urls)
|
||||
}
|
||||
|
||||
function focusMainWindow() {
|
||||
if (!mainWindow) return
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
|
||||
function setInitStep(step: InitStep) {
|
||||
initStep = step
|
||||
logger.log("init step", { step })
|
||||
initEmitter.emit("step", step)
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
const needsMigration = !sqliteFileExists()
|
||||
const sqliteDone = needsMigration ? defer<void>() : undefined
|
||||
let overlay: BrowserWindow | null = null
|
||||
|
||||
const port = await getSidecarPort()
|
||||
const hostname = "127.0.0.1"
|
||||
const url = `http://${hostname}:${port}`
|
||||
const password = randomUUID()
|
||||
|
||||
const loadingTask = (async () => {
|
||||
logger.log("sidecar connection started", { url })
|
||||
|
||||
initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => {
|
||||
setInitStep({ phase: "sqlite_waiting" })
|
||||
if (overlay) sendSqliteMigrationProgress(overlay, progress)
|
||||
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
|
||||
if (progress.type === "Done") sqliteDone?.resolve()
|
||||
})
|
||||
|
||||
if (needsMigration) {
|
||||
const { Database, JsonMigration } = await import("virtual:opencode-server")
|
||||
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
|
||||
progress: (event: { current: number; total: number }) => {
|
||||
const percent = Math.round(event.current / event.total) * 100
|
||||
initEmitter.emit("sqlite", { type: "InProgress", value: percent })
|
||||
},
|
||||
})
|
||||
initEmitter.emit("sqlite", { type: "Done" })
|
||||
|
||||
sqliteDone?.resolve()
|
||||
}
|
||||
|
||||
if (needsMigration) {
|
||||
await sqliteDone?.promise
|
||||
}
|
||||
|
||||
logger.log("spawning sidecar", { url })
|
||||
const { listener, health } = await spawnLocalServer(hostname, port, password, () => {
|
||||
ensureLoopbackNoProxy()
|
||||
useEnvProxy()
|
||||
})
|
||||
server = listener
|
||||
serverReady.resolve({
|
||||
url,
|
||||
username: "opencode",
|
||||
password,
|
||||
})
|
||||
|
||||
await Promise.race([
|
||||
health.wait,
|
||||
delay(30_000).then(() => {
|
||||
throw new Error("Sidecar health check timed out")
|
||||
}),
|
||||
]).catch((error) => {
|
||||
logger.error("sidecar health check failed", error)
|
||||
})
|
||||
|
||||
logger.log("loading task finished")
|
||||
})()
|
||||
|
||||
if (needsMigration) {
|
||||
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
|
||||
if (show) {
|
||||
overlay = createLoadingWindow()
|
||||
await delay(1_000)
|
||||
}
|
||||
}
|
||||
|
||||
await loadingTask
|
||||
setInitStep({ phase: "done" })
|
||||
|
||||
if (overlay) {
|
||||
await loadingComplete.promise
|
||||
}
|
||||
|
||||
mainWindow = createMainWindow()
|
||||
wireMenu()
|
||||
|
||||
overlay?.close()
|
||||
}
|
||||
|
||||
function wireMenu() {
|
||||
if (!mainWindow) return
|
||||
createMenu({
|
||||
trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
|
||||
checkForUpdates: () => {
|
||||
void checkForUpdates(true)
|
||||
},
|
||||
reload: () => mainWindow?.reload(),
|
||||
relaunch: () => {
|
||||
killSidecar()
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
registerIpcHandlers({
|
||||
killSidecar: () => killSidecar(),
|
||||
awaitInitialization: async (sendStep) => {
|
||||
sendStep(initStep)
|
||||
const listener = (step: InitStep) => sendStep(step)
|
||||
initEmitter.on("step", listener)
|
||||
try {
|
||||
logger.log("awaiting server ready")
|
||||
const res = await serverReady.promise
|
||||
logger.log("server ready", { url: res.url })
|
||||
return res
|
||||
} finally {
|
||||
initEmitter.off("step", listener)
|
||||
}
|
||||
},
|
||||
getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
|
||||
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
|
||||
getDefaultServerUrl: () => getDefaultServerUrl(),
|
||||
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
|
||||
getWslConfig: () => Promise.resolve(getWslConfig()),
|
||||
setWslConfig: (config: WslConfig) => setWslConfig(config),
|
||||
getDisplayBackend: async () => null,
|
||||
setDisplayBackend: async () => undefined,
|
||||
parseMarkdown: async (markdown) => parseMarkdown(markdown),
|
||||
checkAppExists: async (appName) => checkAppExists(appName),
|
||||
wslPath: async (path, mode) => wslPath(path, mode),
|
||||
resolveAppPath: async (appName) => resolveAppPath(appName),
|
||||
loadingWindowComplete: () => loadingComplete.resolve(),
|
||||
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail),
|
||||
checkUpdate: async () => checkUpdate(),
|
||||
installUpdate: async () => installUpdate(),
|
||||
setBackgroundColor: (color) => setBackgroundColor(color),
|
||||
})
|
||||
|
||||
function killSidecar() {
|
||||
async function killSidecar() {
|
||||
if (!server) return
|
||||
server.stop()
|
||||
const current = server
|
||||
server = null
|
||||
await current.stop()
|
||||
}
|
||||
|
||||
function ensureLoopbackNoProxy() {
|
||||
@@ -340,163 +107,265 @@ function ensureLoopbackNoProxy() {
|
||||
upsert("no_proxy")
|
||||
}
|
||||
|
||||
async function getSidecarPort() {
|
||||
const fromEnv = process.env.OPENCODE_PORT
|
||||
if (fromEnv) {
|
||||
const parsed = Number.parseInt(fromEnv, 10)
|
||||
if (!Number.isNaN(parsed)) return parsed
|
||||
const main = Effect.gen(function* () {
|
||||
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
|
||||
|
||||
// on macOS apps run in `/` which can cause issues with ripgrep
|
||||
try {
|
||||
process.chdir(homedir())
|
||||
} catch {}
|
||||
|
||||
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
|
||||
|
||||
const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"
|
||||
const onboardingTestRoot = ((): string | undefined => {
|
||||
if (!TEST_ONBOARDING) return
|
||||
|
||||
const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) =>
|
||||
mkdirSync(join(root, dir), { recursive: true }),
|
||||
)
|
||||
process.env.OPENCODE_DB = ":memory:"
|
||||
process.env.XDG_DATA_HOME = join(root, "data")
|
||||
process.env.XDG_CONFIG_HOME = join(root, "config")
|
||||
process.env.XDG_CACHE_HOME = join(root, "cache")
|
||||
process.env.XDG_STATE_HOME = join(root, "state")
|
||||
return root
|
||||
})()
|
||||
app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev")
|
||||
app.setAppUserModelId(appId)
|
||||
app.setPath(
|
||||
"userData",
|
||||
onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId),
|
||||
)
|
||||
if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session"))
|
||||
logger = initLogging()
|
||||
|
||||
try {
|
||||
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
|
||||
} catch (error) {
|
||||
logger.warn("failed to load system certificates", error)
|
||||
}
|
||||
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
logger.log("app starting", {
|
||||
version: app.getVersion(),
|
||||
packaged: app.isPackaged,
|
||||
onboardingTest: Boolean(onboardingTestRoot),
|
||||
})
|
||||
|
||||
ensureLoopbackNoProxy()
|
||||
useEnvProxy()
|
||||
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
|
||||
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
|
||||
preferAppEnv(app.getPath("userData"))
|
||||
|
||||
app.on("second-instance", (_event: Event, argv: string[]) => {
|
||||
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
|
||||
if (urls.length) {
|
||||
logger.log("deep link received via second-instance", { urls })
|
||||
emitDeepLinks(urls)
|
||||
}
|
||||
if (mainWindow) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
|
||||
app.on("open-url", (event: Event, url: string) => {
|
||||
event.preventDefault()
|
||||
logger.log("deep link received via open-url", { url })
|
||||
emitDeepLinks([url])
|
||||
})
|
||||
|
||||
app.on("before-quit", () => {
|
||||
void killSidecar()
|
||||
})
|
||||
|
||||
app.on("will-quit", () => {
|
||||
void killSidecar()
|
||||
})
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
void killSidecar().finally(() => app.exit(0))
|
||||
})
|
||||
}
|
||||
|
||||
const serverReady = Deferred.makeUnsafe<ServerReadyData>()
|
||||
const loadingComplete = Deferred.makeUnsafe<void>()
|
||||
|
||||
registerIpcHandlers({
|
||||
killSidecar: () => killSidecar(),
|
||||
awaitInitialization: Effect.fnUntraced(
|
||||
function* (sendStep) {
|
||||
sendStep(initStep)
|
||||
const listener = (step: InitStep) => sendStep(step)
|
||||
initEmitter.on("step", listener)
|
||||
try {
|
||||
logger.log("awaiting server ready")
|
||||
const res = yield* Deferred.await(serverReady)
|
||||
logger.log("server ready", { url: res.url })
|
||||
return res
|
||||
} finally {
|
||||
initEmitter.off("step", listener)
|
||||
}
|
||||
},
|
||||
(e) => Effect.runPromise(e),
|
||||
),
|
||||
getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
|
||||
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
|
||||
getDefaultServerUrl: () => getDefaultServerUrl(),
|
||||
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
|
||||
getWslConfig: () => Promise.resolve(getWslConfig()),
|
||||
setWslConfig: (config: WslConfig) => setWslConfig(config),
|
||||
getDisplayBackend: async () => null,
|
||||
setDisplayBackend: async () => undefined,
|
||||
parseMarkdown: async (markdown) => parseMarkdown(markdown),
|
||||
checkAppExists: (appName) => checkAppExists(appName),
|
||||
wslPath: async (path, mode) => wslPath(path, mode),
|
||||
resolveAppPath: async (appName) => resolveAppPath(appName),
|
||||
loadingWindowComplete: () => Deferred.doneUnsafe(loadingComplete, Effect.void),
|
||||
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, killSidecar),
|
||||
checkUpdate: async () => checkUpdate(),
|
||||
installUpdate: async () => installUpdate(killSidecar),
|
||||
setBackgroundColor: (color) => setBackgroundColor(color),
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => app.whenReady())
|
||||
|
||||
if (!TEST_ONBOARDING) migrate()
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
registerRendererProtocol()
|
||||
setDockIcon()
|
||||
setupAutoUpdater()
|
||||
|
||||
const needsMigration = ((): boolean => {
|
||||
if (process.env.OPENCODE_DB === ":memory:") return false
|
||||
|
||||
const xdg = process.env.XDG_DATA_HOME
|
||||
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share")
|
||||
return !existsSync(join(base, "opencode", "opencode.db"))
|
||||
})()
|
||||
let overlay: BrowserWindow | null = null
|
||||
|
||||
const port = yield* Effect.gen(function* () {
|
||||
const fromEnv = process.env.OPENCODE_PORT
|
||||
if (fromEnv) {
|
||||
const parsed = Number.parseInt(fromEnv, 10)
|
||||
if (!Number.isNaN(parsed)) return parsed
|
||||
}
|
||||
|
||||
const res = yield* Deferred.make<number, unknown>()
|
||||
const server = createServer()
|
||||
server.on("error", reject)
|
||||
server.on("error", (e) => Deferred.failSync(res, () => e))
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address()
|
||||
if (typeof address !== "object" || !address) {
|
||||
server.close()
|
||||
reject(new Error("Failed to get port"))
|
||||
Deferred.failSync(res, () => new Error("Failed to get port"))
|
||||
return
|
||||
}
|
||||
const port = address.port
|
||||
server.close(() => resolve(port))
|
||||
server.close(() => Effect.runSync(Deferred.succeed(res, port)))
|
||||
})
|
||||
|
||||
return yield* Deferred.await(res)
|
||||
})
|
||||
}
|
||||
const hostname = "127.0.0.1"
|
||||
const url = `http://${hostname}:${port}`
|
||||
const password = randomUUID()
|
||||
|
||||
function sqliteFileExists() {
|
||||
if (process.env.OPENCODE_DB === ":memory:") return true
|
||||
const loadingTask = yield* Effect.gen(function* () {
|
||||
logger.log("sidecar connection started", { url })
|
||||
|
||||
const xdg = process.env.XDG_DATA_HOME
|
||||
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share")
|
||||
return existsSync(join(base, "opencode", "opencode.db"))
|
||||
}
|
||||
|
||||
function setupAutoUpdater() {
|
||||
if (!UPDATER_ENABLED) return
|
||||
autoUpdater.logger = logger
|
||||
autoUpdater.channel = "latest"
|
||||
autoUpdater.allowPrerelease = false
|
||||
autoUpdater.allowDowngrade = true
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = false
|
||||
logger.log("auto updater configured", {
|
||||
channel: autoUpdater.channel,
|
||||
allowPrerelease: autoUpdater.allowPrerelease,
|
||||
allowDowngrade: autoUpdater.allowDowngrade,
|
||||
currentVersion: app.getVersion(),
|
||||
})
|
||||
}
|
||||
|
||||
let downloadedUpdateVersion: string | undefined
|
||||
|
||||
async function checkUpdate() {
|
||||
if (!UPDATER_ENABLED) return { updateAvailable: false }
|
||||
if (downloadedUpdateVersion) {
|
||||
logger.log("returning cached downloaded update", {
|
||||
version: downloadedUpdateVersion,
|
||||
initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => {
|
||||
setInitStep({ phase: "sqlite_waiting" })
|
||||
if (overlay) sendSqliteMigrationProgress(overlay, progress)
|
||||
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
|
||||
})
|
||||
return { updateAvailable: true, version: downloadedUpdateVersion }
|
||||
}
|
||||
logger.log("checking for updates", {
|
||||
currentVersion: app.getVersion(),
|
||||
channel: autoUpdater.channel,
|
||||
allowPrerelease: autoUpdater.allowPrerelease,
|
||||
allowDowngrade: autoUpdater.allowDowngrade,
|
||||
})
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates()
|
||||
const updateInfo = result?.updateInfo
|
||||
logger.log("update metadata fetched", {
|
||||
releaseVersion: updateInfo?.version ?? null,
|
||||
releaseDate: updateInfo?.releaseDate ?? null,
|
||||
releaseName: updateInfo?.releaseName ?? null,
|
||||
files: updateInfo?.files?.map((file) => file.url) ?? [],
|
||||
|
||||
logger.log("spawning sidecar", { url })
|
||||
const { listener, health } = yield* Effect.promise(() =>
|
||||
spawnLocalServer(
|
||||
hostname,
|
||||
port,
|
||||
password,
|
||||
() => {
|
||||
ensureLoopbackNoProxy()
|
||||
useEnvProxy()
|
||||
},
|
||||
{
|
||||
needsMigration,
|
||||
userDataPath: app.getPath("userData"),
|
||||
onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress),
|
||||
onStdout: (message) => logger.log("sidecar stdout", { message }),
|
||||
onStderr: (message) => logger.warn("sidecar stderr", { message }),
|
||||
onExit: (code) => logger.warn("sidecar exited", { code }),
|
||||
},
|
||||
),
|
||||
)
|
||||
server = listener
|
||||
yield* Deferred.succeed(serverReady, {
|
||||
url,
|
||||
username: "opencode",
|
||||
password,
|
||||
})
|
||||
const version = result?.updateInfo?.version
|
||||
if (result?.isUpdateAvailable === false || !version) {
|
||||
logger.log("no update available", {
|
||||
reason: "provider returned no newer version",
|
||||
})
|
||||
return { updateAvailable: false }
|
||||
|
||||
yield* Effect.promise(() => health.wait).pipe(
|
||||
Effect.timeout("30 seconds"),
|
||||
Effect.catch((e) =>
|
||||
Effect.sync(() => {
|
||||
logger.error("sidecar health check failed", e.toString())
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
logger.log("loading task finished")
|
||||
}).pipe(Effect.forkChild)
|
||||
|
||||
if (needsMigration) {
|
||||
const show = yield* loadingTask.pipe(
|
||||
Fiber.await,
|
||||
Effect.timeout("1 second"),
|
||||
Effect.as(false),
|
||||
Effect.catch(() => Effect.succeed(true)),
|
||||
)
|
||||
if (show) {
|
||||
overlay = createLoadingWindow()
|
||||
yield* Effect.sleep("1 second")
|
||||
}
|
||||
logger.log("update available", { version })
|
||||
await autoUpdater.downloadUpdate()
|
||||
logger.log("update download completed", { version })
|
||||
downloadedUpdateVersion = version
|
||||
return { updateAvailable: true, version }
|
||||
} catch (error) {
|
||||
logger.error("update check failed", error)
|
||||
return { updateAvailable: false, failed: true }
|
||||
}
|
||||
}
|
||||
|
||||
async function installUpdate() {
|
||||
if (!downloadedUpdateVersion) {
|
||||
logger.log("install update skipped", {
|
||||
reason: "no downloaded update ready",
|
||||
yield* Fiber.await(loadingTask)
|
||||
setInitStep({ phase: "done" })
|
||||
|
||||
if (overlay) yield* Deferred.await(loadingComplete)
|
||||
|
||||
mainWindow = createMainWindow()
|
||||
if (mainWindow) {
|
||||
createMenu({
|
||||
trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
|
||||
checkForUpdates: () => {
|
||||
void checkForUpdates(true, killSidecar)
|
||||
},
|
||||
reload: () => mainWindow?.reload(),
|
||||
relaunch: () => {
|
||||
void killSidecar().finally(() => {
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
})
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
logger.log("installing downloaded update", {
|
||||
version: downloadedUpdateVersion,
|
||||
})
|
||||
killSidecar()
|
||||
autoUpdater.quitAndInstall()
|
||||
}
|
||||
|
||||
async function checkForUpdates(alertOnFail: boolean) {
|
||||
if (!UPDATER_ENABLED) return
|
||||
logger.log("checkForUpdates invoked", { alertOnFail })
|
||||
const result = await checkUpdate()
|
||||
if (!result.updateAvailable) {
|
||||
if (result.failed) {
|
||||
logger.log("no update decision", { reason: "update check failed" })
|
||||
if (!alertOnFail) return
|
||||
await dialog.showMessageBox({
|
||||
type: "error",
|
||||
message: "Update check failed.",
|
||||
title: "Update Error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.log("no update decision", { reason: "already up to date" })
|
||||
if (!alertOnFail) return
|
||||
await dialog.showMessageBox({
|
||||
type: "info",
|
||||
message: "You're up to date.",
|
||||
title: "No Updates",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const response = await dialog.showMessageBox({
|
||||
type: "info",
|
||||
message: `Update ${result.version ?? ""} downloaded. Restart now?`,
|
||||
title: "Update Ready",
|
||||
buttons: ["Restart", "Later"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
})
|
||||
logger.log("update prompt response", {
|
||||
version: result.version ?? null,
|
||||
restartNow: response.response === 0,
|
||||
})
|
||||
if (response.response === 0) {
|
||||
await installUpdate()
|
||||
}
|
||||
}
|
||||
overlay?.close()
|
||||
})
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function defer<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
Effect.runFork(main)
|
||||
|
||||
@@ -19,7 +19,7 @@ const pickerFilters = (ext?: string[]) => {
|
||||
}
|
||||
|
||||
type Deps = {
|
||||
killSidecar: () => void
|
||||
killSidecar: () => Promise<void> | void
|
||||
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
|
||||
getWindowConfig: () => Promise<WindowConfig> | WindowConfig
|
||||
consumeInitialDeepLinks: () => Promise<string[]> | string[]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MainLogger } from "electron-log"
|
||||
import log from "electron-log/main.js"
|
||||
import { readFileSync, readdirSync, statSync, unlinkSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
@@ -5,11 +6,14 @@ import { dirname, join } from "node:path"
|
||||
const MAX_LOG_AGE_DAYS = 7
|
||||
const TAIL_LINES = 1000
|
||||
|
||||
let logger: MainLogger
|
||||
export const getLogger = () => logger
|
||||
|
||||
export function initLogging() {
|
||||
log.transports.file.maxSize = 5 * 1024 * 1024
|
||||
initConsoleTransport()
|
||||
cleanup()
|
||||
return log
|
||||
return (logger = log)
|
||||
}
|
||||
|
||||
export function tail(): string {
|
||||
|
||||
@@ -23,6 +23,11 @@ export function createMenu(deps: Deps) {
|
||||
enabled: UPDATER_ENABLED,
|
||||
click: () => deps.checkForUpdates(),
|
||||
},
|
||||
{
|
||||
label: "Settings",
|
||||
accelerator: "Cmd+,",
|
||||
click: () => deps.trigger("settings.open"),
|
||||
},
|
||||
{
|
||||
label: "Reload Webview",
|
||||
click: () => deps.reload(),
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
import { app } from "electron"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { app, utilityProcess } from "electron"
|
||||
import type { Details } from "electron"
|
||||
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
||||
import { getUserShell, loadShellEnv } from "./shell-env"
|
||||
import { getStore } from "./store"
|
||||
import type { SqliteMigrationProgress } from "../preload/types"
|
||||
|
||||
export type WslConfig = { enabled: boolean }
|
||||
|
||||
export type HealthCheck = { wait: Promise<void> }
|
||||
|
||||
type SidecarMessage =
|
||||
| { type: "sqlite"; progress: SqliteMigrationProgress }
|
||||
| { type: "ready" }
|
||||
| { type: "stopped" }
|
||||
| { type: "error"; error: { message: string; stack?: string } }
|
||||
|
||||
export type SidecarListener = { stop: () => Promise<void> }
|
||||
|
||||
const SIDECAR_SERVICE_NAME = "opencode server"
|
||||
const SIDECAR_START_STALL_TIMEOUT = 60_000
|
||||
const SIDECAR_STOP_TIMEOUT = 6_000
|
||||
|
||||
type SpawnLocalServerOptions = {
|
||||
needsMigration: boolean
|
||||
userDataPath: string
|
||||
onSqliteProgress?: (progress: SqliteMigrationProgress) => void
|
||||
onStdout?: (message: string) => void
|
||||
onStderr?: (message: string) => void
|
||||
onExit?: (code: number) => void
|
||||
}
|
||||
|
||||
export function getDefaultServerUrl(): string | null {
|
||||
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
|
||||
return typeof value === "string" ? value : null
|
||||
@@ -30,49 +55,152 @@ export function setWslConfig(config: WslConfig) {
|
||||
getStore().set(WSL_ENABLED_KEY, config.enabled)
|
||||
}
|
||||
|
||||
export async function spawnLocalServer(hostname: string, port: number, password: string, configureEnv?: () => void) {
|
||||
prepareServerEnv(password)
|
||||
export function preferAppEnv(userDataPath: string) {
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
Object.assign(process.env, {
|
||||
...(shell ? loadShellEnv(shell) : null),
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
|
||||
})
|
||||
}
|
||||
|
||||
export async function spawnLocalServer(
|
||||
hostname: string,
|
||||
port: number,
|
||||
password: string,
|
||||
configureEnv: () => void,
|
||||
options: SpawnLocalServerOptions,
|
||||
) {
|
||||
configureEnv?.()
|
||||
const { Log, Server } = await import("virtual:opencode-server")
|
||||
await Log.init({ level: "WARN" })
|
||||
const listener = await Server.listen({
|
||||
port,
|
||||
hostname,
|
||||
username: "opencode",
|
||||
password,
|
||||
cors: ["oc://renderer"],
|
||||
const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js")
|
||||
const child = utilityProcess.fork(sidecar, [], {
|
||||
cwd: process.cwd(),
|
||||
env: createSidecarEnv(),
|
||||
serviceName: SIDECAR_SERVICE_NAME,
|
||||
stdio: "pipe",
|
||||
})
|
||||
let exited = false
|
||||
const exit = defer<number>()
|
||||
|
||||
const onProcessGone = (_event: unknown, details: Details) => {
|
||||
if (details.type !== "Utility" || details.name !== SIDECAR_SERVICE_NAME) return
|
||||
options.onStderr?.(`utility process gone reason=${details.reason} exitCode=${details.exitCode}`)
|
||||
}
|
||||
|
||||
app.on("child-process-gone", onProcessGone)
|
||||
child.once("exit", (code) => {
|
||||
exited = true
|
||||
app.off("child-process-gone", onProcessGone)
|
||||
options.onExit?.(code)
|
||||
exit.resolve(code)
|
||||
})
|
||||
child.on("error", (error) => options.onStderr?.(`utility process error: ${serializeError(error).message}`))
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer) => options.onStdout?.(chunk.toString("utf8").trimEnd()))
|
||||
child.stderr?.on("data", (chunk: Buffer) => options.onStderr?.(chunk.toString("utf8").trimEnd()))
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let done = false
|
||||
let timeout: NodeJS.Timeout
|
||||
|
||||
const fail = (error: Error) => {
|
||||
if (done) return
|
||||
done = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
const refreshTimeout = () => {
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
fail(new Error(`Sidecar did not become ready within ${SIDECAR_START_STALL_TIMEOUT}ms: ${sidecar}`))
|
||||
}, SIDECAR_START_STALL_TIMEOUT)
|
||||
}
|
||||
|
||||
const onMessage = (message: SidecarMessage) => {
|
||||
if (message.type === "sqlite") {
|
||||
refreshTimeout()
|
||||
options.onSqliteProgress?.(message.progress)
|
||||
return
|
||||
}
|
||||
if (message.type === "ready") {
|
||||
if (done) return
|
||||
done = true
|
||||
cleanup()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
if (message.type === "error") {
|
||||
fail(Object.assign(new Error(message.error.message), { stack: message.error.stack }))
|
||||
}
|
||||
}
|
||||
const onExit = (code: number) => {
|
||||
fail(new Error(`Sidecar exited before ready with code ${code}`))
|
||||
}
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
child.off("message", onMessage)
|
||||
child.off("exit", onExit)
|
||||
}
|
||||
|
||||
child.on("message", onMessage)
|
||||
child.on("exit", onExit)
|
||||
refreshTimeout()
|
||||
child.postMessage({
|
||||
type: "start",
|
||||
hostname,
|
||||
port,
|
||||
password,
|
||||
userDataPath: options.userDataPath,
|
||||
needsMigration: options.needsMigration,
|
||||
})
|
||||
}).catch((error) => {
|
||||
if (!exited) child.kill()
|
||||
throw error
|
||||
})
|
||||
|
||||
const wait = (async () => {
|
||||
const url = `http://${hostname}:${port}`
|
||||
let healthy = false
|
||||
const gone = exit.promise.then((code) => {
|
||||
if (healthy) return
|
||||
throw new Error(`Sidecar exited before health check passed with code ${code}`)
|
||||
})
|
||||
|
||||
const ready = async () => {
|
||||
while (true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
if (await checkHealth(url, password)) return
|
||||
if (await checkHealth(url, password)) {
|
||||
healthy = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ready()
|
||||
await Promise.race([ready(), gone])
|
||||
})()
|
||||
|
||||
return { listener, health: { wait } }
|
||||
}
|
||||
let stopping: Promise<void> | undefined
|
||||
|
||||
function prepareServerEnv(password: string) {
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
|
||||
const env = {
|
||||
...process.env,
|
||||
...shellEnv,
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
OPENCODE_SERVER_USERNAME: "opencode",
|
||||
OPENCODE_SERVER_PASSWORD: password,
|
||||
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? app.getPath("userData"),
|
||||
return {
|
||||
listener: {
|
||||
stop: () => {
|
||||
if (stopping) return stopping
|
||||
if (exited) return Promise.resolve()
|
||||
child.postMessage({ type: "stop" })
|
||||
stopping = Promise.race([
|
||||
exit.promise.then(() => undefined),
|
||||
delay(SIDECAR_STOP_TIMEOUT).then(() => {
|
||||
if (!exited) child.kill()
|
||||
}),
|
||||
])
|
||||
return stopping
|
||||
},
|
||||
},
|
||||
health: { wait },
|
||||
}
|
||||
Object.assign(process.env, env)
|
||||
}
|
||||
|
||||
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
||||
@@ -100,3 +228,31 @@ export async function checkHealth(url: string, password?: string | null): Promis
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function createSidecarEnv(): Record<string, string> {
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])),
|
||||
)
|
||||
delete env.DEBUG
|
||||
if (process.platform === "linux") delete env.LD_PRELOAD
|
||||
return env
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function serializeError(error: unknown) {
|
||||
if (error instanceof Error) return { message: error.message, stack: error.stack }
|
||||
return { message: String(error) }
|
||||
}
|
||||
|
||||
function defer<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { basename } from "node:path"
|
||||
import { getLogger } from "./logging"
|
||||
|
||||
const TIMEOUT = 5_000
|
||||
|
||||
@@ -55,28 +56,29 @@ export function isNushell(shell: string) {
|
||||
}
|
||||
|
||||
export function loadShellEnv(shell: string) {
|
||||
const logger = getLogger()
|
||||
if (isNushell(shell)) {
|
||||
console.log(`[server] Skipping shell env probe for nushell: ${shell}`)
|
||||
logger.log(`[server] Skipping shell env probe for nushell: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const interactive = probe(shell, "-il")
|
||||
if (interactive.type === "Loaded") {
|
||||
console.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
|
||||
logger.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
|
||||
return interactive.value
|
||||
}
|
||||
if (interactive.type === "Timeout") {
|
||||
console.warn(`[server] Interactive shell env probe timed out: ${shell}`)
|
||||
logger.log(`[server] Interactive shell env probe timed out: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const login = probe(shell, "-l")
|
||||
if (login.type === "Loaded") {
|
||||
console.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
|
||||
logger.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
|
||||
return login.value
|
||||
}
|
||||
|
||||
console.warn(`[server] Falling back to app environment: ${shell}`)
|
||||
logger.log(`[server] Falling back to app environment: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { drizzle } from "drizzle-orm/node-sqlite/driver"
|
||||
import * as http from "node:http"
|
||||
import * as tls from "node:tls"
|
||||
|
||||
type NodeHttpWithEnvProxy = typeof http & {
|
||||
setGlobalProxyFromEnv: () => void
|
||||
}
|
||||
|
||||
type NodeTlsWithSystemCertificates = typeof tls & {
|
||||
getCACertificates: (type: "default" | "system") => string[]
|
||||
setDefaultCACertificates: (certificates: string[]) => void
|
||||
}
|
||||
|
||||
type StartCommand = {
|
||||
type: "start"
|
||||
hostname: string
|
||||
port: number
|
||||
password: string
|
||||
userDataPath: string
|
||||
needsMigration: boolean
|
||||
}
|
||||
|
||||
type StopCommand = { type: "stop" }
|
||||
type SidecarCommand = StartCommand | StopCommand
|
||||
|
||||
type SidecarMessage =
|
||||
| { type: "sqlite"; progress: { type: "InProgress"; value: number } | { type: "Done" } }
|
||||
| { type: "ready" }
|
||||
| { type: "stopped" }
|
||||
| { type: "error"; error: { message: string; stack?: string } }
|
||||
|
||||
type ParentPort = {
|
||||
postMessage(message: SidecarMessage): void
|
||||
on(event: "message", listener: (event: { data: unknown }) => void): void
|
||||
}
|
||||
|
||||
type Listener = {
|
||||
stop(close?: boolean): void | Promise<void>
|
||||
}
|
||||
|
||||
const parentPort = getParentPort()
|
||||
let listener: Listener | undefined
|
||||
|
||||
parentPort.on("message", (event) => {
|
||||
const command = parseCommand(event.data)
|
||||
if (!command) return
|
||||
if (command.type === "stop") {
|
||||
void stop()
|
||||
return
|
||||
}
|
||||
void start(command)
|
||||
})
|
||||
|
||||
async function start(command: StartCommand) {
|
||||
try {
|
||||
prepareSidecarEnv(command.password, command.userDataPath)
|
||||
ensureLoopbackNoProxy()
|
||||
useSystemCertificates()
|
||||
useEnvProxy()
|
||||
const { Database, JsonMigration, Log, Server } = await import("virtual:opencode-server")
|
||||
await Log.init({ level: "WARN" })
|
||||
|
||||
if (command.needsMigration) {
|
||||
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
|
||||
progress: (event: { current: number; total: number }) => {
|
||||
parentPort.postMessage({
|
||||
type: "sqlite",
|
||||
progress: {
|
||||
type: "InProgress",
|
||||
value: event.total === 0 ? 100 : Math.round((event.current / event.total) * 100),
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
parentPort.postMessage({ type: "sqlite", progress: { type: "Done" } })
|
||||
}
|
||||
|
||||
listener = await Server.listen({
|
||||
port: command.port,
|
||||
hostname: command.hostname,
|
||||
username: "opencode",
|
||||
password: command.password,
|
||||
cors: ["oc://renderer"],
|
||||
})
|
||||
parentPort.postMessage({ type: "ready" })
|
||||
} catch (error) {
|
||||
parentPort.postMessage({ type: "error", error: serializeError(error) })
|
||||
setImmediate(() => process.exit(1))
|
||||
}
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
try {
|
||||
await listener?.stop()
|
||||
} finally {
|
||||
listener = undefined
|
||||
parentPort.postMessage({ type: "stopped" })
|
||||
setImmediate(() => process.exit(0))
|
||||
}
|
||||
}
|
||||
|
||||
function prepareSidecarEnv(password: string, userDataPath: string) {
|
||||
Object.assign(process.env, {
|
||||
OPENCODE_SERVER_USERNAME: "opencode",
|
||||
OPENCODE_SERVER_PASSWORD: password,
|
||||
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
|
||||
})
|
||||
}
|
||||
|
||||
function ensureLoopbackNoProxy() {
|
||||
const loopback = ["127.0.0.1", "localhost", "::1"]
|
||||
const upsert = (key: string) => {
|
||||
const items = (process.env[key] ?? "")
|
||||
.split(",")
|
||||
.map((value: string) => value.trim())
|
||||
.filter((value: string) => Boolean(value))
|
||||
|
||||
for (const host of loopback) {
|
||||
if (items.some((value: string) => value.toLowerCase() === host)) continue
|
||||
items.push(host)
|
||||
}
|
||||
|
||||
process.env[key] = items.join(",")
|
||||
}
|
||||
|
||||
upsert("NO_PROXY")
|
||||
upsert("no_proxy")
|
||||
}
|
||||
|
||||
function useSystemCertificates() {
|
||||
try {
|
||||
const nodeTls = tls as NodeTlsWithSystemCertificates
|
||||
nodeTls.setDefaultCACertificates([
|
||||
...new Set([...nodeTls.getCACertificates("default"), ...nodeTls.getCACertificates("system")]),
|
||||
])
|
||||
} catch (error) {
|
||||
console.warn("failed to load system certificates", error)
|
||||
}
|
||||
}
|
||||
|
||||
function useEnvProxy() {
|
||||
try {
|
||||
;(http as NodeHttpWithEnvProxy).setGlobalProxyFromEnv()
|
||||
} catch (error) {
|
||||
console.warn("failed to load proxy environment", error)
|
||||
}
|
||||
}
|
||||
|
||||
function parseCommand(value: unknown): SidecarCommand | undefined {
|
||||
if (!value || typeof value !== "object") return
|
||||
const command = value as Partial<StartCommand | StopCommand>
|
||||
if (command.type === "stop") return { type: "stop" }
|
||||
if (command.type !== "start") return
|
||||
if (typeof command.hostname !== "string") return
|
||||
if (typeof command.port !== "number") return
|
||||
if (typeof command.password !== "string") return
|
||||
if (typeof command.userDataPath !== "string") return
|
||||
if (typeof command.needsMigration !== "boolean") return
|
||||
return {
|
||||
type: "start",
|
||||
hostname: command.hostname,
|
||||
port: command.port,
|
||||
password: command.password,
|
||||
userDataPath: command.userDataPath,
|
||||
needsMigration: command.needsMigration,
|
||||
}
|
||||
}
|
||||
|
||||
function serializeError(error: unknown) {
|
||||
if (error instanceof Error) return { message: error.message, stack: error.stack }
|
||||
return { message: String(error) }
|
||||
}
|
||||
|
||||
function getParentPort() {
|
||||
const port = process.parentPort as ParentPort | undefined
|
||||
if (!port) throw new Error("Sidecar parent port unavailable")
|
||||
return port
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { app, dialog } from "electron"
|
||||
import pkg from "electron-updater"
|
||||
import { UPDATER_ENABLED } from "./constants"
|
||||
import { initLogging } from "./logging"
|
||||
|
||||
const logger = initLogging()
|
||||
const { autoUpdater } = pkg
|
||||
|
||||
let downloadedUpdateVersion: string | undefined
|
||||
|
||||
export function setupAutoUpdater() {
|
||||
if (!UPDATER_ENABLED) return
|
||||
autoUpdater.logger = logger
|
||||
autoUpdater.channel = "latest"
|
||||
autoUpdater.allowPrerelease = false
|
||||
autoUpdater.allowDowngrade = true
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = false
|
||||
logger.log("auto updater configured", {
|
||||
channel: autoUpdater.channel,
|
||||
allowPrerelease: autoUpdater.allowPrerelease,
|
||||
allowDowngrade: autoUpdater.allowDowngrade,
|
||||
currentVersion: app.getVersion(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function checkUpdate() {
|
||||
if (!UPDATER_ENABLED) return { updateAvailable: false }
|
||||
if (downloadedUpdateVersion) {
|
||||
logger.log("returning cached downloaded update", {
|
||||
version: downloadedUpdateVersion,
|
||||
})
|
||||
return { updateAvailable: true, version: downloadedUpdateVersion }
|
||||
}
|
||||
logger.log("checking for updates", {
|
||||
currentVersion: app.getVersion(),
|
||||
channel: autoUpdater.channel,
|
||||
allowPrerelease: autoUpdater.allowPrerelease,
|
||||
allowDowngrade: autoUpdater.allowDowngrade,
|
||||
})
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates()
|
||||
const updateInfo = result?.updateInfo
|
||||
logger.log("update metadata fetched", {
|
||||
releaseVersion: updateInfo?.version ?? null,
|
||||
releaseDate: updateInfo?.releaseDate ?? null,
|
||||
releaseName: updateInfo?.releaseName ?? null,
|
||||
files: updateInfo?.files?.map((file) => file.url) ?? [],
|
||||
})
|
||||
const version = result?.updateInfo?.version
|
||||
if (result?.isUpdateAvailable === false || !version) {
|
||||
logger.log("no update available", {
|
||||
reason: "provider returned no newer version",
|
||||
})
|
||||
return { updateAvailable: false }
|
||||
}
|
||||
logger.log("update available", { version })
|
||||
await autoUpdater.downloadUpdate()
|
||||
logger.log("update download completed", { version })
|
||||
downloadedUpdateVersion = version
|
||||
return { updateAvailable: true, version }
|
||||
} catch (error) {
|
||||
logger.error("update check failed", error)
|
||||
return { updateAvailable: false, failed: true }
|
||||
}
|
||||
}
|
||||
|
||||
export async function installUpdate(killSidecar: () => Promise<void>) {
|
||||
if (!downloadedUpdateVersion) {
|
||||
logger.log("install update skipped", {
|
||||
reason: "no downloaded update ready",
|
||||
})
|
||||
return
|
||||
}
|
||||
logger.log("installing downloaded update", {
|
||||
version: downloadedUpdateVersion,
|
||||
})
|
||||
await killSidecar()
|
||||
autoUpdater.quitAndInstall()
|
||||
}
|
||||
|
||||
export async function checkForUpdates(alertOnFail: boolean, killSidecar: () => Promise<void>) {
|
||||
if (!UPDATER_ENABLED) return
|
||||
logger.log("checkForUpdates invoked", { alertOnFail })
|
||||
const result = await checkUpdate()
|
||||
if (!result.updateAvailable) {
|
||||
if (result.failed) {
|
||||
logger.log("no update decision", { reason: "update check failed" })
|
||||
if (!alertOnFail) return
|
||||
await dialog.showMessageBox({
|
||||
type: "error",
|
||||
message: "Update check failed.",
|
||||
title: "Update Error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.log("no update decision", { reason: "already up to date" })
|
||||
if (!alertOnFail) return
|
||||
await dialog.showMessageBox({
|
||||
type: "info",
|
||||
message: "You're up to date.",
|
||||
title: "No Updates",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const response = await dialog.showMessageBox({
|
||||
type: "info",
|
||||
message: `Update ${result.version ?? ""} downloaded. Restart now?`,
|
||||
title: "Update Ready",
|
||||
buttons: ["Restart", "Later"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
})
|
||||
logger.log("update prompt response", {
|
||||
version: result.version ?? null,
|
||||
restartNow: response.response === 0,
|
||||
})
|
||||
if (response.response === 0) {
|
||||
await installUpdate(killSidecar)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -32,6 +32,7 @@
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
|
||||
Vendored
+2
-2
@@ -91,8 +91,8 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"INCIDENT_WEBHOOK_SIGNING_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"HoneycombWebhookSecret": {
|
||||
"type": "random.index/randomPassword.RandomPassword"
|
||||
"value": string
|
||||
}
|
||||
"R2AccessKey": {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"types": ["@cloudflare/workers-types", "vite/client"],
|
||||
"types": ["@cloudflare/workers-types", "vite/client", "bun"],
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.14.39"
|
||||
version = "1.14.48"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.39/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.39",
|
||||
"version": "1.14.48",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
Vendored
+2
-2
@@ -91,8 +91,8 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"INCIDENT_WEBHOOK_SIGNING_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"HoneycombWebhookSecret": {
|
||||
"type": "random.index/randomPassword.RandomPassword"
|
||||
"value": string
|
||||
}
|
||||
"R2AccessKey": {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
# @opencode-ai/http-recorder
|
||||
|
||||
Record and replay HTTP and WebSocket traffic for Effect's `HttpClient`. Tests
|
||||
exercise real request shapes against deterministic, version-controlled
|
||||
cassettes — no manual mocks, no flakes from upstream drift.
|
||||
|
||||
## Install
|
||||
|
||||
Internal package; depended on as `@opencode-ai/http-recorder` from another
|
||||
workspace package.
|
||||
|
||||
```ts
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
Provide `cassetteLayer(name)` in place of (or layered over) your `HttpClient`.
|
||||
By default the layer records on first run and replays on subsequent runs —
|
||||
no env-var ternary at the call site, and `CI=true` forces strict replay so
|
||||
missing cassettes fail loudly in CI rather than silently re-recording.
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const response = yield* http.execute(HttpClientRequest.get("https://api.example.com/users/1"))
|
||||
return yield* response.json
|
||||
})
|
||||
|
||||
// Records if the cassette is missing, replays if it exists.
|
||||
// In CI (CI=true) always replays — fails loudly on missing fixtures.
|
||||
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one"))))
|
||||
|
||||
// Force a refresh — always hits upstream and overwrites.
|
||||
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" }))))
|
||||
```
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
| ------------- | ----------------------------------------------------------------------------------- |
|
||||
| `auto` | Default. Replay if the cassette exists; record if missing. `CI=true` forces replay. |
|
||||
| `replay` | Strict — match the request to a recorded interaction; error if none. |
|
||||
| `record` | Execute upstream, append the interaction, write the cassette. |
|
||||
| `passthrough` | Bypass the recorder entirely — just call upstream. |
|
||||
|
||||
## Cassette format
|
||||
|
||||
A cassette is JSON at `test/fixtures/recordings/<name>.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": { "name": "users/get-one", "recordedAt": "2026-05-09T..." },
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": { "method": "GET", "url": "...", "headers": {...}, "body": "" },
|
||||
"response": { "status": 200, "headers": {...}, "body": "..." }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Cassettes are normal source files — review them, diff them, commit them.
|
||||
|
||||
## Request matching
|
||||
|
||||
By default, requests match on canonicalized method, URL, headers, and JSON
|
||||
body (object keys sorted). Two dispatch strategies are available:
|
||||
|
||||
- **`match`** (default) — find the first recorded interaction whose request
|
||||
matches the incoming request. Same request twice returns the same response.
|
||||
- **`sequential`** — return interactions in the order they were recorded,
|
||||
validating each one matches as the cursor advances. Use for ordered flows
|
||||
where the same URL is hit multiple times with meaningful state changes
|
||||
(pagination, retries, polling).
|
||||
|
||||
```ts
|
||||
HttpRecorder.cassetteLayer("flow/poll-until-done", { dispatch: "sequential" })
|
||||
```
|
||||
|
||||
Supply your own matcher via `match: (incoming, recorded) => boolean` for
|
||||
custom equivalence (e.g. ignoring a timestamp field in the body).
|
||||
|
||||
## Redaction & secret safety
|
||||
|
||||
Cassettes get checked in, so the recorder is aggressive about not letting
|
||||
secrets escape. Redaction is configured by composing a `Redactor`:
|
||||
|
||||
```ts
|
||||
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
|
||||
|
||||
HttpRecorder.cassetteLayer("anthropic/messages", {
|
||||
redactor: Redactor.defaults({
|
||||
requestHeaders: { allow: ["content-type", "anthropic-version"] },
|
||||
url: { transform: (url) => url.replace(/\/accounts\/[^/]+/, "/accounts/{account}") },
|
||||
body: (parsed) => ({ ...(parsed as object), user_id: "{user}" }),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
`Redactor.defaults({ … })` composes the four built-in redactors with your
|
||||
overrides. For full control, build the stack yourself:
|
||||
|
||||
```ts
|
||||
const redactor = Redactor.compose(
|
||||
Redactor.requestHeaders({ allow: ["content-type", "x-custom"] }),
|
||||
Redactor.responseHeaders(),
|
||||
Redactor.url({ query: ["session-id"] }),
|
||||
Redactor.body((parsed) => /* … */),
|
||||
)
|
||||
```
|
||||
|
||||
What each layer does:
|
||||
|
||||
- **`requestHeaders` / `responseHeaders`** — strip headers to a small
|
||||
allow-list (request default: `content-type`, `accept`, `openai-beta`;
|
||||
response default: `content-type`). Sensitive headers within the
|
||||
allow-list (`authorization`, `cookie`, API-key headers, AWS/GCP tokens,
|
||||
…) are replaced with `[REDACTED]`.
|
||||
- **`url`** — query parameters matching common secret names (`api_key`,
|
||||
`token`, `signature`, AWS signing params, …) are replaced with
|
||||
`[REDACTED]`. URL user/password are replaced. `transform` runs after
|
||||
built-in redaction for path-level scrubbing.
|
||||
- **`body`** — receives the parsed JSON request body and returns a redacted
|
||||
version. No-op for non-JSON bodies.
|
||||
|
||||
After assembling the cassette, the recorder scans every string for known
|
||||
secret patterns (Bearer tokens, `sk-…`, `sk-ant-…`, Google `AIza…` keys,
|
||||
AWS access keys, GitHub tokens, PEM blocks) and for values matching any
|
||||
environment variable named like a credential. If anything is found, the
|
||||
cassette is **not written** and the request fails with `UnsafeCassetteError`
|
||||
listing what was detected.
|
||||
|
||||
## WebSocket recording
|
||||
|
||||
WebSocket support records the open frame plus client/server message
|
||||
streams. It uses the shared `Cassette.Service`, so HTTP and WS interactions
|
||||
can live in the same cassette.
|
||||
|
||||
```ts
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const cassette = yield* HttpRecorder.Cassette.Service
|
||||
const executor = yield* HttpRecorder.makeWebSocketExecutor({
|
||||
name: "ws/subscribe",
|
||||
cassette,
|
||||
live: liveExecutor,
|
||||
})
|
||||
// use executor.open(...)
|
||||
})
|
||||
```
|
||||
|
||||
## Inspecting cassettes programmatically
|
||||
|
||||
`Cassette.Service` exposes `read`, `append`, `exists`, and `list`. `read`
|
||||
returns the recorded interactions for a name; the file format is hidden
|
||||
behind the seam. Useful for CI checks:
|
||||
|
||||
```ts
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const audit = Effect.gen(function* () {
|
||||
const cassettes = yield* HttpRecorder.Cassette.Service
|
||||
const entries = yield* cassettes.list()
|
||||
const issues = yield* Effect.forEach(entries, (entry) =>
|
||||
cassettes
|
||||
.read(entry.name)
|
||||
.pipe(Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) }))),
|
||||
)
|
||||
return issues.filter((i) => i.findings.length > 0)
|
||||
})
|
||||
```
|
||||
|
||||
`cassetteLayer` is the batteries-included entry point — it provides
|
||||
`Cassette.fileSystem({ directory })` automatically. If you want to provide
|
||||
your own `Cassette.Service` (e.g. an in-memory adapter for the recorder's
|
||||
own unit tests), use `recordingLayer` and supply `Cassette.fileSystem` /
|
||||
`Cassette.memory` yourself.
|
||||
|
||||
## Options reference
|
||||
|
||||
```ts
|
||||
type RecordReplayOptions = {
|
||||
mode?: "auto" | "replay" | "record" | "passthrough" // default: "auto" (CI=true forces "replay")
|
||||
directory?: string // default: <cwd>/test/fixtures/recordings
|
||||
metadata?: Record<string, unknown> // merged into cassette.metadata
|
||||
redactor?: Redactor // default: Redactor.defaults()
|
||||
dispatch?: "match" | "sequential" // default: "match"
|
||||
match?: (incoming, recorded) => boolean // custom matcher
|
||||
}
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Purpose |
|
||||
| -------------- | -------------------------------------------------------------------------------- |
|
||||
| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. |
|
||||
| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. |
|
||||
| `cassette.ts` | `Cassette.Service` — reads/writes cassette files, accumulates state. |
|
||||
| `recorder.ts` | Shared transport plumbing: `UnsafeCassetteError`, `appendOrFail`, `ReplayState`. |
|
||||
| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. |
|
||||
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
|
||||
| `schema.ts` | Effect Schema definitions for the cassette JSON format. |
|
||||
| `storage.ts` | Path resolution, JSON encode/decode, sync existence check. |
|
||||
| `matching.ts` | Request matcher, canonicalization, dispatch strategies, mismatch diagnostics. |
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.48",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "bun test --timeout 30000",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { secretFindings, type SecretFinding } from "./redaction"
|
||||
import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
|
||||
|
||||
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
|
||||
|
||||
export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFoundError>()("CassetteNotFoundError", {
|
||||
cassetteName: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Cassette "${this.cassetteName}" not found`
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppendResult {
|
||||
readonly findings: ReadonlyArray<SecretFinding>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
|
||||
readonly append: (name: string, interaction: Interaction, metadata?: CassetteMetadata) => Effect.Effect<AppendResult>
|
||||
readonly exists: (name: string) => Effect.Effect<boolean>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<string>>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
|
||||
|
||||
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
|
||||
fs.existsSync(path.join(options.directory ?? DEFAULT_RECORDINGS_DIR, `${name}.json`))
|
||||
|
||||
const buildCassette = (
|
||||
name: string,
|
||||
interactions: ReadonlyArray<Interaction>,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
): Cassette => ({
|
||||
version: 1,
|
||||
metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) },
|
||||
interactions,
|
||||
})
|
||||
|
||||
const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
|
||||
|
||||
const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
|
||||
|
||||
export const fileSystem = (
|
||||
options: { readonly directory?: string } = {},
|
||||
): Layer.Layer<Service, never, FileSystem.FileSystem> =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
|
||||
const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
|
||||
const directoriesEnsured = new Set<string>()
|
||||
|
||||
const cassettePath = (name: string) => path.join(directory, `${name}.json`)
|
||||
|
||||
const ensureDirectory = (name: string) =>
|
||||
Effect.gen(function* () {
|
||||
const dir = path.dirname(cassettePath(name))
|
||||
if (directoriesEnsured.has(dir)) return
|
||||
yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
|
||||
directoriesEnsured.add(dir)
|
||||
})
|
||||
|
||||
const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
|
||||
Effect.gen(function* () {
|
||||
const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
const nested = yield* Effect.forEach(entries, (entry) => {
|
||||
const full = path.join(current, entry)
|
||||
return fs.stat(full).pipe(
|
||||
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
|
||||
Effect.catch(() => Effect.succeed([] as string[])),
|
||||
)
|
||||
})
|
||||
return nested.flat()
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
read: (name) =>
|
||||
fs.readFileString(cassettePath(name)).pipe(
|
||||
Effect.map((raw) => parseCassette(raw).interactions),
|
||||
Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
|
||||
),
|
||||
append: (name, interaction, metadata) =>
|
||||
Effect.gen(function* () {
|
||||
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
|
||||
if (!recorded.has(name)) recorded.set(name, entry)
|
||||
entry.interactions.push(interaction)
|
||||
entry.findings.push(...secretFindings(interaction))
|
||||
const cassette = buildCassette(name, entry.interactions, metadata)
|
||||
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
|
||||
if (findings.length === 0) {
|
||||
yield* ensureDirectory(name)
|
||||
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
|
||||
}
|
||||
return { findings }
|
||||
}),
|
||||
exists: (name) =>
|
||||
fs.access(cassettePath(name)).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
),
|
||||
list: () =>
|
||||
walk(directory).pipe(
|
||||
Effect.map((files) =>
|
||||
files
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.map((file) =>
|
||||
path
|
||||
.relative(directory, file)
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/\.json$/, ""),
|
||||
)
|
||||
.toSorted((a, b) => a.localeCompare(b)),
|
||||
),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> =>
|
||||
Layer.sync(Service, () => {
|
||||
const stored = new Map<string, Interaction[]>(
|
||||
Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
|
||||
)
|
||||
const accumulatedFindings = new Map<string, SecretFinding[]>()
|
||||
|
||||
return Service.of({
|
||||
read: (name) =>
|
||||
stored.has(name)
|
||||
? Effect.succeed(stored.get(name) ?? [])
|
||||
: Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
|
||||
append: (name, interaction, metadata) =>
|
||||
Effect.sync(() => {
|
||||
const existing = stored.get(name)
|
||||
if (existing) existing.push(interaction)
|
||||
else stored.set(name, [interaction])
|
||||
const findings = accumulatedFindings.get(name)
|
||||
if (findings) findings.push(...secretFindings(interaction))
|
||||
else accumulatedFindings.set(name, [...secretFindings(interaction)])
|
||||
if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata }))
|
||||
return { findings: accumulatedFindings.get(name) ?? [] }
|
||||
}),
|
||||
exists: (name) => Effect.sync(() => stored.has(name)),
|
||||
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
Headers,
|
||||
HttpBody,
|
||||
HttpClient,
|
||||
HttpClientError,
|
||||
HttpClientRequest,
|
||||
HttpClientResponse,
|
||||
UrlParams,
|
||||
} from "effect/unstable/http"
|
||||
import * as CassetteService from "./cassette"
|
||||
import { defaultMatcher, selectMatch, selectSequential, type RequestMatcher } from "./matching"
|
||||
import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder"
|
||||
import { defaults, type Redactor } from "./redactor"
|
||||
import { redactUrl } from "./redaction"
|
||||
import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema"
|
||||
|
||||
export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough"
|
||||
|
||||
export interface RecordReplayOptions {
|
||||
readonly mode?: RecordReplayMode
|
||||
readonly directory?: string
|
||||
readonly metadata?: CassetteMetadata
|
||||
readonly redactor?: Redactor
|
||||
readonly dispatch?: "match" | "sequential"
|
||||
readonly match?: RequestMatcher
|
||||
}
|
||||
|
||||
const BINARY_CONTENT_TYPES: ReadonlyArray<string> = ["vnd.amazon.eventstream", "octet-stream"]
|
||||
|
||||
const isBinaryContentType = (contentType: string | undefined) =>
|
||||
contentType !== undefined && BINARY_CONTENT_TYPES.some((token) => contentType.toLowerCase().includes(token))
|
||||
|
||||
const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
|
||||
isBinaryContentType(contentType)
|
||||
? response.arrayBuffer.pipe(
|
||||
Effect.map((bytes) => ({ body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" as const })),
|
||||
)
|
||||
: response.text.pipe(Effect.map((body) => ({ body })))
|
||||
|
||||
const decodeResponseBody = (snapshot: ResponseSnapshot) =>
|
||||
snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
|
||||
|
||||
export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
HttpClientRequest.makeWith(
|
||||
request.method,
|
||||
redactUrl(request.url),
|
||||
UrlParams.empty,
|
||||
Option.none(),
|
||||
Headers.empty,
|
||||
HttpBody.empty,
|
||||
)
|
||||
|
||||
const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) =>
|
||||
new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }),
|
||||
})
|
||||
|
||||
export const recordingLayer = (
|
||||
name: string,
|
||||
options: Omit<RecordReplayOptions, "directory"> = {},
|
||||
): Layer.Layer<HttpClient.HttpClient, never, HttpClient.HttpClient | CassetteService.Service> =>
|
||||
Layer.effect(
|
||||
HttpClient.HttpClient,
|
||||
Effect.gen(function* () {
|
||||
const upstream = yield* HttpClient.HttpClient
|
||||
const cassetteService = yield* CassetteService.Service
|
||||
const redactor = options.redactor ?? defaults()
|
||||
const match = options.match ?? defaultMatcher
|
||||
const requested = options.mode ?? "auto"
|
||||
const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested
|
||||
const sequential = options.dispatch === "sequential"
|
||||
const replay = yield* makeReplayState(cassetteService, name, httpInteractions)
|
||||
|
||||
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
|
||||
return redactor.request({
|
||||
method: web.method,
|
||||
url: web.url,
|
||||
headers: Object.fromEntries(web.headers.entries()),
|
||||
body: yield* Effect.promise(() => web.text()),
|
||||
})
|
||||
})
|
||||
|
||||
return HttpClient.make((request) => {
|
||||
if (mode === "passthrough") return upstream.execute(request)
|
||||
|
||||
if (mode === "record") {
|
||||
return Effect.gen(function* () {
|
||||
const incoming = yield* snapshotRequest(request)
|
||||
const response = yield* upstream.execute(request)
|
||||
const captured = yield* captureResponseBody(response, response.headers["content-type"])
|
||||
const interaction: HttpInteraction = {
|
||||
transport: "http",
|
||||
request: incoming,
|
||||
response: redactor.response({
|
||||
status: response.status,
|
||||
headers: response.headers as Record<string, string>,
|
||||
...captured,
|
||||
}),
|
||||
}
|
||||
yield* appendOrFail(cassetteService, name, interaction, options.metadata).pipe(
|
||||
Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))),
|
||||
)
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(decodeResponseBody(interaction.response), interaction.response),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const incoming = yield* snapshotRequest(request)
|
||||
const interactions = yield* replay.load.pipe(
|
||||
Effect.mapError(() =>
|
||||
transportError(request, `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`),
|
||||
),
|
||||
)
|
||||
const result = sequential
|
||||
? selectSequential(interactions, incoming, match, yield* replay.cursor)
|
||||
: selectMatch(interactions, incoming, match)
|
||||
if (!result.interaction)
|
||||
return yield* Effect.fail(
|
||||
transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`),
|
||||
)
|
||||
if (sequential) yield* replay.advance
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(decodeResponseBody(result.interaction.response), result.interaction.response),
|
||||
)
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
|
||||
recordingLayer(name, options).pipe(
|
||||
Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
export type {
|
||||
CassetteMetadata,
|
||||
HttpInteraction,
|
||||
Interaction,
|
||||
RequestSnapshot,
|
||||
ResponseSnapshot,
|
||||
WebSocketFrame,
|
||||
WebSocketInteraction,
|
||||
} from "./schema"
|
||||
export { CassetteNotFoundError, hasCassetteSync } from "./cassette"
|
||||
export { defaultMatcher, type RequestMatcher } from "./matching"
|
||||
export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction"
|
||||
export { UnsafeCassetteError } from "./recorder"
|
||||
export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect"
|
||||
export {
|
||||
makeWebSocketExecutor,
|
||||
type WebSocketConnection,
|
||||
type WebSocketExecutor,
|
||||
type WebSocketRecordReplayOptions,
|
||||
type WebSocketRequest,
|
||||
} from "./websocket"
|
||||
|
||||
export * as Cassette from "./cassette"
|
||||
export * as Redactor from "./redactor"
|
||||
|
||||
export * as HttpRecorder from "."
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import { REDACTED, secretFindings } from "./redaction"
|
||||
import type { HttpInteraction, RequestSnapshot } from "./schema"
|
||||
|
||||
const JsonValue = Schema.fromJsonString(Schema.Unknown)
|
||||
export const decodeJson = Schema.decodeUnknownOption(JsonValue)
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
|
||||
export const canonicalizeJson = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(canonicalizeJson)
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.toSorted()
|
||||
.map((key) => [key, canonicalizeJson(value[key])]),
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export type RequestMatcher = (incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean
|
||||
|
||||
export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
|
||||
JSON.stringify({
|
||||
method: snapshot.method,
|
||||
url: snapshot.url,
|
||||
headers: canonicalizeJson(snapshot.headers),
|
||||
body: Option.match(decodeJson(snapshot.body), {
|
||||
onNone: () => snapshot.body,
|
||||
onSome: canonicalizeJson,
|
||||
}),
|
||||
})
|
||||
|
||||
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
|
||||
canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
|
||||
|
||||
const safeText = (value: unknown) => {
|
||||
if (value === undefined) return "undefined"
|
||||
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
|
||||
const text = JSON.stringify(value)
|
||||
if (!text) return String(value)
|
||||
return text.length > 300 ? `${text.slice(0, 300)}...` : text
|
||||
}
|
||||
|
||||
const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
|
||||
|
||||
const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
|
||||
if (Object.is(expected, received)) return []
|
||||
if (isRecord(expected) && isRecord(received)) {
|
||||
return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
|
||||
.toSorted()
|
||||
.flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit))
|
||||
.slice(0, limit)
|
||||
}
|
||||
if (Array.isArray(expected) && Array.isArray(received)) {
|
||||
return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
|
||||
.flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
|
||||
.slice(0, limit)
|
||||
}
|
||||
return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
|
||||
}
|
||||
|
||||
const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
|
||||
[...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
|
||||
if (expected[key] === received[key]) return []
|
||||
if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
|
||||
if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
|
||||
return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
|
||||
})
|
||||
|
||||
export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot): ReadonlyArray<string> => {
|
||||
const lines: string[] = []
|
||||
if (expected.method !== received.method) {
|
||||
lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
|
||||
}
|
||||
if (expected.url !== received.url) {
|
||||
lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
|
||||
}
|
||||
const headers = headerDiffs(expected.headers, received.headers)
|
||||
if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
|
||||
const expectedBody = jsonBody(expected.body)
|
||||
const receivedBody = jsonBody(received.body)
|
||||
const body =
|
||||
expectedBody !== undefined && receivedBody !== undefined
|
||||
? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
|
||||
: expected.body === received.body
|
||||
? []
|
||||
: [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
|
||||
if (body.length > 0) lines.push("body:", ...body)
|
||||
return lines
|
||||
}
|
||||
|
||||
export const mismatchDetail = (interactions: ReadonlyArray<HttpInteraction>, incoming: RequestSnapshot): string => {
|
||||
if (interactions.length === 0) return "cassette has no recorded HTTP interactions"
|
||||
const ranked = interactions
|
||||
.map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) }))
|
||||
.toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index)
|
||||
const best = ranked[0]
|
||||
return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n")
|
||||
}
|
||||
|
||||
export const selectMatch = (
|
||||
interactions: ReadonlyArray<HttpInteraction>,
|
||||
incoming: RequestSnapshot,
|
||||
match: RequestMatcher,
|
||||
): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => {
|
||||
const interaction = interactions.find((candidate) => match(incoming, candidate.request))
|
||||
return { interaction, detail: interaction ? "" : mismatchDetail(interactions, incoming) }
|
||||
}
|
||||
|
||||
export const selectSequential = (
|
||||
interactions: ReadonlyArray<HttpInteraction>,
|
||||
incoming: RequestSnapshot,
|
||||
match: RequestMatcher,
|
||||
index: number,
|
||||
): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => {
|
||||
const interaction = interactions[index]
|
||||
if (!interaction) return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
|
||||
if (!match(incoming, interaction.request))
|
||||
return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
|
||||
return { interaction, detail: "" }
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Effect, Ref, Schema, Scope } from "effect"
|
||||
import type * as CassetteService from "./cassette"
|
||||
import type { CassetteNotFoundError } from "./cassette"
|
||||
import { SecretFindingSchema } from "./redaction"
|
||||
import type { CassetteMetadata, Interaction } from "./schema"
|
||||
|
||||
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
|
||||
cassetteName: Schema.String,
|
||||
findings: Schema.Array(SecretFindingSchema),
|
||||
}) {
|
||||
override get message() {
|
||||
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
|
||||
.map((finding) => `${finding.path} (${finding.reason})`)
|
||||
.join(", ")}`
|
||||
}
|
||||
}
|
||||
|
||||
export type ResolvedMode = "record" | "replay" | "passthrough"
|
||||
|
||||
const isCI = () => {
|
||||
const value = process.env.CI
|
||||
return value !== undefined && value !== "" && value !== "false" && value !== "0"
|
||||
}
|
||||
|
||||
export const resolveAutoMode = (cassette: CassetteService.Interface, name: string): Effect.Effect<ResolvedMode> =>
|
||||
Effect.gen(function* () {
|
||||
if (isCI()) return "replay"
|
||||
return (yield* cassette.exists(name)) ? "replay" : "record"
|
||||
})
|
||||
|
||||
export const appendOrFail = (
|
||||
cassette: CassetteService.Interface,
|
||||
name: string,
|
||||
interaction: Interaction,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
): Effect.Effect<void, UnsafeCassetteError> =>
|
||||
cassette
|
||||
.append(name, interaction, metadata)
|
||||
.pipe(
|
||||
Effect.flatMap(({ findings }) =>
|
||||
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })),
|
||||
),
|
||||
)
|
||||
|
||||
export interface ReplayState<T> {
|
||||
readonly load: Effect.Effect<ReadonlyArray<T>, CassetteNotFoundError>
|
||||
readonly cursor: Effect.Effect<number>
|
||||
readonly advance: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export const makeReplayState = <T>(
|
||||
cassette: CassetteService.Interface,
|
||||
name: string,
|
||||
project: (interactions: ReadonlyArray<Interaction>) => ReadonlyArray<T>,
|
||||
): Effect.Effect<ReplayState<T>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project)))
|
||||
const position = yield* Ref.make(0)
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
const used = yield* Ref.get(position)
|
||||
if (used === 0) return
|
||||
const interactions = yield* load.pipe(Effect.orDie)
|
||||
if (used < interactions.length)
|
||||
yield* Effect.die(
|
||||
new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
return { load, cursor: Ref.get(position), advance: Ref.update(position, (n) => n + 1) }
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user