// Stage a self-contained copy of the root codeburn CLI into app/build/cli so // electron-builder can ship it as `extraResources`. The packaged app spawns // this copy (via Electron-as-node) instead of a globally-installed `codeburn`, // guaranteeing the CLI matches the app's version with no install prerequisite. // // The tsup bundle (dist/main.js) is NOT self-contained: it keeps the runtime // dependencies external (chalk, react, ink, undici, zod, @modelcontextprotocol/ // sdk, ...), and dist/main.js reads `../package.json` for its version string. So // the staged layout mirrors what `npm install` produces: // // build/cli/package.json (root package.json: {version}, type:module) // build/cli/dist/cli.js (Node-version-guard launcher → ./main.js) // build/cli/dist/main.js (the bundle) // build/cli/dist/parse-worker.js (the parse worker thread's own entry) // build/cli/node_modules/ (production dependency closure) // // The production closure is copied out of the already-installed root // node_modules (offline, no reinstall). Run the root CLI build first so dist/ is // fresh — the app `stage-cli` script does exactly that. import { execFileSync, spawn } from 'node:child_process' import { cpSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import electronPath from 'electron' import { topLevelPackagesFromNpmLs } from './stage-cli-packages.mjs' const here = dirname(fileURLToPath(import.meta.url)) // app/scripts const appDir = join(here, '..') // app const root = join(appDir, '..') // repo root const dist = join(root, 'dist') const rootModules = join(root, 'node_modules') const stage = join(appDir, 'build', 'cli') for (const f of ['cli.js', 'main.js', 'parse-worker.js']) { if (!existsSync(join(dist, f))) { throw new Error(`stage-cli: ${join(dist, f)} is missing — build the root CLI first`) } } rmSync(stage, { recursive: true, force: true }) mkdirSync(join(stage, 'dist'), { recursive: true }) copyFileSync(join(root, 'package.json'), join(stage, 'package.json')) copyFileSync(join(dist, 'cli.js'), join(stage, 'dist', 'cli.js')) copyFileSync(join(dist, 'main.js'), join(stage, 'dist', 'main.js')) // The cold-parse worker pool resolves this as a sibling of the bundle it runs // from, so it has to be staged alongside main.js or a packaged app silently // loses every parse thread. copyFileSync(join(dist, 'parse-worker.js'), join(stage, 'dist', 'parse-worker.js')) // Desktop-app launch shim (the app spawns this, not cli.js). Packaged // Electron makes Commander treat argv[1] as a user argument, so ordinary CLI // commands must drop the generated script path. The resident `serve` command // is dispatched directly by main.js from process.argv[2], before Commander // parses, so its script path must stay in place. writeFileSync( join(stage, 'dist', 'launch.js'), [ '// Generated by app/scripts/stage-cli.mjs — do not edit.', "if (process.versions.electron && process.argv[2] !== 'serve') process.argv.splice(1, 1)", "import('./main.js').catch(err => {", ' process.stderr.write(String(err?.message ?? err) + \'\\n\')', ' process.exit(1)', '})', '', ].join('\n'), ) // `npm ls` prints the tree to stdout even when it exits non-zero on peer/ // extraneous warnings, so capture stdout regardless of exit code. // npm_execpath is checked BEFORE the try: swallowing that error here degraded // into the empty-closure message below and hid the real cause (#1466). // Execute npm's JavaScript entry point with the current Node binary. Windows // exposes npm as a .cmd shim, which execFile cannot launch without a shell. const npmCli = process.env.npm_execpath if (!npmCli) throw new Error('stage-cli: npm_execpath is unavailable — run staging through npm scripts (npm run stage-cli), not node directly') let listed = '' try { listed = execFileSync(process.execPath, [npmCli, 'ls', '--omit=dev', '--all', '--parseable'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, }) } catch (err) { listed = err.stdout ? String(err.stdout) : '' } // Every parseable line is a path to a production package instance. Map each // to its top-level node_modules entry (`name` or `@scope/name`), then copy // those dirs whole — a package's own nested node_modules comes with it, which // is exactly the closure it needs at runtime. Names are extracted by segment // position rather than matched against the checkout's absolute path: npm 11 // and later redact UUID-shaped path segments to `***` in this output, so a // checkout under a UUID directory (CI runners, scratch worktrees) broke the // absolute-prefix match and staged an empty closure (#1466). const topLevel = topLevelPackagesFromNpmLs(listed, rootModules) if (topLevel.size === 0) { throw new Error('stage-cli: production dependency closure is empty — is the root `npm install`ed?') } mkdirSync(join(stage, 'node_modules'), { recursive: true }) for (const pkg of topLevel) { const src = join(rootModules, pkg) if (!existsSync(src) || !statSync(src).isDirectory()) continue cpSync(src, join(stage, 'node_modules', pkg), { recursive: true, dereference: true }) } // A partially-copied CLI that crashes on import is worse than none: fail the // build if any of the bundle's external runtime deps did not land. const required = [ 'chalk', 'react', 'ink', 'strip-ansi', 'zod', 'undici', 'selfsigned', 'commander', 'bonjour-service', '@modelcontextprotocol/sdk', ] const missing = required.filter(pkg => !existsSync(join(stage, 'node_modules', pkg))) if (missing.length) { throw new Error(`stage-cli: staged bundle is missing runtime deps: ${missing.join(', ')}`) } // Exercise both exact Electron-as-Node launch paths. A plain Node smoke cannot // catch Commander's Electron argv slicing. Test one ordinary command and parse // a complete ready frame from resident serve; stop the child as soon as that // frame arrives so provider watcher shutdown latency is not part of packaging. const launchPath = join(stage, 'dist', 'launch.js') const smokeRoot = mkdtempSync(join(tmpdir(), 'codeburn-stage-smoke-')) // Packaging must not discover a developer's real provider corpus or inherit // provider-specific path overrides. Keep only process-launch essentials, then // point every standard user-data root at the disposable smoke directory. const electronEnv = {} for (const key of ['PATH', 'TMPDIR', 'TMP', 'TEMP', 'SystemRoot', 'WINDIR', 'ComSpec', 'PATHEXT', 'LD_LIBRARY_PATH', 'DYLD_LIBRARY_PATH', 'LANG', 'LC_ALL', 'LC_CTYPE']) { if (process.env[key] !== undefined) electronEnv[key] = process.env[key] } Object.assign(electronEnv, { ELECTRON_RUN_AS_NODE: '1', HOME: smokeRoot, USERPROFILE: smokeRoot, APPDATA: join(smokeRoot, 'AppData', 'Roaming'), LOCALAPPDATA: join(smokeRoot, 'AppData', 'Local'), XDG_CACHE_HOME: join(smokeRoot, '.cache'), XDG_CONFIG_HOME: join(smokeRoot, '.config'), XDG_DATA_HOME: join(smokeRoot, '.local', 'share'), CODEBURN_CACHE_DIR: join(smokeRoot, '.codeburn-cache'), CODEBURN_DESKTOP_SESSIONS_DIR: join(smokeRoot, '.codeburn-desktop-sessions'), }) try { const modelsHelp = execFileSync(electronPath, [launchPath, 'models', '--help'], { env: electronEnv, encoding: 'utf8', // An x64 Electron under emulation on an ARM64 machine takes several seconds to start. timeout: 30_000, }) if (!/^Usage: codeburn models\b/m.test(modelsHelp)) { throw new Error('stage-cli: Electron launch shim lost ordinary CLI arguments') } await new Promise((resolve, reject) => { const child = spawn(electronPath, [launchPath, 'serve', '--stdio'], { env: electronEnv, // Keep stdin open until readiness is observed. An ignored stdin is already // at EOF, which starts serve shutdown while the gate is still reading the // ready frame and makes a valid package race its own cleanup. stdio: ['pipe', 'pipe', 'pipe'], }) let stdout = '' let stderr = '' let settled = false const finish = (error) => { if (settled) return settled = true clearTimeout(timer) child.kill() if (error) reject(error) else resolve() } const timer = setTimeout(() => { finish(new Error(`stage-cli: Electron launch shim did not enter resident serve within 5s${stderr ? `: ${stderr.trim()}` : ''}`)) }, 5_000) child.stderr.setEncoding('utf8') child.stderr.on('data', chunk => { stderr += chunk }) child.stdout.setEncoding('utf8') child.stdout.on('data', chunk => { stdout += chunk for (;;) { const newline = stdout.indexOf('\n') if (newline < 0) break const line = stdout.slice(0, newline) stdout = stdout.slice(newline + 1) try { const frame = JSON.parse(line) if (frame?.ready === true) finish() } catch { finish(new Error('stage-cli: Electron resident serve emitted a malformed JSON frame')) } } }) child.on('error', error => finish(new Error(`stage-cli: Electron resident serve failed to launch: ${error.message}`))) // `close` waits for piped stdout to drain; `exit` can arrive first even when // the valid ready frame is already buffered in the pipe. child.on('close', code => { if (!settled) finish(new Error(`stage-cli: Electron resident serve exited before ready (exit ${String(code)})`)) }) }) } finally { rmSync(smokeRoot, { recursive: true, force: true }) } console.log(`stage-cli: staged ${topLevel.size} production packages -> ${stage}`)