Merge pull request #3332 from hehuaiyu/fix/windows-bun-shim-resolution

fix(tooling): resolve Bun executable on Windows
This commit is contained in:
Bohdan Triapitsyn
2026-09-05 01:24:34 +03:00
committed by GitHub
11 changed files with 337 additions and 18 deletions
+2 -2
View File
@@ -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`.
+12 -5
View File
@@ -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}`);
@@ -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 = [];
+1 -1
View File
@@ -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",
+76
View File
@@ -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;
}
}
@@ -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',
]);
});
});
@@ -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);