mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-23 15:19:37 +00:00
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
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.
210 lines
9.5 KiB
JavaScript
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}`)
|