diff --git a/bun.lock b/bun.lock index 42960d2aaa..90b73cbdd5 100644 --- a/bun.lock +++ b/bun.lock @@ -352,7 +352,7 @@ "clipboardy": "4.0.0", "decimal.js": "10.5.0", "diff": "catalog:", - "drizzle-orm": "1.0.0-beta.16-ea816b6", + "drizzle-orm": "catalog:", "fuzzysort": "3.1.0", "glob": "13.0.5", "google-auth-library": "10.5.0", @@ -403,8 +403,7 @@ "@types/which": "3.0.4", "@types/yargs": "17.0.33", "@typescript/native-preview": "catalog:", - "drizzle-kit": "1.0.0-beta.16-ea816b6", - "drizzle-orm": "1.0.0-beta.16-ea816b6", + "drizzle-kit": "catalog:", "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", @@ -609,8 +608,8 @@ "ai": "5.0.124", "diff": "8.0.2", "dompurify": "3.3.1", - "drizzle-kit": "1.0.0-beta.16-ea816b6", - "drizzle-orm": "1.0.0-beta.16-ea816b6", + "drizzle-kit": "1.0.0-beta.16-c2458b2", + "drizzle-orm": "1.0.0-beta.16-c2458b2", "fuzzysort": "3.1.0", "hono": "4.10.7", "hono-openapi": "1.1.2", @@ -2782,9 +2781,9 @@ "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], - "drizzle-kit": ["drizzle-kit@1.0.0-beta.16-ea816b6", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GiJQqCNPZP8Kk+i7/sFa3rtXbq26tLDNi3LbMx9aoLuwF2ofk8CS7cySUGdI+r4J3q0a568quC8FZeaFTCw4IA=="], + "drizzle-kit": ["drizzle-kit@1.0.0-beta.16-c2458b2", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-3qgD1qewcOdYAsuLQPGIu/DSxBafvsGu0Uee7/ezjU79Ux4kN+DNrKfPAKBi8NXFpndYAKN/5EjELXFmnrdXXA=="], - "drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="], + "drizzle-orm": ["drizzle-orm@1.0.0-beta.16-c2458b2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-A+9525OCLXVKNFbs4rI8b8+vqZ2r06wei83BPfhT9eEFz8AGh/jVK02L/nsniSxrzUCXXcWm/SMzhqkhDr05Gw=="], "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], diff --git a/package.json b/package.json index d1358a3966..8cc318bac5 100644 --- a/package.json +++ b/package.json @@ -41,9 +41,8 @@ "@tailwindcss/vite": "4.1.11", "diff": "8.0.2", "dompurify": "3.3.1", - "drizzle-kit": "1.0.0-beta.16-ea816b6", - "drizzle-orm": "1.0.0-beta.16-ea816b6", - "effect": "4.0.0-beta.29", + "drizzle-kit": "1.0.0-beta.16-c2458b2", + "drizzle-orm": "1.0.0-beta.16-c2458b2", "ai": "5.0.124", "hono": "4.10.7", "hono-openapi": "1.1.2", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index fae720b0d1..eeabdb4023 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -25,9 +25,15 @@ "exports": { "./*": "./src/*.ts" }, + "imports": { + "#db": { + "bun": "./src/storage/db.bun.ts", + "node": "./src/storage/db.node.ts", + "default": "./src/storage/db.bun.ts" + } + }, "devDependencies": { "@babel/core": "7.28.4", - "@effect/language-service": "0.79.0", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/script": "workspace:*", "@parcel/watcher-darwin-arm64": "2.5.1", @@ -48,8 +54,7 @@ "@types/which": "3.0.4", "@types/yargs": "17.0.33", "@typescript/native-preview": "catalog:", - "drizzle-kit": "1.0.0-beta.16-ea816b6", - "drizzle-orm": "1.0.0-beta.16-ea816b6", + "drizzle-kit": "catalog:", "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", @@ -94,8 +99,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.4", - "@opentui/core": "0.1.87", - "@opentui/solid": "0.1.87", + "@opentui/core": "0.1.86", + "@opentui/solid": "0.1.86", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -110,8 +115,7 @@ "clipboardy": "4.0.0", "decimal.js": "10.5.0", "diff": "catalog:", - "drizzle-orm": "1.0.0-beta.16-ea816b6", - "effect": "catalog:", + "drizzle-orm": "catalog:", "fuzzysort": "3.1.0", "glob": "13.0.5", "google-auth-library": "10.5.0", diff --git a/packages/opencode/script/build-node.ts b/packages/opencode/script/build-node.ts new file mode 100644 index 0000000000..906279e715 --- /dev/null +++ b/packages/opencode/script/build-node.ts @@ -0,0 +1,6 @@ +await Bun.build({ + target: "node", + entrypoints: ["./src/node.ts"], + outdir: "./dist", + format: "esm", +}) diff --git a/packages/opencode/src/node.ts b/packages/opencode/src/node.ts new file mode 100644 index 0000000000..b0e653d6cc --- /dev/null +++ b/packages/opencode/src/node.ts @@ -0,0 +1 @@ +export { Server } from "./server/server" diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 3d435c8c99..180d8258c6 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -25,7 +25,6 @@ import { WorkspaceContext } from "../control-plane/workspace-context" import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware" import { ProjectRoutes } from "./routes/project" import { SessionRoutes } from "./routes/session" -import { PtyRoutes } from "./routes/pty" import { McpRoutes } from "./routes/mcp" import { FileRoutes } from "./routes/file" import { ConfigRoutes } from "./routes/config" @@ -43,6 +42,7 @@ import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" import { MDNS } from "./mdns" import { lazy } from "@/util/lazy" +// import { PtyRoutes } from "./routes/pty" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -54,520 +54,524 @@ export namespace Server { export const createApp = (opts: { cors?: string[] }): Hono => { const app = new Hono() - return app - .onError((err, c) => { - log.error("failed", { - error: err, + return ( + app + .onError((err, c) => { + log.error("failed", { + error: err, + }) + if (err instanceof NamedError) { + let status: ContentfulStatusCode + if (err instanceof NotFoundError) status = 404 + else if (err instanceof Provider.ModelNotFoundError) status = 400 + else if (err.name.startsWith("Worktree")) status = 400 + else status = 500 + return c.json(err.toObject(), { status }) + } + if (err instanceof HTTPException) return err.getResponse() + const message = err instanceof Error && err.stack ? err.stack : err.toString() + return c.json(new NamedError.Unknown({ message }).toObject(), { + status: 500, + }) }) - if (err instanceof NamedError) { - let status: ContentfulStatusCode - if (err instanceof NotFoundError) status = 404 - else if (err instanceof Provider.ModelNotFoundError) status = 400 - else if (err.name.startsWith("Worktree")) status = 400 - else status = 500 - return c.json(err.toObject(), { status }) - } - if (err instanceof HTTPException) return err.getResponse() - const message = err instanceof Error && err.stack ? err.stack : err.toString() - return c.json(new NamedError.Unknown({ message }).toObject(), { - status: 500, + .use((c, next) => { + // Allow CORS preflight requests to succeed without auth. + // Browser clients sending Authorization headers will preflight with OPTIONS. + if (c.req.method === "OPTIONS") return next() + const password = Flag.OPENCODE_SERVER_PASSWORD + if (!password) return next() + const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" + return basicAuth({ username, password })(c, next) }) - }) - .use((c, next) => { - // Allow CORS preflight requests to succeed without auth. - // Browser clients sending Authorization headers will preflight with OPTIONS. - if (c.req.method === "OPTIONS") return next() - const password = Flag.OPENCODE_SERVER_PASSWORD - if (!password) return next() - const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" - return basicAuth({ username, password })(c, next) - }) - .use(async (c, next) => { - const skipLogging = c.req.path === "/log" - if (!skipLogging) { - log.info("request", { + .use(async (c, next) => { + const skipLogging = c.req.path === "/log" + if (!skipLogging) { + log.info("request", { + method: c.req.method, + path: c.req.path, + }) + } + const timer = log.time("request", { method: c.req.method, path: c.req.path, }) - } - const timer = log.time("request", { - method: c.req.method, - path: c.req.path, - }) - await next() - if (!skipLogging) { - timer.stop() - } - }) - .use( - cors({ - origin(input) { - if (!input) return - - if (input.startsWith("http://localhost:")) return input - if (input.startsWith("http://127.0.0.1:")) return input - if ( - input === "tauri://localhost" || - input === "http://tauri.localhost" || - input === "https://tauri.localhost" - ) - return input - - // *.opencode.ai (https only, adjust if needed) - if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) { - return input - } - if (opts?.cors?.includes(input)) { - return input - } - - return - }, - }), - ) - .route("/global", GlobalRoutes()) - .put( - "/auth/:providerID", - describeRoute({ - summary: "Set auth credentials", - description: "Set authentication credentials", - operationId: "auth.set", - responses: { - 200: { - description: "Successfully set authentication credentials", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: z.string(), - }), - ), - validator("json", Auth.Info), - async (c) => { - const providerID = c.req.valid("param").providerID - const info = c.req.valid("json") - await Auth.set(providerID, info) - return c.json(true) - }, - ) - .delete( - "/auth/:providerID", - describeRoute({ - summary: "Remove auth credentials", - description: "Remove authentication credentials", - operationId: "auth.remove", - responses: { - 200: { - description: "Successfully removed authentication credentials", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: z.string(), - }), - ), - async (c) => { - const providerID = c.req.valid("param").providerID - await Auth.remove(providerID) - return c.json(true) - }, - ) - .use(async (c, next) => { - if (c.req.path === "/log") return next() - const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace") - const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() - const directory = Filesystem.resolve( - (() => { - try { - return decodeURIComponent(raw) - } catch { - return raw - } - })(), - ) - - return WorkspaceContext.provide({ - workspaceID, - async fn() { - return Instance.provide({ - directory, - init: InstanceBootstrap, - async fn() { - return next() - }, - }) - }, - }) - }) - .use(WorkspaceRouterMiddleware) - .get( - "/doc", - openAPIRouteHandler(app, { - documentation: { - info: { - title: "opencode", - version: "0.0.3", - description: "opencode api", - }, - openapi: "3.1.1", - }, - }), - ) - .use( - validator( - "query", - z.object({ - directory: z.string().optional(), - workspace: z.string().optional(), - }), - ), - ) - .route("/project", ProjectRoutes()) - .route("/pty", PtyRoutes()) - .route("/config", ConfigRoutes()) - .route("/experimental", ExperimentalRoutes()) - .route("/session", SessionRoutes()) - .route("/permission", PermissionRoutes()) - .route("/question", QuestionRoutes()) - .route("/provider", ProviderRoutes()) - .route("/", FileRoutes()) - .route("/mcp", McpRoutes()) - .route("/tui", TuiRoutes()) - .post( - "/instance/dispose", - describeRoute({ - summary: "Dispose instance", - description: "Clean up and dispose the current OpenCode instance, releasing all resources.", - operationId: "instance.dispose", - responses: { - 200: { - description: "Instance disposed", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Instance.dispose() - return c.json(true) - }, - ) - .get( - "/path", - describeRoute({ - summary: "Get paths", - description: "Retrieve the current working directory and related path information for the OpenCode instance.", - operationId: "path.get", - responses: { - 200: { - description: "Path", - content: { - "application/json": { - schema: resolver( - z - .object({ - home: z.string(), - state: z.string(), - config: z.string(), - worktree: z.string(), - directory: z.string(), - }) - .meta({ - ref: "Path", - }), - ), - }, - }, - }, - }, - }), - async (c) => { - return c.json({ - home: Global.Path.home, - state: Global.Path.state, - config: Global.Path.config, - worktree: Instance.worktree, - directory: Instance.directory, - }) - }, - ) - .get( - "/vcs", - describeRoute({ - summary: "Get VCS info", - description: "Retrieve version control system (VCS) information for the current project, such as git branch.", - operationId: "vcs.get", - responses: { - 200: { - description: "VCS info", - content: { - "application/json": { - schema: resolver(Vcs.Info), - }, - }, - }, - }, - }), - async (c) => { - const branch = await Vcs.branch() - return c.json({ - branch, - }) - }, - ) - .get( - "/command", - describeRoute({ - summary: "List commands", - description: "Get a list of all available commands in the OpenCode system.", - operationId: "command.list", - responses: { - 200: { - description: "List of commands", - content: { - "application/json": { - schema: resolver(Command.Info.array()), - }, - }, - }, - }, - }), - async (c) => { - const commands = await Command.list() - return c.json(commands) - }, - ) - .post( - "/log", - describeRoute({ - summary: "Write log", - description: "Write a log entry to the server logs with specified level and metadata.", - operationId: "app.log", - responses: { - 200: { - description: "Log entry written successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - z.object({ - service: z.string().meta({ description: "Service name for the log entry" }), - level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }), - message: z.string().meta({ description: "Log message" }), - extra: z - .record(z.string(), z.any()) - .optional() - .meta({ description: "Additional metadata for the log entry" }), - }), - ), - async (c) => { - const { service, level, message, extra } = c.req.valid("json") - const logger = Log.create({ service }) - - switch (level) { - case "debug": - logger.debug(message, extra) - break - case "info": - logger.info(message, extra) - break - case "error": - logger.error(message, extra) - break - case "warn": - logger.warn(message, extra) - break + await next() + if (!skipLogging) { + timer.stop() } + }) + .use( + cors({ + origin(input) { + if (!input) return - return c.json(true) - }, - ) - .get( - "/agent", - describeRoute({ - summary: "List agents", - description: "Get a list of all available AI agents in the OpenCode system.", - operationId: "app.agents", - responses: { - 200: { - description: "List of agents", - content: { - "application/json": { - schema: resolver(Agent.Info.array()), - }, - }, - }, - }, - }), - async (c) => { - const modes = await Agent.list() - return c.json(modes) - }, - ) - .get( - "/skill", - describeRoute({ - summary: "List skills", - description: "Get a list of all available skills in the OpenCode system.", - operationId: "app.skills", - responses: { - 200: { - description: "List of skills", - content: { - "application/json": { - schema: resolver(Skill.Info.array()), - }, - }, - }, - }, - }), - async (c) => { - const skills = await Skill.all() - return c.json(skills) - }, - ) - .get( - "/lsp", - describeRoute({ - summary: "Get LSP status", - description: "Get LSP server status", - operationId: "lsp.status", - responses: { - 200: { - description: "LSP server status", - content: { - "application/json": { - schema: resolver(LSP.Status.array()), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await LSP.status()) - }, - ) - .get( - "/formatter", - describeRoute({ - summary: "Get formatter status", - description: "Get formatter status", - operationId: "formatter.status", - responses: { - 200: { - description: "Formatter status", - content: { - "application/json": { - schema: resolver(Format.Status.array()), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await Format.status()) - }, - ) - .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()), - }, - }, - }, - }, - }), - 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() + if (input.startsWith("http://localhost:")) return input + if (input.startsWith("http://127.0.0.1:")) return input + if ( + input === "tauri://localhost" || + input === "http://tauri.localhost" || + input === "https://tauri.localhost" + ) + return input + + // *.opencode.ai (https only, adjust if needed) + if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) { + return input + } + if (opts?.cors?.includes(input)) { + return input } - }) - // Send heartbeat every 10s to prevent stalled proxy streams. - const heartbeat = setInterval(() => { + return + }, + }), + ) + .route("/global", GlobalRoutes()) + .put( + "/auth/:providerID", + describeRoute({ + summary: "Set auth credentials", + description: "Set authentication credentials", + operationId: "auth.set", + responses: { + 200: { + description: "Successfully set authentication credentials", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: z.string(), + }), + ), + validator("json", Auth.Info), + async (c) => { + const providerID = c.req.valid("param").providerID + const info = c.req.valid("json") + await Auth.set(providerID, info) + return c.json(true) + }, + ) + .delete( + "/auth/:providerID", + describeRoute({ + summary: "Remove auth credentials", + description: "Remove authentication credentials", + operationId: "auth.remove", + responses: { + 200: { + description: "Successfully removed authentication credentials", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: z.string(), + }), + ), + async (c) => { + const providerID = c.req.valid("param").providerID + await Auth.remove(providerID) + return c.json(true) + }, + ) + .use(async (c, next) => { + if (c.req.path === "/log") return next() + const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace") + const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() + const directory = Filesystem.resolve( + (() => { + try { + return decodeURIComponent(raw) + } catch { + return raw + } + })(), + ) + + return WorkspaceContext.provide({ + workspaceID, + async fn() { + return Instance.provide({ + directory, + init: InstanceBootstrap, + async fn() { + return next() + }, + }) + }, + }) + }) + .use(WorkspaceRouterMiddleware) + .get( + "/doc", + openAPIRouteHandler(app, { + documentation: { + info: { + title: "opencode", + version: "0.0.3", + description: "opencode api", + }, + openapi: "3.1.1", + }, + }), + ) + .use( + validator( + "query", + z.object({ + directory: z.string().optional(), + workspace: z.string().optional(), + }), + ), + ) + .route("/project", ProjectRoutes()) + // .route("/pty", PtyRoutes()) + .route("/config", ConfigRoutes()) + .route("/experimental", ExperimentalRoutes()) + .route("/session", SessionRoutes()) + .route("/permission", PermissionRoutes()) + .route("/question", QuestionRoutes()) + .route("/provider", ProviderRoutes()) + .route("/", FileRoutes()) + .route("/mcp", McpRoutes()) + .route("/tui", TuiRoutes()) + .post( + "/instance/dispose", + describeRoute({ + summary: "Dispose instance", + description: "Clean up and dispose the current OpenCode instance, releasing all resources.", + operationId: "instance.dispose", + responses: { + 200: { + description: "Instance disposed", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Instance.dispose() + return c.json(true) + }, + ) + .get( + "/path", + describeRoute({ + summary: "Get paths", + description: + "Retrieve the current working directory and related path information for the OpenCode instance.", + operationId: "path.get", + responses: { + 200: { + description: "Path", + content: { + "application/json": { + schema: resolver( + z + .object({ + home: z.string(), + state: z.string(), + config: z.string(), + worktree: z.string(), + directory: z.string(), + }) + .meta({ + ref: "Path", + }), + ), + }, + }, + }, + }, + }), + async (c) => { + return c.json({ + home: Global.Path.home, + state: Global.Path.state, + config: Global.Path.config, + worktree: Instance.worktree, + directory: Instance.directory, + }) + }, + ) + .get( + "/vcs", + describeRoute({ + summary: "Get VCS info", + description: + "Retrieve version control system (VCS) information for the current project, such as git branch.", + operationId: "vcs.get", + responses: { + 200: { + description: "VCS info", + content: { + "application/json": { + schema: resolver(Vcs.Info), + }, + }, + }, + }, + }), + async (c) => { + const branch = await Vcs.branch() + return c.json({ + branch, + }) + }, + ) + .get( + "/command", + describeRoute({ + summary: "List commands", + description: "Get a list of all available commands in the OpenCode system.", + operationId: "command.list", + responses: { + 200: { + description: "List of commands", + content: { + "application/json": { + schema: resolver(Command.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const commands = await Command.list() + return c.json(commands) + }, + ) + .post( + "/log", + describeRoute({ + summary: "Write log", + description: "Write a log entry to the server logs with specified level and metadata.", + operationId: "app.log", + responses: { + 200: { + description: "Log entry written successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + service: z.string().meta({ description: "Service name for the log entry" }), + level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }), + message: z.string().meta({ description: "Log message" }), + extra: z + .record(z.string(), z.any()) + .optional() + .meta({ description: "Additional metadata for the log entry" }), + }), + ), + async (c) => { + const { service, level, message, extra } = c.req.valid("json") + const logger = Log.create({ service }) + + switch (level) { + case "debug": + logger.debug(message, extra) + break + case "info": + logger.info(message, extra) + break + case "error": + logger.error(message, extra) + break + case "warn": + logger.warn(message, extra) + break + } + + return c.json(true) + }, + ) + .get( + "/agent", + describeRoute({ + summary: "List agents", + description: "Get a list of all available AI agents in the OpenCode system.", + operationId: "app.agents", + responses: { + 200: { + description: "List of agents", + content: { + "application/json": { + schema: resolver(Agent.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const modes = await Agent.list() + return c.json(modes) + }, + ) + .get( + "/skill", + describeRoute({ + summary: "List skills", + description: "Get a list of all available skills in the OpenCode system.", + operationId: "app.skills", + responses: { + 200: { + description: "List of skills", + content: { + "application/json": { + schema: resolver(Skill.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const skills = await Skill.all() + return c.json(skills) + }, + ) + .get( + "/lsp", + describeRoute({ + summary: "Get LSP status", + description: "Get LSP server status", + operationId: "lsp.status", + responses: { + 200: { + description: "LSP server status", + content: { + "application/json": { + schema: resolver(LSP.Status.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await LSP.status()) + }, + ) + .get( + "/formatter", + describeRoute({ + summary: "Get formatter status", + description: "Get formatter status", + operationId: "formatter.status", + responses: { + 200: { + description: "Formatter status", + content: { + "application/json": { + schema: resolver(Format.Status.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await Format.status()) + }, + ) + .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()), + }, + }, + }, + }, + }), + 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.heartbeat", + type: "server.connected", properties: {}, }), }) - }, 10_000) + const unsub = Bus.subscribeAll(async (event) => { + await stream.writeSSE({ + data: JSON.stringify(event), + }) + if (event.type === Bus.InstanceDisposed.type) { + stream.close() + } + }) - await new Promise((resolve) => { - stream.onAbort(() => { - clearInterval(heartbeat) - unsub() - resolve() - log.info("event disconnected") + // 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((resolve) => { + stream.onAbort(() => { + clearInterval(heartbeat) + unsub() + resolve() + log.info("event disconnected") + }) }) }) - }) - }, - ) - .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", }, - }) - 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 - }) + .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", + }, + }) + 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 + }) + ) } export async function openapi() { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 5b4e7bdbc0..e0b5cc7786 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -8,12 +8,9 @@ import { Snapshot } from "@/snapshot" import { fn } from "@/util/fn" import { Database, eq, desc, inArray } from "@/storage/db" import { MessageTable, PartTable } from "./session.sql" -import { ProviderTransform } from "@/provider/transform" -import { STATUS_CODES } from "http" -import { Storage } from "@/storage/storage" import { ProviderError } from "@/provider/error" import { iife } from "@/util/iife" -import { type SystemError } from "bun" +import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" export namespace MessageV2 { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 54adf1104a..82fe84cb52 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -31,7 +31,6 @@ import { Flag } from "../flag/flag" import { ulid } from "ulid" import { spawn } from "child_process" import { Command } from "../command" -import { $ } from "bun" import { pathToFileURL, fileURLToPath } from "url" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" @@ -46,6 +45,7 @@ import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" import { Truncate } from "@/tool/truncation" +import { Process } from "@/util/process" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -1784,15 +1784,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the template = template + "\n\n" + input.arguments } - const shell = ConfigMarkdown.shell(template) - if (shell.length > 0) { + const shellMatches = ConfigMarkdown.shell(template) + if (shellMatches.length > 0) { + const sh = Shell.preferred() const results = await Promise.all( - shell.map(async ([, cmd]) => { - try { - return await $`${{ raw: cmd }}`.quiet().nothrow().text() - } catch (error) { - return `Error executing command: ${error instanceof Error ? error.message : String(error)}` - } + shellMatches.map(async ([, cmd]) => { + const out = await Process.text([cmd], { shell: sh, nothrow: true }) + return out.text }), ) let index = 0 diff --git a/packages/opencode/src/storage/db.bun.ts b/packages/opencode/src/storage/db.bun.ts new file mode 100644 index 0000000000..fa6190925a --- /dev/null +++ b/packages/opencode/src/storage/db.bun.ts @@ -0,0 +1,8 @@ +import { Database } from "bun:sqlite" +import { drizzle } from "drizzle-orm/bun-sqlite" + +export function init(path: string) { + const sqlite = new Database(path, { create: true }) + const db = drizzle({ client: sqlite }) + return db +} diff --git a/packages/opencode/src/storage/db.node.ts b/packages/opencode/src/storage/db.node.ts new file mode 100644 index 0000000000..0dba8dcef3 --- /dev/null +++ b/packages/opencode/src/storage/db.node.ts @@ -0,0 +1,8 @@ +import { DatabaseSync } from "node:sqlite" +import { drizzle } from "drizzle-orm/node-sqlite" + +export function init(path: string) { + const sqlite = new DatabaseSync(path) + const db = drizzle({ client: sqlite }) + return db +} diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index beb8e3eb52..72c6916d0f 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -1,5 +1,4 @@ -import { Database as BunDatabase } from "bun:sqlite" -import { drizzle, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" +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" @@ -11,10 +10,10 @@ import { NamedError } from "@opencode-ai/util/error" import z from "zod" import path from "path" import { readFileSync, readdirSync, existsSync } from "fs" -import * as schema from "./schema" import { Installation } from "../installation" import { Flag } from "../flag/flag" import { iife } from "@/util/iife" +import { init } from "#db" declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined @@ -36,17 +35,12 @@ export namespace Database { return path.join(Global.Path.data, `opencode-${safe}.db`) }) - type Schema = typeof schema - export type Transaction = SQLiteTransaction<"sync", void, Schema> + export type Transaction = SQLiteTransaction<"sync", void> type Client = SQLiteBunDatabase type Journal = { sql: string; timestamp: number; name: string }[] - const state = { - sqlite: undefined as BunDatabase | undefined, - } - function time(tag: string) { const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag) if (!match) return 0 @@ -83,17 +77,14 @@ export namespace Database { export const Client = lazy(() => { log.info("opening database", { path: Path }) - const sqlite = new BunDatabase(Path, { create: true }) - state.sqlite = sqlite + const db = init(Path) - sqlite.run("PRAGMA journal_mode = WAL") - sqlite.run("PRAGMA synchronous = NORMAL") - sqlite.run("PRAGMA busy_timeout = 5000") - sqlite.run("PRAGMA cache_size = -64000") - sqlite.run("PRAGMA foreign_keys = ON") - sqlite.run("PRAGMA wal_checkpoint(PASSIVE)") - - const db = drizzle({ client: sqlite }) + db.run("PRAGMA journal_mode = WAL") + db.run("PRAGMA synchronous = NORMAL") + db.run("PRAGMA busy_timeout = 5000") + db.run("PRAGMA cache_size = -64000") + db.run("PRAGMA foreign_keys = ON") + db.run("PRAGMA wal_checkpoint(PASSIVE)") // Apply schema migrations const entries = @@ -117,14 +108,10 @@ export namespace Database { }) export function close() { - const sqlite = state.sqlite - if (!sqlite) return - sqlite.close() - state.sqlite = undefined - Client.reset() + Client().$client.close() } - export type TxOrDb = SQLiteTransaction<"sync", void, any, any> | Client + export type TxOrDb = Transaction | Client const ctx = Context.create<{ tx: TxOrDb diff --git a/packages/opencode/src/util/process.ts b/packages/opencode/src/util/process.ts index 0490969370..bf092c9958 100644 --- a/packages/opencode/src/util/process.ts +++ b/packages/opencode/src/util/process.ts @@ -13,6 +13,7 @@ export namespace Process { abort?: AbortSignal kill?: NodeJS.Signals | number timeout?: number + shell?: string } export interface RunOptions extends Omit { @@ -58,6 +59,7 @@ export namespace Process { const proc = launch(cmd[0], cmd.slice(1), { cwd: opts.cwd, + shell: opts.shell, env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined, stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"], windowsHide: process.platform === "win32",