From 0dafc512a69a7f1bf4541659ffbff3bf22833bb5 Mon Sep 17 00:00:00 2001 From: hehuaiyu Date: Fri, 4 Sep 2026 14:28:00 +0800 Subject: [PATCH] fix(tooling): resolve Bun executable on Windows --- packages/electron/README.md | 4 +- packages/electron/scripts/ensure-electron.mjs | 17 +++-- .../electron/scripts/ensure-electron.test.mjs | 26 +++++++ packages/web/package.json | 2 +- packages/web/scripts/dev-server-watch.mjs | 76 +++++++++++++++++++ .../web/scripts/dev-server-watch.test.mjs | 60 +++++++++++++++ .../lib/opencode/server-startup-runtime.js | 2 +- scripts/dev-web-hmr.mjs | 29 +++++-- scripts/lib/bun-executable.mjs | 59 ++++++++++++++ scripts/lib/bun-executable.test.mjs | 70 +++++++++++++++++ scripts/run-isolated-tests.mjs | 10 ++- 11 files changed, 337 insertions(+), 18 deletions(-) create mode 100644 packages/web/scripts/dev-server-watch.mjs create mode 100644 packages/web/scripts/dev-server-watch.test.mjs create mode 100644 scripts/lib/bun-executable.mjs create mode 100644 scripts/lib/bun-executable.test.mjs diff --git a/packages/electron/README.md b/packages/electron/README.md index 41490145..6cc110f9 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -40,14 +40,14 @@ bun install bun run electron:dev ``` -`bun run electron:dev` starts the web dev server with HMR, then launches Electron against `packages/electron/main.mjs`. +`bun run electron:dev` starts the web dev server with HMR, then launches Electron against `packages/electron/main.mjs`. On Windows, the HMR launcher resolves npm's `bun.cmd` shim to the underlying `bun.exe` before spawning Bun child processes. The Electron workspace package trusts Electron's install script so `bun install` downloads the platform runtime in fresh checkouts and worktrees. Electron's postinstall (`node install.js`) is run by `bun install` with the system Node. Older Electron releases bundled `extract-zip@2.0.1`, which under Node 24 silently unpacked only the first entry of the Electron zip, leaving `dist/` without the binary and `path.txt` missing. Electron 43+ ships its own fixed extractor (`@electron-internal/extract-zip`), but to keep interrupted or wrong-architecture installs from blocking desktop work: - The root `postinstall` runs `ensure-electron.mjs --best-effort`, which detects an incomplete Electron install (missing binary, stale `dist/version`/`path.txt`, or a binary of the wrong architecture) and repairs it by re-running the postinstall under Bun (which extracts correctly), falling back to Node. -- `electron-dev.mjs` runs the same check (fail-fast, not best-effort) before launching, so `bun run electron:dev` self-heals even when an install was interrupted. +- `electron-dev.mjs` runs the same check (fail-fast, not best-effort) before launching, so `bun run electron:dev` self-heals even when an install was interrupted. On Windows, the repair resolves npm's `bun.cmd` shim to the underlying `bun.exe` before spawning it from Node. - The check can be run on demand with `bun run --cwd packages/electron ensure:electron`; set `ELECTRON_SKIP_BINARY_DOWNLOAD=1` to skip repair (e.g. CI without a network). - Unit tests in `scripts/ensure-electron.test.mjs` (run via `bun run --cwd packages/electron test:architecture`) cover healthy/missing/stale installs, wrong-architecture binaries, repair fallback, and `--best-effort`. diff --git a/packages/electron/scripts/ensure-electron.mjs b/packages/electron/scripts/ensure-electron.mjs index 57a12cda..affef54f 100644 --- a/packages/electron/scripts/ensure-electron.mjs +++ b/packages/electron/scripts/ensure-electron.mjs @@ -29,6 +29,8 @@ import path from 'node:path'; import { createRequire } from 'node:module'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { resolveBunExecutable } from '../../../scripts/lib/bun-executable.mjs'; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoElectronDir = path.resolve(__dirname, '..'); @@ -225,7 +227,11 @@ export function isComplete(electronDir, expected = expectedArch()) { return true; } -function resolveInstallCommands(env) { +function resolveInstallCommands(env, bunResolver = resolveBunExecutable) { + const normalize = (commands) => commands.map(([bin, args]) => [ + bin === 'bun' ? bunResolver({ env }) : bin, + args, + ]); if (env.OPENCHAMBER_ELECTRON_INSTALL_COMMANDS) { try { const parsed = JSON.parse(env.OPENCHAMBER_ELECTRON_INSTALL_COMMANDS); @@ -233,22 +239,22 @@ function resolveInstallCommands(env) { Array.isArray(parsed) && parsed.every((command) => Array.isArray(command) && typeof command[0] === 'string') ) { - return parsed; + return normalize(parsed); } } catch { // Fall through to the default commands. } } - return [ + return normalize([ ['bun', ['install.js']], ['node', ['install.js']], - ]; + ]); } export function repair(electronDir, options = {}) { const env = options.env ?? process.env; const runner = options.runner ?? spawnSync; - const commands = options.commands ?? resolveInstallCommands(env); + const commands = options.commands ?? resolveInstallCommands(env, options.bunResolver); // A partial extraction can leave stale entries (and a stale path.txt) that // a re-run would merge with. Start clean so the repair is deterministic. @@ -265,6 +271,7 @@ export function repair(electronDir, options = {}) { cwd: electronDir, stdio: options.stdio ?? 'inherit', env: { ...env, ELECTRON_SKIP_BINARY_DOWNLOAD: undefined }, + windowsHide: true, }); if (result.error) { console.warn(`[electron:ensure] could not run \`${label}\`: ${result.error.message}`); diff --git a/packages/electron/scripts/ensure-electron.test.mjs b/packages/electron/scripts/ensure-electron.test.mjs index 0bc3aa08..01f1f505 100644 --- a/packages/electron/scripts/ensure-electron.test.mjs +++ b/packages/electron/scripts/ensure-electron.test.mjs @@ -259,6 +259,32 @@ test('repair skips a command whose runner errors and keeps trying the rest', (t) assert.deepEqual(calls, commands); }); +test('repair resolves Bun before invoking the install command', (t) => { + const dir = withFixture(t); + const calls = []; + const resolvedBun = 'C:\\npm\\node_modules\\bun\\bin\\bun.exe'; + const result = repair(dir, { + env: { TEST_ENV: 'present' }, + bunResolver: ({ env }) => { + assert.equal(env.TEST_ENV, 'present'); + return resolvedBun; + }, + runner: (bin, args, options) => { + calls.push([bin, args, options.windowsHide]); + if (bin !== resolvedBun) return { status: 1 }; + const platform = platformPath(); + fs.mkdirSync(path.join(dir, 'dist', path.dirname(platform)), { recursive: true }); + fs.writeFileSync(path.join(dir, 'dist', 'version'), '41.2.1'); + fs.writeFileSync(path.join(dir, 'path.txt'), platform); + fs.writeFileSync(path.join(dir, 'dist', platform), headerBytesForArch(expectedArch())); + return { status: 0 }; + }, + }); + + assert.equal(result, true); + assert.deepEqual(calls, [[resolvedBun, ['install.js'], true]]); +}); + test('repair returns false when the runner reports a failure status for every command', (t) => { const dir = withFixture(t); const calls = []; diff --git a/packages/web/package.json b/packages/web/package.json index c25da06d..4c21d6a6 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -14,7 +14,7 @@ "scripts": { "dev": "bun run build:watch", "dev:server": "OPENCHAMBER_RELAY_HOST=${OPENCHAMBER_RELAY_HOST:-off} bun server/index.js --port ${OPENCHAMBER_PORT:-3001}", - "dev:server:watch": "OPENCHAMBER_RELAY_HOST=${OPENCHAMBER_RELAY_HOST:-off} nodemon --watch server --ext js --exec \"bun server/index.js --port ${OPENCHAMBER_PORT:-3001}\"", + "dev:server:watch": "node ./scripts/dev-server-watch.mjs", "build": "vite build", "build:watch": "vite build --watch", "type-check": "tsc --noEmit", diff --git a/packages/web/scripts/dev-server-watch.mjs b/packages/web/scripts/dev-server-watch.mjs new file mode 100644 index 00000000..c07fcb0b --- /dev/null +++ b/packages/web/scripts/dev-server-watch.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { resolveBunExecutable } from '../../../scripts/lib/bun-executable.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const webRoot = path.resolve(__dirname, '..'); + +export function createDevServerWatchCommand(options = {}) { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const bunExecutable = options.bunExecutable ?? resolveBunExecutable({ env, platform }); + const configuredPort = env.OPENCHAMBER_PORT?.trim(); + const port = configuredPort || '3001'; + + if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) { + throw new Error(`Invalid OPENCHAMBER_PORT: ${port}`); + } + + return { + command: bunExecutable, + args: platform === 'win32' + ? ['--watch', 'server/index.js', '--port', port] + : [ + 'x', + 'nodemon', + '--watch', + 'server', + '--ext', + 'js', + '--exec', + `bun server/index.js --port ${port}`, + ], + spawnOptions: { + cwd: webRoot, + stdio: 'inherit', + env: { + ...env, + OPENCHAMBER_RELAY_HOST: env.OPENCHAMBER_RELAY_HOST || 'off', + }, + windowsHide: true, + }, + }; +} + +export function runDevServerWatch(options = {}) { + const runner = options.runner ?? spawn; + const { command, args, spawnOptions } = createDevServerWatchCommand(options); + const child = runner(command, args, spawnOptions); + + child.on('error', (error) => { + console.error('[dev:web:server] Failed to start server watcher:', error); + process.exitCode = 1; + }); + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exitCode = code ?? 1; + }); + return child; +} + +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isMain) { + try { + runDevServerWatch(); + } catch (error) { + console.error('[dev:web:server] Unable to configure server watcher:', error); + process.exitCode = 1; + } +} diff --git a/packages/web/scripts/dev-server-watch.test.mjs b/packages/web/scripts/dev-server-watch.test.mjs new file mode 100644 index 00000000..b4c32628 --- /dev/null +++ b/packages/web/scripts/dev-server-watch.test.mjs @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import { createDevServerWatchCommand } from './dev-server-watch.mjs'; + +describe('createDevServerWatchCommand', () => { + it('uses the package default port and disables relay hosting by default', () => { + const command = createDevServerWatchCommand({ + platform: 'win32', + env: {}, + bunExecutable: '/opt/bun/bin/bun', + }); + + expect(command.command).toBe('/opt/bun/bin/bun'); + expect(command.args).toEqual(['--watch', 'server/index.js', '--port', '3001']); + expect(command.spawnOptions.env.OPENCHAMBER_RELAY_HOST).toBe('off'); + expect(command.spawnOptions.windowsHide).toBe(true); + }); + + it('passes the configured port and relay host to the Bun watcher', () => { + const command = createDevServerWatchCommand({ + platform: 'win32', + env: { + OPENCHAMBER_PORT: '58992', + OPENCHAMBER_RELAY_HOST: 'relay.example.test', + }, + bunExecutable: 'C:\\Tools\\Bun\\bun.exe', + }); + + expect(command.args).toEqual(['--watch', 'server/index.js', '--port', '58992']); + expect(command.spawnOptions.env.OPENCHAMBER_RELAY_HOST).toBe('relay.example.test'); + }); + + it('rejects an invalid configured port before spawning', () => { + expect(() => createDevServerWatchCommand({ + platform: 'win32', + env: { OPENCHAMBER_PORT: 'not-a-port' }, + bunExecutable: 'bun', + })).toThrow(/Invalid OPENCHAMBER_PORT/); + }); + + it('preserves the nodemon watcher command outside Windows', () => { + const command = createDevServerWatchCommand({ + platform: 'linux', + env: { OPENCHAMBER_PORT: '4200' }, + bunExecutable: '/opt/bun/bin/bun', + }); + + expect(command.command).toBe('/opt/bun/bin/bun'); + expect(command.args).toEqual([ + 'x', + 'nodemon', + '--watch', + 'server', + '--ext', + 'js', + '--exec', + 'bun server/index.js --port 4200', + ]); + }); +}); diff --git a/packages/web/server/lib/opencode/server-startup-runtime.js b/packages/web/server/lib/opencode/server-startup-runtime.js index cd945951..824650d3 100644 --- a/packages/web/server/lib/opencode/server-startup-runtime.js +++ b/packages/web/server/lib/opencode/server-startup-runtime.js @@ -138,7 +138,7 @@ export const createServerStartupRuntime = (dependencies) => { // Cover every signal a shell or dev harness may use to stop/restart us, so // the managed OpenCode child is always torn down gracefully instead of // orphaned: SIGINT/SIGQUIT (Ctrl+C/Ctrl+\), SIGTERM (kill/default), SIGHUP - // (terminal close), SIGUSR2 (nodemon restart for `dev:server:watch`). + // (terminal close), SIGUSR2 (nodemon restart outside Windows). process.on('SIGTERM', handleSignal); process.on('SIGINT', handleSignal); process.on('SIGQUIT', handleSignal); diff --git a/scripts/dev-web-hmr.mjs b/scripts/dev-web-hmr.mjs index 76999a33..a897e4a8 100644 --- a/scripts/dev-web-hmr.mjs +++ b/scripts/dev-web-hmr.mjs @@ -5,18 +5,23 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveBunExecutable } from './lib/bun-executable.mjs'; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, '..'); const useDetachedChildren = process.platform === 'darwin'; const webRoot = path.join(repoRoot, 'packages/web'); +const bunExecutable = resolveBunExecutable(); + function run(label, command, args, env = {}, options = {}) { return spawn(command, args, { cwd: options.cwd || repoRoot, stdio: 'inherit', env: { ...process.env, ...env }, detached: useDetachedChildren, + windowsHide: true, }).on('error', (error) => { console.error(`[dev:web:hmr] Failed to start ${label}:`, error); }); @@ -112,15 +117,25 @@ function clearViteCache() { clearViteCache(); -const api = run('api', 'bun', ['run', '--cwd', 'packages/web', 'dev:server:watch'], { - OPENCHAMBER_PORT: backendPort, - // Dev backends share the relay identity with the production instance; never - // let them capture the machine's relay host on their own. - OPENCHAMBER_RELAY_HOST: process.env.OPENCHAMBER_RELAY_HOST || 'off', -}); +const api = run( + 'api', + bunExecutable, + [ + 'run', + '--cwd', + 'packages/web', + 'dev:server:watch', + ], + { + OPENCHAMBER_PORT: backendPort, + // Dev backends share the relay identity with the production instance; never + // let them capture the machine's relay host on their own. + OPENCHAMBER_RELAY_HOST: process.env.OPENCHAMBER_RELAY_HOST || 'off', + }, +); const vite = run( 'vite', - 'bun', + bunExecutable, ['x', 'vite', '--force', '--host', hmrHost, '--port', uiPort, '--strictPort'], { OPENCHAMBER_PORT: backendPort, diff --git a/scripts/lib/bun-executable.mjs b/scripts/lib/bun-executable.mjs new file mode 100644 index 00000000..bab3448f --- /dev/null +++ b/scripts/lib/bun-executable.mjs @@ -0,0 +1,59 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +const findWindowsExecutable = (name, env) => { + const result = spawnSync('where.exe', [name], { + encoding: 'utf8', + env, + windowsHide: true, + }); + if (result.error || result.status !== 0) { + return null; + } + return String(result.stdout || '') + .split(/\r?\n/) + .map((entry) => entry.trim()) + .find(Boolean) ?? null; +}; + +export function resolveBunExecutable(options = {}) { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const fileExists = options.fileExists ?? existsSync; + const findOnPath = options.findOnPath ?? findWindowsExecutable; + const pathApi = platform === 'win32' ? path.win32 : path; + + const npmExecutable = env.npm_execpath?.trim(); + const npmExecutableName = platform === 'win32' ? 'bun.exe' : 'bun'; + if ( + npmExecutable && + pathApi.basename(npmExecutable).toLowerCase() === npmExecutableName && + fileExists(npmExecutable) + ) { + return npmExecutable; + } + + if (platform === 'win32') { + const directExecutable = findOnPath('bun.exe', env); + if (directExecutable) { + return directExecutable; + } + + const shim = findOnPath('bun.cmd', env); + if (shim) { + const executable = pathApi.resolve( + pathApi.dirname(shim), + 'node_modules', + 'bun', + 'bin', + 'bun.exe', + ); + if (fileExists(executable)) { + return executable; + } + } + } + + return 'bun'; +} diff --git a/scripts/lib/bun-executable.test.mjs b/scripts/lib/bun-executable.test.mjs new file mode 100644 index 00000000..8a0a5319 --- /dev/null +++ b/scripts/lib/bun-executable.test.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; + +import { resolveBunExecutable } from './bun-executable.mjs'; + +test('uses the Bun executable that launched the package script', () => { + const executable = '/opt/bun/bin/bun'; + assert.equal(resolveBunExecutable({ + platform: 'linux', + env: { npm_execpath: executable }, + fileExists: (candidate) => candidate === executable, + }), executable); +}); + +test('prefers bun.exe from PATH on Windows', () => { + const executable = 'C:\\Tools\\Bun\\bun.exe'; + assert.equal(resolveBunExecutable({ + platform: 'win32', + env: {}, + fileExists: () => false, + findOnPath: (name) => name === 'bun.exe' ? executable : null, + }), executable); +}); + +test('does not return an extensionless Windows npm shim', () => { + const shim = 'C:\\Users\\dev\\AppData\\Roaming\\npm\\bun'; + const executable = 'C:\\Tools\\Bun\\bun.exe'; + + assert.equal(resolveBunExecutable({ + platform: 'win32', + env: { npm_execpath: shim }, + fileExists: (candidate) => candidate === shim, + findOnPath: (name) => name === 'bun.exe' ? executable : null, + }), executable); +}); + +test('derives bun.exe when Windows PATH exposes only the npm bun.cmd shim', () => { + const shim = 'C:\\Users\\dev\\AppData\\Roaming\\npm\\bun.cmd'; + const executable = path.win32.resolve( + path.win32.dirname(shim), + 'node_modules', + 'bun', + 'bin', + 'bun.exe', + ); + const env = { Path: 'C:\\Users\\dev\\AppData\\Roaming\\npm' }; + const lookups = []; + + assert.equal(resolveBunExecutable({ + platform: 'win32', + env, + fileExists: (candidate) => candidate === executable, + findOnPath: (name, lookupEnv) => { + assert.equal(lookupEnv, env); + lookups.push(name); + return name === 'bun.cmd' ? shim : null; + }, + }), executable); + assert.deepEqual(lookups, ['bun.exe', 'bun.cmd']); +}); + +test('falls back to bun when no directly spawnable executable is available', () => { + assert.equal(resolveBunExecutable({ + platform: 'win32', + env: {}, + fileExists: () => false, + findOnPath: () => null, + }), 'bun'); +}); diff --git a/scripts/run-isolated-tests.mjs b/scripts/run-isolated-tests.mjs index bfc24df2..b2a63b5b 100644 --- a/scripts/run-isolated-tests.mjs +++ b/scripts/run-isolated-tests.mjs @@ -19,9 +19,12 @@ import { spawn } from 'node:child_process'; import { readdirSync, readFileSync, statSync } from 'node:fs'; import path from 'node:path'; +import { resolveBunExecutable } from './lib/bun-executable.mjs'; + const TEST_FILE = /\.(test|spec)\.(js|cjs|mjs|jsx|ts|tsx)$/; const SKIP_DIRS = new Set(['node_modules', 'dist', 'dist-bundle', 'build', 'out', '.git', 'ios', 'android']); const MAX_PARALLEL = 4; +const bunExecutable = resolveBunExecutable(); const collect = (root, found = []) => { for (const entry of readdirSync(root, { withFileTypes: true })) { @@ -44,7 +47,7 @@ const resolveCommand = (file) => { // implements. Node's ESM loader cannot resolve the extensionless local // specifiers these files use (`./sseProxy`), so it never ran them at all. if (isTypeScript || /from\s+['"]bun:test['"]/.test(source)) { - return { label: 'bun', command: 'bun', args: ['test', file] }; + return { label: 'bun', command: bunExecutable, args: ['test', file] }; } if (/from\s+['"]node:test['"]/.test(source)) { return { label: 'node', command: 'node', args: ['--test', file] }; @@ -53,7 +56,10 @@ const resolveCommand = (file) => { }; const run = ({ command, args }) => new Promise((resolve) => { - const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const child = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); let output = ''; child.stdout.on('data', (chunk) => { output += chunk; }); child.stderr.on('data', (chunk) => { output += chunk; });