Merge branch 'dev' into chore/remove-bun-shell-opencode

This commit is contained in:
Dax
2026-03-07 18:57:16 -05:00
committed by GitHub
240 changed files with 8921 additions and 3450 deletions
+5 -5
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.19",
"version": "1.2.21",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -45,8 +45,8 @@
"@types/yargs": "17.0.33",
"@types/which": "3.0.4",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
@@ -106,7 +106,7 @@
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
"diff": "catalog:",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"fuzzysort": "3.1.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
@@ -135,6 +135,6 @@
"zod-to-json-schema": "3.24.5"
},
"overrides": {
"drizzle-orm": "1.0.0-beta.12-a5629fb"
"drizzle-orm": "1.0.0-beta.16-ea816b6"
}
}
+5 -3
View File
@@ -4,7 +4,7 @@ import { $ } from "bun"
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin"
import solidPlugin from "@opentui/solid/bun-plugin"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -51,7 +51,7 @@ const migrations = await Promise.all(
Number(match[6]),
)
: 0
return { sql, timestamp }
return { sql, timestamp, name }
}),
)
console.log(`Loaded ${migrations.length} migrations`)
@@ -161,7 +161,9 @@ for (const item of targets) {
console.log(`building ${name}`)
await $`mkdir -p dist/${name}/bin`
const parserWorker = fs.realpathSync(path.resolve(dir, "./node_modules/@opentui/core/parser.worker.js"))
const localPath = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath)
const workerPath = "./src/cli/cmd/tui/worker.ts"
// Use platform-specific bunfs root path based on target OS
+1 -2
View File
@@ -6,7 +6,6 @@ import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { bootstrap } from "../bootstrap"
import { EOL } from "os"
import { text as streamText } from "node:stream/consumers"
import { Filesystem } from "../../util/filesystem"
import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server"
@@ -338,7 +337,7 @@ export const RunCommand = cmd({
}
}
if (!process.stdin.isTTY) message += "\n" + (await streamText(process.stdin))
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
if (message.trim().length === 0 && !args.command) {
UI.error("You must provide a message or a command")
+5 -4
View File
@@ -3,7 +3,6 @@ import { tui } from "./app"
import { Rpc } from "@/util/rpc"
import { type rpc } from "./worker"
import path from "path"
import { text as streamText } from "node:stream/consumers"
import { fileURLToPath } from "url"
import { UI } from "@/cli/ui"
import { Log } from "@/util/log"
@@ -54,7 +53,7 @@ async function target() {
}
async function input(value?: string) {
const piped = process.stdin.isTTY ? undefined : await streamText(process.stdin)
const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
if (!value) return piped
if (!piped) return value
return piped + "\n" + value
@@ -112,8 +111,10 @@ export const TuiThreadCommand = cmd({
}
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
const root = process.env.PWD ?? process.cwd()
const cwd = args.project ? path.resolve(root, args.project) : process.cwd()
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
const cwd = args.project
? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project))
: root
const file = await target()
try {
process.chdir(cwd)
@@ -56,7 +56,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
setStore("active", order[nextIndex])
evt.preventDefault()
}
if (evt.name === "space") {
if (evt.name === "space" || evt.name === " ") {
if (store.active === "thinking") setStore("thinking", !store.thinking)
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)
+2
View File
@@ -60,6 +60,8 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS")
function number(key: string) {
const value = process.env[key]
+62 -24
View File
@@ -18,24 +18,61 @@ const disposal = {
all: undefined as Promise<void> | undefined,
}
function emit(directory: string) {
GlobalBus.emit("event", {
directory,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
}
function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
return iife(async () => {
const ctx =
input.project && input.worktree
? {
directory: input.directory,
worktree: input.worktree,
project: input.project,
}
: await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({
directory: input.directory,
worktree: sandbox,
project,
}))
await context.provide(ctx, async () => {
await input.init?.()
})
return ctx
})
}
function track(directory: string, next: Promise<Context>) {
const task = next.catch((error) => {
if (cache.get(directory) === task) cache.delete(directory)
throw error
})
cache.set(directory, task)
return task
}
export const Instance = {
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
let existing = cache.get(input.directory)
const directory = Filesystem.resolve(input.directory)
let existing = cache.get(directory)
if (!existing) {
Log.Default.info("creating instance", { directory: input.directory })
existing = iife(async () => {
const { project, sandbox } = await Project.fromDirectory(input.directory)
const ctx = {
directory: input.directory,
worktree: sandbox,
project,
}
await context.provide(ctx, async () => {
await input.init?.()
})
return ctx
})
cache.set(input.directory, existing)
Log.Default.info("creating instance", { directory })
existing = track(
directory,
boot({
directory,
init: input.init,
}),
)
}
const ctx = await existing
return context.provide(ctx, async () => {
@@ -66,19 +103,20 @@ export const Instance = {
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
return State.create(() => Instance.directory, init, dispose)
},
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
const directory = Filesystem.resolve(input.directory)
Log.Default.info("reloading instance", { directory })
await State.dispose(directory)
cache.delete(directory)
const next = track(directory, boot({ ...input, directory }))
emit(directory)
return await next
},
async dispose() {
Log.Default.info("disposing instance", { directory: Instance.directory })
await State.dispose(Instance.directory)
cache.delete(Instance.directory)
GlobalBus.emit("event", {
directory: Instance.directory,
payload: {
type: "server.instance.disposed",
properties: {
directory: Instance.directory,
},
},
})
emit(Instance.directory)
},
async disposeAll() {
if (disposal.all) return disposal.all
+15
View File
@@ -347,6 +347,21 @@ export namespace Project {
return fromRow(row)
}
export async function initGit(input: { directory: string; project: Info }) {
if (input.project.vcs === "git") return input.project
if (!which("git")) throw new Error("Git is not installed")
const result = await git(["init", "--quiet"], {
cwd: input.directory,
})
if (result.exitCode !== 0) {
const text = result.stderr.toString().trim() || result.text().trim()
throw new Error(text || "Failed to initialize git repository")
}
return (await fromDirectory(input.directory)).project
}
export const update = fn(
z.object({
projectID: z.string(),
+3 -10
View File
@@ -195,18 +195,11 @@ export namespace Pty {
session.bufferCursor += excess
})
ptyProcess.onExit(({ exitCode }) => {
if (session.info.status === "exited") return
log.info("session exited", { id, exitCode })
session.info.status = "exited"
for (const [key, ws] of session.subscribers.entries()) {
try {
if (ws.data === key) ws.close()
} catch {
// ignore
}
}
session.subscribers.clear()
Bus.publish(Event.Exited, { id, exitCode })
state().delete(id)
remove(id)
})
Bus.publish(Event.Created, { info })
return info
@@ -228,6 +221,7 @@ export namespace Pty {
export async function remove(id: string) {
const session = state().get(id)
if (!session) return
state().delete(id)
log.info("removing session", { id })
try {
session.process.kill()
@@ -240,7 +234,6 @@ export namespace Pty {
}
}
session.subscribers.clear()
state().delete(id)
Bus.publish(Event.Deleted, { id })
}
@@ -6,6 +6,7 @@ import { Project } from "../../project/project"
import z from "zod"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { InstanceBootstrap } from "../../project/bootstrap"
export const ProjectRoutes = lazy(() =>
new Hono()
@@ -52,6 +53,40 @@ export const ProjectRoutes = lazy(() =>
return c.json(Instance.project)
},
)
.post(
"/git/init",
describeRoute({
summary: "Initialize git repository",
description: "Create a git repository for the current project and return the refreshed project info.",
operationId: "project.initGit",
responses: {
200: {
description: "Project information after git initialization",
content: {
"application/json": {
schema: resolver(Project.Info),
},
},
},
},
}),
async (c) => {
const dir = Instance.directory
const prev = Instance.project
const next = await Project.initGit({
directory: dir,
project: prev,
})
if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
await Instance.reload({
directory: dir,
worktree: dir,
project: next,
init: InstanceBootstrap,
})
return c.json(next)
},
)
.patch(
"/:projectID",
describeRoute({
+10 -7
View File
@@ -38,6 +38,7 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"
import { websocket } from "hono/bun"
import { HTTPException } from "hono/http-exception"
import { errors } from "./error"
import { Filesystem } from "@/util/filesystem"
import { QuestionRoutes } from "./routes/question"
import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global"
@@ -198,13 +199,15 @@ export namespace Server {
if (c.req.path === "/log") return next()
const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = (() => {
try {
return decodeURIComponent(raw)
} catch {
return raw
}
})()
const directory = Filesystem.resolve(
(() => {
try {
return decodeURIComponent(raw)
} catch {
return raw
}
})(),
)
return WorkspaceContext.provide({
workspaceID,
+25 -7
View File
@@ -1,6 +1,5 @@
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { ulid } from "ulid"
import { Provider } from "@/provider/provider"
import { Session } from "@/session"
import { MessageV2 } from "@/session/message-v2"
@@ -122,20 +121,35 @@ export namespace ShareNext {
data: SDK.Model[]
}
function key(item: Data) {
switch (item.type) {
case "session":
return "session"
case "message":
return `message/${item.data.id}`
case "part":
return `part/${item.data.messageID}/${item.data.id}`
case "session_diff":
return "session_diff"
case "model":
return "model"
}
}
const queue = new Map<string, { timeout: NodeJS.Timeout; data: Map<string, Data> }>()
async function sync(sessionID: string, data: Data[]) {
if (disabled) return
const existing = queue.get(sessionID)
if (existing) {
for (const item of data) {
existing.data.set("id" in item ? (item.id as string) : ulid(), item)
existing.data.set(key(item), item)
}
return
}
const dataMap = new Map<string, Data>()
for (const item of data) {
dataMap.set("id" in item ? (item.id as string) : ulid(), item)
dataMap.set(key(item), item)
}
const timeout = setTimeout(async () => {
@@ -182,10 +196,14 @@ export namespace ShareNext {
const diffs = await Session.diff(sessionID)
const messages = await Array.fromAsync(MessageV2.stream(sessionID))
const models = await Promise.all(
messages
.filter((m) => m.info.role === "user")
.map((m) => (m.info as SDK.UserMessage).model)
.map((m) => Provider.getModel(m.providerID, m.modelID).then((m) => m)),
Array.from(
new Map(
messages
.filter((m) => m.info.role === "user")
.map((m) => (m.info as SDK.UserMessage).model)
.map((m) => [`${m.providerID}/${m.modelID}`, m] as const),
).values(),
).map((m) => Provider.getModel(m.providerID, m.modelID).then((item) => item)),
)
await sync(sessionID, [
{
+22 -6
View File
@@ -12,8 +12,11 @@ import z from "zod"
import path from "path"
import { readFileSync, readdirSync, existsSync } from "fs"
import * as schema from "./schema"
import { Installation } from "../installation"
import { Flag } from "../flag/flag"
import { iife } from "@/util/iife"
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number }[] | undefined
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined
export const NotFoundError = NamedError.create(
"NotFoundError",
@@ -25,13 +28,20 @@ export const NotFoundError = NamedError.create(
const log = Log.create({ service: "db" })
export namespace Database {
export const Path = path.join(Global.Path.data, "opencode.db")
export const Path = iife(() => {
const channel = Installation.CHANNEL
if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
return path.join(Global.Path.data, "opencode.db")
const safe = channel.replace(/[^a-zA-Z0-9._-]/g, "-")
return path.join(Global.Path.data, `opencode-${safe}.db`)
})
type Schema = typeof schema
export type Transaction = SQLiteTransaction<"sync", void, Schema>
type Client = SQLiteBunDatabase<Schema>
type Journal = { sql: string; timestamp: number }[]
type Journal = { sql: string; timestamp: number; name: string }[]
const state = {
sqlite: undefined as BunDatabase | undefined,
@@ -62,6 +72,7 @@ export namespace Database {
return {
sql: readFileSync(file, "utf-8"),
timestamp: time(name),
name,
}
})
.filter(Boolean) as Journal
@@ -70,9 +81,9 @@ export namespace Database {
}
export const Client = lazy(() => {
log.info("opening database", { path: path.join(Global.Path.data, "opencode.db") })
log.info("opening database", { path: Path })
const sqlite = new BunDatabase(path.join(Global.Path.data, "opencode.db"), { create: true })
const sqlite = new BunDatabase(Path, { create: true })
state.sqlite = sqlite
sqlite.run("PRAGMA journal_mode = WAL")
@@ -94,6 +105,11 @@ export namespace Database {
count: entries.length,
mode: typeof OPENCODE_MIGRATIONS !== "undefined" ? "bundled" : "dev",
})
if (Flag.OPENCODE_SKIP_MIGRATIONS) {
for (const item of entries) {
item.sql = "select 1;"
}
}
migrate(db, entries)
}
@@ -143,7 +159,7 @@ export namespace Database {
} catch (err) {
if (err instanceof Context.NotFound) {
const effects: (() => void | Promise<void>)[] = []
const result = Client().transaction((tx) => {
const result = (Client().transaction as any)((tx: TxOrDb) => {
return ctx.provide({ tx, effects }, () => callback(tx))
})
for (const effect of effects) effect()
+15 -1
View File
@@ -24,6 +24,15 @@ function normalizeLineEndings(text: string): string {
return text.replaceAll("\r\n", "\n")
}
function detectLineEnding(text: string): "\n" | "\r\n" {
return text.includes("\r\n") ? "\r\n" : "\n"
}
function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
if (ending === "\n") return text
return text.replaceAll("\n", "\r\n")
}
export const EditTool = Tool.define("edit", {
description: DESCRIPTION,
parameters: z.object({
@@ -78,7 +87,12 @@ export const EditTool = Tool.define("edit", {
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
await FileTime.assert(ctx.sessionID, filePath)
contentOld = await Filesystem.readText(filePath)
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
const ending = detectLineEnding(contentOld)
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
contentNew = replace(contentOld, old, next, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
+10 -4
View File
@@ -2,7 +2,7 @@ import { chmod, mkdir, readFile, writeFile } from "fs/promises"
import { createWriteStream, existsSync, statSync } from "fs"
import { lookup } from "mime-types"
import { realpathSync } from "fs"
import { dirname, join, relative } from "path"
import { dirname, join, relative, resolve as pathResolve } from "path"
import { Readable } from "stream"
import { pipeline } from "stream/promises"
import { Glob } from "./glob"
@@ -113,16 +113,22 @@ export namespace Filesystem {
}
}
// We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary.
export function resolve(p: string): string {
return normalizePath(pathResolve(windowsPath(p)))
}
export function windowsPath(p: string): string {
if (process.platform !== "win32") return p
return (
p
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
// Git Bash for Windows paths are typically /<drive>/...
.replace(/^\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
// Cygwin git paths are typically /cygdrive/<drive>/...
.replace(/^\/cygdrive\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
// WSL paths are typically /mnt/<drive>/...
.replace(/^\/mnt\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
)
}
export function overlaps(a: string, b: string) {
+8 -1
View File
@@ -2,7 +2,14 @@ import { z } from "zod"
export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) {
const result = (input: z.infer<T>) => {
const parsed = schema.parse(input)
let parsed
try {
parsed = schema.parse(input)
} catch (e) {
console.trace("schema validation failure stack trace:")
throw e
}
return cb(parsed)
}
result.force = (input: z.infer<T>) => cb(input)
+2 -2
View File
@@ -3,8 +3,8 @@ import whichPkg from "which"
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
const result = whichPkg.sync(cmd, {
nothrow: true,
path: env?.PATH,
pathExt: env?.PATHEXT,
path: env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path,
pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt,
})
return typeof result === "string" ? result : null
}
@@ -25,6 +25,34 @@ async function writeConfig(dir: string, config: object, name = "opencode.json")
await Filesystem.write(path.join(dir, name), JSON.stringify(config))
}
async function check(map: (dir: string) => string) {
if (process.platform !== "win32") return
await using globalTmp = await tmpdir()
await using tmp = await tmpdir({ git: true, config: { snapshot: true } })
const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
Config.global.reset()
try {
await writeConfig(globalTmp.path, {
$schema: "https://opencode.ai/config.json",
snapshot: false,
})
await Instance.provide({
directory: map(tmp.path),
fn: async () => {
const cfg = await Config.get()
expect(cfg.snapshot).toBe(true)
expect(Instance.directory).toBe(Filesystem.resolve(tmp.path))
expect(Instance.project.id).not.toBe("global")
},
})
} finally {
await Instance.disposeAll()
;(Global.Path as { config: string }).config = prev
Config.global.reset()
}
}
test("loads config with defaults when no files exist", async () => {
await using tmp = await tmpdir()
await Instance.provide({
@@ -56,6 +84,23 @@ test("loads JSON config file", async () => {
})
})
test("loads project config from Git Bash and MSYS2 paths on Windows", async () => {
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
await check((dir) => {
const drive = dir[0].toLowerCase()
const rest = dir.slice(2).replaceAll("\\", "/")
return `/${drive}${rest}`
})
})
test("loads project config from Cygwin paths on Windows", async () => {
await check((dir) => {
const drive = dir[0].toLowerCase()
const rest = dir.slice(2).replaceAll("\\", "/")
return `/cygdrive/${drive}${rest}`
})
})
test("ignores legacy tui keys in opencode config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
+3 -1
View File
@@ -50,7 +50,7 @@ const cacheDir = path.join(dir, "cache", "opencode")
await fs.mkdir(cacheDir, { recursive: true })
await fs.writeFile(path.join(cacheDir, "version"), "14")
// Clear provider env vars to ensure clean test state
// Clear provider and server auth env vars to ensure clean test state
delete process.env["ANTHROPIC_API_KEY"]
delete process.env["OPENAI_API_KEY"]
delete process.env["GOOGLE_API_KEY"]
@@ -70,6 +70,8 @@ delete process.env["DEEPSEEK_API_KEY"]
delete process.env["FIREWORKS_API_KEY"]
delete process.env["CEREBRAS_API_KEY"]
delete process.env["SAMBANOVA_API_KEY"]
delete process.env["OPENCODE_SERVER_PASSWORD"]
delete process.env["OPENCODE_SERVER_USERNAME"]
// Now safe to import from src/
const { Log } = await import("../src/util/log")
@@ -0,0 +1,87 @@
import { describe, expect, test } from "bun:test"
import { Bus } from "../../src/bus"
import { Instance } from "../../src/project/instance"
import { Pty } from "../../src/pty"
import { tmpdir } from "../fixture/fixture"
import { setTimeout as sleep } from "node:timers/promises"
const wait = async (fn: () => boolean, ms = 2000) => {
const end = Date.now() + ms
while (Date.now() < end) {
if (fn()) return
await sleep(25)
}
throw new Error("timeout waiting for pty events")
}
const pick = (log: Array<{ type: "created" | "exited" | "deleted"; id: string }>, id: string) => {
return log.filter((evt) => evt.id === id).map((evt) => evt.type)
}
describe("pty", () => {
test("publishes created, exited, deleted in order for /bin/ls + remove", async () => {
if (process.platform === "win32") return
await using dir = await tmpdir({ git: true })
await Instance.provide({
directory: dir.path,
fn: async () => {
const log: Array<{ type: "created" | "exited" | "deleted"; id: string }> = []
const off = [
Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })),
Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })),
Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })),
]
let id = ""
try {
const info = await Pty.create({ command: "/bin/ls", title: "ls" })
id = info.id
await wait(() => pick(log, id).includes("exited"))
await Pty.remove(id)
await wait(() => pick(log, id).length >= 3)
expect(pick(log, id)).toEqual(["created", "exited", "deleted"])
} finally {
off.forEach((x) => x())
if (id) await Pty.remove(id)
}
},
})
})
test("publishes created, exited, deleted in order for /bin/sh + remove", async () => {
if (process.platform === "win32") return
await using dir = await tmpdir({ git: true })
await Instance.provide({
directory: dir.path,
fn: async () => {
const log: Array<{ type: "created" | "exited" | "deleted"; id: string }> = []
const off = [
Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })),
Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })),
Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })),
]
let id = ""
try {
const info = await Pty.create({ command: "/bin/sh", title: "sh" })
id = info.id
await sleep(100)
await Pty.remove(id)
await wait(() => pick(log, id).length >= 3)
expect(pick(log, id)).toEqual(["created", "exited", "deleted"])
} finally {
off.forEach((x) => x())
if (id) await Pty.remove(id)
}
},
})
})
})
@@ -0,0 +1,119 @@
import { afterEach, describe, expect, spyOn, test } from "bun:test"
import path from "path"
import { GlobalBus } from "../../src/bus/global"
import { Snapshot } from "../../src/snapshot"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { Filesystem } from "../../src/util/filesystem"
import { Log } from "../../src/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
afterEach(async () => {
await resetDatabase()
})
describe("project.initGit endpoint", () => {
test("initializes git and reloads immediately", async () => {
await using tmp = await tmpdir()
const app = Server.App()
const seen: { directory?: string; payload: { type: string } }[] = []
const fn = (evt: { directory?: string; payload: { type: string } }) => {
seen.push(evt)
}
const reload = Instance.reload
const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input))
GlobalBus.on("event", fn)
try {
const init = await app.request("/project/git/init", {
method: "POST",
headers: {
"x-opencode-directory": tmp.path,
},
})
const body = await init.json()
expect(init.status).toBe(200)
expect(body).toMatchObject({
id: "global",
vcs: "git",
worktree: tmp.path,
})
expect(reloadSpy).toHaveBeenCalledTimes(1)
expect(reloadSpy.mock.calls[0]?.[0]?.init).toBe(InstanceBootstrap)
expect(seen.some((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed")).toBe(
true,
)
expect(await Filesystem.exists(path.join(tmp.path, ".git", "opencode"))).toBe(false)
const current = await app.request("/project/current", {
headers: {
"x-opencode-directory": tmp.path,
},
})
expect(current.status).toBe(200)
expect(await current.json()).toMatchObject({
id: "global",
vcs: "git",
worktree: tmp.path,
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
expect(await Snapshot.track()).toBeTruthy()
},
})
} finally {
reloadSpy.mockRestore()
GlobalBus.off("event", fn)
}
})
test("does not reload when the project is already git", async () => {
await using tmp = await tmpdir({ git: true })
const app = Server.App()
const seen: { directory?: string; payload: { type: string } }[] = []
const fn = (evt: { directory?: string; payload: { type: string } }) => {
seen.push(evt)
}
const reload = Instance.reload
const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input))
GlobalBus.on("event", fn)
try {
const init = await app.request("/project/git/init", {
method: "POST",
headers: {
"x-opencode-directory": tmp.path,
},
})
expect(init.status).toBe(200)
expect(await init.json()).toMatchObject({
vcs: "git",
worktree: tmp.path,
})
expect(
seen.filter((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed").length,
).toBe(0)
expect(reloadSpy).toHaveBeenCalledTimes(0)
const current = await app.request("/project/current", {
headers: {
"x-opencode-directory": tmp.path,
},
})
expect(current.status).toBe(200)
expect(await current.json()).toMatchObject({
vcs: "git",
worktree: tmp.path,
})
} finally {
reloadSpy.mockRestore()
GlobalBus.off("event", fn)
}
})
})
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Installation } from "../../src/installation"
import { Database } from "../../src/storage/db"
describe("Database.Path", () => {
test("returns database path for the current channel", () => {
const file = path.basename(Database.Path)
const expected = ["latest", "beta"].includes(Installation.CHANNEL)
? "opencode.db"
: `opencode-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`
expect(file).toBe(expected)
})
})
@@ -84,6 +84,7 @@ function createTestDb() {
.map((entry) => ({
sql: readFileSync(path.join(dir, entry.name, "migration.sql"), "utf-8"),
timestamp: Number(entry.name.split("_")[0]),
name: entry.name,
}))
.sort((a, b) => a.timestamp - b.timestamp)
migrate(drizzle({ client: sqlite }), migrations)
+183
View File
@@ -451,6 +451,189 @@ describe("tool.edit", () => {
})
})
describe("line endings", () => {
const old = "alpha\nbeta\ngamma"
const next = "alpha\nbeta-updated\ngamma"
const alt = "alpha\nbeta\nomega"
const normalize = (text: string, ending: "\n" | "\r\n") => {
const normalized = text.replaceAll("\r\n", "\n")
if (ending === "\n") return normalized
return normalized.replaceAll("\n", "\r\n")
}
const count = (content: string) => {
const crlf = content.match(/\r\n/g)?.length ?? 0
const lf = content.match(/\n/g)?.length ?? 0
return {
crlf,
lf: lf - crlf,
}
}
const expectLf = (content: string) => {
const counts = count(content)
expect(counts.crlf).toBe(0)
expect(counts.lf).toBeGreaterThan(0)
}
const expectCrlf = (content: string) => {
const counts = count(content)
expect(counts.lf).toBe(0)
expect(counts.crlf).toBeGreaterThan(0)
}
type Input = {
content: string
oldString: string
newString: string
replaceAll?: boolean
}
const apply = async (input: Input) => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), input.content)
},
})
return await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
const filePath = path.join(tmp.path, "test.txt")
FileTime.read(ctx.sessionID, filePath)
await edit.execute(
{
filePath,
oldString: input.oldString,
newString: input.newString,
replaceAll: input.replaceAll,
},
ctx,
)
return await Bun.file(filePath).text()
},
})
}
test("preserves LF with LF multi-line strings", async () => {
const content = normalize(old + "\n", "\n")
const output = await apply({
content,
oldString: normalize(old, "\n"),
newString: normalize(next, "\n"),
})
expect(output).toBe(normalize(next + "\n", "\n"))
expectLf(output)
})
test("preserves CRLF with CRLF multi-line strings", async () => {
const content = normalize(old + "\n", "\r\n")
const output = await apply({
content,
oldString: normalize(old, "\r\n"),
newString: normalize(next, "\r\n"),
})
expect(output).toBe(normalize(next + "\n", "\r\n"))
expectCrlf(output)
})
test("preserves LF when old/new use CRLF", async () => {
const content = normalize(old + "\n", "\n")
const output = await apply({
content,
oldString: normalize(old, "\r\n"),
newString: normalize(next, "\r\n"),
})
expect(output).toBe(normalize(next + "\n", "\n"))
expectLf(output)
})
test("preserves CRLF when old/new use LF", async () => {
const content = normalize(old + "\n", "\r\n")
const output = await apply({
content,
oldString: normalize(old, "\n"),
newString: normalize(next, "\n"),
})
expect(output).toBe(normalize(next + "\n", "\r\n"))
expectCrlf(output)
})
test("preserves LF when newString uses CRLF", async () => {
const content = normalize(old + "\n", "\n")
const output = await apply({
content,
oldString: normalize(old, "\n"),
newString: normalize(next, "\r\n"),
})
expect(output).toBe(normalize(next + "\n", "\n"))
expectLf(output)
})
test("preserves CRLF when newString uses LF", async () => {
const content = normalize(old + "\n", "\r\n")
const output = await apply({
content,
oldString: normalize(old, "\r\n"),
newString: normalize(next, "\n"),
})
expect(output).toBe(normalize(next + "\n", "\r\n"))
expectCrlf(output)
})
test("preserves LF with mixed old/new line endings", async () => {
const content = normalize(old + "\n", "\n")
const output = await apply({
content,
oldString: "alpha\nbeta\r\ngamma",
newString: "alpha\r\nbeta\nomega",
})
expect(output).toBe(normalize(alt + "\n", "\n"))
expectLf(output)
})
test("preserves CRLF with mixed old/new line endings", async () => {
const content = normalize(old + "\n", "\r\n")
const output = await apply({
content,
oldString: "alpha\r\nbeta\ngamma",
newString: "alpha\nbeta\r\nomega",
})
expect(output).toBe(normalize(alt + "\n", "\r\n"))
expectCrlf(output)
})
test("replaceAll preserves LF for multi-line blocks", async () => {
const blockOld = "alpha\nbeta"
const blockNew = "alpha\nbeta-updated"
const content = normalize(blockOld + "\n" + blockOld + "\n", "\n")
const output = await apply({
content,
oldString: normalize(blockOld, "\n"),
newString: normalize(blockNew, "\n"),
replaceAll: true,
})
expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\n"))
expectLf(output)
})
test("replaceAll preserves CRLF for multi-line blocks", async () => {
const blockOld = "alpha\nbeta"
const blockNew = "alpha\nbeta-updated"
const content = normalize(blockOld + "\n" + blockOld + "\n", "\r\n")
const output = await apply({
content,
oldString: normalize(blockOld, "\r\n"),
newString: normalize(blockNew, "\r\n"),
replaceAll: true,
})
expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\r\n"))
expectCrlf(output)
})
})
describe("concurrent editing", () => {
test("serializes concurrent edits to same file", async () => {
await using tmp = await tmpdir()
@@ -440,4 +440,67 @@ describe("filesystem", () => {
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
})
})
describe("resolve()", () => {
test("resolves slash-prefixed drive paths on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const forward = tmp.path.replaceAll("\\", "/")
expect(Filesystem.resolve(`/${forward}`)).toBe(Filesystem.normalizePath(tmp.path))
})
test("resolves slash-prefixed drive roots on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toUpperCase()
expect(Filesystem.resolve(`/${drive}:`)).toBe(Filesystem.resolve(`${drive}:/`))
})
test("resolves Git Bash and MSYS2 paths on Windows", async () => {
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
const rest = tmp.path.slice(2).replaceAll("\\", "/")
expect(Filesystem.resolve(`/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
})
test("resolves Git Bash and MSYS2 drive roots on Windows", async () => {
// Git Bash and MSYS2 both use /<drive> paths on Windows.
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
expect(Filesystem.resolve(`/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
})
test("resolves Cygwin paths on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
const rest = tmp.path.slice(2).replaceAll("\\", "/")
expect(Filesystem.resolve(`/cygdrive/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
})
test("resolves Cygwin drive roots on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
expect(Filesystem.resolve(`/cygdrive/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
})
test("resolves WSL mount paths on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
const rest = tmp.path.slice(2).replaceAll("\\", "/")
expect(Filesystem.resolve(`/mnt/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
})
test("resolves WSL mount roots on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const drive = tmp.path[0].toLowerCase()
expect(Filesystem.resolve(`/mnt/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
})
})
})
+18
View File
@@ -22,6 +22,13 @@ function env(PATH: string): NodeJS.ProcessEnv {
}
}
function envPath(Path: string): NodeJS.ProcessEnv {
return {
Path,
PathExt: process.env["PathExt"] ?? process.env["PATHEXT"],
}
}
function same(a: string | null, b: string) {
if (process.platform === "win32") {
expect(a?.toLowerCase()).toBe(b.toLowerCase())
@@ -79,4 +86,15 @@ describe("util.which", () => {
expect(which("pathext", { PATH: bin, PATHEXT: ".CMD" })).toBe(file)
})
test("uses Windows Path casing fallback", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const bin = path.join(tmp.path, "bin")
await fs.mkdir(bin)
const file = await cmd(bin, "mixed")
same(which("mixed", envPath(bin)), file)
})
})