codeburn/app/scripts/stage-cli.mjs
iamtoruk 4a100d9d06 feat(app): bundle the CLI inside the packaged app
The packaged app resolved whatever codeburn was on PATH — for real
users the published npm release, which predates every JSON surface the
app calls. The app now ships its own CLI copy under resources/cli
(staged production node_modules tree; tsup output is not
self-contained) and spawns it with Electron's own binary via
ELECTRON_RUN_AS_NODE. No install prerequisite remains.

- resolution order: CODEBURN_BIN, dev repo CLI, bundled, persisted
  path, PATH search
- launch.js shim strips the extra argv element commander mis-slices
  under packaged Electron-as-node (process.versions.electron is set),
  which reproduced the 'too many arguments' class of error
- afterPack hook copies the staged tree (extraResources runs
  node_modules through the production-dep filter and ships it empty);
  lands before signing, signature verified intact
- packaging always restages from src via root build:cli (tsup only,
  no network); ~12MB compressed per artifact

Verified on the built app: Electron-as-node CLI emits current JSON
(providerDetails, currency), and a minimal-PATH GUI launch spawns the
bundled CLI with zero external dependencies. 290/290.
2026-07-16 09:27:46 -07:00

114 lines
5 KiB
JavaScript

// 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}`)