#!/usr/bin/env node /** * Why this script exists: * * The standard `electron-builder install-app-deps` uses @electron/rebuild * internally but does not expose the `ignoreModules` option (as of * electron-builder 26.x). On Windows dev machines that lack the full * Visual C++ / Python build toolchain, `cpu-features@0.0.10` (an optional * performance dependency of `ssh2`) fails to build with node-gyp because * `buildcheck.gypi` is missing from the tarball. This causes the entire * postinstall step to fail and prevents `pnpm install` from completing. * * This script replaces `electron-builder install-app-deps` in the postinstall * lifecycle. It calls @electron/rebuild's JS API directly so that we can pass * `ignoreModules: ['cpu-features']` on Windows. Skipping cpu-features is * safe: ssh2 detects the missing native module and falls back to pure-JS CPU * feature detection automatically. * * On macOS and Linux the full rebuild (including cpu-features) runs as usual. */ import { rebuild } from '@electron/rebuild' import { execFileSync, spawnSync } from 'node:child_process' import { createRequire } from 'node:module' import { existsSync, globSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { platform as osPlatform } from 'node:os' import { resolve } from 'node:path' const require = createRequire(import.meta.url) const projectDir = process.cwd() const electronVersion = JSON.parse( readFileSync(resolve(projectDir, 'node_modules/electron/package.json'), 'utf8') ).version const ignoreModules = process.platform === 'win32' ? ['cpu-features'] : [] if (ignoreModules.length > 0) { console.log(`[rebuild] Skipping modules on Windows: ${ignoreModules.join(', ')}`) } // Why: @electron/rebuild's default module walker doesn't reliably find native // modules inside pnpm's .pnpm/ store. Passing an explicit list of modules to // rebuild via `onlyModules` ensures they're recompiled against Electron's Node // ABI regardless of the package manager's store layout. const NATIVE_MODULES = ['better-sqlite3', 'node-pty', 'cpu-features'] const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m)) const forceRebuild = process.env.ORCA_FORCE_NATIVE_REBUILD === '1' ensureElectronPackageInstalled() if (!forceRebuild) { // Why: Windows cannot unlink a loaded .node DLL, so avoid @electron/rebuild // when the current install already works with Electron's ABI. const probe = probeElectronNativeModules(onlyModules) if (probe.ok) { console.log('[rebuild] Native modules already load in Electron; skipping rebuild.') process.exit(0) } console.log('[rebuild] Native modules do not load in Electron; rebuilding.') if (probe.stderr.trim()) { console.log(probe.stderr.trim()) } } else { console.log('[rebuild] ORCA_FORCE_NATIVE_REBUILD=1 set; forcing native rebuild.') } // Why: cpu-features ships without `buildcheck.gypi`; its own `install` script // generates it by running `node buildcheck.js > buildcheck.gypi` before // node-gyp. @electron/rebuild with `force: true` invokes node-gyp directly // and bypasses that install hook, so if the file is missing (fresh install, // store prune, or a prior failed run) node-gyp aborts with // "buildcheck.gypi not found". Regenerate it here before rebuilding. if (!ignoreModules.includes('cpu-features')) { const cpuFeatureDirs = globSync('node_modules/.pnpm/cpu-features@*/node_modules/cpu-features', { cwd: projectDir }) for (const relDir of cpuFeatureDirs) { const dir = resolve(projectDir, relDir) const gypiPath = resolve(dir, 'buildcheck.gypi') if (existsSync(gypiPath)) { continue } try { const out = execFileSync(process.execPath, ['buildcheck.js'], { cwd: dir, encoding: 'utf8' }) writeFileSync(gypiPath, out) console.log(`[rebuild] Generated ${relDir}/buildcheck.gypi`) } catch (/** @type {any} */ err) { console.error(`[rebuild] Failed to generate ${relDir}/buildcheck.gypi:`, err?.message ?? err) process.exit(1) } } } try { await rebuild({ buildPath: projectDir, electronVersion, ignoreModules, onlyModules, // Why: without force, @electron/rebuild skips modules it considers // "already built" — even when they were compiled for the wrong ABI // (e.g., system Node instead of Electron's embedded Node). This is // common after pnpm install, which compiles native modules for system // Node before postinstall runs this script. force: true }) } catch (/** @type {any} */ err) { console.error('[rebuild] Native module rebuild failed:', err?.message ?? err) if (isWindowsNativeLockError(err)) { console.error( '[rebuild] A Windows process appears to be using a native .node file. ' + 'Close running Orca/Electron/dev processes for this worktree, then rerun `pnpm install` ' + 'or `pnpm run rebuild:electron`.' ) if (isPostinstall() && process.env.ORCA_STRICT_NATIVE_REBUILD !== '1') { console.error( '[rebuild] Continuing postinstall because the failure is a Windows file lock. ' + 'The next dev/start command will re-check native modules.' ) process.exit(0) } } process.exit(1) } function ensureElectronPackageInstalled() { try { require('electron') return } catch (/** @type {any} */ err) { if (!isElectronPackageInstallError(err)) { throw err } } // Why: CI has observed Electron's postinstall exiting cleanly without // writing path.txt; use our strict installer instead of Electron's install.js // so partial extracts and poisoned caches are rejected before native rebuild. console.log('[rebuild] Electron package binary is missing; installing Electron package binary.') resetPartialElectronInstall() try { runElectronPackageBinaryInstall() } catch (/** @type {any} */ err) { console.error('[rebuild] Electron install retry failed:', err?.message ?? err) logElectronInstallDiagnostics() if (continuePostinstallWithoutElectron()) { process.exit(0) } process.exit(1) } try { require('electron') } catch (/** @type {any} */ err) { if (!repairElectronPathFile()) { logElectronInstallDiagnostics() if (continuePostinstallWithoutElectron()) { process.exit(0) } console.error( '[rebuild] Electron package is still unavailable after retry:', err?.message ?? err ) process.exit(1) } try { require('electron') } catch (/** @type {any} */ retryErr) { console.error( '[rebuild] Electron package is still unavailable after repairing path.txt:', retryErr?.message ?? retryErr ) process.exit(1) } } } function runElectronPackageBinaryInstall() { const env = { ...process.env } delete env.ELECTRON_SKIP_BINARY_DOWNLOAD delete env.npm_config_electron_skip_binary_download const result = spawnSync( process.execPath, ['config/scripts/install-electron-package-binary.mjs'], { cwd: projectDir, env, stdio: 'inherit' } ) if (result.error) { throw result.error } if (result.status !== 0) { throw new Error( `config/scripts/install-electron-package-binary.mjs exited with status ${result.status}` ) } } function resetPartialElectronInstall() { const electronPackageDir = resolve(projectDir, 'node_modules/electron') // Why: Electron's installer can leave a partial dist/ tree behind after // skipped or interrupted postinstall runs; retry from a clean target. rmSync(resolve(electronPackageDir, 'dist'), { recursive: true, force: true }) rmSync(resolve(electronPackageDir, 'path.txt'), { force: true }) } function continuePostinstallWithoutElectron() { if (!isPostinstall() || process.env.ORCA_STRICT_ELECTRON_INSTALL === '1') { return false } console.error( '[rebuild] Continuing postinstall because Electron binary installation failed. ' + 'Electron-consuming package scripts and release jobs run ' + 'config/scripts/ensure-native-runtime.mjs --runtime=electron before launching Electron.' ) return true } function repairElectronPathFile() { const electronPackageDir = resolve(projectDir, 'node_modules/electron') const platformPath = getElectronPlatformPath() const electronPath = process.env.ELECTRON_OVERRIDE_DIST_PATH ? resolve(process.env.ELECTRON_OVERRIDE_DIST_PATH, platformPath) : resolve(electronPackageDir, 'dist', platformPath) if (!existsSync(electronPath)) { return false } // Why: Electron's install script has exited successfully in CI after // extraction without leaving path.txt. The package main only needs this file // to point at the already-extracted executable. writeFileSync(resolve(electronPackageDir, 'path.txt'), platformPath) console.log(`[rebuild] Repaired Electron path.txt -> ${platformPath}`) return true } function logElectronInstallDiagnostics() { const electronPackageDir = resolve(projectDir, 'node_modules/electron') const electronDistDir = resolve(electronPackageDir, 'dist') const pathFile = resolve(electronPackageDir, 'path.txt') console.error('[rebuild] Electron install diagnostics:') console.error(` packageDir=${electronPackageDir} exists=${existsSync(electronPackageDir)}`) console.error(` distDir=${electronDistDir} exists=${existsSync(electronDistDir)}`) console.error(` pathFile=${pathFile} exists=${existsSync(pathFile)}`) if (existsSync(electronDistDir)) { console.error(` distEntries=${safeReaddir(electronDistDir).join(', ')}`) } } function safeReaddir(targetPath) { try { return readdirSync(targetPath).slice(0, 20) } catch { return [] } } function getElectronPlatformPath() { const targetPlatform = process.env.npm_config_platform || osPlatform() switch (targetPlatform) { case 'mas': case 'darwin': return 'Electron.app/Contents/MacOS/Electron' case 'freebsd': case 'openbsd': case 'linux': return 'electron' case 'win32': return 'electron.exe' default: throw new Error(`Electron builds are not available on platform: ${targetPlatform}`) } } function probeElectronNativeModules(moduleNames) { let electronExecutable try { electronExecutable = require('electron') } catch (error) { return { ok: false, status: null, stderr: formatError(error) } } const probeSource = ` const { createRequire } = require('node:module') const { release } = require('node:os') const { resolve } = require('node:path') const projectRequire = createRequire(resolve(process.cwd(), 'package.json')) const moduleNames = ${JSON.stringify(moduleNames)} const failures = [] for (const moduleName of moduleNames) { try { loadNativeModule(moduleName) } catch (error) { failures.push(moduleName + ': ' + formatError(error)) } } if (failures.length > 0) { console.error(failures.join('\\n')) process.exit(1) } function loadNativeModule(moduleName) { if (moduleName === 'better-sqlite3') { const Database = projectRequire(moduleName) const db = new Database(':memory:') db.close() return } if (moduleName === 'node-pty') { projectRequire('node-pty') const { loadNativeModule } = projectRequire('node-pty/lib/utils') loadNativeModule(getNodePtyNativeModuleName()) return } projectRequire(moduleName) } function getNodePtyNativeModuleName() { if (process.platform !== 'win32') { return 'pty' } const match = /(\\d+)\\.(\\d+)\\.(\\d+)/g.exec(release()) const buildNumber = match && match.length === 4 ? Number.parseInt(match[3], 10) : 0 return buildNumber >= 18309 ? 'conpty' : 'pty' } function formatError(error) { return error instanceof Error ? error.message : String(error) } ` const result = spawnSync(electronExecutable, ['-e', probeSource], { cwd: projectDir, env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) return { ok: result.status === 0, status: result.status, stderr: [result.stderr, result.stdout, result.error ? formatError(result.error) : ''] .filter(Boolean) .join('\n') } } function isWindowsNativeLockError(error) { if (process.platform !== 'win32') { return false } const text = [error?.message, error?.stack, error?.stdout, error?.stderr] .filter(Boolean) .join('\n') return /(?:EPERM|operation not permitted)[\s\S]*(?:unlink|\.node|conpty\.node|pty\.node)/i.test( text ) } function isPostinstall() { return process.env.npm_lifecycle_event === 'postinstall' } function isElectronPackageInstallError(error) { return /Electron failed to install correctly/i.test(formatError(error)) } function formatError(error) { return error instanceof Error ? error.message : String(error) }