diff --git a/package.json b/package.json index bfcb1c4ea0b..779fb9224c0 100644 --- a/package.json +++ b/package.json @@ -60,5 +60,8 @@ "name": "node", "version": "24.17.0" } + }, + "volta": { + "node": "24.17.0" } } diff --git a/packages/software-factory/README.md b/packages/software-factory/README.md index ae9885337e7..d00d3dd6e66 100644 --- a/packages/software-factory/README.md +++ b/packages/software-factory/README.md @@ -39,6 +39,21 @@ The orchestrator (`runIssueLoop`) is a thin scheduler that picks the next unbloc ## Prerequisites +- Node >= 24 (the repo pins `24.17.0` via `.nvmrc` / Volta — run `nvm use` or let Volta pick it up) +- **One-time build/setup** — on a fresh `pnpm install`-only checkout, run: + + ```bash + cd packages/software-factory + pnpm factory:setup + ``` + + This is idempotent (skips anything already built) and provisions the three + artifacts the factory needs that aren't committed: the boxel-cli API bundle + (`boxel-cli/dist/api.js`), a dev-mode host build with test entries + (`host/dist/tests/index.html`), and the Playwright Chromium headless-shell + binary. `pnpm factory:go` also detects any missing prerequisite up front and + points you back here. Pass `--force` to rebuild everything. + - Docker running - `mise run dev-all` (starts realm server, host app, icons server, Postgres, Synapse) - Active Boxel CLI profile (`boxel profile add`) diff --git a/packages/software-factory/package.json b/packages/software-factory/package.json index fa57bfe70a9..f373527da28 100644 --- a/packages/software-factory/package.json +++ b/packages/software-factory/package.json @@ -9,6 +9,7 @@ "boxel:search": "NODE_NO_WARNINGS=1 node scripts/boxel-search.ts", "cache:prepare": "NODE_NO_WARNINGS=1 node src/cli/cache-realm.ts", "factory:go": "NODE_NO_WARNINGS=1 node src/cli/factory-entrypoint.ts", + "factory:setup": "NODE_NO_WARNINGS=1 node scripts/factory-setup.ts", "smoke:context": "NODE_NO_WARNINGS=1 node scripts/smoke-tests/factory-context-smoke.ts", "smoke:prompt": "NODE_NO_WARNINGS=1 node scripts/smoke-tests/factory-prompt-smoke.ts", "smoke:skill": "NODE_NO_WARNINGS=1 node scripts/smoke-tests/factory-skill-smoke.ts", diff --git a/packages/software-factory/scripts/factory-setup.ts b/packages/software-factory/scripts/factory-setup.ts new file mode 100644 index 00000000000..62da3d318e6 --- /dev/null +++ b/packages/software-factory/scripts/factory-setup.ts @@ -0,0 +1,99 @@ +// This should be first +import '../src/setup-logger.ts'; + +// Idempotent bootstrap for `pnpm factory:go` on a fresh checkout (CS-12186). +// Builds the boxel-cli API bundle, builds the host app in dev mode (so the +// test harness has its `dist/tests/index.html` entry), and downloads the +// Playwright Chromium headless-shell binary — skipping any step whose artifact +// is already present. Node itself can't be installed for you, so a too-old Node +// is reported as an error rather than a build step. +// +// Pass --force to rebuild/reinstall everything regardless of what's present. + +import { spawnSync } from 'node:child_process'; + +import { logger } from '../src/logger.ts'; +import { + MIN_NODE_MAJOR, + PINNED_NODE, + boxelCliApiJsExists, + hostTestHarnessExists, + isNodeVersionOk, + packageRoot, + playwrightHeadlessShellPresent, + repoRoot, +} from '../src/preflight.ts'; + +let log = logger('factory-setup'); + +function run(label: string, cmd: string, args: string[], cwd: string): void { + log.info(`▶ ${label}`); + log.info(` ${cmd} ${args.join(' ')}`); + let result = spawnSync(cmd, args, { + cwd, + stdio: 'inherit', + // pnpm/npx resolve through the shell on Windows. + shell: process.platform === 'win32', + }); + if (result.status !== 0) { + log.error(`✗ ${label} failed (exit ${result.status ?? 'signal'})`); + process.exit(result.status ?? 1); + } +} + +function main(): void { + let force = process.argv.slice(2).includes('--force'); + let root = repoRoot(); + + // Node can't be provisioned by a build step — fail early with guidance. + if (!isNodeVersionOk()) { + log.error( + `Node ${process.versions.node} detected, but the factory requires Node >= ${MIN_NODE_MAJOR}.`, + ); + log.error( + `Install it (the repo pins ${PINNED_NODE} via .nvmrc / devEngines), then re-run pnpm factory:setup.`, + ); + process.exit(1); + } + + if (force || !boxelCliApiJsExists()) { + run( + 'Build boxel-cli API bundle (dist/api.js)', + 'pnpm', + ['--filter', '@cardstack/boxel-cli', 'build:api'], + root, + ); + } else { + log.info('✓ boxel-cli dist/api.js present — skipping'); + } + + if (force || !hostTestHarnessExists()) { + run( + 'Build host app (dev mode, with test entries)', + 'pnpm', + ['--filter', '@cardstack/host', 'build'], + root, + ); + } else { + log.info('✓ host dist/tests/index.html present — skipping'); + } + + if (force || !playwrightHeadlessShellPresent()) { + // Run from this package (its node_modules has @playwright/test). + run( + 'Install Playwright Chromium (headless shell)', + 'pnpm', + ['exec', 'playwright', 'install', 'chromium-headless-shell'], + packageRoot(), + ); + } else { + log.info('✓ Playwright chromium-headless-shell present — skipping'); + } + + log.info('✓ factory:setup complete.'); + log.info( + ' Run: pnpm factory:go --brief-url --target-realm --debug', + ); +} + +main(); diff --git a/packages/software-factory/src/cli/factory-entrypoint.ts b/packages/software-factory/src/cli/factory-entrypoint.ts index ca9f39baf46..293f5c473fa 100644 --- a/packages/software-factory/src/cli/factory-entrypoint.ts +++ b/packages/software-factory/src/cli/factory-entrypoint.ts @@ -1,78 +1,147 @@ // This should be first import '../setup-logger.ts'; -import { BoxelCLIClient } from '@cardstack/boxel-cli/api'; - -import { - FactoryEntrypointUsageError, - getFactoryEntrypointUsage, - parseFactoryEntrypointArgs, - runFactoryEntrypoint, - wantsFactoryEntrypointHelp, -} from '../factory-entrypoint.ts'; -import { FactoryBriefError } from '../factory-brief.ts'; import { configureLogger, logger, setLogTimestampsEnabled } from '../logger.ts'; +import { + formatMissingPrerequisites, + missingPrerequisites, +} from '../preflight.ts'; let log = logger('factory-entrypoint'); +function wantsHelp(argv: string[]): boolean { + let normalized = argv[0] === '--' ? argv.slice(1) : argv; + return normalized.includes('--help'); +} + async function main(): Promise { - if (wantsFactoryEntrypointHelp(process.argv.slice(2))) { - console.log(getFactoryEntrypointUsage()); - return; + let argv = process.argv.slice(2); + let helpRequested = wantsHelp(argv); + + // Preflight BEFORE importing anything from @cardstack/boxel-cli. That import + // resolves to boxel-cli/dist/api.js — one of the build artifacts a fresh + // checkout is missing (CS-12186) — so a missing prerequisite would otherwise + // crash at module load with an opaque ERR_MODULE_NOT_FOUND before any of our + // code runs. Report every missing prerequisite at once and point at + // `pnpm factory:setup`. Skipped for --help so usage stays available on an + // otherwise-provisioned checkout. + if (!helpRequested) { + let missing = missingPrerequisites(); + if (missing.length > 0) { + log.error(formatMissingPrerequisites(missing)); + process.exitCode = 1; + return; + } } - let options = parseFactoryEntrypointArgs(process.argv.slice(2)); - - // --debug raises the log level so debug-gated lines (e.g. full - // run-command response bodies) surface, unless the caller has already - // pinned a level via LOG_LEVELS. It also turns on the timing - // instrumentation (per-line timestamps + per-phase/summary durations), - // which is otherwise off so normal runs stay clean. - if (options.debug) { - setLogTimestampsEnabled(true); - if (!process.env.LOG_LEVELS) { - configureLogger('*=debug'); + // Deferred imports: only load boxel-cli (and the rest of the entrypoint, + // which imports it transitively) once preflight has confirmed dist/api.js + // exists. + let entrypoint; + let briefModule; + let cli; + try { + [entrypoint, briefModule, cli] = await Promise.all([ + import('../factory-entrypoint.ts'), + import('../factory-brief.ts'), + import('@cardstack/boxel-cli/api'), + ]); + } catch (error) { + // The only expected failure here is --help on a checkout that hasn't built + // dist/api.js yet (preflight is skipped for --help). Re-run the check so + // the user still gets the actionable list instead of a raw module error. + let missing = missingPrerequisites(); + if (missing.length > 0) { + log.error(formatMissingPrerequisites(missing)); + process.exitCode = 1; + return; } + throw error; } - await BoxelCLIClient.ensureProfile({ - realmServerUrl: options.realmServerUrl ?? undefined, - }); + let { + FactoryEntrypointUsageError, + getFactoryEntrypointUsage, + parseFactoryEntrypointArgs, + runFactoryEntrypoint, + wantsFactoryEntrypointHelp, + } = entrypoint; + let { FactoryBriefError } = briefModule; + let { BoxelCLIClient } = cli; - log.info(`brief=${options.briefUrl}`); - log.info('Starting seed issue + issue-driven loop...'); + try { + if (wantsFactoryEntrypointHelp(argv)) { + console.log(getFactoryEntrypointUsage()); + return; + } + + let options = parseFactoryEntrypointArgs(argv); + + // --debug raises the log level so debug-gated lines (e.g. full + // run-command response bodies) surface, unless the caller has already + // pinned a level via LOG_LEVELS. It also turns on the timing + // instrumentation (per-line timestamps + per-phase/summary durations), + // which is otherwise off so normal runs stay clean. + if (options.debug) { + setLogTimestampsEnabled(true); + if (!process.env.LOG_LEVELS) { + configureLogger('*=debug'); + } + } - let summary = await runFactoryEntrypoint(options); + await BoxelCLIClient.ensureProfile({ + realmServerUrl: options.realmServerUrl ?? undefined, + }); - if (summary.issueLoop) { - log.info( - `Issue loop complete: outcome=${summary.issueLoop.outcome} ` + - `outerCycles=${summary.issueLoop.outerCycles} ` + - `issues=${summary.issueLoop.issueResults.length}`, - ); - for (let ir of summary.issueLoop.issueResults) { + log.info(`brief=${options.briefUrl}`); + log.info('Starting seed issue + issue-driven loop...'); + + let summary = await runFactoryEntrypoint(options); + + if (summary.issueLoop) { log.info( - ` ${ir.issueId}: ${ir.exitReason} (${ir.innerIterations} iterations, ${ir.toolCallCount} tool calls)`, + `Issue loop complete: outcome=${summary.issueLoop.outcome} ` + + `outerCycles=${summary.issueLoop.outerCycles} ` + + `issues=${summary.issueLoop.issueResults.length}`, ); + for (let ir of summary.issueLoop.issueResults) { + log.info( + ` ${ir.issueId}: ${ir.exitReason} (${ir.innerIterations} iterations, ${ir.toolCallCount} tool calls)`, + ); + } + if (summary.issueLoop.outcome === 'all_issues_done') { + log.info('All issues done.'); + } else { + log.info(`Exiting with outcome=${summary.issueLoop.outcome}`); + } + } + + // Reflect the run outcome in the exit code so CI / callers can detect + // failures without parsing logs. `completed` is the only success state; + // `ready` means the entrypoint returned before the loop ran, which we + // treat as a failed invocation. + if (summary.result.status !== 'completed') { + process.exitCode = 1; + } + + if (options.debug) { + await writeToStdout(JSON.stringify(summary, null, 2) + '\n'); } - if (summary.issueLoop.outcome === 'all_issues_done') { - log.info('All issues done.'); + } catch (error: unknown) { + if (error instanceof FactoryEntrypointUsageError) { + log.error(error.message); + log.error(''); + log.error(getFactoryEntrypointUsage()); + } else if (error instanceof FactoryBriefError) { + log.error(error.message); + } else if (error instanceof Error) { + log.error(error.stack ?? error.message); } else { - log.info(`Exiting with outcome=${summary.issueLoop.outcome}`); + log.error(String(error)); } - } - // Reflect the run outcome in the exit code so CI / callers can detect - // failures without parsing logs. `completed` is the only success state; - // `ready` means the entrypoint returned before the loop ran, which we - // treat as a failed invocation. - if (summary.result.status !== 'completed') { process.exitCode = 1; } - - if (options.debug) { - await writeToStdout(JSON.stringify(summary, null, 2) + '\n'); - } } // Large summaries can exceed the stdout high-water mark, in which case @@ -93,13 +162,7 @@ function writeToStdout(chunk: string): Promise { main() .catch((error: unknown) => { - if (error instanceof FactoryEntrypointUsageError) { - log.error(error.message); - log.error(''); - log.error(getFactoryEntrypointUsage()); - } else if (error instanceof FactoryBriefError) { - log.error(error.message); - } else if (error instanceof Error) { + if (error instanceof Error) { log.error(error.stack ?? error.message); } else { log.error(String(error)); diff --git a/packages/software-factory/src/preflight.ts b/packages/software-factory/src/preflight.ts new file mode 100644 index 00000000000..a7269224303 --- /dev/null +++ b/packages/software-factory/src/preflight.ts @@ -0,0 +1,165 @@ +// Prerequisite detection for `pnpm factory:go`. +// +// A fresh `pnpm install`-only checkout is missing several build artifacts and +// tool binaries that the factory needs, and historically each surfaced as a +// separate opaque crash from deep inside a run (CS-12186). This module detects +// all of them up front so the entrypoint can fail once with a single +// actionable message, and so `pnpm factory:setup` can provision them. +// +// It deliberately imports nothing from `@cardstack/boxel-cli` — one of the +// prerequisites it checks for is boxel-cli's own `dist/api.js`, so importing it +// here would defeat the purpose (the import would crash before the check runs). + +import { existsSync, readdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export const MIN_NODE_MAJOR = 24; +// Matches `.nvmrc` and root package.json `devEngines.runtime`. +export const PINNED_NODE = '24.17.0'; + +export interface Prerequisite { + id: 'node' | 'boxel-cli-api' | 'host-dist' | 'playwright-chromium'; + label: string; + satisfied: boolean; + /** The single command that provisions this prerequisite on its own. */ + fix: string; +} + +// This file lives at packages/software-factory/src/preflight.ts. +export function packageRoot(): string { + return join(import.meta.dirname, '..'); +} + +function packagesDir(): string { + return join(packageRoot(), '..'); +} + +export function repoRoot(): string { + return join(packagesDir(), '..'); +} + +/** boxel-cli's bundled `@cardstack/boxel-cli/api` entry (built by `build:api`). */ +export function boxelCliApiJsPath(): string { + return join(packagesDir(), 'boxel-cli', 'dist', 'api.js'); +} + +/** + * The host test-harness entry `boxel test` loads. Only a dev-mode host build + * (`pnpm --filter @cardstack/host build`, i.e. `vite build --mode=development`) + * emits it; a production build does not. + */ +export function hostTestHarnessPath(): string { + return join(packagesDir(), 'host', 'dist', 'tests', 'index.html'); +} + +export function nodeMajorVersion(): number { + return Number.parseInt(process.versions.node.split('.')[0]!, 10); +} + +export function isNodeVersionOk(): boolean { + return nodeMajorVersion() >= MIN_NODE_MAJOR; +} + +export function boxelCliApiJsExists(): boolean { + return existsSync(boxelCliApiJsPath()); +} + +export function hostTestHarnessExists(): boolean { + return existsSync(hostTestHarnessPath()); +} + +export function playwrightBrowsersRoot(): string | undefined { + let configured = process.env.PLAYWRIGHT_BROWSERS_PATH; + // '0' stores browsers inside node_modules rather than a shared cache; locating + // them reliably there is not worth the complexity, so don't block on it. + if (configured === '0') return undefined; + if (configured) return configured; + + let home = homedir(); + switch (process.platform) { + case 'darwin': + return join(home, 'Library', 'Caches', 'ms-playwright'); + case 'win32': + return join( + process.env.LOCALAPPDATA ?? join(home, 'AppData', 'Local'), + 'ms-playwright', + ); + default: + return join(home, '.cache', 'ms-playwright'); + } +} + +/** + * `boxel test` runs `chromium.launch({ headless: true })`, which uses + * Playwright's `chromium-headless-shell` binary. This is a cheap heuristic — + * the presence of a downloaded `chromium_headless_shell-*` build in the browser + * cache — rather than an actual browser launch. `npx playwright install + * chromium` also downloads the headless shell, so this covers both install + * commands. When the browser path can't be determined (PLAYWRIGHT_BROWSERS_PATH + * set to '0'), assume present so we don't block on a false negative. + */ +export function playwrightHeadlessShellPresent(): boolean { + let root = playwrightBrowsersRoot(); + if (!root) return true; + if (!existsSync(root)) return false; + try { + return readdirSync(root).some((name) => + name.startsWith('chromium_headless_shell-'), + ); + } catch { + return false; + } +} + +export function checkPrerequisites(): Prerequisite[] { + return [ + { + id: 'node', + label: `Node >= ${MIN_NODE_MAJOR} (found ${process.versions.node})`, + satisfied: isNodeVersionOk(), + fix: `Install Node >= ${MIN_NODE_MAJOR} (repo pins ${PINNED_NODE} via .nvmrc); e.g. \`nvm use\` or \`volta install node@${MIN_NODE_MAJOR}\``, + }, + { + id: 'boxel-cli-api', + label: 'boxel-cli API bundle (packages/boxel-cli/dist/api.js)', + satisfied: boxelCliApiJsExists(), + fix: 'pnpm --filter @cardstack/boxel-cli build:api', + }, + { + id: 'host-dist', + label: + 'host dev build with test entries (packages/host/dist/tests/index.html)', + satisfied: hostTestHarnessExists(), + fix: 'pnpm --filter @cardstack/host build', + }, + { + id: 'playwright-chromium', + label: 'Playwright Chromium headless-shell binary', + satisfied: playwrightHeadlessShellPresent(), + fix: 'npx playwright install chromium-headless-shell', + }, + ]; +} + +export function missingPrerequisites(): Prerequisite[] { + return checkPrerequisites().filter((p) => !p.satisfied); +} + +export function formatMissingPrerequisites(missing: Prerequisite[]): string { + let lines = [ + `Cannot run the factory: ${missing.length} prerequisite${ + missing.length === 1 ? ' is' : 's are' + } missing.`, + '', + 'Provision everything at once:', + ' pnpm factory:setup', + '', + 'Or fix each individually:', + ]; + for (let p of missing) { + lines.push(` • ${p.label}`); + lines.push(` ${p.fix}`); + } + return lines.join('\n'); +} diff --git a/packages/software-factory/tests/factory-entrypoint.integration.test.ts b/packages/software-factory/tests/factory-entrypoint.integration.test.ts index eb365e5c77b..3df8e876092 100644 --- a/packages/software-factory/tests/factory-entrypoint.integration.test.ts +++ b/packages/software-factory/tests/factory-entrypoint.integration.test.ts @@ -14,7 +14,19 @@ const { module, test } = QUnit; import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type'; +import { playwrightBrowsersRoot } from '../src/preflight.ts'; + const packageRoot = resolve(import.meta.dirname, '..'); + +// The spawned `factory:go` runs preflight, which locates the Playwright browser +// cache via homedir(). These tests override HOME to isolate the boxel-cli +// profile, which would hide the browsers CI installed under the real home and +// make preflight abort the run before it reaches the behavior under test. Pin +// the cache explicitly (resolved against this process's real home) so preflight +// finds it regardless of the child's HOME. preflight honors +// PLAYWRIGHT_BROWSERS_PATH ahead of homedir(). +const playwrightBrowsersPath = + process.env.PLAYWRIGHT_BROWSERS_PATH ?? playwrightBrowsersRoot(); const stickyNoteFixture = readFileSync( resolve(import.meta.dirname, '../realm/Wiki/sticky-note.json'), 'utf8', @@ -344,6 +356,9 @@ module('factory-entrypoint integration', function () { encoding: 'utf8', env: { ...process.env, + ...(playwrightBrowsersPath + ? { PLAYWRIGHT_BROWSERS_PATH: playwrightBrowsersPath } + : {}), HOME: tempHome, }, }, @@ -454,6 +469,9 @@ module('factory-entrypoint integration', function () { encoding: 'utf8', env: { ...process.env, + ...(playwrightBrowsersPath + ? { PLAYWRIGHT_BROWSERS_PATH: playwrightBrowsersPath } + : {}), HOME: '/tmp/no-boxel-cli-here', }, },