Compare commits

...
Author SHA1 Message Date
Adam 0a53f8e084 chore: cleanup 2026-03-11 13:56:16 -05:00
Adam 6f5b2f786e wip: node-pty 2026-03-11 13:47:20 -05:00
Luke ParkerandGitHub 7910ce5d36 fix: guard Npm.which() against infinite loop when .bin is empty (#16961) 2026-03-11 09:34:58 -04:00
DaxandGitHub 6ad171dba9 Merge branch 'dev' into opencode-2-0 2026-03-10 17:20:19 -04:00
Dax Raad cb5674edc7 sync 2026-03-10 17:00:15 -04:00
Dax Raad b99de4118e refactor(npm): inline pkgPath and lockPath variables 2026-03-10 16:59:01 -04:00
Dax Raad 040700dbc4 unbreak 2026-03-10 16:07:25 -04:00
Dax Raad 4d5da9697e sync 2026-03-10 16:02:40 -04:00
Dax Raad a28648f530 core: enable running in non-Bun environments by using standard Node.js APIs for OAuth servers and retry logic 2026-03-10 16:02:40 -04:00
Dax Raad 4d81e2d4d9 sync 2026-03-10 16:02:40 -04:00
Dax Raad 21e72cbf42 core: cleaner error output and more flexible custom tool directories
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps
- Fixed database connection cleanup to prevent resource leaks between sessions
- Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
2026-03-10 16:02:40 -04:00
Dax Raad 5f277d1e62 core: return structured server info with stop method from workspace server
- Enables graceful server shutdown for workspace management
- Removes unsupported serverUrl getter that threw errors in plugin context
2026-03-10 16:02:40 -04:00
Dax Raad d67e877e28 core: remove shell execution and server URL from plugin API
Plugins no longer receive shell access or server URL to prevent unauthorized
execution and limit plugin sandbox surface area.
2026-03-10 16:02:40 -04:00
Dax Raad d4e51e04b3 sync 2026-03-10 16:02:40 -04:00
Dax Raad 070c1679e4 core: bundle database migrations into node build and auto-start server on port 1338 2026-03-10 16:02:40 -04:00
Dax Raad 406d216cd2 refactor(server): replace Bun serve with Hono node adapters 2026-03-10 16:02:40 -04:00
Dax Raad 5dc8b4ef29 core: add Node.js runtime support
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
2026-03-10 16:02:39 -04:00
Luke ParkerandDax Raad 2f41d89163 fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar (#16853) 2026-03-10 16:02:39 -04:00
Dax Raad b2eae867a1 tui: fix Windows plugin loading by using direct paths instead of file URLs 2026-03-10 16:02:39 -04:00
Dax Raad 3c2fda4d91 core: fix custom tool loading to properly resolve module paths 2026-03-10 16:02:39 -04:00
Dax Raad 2678ceb45e sync 2026-03-10 16:02:39 -04:00
Dax Raad 58a4cd00b6 sync 2026-03-10 16:02:39 -04:00
Dax Raad 0faa191b6d sync 2026-03-10 16:02:39 -04:00
Dax Raad 58cf092105 core: log npm install errors to console for debugging dependency failures 2026-03-10 16:02:39 -04:00
Dax Raad 0ff8bfe1d9 sync 2026-03-10 16:02:39 -04:00
Dax Raad ceb79c786a core: fix CLI tools from npm packages not being accessible after install on Windows 2026-03-10 16:02:39 -04:00
Dax Raad b1a15d559b sync 2026-03-10 16:02:39 -04:00
Dax Raad 124a8abf9b tui: export sessions using consistent Filesystem API instead of Bun.write 2026-03-10 16:02:39 -04:00
Dax Raad 85c2bb342b core: fix npm dependency installation on Windows CI by disabling bin links when symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad 4c57e39466 core: enable npm bin links on non-Windows platforms to allow plugin executables to work while keeping them disabled on Windows CI where symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad 0cdd4e4e16 core: fix dependency installation failures behind corporate proxies or in CI by disabling Bun cache when network interception is detected 2026-03-10 16:02:39 -04:00
Dax Raad a9b01be0c2 core: disable npm bin links to fix package installation in sandboxed environments 2026-03-10 16:02:39 -04:00
Dax Raad 528daf5490 core: dynamically resolve formatter executable paths at runtime
Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
2026-03-10 16:02:39 -04:00
Dax Raad 0e176d3ac3 sync 2026-03-10 16:02:39 -04:00
DaxDax Raadgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
27f359852e Update packages/opencode/src/util/which.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
DaxDax Raadgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
173128d431 Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Dax Raad e8ee1e239f sync 2026-03-10 16:02:39 -04:00
Dax Raad 656fa191c1 refactor: lsp server and core improvements 2026-03-10 16:02:39 -04:00
49 changed files with 1823 additions and 1403 deletions
+5 -2
View File
@@ -1,3 +1,6 @@
plans/ node_modules
bun.lock plans
package.json package.json
bun.lock
.gitignore
package-lock.json
+10 -2
View File
@@ -1,6 +1,5 @@
/// <reference path="../env.d.ts" /> /// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-pr-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) { async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, { const response = await fetch(`https://api.github.com${endpoint}`, {
@@ -24,7 +23,16 @@ interface PR {
} }
export default tool({ export default tool({
description: DESCRIPTION, description: `Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
args: { args: {
query: tool.schema.string().describe("Search query for PR titles and descriptions"), query: tool.schema.string().describe("Search query for PR titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10), limit: tool.schema.number().describe("Maximum number of results to return").default(10),
-10
View File
@@ -1,10 +0,0 @@
Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.
+6 -2
View File
@@ -1,6 +1,5 @@
/// <reference path="../env.d.ts" /> /// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt"
const TEAM = { const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"], desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
@@ -40,7 +39,12 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
} }
export default tool({ export default tool({
description: DESCRIPTION, description: `Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
args: { args: {
assignee: tool.schema assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]]) .enum(ASSIGNEES as [string, ...string[]])
-6
View File
@@ -1,6 +0,0 @@
Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
+1 -1
View File
@@ -128,7 +128,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
#### How is this different from Claude Code? #### How is this different from Claude Code?
It's very similar to Claude Code in terms of capability. Here are the key differences: It's very similar to Claude Code in terms of capability. Here are the key differences::
- 100% open source - 100% open source
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important. - Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
+956 -626
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -11,6 +11,7 @@
"dev:web": "bun --cwd packages/app dev", "dev:web": "bun --cwd packages/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook", "dev:storybook": "bun --cwd packages/storybook storybook",
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck",
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky", "prepare": "husky",
"random": "echo 'Random script'", "random": "echo 'Random script'",
"hello": "echo 'Hello World!'", "hello": "echo 'Hello World!'",
@@ -41,9 +42,9 @@
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "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", "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", "ai": "5.0.124",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
@@ -98,6 +99,7 @@
}, },
"trustedDependencies": [ "trustedDependencies": [
"esbuild", "esbuild",
"node-pty",
"protobufjs", "protobufjs",
"tree-sitter", "tree-sitter",
"tree-sitter-bash", "tree-sitter-bash",
+25 -9
View File
@@ -7,8 +7,9 @@
"private": true, "private": true,
"scripts": { "scripts": {
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000", "test": "bun test --timeout 30000 registry",
"build": "bun run script/build.ts", "build": "bun run script/build.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"dev": "bun run --conditions=browser ./src/index.ts", "dev": "bun run --conditions=browser ./src/index.ts",
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'", "random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
"clean": "echo 'Cleaning up...' && rm -rf node_modules dist", "clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
@@ -25,9 +26,20 @@
"exports": { "exports": {
"./*": "./src/*.ts" "./*": "./src/*.ts"
}, },
"imports": {
"#db": {
"bun": "./src/storage/db.bun.ts",
"node": "./src/storage/db.node.ts",
"default": "./src/storage/db.bun.ts"
},
"#pty": {
"bun": "./src/pty/pty.bun.ts",
"node": "./src/pty/pty.node.ts",
"default": "./src/pty/pty.bun.ts"
}
},
"devDependencies": { "devDependencies": {
"@babel/core": "7.28.4", "@babel/core": "7.28.4",
"@effect/language-service": "0.79.0",
"@octokit/webhooks-types": "7.6.1", "@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1",
@@ -42,13 +54,14 @@
"@types/babel__core": "7.20.5", "@types/babel__core": "7.20.5",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/mime-types": "3.0.1", "@types/mime-types": "3.0.1",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
"@types/turndown": "5.0.5", "@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@types/which": "3.0.4", "@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.16-ea816b6", "effect": "catalog:",
"drizzle-orm": "1.0.0-beta.16-ea816b6", "drizzle-kit": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
"vscode-languageserver-types": "3.17.5", "vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2", "why-is-node-running": "3.2.2",
@@ -81,9 +94,12 @@
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.6.0", "@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3", "@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/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2", "@modelcontextprotocol/sdk": "1.25.2",
"@npmcli/arborist": "9.4.0",
"@octokit/graphql": "9.0.2", "@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:", "@openauthjs/openauth": "catalog:",
@@ -92,8 +108,8 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4", "@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.87", "@opentui/core": "0.1.86",
"@opentui/solid": "0.1.87", "@opentui/solid": "0.1.86",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
@@ -108,8 +124,7 @@
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"decimal.js": "10.5.0", "decimal.js": "10.5.0",
"diff": "catalog:", "diff": "catalog:",
"drizzle-orm": "1.0.0-beta.16-ea816b6", "drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
@@ -120,6 +135,7 @@
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"mime-types": "3.0.2", "mime-types": "3.0.2",
"minimatch": "10.0.3", "minimatch": "10.0.3",
"node-pty": "1.1.0",
"open": "10.1.2", "open": "10.1.2",
"opentui-spinner": "0.0.6", "opentui-spinner": "0.0.6",
"partial-json": "0.1.7", "partial-json": "0.1.7",
+54
View 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",
format: "esm",
external: ["jsonc-parser", "node-pty"],
define: {
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
},
})
console.log("Build complete")
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bun
import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
if (process.platform !== "win32") {
const root = path.join(dir, "node_modules", "node-pty", "prebuilds")
const dirs = await fs.readdir(root, { withFileTypes: true }).catch(() => [])
const files = dirs.filter((x) => x.isDirectory()).map((x) => path.join(root, x.name, "spawn-helper"))
const result = await Promise.all(
files.map(async (file) => {
const stat = await fs.stat(file).catch(() => undefined)
if (!stat) return
if ((stat.mode & 0o111) === 0o111) return
await fs.chmod(file, stat.mode | 0o755)
return file
}),
)
const fixed = result.filter(Boolean)
if (fixed.length) {
console.log(`fixed node-pty permissions for ${fixed.length} helper${fixed.length === 1 ? "" : "s"}`)
}
}
-91
View File
@@ -1,13 +1,5 @@
import z from "zod"
import { Global } from "../global"
import { Log } from "../util/log" import { Log } from "../util/log"
import path from "path"
import { Filesystem } from "../util/filesystem"
import { NamedError } from "@opencode-ai/util/error"
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
import { Lock } from "../util/lock"
import { PackageRegistry } from "./registry"
import { proxied } from "@/util/proxied"
import { Process } from "../util/process" import { Process } from "../util/process"
export namespace BunProc { export namespace BunProc {
@@ -45,87 +37,4 @@ export namespace BunProc {
export function which() { export function which() {
return process.execPath return process.execPath
} }
export const InstallFailedError = NamedError.create(
"BunInstallFailedError",
z.object({
pkg: z.string(),
version: z.string(),
}),
)
export async function install(pkg: string, version = "latest") {
// Use lock to ensure only one install at a time
using _ = await Lock.write("bun-install")
const mod = path.join(Global.Path.cache, "node_modules", pkg)
const pkgjsonPath = path.join(Global.Path.cache, "package.json")
const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
const result = { dependencies: {} as Record<string, string> }
await Filesystem.writeJson(pkgjsonPath, result)
return result
})
if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
const dependencies = parsed.dependencies
const modExists = await Filesystem.exists(mod)
const cachedVersion = dependencies[pkg]
if (!modExists || !cachedVersion) {
// continue to install
} else if (version !== "latest" && cachedVersion === version) {
return mod
} else if (version === "latest") {
const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
if (!isOutdated) return mod
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
}
// Build command arguments
const args = [
"add",
"--force",
"--exact",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []),
"--cwd",
Global.Path.cache,
pkg + "@" + version,
]
// Let Bun handle registry resolution:
// - If .npmrc files exist, Bun will use them automatically
// - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
// - No need to pass --registry flag
log.info("installing package using Bun's default registry resolution", {
pkg,
version,
})
await BunProc.run(args, {
cwd: Global.Path.cache,
}).catch((e) => {
throw new InstallFailedError(
{ pkg, version },
{
cause: e,
},
)
})
// Resolve actual version from installed package when using "latest"
// This ensures subsequent starts use the cached version until explicitly updated
let resolvedVersion = version
if (version === "latest") {
const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
() => null,
)
if (installedPkg?.version) {
resolvedVersion = installedPkg.version
}
}
parsed.dependencies[pkg] = resolvedVersion
await Filesystem.writeJson(pkgjsonPath, parsed)
return mod
}
} }
-14
View File
@@ -1,4 +1,3 @@
import semver from "semver"
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Process } from "../util/process" import { Process } from "../util/process"
@@ -34,17 +33,4 @@ export namespace PackageRegistry {
if (!value) return null if (!value) return null
return value return value
} }
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
const latestVersion = await info(pkg, "version", cwd)
if (!latestVersion) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
}
} }
+1 -1
View File
@@ -23,7 +23,7 @@ export const AcpCommand = cmd({
process.env.OPENCODE_CLIENT = "acp" process.env.OPENCODE_CLIENT = "acp"
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const opts = await resolveNetworkOptions(args) const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts) const server = await Server.listen(opts)
const sdk = createOpencodeClient({ const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`, baseUrl: `http://${server.hostname}:${server.port}`,
+1 -1
View File
@@ -15,7 +15,7 @@ export const ServeCommand = cmd({
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
} }
const opts = await resolveNetworkOptions(args) 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}`) console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {}) await new Promise(() => {})
@@ -9,6 +9,7 @@ import { useToast } from "../ui/toast"
import { useKeybind } from "../context/keybind" import { useKeybind } from "../context/keybind"
import { DialogSessionList } from "./workspace/dialog-session-list" import { DialogSessionList } from "./workspace/dialog-session-list"
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { setTimeout as sleep } from "node:timers/promises"
async function openWorkspace(input: { async function openWorkspace(input: {
dialog: ReturnType<typeof useDialog> dialog: ReturnType<typeof useDialog>
@@ -56,7 +57,7 @@ async function openWorkspace(input: {
return return
} }
if (result.response.status >= 500 && result.response.status < 600) { if (result.response.status >= 500 && result.response.status < 600) {
await Bun.sleep(1000) await sleep(1000)
continue continue
} }
if (!result.data) { if (!result.data) {
@@ -907,12 +907,12 @@ export function Session() {
const filename = options.filename.trim() const filename = options.filename.trim()
const filepath = path.join(exportDir, filename) const filepath = path.join(exportDir, filename)
await Bun.write(filepath, transcript) await Filesystem.write(filepath, transcript)
// Open with EDITOR if available // Open with EDITOR if available
const result = await Editor.open({ value: transcript, renderer }) const result = await Editor.open({ value: transcript, renderer })
if (result !== undefined) { if (result !== undefined) {
await Bun.write(filepath, result) await Filesystem.write(filepath, result)
} }
toast.show({ message: `Session exported to ${filename}`, variant: "success" }) toast.show({ message: `Session exported to ${filename}`, variant: "success" })
+3 -4
View File
@@ -8,7 +8,6 @@ import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2" import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import type { BunWebSocketData } from "hono/bun"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
@@ -38,7 +37,7 @@ GlobalBus.on("event", (event) => {
Rpc.emit("global.event", event) Rpc.emit("global.event", event)
}) })
let server: Bun.Server<BunWebSocketData> | undefined let server: Awaited<ReturnType<typeof Server.listen>> | undefined
const eventStream = { const eventStream = {
abort: undefined as AbortController | undefined, abort: undefined as AbortController | undefined,
@@ -120,7 +119,7 @@ export const rpc = {
}, },
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) { async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
if (server) await server.stop(true) if (server) await server.stop(true)
server = Server.listen(input) server = await Server.listen(input)
return { url: server.url.toString() } return { url: server.url.toString() }
}, },
async checkUpgrade(input: { directory: string }) { async checkUpgrade(input: { directory: string }) {
@@ -143,7 +142,7 @@ export const rpc = {
Log.Default.info("worker shutting down") Log.Default.info("worker shutting down")
if (eventStream.abort) eventStream.abort.abort() if (eventStream.abort) eventStream.abort.abort()
await Instance.disposeAll() await Instance.disposeAll()
if (server) server.stop(true) if (server) await server.stop(true)
}, },
} }
+1 -1
View File
@@ -37,7 +37,7 @@ export const WebCommand = cmd({
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
} }
const opts = await resolveNetworkOptions(args) const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts) const server = await Server.listen(opts)
UI.empty() UI.empty()
UI.println(UI.logo(" ")) UI.println(UI.logo(" "))
UI.empty() UI.empty()
+13 -54
View File
@@ -1,6 +1,6 @@
import { Log } from "../util/log" import { Log } from "../util/log"
import path from "path" import path from "path"
import { pathToFileURL, fileURLToPath } from "url" import { pathToFileURL } from "url"
import { createRequire } from "module" import { createRequire } from "module"
import os from "os" import os from "os"
import z from "zod" import z from "zod"
@@ -22,7 +22,6 @@ import {
} from "jsonc-parser" } from "jsonc-parser"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { LSPServer } from "../lsp/server" import { LSPServer } from "../lsp/server"
import { BunProc } from "@/bun"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown" import { ConfigMarkdown } from "./markdown"
import { constants, existsSync } from "fs" import { constants, existsSync } from "fs"
@@ -30,12 +29,11 @@ import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event" import { Event } from "../server/event"
import { Glob } from "../util/glob" import { Glob } from "../util/glob"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Account } from "@/account" import { Account } from "@/account"
import { ConfigPaths } from "./paths" import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Npm } from "@/npm"
export namespace Config { export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -152,8 +150,7 @@ export namespace Config {
deps.push( deps.push(
iife(async () => { iife(async () => {
const shouldInstall = await needsInstall(dir) await installDependencies(dir)
if (shouldInstall) await installDependencies(dir)
}), }),
) )
@@ -269,6 +266,10 @@ export namespace Config {
} }
export async function installDependencies(dir: string) { export async function installDependencies(dir: string) {
if (!(await isWritable(dir))) {
log.info("config dir is not writable, skipping dependency install", { dir })
return
}
const pkg = path.join(dir, "package.json") const pkg = path.join(dir, "package.json")
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
@@ -282,22 +283,15 @@ export namespace Config {
await Filesystem.writeJson(pkg, json) await Filesystem.writeJson(pkg, json)
const gitignore = path.join(dir, ".gitignore") const gitignore = path.join(dir, ".gitignore")
const hasGitIgnore = await Filesystem.exists(gitignore) if (!(await Filesystem.exists(gitignore)))
if (!hasGitIgnore) await Filesystem.write(
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n")) gitignore,
["node_modules", "plans", "package.json", "bun.lock", ".gitignore", "package-lock.json"].join("\n"),
)
// Install any additional dependencies defined in the package.json // Install any additional dependencies defined in the package.json
// This allows local plugins and custom tools to use external packages // This allows local plugins and custom tools to use external packages
await BunProc.run( await Npm.install(dir)
[
"install",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []),
],
{ cwd: dir },
).catch((err) => {
log.warn("failed to install dependencies", { dir, error: err })
})
} }
async function isWritable(dir: string) { async function isWritable(dir: string) {
@@ -309,41 +303,6 @@ export namespace Config {
} }
} }
export async function needsInstall(dir: string) {
// Some config dirs may be read-only.
// Installing deps there will fail; skip installation in that case.
const writable = await isWritable(dir)
if (!writable) {
log.debug("config dir is not writable, skipping dependency install", { dir })
return false
}
const nodeModules = path.join(dir, "node_modules")
if (!existsSync(nodeModules)) return true
const pkg = path.join(dir, "package.json")
const pkgExists = await Filesystem.exists(pkg)
if (!pkgExists) return true
const parsed = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => null)
const dependencies = parsed?.dependencies ?? {}
const depVersion = dependencies["@opencode-ai/plugin"]
if (!depVersion) return true
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
if (targetVersion === "latest") {
const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
if (!isOutdated) return false
log.info("Cached version is outdated, proceeding with install", {
pkg: "@opencode-ai/plugin",
cachedVersion: depVersion,
})
return true
}
if (depVersion === targetVersion) return false
return true
}
function rel(item: string, patterns: string[]) { function rel(item: string, patterns: string[]) {
const normalizedItem = item.replaceAll("\\", "/") const normalizedItem = item.replaceAll("\\", "/")
for (const pattern of patterns) { for (const pattern of patterns) {
@@ -1,3 +1,4 @@
import { createAdaptorServer } from "@hono/node-server"
import { Hono } from "hono" import { Hono } from "hono"
import { Instance } from "../../project/instance" import { Instance } from "../../project/instance"
import { InstanceBootstrap } from "../../project/bootstrap" import { InstanceBootstrap } from "../../project/bootstrap"
@@ -55,10 +56,24 @@ export namespace WorkspaceServer {
} }
export function Listen(opts: { hostname: string; port: number }) { export function Listen(opts: { hostname: string; port: number }) {
return Bun.serve({ const server = createAdaptorServer({
hostname: opts.hostname,
port: opts.port,
fetch: App().fetch, 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,4 +1,5 @@
import z from "zod" import z from "zod"
import { setTimeout as sleep } from "node:timers/promises"
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import { Database, eq } from "@/storage/db" import { Database, eq } from "@/storage/db"
@@ -116,7 +117,7 @@ export namespace Workspace {
const adaptor = await getAdaptor(space.type) const adaptor = await getAdaptor(space.type)
const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined) const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined)
if (!res || !res.ok || !res.body) { if (!res || !res.ok || !res.body) {
await Bun.sleep(1000) await sleep(1000)
continue continue
} }
await parseSSE(res.body, stop, (event) => { await parseSSE(res.body, stop, (event) => {
@@ -126,7 +127,7 @@ export namespace Workspace {
}) })
}) })
// Wait 250ms and retry if SSE connection fails // Wait 250ms and retry if SSE connection fails
await Bun.sleep(250) await sleep(250)
} }
} }
+83 -65
View File
@@ -1,40 +1,40 @@
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
import { BunProc } from "../bun"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process" import { Process } from "../util/process"
import { which } from "../util/which" import { which } from "../util/which"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Npm } from "@/npm"
export interface Info { export interface Info {
name: string name: string
command: string[]
environment?: Record<string, string> environment?: Record<string, string>
extensions: string[] extensions: string[]
enabled(): Promise<boolean> enabled(): Promise<string[] | false>
} }
export const gofmt: Info = { export const gofmt: Info = {
name: "gofmt", name: "gofmt",
command: ["gofmt", "-w", "$FILE"],
extensions: [".go"], extensions: [".go"],
async enabled() { async enabled() {
return which("gofmt") !== null const p = which("gofmt")
if (p === null) return false
return [p, "-w", "$FILE"]
}, },
} }
export const mix: Info = { export const mix: Info = {
name: "mix", name: "mix",
command: ["mix", "format", "$FILE"],
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"], extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
async enabled() { async enabled() {
return which("mix") !== null const p = which("mix")
if (p === null) return false
return [p, "format", "$FILE"]
}, },
} }
export const prettier: Info = { export const prettier: Info = {
name: "prettier", name: "prettier",
command: [BunProc.which(), "x", "prettier", "--write", "$FILE"],
environment: { environment: {
BUN_BE_BUN: "1", BUN_BE_BUN: "1",
}, },
@@ -73,8 +73,9 @@ export const prettier: Info = {
dependencies?: Record<string, string> dependencies?: Record<string, string>
devDependencies?: Record<string, string> devDependencies?: Record<string, string>
}>(item) }>(item)
if (json.dependencies?.prettier) return true if (json.dependencies?.prettier || json.devDependencies?.prettier) {
if (json.devDependencies?.prettier) return true return [await Npm.which("prettier"), "--write", "$FILE"]
}
} }
return false return false
}, },
@@ -82,7 +83,6 @@ export const prettier: Info = {
export const oxfmt: Info = { export const oxfmt: Info = {
name: "oxfmt", name: "oxfmt",
command: [BunProc.which(), "x", "oxfmt", "$FILE"],
environment: { environment: {
BUN_BE_BUN: "1", BUN_BE_BUN: "1",
}, },
@@ -95,8 +95,9 @@ export const oxfmt: Info = {
dependencies?: Record<string, string> dependencies?: Record<string, string>
devDependencies?: Record<string, string> devDependencies?: Record<string, string>
}>(item) }>(item)
if (json.dependencies?.oxfmt) return true if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) {
if (json.devDependencies?.oxfmt) return true return [await Npm.which("oxfmt"), "$FILE"]
}
} }
return false return false
}, },
@@ -104,7 +105,6 @@ export const oxfmt: Info = {
export const biome: Info = { export const biome: Info = {
name: "biome", name: "biome",
command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"],
environment: { environment: {
BUN_BE_BUN: "1", BUN_BE_BUN: "1",
}, },
@@ -141,7 +141,7 @@ export const biome: Info = {
for (const config of configs) { for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree) const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) { if (found.length > 0) {
return true return [await Npm.which("@biomejs/biome"), "check", "--write", "$FILE"]
} }
} }
return false return false
@@ -150,47 +150,49 @@ export const biome: Info = {
export const zig: Info = { export const zig: Info = {
name: "zig", name: "zig",
command: ["zig", "fmt", "$FILE"],
extensions: [".zig", ".zon"], extensions: [".zig", ".zon"],
async enabled() { async enabled() {
return which("zig") !== null const p = which("zig")
if (p === null) return false
return [p, "fmt", "$FILE"]
}, },
} }
export const clang: Info = { export const clang: Info = {
name: "clang-format", name: "clang-format",
command: ["clang-format", "-i", "$FILE"],
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"], extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
async enabled() { async enabled() {
const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree) const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
return items.length > 0 if (items.length === 0) return false
return ["clang-format", "-i", "$FILE"]
}, },
} }
export const ktlint: Info = { export const ktlint: Info = {
name: "ktlint", name: "ktlint",
command: ["ktlint", "-F", "$FILE"],
extensions: [".kt", ".kts"], extensions: [".kt", ".kts"],
async enabled() { async enabled() {
return which("ktlint") !== null const p = which("ktlint")
if (p === null) return false
return [p, "-F", "$FILE"]
}, },
} }
export const ruff: Info = { export const ruff: Info = {
name: "ruff", name: "ruff",
command: ["ruff", "format", "$FILE"],
extensions: [".py", ".pyi"], extensions: [".py", ".pyi"],
async enabled() { async enabled() {
if (!which("ruff")) return false const p = which("ruff")
if (p === null) return false
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"] const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
for (const config of configs) { for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree) const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) { if (found.length > 0) {
if (config === "pyproject.toml") { if (config === "pyproject.toml") {
const content = await Filesystem.readText(found[0]) const content = await Filesystem.readText(found[0])
if (content.includes("[tool.ruff]")) return true if (content.includes("[tool.ruff]")) return [p, "format", "$FILE"]
} else { } else {
return true return [p, "format", "$FILE"]
} }
} }
} }
@@ -199,7 +201,7 @@ export const ruff: Info = {
const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree) const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
if (found.length > 0) { if (found.length > 0) {
const content = await Filesystem.readText(found[0]) const content = await Filesystem.readText(found[0])
if (content.includes("ruff")) return true if (content.includes("ruff")) return [p, "format", "$FILE"]
} }
} }
return false return false
@@ -208,14 +210,13 @@ export const ruff: Info = {
export const rlang: Info = { export const rlang: Info = {
name: "air", name: "air",
command: ["air", "format", "$FILE"],
extensions: [".R"], extensions: [".R"],
async enabled() { async enabled() {
const airPath = which("air") const airPath = which("air")
if (airPath == null) return false if (airPath == null) return false
try { try {
const proc = Process.spawn(["air", "--help"], { const proc = Process.spawn([airPath, "--help"], {
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
}) })
@@ -227,7 +228,10 @@ export const rlang: Info = {
const firstLine = output.split("\n")[0] const firstLine = output.split("\n")[0]
const hasR = firstLine.includes("R language") const hasR = firstLine.includes("R language")
const hasFormatter = firstLine.includes("formatter") const hasFormatter = firstLine.includes("formatter")
return hasR && hasFormatter if (hasR && hasFormatter) {
return [airPath, "format", "$FILE"]
}
return false
} catch (error) { } catch (error) {
return false return false
} }
@@ -236,14 +240,14 @@ export const rlang: Info = {
export const uvformat: Info = { export const uvformat: Info = {
name: "uv", name: "uv",
command: ["uv", "format", "--", "$FILE"],
extensions: [".py", ".pyi"], extensions: [".py", ".pyi"],
async enabled() { async enabled() {
if (await ruff.enabled()) return false if (await ruff.enabled()) return false
if (which("uv") !== null) { const uvPath = which("uv")
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" }) if (uvPath !== null) {
const proc = Process.spawn([uvPath, "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const code = await proc.exited const code = await proc.exited
return code === 0 if (code === 0) return [uvPath, "format", "--", "$FILE"]
} }
return false return false
}, },
@@ -251,108 +255,118 @@ export const uvformat: Info = {
export const rubocop: Info = { export const rubocop: Info = {
name: "rubocop", name: "rubocop",
command: ["rubocop", "--autocorrect", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"], extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() { async enabled() {
return which("rubocop") !== null const path = which("rubocop")
if (path === null) return false
return [path, "--autocorrect", "$FILE"]
}, },
} }
export const standardrb: Info = { export const standardrb: Info = {
name: "standardrb", name: "standardrb",
command: ["standardrb", "--fix", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"], extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() { async enabled() {
return which("standardrb") !== null const path = which("standardrb")
if (path === null) return false
return [path, "--fix", "$FILE"]
}, },
} }
export const htmlbeautifier: Info = { export const htmlbeautifier: Info = {
name: "htmlbeautifier", name: "htmlbeautifier",
command: ["htmlbeautifier", "$FILE"],
extensions: [".erb", ".html.erb"], extensions: [".erb", ".html.erb"],
async enabled() { async enabled() {
return which("htmlbeautifier") !== null const path = which("htmlbeautifier")
if (path === null) return false
return [path, "$FILE"]
}, },
} }
export const dart: Info = { export const dart: Info = {
name: "dart", name: "dart",
command: ["dart", "format", "$FILE"],
extensions: [".dart"], extensions: [".dart"],
async enabled() { async enabled() {
return which("dart") !== null const path = which("dart")
if (path === null) return false
return [path, "format", "$FILE"]
}, },
} }
export const ocamlformat: Info = { export const ocamlformat: Info = {
name: "ocamlformat", name: "ocamlformat",
command: ["ocamlformat", "-i", "$FILE"],
extensions: [".ml", ".mli"], extensions: [".ml", ".mli"],
async enabled() { async enabled() {
if (!which("ocamlformat")) return false const path = which("ocamlformat")
if (!path) return false
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree) const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
return items.length > 0 if (items.length === 0) return false
return [path, "-i", "$FILE"]
}, },
} }
export const terraform: Info = { export const terraform: Info = {
name: "terraform", name: "terraform",
command: ["terraform", "fmt", "$FILE"],
extensions: [".tf", ".tfvars"], extensions: [".tf", ".tfvars"],
async enabled() { async enabled() {
return which("terraform") !== null const path = which("terraform")
if (path === null) return false
return [path, "fmt", "$FILE"]
}, },
} }
export const latexindent: Info = { export const latexindent: Info = {
name: "latexindent", name: "latexindent",
command: ["latexindent", "-w", "-s", "$FILE"],
extensions: [".tex"], extensions: [".tex"],
async enabled() { async enabled() {
return which("latexindent") !== null const path = which("latexindent")
if (path === null) return false
return [path, "-w", "-s", "$FILE"]
}, },
} }
export const gleam: Info = { export const gleam: Info = {
name: "gleam", name: "gleam",
command: ["gleam", "format", "$FILE"],
extensions: [".gleam"], extensions: [".gleam"],
async enabled() { async enabled() {
return which("gleam") !== null const path = which("gleam")
if (path === null) return false
return [path, "format", "$FILE"]
}, },
} }
export const shfmt: Info = { export const shfmt: Info = {
name: "shfmt", name: "shfmt",
command: ["shfmt", "-w", "$FILE"],
extensions: [".sh", ".bash"], extensions: [".sh", ".bash"],
async enabled() { async enabled() {
return which("shfmt") !== null const path = which("shfmt")
if (path === null) return false
return [path, "-w", "$FILE"]
}, },
} }
export const nixfmt: Info = { export const nixfmt: Info = {
name: "nixfmt", name: "nixfmt",
command: ["nixfmt", "$FILE"],
extensions: [".nix"], extensions: [".nix"],
async enabled() { async enabled() {
return which("nixfmt") !== null const path = which("nixfmt")
if (path === null) return false
return [path, "$FILE"]
}, },
} }
export const rustfmt: Info = { export const rustfmt: Info = {
name: "rustfmt", name: "rustfmt",
command: ["rustfmt", "$FILE"],
extensions: [".rs"], extensions: [".rs"],
async enabled() { async enabled() {
return which("rustfmt") !== null const path = which("rustfmt")
if (path === null) return false
return [path, "$FILE"]
}, },
} }
export const pint: Info = { export const pint: Info = {
name: "pint", name: "pint",
command: ["./vendor/bin/pint", "$FILE"],
extensions: [".php"], extensions: [".php"],
async enabled() { async enabled() {
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree) const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
@@ -361,8 +375,9 @@ export const pint: Info = {
require?: Record<string, string> require?: Record<string, string>
"require-dev"?: Record<string, string> "require-dev"?: Record<string, string>
}>(item) }>(item)
if (json.require?.["laravel/pint"]) return true if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) {
if (json["require-dev"]?.["laravel/pint"]) return true return ["./vendor/bin/pint", "$FILE"]
}
} }
return false return false
}, },
@@ -370,27 +385,30 @@ export const pint: Info = {
export const ormolu: Info = { export const ormolu: Info = {
name: "ormolu", name: "ormolu",
command: ["ormolu", "-i", "$FILE"],
extensions: [".hs"], extensions: [".hs"],
async enabled() { async enabled() {
return which("ormolu") !== null const path = which("ormolu")
if (path === null) return false
return [path, "-i", "$FILE"]
}, },
} }
export const cljfmt: Info = { export const cljfmt: Info = {
name: "cljfmt", name: "cljfmt",
command: ["cljfmt", "fix", "--quiet", "$FILE"],
extensions: [".clj", ".cljs", ".cljc", ".edn"], extensions: [".clj", ".cljs", ".cljc", ".edn"],
async enabled() { async enabled() {
return which("cljfmt") !== null const path = which("cljfmt")
if (path === null) return false
return [path, "fix", "--quiet", "$FILE"]
}, },
} }
export const dfmt: Info = { export const dfmt: Info = {
name: "dfmt", name: "dfmt",
command: ["dfmt", "-i", "$FILE"],
extensions: [".d"], extensions: [".d"],
async enabled() { async enabled() {
return which("dfmt") !== null const path = which("dfmt")
if (path === null) return false
return [path, "-i", "$FILE"]
}, },
} }
+30 -34
View File
@@ -25,14 +25,14 @@ export namespace Format {
export type Status = z.infer<typeof Status> export type Status = z.infer<typeof Status>
const state = Instance.state(async () => { const state = Instance.state(async () => {
const enabled: Record<string, boolean> = {} const cache: Record<string, string[] | false> = {}
const cfg = await Config.get() const cfg = await Config.get()
const formatters: Record<string, Formatter.Info> = {} const formatters: Record<string, Formatter.Info> = {}
if (cfg.formatter === false) { if (cfg.formatter === false) {
log.info("all formatters are disabled") log.info("all formatters are disabled")
return { return {
enabled, cache,
formatters, formatters,
} }
} }
@@ -46,43 +46,41 @@ export namespace Format {
continue continue
} }
const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, { const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [], extensions: [],
...item, ...item,
}) })
if (result.command.length === 0) continue result.enabled = async () => item.command ?? false
result.enabled = async () => true
result.name = name result.name = name
formatters[name] = result formatters[name] = result
} }
return { return {
enabled, cache,
formatters, formatters,
} }
}) })
async function isEnabled(item: Formatter.Info) { async function resolveCommand(item: Formatter.Info) {
const s = await state() const s = await state()
let status = s.enabled[item.name] let command = s.cache[item.name]
if (status === undefined) { if (command === undefined) {
status = await item.enabled() log.info("resolving command", { name: item.name })
s.enabled[item.name] = status command = await item.enabled()
s.cache[item.name] = command
} }
return status return command
} }
async function getFormatter(ext: string) { async function getFormatter(ext: string) {
const formatters = await state().then((x) => x.formatters) const formatters = await state().then((x) => x.formatters)
const result = [] const result: { info: Formatter.Info; command: string[] }[] = []
for (const item of Object.values(formatters)) { for (const item of Object.values(formatters)) {
log.info("checking", { name: item.name, ext })
if (!item.extensions.includes(ext)) continue if (!item.extensions.includes(ext)) continue
if (!(await isEnabled(item))) continue const command = await resolveCommand(item)
if (!command) continue
log.info("enabled", { name: item.name, ext }) log.info("enabled", { name: item.name, ext })
result.push(item) result.push({ info: item, command })
} }
return result return result
} }
@@ -91,11 +89,11 @@ export namespace Format {
const s = await state() const s = await state()
const result: Status[] = [] const result: Status[] = []
for (const formatter of Object.values(s.formatters)) { for (const formatter of Object.values(s.formatters)) {
const enabled = await isEnabled(formatter) const command = await resolveCommand(formatter)
result.push({ result.push({
name: formatter.name, name: formatter.name,
extensions: formatter.extensions, extensions: formatter.extensions,
enabled, enabled: !!command,
}) })
} }
return result return result
@@ -108,29 +106,27 @@ export namespace Format {
log.info("formatting", { file }) log.info("formatting", { file })
const ext = path.extname(file) const ext = path.extname(file)
for (const item of await getFormatter(ext)) { for (const { info, command } of await getFormatter(ext)) {
log.info("running", { command: item.command }) const replaced = command.map((x) => x.replace("$FILE", file))
log.info("running", { replaced })
try { try {
const proc = Process.spawn( const proc = Process.spawn(replaced, {
item.command.map((x) => x.replace("$FILE", file)), cwd: Instance.directory,
{ env: { ...process.env, ...info.environment },
cwd: Instance.directory, stdout: "ignore",
env: { ...process.env, ...item.environment }, stderr: "ignore",
stdout: "ignore", })
stderr: "ignore",
},
)
const exit = await proc.exited const exit = await proc.exited
if (exit !== 0) if (exit !== 0)
log.error("failed", { log.error("failed", {
command: item.command, command,
...item.environment, ...info.environment,
}) })
} catch (error) { } catch (error) {
log.error("failed to format file", { log.error("failed to format file", {
error, error,
command: item.command, command,
...item.environment, ...info.environment,
file, file,
}) })
} }
+1 -1
View File
@@ -18,7 +18,7 @@ export namespace Global {
return process.env.OPENCODE_TEST_HOME || os.homedir() return process.env.OPENCODE_TEST_HOME || os.homedir()
}, },
data, data,
bin: path.join(data, "bin"), bin: path.join(cache, "bin"),
log: path.join(data, "log"), log: path.join(data, "log"),
cache, cache,
config, config,
+30 -173
View File
@@ -3,7 +3,6 @@ import path from "path"
import os from "os" import os from "os"
import { Global } from "../global" import { Global } from "../global"
import { Log } from "../util/log" import { Log } from "../util/log"
import { BunProc } from "../bun"
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
import fs from "fs/promises" import fs from "fs/promises"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
@@ -13,6 +12,7 @@ import { Archive } from "../util/archive"
import { Process } from "../util/process" import { Process } from "../util/process"
import { which } from "../util/which" import { which } from "../util/which"
import { Module } from "@opencode-ai/util/module" import { Module } from "@opencode-ai/util/module"
import { Npm } from "@/npm"
export namespace LSPServer { export namespace LSPServer {
const log = Log.create({ service: "lsp.server" }) const log = Log.create({ service: "lsp.server" })
@@ -102,7 +102,7 @@ export namespace LSPServer {
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory) const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
log.info("typescript server", { tsserver }) log.info("typescript server", { tsserver })
if (!tsserver) return if (!tsserver) return
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], { const proc = spawn(await Npm.which("typescript-language-server"), ["--stdio"], {
cwd: root, cwd: root,
env: { env: {
...process.env, ...process.env,
@@ -128,29 +128,8 @@ export namespace LSPServer {
let binary = which("vue-language-server") let binary = which("vue-language-server")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join( if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
Global.Path.bin, binary = await Npm.which("@vue/language-server")
"node_modules",
"@vue",
"language-server",
"bin",
"vue-language-server.js",
)
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "@vue/language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("--stdio") args.push("--stdio")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
@@ -213,7 +192,7 @@ export namespace LSPServer {
log.info("installed VS Code ESLint server", { serverPath }) log.info("installed VS Code ESLint server", { serverPath })
} }
const proc = spawn(BunProc.which(), [serverPath, "--stdio"], { const proc = spawn(await Npm.which("tsx"), [serverPath, "--stdio"], {
cwd: root, cwd: root,
env: { env: {
...process.env, ...process.env,
@@ -344,8 +323,8 @@ export namespace LSPServer {
if (!bin) { if (!bin) {
const resolved = Module.resolve("biome", root) const resolved = Module.resolve("biome", root)
if (!resolved) return if (!resolved) return
bin = BunProc.which() bin = await Npm.which("biome")
args = ["x", "biome", "lsp-proxy", "--stdio"] args = ["lsp-proxy", "--stdio"]
} }
const proc = spawn(bin, args, { const proc = spawn(bin, args, {
@@ -371,9 +350,7 @@ export namespace LSPServer {
}, },
extensions: [".go"], extensions: [".go"],
async spawn(root) { async spawn(root) {
let bin = which("gopls", { let bin = which("gopls")
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (!which("go")) return if (!which("go")) return
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -408,9 +385,7 @@ export namespace LSPServer {
root: NearestRoot(["Gemfile"]), root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"], extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(root) { async spawn(root) {
let bin = which("rubocop", { let bin = which("rubocop")
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
const ruby = which("ruby") const ruby = which("ruby")
const gem = which("gem") const gem = which("gem")
@@ -515,19 +490,8 @@ export namespace LSPServer {
let binary = which("pyright-langserver") let binary = which("pyright-langserver")
const args = [] const args = []
if (!binary) { if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js") if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (!(await Filesystem.exists(js))) { binary = await Npm.which("pyright")
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "pyright"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
}).exited
}
binary = BunProc.which()
args.push(...["run", js])
} }
args.push("--stdio") args.push("--stdio")
@@ -629,9 +593,7 @@ export namespace LSPServer {
extensions: [".zig", ".zon"], extensions: [".zig", ".zon"],
root: NearestRoot(["build.zig"]), root: NearestRoot(["build.zig"]),
async spawn(root) { async spawn(root) {
let bin = which("zls", { let bin = which("zls")
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
const zig = which("zig") const zig = which("zig")
@@ -741,9 +703,7 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]), root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs"], extensions: [".cs"],
async spawn(root) { async spawn(root) {
let bin = which("csharp-ls", { let bin = which("csharp-ls")
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (!which("dotnet")) { if (!which("dotnet")) {
log.error(".NET SDK is required to install csharp-ls") log.error(".NET SDK is required to install csharp-ls")
@@ -780,9 +740,7 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]), root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
extensions: [".fs", ".fsi", ".fsx", ".fsscript"], extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
async spawn(root) { async spawn(root) {
let bin = which("fsautocomplete", { let bin = which("fsautocomplete")
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (!which("dotnet")) { if (!which("dotnet")) {
log.error(".NET SDK is required to install fsautocomplete") log.error(".NET SDK is required to install fsautocomplete")
@@ -1048,22 +1006,8 @@ export namespace LSPServer {
let binary = which("svelteserver") let binary = which("svelteserver")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js") if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (!(await Filesystem.exists(js))) { binary = await Npm.which("svelte-language-server")
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "svelte-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("--stdio") args.push("--stdio")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
@@ -1095,22 +1039,8 @@ export namespace LSPServer {
let binary = which("astro-ls") let binary = which("astro-ls")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js") if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (!(await Filesystem.exists(js))) { binary = await Npm.which("@astrojs/language-server")
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("--stdio") args.push("--stdio")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
@@ -1359,31 +1289,8 @@ export namespace LSPServer {
let binary = which("yaml-language-server") let binary = which("yaml-language-server")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join( if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
Global.Path.bin, binary = await Npm.which("yaml-language-server")
"node_modules",
"yaml-language-server",
"out",
"server",
"src",
"server.js",
)
const exists = await Filesystem.exists(js)
if (!exists) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "yaml-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("--stdio") args.push("--stdio")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
@@ -1412,9 +1319,7 @@ export namespace LSPServer {
]), ]),
extensions: [".lua"], extensions: [".lua"],
async spawn(root) { async spawn(root) {
let bin = which("lua-language-server", { let bin = which("lua-language-server")
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1550,22 +1455,8 @@ export namespace LSPServer {
let binary = which("intelephense") let binary = which("intelephense")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js") if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (!(await Filesystem.exists(js))) { binary = await Npm.which("intelephense")
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "intelephense"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("--stdio") args.push("--stdio")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
@@ -1647,22 +1538,8 @@ export namespace LSPServer {
let binary = which("bash-language-server") let binary = which("bash-language-server")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js") if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (!(await Filesystem.exists(js))) { binary = await Npm.which("bash-language-server")
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "bash-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("start") args.push("start")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
@@ -1683,9 +1560,7 @@ export namespace LSPServer {
extensions: [".tf", ".tfvars"], extensions: [".tf", ".tfvars"],
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]), root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
async spawn(root) { async spawn(root) {
let bin = which("terraform-ls", { let bin = which("terraform-ls")
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1766,9 +1641,7 @@ export namespace LSPServer {
extensions: [".tex", ".bib"], extensions: [".tex", ".bib"],
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]), root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
async spawn(root) { async spawn(root) {
let bin = which("texlab", { let bin = which("texlab")
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1859,22 +1732,8 @@ export namespace LSPServer {
let binary = which("docker-langserver") let binary = which("docker-langserver")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js") if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
if (!(await Filesystem.exists(js))) { binary = await Npm.which("dockerfile-language-server-nodejs")
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("--stdio") args.push("--stdio")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
@@ -1965,9 +1824,7 @@ export namespace LSPServer {
extensions: [".typ", ".typc"], extensions: [".typ", ".typc"],
root: NearestRoot(["typst.toml"]), root: NearestRoot(["typst.toml"]),
async spawn(root) { async spawn(root) {
let bin = which("tinymist", { let bin = which("tinymist")
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+4 -7
View File
@@ -11,6 +11,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js" } from "@modelcontextprotocol/sdk/types.js"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Process } from "../util/process"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import z from "zod/v4" import z from "zod/v4"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
@@ -166,14 +167,10 @@ export namespace MCP {
const queue = [pid] const queue = [pid]
while (queue.length > 0) { while (queue.length > 0) {
const current = queue.shift()! const current = queue.shift()!
const proc = Bun.spawn(["pgrep", "-P", String(current)], { stdout: "pipe", stderr: "pipe" }) const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true })
const [code, out] = await Promise.all([proc.exited, new Response(proc.stdout).text()]).catch( for (const tok of lines) {
() => [-1, ""] as const,
)
if (code !== 0) continue
for (const tok of out.trim().split(/\s+/)) {
const cpid = parseInt(tok, 10) const cpid = parseInt(tok, 10)
if (!isNaN(cpid) && pids.indexOf(cpid) === -1) { if (!isNaN(cpid) && !pids.includes(cpid)) {
pids.push(cpid) pids.push(cpid)
queue.push(cpid) queue.push(cpid)
} }
+73 -70
View File
@@ -1,4 +1,5 @@
import { createConnection } from "net" import { createConnection } from "net"
import { createServer } from "http"
import { Log } from "../util/log" import { Log } from "../util/log"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider" import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
@@ -52,11 +53,74 @@ interface PendingAuth {
} }
export namespace McpOAuthCallback { export namespace McpOAuthCallback {
let server: ReturnType<typeof Bun.serve> | undefined let server: ReturnType<typeof createServer> | undefined
const pendingAuths = new Map<string, PendingAuth>() const pendingAuths = new Map<string, PendingAuth>()
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
if (url.pathname !== OAUTH_CALLBACK_PATH) {
res.writeHead(404)
res.end("Not found")
return
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
log.info("received oauth callback", { hasCode: !!code, state, error })
// Enforce state parameter presence
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
log.error("oauth callback missing state parameter", { url: url.toString() })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (error) {
const errorMsg = errorDescription || error
if (pendingAuths.has(state)) {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.reject(new Error(errorMsg))
}
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (!code) {
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR("No authorization code provided"))
return
}
// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.resolve(code)
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
}
export async function ensureRunning(): Promise<void> { export async function ensureRunning(): Promise<void> {
if (server) return if (server) return
@@ -66,75 +130,14 @@ export namespace McpOAuthCallback {
return return
} }
server = Bun.serve({ server = createServer(handleRequest)
port: OAUTH_CALLBACK_PORT, await new Promise<void>((resolve, reject) => {
fetch(req) { server!.listen(OAUTH_CALLBACK_PORT, () => {
const url = new URL(req.url) log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
resolve()
if (url.pathname !== OAUTH_CALLBACK_PATH) { })
return new Response("Not found", { status: 404 }) server!.on("error", reject)
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
log.info("received oauth callback", { hasCode: !!code, state, error })
// Enforce state parameter presence
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
log.error("oauth callback missing state parameter", { url: url.toString() })
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
if (error) {
const errorMsg = errorDescription || error
if (pendingAuths.has(state)) {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.reject(new Error(errorMsg))
}
return new Response(HTML_ERROR(errorMsg), {
headers: { "Content-Type": "text/html" },
})
}
if (!code) {
return new Response(HTML_ERROR("No authorization code provided"), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.resolve(code)
return new Response(HTML_SUCCESS, {
headers: { "Content-Type": "text/html" },
})
},
}) })
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
} }
export function waitForCallback(oauthState: string): Promise<string> { export function waitForCallback(oauthState: string): Promise<string> {
@@ -174,7 +177,7 @@ export namespace McpOAuthCallback {
export async function stop(): Promise<void> { export async function stop(): Promise<void> {
if (server) { if (server) {
server.stop() await new Promise<void>((resolve) => server!.close(() => resolve()))
server = undefined server = undefined
log.info("oauth callback server stopped") log.info("oauth callback server stopped")
} }
+8
View File
@@ -0,0 +1,8 @@
import { Server } from "./server/server"
const result = await Server.listen({
port: 1338,
hostname: "0.0.0.0",
})
console.log(result)
+160
View File
@@ -0,0 +1,160 @@
// Workaround: Bun on Windows does not support the UV_FS_O_FILEMAP flag that
// the `tar` package uses for files < 512KB (fs.open returns EINVAL).
// tar silently swallows the error and skips writing files, leaving only empty
// directories. Setting __FAKE_PLATFORM__ makes tar fall back to the plain 'w'
// flag. See tar's get-write-flag.js.
// Must be set before @npmcli/arborist is imported since tar caches the flag
// at module evaluation time — so we use a dynamic import() below.
if (process.platform === "win32") {
process.env.__FAKE_PLATFORM__ = "linux"
}
import semver from "semver"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { Global } from "../global"
import { Lock } from "../util/lock"
import { Log } from "../util/log"
import path from "path"
import { readdir } from "fs/promises"
import { Filesystem } from "@/util/filesystem"
const { Arborist } = await import("@npmcli/arborist")
export namespace Npm {
const log = Log.create({ service: "npm" })
export const InstallFailedError = NamedError.create(
"NpmInstallFailedError",
z.object({
pkg: z.string(),
}),
)
function directory(pkg: string) {
return path.join(Global.Path.cache, "packages", pkg)
}
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
if (!response.ok) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
const latestVersion = data?.["dist-tags"]?.latest
if (!latestVersion) {
log.warn("No latest version found, using cached", { pkg, cachedVersion })
return false
}
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
}
export async function add(pkg: string) {
using _ = await Lock.write("npm-install")
log.info("installing package", {
pkg,
})
const hash = pkg
const dir = directory(hash)
const arborist = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
})
const tree = await arborist.loadVirtual().catch(() => {})
if (tree) {
const first = tree.edgesOut.values().next().value?.to
if (first) {
log.info("package already installed", { pkg })
return first.path
}
}
const result = await arborist
.reify({
add: [pkg],
save: true,
saveType: "prod",
})
.catch((cause) => {
throw new InstallFailedError(
{ pkg },
{
cause,
},
)
})
const first = result.edgesOut.values().next().value?.to
if (!first) throw new InstallFailedError({ pkg })
return first.path
}
export async function install(dir: string) {
log.info("checking dependencies", { dir })
const reify = async () => {
const arb = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
})
await arb.reify().catch(() => {})
}
if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
log.info("node_modules missing, reifying")
await reify()
return
}
const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
const declared = new Set([
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.devDependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
...Object.keys(pkg.optionalDependencies || {}),
])
const root = lock.packages?.[""] || {}
const locked = new Set([
...Object.keys(root.dependencies || {}),
...Object.keys(root.devDependencies || {}),
...Object.keys(root.peerDependencies || {}),
...Object.keys(root.optionalDependencies || {}),
])
for (const name of declared) {
if (!locked.has(name)) {
log.info("dependency not in lock file, reifying", { name })
await reify()
return
}
}
log.info("dependencies in sync")
}
export async function which(pkg: string) {
const dir = path.join(directory(pkg), "node_modules", ".bin")
const files = await readdir(dir).catch(() => [])
if (!files.length) {
await add(pkg)
const retry = await readdir(dir).catch(() => [])
if (!retry.length) throw new Error(`No binary found for package "${pkg}" after install`)
return path.join(dir, retry[0])
}
return path.join(dir, files[0])
}
}
+63 -56
View File
@@ -5,6 +5,7 @@ import { Auth, OAUTH_DUMMY_KEY } from "../auth"
import os from "os" import os from "os"
import { ProviderTransform } from "@/provider/transform" import { ProviderTransform } from "@/provider/transform"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { createServer } from "http"
const log = Log.create({ service: "plugin.codex" }) const log = Log.create({ service: "plugin.codex" })
@@ -240,7 +241,7 @@ interface PendingOAuth {
reject: (error: Error) => void reject: (error: Error) => void
} }
let oauthServer: ReturnType<typeof Bun.serve> | undefined let oauthServer: ReturnType<typeof createServer> | undefined
let pendingOAuth: PendingOAuth | undefined let pendingOAuth: PendingOAuth | undefined
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> { async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
@@ -248,77 +249,83 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` } return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
} }
oauthServer = Bun.serve({ oauthServer = createServer((req, res) => {
port: OAUTH_PORT, const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
fetch(req) {
const url = new URL(req.url)
if (url.pathname === "/auth/callback") { if (url.pathname === "/auth/callback") {
const code = url.searchParams.get("code") const code = url.searchParams.get("code")
const state = url.searchParams.get("state") const state = url.searchParams.get("state")
const error = url.searchParams.get("error") const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description") const errorDescription = url.searchParams.get("error_description")
if (error) { if (error) {
const errorMsg = errorDescription || error const errorMsg = errorDescription || error
pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
headers: { "Content-Type": "text/html" },
})
}
if (!code) {
const errorMsg = "Missing authorization code"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
if (!pendingOAuth || state !== pendingOAuth.state) {
const errorMsg = "Invalid state - potential CSRF attack"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
const current = pendingOAuth
pendingOAuth = undefined pendingOAuth = undefined
res.writeHead(200, { "Content-Type": "text/html" })
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce) res.end(HTML_ERROR(errorMsg))
.then((tokens) => current.resolve(tokens)) return
.catch((err) => current.reject(err))
return new Response(HTML_SUCCESS, {
headers: { "Content-Type": "text/html" },
})
} }
if (url.pathname === "/cancel") { if (!code) {
pendingOAuth?.reject(new Error("Login cancelled")) const errorMsg = "Missing authorization code"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined pendingOAuth = undefined
return new Response("Login cancelled", { status: 200 }) res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
} }
return new Response("Not found", { status: 404 }) if (!pendingOAuth || state !== pendingOAuth.state) {
}, const errorMsg = "Invalid state - potential CSRF attack"
pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
const current = pendingOAuth
pendingOAuth = undefined
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
.then((tokens) => current.resolve(tokens))
.catch((err) => current.reject(err))
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
return
}
if (url.pathname === "/cancel") {
pendingOAuth?.reject(new Error("Login cancelled"))
pendingOAuth = undefined
res.writeHead(200)
res.end("Login cancelled")
return
}
res.writeHead(404)
res.end("Not found")
})
await new Promise<void>((resolve, reject) => {
oauthServer!.listen(OAUTH_PORT, () => {
log.info("codex oauth server started", { port: OAUTH_PORT })
resolve()
})
oauthServer!.on("error", reject)
}) })
log.info("codex oauth server started", { port: OAUTH_PORT })
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` } return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
} }
function stopOAuthServer() { function stopOAuthServer() {
if (oauthServer) { if (oauthServer) {
oauthServer.stop() oauthServer.close(() => {
log.info("codex oauth server stopped")
})
oauthServer = undefined oauthServer = undefined
log.info("codex oauth server stopped")
} }
} }
+8 -8
View File
@@ -4,7 +4,7 @@ import { Bus } from "../bus"
import { Log } from "../util/log" import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk" import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../server/server" import { Server } from "../server/server"
import { BunProc } from "../bun" import { Npm } from "../npm"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex" import { CodexAuthPlugin } from "./codex"
@@ -27,7 +27,9 @@ export namespace Plugin {
directory: Instance.directory, directory: Instance.directory,
fetch: async (...args) => Server.Default().fetch(...args), fetch: async (...args) => Server.Default().fetch(...args),
}) })
log.info("loading config")
const config = await Config.get() const config = await Config.get()
log.info("config loaded")
const hooks: Hooks[] = [] const hooks: Hooks[] = []
const input: PluginInput = { const input: PluginInput = {
client, client,
@@ -37,7 +39,8 @@ export namespace Plugin {
get serverUrl(): URL { get serverUrl(): URL {
throw new Error("Server URL is no longer supported in plugins") throw new Error("Server URL is no longer supported in plugins")
}, },
$: Bun.$, // @ts-expect-error
$: typeof Bun === "undefined" ? undefined : Bun.$,
} }
for (const plugin of INTERNAL_PLUGINS) { for (const plugin of INTERNAL_PLUGINS) {
@@ -59,16 +62,13 @@ export namespace Plugin {
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
log.info("loading plugin", { path: plugin }) log.info("loading plugin", { path: plugin })
if (!plugin.startsWith("file://")) { if (!plugin.startsWith("file://")) {
const lastAtIndex = plugin.lastIndexOf("@") plugin = await Npm.add(plugin).catch((err) => {
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
plugin = await BunProc.install(pkg, version).catch((err) => {
const cause = err instanceof Error ? err.cause : err const cause = err instanceof Error ? err.cause : err
const detail = cause instanceof Error ? cause.message : String(cause ?? err) const detail = cause instanceof Error ? cause.message : String(cause ?? err)
log.error("failed to install plugin", { pkg, version, error: detail }) log.error("failed to install plugin", { plugin, error: detail })
Bus.publish(Session.Event.Error, { Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({ error: new NamedError.Unknown({
message: `Failed to install plugin ${pkg}@${version}: ${detail}`, message: `Failed to install plugin ${plugin}: ${detail}`,
}).toObject(), }).toObject(),
}) })
return "" return ""
+2 -2
View File
@@ -5,7 +5,7 @@ import { Config } from "../config/config"
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda" import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
import { NoSuchModelError, type Provider as SDK } from "ai" import { NoSuchModelError, type Provider as SDK } from "ai"
import { Log } from "../util/log" import { Log } from "../util/log"
import { BunProc } from "../bun" import { Npm } from "../npm"
import { Hash } from "../util/hash" import { Hash } from "../util/hash"
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
@@ -1201,7 +1201,7 @@ export namespace Provider {
let installedPath: string let installedPath: string
if (!model.api.npm.startsWith("file://")) { if (!model.api.npm.startsWith("file://")) {
installedPath = await BunProc.install(model.api.npm, "latest") installedPath = await Npm.add(model.api.npm)
} else { } else {
log.info("loading local provider", { pkg: model.api.npm }) log.info("loading local provider", { pkg: model.api.npm })
installedPath = model.api.npm installedPath = model.api.npm
+19 -23
View File
@@ -1,6 +1,6 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { type IPty } from "bun-pty" import type { Proc } from "#pty"
import z from "zod" import z from "zod"
import { Identifier } from "../id/id" import { Identifier } from "../id/id"
import { Log } from "../util/log" import { Log } from "../util/log"
@@ -23,6 +23,8 @@ export namespace Pty {
close: (code?: number, reason?: string) => void 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. // WebSocket control frame: 0x00 + UTF-8 JSON.
const meta = (cursor: number) => { const meta = (cursor: number) => {
const json = JSON.stringify({ cursor }) const json = JSON.stringify({ cursor })
@@ -33,10 +35,7 @@ export namespace Pty {
return out return out
} }
const pty = lazy(async () => { const pty = lazy(() => import("#pty"))
const { spawn } = await import("bun-pty")
return spawn
})
export const Info = z export const Info = z
.object({ .object({
@@ -83,7 +82,7 @@ export namespace Pty {
interface ActiveSession { interface ActiveSession {
info: Info info: Info
process: IPty process: Proc
buffer: string buffer: string
bufferCursor: number bufferCursor: number
cursor: number cursor: number
@@ -97,9 +96,9 @@ export namespace Pty {
try { try {
session.process.kill() session.process.kill()
} catch {} } catch {}
for (const [key, ws] of session.subscribers.entries()) { for (const [id, ws] of session.subscribers.entries()) {
try { try {
if (ws.data === key) ws.close() if (key(ws) === id) ws.close()
} catch { } catch {
// ignore // ignore
} }
@@ -142,7 +141,7 @@ export namespace Pty {
} }
log.info("creating session", { id, cmd: command, args, cwd }) log.info("creating session", { id, cmd: command, args, cwd })
const spawn = await pty() const { spawn } = await pty()
const ptyProcess = spawn(command, args, { const ptyProcess = spawn(command, args, {
name: "xterm-256color", name: "xterm-256color",
cwd, cwd,
@@ -170,21 +169,21 @@ export namespace Pty {
ptyProcess.onData((chunk) => { ptyProcess.onData((chunk) => {
session.cursor += chunk.length session.cursor += chunk.length
for (const [key, ws] of session.subscribers.entries()) { for (const [id, ws] of session.subscribers.entries()) {
if (ws.readyState !== 1) { if (ws.readyState !== 1) {
session.subscribers.delete(key) session.subscribers.delete(id)
continue continue
} }
if (ws.data !== key) { if (key(ws) !== id) {
session.subscribers.delete(key) session.subscribers.delete(id)
continue continue
} }
try { try {
ws.send(chunk) ws.send(chunk)
} catch { } catch {
session.subscribers.delete(key) session.subscribers.delete(id)
} }
} }
@@ -226,9 +225,9 @@ export namespace Pty {
try { try {
session.process.kill() session.process.kill()
} catch {} } catch {}
for (const [key, ws] of session.subscribers.entries()) { for (const [id, ws] of session.subscribers.entries()) {
try { try {
if (ws.data === key) ws.close() if (key(ws) === id) ws.close()
} catch { } catch {
// ignore // ignore
} }
@@ -259,16 +258,13 @@ export namespace Pty {
} }
log.info("client connected to session", { id }) log.info("client connected to session", { id })
// Use ws.data as the unique key for this connection lifecycle. const sub = key(ws)
// If ws.data is undefined, fallback to ws object.
const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws
// Optionally cleanup if the key somehow exists session.subscribers.delete(sub)
session.subscribers.delete(connectionKey) session.subscribers.set(sub, ws)
session.subscribers.set(connectionKey, ws)
const cleanup = () => { const cleanup = () => {
session.subscribers.delete(connectionKey) session.subscribers.delete(sub)
} }
const start = session.bufferCursor const start = session.bufferCursor
+26
View File
@@ -0,0 +1,26 @@
import { spawn as create } from "bun-pty"
import type { Opts, Proc } from "./pty"
export type { Disp, Exit, Opts, Proc } from "./pty"
export function spawn(file: string, args: string[], opts: Opts): Proc {
const pty = create(file, args, opts)
return {
pid: pty.pid,
onData(listener) {
return pty.onData(listener)
},
onExit(listener) {
return pty.onExit(listener)
},
write(data) {
pty.write(data)
},
resize(cols, rows) {
pty.resize(cols, rows)
},
kill(signal) {
pty.kill(signal)
},
}
}
+26
View File
@@ -0,0 +1,26 @@
import * as pty from "node-pty"
import type { Opts, Proc } from "./pty"
export type { Disp, Exit, Opts, Proc } from "./pty"
export function spawn(file: string, args: string[], opts: Opts): Proc {
const proc = pty.spawn(file, args, opts)
return {
pid: proc.pid,
onData(listener) {
return proc.onData(listener)
},
onExit(listener) {
return proc.onExit(listener)
},
write(data) {
proc.write(data)
},
resize(cols, rows) {
proc.resize(cols, rows)
},
kill(signal) {
proc.kill(signal)
},
}
}
+25
View File
@@ -0,0 +1,25 @@
export type Disp = {
dispose(): void
}
export type Exit = {
exitCode: number
signal?: number | string
}
export type Opts = {
name: string
cols?: number
rows?: number
cwd?: string
env?: Record<string, string>
}
export type Proc = {
pid: number
onData(listener: (data: string) => void): Disp
onExit(listener: (event: Exit) => void): Disp
write(data: string): void
resize(cols: number, rows: number): void
kill(signal?: string): void
}
@@ -28,7 +28,7 @@ export const ProjectRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
const projects = await Project.list() const projects = Project.list()
return c.json(projects) return c.json(projects)
}, },
) )
+5 -6
View File
@@ -1,14 +1,13 @@
import { Hono } from "hono" import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi" import { describeRoute, validator, resolver } from "hono-openapi"
import { upgradeWebSocket } from "hono/bun" import type { UpgradeWebSocket } from "hono/ws"
import z from "zod" import z from "zod"
import { Pty } from "@/pty" import { Pty } from "@/pty"
import { NotFoundError } from "../../storage/db" import { NotFoundError } from "../../storage/db"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy"
export const PtyRoutes = lazy(() => export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
new Hono() return new Hono()
.get( .get(
"/", "/",
describeRoute({ describeRoute({
@@ -196,5 +195,5 @@ export const PtyRoutes = lazy(() =>
}, },
} }
}), }),
), )
) }
+82 -31
View File
@@ -34,7 +34,8 @@ import { ProviderRoutes } from "./routes/provider"
import { InstanceBootstrap } from "../project/bootstrap" import { InstanceBootstrap } from "../project/bootstrap"
import { NotFoundError } from "../storage/db" import { NotFoundError } from "../storage/db"
import type { ContentfulStatusCode } from "hono/utils/http-status" 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 { HTTPException } from "hono/http-exception"
import { errors } from "./error" import { errors } from "./error"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
@@ -48,13 +49,20 @@ import { lazy } from "@/util/lazy"
globalThis.AI_SDK_LOG_WARNINGS = false globalThis.AI_SDK_LOG_WARNINGS = false
export namespace Server { 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[] }) {
const log = Log.create({ service: "server" })
const app = new Hono() const app = new Hono()
return app const ws = createNodeWebSocket({ app })
const route = app
.onError((err, c) => { .onError((err, c) => {
log.error("failed", { log.error("failed", {
error: err, error: err,
@@ -239,7 +247,6 @@ export namespace Server {
), ),
) )
.route("/project", ProjectRoutes()) .route("/project", ProjectRoutes())
.route("/pty", PtyRoutes())
.route("/config", ConfigRoutes()) .route("/config", ConfigRoutes())
.route("/experimental", ExperimentalRoutes()) .route("/experimental", ExperimentalRoutes())
.route("/session", SessionRoutes()) .route("/session", SessionRoutes())
@@ -552,6 +559,7 @@ export namespace Server {
}) })
}, },
) )
.route("/pty", PtyRoutes(ws.upgradeWebSocket))
.all("/*", async (c) => { .all("/*", async (c) => {
const path = c.req.path const path = c.req.path
@@ -568,6 +576,11 @@ export namespace Server {
) )
return response return response
}) })
return {
app: route as Hono,
ws,
}
} }
export async function openapi() { export async function openapi() {
@@ -585,48 +598,86 @@ export namespace Server {
return result return result
} }
export function listen(opts: { export async function listen(opts: {
port: number port: number
hostname: string hostname: string
mdns?: boolean mdns?: boolean
mdnsDomain?: string mdnsDomain?: string
cors?: string[] cors?: string[]
}) { }): Promise<Listener> {
const app = createApp(opts) const log = Log.create({ service: "server" })
const args = { const built = create({
hostname: opts.hostname, ...opts,
idleTimeout: 0, })
fetch: app.fetch, const start = (port: number) =>
websocket: websocket, new Promise<ServerType>((resolve, reject) => {
} as const const server = createAdaptorServer({ fetch: built.app.fetch })
const tryServe = (port: number) => { built.ws.injectWebSocket(server)
try { const fail = (err: Error) => {
return Bun.serve({ ...args, port }) cleanup()
} catch { reject(err)
return undefined }
} 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)
const shouldPublishMDNS = const shouldPublishMDNS =
opts.mdns && opts.mdns &&
server.port && addr.port &&
opts.hostname !== "127.0.0.1" && opts.hostname !== "127.0.0.1" &&
opts.hostname !== "localhost" && opts.hostname !== "localhost" &&
opts.hostname !== "::1" opts.hostname !== "::1"
if (shouldPublishMDNS) { if (shouldPublishMDNS) {
MDNS.publish(server.port!, opts.mdnsDomain) MDNS.publish(addr.port, opts.mdnsDomain)
} else if (opts.mdns) { } else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
} }
const originalStop = server.stop.bind(server) let closing: Promise<void> | undefined
server.stop = async (closeActiveConnections?: boolean) => { return {
if (shouldPublishMDNS) MDNS.unpublish() hostname: opts.hostname,
return originalStop(closeActiveConnections) 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 -4
View File
@@ -8,12 +8,9 @@ import { Snapshot } from "@/snapshot"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import { Database, eq, desc, inArray } from "@/storage/db" import { Database, eq, desc, inArray } from "@/storage/db"
import { MessageTable, PartTable } from "./session.sql" 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 { ProviderError } from "@/provider/error"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { type SystemError } from "bun" import type { SystemError } from "bun"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
export namespace MessageV2 { export namespace MessageV2 {
+7 -9
View File
@@ -31,7 +31,6 @@ import { Flag } from "../flag/flag"
import { ulid } from "ulid" import { ulid } from "ulid"
import { spawn } from "child_process" import { spawn } from "child_process"
import { Command } from "../command" import { Command } from "../command"
import { $ } from "bun"
import { pathToFileURL, fileURLToPath } from "url" import { pathToFileURL, fileURLToPath } from "url"
import { ConfigMarkdown } from "../config/markdown" import { ConfigMarkdown } from "../config/markdown"
import { SessionSummary } from "./summary" import { SessionSummary } from "./summary"
@@ -46,6 +45,7 @@ import { LLM } from "./llm"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell" import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncation" import { Truncate } from "@/tool/truncation"
import { Process } from "@/util/process"
// @ts-ignore // @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false globalThis.AI_SDK_LOG_WARNINGS = false
@@ -1778,15 +1778,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
template = template + "\n\n" + input.arguments template = template + "\n\n" + input.arguments
} }
const shell = ConfigMarkdown.shell(template) const shellMatches = ConfigMarkdown.shell(template)
if (shell.length > 0) { if (shellMatches.length > 0) {
const sh = Shell.preferred()
const results = await Promise.all( const results = await Promise.all(
shell.map(async ([, cmd]) => { shellMatches.map(async ([, cmd]) => {
try { const out = await Process.text([cmd], { shell: sh, nothrow: true })
return await $`${{ raw: cmd }}`.quiet().nothrow().text() return out.text
} catch (error) {
return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
}
}), }),
) )
let index = 0 let index = 0
+8
View File
@@ -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
}
+8
View File
@@ -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
}
+12 -24
View File
@@ -1,5 +1,4 @@
import { Database as BunDatabase } from "bun:sqlite" import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import { drizzle, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import { migrate } from "drizzle-orm/bun-sqlite/migrator" import { migrate } from "drizzle-orm/bun-sqlite/migrator"
import { type SQLiteTransaction } from "drizzle-orm/sqlite-core" import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
export * from "drizzle-orm" export * from "drizzle-orm"
@@ -11,10 +10,10 @@ import { NamedError } from "@opencode-ai/util/error"
import z from "zod" import z from "zod"
import path from "path" import path from "path"
import { readFileSync, readdirSync, existsSync } from "fs" import { readFileSync, readdirSync, existsSync } from "fs"
import * as schema from "./schema"
import { Installation } from "../installation" import { Installation } from "../installation"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { init } from "#db"
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined 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`) return path.join(Global.Path.data, `opencode-${safe}.db`)
}) })
type Schema = typeof schema export type Transaction = SQLiteTransaction<"sync", void>
export type Transaction = SQLiteTransaction<"sync", void, Schema>
type Client = SQLiteBunDatabase type Client = SQLiteBunDatabase
type Journal = { sql: string; timestamp: number; name: string }[] type Journal = { sql: string; timestamp: number; name: string }[]
const state = {
sqlite: undefined as BunDatabase | undefined,
}
function time(tag: string) { function time(tag: string) {
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag) const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag)
if (!match) return 0 if (!match) return 0
@@ -83,17 +77,14 @@ export namespace Database {
export const Client = lazy(() => { export const Client = lazy(() => {
log.info("opening database", { path: Path }) log.info("opening database", { path: Path })
const sqlite = new BunDatabase(Path, { create: true }) const db = init(Path)
state.sqlite = sqlite
sqlite.run("PRAGMA journal_mode = WAL") db.run("PRAGMA journal_mode = WAL")
sqlite.run("PRAGMA synchronous = NORMAL") db.run("PRAGMA synchronous = NORMAL")
sqlite.run("PRAGMA busy_timeout = 5000") db.run("PRAGMA busy_timeout = 5000")
sqlite.run("PRAGMA cache_size = -64000") db.run("PRAGMA cache_size = -64000")
sqlite.run("PRAGMA foreign_keys = ON") db.run("PRAGMA foreign_keys = ON")
sqlite.run("PRAGMA wal_checkpoint(PASSIVE)") db.run("PRAGMA wal_checkpoint(PASSIVE)")
const db = drizzle({ client: sqlite })
// Apply schema migrations // Apply schema migrations
const entries = const entries =
@@ -117,14 +108,11 @@ export namespace Database {
}) })
export function close() { export function close() {
const sqlite = state.sqlite Client().$client.close()
if (!sqlite) return
sqlite.close()
state.sqlite = undefined
Client.reset() Client.reset()
} }
export type TxOrDb = SQLiteTransaction<"sync", void, any, any> | Client export type TxOrDb = Transaction | Client
const ctx = Context.create<{ const ctx = Context.create<{
tx: TxOrDb tx: TxOrDb
+1 -1
View File
@@ -45,7 +45,7 @@ export namespace ToolRegistry {
if (matches.length) await Config.waitForDependencies() if (matches.length) await Config.waitForDependencies()
for (const match of matches) { for (const match of matches) {
const namespace = path.basename(match, path.extname(match)) const namespace = path.basename(match, path.extname(match))
const mod = await import(pathToFileURL(match).href) const mod = await import(process.platform === "win32" ? match : pathToFileURL(match).href)
for (const [id, def] of Object.entries<ToolDefinition>(mod)) { for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
} }
+2
View File
@@ -13,6 +13,7 @@ export namespace Process {
abort?: AbortSignal abort?: AbortSignal
kill?: NodeJS.Signals | number kill?: NodeJS.Signals | number
timeout?: number timeout?: number
shell?: string
} }
export interface RunOptions extends Omit<Options, "stdout" | "stderr"> { export interface RunOptions extends Omit<Options, "stdout" | "stderr"> {
@@ -58,6 +59,7 @@ export namespace Process {
const proc = launch(cmd[0], cmd.slice(1), { const proc = launch(cmd[0], cmd.slice(1), {
cwd: opts.cwd, cwd: opts.cwd,
shell: opts.shell,
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined, env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"], stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
}) })
+5 -1
View File
@@ -1,9 +1,13 @@
import whichPkg from "which" import whichPkg from "which"
import path from "path"
import { Global } from "../global"
export function which(cmd: string, env?: NodeJS.ProcessEnv) { export function which(cmd: string, env?: NodeJS.ProcessEnv) {
const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin
const result = whichPkg.sync(cmd, { const result = whichPkg.sync(cmd, {
nothrow: true, nothrow: true,
path: env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path, path: full,
pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt, pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt,
}) })
return typeof result === "string" ? result : null return typeof result === "string" ? result : null
-53
View File
@@ -1,53 +0,0 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
describe("BunProc registry configuration", () => {
test("should not contain hardcoded registry parameters", async () => {
// Read the bun/index.ts file
const bunIndexPath = path.join(__dirname, "../src/bun/index.ts")
const content = await fs.readFile(bunIndexPath, "utf-8")
// Verify that no hardcoded registry is present
expect(content).not.toContain("--registry=")
expect(content).not.toContain("hasNpmRcConfig")
expect(content).not.toContain("NpmRc")
})
test("should use Bun's default registry resolution", async () => {
// Read the bun/index.ts file
const bunIndexPath = path.join(__dirname, "../src/bun/index.ts")
const content = await fs.readFile(bunIndexPath, "utf-8")
// Verify that it uses Bun's default resolution
expect(content).toContain("Bun's default registry resolution")
expect(content).toContain("Bun will use them automatically")
expect(content).toContain("No need to pass --registry flag")
})
test("should have correct command structure without registry", async () => {
// Read the bun/index.ts file
const bunIndexPath = path.join(__dirname, "../src/bun/index.ts")
const content = await fs.readFile(bunIndexPath, "utf-8")
// Extract the install function
const installFunctionMatch = content.match(/export async function install[\s\S]*?^ }/m)
expect(installFunctionMatch).toBeTruthy()
if (installFunctionMatch) {
const installFunction = installFunctionMatch[0]
// Verify expected arguments are present
expect(installFunction).toContain('"add"')
expect(installFunction).toContain('"--force"')
expect(installFunction).toContain('"--exact"')
expect(installFunction).toContain('"--cwd"')
expect(installFunction).toContain("Global.Path.cache")
expect(installFunction).toContain('pkg + "@" + version')
// Verify no registry argument is added
expect(installFunction).not.toContain('"--registry"')
expect(installFunction).not.toContain('args.push("--registry')
}
})
})