Compare commits

..

41 Commits

Author SHA1 Message Date
Kit Langton
41114b1b6e Merge remote-tracking branch 'upstream/dev' into cli-auth-cloud
# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
#
# Conflicts:
#	bun.lock
2026-03-09 20:08:07 -04:00
Kit Langton
30a501ba11 core: move org selection from account table to account_state
Single source of truth for current selection: account_state now owns
both active_account_id and active_org_id. Simplifies use() to a
single setState call and removes org state from the account table.

Also uses db() wrapper consistently in persistAccount and caps
orgsByAccount concurrency.
2026-03-09 19:44:38 -04:00
Kit Langton
28f795a9a0 core: fix type error in account repo list mapping 2026-03-09 18:24:02 -04:00
Kit Langton
b4b24cf691 core: inline helpers and extract magic number in account repo
Address PR review comments: inline toAccountRepoError and fromRow
helpers, extract ACCOUNT_STATE_ID constant.
2026-03-09 18:23:51 -04:00
Kit Langton
eda87569fe core: rename OPENCODE_CONTROL_TOKEN to OPENCODE_CONSOLE_TOKEN
Align env var name with opencontrol server's default CONSOLE_TOKEN_ENV.
2026-03-09 17:59:02 -04:00
Dax Raad
89d6f60d25 refactor(server): extract createApp function for server initialization
- Replace Server.App() with Server.Default() for internal server access
- Extract server app creation into Server.createApp(opts) for testability
- Move CORS whitelist from module-level variable to function parameter
- Update all tests to use Server.Default() instead of Server.App()
2026-03-09 17:13:52 -04:00
Adam
ee18c9976e chore(app): dev stats 2026-03-09 15:57:24 -05:00
Adam
794532928f fix(app): terminal state corruption 2026-03-09 15:28:35 -05:00
Adam
7b773c65ec chore: cleanup 2026-03-09 15:28:35 -05:00
Adam
e53aa79dc6 chore: cleanup 2026-03-09 15:28:35 -05:00
opencode-agent[bot]
d9a97249c0 chore: generate 2026-03-09 20:15:31 +00:00
James Long
86cef16940 fix(core): put workspace routing behind OPENCODE_EXPERIMENTAL_WORKSPACES flag (#16775) 2026-03-09 16:14:19 -04:00
opencode-agent[bot]
ce38997c76 chore: update nix node_modules hashes 2026-03-09 19:51:58 +00:00
opencode-agent[bot]
7e10c728d4 chore: update nix node_modules hashes 2026-03-09 19:49:01 +00:00
bhaktatejas922
3627c67cf2 docs: update opencode-morph-fast-apply to opencode-morph-plugin in ecosystem (#16634) 2026-03-09 14:39:06 -05:00
opencode-agent[bot]
2518fd81f6 chore: generate 2026-03-09 19:31:33 +00:00
Kit Langton
49deb7207f chore: bump effect beta 2026-03-08 12:41:15 -04:00
Kit Langton
91b9a03e27 Merge upstream/dev into cli-auth-cloud 2026-03-08 12:14:40 -04:00
Kit Langton
a2b527ff29 core: simplify account error payloads 2026-03-07 20:12:16 -05:00
Kit Langton
48cf609115 core: track active account separately from org selection 2026-03-07 14:02:04 -05:00
Kit Langton
a581493c13 test: cover account service flows 2026-03-07 12:46:49 -05:00
Kit Langton
e5b34076df core: remove dead auth command 2026-03-07 12:31:19 -05:00
Kit Langton
0e642ed885 core: separate account repo errors 2026-03-07 12:07:45 -05:00
Kit Langton
7af0546dc0 core: restore session workspace scope 2026-03-07 11:48:41 -05:00
Kit Langton
270f44f41d Merge upstream/dev into cli-auth-cloud 2026-03-07 11:40:15 -05:00
Kit Langton
2db9d317f9 core: use service shape helper for account api 2026-03-07 11:34:17 -05:00
Kit Langton
95279abbbc core: group org lookups by account 2026-03-06 23:05:20 -05:00
Kit Langton
1a2ddf9e0f core: tighten account service and CLI typing
Align the account CLI with the Effect-based service types so polling, org switching, and prompt cancellation narrow cleanly. This keeps the refactor type-safe and avoids leaking legacy wrapper shapes.
2026-03-06 22:11:04 -05:00
Kit Langton
f807875a99 core: effectify CLI account commands with tagged PollResult union
- Rewrite all account CLI commands (login, logout, switch, orgs) as
  Effect.fn with AccountService accessed directly via yield*
- Convert PollResult from plain object union to Schema.TaggedClass
  variants with Schema.Union and Match.valueTags for exhaustive matching
- Add recursive poll function for device auth flow (stack-safe via
  Effect trampolining)
- Add shared Effect runtime at src/effect/runtime.ts
- Add Effect wrappers for @clack/prompts at src/cli/effect/prompt.ts
- Add @effect/language-service TS plugin for editor support
- Refactor repo tests to use testEffect helper with Layer-based setup
- Parallelize org fetching in orgs command

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:34:03 -05:00
Kit Langton
48158ce97d core: simplify account repo/service after review
- persistToken: positional params → named object to prevent swaps
- persistAccount: wrap in Database.transaction for atomicity
- Internal response schemas: Schema.Class → Schema.Struct (lighter)
- fromRow: pass row directly to decoder (strips unknown keys)
- Test: add afterAll runtime disposal, use branded ID in assertion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:39:34 -05:00
Kit Langton
adc9536a16 core: refactor account module to Effect with repo/service split
Rewrite the account layer using Effect for typed error handling, schema
validation, and structured concurrency. Split into three concerns:

- schema.ts: branded types (AccountID, OrgID, AccessToken) and shared
  data classes
- repo.ts: AccountRepo service owning all DB operations via drizzle
- service.ts: AccountService owning HTTP orchestration (OAuth device
  flow, token refresh, org/config fetching) with AccountRepo as a
  dependency

Key improvements:
- Branded types enforce type safety at API boundaries
- Option used consistently instead of null/undefined in internal APIs
- User and orgs fetches parallelized during login poll
- Schema-validated HTTP request/response bodies
- Transient read retry with exponential backoff
- Clock.currentTimeMillis instead of Date.now() for testability
- 11 new repo tests covering all DB operations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:33:22 -05:00
Kit Langton
fec8d5bcf1 core: prevent share auth headers from being sent cross-origin 2026-03-05 16:56:36 -05:00
Kit Langton
e923047219 core: route session sharing through org-scoped control APIs 2026-03-05 16:04:43 -05:00
Kit Langton
b19dc933a4 core: resolve account config env templates after loading control token 2026-03-05 16:03:10 -05:00
Kit Langton
902268e0d1 core: rename workspace scope to org across account and session surfaces 2026-03-05 16:02:17 -05:00
Dax Raad
a44f78c34a core: maintain backward compatibility with existing account data by restoring legacy ControlAccountTable alongside new AccountTable structure 2026-03-01 14:21:55 -05:00
Dax Raad
a5d727e7f9 core: enable workspace-aware configuration and account management commands
Switch from boolean active flag to workspace_id tracking so users can select which workspace context to operate in. Login now automatically selects the first available workspace and stores it on the account record.

Logout command now actually removes account records and supports targeting specific accounts by email. Switch command provides an interactive picker to change active workspace. Workspaces command lists all available workspaces across accounts.

Configuration now loads workspace-specific settings from the server when an active workspace is selected, enabling per-workspace customization of opencode behavior.
2026-03-01 14:21:55 -05:00
Dax Raad
7b5b665b4a core: support managing multiple authenticated accounts with individual workspace access
Enable users to authenticate with multiple accounts and switch between
them, accessing workspaces from each account separately.
2026-03-01 14:21:55 -05:00
Dax Raad
b5515dd2f7 core: add device flow authentication commands
Allow users to authenticate via browser-based OAuth device flow
with opencode login command. Includes login, logout, switch account,
and workspaces list commands for managing multiple accounts.
2026-03-01 14:21:55 -05:00
Dax Raad
d16e5b98dc core: rename control module to account for clearer authentication management
Refactor internal authentication system by renaming the control module to account,
making it easier to understand that this handles user account credentials and
tokens. Simplify database schema management by removing the centralized schema
exports and letting each module manage its own tables directly.
2026-03-01 14:21:55 -05:00
Dax Raad
9dbf3a2042 core: rename auth command to providers for clearer credential management
The auth command has been renamed to providers to better reflect its purpose of managing AI provider credentials. This makes it easier for users to discover and use the credential management features when configuring different AI providers.
2026-03-01 14:21:55 -05:00
59 changed files with 4989 additions and 1797 deletions

View File

@@ -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.

1399
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -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="
}
}

View File

@@ -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",

View 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>
)
}

View File

@@ -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 },
],
})
})
})

View File

@@ -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),

View File

@@ -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"
@@ -2135,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>

View File

@@ -0,0 +1,136 @@
# Bun shell migration plan
Practical phased replacement of Bun `$` calls.
## Goal
Replace runtime Bun shell template-tag usage in `packages/opencode/src` with a unified `Process` API in `util/process.ts`.
Keep behavior stable while improving safety, testability, and observability.
Current baseline from audit:
- 143 runtime command invocations across 17 files
- 84 are git commands
- Largest hotspots:
- `src/cli/cmd/github.ts` (33)
- `src/worktree/index.ts` (22)
- `src/lsp/server.ts` (21)
- `src/installation/index.ts` (20)
- `src/snapshot/index.ts` (18)
## Decisions
- Extend `src/util/process.ts` (do not create a separate exec module).
- Proceed with phased migration for both git and non-git paths.
- Keep plugin `$` compatibility in 1.x and remove in 2.0.
## Non-goals
- Do not remove plugin `$` compatibility in this effort.
- Do not redesign command semantics beyond what is needed to preserve behavior.
## Constraints
- Keep migration phased, not big-bang.
- Minimize behavioral drift.
- Keep these explicit shell-only exceptions:
- `src/session/prompt.ts` raw command execution
- worktree start scripts in `src/worktree/index.ts`
## Process API proposal (`src/util/process.ts`)
Add higher-level wrappers on top of current spawn support.
Core methods:
- `Process.run(cmd, opts)`
- `Process.text(cmd, opts)`
- `Process.lines(cmd, opts)`
- `Process.status(cmd, opts)`
- `Process.shell(command, opts)` for intentional shell execution
Git helpers:
- `Process.git(args, opts)`
- `Process.gitText(args, opts)`
Shared options:
- `cwd`, `env`, `stdin`, `stdout`, `stderr`, `abort`, `timeout`, `kill`
- `allowFailure` / non-throw mode
- optional redaction + trace metadata
Standard result shape:
- `code`, `stdout`, `stderr`, `duration_ms`, `cmd`
- helpers like `text()` and `arrayBuffer()` where useful
## Phased rollout
### Phase 0: Foundation
- Implement Process wrappers in `src/util/process.ts`.
- Refactor `src/util/git.ts` to use Process only.
- Add tests for exit handling, timeout, abort, and output capture.
### Phase 1: High-impact hotspots
Migrate these first:
- `src/cli/cmd/github.ts`
- `src/worktree/index.ts`
- `src/lsp/server.ts`
- `src/installation/index.ts`
- `src/snapshot/index.ts`
Within each file, migrate git paths first where applicable.
### Phase 2: Remaining git-heavy files
Migrate git-centric call sites to `Process.git*` helpers:
- `src/file/index.ts`
- `src/project/vcs.ts`
- `src/file/watcher.ts`
- `src/storage/storage.ts`
- `src/cli/cmd/pr.ts`
### Phase 3: Remaining non-git files
Migrate residual non-git usages:
- `src/cli/cmd/tui/util/clipboard.ts`
- `src/util/archive.ts`
- `src/file/ripgrep.ts`
- `src/tool/bash.ts`
- `src/cli/cmd/uninstall.ts`
### Phase 4: Stabilize
- Remove dead wrappers and one-off patterns.
- Keep plugin `$` compatibility isolated and documented as temporary.
- Create linked 2.0 task for plugin `$` removal.
## Validation strategy
- Unit tests for new `Process` methods and options.
- Integration tests on hotspot modules.
- Smoke tests for install, snapshot, worktree, and GitHub flows.
- Regression checks for output parsing behavior.
## Risk mitigation
- File-by-file PRs with small diffs.
- Preserve behavior first, simplify second.
- Keep shell-only exceptions explicit and documented.
- Add consistent error shaping and logging at Process layer.
## Definition of done
- Runtime Bun `$` usage in `packages/opencode/src` is removed except:
- approved shell-only exceptions
- temporary plugin compatibility path (1.x)
- Git paths use `Process.git*` consistently.
- CI and targeted smoke tests pass.
- 2.0 issue exists for plugin `$` removal.

View File

@@ -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

View File

@@ -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`;

View File

@@ -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",
@@ -81,8 +82,6 @@
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5",
"@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
"@octokit/graphql": "9.0.2",
@@ -110,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",

View File

@@ -1,8 +0,0 @@
#!/usr/bin/env bun
Bun.build({
entrypoints: ["./src/node.ts"],
target: "node",
outdir: "./dist",
format: "esm",
})

View File

@@ -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",
{

View 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)
}
}

View 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)
}),
}),
)
}

View 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>

View 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),
)
}

View 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())
},
})

View File

@@ -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}`)

View File

@@ -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")

View File

@@ -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)

View File

@@ -372,7 +372,7 @@ function App() {
dialog.replace(() => <DialogSessionList />)
},
},
...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI
...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
? [
{
title: "Manage workspaces",

View File

@@ -103,7 +103,7 @@ export function Header() {
<Match when={session()?.parentID}>
<box flexDirection="column" gap={1}>
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={narrow() ? 1 : 0}>
{Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI ? (
{Flag.OPENCODE_EXPERIMENTAL_WORKSPACES ? (
<box flexDirection="column">
<text fg={theme.text}>
<b>Subagent session</b>
@@ -154,7 +154,7 @@ export function Header() {
</Match>
<Match when={true}>
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={1}>
{Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI ? (
{Flag.OPENCODE_EXPERIMENTAL_WORKSPACES ? (
<box flexDirection="column">
<Title session={session} />
<WorkspaceInfo workspace={workspace} />

View File

@@ -383,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()
},
},
@@ -486,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()
},
},

View File

@@ -54,7 +54,7 @@ const startEventStream = (input: { directory: string; workspaceID?: 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({
@@ -110,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,

View 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)),
}
}

View File

@@ -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

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,4 @@
import { ManagedRuntime } from "effect"
import { AccountService } from "@/account/service"
export const runtime = ManagedRuntime.make(AccountService.defaultLayer)

View File

@@ -57,8 +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_TUI =
OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES_TUI")
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"]

View File

@@ -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)

View File

@@ -1 +0,0 @@
export { Server } from "./server/server"

View File

@@ -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.$,
}

View File

@@ -1,11 +1,11 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import { upgradeWebSocket } from "hono/bun"
import z from "zod"
import { Pty } from "@/pty"
import { NotFoundError } from "../../storage/db"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Server } from "../server"
export const PtyRoutes = lazy(() =>
new Hono()
@@ -149,7 +149,7 @@ export const PtyRoutes = lazy(() =>
},
}),
validator("param", z.object({ ptyID: z.string() })),
Server.upgradeWebSocket((c) => {
upgradeWebSocket((c) => {
const id = c.req.param("ptyID")
const cursor = (() => {
const value = c.req.query("cursor")

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,12 @@ import { Snapshot } from "@/snapshot"
import { fn } from "@/util/fn"
import { Database, eq, desc, inArray } from "@/storage/db"
import { MessageTable, PartTable } from "./session.sql"
import { ProviderTransform } from "@/provider/transform"
import { STATUS_CODES } from "http"
import { Storage } from "@/storage/storage"
import { ProviderError } from "@/provider/error"
import { iife } from "@/util/iife"
import { type SystemError } from "bun"
import type { Provider } from "@/provider/provider"
export namespace MessageV2 {
@@ -839,15 +843,15 @@ export namespace MessageV2 {
},
{ cause: e },
).toObject()
case (e as any)?.code === "ECONNRESET":
case (e as SystemError)?.code === "ECONNRESET":
return new MessageV2.APIError(
{
message: "Connection reset by server",
isRetryable: true,
metadata: {
code: (e as any).code ?? "",
syscall: (e as any).syscall ?? "",
message: (e as any).message ?? "",
code: (e as SystemError).code ?? "",
syscall: (e as SystemError).syscall ?? "",
message: (e as SystemError).message ?? "",
},
},
{ cause: e },

View File

@@ -29,11 +29,9 @@ import { ReadTool } from "../tool/read"
import { FileTime } from "../file/time"
import { Flag } from "../flag/flag"
import { ulid } from "ulid"
import { Process } from "../util/process"
import { spawn } from "child_process"
import { Command } from "../command"
import { $ } from "bun"
import { pathToFileURL, fileURLToPath } from "url"
import { ConfigMarkdown } from "../config/markdown"
import { SessionSummary } from "./summary"
@@ -1780,13 +1778,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
template = template + "\n\n" + input.arguments
}
const shellMatches = ConfigMarkdown.shell(template)
if (shellMatches.length > 0) {
const sh = Shell.preferred()
const shell = ConfigMarkdown.shell(template)
if (shell.length > 0) {
const results = await Promise.all(
shellMatches.map(async ([, cmd]) => {
const out = await Process.text([cmd], { shell: sh, nothrow: true })
return out.text
shell.map(async ([, cmd]) => {
try {
return await $`${{ raw: cmd }}`.quiet().nothrow().text()
} catch (error) {
return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
}
}),
)
let index = 0

View File

@@ -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())
}

View File

@@ -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

View File

@@ -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"

View 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),
}),
)

View File

@@ -13,7 +13,6 @@ export namespace Process {
abort?: AbortSignal
kill?: NodeJS.Signals | number
timeout?: number
shell?: string | boolean
}
export interface RunOptions extends Omit<Options, "stdout" | "stderr"> {
@@ -60,7 +59,6 @@ export namespace Process {
const proc = launch(cmd[0], cmd.slice(1), {
cwd: opts.cwd,
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
shell: opts.shell,
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
})
@@ -110,7 +108,6 @@ export namespace Process {
const proc = spawn(cmd, {
cwd: opts.cwd,
env: opts.env,
shell: opts.shell,
stdin: opts.stdin,
abort: opts.abort,
kill: opts.kill,

View 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))

View 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)
}),
)

View 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",
}),
)
}),
)

View File

@@ -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[] = [

View File

@@ -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 {

View File

@@ -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) => {

View File

@@ -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 }>

View 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)))),
})

View File

@@ -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)

View File

@@ -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" },

View 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
}
})

View File

@@ -11,6 +11,11 @@
"paths": {
"@/*": ["./src/*"],
"@tui/*": ["./src/cli/cmd/tui/*"]
}
},
"plugins": [{
"name": "@effect/language-service",
"transform": "@effect/language-service/transform",
"namespaceImportPackages": ["effect", "@effect/*"]
}]
}
}

View File

@@ -21,7 +21,7 @@
align-self: stretch;
width: 100%;
max-width: 100%;
gap: 8px;
gap: 0;
&[data-interrupted] {
color: var(--text-weak);
@@ -98,6 +98,10 @@
align-items: flex-end;
}
[data-slot="user-message-attachments"] + [data-slot="user-message-body"] {
margin-top: 8px;
}
[data-slot="user-message-text"] {
display: inline-block;
white-space: pre-wrap;
@@ -168,7 +172,7 @@
align-items: center;
justify-content: flex-end;
overflow: hidden;
gap: 6px;
gap: 0;
}
[data-slot="user-message-meta-tail"] {

View File

@@ -32,7 +32,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations |
| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime |
| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs |
| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers |
| [opencode-morph-plugin](https://github.com/morphllm/opencode-morph-plugin) | Fast Apply editing, WarpGrep codebase search, and context compaction via Morph |
| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible |
| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions |
| [opencode-notifier](https://github.com/mohak34/opencode-notifier) | Desktop notifications and sound alerts for permission, completion, and error events |