codeburn/app/scripts/stage-cli.mjs
2026-07-22 00:23:24 +02:00

124 lines
5.8 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 {
// 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('npm_execpath is unavailable')
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 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.
// `npm ls --parseable` uses native separators on Windows. Normalize both sides
// before extracting the package name so Store builds do not treat a populated
// node_modules tree as empty merely because it uses `\\` instead of `/`.
const prefix = rootModules.replaceAll('\\', '/') + '/'
const comparisonPrefix = process.platform === 'win32' ? prefix.toLowerCase() : prefix
const topLevel = new Set()
for (const line of listed.split('\n')) {
const normalizedLine = line.trim().replaceAll('\\', '/')
const comparisonLine = process.platform === 'win32' ? normalizedLine.toLowerCase() : normalizedLine
if (!comparisonLine.startsWith(comparisonPrefix)) continue
const rest = normalizedLine.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}`)