mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-11-06 14:55:17 +00:00
215 lines
9.6 KiB
JavaScript
215 lines
9.6 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'
|
|
|
|
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.
|
|
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(', ')}`)
|
|
}
|
|
|
|
// 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}`)
|