mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-20 13:44:27 +00:00
Compare commits
101 Commits
kit/effect
...
brendan/el
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7010a05196 | ||
|
|
0f8520a48a | ||
|
|
3eeeec359a | ||
|
|
65e786258a | ||
|
|
cbc40a5981 | ||
|
|
b9b210a864 | ||
|
|
d473b7e971 | ||
|
|
f5783c4313 | ||
|
|
9439a5647e | ||
|
|
2bfe81ee5c | ||
|
|
fcf1bb010c | ||
|
|
08b6d9c6dc | ||
|
|
0293a8bb80 | ||
|
|
850dbb93eb | ||
|
|
48e867ee20 | ||
|
|
b5ebc541b9 | ||
|
|
bd7a4cec90 | ||
|
|
63af295a17 | ||
|
|
8f0f334d16 | ||
|
|
96df5b48ec | ||
|
|
3e8a528a51 | ||
|
|
74381ce00e | ||
|
|
7a9e6c78b1 | ||
|
|
bf72883439 | ||
|
|
324d6cbcbf | ||
|
|
98e035e257 | ||
|
|
9b134233c2 | ||
|
|
340791292a | ||
|
|
02ac0f0cd3 | ||
|
|
04954a9620 | ||
|
|
fb63fd79a3 | ||
|
|
2e04b66eab | ||
|
|
f0b7c8c374 | ||
|
|
be6f59035a | ||
|
|
27ab51f490 | ||
|
|
bca723e8fe | ||
|
|
1ac39718d8 | ||
|
|
190319fb56 | ||
|
|
3154f0a61c | ||
|
|
0b686b8178 | ||
|
|
4cba56171b | ||
|
|
66342acd31 | ||
|
|
88dae67549 | ||
|
|
0ec42582f3 | ||
|
|
4f82248a68 | ||
|
|
5e069aab97 | ||
|
|
5325b2ec99 | ||
|
|
2a98920922 | ||
|
|
5ea92ea6cb | ||
|
|
a18528a7ee | ||
|
|
ced125a974 | ||
|
|
655fe20beb | ||
|
|
dd0c258e23 | ||
|
|
791e27d289 | ||
|
|
fac0aec69f | ||
|
|
ca26e639f6 | ||
|
|
0b5d54f2cb | ||
|
|
1b408cf06b | ||
|
|
8e102d19ed | ||
|
|
721b2406e9 | ||
|
|
4a6a18cd79 | ||
|
|
c10b5880cc | ||
|
|
e6bf83084c | ||
|
|
6722ee22ee | ||
|
|
870a5731ac | ||
|
|
7910ce5d36 | ||
|
|
6ad171dba9 | ||
|
|
cb5674edc7 | ||
|
|
b99de4118e | ||
|
|
040700dbc4 | ||
|
|
4d5da9697e | ||
|
|
a28648f530 | ||
|
|
4d81e2d4d9 | ||
|
|
21e72cbf42 | ||
|
|
5f277d1e62 | ||
|
|
d67e877e28 | ||
|
|
d4e51e04b3 | ||
|
|
070c1679e4 | ||
|
|
406d216cd2 | ||
|
|
5dc8b4ef29 | ||
|
|
2f41d89163 | ||
|
|
b2eae867a1 | ||
|
|
3c2fda4d91 | ||
|
|
2678ceb45e | ||
|
|
58a4cd00b6 | ||
|
|
0faa191b6d | ||
|
|
58cf092105 | ||
|
|
0ff8bfe1d9 | ||
|
|
ceb79c786a | ||
|
|
b1a15d559b | ||
|
|
124a8abf9b | ||
|
|
85c2bb342b | ||
|
|
4c57e39466 | ||
|
|
0cdd4e4e16 | ||
|
|
a9b01be0c2 | ||
|
|
528daf5490 | ||
|
|
0e176d3ac3 | ||
|
|
27f359852e | ||
|
|
173128d431 | ||
|
|
e8ee1e239f | ||
|
|
656fa191c1 |
@@ -112,6 +112,7 @@
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
|
||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
|
||||
"hono-openapi@1.1.2": "patches/hono-openapi@1.1.2.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ try {
|
||||
|
||||
const servermod = await import("../../opencode/src/server/server")
|
||||
inst = await import("../../opencode/src/project/instance")
|
||||
server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
|
||||
server = await servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
|
||||
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
|
||||
|
||||
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig } from "electron-vite"
|
||||
import * as fs from "node:fs/promises"
|
||||
import appPlugin from "@opencode-ai/app/vite"
|
||||
import { defineConfig } from "electron-vite"
|
||||
|
||||
const channel = (() => {
|
||||
const raw = process.env.OPENCODE_CHANNEL
|
||||
@@ -7,6 +8,8 @@ const channel = (() => {
|
||||
return "dev"
|
||||
})()
|
||||
|
||||
const OPENCODE_SERVER_DIST = "../opencode/dist/node"
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
define: {
|
||||
@@ -17,6 +20,25 @@ export default defineConfig({
|
||||
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: {
|
||||
build: {
|
||||
|
||||
@@ -30,14 +30,18 @@
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"@valibot/to-json-schema": "1.5.0",
|
||||
"effect": "catalog:",
|
||||
"electron-log": "^5",
|
||||
"electron-store": "^10",
|
||||
"electron-updater": "^6",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"marked": "^15",
|
||||
"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": {
|
||||
"@actions/artifact": "4.0.0",
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
import { $ } from "bun"
|
||||
|
||||
import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
|
||||
|
||||
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
|
||||
|
||||
const RUST_TARGET = Bun.env.RUST_TARGET
|
||||
|
||||
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)
|
||||
await $`cd ../opencode && bun script/build-node.ts && bun tsgo`
|
||||
|
||||
@@ -1,279 +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 { 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 = {
|
||||
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 envs = {
|
||||
...base,
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
XDG_STATE_HOME: app.getPath("userData"),
|
||||
...extraEnv,
|
||||
}
|
||||
|
||||
const { cmd, cmdArgs } = buildCommand(args, envs)
|
||||
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: { 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>) {
|
||||
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 shell = process.env.SHELL || "/bin/sh"
|
||||
const line = shell.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
|
||||
console.log(`[cli] Unix mode, shell: ${shell}, command: ${line}`)
|
||||
return { cmd: shell, 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
|
||||
}
|
||||
23
packages/desktop-electron/src/main/env.d.ts
vendored
23
packages/desktop-electron/src/main/env.d.ts
vendored
@@ -5,3 +5,26 @@ interface ImportMetaEnv {
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
declare module "virtual:opencode-server" {
|
||||
export namespace Server {
|
||||
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
|
||||
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
|
||||
}
|
||||
|
||||
@@ -22,23 +22,43 @@ app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev")
|
||||
app.setPath("userData", join(app.getPath("appData"), app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"))
|
||||
const { autoUpdater } = pkg
|
||||
|
||||
import { Database, JsonMigration, Log, type Server } from "virtual:opencode-server"
|
||||
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
|
||||
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
|
||||
import type { CommandChild } from "./cli"
|
||||
import { installCli, syncCli } from "./cli"
|
||||
import { CHANNEL, UPDATER_ENABLED } from "./constants"
|
||||
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
|
||||
import { initLogging } from "./logging"
|
||||
import { parseMarkdown } from "./markdown"
|
||||
import { createMenu } from "./menu"
|
||||
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
|
||||
import {
|
||||
checkHealth,
|
||||
checkHealthOrAskRetry,
|
||||
getDefaultServerUrl,
|
||||
getSavedServerUrl,
|
||||
getWslConfig,
|
||||
setDefaultServerUrl,
|
||||
setWslConfig,
|
||||
spawnLocalServer,
|
||||
} from "./server"
|
||||
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
|
||||
|
||||
type ServerConnection =
|
||||
| { variant: "existing"; url: string }
|
||||
| {
|
||||
variant: "local"
|
||||
url: string
|
||||
password: null | string
|
||||
health: {
|
||||
wait: Promise<void>
|
||||
}
|
||||
server: Server.Listener
|
||||
}
|
||||
|
||||
const initEmitter = new EventEmitter()
|
||||
let initStep: InitStep = { phase: "server_waiting" }
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let sidecar: CommandChild | null = null
|
||||
let server: Server.Listener | null = null
|
||||
const loadingComplete = defer<void>()
|
||||
|
||||
const pendingDeepLinks: string[] = []
|
||||
@@ -82,11 +102,9 @@ function setupApp() {
|
||||
})
|
||||
|
||||
void app.whenReady().then(async () => {
|
||||
// migrate()
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
setDockIcon()
|
||||
setupAutoUpdater()
|
||||
syncCli()
|
||||
await initialize()
|
||||
})
|
||||
}
|
||||
@@ -109,49 +127,79 @@ function setInitStep(step: InitStep) {
|
||||
initEmitter.emit("step", step)
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
const needsMigration = !sqliteFileExists()
|
||||
const sqliteDone = needsMigration ? defer<void>() : undefined
|
||||
let overlay: BrowserWindow | null = null
|
||||
async function setupServerConnection(): Promise<ServerConnection> {
|
||||
const customUrl = await getSavedServerUrl()
|
||||
|
||||
if (customUrl && (await checkHealthOrAskRetry(customUrl))) {
|
||||
serverReady.resolve({ url: customUrl, username: "opencode", password: null })
|
||||
return { variant: "existing", url: customUrl }
|
||||
}
|
||||
|
||||
const port = await getSidecarPort()
|
||||
const hostname = "127.0.0.1"
|
||||
const url = `http://${hostname}:${port}`
|
||||
const localUrl = `http://${hostname}:${port}`
|
||||
|
||||
if (await checkHealth(localUrl)) {
|
||||
serverReady.resolve({ url: localUrl, username: "opencode", password: null })
|
||||
return { variant: "existing", url: localUrl }
|
||||
}
|
||||
|
||||
const password = randomUUID()
|
||||
const { listener, health } = await spawnLocalServer(hostname, port, password)
|
||||
server = listener
|
||||
|
||||
logger.log("spawning sidecar", { url })
|
||||
const { child, health, events } = spawnLocalServer(hostname, port, password)
|
||||
sidecar = child
|
||||
serverReady.resolve({
|
||||
url,
|
||||
username: "opencode",
|
||||
return {
|
||||
variant: "local",
|
||||
url: localUrl,
|
||||
password,
|
||||
})
|
||||
health,
|
||||
server,
|
||||
}
|
||||
}
|
||||
|
||||
const loadingTask = (async () => {
|
||||
logger.log("sidecar connection started", { url })
|
||||
async function initialize() {
|
||||
await Log.init({ print: true, level: "WARN" })
|
||||
|
||||
events.on("sqlite", (progress: SqliteMigrationProgress) => {
|
||||
const needsMigration = !sqliteFileExists()
|
||||
const sqliteDone = needsMigration ? defer<void>() : undefined
|
||||
let loadingWindow: BrowserWindow | undefined
|
||||
|
||||
const serverConnection = await setupServerConnection()
|
||||
|
||||
if (needsMigration) {
|
||||
initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => {
|
||||
setInitStep({ phase: "sqlite_waiting" })
|
||||
if (overlay) sendSqliteMigrationProgress(overlay, progress)
|
||||
if (loadingWindow) sendSqliteMigrationProgress(loadingWindow, progress)
|
||||
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
|
||||
if (progress.type === "Done") sqliteDone?.resolve()
|
||||
})
|
||||
void migrate(initEmitter)
|
||||
}
|
||||
|
||||
if (needsMigration) {
|
||||
await sqliteDone?.promise
|
||||
const loadingTask = (async () => {
|
||||
logger.log("server connection started")
|
||||
|
||||
const cliHealthCheck = (() => {
|
||||
if (serverConnection.variant === "local") {
|
||||
return async () => {
|
||||
const { health } = serverConnection
|
||||
await health.wait
|
||||
serverReady.resolve({
|
||||
url: serverConnection.url,
|
||||
username: "opencode",
|
||||
password: serverConnection.password,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
serverReady.resolve({ url: serverConnection.url, username: "opencode", password: null })
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
if (cliHealthCheck) {
|
||||
if (needsMigration) await sqliteDone?.promise
|
||||
cliHealthCheck()
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
health.wait,
|
||||
delay(30_000).then(() => {
|
||||
throw new Error("Sidecar health check timed out")
|
||||
}),
|
||||
]).catch((error) => {
|
||||
logger.error("sidecar health check failed", error)
|
||||
})
|
||||
|
||||
logger.log("loading task finished")
|
||||
})()
|
||||
|
||||
const globals = {
|
||||
@@ -160,33 +208,30 @@ async function initialize() {
|
||||
}
|
||||
|
||||
if (needsMigration) {
|
||||
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
|
||||
if (show) {
|
||||
overlay = createLoadingWindow(globals)
|
||||
await delay(1_000)
|
||||
}
|
||||
loadingWindow = createLoadingWindow(globals)
|
||||
} else {
|
||||
logger.log("showing main window without loading window")
|
||||
mainWindow = createMainWindow(globals)
|
||||
wireMenu()
|
||||
}
|
||||
|
||||
await loadingTask
|
||||
setInitStep({ phase: "done" })
|
||||
|
||||
if (overlay) {
|
||||
if (loadingWindow) {
|
||||
await loadingComplete.promise
|
||||
}
|
||||
|
||||
mainWindow = createMainWindow(globals)
|
||||
wireMenu()
|
||||
|
||||
overlay?.close()
|
||||
loadingWindow?.close()
|
||||
}
|
||||
|
||||
function wireMenu() {
|
||||
if (!mainWindow) return
|
||||
createMenu({
|
||||
trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
|
||||
installCli: () => {
|
||||
void installCli()
|
||||
},
|
||||
checkForUpdates: () => {
|
||||
void checkForUpdates(true)
|
||||
},
|
||||
@@ -201,7 +246,6 @@ function wireMenu() {
|
||||
|
||||
registerIpcHandlers({
|
||||
killSidecar: () => killSidecar(),
|
||||
installCli: async () => installCli(),
|
||||
awaitInitialization: async (sendStep) => {
|
||||
sendStep(initStep)
|
||||
const listener = (step: InitStep) => sendStep(step)
|
||||
@@ -233,9 +277,9 @@ registerIpcHandlers({
|
||||
})
|
||||
|
||||
function killSidecar() {
|
||||
if (!sidecar) return
|
||||
sidecar.kill()
|
||||
sidecar = null
|
||||
if (!server) return
|
||||
server.stop()
|
||||
server = null
|
||||
}
|
||||
|
||||
function ensureLoopbackNoProxy() {
|
||||
@@ -287,6 +331,18 @@ function sqliteFileExists() {
|
||||
return existsSync(join(base, "opencode", "opencode.db"))
|
||||
}
|
||||
|
||||
async function migrate(events: EventEmitter) {
|
||||
await JsonMigration.run(Database.Client(), {
|
||||
progress: (event: JsonMigration.Progress) => {
|
||||
const percent = Math.floor((event.current / event.total) * 100)
|
||||
events.emit("sqlite", { type: "InProgress", value: percent })
|
||||
if (event.current === event.total) {
|
||||
events.emit("sqlite", { type: "Done" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function setupAutoUpdater() {
|
||||
if (!UPDATER_ENABLED) return
|
||||
autoUpdater.logger = logger
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execFile } from "node:child_process"
|
||||
import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron"
|
||||
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
|
||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, Notification, shell } from "electron"
|
||||
|
||||
import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, WslConfig } from "../preload/types"
|
||||
import { getStore } from "./store"
|
||||
@@ -8,7 +8,6 @@ import { setTitlebar } from "./windows"
|
||||
|
||||
type Deps = {
|
||||
killSidecar: () => void
|
||||
installCli: () => Promise<string>
|
||||
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
|
||||
getDefaultServerUrl: () => Promise<string | null> | string | null
|
||||
setDefaultServerUrl: (url: string | null) => Promise<void> | void
|
||||
@@ -29,7 +28,6 @@ type Deps = {
|
||||
|
||||
export function registerIpcHandlers(deps: Deps) {
|
||||
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
|
||||
ipcMain.handle("install-cli", () => deps.installCli())
|
||||
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
|
||||
const send = (step: InitStep) => event.sender.send("init-step", step)
|
||||
return deps.awaitInitialization(send)
|
||||
|
||||
@@ -5,7 +5,6 @@ import { createMainWindow } from "./windows"
|
||||
|
||||
type Deps = {
|
||||
trigger: (id: string) => void
|
||||
installCli: () => void
|
||||
checkForUpdates: () => void
|
||||
reload: () => void
|
||||
relaunch: () => void
|
||||
@@ -24,10 +23,6 @@ export function createMenu(deps: Deps) {
|
||||
enabled: UPDATER_ENABLED,
|
||||
click: () => deps.checkForUpdates(),
|
||||
},
|
||||
{
|
||||
label: "Install CLI...",
|
||||
click: () => deps.installCli(),
|
||||
},
|
||||
{
|
||||
label: "Reload Webview",
|
||||
click: () => deps.reload(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { serve, type CommandChild } from "./cli"
|
||||
import { bootstrap, Config, Server } from "virtual:opencode-server"
|
||||
import { dialog } from "electron"
|
||||
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
||||
import { store } from "./store"
|
||||
|
||||
@@ -29,8 +30,23 @@ export function setWslConfig(config: WslConfig) {
|
||||
store.set(WSL_ENABLED_KEY, config.enabled)
|
||||
}
|
||||
|
||||
export function spawnLocalServer(hostname: string, port: number, password: string) {
|
||||
const { child, exit, events } = serve(hostname, port, password)
|
||||
export async function getSavedServerUrl(): Promise<string | null> {
|
||||
const config = await bootstrap(process.cwd(), () => Config.get())
|
||||
|
||||
const direct = getDefaultServerUrl()
|
||||
if (direct) return direct
|
||||
|
||||
if (!config) return null
|
||||
return getServerUrlFromConfig(config)
|
||||
}
|
||||
|
||||
export async function spawnLocalServer(hostname: string, port: number, password: string) {
|
||||
const listener = await Server.listen({
|
||||
port,
|
||||
hostname,
|
||||
username: "opencode",
|
||||
password,
|
||||
})
|
||||
|
||||
const wait = (async () => {
|
||||
const url = `http://${hostname}:${port}`
|
||||
@@ -42,19 +58,10 @@ export function spawnLocalServer(hostname: string, port: number, password: strin
|
||||
}
|
||||
}
|
||||
|
||||
const terminated = async () => {
|
||||
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()])
|
||||
await ready()
|
||||
})()
|
||||
|
||||
return { child, health: { wait }, events }
|
||||
return { listener, health: { wait } }
|
||||
}
|
||||
|
||||
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
||||
@@ -83,4 +90,34 @@ export async function checkHealth(url: string, password?: string | null): Promis
|
||||
}
|
||||
}
|
||||
|
||||
export type { CommandChild }
|
||||
export async function checkHealthOrAskRetry(url: string): Promise<boolean> {
|
||||
while (true) {
|
||||
if (await checkHealth(url)) return true
|
||||
|
||||
const result = await dialog.showMessageBox({
|
||||
type: "warning",
|
||||
message: `Could not connect to configured server:\n${url}\n\nWould you like to retry or start a local server instead?`,
|
||||
title: "Connection Failed",
|
||||
buttons: ["Retry", "Start Local"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
})
|
||||
|
||||
if (result.response === 0) continue
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeHostnameForUrl(hostname: string) {
|
||||
if (hostname === "0.0.0.0") return "127.0.0.1"
|
||||
if (hostname === "::") return "[::1]"
|
||||
if (hostname.includes(":") && !hostname.startsWith("[")) return `[${hostname}]`
|
||||
return hostname
|
||||
}
|
||||
|
||||
export function getServerUrlFromConfig(config: Config.Info) {
|
||||
const server = config.server
|
||||
if (!server?.port) return null
|
||||
const host = server.hostname ? normalizeHostnameForUrl(server.hostname) : "127.0.0.1"
|
||||
return `http://${host}:${server.port}`
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { ElectronAPI, InitStep, SqliteMigrationProgress } from "./types"
|
||||
|
||||
const api: ElectronAPI = {
|
||||
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
|
||||
installCli: () => ipcRenderer.invoke("install-cli"),
|
||||
awaitInitialization: (onStep) => {
|
||||
const handler = (_: unknown, step: InitStep) => onStep(step)
|
||||
ipcRenderer.on("init-step", handler)
|
||||
|
||||
@@ -17,7 +17,6 @@ export type TitlebarTheme = {
|
||||
|
||||
export type ElectronAPI = {
|
||||
killSidecar: () => Promise<void>
|
||||
installCli: () => Promise<string>
|
||||
awaitInitialization: (onStep: (step: InitStep) => void) => Promise<ServerReadyData>
|
||||
getDefaultServerUrl: () => Promise<string | null>
|
||||
setDefaultServerUrl: (url: string | null) => Promise<void>
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { initI18n, t } from "./i18n"
|
||||
|
||||
export async function installCli(): Promise<void> {
|
||||
await initI18n()
|
||||
|
||||
try {
|
||||
const path = await window.api.installCli()
|
||||
window.alert(t("desktop.cli.installed.message", { path }))
|
||||
} catch (e) {
|
||||
window.alert(t("desktop.cli.failed.message", { error: String(e) }))
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,13 @@
|
||||
"esModuleInterop": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"rootDir": "src",
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"emitDeclarationOnly": false,
|
||||
"outDir": "node_modules/.ts-dist",
|
||||
"types": ["vite/client", "node", "electron"]
|
||||
},
|
||||
"references": [{ "path": "../app" }],
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2",
|
||||
@@ -89,8 +90,11 @@
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/node-server": "1.19.11",
|
||||
"@hono/node-ws": "1.3.0",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
@@ -104,7 +108,6 @@
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.87",
|
||||
"@opentui/solid": "0.1.87",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -133,6 +136,7 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.0.3",
|
||||
"open": "10.1.2",
|
||||
"openapi-types": "12.1.3",
|
||||
"opentui-spinner": "0.0.6",
|
||||
"partial-json": "0.1.7",
|
||||
"remeda": "catalog:",
|
||||
|
||||
54
packages/opencode/script/build-node.ts
Executable file
54
packages/opencode/script/build-node.ts
Executable file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const dir = path.resolve(__dirname, "..")
|
||||
|
||||
process.chdir(dir)
|
||||
|
||||
// Load migrations from migration directories
|
||||
const migrationDirs = (
|
||||
await fs.promises.readdir(path.join(dir, "migration"), {
|
||||
withFileTypes: true,
|
||||
})
|
||||
)
|
||||
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
|
||||
const migrations = await Promise.all(
|
||||
migrationDirs.map(async (name) => {
|
||||
const file = path.join(dir, "migration", name, "migration.sql")
|
||||
const sql = await Bun.file(file).text()
|
||||
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
|
||||
const timestamp = match
|
||||
? Date.UTC(
|
||||
Number(match[1]),
|
||||
Number(match[2]) - 1,
|
||||
Number(match[3]),
|
||||
Number(match[4]),
|
||||
Number(match[5]),
|
||||
Number(match[6]),
|
||||
)
|
||||
: 0
|
||||
return { sql, timestamp, name }
|
||||
}),
|
||||
)
|
||||
console.log(`Loaded ${migrations.length} migrations`)
|
||||
|
||||
await Bun.build({
|
||||
target: "node",
|
||||
entrypoints: ["./src/node.ts"],
|
||||
outdir: "./dist/node",
|
||||
format: "esm",
|
||||
external: ["jsonc-parser"],
|
||||
define: {
|
||||
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
|
||||
},
|
||||
})
|
||||
|
||||
console.log("Build complete")
|
||||
@@ -22,7 +22,7 @@ const modelsData = process.env.MODELS_DEV_API_JSON
|
||||
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
|
||||
await Bun.write(
|
||||
path.join(dir, "src/provider/models-snapshot.ts"),
|
||||
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as const\n`,
|
||||
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as Record<string, unknown>\n`,
|
||||
)
|
||||
console.log("Generated models-snapshot.ts")
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export const AcpCommand = cmd({
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
const server = await Server.listen(opts)
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { spawn } from "child_process"
|
||||
import { Database } from "../../storage/db"
|
||||
import { Database as BunDatabase } from "bun:sqlite"
|
||||
import { spawn } from "child_process"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
import { EOL } from "os"
|
||||
import type { Argv } from "yargs"
|
||||
import { Database } from "../../storage/db"
|
||||
import { JsonMigration } from "../../storage/json-migration"
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { JsonMigration } from "../../storage/json-migration"
|
||||
import { EOL } from "os"
|
||||
|
||||
const QueryCommand = cmd({
|
||||
command: "$0 [query]",
|
||||
@@ -73,7 +74,7 @@ const MigrateCommand = cmd({
|
||||
let last = -1
|
||||
if (tty) process.stderr.write("\x1b[?25l")
|
||||
try {
|
||||
const stats = await JsonMigration.run(sqlite, {
|
||||
const stats = await JsonMigration.run(drizzle({ client: sqlite }), {
|
||||
progress: (event) => {
|
||||
const percent = Math.floor((event.current / event.total) * 100)
|
||||
if (percent === last) return
|
||||
|
||||
@@ -15,7 +15,7 @@ export const ServeCommand = cmd({
|
||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
const server = await Server.listen(opts)
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
|
||||
await new Promise(() => {})
|
||||
|
||||
@@ -37,7 +37,7 @@ export const WebCommand = cmd({
|
||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
const server = await Server.listen(opts)
|
||||
UI.empty()
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAdaptorServer } from "@hono/node-server"
|
||||
import { Hono } from "hono"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { InstanceBootstrap } from "../../project/bootstrap"
|
||||
@@ -56,10 +57,24 @@ export namespace WorkspaceServer {
|
||||
}
|
||||
|
||||
export function Listen(opts: { hostname: string; port: number }) {
|
||||
return Bun.serve({
|
||||
hostname: opts.hostname,
|
||||
port: opts.port,
|
||||
const server = createAdaptorServer({
|
||||
fetch: App().fetch,
|
||||
})
|
||||
server.listen(opts.port, opts.hostname)
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
port: opts.port,
|
||||
stop() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,41 @@
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
import { EOL } from "os"
|
||||
import path from "path"
|
||||
import yargs from "yargs"
|
||||
import { hideBin } from "yargs/helpers"
|
||||
import { RunCommand } from "./cli/cmd/run"
|
||||
import { GenerateCommand } from "./cli/cmd/generate"
|
||||
import { Log } from "./util/log"
|
||||
import { LoginCommand, LogoutCommand, OrgsCommand, SwitchCommand } from "./cli/cmd/account"
|
||||
import { AcpCommand } from "./cli/cmd/acp"
|
||||
import { ConsoleCommand } from "./cli/cmd/account"
|
||||
import { GenerateCommand } from "./cli/cmd/generate"
|
||||
import { RunCommand } from "./cli/cmd/run"
|
||||
import { ProvidersCommand } from "./cli/cmd/providers"
|
||||
import { AgentCommand } from "./cli/cmd/agent"
|
||||
import { UpgradeCommand } from "./cli/cmd/upgrade"
|
||||
import { UninstallCommand } from "./cli/cmd/uninstall"
|
||||
import { ModelsCommand } from "./cli/cmd/models"
|
||||
import { UI } from "./cli/ui"
|
||||
import { Installation } from "./installation"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { FormatError } from "./cli/error"
|
||||
import { ServeCommand } from "./cli/cmd/serve"
|
||||
import { WorkspaceServeCommand } from "./cli/cmd/workspace-serve"
|
||||
import { Filesystem } from "./util/filesystem"
|
||||
import { DbCommand } from "./cli/cmd/db"
|
||||
import { DebugCommand } from "./cli/cmd/debug"
|
||||
import { StatsCommand } from "./cli/cmd/stats"
|
||||
import { McpCommand } from "./cli/cmd/mcp"
|
||||
import { GithubCommand } from "./cli/cmd/github"
|
||||
import { ExportCommand } from "./cli/cmd/export"
|
||||
import { GithubCommand } from "./cli/cmd/github"
|
||||
import { ImportCommand } from "./cli/cmd/import"
|
||||
import { McpCommand } from "./cli/cmd/mcp"
|
||||
import { ModelsCommand } from "./cli/cmd/models"
|
||||
import { PrCommand } from "./cli/cmd/pr"
|
||||
import { ServeCommand } from "./cli/cmd/serve"
|
||||
import { SessionCommand } from "./cli/cmd/session"
|
||||
import { StatsCommand } from "./cli/cmd/stats"
|
||||
import { AttachCommand } from "./cli/cmd/tui/attach"
|
||||
import { TuiThreadCommand } from "./cli/cmd/tui/thread"
|
||||
import { AcpCommand } from "./cli/cmd/acp"
|
||||
import { EOL } from "os"
|
||||
import { UninstallCommand } from "./cli/cmd/uninstall"
|
||||
import { UpgradeCommand } from "./cli/cmd/upgrade"
|
||||
import { WebCommand } from "./cli/cmd/web"
|
||||
import { PrCommand } from "./cli/cmd/pr"
|
||||
import { SessionCommand } from "./cli/cmd/session"
|
||||
import { DbCommand } from "./cli/cmd/db"
|
||||
import path from "path"
|
||||
import { WorkspaceServeCommand } from "./cli/cmd/workspace-serve"
|
||||
import { FormatError } from "./cli/error"
|
||||
import { UI } from "./cli/ui"
|
||||
import { Global } from "./global"
|
||||
import { JsonMigration } from "./storage/json-migration"
|
||||
import { Installation } from "./installation"
|
||||
import { Database } from "./storage/db"
|
||||
import { JsonMigration } from "./storage/json-migration"
|
||||
import { Filesystem } from "./util/filesystem"
|
||||
import { Log } from "./util/log"
|
||||
|
||||
process.on("unhandledRejection", (e) => {
|
||||
Log.Default.error("rejection", {
|
||||
@@ -95,7 +97,7 @@ let cli = yargs(hideBin(process.argv))
|
||||
let last = -1
|
||||
if (tty) process.stderr.write("\x1b[?25l")
|
||||
try {
|
||||
await JsonMigration.run(Database.Client().$client, {
|
||||
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
|
||||
progress: (event) => {
|
||||
const percent = Math.floor((event.current / event.total) * 100)
|
||||
if (percent === last && event.current !== event.total) return
|
||||
@@ -168,7 +170,7 @@ cli = cli
|
||||
try {
|
||||
await cli.parse()
|
||||
} catch (e) {
|
||||
let data: Record<string, any> = {}
|
||||
const data: Record<string, any> = {}
|
||||
if (e instanceof NamedError) {
|
||||
const obj = e.toObject()
|
||||
Object.assign(data, {
|
||||
|
||||
6
packages/opencode/src/node.ts
Normal file
6
packages/opencode/src/node.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { Config } from "./config/config"
|
||||
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"
|
||||
@@ -23,6 +23,8 @@ export namespace Pty {
|
||||
close: (code?: number, reason?: string) => void
|
||||
}
|
||||
|
||||
const key = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
|
||||
|
||||
// WebSocket control frame: 0x00 + UTF-8 JSON.
|
||||
const meta = (cursor: number) => {
|
||||
const json = JSON.stringify({ cursor })
|
||||
@@ -97,9 +99,9 @@ export namespace Pty {
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
for (const [key, ws] of session.subscribers.entries()) {
|
||||
for (const [id, ws] of session.subscribers.entries()) {
|
||||
try {
|
||||
if (ws.data === key) ws.close()
|
||||
if (key(ws) === id) ws.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -230,9 +232,9 @@ export namespace Pty {
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
for (const [key, ws] of session.subscribers.entries()) {
|
||||
for (const [id, ws] of session.subscribers.entries()) {
|
||||
try {
|
||||
if (ws.data === key) ws.close()
|
||||
if (key(ws) === id) ws.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -263,16 +265,13 @@ export namespace Pty {
|
||||
}
|
||||
log.info("client connected to session", { id })
|
||||
|
||||
// Use ws.data as the unique key for this connection lifecycle.
|
||||
// If ws.data is undefined, fallback to ws object.
|
||||
const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws
|
||||
const sub = key(ws)
|
||||
|
||||
// Optionally cleanup if the key somehow exists
|
||||
session.subscribers.delete(connectionKey)
|
||||
session.subscribers.set(connectionKey, ws)
|
||||
session.subscribers.delete(sub)
|
||||
session.subscribers.set(sub, ws)
|
||||
|
||||
const cleanup = () => {
|
||||
session.subscribers.delete(connectionKey)
|
||||
session.subscribers.delete(sub)
|
||||
}
|
||||
|
||||
const start = session.bufferCursor
|
||||
|
||||
@@ -32,5 +32,5 @@ export const ERRORS = {
|
||||
} as const
|
||||
|
||||
export function errors(...codes: number[]) {
|
||||
return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]]))
|
||||
return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]])) as Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||
import { MCP } from "../../mcp"
|
||||
import { ProviderID, ModelID } from "../../provider/schema"
|
||||
import { ToolRegistry } from "../../tool/registry"
|
||||
import { Worktree } from "../../worktree"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { Project } from "../../project/project"
|
||||
import { MCP } from "../../mcp"
|
||||
import { Session } from "../../session"
|
||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||
import { errors } from "../error"
|
||||
import { ToolRegistry } from "../../tool/registry"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { Worktree } from "../../worktree"
|
||||
import { errors } from "../error"
|
||||
import { WorkspaceRoutes } from "./workspace"
|
||||
|
||||
export const ExperimentalRoutes = lazy(() =>
|
||||
@@ -84,7 +84,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
// Handle both Zod schemas and plain JSON schemas
|
||||
parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters,
|
||||
parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : (t.parameters as unknown),
|
||||
})),
|
||||
)
|
||||
},
|
||||
@@ -211,15 +211,15 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
z.object({
|
||||
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
|
||||
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
|
||||
start: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
|
||||
cursor: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }),
|
||||
search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
|
||||
start: z.coerce.number().optional().meta({
|
||||
description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)",
|
||||
}),
|
||||
cursor: z.coerce.number().optional().meta({
|
||||
description: "Return sessions updated before this timestamp (milliseconds since epoch)",
|
||||
}),
|
||||
search: z.string().optional().meta({
|
||||
description: "Filter sessions by title (case-insensitive)",
|
||||
}),
|
||||
limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
|
||||
archived: z.coerce.boolean().optional().meta({ description: "Include archived sessions (default false)" }),
|
||||
}),
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { upgradeWebSocket } from "hono/bun"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import z from "zod"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { NotFoundError } from "../../storage/db"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
export const PtyRoutes = lazy(() =>
|
||||
new Hono()
|
||||
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
return new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
@@ -197,5 +196,5 @@ export const PtyRoutes = lazy(() =>
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { Hono } from "hono"
|
||||
import { stream } from "hono/streaming"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import { SessionID, MessageID, PartID } from "@/session/schema"
|
||||
import z from "zod"
|
||||
import { Session } from "../../session"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionPrompt } from "../../session/prompt"
|
||||
import { SessionCompaction } from "../../session/compaction"
|
||||
import { SessionRevert } from "../../session/revert"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { SessionSummary } from "@/session/summary"
|
||||
import { Todo } from "../../session/todo"
|
||||
import { Agent } from "../../agent/agent"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Agent } from "../../agent/agent"
|
||||
import { Session } from "../../session"
|
||||
import { SessionCompaction } from "../../session/compaction"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionPrompt } from "../../session/prompt"
|
||||
import { SessionRevert } from "../../session/revert"
|
||||
import { Todo } from "../../session/todo"
|
||||
import { Log } from "../../util/log"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { errors } from "../error"
|
||||
@@ -46,11 +46,12 @@ export const SessionRoutes = lazy(() =>
|
||||
z.object({
|
||||
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
|
||||
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
|
||||
start: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
|
||||
search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
|
||||
start: z.coerce.number().optional().meta({
|
||||
description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)",
|
||||
}),
|
||||
search: z.string().optional().meta({
|
||||
description: "Filter sessions by title (case-insensitive)",
|
||||
}),
|
||||
limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
|
||||
}),
|
||||
),
|
||||
@@ -284,7 +285,10 @@ export const SessionRoutes = lazy(() =>
|
||||
session = await Session.setTitle({ sessionID, title: updates.title })
|
||||
}
|
||||
if (updates.time?.archived !== undefined) {
|
||||
session = await Session.setArchived({ sessionID, time: updates.time.archived })
|
||||
session = await Session.setArchived({
|
||||
sessionID,
|
||||
time: updates.time.archived,
|
||||
})
|
||||
}
|
||||
|
||||
return c.json(session)
|
||||
@@ -841,13 +845,11 @@ export const SessionRoutes = lazy(() =>
|
||||
),
|
||||
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
c.status(204)
|
||||
c.header("Content-Type", "application/json")
|
||||
return stream(c, async () => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
SessionPrompt.prompt({ ...body, sessionID })
|
||||
})
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
SessionPrompt.prompt({ ...body, sessionID })
|
||||
|
||||
return c.body(null, 204)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { streamSSE } from "hono/streaming"
|
||||
import { Log } from "../util/log"
|
||||
import { Bus } from "../bus"
|
||||
import { BusEvent } from "../bus/bus-event"
|
||||
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
import { cors } from "hono/cors"
|
||||
@@ -25,7 +28,7 @@ import { ProviderID } from "../provider/schema"
|
||||
import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
|
||||
import { ProjectRoutes } from "./routes/project"
|
||||
import { SessionRoutes } from "./routes/session"
|
||||
import { PtyRoutes } from "./routes/pty"
|
||||
// import { PtyRoutes } from "./routes/pty"
|
||||
import { McpRoutes } from "./routes/mcp"
|
||||
import { FileRoutes } from "./routes/file"
|
||||
import { ConfigRoutes } from "./routes/config"
|
||||
@@ -35,7 +38,8 @@ import { EventRoutes } from "./routes/event"
|
||||
import { InstanceBootstrap } from "../project/bootstrap"
|
||||
import { NotFoundError } from "../storage/db"
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||
import { websocket } from "hono/bun"
|
||||
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
||||
import { createNodeWebSocket } from "@hono/node-ws"
|
||||
import { HTTPException } from "hono/http-exception"
|
||||
import { errors } from "./error"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
@@ -49,13 +53,20 @@ import { lazy } from "@/util/lazy"
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
|
||||
export namespace Server {
|
||||
const log = Log.create({ service: "server" })
|
||||
export type Listener = {
|
||||
hostname: string
|
||||
port: number
|
||||
url: URL
|
||||
stop: (close?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export const Default = lazy(() => createApp({}))
|
||||
export const Default = lazy(() => create({}).app)
|
||||
|
||||
export const createApp = (opts: { cors?: string[] }): Hono => {
|
||||
function create(opts: { cors?: string[]; password?: string; username?: string }) {
|
||||
const log = Log.create({ service: "server" })
|
||||
const app = new Hono()
|
||||
return app
|
||||
const ws = createNodeWebSocket({ app })
|
||||
const route = app
|
||||
.onError((err, c) => {
|
||||
log.error("failed", {
|
||||
error: err,
|
||||
@@ -241,7 +252,6 @@ export namespace Server {
|
||||
),
|
||||
)
|
||||
.route("/project", ProjectRoutes())
|
||||
.route("/pty", PtyRoutes())
|
||||
.route("/config", ConfigRoutes())
|
||||
.route("/experimental", ExperimentalRoutes())
|
||||
.route("/session", SessionRoutes())
|
||||
@@ -497,22 +507,70 @@ export namespace Server {
|
||||
return c.json(await Format.status())
|
||||
},
|
||||
)
|
||||
.all("/*", async (c) => {
|
||||
const path = c.req.path
|
||||
|
||||
const response = await proxy(`https://app.opencode.ai${path}`, {
|
||||
...c.req,
|
||||
headers: {
|
||||
...c.req.raw.headers,
|
||||
host: "app.opencode.ai",
|
||||
.get(
|
||||
"/event",
|
||||
describeRoute({
|
||||
summary: "Subscribe to events",
|
||||
description: "Get events",
|
||||
operationId: "event.subscribe",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Event stream",
|
||||
content: {
|
||||
"text/event-stream": {
|
||||
schema: resolver(BusEvent.payloads()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
response.headers.set(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:",
|
||||
)
|
||||
return response
|
||||
})
|
||||
}),
|
||||
async (c) => {
|
||||
log.info("event connected")
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
return streamSSE(c, async (stream) => {
|
||||
stream.writeSSE({
|
||||
data: JSON.stringify({
|
||||
type: "server.connected",
|
||||
properties: {},
|
||||
}),
|
||||
})
|
||||
const unsub = Bus.subscribeAll(async (event) => {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify(event),
|
||||
})
|
||||
if (event.type === Bus.InstanceDisposed.type) {
|
||||
stream.close()
|
||||
}
|
||||
})
|
||||
|
||||
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||
const heartbeat = setInterval(() => {
|
||||
stream.writeSSE({
|
||||
data: JSON.stringify({
|
||||
type: "server.heartbeat",
|
||||
properties: {},
|
||||
}),
|
||||
})
|
||||
}, 10_000)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.onAbort(() => {
|
||||
clearInterval(heartbeat)
|
||||
unsub()
|
||||
resolve()
|
||||
log.info("event disconnected")
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
// .route("/pty", PtyRoutes(ws.upgradeWebSocket))
|
||||
|
||||
return {
|
||||
app: route as Hono,
|
||||
ws,
|
||||
}
|
||||
}
|
||||
|
||||
export async function openapi() {
|
||||
@@ -530,52 +588,91 @@ export namespace Server {
|
||||
return result
|
||||
}
|
||||
|
||||
/** @deprecated do not use this dumb shit */
|
||||
export let url: URL
|
||||
|
||||
export function listen(opts: {
|
||||
export async function listen(opts: {
|
||||
port: number
|
||||
hostname: string
|
||||
mdns?: boolean
|
||||
mdnsDomain?: string
|
||||
cors?: string[]
|
||||
}) {
|
||||
url = new URL(`http://${opts.hostname}:${opts.port}`)
|
||||
const app = createApp(opts)
|
||||
const args = {
|
||||
hostname: opts.hostname,
|
||||
idleTimeout: 0,
|
||||
fetch: app.fetch,
|
||||
websocket: websocket,
|
||||
} as const
|
||||
const tryServe = (port: number) => {
|
||||
try {
|
||||
return Bun.serve({ ...args, port })
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
password?: string
|
||||
username?: string
|
||||
}): Promise<Listener> {
|
||||
const log = Log.create({ service: "server" })
|
||||
const built = create({
|
||||
...opts,
|
||||
})
|
||||
const start = (port: number) =>
|
||||
new Promise<ServerType>((resolve, reject) => {
|
||||
const server = createAdaptorServer({ fetch: built.app.fetch })
|
||||
built.ws.injectWebSocket(server)
|
||||
const fail = (err: Error) => {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
const ready = () => {
|
||||
cleanup()
|
||||
resolve(server)
|
||||
}
|
||||
const cleanup = () => {
|
||||
server.off("error", fail)
|
||||
server.off("listening", ready)
|
||||
}
|
||||
server.once("error", fail)
|
||||
server.once("listening", ready)
|
||||
server.listen(port, opts.hostname)
|
||||
})
|
||||
|
||||
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
|
||||
const addr = server.address()
|
||||
if (!addr || typeof addr === "string") {
|
||||
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||
}
|
||||
const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port)
|
||||
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
|
||||
|
||||
const url = new URL("http://localhost")
|
||||
url.hostname = opts.hostname
|
||||
url.port = String(addr.port)
|
||||
Server.url = url
|
||||
|
||||
const shouldPublishMDNS =
|
||||
opts.mdns &&
|
||||
server.port &&
|
||||
addr.port &&
|
||||
opts.hostname !== "127.0.0.1" &&
|
||||
opts.hostname !== "localhost" &&
|
||||
opts.hostname !== "::1"
|
||||
if (shouldPublishMDNS) {
|
||||
MDNS.publish(server.port!, opts.mdnsDomain)
|
||||
MDNS.publish(addr.port, opts.mdnsDomain)
|
||||
} else if (opts.mdns) {
|
||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||
}
|
||||
|
||||
const originalStop = server.stop.bind(server)
|
||||
server.stop = async (closeActiveConnections?: boolean) => {
|
||||
if (shouldPublishMDNS) MDNS.unpublish()
|
||||
return originalStop(closeActiveConnections)
|
||||
let closing: Promise<void> | undefined
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
port: addr.port,
|
||||
url,
|
||||
stop(close?: boolean) {
|
||||
closing ??= new Promise((resolve, reject) => {
|
||||
if (shouldPublishMDNS) MDNS.unpublish()
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
if (close) {
|
||||
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
||||
server.closeAllConnections()
|
||||
}
|
||||
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
|
||||
server.closeIdleConnections()
|
||||
}
|
||||
}
|
||||
})
|
||||
return closing
|
||||
},
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
|
||||
import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
|
||||
export * from "drizzle-orm"
|
||||
import { Context } from "../util/context"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { Global } from "../global"
|
||||
@@ -14,6 +13,7 @@ import { Installation } from "../installation"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { iife } from "@/util/iife"
|
||||
import { init } from "#db"
|
||||
export * from "drizzle-orm"
|
||||
|
||||
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql"
|
||||
import { SessionShareTable } from "../share/share.sql"
|
||||
import path from "path"
|
||||
import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
|
||||
import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite"
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import { MessageTable, PartTable, PermissionTable, SessionTable, TodoTable } from "../session/session.sql"
|
||||
import { SessionShareTable } from "../share/share.sql"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Glob } from "../util/glob"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace JsonMigration {
|
||||
const log = Log.create({ service: "json-migration" })
|
||||
@@ -23,7 +23,7 @@ export namespace JsonMigration {
|
||||
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")
|
||||
|
||||
if (!existsSync(storageDir)) {
|
||||
@@ -43,13 +43,13 @@ export namespace JsonMigration {
|
||||
log.info("starting json to sqlite migration", { storageDir })
|
||||
const start = performance.now()
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
// const db = drizzle({ client: sqlite })
|
||||
|
||||
// Optimize SQLite for bulk inserts
|
||||
sqlite.exec("PRAGMA journal_mode = WAL")
|
||||
sqlite.exec("PRAGMA synchronous = OFF")
|
||||
sqlite.exec("PRAGMA cache_size = 10000")
|
||||
sqlite.exec("PRAGMA temp_store = MEMORY")
|
||||
db.run("PRAGMA journal_mode = WAL")
|
||||
db.run("PRAGMA synchronous = OFF")
|
||||
db.run("PRAGMA cache_size = 10000")
|
||||
db.run("PRAGMA temp_store = MEMORY")
|
||||
const stats = {
|
||||
projects: 0,
|
||||
sessions: 0,
|
||||
@@ -146,7 +146,7 @@ export namespace JsonMigration {
|
||||
|
||||
progress?.({ current, total, label: "starting" })
|
||||
|
||||
sqlite.exec("BEGIN TRANSACTION")
|
||||
db.run("BEGIN TRANSACTION")
|
||||
|
||||
// Migrate projects first (no FK deps)
|
||||
// Derive all IDs from file paths, not JSON content
|
||||
@@ -178,7 +178,10 @@ export namespace JsonMigration {
|
||||
stats.projects += insert(projectValues, ProjectTable, "project")
|
||||
step("projects", end - i)
|
||||
}
|
||||
log.info("migrated projects", { count: stats.projects, duration: Math.round(performance.now() - start) })
|
||||
log.info("migrated projects", {
|
||||
count: stats.projects,
|
||||
duration: Math.round(performance.now() - start),
|
||||
})
|
||||
|
||||
// Migrate sessions (depends on projects)
|
||||
// Derive all IDs from directory/file paths, not JSON content, since earlier
|
||||
@@ -390,7 +393,12 @@ export namespace JsonMigration {
|
||||
errs.push(`session_share missing id/secret/url: ${shareFiles[i + j]}`)
|
||||
continue
|
||||
}
|
||||
shareValues.push({ session_id: sessionID, id: data.id, secret: data.secret, url: data.url })
|
||||
shareValues.push({
|
||||
session_id: sessionID,
|
||||
id: data.id,
|
||||
secret: data.secret,
|
||||
url: data.url,
|
||||
})
|
||||
}
|
||||
stats.shares += insert(shareValues, SessionShareTable, "session_share")
|
||||
step("shares", end - i)
|
||||
@@ -400,7 +408,7 @@ export namespace JsonMigration {
|
||||
log.warn("skipped orphaned session shares", { count: orphans.shares })
|
||||
}
|
||||
|
||||
sqlite.exec("COMMIT")
|
||||
db.run("COMMIT")
|
||||
|
||||
log.info("json migration complete", {
|
||||
projects: stats.projects,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import z from "zod"
|
||||
import type { MessageV2 } from "../session/message-v2"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import type { PermissionNext } from "../permission"
|
||||
import type { MessageV2 } from "../session/message-v2"
|
||||
import type { SessionID, MessageID } from "../session/schema"
|
||||
import { Truncate } from "./truncate"
|
||||
|
||||
export namespace Tool {
|
||||
interface Metadata {
|
||||
export interface Metadata {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
@@ -60,7 +60,9 @@ export namespace Tool {
|
||||
toolInfo.parameters.parse(args)
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
|
||||
throw new Error(toolInfo.formatValidationError(error), { cause: error })
|
||||
throw new Error(toolInfo.formatValidationError(error), {
|
||||
cause: error,
|
||||
})
|
||||
}
|
||||
throw new Error(
|
||||
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
|
||||
|
||||
@@ -17,7 +17,7 @@ export const withStatics =
|
||||
Object.assign(schema, methods(schema))
|
||||
|
||||
declare const NewtypeBrand: unique symbol
|
||||
type NewtypeBrand<Tag extends string> = { readonly [NewtypeBrand]: Tag }
|
||||
export type NewtypeBrand<Tag extends string> = { readonly [NewtypeBrand]: Tag }
|
||||
|
||||
/**
|
||||
* Nominal wrapper for scalar types. The class itself is a valid schema —
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { Database } from "bun:sqlite"
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
|
||||
import path from "path"
|
||||
import { readdirSync, readFileSync } from "fs"
|
||||
import fs from "fs/promises"
|
||||
import { readFileSync, readdirSync } from "fs"
|
||||
import { JsonMigration } from "../../src/storage/json-migration"
|
||||
import path from "path"
|
||||
import { Global } from "../../src/global"
|
||||
import { ProjectTable } from "../../src/project/project.sql"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../../src/session/session.sql"
|
||||
import { MessageTable, PartTable, PermissionTable, SessionTable, TodoTable } from "../../src/session/session.sql"
|
||||
import { SessionShareTable } from "../../src/share/share.sql"
|
||||
import { JsonMigration } from "../../src/storage/json-migration"
|
||||
import { SessionID, MessageID, PartID } from "../../src/session/schema"
|
||||
|
||||
// Test fixtures
|
||||
@@ -53,9 +53,15 @@ async function setupStorageDir() {
|
||||
const storageDir = path.join(Global.Path.data, "storage")
|
||||
await fs.rm(storageDir, { recursive: true, force: true })
|
||||
await fs.mkdir(path.join(storageDir, "project"), { recursive: true })
|
||||
await fs.mkdir(path.join(storageDir, "session", "proj_test123abc"), { recursive: true })
|
||||
await fs.mkdir(path.join(storageDir, "message", "ses_test456def"), { recursive: true })
|
||||
await fs.mkdir(path.join(storageDir, "part", "msg_test789ghi"), { recursive: true })
|
||||
await fs.mkdir(path.join(storageDir, "session", "proj_test123abc"), {
|
||||
recursive: true,
|
||||
})
|
||||
await fs.mkdir(path.join(storageDir, "message", "ses_test456def"), {
|
||||
recursive: true,
|
||||
})
|
||||
await fs.mkdir(path.join(storageDir, "part", "msg_test789ghi"), {
|
||||
recursive: true,
|
||||
})
|
||||
await fs.mkdir(path.join(storageDir, "session_diff"), { recursive: true })
|
||||
await fs.mkdir(path.join(storageDir, "todo"), { recursive: true })
|
||||
await fs.mkdir(path.join(storageDir, "permission"), { recursive: true })
|
||||
@@ -97,10 +103,12 @@ function createTestDb() {
|
||||
describe("JSON to SQLite migration", () => {
|
||||
let storageDir: string
|
||||
let sqlite: Database
|
||||
let db!: ReturnType<typeof drizzle>
|
||||
|
||||
beforeEach(async () => {
|
||||
storageDir = await setupStorageDir()
|
||||
sqlite = createTestDb()
|
||||
db = drizzle({ client: sqlite })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -118,11 +126,10 @@ describe("JSON to SQLite migration", () => {
|
||||
sandboxes: ["/test/sandbox"],
|
||||
})
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.projects).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const projects = db.select().from(ProjectTable).all()
|
||||
expect(projects.length).toBe(1)
|
||||
expect(projects[0].id).toBe(ProjectID.make("proj_test123abc"))
|
||||
@@ -143,11 +150,10 @@ describe("JSON to SQLite migration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.projects).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const projects = db.select().from(ProjectTable).all()
|
||||
expect(projects.length).toBe(1)
|
||||
expect(projects[0].id).toBe(ProjectID.make("proj_filename")) // Uses filename, not JSON id
|
||||
@@ -164,11 +170,10 @@ describe("JSON to SQLite migration", () => {
|
||||
commands: { start: "npm run dev" },
|
||||
})
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.projects).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const projects = db.select().from(ProjectTable).all()
|
||||
expect(projects.length).toBe(1)
|
||||
expect(projects[0].id).toBe(ProjectID.make("proj_with_commands"))
|
||||
@@ -185,11 +190,10 @@ describe("JSON to SQLite migration", () => {
|
||||
sandboxes: [],
|
||||
})
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.projects).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const projects = db.select().from(ProjectTable).all()
|
||||
expect(projects.length).toBe(1)
|
||||
expect(projects[0].id).toBe(ProjectID.make("proj_no_commands"))
|
||||
@@ -216,9 +220,8 @@ describe("JSON to SQLite migration", () => {
|
||||
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()
|
||||
expect(sessions.length).toBe(1)
|
||||
expect(sessions[0].id).toBe(SessionID.make("ses_test456def"))
|
||||
@@ -247,12 +250,11 @@ describe("JSON to SQLite migration", () => {
|
||||
JSON.stringify({ ...fixtures.part }),
|
||||
)
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.messages).toBe(1)
|
||||
expect(stats?.parts).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const messages = db.select().from(MessageTable).all()
|
||||
expect(messages.length).toBe(1)
|
||||
expect(messages[0].id).toBe(MessageID.make("msg_test789ghi"))
|
||||
@@ -287,12 +289,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?.parts).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const messages = db.select().from(MessageTable).all()
|
||||
expect(messages.length).toBe(1)
|
||||
expect(messages[0].id).toBe(MessageID.make("msg_test789ghi"))
|
||||
@@ -329,11 +330,10 @@ describe("JSON to SQLite migration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.messages).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const messages = db.select().from(MessageTable).all()
|
||||
expect(messages.length).toBe(1)
|
||||
expect(messages[0].id).toBe(MessageID.make("msg_from_filename")) // Uses filename, not JSON id
|
||||
@@ -367,11 +367,10 @@ describe("JSON to SQLite migration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.parts).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const parts = db.select().from(PartTable).all()
|
||||
expect(parts.length).toBe(1)
|
||||
expect(parts[0].id).toBe(PartID.make("prt_from_filename")) // Uses filename, not JSON id
|
||||
@@ -392,7 +391,7 @@ describe("JSON to SQLite migration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.sessions).toBe(0)
|
||||
})
|
||||
@@ -420,11 +419,10 @@ describe("JSON to SQLite migration", () => {
|
||||
time: { created: 1700000000000, updated: 1700000001000 },
|
||||
})
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.sessions).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const sessions = db.select().from(SessionTable).all()
|
||||
expect(sessions.length).toBe(1)
|
||||
expect(sessions[0].id).toBe(SessionID.make("ses_migrated"))
|
||||
@@ -452,11 +450,10 @@ describe("JSON to SQLite migration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.sessions).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const sessions = db.select().from(SessionTable).all()
|
||||
expect(sessions.length).toBe(1)
|
||||
expect(sessions[0].id).toBe(SessionID.make("ses_from_filename")) // Uses filename, not JSON id
|
||||
@@ -471,10 +468,9 @@ describe("JSON to SQLite migration", () => {
|
||||
sandboxes: [],
|
||||
})
|
||||
|
||||
await JsonMigration.run(sqlite)
|
||||
await JsonMigration.run(sqlite)
|
||||
await JsonMigration.run(db)
|
||||
await JsonMigration.run(db)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const projects = db.select().from(ProjectTable).all()
|
||||
expect(projects.length).toBe(1) // Still only 1 due to onConflictDoNothing
|
||||
})
|
||||
@@ -507,11 +503,10 @@ describe("JSON to SQLite migration", () => {
|
||||
]),
|
||||
)
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.todos).toBe(2)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
|
||||
expect(todos.length).toBe(2)
|
||||
expect(todos[0].content).toBe("First todo")
|
||||
@@ -540,9 +535,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()
|
||||
|
||||
expect(todos.length).toBe(3)
|
||||
@@ -564,17 +558,28 @@ describe("JSON to SQLite migration", () => {
|
||||
|
||||
// Create permission file (named by projectID, contains array of rules)
|
||||
const permissionData = [
|
||||
{ permission: "file.read", pattern: "/test/file1.ts", action: "allow" as const },
|
||||
{ permission: "file.write", pattern: "/test/file2.ts", action: "ask" as const },
|
||||
{ permission: "command.run", pattern: "npm install", action: "deny" as const },
|
||||
{
|
||||
permission: "file.read",
|
||||
pattern: "/test/file1.ts",
|
||||
action: "allow" as const,
|
||||
},
|
||||
{
|
||||
permission: "file.write",
|
||||
pattern: "/test/file2.ts",
|
||||
action: "ask" as const,
|
||||
},
|
||||
{
|
||||
permission: "command.run",
|
||||
pattern: "npm install",
|
||||
action: "deny" as const,
|
||||
},
|
||||
]
|
||||
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)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const permissions = db.select().from(PermissionTable).all()
|
||||
expect(permissions.length).toBe(1)
|
||||
expect(permissions[0].project_id).toBe("proj_test123abc")
|
||||
@@ -600,11 +605,10 @@ describe("JSON to SQLite migration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.shares).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const shares = db.select().from(SessionShareTable).all()
|
||||
expect(shares.length).toBe(1)
|
||||
expect(shares[0].session_id).toBe("ses_test456def")
|
||||
@@ -616,7 +620,7 @@ describe("JSON to SQLite migration", () => {
|
||||
test("returns empty stats when storage directory does not exist", async () => {
|
||||
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.sessions).toBe(0)
|
||||
@@ -637,12 +641,11 @@ describe("JSON to SQLite migration", () => {
|
||||
})
|
||||
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.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()
|
||||
expect(projects.length).toBe(1)
|
||||
expect(projects[0].id).toBe(ProjectID.make("proj_test123abc"))
|
||||
@@ -666,10 +669,9 @@ describe("JSON to SQLite migration", () => {
|
||||
]),
|
||||
)
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
expect(stats.todos).toBe(2)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
|
||||
expect(todos.length).toBe(2)
|
||||
expect(todos[0].content).toBe("keep-0")
|
||||
@@ -707,20 +709,27 @@ describe("JSON to SQLite migration", () => {
|
||||
|
||||
await Bun.write(
|
||||
path.join(storageDir, "session_share", "ses_test456def.json"),
|
||||
JSON.stringify({ id: "share_ok", secret: "secret", url: "https://ok.example.com" }),
|
||||
JSON.stringify({
|
||||
id: "share_ok",
|
||||
secret: "secret",
|
||||
url: "https://ok.example.com",
|
||||
}),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(storageDir, "session_share", "ses_missing.json"),
|
||||
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.permissions).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(PermissionTable).all().length).toBe(1)
|
||||
expect(db.select().from(SessionShareTable).all().length).toBe(1)
|
||||
@@ -815,15 +824,23 @@ describe("JSON to SQLite migration", () => {
|
||||
|
||||
await Bun.write(
|
||||
path.join(storageDir, "session_share", "ses_test456def.json"),
|
||||
JSON.stringify({ id: "share_ok", secret: "secret", url: "https://ok.example.com" }),
|
||||
JSON.stringify({
|
||||
id: "share_ok",
|
||||
secret: "secret",
|
||||
url: "https://ok.example.com",
|
||||
}),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(storageDir, "session_share", "ses_missing.json"),
|
||||
JSON.stringify({ id: "share_orphan", secret: "secret", url: "https://missing.example.com" }),
|
||||
JSON.stringify({
|
||||
id: "share_orphan",
|
||||
secret: "secret",
|
||||
url: "https://missing.example.com",
|
||||
}),
|
||||
)
|
||||
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)
|
||||
// Sessions: ses_test456def (valid), ses_missing_project (now uses dir path),
|
||||
@@ -837,7 +854,6 @@ describe("JSON to SQLite migration", () => {
|
||||
expect(stats.shares).toBe(1)
|
||||
expect(stats.errors.length).toBeGreaterThanOrEqual(6)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
expect(db.select().from(ProjectTable).all().length).toBe(2)
|
||||
expect(db.select().from(SessionTable).all().length).toBe(3)
|
||||
expect(db.select().from(MessageTable).all().length).toBe(1)
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
"types": [],
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"customConditions": ["browser"],
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "dist/types",
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@tui/*": ["./src/cli/cmd/tui/*"]
|
||||
@@ -19,5 +24,6 @@
|
||||
"namespaceImportPackages": ["effect", "@effect/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
144
patches/hono-openapi@1.1.2.patch
Normal file
144
patches/hono-openapi@1.1.2.patch
Normal file
@@ -0,0 +1,144 @@
|
||||
diff --git a/dist/index.d.cts b/dist/index.d.cts
|
||||
index 0f49b720b0b8c827fef52a2982fb194e98bc0c50..f690bb3304abeb78a1c10323bdb605d81eb99078 100644
|
||||
--- a/dist/index.d.cts
|
||||
+++ b/dist/index.d.cts
|
||||
@@ -5,66 +5,10 @@ import { TypedResponse, RouterRoute, ValidationTargets as ValidationTargets$1, B
|
||||
import { loadVendor as loadVendor$2, ToOpenAPISchemaContext } from '@standard-community/standard-openapi';
|
||||
import { Hook } from '@hono/standard-validator';
|
||||
import { loadVendor as loadVendor$1 } from '@standard-community/standard-json';
|
||||
+import { StandardSchemaV1 } from '@standard-schema/spec';
|
||||
import { StatusCode } from 'hono/utils/http-status';
|
||||
import { JSONSchema7 } from 'json-schema';
|
||||
|
||||
-/** The Standard Schema interface. */
|
||||
-interface StandardSchemaV1<Input = unknown, Output = Input> {
|
||||
- /** The Standard Schema properties. */
|
||||
- readonly "~standard": StandardSchemaV1.Props<Input, Output>;
|
||||
-}
|
||||
-declare namespace StandardSchemaV1 {
|
||||
- /** The Standard Schema properties interface. */
|
||||
- export interface Props<Input = unknown, Output = Input> {
|
||||
- /** The version number of the standard. */
|
||||
- readonly version: 1;
|
||||
- /** The vendor name of the schema library. */
|
||||
- readonly vendor: string;
|
||||
- /** Validates unknown input values. */
|
||||
- readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>;
|
||||
- /** Inferred types associated with the schema. */
|
||||
- readonly types?: Types<Input, Output> | undefined;
|
||||
- }
|
||||
- /** The result interface of the validate function. */
|
||||
- export type Result<Output> = SuccessResult<Output> | FailureResult;
|
||||
- /** The result interface if validation succeeds. */
|
||||
- export interface SuccessResult<Output> {
|
||||
- /** The typed output value. */
|
||||
- readonly value: Output;
|
||||
- /** The non-existent issues. */
|
||||
- readonly issues?: undefined;
|
||||
- }
|
||||
- /** The result interface if validation fails. */
|
||||
- export interface FailureResult {
|
||||
- /** The issues of failed validation. */
|
||||
- readonly issues: ReadonlyArray<Issue>;
|
||||
- }
|
||||
- /** The issue interface of the failure output. */
|
||||
- export interface Issue {
|
||||
- /** The error message of the issue. */
|
||||
- readonly message: string;
|
||||
- /** The path of the issue, if any. */
|
||||
- readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
|
||||
- }
|
||||
- /** The path segment interface of the issue. */
|
||||
- export interface PathSegment {
|
||||
- /** The key representing a path segment. */
|
||||
- readonly key: PropertyKey;
|
||||
- }
|
||||
- /** The Standard Schema types interface. */
|
||||
- export interface Types<Input = unknown, Output = Input> {
|
||||
- /** The input type of the schema. */
|
||||
- readonly input: Input;
|
||||
- /** The output type of the schema. */
|
||||
- readonly output: Output;
|
||||
- }
|
||||
- /** Infers the input type of a Standard Schema. */
|
||||
- export type InferInput<Schema extends StandardSchemaV1> = NonNullable<Schema["~standard"]["types"]>["input"];
|
||||
- /** Infers the output type of a Standard Schema. */
|
||||
- export type InferOutput<Schema extends StandardSchemaV1> = NonNullable<Schema["~standard"]["types"]>["output"];
|
||||
- export { };
|
||||
-}
|
||||
-
|
||||
declare function loadVendor(vendor: string, fn: {
|
||||
toJSONSchema?: Parameters<typeof loadVendor$1>[1];
|
||||
toOpenAPISchema?: Parameters<typeof loadVendor$2>[1];
|
||||
diff --git a/dist/index.d.ts b/dist/index.d.ts
|
||||
index 0f49b720b0b8c827fef52a2982fb194e98bc0c50..f690bb3304abeb78a1c10323bdb605d81eb99078 100644
|
||||
--- a/dist/index.d.ts
|
||||
+++ b/dist/index.d.ts
|
||||
@@ -5,66 +5,10 @@ import { TypedResponse, RouterRoute, ValidationTargets as ValidationTargets$1, B
|
||||
import { loadVendor as loadVendor$2, ToOpenAPISchemaContext } from '@standard-community/standard-openapi';
|
||||
import { Hook } from '@hono/standard-validator';
|
||||
import { loadVendor as loadVendor$1 } from '@standard-community/standard-json';
|
||||
+import { StandardSchemaV1 } from '@standard-schema/spec';
|
||||
import { StatusCode } from 'hono/utils/http-status';
|
||||
import { JSONSchema7 } from 'json-schema';
|
||||
|
||||
-/** The Standard Schema interface. */
|
||||
-interface StandardSchemaV1<Input = unknown, Output = Input> {
|
||||
- /** The Standard Schema properties. */
|
||||
- readonly "~standard": StandardSchemaV1.Props<Input, Output>;
|
||||
-}
|
||||
-declare namespace StandardSchemaV1 {
|
||||
- /** The Standard Schema properties interface. */
|
||||
- export interface Props<Input = unknown, Output = Input> {
|
||||
- /** The version number of the standard. */
|
||||
- readonly version: 1;
|
||||
- /** The vendor name of the schema library. */
|
||||
- readonly vendor: string;
|
||||
- /** Validates unknown input values. */
|
||||
- readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>;
|
||||
- /** Inferred types associated with the schema. */
|
||||
- readonly types?: Types<Input, Output> | undefined;
|
||||
- }
|
||||
- /** The result interface of the validate function. */
|
||||
- export type Result<Output> = SuccessResult<Output> | FailureResult;
|
||||
- /** The result interface if validation succeeds. */
|
||||
- export interface SuccessResult<Output> {
|
||||
- /** The typed output value. */
|
||||
- readonly value: Output;
|
||||
- /** The non-existent issues. */
|
||||
- readonly issues?: undefined;
|
||||
- }
|
||||
- /** The result interface if validation fails. */
|
||||
- export interface FailureResult {
|
||||
- /** The issues of failed validation. */
|
||||
- readonly issues: ReadonlyArray<Issue>;
|
||||
- }
|
||||
- /** The issue interface of the failure output. */
|
||||
- export interface Issue {
|
||||
- /** The error message of the issue. */
|
||||
- readonly message: string;
|
||||
- /** The path of the issue, if any. */
|
||||
- readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
|
||||
- }
|
||||
- /** The path segment interface of the issue. */
|
||||
- export interface PathSegment {
|
||||
- /** The key representing a path segment. */
|
||||
- readonly key: PropertyKey;
|
||||
- }
|
||||
- /** The Standard Schema types interface. */
|
||||
- export interface Types<Input = unknown, Output = Input> {
|
||||
- /** The input type of the schema. */
|
||||
- readonly input: Input;
|
||||
- /** The output type of the schema. */
|
||||
- readonly output: Output;
|
||||
- }
|
||||
- /** Infers the input type of a Standard Schema. */
|
||||
- export type InferInput<Schema extends StandardSchemaV1> = NonNullable<Schema["~standard"]["types"]>["input"];
|
||||
- /** Infers the output type of a Standard Schema. */
|
||||
- export type InferOutput<Schema extends StandardSchemaV1> = NonNullable<Schema["~standard"]["types"]>["output"];
|
||||
- export { };
|
||||
-}
|
||||
-
|
||||
declare function loadVendor(vendor: string, fn: {
|
||||
toJSONSchema?: Parameters<typeof loadVendor$1>[1];
|
||||
toOpenAPISchema?: Parameters<typeof loadVendor$2>[1];
|
||||
Reference in New Issue
Block a user