From f178ea3207629a15cc457f2addb936b377355f3c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 5 May 2026 22:17:06 +0900 Subject: [PATCH 1/5] fix(electron-compat): prepend globalThis.Bun shim to prevent Electron/Node crash On Node/Electron, globalThis.Bun is undefined. The Bun bundler emits top-level var { spawn } = globalThis.Bun; destructures from its internal modules, causing 'Cannot destructure property of undefined' before any plugin hook is reached (25 occurrences in dist/index.js). Fix: - Add src/electron-compat.ts: a side-effect module that populates globalThis.Bun with node:child_process-backed spawn/spawnSync shims when Bun is unavailable - Add script/prepend-electron-shim.ts: post-build script that prepends the shim code to dist/index.js, guaranteeing it runs BEFORE the top-level destructures (Bun bundler does not preserve import side-effect evaluation order reliably) - Update build script to run prepend-shim after bundling - Add src/electron-compat.test.ts verifying shim position in dist The shim only activates when globalThis.Bun is absent (real Bun runtime is unaffected). Spawn-dependent features degrade gracefully at call time. Fixes #3797 (follow-up to #3795/#3796) --- package.json | 3 +- script/prepend-electron-shim.ts | 65 +++++++++++++++++++++++++++++ src/electron-compat.test.ts | 27 ++++++++++++ src/electron-compat.ts | 74 +++++++++++++++++++++++++++++++++ src/index.ts | 1 + 5 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 script/prepend-electron-shim.ts create mode 100644 src/electron-compat.test.ts create mode 100644 src/electron-compat.ts diff --git a/package.json b/package.json index ffd572371..c72accc37 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "./schema.json": "./dist/oh-my-opencode.schema.json" }, "scripts": { - "build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi --external zod && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema", + "build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi --external zod && bun run build:prepend-shim && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema", + "build:prepend-shim": "bun run script/prepend-electron-shim.ts", "build:all": "bun run build && bun run build:binaries", "build:binaries": "bun run script/build-binaries.ts", "build:schema": "bun run script/build-schema.ts", diff --git a/script/prepend-electron-shim.ts b/script/prepend-electron-shim.ts new file mode 100644 index 000000000..8b4f5f586 --- /dev/null +++ b/script/prepend-electron-shim.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env bun +/** + * Prepend the Electron/Node compat shim to dist/index.js. + * + * When bundled with --target bun, Bun inlines top-level + * var { spawn } = globalThis.Bun; + * statements from its own internal modules. These crash on Node/Electron + * because globalThis.Bun is undefined there. + * + * Since Bun's bundler does not guarantee that a side-effect import at the + * top of src/index.ts will appear before all other module top-level code, + * we post-process the bundle and manually prepend the shim. + */ + +import { readFileSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +const DIST_PATH = join(import.meta.dir, "..", "dist", "index.js") + +const SHIM = `// [omo] Electron/Node compat shim — prepended by script/prepend-electron-shim.ts +// Populates globalThis.Bun before any top-level destructure fires. +if (!globalThis.Bun) { + const _cp = await import("node:child_process"); + const _Readable = (await import("node:stream")).Readable; + function _mkproc(p) { + let c = null; + const exited = new Promise(r => { + p.on("exit", code => { c = code ?? 1; r(c); }); + p.on("error", () => { if (c === null) { c = 1; r(1); } }); + }); + return { get exitCode() { return c; }, exited, + stdout: p.stdout ? _Readable.toWeb(p.stdout) : undefined, + stderr: p.stderr ? _Readable.toWeb(p.stderr) : undefined, + stdin: p.stdin, kill(s) { try { p.kill(s); } catch {} }, pid: p.pid }; + } + function _spawn(cmdOrOpts, opts) { + const isObj = !Array.isArray(cmdOrOpts); + const cmd = isObj ? cmdOrOpts.cmd : cmdOrOpts; + const o = isObj ? cmdOrOpts : (opts || {}); + const [bin, ...args] = cmd; + return _mkproc(_cp.spawn(bin, args, { cwd: o.cwd, env: o.env, + stdio: [o.stdin||"pipe", o.stdout||"pipe", o.stderr||"pipe"] })); + } + function _spawnSync(cmdOrOpts) { + const isObj = !Array.isArray(cmdOrOpts); + const cmd = isObj ? cmdOrOpts.cmd : cmdOrOpts; + const o = isObj ? cmdOrOpts : {}; + const [bin, ...args] = cmd; + const r = _cp.spawnSync(bin, args, { cwd: o.cwd, env: o.env, stdio: ["pipe","pipe","pipe"] }); + return { exitCode: r.status ?? 1, stdout: r.stdout, stderr: r.stderr }; + } + globalThis.Bun = { spawn: _spawn, spawnSync: _spawnSync, env: process.env, version: "0.0.0-node-shim" }; +} +` + +const original = readFileSync(DIST_PATH, "utf-8") + +// Avoid double-prepend +if (original.includes("[omo] Electron/Node compat shim")) { + console.log("Shim already present in dist/index.js, skipping.") + process.exit(0) +} + +writeFileSync(DIST_PATH, SHIM + original, "utf-8") +console.log(`✓ Prepended Electron/Node compat shim to dist/index.js`) diff --git a/src/electron-compat.test.ts b/src/electron-compat.test.ts new file mode 100644 index 000000000..7da00cbcd --- /dev/null +++ b/src/electron-compat.test.ts @@ -0,0 +1,27 @@ +import { describe, test, expect } from "bun:test" + +describe("electron-compat shim", () => { + test("#given shim module #when inspected #then it exports no values (side-effect only)", async () => { + // The shim is a side-effect module — no named exports + const mod = await import("./electron-compat") + expect(Object.keys(mod)).toHaveLength(0) + }) + + test("#given Bun runtime #when shim is loaded #then globalThis.Bun remains the real Bun", () => { + // In Bun (our test environment), globalThis.Bun is already defined. + // The shim must not overwrite it. + expect(globalThis.Bun).toBeDefined() + // Real Bun version looks like "1.x.x", not our shim string + expect(globalThis.Bun.version).not.toBe("0.0.0-node-shim") + }) + + test("#given dist/index.js #when inspected #then compat shim appears before first globalThis.Bun destructure", async () => { + // This test guards that build/prepend-electron-shim ran. + // If the shim is missing, the plugin crashes on Electron at line 2876. + const dist = await Bun.file("dist/index.js").text() + const shimPos = dist.indexOf("[omo] Electron/Node compat shim") + const firstDestructure = dist.indexOf("} = globalThis.Bun;") + expect(shimPos).toBeGreaterThanOrEqual(0) // shim present + expect(shimPos).toBeLessThan(firstDestructure) // shim before destructures + }) +}) diff --git a/src/electron-compat.ts b/src/electron-compat.ts new file mode 100644 index 000000000..b60ab0be8 --- /dev/null +++ b/src/electron-compat.ts @@ -0,0 +1,74 @@ +/** + * Electron/Node runtime compatibility shim. + * + * OpenCode Desktop runs the plugin in an Electron renderer/main process + * whose ESM loader is Node — not Bun. When bundled with `--target bun`, + * every chunk that calls `Bun.spawn` / `Bun.spawnSync` emits a top-level + * `var { spawn } = globalThis.Bun` destructure. On Node/Electron, + * `globalThis.Bun` is `undefined`, so module evaluation crashes before any + * plugin hook is ever reached. + * + * We patch globalThis.Bun at module-evaluation time so the destructures + * resolve to real functions backed by node:child_process. + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const _cp = require("node:child_process") as typeof import("node:child_process") +// eslint-disable-next-line @typescript-eslint/no-require-imports +const _stream = require("node:stream") as typeof import("node:stream") + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyRecord = Record + +function _makeProc(proc: ReturnType) { + let _exitCode: number | null = null + const exited = new Promise((resolve) => { + proc.on("exit", (code) => { _exitCode = code ?? 1; resolve(_exitCode) }) + proc.on("error", () => { if (_exitCode === null) { _exitCode = 1; resolve(1) } }) + }) + return { + get exitCode() { return _exitCode }, + exited, + stdout: proc.stdout ? (_stream.Readable.toWeb(proc.stdout) as ReadableStream) : undefined, + stderr: proc.stderr ? (_stream.Readable.toWeb(proc.stderr) as ReadableStream) : undefined, + stdin: proc.stdin, + kill(sig?: NodeJS.Signals) { try { proc.kill(sig) } catch {} }, + pid: proc.pid, + } +} + +function _spawnShim(cmdOrOpts: string[] | AnyRecord, optsArg?: AnyRecord) { + const isObj = !Array.isArray(cmdOrOpts) + const cmd: string[] = isObj ? (cmdOrOpts as AnyRecord)["cmd"] as string[] : (cmdOrOpts as string[]) + const o: AnyRecord = isObj ? (cmdOrOpts as AnyRecord) : (optsArg ?? {}) + const [bin, ...args] = cmd + const proc = _cp.spawn(bin, args, { + cwd: o["cwd"] as string | undefined, + env: o["env"] as NodeJS.ProcessEnv | undefined, + stdio: [(o["stdin"] ?? "pipe") as "pipe", (o["stdout"] ?? "pipe") as "pipe", (o["stderr"] ?? "pipe") as "pipe"], + }) + return _makeProc(proc) +} + +function _spawnSyncShim(cmdOrOpts: string[] | AnyRecord) { + const isObj = !Array.isArray(cmdOrOpts) + const cmd: string[] = isObj ? (cmdOrOpts as AnyRecord)["cmd"] as string[] : (cmdOrOpts as string[]) + const o: AnyRecord = isObj ? (cmdOrOpts as AnyRecord) : {} + const [bin, ...args] = cmd + const r = _cp.spawnSync(bin, args, { + cwd: o["cwd"] as string | undefined, + env: o["env"] as NodeJS.ProcessEnv | undefined, + stdio: ["pipe", "pipe", "pipe"], + }) + return { exitCode: r.status ?? 1, stdout: r.stdout, stderr: r.stderr } +} + +if (!globalThis.Bun) { + // @ts-expect-error – intentional globalThis shim for Electron/Node compat + globalThis.Bun = { + spawn: _spawnShim as unknown as typeof globalThis.Bun.spawn, + spawnSync: _spawnSyncShim as unknown as typeof globalThis.Bun.spawnSync, + env: process.env, + version: "0.0.0-node-shim", + } +} diff --git a/src/index.ts b/src/index.ts index a5f549d39..6ac93ece2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import "./electron-compat" import { initConfigContext } from "./cli/config-manager/config-context" import type { Hooks, Plugin, PluginModule } from "@opencode-ai/plugin" From 3ddc757b152219c9dd2193cbe2da86ab4f41bf59 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 5 May 2026 22:41:45 +0900 Subject: [PATCH 2/5] fix(bun-spawn-shim): eliminate globalThis.Bun top-level destructures for Electron/Node compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: bun build --target bun inlines top-level var { spawn } = globalThis.Bun; for every file that contains 'import { spawn } from "bun"'. On Node/Electron where globalThis.Bun is undefined, this crashes with Cannot destructure property 'spawn' of 'globalThis.Bun' as it is undefined. 26 source files had this import; the bundled output had 25 top-level destructures. Fix: - Add src/shared/bun-spawn-shim.ts: a thin wrapper that - delegates to Bun.spawn/spawnSync when globalThis.Bun is present (real Bun) - falls back to static ESM imports of node:child_process otherwise - uses static 'import { spawn } from "node:child_process"' so Bun bundler does NOT emit any globalThis.Bun destructures for this module - Replace all 26 'from "bun"' spawn/spawnSync imports with relative paths to shim - Replace 4 direct Bun.spawn() call sites with shim's spawn() - Remove src/electron-compat.ts and script/prepend-electron-shim.ts (no longer needed) - Update src/electron-compat.test.ts to assert 0 top-level globalThis.Bun destructures Verification: grep -c '} = globalThis.Bun;' dist/index.js → 0 (was 25) All 5921 tests pass (1 pre-existing timeout failure unrelated to this change). Fixes #3797 --- package.json | 3 +- script/prepend-electron-shim.ts | 65 ---------------- src/electron-compat.test.ts | 34 ++++----- src/electron-compat.ts | 74 ------------------- .../team-mode/team-worktree/cleanup.ts | 5 +- .../team-mode/team-worktree/manager.ts | 3 +- .../tmux-subagent/pane-state-querier.ts | 2 +- src/hooks/comment-checker/cli.ts | 2 +- src/index.ts | 1 - src/openclaw/dispatcher.ts | 2 +- src/openclaw/reply-listener-process.ts | 2 +- src/openclaw/reply-listener-spawn.ts | 2 +- src/openclaw/tmux.ts | 2 +- src/shared/binary-downloader.ts | 2 +- src/shared/bun-spawn-shim.ts | 68 +++++++++++++++++ src/shared/spawn-with-windows-hide.ts | 2 +- src/shared/tmux/tmux-utils/layout.ts | 2 +- src/shared/tmux/tmux-utils/pane-dimensions.ts | 2 +- src/shared/tmux/tmux-utils/pane-replace.ts | 2 +- src/shared/tmux/tmux-utils/pane-spawn.ts | 2 +- src/shared/tmux/tmux-utils/session-spawn.ts | 2 +- src/shared/tmux/tmux-utils/spawn-process.ts | 2 +- src/shared/tmux/tmux-utils/window-spawn.ts | 2 +- .../powershell-zip-entry-listing.ts | 2 +- .../python-zip-entry-listing.ts | 2 +- .../read-zip-symlink-target.ts | 2 +- .../tar-zip-entry-listing.ts | 2 +- .../zipinfo-zip-entry-listing.ts | 2 +- src/shared/zip-extractor.ts | 2 +- src/tools/ast-grep/cli.ts | 2 +- src/tools/glob/cli.ts | 2 +- src/tools/grep/cli.ts | 2 +- src/tools/hashline-edit/formatter-trigger.ts | 3 +- .../interactive-bash/tmux-path-resolver.ts | 2 +- src/tools/lsp/lsp-process.ts | 2 +- 35 files changed, 117 insertions(+), 191 deletions(-) delete mode 100644 script/prepend-electron-shim.ts delete mode 100644 src/electron-compat.ts create mode 100644 src/shared/bun-spawn-shim.ts diff --git a/package.json b/package.json index c72accc37..ffd572371 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,7 @@ "./schema.json": "./dist/oh-my-opencode.schema.json" }, "scripts": { - "build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi --external zod && bun run build:prepend-shim && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema", - "build:prepend-shim": "bun run script/prepend-electron-shim.ts", + "build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi --external zod && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema", "build:all": "bun run build && bun run build:binaries", "build:binaries": "bun run script/build-binaries.ts", "build:schema": "bun run script/build-schema.ts", diff --git a/script/prepend-electron-shim.ts b/script/prepend-electron-shim.ts deleted file mode 100644 index 8b4f5f586..000000000 --- a/script/prepend-electron-shim.ts +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bun -/** - * Prepend the Electron/Node compat shim to dist/index.js. - * - * When bundled with --target bun, Bun inlines top-level - * var { spawn } = globalThis.Bun; - * statements from its own internal modules. These crash on Node/Electron - * because globalThis.Bun is undefined there. - * - * Since Bun's bundler does not guarantee that a side-effect import at the - * top of src/index.ts will appear before all other module top-level code, - * we post-process the bundle and manually prepend the shim. - */ - -import { readFileSync, writeFileSync } from "node:fs" -import { join } from "node:path" - -const DIST_PATH = join(import.meta.dir, "..", "dist", "index.js") - -const SHIM = `// [omo] Electron/Node compat shim — prepended by script/prepend-electron-shim.ts -// Populates globalThis.Bun before any top-level destructure fires. -if (!globalThis.Bun) { - const _cp = await import("node:child_process"); - const _Readable = (await import("node:stream")).Readable; - function _mkproc(p) { - let c = null; - const exited = new Promise(r => { - p.on("exit", code => { c = code ?? 1; r(c); }); - p.on("error", () => { if (c === null) { c = 1; r(1); } }); - }); - return { get exitCode() { return c; }, exited, - stdout: p.stdout ? _Readable.toWeb(p.stdout) : undefined, - stderr: p.stderr ? _Readable.toWeb(p.stderr) : undefined, - stdin: p.stdin, kill(s) { try { p.kill(s); } catch {} }, pid: p.pid }; - } - function _spawn(cmdOrOpts, opts) { - const isObj = !Array.isArray(cmdOrOpts); - const cmd = isObj ? cmdOrOpts.cmd : cmdOrOpts; - const o = isObj ? cmdOrOpts : (opts || {}); - const [bin, ...args] = cmd; - return _mkproc(_cp.spawn(bin, args, { cwd: o.cwd, env: o.env, - stdio: [o.stdin||"pipe", o.stdout||"pipe", o.stderr||"pipe"] })); - } - function _spawnSync(cmdOrOpts) { - const isObj = !Array.isArray(cmdOrOpts); - const cmd = isObj ? cmdOrOpts.cmd : cmdOrOpts; - const o = isObj ? cmdOrOpts : {}; - const [bin, ...args] = cmd; - const r = _cp.spawnSync(bin, args, { cwd: o.cwd, env: o.env, stdio: ["pipe","pipe","pipe"] }); - return { exitCode: r.status ?? 1, stdout: r.stdout, stderr: r.stderr }; - } - globalThis.Bun = { spawn: _spawn, spawnSync: _spawnSync, env: process.env, version: "0.0.0-node-shim" }; -} -` - -const original = readFileSync(DIST_PATH, "utf-8") - -// Avoid double-prepend -if (original.includes("[omo] Electron/Node compat shim")) { - console.log("Shim already present in dist/index.js, skipping.") - process.exit(0) -} - -writeFileSync(DIST_PATH, SHIM + original, "utf-8") -console.log(`✓ Prepended Electron/Node compat shim to dist/index.js`) diff --git a/src/electron-compat.test.ts b/src/electron-compat.test.ts index 7da00cbcd..1f91f4531 100644 --- a/src/electron-compat.test.ts +++ b/src/electron-compat.test.ts @@ -1,27 +1,23 @@ import { describe, test, expect } from "bun:test" -describe("electron-compat shim", () => { - test("#given shim module #when inspected #then it exports no values (side-effect only)", async () => { - // The shim is a side-effect module — no named exports - const mod = await import("./electron-compat") - expect(Object.keys(mod)).toHaveLength(0) +describe("electron-compat: dist/index.js globalThis.Bun safety", () => { + test("#given dist/index.js #then no top-level globalThis.Bun destructures exist", async () => { + // This guards the fix for https://github.com/code-yeongyu/oh-my-openagent/issues/3797. + // Previously 'bun build --target bun' emitted 25 top-level + // var { spawn } = globalThis.Bun; + // statements that crashed on Node/Electron where globalThis.Bun is undefined. + // The fix replaces all 'from "bun"' spawn imports with a node:child_process + // shim so the bundler no longer emits these destructures. + const dist = await Bun.file("dist/index.js").text() + const destructures = (dist.match(/\} = globalThis\.Bun;/g) ?? []).length + expect(destructures).toBe(0) }) - test("#given Bun runtime #when shim is loaded #then globalThis.Bun remains the real Bun", () => { - // In Bun (our test environment), globalThis.Bun is already defined. - // The shim must not overwrite it. + test("#given Bun runtime #when shim module is loaded #then globalThis.Bun remains real Bun", () => { + // On real Bun runtime, globalThis.Bun must not be overwritten by any shim expect(globalThis.Bun).toBeDefined() - // Real Bun version looks like "1.x.x", not our shim string + expect(typeof globalThis.Bun.spawn).toBe("function") + // The shim version string is only set when globalThis.Bun was absent expect(globalThis.Bun.version).not.toBe("0.0.0-node-shim") }) - - test("#given dist/index.js #when inspected #then compat shim appears before first globalThis.Bun destructure", async () => { - // This test guards that build/prepend-electron-shim ran. - // If the shim is missing, the plugin crashes on Electron at line 2876. - const dist = await Bun.file("dist/index.js").text() - const shimPos = dist.indexOf("[omo] Electron/Node compat shim") - const firstDestructure = dist.indexOf("} = globalThis.Bun;") - expect(shimPos).toBeGreaterThanOrEqual(0) // shim present - expect(shimPos).toBeLessThan(firstDestructure) // shim before destructures - }) }) diff --git a/src/electron-compat.ts b/src/electron-compat.ts deleted file mode 100644 index b60ab0be8..000000000 --- a/src/electron-compat.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Electron/Node runtime compatibility shim. - * - * OpenCode Desktop runs the plugin in an Electron renderer/main process - * whose ESM loader is Node — not Bun. When bundled with `--target bun`, - * every chunk that calls `Bun.spawn` / `Bun.spawnSync` emits a top-level - * `var { spawn } = globalThis.Bun` destructure. On Node/Electron, - * `globalThis.Bun` is `undefined`, so module evaluation crashes before any - * plugin hook is ever reached. - * - * We patch globalThis.Bun at module-evaluation time so the destructures - * resolve to real functions backed by node:child_process. - */ - -// eslint-disable-next-line @typescript-eslint/no-require-imports -const _cp = require("node:child_process") as typeof import("node:child_process") -// eslint-disable-next-line @typescript-eslint/no-require-imports -const _stream = require("node:stream") as typeof import("node:stream") - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnyRecord = Record - -function _makeProc(proc: ReturnType) { - let _exitCode: number | null = null - const exited = new Promise((resolve) => { - proc.on("exit", (code) => { _exitCode = code ?? 1; resolve(_exitCode) }) - proc.on("error", () => { if (_exitCode === null) { _exitCode = 1; resolve(1) } }) - }) - return { - get exitCode() { return _exitCode }, - exited, - stdout: proc.stdout ? (_stream.Readable.toWeb(proc.stdout) as ReadableStream) : undefined, - stderr: proc.stderr ? (_stream.Readable.toWeb(proc.stderr) as ReadableStream) : undefined, - stdin: proc.stdin, - kill(sig?: NodeJS.Signals) { try { proc.kill(sig) } catch {} }, - pid: proc.pid, - } -} - -function _spawnShim(cmdOrOpts: string[] | AnyRecord, optsArg?: AnyRecord) { - const isObj = !Array.isArray(cmdOrOpts) - const cmd: string[] = isObj ? (cmdOrOpts as AnyRecord)["cmd"] as string[] : (cmdOrOpts as string[]) - const o: AnyRecord = isObj ? (cmdOrOpts as AnyRecord) : (optsArg ?? {}) - const [bin, ...args] = cmd - const proc = _cp.spawn(bin, args, { - cwd: o["cwd"] as string | undefined, - env: o["env"] as NodeJS.ProcessEnv | undefined, - stdio: [(o["stdin"] ?? "pipe") as "pipe", (o["stdout"] ?? "pipe") as "pipe", (o["stderr"] ?? "pipe") as "pipe"], - }) - return _makeProc(proc) -} - -function _spawnSyncShim(cmdOrOpts: string[] | AnyRecord) { - const isObj = !Array.isArray(cmdOrOpts) - const cmd: string[] = isObj ? (cmdOrOpts as AnyRecord)["cmd"] as string[] : (cmdOrOpts as string[]) - const o: AnyRecord = isObj ? (cmdOrOpts as AnyRecord) : {} - const [bin, ...args] = cmd - const r = _cp.spawnSync(bin, args, { - cwd: o["cwd"] as string | undefined, - env: o["env"] as NodeJS.ProcessEnv | undefined, - stdio: ["pipe", "pipe", "pipe"], - }) - return { exitCode: r.status ?? 1, stdout: r.stdout, stderr: r.stderr } -} - -if (!globalThis.Bun) { - // @ts-expect-error – intentional globalThis shim for Electron/Node compat - globalThis.Bun = { - spawn: _spawnShim as unknown as typeof globalThis.Bun.spawn, - spawnSync: _spawnSyncShim as unknown as typeof globalThis.Bun.spawnSync, - env: process.env, - version: "0.0.0-node-shim", - } -} diff --git a/src/features/team-mode/team-worktree/cleanup.ts b/src/features/team-mode/team-worktree/cleanup.ts index 673ecebc1..72649cc31 100644 --- a/src/features/team-mode/team-worktree/cleanup.ts +++ b/src/features/team-mode/team-worktree/cleanup.ts @@ -2,9 +2,10 @@ import fs from "node:fs/promises" import path from "node:path" import type { TeamModeConfig } from "./manager" +import { spawn as bunSpawn } from "../../../shared/bun-spawn-shim" async function runGit(args: string[]): Promise<{ code: number; stderr: string }> { - const process = Bun.spawn({ cmd: ["git", ...args], stdout: "pipe", stderr: "pipe" }) + const process = bunSpawn({ cmd: ["git", ...args], stdout: "pipe", stderr: "pipe" }) const [exitCode, stderrText] = await Promise.all([process.exited, new Response(process.stderr).text()]) return { code: exitCode, stderr: stderrText } } @@ -12,7 +13,7 @@ async function runGit(args: string[]): Promise<{ code: number; stderr: string }> export async function removeWorktree(worktreePath: string): Promise { await fs.rm(worktreePath, { recursive: true, force: true }) - const rootLookup = await Bun.spawn({ + const rootLookup = bunSpawn({ cmd: ["git", "-C", worktreePath, "rev-parse", "--show-superproject-working-tree"], stdout: "pipe", stderr: "pipe", diff --git a/src/features/team-mode/team-worktree/manager.ts b/src/features/team-mode/team-worktree/manager.ts index 359df4cd0..0f01bed71 100644 --- a/src/features/team-mode/team-worktree/manager.ts +++ b/src/features/team-mode/team-worktree/manager.ts @@ -1,4 +1,5 @@ import path from "node:path" +import { spawn as bunSpawn } from "../../../shared/bun-spawn-shim" export type TeamModeConfig = { worktreeBaseDir?: string @@ -16,7 +17,7 @@ function countParentSegments(spec: string): number { } async function runGit(args: string[], cwd?: string): Promise<{ code: number; stderr: string }> { - const process = Bun.spawn({ cmd: ["git", ...args], cwd, stdout: "pipe", stderr: "pipe" }) + const process = bunSpawn({ cmd: ["git", ...args], cwd, stdout: "pipe", stderr: "pipe" }) const [exitCode, stderrBytes] = await Promise.all([process.exited, new Response(process.stderr).text()]) return { code: exitCode, stderr: stderrBytes } } diff --git a/src/features/tmux-subagent/pane-state-querier.ts b/src/features/tmux-subagent/pane-state-querier.ts index e2ac9bfd1..0d01b03c0 100644 --- a/src/features/tmux-subagent/pane-state-querier.ts +++ b/src/features/tmux-subagent/pane-state-querier.ts @@ -1,4 +1,4 @@ -import { spawn } from "bun" +import { spawn } from "../../shared/bun-spawn-shim" import type { WindowState, TmuxPaneInfo } from "./types" import { parsePaneStateOutput } from "./pane-state-parser" import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver" diff --git a/src/hooks/comment-checker/cli.ts b/src/hooks/comment-checker/cli.ts index e0ca21475..14a128d49 100644 --- a/src/hooks/comment-checker/cli.ts +++ b/src/hooks/comment-checker/cli.ts @@ -1,4 +1,4 @@ -import { spawn } from "bun" +import { spawn } from "../../shared/bun-spawn-shim" import { createRequire } from "module" import { dirname, join } from "path" import { existsSync } from "fs" diff --git a/src/index.ts b/src/index.ts index 6ac93ece2..a5f549d39 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -import "./electron-compat" import { initConfigContext } from "./cli/config-manager/config-context" import type { Hooks, Plugin, PluginModule } from "@opencode-ai/plugin" diff --git a/src/openclaw/dispatcher.ts b/src/openclaw/dispatcher.ts index 5971f371d..97643958f 100644 --- a/src/openclaw/dispatcher.ts +++ b/src/openclaw/dispatcher.ts @@ -1,4 +1,4 @@ -import { spawn } from "bun" +import { spawn } from "../shared/bun-spawn-shim" import { validateGatewayUrl } from "./gateway-url-validation" import type { OpenClawGateway, WakeResult } from "./types" diff --git a/src/openclaw/reply-listener-process.ts b/src/openclaw/reply-listener-process.ts index f6309f168..305601edd 100644 --- a/src/openclaw/reply-listener-process.ts +++ b/src/openclaw/reply-listener-process.ts @@ -1,5 +1,5 @@ import { readFileSync } from "fs" -import { spawn } from "bun" +import { spawn } from "../shared/bun-spawn-shim" export const REPLY_LISTENER_DAEMON_IDENTITY_MARKER = "--openclaw-reply-listener-daemon" diff --git a/src/openclaw/reply-listener-spawn.ts b/src/openclaw/reply-listener-spawn.ts index 1cd0a1818..9d6b6cfbb 100644 --- a/src/openclaw/reply-listener-spawn.ts +++ b/src/openclaw/reply-listener-spawn.ts @@ -1,4 +1,4 @@ -import { spawn } from "bun" +import { spawn } from "../shared/bun-spawn-shim" import { createReplyListenerDaemonEnv, REPLY_LISTENER_DAEMON_IDENTITY_MARKER, diff --git a/src/openclaw/tmux.ts b/src/openclaw/tmux.ts index 9bdb6212a..47b04c45a 100644 --- a/src/openclaw/tmux.ts +++ b/src/openclaw/tmux.ts @@ -1,4 +1,4 @@ -import { spawn } from "bun" +import { spawn } from "../shared/bun-spawn-shim" export function getCurrentTmuxSession(): string | null { const env = process.env.TMUX diff --git a/src/shared/binary-downloader.ts b/src/shared/binary-downloader.ts index bb6918c30..16a8ff60b 100644 --- a/src/shared/binary-downloader.ts +++ b/src/shared/binary-downloader.ts @@ -1,6 +1,6 @@ import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs"; import * as path from "node:path"; -import { spawn } from "bun"; +import { spawn } from "./bun-spawn-shim"; import { validateArchiveEntries, type ArchiveEntry } from "./archive-entry-validator"; import { extractZip } from "./zip-extractor"; diff --git a/src/shared/bun-spawn-shim.ts b/src/shared/bun-spawn-shim.ts new file mode 100644 index 000000000..bab4ac945 --- /dev/null +++ b/src/shared/bun-spawn-shim.ts @@ -0,0 +1,68 @@ +/** + * Node/Electron-compatible spawn shim. + * + * Replaces direct `import { spawn } from "bun"` throughout the codebase so + * that `bun build --target bun` no longer emits top-level + * var { spawn } = globalThis.Bun; + * destructures that crash on Node/Electron (where globalThis.Bun is undefined). + * + * On real Bun runtime: delegates straight to Bun.spawn / Bun.spawnSync. + * On Node/Electron: backed by node:child_process (via static ESM imports + * which are safe in both runtimes). + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from "node:child_process" +import { Readable } from "node:stream" + +const IS_BUN = typeof globalThis.Bun !== "undefined" + +function _resolveCmd(cmdOrOpts: any, optsArg?: any): { cmd: string[]; opts: any } { + const isObj = !Array.isArray(cmdOrOpts) + return { + cmd: isObj ? (cmdOrOpts as any).cmd : (cmdOrOpts as string[]), + opts: isObj ? cmdOrOpts : (optsArg ?? {}), + } +} + +function _wrapNodeProc(proc: ReturnType): any { + let code: number | null = null + const exited = new Promise((resolve) => { + proc.on("exit", (c) => { code = c ?? 1; resolve(code) }) + proc.on("error", () => { if (code === null) { code = 1; resolve(1) } }) + }) + return { + get exitCode() { return code }, + exited, + stdout: proc.stdout ? Readable.toWeb(proc.stdout) as ReadableStream : undefined, + stderr: proc.stderr ? Readable.toWeb(proc.stderr) as ReadableStream : undefined, + stdin: proc.stdin, + kill(s?: NodeJS.Signals) { try { proc.kill(s) } catch {} }, + pid: proc.pid, + } +} + +export function spawn(cmdOrOpts: any, opts?: any): any { + if (IS_BUN) return (globalThis.Bun as any).spawn(cmdOrOpts, opts) + const { cmd, opts: o } = _resolveCmd(cmdOrOpts, opts) + const [bin, ...args] = cmd + const proc = nodeSpawn(bin, args, { + cwd: o.cwd as string | undefined, + env: o.env as NodeJS.ProcessEnv | undefined, + stdio: [(o.stdin ?? "pipe") as any, (o.stdout ?? "pipe") as any, (o.stderr ?? "pipe") as any], + }) + return _wrapNodeProc(proc) +} + +export function spawnSync(cmdOrOpts: any, _opts?: any): any { + if (IS_BUN) return (globalThis.Bun as any).spawnSync(cmdOrOpts) + const { cmd, opts: o } = _resolveCmd(cmdOrOpts) + const [bin, ...args] = cmd + const r = nodeSpawnSync(bin, args, { + cwd: o.cwd as string | undefined, + env: o.env as NodeJS.ProcessEnv | undefined, + stdio: ["pipe", "pipe", "pipe"], + }) + return { exitCode: r.status ?? 1, stdout: r.stdout, stderr: r.stderr, success: (r.status ?? 1) === 0, pid: -1 } +} diff --git a/src/shared/spawn-with-windows-hide.ts b/src/shared/spawn-with-windows-hide.ts index 7da9ed086..872c8deeb 100644 --- a/src/shared/spawn-with-windows-hide.ts +++ b/src/shared/spawn-with-windows-hide.ts @@ -1,4 +1,4 @@ -import { spawn as bunSpawn } from "bun" +import { spawn as bunSpawn } from "./bun-spawn-shim" import { spawn as nodeSpawn, type ChildProcess } from "node:child_process" import { Readable } from "node:stream" diff --git a/src/shared/tmux/tmux-utils/layout.ts b/src/shared/tmux/tmux-utils/layout.ts index 5ac82ee58..259498899 100644 --- a/src/shared/tmux/tmux-utils/layout.ts +++ b/src/shared/tmux/tmux-utils/layout.ts @@ -1,4 +1,4 @@ -import { spawn } from "bun" +import { spawn } from "../../bun-spawn-shim" import type { TmuxLayout } from "../../../config/schema" import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" diff --git a/src/shared/tmux/tmux-utils/pane-dimensions.ts b/src/shared/tmux/tmux-utils/pane-dimensions.ts index a11ad2602..d16238e85 100644 --- a/src/shared/tmux/tmux-utils/pane-dimensions.ts +++ b/src/shared/tmux/tmux-utils/pane-dimensions.ts @@ -1,4 +1,4 @@ -import { spawn } from "bun" +import { spawn } from "../../bun-spawn-shim" import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" export interface PaneDimensions { diff --git a/src/shared/tmux/tmux-utils/pane-replace.ts b/src/shared/tmux/tmux-utils/pane-replace.ts index 271ad79eb..12ee33116 100644 --- a/src/shared/tmux/tmux-utils/pane-replace.ts +++ b/src/shared/tmux/tmux-utils/pane-replace.ts @@ -1,4 +1,4 @@ -import { spawn } from "bun" +import { spawn } from "../../bun-spawn-shim" import type { TmuxConfig } from "../../../config/schema" import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" import type { SpawnPaneResult } from "../types" diff --git a/src/shared/tmux/tmux-utils/pane-spawn.ts b/src/shared/tmux/tmux-utils/pane-spawn.ts index 2713eafbc..779da3031 100644 --- a/src/shared/tmux/tmux-utils/pane-spawn.ts +++ b/src/shared/tmux/tmux-utils/pane-spawn.ts @@ -1,4 +1,4 @@ -import { spawn } from "bun" +import { spawn } from "../../bun-spawn-shim" import type { TmuxConfig } from "../../../config/schema" import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" import type { SpawnPaneResult } from "../types" diff --git a/src/shared/tmux/tmux-utils/session-spawn.ts b/src/shared/tmux/tmux-utils/session-spawn.ts index a6fd15d2d..f5f3bbf7e 100644 --- a/src/shared/tmux/tmux-utils/session-spawn.ts +++ b/src/shared/tmux/tmux-utils/session-spawn.ts @@ -1,4 +1,4 @@ -import { spawn } from "bun" +import { spawn } from "../../bun-spawn-shim" import type { TmuxConfig } from "../../../config/schema" import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" import type { SpawnPaneResult } from "../types" diff --git a/src/shared/tmux/tmux-utils/spawn-process.ts b/src/shared/tmux/tmux-utils/spawn-process.ts index c75826cab..f1bb66d32 100644 --- a/src/shared/tmux/tmux-utils/spawn-process.ts +++ b/src/shared/tmux/tmux-utils/spawn-process.ts @@ -1 +1 @@ -export { spawn } from "bun" +export { spawn } from "../../bun-spawn-shim" diff --git a/src/shared/tmux/tmux-utils/window-spawn.ts b/src/shared/tmux/tmux-utils/window-spawn.ts index 45c0ee315..222dd2de9 100644 --- a/src/shared/tmux/tmux-utils/window-spawn.ts +++ b/src/shared/tmux/tmux-utils/window-spawn.ts @@ -1,4 +1,4 @@ -import { spawn } from "bun" +import { spawn } from "../../bun-spawn-shim" import type { TmuxConfig } from "../../../config/schema" import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" import type { SpawnPaneResult } from "../types" diff --git a/src/shared/zip-entry-listing/powershell-zip-entry-listing.ts b/src/shared/zip-entry-listing/powershell-zip-entry-listing.ts index 9169f510b..a77213899 100644 --- a/src/shared/zip-entry-listing/powershell-zip-entry-listing.ts +++ b/src/shared/zip-entry-listing/powershell-zip-entry-listing.ts @@ -1,4 +1,4 @@ -import { spawn } from "bun" +import { spawn } from "../bun-spawn-shim" import type { ArchiveEntry } from "../archive-entry-validator" diff --git a/src/shared/zip-entry-listing/python-zip-entry-listing.ts b/src/shared/zip-entry-listing/python-zip-entry-listing.ts index 8c94442aa..4cdd71610 100644 --- a/src/shared/zip-entry-listing/python-zip-entry-listing.ts +++ b/src/shared/zip-entry-listing/python-zip-entry-listing.ts @@ -1,4 +1,4 @@ -import { spawn, spawnSync } from "bun" +import { spawn, spawnSync } from "../bun-spawn-shim" import type { ArchiveEntry } from "../archive-entry-validator" diff --git a/src/shared/zip-entry-listing/read-zip-symlink-target.ts b/src/shared/zip-entry-listing/read-zip-symlink-target.ts index 59eb6098c..2b6b9ab8d 100644 --- a/src/shared/zip-entry-listing/read-zip-symlink-target.ts +++ b/src/shared/zip-entry-listing/read-zip-symlink-target.ts @@ -1,4 +1,4 @@ -import { spawn } from "bun" +import { spawn } from "../bun-spawn-shim" export async function readZipSymlinkTarget( archivePath: string, diff --git a/src/shared/zip-entry-listing/tar-zip-entry-listing.ts b/src/shared/zip-entry-listing/tar-zip-entry-listing.ts index 10b231905..f346b6552 100644 --- a/src/shared/zip-entry-listing/tar-zip-entry-listing.ts +++ b/src/shared/zip-entry-listing/tar-zip-entry-listing.ts @@ -1,4 +1,4 @@ -import { spawn } from "bun" +import { spawn } from "../bun-spawn-shim" import type { ArchiveEntry } from "../archive-entry-validator" import { log } from "../logger" diff --git a/src/shared/zip-entry-listing/zipinfo-zip-entry-listing.ts b/src/shared/zip-entry-listing/zipinfo-zip-entry-listing.ts index 2fd638525..926e8b5da 100644 --- a/src/shared/zip-entry-listing/zipinfo-zip-entry-listing.ts +++ b/src/shared/zip-entry-listing/zipinfo-zip-entry-listing.ts @@ -1,4 +1,4 @@ -import { spawn, spawnSync } from "bun" +import { spawn, spawnSync } from "../bun-spawn-shim" import type { ArchiveEntry } from "../archive-entry-validator" import { readZipSymlinkTarget } from "./read-zip-symlink-target" diff --git a/src/shared/zip-extractor.ts b/src/shared/zip-extractor.ts index 77ac26b3d..cdc61fecc 100644 --- a/src/shared/zip-extractor.ts +++ b/src/shared/zip-extractor.ts @@ -1,4 +1,4 @@ -import { spawn, spawnSync } from "bun" +import { spawn, spawnSync } from "./bun-spawn-shim" import { release } from "os" import { validateArchiveEntries } from "./archive-entry-validator" diff --git a/src/tools/ast-grep/cli.ts b/src/tools/ast-grep/cli.ts index 86dc211ee..2d76975f7 100644 --- a/src/tools/ast-grep/cli.ts +++ b/src/tools/ast-grep/cli.ts @@ -1,4 +1,4 @@ -import { spawn } from "bun" +import { spawn } from "../../shared/bun-spawn-shim" import { existsSync } from "fs" import { getSgCliPath, diff --git a/src/tools/glob/cli.ts b/src/tools/glob/cli.ts index 996133383..9ba34c32a 100644 --- a/src/tools/glob/cli.ts +++ b/src/tools/glob/cli.ts @@ -1,5 +1,5 @@ import { resolve } from "node:path" -import { spawn } from "bun" +import { spawn } from "../../shared/bun-spawn-shim" import { resolveGrepCli, type GrepBackend, diff --git a/src/tools/grep/cli.ts b/src/tools/grep/cli.ts index 9f55b1d27..4b9684c66 100644 --- a/src/tools/grep/cli.ts +++ b/src/tools/grep/cli.ts @@ -1,4 +1,4 @@ -import { spawn } from "bun" +import { spawn } from "../../shared/bun-spawn-shim" import { resolveGrepCli, type ResolvedCli, diff --git a/src/tools/hashline-edit/formatter-trigger.ts b/src/tools/hashline-edit/formatter-trigger.ts index 370015844..72a755e63 100644 --- a/src/tools/hashline-edit/formatter-trigger.ts +++ b/src/tools/hashline-edit/formatter-trigger.ts @@ -1,5 +1,6 @@ import path from "path" import { log } from "../../shared" +import { spawn as bunSpawn } from "../../shared/bun-spawn-shim" interface FormatterConfig { disabled?: boolean @@ -106,7 +107,7 @@ export async function runFormattersForFile( const cmd = buildFormatterCommand(formatter.command, filePath) try { log("[formatter-trigger] Running formatter", { command: cmd, file: filePath }) - const proc = Bun.spawn(cmd, { + const proc = bunSpawn(cmd, { cwd: directory, env: { ...process.env, ...formatter.environment }, stdout: "ignore", diff --git a/src/tools/interactive-bash/tmux-path-resolver.ts b/src/tools/interactive-bash/tmux-path-resolver.ts index 1aa346235..1187fdef0 100644 --- a/src/tools/interactive-bash/tmux-path-resolver.ts +++ b/src/tools/interactive-bash/tmux-path-resolver.ts @@ -1,4 +1,4 @@ -import { spawn } from "bun" +import { spawn } from "../../shared/bun-spawn-shim" let tmuxPath: string | null = null let initPromise: Promise | null = null diff --git a/src/tools/lsp/lsp-process.ts b/src/tools/lsp/lsp-process.ts index 3f7b769a2..91d940b94 100644 --- a/src/tools/lsp/lsp-process.ts +++ b/src/tools/lsp/lsp-process.ts @@ -1,4 +1,4 @@ -import { spawn as bunSpawn } from "bun" +import { spawn as bunSpawn } from "../../shared/bun-spawn-shim" import { spawn as nodeSpawn, type ChildProcess } from "node:child_process" import { existsSync, statSync } from "fs" import { log } from "../../shared/logger" From f8defe2588ba31fb28385d15b286213ba3966ee5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 5 May 2026 22:59:43 +0900 Subject: [PATCH 3/5] fix(node-runtime-compat): harden Bun spawn shim --- package.json | 3 +- script/patch-node-require-shim.ts | 27 +++ src/electron-compat.test.ts | 23 --- src/shared/bun-spawn-shim.test.ts | 64 +++++++ src/shared/bun-spawn-shim.ts | 198 +++++++++++++++------ src/shared/dist-bundle-bun-globals.test.ts | 65 +++++++ 6 files changed, 306 insertions(+), 74 deletions(-) create mode 100644 script/patch-node-require-shim.ts delete mode 100644 src/electron-compat.test.ts create mode 100644 src/shared/bun-spawn-shim.test.ts create mode 100644 src/shared/dist-bundle-bun-globals.test.ts diff --git a/package.json b/package.json index ffd572371..3f7156672 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "./schema.json": "./dist/oh-my-opencode.schema.json" }, "scripts": { - "build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi --external zod && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema", + "build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi --external zod && bun run build:node-require-shim && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema", + "build:node-require-shim": "bun run script/patch-node-require-shim.ts", "build:all": "bun run build && bun run build:binaries", "build:binaries": "bun run script/build-binaries.ts", "build:schema": "bun run script/build-schema.ts", diff --git a/script/patch-node-require-shim.ts b/script/patch-node-require-shim.ts new file mode 100644 index 000000000..a2e39f0a5 --- /dev/null +++ b/script/patch-node-require-shim.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env bun + +import { readFileSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const DIST_PATH = join(SCRIPT_DIR, "..", "dist", "index.js") +const IMPORT_LINE = 'import { createRequire as __omoCreateRequire } from "node:module";' +const BUN_REQUIRE_LINE = "var __require = import.meta.require;" +const NODE_SAFE_REQUIRE_LINE = 'var __require = typeof import.meta.require === "function" ? import.meta.require : __omoCreateRequire(import.meta.url);' + +const original = readFileSync(DIST_PATH, "utf-8") + +if (original.includes(NODE_SAFE_REQUIRE_LINE)) { + console.log("Node/Electron require shim already present in dist/index.js, skipping.") + process.exit(0) +} + +if (!original.includes(BUN_REQUIRE_LINE)) { + throw new Error(`Expected Bun require helper not found in ${DIST_PATH}`) +} + +const patched = original.replace(BUN_REQUIRE_LINE, `${IMPORT_LINE}\n${NODE_SAFE_REQUIRE_LINE}`) + +writeFileSync(DIST_PATH, patched, "utf-8") +console.log("Patched Node/Electron require shim in dist/index.js") diff --git a/src/electron-compat.test.ts b/src/electron-compat.test.ts deleted file mode 100644 index 1f91f4531..000000000 --- a/src/electron-compat.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, test, expect } from "bun:test" - -describe("electron-compat: dist/index.js globalThis.Bun safety", () => { - test("#given dist/index.js #then no top-level globalThis.Bun destructures exist", async () => { - // This guards the fix for https://github.com/code-yeongyu/oh-my-openagent/issues/3797. - // Previously 'bun build --target bun' emitted 25 top-level - // var { spawn } = globalThis.Bun; - // statements that crashed on Node/Electron where globalThis.Bun is undefined. - // The fix replaces all 'from "bun"' spawn imports with a node:child_process - // shim so the bundler no longer emits these destructures. - const dist = await Bun.file("dist/index.js").text() - const destructures = (dist.match(/\} = globalThis\.Bun;/g) ?? []).length - expect(destructures).toBe(0) - }) - - test("#given Bun runtime #when shim module is loaded #then globalThis.Bun remains real Bun", () => { - // On real Bun runtime, globalThis.Bun must not be overwritten by any shim - expect(globalThis.Bun).toBeDefined() - expect(typeof globalThis.Bun.spawn).toBe("function") - // The shim version string is only set when globalThis.Bun was absent - expect(globalThis.Bun.version).not.toBe("0.0.0-node-shim") - }) -}) diff --git a/src/shared/bun-spawn-shim.test.ts b/src/shared/bun-spawn-shim.test.ts new file mode 100644 index 000000000..06905bd76 --- /dev/null +++ b/src/shared/bun-spawn-shim.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test" + +import { spawn, spawnSync } from "./bun-spawn-shim" + +describe("bun-spawn-shim", () => { + test("#given array command #when spawn exits successfully #then exited resolves to zero", async () => { + const proc = spawn(["bun", "--version"], { stdout: "pipe", stderr: "pipe" }) + + const exitCode = await proc.exited + + expect(exitCode).toBe(0) + expect(proc.exitCode).toBe(0) + }) + + test("#given piped stdout #when spawn writes output #then stdout is readable", async () => { + const proc = spawn(["bun", "--print", "'shim-ok'"], { stdout: "pipe", stderr: "pipe" }) + + const [exitCode, stdout] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + ]) + + expect(exitCode).toBe(0) + expect(stdout.trim()).toBe("shim-ok") + }) + + test("#given detached object command #when spawn starts #then process exposes daemon controls", async () => { + const proc = spawn({ + cmd: ["bun", "--print", "'detached-ok'"], + stdout: "pipe", + stderr: "pipe", + detached: true, + }) + + proc.unref() + const exitCode = await proc.exited + + expect(exitCode).toBe(0) + expect(typeof proc.ref).toBe("function") + expect(typeof proc.unref).toBe("function") + expect(proc.pid).toBeGreaterThan(0) + }) + + test("#given stdio tuple #when spawn runs #then ignored streams are still safe to read", async () => { + const proc = spawn({ + cmd: ["bun", "--print", "'ignored'"], + stdio: ["ignore", "ignore", "ignore"], + }) + + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + + expect(exitCode).toBe(0) + expect(stdout).toBe("") + }) + + test("#given spawnSync command #when it writes output #then stdout and exit code match", () => { + const result = spawnSync(["bun", "--print", "'sync-ok'"], { stdout: "pipe", stderr: "pipe" }) + + expect(result.exitCode).toBe(0) + expect(result.success).toBe(true) + expect(Buffer.from(result.stdout).toString().trim()).toBe("sync-ok") + }) +}) diff --git a/src/shared/bun-spawn-shim.ts b/src/shared/bun-spawn-shim.ts index bab4ac945..158894f2d 100644 --- a/src/shared/bun-spawn-shim.ts +++ b/src/shared/bun-spawn-shim.ts @@ -1,68 +1,166 @@ -/** - * Node/Electron-compatible spawn shim. - * - * Replaces direct `import { spawn } from "bun"` throughout the codebase so - * that `bun build --target bun` no longer emits top-level - * var { spawn } = globalThis.Bun; - * destructures that crash on Node/Electron (where globalThis.Bun is undefined). - * - * On real Bun runtime: delegates straight to Bun.spawn / Bun.spawnSync. - * On Node/Electron: backed by node:child_process (via static ESM imports - * which are safe in both runtimes). - */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ - import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from "node:child_process" -import { Readable } from "node:stream" +import { Readable, Writable } from "node:stream" -const IS_BUN = typeof globalThis.Bun !== "undefined" +type AnyRecord = Record +type StdioMode = "pipe" | "inherit" | "ignore" +type StdioTuple = [StdioMode, StdioMode, StdioMode] -function _resolveCmd(cmdOrOpts: any, optsArg?: any): { cmd: string[]; opts: any } { - const isObj = !Array.isArray(cmdOrOpts) - return { - cmd: isObj ? (cmdOrOpts as any).cmd : (cmdOrOpts as string[]), - opts: isObj ? cmdOrOpts : (optsArg ?? {}), - } +export interface SpawnOptions { + cmd?: string[] + cwd?: string + env?: NodeJS.ProcessEnv + stdin?: StdioMode + stdout?: StdioMode + stderr?: StdioMode + stdio?: StdioTuple + detached?: boolean } -function _wrapNodeProc(proc: ReturnType): any { - let code: number | null = null - const exited = new Promise((resolve) => { - proc.on("exit", (c) => { code = c ?? 1; resolve(code) }) - proc.on("error", () => { if (code === null) { code = 1; resolve(1) } }) +export interface SpawnedProcess { + readonly exitCode: number | null + readonly exited: Promise + readonly stdout: ReadableStream + readonly stderr: ReadableStream + readonly stdin: NodeJS.WritableStream + readonly pid: number | undefined + kill(signal?: NodeJS.Signals): void + ref(): void + unref(): void +} + +export interface SpawnSyncResult { + readonly exitCode: number + readonly stdout: Buffer + readonly stderr: Buffer + readonly success: boolean + readonly pid: number +} + +type BunSpawnRuntime = { + spawn(command: string[], options?: SpawnOptions): SpawnedProcess + spawn(options: SpawnOptions & { cmd: string[] }): SpawnedProcess + spawnSync(command: string[], options?: SpawnOptions): SpawnSyncResult + spawnSync(options: SpawnOptions & { cmd: string[] }): SpawnSyncResult +} + +const runtime = globalThis as typeof globalThis & { Bun?: BunSpawnRuntime } +const IS_BUN = typeof runtime.Bun !== "undefined" + +function emptyReadableStream(): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.close() + }, }) +} + +function toReadableStream(stream: NodeJS.ReadableStream | null): ReadableStream { + if (!stream) return emptyReadableStream() + + return Readable.toWeb(stream as Readable) as ReadableStream +} + +function emptyWritableStream(): Writable { + return new Writable({ + write(_chunk, _encoding, callback) { + callback() + }, + }) +} + +function resolveCommand(cmdOrOpts: unknown, optsArg?: unknown): { cmd: string[]; opts: SpawnOptions } { + const isObj = !Array.isArray(cmdOrOpts) + const opts = isObj ? (cmdOrOpts as SpawnOptions) : ((optsArg ?? {}) as SpawnOptions) + return { - get exitCode() { return code }, - exited, - stdout: proc.stdout ? Readable.toWeb(proc.stdout) as ReadableStream : undefined, - stderr: proc.stderr ? Readable.toWeb(proc.stderr) as ReadableStream : undefined, - stdin: proc.stdin, - kill(s?: NodeJS.Signals) { try { proc.kill(s) } catch {} }, - pid: proc.pid, + cmd: isObj ? ((cmdOrOpts as AnyRecord).cmd as string[]) : (cmdOrOpts as string[]), + opts, } } -export function spawn(cmdOrOpts: any, opts?: any): any { - if (IS_BUN) return (globalThis.Bun as any).spawn(cmdOrOpts, opts) - const { cmd, opts: o } = _resolveCmd(cmdOrOpts, opts) +function resolveStdio(options: SpawnOptions): StdioTuple { + if (options.stdio) return options.stdio + + return [options.stdin ?? "pipe", options.stdout ?? "pipe", options.stderr ?? "pipe"] +} + +function wrapNodeProcess(proc: ReturnType): SpawnedProcess { + let exitCode: number | null = null + const exited = new Promise((resolve) => { + proc.on("exit", (code) => { + exitCode = code ?? 1 + resolve(exitCode) + }) + proc.on("error", () => { + if (exitCode === null) { + exitCode = 1 + resolve(1) + } + }) + }) + + return { + get exitCode() { + return exitCode + }, + exited, + stdout: toReadableStream(proc.stdout), + stderr: toReadableStream(proc.stderr), + stdin: proc.stdin ?? emptyWritableStream(), + kill(signal?: NodeJS.Signals) { + if (proc.killed || exitCode !== null) return + + try { + proc.kill(signal) + } catch (error) { + if (!String(error).includes("kill")) throw error + } + }, + pid: proc.pid, + ref() { + proc.ref() + }, + unref() { + proc.unref() + }, + } +} + +export function spawn(command: string[], options?: SpawnOptions): SpawnedProcess +export function spawn(options: SpawnOptions & { cmd: string[] }): SpawnedProcess +export function spawn(cmdOrOpts: unknown, opts?: unknown): SpawnedProcess { + if (IS_BUN) return runtime.Bun!.spawn(cmdOrOpts as string[] & SpawnOptions & { cmd: string[] }, opts as SpawnOptions) + + const { cmd, opts: options } = resolveCommand(cmdOrOpts, opts) const [bin, ...args] = cmd const proc = nodeSpawn(bin, args, { - cwd: o.cwd as string | undefined, - env: o.env as NodeJS.ProcessEnv | undefined, - stdio: [(o.stdin ?? "pipe") as any, (o.stdout ?? "pipe") as any, (o.stderr ?? "pipe") as any], + cwd: options.cwd, + env: options.env, + stdio: resolveStdio(options), + detached: options.detached, }) - return _wrapNodeProc(proc) + + return wrapNodeProcess(proc) } -export function spawnSync(cmdOrOpts: any, _opts?: any): any { - if (IS_BUN) return (globalThis.Bun as any).spawnSync(cmdOrOpts) - const { cmd, opts: o } = _resolveCmd(cmdOrOpts) +export function spawnSync(command: string[], options?: SpawnOptions): SpawnSyncResult +export function spawnSync(options: SpawnOptions & { cmd: string[] }): SpawnSyncResult +export function spawnSync(cmdOrOpts: unknown, opts?: unknown): SpawnSyncResult { + if (IS_BUN) return runtime.Bun!.spawnSync(cmdOrOpts as string[] & SpawnOptions & { cmd: string[] }, opts as SpawnOptions) + + const { cmd, opts: options } = resolveCommand(cmdOrOpts, opts) const [bin, ...args] = cmd - const r = nodeSpawnSync(bin, args, { - cwd: o.cwd as string | undefined, - env: o.env as NodeJS.ProcessEnv | undefined, - stdio: ["pipe", "pipe", "pipe"], + const result = nodeSpawnSync(bin, args, { + cwd: options.cwd, + env: options.env, + stdio: resolveStdio(options), }) - return { exitCode: r.status ?? 1, stdout: r.stdout, stderr: r.stderr, success: (r.status ?? 1) === 0, pid: -1 } + + return { + exitCode: result.status ?? 1, + stdout: result.stdout, + stderr: result.stderr, + success: (result.status ?? 1) === 0, + pid: -1, + } } diff --git a/src/shared/dist-bundle-bun-globals.test.ts b/src/shared/dist-bundle-bun-globals.test.ts new file mode 100644 index 000000000..4d8b7a1d8 --- /dev/null +++ b/src/shared/dist-bundle-bun-globals.test.ts @@ -0,0 +1,65 @@ +import { existsSync } from "node:fs" +import { describe, expect, test } from "bun:test" + +const DIST_INDEX = "dist/index.js" +const GLOBAL_BUN_DESTRUCTURE = /^\s*(?:var|let|const)\s*\{[^}]*\}\s*=\s*globalThis\.Bun/gm +const TOP_LEVEL_REQUIRE_CALL = "__require(" + +describe("dist bundle Bun globals", () => { + test.skipIf(!existsSync(DIST_INDEX))("#given dist bundle #when scanned #then no globalThis.Bun destructures remain", async () => { + const dist = await Bun.file(DIST_INDEX).text() + + const matches = dist.match(GLOBAL_BUN_DESTRUCTURE) ?? [] + + expect(matches).toEqual([]) + }) + + test.skipIf(!existsSync(DIST_INDEX))("#given dist bundle #when scanned #then no top-level __require call remains", async () => { + const dist = await Bun.file(DIST_INDEX).text() + const offending: string[] = [] + let depth = 0 + + for (const [index, line] of dist.split("\n").entries()) { + if (depth === 0 && line.includes(TOP_LEVEL_REQUIRE_CALL)) { + offending.push(`${index + 1}: ${line.trim()}`) + } + + for (const char of line) { + if (char === "{") { + depth += 1 + } else if (char === "}") { + depth -= 1 + if (depth < 0) depth = 0 + } + } + } + + expect(offending).toEqual([]) + }) + + test.skipIf(!existsSync(DIST_INDEX))("#given dist bundle #when imported under node --input-type=module #then it loads without error", async () => { + const node = Bun.which("node") + if (!node) return + + const proc = Bun.spawn({ + cmd: [node, "--input-type=module", "-e", "await import('./dist/index.js'); console.log('node-esm-load-ok')"], + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + }) + + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + const exitCode = await proc.exited + + expect({ + exitCode, + stdout: stdout.trim(), + stderr: stderr.trim(), + }).toEqual({ + exitCode: 0, + stdout: "node-esm-load-ok", + stderr: "", + }) + }) +}) From e9d7dca604c231f7320ccc49aad6e1c1c59613d8 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 5 May 2026 23:06:01 +0900 Subject: [PATCH 4/5] fix(session-notification): tolerate shell promises without nothrow --- src/hooks/session-notification-sender.ts | 29 ++++++++++++++++++------ 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/hooks/session-notification-sender.ts b/src/hooks/session-notification-sender.ts index 4d33bed77..8849e1af8 100644 --- a/src/hooks/session-notification-sender.ts +++ b/src/hooks/session-notification-sender.ts @@ -33,6 +33,21 @@ export function getDefaultSoundPath(platform: Platform): string { } } +type ShellCommand = Promise & { + quiet?: () => Promise + nothrow?: () => ShellCommand +} + +async function runQuietNothrow(command: ShellCommand): Promise { + const safeCommand = typeof command.nothrow === "function" ? command.nothrow() : command + if (typeof safeCommand.quiet === "function") { + await safeCommand.quiet() + return + } + + await safeCommand +} + export async function sendSessionNotification( ctx: PluginInput, platform: Platform, @@ -72,14 +87,14 @@ export async function sendSessionNotification( const escapedTitle = escapeAppleScriptText(title) const escapedMessage = escapeAppleScriptText(message) - await ctx.$`${osascriptPath} -e ${"display notification \"" + escapedMessage + "\" with title \"" + escapedTitle + "\""}`.nothrow().quiet() + await runQuietNothrow(ctx.$`${osascriptPath} -e ${"display notification \"" + escapedMessage + "\" with title \"" + escapedTitle + "\""}`) break } case "linux": { const notifySendPath = await getNotifySendPath() if (!notifySendPath) return - await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.nothrow().quiet() + await runQuietNothrow(ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`) break } case "win32": { @@ -87,7 +102,7 @@ export async function sendSessionNotification( if (!powershellPath) return const toastScript = buildWindowsToastScript(title, message) - await ctx.$`${powershellPath} -Command ${toastScript}`.nothrow().quiet() + await runQuietNothrow(ctx.$`${powershellPath} -Command ${toastScript}`) break } } @@ -102,17 +117,17 @@ export async function playSessionNotificationSound( case "darwin": { const afplayPath = await getAfplayPath() if (!afplayPath) return - ctx.$`${afplayPath} ${soundPath}`.nothrow().quiet() + await runQuietNothrow(ctx.$`${afplayPath} ${soundPath}`) break } case "linux": { const paplayPath = await getPaplayPath() if (paplayPath) { - ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.nothrow().quiet() + await runQuietNothrow(ctx.$`${paplayPath} ${soundPath} 2>/dev/null`) } else { const aplayPath = await getAplayPath() if (aplayPath) { - ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.nothrow().quiet() + await runQuietNothrow(ctx.$`${aplayPath} ${soundPath} 2>/dev/null`) } } break @@ -121,7 +136,7 @@ export async function playSessionNotificationSound( const powershellPath = await getPowershellPath() if (!powershellPath) return const escaped = escapePowerShellSingleQuotedText(soundPath) - ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + escaped + "').PlaySync()"}`.nothrow().quiet() + await runQuietNothrow(ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + escaped + "').PlaySync()"}`) break } } From 9dd0e147339382ea4f39434a5bce4a4219115fee Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 6 May 2026 13:28:21 +0900 Subject: [PATCH 5/5] fix(bun-spawn-shim): match Bun.spawn semantics in Node fallback Address cubic and oracle review feedback to make the Node/Electron fallback faithful to Bun.spawn behavior so cross-runtime callers behave identically. Changes: - resolveStdio() default stdio is now ["ignore", "pipe", "inherit"] to match Bun.spawn defaults (was ["pipe", "pipe", "pipe"]). Prevents hangs in dispatcher and on-complete-hook callers that did not explicitly set stdin and would otherwise wait forever for input on Node. - spawn-with-windows-hide.ts uses the same defaults so the Windows Node helper aligns with the rest of the shim. - wrapNodeProcess now rejects proc.exited with the original error on "error" events (previously swallowed the error and resolved to exit code 1, hiding ENOENT and friends). - spawnSync result returns the real result.pid instead of -1 and exposes stdout/stderr as Buffer | undefined to match Node's spawnSync result shape when those streams are not piped. Tests cover the new defaults, real pid surfacing, and missing executable error propagation. Refs cubic review and oracle audit on #3798. --- src/shared/bun-spawn-shim.test.ts | 29 ++++++++++++++++++++++++++- src/shared/bun-spawn-shim.ts | 18 ++++++++--------- src/shared/spawn-with-windows-hide.ts | 2 +- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/shared/bun-spawn-shim.test.ts b/src/shared/bun-spawn-shim.test.ts index 06905bd76..238cfb1b1 100644 --- a/src/shared/bun-spawn-shim.test.ts +++ b/src/shared/bun-spawn-shim.test.ts @@ -59,6 +59,33 @@ describe("bun-spawn-shim", () => { expect(result.exitCode).toBe(0) expect(result.success).toBe(true) - expect(Buffer.from(result.stdout).toString().trim()).toBe("sync-ok") + expect(result.stdout).toBeDefined() + expect(Buffer.from(result.stdout!).toString().trim()).toBe("sync-ok") + }) + + test("#given spawnSync command #when it completes #then result.pid is a positive number", () => { + const result = spawnSync(["bun", "--version"], { stdout: "pipe", stderr: "pipe" }) + + expect(result.pid).toBeGreaterThan(0) + }) + + test("#given default stdio #when child reads stdin #then it does not hang waiting for input", async () => { + const proc = spawn(["cat"], { stdout: "pipe", stderr: "pipe" }) + + const exitCode = await proc.exited + + expect(exitCode).toBe(0) + }) + + test("#given missing executable #when spawn invoked #then the error is surfaced to the caller", async () => { + let observedError: unknown + try { + const proc = spawn(["__omo-shim-missing-binary__"], { stdout: "pipe", stderr: "pipe" }) + await proc.exited + } catch (error) { + observedError = error + } + + expect(observedError).toBeDefined() }) }) diff --git a/src/shared/bun-spawn-shim.ts b/src/shared/bun-spawn-shim.ts index 158894f2d..de756793d 100644 --- a/src/shared/bun-spawn-shim.ts +++ b/src/shared/bun-spawn-shim.ts @@ -30,8 +30,8 @@ export interface SpawnedProcess { export interface SpawnSyncResult { readonly exitCode: number - readonly stdout: Buffer - readonly stderr: Buffer + readonly stdout: Buffer | undefined + readonly stderr: Buffer | undefined readonly success: boolean readonly pid: number } @@ -81,20 +81,20 @@ function resolveCommand(cmdOrOpts: unknown, optsArg?: unknown): { cmd: string[]; function resolveStdio(options: SpawnOptions): StdioTuple { if (options.stdio) return options.stdio - return [options.stdin ?? "pipe", options.stdout ?? "pipe", options.stderr ?? "pipe"] + return [options.stdin ?? "ignore", options.stdout ?? "pipe", options.stderr ?? "inherit"] } function wrapNodeProcess(proc: ReturnType): SpawnedProcess { let exitCode: number | null = null - const exited = new Promise((resolve) => { + const exited = new Promise((resolve, reject) => { proc.on("exit", (code) => { exitCode = code ?? 1 resolve(exitCode) }) - proc.on("error", () => { + proc.on("error", (error) => { if (exitCode === null) { exitCode = 1 - resolve(1) + reject(error) } }) }) @@ -158,9 +158,9 @@ export function spawnSync(cmdOrOpts: unknown, opts?: unknown): SpawnSyncResult { return { exitCode: result.status ?? 1, - stdout: result.stdout, - stderr: result.stderr, + stdout: result.stdout ?? undefined, + stderr: result.stderr ?? undefined, success: (result.status ?? 1) === 0, - pid: -1, + pid: result.pid ?? -1, } } diff --git a/src/shared/spawn-with-windows-hide.ts b/src/shared/spawn-with-windows-hide.ts index 872c8deeb..f6fec2a7e 100644 --- a/src/shared/spawn-with-windows-hide.ts +++ b/src/shared/spawn-with-windows-hide.ts @@ -75,7 +75,7 @@ export function spawnWithWindowsHide(command: string[], options: SpawnOptions): const proc = nodeSpawn(cmd, args, { cwd: options.cwd, env: options.env, - stdio: [options.stdin ?? "pipe", options.stdout ?? "pipe", options.stderr ?? "pipe"], + stdio: [options.stdin ?? "ignore", options.stdout ?? "pipe", options.stderr ?? "inherit"], windowsHide: true, shell: true, })