electron: remove cli and use server code directly

pull/17803/head
Brendan Allan 2026-03-26 16:31:17 +08:00
parent 37883a9f3a
commit 759acb60cd
No known key found for this signature in database
GPG Key ID: 41E835AEA046A32E
27 changed files with 149 additions and 436 deletions

View File

@ -34,11 +34,6 @@ const getBase = (): Configuration => ({
}, },
files: ["out/**/*", "resources/**/*"], files: ["out/**/*", "resources/**/*"],
extraResources: [ extraResources: [
{
from: "resources/",
to: "",
filter: ["opencode-cli*"],
},
{ {
from: "native/", from: "native/",
to: "native/", to: "native/",

View File

@ -1,5 +1,6 @@
import { defineConfig } from "electron-vite" import { defineConfig } from "electron-vite"
import appPlugin from "@opencode-ai/app/vite" import appPlugin from "@opencode-ai/app/vite"
import * as fs from "node:fs/promises"
const channel = (() => { const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL const raw = process.env.OPENCODE_CHANNEL
@ -7,6 +8,8 @@ const channel = (() => {
return "dev" return "dev"
})() })()
const OPENCODE_SERVER_DIST = "../opencode/dist/node"
export default defineConfig({ export default defineConfig({
main: { main: {
define: { define: {
@ -17,6 +20,25 @@ export default defineConfig({
input: { index: "src/main/index.ts" }, input: { index: "src/main/index.ts" },
}, },
}, },
plugins: [
{
name: "opencode:virtual-server-module",
enforce: "pre",
resolveId(id) {
if (id === "virtual:opencode-server") return this.resolve(`${OPENCODE_SERVER_DIST}/node.js`)
},
},
{
name: "opencode:copy-server-assets",
enforce: "post",
async closeBundle() {
for (const l of await fs.readdir(OPENCODE_SERVER_DIST)) {
if (l.endsWith(".js")) continue
await fs.writeFile(`./out/main/${l}`, await fs.readFile(`${OPENCODE_SERVER_DIST}/${l}`))
}
},
},
],
}, },
preload: { preload: {
build: { build: {

View File

@ -30,6 +30,7 @@
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4", "@solidjs/router": "0.15.4",
"@valibot/to-json-schema": "1.6.0",
"effect": "catalog:", "effect": "catalog:",
"electron-context-menu": "4.1.2", "electron-context-menu": "4.1.2",
"electron-log": "^5", "electron-log": "^5",
@ -38,7 +39,9 @@
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"marked": "^15", "marked": "^15",
"solid-js": "catalog:", "solid-js": "catalog:",
"tree-kill": "^1.2.2" "sury": "11.0.0-alpha.4",
"tree-kill": "^1.2.2",
"zod-openapi": "5.4.6"
}, },
"devDependencies": { "devDependencies": {
"@actions/artifact": "4.0.0", "@actions/artifact": "4.0.0",

View File

@ -1,17 +1,5 @@
import { $ } from "bun" import { $ } from "bun"
import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}` await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
const RUST_TARGET = Bun.env.RUST_TARGET await $`cd ../opencode && bun script/build-node.ts`
const sidecarConfig = getCurrentSidecar(RUST_TARGET)
const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`)
await (sidecarConfig.ocBinary.includes("-baseline")
? $`cd ../opencode && bun run build --single --baseline`
: $`cd ../opencode && bun run build --single`)
await copyBinaryToSidecarFolder(binaryPath, RUST_TARGET)

View File

@ -1,283 +0,0 @@
import { execFileSync, spawn } from "node:child_process"
import { EventEmitter } from "node:events"
import { chmodSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { dirname, join } from "node:path"
import readline from "node:readline"
import { fileURLToPath } from "node:url"
import { app } from "electron"
import treeKill from "tree-kill"
import { WSL_ENABLED_KEY } from "./constants"
import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
import { store } from "./store"
const CLI_INSTALL_DIR = ".opencode/bin"
const CLI_BINARY_NAME = "opencode"
export type ServerConfig = {
hostname?: string
port?: number
}
export type Config = {
server?: ServerConfig
}
export type TerminatedPayload = { code: number | null; signal: number | null }
export type CommandEvent =
| { type: "stdout"; value: string }
| { type: "stderr"; value: string }
| { type: "error"; value: string }
| { type: "terminated"; value: TerminatedPayload }
| { type: "sqlite"; value: SqliteMigrationProgress }
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
export type CommandChild = {
pid: number | undefined
kill: () => void
}
const root = dirname(fileURLToPath(import.meta.url))
export function getSidecarPath() {
const suffix = process.platform === "win32" ? ".exe" : ""
const path = app.isPackaged
? join(process.resourcesPath, `opencode-cli${suffix}`)
: join(root, "../../resources", `opencode-cli${suffix}`)
console.log(`[cli] Sidecar path resolved: ${path} (isPackaged: ${app.isPackaged})`)
return path
}
export async function getConfig(): Promise<Config | null> {
const { events } = spawnCommand("debug config", {})
let output = ""
await new Promise<void>((resolve) => {
events.on("stdout", (line: string) => {
output += line
})
events.on("stderr", (line: string) => {
output += line
})
events.on("terminated", () => resolve())
events.on("error", () => resolve())
})
try {
return JSON.parse(output) as Config
} catch {
return null
}
}
export async function installCli(): Promise<string> {
if (process.platform === "win32") {
throw new Error("CLI installation is only supported on macOS & Linux")
}
const sidecar = getSidecarPath()
const scriptPath = join(app.getAppPath(), "install")
const script = readFileSync(scriptPath, "utf8")
const tempScript = join(tmpdir(), "opencode-install.sh")
writeFileSync(tempScript, script, "utf8")
chmodSync(tempScript, 0o755)
const cmd = spawn(tempScript, ["--binary", sidecar], { stdio: "pipe" })
return await new Promise<string>((resolve, reject) => {
cmd.on("exit", (code: number | null) => {
try {
unlinkSync(tempScript)
} catch {}
if (code === 0) {
const installPath = getCliInstallPath()
if (installPath) return resolve(installPath)
return reject(new Error("Could not determine install path"))
}
reject(new Error("Install script failed"))
})
})
}
export function syncCli() {
if (!app.isPackaged) return
const installPath = getCliInstallPath()
if (!installPath) return
let version = ""
try {
version = execFileSync(installPath, ["--version"], { windowsHide: true }).toString().trim()
} catch {
return
}
const cli = parseVersion(version)
const appVersion = parseVersion(app.getVersion())
if (!cli || !appVersion) return
if (compareVersions(cli, appVersion) >= 0) return
void installCli().catch(() => undefined)
}
export function serve(hostname: string, port: number, password: string) {
const args = `--print-logs --log-level WARN serve --hostname ${hostname} --port ${port}`
const env = {
OPENCODE_SERVER_USERNAME: "opencode",
OPENCODE_SERVER_PASSWORD: password,
}
return spawnCommand(args, env)
}
export function spawnCommand(args: string, extraEnv: Record<string, string>) {
console.log(`[cli] Spawning command with args: ${args}`)
const base = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
)
const env = {
...base,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
XDG_STATE_HOME: app.getPath("userData"),
...extraEnv,
}
const shell = process.platform === "win32" ? null : getUserShell()
const envs = shell ? mergeShellEnv(loadShellEnv(shell), env) : env
const { cmd, cmdArgs } = buildCommand(args, envs, shell)
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
const child = spawn(cmd, cmdArgs, {
env: envs,
detached: process.platform !== "win32",
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
})
console.log(`[cli] Spawned process with PID: ${child.pid}`)
const events = new EventEmitter()
const exit = new Promise<TerminatedPayload>((resolve) => {
child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => {
console.log(`[cli] Process exited with code: ${code}, signal: ${signal}`)
resolve({ code: code ?? null, signal: null })
})
child.on("error", (error: Error) => {
console.error(`[cli] Process error: ${error.message}`)
events.emit("error", error.message)
})
})
const stdout = child.stdout
const stderr = child.stderr
if (stdout) {
readline.createInterface({ input: stdout }).on("line", (line: string) => {
if (handleSqliteProgress(events, line)) return
events.emit("stdout", `${line}\n`)
})
}
if (stderr) {
readline.createInterface({ input: stderr }).on("line", (line: string) => {
if (handleSqliteProgress(events, line)) return
events.emit("stderr", `${line}\n`)
})
}
exit.then((payload) => {
events.emit("terminated", payload)
})
const kill = () => {
if (!child.pid) return
treeKill(child.pid)
}
return { events, child: { pid: child.pid, kill }, exit }
}
function handleSqliteProgress(events: EventEmitter, line: string) {
const stripped = line.startsWith("sqlite-migration:") ? line.slice("sqlite-migration:".length).trim() : null
if (!stripped) return false
if (stripped === "done") {
events.emit("sqlite", { type: "Done" })
return true
}
const value = Number.parseInt(stripped, 10)
if (!Number.isNaN(value)) {
events.emit("sqlite", { type: "InProgress", value })
return true
}
return false
}
function buildCommand(args: string, env: Record<string, string>, shell: string | null) {
if (process.platform === "win32" && isWslEnabled()) {
console.log(`[cli] Using WSL mode`)
const version = app.getVersion()
const script = [
"set -e",
'BIN="$HOME/.opencode/bin/opencode"',
'if [ ! -x "$BIN" ]; then',
` curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)} --no-modify-path`,
"fi",
`${envPrefix(env)} exec "$BIN" ${args}`,
].join("\n")
return { cmd: "wsl", cmdArgs: ["-e", "bash", "-lc", script] }
}
if (process.platform === "win32") {
const sidecar = getSidecarPath()
console.log(`[cli] Windows direct mode, sidecar: ${sidecar}`)
return { cmd: sidecar, cmdArgs: args.split(" ") }
}
const sidecar = getSidecarPath()
const user = shell || getUserShell()
const line = user.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
console.log(`[cli] Unix mode, shell: ${user}, command: ${line}`)
return { cmd: user, cmdArgs: ["-l", "-c", line] }
}
function envPrefix(env: Record<string, string>) {
const entries = Object.entries(env).map(([key, value]) => `${key}=${shellEscape(value)}`)
return entries.join(" ")
}
function shellEscape(input: string) {
if (!input) return "''"
return `'${input.replace(/'/g, `'"'"'`)}'`
}
function getCliInstallPath() {
const home = process.env.HOME
if (!home) return null
return join(home, CLI_INSTALL_DIR, CLI_BINARY_NAME)
}
function isWslEnabled() {
return store.get(WSL_ENABLED_KEY) === true
}
function parseVersion(value: string) {
const parts = value
.replace(/^v/, "")
.split(".")
.map((part) => Number.parseInt(part, 10))
if (parts.some((part) => Number.isNaN(part))) return null
return parts
}
function compareVersions(a: number[], b: number[]) {
const len = Math.max(a.length, b.length)
for (let i = 0; i < len; i += 1) {
const left = a[i] ?? 0
const right = b[i] ?? 0
if (left > right) return 1
if (left < right) return -1
}
return 0
}

View File

@ -5,3 +5,25 @@ interface ImportMetaEnv {
interface ImportMeta { interface ImportMeta {
readonly env: ImportMetaEnv readonly env: ImportMetaEnv
} }
declare module "virtual:opencode-server" {
export namespace Server {
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener
}
export namespace Config {
export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
export type Info = import("../../../opencode/dist/types/src/node").Config.Info
}
export namespace Log {
export const init: typeof import("../../../opencode/dist/types/src/node").Log.init
}
export namespace Database {
export const Path: typeof import("../../../opencode/dist/types/src/node").Database.Path
export const Client: typeof import("../../../opencode/dist/types/src/node").Database.Client
}
export namespace JsonMigration {
export type Progress = import("../../../opencode/dist/types/src/node").JsonMigration.Progress
export const run: typeof import("../../../opencode/dist/types/src/node").JsonMigration.run
}
export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap
}

View File

@ -27,8 +27,6 @@ const { autoUpdater } = pkg
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
import { checkAppExists, resolveAppPath, wslPath } from "./apps" import { checkAppExists, resolveAppPath, wslPath } from "./apps"
import type { CommandChild } from "./cli"
import { installCli, syncCli } from "./cli"
import { CHANNEL, UPDATER_ENABLED } from "./constants" import { CHANNEL, UPDATER_ENABLED } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
import { initLogging } from "./logging" import { initLogging } from "./logging"
@ -36,12 +34,13 @@ import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu" import { createMenu } from "./menu"
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
import { Server } from "virtual:opencode-server"
const initEmitter = new EventEmitter() const initEmitter = new EventEmitter()
let initStep: InitStep = { phase: "server_waiting" } let initStep: InitStep = { phase: "server_waiting" }
let mainWindow: BrowserWindow | null = null let mainWindow: BrowserWindow | null = null
let sidecar: CommandChild | null = null let server: Server.Listener | null = null
const loadingComplete = defer<void>() const loadingComplete = defer<void>()
const pendingDeepLinks: string[] = [] const pendingDeepLinks: string[] = []
@ -96,11 +95,9 @@ function setupApp() {
} }
void app.whenReady().then(async () => { void app.whenReady().then(async () => {
// migrate()
app.setAsDefaultProtocolClient("opencode") app.setAsDefaultProtocolClient("opencode")
setDockIcon() setDockIcon()
setupAutoUpdater() setupAutoUpdater()
syncCli()
await initialize() await initialize()
}) })
} }
@ -134,8 +131,8 @@ async function initialize() {
const password = randomUUID() const password = randomUUID()
logger.log("spawning sidecar", { url }) logger.log("spawning sidecar", { url })
const { child, health, events } = spawnLocalServer(hostname, port, password) const { listener, health } = await spawnLocalServer(hostname, port, password)
sidecar = child server = listener
serverReady.resolve({ serverReady.resolve({
url, url,
username: "opencode", username: "opencode",
@ -145,7 +142,7 @@ async function initialize() {
const loadingTask = (async () => { const loadingTask = (async () => {
logger.log("sidecar connection started", { url }) logger.log("sidecar connection started", { url })
events.on("sqlite", (progress: SqliteMigrationProgress) => { initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => {
setInitStep({ phase: "sqlite_waiting" }) setInitStep({ phase: "sqlite_waiting" })
if (overlay) sendSqliteMigrationProgress(overlay, progress) if (overlay) sendSqliteMigrationProgress(overlay, progress)
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
@ -198,9 +195,6 @@ function wireMenu() {
if (!mainWindow) return if (!mainWindow) return
createMenu({ createMenu({
trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id), trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
installCli: () => {
void installCli()
},
checkForUpdates: () => { checkForUpdates: () => {
void checkForUpdates(true) void checkForUpdates(true)
}, },
@ -215,7 +209,6 @@ function wireMenu() {
registerIpcHandlers({ registerIpcHandlers({
killSidecar: () => killSidecar(), killSidecar: () => killSidecar(),
installCli: async () => installCli(),
awaitInitialization: async (sendStep) => { awaitInitialization: async (sendStep) => {
sendStep(initStep) sendStep(initStep)
const listener = (step: InitStep) => sendStep(step) const listener = (step: InitStep) => sendStep(step)
@ -247,16 +240,9 @@ registerIpcHandlers({
}) })
function killSidecar() { function killSidecar() {
if (!sidecar) return if (!server) return
const pid = sidecar.pid server.stop()
sidecar.kill() server = null
sidecar = null
// tree-kill is async; also send process group signal as immediate fallback
if (pid && process.platform !== "win32") {
try {
process.kill(-pid, "SIGTERM")
} catch {}
}
} }
function ensureLoopbackNoProxy() { function ensureLoopbackNoProxy() {

View File

@ -13,7 +13,6 @@ const pickerFilters = (ext?: string[]) => {
type Deps = { type Deps = {
killSidecar: () => void killSidecar: () => void
installCli: () => Promise<string>
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData> awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
getDefaultServerUrl: () => Promise<string | null> | string | null getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void setDefaultServerUrl: (url: string | null) => Promise<void> | void
@ -34,7 +33,6 @@ type Deps = {
export function registerIpcHandlers(deps: Deps) { export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("kill-sidecar", () => deps.killSidecar()) ipcMain.handle("kill-sidecar", () => deps.killSidecar())
ipcMain.handle("install-cli", () => deps.installCli())
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => { ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
const send = (step: InitStep) => event.sender.send("init-step", step) const send = (step: InitStep) => event.sender.send("init-step", step)
return deps.awaitInitialization(send) return deps.awaitInitialization(send)

View File

@ -5,7 +5,6 @@ import { createMainWindow } from "./windows"
type Deps = { type Deps = {
trigger: (id: string) => void trigger: (id: string) => void
installCli: () => void
checkForUpdates: () => void checkForUpdates: () => void
reload: () => void reload: () => void
relaunch: () => void relaunch: () => void
@ -24,10 +23,6 @@ export function createMenu(deps: Deps) {
enabled: UPDATER_ENABLED, enabled: UPDATER_ENABLED,
click: () => deps.checkForUpdates(), click: () => deps.checkForUpdates(),
}, },
{
label: "Install CLI...",
click: () => deps.installCli(),
},
{ {
label: "Reload Webview", label: "Reload Webview",
click: () => deps.reload(), click: () => deps.reload(),

View File

@ -1,4 +1,4 @@
import { serve, type CommandChild } from "./cli" import { Server } from "virtual:opencode-server"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import { store } from "./store" import { store } from "./store"
@ -29,8 +29,13 @@ export function setWslConfig(config: WslConfig) {
store.set(WSL_ENABLED_KEY, config.enabled) store.set(WSL_ENABLED_KEY, config.enabled)
} }
export function spawnLocalServer(hostname: string, port: number, password: string) { export async function spawnLocalServer(hostname: string, port: number, password: string) {
const { child, exit, events } = serve(hostname, port, password) const listener = await Server.listen({
port,
hostname,
username: "opencode",
password,
})
const wait = (async () => { const wait = (async () => {
const url = `http://${hostname}:${port}` const url = `http://${hostname}:${port}`
@ -42,19 +47,10 @@ export function spawnLocalServer(hostname: string, port: number, password: strin
} }
} }
const terminated = async () => { await ready()
const payload = await exit
throw new Error(
`Sidecar terminated before becoming healthy (code=${payload.code ?? "unknown"} signal=${
payload.signal ?? "unknown"
})`,
)
}
await Promise.race([ready(), terminated()])
})() })()
return { child, health: { wait }, events } return { listener, health: { wait } }
} }
export async function checkHealth(url: string, password?: string | null): Promise<boolean> { export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
@ -82,5 +78,3 @@ export async function checkHealth(url: string, password?: string | null): Promis
return false return false
} }
} }
export type { CommandChild }

View File

@ -58,7 +58,7 @@ await $`bun install --linker=${link} --os="*" --cpu="*" @lydell/node-pty@1.2.0-b
await Bun.build({ await Bun.build({
target: "node", target: "node",
entrypoints: ["./src/node.ts"], entrypoints: ["./src/node.ts"],
outdir: "./dist", outdir: "./dist/node",
format: "esm", format: "esm",
sourcemap: "linked", sourcemap: "linked",
external: ["jsonc-parser"], external: ["jsonc-parser"],

View File

@ -1,6 +1,7 @@
import type { Argv } from "yargs" import type { Argv } from "yargs"
import { spawn } from "child_process" import { spawn } from "child_process"
import { Database } from "../../storage/db" import { Database } from "../../storage/db"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { Database as BunDatabase } from "bun:sqlite" import { Database as BunDatabase } from "bun:sqlite"
import { UI } from "../ui" import { UI } from "../ui"
import { cmd } from "./cmd" import { cmd } from "./cmd"
@ -74,7 +75,7 @@ const MigrateCommand = cmd({
let last = -1 let last = -1
if (tty) process.stderr.write("\x1b[?25l") if (tty) process.stderr.write("\x1b[?25l")
try { try {
const stats = await JsonMigration.run(sqlite, { const stats = await JsonMigration.run(drizzle({ client: sqlite }), {
progress: (event) => { progress: (event) => {
const percent = Math.floor((event.current / event.total) * 100) const percent = Math.floor((event.current / event.total) * 100)
if (percent === last) return if (percent === last) return

View File

@ -7,7 +7,7 @@ import { Flag } from "../../flag/flag"
import { bootstrap } from "../bootstrap" import { bootstrap } from "../bootstrap"
import { EOL } from "os" import { EOL } from "os"
import { Filesystem } from "../../util/filesystem" import { Filesystem } from "../../util/filesystem"
import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server" import { Server } from "../../server/server"
import { Provider } from "../../provider/provider" import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
@ -667,7 +667,7 @@ export const RunCommand = cmd({
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init) const request = new Request(input, init)
return Server.Default().fetch(request) return Server.Default().app.fetch(request)
}) as typeof globalThis.fetch }) as typeof globalThis.fetch
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
await execute(sdk) await execute(sdk)

View File

@ -125,7 +125,7 @@ export const rpc = {
headers, headers,
body: input.body, body: input.body,
}) })
const response = await Server.Default().fetch(request) const response = await Server.Default().app.fetch(request)
const body = await response.text() const body = await response.text()
return { return {
status: response.status, status: response.status,

View File

@ -36,6 +36,7 @@ import { Database } from "./storage/db"
import { errorMessage } from "./util/error" import { errorMessage } from "./util/error"
import { PluginCommand } from "./cli/cmd/plug" import { PluginCommand } from "./cli/cmd/plug"
import { Heap } from "./cli/heap" import { Heap } from "./cli/heap"
import { drizzle } from "drizzle-orm/bun-sqlite"
process.on("unhandledRejection", (e) => { process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", { Log.Default.error("rejection", {
@ -119,7 +120,7 @@ const cli = yargs(args)
let last = -1 let last = -1
if (tty) process.stderr.write("\x1b[?25l") if (tty) process.stderr.write("\x1b[?25l")
try { try {
await JsonMigration.run(Database.Client().$client, { await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
progress: (event) => { progress: (event) => {
const percent = Math.floor((event.current / event.total) * 100) const percent = Math.floor((event.current / event.total) * 100)
if (percent === last && event.current !== event.total) return if (percent === last && event.current !== event.total) return

View File

@ -1 +1,6 @@
export { Config } from "./config/config"
export { Server } from "./server/server" export { Server } from "./server/server"
export { bootstrap } from "./cli/bootstrap"
export { Log } from "./util/log"
export { Database } from "./storage/db"
export { JsonMigration } from "./storage/json-migration"

View File

@ -119,7 +119,7 @@ export namespace Plugin {
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`, Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
} }
: undefined, : undefined,
fetch: async (...args) => Server.Default().fetch(...args), fetch: async (...args) => Server.Default().app.fetch(...args),
}) })
const cfg = yield* config.get() const cfg = yield* config.get()
const input: PluginInput = { const input: PluginInput = {

View File

@ -0,0 +1,2 @@
// Auto-generated by build.ts - do not edit
export declare const snapshot: Record<string, unknown>

File diff suppressed because one or more lines are too long

View File

@ -843,9 +843,6 @@ export const SessionRoutes = lazy(() =>
), ),
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })), validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
async (c) => { async (c) => {
c.status(204)
c.header("Content-Type", "application/json")
return stream(c, async () => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json") const body = c.req.valid("json")
SessionPrompt.prompt({ ...body, sessionID }).catch((err) => { SessionPrompt.prompt({ ...body, sessionID }).catch((err) => {
@ -855,7 +852,8 @@ export const SessionRoutes = lazy(() =>
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(), error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
}) })
}) })
})
return c.body(null, 204)
}, },
) )
.post( .post(

View File

@ -2,6 +2,7 @@ import { Log } from "../util/log"
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi" import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import { Hono } from "hono" import { Hono } from "hono"
import { compress } from "hono/compress" import { compress } from "hono/compress"
import { createNodeWebSocket } from "@hono/node-ws"
import { cors } from "hono/cors" import { cors } from "hono/cors"
import { basicAuth } from "hono/basic-auth" import { basicAuth } from "hono/basic-auth"
import type { UpgradeWebSocket } from "hono/ws" import type { UpgradeWebSocket } from "hono/ws"
@ -9,8 +10,6 @@ import z from "zod"
import { Auth } from "../auth" import { Auth } from "../auth"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { ProviderID } from "../provider/schema" import { ProviderID } from "../provider/schema"
import { createAdaptorServer, type ServerType } from "@hono/node-server"
import { createNodeWebSocket } from "@hono/node-ws"
import { WorkspaceRouterMiddleware } from "./router" import { WorkspaceRouterMiddleware } from "./router"
import { errors } from "./error" import { errors } from "./error"
import { GlobalRoutes } from "./routes/global" import { GlobalRoutes } from "./routes/global"
@ -19,6 +18,7 @@ import { lazy } from "@/util/lazy"
import { errorHandler } from "./middleware" import { errorHandler } from "./middleware"
import { InstanceRoutes } from "./instance" import { InstanceRoutes } from "./instance"
import { initProjectors } from "./projectors" import { initProjectors } from "./projectors"
import { createAdaptorServer, type ServerType } from "@hono/node-server"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false globalThis.AI_SDK_LOG_WARNINGS = false
@ -42,7 +42,7 @@ export namespace Server {
return false return false
} }
export const Default = lazy(() => create({}).app) export const Default = lazy(() => create({}))
export function ControlPlaneRoutes(upgrade: UpgradeWebSocket, app = new Hono(), opts?: { cors?: string[] }): Hono { export function ControlPlaneRoutes(upgrade: UpgradeWebSocket, app = new Hono(), opts?: { cors?: string[] }): Hono {
return app return app

View File

@ -1,5 +1,5 @@
import { Database } from "bun:sqlite" import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite" import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite"
import { Global } from "../global" import { Global } from "../global"
import { Log } from "../util/log" import { Log } from "../util/log"
import { ProjectTable } from "../project/project.sql" import { ProjectTable } from "../project/project.sql"
@ -23,7 +23,7 @@ export namespace JsonMigration {
progress?: (event: Progress) => void progress?: (event: Progress) => void
} }
export async function run(sqlite: Database, options?: Options) { export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<any, any>, options?: Options) {
const storageDir = path.join(Global.Path.data, "storage") const storageDir = path.join(Global.Path.data, "storage")
if (!existsSync(storageDir)) { if (!existsSync(storageDir)) {
@ -43,13 +43,13 @@ export namespace JsonMigration {
log.info("starting json to sqlite migration", { storageDir }) log.info("starting json to sqlite migration", { storageDir })
const start = performance.now() const start = performance.now()
const db = drizzle({ client: sqlite }) // const db = drizzle({ client: sqlite })
// Optimize SQLite for bulk inserts // Optimize SQLite for bulk inserts
sqlite.exec("PRAGMA journal_mode = WAL") db.run("PRAGMA journal_mode = WAL")
sqlite.exec("PRAGMA synchronous = OFF") db.run("PRAGMA synchronous = OFF")
sqlite.exec("PRAGMA cache_size = 10000") db.run("PRAGMA cache_size = 10000")
sqlite.exec("PRAGMA temp_store = MEMORY") db.run("PRAGMA temp_store = MEMORY")
const stats = { const stats = {
projects: 0, projects: 0,
sessions: 0, sessions: 0,
@ -146,7 +146,7 @@ export namespace JsonMigration {
progress?.({ current, total, label: "starting" }) progress?.({ current, total, label: "starting" })
sqlite.exec("BEGIN TRANSACTION") db.run("BEGIN TRANSACTION")
// Migrate projects first (no FK deps) // Migrate projects first (no FK deps)
// Derive all IDs from file paths, not JSON content // Derive all IDs from file paths, not JSON content
@ -400,7 +400,7 @@ export namespace JsonMigration {
log.warn("skipped orphaned session shares", { count: orphans.shares }) log.warn("skipped orphaned session shares", { count: orphans.shares })
} }
sqlite.exec("COMMIT") db.run("COMMIT")
log.info("json migration complete", { log.info("json migration complete", {
projects: stats.projects, projects: stats.projects,

View File

@ -19,7 +19,7 @@ afterEach(async () => {
describe("project.initGit endpoint", () => { describe("project.initGit endpoint", () => {
test("initializes git and reloads immediately", async () => { test("initializes git and reloads immediately", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const app = Server.Default() const app = Server.Default().app
const seen: { directory?: string; payload: { type: string } }[] = [] const seen: { directory?: string; payload: { type: string } }[] = []
const fn = (evt: { directory?: string; payload: { type: string } }) => { const fn = (evt: { directory?: string; payload: { type: string } }) => {
seen.push(evt) seen.push(evt)
@ -76,7 +76,7 @@ describe("project.initGit endpoint", () => {
test("does not reload when the project is already git", async () => { test("does not reload when the project is already git", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const app = Server.Default() const app = Server.Default().app
const seen: { directory?: string; payload: { type: string } }[] = [] const seen: { directory?: string; payload: { type: string } }[] = []
const fn = (evt: { directory?: string; payload: { type: string } }) => { const fn = (evt: { directory?: string; payload: { type: string } }) => {
seen.push(evt) seen.push(evt)

View File

@ -42,7 +42,7 @@ describe("session action routes", () => {
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await Session.create({})
const cancel = spyOn(SessionPrompt, "cancel").mockResolvedValue() const cancel = spyOn(SessionPrompt, "cancel").mockResolvedValue()
const app = Server.Default() const app = Server.Default().app
const res = await app.request(`/session/${session.id}/abort`, { const res = await app.request(`/session/${session.id}/abort`, {
method: "POST", method: "POST",
@ -66,7 +66,7 @@ describe("session action routes", () => {
const msg = await user(session.id, "hello") const msg = await user(session.id, "hello")
const busy = spyOn(SessionPrompt, "assertNotBusy").mockRejectedValue(new Session.BusyError(session.id)) const busy = spyOn(SessionPrompt, "assertNotBusy").mockRejectedValue(new Session.BusyError(session.id))
const remove = spyOn(Session, "removeMessage").mockResolvedValue(msg.id) const remove = spyOn(Session, "removeMessage").mockResolvedValue(msg.id)
const app = Server.Default() const app = Server.Default().app
const res = await app.request(`/session/${session.id}/message/${msg.id}`, { const res = await app.request(`/session/${session.id}/message/${msg.id}`, {
method: "DELETE", method: "DELETE",

View File

@ -60,7 +60,7 @@ describe("session messages endpoint", () => {
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await Session.create({})
const ids = await fill(session.id, 5) const ids = await fill(session.id, 5)
const app = Server.Default() const app = Server.Default().app
const a = await app.request(`/session/${session.id}/message?limit=2`) const a = await app.request(`/session/${session.id}/message?limit=2`)
expect(a.status).toBe(200) expect(a.status).toBe(200)
@ -89,7 +89,7 @@ describe("session messages endpoint", () => {
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await Session.create({})
const ids = await fill(session.id, 3) const ids = await fill(session.id, 3)
const app = Server.Default() const app = Server.Default().app
const res = await app.request(`/session/${session.id}/message`) const res = await app.request(`/session/${session.id}/message`)
expect(res.status).toBe(200) expect(res.status).toBe(200)
@ -109,7 +109,7 @@ describe("session messages endpoint", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await Session.create({})
const app = Server.Default() const app = Server.Default().app
const bad = await app.request(`/session/${session.id}/message?limit=2&before=bad`) const bad = await app.request(`/session/${session.id}/message?limit=2&before=bad`)
expect(bad.status).toBe(400) expect(bad.status).toBe(400)
@ -131,7 +131,7 @@ describe("session messages endpoint", () => {
fn: async () => { fn: async () => {
const session = await Session.create({}) const session = await Session.create({})
await fill(session.id, 520) await fill(session.id, 520)
const app = Server.Default() const app = Server.Default().app
const res = await app.request(`/session/${session.id}/message?limit=510`) const res = await app.request(`/session/${session.id}/message?limit=510`)
expect(res.status).toBe(200) expect(res.status).toBe(200)

View File

@ -21,7 +21,7 @@ describe("tui.selectSession endpoint", () => {
const session = await Session.create({}) const session = await Session.create({})
// #when // #when
const app = Server.Default() const app = Server.Default().app
const response = await app.request("/tui/select-session", { const response = await app.request("/tui/select-session", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -47,7 +47,7 @@ describe("tui.selectSession endpoint", () => {
const nonExistentSessionID = "ses_nonexistent123" const nonExistentSessionID = "ses_nonexistent123"
// #when // #when
const app = Server.Default() const app = Server.Default().app
const response = await app.request("/tui/select-session", { const response = await app.request("/tui/select-session", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -69,7 +69,7 @@ describe("tui.selectSession endpoint", () => {
const invalidSessionID = "invalid_session_id" const invalidSessionID = "invalid_session_id"
// #when // #when
const app = Server.Default() const app = Server.Default().app
const response = await app.request("/tui/select-session", { const response = await app.request("/tui/select-session", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },

View File

@ -1,6 +1,6 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test" import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { Database } from "bun:sqlite" import { Database } from "bun:sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite" import { drizzle, SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import { migrate } from "drizzle-orm/bun-sqlite/migrator" import { migrate } from "drizzle-orm/bun-sqlite/migrator"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
@ -89,18 +89,21 @@ function createTestDb() {
name: entry.name, name: entry.name,
})) }))
.sort((a, b) => a.timestamp - b.timestamp) .sort((a, b) => a.timestamp - b.timestamp)
migrate(drizzle({ client: sqlite }), migrations)
return sqlite const db = drizzle({ client: sqlite })
migrate(db, migrations)
return [sqlite, db] as const
} }
describe("JSON to SQLite migration", () => { describe("JSON to SQLite migration", () => {
let storageDir: string let storageDir: string
let sqlite: Database let sqlite: Database
let db: SQLiteBunDatabase
beforeEach(async () => { beforeEach(async () => {
storageDir = await setupStorageDir() storageDir = await setupStorageDir()
sqlite = createTestDb() ;[sqlite, db] = createTestDb()
}) })
afterEach(async () => { afterEach(async () => {
@ -118,11 +121,10 @@ describe("JSON to SQLite migration", () => {
sandboxes: ["/test/sandbox"], sandboxes: ["/test/sandbox"],
}) })
const stats = await JsonMigration.run(sqlite) const stats = await JsonMigration.run(db)
expect(stats?.projects).toBe(1) expect(stats?.projects).toBe(1)
const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all() const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1) expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_test123abc")) expect(projects[0].id).toBe(ProjectID.make("proj_test123abc"))
@ -143,11 +145,10 @@ describe("JSON to SQLite migration", () => {
}), }),
) )
const stats = await JsonMigration.run(sqlite) const stats = await JsonMigration.run(db)
expect(stats?.projects).toBe(1) expect(stats?.projects).toBe(1)
const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all() const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1) expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_filename")) // Uses filename, not JSON id expect(projects[0].id).toBe(ProjectID.make("proj_filename")) // Uses filename, not JSON id
@ -164,11 +165,10 @@ describe("JSON to SQLite migration", () => {
commands: { start: "npm run dev" }, commands: { start: "npm run dev" },
}) })
const stats = await JsonMigration.run(sqlite) const stats = await JsonMigration.run(db)
expect(stats?.projects).toBe(1) expect(stats?.projects).toBe(1)
const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all() const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1) expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_with_commands")) expect(projects[0].id).toBe(ProjectID.make("proj_with_commands"))
@ -185,11 +185,10 @@ describe("JSON to SQLite migration", () => {
sandboxes: [], sandboxes: [],
}) })
const stats = await JsonMigration.run(sqlite) const stats = await JsonMigration.run(db)
expect(stats?.projects).toBe(1) expect(stats?.projects).toBe(1)
const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all() const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1) expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_no_commands")) expect(projects[0].id).toBe(ProjectID.make("proj_no_commands"))
@ -216,9 +215,8 @@ describe("JSON to SQLite migration", () => {
share: { url: "https://example.com/share" }, share: { url: "https://example.com/share" },
}) })
await JsonMigration.run(sqlite) await JsonMigration.run(db)
const db = drizzle({ client: sqlite })
const sessions = db.select().from(SessionTable).all() const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1) expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe(SessionID.make("ses_test456def")) expect(sessions[0].id).toBe(SessionID.make("ses_test456def"))
@ -247,12 +245,11 @@ describe("JSON to SQLite migration", () => {
JSON.stringify({ ...fixtures.part }), JSON.stringify({ ...fixtures.part }),
) )
const stats = await JsonMigration.run(sqlite) const stats = await JsonMigration.run(db)
expect(stats?.messages).toBe(1) expect(stats?.messages).toBe(1)
expect(stats?.parts).toBe(1) expect(stats?.parts).toBe(1)
const db = drizzle({ client: sqlite })
const messages = db.select().from(MessageTable).all() const messages = db.select().from(MessageTable).all()
expect(messages.length).toBe(1) expect(messages.length).toBe(1)
expect(messages[0].id).toBe(MessageID.make("msg_test789ghi")) expect(messages[0].id).toBe(MessageID.make("msg_test789ghi"))
@ -287,12 +284,11 @@ describe("JSON to SQLite migration", () => {
}), }),
) )
const stats = await JsonMigration.run(sqlite) const stats = await JsonMigration.run(db)
expect(stats?.messages).toBe(1) expect(stats?.messages).toBe(1)
expect(stats?.parts).toBe(1) expect(stats?.parts).toBe(1)
const db = drizzle({ client: sqlite })
const messages = db.select().from(MessageTable).all() const messages = db.select().from(MessageTable).all()
expect(messages.length).toBe(1) expect(messages.length).toBe(1)
expect(messages[0].id).toBe(MessageID.make("msg_test789ghi")) expect(messages[0].id).toBe(MessageID.make("msg_test789ghi"))
@ -329,11 +325,10 @@ describe("JSON to SQLite migration", () => {
}), }),
) )
const stats = await JsonMigration.run(sqlite) const stats = await JsonMigration.run(db)
expect(stats?.messages).toBe(1) expect(stats?.messages).toBe(1)
const db = drizzle({ client: sqlite })
const messages = db.select().from(MessageTable).all() const messages = db.select().from(MessageTable).all()
expect(messages.length).toBe(1) expect(messages.length).toBe(1)
expect(messages[0].id).toBe(MessageID.make("msg_from_filename")) // Uses filename, not JSON id expect(messages[0].id).toBe(MessageID.make("msg_from_filename")) // Uses filename, not JSON id
@ -367,11 +362,10 @@ describe("JSON to SQLite migration", () => {
}), }),
) )
const stats = await JsonMigration.run(sqlite) const stats = await JsonMigration.run(db)
expect(stats?.parts).toBe(1) expect(stats?.parts).toBe(1)
const db = drizzle({ client: sqlite })
const parts = db.select().from(PartTable).all() const parts = db.select().from(PartTable).all()
expect(parts.length).toBe(1) expect(parts.length).toBe(1)
expect(parts[0].id).toBe(PartID.make("prt_from_filename")) // Uses filename, not JSON id expect(parts[0].id).toBe(PartID.make("prt_from_filename")) // Uses filename, not JSON id
@ -392,7 +386,7 @@ describe("JSON to SQLite migration", () => {
}), }),
) )
const stats = await JsonMigration.run(sqlite) const stats = await JsonMigration.run(db)
expect(stats?.sessions).toBe(0) expect(stats?.sessions).toBe(0)
}) })
@ -420,11 +414,10 @@ describe("JSON to SQLite migration", () => {
time: { created: 1700000000000, updated: 1700000001000 }, time: { created: 1700000000000, updated: 1700000001000 },
}) })
const stats = await JsonMigration.run(sqlite) const stats = await JsonMigration.run(db)
expect(stats?.sessions).toBe(1) expect(stats?.sessions).toBe(1)
const db = drizzle({ client: sqlite })
const sessions = db.select().from(SessionTable).all() const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1) expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe(SessionID.make("ses_migrated")) expect(sessions[0].id).toBe(SessionID.make("ses_migrated"))
@ -452,11 +445,10 @@ describe("JSON to SQLite migration", () => {
}), }),
) )
const stats = await JsonMigration.run(sqlite) const stats = await JsonMigration.run(db)
expect(stats?.sessions).toBe(1) expect(stats?.sessions).toBe(1)
const db = drizzle({ client: sqlite })
const sessions = db.select().from(SessionTable).all() const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1) expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe(SessionID.make("ses_from_filename")) // Uses filename, not JSON id expect(sessions[0].id).toBe(SessionID.make("ses_from_filename")) // Uses filename, not JSON id
@ -471,10 +463,9 @@ describe("JSON to SQLite migration", () => {
sandboxes: [], sandboxes: [],
}) })
await JsonMigration.run(sqlite) await JsonMigration.run(db)
await JsonMigration.run(sqlite) await JsonMigration.run(db)
const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all() const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1) // Still only 1 due to onConflictDoNothing expect(projects.length).toBe(1) // Still only 1 due to onConflictDoNothing
}) })
@ -507,11 +498,10 @@ describe("JSON to SQLite migration", () => {
]), ]),
) )
const stats = await JsonMigration.run(sqlite) const stats = await JsonMigration.run(db)
expect(stats?.todos).toBe(2) expect(stats?.todos).toBe(2)
const db = drizzle({ client: sqlite })
const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all() const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
expect(todos.length).toBe(2) expect(todos.length).toBe(2)
expect(todos[0].content).toBe("First todo") expect(todos[0].content).toBe("First todo")
@ -540,9 +530,8 @@ describe("JSON to SQLite migration", () => {
]), ]),
) )
await JsonMigration.run(sqlite) await JsonMigration.run(db)
const db = drizzle({ client: sqlite })
const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all() const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
expect(todos.length).toBe(3) expect(todos.length).toBe(3)
@ -570,11 +559,10 @@ describe("JSON to SQLite migration", () => {
] ]
await Bun.write(path.join(storageDir, "permission", "proj_test123abc.json"), JSON.stringify(permissionData)) await Bun.write(path.join(storageDir, "permission", "proj_test123abc.json"), JSON.stringify(permissionData))
const stats = await JsonMigration.run(sqlite) const stats = await JsonMigration.run(db)
expect(stats?.permissions).toBe(1) expect(stats?.permissions).toBe(1)
const db = drizzle({ client: sqlite })
const permissions = db.select().from(PermissionTable).all() const permissions = db.select().from(PermissionTable).all()
expect(permissions.length).toBe(1) expect(permissions.length).toBe(1)
expect(permissions[0].project_id).toBe("proj_test123abc") expect(permissions[0].project_id).toBe("proj_test123abc")
@ -600,11 +588,10 @@ describe("JSON to SQLite migration", () => {
}), }),
) )
const stats = await JsonMigration.run(sqlite) const stats = await JsonMigration.run(db)
expect(stats?.shares).toBe(1) expect(stats?.shares).toBe(1)
const db = drizzle({ client: sqlite })
const shares = db.select().from(SessionShareTable).all() const shares = db.select().from(SessionShareTable).all()
expect(shares.length).toBe(1) expect(shares.length).toBe(1)
expect(shares[0].session_id).toBe("ses_test456def") expect(shares[0].session_id).toBe("ses_test456def")
@ -616,7 +603,7 @@ describe("JSON to SQLite migration", () => {
test("returns empty stats when storage directory does not exist", async () => { test("returns empty stats when storage directory does not exist", async () => {
await fs.rm(storageDir, { recursive: true, force: true }) await fs.rm(storageDir, { recursive: true, force: true })
const stats = await JsonMigration.run(sqlite) const stats = await JsonMigration.run(db)
expect(stats.projects).toBe(0) expect(stats.projects).toBe(0)
expect(stats.sessions).toBe(0) expect(stats.sessions).toBe(0)
@ -637,12 +624,11 @@ describe("JSON to SQLite migration", () => {
}) })
await Bun.write(path.join(storageDir, "project", "broken.json"), "{ invalid json") await Bun.write(path.join(storageDir, "project", "broken.json"), "{ invalid json")
const stats = await JsonMigration.run(sqlite) const stats = await JsonMigration.run(db)
expect(stats.projects).toBe(1) expect(stats.projects).toBe(1)
expect(stats.errors.some((x) => x.includes("failed to read") && x.includes("broken.json"))).toBe(true) expect(stats.errors.some((x) => x.includes("failed to read") && x.includes("broken.json"))).toBe(true)
const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all() const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1) expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectID.make("proj_test123abc")) expect(projects[0].id).toBe(ProjectID.make("proj_test123abc"))
@ -666,10 +652,9 @@ describe("JSON to SQLite migration", () => {
]), ]),
) )
const stats = await JsonMigration.run(sqlite) const stats = await JsonMigration.run(db)
expect(stats.todos).toBe(2) expect(stats.todos).toBe(2)
const db = drizzle({ client: sqlite })
const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all() const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
expect(todos.length).toBe(2) expect(todos.length).toBe(2)
expect(todos[0].content).toBe("keep-0") expect(todos[0].content).toBe("keep-0")
@ -714,13 +699,12 @@ describe("JSON to SQLite migration", () => {
JSON.stringify({ id: "share_missing", secret: "secret", url: "https://missing.example.com" }), JSON.stringify({ id: "share_missing", secret: "secret", url: "https://missing.example.com" }),
) )
const stats = await JsonMigration.run(sqlite) const stats = await JsonMigration.run(db)
expect(stats.todos).toBe(1) expect(stats.todos).toBe(1)
expect(stats.permissions).toBe(1) expect(stats.permissions).toBe(1)
expect(stats.shares).toBe(1) expect(stats.shares).toBe(1)
const db = drizzle({ client: sqlite })
expect(db.select().from(TodoTable).all().length).toBe(1) expect(db.select().from(TodoTable).all().length).toBe(1)
expect(db.select().from(PermissionTable).all().length).toBe(1) expect(db.select().from(PermissionTable).all().length).toBe(1)
expect(db.select().from(SessionShareTable).all().length).toBe(1) expect(db.select().from(SessionShareTable).all().length).toBe(1)
@ -823,7 +807,7 @@ describe("JSON to SQLite migration", () => {
) )
await Bun.write(path.join(storageDir, "session_share", "ses_broken.json"), "{ nope") await Bun.write(path.join(storageDir, "session_share", "ses_broken.json"), "{ nope")
const stats = await JsonMigration.run(sqlite) const stats = await JsonMigration.run(db)
// Projects: proj_test123abc (valid), proj_missing_id (now derives id from filename) // Projects: proj_test123abc (valid), proj_missing_id (now derives id from filename)
// Sessions: ses_test456def (valid), ses_missing_project (now uses dir path), // Sessions: ses_test456def (valid), ses_missing_project (now uses dir path),
@ -837,7 +821,6 @@ describe("JSON to SQLite migration", () => {
expect(stats.shares).toBe(1) expect(stats.shares).toBe(1)
expect(stats.errors.length).toBeGreaterThanOrEqual(6) expect(stats.errors.length).toBeGreaterThanOrEqual(6)
const db = drizzle({ client: sqlite })
expect(db.select().from(ProjectTable).all().length).toBe(2) expect(db.select().from(ProjectTable).all().length).toBe(2)
expect(db.select().from(SessionTable).all().length).toBe(3) expect(db.select().from(SessionTable).all().length).toBe(3)
expect(db.select().from(MessageTable).all().length).toBe(1) expect(db.select().from(MessageTable).all().length).toBe(1)