mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-10 00:24:03 +00:00
Compare commits
57 Commits
v1.2.23
...
cli-auth-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41114b1b6e | ||
|
|
30a501ba11 | ||
|
|
28f795a9a0 | ||
|
|
b4b24cf691 | ||
|
|
eda87569fe | ||
|
|
89d6f60d25 | ||
|
|
ee18c9976e | ||
|
|
794532928f | ||
|
|
7b773c65ec | ||
|
|
e53aa79dc6 | ||
|
|
d9a97249c0 | ||
|
|
86cef16940 | ||
|
|
ce38997c76 | ||
|
|
7e10c728d4 | ||
|
|
3627c67cf2 | ||
|
|
2518fd81f6 | ||
|
|
39ef7fc90e | ||
|
|
37ae0a4051 | ||
|
|
b312928e9f | ||
|
|
2f2856e20a | ||
|
|
831eb6881b | ||
|
|
f20ee2fad2 | ||
|
|
8b9710e56c | ||
|
|
c6262f9d40 | ||
|
|
b749fa90f2 | ||
|
|
8a51cbd253 | ||
|
|
399b8f0701 | ||
|
|
3742e42fdf | ||
|
|
0388ec6862 | ||
|
|
366b8a8034 | ||
|
|
ef9bc4ec9e | ||
|
|
5838b58913 | ||
|
|
49deb7207f | ||
|
|
91b9a03e27 | ||
|
|
a2b527ff29 | ||
|
|
48cf609115 | ||
|
|
a581493c13 | ||
|
|
e5b34076df | ||
|
|
0e642ed885 | ||
|
|
7af0546dc0 | ||
|
|
270f44f41d | ||
|
|
2db9d317f9 | ||
|
|
95279abbbc | ||
|
|
1a2ddf9e0f | ||
|
|
f807875a99 | ||
|
|
48158ce97d | ||
|
|
adc9536a16 | ||
|
|
fec8d5bcf1 | ||
|
|
e923047219 | ||
|
|
b19dc933a4 | ||
|
|
902268e0d1 | ||
|
|
a44f78c34a | ||
|
|
a5d727e7f9 | ||
|
|
7b5b665b4a | ||
|
|
b5515dd2f7 | ||
|
|
d16e5b98dc | ||
|
|
9dbf3a2042 |
38
.github/workflows/storybook.yml
vendored
Normal file
38
.github/workflows/storybook.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: storybook
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
paths:
|
||||
- ".github/workflows/storybook.yml"
|
||||
- "package.json"
|
||||
- "bun.lock"
|
||||
- "packages/storybook/**"
|
||||
- "packages/ui/**"
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
paths:
|
||||
- ".github/workflows/storybook.yml"
|
||||
- "package.json"
|
||||
- "bun.lock"
|
||||
- "packages/storybook/**"
|
||||
- "packages/ui/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: storybook build
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Build Storybook
|
||||
run: bun --cwd packages/storybook build
|
||||
@@ -5,6 +5,11 @@
|
||||
"options": {},
|
||||
},
|
||||
},
|
||||
"permission": {
|
||||
"edit": {
|
||||
"packages/opencode/migration/*": "deny",
|
||||
},
|
||||
},
|
||||
"mcp": {},
|
||||
"tools": {
|
||||
"github-triage": false,
|
||||
|
||||
@@ -122,3 +122,7 @@ const table = sqliteTable("session", {
|
||||
- Avoid mocks as much as possible
|
||||
- Test actual implementation, do not duplicate logic into tests
|
||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||
|
||||
## Type Checking
|
||||
|
||||
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=",
|
||||
"aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=",
|
||||
"aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=",
|
||||
"x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ="
|
||||
"x86_64-linux": "sha256-+SMpaj0jeIHjlddAu6QIwojmWFVIiA8/G32hiQMjcOk=",
|
||||
"aarch64-linux": "sha256-uo63IF6OCMab+O3ngn1sVxqIGJMm04HXuDgIRmXNTNk=",
|
||||
"aarch64-darwin": "sha256-yB2tWm6AsX6UifnDqe7VldhN5zTQkDoqZ87AGQYjxT4=",
|
||||
"x86_64-darwin": "sha256-nNhtqMSG4/y+uxjj14Jc5QQ7X6hQli9ni4v56XAvaAU="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"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",
|
||||
"ai": "5.0.124",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.23",
|
||||
"version": "1.2.24",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
432
packages/app/src/components/debug-bar.tsx
Normal file
432
packages/app/src/components/debug-bar.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import { useIsRouting, useLocation } from "@solidjs/router"
|
||||
import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
|
||||
type Mem = Performance & {
|
||||
memory?: {
|
||||
usedJSHeapSize: number
|
||||
jsHeapSizeLimit: number
|
||||
}
|
||||
}
|
||||
|
||||
type Evt = PerformanceEntry & {
|
||||
interactionId?: number
|
||||
processingStart?: number
|
||||
}
|
||||
|
||||
type Shift = PerformanceEntry & {
|
||||
hadRecentInput: boolean
|
||||
value: number
|
||||
}
|
||||
|
||||
type Obs = PerformanceObserverInit & {
|
||||
durationThreshold?: number
|
||||
}
|
||||
|
||||
const span = 5000
|
||||
|
||||
const ms = (n?: number, d = 0) => {
|
||||
if (n === undefined || Number.isNaN(n)) return "n/a"
|
||||
return `${n.toFixed(d)}ms`
|
||||
}
|
||||
|
||||
const time = (n?: number) => {
|
||||
if (n === undefined || Number.isNaN(n)) return "n/a"
|
||||
return `${Math.round(n)}`
|
||||
}
|
||||
|
||||
const mb = (n?: number) => {
|
||||
if (n === undefined || Number.isNaN(n)) return "n/a"
|
||||
const v = n / 1024 / 1024
|
||||
return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
|
||||
}
|
||||
|
||||
const bad = (n: number | undefined, limit: number, low = false) => {
|
||||
if (n === undefined || Number.isNaN(n)) return false
|
||||
return low ? n < limit : n > limit
|
||||
}
|
||||
|
||||
const session = (path: string) => path.includes("/session")
|
||||
|
||||
function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string }) {
|
||||
return (
|
||||
<Tooltip value={props.tip} placement="left">
|
||||
<div class="flex w-full flex-col items-center px-0.5 py-1 text-center">
|
||||
<div class="text-[7px] font-black uppercase tracking-[0.04em] opacity-70 leading-none">{props.label}</div>
|
||||
<div
|
||||
classList={{
|
||||
"text-[9px] font-semibold leading-none tabular-nums": true,
|
||||
"text-text-on-critical-base": !!props.bad,
|
||||
"opacity-70": !!props.dim,
|
||||
}}
|
||||
>
|
||||
{props.value}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function DebugBar() {
|
||||
const location = useLocation()
|
||||
const routing = useIsRouting()
|
||||
const [state, setState] = createStore({
|
||||
cls: undefined as number | undefined,
|
||||
delay: undefined as number | undefined,
|
||||
fps: undefined as number | undefined,
|
||||
gap: undefined as number | undefined,
|
||||
heap: {
|
||||
limit: undefined as number | undefined,
|
||||
used: undefined as number | undefined,
|
||||
},
|
||||
inp: undefined as number | undefined,
|
||||
jank: undefined as number | undefined,
|
||||
long: {
|
||||
block: undefined as number | undefined,
|
||||
count: undefined as number | undefined,
|
||||
max: undefined as number | undefined,
|
||||
},
|
||||
nav: {
|
||||
dur: undefined as number | undefined,
|
||||
pending: false,
|
||||
},
|
||||
})
|
||||
|
||||
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
|
||||
const heapv = () => {
|
||||
const value = heap()
|
||||
if (value === undefined) return "n/a"
|
||||
return `${Math.round(value * 100)}%`
|
||||
}
|
||||
const longv = () => (state.long.count === undefined ? "n/a" : `${time(state.long.block)}/${state.long.count}`)
|
||||
const navv = () => (state.nav.pending ? "..." : time(state.nav.dur))
|
||||
|
||||
let prev = ""
|
||||
let start = 0
|
||||
let init = false
|
||||
let one = 0
|
||||
let two = 0
|
||||
|
||||
createEffect(() => {
|
||||
const busy = routing()
|
||||
const next = `${location.pathname}${location.search}`
|
||||
|
||||
if (!init) {
|
||||
init = true
|
||||
prev = next
|
||||
return
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
if (one !== 0) cancelAnimationFrame(one)
|
||||
if (two !== 0) cancelAnimationFrame(two)
|
||||
one = 0
|
||||
two = 0
|
||||
if (start !== 0) return
|
||||
start = performance.now()
|
||||
if (session(prev)) setState("nav", { dur: undefined, pending: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (start === 0) {
|
||||
prev = next
|
||||
return
|
||||
}
|
||||
|
||||
const at = start
|
||||
const from = prev
|
||||
start = 0
|
||||
prev = next
|
||||
|
||||
if (!(session(from) || session(next))) return
|
||||
|
||||
if (one !== 0) cancelAnimationFrame(one)
|
||||
if (two !== 0) cancelAnimationFrame(two)
|
||||
one = requestAnimationFrame(() => {
|
||||
one = 0
|
||||
two = requestAnimationFrame(() => {
|
||||
two = 0
|
||||
setState("nav", { dur: performance.now() - at, pending: false })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const obs: PerformanceObserver[] = []
|
||||
const fps: Array<{ at: number; dur: number }> = []
|
||||
const long: Array<{ at: number; dur: number }> = []
|
||||
const seen = new Map<number | string, { at: number; delay: number; dur: number }>()
|
||||
let hasLong = false
|
||||
let poll: number | undefined
|
||||
let raf = 0
|
||||
let last = 0
|
||||
let snap = 0
|
||||
|
||||
const trim = (list: Array<{ at: number; dur: number }>, span: number, at: number) => {
|
||||
while (list[0] && at - list[0].at > span) list.shift()
|
||||
}
|
||||
|
||||
const syncFrame = (at: number) => {
|
||||
trim(fps, span, at)
|
||||
const total = fps.reduce((sum, entry) => sum + entry.dur, 0)
|
||||
const gap = fps.reduce((max, entry) => Math.max(max, entry.dur), 0)
|
||||
const jank = fps.filter((entry) => entry.dur > 32).length
|
||||
batch(() => {
|
||||
setState("fps", total > 0 ? (fps.length * 1000) / total : undefined)
|
||||
setState("gap", gap > 0 ? gap : undefined)
|
||||
setState("jank", jank)
|
||||
})
|
||||
}
|
||||
|
||||
const syncLong = (at = performance.now()) => {
|
||||
if (!hasLong) return
|
||||
trim(long, span, at)
|
||||
const block = long.reduce((sum, entry) => sum + Math.max(0, entry.dur - 50), 0)
|
||||
const max = long.reduce((hi, entry) => Math.max(hi, entry.dur), 0)
|
||||
setState("long", { block, count: long.length, max })
|
||||
}
|
||||
|
||||
const syncInp = (at = performance.now()) => {
|
||||
for (const [key, entry] of seen) {
|
||||
if (at - entry.at > span) seen.delete(key)
|
||||
}
|
||||
let delay = 0
|
||||
let inp = 0
|
||||
for (const entry of seen.values()) {
|
||||
delay = Math.max(delay, entry.delay)
|
||||
inp = Math.max(inp, entry.dur)
|
||||
}
|
||||
batch(() => {
|
||||
setState("delay", delay > 0 ? delay : undefined)
|
||||
setState("inp", inp > 0 ? inp : undefined)
|
||||
})
|
||||
}
|
||||
|
||||
const syncHeap = () => {
|
||||
const mem = (performance as Mem).memory
|
||||
if (!mem) return
|
||||
setState("heap", { limit: mem.jsHeapSizeLimit, used: mem.usedJSHeapSize })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
fps.length = 0
|
||||
long.length = 0
|
||||
seen.clear()
|
||||
last = 0
|
||||
snap = 0
|
||||
batch(() => {
|
||||
setState("fps", undefined)
|
||||
setState("gap", undefined)
|
||||
setState("jank", undefined)
|
||||
setState("delay", undefined)
|
||||
setState("inp", undefined)
|
||||
if (hasLong) setState("long", { block: 0, count: 0, max: 0 })
|
||||
})
|
||||
}
|
||||
|
||||
const watch = (type: string, init: Obs, fn: (entries: PerformanceEntry[]) => void) => {
|
||||
if (typeof PerformanceObserver === "undefined") return false
|
||||
if (!(PerformanceObserver.supportedEntryTypes ?? []).includes(type)) return false
|
||||
const ob = new PerformanceObserver((list) => fn(list.getEntries()))
|
||||
try {
|
||||
ob.observe(init)
|
||||
obs.push(ob)
|
||||
return true
|
||||
} catch {
|
||||
ob.disconnect()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
watch("layout-shift", { buffered: true, type: "layout-shift" }, (entries) => {
|
||||
const add = entries.reduce((sum, entry) => {
|
||||
const item = entry as Shift
|
||||
if (item.hadRecentInput) return sum
|
||||
return sum + item.value
|
||||
}, 0)
|
||||
if (add === 0) return
|
||||
setState("cls", (value) => (value ?? 0) + add)
|
||||
})
|
||||
) {
|
||||
setState("cls", 0)
|
||||
}
|
||||
|
||||
if (
|
||||
watch("longtask", { buffered: true, type: "longtask" }, (entries) => {
|
||||
const at = performance.now()
|
||||
long.push(...entries.map((entry) => ({ at: entry.startTime, dur: entry.duration })))
|
||||
syncLong(at)
|
||||
})
|
||||
) {
|
||||
hasLong = true
|
||||
setState("long", { block: 0, count: 0, max: 0 })
|
||||
}
|
||||
|
||||
watch("event", { buffered: true, durationThreshold: 16, type: "event" }, (entries) => {
|
||||
for (const raw of entries) {
|
||||
const entry = raw as Evt
|
||||
if (entry.duration < 16) continue
|
||||
const key =
|
||||
entry.interactionId && entry.interactionId > 0
|
||||
? entry.interactionId
|
||||
: `${entry.name}:${Math.round(entry.startTime)}`
|
||||
const prev = seen.get(key)
|
||||
const delay = Math.max(0, (entry.processingStart ?? entry.startTime) - entry.startTime)
|
||||
seen.set(key, {
|
||||
at: entry.startTime,
|
||||
delay: Math.max(prev?.delay ?? 0, delay),
|
||||
dur: Math.max(prev?.dur ?? 0, entry.duration),
|
||||
})
|
||||
if (seen.size <= 200) continue
|
||||
const first = seen.keys().next().value
|
||||
if (first !== undefined) seen.delete(first)
|
||||
}
|
||||
syncInp()
|
||||
})
|
||||
|
||||
const loop = (at: number) => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
raf = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (last === 0) {
|
||||
last = at
|
||||
raf = requestAnimationFrame(loop)
|
||||
return
|
||||
}
|
||||
|
||||
fps.push({ at, dur: at - last })
|
||||
last = at
|
||||
|
||||
if (at - snap >= 250) {
|
||||
snap = at
|
||||
syncFrame(at)
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
if (raf !== 0) cancelAnimationFrame(raf)
|
||||
raf = 0
|
||||
if (poll === undefined) return
|
||||
clearInterval(poll)
|
||||
poll = undefined
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (document.visibilityState !== "visible") return
|
||||
if (poll === undefined) {
|
||||
poll = window.setInterval(() => {
|
||||
syncLong()
|
||||
syncInp()
|
||||
syncHeap()
|
||||
}, 1000)
|
||||
}
|
||||
if (raf !== 0) return
|
||||
raf = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
const vis = () => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
reset()
|
||||
start()
|
||||
}
|
||||
|
||||
syncHeap()
|
||||
start()
|
||||
document.addEventListener("visibilitychange", vis)
|
||||
|
||||
onCleanup(() => {
|
||||
if (one !== 0) cancelAnimationFrame(one)
|
||||
if (two !== 0) cancelAnimationFrame(two)
|
||||
stop()
|
||||
document.removeEventListener("visibilitychange", vis)
|
||||
for (const ob of obs) ob.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Development performance diagnostics"
|
||||
class="pointer-events-auto h-full min-h-0 w-[36px] shrink-0 overflow-y-auto text-text-on-interactive-base no-scrollbar sm:w-[38px]"
|
||||
style={{ "background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)" }}
|
||||
>
|
||||
<div class="flex min-h-full flex-col gap-0.5 py-2 font-mono">
|
||||
<Cell
|
||||
label="NAV"
|
||||
tip="Last completed route transition touching a session page, measured from router start until the first paint after it settles."
|
||||
value={navv()}
|
||||
bad={bad(state.nav.dur, 400)}
|
||||
dim={state.nav.dur === undefined && !state.nav.pending}
|
||||
/>
|
||||
<Cell
|
||||
label="FPS"
|
||||
tip="Rolling frames per second over the last 5 seconds."
|
||||
value={state.fps === undefined ? "n/a" : `${Math.round(state.fps)}`}
|
||||
bad={bad(state.fps, 50, true)}
|
||||
dim={state.fps === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="FRM"
|
||||
tip="Worst frame time over the last 5 seconds."
|
||||
value={time(state.gap)}
|
||||
bad={bad(state.gap, 50)}
|
||||
dim={state.gap === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="JNK"
|
||||
tip="Frames over 32ms in the last 5 seconds."
|
||||
value={state.jank === undefined ? "n/a" : `${state.jank}`}
|
||||
bad={bad(state.jank, 8)}
|
||||
dim={state.jank === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="LNG"
|
||||
tip={`Blocked time and long-task count in the last 5 seconds. Max task: ${ms(state.long.max)}.`}
|
||||
value={longv()}
|
||||
bad={bad(state.long.block, 200)}
|
||||
dim={state.long.count === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="DLY"
|
||||
tip="Worst observed input delay in the last 5 seconds."
|
||||
value={time(state.delay)}
|
||||
bad={bad(state.delay, 100)}
|
||||
dim={state.delay === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="INP"
|
||||
tip="Approximate interaction duration over the last 5 seconds. This is INP-like, not the official Web Vitals INP."
|
||||
value={time(state.inp)}
|
||||
bad={bad(state.inp, 200)}
|
||||
dim={state.inp === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="CLS"
|
||||
tip="Cumulative layout shift for the current app lifetime."
|
||||
value={state.cls === undefined ? "n/a" : state.cls.toFixed(2)}
|
||||
bad={bad(state.cls, 0.1)}
|
||||
dim={state.cls === undefined}
|
||||
/>
|
||||
<Cell
|
||||
label="MEM"
|
||||
tip={
|
||||
state.heap.used === undefined
|
||||
? "Used JS heap vs heap limit. Chromium only."
|
||||
: `Used JS heap vs heap limit. ${mb(state.heap.used)} of ${mb(state.heap.limit)}.`
|
||||
}
|
||||
value={heapv()}
|
||||
bad={bad(heap(), 0.8)}
|
||||
dim={state.heap.used === undefined}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
let getWorkspaceTerminalCacheKey: (dir: string) => string
|
||||
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
|
||||
let migrateTerminalState: (value: unknown) => unknown
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
@@ -17,6 +18,7 @@ beforeAll(async () => {
|
||||
const mod = await import("./terminal")
|
||||
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
|
||||
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
|
||||
migrateTerminalState = mod.migrateTerminalState
|
||||
})
|
||||
|
||||
describe("getWorkspaceTerminalCacheKey", () => {
|
||||
@@ -37,3 +39,44 @@ describe("getLegacyTerminalStorageKeys", () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrateTerminalState", () => {
|
||||
test("drops invalid terminals and restores a valid active terminal", () => {
|
||||
expect(
|
||||
migrateTerminalState({
|
||||
active: "missing",
|
||||
all: [
|
||||
null,
|
||||
{ id: "one", title: "Terminal 2" },
|
||||
{ id: "one", title: "duplicate", titleNumber: 9 },
|
||||
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 },
|
||||
{ title: "no-id" },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
active: "one",
|
||||
all: [
|
||||
{ id: "one", title: "Terminal 2", titleNumber: 2 },
|
||||
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps a valid active id", () => {
|
||||
expect(
|
||||
migrateTerminalState({
|
||||
active: "two",
|
||||
all: [
|
||||
{ id: "one", title: "Terminal 1" },
|
||||
{ id: "two", title: "shell", titleNumber: 7 },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
active: "two",
|
||||
all: [
|
||||
{ id: "one", title: "Terminal 1", titleNumber: 1 },
|
||||
{ id: "two", title: "shell", titleNumber: 7 },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,71 @@ export type LocalPTY = {
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
const MAX_TERMINAL_SESSIONS = 20
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function text(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
function num(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
function numberFromTitle(title: string) {
|
||||
const match = title.match(/^Terminal (\d+)$/)
|
||||
if (!match) return
|
||||
const value = Number(match[1])
|
||||
if (!Number.isFinite(value) || value <= 0) return
|
||||
return value
|
||||
}
|
||||
|
||||
function pty(value: unknown): LocalPTY | undefined {
|
||||
if (!record(value)) return
|
||||
|
||||
const id = text(value.id)
|
||||
if (!id) return
|
||||
|
||||
const title = text(value.title) ?? ""
|
||||
const number = num(value.titleNumber)
|
||||
const rows = num(value.rows)
|
||||
const cols = num(value.cols)
|
||||
const buffer = text(value.buffer)
|
||||
const scrollY = num(value.scrollY)
|
||||
const cursor = num(value.cursor)
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
titleNumber: number && number > 0 ? number : (numberFromTitle(title) ?? 0),
|
||||
...(rows !== undefined ? { rows } : {}),
|
||||
...(cols !== undefined ? { cols } : {}),
|
||||
...(buffer !== undefined ? { buffer } : {}),
|
||||
...(scrollY !== undefined ? { scrollY } : {}),
|
||||
...(cursor !== undefined ? { cursor } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateTerminalState(value: unknown) {
|
||||
if (!record(value)) return value
|
||||
|
||||
const seen = new Set<string>()
|
||||
const all = (Array.isArray(value.all) ? value.all : []).flatMap((item) => {
|
||||
const next = pty(item)
|
||||
if (!next || seen.has(next.id)) return []
|
||||
seen.add(next.id)
|
||||
return [next]
|
||||
})
|
||||
|
||||
const active = text(value.active)
|
||||
|
||||
return {
|
||||
active: active && seen.has(active) ? active : all[0]?.id,
|
||||
all,
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceTerminalCacheKey(dir: string) {
|
||||
return `${dir}:${WORKSPACE_KEY}`
|
||||
}
|
||||
@@ -71,16 +136,11 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
|
||||
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
|
||||
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
|
||||
|
||||
const numberFromTitle = (title: string) => {
|
||||
const match = title.match(/^Terminal (\d+)$/)
|
||||
if (!match) return
|
||||
const value = Number(match[1])
|
||||
if (!Number.isFinite(value) || value <= 0) return
|
||||
return value
|
||||
}
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.workspace(dir, "terminal", legacy),
|
||||
{
|
||||
...Persist.workspace(dir, "terminal", legacy),
|
||||
migrate: migrateTerminalState,
|
||||
},
|
||||
createStore<{
|
||||
active?: string
|
||||
all: LocalPTY[]
|
||||
@@ -128,26 +188,6 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
const meta = { migrated: false }
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
if (meta.migrated) return
|
||||
meta.migrated = true
|
||||
|
||||
setStore("all", (all) => {
|
||||
const next = all.map((pty) => {
|
||||
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
|
||||
if (direct !== undefined) return pty
|
||||
const parsed = numberFromTitle(pty.title)
|
||||
if (parsed === undefined) return pty
|
||||
return { ...pty, titleNumber: parsed }
|
||||
})
|
||||
if (next.every((pty, index) => pty === all[index])) return all
|
||||
return next
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
all: createMemo(() => store.all),
|
||||
|
||||
@@ -54,6 +54,7 @@ import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { DialogEditProject } from "@/components/dialog-edit-project"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { Titlebar } from "@/components/titlebar"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useLanguage, type Locale } from "@/context/language"
|
||||
@@ -424,6 +425,17 @@ export default function Layout(props: ParentProps) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
e.details?.type === "question.replied" ||
|
||||
e.details?.type === "question.rejected" ||
|
||||
e.details?.type === "permission.replied"
|
||||
) {
|
||||
const props = e.details.properties as { sessionID: string }
|
||||
const sessionKey = `${e.name}:${props.sessionID}`
|
||||
dismissSessionAlert(sessionKey)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
|
||||
const title =
|
||||
e.details.type === "permission.asked"
|
||||
@@ -2124,193 +2136,204 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
<Titlebar />
|
||||
<div class="flex-1 min-h-0 relative overflow-x-hidden">
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-desktop"
|
||||
classList={{
|
||||
"hidden xl:block": true,
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
aim.reset()
|
||||
if (!sidebarHovering()) return
|
||||
<div class="flex-1 min-h-0 min-w-0 flex">
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<div class="size-full relative overflow-x-hidden">
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-desktop"
|
||||
classList={{
|
||||
"hidden xl:block": true,
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
aim.reset()
|
||||
if (!sidebarHovering()) return
|
||||
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<div class="@container w-full h-full contain-strict">
|
||||
<SidebarContent
|
||||
opened={() => layout.sidebar.opened()}
|
||||
aimMove={aim.move}
|
||||
projects={() => layout.projects.list()}
|
||||
renderProject={(project) => (
|
||||
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} />
|
||||
)}
|
||||
handleDragStart={handleDragStart}
|
||||
handleDragEnd={handleDragEnd}
|
||||
handleDragOver={handleDragOver}
|
||||
openProjectLabel={language.t("command.project.open")}
|
||||
openProjectKeybind={() => command.keybind("project.open")}
|
||||
onOpenProject={chooseProject}
|
||||
renderProjectOverlay={() => (
|
||||
<ProjectDragOverlay projects={() => layout.projects.list()} activeProject={() => store.activeProject} />
|
||||
)}
|
||||
settingsLabel={() => language.t("sidebar.settings")}
|
||||
settingsKeybind={() => command.keybind("settings.open")}
|
||||
onOpenSettings={openSettings}
|
||||
helpLabel={() => language.t("sidebar.help")}
|
||||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
renderPanel={() => (
|
||||
<Show when={currentProject()} keyed>
|
||||
{(project) => <SidebarPanel project={project} merged />}
|
||||
</Show>
|
||||
)}
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<div class="@container w-full h-full contain-strict">
|
||||
<SidebarContent
|
||||
opened={() => layout.sidebar.opened()}
|
||||
aimMove={aim.move}
|
||||
projects={() => layout.projects.list()}
|
||||
renderProject={(project) => (
|
||||
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} />
|
||||
)}
|
||||
handleDragStart={handleDragStart}
|
||||
handleDragEnd={handleDragEnd}
|
||||
handleDragOver={handleDragOver}
|
||||
openProjectLabel={language.t("command.project.open")}
|
||||
openProjectKeybind={() => command.keybind("project.open")}
|
||||
onOpenProject={chooseProject}
|
||||
renderProjectOverlay={() => (
|
||||
<ProjectDragOverlay
|
||||
projects={() => layout.projects.list()}
|
||||
activeProject={() => store.activeProject}
|
||||
/>
|
||||
)}
|
||||
settingsLabel={() => language.t("sidebar.settings")}
|
||||
settingsKeybind={() => command.keybind("settings.open")}
|
||||
onOpenSettings={openSettings}
|
||||
helpLabel={() => language.t("sidebar.help")}
|
||||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
renderPanel={() => (
|
||||
<Show when={currentProject()} keyed>
|
||||
{(project) => <SidebarPanel project={project} merged />}
|
||||
</Show>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div onPointerDown={() => setSizing(true)}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
collapseThreshold={244}
|
||||
onResize={(w) => {
|
||||
setSizing(true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setSizing(false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
onCollapse={layout.sidebar.close}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
/>
|
||||
</div>
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div onPointerDown={() => setSizing(true)}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
collapseThreshold={244}
|
||||
onResize={(w) => {
|
||||
setSizing(true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setSizing(false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
|
||||
<div class="xl:hidden">
|
||||
<div
|
||||
classList={{
|
||||
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
|
||||
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
||||
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
||||
}}
|
||||
onCollapse={layout.sidebar.close}
|
||||
/>
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
classList={{
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"translate-x-0": layout.mobileSidebar.opened(),
|
||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SidebarContent
|
||||
mobile
|
||||
opened={() => layout.sidebar.opened()}
|
||||
aimMove={aim.move}
|
||||
projects={() => layout.projects.list()}
|
||||
renderProject={(project) => (
|
||||
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile />
|
||||
)}
|
||||
handleDragStart={handleDragStart}
|
||||
handleDragEnd={handleDragEnd}
|
||||
handleDragOver={handleDragOver}
|
||||
openProjectLabel={language.t("command.project.open")}
|
||||
openProjectKeybind={() => command.keybind("project.open")}
|
||||
onOpenProject={chooseProject}
|
||||
renderProjectOverlay={() => (
|
||||
<ProjectDragOverlay
|
||||
projects={() => layout.projects.list()}
|
||||
activeProject={() => store.activeProject}
|
||||
/>
|
||||
)}
|
||||
settingsLabel={() => language.t("sidebar.settings")}
|
||||
settingsKeybind={() => command.keybind("settings.open")}
|
||||
onOpenSettings={openSettings}
|
||||
helpLabel={() => language.t("sidebar.help")}
|
||||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
renderPanel={() => <SidebarPanel project={currentProject()} mobile />}
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
</Show>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
/>
|
||||
<div
|
||||
classList={{
|
||||
"absolute inset-0": true,
|
||||
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||
"z-20": true,
|
||||
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||
!sizing(),
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
|
||||
}}
|
||||
>
|
||||
<main
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="xl:hidden">
|
||||
<div
|
||||
classList={{
|
||||
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
|
||||
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
||||
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
||||
}}
|
||||
/>
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
classList={{
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"translate-x-0": layout.mobileSidebar.opened(),
|
||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SidebarContent
|
||||
mobile
|
||||
opened={() => layout.sidebar.opened()}
|
||||
aimMove={aim.move}
|
||||
projects={() => layout.projects.list()}
|
||||
renderProject={(project) => (
|
||||
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile />
|
||||
)}
|
||||
handleDragStart={handleDragStart}
|
||||
handleDragEnd={handleDragEnd}
|
||||
handleDragOver={handleDragOver}
|
||||
openProjectLabel={language.t("command.project.open")}
|
||||
openProjectKeybind={() => command.keybind("project.open")}
|
||||
onOpenProject={chooseProject}
|
||||
renderProjectOverlay={() => (
|
||||
<ProjectDragOverlay projects={() => layout.projects.list()} activeProject={() => store.activeProject} />
|
||||
)}
|
||||
settingsLabel={() => language.t("sidebar.settings")}
|
||||
settingsKeybind={() => command.keybind("settings.open")}
|
||||
onOpenSettings={openSettings}
|
||||
helpLabel={() => language.t("sidebar.help")}
|
||||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
renderPanel={() => <SidebarPanel project={currentProject()} mobile />}
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"absolute inset-0": true,
|
||||
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||
"z-20": true,
|
||||
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||
!sizing(),
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
|
||||
}}
|
||||
>
|
||||
<main
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||
"opacity-100 translate-x-0 pointer-events-auto": peeked() && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2 pointer-events-none": !peeked() || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
|
||||
}}
|
||||
onMouseMove={disarm}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
aim.reset()
|
||||
}}
|
||||
onPointerDown={disarm}
|
||||
onMouseLeave={() => {
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<Show when={peek()} keyed>
|
||||
{(project) => <SidebarPanel project={project} merged={false} />}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||
"opacity-100 translate-x-0": peeked() && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2": !peeked() || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||
"opacity-100 translate-x-0 pointer-events-auto": peeked() && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2 pointer-events-none": !peeked() || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
|
||||
}}
|
||||
onMouseMove={disarm}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
aim.reset()
|
||||
}}
|
||||
onPointerDown={disarm}
|
||||
onMouseLeave={() => {
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<Show when={peek()} keyed>
|
||||
{(project) => <SidebarPanel project={project} merged={false} />}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||
"opacity-100 translate-x-0": peeked() && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2": !peeked() || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
</div>
|
||||
<Toast.Region />
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,6 @@ import { createOpenReviewFile, createSizing } from "@/pages/session/helpers"
|
||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers"
|
||||
import { createScrollSpy } from "@/pages/session/scroll-spy"
|
||||
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
@@ -486,20 +485,49 @@ export default function Page() {
|
||||
return "main"
|
||||
})
|
||||
|
||||
const activeMessage = createMemo(() => {
|
||||
if (!store.messageId) return lastUserMessage()
|
||||
const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
|
||||
return found ?? lastUserMessage()
|
||||
})
|
||||
const setActiveMessage = (message: UserMessage | undefined) => {
|
||||
messageMark = scrollMark
|
||||
setStore("messageId", message?.id)
|
||||
}
|
||||
|
||||
const anchor = (id: string) => `message-${id}`
|
||||
|
||||
const cursor = () => {
|
||||
const root = scroller
|
||||
if (!root) return store.messageId
|
||||
|
||||
const box = root.getBoundingClientRect()
|
||||
const line = box.top + 100
|
||||
const list = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
|
||||
.map((el) => {
|
||||
const id = el.dataset.messageId
|
||||
if (!id) return
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
return { id, top: rect.top, bottom: rect.bottom }
|
||||
})
|
||||
.filter((item): item is { id: string; top: number; bottom: number } => !!item)
|
||||
|
||||
const shown = list.filter((item) => item.bottom > box.top && item.top < box.bottom)
|
||||
const hit = shown.find((item) => item.top <= line && item.bottom >= line)
|
||||
if (hit) return hit.id
|
||||
|
||||
const near = [...shown].sort((a, b) => {
|
||||
const da = Math.abs(a.top - line)
|
||||
const db = Math.abs(b.top - line)
|
||||
if (da !== db) return da - db
|
||||
return a.top - b.top
|
||||
})[0]
|
||||
if (near) return near.id
|
||||
|
||||
return list.filter((item) => item.top <= line).at(-1)?.id ?? list[0]?.id ?? store.messageId
|
||||
}
|
||||
|
||||
function navigateMessageByOffset(offset: number) {
|
||||
const msgs = visibleUserMessages()
|
||||
if (msgs.length === 0) return
|
||||
|
||||
const current = store.messageId
|
||||
const current = store.messageId && messageMark === scrollMark ? store.messageId : cursor()
|
||||
const base = current ? msgs.findIndex((m) => m.id === current) : msgs.length
|
||||
const currentIndex = base === -1 ? msgs.length : base
|
||||
const targetIndex = currentIndex + offset
|
||||
@@ -572,6 +600,8 @@ export default function Page() {
|
||||
let dockHeight = 0
|
||||
let scroller: HTMLDivElement | undefined
|
||||
let content: HTMLDivElement | undefined
|
||||
let scrollMark = 0
|
||||
let messageMark = 0
|
||||
|
||||
const scrollGestureWindowMs = 250
|
||||
|
||||
@@ -616,6 +646,7 @@ export default function Page() {
|
||||
() => {
|
||||
setStore("messageId", undefined)
|
||||
setStore("changes", "session")
|
||||
setUi("pendingMessage", undefined)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -1110,12 +1141,6 @@ export default function Page() {
|
||||
|
||||
let scrollStateFrame: number | undefined
|
||||
let scrollStateTarget: HTMLDivElement | undefined
|
||||
const scrollSpy = createScrollSpy({
|
||||
onActive: (id) => {
|
||||
if (id === store.messageId) return
|
||||
setStore("messageId", id)
|
||||
},
|
||||
})
|
||||
|
||||
const updateScrollState = (el: HTMLDivElement) => {
|
||||
const max = el.scrollHeight - el.clientHeight
|
||||
@@ -1163,31 +1188,21 @@ export default function Page() {
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
sessionKey,
|
||||
() => {
|
||||
scrollSpy.clear()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const anchor = (id: string) => `message-${id}`
|
||||
|
||||
const setScrollRef = (el: HTMLDivElement | undefined) => {
|
||||
scroller = el
|
||||
autoScroll.scrollRef(el)
|
||||
scrollSpy.setContainer(el)
|
||||
if (el) scheduleScrollState(el)
|
||||
}
|
||||
|
||||
const markUserScroll = () => {
|
||||
scrollMark += 1
|
||||
}
|
||||
|
||||
createResizeObserver(
|
||||
() => content,
|
||||
() => {
|
||||
const el = scroller
|
||||
if (el) scheduleScrollState(el)
|
||||
scrollSpy.markDirty()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1220,7 +1235,6 @@ export default function Page() {
|
||||
if (stick) autoScroll.forceScrollToBottom()
|
||||
|
||||
if (el) scheduleScrollState(el)
|
||||
scrollSpy.markDirty()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1248,7 +1262,6 @@ export default function Page() {
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
scrollSpy.destroy()
|
||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||
})
|
||||
@@ -1280,7 +1293,7 @@ export default function Page() {
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={params.id}>
|
||||
<Show when={activeMessage()}>
|
||||
<Show when={lastUserMessage()}>
|
||||
<MessageTimeline
|
||||
mobileChanges={mobileChanges()}
|
||||
mobileFallback={reviewContent({
|
||||
@@ -1300,8 +1313,7 @@ export default function Page() {
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
isDesktop={isDesktop()}
|
||||
onScrollSpyScroll={scrollSpy.onScroll}
|
||||
onUserScroll={markUserScroll}
|
||||
onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
centered={centered()}
|
||||
@@ -1320,8 +1332,6 @@ export default function Page() {
|
||||
}}
|
||||
renderedUserMessages={historyWindow.renderedUserMessages()}
|
||||
anchor={anchor}
|
||||
onRegisterMessage={scrollSpy.register}
|
||||
onUnregisterMessage={scrollSpy.unregister}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
@@ -192,8 +193,7 @@ export function MessageTimeline(props: {
|
||||
onAutoScrollHandleScroll: () => void
|
||||
onMarkScrollGesture: (target?: EventTarget | null) => void
|
||||
hasScrollGesture: () => boolean
|
||||
isDesktop: boolean
|
||||
onScrollSpyScroll: () => void
|
||||
onUserScroll: () => void
|
||||
onTurnBackfillScroll: () => void
|
||||
onAutoScrollInteraction: (event: MouseEvent) => void
|
||||
centered: boolean
|
||||
@@ -204,8 +204,6 @@ export function MessageTimeline(props: {
|
||||
onLoadEarlier: () => void
|
||||
renderedUserMessages: UserMessage[]
|
||||
anchor: (id: string) => string
|
||||
onRegisterMessage: (el: HTMLDivElement, id: string) => void
|
||||
onUnregisterMessage: (id: string) => void
|
||||
}) {
|
||||
let touchGesture: number | undefined
|
||||
|
||||
@@ -235,6 +233,40 @@ export function MessageTimeline(props: {
|
||||
if (!id) return idle
|
||||
return sync.data.session_status[id] ?? idle
|
||||
})
|
||||
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
|
||||
|
||||
const [slot, setSlot] = createStore({
|
||||
open: false,
|
||||
show: false,
|
||||
fade: false,
|
||||
})
|
||||
|
||||
let f: number | undefined
|
||||
const clear = () => {
|
||||
if (f !== undefined) window.clearTimeout(f)
|
||||
f = undefined
|
||||
}
|
||||
|
||||
onCleanup(clear)
|
||||
createEffect(
|
||||
on(
|
||||
working,
|
||||
(on, prev) => {
|
||||
clear()
|
||||
if (on) {
|
||||
setSlot({ open: true, show: true, fade: false })
|
||||
return
|
||||
}
|
||||
if (prev) {
|
||||
setSlot({ open: false, show: true, fade: true })
|
||||
f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
|
||||
return
|
||||
}
|
||||
setSlot({ open: false, show: false, fade: false })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const activeMessageID = createMemo(() => {
|
||||
const parentID = pending()?.parentID
|
||||
if (parentID) {
|
||||
@@ -539,9 +571,9 @@ export function MessageTimeline(props: {
|
||||
props.onScheduleScrollState(e.currentTarget)
|
||||
props.onTurnBackfillScroll()
|
||||
if (!props.hasScrollGesture()) return
|
||||
props.onUserScroll()
|
||||
props.onAutoScrollHandleScroll()
|
||||
props.onMarkScrollGesture(e.currentTarget)
|
||||
if (props.isDesktop) props.onScrollSpyScroll()
|
||||
}}
|
||||
onClick={props.onAutoScrollInteraction}
|
||||
class="relative min-w-0 w-full h-full"
|
||||
@@ -573,43 +605,64 @@ export function MessageTimeline(props: {
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={titleValue() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
{titleValue()}
|
||||
</h1>
|
||||
}
|
||||
<div class="flex items-center min-w-0 grow-1">
|
||||
<div
|
||||
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
style={{
|
||||
width: slot.open ? "16px" : "0px",
|
||||
"margin-right": slot.open ? "8px" : "0px",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
value={title.draft}
|
||||
disabled={title.saving}
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
<Show when={slot.show}>
|
||||
<div
|
||||
class="transition-opacity duration-200 ease-out"
|
||||
classList={{
|
||||
"opacity-0": slot.fade,
|
||||
}}
|
||||
>
|
||||
<Spinner class="size-4" style={{ color: "var(--icon-interactive-base)" }} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={titleValue() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
{titleValue()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
value={title.draft}
|
||||
disabled={title.saving}
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={sessionID()}>
|
||||
{(id) => (
|
||||
@@ -707,10 +760,6 @@ export function MessageTimeline(props: {
|
||||
<div
|
||||
id={props.anchor(messageID)}
|
||||
data-message-id={messageID}
|
||||
ref={(el) => {
|
||||
props.onRegisterMessage(el, messageID)
|
||||
onCleanup(() => props.onUnregisterMessage(messageID))
|
||||
}}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createScrollSpy, pickOffsetId, pickVisibleId } from "./scroll-spy"
|
||||
|
||||
const rect = (top: number, height = 80): DOMRect =>
|
||||
({
|
||||
x: 0,
|
||||
y: top,
|
||||
top,
|
||||
left: 0,
|
||||
right: 800,
|
||||
bottom: top + height,
|
||||
width: 800,
|
||||
height,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect
|
||||
|
||||
const setRect = (el: Element, top: number, height = 80) => {
|
||||
Object.defineProperty(el, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => rect(top, height),
|
||||
})
|
||||
}
|
||||
|
||||
describe("pickVisibleId", () => {
|
||||
test("prefers higher intersection ratio", () => {
|
||||
const id = pickVisibleId(
|
||||
[
|
||||
{ id: "a", ratio: 0.2, top: 100 },
|
||||
{ id: "b", ratio: 0.8, top: 300 },
|
||||
],
|
||||
120,
|
||||
)
|
||||
|
||||
expect(id).toBe("b")
|
||||
})
|
||||
|
||||
test("breaks ratio ties by nearest line", () => {
|
||||
const id = pickVisibleId(
|
||||
[
|
||||
{ id: "a", ratio: 0.5, top: 90 },
|
||||
{ id: "b", ratio: 0.5, top: 140 },
|
||||
],
|
||||
130,
|
||||
)
|
||||
|
||||
expect(id).toBe("b")
|
||||
})
|
||||
})
|
||||
|
||||
describe("pickOffsetId", () => {
|
||||
test("uses binary search cutoff", () => {
|
||||
const id = pickOffsetId(
|
||||
[
|
||||
{ id: "a", top: 0 },
|
||||
{ id: "b", top: 200 },
|
||||
{ id: "c", top: 400 },
|
||||
],
|
||||
350,
|
||||
)
|
||||
|
||||
expect(id).toBe("b")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createScrollSpy fallback", () => {
|
||||
test("tracks active id from offsets and dirty refresh", () => {
|
||||
const active: string[] = []
|
||||
const root = document.createElement("div") as HTMLDivElement
|
||||
const one = document.createElement("div")
|
||||
const two = document.createElement("div")
|
||||
const three = document.createElement("div")
|
||||
|
||||
root.append(one, two, three)
|
||||
document.body.append(root)
|
||||
|
||||
Object.defineProperty(root, "scrollTop", { configurable: true, writable: true, value: 250 })
|
||||
setRect(root, 0, 800)
|
||||
setRect(one, -250)
|
||||
setRect(two, -50)
|
||||
setRect(three, 150)
|
||||
|
||||
const queue: FrameRequestCallback[] = []
|
||||
const flush = () => {
|
||||
const run = [...queue]
|
||||
queue.length = 0
|
||||
for (const cb of run) cb(0)
|
||||
}
|
||||
|
||||
const spy = createScrollSpy({
|
||||
onActive: (id) => active.push(id),
|
||||
raf: (cb) => (queue.push(cb), queue.length),
|
||||
caf: () => {},
|
||||
IntersectionObserver: undefined,
|
||||
ResizeObserver: undefined,
|
||||
MutationObserver: undefined,
|
||||
})
|
||||
|
||||
spy.setContainer(root)
|
||||
spy.register(one, "a")
|
||||
spy.register(two, "b")
|
||||
spy.register(three, "c")
|
||||
spy.onScroll()
|
||||
flush()
|
||||
|
||||
expect(spy.getActiveId()).toBe("b")
|
||||
expect(active.at(-1)).toBe("b")
|
||||
|
||||
root.scrollTop = 450
|
||||
setRect(one, -450)
|
||||
setRect(two, -250)
|
||||
setRect(three, -50)
|
||||
spy.onScroll()
|
||||
flush()
|
||||
expect(spy.getActiveId()).toBe("c")
|
||||
|
||||
root.scrollTop = 250
|
||||
setRect(one, -250)
|
||||
setRect(two, 250)
|
||||
setRect(three, 150)
|
||||
spy.markDirty()
|
||||
spy.onScroll()
|
||||
flush()
|
||||
expect(spy.getActiveId()).toBe("a")
|
||||
|
||||
spy.destroy()
|
||||
})
|
||||
})
|
||||
@@ -1,275 +0,0 @@
|
||||
type Visible = {
|
||||
id: string
|
||||
ratio: number
|
||||
top: number
|
||||
}
|
||||
|
||||
type Offset = {
|
||||
id: string
|
||||
top: number
|
||||
}
|
||||
|
||||
type Input = {
|
||||
onActive: (id: string) => void
|
||||
raf?: (cb: FrameRequestCallback) => number
|
||||
caf?: (id: number) => void
|
||||
IntersectionObserver?: typeof globalThis.IntersectionObserver
|
||||
ResizeObserver?: typeof globalThis.ResizeObserver
|
||||
MutationObserver?: typeof globalThis.MutationObserver
|
||||
}
|
||||
|
||||
export const pickVisibleId = (list: Visible[], line: number) => {
|
||||
if (list.length === 0) return
|
||||
|
||||
const sorted = [...list].sort((a, b) => {
|
||||
if (b.ratio !== a.ratio) return b.ratio - a.ratio
|
||||
|
||||
const da = Math.abs(a.top - line)
|
||||
const db = Math.abs(b.top - line)
|
||||
if (da !== db) return da - db
|
||||
|
||||
return a.top - b.top
|
||||
})
|
||||
|
||||
return sorted[0]?.id
|
||||
}
|
||||
|
||||
export const pickOffsetId = (list: Offset[], cutoff: number) => {
|
||||
if (list.length === 0) return
|
||||
|
||||
let lo = 0
|
||||
let hi = list.length - 1
|
||||
let out = 0
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
const top = list[mid]?.top
|
||||
if (top === undefined) break
|
||||
|
||||
if (top <= cutoff) {
|
||||
out = mid
|
||||
lo = mid + 1
|
||||
continue
|
||||
}
|
||||
|
||||
hi = mid - 1
|
||||
}
|
||||
|
||||
return list[out]?.id
|
||||
}
|
||||
|
||||
export const createScrollSpy = (input: Input) => {
|
||||
const raf = input.raf ?? requestAnimationFrame
|
||||
const caf = input.caf ?? cancelAnimationFrame
|
||||
const CtorIO = input.IntersectionObserver ?? globalThis.IntersectionObserver
|
||||
const CtorRO = input.ResizeObserver ?? globalThis.ResizeObserver
|
||||
const CtorMO = input.MutationObserver ?? globalThis.MutationObserver
|
||||
|
||||
let root: HTMLDivElement | undefined
|
||||
let io: IntersectionObserver | undefined
|
||||
let ro: ResizeObserver | undefined
|
||||
let mo: MutationObserver | undefined
|
||||
let frame: number | undefined
|
||||
let active: string | undefined
|
||||
let dirty = true
|
||||
|
||||
const node = new Map<string, HTMLElement>()
|
||||
const id = new WeakMap<HTMLElement, string>()
|
||||
const visible = new Map<string, { ratio: number; top: number }>()
|
||||
let offset: Offset[] = []
|
||||
|
||||
const schedule = () => {
|
||||
if (frame !== undefined) return
|
||||
frame = raf(() => {
|
||||
frame = undefined
|
||||
update()
|
||||
})
|
||||
}
|
||||
|
||||
const refreshOffset = () => {
|
||||
const el = root
|
||||
if (!el) {
|
||||
offset = []
|
||||
dirty = false
|
||||
return
|
||||
}
|
||||
|
||||
const base = el.getBoundingClientRect().top
|
||||
offset = [...node].map(([next, item]) => ({
|
||||
id: next,
|
||||
top: item.getBoundingClientRect().top - base + el.scrollTop,
|
||||
}))
|
||||
offset.sort((a, b) => a.top - b.top)
|
||||
dirty = false
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
const el = root
|
||||
if (!el) return
|
||||
|
||||
const line = el.getBoundingClientRect().top + 100
|
||||
const next =
|
||||
pickVisibleId(
|
||||
[...visible].map(([k, v]) => ({
|
||||
id: k,
|
||||
ratio: v.ratio,
|
||||
top: v.top,
|
||||
})),
|
||||
line,
|
||||
) ??
|
||||
(() => {
|
||||
if (dirty) refreshOffset()
|
||||
return pickOffsetId(offset, el.scrollTop + 100)
|
||||
})()
|
||||
|
||||
if (!next || next === active) return
|
||||
active = next
|
||||
input.onActive(next)
|
||||
}
|
||||
|
||||
const observe = () => {
|
||||
const el = root
|
||||
if (!el) return
|
||||
|
||||
io?.disconnect()
|
||||
io = undefined
|
||||
if (CtorIO) {
|
||||
try {
|
||||
io = new CtorIO(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
const item = entry.target
|
||||
if (!(item instanceof HTMLElement)) continue
|
||||
const key = id.get(item)
|
||||
if (!key) continue
|
||||
|
||||
if (!entry.isIntersecting || entry.intersectionRatio <= 0) {
|
||||
visible.delete(key)
|
||||
continue
|
||||
}
|
||||
|
||||
visible.set(key, {
|
||||
ratio: entry.intersectionRatio,
|
||||
top: entry.boundingClientRect.top,
|
||||
})
|
||||
}
|
||||
|
||||
schedule()
|
||||
},
|
||||
{
|
||||
root: el,
|
||||
threshold: [0, 0.25, 0.5, 0.75, 1],
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
io = undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (io) {
|
||||
for (const item of node.values()) io.observe(item)
|
||||
}
|
||||
|
||||
ro?.disconnect()
|
||||
ro = undefined
|
||||
if (CtorRO) {
|
||||
ro = new CtorRO(() => {
|
||||
dirty = true
|
||||
schedule()
|
||||
})
|
||||
ro.observe(el)
|
||||
for (const item of node.values()) ro.observe(item)
|
||||
}
|
||||
|
||||
mo?.disconnect()
|
||||
mo = undefined
|
||||
if (CtorMO) {
|
||||
mo = new CtorMO(() => {
|
||||
dirty = true
|
||||
schedule()
|
||||
})
|
||||
mo.observe(el, { subtree: true, childList: true, characterData: true })
|
||||
}
|
||||
|
||||
dirty = true
|
||||
schedule()
|
||||
}
|
||||
|
||||
const setContainer = (el?: HTMLDivElement) => {
|
||||
if (root === el) return
|
||||
|
||||
root = el
|
||||
visible.clear()
|
||||
active = undefined
|
||||
observe()
|
||||
}
|
||||
|
||||
const register = (el: HTMLElement, key: string) => {
|
||||
const prev = node.get(key)
|
||||
if (prev && prev !== el) {
|
||||
io?.unobserve(prev)
|
||||
ro?.unobserve(prev)
|
||||
}
|
||||
|
||||
node.set(key, el)
|
||||
id.set(el, key)
|
||||
if (io) io.observe(el)
|
||||
if (ro) ro.observe(el)
|
||||
dirty = true
|
||||
schedule()
|
||||
}
|
||||
|
||||
const unregister = (key: string) => {
|
||||
const item = node.get(key)
|
||||
if (!item) return
|
||||
|
||||
io?.unobserve(item)
|
||||
ro?.unobserve(item)
|
||||
node.delete(key)
|
||||
visible.delete(key)
|
||||
dirty = true
|
||||
schedule()
|
||||
}
|
||||
|
||||
const markDirty = () => {
|
||||
dirty = true
|
||||
schedule()
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
for (const item of node.values()) {
|
||||
io?.unobserve(item)
|
||||
ro?.unobserve(item)
|
||||
}
|
||||
|
||||
node.clear()
|
||||
visible.clear()
|
||||
offset = []
|
||||
active = undefined
|
||||
dirty = true
|
||||
}
|
||||
|
||||
const destroy = () => {
|
||||
if (frame !== undefined) caf(frame)
|
||||
frame = undefined
|
||||
clear()
|
||||
io?.disconnect()
|
||||
ro?.disconnect()
|
||||
mo?.disconnect()
|
||||
io = undefined
|
||||
ro = undefined
|
||||
mo = undefined
|
||||
root = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
setContainer,
|
||||
register,
|
||||
unregister,
|
||||
onScroll: schedule,
|
||||
markDirty,
|
||||
clear,
|
||||
destroy,
|
||||
getActiveId: () => active,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, onMount } from "solid-js"
|
||||
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { messageIdFromHash } from "./message-id-from-hash"
|
||||
|
||||
export { messageIdFromHash } from "./message-id-from-hash"
|
||||
@@ -26,17 +26,38 @@ export const useSessionHashScroll = (input: {
|
||||
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
|
||||
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
|
||||
let pendingKey = ""
|
||||
let clearing = false
|
||||
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const frames = new Set<number>()
|
||||
const queue = (fn: () => void) => {
|
||||
const id = requestAnimationFrame(() => {
|
||||
frames.delete(id)
|
||||
fn()
|
||||
})
|
||||
frames.add(id)
|
||||
}
|
||||
const cancel = () => {
|
||||
for (const id of frames) cancelAnimationFrame(id)
|
||||
frames.clear()
|
||||
}
|
||||
|
||||
const clearMessageHash = () => {
|
||||
cancel()
|
||||
input.consumePendingMessage(input.sessionKey())
|
||||
if (input.pendingMessage()) input.setPendingMessage(undefined)
|
||||
if (!location.hash) return
|
||||
clearing = true
|
||||
navigate(location.pathname + location.search, { replace: true })
|
||||
}
|
||||
|
||||
const updateHash = (id: string) => {
|
||||
navigate(location.pathname + location.search + `#${input.anchor(id)}`, {
|
||||
const hash = `#${input.anchor(id)}`
|
||||
if (location.hash === hash) return
|
||||
clearing = false
|
||||
navigate(location.pathname + location.search + hash, {
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
@@ -54,51 +75,37 @@ export const useSessionHashScroll = (input: {
|
||||
return true
|
||||
}
|
||||
|
||||
const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => {
|
||||
const el = document.getElementById(input.anchor(id))
|
||||
if (el) return scrollToElement(el, behavior)
|
||||
if (left <= 0) return false
|
||||
queue(() => {
|
||||
seek(id, behavior, left - 1)
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
||||
console.log({ message, behavior })
|
||||
cancel()
|
||||
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
|
||||
|
||||
const index = messageIndex().get(message.id) ?? -1
|
||||
if (index !== -1 && index < input.turnStart()) {
|
||||
input.setTurnStart(index)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(input.anchor(message.id))
|
||||
if (!el) {
|
||||
requestAnimationFrame(() => {
|
||||
const next = document.getElementById(input.anchor(message.id))
|
||||
if (!next) return
|
||||
scrollToElement(next, behavior)
|
||||
})
|
||||
return
|
||||
}
|
||||
scrollToElement(el, behavior)
|
||||
queue(() => {
|
||||
seek(message.id, behavior)
|
||||
})
|
||||
|
||||
updateHash(message.id)
|
||||
return
|
||||
}
|
||||
|
||||
const el = document.getElementById(input.anchor(message.id))
|
||||
if (!el) {
|
||||
updateHash(message.id)
|
||||
requestAnimationFrame(() => {
|
||||
const next = document.getElementById(input.anchor(message.id))
|
||||
if (!next) return
|
||||
if (!scrollToElement(next, behavior)) return
|
||||
})
|
||||
return
|
||||
}
|
||||
if (scrollToElement(el, behavior)) {
|
||||
if (seek(message.id, behavior)) {
|
||||
updateHash(message.id)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const next = document.getElementById(input.anchor(message.id))
|
||||
if (!next) return
|
||||
if (!scrollToElement(next, behavior)) return
|
||||
})
|
||||
updateHash(message.id)
|
||||
}
|
||||
|
||||
@@ -135,9 +142,11 @@ export const useSessionHashScroll = (input: {
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
location.hash
|
||||
const hash = location.hash
|
||||
if (!hash) clearing = false
|
||||
if (!input.sessionID() || !input.messagesReady()) return
|
||||
requestAnimationFrame(() => applyHash("auto"))
|
||||
cancel()
|
||||
queue(() => applyHash("auto"))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -159,16 +168,19 @@ export const useSessionHashScroll = (input: {
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetId) targetId = messageIdFromHash(location.hash)
|
||||
if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
|
||||
if (!targetId) return
|
||||
if (input.currentMessageId() === targetId) return
|
||||
|
||||
const pending = input.pendingMessage() === targetId
|
||||
const msg = messageById().get(targetId)
|
||||
if (!msg) return
|
||||
|
||||
if (input.pendingMessage() === targetId) input.setPendingMessage(undefined)
|
||||
if (pending) input.setPendingMessage(undefined)
|
||||
if (input.currentMessageId() === targetId && !pending) return
|
||||
|
||||
input.autoScroll.pause()
|
||||
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
|
||||
cancel()
|
||||
queue(() => scrollToMessage(msg, "auto"))
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
@@ -177,6 +189,8 @@ export const useSessionHashScroll = (input: {
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(cancel)
|
||||
|
||||
return {
|
||||
clearMessageHash,
|
||||
scrollToMessage,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.23",
|
||||
"version": "1.2.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.23",
|
||||
"version": "1.2.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.23",
|
||||
"version": "1.2.24",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.23",
|
||||
"version": "1.2.24",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.2.23",
|
||||
"version": "1.2.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.2.23",
|
||||
"version": "1.2.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.23",
|
||||
"version": "1.2.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.2.23"
|
||||
version = "1.2.24"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.23/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.23/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.23/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.23/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.23/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.23",
|
||||
"version": "1.2.24",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE `account` (
|
||||
`id` text PRIMARY KEY,
|
||||
`email` text NOT NULL,
|
||||
`url` text NOT NULL,
|
||||
`access_token` text NOT NULL,
|
||||
`refresh_token` text NOT NULL,
|
||||
`token_expiry` integer,
|
||||
`selected_org_id` text,
|
||||
`time_created` integer NOT NULL,
|
||||
`time_updated` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `account_state` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`active_account_id` text,
|
||||
FOREIGN KEY (`active_account_id`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE `account_state` ADD `active_org_id` text;--> statement-breakpoint
|
||||
UPDATE `account_state` SET `active_org_id` = (SELECT `selected_org_id` FROM `account` WHERE `account`.`id` = `account_state`.`active_account_id`);--> statement-breakpoint
|
||||
ALTER TABLE `account` DROP COLUMN `selected_org_id`;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.2.23",
|
||||
"version": "1.2.24",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -27,6 +27,7 @@
|
||||
},
|
||||
"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",
|
||||
@@ -41,6 +42,7 @@
|
||||
"@types/babel__core": "7.20.5",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@types/which": "3.0.4",
|
||||
@@ -107,6 +109,7 @@
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
@@ -121,6 +124,7 @@
|
||||
"opentui-spinner": "0.0.6",
|
||||
"partial-json": "0.1.7",
|
||||
"remeda": "catalog:",
|
||||
"semver": "^7.6.3",
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import { sqliteTable, text, integer, primaryKey, uniqueIndex } from "drizzle-orm/sqlite-core"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
|
||||
import { Timestamps } from "@/storage/schema.sql"
|
||||
|
||||
export const AccountTable = sqliteTable("account", {
|
||||
id: text().primaryKey(),
|
||||
email: text().notNull(),
|
||||
url: text().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().references(() => AccountTable.id, { onDelete: "set null" }),
|
||||
active_org_id: text(),
|
||||
})
|
||||
|
||||
// LEGACY
|
||||
export const ControlAccountTable = sqliteTable(
|
||||
"control_account",
|
||||
{
|
||||
43
packages/opencode/src/account/index.ts
Normal file
43
packages/opencode/src/account/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Effect, Option, ServiceMap } from "effect"
|
||||
|
||||
import {
|
||||
Account as AccountSchema,
|
||||
type AccountError,
|
||||
type AccessToken,
|
||||
AccountID,
|
||||
AccountService,
|
||||
OrgID,
|
||||
} from "./service"
|
||||
|
||||
export { AccessToken, AccountID, OrgID } from "./service"
|
||||
|
||||
import { runtime } from "@/effect/runtime"
|
||||
|
||||
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: AccountServiceShape) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runPromise(AccountService.use(f))
|
||||
}
|
||||
|
||||
export namespace Account {
|
||||
export const Account = AccountSchema
|
||||
export type Account = AccountSchema
|
||||
|
||||
export function active(): Account | undefined {
|
||||
return Option.getOrUndefined(runSync((service) => service.active()))
|
||||
}
|
||||
|
||||
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
|
||||
const config = await runPromise((service) => service.config(accountID, orgID))
|
||||
return Option.getOrUndefined(config)
|
||||
}
|
||||
|
||||
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
|
||||
const token = await runPromise((service) => service.token(accountID))
|
||||
return Option.getOrUndefined(token)
|
||||
}
|
||||
}
|
||||
138
packages/opencode/src/account/repo.ts
Normal file
138
packages/opencode/src/account/repo.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
|
||||
|
||||
import { Database } from "@/storage/db"
|
||||
import { AccountStateTable, AccountTable } from "./account.sql"
|
||||
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
|
||||
|
||||
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>
|
||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
|
||||
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
|
||||
readonly persistToken: (input: {
|
||||
accountID: AccountID
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiry: Option.Option<number>
|
||||
}) => Effect.Effect<void, AccountRepoError>
|
||||
readonly persistAccount: (input: {
|
||||
id: AccountID
|
||||
email: string
|
||||
url: string
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiry: number
|
||||
orgID: Option.Option<OrgID>
|
||||
}) => Effect.Effect<void, AccountRepoError>
|
||||
}
|
||||
>()("@opencode/AccountRepo") {
|
||||
static readonly layer: Layer.Layer<AccountRepo> = Layer.succeed(
|
||||
AccountRepo,
|
||||
AccountRepo.of({
|
||||
active: Effect.fn("AccountRepo.active")(() =>
|
||||
db((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decodeAccount(row)) : Option.none()))),
|
||||
),
|
||||
|
||||
list: Effect.fn("AccountRepo.list")(() => db((db) => db.select().from(AccountTable).all().map((row) => decodeAccount({ ...row, active_org_id: null })))),
|
||||
|
||||
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),
|
||||
),
|
||||
|
||||
use: Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
|
||||
db((db) => setState(db, accountID, Option.getOrNull(orgID))).pipe(Effect.asVoid),
|
||||
),
|
||||
|
||||
getRow: Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
|
||||
db((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
|
||||
Effect.map(Option.fromNullishOr),
|
||||
),
|
||||
),
|
||||
|
||||
persistToken: Effect.fn("AccountRepo.persistToken")((input) =>
|
||||
db((db) =>
|
||||
db
|
||||
.update(AccountTable)
|
||||
.set({
|
||||
access_token: input.accessToken,
|
||||
refresh_token: input.refreshToken,
|
||||
token_expiry: Option.getOrNull(input.expiry),
|
||||
})
|
||||
.where(eq(AccountTable.id, input.accountID))
|
||||
.run(),
|
||||
).pipe(Effect.asVoid),
|
||||
),
|
||||
|
||||
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,
|
||||
})
|
||||
.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)
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
73
packages/opencode/src/account/schema.ts
Normal file
73
packages/opencode/src/account/schema.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
export const AccountID = Schema.String.pipe(
|
||||
Schema.brand("AccountId"),
|
||||
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
|
||||
)
|
||||
export type AccountID = Schema.Schema.Type<typeof AccountID>
|
||||
|
||||
export const OrgID = Schema.String.pipe(
|
||||
Schema.brand("OrgId"),
|
||||
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
|
||||
)
|
||||
export type OrgID = Schema.Schema.Type<typeof OrgID>
|
||||
|
||||
export const AccessToken = Schema.String.pipe(
|
||||
Schema.brand("AccessToken"),
|
||||
withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
|
||||
)
|
||||
export type AccessToken = Schema.Schema.Type<typeof AccessToken>
|
||||
|
||||
export class Account extends Schema.Class<Account>("Account")({
|
||||
id: AccountID,
|
||||
email: Schema.String,
|
||||
url: Schema.String,
|
||||
active_org_id: Schema.NullOr(OrgID),
|
||||
}) {}
|
||||
|
||||
export class Org extends Schema.Class<Org>("Org")({
|
||||
id: OrgID,
|
||||
name: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class AccountRepoError extends Schema.TaggedErrorClass<AccountRepoError>()("AccountRepoError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceError>()("AccountServiceError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export type AccountError = AccountRepoError | AccountServiceError
|
||||
|
||||
export class Login extends Schema.Class<Login>("Login")({
|
||||
code: Schema.String,
|
||||
user: Schema.String,
|
||||
url: Schema.String,
|
||||
server: Schema.String,
|
||||
expiry: Schema.Number,
|
||||
interval: Schema.Number,
|
||||
}) {}
|
||||
|
||||
export class PollSuccess extends Schema.TaggedClass<PollSuccess>()("PollSuccess", {
|
||||
email: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class PollPending extends Schema.TaggedClass<PollPending>()("PollPending", {}) {}
|
||||
|
||||
export class PollSlow extends Schema.TaggedClass<PollSlow>()("PollSlow", {}) {}
|
||||
|
||||
export class PollExpired extends Schema.TaggedClass<PollExpired>()("PollExpired", {}) {}
|
||||
|
||||
export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {}
|
||||
|
||||
export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
|
||||
cause: Schema.Defect,
|
||||
}) {}
|
||||
|
||||
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
|
||||
export type PollResult = Schema.Schema.Type<typeof PollResult>
|
||||
385
packages/opencode/src/account/service.ts
Normal file
385
packages/opencode/src/account/service.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
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"
|
||||
import {
|
||||
type AccountError,
|
||||
AccessToken,
|
||||
Account,
|
||||
AccountID,
|
||||
AccountRepoError,
|
||||
AccountServiceError,
|
||||
Login,
|
||||
Org,
|
||||
OrgID,
|
||||
PollDenied,
|
||||
PollError,
|
||||
PollExpired,
|
||||
PollPending,
|
||||
type PollResult,
|
||||
PollSlow,
|
||||
PollSuccess,
|
||||
} from "./schema"
|
||||
|
||||
export * from "./schema"
|
||||
|
||||
export type AccountOrgs = {
|
||||
account: Account
|
||||
orgs: Org[]
|
||||
}
|
||||
|
||||
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 TokenRefresh = Schema.Struct({
|
||||
access_token: Schema.String,
|
||||
refresh_token: Schema.optional(Schema.String),
|
||||
expires_in: Schema.optional(Schema.Number),
|
||||
})
|
||||
|
||||
const DeviceCode = Schema.Struct({
|
||||
device_code: Schema.String,
|
||||
user_code: Schema.String,
|
||||
verification_uri_complete: Schema.String,
|
||||
expires_in: Schema.Number,
|
||||
interval: Schema.Number,
|
||||
})
|
||||
|
||||
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),
|
||||
})
|
||||
|
||||
const User = Schema.Struct({
|
||||
id: Schema.optional(AccountID),
|
||||
email: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const ClientId = Schema.Struct({ client_id: Schema.String })
|
||||
|
||||
const DeviceTokenRequest = Schema.Struct({
|
||||
grant_type: Schema.String,
|
||||
device_code: Schema.String,
|
||||
client_id: Schema.String,
|
||||
})
|
||||
|
||||
const serverDefault = "https://web-14275-d60e67f5-pyqs0590.onporter.run"
|
||||
const clientId = "opencode-cli"
|
||||
|
||||
const toAccountServiceError = (message: string, cause?: unknown) => new AccountServiceError({ message, cause })
|
||||
|
||||
const mapAccountServiceError =
|
||||
(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((error) =>
|
||||
error instanceof AccountServiceError ? error : toAccountServiceError(`${message} (${operation})`, error),
|
||||
),
|
||||
)
|
||||
|
||||
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<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<Org[], AccountError>
|
||||
readonly config: (
|
||||
accountID: AccountID,
|
||||
orgID: OrgID,
|
||||
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
|
||||
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
|
||||
readonly login: (url?: string) => Effect.Effect<Login, AccountError>
|
||||
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
||||
}
|
||||
>()("@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 execute = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
|
||||
http.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
|
||||
|
||||
const executeRead = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
|
||||
httpRead.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
|
||||
|
||||
const executeEffect = <E>(operation: string, request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||
request.pipe(
|
||||
Effect.flatMap((req) => http.execute(req)),
|
||||
mapAccountServiceError(operation, "HTTP request failed"),
|
||||
)
|
||||
|
||||
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 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.bodyUrlParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: found.refresh_token,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
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.fromNullishOr(parsed.expires_in).pipe(Option.map((e) => now + e * 1000))
|
||||
|
||||
yield* repo.persistToken({
|
||||
accountID: AccountID.make(found.id),
|
||||
accessToken: parsed.access_token,
|
||||
refreshToken: parsed.refresh_token ?? found.refresh_token,
|
||||
expiry,
|
||||
})
|
||||
|
||||
return Option.some(AccessToken.make(parsed.access_token))
|
||||
})
|
||||
|
||||
const resolveAccess = Effect.fn("AccountService.resolveAccess")(function* (accountID: AccountID) {
|
||||
const maybeAccount = yield* repo.getRow(accountID)
|
||||
if (Option.isNone(maybeAccount)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
|
||||
|
||||
const account = maybeAccount.value
|
||||
const accessToken = yield* tokenForRow(account)
|
||||
if (Option.isNone(accessToken)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
|
||||
|
||||
return Option.some({ account, accessToken: accessToken.value })
|
||||
})
|
||||
|
||||
const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
|
||||
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
|
||||
)
|
||||
|
||||
const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
|
||||
const accounts = yield* repo.list()
|
||||
return yield* Effect.forEach(
|
||||
accounts,
|
||||
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
})
|
||||
|
||||
const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return []
|
||||
|
||||
const { account, accessToken } = resolved.value
|
||||
|
||||
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) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return Option.none()
|
||||
|
||||
const { account, accessToken } = resolved.value
|
||||
|
||||
const response = yield* executeRead(
|
||||
"config",
|
||||
HttpClientRequest.get(`${account.url}/api/config`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(accessToken),
|
||||
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
|
||||
),
|
||||
)
|
||||
|
||||
const ok = yield* okOrNone("config", response)
|
||||
if (Option.isNone(ok)) return Option.none()
|
||||
|
||||
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* (url?: string) {
|
||||
const server = url ?? serverDefault
|
||||
|
||||
const response = yield* executeEffect(
|
||||
"login",
|
||||
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(ClientId)({ client_id: clientId }),
|
||||
),
|
||||
)
|
||||
|
||||
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,
|
||||
user: parsed.user_code,
|
||||
url: `${server}${parsed.verification_uri_complete}`,
|
||||
server,
|
||||
expiry: parsed.expires_in,
|
||||
interval: parsed.interval,
|
||||
})
|
||||
})
|
||||
|
||||
const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
|
||||
const response = yield* executeEffect(
|
||||
"poll",
|
||||
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
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("poll", "Failed to decode response"),
|
||||
)
|
||||
|
||||
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 access = parsed.access_token
|
||||
|
||||
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"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
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 + (parsed.expires_in ?? 0) * 1000
|
||||
const refresh = parsed.refresh_token ?? ""
|
||||
|
||||
yield* repo.persistAccount({
|
||||
id: userId,
|
||||
email: userEmail,
|
||||
url: input.server,
|
||||
accessToken: access,
|
||||
refreshToken: refresh,
|
||||
expiry,
|
||||
orgID: firstOrgID,
|
||||
})
|
||||
|
||||
return new PollSuccess({ email: userEmail })
|
||||
})
|
||||
|
||||
return AccountService.of({
|
||||
active: repo.active,
|
||||
list: repo.list,
|
||||
orgsByAccount,
|
||||
remove: repo.remove,
|
||||
use: repo.use,
|
||||
orgs,
|
||||
config,
|
||||
token,
|
||||
login,
|
||||
poll,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
static readonly defaultLayer = AccountService.layer.pipe(
|
||||
Layer.provide(AccountRepo.layer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from "@agentclientprotocol/sdk"
|
||||
|
||||
import { Log } from "../util/log"
|
||||
import { pathToFileURL } from "bun"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Hash } from "../util/hash"
|
||||
import { ACPSessionManager } from "./session"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { semver } from "bun"
|
||||
import semver from "semver"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { Log } from "../util/log"
|
||||
import { Process } from "../util/process"
|
||||
@@ -45,6 +45,6 @@ export namespace PackageRegistry {
|
||||
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
|
||||
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
|
||||
|
||||
return semver.order(cachedVersion, latestVersion) === -1
|
||||
return semver.lt(cachedVersion, latestVersion)
|
||||
}
|
||||
}
|
||||
|
||||
177
packages/opencode/src/cli/cmd/account.ts
Normal file
177
packages/opencode/src/cli/cmd/account.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { Duration, Effect, Match, Option } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { runtime } from "@/effect/runtime"
|
||||
import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service"
|
||||
import { type AccountError } from "@/account/schema"
|
||||
import * as Prompt from "../effect/prompt"
|
||||
import open from "open"
|
||||
|
||||
const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() => undefined))
|
||||
|
||||
const println = (msg: string) => Effect.sync(() => UI.println(msg))
|
||||
|
||||
const loginEffect = Effect.fn("login")(function* (url?: string) {
|
||||
const service = yield* AccountService
|
||||
|
||||
yield* Prompt.intro("Log in")
|
||||
const login = yield* service.login(url)
|
||||
|
||||
yield* Prompt.log.info("Go to: " + login.url)
|
||||
yield* Prompt.log.info("Enter code: " + login.user)
|
||||
yield* openBrowser(login.url)
|
||||
|
||||
const s = Prompt.spinner()
|
||||
yield* s.start("Waiting for authorization...")
|
||||
|
||||
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(wait + 5000)
|
||||
return result
|
||||
})
|
||||
|
||||
const result = yield* poll(login.interval * 1000).pipe(
|
||||
Effect.timeout(Duration.seconds(login.expiry)),
|
||||
Effect.catchTag("TimeoutError", () => Effect.succeed(new PollExpired())),
|
||||
)
|
||||
|
||||
yield* Match.valueTags(result, {
|
||||
PollSuccess: (r) =>
|
||||
Effect.gen(function* () {
|
||||
yield* s.stop("Logged in as " + r.email)
|
||||
yield* Prompt.outro("Done")
|
||||
}),
|
||||
PollExpired: () => s.stop("Device code expired", 1),
|
||||
PollDenied: () => s.stop("Authorization denied", 1),
|
||||
PollError: (r) => s.stop("Error: " + String(r.cause), 1),
|
||||
PollPending: () => s.stop("Unexpected state", 1),
|
||||
PollSlow: () => s.stop("Unexpected state", 1),
|
||||
})
|
||||
})
|
||||
|
||||
const logoutEffect = Effect.fn("logout")(function* (email?: string) {
|
||||
const service = yield* AccountService
|
||||
|
||||
if (email) {
|
||||
const accounts = yield* service.list()
|
||||
const match = accounts.find((a) => a.email === email)
|
||||
if (!match) return yield* println("Account not found: " + email)
|
||||
yield* service.remove(match.id)
|
||||
yield* println("Logged out from " + email)
|
||||
return
|
||||
}
|
||||
|
||||
const active = yield* service.active()
|
||||
if (Option.isNone(active)) return yield* println("Not logged in")
|
||||
yield* service.remove(active.value.id)
|
||||
yield* println("Logged out from " + active.value.email)
|
||||
})
|
||||
|
||||
interface OrgChoice {
|
||||
orgID: OrgID
|
||||
accountID: AccountID
|
||||
label: string
|
||||
}
|
||||
|
||||
const switchEffect = Effect.fn("switch")(function* () {
|
||||
const service = yield* AccountService
|
||||
|
||||
const groups = yield* service.orgsByAccount()
|
||||
if (groups.length === 0) return yield* println("Not logged in")
|
||||
|
||||
const active = yield* service.active()
|
||||
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
|
||||
|
||||
const opts = groups.flatMap((group) =>
|
||||
group.orgs.map((org) => {
|
||||
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
|
||||
return {
|
||||
value: { orgID: org.id, accountID: group.account.id, label: org.name },
|
||||
label: isActive
|
||||
? `${org.name} (${group.account.email})` + UI.Style.TEXT_DIM + " (active)"
|
||||
: `${org.name} (${group.account.email})`,
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (opts.length === 0) return yield* println("No orgs found")
|
||||
|
||||
yield* Prompt.intro("Switch org")
|
||||
|
||||
const selected = yield* Prompt.select<OrgChoice>({ message: "Select org", options: opts })
|
||||
if (Option.isNone(selected)) return
|
||||
|
||||
const choice = selected.value
|
||||
yield* service.use(choice.accountID, Option.some(choice.orgID))
|
||||
yield* Prompt.outro("Switched to " + choice.label)
|
||||
})
|
||||
|
||||
const orgsEffect = Effect.fn("orgs")(function* () {
|
||||
const service = yield* AccountService
|
||||
|
||||
const groups = yield* service.orgsByAccount()
|
||||
if (groups.length === 0) return yield* println("No accounts found")
|
||||
if (!groups.some((group) => group.orgs.length > 0)) return yield* println("No orgs found")
|
||||
|
||||
const active = yield* service.active()
|
||||
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
|
||||
|
||||
for (const group of groups) {
|
||||
for (const org of group.orgs) {
|
||||
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
|
||||
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
|
||||
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
|
||||
const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL
|
||||
const id = UI.Style.TEXT_DIM + org.id + UI.Style.TEXT_NORMAL
|
||||
yield* println(` ${dot} ${name} ${email} ${id}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const LoginCommand = cmd({
|
||||
command: "login [url]",
|
||||
describe: "log in to an opencode account",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("url", {
|
||||
describe: "server URL",
|
||||
type: "string",
|
||||
}),
|
||||
async handler(args) {
|
||||
UI.empty()
|
||||
await runtime.runPromise(loginEffect(args.url))
|
||||
},
|
||||
})
|
||||
|
||||
export const LogoutCommand = cmd({
|
||||
command: "logout [email]",
|
||||
describe: "log out from an account",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("email", {
|
||||
describe: "account email to log out from",
|
||||
type: "string",
|
||||
}),
|
||||
async handler(args) {
|
||||
UI.empty()
|
||||
await runtime.runPromise(logoutEffect(args.email))
|
||||
},
|
||||
})
|
||||
|
||||
export const SwitchCommand = cmd({
|
||||
command: "switch",
|
||||
describe: "switch active org",
|
||||
async handler() {
|
||||
UI.empty()
|
||||
await runtime.runPromise(switchEffect())
|
||||
},
|
||||
})
|
||||
|
||||
export const OrgsCommand = cmd({
|
||||
command: "orgs",
|
||||
describe: "list all orgs",
|
||||
async handler() {
|
||||
UI.empty()
|
||||
await runtime.runPromise(orgsEffect())
|
||||
},
|
||||
})
|
||||
@@ -27,8 +27,9 @@ import { Provider } from "../../provider/provider"
|
||||
import { Bus } from "../../bus"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { $ } from "bun"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Process } from "@/util/process"
|
||||
import { git } from "@/util/git"
|
||||
|
||||
type GitHubAuthor = {
|
||||
login: string
|
||||
@@ -255,7 +256,7 @@ export const GithubInstallCommand = cmd({
|
||||
}
|
||||
|
||||
// Get repo info
|
||||
const info = (await $`git remote get-url origin`.quiet().nothrow().text()).trim()
|
||||
const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
|
||||
const parsed = parseGitHubRemote(info)
|
||||
if (!parsed) {
|
||||
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
||||
@@ -493,6 +494,26 @@ export const GithubRunCommand = cmd({
|
||||
? "pr_review"
|
||||
: "issue"
|
||||
: undefined
|
||||
const gitText = async (args: string[]) => {
|
||||
const result = await git(args, { cwd: Instance.worktree })
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
||||
}
|
||||
return result.text().trim()
|
||||
}
|
||||
const gitRun = async (args: string[]) => {
|
||||
const result = await git(args, { cwd: Instance.worktree })
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
||||
}
|
||||
return result
|
||||
}
|
||||
const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree })
|
||||
const commitChanges = async (summary: string, actor?: string) => {
|
||||
const args = ["commit", "-m", summary]
|
||||
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
|
||||
await gitRun(args)
|
||||
}
|
||||
|
||||
try {
|
||||
if (useGithubToken) {
|
||||
@@ -553,7 +574,7 @@ export const GithubRunCommand = cmd({
|
||||
}
|
||||
const branchPrefix = isWorkflowDispatchEvent ? "dispatch" : "schedule"
|
||||
const branch = await checkoutNewBranch(branchPrefix)
|
||||
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||
const head = await gitText(["rev-parse", "HEAD"])
|
||||
const response = await chat(userPrompt, promptFiles)
|
||||
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch)
|
||||
if (switched) {
|
||||
@@ -587,7 +608,7 @@ export const GithubRunCommand = cmd({
|
||||
// Local PR
|
||||
if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) {
|
||||
await checkoutLocalBranch(prData)
|
||||
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||
const head = await gitText(["rev-parse", "HEAD"])
|
||||
const dataPrompt = buildPromptDataForPR(prData)
|
||||
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
|
||||
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, prData.headRefName)
|
||||
@@ -605,7 +626,7 @@ export const GithubRunCommand = cmd({
|
||||
// Fork PR
|
||||
else {
|
||||
const forkBranch = await checkoutForkBranch(prData)
|
||||
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||
const head = await gitText(["rev-parse", "HEAD"])
|
||||
const dataPrompt = buildPromptDataForPR(prData)
|
||||
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
|
||||
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, forkBranch)
|
||||
@@ -624,7 +645,7 @@ export const GithubRunCommand = cmd({
|
||||
// Issue
|
||||
else {
|
||||
const branch = await checkoutNewBranch("issue")
|
||||
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||
const head = await gitText(["rev-parse", "HEAD"])
|
||||
const issueData = await fetchIssue()
|
||||
const dataPrompt = buildPromptDataForIssue(issueData)
|
||||
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
|
||||
@@ -658,7 +679,7 @@ export const GithubRunCommand = cmd({
|
||||
exitCode = 1
|
||||
console.error(e instanceof Error ? e.message : String(e))
|
||||
let msg = e
|
||||
if (e instanceof $.ShellError) {
|
||||
if (e instanceof Process.RunFailedError) {
|
||||
msg = e.stderr.toString()
|
||||
} else if (e instanceof Error) {
|
||||
msg = e.message
|
||||
@@ -1049,29 +1070,29 @@ export const GithubRunCommand = cmd({
|
||||
const config = "http.https://github.com/.extraheader"
|
||||
// actions/checkout@v6 no longer stores credentials in .git/config,
|
||||
// so this may not exist - use nothrow() to handle gracefully
|
||||
const ret = await $`git config --local --get ${config}`.nothrow()
|
||||
const ret = await gitStatus(["config", "--local", "--get", config])
|
||||
if (ret.exitCode === 0) {
|
||||
gitConfig = ret.stdout.toString().trim()
|
||||
await $`git config --local --unset-all ${config}`
|
||||
await gitRun(["config", "--local", "--unset-all", config])
|
||||
}
|
||||
|
||||
const newCredentials = Buffer.from(`x-access-token:${appToken}`, "utf8").toString("base64")
|
||||
|
||||
await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"`
|
||||
await $`git config --global user.name "${AGENT_USERNAME}"`
|
||||
await $`git config --global user.email "${AGENT_USERNAME}@users.noreply.github.com"`
|
||||
await gitRun(["config", "--local", config, `AUTHORIZATION: basic ${newCredentials}`])
|
||||
await gitRun(["config", "--global", "user.name", AGENT_USERNAME])
|
||||
await gitRun(["config", "--global", "user.email", `${AGENT_USERNAME}@users.noreply.github.com`])
|
||||
}
|
||||
|
||||
async function restoreGitConfig() {
|
||||
if (gitConfig === undefined) return
|
||||
const config = "http.https://github.com/.extraheader"
|
||||
await $`git config --local ${config} "${gitConfig}"`
|
||||
await gitRun(["config", "--local", config, gitConfig])
|
||||
}
|
||||
|
||||
async function checkoutNewBranch(type: "issue" | "schedule" | "dispatch") {
|
||||
console.log("Checking out new branch...")
|
||||
const branch = generateBranchName(type)
|
||||
await $`git checkout -b ${branch}`
|
||||
await gitRun(["checkout", "-b", branch])
|
||||
return branch
|
||||
}
|
||||
|
||||
@@ -1081,8 +1102,8 @@ export const GithubRunCommand = cmd({
|
||||
const branch = pr.headRefName
|
||||
const depth = Math.max(pr.commits.totalCount, 20)
|
||||
|
||||
await $`git fetch origin --depth=${depth} ${branch}`
|
||||
await $`git checkout ${branch}`
|
||||
await gitRun(["fetch", "origin", `--depth=${depth}`, branch])
|
||||
await gitRun(["checkout", branch])
|
||||
}
|
||||
|
||||
async function checkoutForkBranch(pr: GitHubPullRequest) {
|
||||
@@ -1092,9 +1113,9 @@ export const GithubRunCommand = cmd({
|
||||
const localBranch = generateBranchName("pr")
|
||||
const depth = Math.max(pr.commits.totalCount, 20)
|
||||
|
||||
await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git`
|
||||
await $`git fetch fork --depth=${depth} ${remoteBranch}`
|
||||
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
|
||||
await gitRun(["remote", "add", "fork", `https://github.com/${pr.headRepository.nameWithOwner}.git`])
|
||||
await gitRun(["fetch", "fork", `--depth=${depth}`, remoteBranch])
|
||||
await gitRun(["checkout", "-b", localBranch, `fork/${remoteBranch}`])
|
||||
return localBranch
|
||||
}
|
||||
|
||||
@@ -1115,28 +1136,23 @@ export const GithubRunCommand = cmd({
|
||||
async function pushToNewBranch(summary: string, branch: string, commit: boolean, isSchedule: boolean) {
|
||||
console.log("Pushing to new branch...")
|
||||
if (commit) {
|
||||
await $`git add .`
|
||||
await gitRun(["add", "."])
|
||||
if (isSchedule) {
|
||||
// No co-author for scheduled events - the schedule is operating as the repo
|
||||
await $`git commit -m "${summary}"`
|
||||
await commitChanges(summary)
|
||||
} else {
|
||||
await $`git commit -m "${summary}
|
||||
|
||||
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
await commitChanges(summary, actor)
|
||||
}
|
||||
}
|
||||
await $`git push -u origin ${branch}`
|
||||
await gitRun(["push", "-u", "origin", branch])
|
||||
}
|
||||
|
||||
async function pushToLocalBranch(summary: string, commit: boolean) {
|
||||
console.log("Pushing to local branch...")
|
||||
if (commit) {
|
||||
await $`git add .`
|
||||
await $`git commit -m "${summary}
|
||||
|
||||
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
await gitRun(["add", "."])
|
||||
await commitChanges(summary, actor)
|
||||
}
|
||||
await $`git push`
|
||||
await gitRun(["push"])
|
||||
}
|
||||
|
||||
async function pushToForkBranch(summary: string, pr: GitHubPullRequest, commit: boolean) {
|
||||
@@ -1145,30 +1161,28 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
const remoteBranch = pr.headRefName
|
||||
|
||||
if (commit) {
|
||||
await $`git add .`
|
||||
await $`git commit -m "${summary}
|
||||
|
||||
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
await gitRun(["add", "."])
|
||||
await commitChanges(summary, actor)
|
||||
}
|
||||
await $`git push fork HEAD:${remoteBranch}`
|
||||
await gitRun(["push", "fork", `HEAD:${remoteBranch}`])
|
||||
}
|
||||
|
||||
async function branchIsDirty(originalHead: string, expectedBranch: string) {
|
||||
console.log("Checking if branch is dirty...")
|
||||
// Detect if the agent switched branches during chat (e.g. created
|
||||
// its own branch, committed, and possibly pushed/created a PR).
|
||||
const current = (await $`git rev-parse --abbrev-ref HEAD`).stdout.toString().trim()
|
||||
const current = await gitText(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
if (current !== expectedBranch) {
|
||||
console.log(`Branch changed during chat: expected ${expectedBranch}, now on ${current}`)
|
||||
return { dirty: true, uncommittedChanges: false, switched: true }
|
||||
}
|
||||
|
||||
const ret = await $`git status --porcelain`
|
||||
const ret = await gitStatus(["status", "--porcelain"])
|
||||
const status = ret.stdout.toString().trim()
|
||||
if (status.length > 0) {
|
||||
return { dirty: true, uncommittedChanges: true, switched: false }
|
||||
}
|
||||
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
|
||||
const head = await gitText(["rev-parse", "HEAD"])
|
||||
return {
|
||||
dirty: head !== originalHead,
|
||||
uncommittedChanges: false,
|
||||
@@ -1180,11 +1194,11 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
// Falls back to fetching from origin when local refs are missing
|
||||
// (common in shallow clones from actions/checkout).
|
||||
async function hasNewCommits(base: string, head: string) {
|
||||
const result = await $`git rev-list --count ${base}..${head}`.nothrow()
|
||||
const result = await gitStatus(["rev-list", "--count", `${base}..${head}`])
|
||||
if (result.exitCode !== 0) {
|
||||
console.log(`rev-list failed, fetching origin/${base}...`)
|
||||
await $`git fetch origin ${base} --depth=1`.nothrow()
|
||||
const retry = await $`git rev-list --count origin/${base}..${head}`.nothrow()
|
||||
await gitStatus(["fetch", "origin", base, "--depth=1"])
|
||||
const retry = await gitStatus(["rev-list", "--count", `origin/${base}..${head}`])
|
||||
if (retry.exitCode !== 0) return true // assume dirty if we can't tell
|
||||
return parseInt(retry.stdout.toString().trim()) > 0
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ShareNext } from "../../share/share-next"
|
||||
import { EOL } from "os"
|
||||
import { Filesystem } from "../../util/filesystem"
|
||||
|
||||
/** Discriminated union returned by the ShareNext API (GET /api/share/:id/data) */
|
||||
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
|
||||
export type ShareData =
|
||||
| { type: "session"; data: SDKSession }
|
||||
| { type: "message"; data: Message }
|
||||
@@ -24,6 +24,14 @@ export function parseShareUrl(url: string): string | null {
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
export function shouldAttachShareAuthHeaders(shareUrl: string, controlBaseUrl: string): boolean {
|
||||
try {
|
||||
return new URL(shareUrl).origin === new URL(controlBaseUrl).origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform ShareNext API response (flat array) into the nested structure for local file storage.
|
||||
*
|
||||
@@ -97,8 +105,21 @@ export const ImportCommand = cmd({
|
||||
return
|
||||
}
|
||||
|
||||
const baseUrl = await ShareNext.url()
|
||||
const response = await fetch(`${baseUrl}/api/share/${slug}/data`)
|
||||
const parsed = new URL(args.file)
|
||||
const baseUrl = parsed.origin
|
||||
const req = await ShareNext.request()
|
||||
const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
|
||||
|
||||
const dataPath = req.api.data(slug)
|
||||
let response = await fetch(`${baseUrl}${dataPath}`, {
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok && dataPath !== `/api/share/${slug}/data`) {
|
||||
response = await fetch(`${baseUrl}/api/share/${slug}/data`, {
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { $ } from "bun"
|
||||
import { Process } from "@/util/process"
|
||||
import { git } from "@/util/git"
|
||||
|
||||
export const PrCommand = cmd({
|
||||
command: "pr <number>",
|
||||
@@ -27,21 +28,35 @@ export const PrCommand = cmd({
|
||||
UI.println(`Fetching and checking out PR #${prNumber}...`)
|
||||
|
||||
// Use gh pr checkout with custom branch name
|
||||
const result = await $`gh pr checkout ${prNumber} --branch ${localBranchName} --force`.nothrow()
|
||||
const result = await Process.run(
|
||||
["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"],
|
||||
{
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
if (result.code !== 0) {
|
||||
UI.error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Fetch PR info for fork handling and session link detection
|
||||
const prInfoResult =
|
||||
await $`gh pr view ${prNumber} --json headRepository,headRepositoryOwner,isCrossRepository,headRefName,body`.nothrow()
|
||||
const prInfoResult = await Process.text(
|
||||
[
|
||||
"gh",
|
||||
"pr",
|
||||
"view",
|
||||
`${prNumber}`,
|
||||
"--json",
|
||||
"headRepository,headRepositoryOwner,isCrossRepository,headRefName,body",
|
||||
],
|
||||
{ nothrow: true },
|
||||
)
|
||||
|
||||
let sessionId: string | undefined
|
||||
|
||||
if (prInfoResult.exitCode === 0) {
|
||||
const prInfoText = prInfoResult.text()
|
||||
if (prInfoResult.code === 0) {
|
||||
const prInfoText = prInfoResult.text
|
||||
if (prInfoText.trim()) {
|
||||
const prInfo = JSON.parse(prInfoText)
|
||||
|
||||
@@ -52,15 +67,19 @@ export const PrCommand = cmd({
|
||||
const remoteName = forkOwner
|
||||
|
||||
// Check if remote already exists
|
||||
const remotes = (await $`git remote`.nothrow().text()).trim()
|
||||
const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim()
|
||||
if (!remotes.split("\n").includes(remoteName)) {
|
||||
await $`git remote add ${remoteName} https://github.com/${forkOwner}/${forkName}.git`.nothrow()
|
||||
await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
UI.println(`Added fork remote: ${remoteName}`)
|
||||
}
|
||||
|
||||
// Set upstream to the fork so pushes go there
|
||||
const headRefName = prInfo.headRefName
|
||||
await $`git branch --set-upstream-to=${remoteName}/${headRefName} ${localBranchName}`.nothrow()
|
||||
await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
}
|
||||
|
||||
// Check for opencode session link in PR body
|
||||
@@ -71,9 +90,11 @@ export const PrCommand = cmd({
|
||||
UI.println(`Found opencode session: ${sessionUrl}`)
|
||||
UI.println(`Importing session...`)
|
||||
|
||||
const importResult = await $`opencode import ${sessionUrl}`.nothrow()
|
||||
if (importResult.exitCode === 0) {
|
||||
const importOutput = importResult.text().trim()
|
||||
const importResult = await Process.text(["opencode", "import", sessionUrl], {
|
||||
nothrow: true,
|
||||
})
|
||||
if (importResult.code === 0) {
|
||||
const importOutput = importResult.text.trim()
|
||||
// Extract session ID from the output (format: "Imported session: <session-id>")
|
||||
const sessionIdMatch = importOutput.match(/Imported session: ([a-zA-Z0-9_-]+)/)
|
||||
if (sessionIdMatch) {
|
||||
|
||||
@@ -13,27 +13,13 @@ import { Instance } from "../../project/instance"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { Process } from "../../util/process"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
type PluginAuth = NonNullable<Hooks["auth"]>
|
||||
|
||||
/**
|
||||
* Handle plugin-based authentication flow.
|
||||
* Returns true if auth was handled, false if it should fall through to default handling.
|
||||
*/
|
||||
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> {
|
||||
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): Promise<boolean> {
|
||||
let index = 0
|
||||
if (methodName) {
|
||||
const match = plugin.auth.methods.findIndex((x) => x.label.toLowerCase() === methodName.toLowerCase())
|
||||
if (match === -1) {
|
||||
prompts.log.error(
|
||||
`Unknown method "${methodName}" for ${provider}. Available: ${plugin.auth.methods.map((x) => x.label).join(", ")}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
index = match
|
||||
} else if (plugin.auth.methods.length > 1) {
|
||||
const selected = await prompts.select({
|
||||
if (plugin.auth.methods.length > 1) {
|
||||
const method = await prompts.select({
|
||||
message: "Login method",
|
||||
options: [
|
||||
...plugin.auth.methods.map((x, index) => ({
|
||||
@@ -42,13 +28,12 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
})),
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
index = parseInt(selected)
|
||||
if (prompts.isCancel(method)) throw new UI.CancelledError()
|
||||
index = parseInt(method)
|
||||
}
|
||||
const method = plugin.auth.methods[index]
|
||||
|
||||
// Handle prompts for all auth types
|
||||
await sleep(10)
|
||||
await Bun.sleep(10)
|
||||
const inputs: Record<string, string> = {}
|
||||
if (method.prompts) {
|
||||
for (const prompt of method.prompts) {
|
||||
@@ -171,11 +156,6 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a deduplicated list of plugin-registered auth providers that are not
|
||||
* already present in models.dev, respecting enabled/disabled provider lists.
|
||||
* Pure function with no side effects; safe to test without mocking.
|
||||
*/
|
||||
export function resolvePluginProviders(input: {
|
||||
hooks: Hooks[]
|
||||
existingProviders: Record<string, unknown>
|
||||
@@ -203,19 +183,20 @@ export function resolvePluginProviders(input: {
|
||||
return result
|
||||
}
|
||||
|
||||
export const AuthCommand = cmd({
|
||||
command: "auth",
|
||||
describe: "manage credentials",
|
||||
export const ProvidersCommand = cmd({
|
||||
command: "providers",
|
||||
aliases: ["auth"],
|
||||
describe: "manage AI providers and credentials",
|
||||
builder: (yargs) =>
|
||||
yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(),
|
||||
yargs.command(ProvidersListCommand).command(ProvidersLoginCommand).command(ProvidersLogoutCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const AuthListCommand = cmd({
|
||||
export const ProvidersListCommand = cmd({
|
||||
command: "list",
|
||||
aliases: ["ls"],
|
||||
describe: "list providers",
|
||||
async handler() {
|
||||
describe: "list providers and credentials",
|
||||
async handler(_args) {
|
||||
UI.empty()
|
||||
const authPath = path.join(Global.Path.data, "auth.json")
|
||||
const homedir = os.homedir()
|
||||
@@ -231,7 +212,6 @@ export const AuthListCommand = cmd({
|
||||
|
||||
prompts.outro(`${results.length} credentials`)
|
||||
|
||||
// Environment variables section
|
||||
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
|
||||
|
||||
for (const [providerID, provider] of Object.entries(database)) {
|
||||
@@ -258,25 +238,14 @@ export const AuthListCommand = cmd({
|
||||
},
|
||||
})
|
||||
|
||||
export const AuthLoginCommand = cmd({
|
||||
export const ProvidersLoginCommand = cmd({
|
||||
command: "login [url]",
|
||||
describe: "log in to a provider",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("url", {
|
||||
describe: "opencode auth provider",
|
||||
type: "string",
|
||||
})
|
||||
.option("provider", {
|
||||
alias: ["p"],
|
||||
describe: "provider id or name to log in to (skips provider selection)",
|
||||
type: "string",
|
||||
})
|
||||
.option("method", {
|
||||
alias: ["m"],
|
||||
describe: "login method label (skips method selection)",
|
||||
type: "string",
|
||||
}),
|
||||
yargs.positional("url", {
|
||||
describe: "opencode auth provider",
|
||||
type: "string",
|
||||
}),
|
||||
async handler(args) {
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
@@ -284,8 +253,7 @@ export const AuthLoginCommand = cmd({
|
||||
UI.empty()
|
||||
prompts.intro("Add credential")
|
||||
if (args.url) {
|
||||
const url = args.url.replace(/\/+$/, "")
|
||||
const wellknown = await fetch(`${url}/.well-known/opencode`).then((x) => x.json() as any)
|
||||
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
|
||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||
const proc = Process.spawn(wellknown.auth.command, {
|
||||
stdout: "pipe",
|
||||
@@ -301,12 +269,12 @@ export const AuthLoginCommand = cmd({
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
await Auth.set(url, {
|
||||
await Auth.set(args.url, {
|
||||
type: "wellknown",
|
||||
key: wellknown.auth.env,
|
||||
token: token.trim(),
|
||||
})
|
||||
prompts.log.success("Logged into " + url)
|
||||
prompts.log.success("Logged into " + args.url)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
@@ -343,76 +311,59 @@ export const AuthLoginCommand = cmd({
|
||||
enabled,
|
||||
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
|
||||
})
|
||||
const options = [
|
||||
...pipe(
|
||||
providers,
|
||||
values(),
|
||||
sortBy(
|
||||
(x) => priority[x.id] ?? 99,
|
||||
(x) => x.name ?? x.id,
|
||||
let provider = await prompts.autocomplete({
|
||||
message: "Select provider",
|
||||
maxItems: 8,
|
||||
options: [
|
||||
...pipe(
|
||||
providers,
|
||||
values(),
|
||||
sortBy(
|
||||
(x) => priority[x.id] ?? 99,
|
||||
(x) => x.name ?? x.id,
|
||||
),
|
||||
map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: {
|
||||
opencode: "recommended",
|
||||
anthropic: "Claude Max or API key",
|
||||
openai: "ChatGPT Plus/Pro or API key",
|
||||
}[x.id],
|
||||
})),
|
||||
),
|
||||
map((x) => ({
|
||||
...pluginProviders.map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: {
|
||||
opencode: "recommended",
|
||||
anthropic: "Claude Max or API key",
|
||||
openai: "ChatGPT Plus/Pro or API key",
|
||||
}[x.id],
|
||||
hint: "plugin",
|
||||
})),
|
||||
),
|
||||
...pluginProviders.map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: "plugin",
|
||||
})),
|
||||
]
|
||||
{
|
||||
value: "other",
|
||||
label: "Other",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
let provider: string
|
||||
if (args.provider) {
|
||||
const input = args.provider
|
||||
const byID = options.find((x) => x.value === input)
|
||||
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
|
||||
const match = byID ?? byName
|
||||
if (!match) {
|
||||
prompts.log.error(`Unknown provider "${input}"`)
|
||||
process.exit(1)
|
||||
}
|
||||
provider = match.value
|
||||
} else {
|
||||
const selected = await prompts.autocomplete({
|
||||
message: "Select provider",
|
||||
maxItems: 8,
|
||||
options: [
|
||||
...options,
|
||||
{
|
||||
value: "other",
|
||||
label: "Other",
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
provider = selected as string
|
||||
}
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
|
||||
const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
|
||||
if (plugin && plugin.auth) {
|
||||
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
|
||||
const handled = await handlePluginAuth({ auth: plugin.auth }, provider)
|
||||
if (handled) return
|
||||
}
|
||||
|
||||
if (provider === "other") {
|
||||
const custom = await prompts.text({
|
||||
provider = await prompts.text({
|
||||
message: "Enter provider id",
|
||||
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
|
||||
})
|
||||
if (prompts.isCancel(custom)) throw new UI.CancelledError()
|
||||
provider = custom.replace(/^@ai-sdk\//, "")
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
provider = provider.replace(/^@ai-sdk\//, "")
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
|
||||
// Check if a plugin provides auth for this custom provider
|
||||
const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
|
||||
if (customPlugin && customPlugin.auth) {
|
||||
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
|
||||
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider)
|
||||
if (handled) return
|
||||
}
|
||||
|
||||
@@ -461,10 +412,10 @@ export const AuthLoginCommand = cmd({
|
||||
},
|
||||
})
|
||||
|
||||
export const AuthLogoutCommand = cmd({
|
||||
export const ProvidersLogoutCommand = cmd({
|
||||
command: "logout",
|
||||
describe: "log out from a configured provider",
|
||||
async handler() {
|
||||
async handler(_args) {
|
||||
UI.empty()
|
||||
const credentials = await Auth.all().then((x) => Object.entries(x))
|
||||
prompts.intro("Remove credential")
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Argv } from "yargs"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "bun"
|
||||
import { pathToFileURL } from "url"
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { Flag } from "../../flag/flag"
|
||||
@@ -667,7 +667,7 @@ export const RunCommand = cmd({
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
return Server.App().fetch(request)
|
||||
return Server.Default().fetch(request)
|
||||
}) as typeof globalThis.fetch
|
||||
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
|
||||
await execute(sdk)
|
||||
|
||||
@@ -20,6 +20,7 @@ import { DialogHelp } from "./ui/dialog-help"
|
||||
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 } from "@tui/context/keybind"
|
||||
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
||||
import { Home } from "@tui/routes/home"
|
||||
@@ -371,6 +372,22 @@ function App() {
|
||||
dialog.replace(() => <DialogSessionList />)
|
||||
},
|
||||
},
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
? [
|
||||
{
|
||||
title: "Manage workspaces",
|
||||
value: "workspace.list",
|
||||
category: "Workspace",
|
||||
suggested: true,
|
||||
slash: {
|
||||
name: "workspaces",
|
||||
},
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogWorkspaceList />)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: "New session",
|
||||
suggested: route.data.type === "session",
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import { DialogSessionList } from "./workspace/dialog-session-list"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
|
||||
async function openWorkspace(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
route: ReturnType<typeof useRoute>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
toast: ReturnType<typeof useToast>
|
||||
workspaceID: string
|
||||
forceCreate?: boolean
|
||||
}) {
|
||||
const cacheSession = (session: Session) => {
|
||||
input.sync.set(
|
||||
"session",
|
||||
[...input.sync.data.session.filter((item) => item.id !== session.id), session].toSorted((a, b) =>
|
||||
a.id.localeCompare(b.id),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: input.sdk.url,
|
||||
fetch: input.sdk.fetch,
|
||||
directory: input.sync.data.path.directory || input.sdk.directory,
|
||||
experimental_workspaceID: input.workspaceID,
|
||||
})
|
||||
const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
|
||||
const session = listed?.data?.[0]
|
||||
if (session?.id) {
|
||||
cacheSession(session)
|
||||
input.route.navigate({
|
||||
type: "session",
|
||||
sessionID: session.id,
|
||||
})
|
||||
input.dialog.clear()
|
||||
return
|
||||
}
|
||||
let created: Session | undefined
|
||||
while (!created) {
|
||||
const result = await client.session.create({}).catch(() => undefined)
|
||||
if (!result) {
|
||||
input.toast.show({
|
||||
message: "Failed to open workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (result.response.status >= 500 && result.response.status < 600) {
|
||||
await Bun.sleep(1000)
|
||||
continue
|
||||
}
|
||||
if (!result.data) {
|
||||
input.toast.show({
|
||||
message: "Failed to open workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
created = result.data
|
||||
}
|
||||
cacheSession(created)
|
||||
input.route.navigate({
|
||||
type: "session",
|
||||
sessionID: created.id,
|
||||
})
|
||||
input.dialog.clear()
|
||||
}
|
||||
|
||||
function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> }) {
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const [creating, setCreating] = createSignal<string>()
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("medium")
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const type = creating()
|
||||
if (type) {
|
||||
return [
|
||||
{
|
||||
title: `Creating ${type} workspace...`,
|
||||
value: "creating" as const,
|
||||
description: "This can take a while for remote environments",
|
||||
},
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
title: "Worktree",
|
||||
value: "worktree" as const,
|
||||
description: "Create a local git worktree",
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const createWorkspace = async (type: string) => {
|
||||
if (creating()) return
|
||||
setCreating(type)
|
||||
|
||||
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
|
||||
console.log(err)
|
||||
return undefined
|
||||
})
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
const workspace = result?.data
|
||||
if (!workspace) {
|
||||
setCreating(undefined)
|
||||
toast.show({
|
||||
message: "Failed to create workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
await sync.workspace.sync()
|
||||
await props.onSelect(workspace.id)
|
||||
setCreating(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title={creating() ? "Creating Workspace" : "New Workspace"}
|
||||
skipFilter={true}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
if (option.value === "creating") return
|
||||
void createWorkspace(option.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogWorkspaceList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const keybind = useKeybind()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [counts, setCounts] = createSignal<Record<string, number | null | undefined>>({})
|
||||
|
||||
const open = (workspaceID: string, forceCreate?: boolean) =>
|
||||
openWorkspace({
|
||||
dialog,
|
||||
route,
|
||||
sdk,
|
||||
sync,
|
||||
toast,
|
||||
workspaceID,
|
||||
forceCreate,
|
||||
})
|
||||
|
||||
async function selectWorkspace(workspaceID: string) {
|
||||
if (workspaceID === "__local__") {
|
||||
if (localCount() > 0) {
|
||||
dialog.replace(() => <DialogSessionList localOnly={true} />)
|
||||
return
|
||||
}
|
||||
route.navigate({
|
||||
type: "home",
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
const count = counts()[workspaceID]
|
||||
if (count && count > 0) {
|
||||
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
|
||||
return
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
await open(workspaceID)
|
||||
return
|
||||
}
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: sdk.fetch,
|
||||
directory: sync.data.path.directory || sdk.directory,
|
||||
experimental_workspaceID: workspaceID,
|
||||
})
|
||||
const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
|
||||
if (listed?.data?.length) {
|
||||
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
|
||||
return
|
||||
}
|
||||
await open(workspaceID)
|
||||
}
|
||||
|
||||
const currentWorkspaceID = createMemo(() => {
|
||||
if (route.data.type === "session") {
|
||||
return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__"
|
||||
}
|
||||
return "__local__"
|
||||
})
|
||||
|
||||
const localCount = createMemo(
|
||||
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
|
||||
)
|
||||
|
||||
let run = 0
|
||||
createEffect(() => {
|
||||
const workspaces = sync.data.workspaceList
|
||||
const next = ++run
|
||||
if (!workspaces.length) {
|
||||
setCounts({})
|
||||
return
|
||||
}
|
||||
setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
|
||||
void Promise.all(
|
||||
workspaces.map(async (workspace) => {
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: sdk.fetch,
|
||||
directory: sync.data.path.directory || sdk.directory,
|
||||
experimental_workspaceID: workspace.id,
|
||||
})
|
||||
const result = await client.session.list({ roots: true }).catch(() => undefined)
|
||||
return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
|
||||
}),
|
||||
).then((entries) => {
|
||||
if (run !== next) return
|
||||
setCounts(Object.fromEntries(entries))
|
||||
})
|
||||
})
|
||||
|
||||
const options = createMemo(() => [
|
||||
{
|
||||
title: "Local",
|
||||
value: "__local__",
|
||||
category: "Workspace",
|
||||
description: "Use the local machine",
|
||||
footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
|
||||
},
|
||||
...sync.data.workspaceList.map((workspace) => {
|
||||
const count = counts()[workspace.id]
|
||||
return {
|
||||
title:
|
||||
toDelete() === workspace.id
|
||||
? `Delete ${workspace.id}? Press ${keybind.print("session_delete")} again`
|
||||
: workspace.id,
|
||||
value: workspace.id,
|
||||
category: workspace.type,
|
||||
description: workspace.branch ? `Branch ${workspace.branch}` : undefined,
|
||||
footer:
|
||||
count === undefined
|
||||
? "Loading sessions..."
|
||||
: count === null
|
||||
? "Sessions unavailable"
|
||||
: `${count} session${count === 1 ? "" : "s"}`,
|
||||
}
|
||||
}),
|
||||
{
|
||||
title: "+ New workspace",
|
||||
value: "__create__",
|
||||
category: "Actions",
|
||||
description: "Create a new workspace",
|
||||
},
|
||||
])
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
void sync.workspace.sync()
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Workspaces"
|
||||
skipFilter={true}
|
||||
options={options()}
|
||||
current={currentWorkspaceID()}
|
||||
onMove={() => {
|
||||
setToDelete(undefined)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
setToDelete(undefined)
|
||||
if (option.value === "__create__") {
|
||||
dialog.replace(() => <DialogWorkspaceCreate onSelect={(workspaceID) => open(workspaceID, true)} />)
|
||||
return
|
||||
}
|
||||
void selectWorkspace(option.value)
|
||||
}}
|
||||
keybind={[
|
||||
{
|
||||
keybind: keybind.all.session_delete?.[0],
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (option.value === "__create__" || option.value === "__local__") return
|
||||
if (toDelete() !== option.value) {
|
||||
setToDelete(option.value)
|
||||
return
|
||||
}
|
||||
const result = await sdk.client.experimental.workspace.remove({ id: option.value }).catch(() => undefined)
|
||||
setToDelete(undefined)
|
||||
if (result?.error) {
|
||||
toast.show({
|
||||
message: "Failed to delete workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (currentWorkspaceID() === option.value) {
|
||||
route.navigate({
|
||||
type: "home",
|
||||
})
|
||||
}
|
||||
await sync.workspace.sync()
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -539,12 +539,25 @@ export function Prompt(props: PromptProps) {
|
||||
promptModelWarning()
|
||||
return
|
||||
}
|
||||
const sessionID = props.sessionID
|
||||
? props.sessionID
|
||||
: await (async () => {
|
||||
const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
|
||||
return sessionID
|
||||
})()
|
||||
|
||||
let sessionID = props.sessionID
|
||||
if (sessionID == null) {
|
||||
const res = await sdk.client.session.create({})
|
||||
|
||||
if (res.error) {
|
||||
console.log("Creating a session failed:", res.error)
|
||||
|
||||
toast.show({
|
||||
message: "Creating a session failed. Open console for more details.",
|
||||
variant: "error",
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sessionID = res.data.id
|
||||
}
|
||||
|
||||
const messageID = Identifier.ascending("message")
|
||||
let inputText = store.prompt.input
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { DialogSessionRename } from "../dialog-session-rename"
|
||||
import { useKV } from "../../context/kv"
|
||||
import { createDebouncedSignal } from "../../util/signal"
|
||||
import { Spinner } from "../spinner"
|
||||
import { useToast } from "../../ui/toast"
|
||||
|
||||
export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const keybind = useKeybind()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
const toast = useToast()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
|
||||
const [listed, listedActions] = createResource(
|
||||
() => props.workspaceID,
|
||||
async (workspaceID) => {
|
||||
if (!workspaceID) return undefined
|
||||
const result = await sdk.client.session.list({ roots: true })
|
||||
return result.data ?? []
|
||||
},
|
||||
)
|
||||
|
||||
const [searchResults] = createResource(search, async (query) => {
|
||||
if (!query || props.localOnly) return undefined
|
||||
const result = await sdk.client.session.list({
|
||||
search: query,
|
||||
limit: 30,
|
||||
...(props.workspaceID ? { roots: true } : {}),
|
||||
})
|
||||
return result.data ?? []
|
||||
})
|
||||
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
|
||||
const sessions = createMemo(() => {
|
||||
if (searchResults()) return searchResults()!
|
||||
if (props.workspaceID) return listed() ?? []
|
||||
if (props.localOnly) return sync.data.session.filter((session) => !session.workspaceID)
|
||||
return sync.data.session
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
return sessions()
|
||||
.filter((x) => {
|
||||
if (x.parentID !== undefined) return false
|
||||
if (props.workspaceID && listed()) return true
|
||||
if (props.workspaceID) return x.workspaceID === props.workspaceID
|
||||
if (props.localOnly) return !x.workspaceID
|
||||
return true
|
||||
})
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.map((x) => {
|
||||
const date = new Date(x.time.updated)
|
||||
let category = date.toDateString()
|
||||
if (category === today) {
|
||||
category = "Today"
|
||||
}
|
||||
const isDeleting = toDelete() === x.id
|
||||
const status = sync.data.session_status?.[x.id]
|
||||
const isWorking = status?.type === "busy"
|
||||
return {
|
||||
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: x.id,
|
||||
category,
|
||||
footer: Locale.time(x.time.updated),
|
||||
gutter: isWorking ? <Spinner /> : undefined,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title={props.workspaceID ? `Workspace Sessions` : props.localOnly ? "Local Sessions" : "Sessions"}
|
||||
options={options()}
|
||||
skipFilter={!props.localOnly}
|
||||
current={currentSessionID()}
|
||||
onFilter={setSearch}
|
||||
onMove={() => {
|
||||
setToDelete(undefined)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: option.value,
|
||||
})
|
||||
dialog.clear()
|
||||
}}
|
||||
keybind={[
|
||||
{
|
||||
keybind: keybind.all.session_delete?.[0],
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (toDelete() === option.value) {
|
||||
const deleted = await sdk.client.session
|
||||
.delete({
|
||||
sessionID: option.value,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
setToDelete(undefined)
|
||||
if (!deleted) {
|
||||
toast.show({
|
||||
message: "Failed to delete session",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (props.workspaceID) {
|
||||
listedActions.mutate((sessions) => sessions?.filter((session) => session.id !== option.value))
|
||||
return
|
||||
}
|
||||
sync.set(
|
||||
"session",
|
||||
sync.data.session.filter((session) => session.id !== option.value),
|
||||
)
|
||||
return
|
||||
}
|
||||
setToDelete(option.value)
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: keybind.all.session_rename?.[0],
|
||||
title: "rename",
|
||||
onTrigger: async (option) => {
|
||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { batch, onCleanup, onMount } from "solid-js"
|
||||
|
||||
export type EventSource = {
|
||||
on: (handler: (event: Event) => void) => () => void
|
||||
setWorkspace?: (workspaceID?: string) => void
|
||||
}
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
@@ -17,13 +18,21 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
events?: EventSource
|
||||
}) => {
|
||||
const abort = new AbortController()
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: props.url,
|
||||
signal: abort.signal,
|
||||
directory: props.directory,
|
||||
fetch: props.fetch,
|
||||
headers: props.headers,
|
||||
})
|
||||
let workspaceID: string | undefined
|
||||
let sse: AbortController | undefined
|
||||
|
||||
function createSDK() {
|
||||
return createOpencodeClient({
|
||||
baseUrl: props.url,
|
||||
signal: abort.signal,
|
||||
directory: props.directory,
|
||||
fetch: props.fetch,
|
||||
headers: props.headers,
|
||||
experimental_workspaceID: workspaceID,
|
||||
})
|
||||
}
|
||||
|
||||
let sdk = createSDK()
|
||||
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
@@ -61,41 +70,56 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
flush()
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// If an event source is provided, use it instead of SSE
|
||||
function startSSE() {
|
||||
sse?.abort()
|
||||
const ctrl = new AbortController()
|
||||
sse = ctrl
|
||||
;(async () => {
|
||||
while (true) {
|
||||
if (abort.signal.aborted || ctrl.signal.aborted) break
|
||||
const events = await sdk.event.subscribe({}, { signal: ctrl.signal })
|
||||
|
||||
for await (const event of events.stream) {
|
||||
if (ctrl.signal.aborted) break
|
||||
handleEvent(event)
|
||||
}
|
||||
|
||||
if (timer) clearTimeout(timer)
|
||||
if (queue.length > 0) flush()
|
||||
}
|
||||
})().catch(() => {})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (props.events) {
|
||||
const unsub = props.events.on(handleEvent)
|
||||
onCleanup(unsub)
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to SSE
|
||||
while (true) {
|
||||
if (abort.signal.aborted) break
|
||||
const events = await sdk.event.subscribe(
|
||||
{},
|
||||
{
|
||||
signal: abort.signal,
|
||||
},
|
||||
)
|
||||
|
||||
for await (const event of events.stream) {
|
||||
handleEvent(event)
|
||||
}
|
||||
|
||||
// Flush any remaining events
|
||||
if (timer) clearTimeout(timer)
|
||||
if (queue.length > 0) {
|
||||
flush()
|
||||
}
|
||||
} else {
|
||||
startSSE()
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
abort.abort()
|
||||
sse?.abort()
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
|
||||
return { client: sdk, event: emitter, url: props.url }
|
||||
return {
|
||||
get client() {
|
||||
return sdk
|
||||
},
|
||||
directory: props.directory,
|
||||
event: emitter,
|
||||
fetch: props.fetch ?? fetch,
|
||||
setWorkspace(next?: string) {
|
||||
if (workspaceID === next) return
|
||||
workspaceID = next
|
||||
sdk = createSDK()
|
||||
props.events?.setWorkspace?.(next)
|
||||
if (!props.events) startSSE()
|
||||
},
|
||||
url: props.url,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useArgs } from "./args"
|
||||
import { batch, onMount } from "solid-js"
|
||||
import { Log } from "@/util/log"
|
||||
import type { Path } from "@opencode-ai/sdk"
|
||||
import type { Workspace } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
name: "Sync",
|
||||
@@ -73,6 +74,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
formatter: FormatterStatus[]
|
||||
vcs: VcsInfo | undefined
|
||||
path: Path
|
||||
workspaceList: Workspace[]
|
||||
}>({
|
||||
provider_next: {
|
||||
all: [],
|
||||
@@ -100,10 +102,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
formatter: [],
|
||||
vcs: undefined,
|
||||
path: { state: "", config: "", worktree: "", directory: "" },
|
||||
workspaceList: [],
|
||||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
|
||||
async function syncWorkspaces() {
|
||||
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
||||
if (!result?.data) return
|
||||
setStore("workspaceList", reconcile(result.data))
|
||||
}
|
||||
|
||||
sdk.event.listen((e) => {
|
||||
const event = e.details
|
||||
switch (event.type) {
|
||||
@@ -413,6 +422,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
||||
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
|
||||
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
|
||||
syncWorkspaces(),
|
||||
]).then(() => {
|
||||
setStore("status", "complete")
|
||||
})
|
||||
@@ -481,6 +491,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
fullSyncedSessions.add(sessionID)
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
get(workspaceID: string) {
|
||||
return store.workspaceList.find((workspace) => workspace.id === workspaceID)
|
||||
},
|
||||
sync: syncWorkspaces,
|
||||
},
|
||||
bootstrap,
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createMemo, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { createEffect, createMemo, Match, on, onMount, Show, Switch } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { Logo } from "../component/logo"
|
||||
@@ -14,6 +14,7 @@ import { usePromptRef } from "../context/prompt"
|
||||
import { Installation } from "@/installation"
|
||||
import { useKV } from "../context/kv"
|
||||
import { useCommandDialog } from "../component/dialog-command"
|
||||
import { useLocal } from "../context/local"
|
||||
|
||||
// TODO: what is the best way to do this?
|
||||
let once = false
|
||||
@@ -76,6 +77,7 @@ export function Home() {
|
||||
|
||||
let prompt: PromptRef
|
||||
const args = useArgs()
|
||||
const local = useLocal()
|
||||
onMount(() => {
|
||||
if (once) return
|
||||
if (route.initialPrompt) {
|
||||
@@ -84,9 +86,21 @@ export function Home() {
|
||||
} else if (args.prompt) {
|
||||
prompt.set({ input: args.prompt, parts: [] })
|
||||
once = true
|
||||
prompt.submit()
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for sync and model store to be ready before auto-submitting --prompt
|
||||
createEffect(
|
||||
on(
|
||||
() => sync.ready && local.model.ready,
|
||||
(ready) => {
|
||||
if (!ready) return
|
||||
if (!args.prompt) return
|
||||
if (prompt.current?.input !== args.prompt) return
|
||||
prompt.submit()
|
||||
},
|
||||
),
|
||||
)
|
||||
const directory = useDirectory()
|
||||
|
||||
const keybind = useKeybind()
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SplitBorder } from "@tui/component/border"
|
||||
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
|
||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
|
||||
const Title = (props: { session: Accessor<Session> }) => {
|
||||
@@ -29,6 +30,17 @@ const ContextInfo = (props: { context: Accessor<string | undefined>; cost: Acces
|
||||
)
|
||||
}
|
||||
|
||||
const WorkspaceInfo = (props: { workspace: Accessor<string | undefined> }) => {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<Show when={props.workspace()}>
|
||||
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
|
||||
{props.workspace()}
|
||||
</text>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const route = useRouteData("session")
|
||||
const sync = useSync()
|
||||
@@ -59,6 +71,14 @@ export function Header() {
|
||||
return result
|
||||
})
|
||||
|
||||
const workspace = createMemo(() => {
|
||||
const id = session()?.workspaceID
|
||||
if (!id) return "Workspace local"
|
||||
const info = sync.workspace.get(id)
|
||||
if (!info) return `Workspace ${id}`
|
||||
return `Workspace ${id} (${info.type})`
|
||||
})
|
||||
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
const command = useCommandDialog()
|
||||
@@ -83,9 +103,19 @@ export function Header() {
|
||||
<Match when={session()?.parentID}>
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={narrow() ? 1 : 0}>
|
||||
<text fg={theme.text}>
|
||||
<b>Subagent session</b>
|
||||
</text>
|
||||
{Flag.OPENCODE_EXPERIMENTAL_WORKSPACES ? (
|
||||
<box flexDirection="column">
|
||||
<text fg={theme.text}>
|
||||
<b>Subagent session</b>
|
||||
</text>
|
||||
<WorkspaceInfo workspace={workspace} />
|
||||
</box>
|
||||
) : (
|
||||
<text fg={theme.text}>
|
||||
<b>Subagent session</b>
|
||||
</text>
|
||||
)}
|
||||
|
||||
<ContextInfo context={context} cost={cost} />
|
||||
</box>
|
||||
<box flexDirection="row" gap={2}>
|
||||
@@ -124,7 +154,14 @@ export function Header() {
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={1}>
|
||||
<Title session={session} />
|
||||
{Flag.OPENCODE_EXPERIMENTAL_WORKSPACES ? (
|
||||
<box flexDirection="column">
|
||||
<Title session={session} />
|
||||
<WorkspaceInfo workspace={workspace} />
|
||||
</box>
|
||||
) : (
|
||||
<Title session={session} />
|
||||
)}
|
||||
<ContextInfo context={context} cost={cost} />
|
||||
</box>
|
||||
</Match>
|
||||
|
||||
@@ -182,6 +182,12 @@ export function Session() {
|
||||
return new CustomSpeedScroll(3)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (session()?.workspaceID) {
|
||||
sdk.setWorkspace(session()?.workspaceID)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(async () => {
|
||||
await sync.session
|
||||
.sync(route.sessionID)
|
||||
@@ -377,7 +383,12 @@ export function Session() {
|
||||
sessionID: route.sessionID,
|
||||
})
|
||||
.then((res) => copy(res.data!.share!.url))
|
||||
.catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
|
||||
.catch((error) => {
|
||||
toast.show({
|
||||
message: error instanceof Error ? error.message : "Failed to share session",
|
||||
variant: "error",
|
||||
})
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -480,7 +491,12 @@ export function Session() {
|
||||
sessionID: route.sessionID,
|
||||
})
|
||||
.then(() => toast.show({ message: "Session unshared successfully", variant: "success" }))
|
||||
.catch(() => toast.show({ message: "Failed to unshare session", variant: "error" }))
|
||||
.catch((error) => {
|
||||
toast.show({
|
||||
message: error instanceof Error ? error.message : "Failed to unshare session",
|
||||
variant: "error",
|
||||
})
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
|
||||
@@ -42,6 +42,9 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
|
||||
function createEventSource(client: RpcClient): EventSource {
|
||||
return {
|
||||
on: (handler) => client.on<Event>("event", handler),
|
||||
setWorkspace: (workspaceID) => {
|
||||
void client.call("setWorkspace", { workspaceID })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { $ } from "bun"
|
||||
import { platform, release } from "os"
|
||||
import clipboardy from "clipboardy"
|
||||
import { lazy } from "../../../../util/lazy.js"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "../../../../util/filesystem"
|
||||
import { Process } from "../../../../util/process"
|
||||
import { which } from "../../../../util/which"
|
||||
@@ -34,23 +34,38 @@ export namespace Clipboard {
|
||||
if (os === "darwin") {
|
||||
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
|
||||
try {
|
||||
await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'`
|
||||
.nothrow()
|
||||
.quiet()
|
||||
await Process.run(
|
||||
[
|
||||
"osascript",
|
||||
"-e",
|
||||
'set imageData to the clipboard as "PNGf"',
|
||||
"-e",
|
||||
`set fileRef to open for access POSIX file "${tmpfile}" with write permission`,
|
||||
"-e",
|
||||
"set eof fileRef to 0",
|
||||
"-e",
|
||||
"write imageData to fileRef",
|
||||
"-e",
|
||||
"close access fileRef",
|
||||
],
|
||||
{ nothrow: true },
|
||||
)
|
||||
const buffer = await Filesystem.readBytes(tmpfile)
|
||||
return { data: buffer.toString("base64"), mime: "image/png" }
|
||||
} catch {
|
||||
} finally {
|
||||
await $`rm -f "${tmpfile}"`.nothrow().quiet()
|
||||
await fs.rm(tmpfile, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
if (os === "win32" || release().includes("WSL")) {
|
||||
const script =
|
||||
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
|
||||
const base64 = await $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text()
|
||||
if (base64) {
|
||||
const imageBuffer = Buffer.from(base64.trim(), "base64")
|
||||
const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
|
||||
nothrow: true,
|
||||
})
|
||||
if (base64.text) {
|
||||
const imageBuffer = Buffer.from(base64.text.trim(), "base64")
|
||||
if (imageBuffer.length > 0) {
|
||||
return { data: imageBuffer.toString("base64"), mime: "image/png" }
|
||||
}
|
||||
@@ -58,13 +73,15 @@ export namespace Clipboard {
|
||||
}
|
||||
|
||||
if (os === "linux") {
|
||||
const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer()
|
||||
if (wayland && wayland.byteLength > 0) {
|
||||
return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" }
|
||||
const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
|
||||
if (wayland.stdout.byteLength > 0) {
|
||||
return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
|
||||
}
|
||||
const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer()
|
||||
if (x11 && x11.byteLength > 0) {
|
||||
return { data: Buffer.from(x11).toString("base64"), mime: "image/png" }
|
||||
const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
|
||||
nothrow: true,
|
||||
})
|
||||
if (x11.stdout.byteLength > 0) {
|
||||
return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +98,7 @@ export namespace Clipboard {
|
||||
console.log("clipboard: using osascript")
|
||||
return async (text: string) => {
|
||||
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet()
|
||||
await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ const eventStream = {
|
||||
abort: undefined as AbortController | undefined,
|
||||
}
|
||||
|
||||
const startEventStream = (directory: string) => {
|
||||
const startEventStream = (input: { directory: string; workspaceID?: string }) => {
|
||||
if (eventStream.abort) eventStream.abort.abort()
|
||||
const abort = new AbortController()
|
||||
eventStream.abort = abort
|
||||
@@ -54,12 +54,13 @@ const startEventStream = (directory: string) => {
|
||||
const request = new Request(input, init)
|
||||
const auth = getAuthorizationHeader()
|
||||
if (auth) request.headers.set("Authorization", auth)
|
||||
return Server.App().fetch(request)
|
||||
return Server.Default().fetch(request)
|
||||
}) as typeof globalThis.fetch
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: "http://opencode.internal",
|
||||
directory,
|
||||
directory: input.directory,
|
||||
experimental_workspaceID: input.workspaceID,
|
||||
fetch: fetchFn,
|
||||
signal,
|
||||
})
|
||||
@@ -95,7 +96,7 @@ const startEventStream = (directory: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
startEventStream(process.cwd())
|
||||
startEventStream({ directory: process.cwd() })
|
||||
|
||||
export const rpc = {
|
||||
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
|
||||
@@ -109,7 +110,7 @@ export const rpc = {
|
||||
headers,
|
||||
body: input.body,
|
||||
})
|
||||
const response = await Server.App().fetch(request)
|
||||
const response = await Server.Default().fetch(request)
|
||||
const body = await response.text()
|
||||
return {
|
||||
status: response.status,
|
||||
@@ -135,6 +136,9 @@ export const rpc = {
|
||||
Config.global.reset()
|
||||
await Instance.disposeAll()
|
||||
},
|
||||
async setWorkspace(input: { workspaceID?: string }) {
|
||||
startEventStream({ directory: process.cwd(), workspaceID: input.workspaceID })
|
||||
},
|
||||
async shutdown() {
|
||||
Log.Default.info("worker shutting down")
|
||||
if (eventStream.abort) eventStream.abort.abort()
|
||||
|
||||
@@ -3,11 +3,11 @@ import { UI } from "../ui"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { Installation } from "../../installation"
|
||||
import { Global } from "../../global"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Filesystem } from "../../util/filesystem"
|
||||
import { Process } from "../../util/process"
|
||||
|
||||
interface UninstallArgs {
|
||||
keepConfig: boolean
|
||||
@@ -192,16 +192,13 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar
|
||||
const cmd = cmds[method]
|
||||
if (cmd) {
|
||||
spinner.start(`Running ${cmd.join(" ")}...`)
|
||||
const result =
|
||||
method === "choco"
|
||||
? await $`echo Y | choco uninstall opencode -y -r`.quiet().nothrow()
|
||||
: await $`${cmd}`.quiet().nothrow()
|
||||
if (result.exitCode !== 0) {
|
||||
spinner.stop(`Package manager uninstall failed: exit code ${result.exitCode}`, 1)
|
||||
if (
|
||||
method === "choco" &&
|
||||
result.stdout.toString("utf8").includes("not running from an elevated command shell")
|
||||
) {
|
||||
const result = await Process.run(method === "choco" ? ["choco", "uninstall", "opencode", "-y", "-r"] : cmd, {
|
||||
nothrow: true,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
spinner.stop(`Package manager uninstall failed: exit code ${result.code}`, 1)
|
||||
const text = `${result.stdout.toString("utf8")}\n${result.stderr.toString("utf8")}`
|
||||
if (method === "choco" && text.includes("not running from an elevated command shell")) {
|
||||
prompts.log.warn(`You may need to run '${cmd.join(" ")}' from an elevated command shell`)
|
||||
} else {
|
||||
prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`)
|
||||
|
||||
25
packages/opencode/src/cli/effect/prompt.ts
Normal file
25
packages/opencode/src/cli/effect/prompt.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { Effect, Option } from "effect"
|
||||
|
||||
export const intro = (msg: string) => Effect.sync(() => prompts.intro(msg))
|
||||
export const outro = (msg: string) => Effect.sync(() => prompts.outro(msg))
|
||||
|
||||
export const log = {
|
||||
info: (msg: string) => Effect.sync(() => prompts.log.info(msg)),
|
||||
}
|
||||
|
||||
export const select = <Value>(opts: Parameters<typeof prompts.select<Value>>[0]) =>
|
||||
Effect.tryPromise(() => prompts.select(opts)).pipe(
|
||||
Effect.map((result) => {
|
||||
if (prompts.isCancel(result)) return Option.none<Value>()
|
||||
return Option.some(result)
|
||||
}),
|
||||
)
|
||||
|
||||
export const spinner = () => {
|
||||
const s = prompts.spinner()
|
||||
return {
|
||||
start: (msg: string) => Effect.sync(() => s.start(msg)),
|
||||
stop: (msg: string, code?: number) => Effect.sync(() => s.stop(msg, code)),
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { lazy } from "../util/lazy"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Auth } from "../auth"
|
||||
import { Env } from "../env"
|
||||
import {
|
||||
type ParseError as JsoncParseError,
|
||||
applyEdits,
|
||||
@@ -32,7 +33,7 @@ import { Glob } from "../util/glob"
|
||||
import { PackageRegistry } from "@/bun/registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Control } from "@/control"
|
||||
import { Account } from "@/account"
|
||||
import { ConfigPaths } from "./paths"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
@@ -108,10 +109,6 @@ export namespace Config {
|
||||
}
|
||||
}
|
||||
|
||||
const token = await Control.token()
|
||||
if (token) {
|
||||
}
|
||||
|
||||
// Global user config overrides remote config.
|
||||
result = mergeConfigConcatArrays(result, await global())
|
||||
|
||||
@@ -178,6 +175,26 @@ export namespace Config {
|
||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||
}
|
||||
|
||||
const active = Account.active()
|
||||
if (active?.active_org_id) {
|
||||
const config = await Account.config(active.id, active.active_org_id)
|
||||
const token = await Account.token(active.id)
|
||||
if (token) {
|
||||
process.env["OPENCODE_CONSOLE_TOKEN"] = token
|
||||
Env.set("OPENCODE_CONSOLE_TOKEN", token)
|
||||
}
|
||||
|
||||
if (config) {
|
||||
result = mergeConfigConcatArrays(
|
||||
result,
|
||||
await load(JSON.stringify(config), {
|
||||
dir: path.dirname(`${active.url}/api/config`),
|
||||
source: `${active.url}/api/config`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Load managed config files last (highest priority) - enterprise admin-controlled
|
||||
// Kept separate from directories array to avoid write operations when installing plugins
|
||||
// which would fail on system directories requiring elevated permissions
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Instance } from "@/project/instance"
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import { Installation } from "../installation"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { getAdaptor } from "./adaptors"
|
||||
import { Workspace } from "./workspace"
|
||||
import { WorkspaceContext } from "./workspace-context"
|
||||
@@ -38,7 +37,7 @@ async function routeRequest(req: Request) {
|
||||
|
||||
export const WorkspaceRouterMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
// Only available in development for now
|
||||
if (!Installation.isLocal()) {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||
return next()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { eq, and } from "drizzle-orm"
|
||||
import { Database } from "@/storage/db"
|
||||
import { ControlAccountTable } from "./control.sql"
|
||||
import z from "zod"
|
||||
|
||||
export * from "./control.sql"
|
||||
|
||||
export namespace Control {
|
||||
export const Account = z.object({
|
||||
email: z.string(),
|
||||
url: z.string(),
|
||||
})
|
||||
export type Account = z.infer<typeof Account>
|
||||
|
||||
function fromRow(row: (typeof ControlAccountTable)["$inferSelect"]): Account {
|
||||
return {
|
||||
email: row.email,
|
||||
url: row.url,
|
||||
}
|
||||
}
|
||||
|
||||
export function account(): Account | undefined {
|
||||
const row = Database.use((db) =>
|
||||
db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(),
|
||||
)
|
||||
return row ? fromRow(row) : undefined
|
||||
}
|
||||
|
||||
export async function token(): Promise<string | undefined> {
|
||||
const row = Database.use((db) =>
|
||||
db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(),
|
||||
)
|
||||
if (!row) return undefined
|
||||
if (row.token_expiry && row.token_expiry > Date.now()) return row.access_token
|
||||
|
||||
const res = await fetch(`${row.url}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: row.refresh_token,
|
||||
}).toString(),
|
||||
})
|
||||
|
||||
if (!res.ok) return
|
||||
|
||||
const json = (await res.json()) as {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
}
|
||||
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(ControlAccountTable)
|
||||
.set({
|
||||
access_token: json.access_token,
|
||||
refresh_token: json.refresh_token ?? row.refresh_token,
|
||||
token_expiry: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined,
|
||||
})
|
||||
.where(and(eq(ControlAccountTable.email, row.email), eq(ControlAccountTable.url, row.url)))
|
||||
.run(),
|
||||
)
|
||||
|
||||
return json.access_token
|
||||
}
|
||||
}
|
||||
4
packages/opencode/src/effect/runtime.ts
Normal file
4
packages/opencode/src/effect/runtime.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { ManagedRuntime } from "effect"
|
||||
import { AccountService } from "@/account/service"
|
||||
|
||||
export const runtime = ManagedRuntime.make(AccountService.defaultLayer)
|
||||
@@ -1,6 +1,5 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import z from "zod"
|
||||
import { $ } from "bun"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
@@ -11,6 +10,7 @@ import { Instance } from "../project/instance"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { Global } from "../global"
|
||||
import { git } from "@/util/git"
|
||||
|
||||
export namespace File {
|
||||
const log = Log.create({ service: "file" })
|
||||
@@ -418,11 +418,11 @@ export namespace File {
|
||||
const project = Instance.project
|
||||
if (project.vcs !== "git") return []
|
||||
|
||||
const diffOutput = await $`git -c core.fsmonitor=false -c core.quotepath=false diff --numstat HEAD`
|
||||
.cwd(Instance.directory)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
const diffOutput = (
|
||||
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
||||
cwd: Instance.directory,
|
||||
})
|
||||
).text()
|
||||
|
||||
const changedFiles: Info[] = []
|
||||
|
||||
@@ -439,12 +439,14 @@ export namespace File {
|
||||
}
|
||||
}
|
||||
|
||||
const untrackedOutput =
|
||||
await $`git -c core.fsmonitor=false -c core.quotepath=false ls-files --others --exclude-standard`
|
||||
.cwd(Instance.directory)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
const untrackedOutput = (
|
||||
await git(
|
||||
["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard"],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
},
|
||||
)
|
||||
).text()
|
||||
|
||||
if (untrackedOutput.trim()) {
|
||||
const untrackedFiles = untrackedOutput.trim().split("\n")
|
||||
@@ -465,12 +467,14 @@ export namespace File {
|
||||
}
|
||||
|
||||
// Get deleted files
|
||||
const deletedOutput =
|
||||
await $`git -c core.fsmonitor=false -c core.quotepath=false diff --name-only --diff-filter=D HEAD`
|
||||
.cwd(Instance.directory)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
const deletedOutput = (
|
||||
await git(
|
||||
["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--name-only", "--diff-filter=D", "HEAD"],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
},
|
||||
)
|
||||
).text()
|
||||
|
||||
if (deletedOutput.trim()) {
|
||||
const deletedFiles = deletedOutput.trim().split("\n")
|
||||
@@ -541,16 +545,14 @@ export namespace File {
|
||||
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
||||
|
||||
if (project.vcs === "git") {
|
||||
let diff = await $`git -c core.fsmonitor=false diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
|
||||
let diff = (await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })).text()
|
||||
if (!diff.trim()) {
|
||||
diff = await $`git -c core.fsmonitor=false diff --staged ${file}`
|
||||
.cwd(Instance.directory)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
diff = (
|
||||
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: Instance.directory })
|
||||
).text()
|
||||
}
|
||||
if (diff.trim()) {
|
||||
const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text()
|
||||
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||
context: Infinity,
|
||||
ignoreWhitespace: true,
|
||||
|
||||
@@ -5,7 +5,7 @@ import fs from "fs/promises"
|
||||
import z from "zod"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { $ } from "bun"
|
||||
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
@@ -338,7 +338,7 @@ export namespace Ripgrep {
|
||||
limit?: number
|
||||
follow?: boolean
|
||||
}) {
|
||||
const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
|
||||
const args = [`${await filepath()}`, "--json", "--hidden", "--glob=!.git/*"]
|
||||
if (input.follow) args.push("--follow")
|
||||
|
||||
if (input.glob) {
|
||||
@@ -354,14 +354,16 @@ export namespace Ripgrep {
|
||||
args.push("--")
|
||||
args.push(input.pattern)
|
||||
|
||||
const command = args.join(" ")
|
||||
const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow()
|
||||
if (result.exitCode !== 0) {
|
||||
const result = await Process.text(args, {
|
||||
cwd: input.cwd,
|
||||
nothrow: true,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
const lines = result.text().trim().split(/\r?\n/).filter(Boolean)
|
||||
const lines = result.text.trim().split(/\r?\n/).filter(Boolean)
|
||||
// Parse JSON lines from ripgrep output
|
||||
|
||||
return lines
|
||||
|
||||
@@ -11,9 +11,9 @@ import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { $ } from "bun"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { readdir } from "fs/promises"
|
||||
import { git } from "@/util/git"
|
||||
|
||||
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||
|
||||
@@ -88,13 +88,10 @@ export namespace FileWatcher {
|
||||
}
|
||||
|
||||
if (Instance.project.vcs === "git") {
|
||||
const vcsDir = await $`git rev-parse --git-dir`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.cwd(Instance.worktree)
|
||||
.text()
|
||||
.then((x) => path.resolve(Instance.worktree, x.trim()))
|
||||
.catch(() => undefined)
|
||||
const result = await git(["rev-parse", "--git-dir"], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
const vcsDir = result.exitCode === 0 ? path.resolve(Instance.worktree, result.text().trim()) : undefined
|
||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||
const gitDirContents = await readdir(vcsDir).catch(() => [])
|
||||
const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")
|
||||
|
||||
@@ -57,6 +57,7 @@ export namespace Flag {
|
||||
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
|
||||
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
|
||||
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
|
||||
export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES")
|
||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
||||
|
||||
@@ -3,7 +3,8 @@ import { hideBin } from "yargs/helpers"
|
||||
import { RunCommand } from "./cli/cmd/run"
|
||||
import { GenerateCommand } from "./cli/cmd/generate"
|
||||
import { Log } from "./util/log"
|
||||
import { AuthCommand } from "./cli/cmd/auth"
|
||||
import { LoginCommand, LogoutCommand, SwitchCommand, OrgsCommand } from "./cli/cmd/account"
|
||||
import { ProvidersCommand } from "./cli/cmd/providers"
|
||||
import { AgentCommand } from "./cli/cmd/agent"
|
||||
import { UpgradeCommand } from "./cli/cmd/upgrade"
|
||||
import { UninstallCommand } from "./cli/cmd/uninstall"
|
||||
@@ -134,7 +135,11 @@ let cli = yargs(hideBin(process.argv))
|
||||
.command(RunCommand)
|
||||
.command(GenerateCommand)
|
||||
.command(DebugCommand)
|
||||
.command(AuthCommand)
|
||||
.command(LoginCommand)
|
||||
.command(LogoutCommand)
|
||||
.command(SwitchCommand)
|
||||
.command(OrgsCommand)
|
||||
.command(ProvidersCommand)
|
||||
.command(AgentCommand)
|
||||
.command(UpgradeCommand)
|
||||
.command(UninstallCommand)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import path from "path"
|
||||
import { $ } from "bun"
|
||||
import z from "zod"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Log } from "../util/log"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Process } from "@/util/process"
|
||||
import { buffer } from "node:stream/consumers"
|
||||
|
||||
declare global {
|
||||
const OPENCODE_VERSION: string
|
||||
@@ -15,6 +16,38 @@ declare global {
|
||||
export namespace Installation {
|
||||
const log = Log.create({ service: "installation" })
|
||||
|
||||
async function text(cmd: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) {
|
||||
return Process.text(cmd, {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
nothrow: true,
|
||||
}).then((x) => x.text)
|
||||
}
|
||||
|
||||
async function upgradeCurl(target: string) {
|
||||
const body = await fetch("https://opencode.ai/install").then((res) => {
|
||||
if (!res.ok) throw new Error(res.statusText)
|
||||
return res.text()
|
||||
})
|
||||
const proc = Process.spawn(["bash"], {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
VERSION: target,
|
||||
},
|
||||
})
|
||||
if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
|
||||
proc.stdin.end(body)
|
||||
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
|
||||
return {
|
||||
code,
|
||||
stdout,
|
||||
stderr,
|
||||
}
|
||||
}
|
||||
|
||||
export type Method = Awaited<ReturnType<typeof method>>
|
||||
|
||||
export const Event = {
|
||||
@@ -65,31 +98,31 @@ export namespace Installation {
|
||||
const checks = [
|
||||
{
|
||||
name: "npm" as const,
|
||||
command: () => $`npm list -g --depth=0`.throws(false).quiet().text(),
|
||||
command: () => text(["npm", "list", "-g", "--depth=0"]),
|
||||
},
|
||||
{
|
||||
name: "yarn" as const,
|
||||
command: () => $`yarn global list`.throws(false).quiet().text(),
|
||||
command: () => text(["yarn", "global", "list"]),
|
||||
},
|
||||
{
|
||||
name: "pnpm" as const,
|
||||
command: () => $`pnpm list -g --depth=0`.throws(false).quiet().text(),
|
||||
command: () => text(["pnpm", "list", "-g", "--depth=0"]),
|
||||
},
|
||||
{
|
||||
name: "bun" as const,
|
||||
command: () => $`bun pm ls -g`.throws(false).quiet().text(),
|
||||
command: () => text(["bun", "pm", "ls", "-g"]),
|
||||
},
|
||||
{
|
||||
name: "brew" as const,
|
||||
command: () => $`brew list --formula opencode`.throws(false).quiet().text(),
|
||||
command: () => text(["brew", "list", "--formula", "opencode"]),
|
||||
},
|
||||
{
|
||||
name: "scoop" as const,
|
||||
command: () => $`scoop list opencode`.throws(false).quiet().text(),
|
||||
command: () => text(["scoop", "list", "opencode"]),
|
||||
},
|
||||
{
|
||||
name: "choco" as const,
|
||||
command: () => $`choco list --limit-output opencode`.throws(false).quiet().text(),
|
||||
command: () => text(["choco", "list", "--limit-output", "opencode"]),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -121,61 +154,70 @@ export namespace Installation {
|
||||
)
|
||||
|
||||
async function getBrewFormula() {
|
||||
const tapFormula = await $`brew list --formula anomalyco/tap/opencode`.throws(false).quiet().text()
|
||||
const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
|
||||
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
|
||||
const coreFormula = await $`brew list --formula opencode`.throws(false).quiet().text()
|
||||
const coreFormula = await text(["brew", "list", "--formula", "opencode"])
|
||||
if (coreFormula.includes("opencode")) return "opencode"
|
||||
return "opencode"
|
||||
}
|
||||
|
||||
export async function upgrade(method: Method, target: string) {
|
||||
let cmd
|
||||
let result: Awaited<ReturnType<typeof upgradeCurl>> | undefined
|
||||
switch (method) {
|
||||
case "curl":
|
||||
cmd = $`curl -fsSL https://opencode.ai/install | bash`.env({
|
||||
...process.env,
|
||||
VERSION: target,
|
||||
})
|
||||
result = await upgradeCurl(target)
|
||||
break
|
||||
case "npm":
|
||||
cmd = $`npm install -g opencode-ai@${target}`
|
||||
result = await Process.run(["npm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
|
||||
break
|
||||
case "pnpm":
|
||||
cmd = $`pnpm install -g opencode-ai@${target}`
|
||||
result = await Process.run(["pnpm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
|
||||
break
|
||||
case "bun":
|
||||
cmd = $`bun install -g opencode-ai@${target}`
|
||||
result = await Process.run(["bun", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
|
||||
break
|
||||
case "brew": {
|
||||
const formula = await getBrewFormula()
|
||||
if (formula.includes("/")) {
|
||||
cmd =
|
||||
$`brew tap anomalyco/tap && cd "$(brew --repo anomalyco/tap)" && git pull --ff-only && brew upgrade ${formula}`.env(
|
||||
{
|
||||
HOMEBREW_NO_AUTO_UPDATE: "1",
|
||||
...process.env,
|
||||
},
|
||||
)
|
||||
break
|
||||
}
|
||||
cmd = $`brew upgrade ${formula}`.env({
|
||||
const env = {
|
||||
HOMEBREW_NO_AUTO_UPDATE: "1",
|
||||
...process.env,
|
||||
})
|
||||
}
|
||||
if (formula.includes("/")) {
|
||||
const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true })
|
||||
if (tap.code !== 0) {
|
||||
result = tap
|
||||
break
|
||||
}
|
||||
const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true })
|
||||
if (repo.code !== 0) {
|
||||
result = repo
|
||||
break
|
||||
}
|
||||
const dir = repo.text.trim()
|
||||
if (dir) {
|
||||
const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: dir, env, nothrow: true })
|
||||
if (pull.code !== 0) {
|
||||
result = pull
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true })
|
||||
break
|
||||
}
|
||||
|
||||
case "choco":
|
||||
cmd = $`echo Y | choco upgrade opencode --version=${target}`
|
||||
result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true })
|
||||
break
|
||||
case "scoop":
|
||||
cmd = $`scoop install opencode@${target}`
|
||||
result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true })
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unknown method: ${method}`)
|
||||
}
|
||||
const result = await cmd.quiet().throws(false)
|
||||
if (result.exitCode !== 0) {
|
||||
const stderr = method === "choco" ? "not running from an elevated command shell" : result.stderr.toString("utf8")
|
||||
if (!result || result.code !== 0) {
|
||||
const stderr =
|
||||
method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || ""
|
||||
throw new UpgradeFailedError({
|
||||
stderr: stderr,
|
||||
})
|
||||
@@ -186,7 +228,7 @@ export namespace Installation {
|
||||
stdout: result.stdout.toString(),
|
||||
stderr: result.stderr.toString(),
|
||||
})
|
||||
await $`${process.execPath} --version`.nothrow().quiet().text()
|
||||
await Process.text([process.execPath, "--version"], { nothrow: true })
|
||||
}
|
||||
|
||||
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
||||
@@ -199,7 +241,7 @@ export namespace Installation {
|
||||
if (detectedMethod === "brew") {
|
||||
const formula = await getBrewFormula()
|
||||
if (formula.includes("/")) {
|
||||
const infoJson = await $`brew info --json=v2 ${formula}`.quiet().text()
|
||||
const infoJson = await text(["brew", "info", "--json=v2", formula])
|
||||
const info = JSON.parse(infoJson)
|
||||
const version = info.formulae?.[0]?.versions?.stable
|
||||
if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`)
|
||||
@@ -215,7 +257,7 @@ export namespace Installation {
|
||||
|
||||
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
|
||||
const registry = await iife(async () => {
|
||||
const r = (await $`npm config get registry`.quiet().nothrow().text()).trim()
|
||||
const r = (await text(["npm", "config", "get", "registry"])).trim()
|
||||
const reg = r || "https://registry.npmjs.org"
|
||||
return reg.endsWith("/") ? reg.slice(0, -1) : reg
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import os from "os"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import { BunProc } from "../bun"
|
||||
import { $ } from "bun"
|
||||
import { text } from "node:stream/consumers"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
@@ -13,6 +12,7 @@ import { Flag } from "../flag/flag"
|
||||
import { Archive } from "../util/archive"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { Module } from "@opencode-ai/util/module"
|
||||
|
||||
export namespace LSPServer {
|
||||
const log = Log.create({ service: "lsp.server" })
|
||||
@@ -21,6 +21,8 @@ export namespace LSPServer {
|
||||
.stat(p)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
const run = (cmd: string[], opts: Process.RunOptions = {}) => Process.run(cmd, { ...opts, nothrow: true })
|
||||
const output = (cmd: string[], opts: Process.RunOptions = {}) => Process.text(cmd, { ...opts, nothrow: true })
|
||||
|
||||
export interface Handle {
|
||||
process: ChildProcessWithoutNullStreams
|
||||
@@ -97,7 +99,7 @@ export namespace LSPServer {
|
||||
),
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
|
||||
async spawn(root) {
|
||||
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(() => {})
|
||||
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"], {
|
||||
@@ -172,7 +174,7 @@ export namespace LSPServer {
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
|
||||
async spawn(root) {
|
||||
const eslint = await Bun.resolve("eslint", Instance.directory).catch(() => {})
|
||||
const eslint = Module.resolve("eslint", Instance.directory)
|
||||
if (!eslint) return
|
||||
log.info("spawning eslint server")
|
||||
const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
|
||||
@@ -205,8 +207,8 @@ export namespace LSPServer {
|
||||
await fs.rename(extractedPath, finalPath)
|
||||
|
||||
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
|
||||
await $`${npmCmd} install`.cwd(finalPath).quiet()
|
||||
await $`${npmCmd} run compile`.cwd(finalPath).quiet()
|
||||
await Process.run([npmCmd, "install"], { cwd: finalPath })
|
||||
await Process.run([npmCmd, "run", "compile"], { cwd: finalPath })
|
||||
|
||||
log.info("installed VS Code ESLint server", { serverPath })
|
||||
}
|
||||
@@ -340,7 +342,7 @@ export namespace LSPServer {
|
||||
let args = ["lsp-proxy", "--stdio"]
|
||||
|
||||
if (!bin) {
|
||||
const resolved = await Bun.resolve("biome", root).catch(() => undefined)
|
||||
const resolved = Module.resolve("biome", root)
|
||||
if (!resolved) return
|
||||
bin = BunProc.which()
|
||||
args = ["x", "biome", "lsp-proxy", "--stdio"]
|
||||
@@ -602,10 +604,11 @@ export namespace LSPServer {
|
||||
recursive: true,
|
||||
})
|
||||
|
||||
await $`mix deps.get && mix compile && mix elixir_ls.release2 -o release`
|
||||
.quiet()
|
||||
.cwd(path.join(Global.Path.bin, "elixir-ls-master"))
|
||||
.env({ MIX_ENV: "prod", ...process.env })
|
||||
const cwd = path.join(Global.Path.bin, "elixir-ls-master")
|
||||
const env = { MIX_ENV: "prod", ...process.env }
|
||||
await Process.run(["mix", "deps.get"], { cwd, env })
|
||||
await Process.run(["mix", "compile"], { cwd, env })
|
||||
await Process.run(["mix", "elixir_ls.release2", "-o", "release"], { cwd, env })
|
||||
|
||||
log.info(`installed elixir-ls`, {
|
||||
path: elixirLsPath,
|
||||
@@ -706,7 +709,7 @@ export namespace LSPServer {
|
||||
})
|
||||
if (!ok) return
|
||||
} else {
|
||||
await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow()
|
||||
await run(["tar", "-xf", tempPath], { cwd: Global.Path.bin })
|
||||
}
|
||||
|
||||
await fs.rm(tempPath, { force: true })
|
||||
@@ -719,7 +722,7 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
await $`chmod +x ${bin}`.quiet().nothrow()
|
||||
await fs.chmod(bin, 0o755).catch(() => {})
|
||||
}
|
||||
|
||||
log.info(`installed zls`, { bin })
|
||||
@@ -831,11 +834,11 @@ export namespace LSPServer {
|
||||
// This is specific to macOS where sourcekit-lsp is typically installed with Xcode
|
||||
if (!which("xcrun")) return
|
||||
|
||||
const lspLoc = await $`xcrun --find sourcekit-lsp`.quiet().nothrow()
|
||||
const lspLoc = await output(["xcrun", "--find", "sourcekit-lsp"])
|
||||
|
||||
if (lspLoc.exitCode !== 0) return
|
||||
if (lspLoc.code !== 0) return
|
||||
|
||||
const bin = lspLoc.text().trim()
|
||||
const bin = lspLoc.text.trim()
|
||||
|
||||
return {
|
||||
process: spawn(bin, {
|
||||
@@ -1010,7 +1013,7 @@ export namespace LSPServer {
|
||||
if (!ok) return
|
||||
}
|
||||
if (tar) {
|
||||
await $`tar -xf ${archive}`.cwd(Global.Path.bin).quiet().nothrow()
|
||||
await run(["tar", "-xf", archive], { cwd: Global.Path.bin })
|
||||
}
|
||||
await fs.rm(archive, { force: true })
|
||||
|
||||
@@ -1021,7 +1024,7 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
await $`chmod +x ${bin}`.quiet().nothrow()
|
||||
await fs.chmod(bin, 0o755).catch(() => {})
|
||||
}
|
||||
|
||||
await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {})
|
||||
@@ -1082,7 +1085,7 @@ export namespace LSPServer {
|
||||
extensions: [".astro"],
|
||||
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
|
||||
async spawn(root) {
|
||||
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", Instance.directory).catch(() => {})
|
||||
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
|
||||
if (!tsserver) {
|
||||
log.info("typescript not found, required for Astro language server")
|
||||
return
|
||||
@@ -1130,7 +1133,30 @@ export namespace LSPServer {
|
||||
|
||||
export const JDTLS: Info = {
|
||||
id: "jdtls",
|
||||
root: NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"]),
|
||||
root: async (file) => {
|
||||
// Without exclusions, NearestRoot defaults to instance directory so we can't
|
||||
// distinguish between a) no project found and b) project found at instance dir.
|
||||
// So we can't choose the root from (potential) monorepo markers first.
|
||||
// Look for potential subproject markers first while excluding potential monorepo markers.
|
||||
const settingsMarkers = ["settings.gradle", "settings.gradle.kts"]
|
||||
const gradleMarkers = ["gradlew", "gradlew.bat"]
|
||||
const exclusionsForMonorepos = gradleMarkers.concat(settingsMarkers)
|
||||
|
||||
const [projectRoot, wrapperRoot, settingsRoot] = await Promise.all([
|
||||
NearestRoot(
|
||||
["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"],
|
||||
exclusionsForMonorepos,
|
||||
)(file),
|
||||
NearestRoot(gradleMarkers, settingsMarkers)(file),
|
||||
NearestRoot(settingsMarkers)(file),
|
||||
])
|
||||
|
||||
// If projectRoot is undefined we know we are in a monorepo or no project at all.
|
||||
// So can safely fall through to the other roots
|
||||
if (projectRoot) return projectRoot
|
||||
if (wrapperRoot) return wrapperRoot
|
||||
if (settingsRoot) return settingsRoot
|
||||
},
|
||||
extensions: [".java"],
|
||||
async spawn(root) {
|
||||
const java = which("java")
|
||||
@@ -1138,13 +1164,10 @@ export namespace LSPServer {
|
||||
log.error("Java 21 or newer is required to run the JDTLS. Please install it first.")
|
||||
return
|
||||
}
|
||||
const javaMajorVersion = await $`java -version`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.then(({ stderr }) => {
|
||||
const m = /"(\d+)\.\d+\.\d+"/.exec(stderr.toString())
|
||||
return !m ? undefined : parseInt(m[1])
|
||||
})
|
||||
const javaMajorVersion = await run(["java", "-version"]).then((result) => {
|
||||
const m = /"(\d+)\.\d+\.\d+"/.exec(result.stderr.toString())
|
||||
return !m ? undefined : parseInt(m[1])
|
||||
})
|
||||
if (javaMajorVersion == null || javaMajorVersion < 21) {
|
||||
log.error("JDTLS requires at least Java 21.")
|
||||
return
|
||||
@@ -1161,27 +1184,27 @@ export namespace LSPServer {
|
||||
const archiveName = "release.tar.gz"
|
||||
|
||||
log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath })
|
||||
const curlResult = await $`curl -L -o ${archiveName} '${releaseURL}'`.cwd(distPath).quiet().nothrow()
|
||||
if (curlResult.exitCode !== 0) {
|
||||
log.error("Failed to download JDTLS", { exitCode: curlResult.exitCode, stderr: curlResult.stderr.toString() })
|
||||
const download = await fetch(releaseURL)
|
||||
if (!download.ok || !download.body) {
|
||||
log.error("Failed to download JDTLS", { status: download.status, statusText: download.statusText })
|
||||
return
|
||||
}
|
||||
await Filesystem.writeStream(path.join(distPath, archiveName), download.body)
|
||||
|
||||
log.info("Extracting JDTLS archive")
|
||||
const tarResult = await $`tar -xzf ${archiveName}`.cwd(distPath).quiet().nothrow()
|
||||
if (tarResult.exitCode !== 0) {
|
||||
log.error("Failed to extract JDTLS", { exitCode: tarResult.exitCode, stderr: tarResult.stderr.toString() })
|
||||
const tarResult = await run(["tar", "-xzf", archiveName], { cwd: distPath })
|
||||
if (tarResult.code !== 0) {
|
||||
log.error("Failed to extract JDTLS", { exitCode: tarResult.code, stderr: tarResult.stderr.toString() })
|
||||
return
|
||||
}
|
||||
|
||||
await fs.rm(path.join(distPath, archiveName), { force: true })
|
||||
log.info("JDTLS download and extraction completed")
|
||||
}
|
||||
const jarFileName = await $`ls org.eclipse.equinox.launcher_*.jar`
|
||||
.cwd(launcherDir)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.then(({ stdout }) => stdout.toString().trim())
|
||||
const jarFileName =
|
||||
(await fs.readdir(launcherDir).catch(() => []))
|
||||
.find((item) => /^org\.eclipse\.equinox\.launcher_.*\.jar$/.test(item))
|
||||
?.trim() ?? ""
|
||||
const launcherJar = path.join(launcherDir, jarFileName)
|
||||
if (!(await pathExists(launcherJar))) {
|
||||
log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`)
|
||||
@@ -1294,7 +1317,15 @@ export namespace LSPServer {
|
||||
|
||||
await fs.mkdir(distPath, { recursive: true })
|
||||
const archivePath = path.join(distPath, "kotlin-ls.zip")
|
||||
await $`curl -L -o '${archivePath}' '${releaseURL}'`.quiet().nothrow()
|
||||
const download = await fetch(releaseURL)
|
||||
if (!download.ok || !download.body) {
|
||||
log.error("Failed to download Kotlin Language Server", {
|
||||
status: download.status,
|
||||
statusText: download.statusText,
|
||||
})
|
||||
return
|
||||
}
|
||||
await Filesystem.writeStream(archivePath, download.body)
|
||||
const ok = await Archive.extractZip(archivePath, distPath)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
@@ -1304,7 +1335,7 @@ export namespace LSPServer {
|
||||
if (!ok) return
|
||||
await fs.rm(archivePath, { force: true })
|
||||
if (process.platform !== "win32") {
|
||||
await $`chmod +x ${launcherScript}`.quiet().nothrow()
|
||||
await fs.chmod(launcherScript, 0o755).catch(() => {})
|
||||
}
|
||||
log.info("Installed Kotlin Language Server", { path: launcherScript })
|
||||
}
|
||||
@@ -1468,10 +1499,9 @@ export namespace LSPServer {
|
||||
})
|
||||
if (!ok) return
|
||||
} else {
|
||||
const ok = await $`tar -xzf ${tempPath} -C ${installDir}`
|
||||
.quiet()
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
const ok = await run(["tar", "-xzf", tempPath, "-C", installDir])
|
||||
.then((result) => result.code === 0)
|
||||
.catch((error: unknown) => {
|
||||
log.error("Failed to extract lua-language-server archive", { error })
|
||||
return false
|
||||
})
|
||||
@@ -1489,11 +1519,15 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
const ok = await $`chmod +x ${bin}`.quiet().catch((error) => {
|
||||
log.error("Failed to set executable permission for lua-language-server binary", {
|
||||
error,
|
||||
const ok = await fs
|
||||
.chmod(bin, 0o755)
|
||||
.then(() => true)
|
||||
.catch((error: unknown) => {
|
||||
log.error("Failed to set executable permission for lua-language-server binary", {
|
||||
error,
|
||||
})
|
||||
return false
|
||||
})
|
||||
})
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
@@ -1707,7 +1741,7 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
await $`chmod +x ${bin}`.quiet().nothrow()
|
||||
await fs.chmod(bin, 0o755).catch(() => {})
|
||||
}
|
||||
|
||||
log.info(`installed terraform-ls`, { bin })
|
||||
@@ -1790,7 +1824,7 @@ export namespace LSPServer {
|
||||
if (!ok) return
|
||||
}
|
||||
if (ext === "tar.gz") {
|
||||
await $`tar -xzf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow()
|
||||
await run(["tar", "-xzf", tempPath], { cwd: Global.Path.bin })
|
||||
}
|
||||
|
||||
await fs.rm(tempPath, { force: true })
|
||||
@@ -1803,7 +1837,7 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
await $`chmod +x ${bin}`.quiet().nothrow()
|
||||
await fs.chmod(bin, 0o755).catch(() => {})
|
||||
}
|
||||
|
||||
log.info("installed texlab", { bin })
|
||||
@@ -1995,7 +2029,7 @@ export namespace LSPServer {
|
||||
})
|
||||
if (!ok) return
|
||||
} else {
|
||||
await $`tar -xzf ${tempPath} --strip-components=1`.cwd(Global.Path.bin).quiet().nothrow()
|
||||
await run(["tar", "-xzf", tempPath, "--strip-components=1"], { cwd: Global.Path.bin })
|
||||
}
|
||||
|
||||
await fs.rm(tempPath, { force: true })
|
||||
@@ -2008,7 +2042,7 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
await $`chmod +x ${bin}`.quiet().nothrow()
|
||||
await fs.chmod(bin, 0o755).catch(() => {})
|
||||
}
|
||||
|
||||
log.info("installed tinymist", { bin })
|
||||
|
||||
@@ -25,8 +25,7 @@ export namespace Plugin {
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: "http://localhost:4096",
|
||||
directory: Instance.directory,
|
||||
// @ts-ignore - fetch type incompatibility
|
||||
fetch: async (...args) => Server.App().fetch(...args),
|
||||
fetch: async (...args) => Server.Default().fetch(...args),
|
||||
})
|
||||
const config = await Config.get()
|
||||
const hooks: Hooks[] = []
|
||||
@@ -35,7 +34,9 @@ export namespace Plugin {
|
||||
project: Instance.project,
|
||||
worktree: Instance.worktree,
|
||||
directory: Instance.directory,
|
||||
serverUrl: Server.url(),
|
||||
get serverUrl(): URL {
|
||||
throw new Error("Server URL is no longer supported in plugins")
|
||||
},
|
||||
$: Bun.$,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Log } from "@/util/log"
|
||||
import { Instance } from "./instance"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { git } from "@/util/git"
|
||||
|
||||
const log = Log.create({ service: "vcs" })
|
||||
|
||||
@@ -29,13 +29,13 @@ export namespace Vcs {
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
async function currentBranch() {
|
||||
return $`git rev-parse --abbrev-ref HEAD`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.cwd(Instance.worktree)
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
.catch(() => undefined)
|
||||
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
if (result.exitCode !== 0) return
|
||||
const text = result.text().trim()
|
||||
if (!text) return
|
||||
return text
|
||||
}
|
||||
|
||||
const state = Instance.state(
|
||||
|
||||
@@ -480,6 +480,7 @@ export namespace Provider {
|
||||
|
||||
const aiGatewayHeaders = {
|
||||
"User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||
"anthropic-beta": "context-1m-2025-08-07",
|
||||
...(providerConfig?.options?.aiGatewayHeaders || {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -440,7 +440,9 @@ export namespace ProviderTransform {
|
||||
const copilotEfforts = iife(() => {
|
||||
if (id.includes("5.1-codex-max") || id.includes("5.2") || id.includes("5.3"))
|
||||
return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
|
||||
return WIDELY_SUPPORTED_EFFORTS
|
||||
const arr = [...WIDELY_SUPPORTED_EFFORTS]
|
||||
if (id.includes("gpt-5") && model.release_date >= "2025-12-04") arr.push("xhigh")
|
||||
return arr
|
||||
})
|
||||
return Object.fromEntries(
|
||||
copilotEfforts.map((effort) => [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,8 @@ import { Flag } from "../flag/flag"
|
||||
import { ulid } from "ulid"
|
||||
import { spawn } from "child_process"
|
||||
import { Command } from "../command"
|
||||
import { $, fileURLToPath, pathToFileURL } from "bun"
|
||||
import { $ } from "bun"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
import { SessionSummary } from "./summary"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { Account } from "@/account"
|
||||
import { Config } from "@/config/config"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Session } from "@/session"
|
||||
@@ -11,8 +12,51 @@ import type * as SDK from "@opencode-ai/sdk/v2"
|
||||
export namespace ShareNext {
|
||||
const log = Log.create({ service: "share-next" })
|
||||
|
||||
type ApiEndpoints = {
|
||||
create: string
|
||||
sync: (shareId: string) => string
|
||||
remove: (shareId: string) => string
|
||||
data: (shareId: string) => string
|
||||
}
|
||||
|
||||
function apiEndpoints(resource: string): ApiEndpoints {
|
||||
return {
|
||||
create: `/api/${resource}`,
|
||||
sync: (shareId) => `/api/${resource}/${shareId}/sync`,
|
||||
remove: (shareId) => `/api/${resource}/${shareId}`,
|
||||
data: (shareId) => `/api/${resource}/${shareId}/data`,
|
||||
}
|
||||
}
|
||||
|
||||
const legacyApi = apiEndpoints("share")
|
||||
const controlApi = apiEndpoints("shares")
|
||||
|
||||
export async function url() {
|
||||
return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
|
||||
const req = await request()
|
||||
return req.baseUrl
|
||||
}
|
||||
|
||||
export async function request(): Promise<{
|
||||
headers: Record<string, string>
|
||||
api: ApiEndpoints
|
||||
baseUrl: string
|
||||
}> {
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
const active = Account.active()
|
||||
if (!active?.active_org_id) {
|
||||
const baseUrl = await Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
|
||||
return { headers, api: legacyApi, baseUrl }
|
||||
}
|
||||
|
||||
const token = await Account.token(active.id)
|
||||
if (!token) {
|
||||
throw new Error("No active OpenControl token available for sharing")
|
||||
}
|
||||
|
||||
headers["authorization"] = `Bearer ${token}`
|
||||
headers["x-org-id"] = active.active_org_id
|
||||
return { headers, api: controlApi, baseUrl: active.url }
|
||||
}
|
||||
|
||||
const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
|
||||
@@ -68,15 +112,20 @@ export namespace ShareNext {
|
||||
export async function create(sessionID: string) {
|
||||
if (disabled) return { id: "", url: "", secret: "" }
|
||||
log.info("creating share", { sessionID })
|
||||
const result = await fetch(`${await url()}/api/share`, {
|
||||
const req = await request()
|
||||
const response = await fetch(`${req.baseUrl}${req.api.create}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { ...req.headers, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionID: sessionID }),
|
||||
})
|
||||
.then((x) => x.json())
|
||||
.then((x) => x as { id: string; url: string; secret: string })
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => response.statusText)
|
||||
throw new Error(`Failed to create share (${response.status}): ${message || response.statusText}`)
|
||||
}
|
||||
|
||||
const result = (await response.json()) as { id: string; url: string; secret: string }
|
||||
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(SessionShareTable)
|
||||
@@ -159,16 +208,19 @@ export namespace ShareNext {
|
||||
const share = get(sessionID)
|
||||
if (!share) return
|
||||
|
||||
await fetch(`${await url()}/api/share/${share.id}/sync`, {
|
||||
const req = await request()
|
||||
const response = await fetch(`${req.baseUrl}${req.api.sync(share.id)}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { ...req.headers, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
secret: share.secret,
|
||||
data: Array.from(queued.data.values()),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
log.warn("failed to sync share", { sessionID, shareID: share.id, status: response.status })
|
||||
}
|
||||
}, 1000)
|
||||
queue.set(sessionID, { timeout, data: dataMap })
|
||||
}
|
||||
@@ -178,15 +230,21 @@ export namespace ShareNext {
|
||||
log.info("removing share", { sessionID })
|
||||
const share = get(sessionID)
|
||||
if (!share) return
|
||||
await fetch(`${await url()}/api/share/${share.id}`, {
|
||||
|
||||
const req = await request()
|
||||
const response = await fetch(`${req.baseUrl}${req.api.remove(share.id)}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { ...req.headers, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
secret: share.secret,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => response.statusText)
|
||||
throw new Error(`Failed to remove share (${response.status}): ${message || response.statusText}`)
|
||||
}
|
||||
|
||||
Database.use((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
@@ -9,12 +8,17 @@ import z from "zod"
|
||||
import { Config } from "../config/config"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Scheduler } from "../scheduler"
|
||||
import { Process } from "@/util/process"
|
||||
|
||||
export namespace Snapshot {
|
||||
const log = Log.create({ service: "snapshot" })
|
||||
const hour = 60 * 60 * 1000
|
||||
const prune = "7.days"
|
||||
|
||||
function args(git: string, cmd: string[]) {
|
||||
return ["--git-dir", git, "--work-tree", Instance.worktree, ...cmd]
|
||||
}
|
||||
|
||||
export function init() {
|
||||
Scheduler.register({
|
||||
id: "snapshot.cleanup",
|
||||
@@ -34,13 +38,13 @@ export namespace Snapshot {
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!exists) return
|
||||
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
.nothrow()
|
||||
if (result.exitCode !== 0) {
|
||||
const result = await Process.run(["git", ...args(git, ["gc", `--prune=${prune}`])], {
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.exitCode,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr.toString(),
|
||||
stdout: result.stdout.toString(),
|
||||
})
|
||||
@@ -55,27 +59,27 @@ export namespace Snapshot {
|
||||
if (cfg.snapshot === false) return
|
||||
const git = gitdir()
|
||||
if (await fs.mkdir(git, { recursive: true })) {
|
||||
await $`git init`
|
||||
.env({
|
||||
await Process.run(["git", "init"], {
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_DIR: git,
|
||||
GIT_WORK_TREE: Instance.worktree,
|
||||
})
|
||||
.quiet()
|
||||
.nothrow()
|
||||
},
|
||||
nothrow: true,
|
||||
})
|
||||
|
||||
// Configure git to not convert line endings on Windows
|
||||
await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow()
|
||||
await $`git --git-dir ${git} config core.longpaths true`.quiet().nothrow()
|
||||
await $`git --git-dir ${git} config core.symlinks true`.quiet().nothrow()
|
||||
await $`git --git-dir ${git} config core.fsmonitor false`.quiet().nothrow()
|
||||
await Process.run(["git", "--git-dir", git, "config", "core.autocrlf", "false"], { nothrow: true })
|
||||
await Process.run(["git", "--git-dir", git, "config", "core.longpaths", "true"], { nothrow: true })
|
||||
await Process.run(["git", "--git-dir", git, "config", "core.symlinks", "true"], { nothrow: true })
|
||||
await Process.run(["git", "--git-dir", git, "config", "core.fsmonitor", "false"], { nothrow: true })
|
||||
log.info("initialized")
|
||||
}
|
||||
await add(git)
|
||||
const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
.nothrow()
|
||||
.text()
|
||||
const hash = await Process.text(["git", ...args(git, ["write-tree"])], {
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
}).then((x) => x.text)
|
||||
log.info("tracking", { hash, cwd: Instance.directory, git })
|
||||
return hash.trim()
|
||||
}
|
||||
@@ -89,19 +93,32 @@ export namespace Snapshot {
|
||||
export async function patch(hash: string): Promise<Patch> {
|
||||
const git = gitdir()
|
||||
await add(git)
|
||||
const result =
|
||||
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
.nothrow()
|
||||
const result = await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
...args(git, ["diff", "--no-ext-diff", "--name-only", hash, "--", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
|
||||
// If git diff fails, return empty patch
|
||||
if (result.exitCode !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.exitCode })
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] }
|
||||
}
|
||||
|
||||
const files = result.text()
|
||||
const files = result.text
|
||||
return {
|
||||
hash,
|
||||
files: files
|
||||
@@ -116,20 +133,37 @@ export namespace Snapshot {
|
||||
export async function restore(snapshot: string) {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const git = gitdir()
|
||||
const result =
|
||||
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f`
|
||||
.quiet()
|
||||
.cwd(Instance.worktree)
|
||||
.nothrow()
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const result = await Process.run(
|
||||
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["read-tree", snapshot])],
|
||||
{
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
if (result.code === 0) {
|
||||
const checkout = await Process.run(
|
||||
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["checkout-index", "-a", "-f"])],
|
||||
{
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
if (checkout.code === 0) return
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.stderr.toString(),
|
||||
stdout: result.stdout.toString(),
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr.toString(),
|
||||
stdout: checkout.stdout.toString(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr.toString(),
|
||||
stdout: result.stdout.toString(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function revert(patches: Patch[]) {
|
||||
@@ -139,19 +173,37 @@ export namespace Snapshot {
|
||||
for (const file of item.files) {
|
||||
if (files.has(file)) continue
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
const result =
|
||||
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}`
|
||||
.quiet()
|
||||
.cwd(Instance.worktree)
|
||||
.nothrow()
|
||||
if (result.exitCode !== 0) {
|
||||
const result = await Process.run(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["checkout", item.hash, "--", file]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
if (result.code !== 0) {
|
||||
const relativePath = path.relative(Instance.worktree, file)
|
||||
const checkTree =
|
||||
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}`
|
||||
.quiet()
|
||||
.cwd(Instance.worktree)
|
||||
.nothrow()
|
||||
if (checkTree.exitCode === 0 && checkTree.text().trim()) {
|
||||
const checkTree = await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["ls-tree", item.hash, "--", relativePath]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
if (checkTree.code === 0 && checkTree.text.trim()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", {
|
||||
file,
|
||||
})
|
||||
@@ -168,23 +220,36 @@ export namespace Snapshot {
|
||||
export async function diff(hash: string) {
|
||||
const git = gitdir()
|
||||
await add(git)
|
||||
const result =
|
||||
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
|
||||
.quiet()
|
||||
.cwd(Instance.worktree)
|
||||
.nothrow()
|
||||
const result = await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
...args(git, ["diff", "--no-ext-diff", hash, "--", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
exitCode: result.exitCode,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr.toString(),
|
||||
stdout: result.stdout.toString(),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
return result.text().trim()
|
||||
return result.text.trim()
|
||||
}
|
||||
|
||||
export const FileDiff = z
|
||||
@@ -205,12 +270,24 @@ export namespace Snapshot {
|
||||
const result: FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
|
||||
const statuses =
|
||||
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
.nothrow()
|
||||
.text()
|
||||
const statuses = await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
...args(git, ["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
},
|
||||
).then((x) => x.text)
|
||||
|
||||
for (const line of statuses.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
@@ -220,26 +297,57 @@ export namespace Snapshot {
|
||||
status.set(file, kind)
|
||||
}
|
||||
|
||||
for await (const line of $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
.nothrow()
|
||||
.lines()) {
|
||||
for (const line of await Process.lines(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
},
|
||||
)) {
|
||||
if (!line) continue
|
||||
const [additions, deletions, file] = line.split("\t")
|
||||
const isBinaryFile = additions === "-" && deletions === "-"
|
||||
const before = isBinaryFile
|
||||
? ""
|
||||
: await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
: await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["show", `${from}:${file}`]),
|
||||
],
|
||||
{ nothrow: true },
|
||||
).then((x) => x.text)
|
||||
const after = isBinaryFile
|
||||
? ""
|
||||
: await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
: await Process.text(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["show", `${to}:${file}`]),
|
||||
],
|
||||
{ nothrow: true },
|
||||
).then((x) => x.text)
|
||||
const added = isBinaryFile ? 0 : parseInt(additions)
|
||||
const deleted = isBinaryFile ? 0 : parseInt(deletions)
|
||||
result.push({
|
||||
@@ -261,10 +369,22 @@ export namespace Snapshot {
|
||||
|
||||
async function add(git: string) {
|
||||
await syncExclude(git)
|
||||
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} add .`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
.nothrow()
|
||||
await Process.run(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
...args(git, ["add", "."]),
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
nothrow: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function syncExclude(git: string) {
|
||||
@@ -281,11 +401,10 @@ export namespace Snapshot {
|
||||
}
|
||||
|
||||
async function excludes() {
|
||||
const file = await $`git rev-parse --path-format=absolute --git-path info/exclude`
|
||||
.quiet()
|
||||
.cwd(Instance.worktree)
|
||||
.nothrow()
|
||||
.text()
|
||||
const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
||||
cwd: Instance.worktree,
|
||||
nothrow: true,
|
||||
}).then((x) => x.text)
|
||||
if (!file.trim()) return
|
||||
const exists = await fs
|
||||
.stat(file.trim())
|
||||
|
||||
@@ -39,7 +39,7 @@ export namespace Database {
|
||||
type Schema = typeof schema
|
||||
export type Transaction = SQLiteTransaction<"sync", void, Schema>
|
||||
|
||||
type Client = SQLiteBunDatabase<Schema>
|
||||
type Client = SQLiteBunDatabase
|
||||
|
||||
type Journal = { sql: string; timestamp: number; name: string }[]
|
||||
|
||||
@@ -93,7 +93,7 @@ export namespace Database {
|
||||
sqlite.run("PRAGMA foreign_keys = ON")
|
||||
sqlite.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
|
||||
const db = drizzle({ client: sqlite, schema })
|
||||
const db = drizzle({ client: sqlite })
|
||||
|
||||
// Apply schema migrations
|
||||
const entries =
|
||||
@@ -124,7 +124,7 @@ export namespace Database {
|
||||
Client.reset()
|
||||
}
|
||||
|
||||
export type TxOrDb = Transaction | Client
|
||||
export type TxOrDb = SQLiteTransaction<"sync", void, any, any> | Client
|
||||
|
||||
const ctx = Context.create<{
|
||||
tx: TxOrDb
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { ControlAccountTable } from "../control/control.sql"
|
||||
export { AccountTable, AccountStateTable, ControlAccountTable } from "../account/account.sql"
|
||||
export { ProjectTable } from "../project/project.sql"
|
||||
export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql"
|
||||
export { SessionShareTable } from "../share/share.sql"
|
||||
export { ProjectTable } from "../project/project.sql"
|
||||
export { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||
|
||||
@@ -5,10 +5,10 @@ import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { Lock } from "../util/lock"
|
||||
import { $ } from "bun"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import z from "zod"
|
||||
import { Glob } from "../util/glob"
|
||||
import { git } from "@/util/git"
|
||||
|
||||
export namespace Storage {
|
||||
const log = Log.create({ service: "storage" })
|
||||
@@ -49,18 +49,15 @@ export namespace Storage {
|
||||
}
|
||||
if (!worktree) continue
|
||||
if (!(await Filesystem.isDir(worktree))) continue
|
||||
const [id] = await $`git rev-list --max-parents=0 --all`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.cwd(worktree)
|
||||
const result = await git(["rev-list", "--max-parents=0", "--all"], {
|
||||
cwd: worktree,
|
||||
})
|
||||
const [id] = result
|
||||
.text()
|
||||
.then((x) =>
|
||||
x
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((x) => x.trim())
|
||||
.toSorted(),
|
||||
)
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((x) => x.trim())
|
||||
.toSorted()
|
||||
if (!id) continue
|
||||
projectID = id
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import { Log } from "../util/log"
|
||||
import { Instance } from "../project/instance"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Language } from "web-tree-sitter"
|
||||
import fs from "fs/promises"
|
||||
|
||||
import { $ } from "bun"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Flag } from "@/flag/flag.ts"
|
||||
@@ -116,12 +116,7 @@ export const BashTool = Tool.define("bash", async () => {
|
||||
if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) {
|
||||
for (const arg of command.slice(1)) {
|
||||
if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue
|
||||
const resolved = await $`realpath ${arg}`
|
||||
.cwd(cwd)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
const resolved = await fs.realpath(path.resolve(cwd, arg)).catch(() => "")
|
||||
log.info("resolved path", { arg, resolved })
|
||||
if (resolved) {
|
||||
const normalized =
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import { Process } from "./process"
|
||||
|
||||
export namespace Archive {
|
||||
export async function extractZip(zipPath: string, destDir: string) {
|
||||
@@ -8,9 +8,10 @@ export namespace Archive {
|
||||
const winDestDir = path.resolve(destDir)
|
||||
// $global:ProgressPreference suppresses PowerShell's blue progress bar popup
|
||||
const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force`
|
||||
await $`powershell -NoProfile -NonInteractive -Command ${cmd}`.quiet()
|
||||
} else {
|
||||
await $`unzip -o -q ${zipPath} -d ${destDir}`.quiet()
|
||||
await Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd])
|
||||
return
|
||||
}
|
||||
|
||||
await Process.run(["unzip", "-o", "-q", zipPath, "-d", destDir])
|
||||
}
|
||||
}
|
||||
|
||||
11
packages/opencode/src/util/effect-http-client.ts
Normal file
11
packages/opencode/src/util/effect-http-client.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Schedule } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
|
||||
export const withTransientReadRetry = <E, R>(client: HttpClient.HttpClient.With<E, R>) =>
|
||||
client.pipe(
|
||||
HttpClient.retryTransient({
|
||||
retryOn: "errors-and-responses",
|
||||
times: 2,
|
||||
schedule: Schedule.exponential(200).pipe(Schedule.jittered),
|
||||
}),
|
||||
)
|
||||
@@ -25,6 +25,10 @@ export namespace Process {
|
||||
stderr: Buffer
|
||||
}
|
||||
|
||||
export interface TextResult extends Result {
|
||||
text: string
|
||||
}
|
||||
|
||||
export class RunFailedError extends Error {
|
||||
readonly cmd: string[]
|
||||
readonly code: number
|
||||
@@ -114,13 +118,33 @@ export namespace Process {
|
||||
|
||||
if (!proc.stdout || !proc.stderr) throw new Error("Process output not available")
|
||||
|
||||
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
|
||||
const out = {
|
||||
code,
|
||||
stdout,
|
||||
stderr,
|
||||
}
|
||||
const out = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
|
||||
.then(([code, stdout, stderr]) => ({
|
||||
code,
|
||||
stdout,
|
||||
stderr,
|
||||
}))
|
||||
.catch((err: unknown) => {
|
||||
if (!opts.nothrow) throw err
|
||||
return {
|
||||
code: 1,
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
|
||||
}
|
||||
})
|
||||
if (out.code === 0 || opts.nothrow) return out
|
||||
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
|
||||
}
|
||||
|
||||
export async function text(cmd: string[], opts: RunOptions = {}): Promise<TextResult> {
|
||||
const out = await run(cmd, opts)
|
||||
return {
|
||||
...out,
|
||||
text: out.stdout.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function lines(cmd: string[], opts: RunOptions = {}): Promise<string[]> {
|
||||
return (await text(cmd, opts)).text.split(/\r?\n/).filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
17
packages/opencode/src/util/schema.ts
Normal file
17
packages/opencode/src/util/schema.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
/**
|
||||
* Attach static methods to a schema object. Designed to be used with `.pipe()`:
|
||||
*
|
||||
* @example
|
||||
* export const Foo = fooSchema.pipe(
|
||||
* withStatics((schema) => ({
|
||||
* zero: schema.makeUnsafe(0),
|
||||
* from: Schema.decodeUnknownOption(schema),
|
||||
* }))
|
||||
* )
|
||||
*/
|
||||
export const withStatics =
|
||||
<S extends object, M extends Record<string, unknown>>(methods: (schema: S) => M) =>
|
||||
(schema: S): S & M =>
|
||||
Object.assign(schema, methods(schema))
|
||||
@@ -1,4 +1,3 @@
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
@@ -11,6 +10,8 @@ import { Database, eq } from "../storage/db"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import { fn } from "../util/fn"
|
||||
import { Log } from "../util/log"
|
||||
import { Process } from "../util/process"
|
||||
import { git } from "../util/git"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
|
||||
@@ -248,14 +249,14 @@ export namespace Worktree {
|
||||
}
|
||||
|
||||
async function sweep(root: string) {
|
||||
const first = await $`git clean -ffdx`.quiet().nothrow().cwd(root)
|
||||
const first = await git(["clean", "-ffdx"], { cwd: root })
|
||||
if (first.exitCode === 0) return first
|
||||
|
||||
const entries = failed(first)
|
||||
if (!entries.length) return first
|
||||
|
||||
await prune(root, entries)
|
||||
return $`git clean -ffdx`.quiet().nothrow().cwd(root)
|
||||
return git(["clean", "-ffdx"], { cwd: root })
|
||||
}
|
||||
|
||||
async function canonical(input: string) {
|
||||
@@ -274,7 +275,9 @@ export namespace Worktree {
|
||||
if (await exists(directory)) continue
|
||||
|
||||
const ref = `refs/heads/${branch}`
|
||||
const branchCheck = await $`git show-ref --verify --quiet ${ref}`.quiet().nothrow().cwd(Instance.worktree)
|
||||
const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
if (branchCheck.exitCode === 0) continue
|
||||
|
||||
return Info.parse({ name, branch, directory })
|
||||
@@ -285,9 +288,9 @@ export namespace Worktree {
|
||||
|
||||
async function runStartCommand(directory: string, cmd: string) {
|
||||
if (process.platform === "win32") {
|
||||
return $`cmd /c ${cmd}`.nothrow().cwd(directory)
|
||||
return Process.run(["cmd", "/c", cmd], { cwd: directory, nothrow: true })
|
||||
}
|
||||
return $`bash -lc ${cmd}`.nothrow().cwd(directory)
|
||||
return Process.run(["bash", "-lc", cmd], { cwd: directory, nothrow: true })
|
||||
}
|
||||
|
||||
type StartKind = "project" | "worktree"
|
||||
@@ -297,7 +300,7 @@ export namespace Worktree {
|
||||
if (!text) return true
|
||||
|
||||
const ran = await runStartCommand(directory, text)
|
||||
if (ran.exitCode === 0) return true
|
||||
if (ran.code === 0) return true
|
||||
|
||||
log.error("worktree start command failed", {
|
||||
kind,
|
||||
@@ -344,10 +347,9 @@ export namespace Worktree {
|
||||
}
|
||||
|
||||
export async function createFromInfo(info: Info, startCommand?: string) {
|
||||
const created = await $`git worktree add --no-checkout -b ${info.branch} ${info.directory}`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.cwd(Instance.worktree)
|
||||
const created = await git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
if (created.exitCode !== 0) {
|
||||
throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
|
||||
}
|
||||
@@ -359,7 +361,7 @@ export namespace Worktree {
|
||||
|
||||
return () => {
|
||||
const start = async () => {
|
||||
const populated = await $`git reset --hard`.quiet().nothrow().cwd(info.directory)
|
||||
const populated = await git(["reset", "--hard"], { cwd: info.directory })
|
||||
if (populated.exitCode !== 0) {
|
||||
const message = errorText(populated) || "Failed to populate worktree"
|
||||
log.error("worktree checkout failed", { directory: info.directory, message })
|
||||
@@ -476,10 +478,10 @@ export namespace Worktree {
|
||||
|
||||
const stop = async (target: string) => {
|
||||
if (!(await exists(target))) return
|
||||
await $`git fsmonitor--daemon stop`.quiet().nothrow().cwd(target)
|
||||
await git(["fsmonitor--daemon", "stop"], { cwd: target })
|
||||
}
|
||||
|
||||
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
|
||||
const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||
if (list.exitCode !== 0) {
|
||||
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
|
||||
}
|
||||
@@ -496,9 +498,11 @@ export namespace Worktree {
|
||||
}
|
||||
|
||||
await stop(entry.path)
|
||||
const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
|
||||
const removed = await git(["worktree", "remove", "--force", entry.path], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
if (removed.exitCode !== 0) {
|
||||
const next = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
|
||||
const next = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||
if (next.exitCode !== 0) {
|
||||
throw new RemoveFailedError({
|
||||
message: errorText(removed) || errorText(next) || "Failed to remove git worktree",
|
||||
@@ -515,7 +519,7 @@ export namespace Worktree {
|
||||
|
||||
const branch = entry.branch?.replace(/^refs\/heads\//, "")
|
||||
if (branch) {
|
||||
const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree)
|
||||
const deleted = await git(["branch", "-D", branch], { cwd: Instance.worktree })
|
||||
if (deleted.exitCode !== 0) {
|
||||
throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" })
|
||||
}
|
||||
@@ -535,7 +539,7 @@ export namespace Worktree {
|
||||
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
|
||||
}
|
||||
|
||||
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
|
||||
const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||
if (list.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
|
||||
}
|
||||
@@ -568,7 +572,7 @@ export namespace Worktree {
|
||||
throw new ResetFailedError({ message: "Worktree not found" })
|
||||
}
|
||||
|
||||
const remoteList = await $`git remote`.quiet().nothrow().cwd(Instance.worktree)
|
||||
const remoteList = await git(["remote"], { cwd: Instance.worktree })
|
||||
if (remoteList.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
|
||||
}
|
||||
@@ -587,18 +591,19 @@ export namespace Worktree {
|
||||
: ""
|
||||
|
||||
const remoteHead = remote
|
||||
? await $`git symbolic-ref refs/remotes/${remote}/HEAD`.quiet().nothrow().cwd(Instance.worktree)
|
||||
? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
|
||||
: { exitCode: 1, stdout: undefined, stderr: undefined }
|
||||
|
||||
const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : ""
|
||||
const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
|
||||
const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
|
||||
|
||||
const mainCheck = await $`git show-ref --verify --quiet refs/heads/main`.quiet().nothrow().cwd(Instance.worktree)
|
||||
const masterCheck = await $`git show-ref --verify --quiet refs/heads/master`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.cwd(Instance.worktree)
|
||||
const mainCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/main"], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
const masterCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/master"], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
|
||||
|
||||
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
|
||||
@@ -607,7 +612,7 @@ export namespace Worktree {
|
||||
}
|
||||
|
||||
if (remoteBranch) {
|
||||
const fetch = await $`git fetch ${remote} ${remoteBranch}`.quiet().nothrow().cwd(Instance.worktree)
|
||||
const fetch = await git(["fetch", remote, remoteBranch], { cwd: Instance.worktree })
|
||||
if (fetch.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
|
||||
}
|
||||
@@ -619,7 +624,7 @@ export namespace Worktree {
|
||||
|
||||
const worktreePath = entry.path
|
||||
|
||||
const resetToTarget = await $`git reset --hard ${target}`.quiet().nothrow().cwd(worktreePath)
|
||||
const resetToTarget = await git(["reset", "--hard", target], { cwd: worktreePath })
|
||||
if (resetToTarget.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" })
|
||||
}
|
||||
@@ -629,22 +634,26 @@ export namespace Worktree {
|
||||
throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
|
||||
}
|
||||
|
||||
const update = await $`git submodule update --init --recursive --force`.quiet().nothrow().cwd(worktreePath)
|
||||
const update = await git(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath })
|
||||
if (update.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" })
|
||||
}
|
||||
|
||||
const subReset = await $`git submodule foreach --recursive git reset --hard`.quiet().nothrow().cwd(worktreePath)
|
||||
const subReset = await git(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], {
|
||||
cwd: worktreePath,
|
||||
})
|
||||
if (subReset.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" })
|
||||
}
|
||||
|
||||
const subClean = await $`git submodule foreach --recursive git clean -fdx`.quiet().nothrow().cwd(worktreePath)
|
||||
const subClean = await git(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], {
|
||||
cwd: worktreePath,
|
||||
})
|
||||
if (subClean.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" })
|
||||
}
|
||||
|
||||
const status = await $`git -c core.fsmonitor=false status --porcelain=v1`.quiet().nothrow().cwd(worktreePath)
|
||||
const status = await git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
|
||||
if (status.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
|
||||
}
|
||||
|
||||
332
packages/opencode/test/account/repo.test.ts
Normal file
332
packages/opencode/test/account/repo.test.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
|
||||
import { AccountRepo } from "../../src/account/repo"
|
||||
import { AccountID, OrgID } from "../../src/account/schema"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { testEffect } from "../fixture/effect"
|
||||
|
||||
const reset = Layer.effectDiscard(Effect.promise(() => resetDatabase()))
|
||||
|
||||
const it = testEffect(Layer.merge(AccountRepo.layer, reset))
|
||||
|
||||
it.effect(
|
||||
"list returns empty when no accounts exist",
|
||||
Effect.gen(function* () {
|
||||
const accounts = yield* AccountRepo.use((r) => r.list())
|
||||
expect(accounts).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"active returns none when no accounts exist",
|
||||
Effect.gen(function* () {
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
expect(Option.isNone(active)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"persistAccount inserts and getRow retrieves",
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
url: "https://control.example.com",
|
||||
accessToken: "at_123",
|
||||
refreshToken: "rt_456",
|
||||
expiry: Date.now() + 3600_000,
|
||||
orgID: Option.some(OrgID.make("org-1")),
|
||||
}),
|
||||
)
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
expect(Option.isSome(row)).toBe(true)
|
||||
const value = Option.getOrThrow(row)
|
||||
expect(value.id).toBe("user-1")
|
||||
expect(value.email).toBe("test@example.com")
|
||||
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-1"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"persistAccount sets the active account and org",
|
||||
Effect.gen(function* () {
|
||||
const id1 = AccountID.make("user-1")
|
||||
const id2 = AccountID.make("user-2")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id: id1,
|
||||
email: "first@example.com",
|
||||
url: "https://control.example.com",
|
||||
accessToken: "at_1",
|
||||
refreshToken: "rt_1",
|
||||
expiry: Date.now() + 3600_000,
|
||||
orgID: Option.some(OrgID.make("org-1")),
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id: id2,
|
||||
email: "second@example.com",
|
||||
url: "https://control.example.com",
|
||||
accessToken: "at_2",
|
||||
refreshToken: "rt_2",
|
||||
expiry: Date.now() + 3600_000,
|
||||
orgID: Option.some(OrgID.make("org-2")),
|
||||
}),
|
||||
)
|
||||
|
||||
// Last persisted account is active with its org
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
expect(Option.isSome(active)).toBe(true)
|
||||
expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-2"))
|
||||
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"list returns all accounts",
|
||||
Effect.gen(function* () {
|
||||
const id1 = AccountID.make("user-1")
|
||||
const id2 = AccountID.make("user-2")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id: id1,
|
||||
email: "a@example.com",
|
||||
url: "https://control.example.com",
|
||||
accessToken: "at_1",
|
||||
refreshToken: "rt_1",
|
||||
expiry: Date.now() + 3600_000,
|
||||
orgID: Option.none(),
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id: id2,
|
||||
email: "b@example.com",
|
||||
url: "https://control.example.com",
|
||||
accessToken: "at_2",
|
||||
refreshToken: "rt_2",
|
||||
expiry: Date.now() + 3600_000,
|
||||
orgID: Option.some(OrgID.make("org-1")),
|
||||
}),
|
||||
)
|
||||
|
||||
const accounts = yield* AccountRepo.use((r) => r.list())
|
||||
expect(accounts.length).toBe(2)
|
||||
expect(accounts.map((a) => a.email).sort()).toEqual(["a@example.com", "b@example.com"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"remove deletes an account",
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
url: "https://control.example.com",
|
||||
accessToken: "at_1",
|
||||
refreshToken: "rt_1",
|
||||
expiry: Date.now() + 3600_000,
|
||||
orgID: Option.none(),
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) => r.remove(id))
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
expect(Option.isNone(row)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"use stores the selected org and marks the account active",
|
||||
Effect.gen(function* () {
|
||||
const id1 = AccountID.make("user-1")
|
||||
const id2 = AccountID.make("user-2")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id: id1,
|
||||
email: "first@example.com",
|
||||
url: "https://control.example.com",
|
||||
accessToken: "at_1",
|
||||
refreshToken: "rt_1",
|
||||
expiry: Date.now() + 3600_000,
|
||||
orgID: Option.none(),
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id: id2,
|
||||
email: "second@example.com",
|
||||
url: "https://control.example.com",
|
||||
accessToken: "at_2",
|
||||
refreshToken: "rt_2",
|
||||
expiry: Date.now() + 3600_000,
|
||||
orgID: Option.none(),
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) => r.use(id1, Option.some(OrgID.make("org-99"))))
|
||||
const active1 = yield* AccountRepo.use((r) => r.active())
|
||||
expect(Option.getOrThrow(active1).id).toBe(id1)
|
||||
expect(Option.getOrThrow(active1).active_org_id).toBe(OrgID.make("org-99"))
|
||||
|
||||
yield* AccountRepo.use((r) => r.use(id1, Option.none()))
|
||||
const active2 = yield* AccountRepo.use((r) => r.active())
|
||||
expect(Option.getOrThrow(active2).active_org_id).toBeNull()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"persistToken updates token fields",
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
url: "https://control.example.com",
|
||||
accessToken: "old_token",
|
||||
refreshToken: "old_refresh",
|
||||
expiry: 1000,
|
||||
orgID: Option.none(),
|
||||
}),
|
||||
)
|
||||
|
||||
const expiry = Date.now() + 7200_000
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistToken({
|
||||
accountID: id,
|
||||
accessToken: "new_token",
|
||||
refreshToken: "new_refresh",
|
||||
expiry: Option.some(expiry),
|
||||
}),
|
||||
)
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const value = Option.getOrThrow(row)
|
||||
expect(value.access_token).toBe("new_token")
|
||||
expect(value.refresh_token).toBe("new_refresh")
|
||||
expect(value.token_expiry).toBe(expiry)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"persistToken with no expiry sets token_expiry to null",
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
url: "https://control.example.com",
|
||||
accessToken: "old_token",
|
||||
refreshToken: "old_refresh",
|
||||
expiry: 1000,
|
||||
orgID: Option.none(),
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistToken({
|
||||
accountID: id,
|
||||
accessToken: "new_token",
|
||||
refreshToken: "new_refresh",
|
||||
expiry: Option.none(),
|
||||
}),
|
||||
)
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
expect(Option.getOrThrow(row).token_expiry).toBeNull()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"persistAccount upserts on conflict",
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
url: "https://control.example.com",
|
||||
accessToken: "at_v1",
|
||||
refreshToken: "rt_v1",
|
||||
expiry: 1000,
|
||||
orgID: Option.some(OrgID.make("org-1")),
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
url: "https://control.example.com",
|
||||
accessToken: "at_v2",
|
||||
refreshToken: "rt_v2",
|
||||
expiry: 2000,
|
||||
orgID: Option.some(OrgID.make("org-2")),
|
||||
}),
|
||||
)
|
||||
|
||||
const accounts = yield* AccountRepo.use((r) => r.list())
|
||||
expect(accounts.length).toBe(1)
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const value = Option.getOrThrow(row)
|
||||
expect(value.access_token).toBe("at_v2")
|
||||
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"remove clears active state when deleting the active account",
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
url: "https://control.example.com",
|
||||
accessToken: "at_1",
|
||||
refreshToken: "rt_1",
|
||||
expiry: Date.now() + 3600_000,
|
||||
orgID: Option.some(OrgID.make("org-1")),
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) => r.remove(id))
|
||||
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
expect(Option.isNone(active)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"getRow returns none for nonexistent account",
|
||||
Effect.gen(function* () {
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
|
||||
expect(Option.isNone(row)).toBe(true)
|
||||
}),
|
||||
)
|
||||
217
packages/opencode/test/account/service.test.ts
Normal file
217
packages/opencode/test/account/service.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Option, Ref, Schema } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
import { AccountRepo } from "../../src/account/repo"
|
||||
import { AccountService } from "../../src/account/service"
|
||||
import { AccountID, Login, Org, OrgID } from "../../src/account/schema"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { testEffect } from "../fixture/effect"
|
||||
|
||||
const reset = Layer.effectDiscard(Effect.promise(() => resetDatabase()))
|
||||
|
||||
const it = testEffect(Layer.merge(AccountRepo.layer, reset))
|
||||
|
||||
const live = (client: HttpClient.HttpClient) =>
|
||||
AccountService.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
|
||||
|
||||
const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
|
||||
HttpClientResponse.fromWeb(
|
||||
req,
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
)
|
||||
|
||||
const encodeOrg = Schema.encodeSync(Org)
|
||||
|
||||
const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
|
||||
|
||||
it.effect(
|
||||
"orgsByAccount groups orgs per account",
|
||||
Effect.gen(function* () {
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id: AccountID.make("user-1"),
|
||||
email: "one@example.com",
|
||||
url: "https://one.example.com",
|
||||
accessToken: "at_1",
|
||||
refreshToken: "rt_1",
|
||||
expiry: Date.now() + 60_000,
|
||||
orgID: Option.none(),
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id: AccountID.make("user-2"),
|
||||
email: "two@example.com",
|
||||
url: "https://two.example.com",
|
||||
accessToken: "at_2",
|
||||
refreshToken: "rt_2",
|
||||
expiry: Date.now() + 60_000,
|
||||
orgID: Option.none(),
|
||||
}),
|
||||
)
|
||||
|
||||
const seen = yield* Ref.make<string[]>([])
|
||||
const client = HttpClient.make((req) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(seen, (xs) => [...xs, `${req.method} ${req.url}`])
|
||||
|
||||
if (req.url === "https://one.example.com/api/orgs") {
|
||||
return json(req, [org("org-1", "One")])
|
||||
}
|
||||
|
||||
if (req.url === "https://two.example.com/api/orgs") {
|
||||
return json(req, [org("org-2", "Two A"), org("org-3", "Two B")])
|
||||
}
|
||||
|
||||
return json(req, [], 404)
|
||||
}),
|
||||
)
|
||||
|
||||
const rows = yield* AccountService.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
|
||||
|
||||
expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
|
||||
[AccountID.make("user-1"), [OrgID.make("org-1")]],
|
||||
[AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]],
|
||||
])
|
||||
expect(yield* Ref.get(seen)).toEqual([
|
||||
"GET https://one.example.com/api/orgs",
|
||||
"GET https://two.example.com/api/orgs",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"token refresh persists the new token",
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "user@example.com",
|
||||
url: "https://one.example.com",
|
||||
accessToken: "at_old",
|
||||
refreshToken: "rt_old",
|
||||
expiry: Date.now() - 1_000,
|
||||
orgID: Option.none(),
|
||||
}),
|
||||
)
|
||||
|
||||
const client = HttpClient.make((req) =>
|
||||
Effect.succeed(
|
||||
req.url === "https://one.example.com/oauth/token"
|
||||
? json(req, {
|
||||
access_token: "at_new",
|
||||
refresh_token: "rt_new",
|
||||
expires_in: 60,
|
||||
})
|
||||
: json(req, {}, 404),
|
||||
),
|
||||
)
|
||||
|
||||
const token = yield* AccountService.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
|
||||
|
||||
expect(Option.getOrThrow(token)).toBeDefined()
|
||||
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const value = Option.getOrThrow(row)
|
||||
expect(value.access_token).toBe("at_new")
|
||||
expect(value.refresh_token).toBe("rt_new")
|
||||
expect(value.token_expiry).toBeGreaterThan(Date.now())
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"config sends the selected org header",
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "user@example.com",
|
||||
url: "https://one.example.com",
|
||||
accessToken: "at_1",
|
||||
refreshToken: "rt_1",
|
||||
expiry: Date.now() + 60_000,
|
||||
orgID: Option.none(),
|
||||
}),
|
||||
)
|
||||
|
||||
const seen = yield* Ref.make<{ auth?: string; org?: string }>({})
|
||||
const client = HttpClient.make((req) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.set(seen, {
|
||||
auth: req.headers.authorization,
|
||||
org: req.headers["x-org-id"],
|
||||
})
|
||||
|
||||
if (req.url === "https://one.example.com/api/config") {
|
||||
return json(req, { config: { theme: "light", seats: 5 } })
|
||||
}
|
||||
|
||||
return json(req, {}, 404)
|
||||
}),
|
||||
)
|
||||
|
||||
const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
|
||||
|
||||
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
|
||||
expect(yield* Ref.get(seen)).toEqual({
|
||||
auth: "Bearer at_1",
|
||||
org: "org-9",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"poll stores the account and first org on success",
|
||||
Effect.gen(function* () {
|
||||
const login = new Login({
|
||||
code: "device-code",
|
||||
user: "user-code",
|
||||
url: "https://one.example.com/verify",
|
||||
server: "https://one.example.com",
|
||||
expiry: 600,
|
||||
interval: 5,
|
||||
})
|
||||
|
||||
const client = HttpClient.make((req) =>
|
||||
Effect.succeed(
|
||||
req.url === "https://one.example.com/auth/device/token"
|
||||
? json(req, {
|
||||
access_token: "at_1",
|
||||
refresh_token: "rt_1",
|
||||
expires_in: 60,
|
||||
})
|
||||
: req.url === "https://one.example.com/api/user"
|
||||
? json(req, { id: "user-1", email: "user@example.com" })
|
||||
: req.url === "https://one.example.com/api/orgs"
|
||||
? json(req, [org("org-1", "One")])
|
||||
: json(req, {}, 404),
|
||||
),
|
||||
)
|
||||
|
||||
const res = yield* AccountService.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
|
||||
|
||||
expect(res._tag).toBe("PollSuccess")
|
||||
if (res._tag === "PollSuccess") {
|
||||
expect(res.email).toBe("user@example.com")
|
||||
}
|
||||
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
expect(Option.getOrThrow(active)).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
active_org_id: "org-1",
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -1,5 +1,10 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { parseShareUrl, transformShareData, type ShareData } from "../../src/cli/cmd/import"
|
||||
import {
|
||||
parseShareUrl,
|
||||
shouldAttachShareAuthHeaders,
|
||||
transformShareData,
|
||||
type ShareData,
|
||||
} from "../../src/cli/cmd/import"
|
||||
|
||||
// parseShareUrl tests
|
||||
test("parses valid share URLs", () => {
|
||||
@@ -15,6 +20,19 @@ test("rejects invalid URLs", () => {
|
||||
expect(parseShareUrl("not-a-url")).toBeNull()
|
||||
})
|
||||
|
||||
test("only attaches share auth headers for same-origin URLs", () => {
|
||||
expect(shouldAttachShareAuthHeaders("https://control.example.com/share/abc", "https://control.example.com")).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
shouldAttachShareAuthHeaders("https://other.example.com/share/abc", "https://control.example.com"),
|
||||
).toBe(false)
|
||||
expect(shouldAttachShareAuthHeaders("https://control.example.com:443/share/abc", "https://control.example.com")).toBe(
|
||||
true,
|
||||
)
|
||||
expect(shouldAttachShareAuthHeaders("not-a-url", "https://control.example.com")).toBe(false)
|
||||
})
|
||||
|
||||
// transformShareData tests
|
||||
test("transforms share data to storage format", () => {
|
||||
const data: ShareData[] = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect, describe } from "bun:test"
|
||||
import { resolvePluginProviders } from "../../src/cli/cmd/auth"
|
||||
import { resolvePluginProviders } from "../../src/cli/cmd/providers"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
|
||||
function hookWithAuth(provider: string): Hooks {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test, expect, describe, mock, afterEach } from "bun:test"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
@@ -242,6 +243,52 @@ test("preserves env variables when adding $schema to config", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("resolves env templates in account config with account token", async () => {
|
||||
const originalActive = Account.active
|
||||
const originalConfig = Account.config
|
||||
const originalToken = Account.token
|
||||
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
|
||||
|
||||
Account.active = mock(() => ({
|
||||
id: AccountID.make("account-1"),
|
||||
email: "user@example.com",
|
||||
url: "https://control.example.com",
|
||||
active_org_id: OrgID.make("org-1"),
|
||||
}))
|
||||
|
||||
Account.config = mock(async () => ({
|
||||
provider: {
|
||||
opencode: {
|
||||
options: {
|
||||
apiKey: "{env:OPENCODE_CONSOLE_TOKEN}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
Account.token = mock(async () => AccessToken.make("st_test_token"))
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
Account.active = originalActive
|
||||
Account.config = originalConfig
|
||||
Account.token = originalToken
|
||||
if (originalControlToken !== undefined) {
|
||||
process.env["OPENCODE_CONSOLE_TOKEN"] = originalControlToken
|
||||
} else {
|
||||
delete process.env["OPENCODE_CONSOLE_TOKEN"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("handles file inclusion substitution", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
@@ -10,12 +10,22 @@ import { Database } from "../../src/storage/db"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import * as adaptors from "../../src/control-plane/adaptors"
|
||||
import type { Adaptor } from "../../src/control-plane/types"
|
||||
import { Flag } from "../../src/flag/flag"
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
// @ts-expect-error don't do this normally, but it works
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
|
||||
afterEach(() => {
|
||||
// @ts-expect-error don't do this normally, but it works
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original
|
||||
})
|
||||
|
||||
type State = {
|
||||
workspace?: "first" | "second"
|
||||
calls: Array<{ method: string; url: string; body?: string }>
|
||||
|
||||
7
packages/opencode/test/fixture/effect.ts
Normal file
7
packages/opencode/test/fixture/effect.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { test } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => ({
|
||||
effect: <A, E2>(name: string, value: Effect.Effect<A, E2, R>) =>
|
||||
test(name, () => Effect.runPromise(value.pipe(Effect.provide(layer)))),
|
||||
})
|
||||
@@ -198,6 +198,30 @@ test("GitLab Duo: config apiKey takes precedence over environment variable", asy
|
||||
})
|
||||
})
|
||||
|
||||
test("GitLab Duo: includes context-1m beta header in aiGatewayHeaders", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("GITLAB_TOKEN", "test-token")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["gitlab"]).toBeDefined()
|
||||
expect(providers["gitlab"].options?.aiGatewayHeaders?.["anthropic-beta"]).toContain("context-1m-2025-08-07")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("GitLab Duo: supports feature flags configuration", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
@@ -2002,6 +2002,35 @@ describe("ProviderTransform.variants", () => {
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"])
|
||||
})
|
||||
|
||||
test("gpt-5.3-codex includes xhigh", () => {
|
||||
const model = createMockModel({
|
||||
id: "gpt-5.3-codex",
|
||||
providerID: "github-copilot",
|
||||
api: {
|
||||
id: "gpt-5.3-codex",
|
||||
url: "https://api.githubcopilot.com",
|
||||
npm: "@ai-sdk/github-copilot",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"])
|
||||
})
|
||||
|
||||
test("gpt-5.4 includes xhigh", () => {
|
||||
const model = createMockModel({
|
||||
id: "gpt-5.4",
|
||||
release_date: "2026-03-05",
|
||||
providerID: "github-copilot",
|
||||
api: {
|
||||
id: "gpt-5.4",
|
||||
url: "https://api.githubcopilot.com",
|
||||
npm: "@ai-sdk/github-copilot",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("@ai-sdk/cerebras", () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ afterEach(async () => {
|
||||
describe("project.initGit endpoint", () => {
|
||||
test("initializes git and reloads immediately", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const app = Server.App()
|
||||
const app = Server.Default()
|
||||
const seen: { directory?: string; payload: { type: string } }[] = []
|
||||
const fn = (evt: { directory?: string; payload: { type: string } }) => {
|
||||
seen.push(evt)
|
||||
@@ -75,7 +75,7 @@ describe("project.initGit endpoint", () => {
|
||||
|
||||
test("does not reload when the project is already git", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const app = Server.App()
|
||||
const app = Server.Default()
|
||||
const seen: { directory?: string; payload: { type: string } }[] = []
|
||||
const fn = (evt: { directory?: string; payload: { type: string } }) => {
|
||||
seen.push(evt)
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("tui.selectSession endpoint", () => {
|
||||
const session = await Session.create({})
|
||||
|
||||
// #when
|
||||
const app = Server.App()
|
||||
const app = Server.Default()
|
||||
const response = await app.request("/tui/select-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -42,7 +42,7 @@ describe("tui.selectSession endpoint", () => {
|
||||
const nonExistentSessionID = "ses_nonexistent123"
|
||||
|
||||
// #when
|
||||
const app = Server.App()
|
||||
const app = Server.Default()
|
||||
const response = await app.request("/tui/select-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -63,7 +63,7 @@ describe("tui.selectSession endpoint", () => {
|
||||
const invalidSessionID = "invalid_session_id"
|
||||
|
||||
// #when
|
||||
const app = Server.App()
|
||||
const app = Server.Default()
|
||||
const response = await app.request("/tui/select-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
76
packages/opencode/test/share/share-next.test.ts
Normal file
76
packages/opencode/test/share/share-next.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { test, expect, mock } from "bun:test"
|
||||
import { ShareNext } from "../../src/share/share-next"
|
||||
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
|
||||
import { Config } from "../../src/config/config"
|
||||
|
||||
test("ShareNext.request uses legacy share API without active org account", async () => {
|
||||
const originalActive = Account.active
|
||||
const originalConfigGet = Config.get
|
||||
|
||||
Account.active = mock(() => undefined)
|
||||
Config.get = mock(async () => ({ enterprise: { url: "https://legacy-share.example.com" } }))
|
||||
|
||||
try {
|
||||
const req = await ShareNext.request()
|
||||
|
||||
expect(req.api.create).toBe("/api/share")
|
||||
expect(req.api.sync("shr_123")).toBe("/api/share/shr_123/sync")
|
||||
expect(req.api.remove("shr_123")).toBe("/api/share/shr_123")
|
||||
expect(req.api.data("shr_123")).toBe("/api/share/shr_123/data")
|
||||
expect(req.baseUrl).toBe("https://legacy-share.example.com")
|
||||
expect(req.headers).toEqual({})
|
||||
} finally {
|
||||
Account.active = originalActive
|
||||
Config.get = originalConfigGet
|
||||
}
|
||||
})
|
||||
|
||||
test("ShareNext.request uses org share API with auth headers when account is active", async () => {
|
||||
const originalActive = Account.active
|
||||
const originalToken = Account.token
|
||||
|
||||
Account.active = mock(() => ({
|
||||
id: AccountID.make("account-1"),
|
||||
email: "user@example.com",
|
||||
url: "https://control.example.com",
|
||||
active_org_id: OrgID.make("org-1"),
|
||||
}))
|
||||
Account.token = mock(async () => AccessToken.make("st_test_token"))
|
||||
|
||||
try {
|
||||
const req = await ShareNext.request()
|
||||
|
||||
expect(req.api.create).toBe("/api/shares")
|
||||
expect(req.api.sync("shr_123")).toBe("/api/shares/shr_123/sync")
|
||||
expect(req.api.remove("shr_123")).toBe("/api/shares/shr_123")
|
||||
expect(req.api.data("shr_123")).toBe("/api/shares/shr_123/data")
|
||||
expect(req.baseUrl).toBe("https://control.example.com")
|
||||
expect(req.headers).toEqual({
|
||||
authorization: "Bearer st_test_token",
|
||||
"x-org-id": "org-1",
|
||||
})
|
||||
} finally {
|
||||
Account.active = originalActive
|
||||
Account.token = originalToken
|
||||
}
|
||||
})
|
||||
|
||||
test("ShareNext.request fails when org account has no token", async () => {
|
||||
const originalActive = Account.active
|
||||
const originalToken = Account.token
|
||||
|
||||
Account.active = mock(() => ({
|
||||
id: AccountID.make("account-1"),
|
||||
email: "user@example.com",
|
||||
url: "https://control.example.com",
|
||||
active_org_id: OrgID.make("org-1"),
|
||||
}))
|
||||
Account.token = mock(async () => undefined)
|
||||
|
||||
try {
|
||||
await expect(ShareNext.request()).rejects.toThrow("No active OpenControl token available for sharing")
|
||||
} finally {
|
||||
Account.active = originalActive
|
||||
Account.token = originalToken
|
||||
}
|
||||
})
|
||||
59
packages/opencode/test/util/module.test.ts
Normal file
59
packages/opencode/test/util/module.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Module } from "@opencode-ai/util/module"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("util.module", () => {
|
||||
test("resolves package subpaths from the provided dir", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const root = path.join(tmp.path, "proj")
|
||||
const file = path.join(root, "node_modules/typescript/lib/tsserver.js")
|
||||
await Filesystem.write(file, "export {}\n")
|
||||
await Filesystem.writeJson(path.join(root, "node_modules/typescript/package.json"), { name: "typescript" })
|
||||
|
||||
expect(Module.resolve("typescript/lib/tsserver.js", root)).toBe(file)
|
||||
})
|
||||
|
||||
test("resolves packages through ancestor node_modules", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const root = path.join(tmp.path, "proj")
|
||||
const cwd = path.join(root, "apps/web")
|
||||
const file = path.join(root, "node_modules/eslint/lib/api.js")
|
||||
await Filesystem.write(file, "export {}\n")
|
||||
await Filesystem.writeJson(path.join(root, "node_modules/eslint/package.json"), {
|
||||
name: "eslint",
|
||||
main: "lib/api.js",
|
||||
})
|
||||
await Filesystem.write(path.join(cwd, ".keep"), "")
|
||||
|
||||
expect(Module.resolve("eslint", cwd)).toBe(file)
|
||||
})
|
||||
|
||||
test("resolves relative to the provided dir", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const a = path.join(tmp.path, "a")
|
||||
const b = path.join(tmp.path, "b")
|
||||
const left = path.join(a, "node_modules/biome/index.js")
|
||||
const right = path.join(b, "node_modules/biome/index.js")
|
||||
await Filesystem.write(left, "export {}\n")
|
||||
await Filesystem.write(right, "export {}\n")
|
||||
await Filesystem.writeJson(path.join(a, "node_modules/biome/package.json"), {
|
||||
name: "biome",
|
||||
main: "index.js",
|
||||
})
|
||||
await Filesystem.writeJson(path.join(b, "node_modules/biome/package.json"), {
|
||||
name: "biome",
|
||||
main: "index.js",
|
||||
})
|
||||
|
||||
expect(Module.resolve("biome", a)).toBe(left)
|
||||
expect(Module.resolve("biome", b)).toBe(right)
|
||||
expect(Module.resolve("biome", a)).not.toBe(Module.resolve("biome", b))
|
||||
})
|
||||
|
||||
test("returns undefined when resolution fails", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
expect(Module.resolve("missing-package", tmp.path)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,11 @@
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@tui/*": ["./src/cli/cmd/tui/*"]
|
||||
}
|
||||
},
|
||||
"plugins": [{
|
||||
"name": "@effect/language-service",
|
||||
"transform": "@effect/language-service/transform",
|
||||
"namespaceImportPackages": ["effect", "@effect/*"]
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.23",
|
||||
"version": "1.2.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
"$schema": "https://json.schemastore.org/package",
|
||||
"name": "@opencode-ai/script",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:"
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "^7.5.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user