// 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/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 } from 'node:child_process' import { cpSync, copyFileSync, existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' 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']) { 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')) // Desktop-app launch shim (the app spawns this, not cli.js). The packaged app // runs the CLI with Electron's own binary as Node (ELECTRON_RUN_AS_NODE=1). // Because process.versions.electron is then set, commander's argv auto-detection // treats a packaged (non-defaultApp) Electron as `from: 'electron'` and slices // argv by 1 instead of 2 — leaving this script's own path among the parsed args // and shifting every real argument. Drop that element before handing off so the // CLI's `program.parse()` sees the true args. Guarded so the shim also works if // ever run under plain Node. Kept in the app (not the shared CLI source) so the // published CLI is untouched. writeFileSync( join(stage, 'dist', 'launch.js'), [ '// Generated by app/scripts/stage-cli.mjs — do not edit.', 'if (process.versions.electron) 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. let listed = '' try { listed = execFileSync('npm', ['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 an absolute path to a production package instance. // Map each back 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. const prefix = rootModules + '/' const topLevel = new Set() for (const line of listed.split('\n')) { if (!line.startsWith(prefix)) continue const rest = line.slice(prefix.length) const match = rest.match(/^(@[^/]+\/[^/]+|[^/]+)/) if (match) topLevel.add(match[1]) } 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(', ')}`) } console.log(`stage-cli: staged ${topLevel.size} production packages -> ${stage}`)