mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-12 01:24:42 +00:00
Compare commits
38 Commits
snapshot-t
...
node-pty
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a53f8e084 | ||
|
|
6f5b2f786e | ||
|
|
7910ce5d36 | ||
|
|
6ad171dba9 | ||
|
|
cb5674edc7 | ||
|
|
b99de4118e | ||
|
|
040700dbc4 | ||
|
|
4d5da9697e | ||
|
|
a28648f530 | ||
|
|
4d81e2d4d9 | ||
|
|
21e72cbf42 | ||
|
|
5f277d1e62 | ||
|
|
d67e877e28 | ||
|
|
d4e51e04b3 | ||
|
|
070c1679e4 | ||
|
|
406d216cd2 | ||
|
|
5dc8b4ef29 | ||
|
|
2f41d89163 | ||
|
|
b2eae867a1 | ||
|
|
3c2fda4d91 | ||
|
|
2678ceb45e | ||
|
|
58a4cd00b6 | ||
|
|
0faa191b6d | ||
|
|
58cf092105 | ||
|
|
0ff8bfe1d9 | ||
|
|
ceb79c786a | ||
|
|
b1a15d559b | ||
|
|
124a8abf9b | ||
|
|
85c2bb342b | ||
|
|
4c57e39466 | ||
|
|
0cdd4e4e16 | ||
|
|
a9b01be0c2 | ||
|
|
528daf5490 | ||
|
|
0e176d3ac3 | ||
|
|
27f359852e | ||
|
|
173128d431 | ||
|
|
e8ee1e239f | ||
|
|
656fa191c1 |
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
@@ -149,10 +149,6 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Cache apt packages
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
uses: actions/cache@v4
|
||||
|
||||
8
.opencode/.gitignore
vendored
8
.opencode/.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
plans/
|
||||
bun.lock
|
||||
node_modules
|
||||
plans
|
||||
package.json
|
||||
package-lock.json
|
||||
bun.lock
|
||||
.gitignore
|
||||
package-lock.json
|
||||
@@ -1,912 +0,0 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { extend, useKeyboard, useTerminalDimensions, type RenderableConstructor } from "@opentui/solid"
|
||||
import { RGBA, VignetteEffect, type OptimizedBuffer, type RenderContext } from "@opentui/core"
|
||||
import { ThreeRenderable, THREE } from "@opentui/core/3d"
|
||||
import type { TuiApi, TuiKeybindSet, TuiPluginInit, TuiPluginInput } 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 pick = (value: unknown, fallback: string) => {
|
||||
if (typeof value !== "string") return fallback
|
||||
if (!value.trim()) return fallback
|
||||
return value
|
||||
}
|
||||
|
||||
const num = (value: unknown, fallback: number) => {
|
||||
if (typeof value !== "number") return fallback
|
||||
return value
|
||||
}
|
||||
|
||||
const rec = (value: unknown) => {
|
||||
if (!value || typeof value !== "object") return
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
type Cfg = {
|
||||
label: string
|
||||
route: string
|
||||
vignette: number
|
||||
keybinds: Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
type Route = {
|
||||
modal: string
|
||||
screen: string
|
||||
}
|
||||
|
||||
type State = {
|
||||
tab: number
|
||||
count: number
|
||||
source: string
|
||||
note: string
|
||||
selected: string
|
||||
local: number
|
||||
}
|
||||
|
||||
const cfg = (options: Record<string, unknown> | undefined) => {
|
||||
return {
|
||||
label: pick(options?.label, "smoke"),
|
||||
route: pick(options?.route, "workspace-smoke"),
|
||||
vignette: Math.max(0, num(options?.vignette, 0.35)),
|
||||
keybinds: rec(options?.keybinds),
|
||||
}
|
||||
}
|
||||
|
||||
const names = (input: Cfg) => {
|
||||
return {
|
||||
modal: `${input.route}.modal`,
|
||||
screen: `${input.route}.screen`,
|
||||
}
|
||||
}
|
||||
|
||||
type Keys = TuiKeybindSet
|
||||
const ui = {
|
||||
panel: "#1d1d1d",
|
||||
border: "#4a4a4a",
|
||||
text: "#f0f0f0",
|
||||
muted: "#a5a5a5",
|
||||
accent: "#5f87ff",
|
||||
}
|
||||
|
||||
type Color = RGBA | string
|
||||
|
||||
const tone = (api: TuiApi) => {
|
||||
const map = api.theme.current as Record<string, unknown>
|
||||
const get = (name: string, fallback: string): Color => {
|
||||
const value = map[name]
|
||||
if (typeof value === "string") return value
|
||||
if (value && typeof value === "object") return value as RGBA
|
||||
return fallback
|
||||
}
|
||||
return {
|
||||
panel: get("backgroundPanel", ui.panel),
|
||||
border: get("border", ui.border),
|
||||
text: get("text", ui.text),
|
||||
muted: get("textMuted", ui.muted),
|
||||
accent: get("primary", ui.accent),
|
||||
selected: get("selectedListItemText", ui.text),
|
||||
}
|
||||
}
|
||||
|
||||
type Skin = {
|
||||
panel: Color
|
||||
border: Color
|
||||
text: Color
|
||||
muted: Color
|
||||
accent: Color
|
||||
selected: Color
|
||||
}
|
||||
type CubeOpts = ConstructorParameters<typeof ThreeRenderable>[1] & {
|
||||
tint?: Color
|
||||
spec?: Color
|
||||
ambient?: Color
|
||||
key_light?: Color
|
||||
fill_light?: Color
|
||||
}
|
||||
|
||||
const rgb = (value: unknown, fallback: string) => {
|
||||
if (typeof value === "string") return new THREE.Color(value)
|
||||
if (value && typeof value === "object") {
|
||||
const item = value as { r?: unknown; g?: unknown; b?: unknown }
|
||||
if (typeof item.r === "number" && typeof item.g === "number" && typeof item.b === "number") {
|
||||
return new THREE.Color(item.r, item.g, item.b)
|
||||
}
|
||||
}
|
||||
return new THREE.Color(fallback)
|
||||
}
|
||||
|
||||
class Cube extends ThreeRenderable {
|
||||
private cube: THREE.Mesh
|
||||
private mat: THREE.MeshPhongMaterial
|
||||
private amb: THREE.AmbientLight
|
||||
private key: THREE.DirectionalLight
|
||||
private fill: THREE.DirectionalLight
|
||||
|
||||
constructor(ctx: RenderContext, opts: CubeOpts) {
|
||||
const scene = new THREE.Scene()
|
||||
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100)
|
||||
camera.position.set(0, 0, 2.55)
|
||||
|
||||
const amb = new THREE.AmbientLight(rgb(opts.ambient, "#666666"), 1.0)
|
||||
scene.add(amb)
|
||||
|
||||
const key = new THREE.DirectionalLight(rgb(opts.key_light, "#fff2e6"), 1.2)
|
||||
key.position.set(2.5, 2.0, 3.0)
|
||||
scene.add(key)
|
||||
|
||||
const fill = new THREE.DirectionalLight(rgb(opts.fill_light, "#80b3ff"), 0.6)
|
||||
fill.position.set(-2.0, -1.5, 2.5)
|
||||
scene.add(fill)
|
||||
|
||||
const geo = new THREE.BoxGeometry(1.0, 1.0, 1.0)
|
||||
const mat = new THREE.MeshPhongMaterial({
|
||||
color: rgb(opts.tint, "#40ccff"),
|
||||
shininess: 80,
|
||||
specular: rgb(opts.spec, "#e6e6ff"),
|
||||
})
|
||||
const cube = new THREE.Mesh(geo, mat)
|
||||
cube.scale.setScalar(1.12)
|
||||
scene.add(cube)
|
||||
|
||||
super(ctx, {
|
||||
...opts,
|
||||
scene,
|
||||
camera,
|
||||
renderer: {
|
||||
focalLength: 8,
|
||||
alpha: true,
|
||||
backgroundColor: RGBA.fromValues(0, 0, 0, 0),
|
||||
},
|
||||
})
|
||||
|
||||
this.cube = cube
|
||||
this.mat = mat
|
||||
this.amb = amb
|
||||
this.key = key
|
||||
this.fill = fill
|
||||
}
|
||||
|
||||
set tint(value: Color | undefined) {
|
||||
this.mat.color.copy(rgb(value, "#40ccff"))
|
||||
}
|
||||
|
||||
set spec(value: Color | undefined) {
|
||||
this.mat.specular.copy(rgb(value, "#e6e6ff"))
|
||||
}
|
||||
|
||||
set ambient(value: Color | undefined) {
|
||||
this.amb.color.copy(rgb(value, "#666666"))
|
||||
}
|
||||
|
||||
set key_light(value: Color | undefined) {
|
||||
this.key.color.copy(rgb(value, "#fff2e6"))
|
||||
}
|
||||
|
||||
set fill_light(value: Color | undefined) {
|
||||
this.fill.color.copy(rgb(value, "#80b3ff"))
|
||||
}
|
||||
|
||||
protected override renderSelf(buf: OptimizedBuffer, dt: number): void {
|
||||
const delta = dt / 1000
|
||||
this.cube.rotation.x += delta * 0.6
|
||||
this.cube.rotation.y += delta * 0.4
|
||||
this.cube.rotation.z += delta * 0.2
|
||||
super.renderSelf(buf, dt)
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@opentui/solid" {
|
||||
interface OpenTUIComponents {
|
||||
smoke_cube: RenderableConstructor
|
||||
}
|
||||
}
|
||||
|
||||
extend({ smoke_cube: Cube as unknown as RenderableConstructor })
|
||||
|
||||
const Btn = (props: { txt: string; run: () => void; skin: Skin; on?: boolean }) => {
|
||||
return (
|
||||
<box
|
||||
onMouseUp={() => {
|
||||
props.run()
|
||||
}}
|
||||
backgroundColor={props.on ? props.skin.accent : props.skin.border}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text fg={props.on ? props.skin.selected : props.skin.text}>{props.txt}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const parse = (params: Record<string, unknown> | undefined) => {
|
||||
const tab = typeof params?.tab === "number" ? params.tab : 0
|
||||
const count = typeof params?.count === "number" ? params.count : 0
|
||||
const source = typeof params?.source === "string" ? params.source : "unknown"
|
||||
const note = typeof params?.note === "string" ? params.note : ""
|
||||
const selected = typeof params?.selected === "string" ? params.selected : ""
|
||||
const local = typeof params?.local === "number" ? params.local : 0
|
||||
return {
|
||||
tab: Math.max(0, Math.min(tab, tabs.length - 1)),
|
||||
count,
|
||||
source,
|
||||
note,
|
||||
selected,
|
||||
local: Math.max(0, local),
|
||||
}
|
||||
}
|
||||
|
||||
const current = (api: TuiApi, route: Route) => {
|
||||
const value = api.route.current
|
||||
const ok = Object.values(route).includes(value.name)
|
||||
if (!ok) return parse(undefined)
|
||||
if (!("params" in value)) return parse(undefined)
|
||||
return parse(value.params)
|
||||
}
|
||||
|
||||
const opts = [
|
||||
{
|
||||
title: "Overview",
|
||||
value: 0,
|
||||
description: "Switch to overview tab",
|
||||
},
|
||||
{
|
||||
title: "Counter",
|
||||
value: 1,
|
||||
description: "Switch to counter tab",
|
||||
},
|
||||
{
|
||||
title: "Help",
|
||||
value: 2,
|
||||
description: "Switch to help tab",
|
||||
},
|
||||
]
|
||||
|
||||
const host = (api: TuiApi, input: Cfg, skin: Skin) => {
|
||||
api.ui.dialog.setSize("medium")
|
||||
api.ui.dialog.replace(() => (
|
||||
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
|
||||
<text fg={skin.text}>
|
||||
<b>{input.label} host overlay</b>
|
||||
</text>
|
||||
<text fg={skin.muted}>Using api.ui.dialog stack with built-in backdrop</text>
|
||||
<text fg={skin.muted}>esc closes · depth {api.ui.dialog.depth}</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Btn txt="close" run={() => api.ui.dialog.clear()} skin={skin} on />
|
||||
</box>
|
||||
</box>
|
||||
))
|
||||
}
|
||||
|
||||
const warn = (api: TuiApi, route: Route, value: State) => {
|
||||
const DialogAlert = api.ui.DialogAlert
|
||||
api.ui.dialog.setSize("medium")
|
||||
api.ui.dialog.replace(() => (
|
||||
<DialogAlert
|
||||
title="Smoke alert"
|
||||
message="Testing built-in alert dialog"
|
||||
onConfirm={() => api.route.navigate(route.screen, { ...value, source: "alert" })}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const check = (api: TuiApi, route: Route, value: State) => {
|
||||
const DialogConfirm = api.ui.DialogConfirm
|
||||
api.ui.dialog.setSize("medium")
|
||||
api.ui.dialog.replace(() => (
|
||||
<DialogConfirm
|
||||
title="Smoke confirm"
|
||||
message="Apply +1 to counter?"
|
||||
onConfirm={() => api.route.navigate(route.screen, { ...value, count: value.count + 1, source: "confirm" })}
|
||||
onCancel={() => api.route.navigate(route.screen, { ...value, source: "confirm-cancel" })}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const entry = (api: TuiApi, route: Route, value: State) => {
|
||||
const DialogPrompt = api.ui.DialogPrompt
|
||||
api.ui.dialog.setSize("medium")
|
||||
api.ui.dialog.replace(() => (
|
||||
<DialogPrompt
|
||||
title="Smoke prompt"
|
||||
value={value.note}
|
||||
onConfirm={(note) => {
|
||||
api.ui.dialog.clear()
|
||||
api.route.navigate(route.screen, { ...value, note, source: "prompt" })
|
||||
}}
|
||||
onCancel={() => {
|
||||
api.ui.dialog.clear()
|
||||
api.route.navigate(route.screen, value)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const picker = (api: TuiApi, route: Route, value: State) => {
|
||||
const DialogSelect = api.ui.DialogSelect
|
||||
api.ui.dialog.setSize("medium")
|
||||
api.ui.dialog.replace(() => (
|
||||
<DialogSelect
|
||||
title="Smoke select"
|
||||
options={opts}
|
||||
current={value.tab}
|
||||
onSelect={(item) => {
|
||||
api.ui.dialog.clear()
|
||||
api.route.navigate(route.screen, {
|
||||
...value,
|
||||
tab: typeof item.value === "number" ? item.value : value.tab,
|
||||
selected: item.title,
|
||||
source: "select",
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
const Screen = (props: {
|
||||
api: TuiApi
|
||||
input: Cfg
|
||||
route: Route
|
||||
keys: Keys
|
||||
meta: TuiPluginInit
|
||||
params?: Record<string, unknown>
|
||||
}) => {
|
||||
const dim = useTerminalDimensions()
|
||||
const value = parse(props.params)
|
||||
const skin = tone(props.api)
|
||||
const set = (local: number, base?: State) => {
|
||||
const next = base ?? current(props.api, props.route)
|
||||
props.api.route.navigate(props.route.screen, { ...next, local: Math.max(0, local), source: "local" })
|
||||
}
|
||||
const push = (base?: State) => {
|
||||
const next = base ?? current(props.api, props.route)
|
||||
set(next.local + 1, next)
|
||||
}
|
||||
const open = () => {
|
||||
const next = current(props.api, props.route)
|
||||
if (next.local > 0) return
|
||||
set(1, next)
|
||||
}
|
||||
const pop = (base?: State) => {
|
||||
const next = base ?? current(props.api, props.route)
|
||||
const local = Math.max(0, next.local - 1)
|
||||
set(local, 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
|
||||
}
|
||||
|
||||
if (next.local > 0) {
|
||||
if (evt.name === "escape" || props.keys.match("local_close", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
pop(next)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("local_push", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
push(next)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (props.keys.match("home", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate("home")
|
||||
return
|
||||
}
|
||||
|
||||
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 (
|
||||
<box width={dim().width} height={dim().height} backgroundColor={skin.panel} position="relative">
|
||||
<box
|
||||
flexDirection="column"
|
||||
width="100%"
|
||||
height="100%"
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
|
||||
<text fg={skin.text}>
|
||||
<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>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} paddingBottom={1}>
|
||||
{tabs.map((item, i) => {
|
||||
const on = value.tab === i
|
||||
return (
|
||||
<Btn
|
||||
txt={item}
|
||||
run={() => props.api.route.navigate(props.route.screen, { ...value, tab: i })}
|
||||
skin={skin}
|
||||
on={on}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</box>
|
||||
|
||||
<box
|
||||
border
|
||||
borderColor={skin.border}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
flexGrow={1}
|
||||
>
|
||||
{value.tab === 0 ? (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={skin.text}>Route: {props.route.screen}</text>
|
||||
<text fg={skin.muted}>plugin state: {props.meta.state}</text>
|
||||
<text fg={skin.muted}>
|
||||
first: {props.meta.state === "first" ? "yes" : "no"} · updated:{" "}
|
||||
{props.meta.state === "updated" ? "yes" : "no"} · loads: {props.meta.entry.load_count}
|
||||
</text>
|
||||
<text fg={skin.muted}>plugin source: {props.meta.entry.source}</text>
|
||||
<text fg={skin.muted}>source: {value.source}</text>
|
||||
<text fg={skin.muted}>note: {value.note || "(none)"}</text>
|
||||
<text fg={skin.muted}>selected: {value.selected || "(none)"}</text>
|
||||
<text fg={skin.muted}>local stack depth: {value.local}</text>
|
||||
<text fg={skin.muted}>host stack open: {props.api.ui.dialog.open ? "yes" : "no"}</text>
|
||||
</box>
|
||||
) : null}
|
||||
|
||||
{value.tab === 1 ? (
|
||||
<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
|
||||
</text>
|
||||
</box>
|
||||
) : null}
|
||||
|
||||
{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
|
||||
</text>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("local")} local stack | {props.keys.print("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
|
||||
</text>
|
||||
<text fg={skin.muted}>{props.keys.print("home")} returns home</text>
|
||||
</box>
|
||||
) : null}
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} paddingTop={1}>
|
||||
<Btn txt="go home" run={() => props.api.route.navigate("home")} skin={skin} />
|
||||
<Btn txt="modal" run={() => props.api.route.navigate(props.route.modal, value)} skin={skin} on />
|
||||
<Btn txt="local overlay" run={show} skin={skin} />
|
||||
<Btn txt="host overlay" run={() => host(props.api, props.input, skin)} skin={skin} />
|
||||
<Btn txt="alert" run={() => warn(props.api, props.route, value)} skin={skin} />
|
||||
<Btn txt="confirm" run={() => check(props.api, props.route, value)} skin={skin} />
|
||||
<Btn txt="prompt" run={() => entry(props.api, props.route, value)} skin={skin} />
|
||||
<Btn txt="select" run={() => picker(props.api, props.route, value)} skin={skin} />
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box
|
||||
visible={value.local > 0}
|
||||
width={dim().width}
|
||||
height={dim().height}
|
||||
alignItems="center"
|
||||
position="absolute"
|
||||
zIndex={3000}
|
||||
paddingTop={dim().height / 4}
|
||||
left={0}
|
||||
top={0}
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 160)}
|
||||
onMouseUp={() => {
|
||||
pop()
|
||||
}}
|
||||
>
|
||||
<box
|
||||
onMouseUp={(evt) => {
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
width={60}
|
||||
maxWidth={dim().width - 2}
|
||||
backgroundColor={skin.panel}
|
||||
border
|
||||
borderColor={skin.border}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
gap={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<text fg={skin.text}>
|
||||
<b>{props.input.label} local overlay</b>
|
||||
</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
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Btn txt="push" run={push} skin={skin} on />
|
||||
<Btn txt="pop" run={pop} skin={skin} />
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const Modal = (props: { api: TuiApi; input: Cfg; route: Route; keys: Keys; params?: Record<string, unknown> }) => {
|
||||
const Dialog = props.api.ui.Dialog
|
||||
const value = parse(props.params)
|
||||
const skin = tone(props.api)
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (props.api.route.current.name !== props.route.modal) return
|
||||
|
||||
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 (
|
||||
<box width="100%" height="100%" backgroundColor={skin.panel}>
|
||||
<Dialog onClose={() => props.api.route.navigate("home")}>
|
||||
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
|
||||
<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}>
|
||||
{props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Btn
|
||||
txt="open screen"
|
||||
run={() => props.api.route.navigate(props.route.screen, { ...value, source: "modal" })}
|
||||
skin={skin}
|
||||
on
|
||||
/>
|
||||
<Btn txt="cancel" run={() => props.api.route.navigate("home")} skin={skin} />
|
||||
</box>
|
||||
</box>
|
||||
</Dialog>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const slot = (input: Cfg) => ({
|
||||
id: "workspace-smoke",
|
||||
slots: {
|
||||
home_logo(ctx) {
|
||||
const map = ctx.theme.current as Record<string, unknown>
|
||||
const get = (name: string, fallback: string) => {
|
||||
const value = map[name]
|
||||
if (typeof value === "string") return value
|
||||
if (value && typeof value === "object") return value as RGBA
|
||||
return fallback
|
||||
}
|
||||
const art = [
|
||||
" $$\\",
|
||||
" $$ |",
|
||||
" $$$$$$$\\ $$$$$$\\$$$$\\ $$$$$$\\ $$ | $$\\ $$$$$$\\",
|
||||
"$$ _____|$$ _$$ _$$\\ $$ __$$\\ $$ | $$ |$$ __$$\\",
|
||||
"\\$$$$$$\\ $$ / $$ / $$ |$$ / $$ |$$$$$$ / $$$$$$$$ |",
|
||||
" \\____$$\\ $$ | $$ | $$ |$$ | $$ |$$ _$$< $$ ____|",
|
||||
"$$$$$$$ |$$ | $$ | $$ |\\$$$$$$ |$$ | \\$$\\ \\$$$$$$$\\",
|
||||
"\\_______/ \\__| \\__| \\__| \\______/ \\__| \\__| \\_______|",
|
||||
]
|
||||
const ink = [
|
||||
get("primary", ui.accent),
|
||||
get("textMuted", ui.muted),
|
||||
get("info", ui.accent),
|
||||
get("text", ui.text),
|
||||
get("success", ui.accent),
|
||||
get("warning", ui.accent),
|
||||
get("secondary", ui.accent),
|
||||
get("error", ui.accent),
|
||||
]
|
||||
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
{art.map((line, i) => (
|
||||
<text fg={ink[i]}>{line}</text>
|
||||
))}
|
||||
</box>
|
||||
)
|
||||
},
|
||||
sidebar_top(ctx, value) {
|
||||
const map = ctx.theme.current as Record<string, unknown>
|
||||
const get = (name: string, fallback: string) => {
|
||||
const item = map[name]
|
||||
if (typeof item === "string") return item
|
||||
if (item && typeof item === "object") return item as RGBA
|
||||
return fallback
|
||||
}
|
||||
|
||||
return (
|
||||
<smoke_cube
|
||||
id={`smoke-cube-${value.session_id.slice(0, 8)}`}
|
||||
width="100%"
|
||||
height={16}
|
||||
tint={get("primary", ui.accent)}
|
||||
spec={get("text", ui.text)}
|
||||
ambient={get("textMuted", ui.muted)}
|
||||
key_light={get("success", ui.accent)}
|
||||
fill_light={get("info", ui.accent)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const reg = (api: TuiApi, 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",
|
||||
},
|
||||
onSelect: () => {
|
||||
api.route.navigate(route.modal, { source: "command" })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} screen`,
|
||||
value: "plugin.smoke.screen",
|
||||
keybind: keys.get("screen"),
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-screen",
|
||||
},
|
||||
onSelect: () => {
|
||||
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} alert dialog`,
|
||||
value: "plugin.smoke.alert",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-alert",
|
||||
},
|
||||
onSelect: () => {
|
||||
warn(api, route, current(api, route))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} confirm dialog`,
|
||||
value: "plugin.smoke.confirm",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-confirm",
|
||||
},
|
||||
onSelect: () => {
|
||||
check(api, route, current(api, route))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `${input.label} prompt dialog`,
|
||||
value: "plugin.smoke.prompt",
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-prompt",
|
||||
},
|
||||
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",
|
||||
keybind: keys.get("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,
|
||||
})
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const tui = async (input: TuiPluginInput, options: Record<string, unknown> | null, meta: TuiPluginInit) => {
|
||||
if (options?.enabled === false) return
|
||||
|
||||
await input.api.theme.install("./smoke-theme.json")
|
||||
input.api.theme.set("smoke-theme")
|
||||
|
||||
const value = cfg(options ?? undefined)
|
||||
const route = names(value)
|
||||
const keys = input.api.keybind.create(bind, value.keybinds)
|
||||
const fx = new VignetteEffect(value.vignette)
|
||||
input.renderer.addPostProcessFn(fx.apply.bind(fx))
|
||||
|
||||
input.api.route.register([
|
||||
{
|
||||
name: route.screen,
|
||||
render: ({ params }) => (
|
||||
<Screen api={input.api} input={value} route={route} keys={keys} meta={meta} params={params} />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: route.modal,
|
||||
render: ({ params }) => <Modal api={input.api} input={value} route={route} keys={keys} params={params} />,
|
||||
},
|
||||
])
|
||||
|
||||
reg(input.api, value, keys)
|
||||
input.slots.register(slot(value))
|
||||
}
|
||||
|
||||
export default {
|
||||
tui,
|
||||
}
|
||||
1
.opencode/themes/.gitignore
vendored
1
.opencode/themes/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
smoke-theme.json
|
||||
@@ -48,11 +48,11 @@
|
||||
"light": "nord10"
|
||||
},
|
||||
"text": {
|
||||
"dark": "nord6",
|
||||
"dark": "nord4",
|
||||
"light": "nord0"
|
||||
},
|
||||
"textMuted": {
|
||||
"dark": "#8B95A7",
|
||||
"dark": "nord3",
|
||||
"light": "nord1"
|
||||
},
|
||||
"background": {
|
||||
@@ -64,7 +64,7 @@
|
||||
"light": "nord5"
|
||||
},
|
||||
"backgroundElement": {
|
||||
"dark": "nord2",
|
||||
"dark": "nord1",
|
||||
"light": "nord4"
|
||||
},
|
||||
"border": {
|
||||
@@ -88,11 +88,11 @@
|
||||
"light": "nord11"
|
||||
},
|
||||
"diffContext": {
|
||||
"dark": "#8B95A7",
|
||||
"dark": "nord3",
|
||||
"light": "nord3"
|
||||
},
|
||||
"diffHunkHeader": {
|
||||
"dark": "#8B95A7",
|
||||
"dark": "nord3",
|
||||
"light": "nord3"
|
||||
},
|
||||
"diffHighlightAdded": {
|
||||
@@ -104,12 +104,12 @@
|
||||
"light": "nord11"
|
||||
},
|
||||
"diffAddedBg": {
|
||||
"dark": "#36413C",
|
||||
"light": "#E6EBE7"
|
||||
"dark": "#3B4252",
|
||||
"light": "#E5E9F0"
|
||||
},
|
||||
"diffRemovedBg": {
|
||||
"dark": "#43393D",
|
||||
"light": "#ECE6E8"
|
||||
"dark": "#3B4252",
|
||||
"light": "#E5E9F0"
|
||||
},
|
||||
"diffContextBg": {
|
||||
"dark": "nord1",
|
||||
@@ -120,12 +120,12 @@
|
||||
"light": "nord4"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#303A35",
|
||||
"light": "#DDE4DF"
|
||||
"dark": "#3B4252",
|
||||
"light": "#E5E9F0"
|
||||
},
|
||||
"diffRemovedLineNumberBg": {
|
||||
"dark": "#3C3336",
|
||||
"light": "#E4DDE0"
|
||||
"dark": "#3B4252",
|
||||
"light": "#E5E9F0"
|
||||
},
|
||||
"markdownText": {
|
||||
"dark": "nord4",
|
||||
@@ -148,7 +148,7 @@
|
||||
"light": "nord14"
|
||||
},
|
||||
"markdownBlockQuote": {
|
||||
"dark": "#8B95A7",
|
||||
"dark": "nord3",
|
||||
"light": "nord3"
|
||||
},
|
||||
"markdownEmph": {
|
||||
@@ -160,7 +160,7 @@
|
||||
"light": "nord13"
|
||||
},
|
||||
"markdownHorizontalRule": {
|
||||
"dark": "#8B95A7",
|
||||
"dark": "nord3",
|
||||
"light": "nord3"
|
||||
},
|
||||
"markdownListItem": {
|
||||
@@ -184,7 +184,7 @@
|
||||
"light": "nord0"
|
||||
},
|
||||
"syntaxComment": {
|
||||
"dark": "#8B95A7",
|
||||
"dark": "nord3",
|
||||
"light": "nord3"
|
||||
},
|
||||
"syntaxKeyword": {
|
||||
@@ -1,6 +1,5 @@
|
||||
/// <reference path="../env.d.ts" />
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import DESCRIPTION from "./github-pr-search.txt"
|
||||
|
||||
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
const response = await fetch(`https://api.github.com${endpoint}`, {
|
||||
@@ -24,7 +23,16 @@ interface PR {
|
||||
}
|
||||
|
||||
export default tool({
|
||||
description: DESCRIPTION,
|
||||
description: `Use this tool to search GitHub pull requests by title and description.
|
||||
|
||||
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
|
||||
- PR number and title
|
||||
- Author
|
||||
- State (open/closed/merged)
|
||||
- Labels
|
||||
- Description snippet
|
||||
|
||||
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
|
||||
args: {
|
||||
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
|
||||
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
Use this tool to search GitHub pull requests by title and description.
|
||||
|
||||
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
|
||||
- PR number and title
|
||||
- Author
|
||||
- State (open/closed/merged)
|
||||
- Labels
|
||||
- Description snippet
|
||||
|
||||
Use the query parameter to search for keywords that might appear in PR titles or descriptions.
|
||||
@@ -1,6 +1,5 @@
|
||||
/// <reference path="../env.d.ts" />
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import DESCRIPTION from "./github-triage.txt"
|
||||
|
||||
const TEAM = {
|
||||
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
||||
@@ -40,7 +39,12 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||
}
|
||||
|
||||
export default tool({
|
||||
description: DESCRIPTION,
|
||||
description: `Use this tool to assign and/or label a GitHub issue.
|
||||
|
||||
Choose labels and assignee using the current triage policy and ownership rules.
|
||||
Pick the most fitting labels for the issue and assign one owner.
|
||||
|
||||
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
|
||||
args: {
|
||||
assignee: tool.schema
|
||||
.enum(ASSIGNEES as [string, ...string[]])
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
Use this tool to assign and/or label a GitHub issue.
|
||||
|
||||
Choose labels and assignee using the current triage policy and ownership rules.
|
||||
Pick the most fitting labels for the issue and assign one owner.
|
||||
|
||||
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"theme": "smoke-theme",
|
||||
"plugin": [
|
||||
[
|
||||
"./plugins/tui-smoke.tsx",
|
||||
{
|
||||
"enabled": true,
|
||||
"label": "workspace",
|
||||
"keybinds": {
|
||||
"modal": "ctrl+alt+m",
|
||||
"screen": "ctrl+alt+o",
|
||||
"home": "escape,ctrl+shift+h",
|
||||
"dialog_close": "escape,q"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -128,7 +128,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
|
||||
|
||||
#### How is this different from Claude Code?
|
||||
|
||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
||||
It's very similar to Claude Code in terms of capability. Here are the key differences::
|
||||
|
||||
- 100% open source
|
||||
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
|
||||
|
||||
@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
|
||||
|
||||
---
|
||||
|
||||
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
**加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
|
||||
|
||||
---
|
||||
|
||||
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
**加入我們的社群** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
|
||||
"prepare": "husky",
|
||||
"random": "echo 'Random script'",
|
||||
"hello": "echo 'Hello World!'",
|
||||
@@ -41,9 +42,9 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"effect": "4.0.0-beta.29",
|
||||
"drizzle-kit": "1.0.0-beta.16-c2458b2",
|
||||
"drizzle-orm": "1.0.0-beta.16-c2458b2",
|
||||
"ai": "5.0.124",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -98,6 +99,7 @@
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
"node-pty",
|
||||
"protobufjs",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
|
||||
@@ -9,12 +9,14 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
await prompt.fill("/terminal")
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/terminal")
|
||||
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(terminal).toBeVisible()
|
||||
|
||||
await prompt.fill("/terminal")
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/terminal")
|
||||
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export const promptSelector = '[data-component="prompt-input"]'
|
||||
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
|
||||
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
|
||||
export const terminalSelector = '[data-component="terminal"]'
|
||||
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
||||
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
||||
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { waitSessionIdle, withSession } from "../actions"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
const count = 14
|
||||
|
||||
function body(mark: string) {
|
||||
return [
|
||||
`title ${mark}`,
|
||||
`mark ${mark}`,
|
||||
...Array.from({ length: 32 }, (_, i) => `line ${String(i + 1).padStart(2, "0")} ${mark}`),
|
||||
]
|
||||
}
|
||||
|
||||
function files(tag: string) {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const id = String(i).padStart(2, "0")
|
||||
return {
|
||||
file: `review-scroll-${id}.txt`,
|
||||
mark: `${tag}-${id}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function seed(list: ReturnType<typeof files>) {
|
||||
const out = ["*** Begin Patch"]
|
||||
|
||||
for (const item of list) {
|
||||
out.push(`*** Add File: ${item.file}`)
|
||||
for (const line of body(item.mark)) out.push(`+${line}`)
|
||||
}
|
||||
|
||||
out.push("*** End Patch")
|
||||
return out.join("\n")
|
||||
}
|
||||
|
||||
function edit(file: string, prev: string, next: string) {
|
||||
return ["*** Begin Patch", `*** Update File: ${file}`, "@@", `-mark ${prev}`, `+mark ${next}`, "*** End Patch"].join(
|
||||
"\n",
|
||||
)
|
||||
}
|
||||
|
||||
async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
|
||||
await sdk.session.promptAsync({
|
||||
sessionID,
|
||||
agent: "build",
|
||||
system: [
|
||||
"You are seeding deterministic e2e UI state.",
|
||||
"Your only valid response is one apply_patch tool call.",
|
||||
`Use this JSON input: ${JSON.stringify({ patchText })}`,
|
||||
"Do not call any other tools.",
|
||||
"Do not output plain text.",
|
||||
].join("\n"),
|
||||
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
||||
})
|
||||
|
||||
await waitSessionIdle(sdk, sessionID, 120_000)
|
||||
}
|
||||
|
||||
async function show(page: Parameters<typeof test>[0]["page"]) {
|
||||
const btn = page.getByRole("button", { name: "Toggle review" }).first()
|
||||
await expect(btn).toBeVisible()
|
||||
if ((await btn.getAttribute("aria-expanded")) !== "true") await btn.click()
|
||||
await expect(btn).toHaveAttribute("aria-expanded", "true")
|
||||
}
|
||||
|
||||
async function expand(page: Parameters<typeof test>[0]["page"]) {
|
||||
const close = page.getByRole("button", { name: /^Collapse all$/i }).first()
|
||||
const open = await close
|
||||
.isVisible()
|
||||
.then((value) => value)
|
||||
.catch(() => false)
|
||||
|
||||
const btn = page.getByRole("button", { name: /^Expand all$/i }).first()
|
||||
if (open) {
|
||||
await close.click()
|
||||
await expect(btn).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(btn).toBeVisible()
|
||||
await btn.click()
|
||||
await expect(close).toBeVisible()
|
||||
}
|
||||
|
||||
async function waitMark(page: Parameters<typeof test>[0]["page"], file: string, mark: string) {
|
||||
await page.waitForFunction(
|
||||
({ file, mark }) => {
|
||||
const head = Array.from(document.querySelectorAll("h3")).find(
|
||||
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
|
||||
)
|
||||
if (!(head instanceof HTMLElement)) return false
|
||||
|
||||
return Array.from(head.parentElement?.querySelectorAll("diffs-container") ?? []).some((host) => {
|
||||
if (!(host instanceof HTMLElement)) return false
|
||||
const root = host.shadowRoot
|
||||
return root?.textContent?.includes(`mark ${mark}`) ?? false
|
||||
})
|
||||
},
|
||||
{ file, mark },
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
}
|
||||
|
||||
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
|
||||
test.setTimeout(180_000)
|
||||
|
||||
const tag = `review-${Date.now()}`
|
||||
const list = files(tag)
|
||||
const hit = list[list.length - 2]!
|
||||
const next = `${tag}-live`
|
||||
|
||||
await page.setViewportSize({ width: 1600, height: 1000 })
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
|
||||
await withSession(sdk, `e2e review ${tag}`, async (session) => {
|
||||
await patch(sdk, session.id, seed(list))
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
|
||||
return info?.summary?.files ?? 0
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(list.length)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||
return diff.length
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(list.length)
|
||||
|
||||
await project.gotoSession(session.id)
|
||||
await show(page)
|
||||
|
||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
|
||||
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
||||
await expect(view).toBeVisible()
|
||||
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
|
||||
await expect(heads).toHaveCount(list.length, {
|
||||
timeout: 60_000,
|
||||
})
|
||||
|
||||
await expand(page)
|
||||
await waitMark(page, hit.file, hit.mark)
|
||||
|
||||
const row = page
|
||||
.getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
|
||||
.first()
|
||||
await expect(row).toBeVisible()
|
||||
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
|
||||
|
||||
await expect.poll(() => view.evaluate((el) => el.scrollTop)).toBeGreaterThan(200)
|
||||
const prev = await view.evaluate((el) => el.scrollTop)
|
||||
|
||||
await patch(sdk, session.id, edit(hit.file, hit.mark, next))
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||
const item = diff.find((item) => item.file === hit.file)
|
||||
return typeof item?.after === "string" ? item.after : ""
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toContain(`mark ${next}`)
|
||||
|
||||
await waitMark(page, hit.file, next)
|
||||
|
||||
await expect
|
||||
.poll(async () => Math.abs((await view.evaluate((el) => el.scrollTop)) - prev), { timeout: 60_000 })
|
||||
.toBeLessThanOrEqual(16)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -490,18 +490,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
setComposing(false)
|
||||
}
|
||||
|
||||
const handleCompositionStart = () => {
|
||||
setComposing(true)
|
||||
}
|
||||
|
||||
const handleCompositionEnd = () => {
|
||||
setComposing(false)
|
||||
requestAnimationFrame(() => {
|
||||
if (composing()) return
|
||||
reconcile(prompt.current().filter((part) => part.type !== "image"))
|
||||
})
|
||||
}
|
||||
|
||||
const agentList = createMemo(() =>
|
||||
sync.data.agent
|
||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||
@@ -692,27 +680,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const reconcile = (input: Prompt) => {
|
||||
if (mirror.input) {
|
||||
mirror.input = false
|
||||
if (isNormalizedEditor()) return
|
||||
|
||||
renderEditorWithCursor(input)
|
||||
return
|
||||
}
|
||||
|
||||
const dom = parseFromDOM()
|
||||
if (isNormalizedEditor() && isPromptEqual(input, dom)) return
|
||||
|
||||
renderEditorWithCursor(input)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => prompt.current(),
|
||||
(parts) => {
|
||||
if (composing()) return
|
||||
reconcile(parts.filter((part) => part.type !== "image"))
|
||||
(currentParts) => {
|
||||
const inputParts = currentParts.filter((part) => part.type !== "image")
|
||||
|
||||
if (mirror.input) {
|
||||
mirror.input = false
|
||||
if (isNormalizedEditor()) return
|
||||
|
||||
renderEditorWithCursor(inputParts)
|
||||
return
|
||||
}
|
||||
|
||||
const domParts = parseFromDOM()
|
||||
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
|
||||
|
||||
renderEditorWithCursor(inputParts)
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1223,8 +1208,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
spellcheck={store.mode === "normal"}
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onCompositionStart={() => setComposing(true)}
|
||||
onCompositionEnd={() => setComposing(false)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
|
||||
@@ -185,9 +185,7 @@ export function StatusPopover() {
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const plugins = createMemo(() =>
|
||||
(sync.data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
|
||||
)
|
||||
const plugins = createMemo(() => sync.data.config.plugin ?? [])
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
const overallHealthy = createMemo(() => {
|
||||
|
||||
@@ -862,36 +862,6 @@ export default function Page() {
|
||||
</div>
|
||||
)
|
||||
|
||||
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
||||
if (store.changes === "turn") return emptyTurn()
|
||||
|
||||
if (hasReview() && !diffsReady()) {
|
||||
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||
}
|
||||
|
||||
if (reviewEmptyKey() === "session.review.noVcs") {
|
||||
return (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-14-medium text-text-strong">Create a Git repository</div>
|
||||
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
|
||||
Track, review, and undo changes in this project
|
||||
</div>
|
||||
</div>
|
||||
<Button size="large" disabled={ui.git} onClick={initGit}>
|
||||
{ui.git ? "Creating Git repository..." : "Create Git repository"}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const reviewContent = (input: {
|
||||
diffStyle: DiffStyle
|
||||
onDiffStyleChange?: (style: DiffStyle) => void
|
||||
@@ -900,25 +870,98 @@ export default function Page() {
|
||||
emptyClass: string
|
||||
}) => (
|
||||
<Show when={!store.deferRender}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={reviewEmpty(input)}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
<Switch>
|
||||
<Match when={store.changes === "turn" && !!params.id}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={emptyTurn()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
|
||||
>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={
|
||||
store.changes === "turn" ? (
|
||||
emptyTurn()
|
||||
) : reviewEmptyKey() === "session.review.noVcs" ? (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-14-medium text-text-strong">Create a Git repository</div>
|
||||
<div
|
||||
class="text-14-regular text-text-base max-w-md"
|
||||
style={{ "line-height": "var(--line-height-normal)" }}
|
||||
>
|
||||
Track, review, and undo changes in this project
|
||||
</div>
|
||||
</div>
|
||||
<Button size="large" disabled={ui.git} onClick={initGit}>
|
||||
{ui.git ? "Creating Git repository..." : "Create Git repository"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export const todoState = (input: {
|
||||
count: number
|
||||
done: boolean
|
||||
live: boolean
|
||||
}): "hide" | "clear" | "open" | "close" => {
|
||||
if (input.count === 0) return "hide"
|
||||
if (!input.live) return "clear"
|
||||
if (!input.done) return "open"
|
||||
return "close"
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { todoState } from "./session-composer-helpers"
|
||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||
|
||||
const session = (input: { id: string; parentID?: string }) =>
|
||||
@@ -104,25 +103,3 @@ describe("sessionQuestionRequest", () => {
|
||||
expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand")
|
||||
})
|
||||
})
|
||||
|
||||
describe("todoState", () => {
|
||||
test("hides when there are no todos", () => {
|
||||
expect(todoState({ count: 0, done: false, live: true })).toBe("hide")
|
||||
})
|
||||
|
||||
test("opens while the session is still working", () => {
|
||||
expect(todoState({ count: 2, done: false, live: true })).toBe("open")
|
||||
})
|
||||
|
||||
test("closes completed todos after a running turn", () => {
|
||||
expect(todoState({ count: 2, done: true, live: true })).toBe("close")
|
||||
})
|
||||
|
||||
test("clears stale todos when the turn ends", () => {
|
||||
expect(todoState({ count: 2, done: false, live: false })).toBe("clear")
|
||||
})
|
||||
|
||||
test("clears completed todos when the session is no longer live", () => {
|
||||
expect(todoState({ count: 2, done: true, live: false })).toBe("clear")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,11 +8,8 @@ import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { todoState } from "./session-composer-helpers"
|
||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
export function createSessionComposerBlocked() {
|
||||
const params = useParams()
|
||||
const permission = usePermission()
|
||||
@@ -62,22 +59,9 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
||||
return globalSync.data.session_todo[id] ?? []
|
||||
})
|
||||
|
||||
const done = createMemo(
|
||||
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
||||
)
|
||||
|
||||
const status = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return idle
|
||||
return sync.data.session_status[id] ?? idle
|
||||
})
|
||||
|
||||
const busy = createMemo(() => status().type !== "idle")
|
||||
const live = createMemo(() => busy() || blocked())
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
responding: undefined as string | undefined,
|
||||
dock: todos().length > 0 && live(),
|
||||
dock: todos().length > 0,
|
||||
closing: false,
|
||||
opening: false,
|
||||
})
|
||||
@@ -105,6 +89,10 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
||||
})
|
||||
}
|
||||
|
||||
const done = createMemo(
|
||||
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
||||
)
|
||||
|
||||
let timer: number | undefined
|
||||
let raf: number | undefined
|
||||
|
||||
@@ -123,42 +111,21 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
||||
}, closeMs())
|
||||
}
|
||||
|
||||
// Keep stale turn todos from reopening if the model never clears them.
|
||||
const clear = () => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
globalSync.todo.set(id, [])
|
||||
sync.set("todo", id, [])
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [todos().length, done(), live()] as const,
|
||||
([count, complete, active]) => {
|
||||
() => [todos().length, done()] as const,
|
||||
([count, complete], prev) => {
|
||||
if (raf) cancelAnimationFrame(raf)
|
||||
raf = undefined
|
||||
|
||||
const next = todoState({
|
||||
count,
|
||||
done: complete,
|
||||
live: active,
|
||||
})
|
||||
|
||||
if (next === "hide") {
|
||||
if (count === 0) {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
setStore({ dock: false, closing: false, opening: false })
|
||||
return
|
||||
}
|
||||
|
||||
if (next === "clear") {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
clear()
|
||||
return
|
||||
}
|
||||
|
||||
if (next === "open") {
|
||||
if (!complete) {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
const hidden = !store.dock || store.closing
|
||||
@@ -175,8 +142,13 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
||||
return
|
||||
}
|
||||
|
||||
if (prev && prev[1]) {
|
||||
if (store.closing && !timer) scheduleClose()
|
||||
return
|
||||
}
|
||||
|
||||
setStore({ dock: true, opening: false, closing: true })
|
||||
if (!timer) scheduleClose()
|
||||
scheduleClose()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -764,7 +764,6 @@ export function MessageTimeline(props: {
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
style={{ "content-visibility": "auto", "contain-intrinsic-size": "auto 500px" }}
|
||||
>
|
||||
<Show when={commentCount() > 0}>
|
||||
<div class="w-full px-4 md:px-5 pb-2">
|
||||
|
||||
@@ -8,12 +8,6 @@ import { useI18n } from "~/context/i18n"
|
||||
export function Footer() {
|
||||
const language = useLanguage()
|
||||
const i18n = useI18n()
|
||||
const community = createMemo(() => {
|
||||
const locale = language.locale()
|
||||
return locale === "zh" || locale === "zht"
|
||||
? ({ key: "footer.feishu", link: language.route("/feishu") } as const)
|
||||
: ({ key: "footer.discord", link: language.route("/discord") } as const)
|
||||
})
|
||||
const githubData = createAsync(() => github())
|
||||
const starCount = createMemo(() =>
|
||||
githubData()?.stars
|
||||
@@ -38,7 +32,7 @@ export function Footer() {
|
||||
<a href={language.route("/changelog")}>{i18n.t("footer.changelog")}</a>
|
||||
</div>
|
||||
<div data-slot="cell">
|
||||
<a href={community().link}>{i18n.t(community().key)}</a>
|
||||
<a href={language.route("/discord")}>{i18n.t("footer.discord")}</a>
|
||||
</div>
|
||||
<div data-slot="cell">
|
||||
<a href={config.social.twitter}>{i18n.t("footer.x")}</a>
|
||||
|
||||
@@ -21,7 +21,6 @@ export const dict = {
|
||||
"footer.github": "GitHub",
|
||||
"footer.docs": "Docs",
|
||||
"footer.changelog": "Changelog",
|
||||
"footer.feishu": "Feishu",
|
||||
"footer.discord": "Discord",
|
||||
"footer.x": "X",
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ export const dict = {
|
||||
"footer.github": "GitHub",
|
||||
"footer.docs": "文档",
|
||||
"footer.changelog": "更新日志",
|
||||
"footer.feishu": "飞书",
|
||||
"footer.discord": "Discord",
|
||||
"footer.x": "X",
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ export const dict = {
|
||||
"footer.github": "GitHub",
|
||||
"footer.docs": "文件",
|
||||
"footer.changelog": "更新日誌",
|
||||
"footer.feishu": "飞书",
|
||||
"footer.discord": "Discord",
|
||||
"footer.x": "X",
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { redirect } from "@solidjs/router"
|
||||
|
||||
export async function GET() {
|
||||
return redirect(
|
||||
"https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true",
|
||||
)
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export function syncCli() {
|
||||
|
||||
let version = ""
|
||||
try {
|
||||
version = execFileSync(installPath, ["--version"], { windowsHide: true }).toString().trim()
|
||||
version = execFileSync(installPath, ["--version"]).toString().trim()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
@@ -147,7 +147,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
|
||||
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
|
||||
const child = spawn(cmd, cmdArgs, {
|
||||
env: envs,
|
||||
detached: process.platform !== "win32",
|
||||
detached: true,
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
|
||||
@@ -8,37 +8,3 @@
|
||||
- **Command**: `bun run db generate --name <slug>`.
|
||||
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
|
||||
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
|
||||
|
||||
# opencode Effect guide
|
||||
|
||||
Instructions to follow when writing Effect.
|
||||
|
||||
## Schemas
|
||||
|
||||
- Use `Schema.Class` for data types with multiple fields.
|
||||
- Use branded schemas (`Schema.brand`) for single-value types.
|
||||
|
||||
## Services
|
||||
|
||||
- Services use `ServiceMap.Service<ServiceName, ServiceName.Service>()("@console/<Name>")`.
|
||||
- In `Layer.effect`, always return service implementations with `ServiceName.of({ ... })`, never a plain object.
|
||||
|
||||
## Errors
|
||||
|
||||
- Use `Schema.TaggedErrorClass` for typed errors.
|
||||
- For defect-like causes, use `Schema.Defect` instead of `unknown`.
|
||||
- In `Effect.gen`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
|
||||
|
||||
## Effects
|
||||
|
||||
- Use `Effect.gen(function* () { ... })` for composition.
|
||||
- Use `Effect.fn("ServiceName.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
|
||||
- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary `flow` or outer `.pipe()` wrappers.
|
||||
|
||||
## Time
|
||||
|
||||
- Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`.
|
||||
|
||||
## Errors
|
||||
|
||||
- In `Effect.gen/fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "bun test --timeout 30000",
|
||||
"test": "bun test --timeout 30000 registry",
|
||||
"build": "bun run script/build.ts",
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
|
||||
"clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
|
||||
@@ -25,9 +26,20 @@
|
||||
"exports": {
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#db": {
|
||||
"bun": "./src/storage/db.bun.ts",
|
||||
"node": "./src/storage/db.node.ts",
|
||||
"default": "./src/storage/db.bun.ts"
|
||||
},
|
||||
"#pty": {
|
||||
"bun": "./src/pty/pty.bun.ts",
|
||||
"node": "./src/pty/pty.node.ts",
|
||||
"default": "./src/pty/pty.bun.ts"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.4",
|
||||
"@effect/language-service": "0.79.0",
|
||||
"@octokit/webhooks-types": "7.6.1",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
@@ -42,13 +54,14 @@
|
||||
"@types/babel__core": "7.20.5",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@types/which": "3.0.4",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"effect": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2",
|
||||
@@ -81,9 +94,12 @@
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||
"@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:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
@@ -92,8 +108,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.0.0-20260310-a34fbfd3",
|
||||
"@opentui/solid": "0.0.0-20260310-a34fbfd3",
|
||||
"@opentui/core": "0.1.86",
|
||||
"@opentui/solid": "0.1.86",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -108,8 +124,7 @@
|
||||
"clipboardy": "4.0.0",
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"effect": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
@@ -120,6 +135,7 @@
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.0.3",
|
||||
"node-pty": "1.1.0",
|
||||
"open": "10.1.2",
|
||||
"opentui-spinner": "0.0.6",
|
||||
"partial-json": "0.1.7",
|
||||
|
||||
54
packages/opencode/script/build-node.ts
Executable file
54
packages/opencode/script/build-node.ts
Executable file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const dir = path.resolve(__dirname, "..")
|
||||
|
||||
process.chdir(dir)
|
||||
|
||||
// Load migrations from migration directories
|
||||
const migrationDirs = (
|
||||
await fs.promises.readdir(path.join(dir, "migration"), {
|
||||
withFileTypes: true,
|
||||
})
|
||||
)
|
||||
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
|
||||
const migrations = await Promise.all(
|
||||
migrationDirs.map(async (name) => {
|
||||
const file = path.join(dir, "migration", name, "migration.sql")
|
||||
const sql = await Bun.file(file).text()
|
||||
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
|
||||
const timestamp = match
|
||||
? Date.UTC(
|
||||
Number(match[1]),
|
||||
Number(match[2]) - 1,
|
||||
Number(match[3]),
|
||||
Number(match[4]),
|
||||
Number(match[5]),
|
||||
Number(match[6]),
|
||||
)
|
||||
: 0
|
||||
return { sql, timestamp, name }
|
||||
}),
|
||||
)
|
||||
console.log(`Loaded ${migrations.length} migrations`)
|
||||
|
||||
await Bun.build({
|
||||
target: "node",
|
||||
entrypoints: ["./src/node.ts"],
|
||||
outdir: "./dist",
|
||||
format: "esm",
|
||||
external: ["jsonc-parser", "node-pty"],
|
||||
define: {
|
||||
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
|
||||
},
|
||||
})
|
||||
|
||||
console.log("Build complete")
|
||||
@@ -4,7 +4,7 @@ import { $ } from "bun"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import solidPlugin from "@opentui/solid/bun-plugin"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
@@ -59,7 +59,6 @@ console.log(`Loaded ${migrations.length} migrations`)
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const plugin = createSolidTransformPlugin({ mode: "build" })
|
||||
|
||||
const allTargets: {
|
||||
os: string
|
||||
@@ -174,7 +173,7 @@ for (const item of targets) {
|
||||
await Bun.build({
|
||||
conditions: ["browser"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [plugin],
|
||||
plugins: [solidPlugin],
|
||||
sourcemap: "external",
|
||||
compile: {
|
||||
autoloadBunfig: false,
|
||||
|
||||
28
packages/opencode/script/fix-node-pty.ts
Normal file
28
packages/opencode/script/fix-node-pty.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const dir = path.resolve(__dirname, "..")
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
const root = path.join(dir, "node_modules", "node-pty", "prebuilds")
|
||||
const dirs = await fs.readdir(root, { withFileTypes: true }).catch(() => [])
|
||||
const files = dirs.filter((x) => x.isDirectory()).map((x) => path.join(root, x.name, "spawn-helper"))
|
||||
const result = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const stat = await fs.stat(file).catch(() => undefined)
|
||||
if (!stat) return
|
||||
if ((stat.mode & 0o111) === 0o111) return
|
||||
await fs.chmod(file, stat.mode | 0o755)
|
||||
return file
|
||||
}),
|
||||
)
|
||||
const fixed = result.filter(Boolean)
|
||||
if (fixed.length) {
|
||||
console.log(`fixed node-pty permissions for ${fixed.length} helper${fixed.length === 1 ? "" : "s"}`)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,20 @@
|
||||
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
|
||||
|
||||
import { type AccessToken, type AccountID, type OrgID, type RefreshToken } from "./schema"
|
||||
import { Timestamps } from "../storage/schema.sql"
|
||||
|
||||
export const AccountTable = sqliteTable("account", {
|
||||
id: text().$type<AccountID>().primaryKey(),
|
||||
id: text().primaryKey(),
|
||||
email: text().notNull(),
|
||||
url: text().notNull(),
|
||||
access_token: text().$type<AccessToken>().notNull(),
|
||||
refresh_token: text().$type<RefreshToken>().notNull(),
|
||||
access_token: text().notNull(),
|
||||
refresh_token: text().notNull(),
|
||||
token_expiry: integer(),
|
||||
...Timestamps,
|
||||
})
|
||||
|
||||
export const AccountStateTable = sqliteTable("account_state", {
|
||||
id: integer().primaryKey(),
|
||||
active_account_id: text()
|
||||
.$type<AccountID>()
|
||||
.references(() => AccountTable.id, { onDelete: "set null" }),
|
||||
active_org_id: text().$type<OrgID>(),
|
||||
active_account_id: text().references(() => AccountTable.id, { onDelete: "set null" }),
|
||||
active_org_id: text(),
|
||||
})
|
||||
|
||||
// LEGACY
|
||||
@@ -27,8 +23,8 @@ export const ControlAccountTable = sqliteTable(
|
||||
{
|
||||
email: text().notNull(),
|
||||
url: text().notNull(),
|
||||
access_token: text().$type<AccessToken>().notNull(),
|
||||
refresh_token: text().$type<RefreshToken>().notNull(),
|
||||
access_token: text().notNull(),
|
||||
refresh_token: text().notNull(),
|
||||
token_expiry: integer(),
|
||||
active: integer({ mode: "boolean" })
|
||||
.notNull()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Option } from "effect"
|
||||
import { Effect, Option, ServiceMap } from "effect"
|
||||
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
@@ -13,11 +13,13 @@ export { AccessToken, AccountID, OrgID } from "./service"
|
||||
|
||||
import { runtime } from "@/effect/runtime"
|
||||
|
||||
function runSync<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
|
||||
type AccountServiceShape = ServiceMap.Service.Shape<typeof AccountService>
|
||||
|
||||
function runSync<A>(f: (service: AccountServiceShape) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runSync(AccountService.use(f))
|
||||
}
|
||||
|
||||
function runPromise<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
|
||||
function runPromise<A>(f: (service: AccountServiceShape) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runPromise(AccountService.use(f))
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,43 @@ import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
|
||||
|
||||
import { Database } from "@/storage/db"
|
||||
import { AccountStateTable, AccountTable } from "./account.sql"
|
||||
import { AccessToken, Account, AccountID, AccountRepoError, OrgID, RefreshToken } from "./schema"
|
||||
import { Account, AccountID, AccountRepoError, OrgID } from "./schema"
|
||||
|
||||
export type AccountRow = (typeof AccountTable)["$inferSelect"]
|
||||
|
||||
const decodeAccount = Schema.decodeUnknownSync(Account)
|
||||
|
||||
type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never
|
||||
|
||||
const ACCOUNT_STATE_ID = 1
|
||||
|
||||
export namespace AccountRepo {
|
||||
export interface Service {
|
||||
const db = <A>(run: (db: DbClient) => A) =>
|
||||
Effect.try({
|
||||
try: () => Database.use(run),
|
||||
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
|
||||
})
|
||||
|
||||
const current = (db: DbClient) => {
|
||||
const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
|
||||
if (!state?.active_account_id) return
|
||||
const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get()
|
||||
if (!account) return
|
||||
return { ...account, active_org_id: state.active_org_id ?? null }
|
||||
}
|
||||
|
||||
const setState = (db: DbClient, accountID: AccountID, orgID: string | null) =>
|
||||
db
|
||||
.insert(AccountStateTable)
|
||||
.values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: orgID })
|
||||
.onConflictDoUpdate({
|
||||
target: AccountStateTable.id,
|
||||
set: { active_account_id: accountID, active_org_id: orgID },
|
||||
})
|
||||
.run()
|
||||
|
||||
export class AccountRepo extends ServiceMap.Service<
|
||||
AccountRepo,
|
||||
{
|
||||
readonly active: () => Effect.Effect<Option.Option<Account>, AccountRepoError>
|
||||
readonly list: () => Effect.Effect<Account[], AccountRepoError>
|
||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
|
||||
@@ -20,96 +47,62 @@ export namespace AccountRepo {
|
||||
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
|
||||
readonly persistToken: (input: {
|
||||
accountID: AccountID
|
||||
accessToken: AccessToken
|
||||
refreshToken: RefreshToken
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiry: Option.Option<number>
|
||||
}) => Effect.Effect<void, AccountRepoError>
|
||||
readonly persistAccount: (input: {
|
||||
id: AccountID
|
||||
email: string
|
||||
url: string
|
||||
accessToken: AccessToken
|
||||
refreshToken: RefreshToken
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiry: number
|
||||
orgID: Option.Option<OrgID>
|
||||
}) => Effect.Effect<void, AccountRepoError>
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Service>()("@opencode/AccountRepo") {
|
||||
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
|
||||
>()("@opencode/AccountRepo") {
|
||||
static readonly layer: Layer.Layer<AccountRepo> = Layer.succeed(
|
||||
AccountRepo,
|
||||
Effect.gen(function* () {
|
||||
const decode = Schema.decodeUnknownSync(Account)
|
||||
AccountRepo.of({
|
||||
active: Effect.fn("AccountRepo.active")(() =>
|
||||
db((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decodeAccount(row)) : Option.none()))),
|
||||
),
|
||||
|
||||
const query = <A>(f: (db: DbClient) => A) =>
|
||||
Effect.try({
|
||||
try: () => Database.use(f),
|
||||
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
|
||||
})
|
||||
|
||||
const tx = <A>(f: (db: DbClient) => A) =>
|
||||
Effect.try({
|
||||
try: () => Database.transaction(f),
|
||||
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
|
||||
})
|
||||
|
||||
const current = (db: DbClient) => {
|
||||
const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
|
||||
if (!state?.active_account_id) return
|
||||
const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get()
|
||||
if (!account) return
|
||||
return { ...account, active_org_id: state.active_org_id ?? null }
|
||||
}
|
||||
|
||||
const state = (db: DbClient, accountID: AccountID, orgID: Option.Option<OrgID>) => {
|
||||
const id = Option.getOrNull(orgID)
|
||||
return db
|
||||
.insert(AccountStateTable)
|
||||
.values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: id })
|
||||
.onConflictDoUpdate({
|
||||
target: AccountStateTable.id,
|
||||
set: { active_account_id: accountID, active_org_id: id },
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
const active = Effect.fn("AccountRepo.active")(() =>
|
||||
query((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))),
|
||||
)
|
||||
|
||||
const list = Effect.fn("AccountRepo.list")(() =>
|
||||
query((db) =>
|
||||
list: Effect.fn("AccountRepo.list")(() =>
|
||||
db((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(AccountTable)
|
||||
.all()
|
||||
.map((row: AccountRow) => decode({ ...row, active_org_id: null })),
|
||||
.map((row) => decodeAccount({ ...row, active_org_id: null })),
|
||||
),
|
||||
)
|
||||
),
|
||||
|
||||
const remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
|
||||
tx((db) => {
|
||||
db.update(AccountStateTable)
|
||||
.set({ active_account_id: null, active_org_id: null })
|
||||
.where(eq(AccountStateTable.active_account_id, accountID))
|
||||
.run()
|
||||
db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
|
||||
}).pipe(Effect.asVoid),
|
||||
)
|
||||
remove: Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
|
||||
db((db) =>
|
||||
Database.transaction((tx) => {
|
||||
tx.update(AccountStateTable)
|
||||
.set({ active_account_id: null, active_org_id: null })
|
||||
.where(eq(AccountStateTable.active_account_id, accountID))
|
||||
.run()
|
||||
tx.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
|
||||
}),
|
||||
).pipe(Effect.asVoid),
|
||||
),
|
||||
|
||||
const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
|
||||
query((db) => state(db, accountID, orgID)).pipe(Effect.asVoid),
|
||||
)
|
||||
use: Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
|
||||
db((db) => setState(db, accountID, Option.getOrNull(orgID))).pipe(Effect.asVoid),
|
||||
),
|
||||
|
||||
const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
|
||||
query((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
|
||||
getRow: Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
|
||||
db((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
|
||||
Effect.map(Option.fromNullishOr),
|
||||
),
|
||||
)
|
||||
),
|
||||
|
||||
const persistToken = Effect.fn("AccountRepo.persistToken")((input) =>
|
||||
query((db) =>
|
||||
persistToken: Effect.fn("AccountRepo.persistToken")((input) =>
|
||||
db((db) =>
|
||||
db
|
||||
.update(AccountTable)
|
||||
.set({
|
||||
@@ -120,41 +113,34 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
|
||||
.where(eq(AccountTable.id, input.accountID))
|
||||
.run(),
|
||||
).pipe(Effect.asVoid),
|
||||
)
|
||||
),
|
||||
|
||||
const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) =>
|
||||
tx((db) => {
|
||||
db.insert(AccountTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
email: input.email,
|
||||
url: input.url,
|
||||
access_token: input.accessToken,
|
||||
refresh_token: input.refreshToken,
|
||||
token_expiry: input.expiry,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: AccountTable.id,
|
||||
set: {
|
||||
persistAccount: Effect.fn("AccountRepo.persistAccount")((input) => {
|
||||
const orgID = Option.getOrNull(input.orgID)
|
||||
return db((db) =>
|
||||
Database.transaction((tx) => {
|
||||
tx.insert(AccountTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
email: input.email,
|
||||
url: input.url,
|
||||
access_token: input.accessToken,
|
||||
refresh_token: input.refreshToken,
|
||||
token_expiry: input.expiry,
|
||||
},
|
||||
})
|
||||
.run()
|
||||
void state(db, input.id, input.orgID)
|
||||
}).pipe(Effect.asVoid),
|
||||
)
|
||||
|
||||
return AccountRepo.of({
|
||||
active,
|
||||
list,
|
||||
remove,
|
||||
use,
|
||||
getRow,
|
||||
persistToken,
|
||||
persistAccount,
|
||||
})
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: AccountTable.id,
|
||||
set: {
|
||||
access_token: input.accessToken,
|
||||
refresh_token: input.refreshToken,
|
||||
token_expiry: input.expiry,
|
||||
},
|
||||
})
|
||||
.run()
|
||||
setState(tx, input.id, orgID)
|
||||
}),
|
||||
).pipe(Effect.asVoid)
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,24 +20,6 @@ export const AccessToken = Schema.String.pipe(
|
||||
)
|
||||
export type AccessToken = Schema.Schema.Type<typeof AccessToken>
|
||||
|
||||
export const RefreshToken = Schema.String.pipe(
|
||||
Schema.brand("RefreshToken"),
|
||||
withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
|
||||
)
|
||||
export type RefreshToken = Schema.Schema.Type<typeof RefreshToken>
|
||||
|
||||
export const DeviceCode = Schema.String.pipe(
|
||||
Schema.brand("DeviceCode"),
|
||||
withStatics((s) => ({ make: (code: string) => s.makeUnsafe(code) })),
|
||||
)
|
||||
export type DeviceCode = Schema.Schema.Type<typeof DeviceCode>
|
||||
|
||||
export const UserCode = Schema.String.pipe(
|
||||
Schema.brand("UserCode"),
|
||||
withStatics((s) => ({ make: (code: string) => s.makeUnsafe(code) })),
|
||||
)
|
||||
export type UserCode = Schema.Schema.Type<typeof UserCode>
|
||||
|
||||
export class Account extends Schema.Class<Account>("Account")({
|
||||
id: AccountID,
|
||||
email: Schema.String,
|
||||
@@ -63,12 +45,12 @@ export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceE
|
||||
export type AccountError = AccountRepoError | AccountServiceError
|
||||
|
||||
export class Login extends Schema.Class<Login>("Login")({
|
||||
code: DeviceCode,
|
||||
user: UserCode,
|
||||
code: Schema.String,
|
||||
user: Schema.String,
|
||||
url: Schema.String,
|
||||
server: Schema.String,
|
||||
expiry: Schema.Duration,
|
||||
interval: Schema.Duration,
|
||||
expiry: Schema.Number,
|
||||
interval: Schema.Number,
|
||||
}) {}
|
||||
|
||||
export class PollSuccess extends Schema.TaggedClass<PollSuccess>()("PollSuccess", {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Clock, Effect, Layer, Option, Schema, ServiceMap } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
HttpClient,
|
||||
HttpClientError,
|
||||
HttpClientRequest,
|
||||
HttpClientResponse,
|
||||
} from "effect/unstable/http"
|
||||
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { AccountRepo, type AccountRow } from "./repo"
|
||||
@@ -8,8 +14,6 @@ import {
|
||||
AccessToken,
|
||||
Account,
|
||||
AccountID,
|
||||
DeviceCode,
|
||||
RefreshToken,
|
||||
AccountServiceError,
|
||||
Login,
|
||||
Org,
|
||||
@@ -21,101 +25,83 @@ import {
|
||||
type PollResult,
|
||||
PollSlow,
|
||||
PollSuccess,
|
||||
UserCode,
|
||||
} from "./schema"
|
||||
|
||||
export * from "./schema"
|
||||
|
||||
export type AccountOrgs = {
|
||||
account: Account
|
||||
orgs: readonly Org[]
|
||||
orgs: Org[]
|
||||
}
|
||||
|
||||
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
||||
const RemoteOrg = Schema.Struct({
|
||||
id: Schema.optional(OrgID),
|
||||
name: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const RemoteOrgs = Schema.Array(RemoteOrg)
|
||||
|
||||
const RemoteConfig = Schema.Struct({
|
||||
config: Schema.Record(Schema.String, Schema.Json),
|
||||
}) {}
|
||||
})
|
||||
|
||||
const DurationFromSeconds = Schema.Number.pipe(
|
||||
Schema.decodeTo(Schema.Duration, {
|
||||
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
||||
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
||||
}),
|
||||
)
|
||||
const TokenRefresh = Schema.Struct({
|
||||
access_token: Schema.String,
|
||||
refresh_token: Schema.optional(Schema.String),
|
||||
expires_in: Schema.optional(Schema.Number),
|
||||
})
|
||||
|
||||
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
|
||||
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||
device_code: DeviceCode,
|
||||
user_code: UserCode,
|
||||
const DeviceCode = Schema.Struct({
|
||||
device_code: Schema.String,
|
||||
user_code: Schema.String,
|
||||
verification_uri_complete: Schema.String,
|
||||
expires_in: DurationFromSeconds,
|
||||
interval: DurationFromSeconds,
|
||||
}) {}
|
||||
expires_in: Schema.Number,
|
||||
interval: Schema.Number,
|
||||
})
|
||||
|
||||
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
||||
access_token: AccessToken,
|
||||
refresh_token: RefreshToken,
|
||||
token_type: Schema.Literal("Bearer"),
|
||||
expires_in: DurationFromSeconds,
|
||||
}) {}
|
||||
const DeviceToken = Schema.Struct({
|
||||
access_token: Schema.optional(Schema.String),
|
||||
refresh_token: Schema.optional(Schema.String),
|
||||
expires_in: Schema.optional(Schema.Number),
|
||||
error: Schema.optional(Schema.String),
|
||||
error_description: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
||||
error: Schema.String,
|
||||
error_description: Schema.String,
|
||||
}) {
|
||||
toPollResult(): PollResult {
|
||||
if (this.error === "authorization_pending") return new PollPending()
|
||||
if (this.error === "slow_down") return new PollSlow()
|
||||
if (this.error === "expired_token") return new PollExpired()
|
||||
if (this.error === "access_denied") return new PollDenied()
|
||||
return new PollError({ cause: this.error })
|
||||
}
|
||||
}
|
||||
const User = Schema.Struct({
|
||||
id: Schema.optional(AccountID),
|
||||
email: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
||||
const ClientId = Schema.Struct({ client_id: Schema.String })
|
||||
|
||||
class User extends Schema.Class<User>("User")({
|
||||
id: AccountID,
|
||||
email: Schema.String,
|
||||
}) {}
|
||||
|
||||
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
||||
|
||||
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
||||
const DeviceTokenRequest = Schema.Struct({
|
||||
grant_type: Schema.String,
|
||||
device_code: DeviceCode,
|
||||
device_code: Schema.String,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
|
||||
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
||||
grant_type: Schema.String,
|
||||
refresh_token: RefreshToken,
|
||||
client_id: Schema.String,
|
||||
}) {}
|
||||
})
|
||||
|
||||
const clientId = "opencode-cli"
|
||||
|
||||
const toAccountServiceError = (message: string, cause?: unknown) => new AccountServiceError({ message, cause })
|
||||
|
||||
const mapAccountServiceError =
|
||||
(message = "Account service operation failed") =>
|
||||
(operation: string, message = "Account service operation failed") =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
|
||||
effect.pipe(
|
||||
Effect.mapError((cause) =>
|
||||
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof AccountServiceError ? error : toAccountServiceError(`${message} (${operation})`, error),
|
||||
),
|
||||
)
|
||||
|
||||
export namespace AccountService {
|
||||
export interface Service {
|
||||
export class AccountService extends ServiceMap.Service<
|
||||
AccountService,
|
||||
{
|
||||
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
|
||||
readonly list: () => Effect.Effect<Account[], AccountError>
|
||||
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
||||
readonly orgsByAccount: () => Effect.Effect<AccountOrgs[], AccountError>
|
||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
|
||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
|
||||
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
|
||||
readonly orgs: (accountID: AccountID) => Effect.Effect<Org[], AccountError>
|
||||
readonly config: (
|
||||
accountID: AccountID,
|
||||
orgID: OrgID,
|
||||
@@ -124,98 +110,80 @@ export namespace AccountService {
|
||||
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
||||
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountService extends ServiceMap.Service<AccountService, AccountService.Service>()("@opencode/Account") {
|
||||
>()("@opencode/Account") {
|
||||
static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
||||
AccountService,
|
||||
Effect.gen(function* () {
|
||||
const repo = yield* AccountRepo
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const httpRead = withTransientReadRetry(http)
|
||||
const httpOk = HttpClient.filterStatusOk(http)
|
||||
const httpReadOk = HttpClient.filterStatusOk(httpRead)
|
||||
|
||||
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||
const execute = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
|
||||
http.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
|
||||
|
||||
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||
const executeRead = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
|
||||
httpRead.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
|
||||
|
||||
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
const executeEffect = <E>(operation: string, request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => httpOk.execute(req)),
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
Effect.flatMap((req) => http.execute(req)),
|
||||
mapAccountServiceError(operation, "HTTP request failed"),
|
||||
)
|
||||
|
||||
// Returns a usable access token for a stored account row, refreshing and
|
||||
// persisting it when the cached token has expired.
|
||||
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
||||
const okOrNone = (operation: string, response: HttpClientResponse.HttpClientResponse) =>
|
||||
HttpClientResponse.filterStatusOk(response).pipe(
|
||||
Effect.map(Option.some),
|
||||
Effect.catch((error) =>
|
||||
HttpClientError.isHttpClientError(error) && error.reason._tag === "StatusCodeError"
|
||||
? Effect.succeed(Option.none<HttpClientResponse.HttpClientResponse>())
|
||||
: Effect.fail(error),
|
||||
),
|
||||
mapAccountServiceError(operation),
|
||||
)
|
||||
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
|
||||
const tokenForRow = Effect.fn("AccountService.tokenForRow")(function* (found: AccountRow) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (found.token_expiry && found.token_expiry > now) return Option.some(AccessToken.make(found.access_token))
|
||||
|
||||
const response = yield* execute(
|
||||
"token.refresh",
|
||||
HttpClientRequest.post(`${found.url}/oauth/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
|
||||
new TokenRefreshRequest({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: row.refresh_token,
|
||||
client_id: clientId,
|
||||
}),
|
||||
),
|
||||
HttpClientRequest.bodyUrlParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: found.refresh_token,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
const ok = yield* okOrNone("token.refresh", response)
|
||||
if (Option.isNone(ok)) return Option.none()
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(ok.value).pipe(
|
||||
mapAccountServiceError("token.refresh", "Failed to decode response"),
|
||||
)
|
||||
|
||||
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
|
||||
const expiry = Option.fromNullishOr(parsed.expires_in).pipe(Option.map((e) => now + e * 1000))
|
||||
|
||||
yield* repo.persistToken({
|
||||
accountID: row.id,
|
||||
accountID: AccountID.make(found.id),
|
||||
accessToken: parsed.access_token,
|
||||
refreshToken: parsed.refresh_token,
|
||||
refreshToken: parsed.refresh_token ?? found.refresh_token,
|
||||
expiry,
|
||||
})
|
||||
|
||||
return parsed.access_token
|
||||
return Option.some(AccessToken.make(parsed.access_token))
|
||||
})
|
||||
|
||||
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
|
||||
const resolveAccess = Effect.fn("AccountService.resolveAccess")(function* (accountID: AccountID) {
|
||||
const maybeAccount = yield* repo.getRow(accountID)
|
||||
if (Option.isNone(maybeAccount)) return Option.none()
|
||||
if (Option.isNone(maybeAccount)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
|
||||
|
||||
const account = maybeAccount.value
|
||||
const accessToken = yield* resolveToken(account)
|
||||
return Option.some({ account, accessToken })
|
||||
})
|
||||
const accessToken = yield* tokenForRow(account)
|
||||
if (Option.isNone(accessToken)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
|
||||
|
||||
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||
const response = yield* executeReadOk(
|
||||
HttpClientRequest.get(`${url}/api/orgs`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
),
|
||||
)
|
||||
|
||||
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
})
|
||||
|
||||
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||
const response = yield* executeReadOk(
|
||||
HttpClientRequest.get(`${url}/api/user`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
),
|
||||
)
|
||||
|
||||
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
)
|
||||
return Option.some({ account, accessToken: accessToken.value })
|
||||
})
|
||||
|
||||
const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
|
||||
@@ -224,17 +192,11 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
|
||||
const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
|
||||
const accounts = yield* repo.list()
|
||||
const [errors, results] = yield* Effect.partition(
|
||||
return yield* Effect.forEach(
|
||||
accounts,
|
||||
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
for (const error of errors) {
|
||||
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
|
||||
Effect.annotateLogs({ error: String(error) }),
|
||||
)
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {
|
||||
@@ -243,7 +205,23 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
|
||||
const { account, accessToken } = resolved.value
|
||||
|
||||
return yield* fetchOrgs(account.url, accessToken)
|
||||
const response = yield* executeRead(
|
||||
"orgs",
|
||||
HttpClientRequest.get(`${account.url}/api/orgs`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
),
|
||||
)
|
||||
|
||||
const ok = yield* okOrNone("orgs", response)
|
||||
if (Option.isNone(ok)) return []
|
||||
|
||||
const orgs = yield* HttpClientResponse.schemaBodyJson(RemoteOrgs)(ok.value).pipe(
|
||||
mapAccountServiceError("orgs", "Failed to decode response"),
|
||||
)
|
||||
return orgs
|
||||
.filter((org) => org.id !== undefined && org.name !== undefined)
|
||||
.map((org) => new Org({ id: org.id!, name: org.name! }))
|
||||
})
|
||||
|
||||
const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) {
|
||||
@@ -253,6 +231,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
const { account, accessToken } = resolved.value
|
||||
|
||||
const response = yield* executeRead(
|
||||
"config",
|
||||
HttpClientRequest.get(`${account.url}/api/config`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
@@ -260,26 +239,32 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
),
|
||||
)
|
||||
|
||||
if (response.status === 404) return Option.none()
|
||||
const ok = yield* okOrNone("config", response)
|
||||
if (Option.isNone(ok)) return Option.none()
|
||||
|
||||
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok.value).pipe(
|
||||
mapAccountServiceError("config", "Failed to decode response"),
|
||||
)
|
||||
return Option.some(parsed.config)
|
||||
})
|
||||
|
||||
const login = Effect.fn("AccountService.login")(function* (server: string) {
|
||||
const response = yield* executeEffectOk(
|
||||
const response = yield* executeEffect(
|
||||
"login",
|
||||
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
|
||||
HttpClientRequest.schemaBodyJson(ClientId)({ client_id: clientId }),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
const ok = yield* okOrNone("login", response)
|
||||
if (Option.isNone(ok)) {
|
||||
const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
||||
return yield* toAccountServiceError(`Failed to initiate device flow: ${body || response.status}`)
|
||||
}
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceCode)(ok.value).pipe(
|
||||
mapAccountServiceError("login", "Failed to decode response"),
|
||||
)
|
||||
return new Login({
|
||||
code: parsed.device_code,
|
||||
@@ -292,49 +277,91 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
})
|
||||
|
||||
const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
|
||||
const response = yield* executeEffectOk(
|
||||
const response = yield* executeEffect(
|
||||
"poll",
|
||||
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
|
||||
new DeviceTokenRequest({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
device_code: input.code,
|
||||
client_id: clientId,
|
||||
}),
|
||||
),
|
||||
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
device_code: input.code,
|
||||
client_id: clientId,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
|
||||
mapAccountServiceError("Failed to decode response"),
|
||||
mapAccountServiceError("poll", "Failed to decode response"),
|
||||
)
|
||||
|
||||
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
|
||||
const accessToken = parsed.access_token
|
||||
if (!parsed.access_token) {
|
||||
if (parsed.error === "authorization_pending") return new PollPending()
|
||||
if (parsed.error === "slow_down") return new PollSlow()
|
||||
if (parsed.error === "expired_token") return new PollExpired()
|
||||
if (parsed.error === "access_denied") return new PollDenied()
|
||||
return new PollError({ cause: parsed.error })
|
||||
}
|
||||
|
||||
const user = fetchUser(input.server, accessToken)
|
||||
const orgs = fetchOrgs(input.server, accessToken)
|
||||
const access = parsed.access_token
|
||||
|
||||
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
|
||||
const fetchUser = executeRead(
|
||||
"poll.user",
|
||||
HttpClientRequest.get(`${input.server}/api/user`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(access),
|
||||
),
|
||||
).pipe(
|
||||
Effect.flatMap((r) =>
|
||||
HttpClientResponse.schemaBodyJson(User)(r).pipe(
|
||||
mapAccountServiceError("poll.user", "Failed to decode response"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// TODO: When there are multiple orgs, let the user choose
|
||||
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
|
||||
const fetchOrgs = executeRead(
|
||||
"poll.orgs",
|
||||
HttpClientRequest.get(`${input.server}/api/orgs`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(access),
|
||||
),
|
||||
).pipe(
|
||||
Effect.flatMap((r) =>
|
||||
HttpClientResponse.schemaBodyJson(RemoteOrgs)(r).pipe(
|
||||
mapAccountServiceError("poll.orgs", "Failed to decode response"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const [user, remoteOrgs] = yield* Effect.all([fetchUser, fetchOrgs], { concurrency: 2 })
|
||||
|
||||
const userId = user.id
|
||||
const userEmail = user.email
|
||||
|
||||
if (!userId || !userEmail) {
|
||||
return new PollError({ cause: "No id or email in response" })
|
||||
}
|
||||
|
||||
const firstOrgID = remoteOrgs.length > 0 ? Option.fromNullishOr(remoteOrgs[0].id) : Option.none()
|
||||
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const expiry = now + Duration.toMillis(parsed.expires_in)
|
||||
const refreshToken = parsed.refresh_token
|
||||
const expiry = now + (parsed.expires_in ?? 0) * 1000
|
||||
const refresh = parsed.refresh_token ?? ""
|
||||
if (!refresh) {
|
||||
yield* Effect.logWarning(
|
||||
"Server did not return a refresh token — session may expire without ability to refresh",
|
||||
)
|
||||
}
|
||||
|
||||
yield* repo.persistAccount({
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
id: userId,
|
||||
email: userEmail,
|
||||
url: input.server,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accessToken: access,
|
||||
refreshToken: refresh,
|
||||
expiry,
|
||||
orgID: firstOrgID,
|
||||
})
|
||||
|
||||
return new PollSuccess({ email: account.email })
|
||||
return new PollSuccess({ email: userEmail })
|
||||
})
|
||||
|
||||
return AccountService.of({
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import z from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { Lock } from "../util/lock"
|
||||
import { PackageRegistry } from "./registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
export namespace BunProc {
|
||||
@@ -45,88 +37,4 @@ export namespace BunProc {
|
||||
export function which() {
|
||||
return process.execPath
|
||||
}
|
||||
|
||||
export const InstallFailedError = NamedError.create(
|
||||
"BunInstallFailedError",
|
||||
z.object({
|
||||
pkg: z.string(),
|
||||
version: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export async function install(pkg: string, version = "latest") {
|
||||
// Use lock to ensure only one install at a time
|
||||
using _ = await Lock.write("bun-install")
|
||||
|
||||
const mod = path.join(Global.Path.cache, "node_modules", pkg)
|
||||
const pkgjsonPath = path.join(Global.Path.cache, "package.json")
|
||||
const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
|
||||
const result = { dependencies: {} as Record<string, string> }
|
||||
await Filesystem.writeJson(pkgjsonPath, result)
|
||||
return result
|
||||
})
|
||||
if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
|
||||
const dependencies = parsed.dependencies
|
||||
const modExists = await Filesystem.exists(mod)
|
||||
const cachedVersion = dependencies[pkg]
|
||||
|
||||
if (!modExists || !cachedVersion) {
|
||||
// continue to install
|
||||
} else if (version === "latest") {
|
||||
if (!PackageRegistry.online()) return mod
|
||||
const stale = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
|
||||
if (!stale) return mod
|
||||
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
|
||||
} else if (cachedVersion === version) {
|
||||
return mod
|
||||
}
|
||||
|
||||
// Build command arguments
|
||||
const args = [
|
||||
"add",
|
||||
"--force",
|
||||
"--exact",
|
||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
||||
"--cwd",
|
||||
Global.Path.cache,
|
||||
pkg + "@" + version,
|
||||
]
|
||||
|
||||
// Let Bun handle registry resolution:
|
||||
// - If .npmrc files exist, Bun will use them automatically
|
||||
// - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
|
||||
// - No need to pass --registry flag
|
||||
log.info("installing package using Bun's default registry resolution", {
|
||||
pkg,
|
||||
version,
|
||||
})
|
||||
|
||||
await BunProc.run(args, {
|
||||
cwd: Global.Path.cache,
|
||||
}).catch((e) => {
|
||||
throw new InstallFailedError(
|
||||
{ pkg, version },
|
||||
{
|
||||
cause: e,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// Resolve actual version from installed package when using "latest"
|
||||
// This ensures subsequent starts use the cached version until explicitly updated
|
||||
let resolvedVersion = version
|
||||
if (version === "latest") {
|
||||
const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
|
||||
() => null,
|
||||
)
|
||||
if (installedPkg?.version) {
|
||||
resolvedVersion = installedPkg.version
|
||||
}
|
||||
}
|
||||
|
||||
parsed.dependencies[pkg] = resolvedVersion
|
||||
await Filesystem.writeJson(pkgjsonPath, parsed)
|
||||
return mod
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import semver from "semver"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { Log } from "../util/log"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
@@ -9,47 +9,28 @@ export namespace PackageRegistry {
|
||||
return process.execPath
|
||||
}
|
||||
|
||||
export function online() {
|
||||
const nav = globalThis.navigator
|
||||
if (!nav || typeof nav.onLine !== "boolean") return true
|
||||
return nav.onLine
|
||||
}
|
||||
|
||||
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
|
||||
if (!online()) {
|
||||
log.debug("offline, skipping bun info", { pkg, field })
|
||||
return null
|
||||
}
|
||||
|
||||
const { code, stdout, stderr } = await Process.run([which(), "info", pkg, field], {
|
||||
const result = Process.spawn([which(), "info", pkg, field], {
|
||||
cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
nothrow: true,
|
||||
})
|
||||
|
||||
const code = await result.exited
|
||||
const stdout = result.stdout ? await text(result.stdout) : ""
|
||||
const stderr = result.stderr ? await text(result.stderr) : ""
|
||||
|
||||
if (code !== 0) {
|
||||
log.warn("bun info failed", { pkg, field, code, stderr: stderr.toString() })
|
||||
log.warn("bun info failed", { pkg, field, code, stderr })
|
||||
return null
|
||||
}
|
||||
|
||||
const value = stdout.toString().trim()
|
||||
const value = stdout.trim()
|
||||
if (!value) return null
|
||||
return value
|
||||
}
|
||||
|
||||
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
|
||||
const latestVersion = await info(pkg, "version", cwd)
|
||||
if (!latestVersion) {
|
||||
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
|
||||
return false
|
||||
}
|
||||
|
||||
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
|
||||
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
|
||||
|
||||
return semver.lt(cachedVersion, latestVersion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,17 +24,17 @@ const loginEffect = Effect.fn("login")(function* (url: string) {
|
||||
const s = Prompt.spinner()
|
||||
yield* s.start("Waiting for authorization...")
|
||||
|
||||
const poll = (wait: Duration.Duration): Effect.Effect<PollResult, AccountError> =>
|
||||
const poll = (wait: number): Effect.Effect<PollResult, AccountError> =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.sleep(wait)
|
||||
const result = yield* service.poll(login)
|
||||
if (result._tag === "PollPending") return yield* poll(wait)
|
||||
if (result._tag === "PollSlow") return yield* poll(Duration.sum(wait, Duration.seconds(5)))
|
||||
if (result._tag === "PollSlow") return yield* poll(wait + 5000)
|
||||
return result
|
||||
})
|
||||
|
||||
const result = yield* poll(login.interval).pipe(
|
||||
Effect.timeout(login.expiry),
|
||||
const result = yield* poll(login.interval * 1000).pipe(
|
||||
Effect.timeout(Duration.seconds(login.expiry)),
|
||||
Effect.catchTag("TimeoutError", () => Effect.succeed(new PollExpired())),
|
||||
)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export const AcpCommand = cmd({
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
const server = await Server.listen(opts)
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
|
||||
@@ -86,7 +86,7 @@ export const ImportCommand = cmd({
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
let exportData:
|
||||
| {
|
||||
info: SDKSession
|
||||
info: Session.Info
|
||||
messages: Array<{
|
||||
info: Message
|
||||
parts: Part[]
|
||||
@@ -152,7 +152,7 @@ export const ImportCommand = cmd({
|
||||
return
|
||||
}
|
||||
|
||||
const row = Session.toRow({ ...exportData.info, projectID: Instance.project.id })
|
||||
const row = { ...Session.toRow(exportData.info), project_id: Instance.project.id }
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(SessionTable)
|
||||
|
||||
@@ -15,7 +15,7 @@ export const ServeCommand = cmd({
|
||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
const server = await Server.listen(opts)
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
|
||||
await new Promise(() => {})
|
||||
|
||||
@@ -1,25 +1,13 @@
|
||||
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import { Selection } from "@tui/util/selection"
|
||||
import { createCliRenderer, MouseButton, TextAttributes, type CliRendererConfig, type ParsedKey } from "@opentui/core"
|
||||
import { MouseButton, TextAttributes } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import {
|
||||
Switch,
|
||||
Match,
|
||||
createEffect,
|
||||
createMemo,
|
||||
untrack,
|
||||
ErrorBoundary,
|
||||
createSignal,
|
||||
onMount,
|
||||
batch,
|
||||
Show,
|
||||
on,
|
||||
} from "solid-js"
|
||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
||||
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
|
||||
import { Installation } from "@/installation"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Dialog as DialogUI, DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
||||
@@ -33,7 +21,7 @@ import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command
|
||||
import { DialogAgent } from "@tui/component/dialog-agent"
|
||||
import { DialogSessionList } from "@tui/component/dialog-session-list"
|
||||
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
|
||||
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
|
||||
import { KeybindProvider } from "@tui/context/keybind"
|
||||
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
||||
import { Home } from "@tui/routes/home"
|
||||
import { Session } from "@tui/routes/session"
|
||||
@@ -41,9 +29,6 @@ import { PromptHistoryProvider } from "./component/prompt/history"
|
||||
import { FrecencyProvider } from "./component/prompt/frecency"
|
||||
import { PromptStashProvider } from "./component/prompt/stash"
|
||||
import { DialogAlert } from "./ui/dialog-alert"
|
||||
import { DialogConfirm } from "./ui/dialog-confirm"
|
||||
import { DialogPrompt } from "./ui/dialog-prompt"
|
||||
import { DialogSelect } from "./ui/dialog-select"
|
||||
import { ToastProvider, useToast } from "./ui/toast"
|
||||
import { ExitProvider, useExit } from "./context/exit"
|
||||
import { Session as SessionApi } from "@/session"
|
||||
@@ -56,8 +41,6 @@ import { writeHeapSnapshot } from "v8"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { TuiConfigProvider } from "./context/tui-config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import type { TuiApi, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
|
||||
import { TuiPlugin } from "./plugin"
|
||||
|
||||
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||
// can't set raw mode if not a TTY
|
||||
@@ -121,25 +104,6 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||
|
||||
import type { EventSource } from "./context/sdk"
|
||||
|
||||
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
|
||||
return {
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: {},
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
consoleOptions: {
|
||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||
onCopySelection: (text) => {
|
||||
Clipboard.copy(text).catch((error) => {
|
||||
console.error(`Failed to copy console selection to clipboard: ${error}`)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function tui(input: {
|
||||
url: string
|
||||
args: Args
|
||||
@@ -165,57 +129,73 @@ export function tui(input: {
|
||||
resolve()
|
||||
}
|
||||
|
||||
const renderer = await createCliRenderer(rendererConfig(input.config))
|
||||
|
||||
await render(() => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />}
|
||||
>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ExitProvider onExit={onExit}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<SyncProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}, renderer)
|
||||
render(
|
||||
() => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />}
|
||||
>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ExitProvider onExit={onExit}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<SyncProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
},
|
||||
{
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: {},
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
consoleOptions: {
|
||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||
onCopySelection: (text) => {
|
||||
Clipboard.copy(text).catch((error) => {
|
||||
console.error(`Failed to copy console selection to clipboard: ${error}`)
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -228,226 +208,12 @@ function App() {
|
||||
const local = useLocal()
|
||||
const kv = useKV()
|
||||
const command = useCommandDialog()
|
||||
const keybind = useKeybind()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const { theme, mode, setMode } = themeState
|
||||
const { theme, mode, setMode } = useTheme()
|
||||
const sync = useSync()
|
||||
const exit = useExit()
|
||||
const promptRef = usePromptRef()
|
||||
const routes = new Map<string, { key: symbol; render: TuiRouteDefinition["render"] }[]>()
|
||||
const [routeRev, setRouteRev] = createSignal(0)
|
||||
const routeView = (name: string) => {
|
||||
routeRev()
|
||||
return routes.get(name)?.at(-1)?.render
|
||||
}
|
||||
|
||||
const api: TuiApi<JSX.Element> = {
|
||||
command: {
|
||||
register(cb) {
|
||||
command.register(() => cb())
|
||||
},
|
||||
trigger(value) {
|
||||
command.trigger(value)
|
||||
},
|
||||
},
|
||||
route: {
|
||||
register(input) {
|
||||
const key = Symbol()
|
||||
for (const item of input) {
|
||||
const list = routes.get(item.name) ?? []
|
||||
list.push({ key, render: item.render })
|
||||
routes.set(item.name, list)
|
||||
}
|
||||
setRouteRev((x) => x + 1)
|
||||
return () => {
|
||||
for (const item of input) {
|
||||
const list = routes.get(item.name)
|
||||
if (!list) continue
|
||||
routes.set(
|
||||
item.name,
|
||||
list.filter((x) => x.key !== key),
|
||||
)
|
||||
if (!routes.get(item.name)?.length) routes.delete(item.name)
|
||||
}
|
||||
setRouteRev((x) => x + 1)
|
||||
}
|
||||
},
|
||||
navigate(name, params) {
|
||||
if (name === "home") {
|
||||
route.navigate({ type: "home" })
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "session") {
|
||||
const sessionID = params?.sessionID
|
||||
if (typeof sessionID !== "string") return
|
||||
route.navigate({ type: "session", sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
route.navigate({ type: "plugin", id: name, data: params })
|
||||
},
|
||||
get current() {
|
||||
if (route.data.type === "home") return { name: "home" }
|
||||
if (route.data.type === "session") {
|
||||
return {
|
||||
name: "session",
|
||||
params: {
|
||||
sessionID: route.data.sessionID,
|
||||
initialPrompt: route.data.initialPrompt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: route.data.id,
|
||||
params: route.data.data,
|
||||
}
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
Dialog(props) {
|
||||
return (
|
||||
<DialogUI size={props.size} onClose={props.onClose}>
|
||||
{props.children as JSX.Element}
|
||||
</DialogUI>
|
||||
)
|
||||
},
|
||||
DialogAlert(props) {
|
||||
return <DialogAlert {...props} />
|
||||
},
|
||||
DialogConfirm(props) {
|
||||
return <DialogConfirm {...props} />
|
||||
},
|
||||
DialogPrompt(props) {
|
||||
return <DialogPrompt {...props} description={props.description as (() => JSX.Element) | undefined} />
|
||||
},
|
||||
DialogSelect(props) {
|
||||
const list = props.options.map((item) => ({
|
||||
...item,
|
||||
footer: item.footer as JSX.Element | string | undefined,
|
||||
onSelect: () => item.onSelect?.(),
|
||||
}))
|
||||
return (
|
||||
<DialogSelect
|
||||
title={props.title}
|
||||
placeholder={props.placeholder}
|
||||
options={list}
|
||||
flat={props.flat}
|
||||
onMove={
|
||||
props.onMove
|
||||
? (item) =>
|
||||
props.onMove?.({
|
||||
title: item.title,
|
||||
value: item.value,
|
||||
description: item.description,
|
||||
footer: item.footer,
|
||||
category: item.category,
|
||||
disabled: item.disabled,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onFilter={props.onFilter}
|
||||
onSelect={
|
||||
props.onSelect
|
||||
? (item) =>
|
||||
props.onSelect?.({
|
||||
title: item.title,
|
||||
value: item.value,
|
||||
description: item.description,
|
||||
footer: item.footer,
|
||||
category: item.category,
|
||||
disabled: item.disabled,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
skipFilter={props.skipFilter}
|
||||
current={props.current}
|
||||
/>
|
||||
)
|
||||
},
|
||||
toast(input) {
|
||||
toast.show({
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
variant: input.variant ?? "info",
|
||||
duration: input.duration,
|
||||
})
|
||||
},
|
||||
dialog: {
|
||||
replace(render, onClose) {
|
||||
dialog.replace(render, onClose)
|
||||
},
|
||||
clear() {
|
||||
dialog.clear()
|
||||
},
|
||||
setSize(size) {
|
||||
dialog.setSize(size)
|
||||
},
|
||||
get size() {
|
||||
return dialog.size
|
||||
},
|
||||
get depth() {
|
||||
return dialog.stack.length
|
||||
},
|
||||
get open() {
|
||||
return dialog.stack.length > 0
|
||||
},
|
||||
},
|
||||
},
|
||||
keybind: {
|
||||
parse(evt: ParsedKey) {
|
||||
return keybind.parse(evt)
|
||||
},
|
||||
match(key, evt: ParsedKey) {
|
||||
return keybind.match(key, evt)
|
||||
},
|
||||
print(key) {
|
||||
return keybind.print(key)
|
||||
},
|
||||
create(defaults, overrides) {
|
||||
return keybind.create(defaults, overrides)
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
get current() {
|
||||
return theme
|
||||
},
|
||||
get selected() {
|
||||
return themeState.selected
|
||||
},
|
||||
has(name) {
|
||||
return themeState.has(name)
|
||||
},
|
||||
set(name) {
|
||||
return themeState.set(name)
|
||||
},
|
||||
async install(_jsonPath) {
|
||||
throw new Error("theme.install is only available in plugin context")
|
||||
},
|
||||
mode() {
|
||||
return themeState.mode()
|
||||
},
|
||||
get ready() {
|
||||
return themeState.ready
|
||||
},
|
||||
},
|
||||
}
|
||||
const [ready, setReady] = createSignal(false)
|
||||
TuiPlugin.init({
|
||||
client: sdk.client,
|
||||
event: sdk.event,
|
||||
renderer,
|
||||
api,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load TUI plugins", error)
|
||||
})
|
||||
.finally(() => {
|
||||
setReady(true)
|
||||
})
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
@@ -490,6 +256,10 @@ function App() {
|
||||
}
|
||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
|
||||
|
||||
createEffect(() => {
|
||||
console.log(JSON.stringify(route.data))
|
||||
})
|
||||
|
||||
// Update terminal window title based on current route and session
|
||||
createEffect(() => {
|
||||
if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
|
||||
@@ -506,13 +276,9 @@ function App() {
|
||||
return
|
||||
}
|
||||
|
||||
// Truncate title to 40 chars max
|
||||
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
|
||||
renderer.setTerminalTitle(`OC | ${title}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (route.data.type === "plugin") {
|
||||
renderer.setTerminalTitle(`OC | ${route.data.id}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -986,58 +752,29 @@ function App() {
|
||||
})
|
||||
})
|
||||
|
||||
const plugin = createMemo(() => {
|
||||
if (route.data.type !== "plugin") return
|
||||
const render = routeView(route.data.id)
|
||||
if (!render) return <PluginRouteMissing id={route.data.id} onHome={() => route.navigate({ type: "home" })} />
|
||||
return render({ params: route.data.data })
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={ready()}>
|
||||
<box
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
backgroundColor={theme.background}
|
||||
onMouseDown={(evt) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
<box
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
backgroundColor={theme.background}
|
||||
onMouseDown={(evt) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
|
||||
if (!Selection.copy(renderer, toast)) return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={
|
||||
Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)
|
||||
}
|
||||
>
|
||||
<Show when={Flag.OPENCODE_SHOW_TTFD}>
|
||||
<TimeToFirstDraw />
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
<Home />
|
||||
</Match>
|
||||
<Match when={route.data.type === "session"}>
|
||||
<Session />
|
||||
</Match>
|
||||
</Switch>
|
||||
{plugin()}
|
||||
<TuiPlugin.Slot name="app" />
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function PluginRouteMissing(props: { id: string; onHome: () => void }) {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
|
||||
<text fg={theme.warning}>Unknown plugin route: {props.id}</text>
|
||||
<box onMouseUp={props.onHome} backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
|
||||
<text fg={theme.text}>go home</text>
|
||||
</box>
|
||||
if (!Selection.copy(renderer, toast)) return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
<Home />
|
||||
</Match>
|
||||
<Match when={route.data.type === "session"}>
|
||||
<Session />
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { type KeybindKey, useKeybind } from "@tui/context/keybind"
|
||||
|
||||
type Context = ReturnType<typeof init>
|
||||
const ctx = createContext<Context>()
|
||||
@@ -21,7 +21,7 @@ export type Slash = {
|
||||
}
|
||||
|
||||
export type CommandOption = DialogSelectOption<string> & {
|
||||
keybind?: string
|
||||
keybind?: KeybindKey
|
||||
suggested?: boolean
|
||||
slash?: Slash
|
||||
hidden?: boolean
|
||||
|
||||
@@ -16,8 +16,7 @@ export function DialogStatus() {
|
||||
|
||||
const plugins = createMemo(() => {
|
||||
const list = sync.data.config.plugin ?? []
|
||||
const result = list.map((item) => {
|
||||
const value = typeof item === "string" ? item : item[0]
|
||||
const result = list.map((value) => {
|
||||
if (value.startsWith("file://")) {
|
||||
const path = fileURLToPath(value)
|
||||
const parts = path.split("/")
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useToast } from "../ui/toast"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import { DialogSessionList } from "./workspace/dialog-session-list"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
async function openWorkspace(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
@@ -56,7 +57,7 @@ async function openWorkspace(input: {
|
||||
return
|
||||
}
|
||||
if (result.response.status >= 500 && result.response.status < 600) {
|
||||
await Bun.sleep(1000)
|
||||
await sleep(1000)
|
||||
continue
|
||||
}
|
||||
if (!result.data) {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
|
||||
export type PluginKeybindMap = Record<string, string>
|
||||
|
||||
type Base<Info> = {
|
||||
parse: (evt: ParsedKey) => Info
|
||||
match: (key: string, evt: ParsedKey) => boolean
|
||||
print: (key: string) => string
|
||||
}
|
||||
|
||||
export type PluginKeybind<Info> = {
|
||||
readonly all: PluginKeybindMap
|
||||
get: (name: string) => string
|
||||
parse: (evt: ParsedKey) => Info
|
||||
match: (name: string, evt: ParsedKey) => boolean
|
||||
print: (name: string) => string
|
||||
}
|
||||
|
||||
const txt = (value: unknown) => {
|
||||
if (typeof value !== "string") return
|
||||
if (!value.trim()) return
|
||||
return value
|
||||
}
|
||||
|
||||
export function createPluginKeybind<Info>(
|
||||
base: Base<Info>,
|
||||
defaults: PluginKeybindMap,
|
||||
overrides?: Record<string, unknown>,
|
||||
): PluginKeybind<Info> {
|
||||
const all = Object.freeze(
|
||||
Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])),
|
||||
) as PluginKeybindMap
|
||||
const get = (name: string) => all[name] ?? name
|
||||
|
||||
return {
|
||||
get all() {
|
||||
return all
|
||||
},
|
||||
get,
|
||||
parse: (evt) => base.parse(evt),
|
||||
match: (name, evt) => base.match(get(name), evt),
|
||||
print: (name) => base.print(get(name)),
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import type { ParsedKey, Renderable } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { createPluginKeybind, type PluginKeybindMap } from "./keybind-plugin"
|
||||
import { useTuiConfig } from "./tui-config"
|
||||
|
||||
export type KeybindKey = keyof NonNullable<TuiConfig.Info["keybinds"]> & string
|
||||
@@ -81,27 +80,21 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
|
||||
}
|
||||
return Keybind.fromParsedKey(evt, store.leader)
|
||||
},
|
||||
match(key: string, evt: ParsedKey) {
|
||||
const list = keybinds()[key] ?? Keybind.parse(key)
|
||||
if (!list.length) return false
|
||||
match(key: KeybindKey, evt: ParsedKey) {
|
||||
const keybind = keybinds()[key]
|
||||
if (!keybind) return false
|
||||
const parsed: Keybind.Info = result.parse(evt)
|
||||
for (const item of list) {
|
||||
if (Keybind.match(item, parsed)) {
|
||||
for (const key of keybind) {
|
||||
if (Keybind.match(key, parsed)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
print(key: string) {
|
||||
const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0)
|
||||
print(key: KeybindKey) {
|
||||
const first = keybinds()[key]?.at(0)
|
||||
if (!first) return ""
|
||||
const text = Keybind.toString(first)
|
||||
const lead = keybinds().leader?.[0]
|
||||
if (!lead) return text
|
||||
return text.replace("<leader>", Keybind.toString(lead))
|
||||
},
|
||||
create(defaults: PluginKeybindMap, overrides?: Record<string, unknown>) {
|
||||
return createPluginKeybind(result, defaults, overrides)
|
||||
const result = Keybind.toString(first)
|
||||
return result.replace("<leader>", Keybind.toString(keybinds().leader![0]!))
|
||||
},
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -14,13 +14,7 @@ export type SessionRoute = {
|
||||
initialPrompt?: PromptInfo
|
||||
}
|
||||
|
||||
export type PluginRoute = {
|
||||
type: "plugin"
|
||||
id: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type Route = HomeRoute | SessionRoute | PluginRoute
|
||||
export type Route = HomeRoute | SessionRoute
|
||||
|
||||
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
||||
name: "Route",
|
||||
@@ -38,6 +32,7 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
||||
return store
|
||||
},
|
||||
navigate(route: Route) {
|
||||
console.log("navigate", route)
|
||||
setStore(route)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ import { createStore, produce } from "solid-js/store"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { useTuiConfig } from "./tui-config"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
type ThemeColors = {
|
||||
primary: RGBA
|
||||
@@ -175,56 +174,6 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
|
||||
carbonfox,
|
||||
}
|
||||
|
||||
type State = {
|
||||
themes: Record<string, ThemeJson>
|
||||
mode: "dark" | "light"
|
||||
active: string
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore<State>({
|
||||
themes: DEFAULT_THEMES,
|
||||
mode: "dark",
|
||||
active: "opencode",
|
||||
ready: false,
|
||||
})
|
||||
|
||||
function mergeThemes(themes: Record<string, ThemeJson>) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
for (const [name, theme] of Object.entries(themes)) {
|
||||
if (draft.themes[name]) continue
|
||||
draft.themes[name] = theme
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function allThemes() {
|
||||
return store.themes
|
||||
}
|
||||
|
||||
function isTheme(theme: unknown): theme is ThemeJson {
|
||||
if (!isRecord(theme)) return false
|
||||
if (!isRecord(theme.theme)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function hasTheme(name: string) {
|
||||
if (!name) return false
|
||||
return allThemes()[name] !== undefined
|
||||
}
|
||||
|
||||
export function addTheme(name: string, theme: unknown) {
|
||||
if (!name) return false
|
||||
if (!isTheme(theme)) return false
|
||||
if (hasTheme(name)) return false
|
||||
mergeThemes({
|
||||
[name]: theme,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
|
||||
const defs = theme.defs ?? {}
|
||||
function resolveColor(c: ColorValue): RGBA {
|
||||
@@ -333,14 +282,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
init: (props: { mode: "dark" | "light" }) => {
|
||||
const config = useTuiConfig()
|
||||
const kv = useKV()
|
||||
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.mode = kv.get("theme_mode", props.mode)
|
||||
draft.active = (config.theme ?? kv.get("theme", "opencode")) as string
|
||||
draft.ready = false
|
||||
}),
|
||||
)
|
||||
const [store, setStore] = createStore({
|
||||
themes: DEFAULT_THEMES,
|
||||
mode: kv.get("theme_mode", props.mode),
|
||||
active: (config.theme ?? kv.get("theme", "opencode")) as string,
|
||||
ready: false,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const theme = config.theme
|
||||
@@ -348,49 +295,55 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
function init() {
|
||||
Promise.allSettled([
|
||||
resolveSystemTheme(),
|
||||
getCustomThemes()
|
||||
.then((custom) => {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
Object.assign(draft.themes, custom)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
setStore("active", "opencode")
|
||||
}),
|
||||
]).finally(() => {
|
||||
setStore("ready", true)
|
||||
})
|
||||
resolveSystemTheme()
|
||||
getCustomThemes()
|
||||
.then((custom) => {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
Object.assign(draft.themes, custom)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
setStore("active", "opencode")
|
||||
})
|
||||
.finally(() => {
|
||||
if (store.active !== "system") {
|
||||
setStore("ready", true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMount(init)
|
||||
|
||||
function resolveSystemTheme() {
|
||||
return renderer
|
||||
console.log("resolveSystemTheme")
|
||||
renderer
|
||||
.getPalette({
|
||||
size: 16,
|
||||
})
|
||||
.then((colors) => {
|
||||
console.log(colors.palette)
|
||||
if (!colors.palette[0]) {
|
||||
if (store.active === "system") {
|
||||
setStore("active", "opencode")
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.active = "opencode"
|
||||
draft.ready = true
|
||||
}),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.themes.system = generateSystem(colors, store.mode)
|
||||
if (store.active === "system") {
|
||||
draft.ready = true
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
if (store.active === "system") {
|
||||
setStore("active", "opencode")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const renderer = useRenderer()
|
||||
@@ -417,10 +370,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
return store.active
|
||||
},
|
||||
all() {
|
||||
return allThemes()
|
||||
},
|
||||
has(name: string) {
|
||||
return hasTheme(name)
|
||||
return store.themes
|
||||
},
|
||||
syntax,
|
||||
subtleSyntax,
|
||||
@@ -432,10 +382,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
kv.set("theme_mode", mode)
|
||||
},
|
||||
set(theme: string) {
|
||||
if (!hasTheme(theme)) return false
|
||||
setStore("active", theme)
|
||||
kv.set("theme", theme)
|
||||
return true
|
||||
},
|
||||
get ready() {
|
||||
return store.ready
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
import {
|
||||
type TuiPlugin as TuiPluginFn,
|
||||
type TuiPluginInit,
|
||||
type TuiPluginInput,
|
||||
type TuiTheme,
|
||||
type TuiSlotContext,
|
||||
type TuiSlotMap,
|
||||
type TuiSlots,
|
||||
type SlotMode,
|
||||
} from "@opencode-ai/plugin/tui"
|
||||
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
|
||||
import type { CliRenderer } from "@opentui/core"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
import { Config } from "@/config/config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { Log } from "@/util/log"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { resolvePluginTarget, uniqueModuleEntries } from "@/plugin/shared"
|
||||
import { PluginMeta } from "@/plugin/meta"
|
||||
import { addTheme, hasTheme } from "./context/theme"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
type SlotProps<K extends keyof TuiSlotMap> = {
|
||||
name: K
|
||||
mode?: SlotMode
|
||||
children?: JSX.Element
|
||||
} & TuiSlotMap[K]
|
||||
|
||||
type Slot = <K extends keyof TuiSlotMap>(props: SlotProps<K>) => JSX.Element | null
|
||||
type InitInput = Omit<TuiPluginInput<CliRenderer>, "slots">
|
||||
|
||||
function empty<K extends keyof TuiSlotMap>(_props: SlotProps<K>) {
|
||||
return null
|
||||
}
|
||||
|
||||
function isTuiSlotPlugin(value: unknown): value is SolidPlugin<TuiSlotMap, TuiSlotContext> {
|
||||
if (!isRecord(value)) return false
|
||||
if (typeof value.id !== "string") return false
|
||||
if (!isRecord(value.slots)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function getTuiSlotPlugin(value: unknown) {
|
||||
if (isTuiSlotPlugin(value)) return value
|
||||
if (!isRecord(value)) return
|
||||
if (!isTuiSlotPlugin(value.slots)) return
|
||||
return value.slots
|
||||
}
|
||||
|
||||
function isTuiPlugin(value: unknown): value is TuiPluginFn<CliRenderer> {
|
||||
return typeof value === "function"
|
||||
}
|
||||
|
||||
function getTuiPlugin(value: unknown) {
|
||||
if (!isRecord(value) || !("tui" in value)) return
|
||||
if (!isTuiPlugin(value.tui)) return
|
||||
return value.tui
|
||||
}
|
||||
|
||||
function isTheme(value: unknown) {
|
||||
if (!isRecord(value)) return false
|
||||
if (!isRecord(value.theme)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function localDir(file: string) {
|
||||
const dir = path.dirname(file)
|
||||
if (path.basename(dir) === ".opencode") return path.join(dir, "themes")
|
||||
return path.join(dir, ".opencode", "themes")
|
||||
}
|
||||
|
||||
function scopeDir(pluginMeta: TuiConfig.PluginMeta) {
|
||||
if (pluginMeta.scope === "local") return localDir(pluginMeta.source)
|
||||
return path.join(Global.Path.config, "themes")
|
||||
}
|
||||
|
||||
function pluginRoot(spec: string, target: string) {
|
||||
if (spec.startsWith("file://")) return path.dirname(fileURLToPath(spec))
|
||||
if (target.startsWith("file://")) return path.dirname(fileURLToPath(target))
|
||||
return target
|
||||
}
|
||||
|
||||
function resolveThemePath(root: string, file: string) {
|
||||
if (file.startsWith("file://")) return fileURLToPath(file)
|
||||
if (path.isAbsolute(file)) return file
|
||||
return path.resolve(root, file)
|
||||
}
|
||||
|
||||
function themeName(file: string) {
|
||||
return path.basename(file, path.extname(file))
|
||||
}
|
||||
|
||||
function getPluginMeta(config: TuiConfig.Info, item: Config.PluginSpec) {
|
||||
const key = Config.getPluginName(item)
|
||||
const value = config.plugin_meta?.[key]
|
||||
if (!value) {
|
||||
throw new Error(`missing plugin metadata for ${key}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function makeInstallFn(meta: TuiConfig.PluginMeta, root: string): TuiTheme["install"] {
|
||||
return async (file) => {
|
||||
const src = resolveThemePath(root, file)
|
||||
const theme = themeName(src)
|
||||
if (hasTheme(theme)) return
|
||||
|
||||
const text = await Bun.file(src)
|
||||
.text()
|
||||
.catch((error) => {
|
||||
throw new Error(`failed to read theme at ${src}: ${error}`)
|
||||
})
|
||||
const data = JSON.parse(text)
|
||||
if (!isTheme(data)) {
|
||||
throw new Error(`invalid theme at ${src}`)
|
||||
}
|
||||
|
||||
const dest = path.join(scopeDir(meta), `${theme}.json`)
|
||||
if (!(await Filesystem.exists(dest))) {
|
||||
await Filesystem.write(dest, text)
|
||||
}
|
||||
|
||||
addTheme(theme, data)
|
||||
}
|
||||
}
|
||||
|
||||
export namespace TuiPlugin {
|
||||
const log = Log.create({ service: "tui.plugin" })
|
||||
let loaded: Promise<void> | undefined
|
||||
let view: Slot = empty
|
||||
|
||||
export const Slot: Slot = (props) => view(props)
|
||||
|
||||
function setupSlots(input: InitInput): TuiSlots {
|
||||
const reg = createSolidSlotRegistry<TuiSlotMap, TuiSlotContext>(
|
||||
input.renderer,
|
||||
{
|
||||
theme: input.api.theme,
|
||||
},
|
||||
{
|
||||
onPluginError(event) {
|
||||
console.error("[tui.slot] plugin error", {
|
||||
plugin: event.pluginId,
|
||||
slot: event.slot,
|
||||
phase: event.phase,
|
||||
source: event.source,
|
||||
message: event.error.message,
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const slot = createSlot<TuiSlotMap, TuiSlotContext>(reg)
|
||||
view = (props) => slot(props)
|
||||
return {
|
||||
register(pluginSlot) {
|
||||
if (!isTuiSlotPlugin(pluginSlot)) return () => {}
|
||||
return reg.register(pluginSlot)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function init(input: InitInput) {
|
||||
if (loaded) return loaded
|
||||
loaded = load({
|
||||
...input,
|
||||
slots: setupSlots(input),
|
||||
})
|
||||
return loaded
|
||||
}
|
||||
|
||||
async function load(input: TuiPluginInput<CliRenderer>) {
|
||||
const dir = process.cwd()
|
||||
|
||||
await Instance.provide({
|
||||
directory: dir,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
const plugins = config.plugin ?? []
|
||||
let deps: Promise<void> | undefined
|
||||
const wait = async () => {
|
||||
if (deps) {
|
||||
await deps
|
||||
return
|
||||
}
|
||||
deps = TuiConfig.waitForDependencies().catch((error) => {
|
||||
log.warn("failed waiting for tui plugin dependencies", { error })
|
||||
})
|
||||
await deps
|
||||
}
|
||||
|
||||
const prep = async (item: (typeof plugins)[number], retry = false) => {
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
log.info("loading tui plugin", { path: spec, retry })
|
||||
const target = await resolvePluginTarget(spec).catch((error) => {
|
||||
log.error("failed to resolve tui plugin", { path: spec, retry, error })
|
||||
return
|
||||
})
|
||||
if (!target) return
|
||||
const meta = await PluginMeta.touch(spec, target).catch((error) => {
|
||||
log.warn("failed to track tui plugin", { path: spec, retry, error })
|
||||
})
|
||||
if (meta && meta.state !== "same") {
|
||||
log.info("tui plugin metadata updated", {
|
||||
path: spec,
|
||||
retry,
|
||||
state: meta.state,
|
||||
source: meta.entry.source,
|
||||
version: meta.entry.version,
|
||||
modified: meta.entry.modified,
|
||||
})
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const init: TuiPluginInit = meta
|
||||
? {
|
||||
state: meta.state,
|
||||
entry: meta.entry,
|
||||
}
|
||||
: {
|
||||
state: "first",
|
||||
entry: {
|
||||
name: spec,
|
||||
source: spec.startsWith("file://") ? "file" : "npm",
|
||||
spec,
|
||||
target,
|
||||
first_time: now,
|
||||
last_time: now,
|
||||
time_changed: now,
|
||||
load_count: 1,
|
||||
fingerprint: target,
|
||||
},
|
||||
}
|
||||
|
||||
const root = pluginRoot(spec, target)
|
||||
const install = makeInstallFn(getPluginMeta(config, item), root)
|
||||
const mod = await import(target).catch((error) => {
|
||||
log.error("failed to load tui plugin", { path: spec, retry, error })
|
||||
return
|
||||
})
|
||||
if (!mod) return
|
||||
|
||||
return {
|
||||
item,
|
||||
spec,
|
||||
mod,
|
||||
install,
|
||||
init,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const loaded = await Promise.all(plugins.map((item) => prep(item)))
|
||||
|
||||
for (let i = 0; i < plugins.length; i++) {
|
||||
let load = loaded[i]
|
||||
if (!load) {
|
||||
const item = plugins[i]
|
||||
if (!item) continue
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
if (!spec.startsWith("file://")) continue
|
||||
await wait()
|
||||
load = await prep(item, true)
|
||||
}
|
||||
if (!load) continue
|
||||
|
||||
// Keep plugin execution sequential for deterministic side effects:
|
||||
// command registration order affects keybind/command precedence,
|
||||
// route registration is last-wins when ids collide,
|
||||
// and hook chains rely on stable plugin ordering.
|
||||
for (const [name, value] of uniqueModuleEntries(load.mod)) {
|
||||
if (!value || typeof value !== "object") {
|
||||
log.warn("ignoring non-object tui plugin export", {
|
||||
path: load.spec,
|
||||
name,
|
||||
type: value === null ? "null" : typeof value,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const slotPlugin = getTuiSlotPlugin(value)
|
||||
if (slotPlugin) input.slots.register(slotPlugin)
|
||||
|
||||
const tuiPlugin = getTuiPlugin(value)
|
||||
if (!tuiPlugin) continue
|
||||
await tuiPlugin(
|
||||
{
|
||||
...input,
|
||||
api: {
|
||||
command: input.api.command,
|
||||
route: input.api.route,
|
||||
ui: input.api.ui,
|
||||
keybind: input.api.keybind,
|
||||
theme: Object.create(input.api.theme, {
|
||||
install: {
|
||||
value: load.install,
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
Config.pluginOptions(load.item) ?? null,
|
||||
load.init,
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await PluginMeta.persist().catch((error) => {
|
||||
log.warn("failed to persist tui plugin metadata", { error })
|
||||
})
|
||||
}
|
||||
},
|
||||
}).catch((error) => {
|
||||
log.error("failed to load tui plugins", { directory: dir, error })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import "@opentui/solid/runtime-plugin-support"
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createEffect, createMemo, Match, on, onMount, Show, Switch } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
@@ -16,7 +15,6 @@ import { Installation } from "@/installation"
|
||||
import { useKV } from "../context/kv"
|
||||
import { useCommandDialog } from "../component/dialog-command"
|
||||
import { useLocal } from "../context/local"
|
||||
import { TuiPlugin } from "../plugin"
|
||||
|
||||
// TODO: what is the best way to do this?
|
||||
let once = false
|
||||
@@ -59,8 +57,8 @@ export function Home() {
|
||||
])
|
||||
|
||||
const Hint = (
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<Show when={connectedMcpCount() > 0}>
|
||||
<Show when={connectedMcpCount() > 0}>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<text fg={theme.text}>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
@@ -73,8 +71,8 @@ export function Home() {
|
||||
</Match>
|
||||
</Switch>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
|
||||
let prompt: PromptRef
|
||||
@@ -113,9 +111,7 @@ export function Home() {
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<box height={4} minHeight={0} flexShrink={1} />
|
||||
<box flexShrink={0}>
|
||||
<TuiPlugin.Slot name="home_logo" mode="replace">
|
||||
<Logo />
|
||||
</TuiPlugin.Slot>
|
||||
<Logo />
|
||||
</box>
|
||||
<box height={1} minHeight={0} flexShrink={1} />
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||
|
||||
@@ -70,6 +70,7 @@ import { Toast, useToast } from "../../ui/toast"
|
||||
import { useKV } from "../../context/kv.tsx"
|
||||
import { Editor } from "../../util/editor"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { Footer } from "./footer.tsx"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
@@ -906,12 +907,12 @@ export function Session() {
|
||||
const filename = options.filename.trim()
|
||||
const filepath = path.join(exportDir, filename)
|
||||
|
||||
await Bun.write(filepath, transcript)
|
||||
await Filesystem.write(filepath, transcript)
|
||||
|
||||
// Open with EDITOR if available
|
||||
const result = await Editor.open({ value: transcript, renderer })
|
||||
if (result !== undefined) {
|
||||
await Bun.write(filepath, result)
|
||||
await Filesystem.write(filepath, result)
|
||||
}
|
||||
|
||||
toast.show({ message: `Session exported to ${filename}`, variant: "success" })
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useKeybind } from "../../context/keybind"
|
||||
import { useDirectory } from "../../context/directory"
|
||||
import { useKV } from "../../context/kv"
|
||||
import { TodoItem } from "../../component/todo-item"
|
||||
import { TuiPlugin } from "../../plugin"
|
||||
|
||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const sync = useSync()
|
||||
@@ -91,7 +90,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
}}
|
||||
>
|
||||
<box flexShrink={0} gap={1} paddingRight={1}>
|
||||
<TuiPlugin.Slot name="sidebar_top" session_id={props.sessionID} />
|
||||
<box paddingRight={1}>
|
||||
<text fg={theme.text}>
|
||||
<b>{session().title}</b>
|
||||
|
||||
@@ -35,7 +35,6 @@ export function Dialog(
|
||||
height={dimensions().height}
|
||||
alignItems="center"
|
||||
position="absolute"
|
||||
zIndex={3000}
|
||||
paddingTop={dimensions().height / 4}
|
||||
left={0}
|
||||
top={0}
|
||||
@@ -71,10 +70,8 @@ function init() {
|
||||
useKeyboard((evt) => {
|
||||
if (store.stack.length === 0) return
|
||||
if (evt.defaultPrevented) return
|
||||
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()) return
|
||||
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
|
||||
if (renderer.getSelection()) {
|
||||
renderer.clearSelection()
|
||||
}
|
||||
const current = store.stack.at(-1)!
|
||||
current.onClose?.()
|
||||
setStore("stack", store.stack.slice(0, -1))
|
||||
@@ -154,7 +151,6 @@ export function DialogProvider(props: ParentProps) {
|
||||
{props.children}
|
||||
<box
|
||||
position="absolute"
|
||||
zIndex={3000}
|
||||
onMouseDown={(evt) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
|
||||
@@ -8,7 +8,6 @@ import { upgrade } from "@/cli/upgrade"
|
||||
import { Config } from "@/config/config"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
||||
import type { BunWebSocketData } from "hono/bun"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
@@ -38,7 +37,7 @@ GlobalBus.on("event", (event) => {
|
||||
Rpc.emit("global.event", event)
|
||||
})
|
||||
|
||||
let server: Bun.Server<BunWebSocketData> | undefined
|
||||
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
|
||||
|
||||
const eventStream = {
|
||||
abort: undefined as AbortController | undefined,
|
||||
@@ -120,7 +119,7 @@ export const rpc = {
|
||||
},
|
||||
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
|
||||
if (server) await server.stop(true)
|
||||
server = Server.listen(input)
|
||||
server = await Server.listen(input)
|
||||
return { url: server.url.toString() }
|
||||
},
|
||||
async checkUpgrade(input: { directory: string }) {
|
||||
@@ -143,7 +142,7 @@ export const rpc = {
|
||||
Log.Default.info("worker shutting down")
|
||||
if (eventStream.abort) eventStream.abort.abort()
|
||||
await Instance.disposeAll()
|
||||
if (server) server.stop(true)
|
||||
if (server) await server.stop(true)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export const WebCommand = cmd({
|
||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
const server = await Server.listen(opts)
|
||||
UI.empty()
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from "jsonc-parser"
|
||||
import { Instance } from "../project/instance"
|
||||
import { LSPServer } from "../lsp/server"
|
||||
import { BunProc } from "@/bun"
|
||||
import { Installation } from "@/installation"
|
||||
import { ConfigMarkdown } from "./markdown"
|
||||
import { constants, existsSync } from "fs"
|
||||
@@ -30,21 +29,14 @@ import { Bus } from "@/bus"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Event } from "../server/event"
|
||||
import { Glob } from "../util/glob"
|
||||
import { PackageRegistry } from "@/bun/registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Account } from "@/account"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { ConfigPaths } from "./paths"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Npm } from "@/npm"
|
||||
|
||||
export namespace Config {
|
||||
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
||||
const PluginOptions = z.record(z.string(), z.unknown())
|
||||
export const PluginSpec = z.union([z.string(), z.tuple([z.string(), PluginOptions])])
|
||||
|
||||
export type PluginOptions = z.infer<typeof PluginOptions>
|
||||
export type PluginSpec = z.infer<typeof PluginSpec>
|
||||
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
@@ -158,8 +150,7 @@ export namespace Config {
|
||||
|
||||
deps.push(
|
||||
iife(async () => {
|
||||
const shouldInstall = await needsInstall(dir)
|
||||
if (shouldInstall) await installDependencies(dir)
|
||||
await installDependencies(dir)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -275,6 +266,10 @@ export namespace Config {
|
||||
}
|
||||
|
||||
export async function installDependencies(dir: string) {
|
||||
if (!(await isWritable(dir))) {
|
||||
log.info("config dir is not writable, skipping dependency install", { dir })
|
||||
return
|
||||
}
|
||||
const pkg = path.join(dir, "package.json")
|
||||
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
|
||||
|
||||
@@ -288,22 +283,15 @@ export namespace Config {
|
||||
await Filesystem.writeJson(pkg, json)
|
||||
|
||||
const gitignore = path.join(dir, ".gitignore")
|
||||
const hasGitIgnore = await Filesystem.exists(gitignore)
|
||||
if (!hasGitIgnore)
|
||||
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
||||
if (!(await Filesystem.exists(gitignore)))
|
||||
await Filesystem.write(
|
||||
gitignore,
|
||||
["node_modules", "plans", "package.json", "bun.lock", ".gitignore", "package-lock.json"].join("\n"),
|
||||
)
|
||||
|
||||
// Install any additional dependencies defined in the package.json
|
||||
// This allows local plugins and custom tools to use external packages
|
||||
await BunProc.run(
|
||||
[
|
||||
"install",
|
||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
||||
],
|
||||
{ cwd: dir },
|
||||
).catch((err) => {
|
||||
log.warn("failed to install dependencies", { dir, error: err })
|
||||
})
|
||||
await Npm.install(dir)
|
||||
}
|
||||
|
||||
async function isWritable(dir: string) {
|
||||
@@ -315,42 +303,6 @@ export namespace Config {
|
||||
}
|
||||
}
|
||||
|
||||
export async function needsInstall(dir: string) {
|
||||
// Some config dirs may be read-only.
|
||||
// Installing deps there will fail; skip installation in that case.
|
||||
const writable = await isWritable(dir)
|
||||
if (!writable) {
|
||||
log.debug("config dir is not writable, skipping dependency install", { dir })
|
||||
return false
|
||||
}
|
||||
|
||||
const nodeModules = path.join(dir, "node_modules")
|
||||
if (!existsSync(nodeModules)) return true
|
||||
|
||||
const pkg = path.join(dir, "package.json")
|
||||
const pkgExists = await Filesystem.exists(pkg)
|
||||
if (!pkgExists) return true
|
||||
|
||||
const parsed = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => null)
|
||||
const dependencies = parsed?.dependencies ?? {}
|
||||
const depVersion = dependencies["@opencode-ai/plugin"]
|
||||
if (!depVersion) return true
|
||||
|
||||
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
|
||||
if (targetVersion === "latest") {
|
||||
if (!PackageRegistry.online()) return false
|
||||
const stale = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
|
||||
if (!stale) return false
|
||||
log.info("Cached version is outdated, proceeding with install", {
|
||||
pkg: "@opencode-ai/plugin",
|
||||
cachedVersion: depVersion,
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (depVersion === targetVersion) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function rel(item: string, patterns: string[]) {
|
||||
const normalizedItem = item.replaceAll("\\", "/")
|
||||
for (const pattern of patterns) {
|
||||
@@ -479,7 +431,7 @@ export namespace Config {
|
||||
}
|
||||
|
||||
async function loadPlugin(dir: string) {
|
||||
const plugins: PluginSpec[] = []
|
||||
const plugins: string[] = []
|
||||
|
||||
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: dir,
|
||||
@@ -492,32 +444,6 @@ export namespace Config {
|
||||
return plugins
|
||||
}
|
||||
|
||||
export function pluginSpecifier(plugin: PluginSpec): string {
|
||||
return Array.isArray(plugin) ? plugin[0] : plugin
|
||||
}
|
||||
|
||||
export function pluginOptions(plugin: PluginSpec): PluginOptions | undefined {
|
||||
return Array.isArray(plugin) ? plugin[1] : undefined
|
||||
}
|
||||
|
||||
export function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): PluginSpec {
|
||||
const spec = pluginSpecifier(plugin)
|
||||
try {
|
||||
const resolved = import.meta.resolve!(spec, configFilepath)
|
||||
if (Array.isArray(plugin)) return [resolved, plugin[1]]
|
||||
return resolved
|
||||
} catch {
|
||||
try {
|
||||
const require = createRequire(configFilepath)
|
||||
const resolved = pathToFileURL(require.resolve(spec)).href
|
||||
if (Array.isArray(plugin)) return [resolved, plugin[1]]
|
||||
return resolved
|
||||
} catch {
|
||||
return plugin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a canonical plugin name from a plugin specifier.
|
||||
* - For file:// URLs: extracts filename without extension
|
||||
@@ -528,16 +454,15 @@ export namespace Config {
|
||||
* getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode"
|
||||
* getPluginName("@scope/pkg@1.0.0") // "@scope/pkg"
|
||||
*/
|
||||
export function getPluginName(plugin: PluginSpec): string {
|
||||
const spec = pluginSpecifier(plugin)
|
||||
if (spec.startsWith("file://")) {
|
||||
return path.parse(new URL(spec).pathname).name
|
||||
export function getPluginName(plugin: string): string {
|
||||
if (plugin.startsWith("file://")) {
|
||||
return path.parse(new URL(plugin).pathname).name
|
||||
}
|
||||
const lastAt = spec.lastIndexOf("@")
|
||||
const lastAt = plugin.lastIndexOf("@")
|
||||
if (lastAt > 0) {
|
||||
return spec.substring(0, lastAt)
|
||||
return plugin.substring(0, lastAt)
|
||||
}
|
||||
return spec
|
||||
return plugin
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -551,14 +476,14 @@ export namespace Config {
|
||||
* Since plugins are added in low-to-high priority order,
|
||||
* we reverse, deduplicate (keeping first occurrence), then restore order.
|
||||
*/
|
||||
export function deduplicatePlugins(plugins: PluginSpec[]): PluginSpec[] {
|
||||
export function deduplicatePlugins(plugins: string[]): string[] {
|
||||
// seenNames: canonical plugin names for duplicate detection
|
||||
// e.g., "oh-my-opencode", "@scope/pkg"
|
||||
const seenNames = new Set<string>()
|
||||
|
||||
// uniqueSpecifiers: full plugin specifiers to return
|
||||
// e.g., "oh-my-opencode@2.4.3", ["file:///path/to/plugin.js", { ... }]
|
||||
const uniqueSpecifiers: PluginSpec[] = []
|
||||
// e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js"
|
||||
const uniqueSpecifiers: string[] = []
|
||||
|
||||
for (const specifier of plugins.toReversed()) {
|
||||
const name = getPluginName(specifier)
|
||||
@@ -1062,7 +987,7 @@ export namespace Config {
|
||||
ignore: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
plugin: PluginSpec.array().optional(),
|
||||
plugin: z.string().array().optional(),
|
||||
snapshot: z.boolean().optional(),
|
||||
share: z
|
||||
.enum(["manual", "auto", "disabled"])
|
||||
@@ -1310,7 +1235,19 @@ export namespace Config {
|
||||
const data = parsed.data
|
||||
if (data.plugin && isFile) {
|
||||
for (let i = 0; i < data.plugin.length; i++) {
|
||||
data.plugin[i] = resolvePluginSpec(data.plugin[i], options.path)
|
||||
const plugin = data.plugin[i]
|
||||
try {
|
||||
data.plugin[i] = import.meta.resolve!(plugin, options.path)
|
||||
} catch (e) {
|
||||
try {
|
||||
// import.meta.resolve sometimes fails with newly created node_modules
|
||||
const require = createRequire(options.path)
|
||||
const resolvedPath = require.resolve(plugin)
|
||||
data.plugin[i] = pathToFileURL(resolvedPath).href
|
||||
} catch {
|
||||
// Ignore, plugin might be a generic string identifier like "mcp-server"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
@@ -1357,6 +1294,10 @@ export namespace Config {
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function patchJsonc(input: string, patch: unknown, path: string[] = []): string {
|
||||
if (!isRecord(patch)) {
|
||||
const edits = modify(input, path, patch, {
|
||||
@@ -1450,3 +1391,5 @@ export namespace Config {
|
||||
return state().then((x) => x.directories)
|
||||
}
|
||||
}
|
||||
Filesystem.write
|
||||
Filesystem.write
|
||||
|
||||
@@ -29,7 +29,6 @@ export const TuiInfo = z
|
||||
$schema: z.string().optional(),
|
||||
theme: z.string().optional(),
|
||||
keybinds: KeybindOverride.optional(),
|
||||
plugin: Config.PluginSpec.array().optional(),
|
||||
})
|
||||
.extend(TuiOptions.shape)
|
||||
.strict()
|
||||
|
||||
@@ -8,7 +8,6 @@ import { TuiInfo } from "./tui-schema"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Log } from "@/util/log"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Global } from "@/global"
|
||||
|
||||
export namespace TuiConfig {
|
||||
@@ -16,43 +15,10 @@ export namespace TuiConfig {
|
||||
|
||||
export const Info = TuiInfo
|
||||
|
||||
export type PluginMeta = {
|
||||
scope: "global" | "local"
|
||||
source: string
|
||||
}
|
||||
|
||||
type PluginEntry = {
|
||||
item: Config.PluginSpec
|
||||
meta: PluginMeta
|
||||
}
|
||||
|
||||
export type Info = z.output<typeof Info> & {
|
||||
plugin_meta?: Record<string, PluginMeta>
|
||||
}
|
||||
|
||||
function pluginScope(file: string): PluginMeta["scope"] {
|
||||
if (Instance.containsPath(file)) return "local"
|
||||
return "global"
|
||||
}
|
||||
|
||||
function dedupePlugins(list: PluginEntry[]) {
|
||||
const seen = new Set<string>()
|
||||
const result: PluginEntry[] = []
|
||||
for (const item of list.toReversed()) {
|
||||
const name = Config.getPluginName(item.item)
|
||||
if (seen.has(name)) continue
|
||||
seen.add(name)
|
||||
result.push(item)
|
||||
}
|
||||
return result.toReversed()
|
||||
}
|
||||
export type Info = z.output<typeof Info>
|
||||
|
||||
function mergeInfo(target: Info, source: Info): Info {
|
||||
const merged = mergeDeep(target, source)
|
||||
if (target.plugin && source.plugin) {
|
||||
merged.plugin = [...target.plugin, ...source.plugin]
|
||||
}
|
||||
return merged
|
||||
return mergeDeep(target, source)
|
||||
}
|
||||
|
||||
function customPath() {
|
||||
@@ -73,74 +39,37 @@ export namespace TuiConfig {
|
||||
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
|
||||
|
||||
let result: Info = {}
|
||||
const pluginEntries: PluginEntry[] = []
|
||||
|
||||
const mergeFile = async (file: string) => {
|
||||
const data = await loadFile(file)
|
||||
result = mergeInfo(result, data)
|
||||
if (!data.plugin?.length) return
|
||||
const sourceScope = pluginScope(file)
|
||||
for (const item of data.plugin) {
|
||||
pluginEntries.push({
|
||||
item,
|
||||
meta: {
|
||||
scope: sourceScope,
|
||||
source: file,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||
await mergeFile(file)
|
||||
result = mergeInfo(result, await loadFile(file))
|
||||
}
|
||||
|
||||
if (custom) {
|
||||
await mergeFile(custom)
|
||||
result = mergeInfo(result, await loadFile(custom))
|
||||
log.debug("loaded custom tui config", { path: custom })
|
||||
}
|
||||
|
||||
for (const file of projectFiles) {
|
||||
await mergeFile(file)
|
||||
result = mergeInfo(result, await loadFile(file))
|
||||
}
|
||||
|
||||
for (const dir of unique(directories)) {
|
||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
||||
await mergeFile(file)
|
||||
result = mergeInfo(result, await loadFile(file))
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(managed)) {
|
||||
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
|
||||
await mergeFile(file)
|
||||
result = mergeInfo(result, await loadFile(file))
|
||||
}
|
||||
}
|
||||
|
||||
const merged = dedupePlugins(pluginEntries)
|
||||
result.keybinds = Config.Keybinds.parse(result.keybinds ?? {})
|
||||
result.plugin = merged.map((item) => item.item)
|
||||
result.plugin_meta = merged.length
|
||||
? Object.fromEntries(merged.map((item) => [Config.getPluginName(item.item), item.meta]))
|
||||
: undefined
|
||||
|
||||
const deps: Promise<void>[] = []
|
||||
if (result.plugin?.length) {
|
||||
for (const dir of unique(directories)) {
|
||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||
deps.push(
|
||||
(async () => {
|
||||
const shouldInstall = await Config.needsInstall(dir)
|
||||
if (!shouldInstall) return
|
||||
await Config.installDependencies(dir)
|
||||
})(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config: result,
|
||||
deps,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -148,11 +77,6 @@ export namespace TuiConfig {
|
||||
return state().then((x) => x.config)
|
||||
}
|
||||
|
||||
export async function waitForDependencies() {
|
||||
const deps = await state().then((x) => x.deps)
|
||||
await Promise.all(deps)
|
||||
}
|
||||
|
||||
async function loadFile(filepath: string): Promise<Info> {
|
||||
const text = await ConfigPaths.readFile(filepath)
|
||||
if (!text) return {}
|
||||
@@ -163,19 +87,19 @@ export namespace TuiConfig {
|
||||
}
|
||||
|
||||
async function load(text: string, configFilepath: string): Promise<Info> {
|
||||
const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
|
||||
if (!isRecord(raw)) return {}
|
||||
const data = await ConfigPaths.parseText(text, configFilepath, "empty")
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return {}
|
||||
|
||||
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||
// (mirroring the old opencode.json shape) still get their settings applied.
|
||||
const normalized = (() => {
|
||||
const copy = { ...raw }
|
||||
const copy = { ...(data as Record<string, unknown>) }
|
||||
if (!("tui" in copy)) return copy
|
||||
if (!isRecord(copy.tui)) {
|
||||
if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) {
|
||||
delete copy.tui
|
||||
return copy
|
||||
}
|
||||
const tui = copy.tui
|
||||
const tui = copy.tui as Record<string, unknown>
|
||||
delete copy.tui
|
||||
return {
|
||||
...tui,
|
||||
@@ -189,13 +113,6 @@ export namespace TuiConfig {
|
||||
return {}
|
||||
}
|
||||
|
||||
const data = parsed.data
|
||||
if (data.plugin) {
|
||||
for (let i = 0; i < data.plugin.length; i++) {
|
||||
data.plugin[i] = Config.resolvePluginSpec(data.plugin[i], configFilepath)
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
return parsed.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import z from "zod"
|
||||
import { Identifier } from "@/id/id"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
|
||||
export const WorkspaceInfo = z.object({
|
||||
id: Identifier.schema("workspace"),
|
||||
@@ -9,7 +8,7 @@ export const WorkspaceInfo = z.object({
|
||||
name: z.string().nullable(),
|
||||
directory: z.string().nullable(),
|
||||
extra: z.unknown().nullable(),
|
||||
projectID: ProjectID.zod,
|
||||
projectID: z.string(),
|
||||
})
|
||||
export type WorkspaceInfo = z.infer<typeof WorkspaceInfo>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAdaptorServer } from "@hono/node-server"
|
||||
import { Hono } from "hono"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { InstanceBootstrap } from "../../project/bootstrap"
|
||||
@@ -55,10 +56,24 @@ export namespace WorkspaceServer {
|
||||
}
|
||||
|
||||
export function Listen(opts: { hostname: string; port: number }) {
|
||||
return Bun.serve({
|
||||
hostname: opts.hostname,
|
||||
port: opts.port,
|
||||
const server = createAdaptorServer({
|
||||
fetch: App().fetch,
|
||||
})
|
||||
server.listen(opts.port, opts.hostname)
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
port: opts.port,
|
||||
stop() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import type { ProjectID } from "../project/schema"
|
||||
|
||||
export const WorkspaceTable = sqliteTable("workspace", {
|
||||
id: text().primaryKey(),
|
||||
@@ -10,7 +9,6 @@ export const WorkspaceTable = sqliteTable("workspace", {
|
||||
directory: text(),
|
||||
extra: text({ mode: "json" }),
|
||||
project_id: text()
|
||||
.$type<ProjectID>()
|
||||
.notNull()
|
||||
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import z from "zod"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Identifier } from "@/id/id"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Database, eq } from "@/storage/db"
|
||||
@@ -6,7 +7,6 @@ import { Project } from "@/project/project"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Log } from "@/util/log"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { WorkspaceTable } from "./workspace.sql"
|
||||
import { getAdaptor } from "./adaptors"
|
||||
import { WorkspaceInfo } from "./types"
|
||||
@@ -49,7 +49,7 @@ export namespace Workspace {
|
||||
id: Identifier.schema("workspace").optional(),
|
||||
type: Info.shape.type,
|
||||
branch: Info.shape.branch,
|
||||
projectID: ProjectID.zod,
|
||||
projectID: Info.shape.projectID,
|
||||
extra: Info.shape.extra,
|
||||
})
|
||||
|
||||
@@ -117,7 +117,7 @@ export namespace Workspace {
|
||||
const adaptor = await getAdaptor(space.type)
|
||||
const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined)
|
||||
if (!res || !res.ok || !res.body) {
|
||||
await Bun.sleep(1000)
|
||||
await sleep(1000)
|
||||
continue
|
||||
}
|
||||
await parseSSE(res.body, stop, (event) => {
|
||||
@@ -127,7 +127,7 @@ export namespace Workspace {
|
||||
})
|
||||
})
|
||||
// Wait 250ms and retry if SSE connection fails
|
||||
await Bun.sleep(250)
|
||||
await sleep(250)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,10 @@ export namespace Flag {
|
||||
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
|
||||
export declare const OPENCODE_TUI_CONFIG: string | undefined
|
||||
export declare const OPENCODE_CONFIG_DIR: string | undefined
|
||||
export declare const OPENCODE_PLUGIN_META_FILE: string | undefined
|
||||
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
|
||||
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
|
||||
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
|
||||
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
|
||||
export const OPENCODE_SHOW_TTFD = truthy("OPENCODE_SHOW_TTFD")
|
||||
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
|
||||
export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")
|
||||
export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD")
|
||||
@@ -107,17 +105,6 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", {
|
||||
configurable: false,
|
||||
})
|
||||
|
||||
// Dynamic getter for OPENCODE_PLUGIN_META_FILE
|
||||
// This must be evaluated at access time, not module load time,
|
||||
// because tests and external tooling may set this env var at runtime
|
||||
Object.defineProperty(Flag, "OPENCODE_PLUGIN_META_FILE", {
|
||||
get() {
|
||||
return process.env["OPENCODE_PLUGIN_META_FILE"]
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
|
||||
// Dynamic getter for OPENCODE_CLIENT
|
||||
// This must be evaluated at access time, not module load time,
|
||||
// because some commands override the client at runtime
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
import { text } from "node:stream/consumers"
|
||||
import { BunProc } from "../bun"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Npm } from "@/npm"
|
||||
|
||||
export interface Info {
|
||||
name: string
|
||||
command: string[]
|
||||
environment?: Record<string, string>
|
||||
extensions: string[]
|
||||
enabled(): Promise<boolean>
|
||||
enabled(): Promise<string[] | false>
|
||||
}
|
||||
|
||||
export const gofmt: Info = {
|
||||
name: "gofmt",
|
||||
command: ["gofmt", "-w", "$FILE"],
|
||||
extensions: [".go"],
|
||||
async enabled() {
|
||||
return which("gofmt") !== null
|
||||
const p = which("gofmt")
|
||||
if (p === null) return false
|
||||
return [p, "-w", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const mix: Info = {
|
||||
name: "mix",
|
||||
command: ["mix", "format", "$FILE"],
|
||||
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||
async enabled() {
|
||||
return which("mix") !== null
|
||||
const p = which("mix")
|
||||
if (p === null) return false
|
||||
return [p, "format", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const prettier: Info = {
|
||||
name: "prettier",
|
||||
command: [BunProc.which(), "x", "prettier", "--write", "$FILE"],
|
||||
environment: {
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
@@ -73,8 +73,9 @@ export const prettier: Info = {
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
}>(item)
|
||||
if (json.dependencies?.prettier) return true
|
||||
if (json.devDependencies?.prettier) return true
|
||||
if (json.dependencies?.prettier || json.devDependencies?.prettier) {
|
||||
return [await Npm.which("prettier"), "--write", "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -82,7 +83,6 @@ export const prettier: Info = {
|
||||
|
||||
export const oxfmt: Info = {
|
||||
name: "oxfmt",
|
||||
command: [BunProc.which(), "x", "oxfmt", "$FILE"],
|
||||
environment: {
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
@@ -95,8 +95,9 @@ export const oxfmt: Info = {
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
}>(item)
|
||||
if (json.dependencies?.oxfmt) return true
|
||||
if (json.devDependencies?.oxfmt) return true
|
||||
if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) {
|
||||
return [await Npm.which("oxfmt"), "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -104,7 +105,6 @@ export const oxfmt: Info = {
|
||||
|
||||
export const biome: Info = {
|
||||
name: "biome",
|
||||
command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"],
|
||||
environment: {
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
@@ -141,7 +141,7 @@ export const biome: Info = {
|
||||
for (const config of configs) {
|
||||
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
|
||||
if (found.length > 0) {
|
||||
return true
|
||||
return [await Npm.which("@biomejs/biome"), "check", "--write", "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -150,47 +150,49 @@ export const biome: Info = {
|
||||
|
||||
export const zig: Info = {
|
||||
name: "zig",
|
||||
command: ["zig", "fmt", "$FILE"],
|
||||
extensions: [".zig", ".zon"],
|
||||
async enabled() {
|
||||
return which("zig") !== null
|
||||
const p = which("zig")
|
||||
if (p === null) return false
|
||||
return [p, "fmt", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const clang: Info = {
|
||||
name: "clang-format",
|
||||
command: ["clang-format", "-i", "$FILE"],
|
||||
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
|
||||
async enabled() {
|
||||
const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
|
||||
return items.length > 0
|
||||
if (items.length === 0) return false
|
||||
return ["clang-format", "-i", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const ktlint: Info = {
|
||||
name: "ktlint",
|
||||
command: ["ktlint", "-F", "$FILE"],
|
||||
extensions: [".kt", ".kts"],
|
||||
async enabled() {
|
||||
return which("ktlint") !== null
|
||||
const p = which("ktlint")
|
||||
if (p === null) return false
|
||||
return [p, "-F", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const ruff: Info = {
|
||||
name: "ruff",
|
||||
command: ["ruff", "format", "$FILE"],
|
||||
extensions: [".py", ".pyi"],
|
||||
async enabled() {
|
||||
if (!which("ruff")) return false
|
||||
const p = which("ruff")
|
||||
if (p === null) return false
|
||||
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
|
||||
for (const config of configs) {
|
||||
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
|
||||
if (found.length > 0) {
|
||||
if (config === "pyproject.toml") {
|
||||
const content = await Filesystem.readText(found[0])
|
||||
if (content.includes("[tool.ruff]")) return true
|
||||
if (content.includes("[tool.ruff]")) return [p, "format", "$FILE"]
|
||||
} else {
|
||||
return true
|
||||
return [p, "format", "$FILE"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,7 +201,7 @@ export const ruff: Info = {
|
||||
const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
|
||||
if (found.length > 0) {
|
||||
const content = await Filesystem.readText(found[0])
|
||||
if (content.includes("ruff")) return true
|
||||
if (content.includes("ruff")) return [p, "format", "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -208,14 +210,13 @@ export const ruff: Info = {
|
||||
|
||||
export const rlang: Info = {
|
||||
name: "air",
|
||||
command: ["air", "format", "$FILE"],
|
||||
extensions: [".R"],
|
||||
async enabled() {
|
||||
const airPath = which("air")
|
||||
if (airPath == null) return false
|
||||
|
||||
try {
|
||||
const proc = Process.spawn(["air", "--help"], {
|
||||
const proc = Process.spawn([airPath, "--help"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
@@ -227,7 +228,10 @@ export const rlang: Info = {
|
||||
const firstLine = output.split("\n")[0]
|
||||
const hasR = firstLine.includes("R language")
|
||||
const hasFormatter = firstLine.includes("formatter")
|
||||
return hasR && hasFormatter
|
||||
if (hasR && hasFormatter) {
|
||||
return [airPath, "format", "$FILE"]
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
@@ -236,14 +240,14 @@ export const rlang: Info = {
|
||||
|
||||
export const uvformat: Info = {
|
||||
name: "uv",
|
||||
command: ["uv", "format", "--", "$FILE"],
|
||||
extensions: [".py", ".pyi"],
|
||||
async enabled() {
|
||||
if (await ruff.enabled()) return false
|
||||
if (which("uv") !== null) {
|
||||
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
|
||||
const uvPath = which("uv")
|
||||
if (uvPath !== null) {
|
||||
const proc = Process.spawn([uvPath, "format", "--help"], { stderr: "pipe", stdout: "pipe" })
|
||||
const code = await proc.exited
|
||||
return code === 0
|
||||
if (code === 0) return [uvPath, "format", "--", "$FILE"]
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -251,108 +255,118 @@ export const uvformat: Info = {
|
||||
|
||||
export const rubocop: Info = {
|
||||
name: "rubocop",
|
||||
command: ["rubocop", "--autocorrect", "$FILE"],
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async enabled() {
|
||||
return which("rubocop") !== null
|
||||
const path = which("rubocop")
|
||||
if (path === null) return false
|
||||
return [path, "--autocorrect", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const standardrb: Info = {
|
||||
name: "standardrb",
|
||||
command: ["standardrb", "--fix", "$FILE"],
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async enabled() {
|
||||
return which("standardrb") !== null
|
||||
const path = which("standardrb")
|
||||
if (path === null) return false
|
||||
return [path, "--fix", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const htmlbeautifier: Info = {
|
||||
name: "htmlbeautifier",
|
||||
command: ["htmlbeautifier", "$FILE"],
|
||||
extensions: [".erb", ".html.erb"],
|
||||
async enabled() {
|
||||
return which("htmlbeautifier") !== null
|
||||
const path = which("htmlbeautifier")
|
||||
if (path === null) return false
|
||||
return [path, "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const dart: Info = {
|
||||
name: "dart",
|
||||
command: ["dart", "format", "$FILE"],
|
||||
extensions: [".dart"],
|
||||
async enabled() {
|
||||
return which("dart") !== null
|
||||
const path = which("dart")
|
||||
if (path === null) return false
|
||||
return [path, "format", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const ocamlformat: Info = {
|
||||
name: "ocamlformat",
|
||||
command: ["ocamlformat", "-i", "$FILE"],
|
||||
extensions: [".ml", ".mli"],
|
||||
async enabled() {
|
||||
if (!which("ocamlformat")) return false
|
||||
const path = which("ocamlformat")
|
||||
if (!path) return false
|
||||
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
|
||||
return items.length > 0
|
||||
if (items.length === 0) return false
|
||||
return [path, "-i", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const terraform: Info = {
|
||||
name: "terraform",
|
||||
command: ["terraform", "fmt", "$FILE"],
|
||||
extensions: [".tf", ".tfvars"],
|
||||
async enabled() {
|
||||
return which("terraform") !== null
|
||||
const path = which("terraform")
|
||||
if (path === null) return false
|
||||
return [path, "fmt", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const latexindent: Info = {
|
||||
name: "latexindent",
|
||||
command: ["latexindent", "-w", "-s", "$FILE"],
|
||||
extensions: [".tex"],
|
||||
async enabled() {
|
||||
return which("latexindent") !== null
|
||||
const path = which("latexindent")
|
||||
if (path === null) return false
|
||||
return [path, "-w", "-s", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const gleam: Info = {
|
||||
name: "gleam",
|
||||
command: ["gleam", "format", "$FILE"],
|
||||
extensions: [".gleam"],
|
||||
async enabled() {
|
||||
return which("gleam") !== null
|
||||
const path = which("gleam")
|
||||
if (path === null) return false
|
||||
return [path, "format", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const shfmt: Info = {
|
||||
name: "shfmt",
|
||||
command: ["shfmt", "-w", "$FILE"],
|
||||
extensions: [".sh", ".bash"],
|
||||
async enabled() {
|
||||
return which("shfmt") !== null
|
||||
const path = which("shfmt")
|
||||
if (path === null) return false
|
||||
return [path, "-w", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const nixfmt: Info = {
|
||||
name: "nixfmt",
|
||||
command: ["nixfmt", "$FILE"],
|
||||
extensions: [".nix"],
|
||||
async enabled() {
|
||||
return which("nixfmt") !== null
|
||||
const path = which("nixfmt")
|
||||
if (path === null) return false
|
||||
return [path, "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const rustfmt: Info = {
|
||||
name: "rustfmt",
|
||||
command: ["rustfmt", "$FILE"],
|
||||
extensions: [".rs"],
|
||||
async enabled() {
|
||||
return which("rustfmt") !== null
|
||||
const path = which("rustfmt")
|
||||
if (path === null) return false
|
||||
return [path, "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const pint: Info = {
|
||||
name: "pint",
|
||||
command: ["./vendor/bin/pint", "$FILE"],
|
||||
extensions: [".php"],
|
||||
async enabled() {
|
||||
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
|
||||
@@ -361,8 +375,9 @@ export const pint: Info = {
|
||||
require?: Record<string, string>
|
||||
"require-dev"?: Record<string, string>
|
||||
}>(item)
|
||||
if (json.require?.["laravel/pint"]) return true
|
||||
if (json["require-dev"]?.["laravel/pint"]) return true
|
||||
if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) {
|
||||
return ["./vendor/bin/pint", "$FILE"]
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
@@ -370,27 +385,30 @@ export const pint: Info = {
|
||||
|
||||
export const ormolu: Info = {
|
||||
name: "ormolu",
|
||||
command: ["ormolu", "-i", "$FILE"],
|
||||
extensions: [".hs"],
|
||||
async enabled() {
|
||||
return which("ormolu") !== null
|
||||
const path = which("ormolu")
|
||||
if (path === null) return false
|
||||
return [path, "-i", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const cljfmt: Info = {
|
||||
name: "cljfmt",
|
||||
command: ["cljfmt", "fix", "--quiet", "$FILE"],
|
||||
extensions: [".clj", ".cljs", ".cljc", ".edn"],
|
||||
async enabled() {
|
||||
return which("cljfmt") !== null
|
||||
const path = which("cljfmt")
|
||||
if (path === null) return false
|
||||
return [path, "fix", "--quiet", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
export const dfmt: Info = {
|
||||
name: "dfmt",
|
||||
command: ["dfmt", "-i", "$FILE"],
|
||||
extensions: [".d"],
|
||||
async enabled() {
|
||||
return which("dfmt") !== null
|
||||
const path = which("dfmt")
|
||||
if (path === null) return false
|
||||
return [path, "-i", "$FILE"]
|
||||
},
|
||||
}
|
||||
|
||||
@@ -25,14 +25,14 @@ export namespace Format {
|
||||
export type Status = z.infer<typeof Status>
|
||||
|
||||
const state = Instance.state(async () => {
|
||||
const enabled: Record<string, boolean> = {}
|
||||
const cache: Record<string, string[] | false> = {}
|
||||
const cfg = await Config.get()
|
||||
|
||||
const formatters: Record<string, Formatter.Info> = {}
|
||||
if (cfg.formatter === false) {
|
||||
log.info("all formatters are disabled")
|
||||
return {
|
||||
enabled,
|
||||
cache,
|
||||
formatters,
|
||||
}
|
||||
}
|
||||
@@ -46,43 +46,41 @@ export namespace Format {
|
||||
continue
|
||||
}
|
||||
const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, {
|
||||
command: [],
|
||||
extensions: [],
|
||||
...item,
|
||||
})
|
||||
|
||||
if (result.command.length === 0) continue
|
||||
|
||||
result.enabled = async () => true
|
||||
result.enabled = async () => item.command ?? false
|
||||
result.name = name
|
||||
formatters[name] = result
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
cache,
|
||||
formatters,
|
||||
}
|
||||
})
|
||||
|
||||
async function isEnabled(item: Formatter.Info) {
|
||||
async function resolveCommand(item: Formatter.Info) {
|
||||
const s = await state()
|
||||
let status = s.enabled[item.name]
|
||||
if (status === undefined) {
|
||||
status = await item.enabled()
|
||||
s.enabled[item.name] = status
|
||||
let command = s.cache[item.name]
|
||||
if (command === undefined) {
|
||||
log.info("resolving command", { name: item.name })
|
||||
command = await item.enabled()
|
||||
s.cache[item.name] = command
|
||||
}
|
||||
return status
|
||||
return command
|
||||
}
|
||||
|
||||
async function getFormatter(ext: string) {
|
||||
const formatters = await state().then((x) => x.formatters)
|
||||
const result = []
|
||||
const result: { info: Formatter.Info; command: string[] }[] = []
|
||||
for (const item of Object.values(formatters)) {
|
||||
log.info("checking", { name: item.name, ext })
|
||||
if (!item.extensions.includes(ext)) continue
|
||||
if (!(await isEnabled(item))) continue
|
||||
const command = await resolveCommand(item)
|
||||
if (!command) continue
|
||||
log.info("enabled", { name: item.name, ext })
|
||||
result.push(item)
|
||||
result.push({ info: item, command })
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -91,11 +89,11 @@ export namespace Format {
|
||||
const s = await state()
|
||||
const result: Status[] = []
|
||||
for (const formatter of Object.values(s.formatters)) {
|
||||
const enabled = await isEnabled(formatter)
|
||||
const command = await resolveCommand(formatter)
|
||||
result.push({
|
||||
name: formatter.name,
|
||||
extensions: formatter.extensions,
|
||||
enabled,
|
||||
enabled: !!command,
|
||||
})
|
||||
}
|
||||
return result
|
||||
@@ -108,29 +106,27 @@ export namespace Format {
|
||||
log.info("formatting", { file })
|
||||
const ext = path.extname(file)
|
||||
|
||||
for (const item of await getFormatter(ext)) {
|
||||
log.info("running", { command: item.command })
|
||||
for (const { info, command } of await getFormatter(ext)) {
|
||||
const replaced = command.map((x) => x.replace("$FILE", file))
|
||||
log.info("running", { replaced })
|
||||
try {
|
||||
const proc = Process.spawn(
|
||||
item.command.map((x) => x.replace("$FILE", file)),
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
env: { ...process.env, ...item.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
const proc = Process.spawn(replaced, {
|
||||
cwd: Instance.directory,
|
||||
env: { ...process.env, ...info.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0)
|
||||
log.error("failed", {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
command,
|
||||
...info.environment,
|
||||
})
|
||||
} catch (error) {
|
||||
log.error("failed to format file", {
|
||||
error,
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
command,
|
||||
...info.environment,
|
||||
file,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export namespace Global {
|
||||
return process.env.OPENCODE_TEST_HOME || os.homedir()
|
||||
},
|
||||
data,
|
||||
bin: path.join(data, "bin"),
|
||||
bin: path.join(cache, "bin"),
|
||||
log: path.join(data, "log"),
|
||||
cache,
|
||||
config,
|
||||
|
||||
@@ -114,7 +114,6 @@ export namespace LSP {
|
||||
return {
|
||||
process: spawn(item.command[0], item.command.slice(1), {
|
||||
cwd: root,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
...item.env,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { spawn as launch, type ChildProcessWithoutNullStreams } from "child_process"
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "child_process"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import { BunProc } from "../bun"
|
||||
import { text } from "node:stream/consumers"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
@@ -13,11 +12,7 @@ import { Archive } from "../util/archive"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { Module } from "@opencode-ai/util/module"
|
||||
|
||||
const spawn = ((cmd, args, opts) => {
|
||||
if (Array.isArray(args)) return launch(cmd, [...args], { ...(opts ?? {}), windowsHide: true })
|
||||
return launch(cmd, { ...(args ?? {}), windowsHide: true })
|
||||
}) as typeof launch
|
||||
import { Npm } from "@/npm"
|
||||
|
||||
export namespace LSPServer {
|
||||
const log = Log.create({ service: "lsp.server" })
|
||||
@@ -107,7 +102,7 @@ export namespace LSPServer {
|
||||
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
|
||||
log.info("typescript server", { tsserver })
|
||||
if (!tsserver) return
|
||||
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
|
||||
const proc = spawn(await Npm.which("typescript-language-server"), ["--stdio"], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -133,29 +128,8 @@ export namespace LSPServer {
|
||||
let binary = which("vue-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(
|
||||
Global.Path.bin,
|
||||
"node_modules",
|
||||
"@vue",
|
||||
"language-server",
|
||||
"bin",
|
||||
"vue-language-server.js",
|
||||
)
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "@vue/language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("@vue/language-server")
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -218,7 +192,7 @@ export namespace LSPServer {
|
||||
log.info("installed VS Code ESLint server", { serverPath })
|
||||
}
|
||||
|
||||
const proc = spawn(BunProc.which(), [serverPath, "--stdio"], {
|
||||
const proc = spawn(await Npm.which("tsx"), [serverPath, "--stdio"], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -349,8 +323,8 @@ export namespace LSPServer {
|
||||
if (!bin) {
|
||||
const resolved = Module.resolve("biome", root)
|
||||
if (!resolved) return
|
||||
bin = BunProc.which()
|
||||
args = ["x", "biome", "lsp-proxy", "--stdio"]
|
||||
bin = await Npm.which("biome")
|
||||
args = ["lsp-proxy", "--stdio"]
|
||||
}
|
||||
|
||||
const proc = spawn(bin, args, {
|
||||
@@ -376,9 +350,7 @@ export namespace LSPServer {
|
||||
},
|
||||
extensions: [".go"],
|
||||
async spawn(root) {
|
||||
let bin = which("gopls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("gopls")
|
||||
if (!bin) {
|
||||
if (!which("go")) return
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
@@ -413,9 +385,7 @@ export namespace LSPServer {
|
||||
root: NearestRoot(["Gemfile"]),
|
||||
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
||||
async spawn(root) {
|
||||
let bin = which("rubocop", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("rubocop")
|
||||
if (!bin) {
|
||||
const ruby = which("ruby")
|
||||
const gem = which("gem")
|
||||
@@ -520,19 +490,8 @@ export namespace LSPServer {
|
||||
let binary = which("pyright-langserver")
|
||||
const args = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "pyright"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push(...["run", js])
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("pyright")
|
||||
}
|
||||
args.push("--stdio")
|
||||
|
||||
@@ -634,9 +593,7 @@ export namespace LSPServer {
|
||||
extensions: [".zig", ".zon"],
|
||||
root: NearestRoot(["build.zig"]),
|
||||
async spawn(root) {
|
||||
let bin = which("zls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("zls")
|
||||
|
||||
if (!bin) {
|
||||
const zig = which("zig")
|
||||
@@ -746,9 +703,7 @@ export namespace LSPServer {
|
||||
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
|
||||
extensions: [".cs"],
|
||||
async spawn(root) {
|
||||
let bin = which("csharp-ls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("csharp-ls")
|
||||
if (!bin) {
|
||||
if (!which("dotnet")) {
|
||||
log.error(".NET SDK is required to install csharp-ls")
|
||||
@@ -785,9 +740,7 @@ export namespace LSPServer {
|
||||
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
|
||||
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
|
||||
async spawn(root) {
|
||||
let bin = which("fsautocomplete", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("fsautocomplete")
|
||||
if (!bin) {
|
||||
if (!which("dotnet")) {
|
||||
log.error(".NET SDK is required to install fsautocomplete")
|
||||
@@ -1053,22 +1006,8 @@ export namespace LSPServer {
|
||||
let binary = which("svelteserver")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "svelte-language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("svelte-language-server")
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -1100,22 +1039,8 @@ export namespace LSPServer {
|
||||
let binary = which("astro-ls")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("@astrojs/language-server")
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -1364,31 +1289,8 @@ export namespace LSPServer {
|
||||
let binary = which("yaml-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(
|
||||
Global.Path.bin,
|
||||
"node_modules",
|
||||
"yaml-language-server",
|
||||
"out",
|
||||
"server",
|
||||
"src",
|
||||
"server.js",
|
||||
)
|
||||
const exists = await Filesystem.exists(js)
|
||||
if (!exists) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "yaml-language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("yaml-language-server")
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -1417,9 +1319,7 @@ export namespace LSPServer {
|
||||
]),
|
||||
extensions: [".lua"],
|
||||
async spawn(root) {
|
||||
let bin = which("lua-language-server", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("lua-language-server")
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
@@ -1555,22 +1455,8 @@ export namespace LSPServer {
|
||||
let binary = which("intelephense")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "intelephense"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("intelephense")
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -1652,22 +1538,8 @@ export namespace LSPServer {
|
||||
let binary = which("bash-language-server")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "bash-language-server"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("bash-language-server")
|
||||
}
|
||||
args.push("start")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -1688,9 +1560,7 @@ export namespace LSPServer {
|
||||
extensions: [".tf", ".tfvars"],
|
||||
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
|
||||
async spawn(root) {
|
||||
let bin = which("terraform-ls", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("terraform-ls")
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
@@ -1771,9 +1641,7 @@ export namespace LSPServer {
|
||||
extensions: [".tex", ".bib"],
|
||||
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
|
||||
async spawn(root) {
|
||||
let bin = which("texlab", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("texlab")
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
@@ -1864,22 +1732,8 @@ export namespace LSPServer {
|
||||
let binary = which("docker-langserver")
|
||||
const args: string[] = []
|
||||
if (!binary) {
|
||||
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
|
||||
if (!(await Filesystem.exists(js))) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
|
||||
cwd: Global.Path.bin,
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_BE_BUN: "1",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
}).exited
|
||||
}
|
||||
binary = BunProc.which()
|
||||
args.push("run", js)
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
binary = await Npm.which("dockerfile-language-server-nodejs")
|
||||
}
|
||||
args.push("--stdio")
|
||||
const proc = spawn(binary, args, {
|
||||
@@ -1970,9 +1824,7 @@ export namespace LSPServer {
|
||||
extensions: [".typ", ".typc"],
|
||||
root: NearestRoot(["typst.toml"]),
|
||||
async spawn(root) {
|
||||
let bin = which("tinymist", {
|
||||
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
|
||||
})
|
||||
let bin = which("tinymist")
|
||||
|
||||
if (!bin) {
|
||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Config } from "../config/config"
|
||||
import { Log } from "../util/log"
|
||||
import { Process } from "../util/process"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import z from "zod/v4"
|
||||
import { Instance } from "../project/instance"
|
||||
@@ -166,14 +167,10 @@ export namespace MCP {
|
||||
const queue = [pid]
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!
|
||||
const proc = Bun.spawn(["pgrep", "-P", String(current)], { stdout: "pipe", stderr: "pipe" })
|
||||
const [code, out] = await Promise.all([proc.exited, new Response(proc.stdout).text()]).catch(
|
||||
() => [-1, ""] as const,
|
||||
)
|
||||
if (code !== 0) continue
|
||||
for (const tok of out.trim().split(/\s+/)) {
|
||||
const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true })
|
||||
for (const tok of lines) {
|
||||
const cpid = parseInt(tok, 10)
|
||||
if (!isNaN(cpid) && pids.indexOf(cpid) === -1) {
|
||||
if (!isNaN(cpid) && !pids.includes(cpid)) {
|
||||
pids.push(cpid)
|
||||
queue.push(cpid)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createConnection } from "net"
|
||||
import { createServer } from "http"
|
||||
import { Log } from "../util/log"
|
||||
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||
|
||||
@@ -52,11 +53,74 @@ interface PendingAuth {
|
||||
}
|
||||
|
||||
export namespace McpOAuthCallback {
|
||||
let server: ReturnType<typeof Bun.serve> | undefined
|
||||
let server: ReturnType<typeof createServer> | undefined
|
||||
const pendingAuths = new Map<string, PendingAuth>()
|
||||
|
||||
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
|
||||
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
|
||||
|
||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
return
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
log.info("received oauth callback", { hasCode: !!code, state, error })
|
||||
|
||||
// Enforce state parameter presence
|
||||
if (!state) {
|
||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||
log.error("oauth callback missing state parameter", { url: url.toString() })
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
if (pendingAuths.has(state)) {
|
||||
const pending = pendingAuths.get(state)!
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.reject(new Error(errorMsg))
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR("No authorization code provided"))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
if (!pendingAuths.has(state)) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
const pending = pendingAuths.get(state)!
|
||||
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.resolve(code)
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_SUCCESS)
|
||||
}
|
||||
|
||||
export async function ensureRunning(): Promise<void> {
|
||||
if (server) return
|
||||
|
||||
@@ -66,75 +130,14 @@ export namespace McpOAuthCallback {
|
||||
return
|
||||
}
|
||||
|
||||
server = Bun.serve({
|
||||
port: OAUTH_CALLBACK_PORT,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
log.info("received oauth callback", { hasCode: !!code, state, error })
|
||||
|
||||
// Enforce state parameter presence
|
||||
if (!state) {
|
||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||
log.error("oauth callback missing state parameter", { url: url.toString() })
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
if (pendingAuths.has(state)) {
|
||||
const pending = pendingAuths.get(state)!
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.reject(new Error(errorMsg))
|
||||
}
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return new Response(HTML_ERROR("No authorization code provided"), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
if (!pendingAuths.has(state)) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
const pending = pendingAuths.get(state)!
|
||||
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.resolve(code)
|
||||
|
||||
return new Response(HTML_SUCCESS, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
},
|
||||
server = createServer(handleRequest)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server!.listen(OAUTH_CALLBACK_PORT, () => {
|
||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
||||
resolve()
|
||||
})
|
||||
server!.on("error", reject)
|
||||
})
|
||||
|
||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
||||
}
|
||||
|
||||
export function waitForCallback(oauthState: string): Promise<string> {
|
||||
@@ -174,7 +177,7 @@ export namespace McpOAuthCallback {
|
||||
|
||||
export async function stop(): Promise<void> {
|
||||
if (server) {
|
||||
server.stop()
|
||||
await new Promise<void>((resolve) => server!.close(() => resolve()))
|
||||
server = undefined
|
||||
log.info("oauth callback server stopped")
|
||||
}
|
||||
|
||||
8
packages/opencode/src/node.ts
Normal file
8
packages/opencode/src/node.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Server } from "./server/server"
|
||||
|
||||
const result = await Server.listen({
|
||||
port: 1338,
|
||||
hostname: "0.0.0.0",
|
||||
})
|
||||
|
||||
console.log(result)
|
||||
160
packages/opencode/src/npm/index.ts
Normal file
160
packages/opencode/src/npm/index.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
// Workaround: Bun on Windows does not support the UV_FS_O_FILEMAP flag that
|
||||
// the `tar` package uses for files < 512KB (fs.open returns EINVAL).
|
||||
// tar silently swallows the error and skips writing files, leaving only empty
|
||||
// directories. Setting __FAKE_PLATFORM__ makes tar fall back to the plain 'w'
|
||||
// flag. See tar's get-write-flag.js.
|
||||
// Must be set before @npmcli/arborist is imported since tar caches the flag
|
||||
// at module evaluation time — so we use a dynamic import() below.
|
||||
if (process.platform === "win32") {
|
||||
process.env.__FAKE_PLATFORM__ = "linux"
|
||||
}
|
||||
|
||||
import semver from "semver"
|
||||
import z from "zod"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Global } from "../global"
|
||||
import { Lock } from "../util/lock"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { readdir } from "fs/promises"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
const { Arborist } = await import("@npmcli/arborist")
|
||||
|
||||
export namespace Npm {
|
||||
const log = Log.create({ service: "npm" })
|
||||
|
||||
export const InstallFailedError = NamedError.create(
|
||||
"NpmInstallFailedError",
|
||||
z.object({
|
||||
pkg: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
function directory(pkg: string) {
|
||||
return path.join(Global.Path.cache, "packages", pkg)
|
||||
}
|
||||
|
||||
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
|
||||
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
|
||||
if (!response.ok) {
|
||||
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
|
||||
return false
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
|
||||
const latestVersion = data?.["dist-tags"]?.latest
|
||||
if (!latestVersion) {
|
||||
log.warn("No latest version found, using cached", { pkg, cachedVersion })
|
||||
return false
|
||||
}
|
||||
|
||||
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
|
||||
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
|
||||
|
||||
return semver.lt(cachedVersion, latestVersion)
|
||||
}
|
||||
|
||||
export async function add(pkg: string) {
|
||||
using _ = await Lock.write("npm-install")
|
||||
log.info("installing package", {
|
||||
pkg,
|
||||
})
|
||||
const hash = pkg
|
||||
const dir = directory(hash)
|
||||
|
||||
const arborist = new Arborist({
|
||||
path: dir,
|
||||
binLinks: true,
|
||||
progress: false,
|
||||
savePrefix: "",
|
||||
})
|
||||
const tree = await arborist.loadVirtual().catch(() => {})
|
||||
if (tree) {
|
||||
const first = tree.edgesOut.values().next().value?.to
|
||||
if (first) {
|
||||
log.info("package already installed", { pkg })
|
||||
return first.path
|
||||
}
|
||||
}
|
||||
|
||||
const result = await arborist
|
||||
.reify({
|
||||
add: [pkg],
|
||||
save: true,
|
||||
saveType: "prod",
|
||||
})
|
||||
.catch((cause) => {
|
||||
throw new InstallFailedError(
|
||||
{ pkg },
|
||||
{
|
||||
cause,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
const first = result.edgesOut.values().next().value?.to
|
||||
if (!first) throw new InstallFailedError({ pkg })
|
||||
return first.path
|
||||
}
|
||||
|
||||
export async function install(dir: string) {
|
||||
log.info("checking dependencies", { dir })
|
||||
|
||||
const reify = async () => {
|
||||
const arb = new Arborist({
|
||||
path: dir,
|
||||
binLinks: true,
|
||||
progress: false,
|
||||
savePrefix: "",
|
||||
})
|
||||
await arb.reify().catch(() => {})
|
||||
}
|
||||
|
||||
if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
|
||||
log.info("node_modules missing, reifying")
|
||||
await reify()
|
||||
return
|
||||
}
|
||||
|
||||
const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
|
||||
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
|
||||
|
||||
const declared = new Set([
|
||||
...Object.keys(pkg.dependencies || {}),
|
||||
...Object.keys(pkg.devDependencies || {}),
|
||||
...Object.keys(pkg.peerDependencies || {}),
|
||||
...Object.keys(pkg.optionalDependencies || {}),
|
||||
])
|
||||
|
||||
const root = lock.packages?.[""] || {}
|
||||
const locked = new Set([
|
||||
...Object.keys(root.dependencies || {}),
|
||||
...Object.keys(root.devDependencies || {}),
|
||||
...Object.keys(root.peerDependencies || {}),
|
||||
...Object.keys(root.optionalDependencies || {}),
|
||||
])
|
||||
|
||||
for (const name of declared) {
|
||||
if (!locked.has(name)) {
|
||||
log.info("dependency not in lock file, reifying", { name })
|
||||
await reify()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.info("dependencies in sync")
|
||||
}
|
||||
|
||||
export async function which(pkg: string) {
|
||||
const dir = path.join(directory(pkg), "node_modules", ".bin")
|
||||
const files = await readdir(dir).catch(() => [])
|
||||
if (!files.length) {
|
||||
await add(pkg)
|
||||
const retry = await readdir(dir).catch(() => [])
|
||||
if (!retry.length) throw new Error(`No binary found for package "${pkg}" after install`)
|
||||
return path.join(dir, retry[0])
|
||||
}
|
||||
return path.join(dir, files[0])
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { Database, eq } from "@/storage/db"
|
||||
import { PermissionTable } from "@/session/session.sql"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Log } from "@/util/log"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
@@ -91,7 +90,7 @@ export namespace PermissionNext {
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
|
||||
export const Approval = z.object({
|
||||
projectID: ProjectID.zod,
|
||||
projectID: z.string(),
|
||||
patterns: z.string().array(),
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Auth, OAUTH_DUMMY_KEY } from "../auth"
|
||||
import os from "os"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { createServer } from "http"
|
||||
|
||||
const log = Log.create({ service: "plugin.codex" })
|
||||
|
||||
@@ -240,7 +241,7 @@ interface PendingOAuth {
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
let oauthServer: ReturnType<typeof Bun.serve> | undefined
|
||||
let oauthServer: ReturnType<typeof createServer> | undefined
|
||||
let pendingOAuth: PendingOAuth | undefined
|
||||
|
||||
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
|
||||
@@ -248,77 +249,83 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||
}
|
||||
|
||||
oauthServer = Bun.serve({
|
||||
port: OAUTH_PORT,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
oauthServer = createServer((req, res) => {
|
||||
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
|
||||
|
||||
if (url.pathname === "/auth/callback") {
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
if (url.pathname === "/auth/callback") {
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
const errorMsg = "Missing authorization code"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||
const errorMsg = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
const current = pendingOAuth
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
|
||||
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
return new Response(HTML_SUCCESS, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === "/cancel") {
|
||||
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||
if (!code) {
|
||||
const errorMsg = "Missing authorization code"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response("Login cancelled", { status: 200 })
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||
const errorMsg = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
const current = pendingOAuth
|
||||
pendingOAuth = undefined
|
||||
|
||||
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_SUCCESS)
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === "/cancel") {
|
||||
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(200)
|
||||
res.end("Login cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
oauthServer!.listen(OAUTH_PORT, () => {
|
||||
log.info("codex oauth server started", { port: OAUTH_PORT })
|
||||
resolve()
|
||||
})
|
||||
oauthServer!.on("error", reject)
|
||||
})
|
||||
|
||||
log.info("codex oauth server started", { port: OAUTH_PORT })
|
||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||
}
|
||||
|
||||
function stopOAuthServer() {
|
||||
if (oauthServer) {
|
||||
oauthServer.stop()
|
||||
oauthServer.close(() => {
|
||||
log.info("codex oauth server stopped")
|
||||
})
|
||||
oauthServer = undefined
|
||||
log.info("codex oauth server stopped")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ import { Bus } from "../bus"
|
||||
import { Log } from "../util/log"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { Server } from "../server/server"
|
||||
import { Npm } from "../npm"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { CodexAuthPlugin } from "./codex"
|
||||
import { Session } from "../session"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { CopilotAuthPlugin } from "./copilot"
|
||||
import { parsePluginSpecifier, resolvePluginTarget, uniqueModuleEntries } from "./shared"
|
||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
|
||||
|
||||
export namespace Plugin {
|
||||
@@ -27,7 +27,9 @@ export namespace Plugin {
|
||||
directory: Instance.directory,
|
||||
fetch: async (...args) => Server.Default().fetch(...args),
|
||||
})
|
||||
log.info("loading config")
|
||||
const config = await Config.get()
|
||||
log.info("config loaded")
|
||||
const hooks: Hooks[] = []
|
||||
const input: PluginInput = {
|
||||
client,
|
||||
@@ -37,7 +39,8 @@ export namespace Plugin {
|
||||
get serverUrl(): URL {
|
||||
throw new Error("Server URL is no longer supported in plugins")
|
||||
},
|
||||
$: Bun.$,
|
||||
// @ts-expect-error
|
||||
$: typeof Bun === "undefined" ? undefined : Bun.$,
|
||||
}
|
||||
|
||||
for (const plugin of INTERNAL_PLUGINS) {
|
||||
@@ -54,83 +57,45 @@ export namespace Plugin {
|
||||
plugins = [...BUILTIN, ...plugins]
|
||||
}
|
||||
|
||||
async function resolvePlugin(spec: string) {
|
||||
const parsed = parsePluginSpecifier(spec)
|
||||
const target = await resolvePluginTarget(spec, parsed).catch((err) => {
|
||||
const cause = err instanceof Error ? err.cause : err
|
||||
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
|
||||
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${detail}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return ""
|
||||
})
|
||||
if (!target) return
|
||||
return target
|
||||
}
|
||||
|
||||
function isServerPlugin(value: unknown): value is PluginInstance {
|
||||
return typeof value === "function"
|
||||
}
|
||||
|
||||
function getServerPlugin(value: unknown) {
|
||||
if (isServerPlugin(value)) return value
|
||||
if (!value || typeof value !== "object" || !("server" in value)) return
|
||||
if (!isServerPlugin(value.server)) return
|
||||
return value.server
|
||||
}
|
||||
|
||||
const prep = async (item: (typeof plugins)[number]) => {
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
for (let plugin of plugins) {
|
||||
// ignore old codex plugin since it is supported first party now
|
||||
if (spec.includes("opencode-openai-codex-auth") || spec.includes("opencode-copilot-auth")) return
|
||||
log.info("loading plugin", { path: spec })
|
||||
const target = await resolvePlugin(spec)
|
||||
if (!target) return
|
||||
const mod = await import(target).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
log.error("failed to load plugin", { path: spec, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${spec}: ${message}`,
|
||||
}).toObject(),
|
||||
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
|
||||
log.info("loading plugin", { path: plugin })
|
||||
if (!plugin.startsWith("file://")) {
|
||||
plugin = await Npm.add(plugin).catch((err) => {
|
||||
const cause = err instanceof Error ? err.cause : err
|
||||
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
|
||||
log.error("failed to install plugin", { plugin, error: detail })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to install plugin ${plugin}: ${detail}`,
|
||||
}).toObject(),
|
||||
})
|
||||
return ""
|
||||
})
|
||||
return
|
||||
})
|
||||
if (!mod) return
|
||||
return {
|
||||
item,
|
||||
spec,
|
||||
mod,
|
||||
if (!plugin) continue
|
||||
}
|
||||
}
|
||||
|
||||
const loaded = await Promise.all(plugins.map((item) => prep(item)))
|
||||
for (const load of loaded) {
|
||||
if (!load) continue
|
||||
|
||||
// Keep plugin execution sequential so hook registration and execution
|
||||
// order remains deterministic across plugin runs.
|
||||
// Prevent duplicate initialization when plugins export the same function
|
||||
// as both a named export and default export (e.g., `export const X` and `export default X`).
|
||||
// uniqueModuleEntries keeps only the first export for each shared value reference.
|
||||
await (async () => {
|
||||
for (const [, entry] of uniqueModuleEntries(load.mod)) {
|
||||
const server = getServerPlugin(entry)
|
||||
if (!server) throw new TypeError("Plugin export is not a function")
|
||||
hooks.push(await server(input, Config.pluginOptions(load.item)))
|
||||
}
|
||||
})().catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
log.error("failed to load plugin", { path: load.spec, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${load.spec}: ${message}`,
|
||||
}).toObject(),
|
||||
// Object.entries(mod) would return both entries pointing to the same function reference.
|
||||
await import(plugin)
|
||||
.then(async (mod) => {
|
||||
const seen = new Set<PluginInstance>()
|
||||
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
|
||||
if (seen.has(fn)) continue
|
||||
seen.add(fn)
|
||||
hooks.push(await fn(input))
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
log.error("failed to load plugin", { path: plugin, error: message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: new NamedError.Unknown({
|
||||
message: `Failed to load plugin ${plugin}: ${message}`,
|
||||
}).toObject(),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
import { parsePluginSpecifier } from "./shared"
|
||||
|
||||
export namespace PluginMeta {
|
||||
type Source = "file" | "npm"
|
||||
|
||||
export type Entry = {
|
||||
name: string
|
||||
source: Source
|
||||
spec: string
|
||||
target: string
|
||||
requested?: string
|
||||
version?: string
|
||||
modified?: number
|
||||
first_time: number
|
||||
last_time: number
|
||||
time_changed: number
|
||||
load_count: number
|
||||
fingerprint: string
|
||||
}
|
||||
|
||||
export type State = "first" | "updated" | "same"
|
||||
|
||||
type Store = Record<string, Entry>
|
||||
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint">
|
||||
|
||||
const cache = {
|
||||
ready: false,
|
||||
path: "",
|
||||
store: {} as Store,
|
||||
dirty: false,
|
||||
}
|
||||
|
||||
function storePath() {
|
||||
return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json")
|
||||
}
|
||||
|
||||
function sourceKind(spec: string): Source {
|
||||
if (spec.startsWith("file://")) return "file"
|
||||
return "npm"
|
||||
}
|
||||
|
||||
function entryKey(spec: string) {
|
||||
if (spec.startsWith("file://")) return `file:${fileURLToPath(spec)}`
|
||||
return `npm:${parsePluginSpecifier(spec).pkg}`
|
||||
}
|
||||
|
||||
function entryName(spec: string) {
|
||||
if (spec.startsWith("file://")) return path.parse(fileURLToPath(spec)).name
|
||||
return parsePluginSpecifier(spec).pkg
|
||||
}
|
||||
|
||||
function fileTarget(spec: string, target: string) {
|
||||
if (spec.startsWith("file://")) return fileURLToPath(spec)
|
||||
if (target.startsWith("file://")) return fileURLToPath(target)
|
||||
return
|
||||
}
|
||||
|
||||
function modifiedAt(file: string) {
|
||||
const stat = Filesystem.stat(file)
|
||||
if (!stat) return
|
||||
const value = stat.mtimeMs
|
||||
return Math.floor(typeof value === "bigint" ? Number(value) : value)
|
||||
}
|
||||
|
||||
function resolvedTarget(target: string) {
|
||||
if (target.startsWith("file://")) return fileURLToPath(target)
|
||||
return target
|
||||
}
|
||||
|
||||
async function npmVersion(target: string) {
|
||||
const resolved = resolvedTarget(target)
|
||||
const stat = Filesystem.stat(resolved)
|
||||
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
|
||||
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
|
||||
.then((item) => item.version)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
async function entryCore(spec: string, target: string): Promise<Core> {
|
||||
const source = sourceKind(spec)
|
||||
if (source === "file") {
|
||||
const file = fileTarget(spec, target)
|
||||
return {
|
||||
name: entryName(spec),
|
||||
source,
|
||||
spec,
|
||||
target,
|
||||
modified: file ? modifiedAt(file) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: entryName(spec),
|
||||
source,
|
||||
spec,
|
||||
target,
|
||||
requested: parsePluginSpecifier(spec).version,
|
||||
version: await npmVersion(target),
|
||||
}
|
||||
}
|
||||
|
||||
function fingerprint(value: Core) {
|
||||
if (value.source === "file") return [value.target, value.modified ?? ""].join("|")
|
||||
return [value.target, value.requested ?? "", value.version ?? ""].join("|")
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const next = storePath()
|
||||
if (cache.ready && cache.path === next) return
|
||||
cache.path = next
|
||||
cache.store = await Filesystem.readJson<Store>(next).catch(() => ({}) as Store)
|
||||
cache.dirty = false
|
||||
cache.ready = true
|
||||
}
|
||||
|
||||
export async function touch(spec: string, target: string): Promise<{ state: State; entry: Entry }> {
|
||||
await load()
|
||||
const now = Date.now()
|
||||
const id = entryKey(spec)
|
||||
const prev = cache.store[id]
|
||||
const core = await entryCore(spec, target)
|
||||
const entry: Entry = {
|
||||
...core,
|
||||
first_time: prev?.first_time ?? now,
|
||||
last_time: now,
|
||||
time_changed: prev?.time_changed ?? now,
|
||||
load_count: (prev?.load_count ?? 0) + 1,
|
||||
fingerprint: fingerprint(core),
|
||||
}
|
||||
|
||||
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
|
||||
if (state === "updated") entry.time_changed = now
|
||||
|
||||
cache.store[id] = entry
|
||||
cache.dirty = true
|
||||
return {
|
||||
state,
|
||||
entry,
|
||||
}
|
||||
}
|
||||
|
||||
export async function persist() {
|
||||
await load()
|
||||
if (!cache.dirty) return
|
||||
await Filesystem.writeJson(cache.path, cache.store)
|
||||
cache.dirty = false
|
||||
}
|
||||
|
||||
export async function list(): Promise<Store> {
|
||||
await load()
|
||||
return { ...cache.store }
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { BunProc } from "@/bun"
|
||||
|
||||
export function parsePluginSpecifier(spec: string) {
|
||||
const lastAt = spec.lastIndexOf("@")
|
||||
const pkg = lastAt > 0 ? spec.substring(0, lastAt) : spec
|
||||
const version = lastAt > 0 ? spec.substring(lastAt + 1) : "latest"
|
||||
return { pkg, version }
|
||||
}
|
||||
|
||||
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
|
||||
if (spec.startsWith("file://")) return spec
|
||||
return BunProc.install(parsed.pkg, parsed.version)
|
||||
}
|
||||
|
||||
export function uniqueModuleEntries(mod: Record<string, unknown>) {
|
||||
const seen = new Set<unknown>()
|
||||
const entries: [string, unknown][] = []
|
||||
|
||||
for (const [name, entry] of Object.entries(mod)) {
|
||||
if (seen.has(entry)) continue
|
||||
seen.add(entry)
|
||||
entries.push([name, entry])
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
|
||||
import { Timestamps } from "../storage/schema.sql"
|
||||
import type { ProjectID } from "./schema"
|
||||
|
||||
export const ProjectTable = sqliteTable("project", {
|
||||
id: text().$type<ProjectID>().primaryKey(),
|
||||
id: text().primaryKey(),
|
||||
worktree: text().notNull(),
|
||||
vcs: text(),
|
||||
name: text(),
|
||||
|
||||
@@ -15,7 +15,6 @@ import { existsSync } from "fs"
|
||||
import { git } from "../util/git"
|
||||
import { Glob } from "../util/glob"
|
||||
import { which } from "../util/which"
|
||||
import { ProjectID } from "./schema"
|
||||
|
||||
export namespace Project {
|
||||
const log = Log.create({ service: "project" })
|
||||
@@ -34,7 +33,7 @@ export namespace Project {
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: ProjectID.zod,
|
||||
id: z.string(),
|
||||
worktree: z.string(),
|
||||
vcs: z.literal("git").optional(),
|
||||
name: z.string().optional(),
|
||||
@@ -74,7 +73,7 @@ export namespace Project {
|
||||
? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
|
||||
: undefined
|
||||
return {
|
||||
id: ProjectID.make(row.id),
|
||||
id: row.id,
|
||||
worktree: row.worktree,
|
||||
vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
|
||||
name: row.name ?? undefined,
|
||||
@@ -92,7 +91,6 @@ export namespace Project {
|
||||
function readCachedId(dir: string) {
|
||||
return Filesystem.readText(path.join(dir, "opencode"))
|
||||
.then((x) => x.trim())
|
||||
.then(ProjectID.make)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
@@ -113,7 +111,7 @@ export namespace Project {
|
||||
|
||||
if (!gitBinary) {
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
id: id ?? "global",
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
|
||||
@@ -132,7 +130,7 @@ export namespace Project {
|
||||
|
||||
if (!worktree) {
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
id: id ?? "global",
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
|
||||
@@ -162,14 +160,14 @@ export namespace Project {
|
||||
|
||||
if (!roots) {
|
||||
return {
|
||||
id: ProjectID.global,
|
||||
id: "global",
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
|
||||
}
|
||||
}
|
||||
|
||||
id = roots[0] ? ProjectID.make(roots[0]) : undefined
|
||||
id = roots[0]
|
||||
if (id) {
|
||||
await Filesystem.write(path.join(dotgit, "opencode"), id).catch(() => undefined)
|
||||
}
|
||||
@@ -177,7 +175,7 @@ export namespace Project {
|
||||
|
||||
if (!id) {
|
||||
return {
|
||||
id: ProjectID.global,
|
||||
id: "global",
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: "git",
|
||||
@@ -210,7 +208,7 @@ export namespace Project {
|
||||
}
|
||||
|
||||
return {
|
||||
id: ProjectID.global,
|
||||
id: "global",
|
||||
worktree: "/",
|
||||
sandbox: "/",
|
||||
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
|
||||
@@ -230,7 +228,7 @@ export namespace Project {
|
||||
updated: Date.now(),
|
||||
},
|
||||
}
|
||||
if (data.id !== ProjectID.global) {
|
||||
if (data.id !== "global") {
|
||||
await migrateFromGlobal(data.id, data.worktree)
|
||||
}
|
||||
return fresh
|
||||
@@ -310,12 +308,12 @@ export namespace Project {
|
||||
return
|
||||
}
|
||||
|
||||
async function migrateFromGlobal(id: ProjectID, worktree: string) {
|
||||
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, ProjectID.global)).get())
|
||||
async function migrateFromGlobal(id: string, worktree: string) {
|
||||
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, "global")).get())
|
||||
if (!row) return
|
||||
|
||||
const sessions = Database.use((db) =>
|
||||
db.select().from(SessionTable).where(eq(SessionTable.project_id, ProjectID.global)).all(),
|
||||
db.select().from(SessionTable).where(eq(SessionTable.project_id, "global")).all(),
|
||||
)
|
||||
if (sessions.length === 0) return
|
||||
|
||||
@@ -325,14 +323,14 @@ export namespace Project {
|
||||
// Skip sessions that belong to a different directory
|
||||
if (row.directory && row.directory !== worktree) return
|
||||
|
||||
log.info("migrating session", { sessionID: row.id, from: ProjectID.global, to: id })
|
||||
log.info("migrating session", { sessionID: row.id, from: "global", to: id })
|
||||
Database.use((db) => db.update(SessionTable).set({ project_id: id }).where(eq(SessionTable.id, row.id)).run())
|
||||
}).catch((error) => {
|
||||
log.error("failed to migrate sessions from global to project", { error, projectId: id })
|
||||
})
|
||||
}
|
||||
|
||||
export function setInitialized(id: ProjectID) {
|
||||
export function setInitialized(id: string) {
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(ProjectTable)
|
||||
@@ -354,7 +352,7 @@ export namespace Project {
|
||||
)
|
||||
}
|
||||
|
||||
export function get(id: ProjectID): Info | undefined {
|
||||
export function get(id: string): Info | undefined {
|
||||
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) return undefined
|
||||
return fromRow(row)
|
||||
@@ -377,13 +375,12 @@ export namespace Project {
|
||||
|
||||
export const update = fn(
|
||||
z.object({
|
||||
projectID: ProjectID.zod,
|
||||
projectID: z.string(),
|
||||
name: z.string().optional(),
|
||||
icon: Info.shape.icon.optional(),
|
||||
commands: Info.shape.commands.optional(),
|
||||
}),
|
||||
async (input) => {
|
||||
const id = ProjectID.make(input.projectID)
|
||||
const result = Database.use((db) =>
|
||||
db
|
||||
.update(ProjectTable)
|
||||
@@ -394,7 +391,7 @@ export namespace Project {
|
||||
commands: input.commands,
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.where(eq(ProjectTable.id, id))
|
||||
.where(eq(ProjectTable.id, input.projectID))
|
||||
.returning()
|
||||
.get(),
|
||||
)
|
||||
@@ -410,7 +407,7 @@ export namespace Project {
|
||||
},
|
||||
)
|
||||
|
||||
export async function sandboxes(id: ProjectID) {
|
||||
export async function sandboxes(id: string) {
|
||||
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) return []
|
||||
const data = fromRow(row)
|
||||
@@ -422,7 +419,7 @@ export namespace Project {
|
||||
return valid
|
||||
}
|
||||
|
||||
export async function addSandbox(id: ProjectID, directory: string) {
|
||||
export async function addSandbox(id: string, directory: string) {
|
||||
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) throw new Error(`Project not found: ${id}`)
|
||||
const sandboxes = [...row.sandboxes]
|
||||
@@ -446,7 +443,7 @@ export namespace Project {
|
||||
return data
|
||||
}
|
||||
|
||||
export async function removeSandbox(id: ProjectID, directory: string) {
|
||||
export async function removeSandbox(id: string, directory: string) {
|
||||
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) throw new Error(`Project not found: ${id}`)
|
||||
const sandboxes = row.sandboxes.filter((s) => s !== directory)
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectId"))
|
||||
|
||||
export type ProjectID = typeof projectIdSchema.Type
|
||||
|
||||
export const ProjectID = projectIdSchema.pipe(
|
||||
withStatics((schema: typeof projectIdSchema) => ({
|
||||
global: schema.makeUnsafe("global"),
|
||||
make: (id: string) => schema.makeUnsafe(id),
|
||||
zod: z.string().pipe(z.custom<ProjectID>()),
|
||||
})),
|
||||
)
|
||||
@@ -40,6 +40,14 @@ export namespace ProviderError {
|
||||
return /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
|
||||
}
|
||||
|
||||
function error(providerID: string, error: APICallError) {
|
||||
if (providerID.includes("github-copilot") && error.statusCode === 403) {
|
||||
return "Please reauthenticate with the copilot provider to ensure your credentials work properly with OpenCode."
|
||||
}
|
||||
|
||||
return error.message
|
||||
}
|
||||
|
||||
function message(providerID: string, e: APICallError) {
|
||||
return iife(() => {
|
||||
const msg = e.message
|
||||
@@ -52,6 +60,10 @@ export namespace ProviderError {
|
||||
return "Unknown error"
|
||||
}
|
||||
|
||||
const transformed = error(providerID, e)
|
||||
if (transformed !== msg) {
|
||||
return transformed
|
||||
}
|
||||
if (!e.responseBody || (e.statusCode && msg !== STATUS_CODES[e.statusCode])) {
|
||||
return msg
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Config } from "../config/config"
|
||||
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
|
||||
import { NoSuchModelError, type Provider as SDK } from "ai"
|
||||
import { Log } from "../util/log"
|
||||
import { BunProc } from "../bun"
|
||||
import { Npm } from "../npm"
|
||||
import { Hash } from "../util/hash"
|
||||
import { Plugin } from "../plugin"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
@@ -67,11 +67,7 @@ export namespace Provider {
|
||||
const project =
|
||||
options["project"] ?? Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
|
||||
const location =
|
||||
options["location"] ??
|
||||
Env.get("GOOGLE_VERTEX_LOCATION") ??
|
||||
Env.get("GOOGLE_CLOUD_LOCATION") ??
|
||||
Env.get("VERTEX_LOCATION") ??
|
||||
"us-central1"
|
||||
options["location"] ?? Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-central1"
|
||||
const endpoint = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`
|
||||
|
||||
return {
|
||||
@@ -441,11 +437,7 @@ export namespace Provider {
|
||||
Env.get("GCLOUD_PROJECT")
|
||||
|
||||
const location =
|
||||
provider.options?.location ??
|
||||
Env.get("GOOGLE_VERTEX_LOCATION") ??
|
||||
Env.get("GOOGLE_CLOUD_LOCATION") ??
|
||||
Env.get("VERTEX_LOCATION") ??
|
||||
"us-central1"
|
||||
provider.options?.location ?? Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-central1"
|
||||
|
||||
const autoload = Boolean(project)
|
||||
if (!autoload) return { autoload: false }
|
||||
@@ -1209,7 +1201,7 @@ export namespace Provider {
|
||||
|
||||
let installedPath: string
|
||||
if (!model.api.npm.startsWith("file://")) {
|
||||
installedPath = await BunProc.install(model.api.npm, "latest")
|
||||
installedPath = await Npm.add(model.api.npm)
|
||||
} else {
|
||||
log.info("loading local provider", { pkg: model.api.npm })
|
||||
installedPath = model.api.npm
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { type IPty } from "bun-pty"
|
||||
import type { Proc } from "#pty"
|
||||
import z from "zod"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Log } from "../util/log"
|
||||
@@ -23,6 +23,8 @@ export namespace Pty {
|
||||
close: (code?: number, reason?: string) => void
|
||||
}
|
||||
|
||||
const key = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
|
||||
|
||||
// WebSocket control frame: 0x00 + UTF-8 JSON.
|
||||
const meta = (cursor: number) => {
|
||||
const json = JSON.stringify({ cursor })
|
||||
@@ -33,10 +35,7 @@ export namespace Pty {
|
||||
return out
|
||||
}
|
||||
|
||||
const pty = lazy(async () => {
|
||||
const { spawn } = await import("bun-pty")
|
||||
return spawn
|
||||
})
|
||||
const pty = lazy(() => import("#pty"))
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
@@ -83,7 +82,7 @@ export namespace Pty {
|
||||
|
||||
interface ActiveSession {
|
||||
info: Info
|
||||
process: IPty
|
||||
process: Proc
|
||||
buffer: string
|
||||
bufferCursor: number
|
||||
cursor: number
|
||||
@@ -97,9 +96,9 @@ export namespace Pty {
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
for (const [key, ws] of session.subscribers.entries()) {
|
||||
for (const [id, ws] of session.subscribers.entries()) {
|
||||
try {
|
||||
if (ws.data === key) ws.close()
|
||||
if (key(ws) === id) ws.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -142,7 +141,7 @@ export namespace Pty {
|
||||
}
|
||||
log.info("creating session", { id, cmd: command, args, cwd })
|
||||
|
||||
const spawn = await pty()
|
||||
const { spawn } = await pty()
|
||||
const ptyProcess = spawn(command, args, {
|
||||
name: "xterm-256color",
|
||||
cwd,
|
||||
@@ -170,21 +169,21 @@ export namespace Pty {
|
||||
ptyProcess.onData((chunk) => {
|
||||
session.cursor += chunk.length
|
||||
|
||||
for (const [key, ws] of session.subscribers.entries()) {
|
||||
for (const [id, ws] of session.subscribers.entries()) {
|
||||
if (ws.readyState !== 1) {
|
||||
session.subscribers.delete(key)
|
||||
session.subscribers.delete(id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (ws.data !== key) {
|
||||
session.subscribers.delete(key)
|
||||
if (key(ws) !== id) {
|
||||
session.subscribers.delete(id)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
ws.send(chunk)
|
||||
} catch {
|
||||
session.subscribers.delete(key)
|
||||
session.subscribers.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,9 +225,9 @@ export namespace Pty {
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
for (const [key, ws] of session.subscribers.entries()) {
|
||||
for (const [id, ws] of session.subscribers.entries()) {
|
||||
try {
|
||||
if (ws.data === key) ws.close()
|
||||
if (key(ws) === id) ws.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -259,16 +258,13 @@ export namespace Pty {
|
||||
}
|
||||
log.info("client connected to session", { id })
|
||||
|
||||
// Use ws.data as the unique key for this connection lifecycle.
|
||||
// If ws.data is undefined, fallback to ws object.
|
||||
const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws
|
||||
const sub = key(ws)
|
||||
|
||||
// Optionally cleanup if the key somehow exists
|
||||
session.subscribers.delete(connectionKey)
|
||||
session.subscribers.set(connectionKey, ws)
|
||||
session.subscribers.delete(sub)
|
||||
session.subscribers.set(sub, ws)
|
||||
|
||||
const cleanup = () => {
|
||||
session.subscribers.delete(connectionKey)
|
||||
session.subscribers.delete(sub)
|
||||
}
|
||||
|
||||
const start = session.bufferCursor
|
||||
|
||||
26
packages/opencode/src/pty/pty.bun.ts
Normal file
26
packages/opencode/src/pty/pty.bun.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { spawn as create } from "bun-pty"
|
||||
import type { Opts, Proc } from "./pty"
|
||||
|
||||
export type { Disp, Exit, Opts, Proc } from "./pty"
|
||||
|
||||
export function spawn(file: string, args: string[], opts: Opts): Proc {
|
||||
const pty = create(file, args, opts)
|
||||
return {
|
||||
pid: pty.pid,
|
||||
onData(listener) {
|
||||
return pty.onData(listener)
|
||||
},
|
||||
onExit(listener) {
|
||||
return pty.onExit(listener)
|
||||
},
|
||||
write(data) {
|
||||
pty.write(data)
|
||||
},
|
||||
resize(cols, rows) {
|
||||
pty.resize(cols, rows)
|
||||
},
|
||||
kill(signal) {
|
||||
pty.kill(signal)
|
||||
},
|
||||
}
|
||||
}
|
||||
26
packages/opencode/src/pty/pty.node.ts
Normal file
26
packages/opencode/src/pty/pty.node.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as pty from "node-pty"
|
||||
import type { Opts, Proc } from "./pty"
|
||||
|
||||
export type { Disp, Exit, Opts, Proc } from "./pty"
|
||||
|
||||
export function spawn(file: string, args: string[], opts: Opts): Proc {
|
||||
const proc = pty.spawn(file, args, opts)
|
||||
return {
|
||||
pid: proc.pid,
|
||||
onData(listener) {
|
||||
return proc.onData(listener)
|
||||
},
|
||||
onExit(listener) {
|
||||
return proc.onExit(listener)
|
||||
},
|
||||
write(data) {
|
||||
proc.write(data)
|
||||
},
|
||||
resize(cols, rows) {
|
||||
proc.resize(cols, rows)
|
||||
},
|
||||
kill(signal) {
|
||||
proc.kill(signal)
|
||||
},
|
||||
}
|
||||
}
|
||||
25
packages/opencode/src/pty/pty.ts
Normal file
25
packages/opencode/src/pty/pty.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type Disp = {
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export type Exit = {
|
||||
exitCode: number
|
||||
signal?: number | string
|
||||
}
|
||||
|
||||
export type Opts = {
|
||||
name: string
|
||||
cols?: number
|
||||
rows?: number
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
export type Proc = {
|
||||
pid: number
|
||||
onData(listener: (data: string) => void): Disp
|
||||
onExit(listener: (event: Exit) => void): Disp
|
||||
write(data: string): void
|
||||
resize(cols: number, rows: number): void
|
||||
kill(signal?: string): void
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { resolver } from "hono-openapi"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { Project } from "../../project/project"
|
||||
import z from "zod"
|
||||
import { ProjectID } from "../../project/schema"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { InstanceBootstrap } from "../../project/bootstrap"
|
||||
@@ -29,7 +28,7 @@ export const ProjectRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const projects = await Project.list()
|
||||
const projects = Project.list()
|
||||
return c.json(projects)
|
||||
},
|
||||
)
|
||||
@@ -106,7 +105,7 @@ export const ProjectRoutes = lazy(() =>
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ projectID: ProjectID.zod })),
|
||||
validator("param", z.object({ projectID: z.string() })),
|
||||
validator("json", Project.update.schema.omit({ projectID: true })),
|
||||
async (c) => {
|
||||
const projectID = c.req.valid("param").projectID
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { upgradeWebSocket } from "hono/bun"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import z from "zod"
|
||||
import { Pty } from "@/pty"
|
||||
import { NotFoundError } from "../../storage/db"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
export const PtyRoutes = lazy(() =>
|
||||
new Hono()
|
||||
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
return new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
@@ -196,5 +195,5 @@ export const PtyRoutes = lazy(() =>
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ import { ProviderRoutes } from "./routes/provider"
|
||||
import { InstanceBootstrap } from "../project/bootstrap"
|
||||
import { NotFoundError } from "../storage/db"
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||
import { websocket } from "hono/bun"
|
||||
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
||||
import { createNodeWebSocket } from "@hono/node-ws"
|
||||
import { HTTPException } from "hono/http-exception"
|
||||
import { errors } from "./error"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
@@ -48,13 +49,20 @@ import { lazy } from "@/util/lazy"
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
|
||||
export namespace Server {
|
||||
const log = Log.create({ service: "server" })
|
||||
export type Listener = {
|
||||
hostname: string
|
||||
port: number
|
||||
url: URL
|
||||
stop: (close?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export const Default = lazy(() => createApp({}))
|
||||
export const Default = lazy(() => create({}).app)
|
||||
|
||||
export const createApp = (opts: { cors?: string[] }): Hono => {
|
||||
function create(opts: { cors?: string[] }) {
|
||||
const log = Log.create({ service: "server" })
|
||||
const app = new Hono()
|
||||
return app
|
||||
const ws = createNodeWebSocket({ app })
|
||||
const route = app
|
||||
.onError((err, c) => {
|
||||
log.error("failed", {
|
||||
error: err,
|
||||
@@ -239,7 +247,6 @@ export namespace Server {
|
||||
),
|
||||
)
|
||||
.route("/project", ProjectRoutes())
|
||||
.route("/pty", PtyRoutes())
|
||||
.route("/config", ConfigRoutes())
|
||||
.route("/experimental", ExperimentalRoutes())
|
||||
.route("/session", SessionRoutes())
|
||||
@@ -552,6 +559,7 @@ export namespace Server {
|
||||
})
|
||||
},
|
||||
)
|
||||
.route("/pty", PtyRoutes(ws.upgradeWebSocket))
|
||||
.all("/*", async (c) => {
|
||||
const path = c.req.path
|
||||
|
||||
@@ -568,6 +576,11 @@ export namespace Server {
|
||||
)
|
||||
return response
|
||||
})
|
||||
|
||||
return {
|
||||
app: route as Hono,
|
||||
ws,
|
||||
}
|
||||
}
|
||||
|
||||
export async function openapi() {
|
||||
@@ -585,48 +598,86 @@ export namespace Server {
|
||||
return result
|
||||
}
|
||||
|
||||
export function listen(opts: {
|
||||
export async function listen(opts: {
|
||||
port: number
|
||||
hostname: string
|
||||
mdns?: boolean
|
||||
mdnsDomain?: string
|
||||
cors?: string[]
|
||||
}) {
|
||||
const app = createApp(opts)
|
||||
const args = {
|
||||
hostname: opts.hostname,
|
||||
idleTimeout: 0,
|
||||
fetch: app.fetch,
|
||||
websocket: websocket,
|
||||
} as const
|
||||
const tryServe = (port: number) => {
|
||||
try {
|
||||
return Bun.serve({ ...args, port })
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}): Promise<Listener> {
|
||||
const log = Log.create({ service: "server" })
|
||||
const built = create({
|
||||
...opts,
|
||||
})
|
||||
const start = (port: number) =>
|
||||
new Promise<ServerType>((resolve, reject) => {
|
||||
const server = createAdaptorServer({ fetch: built.app.fetch })
|
||||
built.ws.injectWebSocket(server)
|
||||
const fail = (err: Error) => {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
const ready = () => {
|
||||
cleanup()
|
||||
resolve(server)
|
||||
}
|
||||
const cleanup = () => {
|
||||
server.off("error", fail)
|
||||
server.off("listening", ready)
|
||||
}
|
||||
server.once("error", fail)
|
||||
server.once("listening", ready)
|
||||
server.listen(port, opts.hostname)
|
||||
})
|
||||
|
||||
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
|
||||
const addr = server.address()
|
||||
if (!addr || typeof addr === "string") {
|
||||
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||
}
|
||||
const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port)
|
||||
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
|
||||
|
||||
const url = new URL("http://localhost")
|
||||
url.hostname = opts.hostname
|
||||
url.port = String(addr.port)
|
||||
|
||||
const shouldPublishMDNS =
|
||||
opts.mdns &&
|
||||
server.port &&
|
||||
addr.port &&
|
||||
opts.hostname !== "127.0.0.1" &&
|
||||
opts.hostname !== "localhost" &&
|
||||
opts.hostname !== "::1"
|
||||
if (shouldPublishMDNS) {
|
||||
MDNS.publish(server.port!, opts.mdnsDomain)
|
||||
MDNS.publish(addr.port, opts.mdnsDomain)
|
||||
} else if (opts.mdns) {
|
||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||
}
|
||||
|
||||
const originalStop = server.stop.bind(server)
|
||||
server.stop = async (closeActiveConnections?: boolean) => {
|
||||
if (shouldPublishMDNS) MDNS.unpublish()
|
||||
return originalStop(closeActiveConnections)
|
||||
let closing: Promise<void> | undefined
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
port: addr.port,
|
||||
url,
|
||||
stop(close?: boolean) {
|
||||
closing ??= new Promise((resolve, reject) => {
|
||||
if (shouldPublishMDNS) MDNS.unpublish()
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
if (close) {
|
||||
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
||||
server.closeAllConnections()
|
||||
}
|
||||
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
|
||||
server.closeIdleConnections()
|
||||
}
|
||||
}
|
||||
})
|
||||
return closing
|
||||
},
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import { fn } from "@/util/fn"
|
||||
import { Command } from "../command"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { WorkspaceContext } from "../control-plane/workspace-context"
|
||||
import { ProjectID } from "../project/schema"
|
||||
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
@@ -121,7 +120,7 @@ export namespace Session {
|
||||
.object({
|
||||
id: Identifier.schema("session"),
|
||||
slug: z.string(),
|
||||
projectID: ProjectID.zod,
|
||||
projectID: z.string(),
|
||||
workspaceID: z.string().optional(),
|
||||
directory: z.string(),
|
||||
parentID: Identifier.schema("session").optional(),
|
||||
@@ -163,7 +162,7 @@ export namespace Session {
|
||||
|
||||
export const ProjectInfo = z
|
||||
.object({
|
||||
id: ProjectID.zod,
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
worktree: z.string(),
|
||||
})
|
||||
|
||||
@@ -8,12 +8,9 @@ import { Snapshot } from "@/snapshot"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Database, eq, desc, inArray } from "@/storage/db"
|
||||
import { MessageTable, PartTable } from "./session.sql"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { STATUS_CODES } from "http"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { ProviderError } from "@/provider/error"
|
||||
import { iife } from "@/util/iife"
|
||||
import { type SystemError } from "bun"
|
||||
import type { SystemError } from "bun"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
|
||||
export namespace MessageV2 {
|
||||
|
||||
@@ -31,7 +31,6 @@ import { Flag } from "../flag/flag"
|
||||
import { ulid } from "ulid"
|
||||
import { spawn } from "child_process"
|
||||
import { Command } from "../command"
|
||||
import { $ } from "bun"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
import { SessionSummary } from "./summary"
|
||||
@@ -46,6 +45,7 @@ import { LLM } from "./llm"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Truncate } from "@/tool/truncation"
|
||||
import { Process } from "@/util/process"
|
||||
|
||||
// @ts-ignore
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
@@ -650,12 +650,7 @@ export namespace SessionPrompt {
|
||||
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||
|
||||
// Build system prompt, adding structured output instruction if needed
|
||||
const skills = await SystemPrompt.skills(agent)
|
||||
const system = [
|
||||
...(await SystemPrompt.environment(model)),
|
||||
...(skills ? [skills] : []),
|
||||
...(await InstructionPrompt.system()),
|
||||
]
|
||||
const system = [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())]
|
||||
const format = lastUser.format ?? { type: "text" }
|
||||
if (format.type === "json_schema") {
|
||||
system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
|
||||
@@ -1634,7 +1629,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
const proc = spawn(shell, args, {
|
||||
cwd,
|
||||
detached: process.platform !== "win32",
|
||||
windowsHide: process.platform === "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -1784,15 +1778,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
template = template + "\n\n" + input.arguments
|
||||
}
|
||||
|
||||
const shell = ConfigMarkdown.shell(template)
|
||||
if (shell.length > 0) {
|
||||
const shellMatches = ConfigMarkdown.shell(template)
|
||||
if (shellMatches.length > 0) {
|
||||
const sh = Shell.preferred()
|
||||
const results = await Promise.all(
|
||||
shell.map(async ([, cmd]) => {
|
||||
try {
|
||||
return await $`${{ raw: cmd }}`.quiet().nothrow().text()
|
||||
} catch (error) {
|
||||
return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
shellMatches.map(async ([, cmd]) => {
|
||||
const out = await Process.text([cmd], { shell: sh, nothrow: true })
|
||||
return out.text
|
||||
}),
|
||||
)
|
||||
let index = 0
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ProjectTable } from "../project/project.sql"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
import type { Snapshot } from "../snapshot"
|
||||
import type { PermissionNext } from "../permission/next"
|
||||
import type { ProjectID } from "../project/schema"
|
||||
import { Timestamps } from "../storage/schema.sql"
|
||||
|
||||
type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
|
||||
@@ -14,7 +13,6 @@ export const SessionTable = sqliteTable(
|
||||
{
|
||||
id: text().primaryKey(),
|
||||
project_id: text()
|
||||
.$type<ProjectID>()
|
||||
.notNull()
|
||||
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
||||
workspace_id: text(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user