codeburn/app/scripts/stage-cli.mjs
ozymandiashh 0f5a33450c
Some checks are pending
Tests / test (26) (push) Waiting to run
Tests / test-platforms (macos-latest) (push) Waiting to run
Tests / test-platforms (windows-latest) (push) Waiting to run
Tests / desktop (push) Waiting to run
CI / semgrep (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
Tests / test (24) (push) Waiting to run
fix(app): stage the CLI closure without anchoring on the checkout's absolute path (#1473)
npm redacts UUID-shaped path segments to `***` in `npm ls --parseable`, so matching each line against the checkout's absolute <root>/node_modules/ prefix found nothing on any path containing a UUID and packaging aborted with the misleading "is the root npm installed?".
The top-level package name is now read by segment position: after the N-th /node_modules segment, N counted off the real root path, which the redaction renames around but cannot move.
Closure verified identical: 160 packages, same names and versions, same 55M, staged from a UUID worktree and from a UUID-free checkout of main.
Reproduced before and after: main aborts from the UUID path, the branch stages 160 packages from that same path.
A non-zero `npm ls` exit still yields its stdout, so an extraneous package in node_modules does not abort staging.
A missing npm_execpath now fails with its own message instead of degrading into the empty-closure one.
Review patch on the branch: the two hand-rolled path scanners collapse to one split('/node_modules/'), and the npm attribution is corrected from 12 to 11 and later, reproduced on npm 11.19.0.
Fixes #1466.
2026-09-18 04:59:38 -07:00

210 lines
9.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/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}`)