From ba5fedd5c53e515e6d7d9995a7bd7b86dacde1ac Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 15 May 2026 10:09:53 +0200 Subject: [PATCH 01/11] Add `boxel lint` top-level command Lints every lintable (.gts/.gjs/.ts/.js) file in a realm via the realm `_lint` endpoint, or a single file when a realm-relative path is passed. Aggregates per-file violations into a single summary; exits non-zero on any error-severity violation. This is the realm-wide companion to the existing single-file `boxel file lint ` command. Closes the first gap from the Phase 1 runbook (CS-11149): the software factory's `runLintInMemory` validator becomes reachable from an interactive Claude Code session via Bash. `software-factory` keeps its in-process `runLintInMemory` for now; both coexist during the migration window. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/boxel-cli/src/build-program.ts | 2 + packages/boxel-cli/src/commands/lint.ts | 280 ++++++++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 packages/boxel-cli/src/commands/lint.ts diff --git a/packages/boxel-cli/src/build-program.ts b/packages/boxel-cli/src/build-program.ts index 38921e9a46c..1a38e5fac3e 100644 --- a/packages/boxel-cli/src/build-program.ts +++ b/packages/boxel-cli/src/build-program.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { profileCommand } from './commands/profile'; import { registerConsolidateWorkspacesCommand } from './commands/consolidate-workspaces'; +import { registerLintCommand } from './commands/lint'; import { registerReadTranspiledCommand } from './commands/read-transpiled'; import { registerRealmCommand } from './commands/realm/index'; import { registerFileCommand } from './commands/file/index'; @@ -85,6 +86,7 @@ Environment variables (for 'add'): ); registerFileCommand(program); + registerLintCommand(program); registerRealmCommand(program); registerRunCommand(program); registerSearchCommand(program); diff --git a/packages/boxel-cli/src/commands/lint.ts b/packages/boxel-cli/src/commands/lint.ts new file mode 100644 index 00000000000..b46c5f6063f --- /dev/null +++ b/packages/boxel-cli/src/commands/lint.ts @@ -0,0 +1,280 @@ +import type { Command } from 'commander'; +import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; +import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type'; +import { + getProfileManager, + NO_ACTIVE_PROFILE_ERROR, + type ProfileManager, +} from '../lib/profile-manager'; +import { FG_RED, FG_YELLOW, DIM, RESET } from '../lib/colors'; +import { cliLog } from '../lib/cli-log'; +import { lint as lintSingleFile, type LintMessage } from './file/lint'; +import { listFiles } from './file/list'; + +const LINTABLE_EXTENSIONS = ['.gts', '.gjs', '.ts', '.js'] as const; + +export interface LintRealmViolation { + rule: string | null; + file: string; + line: number; + column: number; + message: string; + severity: 'error' | 'warning'; +} + +export interface LintRealmResult { + status: 'passed' | 'failed' | 'error'; + filesChecked: number; + filesWithErrors: number; + errorCount: number; + warningCount: number; + durationMs: number; + lintableFiles: string[]; + violations: LintRealmViolation[]; + errorMessage?: string; +} + +export interface LintRealmOptions { + /** Optional realm-relative path. When set, lints only that file. */ + path?: string; + profileManager?: ProfileManager; +} + +/** + * Lint every lintable file (`.gts`, `.gjs`, `.ts`, `.js`) in a realm, + * or a single file when `options.path` is set. Source is fetched from + * the realm; the realm's `_lint` endpoint runs ESLint + Prettier with + * the `@cardstack/boxel` rules. + */ +export async function lintRealm( + realmUrl: string, + options?: LintRealmOptions, +): Promise { + let pm = options?.profileManager ?? getProfileManager(); + let active = pm.getActiveProfile(); + if (!active) { + return emptyErrorResult(NO_ACTIVE_PROFILE_ERROR); + } + + let normalizedRealmUrl = ensureTrailingSlash(realmUrl); + let startedAt = Date.now(); + + let lintableFiles: string[]; + if (options?.path) { + let path = options.path; + if (!LINTABLE_EXTENSIONS.some((ext) => path.endsWith(ext))) { + return emptyErrorResult( + `Path "${path}" is not lintable — must end with one of ${LINTABLE_EXTENSIONS.join(', ')}`, + ); + } + lintableFiles = [path]; + } else { + let listResult = await listFiles(normalizedRealmUrl, { + profileManager: pm, + }); + if (listResult.error) { + return emptyErrorResult( + `Failed to list realm files: ${listResult.error}`, + ); + } + lintableFiles = listResult.filenames.filter((f) => + LINTABLE_EXTENSIONS.some((ext) => f.endsWith(ext)), + ); + } + + if (lintableFiles.length === 0) { + return { + status: 'passed', + filesChecked: 0, + filesWithErrors: 0, + errorCount: 0, + warningCount: 0, + durationMs: Date.now() - startedAt, + lintableFiles: [], + violations: [], + }; + } + + let violations: LintRealmViolation[] = []; + let filesWithErrors = 0; + let errorCount = 0; + let warningCount = 0; + + for (let file of lintableFiles) { + let source: string; + try { + let readUrl = new URL(file, normalizedRealmUrl).href; + let response = await pm.authedRealmFetch(readUrl, { + method: 'GET', + headers: { Accept: SupportedMimeType.CardSource }, + }); + if (!response.ok) { + let body = await response.text().catch(() => '(no body)'); + recordReadError( + file, + `HTTP ${response.status}: ${body.slice(0, 300)}`, + violations, + ); + filesWithErrors += 1; + errorCount += 1; + continue; + } + source = await response.text(); + } catch (err) { + recordReadError( + file, + err instanceof Error ? err.message : String(err), + violations, + ); + filesWithErrors += 1; + errorCount += 1; + continue; + } + + let result = await lintSingleFile(normalizedRealmUrl, source, file, { + profileManager: pm, + }); + + if (!result.ok) { + recordReadError(file, result.error ?? 'lint failed', violations); + filesWithErrors += 1; + errorCount += 1; + continue; + } + + let fileHasError = false; + for (let msg of result.messages ?? []) { + let severity: 'error' | 'warning' = + msg.severity === 2 ? 'error' : 'warning'; + violations.push({ + rule: msg.ruleId, + file, + line: msg.line, + column: msg.column, + message: msg.message, + severity, + }); + if (severity === 'error') { + errorCount += 1; + fileHasError = true; + } else { + warningCount += 1; + } + } + if (fileHasError) filesWithErrors += 1; + } + + return { + status: errorCount === 0 ? 'passed' : 'failed', + filesChecked: lintableFiles.length, + filesWithErrors, + errorCount, + warningCount, + durationMs: Date.now() - startedAt, + lintableFiles, + violations, + }; +} + +function recordReadError( + file: string, + detail: string, + violations: LintRealmViolation[], +): void { + violations.push({ + rule: 'lint-error', + file, + line: 0, + column: 0, + message: detail, + severity: 'error', + }); +} + +function emptyErrorResult(message: string): LintRealmResult { + return { + status: 'error', + filesChecked: 0, + filesWithErrors: 0, + errorCount: 0, + warningCount: 0, + durationMs: 0, + lintableFiles: [], + violations: [], + errorMessage: message, + }; +} + +interface LintCliOptions { + realm: string; + json?: boolean; +} + +export function registerLintCommand(program: Command): void { + program + .command('lint') + .description( + 'Lint every lintable (.gts/.gjs/.ts/.js) file in a realm via the realm lint endpoint. Pass a realm-relative path to lint a single file.', + ) + .argument( + '[path]', + 'Optional realm-relative file path. When omitted, lints every lintable file in the realm.', + ) + .requiredOption('--realm ', 'The realm URL to lint against') + .option('--json', 'Output structured JSON result') + .action(async (path: string | undefined, opts: LintCliOptions) => { + let result: LintRealmResult; + try { + result = await lintRealm(opts.realm, path ? { path } : {}); + } catch (err) { + console.error( + `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + + if (opts.json) { + cliLog.output(JSON.stringify(result, null, 2)); + if (result.status !== 'passed') { + process.exit(1); + } + return; + } + + if (result.errorMessage) { + console.error(`${FG_RED}Error:${RESET} ${result.errorMessage}`); + process.exit(1); + } + + if (result.violations.length === 0) { + console.log( + `${DIM}No lint issues found (${result.filesChecked} file(s) checked).${RESET}`, + ); + return; + } + + let currentFile: string | undefined; + for (let v of result.violations) { + if (v.file !== currentFile) { + currentFile = v.file; + console.log(`\n${DIM}${v.file}${RESET}`); + } + let color = v.severity === 'error' ? FG_RED : FG_YELLOW; + let rule = v.rule ? ` (${v.rule})` : ''; + console.log( + ` ${color}${v.severity}${RESET} ${v.line}:${v.column} ${v.message}${DIM}${rule}${RESET}`, + ); + } + + console.log( + `\n${DIM}${result.errorCount} error(s), ${result.warningCount} warning(s) across ${result.filesChecked} file(s)${RESET}`, + ); + + if (result.errorCount > 0) { + process.exit(1); + } + }); +} + +// Re-export for callers that want the type alongside the function. +export type { LintMessage }; From c2dfa2ac7e42a1f6ce2d6f744de9c7750f27391c Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 15 May 2026 10:26:45 +0200 Subject: [PATCH 02/11] Add `boxel parse` top-level command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifts the factory's glint runner + JSON document validator from `packages/software-factory/src/parse-execution.ts` into a top-level `boxel parse` command in boxel-cli. Behavior matches the existing factory tool: - Without a path: discovers every `.gts` / `.gjs` / `.ts` in the realm plus every `.json` file linked as a `Spec.linkedExamples`, runs glint (`ember-tsc`) over the GTS batch in a temp dir with monorepo-aware tsconfig paths, and validates the document structure of each JSON example. - With a path: parses just that single file (GTS → glint, JSON → document validation). Path resolution is anchored on this file's `__dirname`, so the command requires the Boxel monorepo layout — `packages/base`, `packages/host`, `packages/boxel-ui`, and `@glint/ember-tsc` (added as a boxel-cli devDependency) must all be resolvable. This is a factory-developer tool, not an end-user CLI feature. The factory keeps its own copy of `parse-execution.ts` for now; both coexist during the CS-11149 migration window. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/boxel-cli/package.json | 1 + packages/boxel-cli/src/build-program.ts | 2 + packages/boxel-cli/src/commands/parse.ts | 711 +++++++++++++++++++++++ pnpm-lock.yaml | 3 + 4 files changed, 717 insertions(+) create mode 100644 packages/boxel-cli/src/commands/parse.ts diff --git a/packages/boxel-cli/package.json b/packages/boxel-cli/package.json index 40957320605..eab722f4e11 100644 --- a/packages/boxel-cli/package.json +++ b/packages/boxel-cli/package.json @@ -39,6 +39,7 @@ "@cardstack/local-types": "workspace:*", "@cardstack/postgres": "workspace:*", "@cardstack/runtime-common": "workspace:*", + "@glint/ember-tsc": "catalog:", "content-tag": "catalog:", "@types/jsonwebtoken": "catalog:", "@types/node": "catalog:", diff --git a/packages/boxel-cli/src/build-program.ts b/packages/boxel-cli/src/build-program.ts index 1a38e5fac3e..0faca9ac7c8 100644 --- a/packages/boxel-cli/src/build-program.ts +++ b/packages/boxel-cli/src/build-program.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { profileCommand } from './commands/profile'; import { registerConsolidateWorkspacesCommand } from './commands/consolidate-workspaces'; import { registerLintCommand } from './commands/lint'; +import { registerParseCommand } from './commands/parse'; import { registerReadTranspiledCommand } from './commands/read-transpiled'; import { registerRealmCommand } from './commands/realm/index'; import { registerFileCommand } from './commands/file/index'; @@ -87,6 +88,7 @@ Environment variables (for 'add'): registerFileCommand(program); registerLintCommand(program); + registerParseCommand(program); registerRealmCommand(program); registerRunCommand(program); registerSearchCommand(program); diff --git a/packages/boxel-cli/src/commands/parse.ts b/packages/boxel-cli/src/commands/parse.ts new file mode 100644 index 00000000000..4bb9e768721 --- /dev/null +++ b/packages/boxel-cli/src/commands/parse.ts @@ -0,0 +1,711 @@ +import type { Command } from 'commander'; +import { execFile } from 'node:child_process'; +import { + mkdtempSync, + mkdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; + +import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; +import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type'; + +import { + getProfileManager, + NO_ACTIVE_PROFILE_ERROR, + type ProfileManager, +} from '../lib/profile-manager'; +import { FG_RED, DIM, RESET } from '../lib/colors'; +import { cliLog } from '../lib/cli-log'; +import { search } from './search'; + +/** + * Inlined to avoid cascading the runtime-common index's URL-style + * imports (`https://cardstack.com/base/*`) into boxel-cli's + * tsconfig, which doesn't carry the monorepo path mappings the + * factory's tsconfig does. Equivalent to `specRef` in + * `@cardstack/runtime-common/constants`. + */ +const SPEC_TYPE = { + module: 'https://cardstack.com/base/spec', + name: 'Spec', +} as const; + +/** + * `boxel parse` runs glint (`ember-tsc`) over `.gts` / `.gjs` / `.ts` + * files in a realm and validates the document structure of any + * `.json` files linked as `Spec.linkedExamples`. Source is fetched + * from the realm; type-checking happens locally. + * + * Path resolution assumes a Boxel monorepo layout — `packages/base`, + * `packages/host`, `packages/boxel-ui`, and `@glint/ember-tsc` are + * discovered relative to this file. The published CLI installed + * outside the monorepo will not be able to run this command (the + * binary won't be present and the type-path mappings won't resolve); + * `boxel parse` is a factory-developer tool, not an end-user one. + * + * Lifted from `packages/software-factory/src/parse-execution.ts` + * during CS-11149 so the same engine is reachable from a + * subscription-billed Claude Code session via Bash. + */ + +const PARSEABLE_GTS_EXTENSIONS = ['.gts', '.gjs', '.ts'] as const; +const PARSEABLE_JSON_EXTENSION = '.json'; + +/** + * Monorepo layout: this file lives at + * `packages/boxel-cli/src/commands/parse.ts`. Up three levels reaches + * `packages/`, alongside `base`, `host`, `boxel-ui`, etc. + */ +const PACKAGES_PATH = resolve(__dirname, '..', '..', '..'); +const BASE_PKG_PATH = join(PACKAGES_PATH, 'base'); +const HOST_PKG_PATH = join(PACKAGES_PATH, 'host'); +const BOXEL_UI_PATH = join(PACKAGES_PATH, 'boxel-ui', 'addon', 'src'); +const NODE_MODULES_PATH = join(HOST_PKG_PATH, 'node_modules'); + +let cachedTsconfigContent: string | undefined; + +export interface ParseError { + file: string; + line: number; + column: number; + message: string; +} + +export interface ParseRealmResult { + status: 'passed' | 'failed' | 'error'; + filesChecked: number; + filesWithErrors: number; + errorCount: number; + durationMs: number; + parseableFiles: string[]; + errors: ParseError[]; + errorMessage?: string; +} + +export interface ParseRealmOptions { + /** + * Optional realm-relative path. When set, parses only this file. + * `.gts` / `.gjs` / `.ts` paths run through glint; + * `.json` paths are validated for card document structure. + */ + path?: string; + profileManager?: ProfileManager; +} + +interface SpecExampleInfo { + specId: string; + exampleUrls: string[]; +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +export async function parseRealm( + realmUrl: string, + options?: ParseRealmOptions, +): Promise { + let pm = options?.profileManager ?? getProfileManager(); + let active = pm.getActiveProfile(); + if (!active) { + return emptyErrorResult(NO_ACTIVE_PROFILE_ERROR); + } + + let normalizedRealmUrl = ensureTrailingSlash(realmUrl); + let startedAt = Date.now(); + + let gtsFiles: string[] = []; + let jsonFiles: string[] = []; + + if (options?.path) { + let path = options.path; + if (PARSEABLE_GTS_EXTENSIONS.some((ext) => path.endsWith(ext))) { + gtsFiles = [path]; + } else if (path.endsWith(PARSEABLE_JSON_EXTENSION)) { + jsonFiles = [path]; + } else { + return emptyErrorResult( + `Path "${path}" is not parseable — must end with one of ${PARSEABLE_GTS_EXTENSIONS.join(', ')}, or ${PARSEABLE_JSON_EXTENSION}`, + ); + } + } else { + try { + [gtsFiles, jsonFiles] = await Promise.all([ + discoverParseableGtsFiles(normalizedRealmUrl, pm), + discoverJsonExampleFiles(normalizedRealmUrl, pm), + ]); + } catch (err) { + return emptyErrorResult( + `Failed to discover parseable files: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + let parseableFiles = [...gtsFiles, ...jsonFiles]; + + if (parseableFiles.length === 0) { + return { + status: 'passed', + filesChecked: 0, + filesWithErrors: 0, + errorCount: 0, + durationMs: Date.now() - startedAt, + parseableFiles: [], + errors: [], + }; + } + + let errors: ParseError[] = []; + let filesWithErrors = new Set(); + + if (gtsFiles.length > 0) { + let gtsContents: { path: string; content: string }[] = []; + for (let file of gtsFiles) { + let readResult = await fetchSource(normalizedRealmUrl, file, pm); + if (!readResult.ok) { + errors.push({ + file, + line: 0, + column: 0, + message: `Could not read ${file}: ${readResult.error}`, + }); + filesWithErrors.add(file); + continue; + } + gtsContents.push({ path: file, content: readResult.content }); + } + + if (gtsContents.length > 0) { + try { + let glintErrors = await runGlintCheck(gtsContents); + for (let e of glintErrors) { + errors.push(e); + filesWithErrors.add(e.file); + } + } catch (err) { + let firstFile = gtsContents[0].path; + errors.push({ + file: firstFile, + line: 0, + column: 0, + message: `Glint check failed: ${err instanceof Error ? err.message : String(err)}`, + }); + filesWithErrors.add(firstFile); + } + } + } + + for (let jsonUrl of jsonFiles) { + let readResult = await fetchSource(normalizedRealmUrl, jsonUrl, pm); + if (!readResult.ok) { + errors.push({ + file: jsonUrl, + line: 0, + column: 0, + message: `Could not read ${jsonUrl}: ${readResult.error}`, + }); + filesWithErrors.add(jsonUrl); + continue; + } + let jsonErrors = parseJsonFile(jsonUrl, readResult.content); + for (let e of jsonErrors) { + errors.push(e); + filesWithErrors.add(e.file); + } + } + + return { + status: errors.length === 0 ? 'passed' : 'failed', + filesChecked: parseableFiles.length, + filesWithErrors: filesWithErrors.size, + errorCount: errors.length, + durationMs: Date.now() - startedAt, + parseableFiles, + errors, + }; +} + +// --------------------------------------------------------------------------- +// File discovery +// --------------------------------------------------------------------------- + +async function discoverParseableGtsFiles( + realmUrl: string, + pm: ProfileManager, +): Promise { + let mtimesUrl = `${realmUrl}_mtimes`; + let response = await pm.authedRealmFetch(mtimesUrl, { + method: 'GET', + headers: { Accept: SupportedMimeType.Mtimes }, + }); + if (!response.ok) { + let body = await response.text().catch(() => '(no body)'); + throw new Error( + `_mtimes returned HTTP ${response.status}: ${body.slice(0, 300)}`, + ); + } + let json = (await response.json()) as { + data?: { attributes?: { mtimes?: Record } }; + }; + let mtimes = + json?.data?.attributes?.mtimes ?? + (json as unknown as Record); + + let filenames: string[] = []; + for (let fullUrl of Object.keys(mtimes)) { + if (!fullUrl.startsWith(realmUrl)) continue; + let relativePath = fullUrl.slice(realmUrl.length); + if (!relativePath || relativePath.endsWith('/')) continue; + if (PARSEABLE_GTS_EXTENSIONS.some((ext) => relativePath.endsWith(ext))) { + filenames.push(relativePath); + } + } + return filenames.sort(); +} + +async function discoverJsonExampleFiles( + realmUrl: string, + pm: ProfileManager, +): Promise { + let searchResult = await search( + realmUrl, + { filter: { type: SPEC_TYPE } }, + { profileManager: pm }, + ); + if (!searchResult.ok) { + return []; + } + + let specs: SpecExampleInfo[] = []; + for (let card of searchResult.data ?? []) { + let specId = (card as Record).id as string | undefined; + if (!specId) continue; + + let attributes = (card as Record).attributes as + | Record + | undefined; + if (!attributes) continue; + let specType = attributes.specType as string | undefined; + if (specType === 'field') continue; + + let relationships = (card as Record).relationships as + | Record + | undefined; + let rawExampleUrls = extractLinkedExamples(relationships); + let specCardUrl = new URL(specId, realmUrl).href; + let exampleUrls: string[] = []; + for (let rawUrl of rawExampleUrls) { + let absoluteUrl = new URL(rawUrl, specCardUrl).href; + if (absoluteUrl.startsWith(realmUrl)) { + exampleUrls.push(absoluteUrl.slice(realmUrl.length)); + } + } + specs.push({ specId, exampleUrls }); + } + + let urls: string[] = []; + for (let spec of specs) { + for (let url of spec.exampleUrls) { + let normalized = url.endsWith(PARSEABLE_JSON_EXTENSION) + ? url + : `${url}${PARSEABLE_JSON_EXTENSION}`; + if (!urls.includes(normalized)) urls.push(normalized); + } + } + return urls.sort(); +} + +function extractLinkedExamples( + relationships: Record | undefined, +): string[] { + if (!relationships) return []; + let urls: string[] = []; + for (let i = 0; ; i++) { + let entry = relationships[`linkedExamples.${i}`] as + | { links?: { self?: string } } + | undefined; + if (!entry?.links?.self) break; + urls.push(entry.links.self); + } + if (urls.length === 0) { + let examples = relationships['linkedExamples'] as + | { links?: { self?: string } } + | undefined; + if (examples?.links?.self) urls.push(examples.links.self); + } + return urls; +} + +// --------------------------------------------------------------------------- +// Source reading +// --------------------------------------------------------------------------- + +async function fetchSource( + realmUrl: string, + path: string, + pm: ProfileManager, +): Promise<{ ok: true; content: string } | { ok: false; error: string }> { + try { + let readUrl = new URL(path, realmUrl).href; + let response = await pm.authedRealmFetch(readUrl, { + method: 'GET', + headers: { Accept: SupportedMimeType.CardSource }, + }); + if (!response.ok) { + let body = await response.text().catch(() => '(no body)'); + return { + ok: false, + error: `HTTP ${response.status}: ${body.slice(0, 300)}`, + }; + } + return { ok: true, content: await response.text() }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +// --------------------------------------------------------------------------- +// Glint (ember-tsc) type checking +// --------------------------------------------------------------------------- + +/** + * Run `ember-tsc --noEmit` against a set of `.gts` / `.gjs` / `.ts` + * files in a temp dir. Symlinks the host package's node_modules and + * writes a tsconfig with the same monorepo path mappings the realm + * uses at runtime, then parses TS diagnostics from stdout. + */ +async function runGlintCheck( + files: { path: string; content: string }[], +): Promise { + let tempDir = mkdtempSync(join(tmpdir(), 'boxel-parse-')); + + try { + for (let file of files) { + let normalized = join(tempDir, file.path); + let resolved = resolve(normalized); + if (!resolved.startsWith(tempDir + '/')) continue; + mkdirSync(dirname(resolved), { recursive: true }); + writeFileSync(resolved, file.content, 'utf8'); + } + + if (!cachedTsconfigContent) { + let tsconfig = { + compilerOptions: { + target: 'es2022', + allowJs: true, + moduleResolution: 'bundler', + allowSyntheticDefaultImports: true, + noEmit: true, + baseUrl: '.', + module: 'es2022', + strict: true, + experimentalDecorators: true, + skipLibCheck: true, + noUnusedLocals: false, + noUnusedParameters: false, + types: ['qunit-dom', '@cardstack/local-types'], + paths: { + 'https://cardstack.com/base/*': [`${BASE_PKG_PATH}/*`], + '@cardstack/host/tests/*': [`${HOST_PKG_PATH}/tests/*`], + '@cardstack/host/*': [`${HOST_PKG_PATH}/app/*`], + '@cardstack/boxel-host/commands/*': [ + `${HOST_PKG_PATH}/app/commands/*`, + ], + '@cardstack/boxel-ui/*': [`${BOXEL_UI_PATH}/*`], + '*': [`${HOST_PKG_PATH}/types/*`], + }, + }, + include: ['**/*.ts', '**/*.gts', '**/*.gjs'], + exclude: ['node_modules'], + }; + cachedTsconfigContent = JSON.stringify(tsconfig, null, 2); + } + writeFileSync( + join(tempDir, 'tsconfig.json'), + cachedTsconfigContent, + 'utf8', + ); + + symlinkSync(NODE_MODULES_PATH, join(tempDir, 'node_modules')); + + let emberTscBin = resolve( + __dirname, + '..', + '..', + 'node_modules', + '.bin', + 'ember-tsc', + ); + + let { output, exitedWithError } = await new Promise<{ + output: string; + exitedWithError: boolean; + }>((resolvePromise, reject) => { + let child = execFile( + emberTscBin, + ['--noEmit', '--project', join(tempDir, 'tsconfig.json')], + { + cwd: tempDir, + timeout: 120_000, + maxBuffer: 10 * 1024 * 1024, + }, + (error, stdout, stderr) => { + if (error && !stdout && !stderr) { + reject(new Error(`ember-tsc execution failed: ${error.message}`)); + return; + } + if (child.killed || error?.killed) { + reject(new Error('ember-tsc was killed (timeout or signal)')); + return; + } + resolvePromise({ + output: stdout + stderr, + exitedWithError: !!error, + }); + }, + ); + }); + + let errors: ParseError[] = []; + let totalDiagnosticLines = 0; + for (let line of output.split('\n')) { + let match = line.match( + /^(.+?)\((\d+),(\d+)\):\s*error\s+(TS\d+):\s*(.+)/, + ); + if (!match) continue; + + totalDiagnosticLines++; + + let [, filePath, lineStr, colStr, tsCode, message] = match; + let absolutePath = resolve(tempDir, filePath); + if (!absolutePath.startsWith(tempDir)) continue; + + if (tsCode === 'TS2353' && message.includes("'scoped'")) continue; + + let realmPath = absolutePath.slice(tempDir.length + 1); + let originalFile = files.find((f) => f.path === realmPath); + if (!originalFile) continue; + + errors.push({ + file: originalFile.path, + line: parseInt(lineStr, 10), + column: parseInt(colStr, 10), + message, + }); + } + + if (exitedWithError && errors.length === 0 && totalDiagnosticLines === 0) { + let truncatedOutput = output.slice(0, 500).trim(); + errors.push({ + file: files[0]?.path ?? 'unknown', + line: 0, + column: 0, + message: `ember-tsc exited with errors but produced no TS diagnostics. Check the tsconfig paths and node_modules symlink. Output: ${truncatedOutput}`, + }); + } + + return errors; + } finally { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + } +} + +// --------------------------------------------------------------------------- +// JSON document validation +// --------------------------------------------------------------------------- + +function parseJsonFile(filename: string, source: string): ParseError[] { + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch (err) { + let message = err instanceof Error ? err.message : String(err); + return [ + { + file: filename, + line: 0, + column: 0, + message: `Invalid JSON: ${message}`, + }, + ]; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return [ + { + file: filename, + line: 0, + column: 0, + message: 'Card document must be a JSON object', + }, + ]; + } + return validateCardDocumentStructure( + filename, + parsed as { data: Record }, + ); +} + +function validateCardDocumentStructure( + filename: string, + doc: { data: Record }, +): ParseError[] { + let errors: ParseError[] = []; + let data = doc.data; + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + errors.push({ + file: filename, + line: 0, + column: 0, + message: 'Card document must have a "data" object', + }); + return errors; + } + let dataObj = data as Record; + if (typeof dataObj.type !== 'string') { + errors.push({ + file: filename, + line: 0, + column: 0, + message: 'Card document "data.type" must be a string', + }); + } + let meta = dataObj.meta as Record | undefined; + if (typeof meta !== 'object' || meta === null) { + errors.push({ + file: filename, + line: 0, + column: 0, + message: 'Card document must have a "data.meta" object', + }); + } else { + let adoptsFrom = meta.adoptsFrom as Record | undefined; + if (typeof adoptsFrom !== 'object' || adoptsFrom === null) { + errors.push({ + file: filename, + line: 0, + column: 0, + message: + 'Card document must have a "data.meta.adoptsFrom" object with "module" and "name"', + }); + } else { + if (typeof adoptsFrom.module !== 'string') { + errors.push({ + file: filename, + line: 0, + column: 0, + message: '"data.meta.adoptsFrom.module" must be a string', + }); + } + if (typeof adoptsFrom.name !== 'string') { + errors.push({ + file: filename, + line: 0, + column: 0, + message: '"data.meta.adoptsFrom.name" must be a string', + }); + } + } + } + return errors; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function emptyErrorResult(message: string): ParseRealmResult { + return { + status: 'error', + filesChecked: 0, + filesWithErrors: 0, + errorCount: 0, + durationMs: 0, + parseableFiles: [], + errors: [], + errorMessage: message, + }; +} + +// --------------------------------------------------------------------------- +// CLI surface +// --------------------------------------------------------------------------- + +interface ParseCliOptions { + realm: string; + json?: boolean; +} + +export function registerParseCommand(program: Command): void { + program + .command('parse') + .description( + "Type-check every .gts / .gjs / .ts file in a realm with glint, plus validate the document structure of any .json files linked as Spec.linkedExamples. Pass a realm-relative path to parse a single file. Monorepo-only (relies on packages/base, packages/host, packages/boxel-ui, and @glint/ember-tsc resolvable from this CLI's location).", + ) + .argument( + '[path]', + 'Optional realm-relative file path. When omitted, parses every parseable file (gts/gjs/ts + Spec linkedExamples JSON) in the realm.', + ) + .requiredOption('--realm ', 'The realm URL to parse against') + .option('--json', 'Output structured JSON result') + .action(async (path: string | undefined, opts: ParseCliOptions) => { + let result: ParseRealmResult; + try { + result = await parseRealm(opts.realm, path ? { path } : {}); + } catch (err) { + console.error( + `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + + if (opts.json) { + cliLog.output(JSON.stringify(result, null, 2)); + if (result.status !== 'passed') { + process.exit(1); + } + return; + } + + if (result.errorMessage) { + console.error(`${FG_RED}Error:${RESET} ${result.errorMessage}`); + process.exit(1); + } + + if (result.errors.length === 0) { + console.log( + `${DIM}No parse errors (${result.filesChecked} file(s) checked).${RESET}`, + ); + return; + } + + let currentFile: string | undefined; + for (let e of result.errors) { + if (e.file !== currentFile) { + currentFile = e.file; + console.log(`\n${DIM}${e.file}${RESET}`); + } + console.log( + ` ${FG_RED}error${RESET} ${e.line}:${e.column} ${e.message}`, + ); + } + + console.log( + `\n${DIM}${result.errorCount} error(s) across ${result.filesWithErrors} of ${result.filesChecked} file(s)${RESET}`, + ); + + if (result.errorCount > 0) { + process.exit(1); + } + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba51f78d440..4934040bf37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1044,6 +1044,9 @@ importers: '@cardstack/runtime-common': specifier: workspace:* version: link:../runtime-common + '@glint/ember-tsc': + specifier: 'catalog:' + version: 1.5.0(typescript@5.9.3) '@types/jsonwebtoken': specifier: 'catalog:' version: 9.0.10 From d665cd262f13c387d27d3909d8c6d74a60f6ada0 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 15 May 2026 10:32:01 +0200 Subject: [PATCH 03/11] Add `boxel test` top-level command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifts the factory's in-memory QUnit runner (`runTestsInMemory`) from `packages/software-factory/src/test-run-execution.ts` into a top-level `boxel test` command in boxel-cli. The runner: - Discovers every `*.test.gts` file in the realm. - Locates the host app's compiled `dist/` (env override, sibling packages/host, or the root checkout when in a git worktree). - Spins up a tiny HTTP server that serves the host's test bundles + a synthesized QUnit harness page with live-test enabled. - Drives a headless Chromium against that page with the realm URL in the query string; injects the per-realm JWT (if the active profile has one) via `page.route()` so private realms can be reached. - Collects per-test QUnit results via `QUnit.on('testEnd' / 'runEnd')` hooks and aggregates them into pass/fail/skip counts + per-failure details. Unlike the factory's `executeTestRunFromRealm`, this command does NOT create or update a TestRun card — results are returned in-memory only. Card persistence is the agent's responsibility in the new Phase 1 flow. `@playwright/test` is added as a boxel-cli devDependency. The `findHostDistPackageDir` discovery helper is inlined from `@cardstack/realm-test-harness/host-dist` to avoid pulling the harness in as a dependency. Like `boxel parse`, this is a monorepo-only command and not usable from the published CLI. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/boxel-cli/package.json | 1 + packages/boxel-cli/src/build-program.ts | 2 + packages/boxel-cli/src/commands/test.ts | 692 ++++++++++++++++++++++++ pnpm-lock.yaml | 3 + 4 files changed, 698 insertions(+) create mode 100644 packages/boxel-cli/src/commands/test.ts diff --git a/packages/boxel-cli/package.json b/packages/boxel-cli/package.json index eab722f4e11..b594bcf4b04 100644 --- a/packages/boxel-cli/package.json +++ b/packages/boxel-cli/package.json @@ -40,6 +40,7 @@ "@cardstack/postgres": "workspace:*", "@cardstack/runtime-common": "workspace:*", "@glint/ember-tsc": "catalog:", + "@playwright/test": "catalog:", "content-tag": "catalog:", "@types/jsonwebtoken": "catalog:", "@types/node": "catalog:", diff --git a/packages/boxel-cli/src/build-program.ts b/packages/boxel-cli/src/build-program.ts index 0faca9ac7c8..ea0e7162f22 100644 --- a/packages/boxel-cli/src/build-program.ts +++ b/packages/boxel-cli/src/build-program.ts @@ -8,6 +8,7 @@ import { registerRealmCommand } from './commands/realm/index'; import { registerFileCommand } from './commands/file/index'; import { registerRunCommand } from './commands/run-command'; import { registerSearchCommand } from './commands/search'; +import { registerTestCommand } from './commands/test'; import { setQuiet } from './lib/cli-log'; import { warnIfMisplacedLocalRealmDirs } from './lib/realm-local-paths'; @@ -92,6 +93,7 @@ Environment variables (for 'add'): registerRealmCommand(program); registerRunCommand(program); registerSearchCommand(program); + registerTestCommand(program); registerReadTranspiledCommand(program); registerConsolidateWorkspacesCommand(program); diff --git a/packages/boxel-cli/src/commands/test.ts b/packages/boxel-cli/src/commands/test.ts new file mode 100644 index 00000000000..812e300cffd --- /dev/null +++ b/packages/boxel-cli/src/commands/test.ts @@ -0,0 +1,692 @@ +import type { Command } from 'commander'; +import { spawnSync } from 'node:child_process'; +import { readFileSync, statSync } from 'node:fs'; +import { createServer, type Server } from 'node:http'; +import { dirname, join, normalize, resolve } from 'node:path'; + +import { chromium } from '@playwright/test'; +import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; + +import { + getProfileManager, + NO_ACTIVE_PROFILE_ERROR, + type ProfileManager, +} from '../lib/profile-manager'; +import { FG_RED, FG_GREEN, DIM, RESET } from '../lib/colors'; +import { cliLog } from '../lib/cli-log'; +import { listFiles } from './file/list'; + +/** + * `boxel test` runs the realm's QUnit test suite by driving a + * headless Chromium instance against the host app's compiled test + * bundle. Lifted from + * `packages/software-factory/src/test-run-execution.ts` (the + * `runTestsInMemory` path) during CS-11149 so the same engine is + * reachable from a subscription-billed Claude Code session via Bash. + * + * Like `boxel parse`, this is a monorepo-only command — it locates + * the host app's `dist/` (test bundles + assets) via either + * `TEST_HARNESS_HOST_DIST_PACKAGE_DIR`, the sibling `packages/host` + * directory, or the root repo's `packages/host` directory when run + * from a git worktree. It does not work in the published CLI. + * + * Unlike the factory's `executeTestRunFromRealm`, this command does + * NOT create or update a TestRun card — it returns in-memory results + * only. Card persistence is the agent's job in the new Phase 1 flow. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface QunitTestResult { + name: string; + module: string; + status: 'passed' | 'failed' | 'skipped' | 'todo'; + runtime: number; + errors: { message: string; stack?: string }[]; +} + +interface QunitRunSummary { + status: 'passed' | 'failed'; + testCounts: { + passed: number; + failed: number; + skipped: number; + todo: number; + total: number; + }; + runtime: number; +} + +interface QunitResults { + tests: QunitTestResult[]; + runEnd: QunitRunSummary | null; +} + +export interface TestFailure { + testName: string; + module: string; + message: string; + stackTrace?: string; +} + +export interface RunTestsResult { + status: 'passed' | 'failed' | 'error'; + passedCount: number; + failedCount: number; + skippedCount: number; + durationMs: number; + /** Realm-relative `.test.gts` paths discovered before the run. */ + testFiles: string[]; + failures: TestFailure[]; + /** Set only when `status === 'error'`. */ + errorMessage?: string; +} + +export interface RunTestsOptions { + /** + * URL of the host app served by the realm-server compat proxy. + * Defaults to the realm server URL from the active profile, which + * is what the dev `mise run dev-all` stack exposes. + */ + hostAppUrl?: string; + /** Path to the host app's dist directory; auto-discovered otherwise. */ + hostDistDir?: string; + /** Stream browser console output to stderr for debugging. */ + debug?: boolean; + profileManager?: ProfileManager; +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +export async function runTestsForRealm( + realmUrl: string, + options?: RunTestsOptions, +): Promise { + let pm = options?.profileManager ?? getProfileManager(); + let active = pm.getActiveProfile(); + if (!active) { + return emptyErrorResult(NO_ACTIVE_PROFILE_ERROR); + } + + let normalizedRealmUrl = ensureTrailingSlash(realmUrl); + let hostAppUrl = ensureTrailingSlash( + options?.hostAppUrl ?? active.profile.realmServerUrl, + ); + + let testFiles: string[]; + try { + let listing = await listFiles(normalizedRealmUrl, { profileManager: pm }); + if (listing.error) { + return emptyErrorResult( + `Failed to discover test files: ${listing.error}`, + ); + } + testFiles = listing.filenames.filter((f) => f.endsWith('.test.gts')); + } catch (err) { + return emptyErrorResult( + `Failed to discover test files: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + if (testFiles.length === 0) { + return { + status: 'passed', + passedCount: 0, + failedCount: 0, + skippedCount: 0, + durationMs: 0, + testFiles: [], + failures: [], + }; + } + + try { + let { qunitResults, durationMs } = await runQunitInBrowser({ + pm, + targetRealm: normalizedRealmUrl, + hostAppUrl, + hostDistDir: options?.hostDistDir, + debug: options?.debug, + }); + + let summary = summarizeQunitResults(qunitResults); + return { + ...summary, + durationMs, + testFiles, + }; + } catch (err) { + let errorMessage = err instanceof Error ? err.message : String(err); + return { + status: 'error', + passedCount: 0, + failedCount: 0, + skippedCount: 0, + durationMs: 0, + testFiles, + failures: [], + errorMessage, + }; + } +} + +// --------------------------------------------------------------------------- +// QUnit Runner +// --------------------------------------------------------------------------- + +interface QunitRunnerOptions { + pm: ProfileManager; + targetRealm: string; + hostAppUrl: string; + hostDistDir?: string; + debug?: boolean; +} + +async function runQunitInBrowser(options: QunitRunnerOptions): Promise<{ + qunitResults: QunitResults; + durationMs: number; +}> { + let start = Date.now(); + let browser; + let testPageServer: Server | undefined; + + try { + let hostDistDir = + options.hostDistDir ?? + join( + findHostDistPackageDir() ?? + resolve(__dirname, '..', '..', '..', 'host'), + 'dist', + ); + + if (!fileExists(join(hostDistDir, 'tests', 'index.html'))) { + throw new Error( + `Host app dist not found at ${hostDistDir}. Build the host app (e.g., \`pnpm --filter @cardstack/host build\`) or set TEST_HARNESS_HOST_DIST_PACKAGE_DIR.`, + ); + } + + let { + url: testPageUrl, + server, + setHtml, + } = await startTestPageServer(hostDistDir); + testPageServer = server; + + let html = buildQunitTestPageHtml({ + assetServerUrl: testPageUrl, + hostDistDir, + realmProxyUrl: options.hostAppUrl, + }); + setHtml(html); + + browser = await chromium.launch({ headless: true }); + let page = await browser.newPage(); + + if (options.debug) { + page.on('console', (msg) => { + process.stderr.write(`[browser ${msg.type()}] ${msg.text()}\n`); + }); + page.on('pageerror', (err) => { + process.stderr.write(`[browser pageerror] ${err.message}\n`); + }); + } + + let realmToken = options.pm.getRealmToken(options.targetRealm); + if (realmToken) { + let realmOrigin = new URL(options.targetRealm).origin; + await page.route(`${realmOrigin}/**`, (route) => { + let headers = { + ...route.request().headers(), + Authorization: realmToken!, + }; + route.continue({ headers }); + }); + } + + let realmParam = encodeURIComponent(options.targetRealm); + let pageUrl = `${testPageUrl}?liveTest=true&realmURL=${realmParam}&hidepassed`; + + await page.goto(pageUrl, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => + (window as unknown as { __qunitResults?: { runEnd: unknown } }) + .__qunitResults?.runEnd !== null, + null, + { timeout: 300_000 }, + ); + + let qunitResults = (await page.evaluate( + () => + (window as unknown as { __qunitResults: QunitResults }).__qunitResults, + )) as QunitResults; + + return { qunitResults, durationMs: Date.now() - start }; + } finally { + if (browser) { + await browser.close().catch(() => {}); + } + if (testPageServer) { + testPageServer.close(); + } + } +} + +// --------------------------------------------------------------------------- +// Test page HTML + asset server +// --------------------------------------------------------------------------- + +function buildQunitTestPageHtml(opts: { + assetServerUrl: string; + hostDistDir: string; + realmProxyUrl: string; +}): string { + let host = opts.assetServerUrl.replace(/\/$/, ''); + let browserOrigin = opts.realmProxyUrl.replace(/\/$/, ''); + + let testIndexPath = resolve(opts.hostDistDir, 'tests', 'index.html'); + let testIndexHtml: string; + try { + testIndexHtml = readFileSync(testIndexPath, 'utf8'); + } catch { + throw new Error( + `Could not read host test page at ${testIndexPath}. Build the host app with test support.`, + ); + } + + let metaTags = (testIndexHtml.match(/]+>/g) ?? []) + .filter((tag) => !tag.includes('charset') && !tag.includes('viewport')) + .map((tag) => { + if (!tag.includes('config/environment')) return tag; + let match = tag.match(/content="([^"]+)"/); + if (!match) return tag; + try { + let config = JSON.parse(decodeURIComponent(match[1])); + if (config.resolvedBaseRealmURL) { + config.resolvedBaseRealmURL = `${browserOrigin}/base/`; + } + if (config.resolvedSkillsRealmURL) { + config.resolvedSkillsRealmURL = `${browserOrigin}/skills/`; + } + if (config.resolvedOpenRouterRealmURL) { + config.resolvedOpenRouterRealmURL = `${browserOrigin}/openrouter/`; + } + if (config.realmServerURL) { + config.realmServerURL = `${browserOrigin}/`; + } + let encoded = encodeURIComponent(JSON.stringify(config)); + return tag.replace(/content="[^"]+"/, `content="${encoded}"`); + } catch { + return tag; + } + }); + + let scriptTags = ( + testIndexHtml.match(/]*src="[^"]*"[^>]*><\/script>/g) ?? [] + ) + .filter( + (tag) => + !tag.includes('testem.js') && !tag.includes('ember-cli-live-reload'), + ) + .map((tag) => tag.replace(/src="\/([^"]*)"/g, `src="${host}/$1"`)); + + let linkTags = ( + testIndexHtml.match(/]*rel="stylesheet"[^>]*>/g) ?? [] + ).map((tag) => tag.replace(/href="\/([^"]*)"/g, `href="${host}/$1"`)); + + let moduleScripts = ( + testIndexHtml.match(/ + + ${moduleScripts.join('\n ')} + ${scriptTags.join('\n ')} + +`; +} + +async function startTestPageServer(hostDistDir: string): Promise<{ + url: string; + server: Server; + setHtml: (h: string) => void; +}> { + let mimeTypes: Record = { + '.js': 'application/javascript', + '.css': 'text/css', + '.map': 'application/json', + '.html': 'text/html', + '.wasm': 'application/wasm', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.woff2': 'font/woff2', + '.woff': 'font/woff', + '.ttf': 'font/ttf', + }; + + let html = ''; + let setHtml = (h: string) => { + html = h; + }; + + return new Promise((res, rej) => { + let server = createServer((req, reply) => { + let url = (req.url ?? '/').split('?')[0]; + + if (url !== '/') { + let normalized = normalize(url.slice(1)); + if (normalized.startsWith('..') || normalized.startsWith('/')) { + reply.writeHead(403); + reply.end('Forbidden'); + return; + } + let filePath = resolve(hostDistDir, normalized); + if (!filePath.startsWith(resolve(hostDistDir))) { + reply.writeHead(403); + reply.end('Forbidden'); + return; + } + try { + let content = readFileSync(filePath); + let ext = filePath.match(/\.[^.]+$/)?.[0] ?? ''; + let contentType = mimeTypes[ext] ?? 'application/octet-stream'; + reply.writeHead(200, { + 'Content-Type': contentType, + 'Access-Control-Allow-Origin': '*', + }); + reply.end(content); + } catch { + reply.writeHead(404); + reply.end('Not found'); + } + return; + } + + reply.writeHead(200, { + 'Content-Type': 'text/html', + 'Access-Control-Allow-Origin': '*', + }); + reply.end(html); + }); + server.on('error', rej); + server.listen(0, '127.0.0.1', () => { + let addr = server.address(); + if (!addr || typeof addr === 'string') { + rej(new Error('Failed to start test page server')); + return; + } + res({ url: `http://127.0.0.1:${addr.port}`, server, setHtml }); + }); + }); +} + +// --------------------------------------------------------------------------- +// Host dist discovery — inlined from @cardstack/realm-test-harness +// --------------------------------------------------------------------------- + +function fileExists(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +function findHostDistPackageDir(): string | undefined { + let packageRoot = resolve(__dirname, '..', '..'); + let workspaceRoot = resolve(packageRoot, '..', '..'); + let hostDir = resolve(packageRoot, '..', 'host'); + + let rootRepoCheckoutDir = findRootRepoCheckoutDir(workspaceRoot); + let rootRepoHostDir = + rootRepoCheckoutDir && rootRepoCheckoutDir !== workspaceRoot + ? resolve(rootRepoCheckoutDir, 'packages', 'host') + : undefined; + + let candidates = [ + process.env.TEST_HARNESS_HOST_DIST_PACKAGE_DIR, + hostDir, + rootRepoHostDir, + ] + .filter((value): value is string => Boolean(value)) + .map((value) => resolve(value)); + + let seen = new Set(); + for (let candidate of candidates) { + if (seen.has(candidate)) continue; + seen.add(candidate); + if (fileExists(join(candidate, 'dist', 'index.html'))) { + return candidate; + } + } + return undefined; +} + +function findRootRepoCheckoutDir(workspaceRoot: string): string | undefined { + let result = spawnSync( + 'git', + ['rev-parse', '--path-format=absolute', '--git-common-dir'], + { + cwd: workspaceRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }, + ); + if (result.status !== 0) return undefined; + let commonDir = result.stdout.trim(); + if (!commonDir.endsWith(`${join('.git')}`)) return undefined; + return dirname(commonDir); +} + +// --------------------------------------------------------------------------- +// QUnit result summarization +// --------------------------------------------------------------------------- + +interface QunitSummary { + status: 'passed' | 'failed' | 'error'; + passedCount: number; + failedCount: number; + skippedCount: number; + failures: TestFailure[]; + errorMessage?: string; +} + +function summarizeQunitResults(results: QunitResults): QunitSummary { + if (!results.runEnd) { + return { + status: 'error', + passedCount: 0, + failedCount: 0, + skippedCount: 0, + failures: [], + errorMessage: 'QUnit did not complete — runEnd event was not received', + }; + } + + let passedCount = 0; + let failedCount = 0; + let skippedCount = 0; + let failures: TestFailure[] = []; + + for (let test of results.tests) { + if (test.status === 'failed') { + failedCount += 1; + let firstError = test.errors[0]; + failures.push({ + testName: test.name, + module: test.module || 'default', + message: firstError?.message ?? 'Test failed', + ...(firstError?.stack + ? { stackTrace: firstError.stack.slice(0, 500) } + : {}), + }); + } else if (test.status === 'skipped' || test.status === 'todo') { + skippedCount += 1; + } else { + passedCount += 1; + } + } + + let status: QunitSummary['status']; + if (results.tests.length === 0) { + status = 'error'; + } else if (failedCount > 0) { + status = 'failed'; + } else if (passedCount === 0 && skippedCount > 0) { + status = 'failed'; + } else { + status = 'passed'; + } + + return { status, passedCount, failedCount, skippedCount, failures }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function emptyErrorResult(message: string): RunTestsResult { + return { + status: 'error', + passedCount: 0, + failedCount: 0, + skippedCount: 0, + durationMs: 0, + testFiles: [], + failures: [], + errorMessage: message, + }; +} + +// --------------------------------------------------------------------------- +// CLI surface +// --------------------------------------------------------------------------- + +interface TestCliOptions { + realm: string; + hostAppUrl?: string; + hostDistDir?: string; + debug?: boolean; + json?: boolean; +} + +export function registerTestCommand(program: Command): void { + program + .command('test') + .description( + "Run the realm's QUnit test suite (every `*.test.gts` file) in a headless Chromium driven against the host app. Monorepo-only: relies on the host app's compiled `dist/` being reachable from this CLI's location (or via TEST_HARNESS_HOST_DIST_PACKAGE_DIR).", + ) + .requiredOption('--realm ', 'The realm URL to test') + .option( + '--host-app-url ', + "Host app URL (compat proxy). Defaults to the active profile's realm-server URL.", + ) + .option( + '--host-dist-dir ', + 'Override the host app dist directory used to build the test page.', + ) + .option('--debug', 'Stream browser console output to stderr') + .option('--json', 'Output structured JSON result') + .action(async (opts: TestCliOptions) => { + let result: RunTestsResult; + try { + result = await runTestsForRealm(opts.realm, { + ...(opts.hostAppUrl ? { hostAppUrl: opts.hostAppUrl } : {}), + ...(opts.hostDistDir ? { hostDistDir: opts.hostDistDir } : {}), + ...(opts.debug ? { debug: true } : {}), + }); + } catch (err) { + console.error( + `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + + if (opts.json) { + cliLog.output(JSON.stringify(result, null, 2)); + if (result.status !== 'passed') { + process.exit(1); + } + return; + } + + if (result.errorMessage) { + console.error(`${FG_RED}Error:${RESET} ${result.errorMessage}`); + } + + if (result.testFiles.length === 0) { + console.log(`${DIM}No .test.gts files found in the realm.${RESET}`); + return; + } + + if (result.failures.length > 0) { + for (let f of result.failures) { + console.log( + `\n${FG_RED}FAIL${RESET} ${DIM}${f.module}${RESET} › ${f.testName}`, + ); + console.log(` ${f.message}`); + if (f.stackTrace) { + console.log( + ` ${DIM}${f.stackTrace.split('\n').slice(0, 3).join('\n ')}${RESET}`, + ); + } + } + } + + let statusColor = + result.status === 'passed' + ? FG_GREEN + : result.status === 'failed' + ? FG_RED + : FG_RED; + console.log( + `\n${statusColor}${result.status}${RESET} ${DIM}—${RESET} ${result.passedCount} passed, ${result.failedCount} failed${result.skippedCount > 0 ? `, ${result.skippedCount} skipped` : ''} ${DIM}(${result.durationMs}ms across ${result.testFiles.length} file(s))${RESET}`, + ); + + if (result.status !== 'passed') { + process.exit(1); + } + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4934040bf37..8dac8ff6468 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1047,6 +1047,9 @@ importers: '@glint/ember-tsc': specifier: 'catalog:' version: 1.5.0(typescript@5.9.3) + '@playwright/test': + specifier: 'catalog:' + version: 1.60.0 '@types/jsonwebtoken': specifier: 'catalog:' version: 9.0.10 From 689dbb4a1872cf5d116c2cb551d2987a23ac0efc Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Mon, 18 May 2026 13:32:51 +0200 Subject: [PATCH 04/11] Exclude playwright + fsevents from the boxel-cli bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm --filter @cardstack/boxel-cli build` was failing with `No loader is configured for ".node" files: fsevents.node`. esbuild was trying to bundle Playwright's transitive deps — specifically the native `.node` files that ship with fsevents (macOS file watcher) and playwright itself — and obviously can't inline those. Added Playwright (`@playwright/test`, `playwright`, `playwright-core`) and `fsevents` to the external list. They stay as runtime `require`s; node resolves them from `packages/boxel-cli/node_modules/` when `boxel test` actually runs. Matches `boxel test`'s existing monorepo-only constraint — in published form, those `devDependencies` aren't installed and `boxel test` errors at import-time with a clear message. Bundle grew from 144 KB to 253 KB (still small) because the playwright-free bundle was missing realm-server / runtime-common code; the externals tweak doesn't bundle node_modules wholesale, so the inlined size is now correct. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/boxel-cli/scripts/build.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/boxel-cli/scripts/build.ts b/packages/boxel-cli/scripts/build.ts index 4eaf6ca3b8e..b25a9f3955f 100644 --- a/packages/boxel-cli/scripts/build.ts +++ b/packages/boxel-cli/scripts/build.ts @@ -10,7 +10,19 @@ const commonConfig = { platform: 'node' as const, target: 'node18', format: 'cjs' as const, - external: nodeBuiltins, + external: [ + ...nodeBuiltins, + // Playwright (drives `boxel test`) and its native-module transitive + // deps (fsevents on macOS, etc.) can't be bundled by esbuild — they + // contain `.node` files and runtime `require.resolve` calls. boxel-cli + // keeps them as runtime requires; they're picked up from node_modules + // when `boxel test` actually runs. Monorepo-only by consequence — + // matches `boxel test`'s existing monorepo-only constraint. + '@playwright/test', + 'playwright', + 'playwright-core', + 'fsevents', + ], sourcemap: false, minify: true, metafile: true, From 10fb91f8229ffb5b3809b30dba768c03f120013b Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Mon, 18 May 2026 14:05:38 +0200 Subject: [PATCH 05/11] Resolve monorepo paths via package.json lookup, not __dirname math MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `boxel parse` and `boxel test` were both computing the monorepo layout by counting `..` segments up from `__dirname`. That works when the CLI runs from `src/commands/...` (ts-node fallback) but breaks when the same code runs from `dist/index.js` (the bundled form) — `__dirname` is at a different depth. Replaced the `__dirname` walking with a `findBoxelCliRoot` helper that walks up looking for the `@cardstack/boxel-cli` package.json. Robust against both entry modes (and any future bundling relocations). Drive-by fixes: - `parse.ts` was missing `dirname` from its `node:path` imports after the refactor; restored. - `find-package-root.ts` uses `for (;;)` instead of `while (true)` to keep ESLint's `no-constant-condition` happy. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/boxel-cli/src/commands/parse.ts | 18 +++------- packages/boxel-cli/src/commands/test.ts | 10 +++--- .../boxel-cli/src/lib/find-package-root.ts | 34 +++++++++++++++++++ 3 files changed, 44 insertions(+), 18 deletions(-) create mode 100644 packages/boxel-cli/src/lib/find-package-root.ts diff --git a/packages/boxel-cli/src/commands/parse.ts b/packages/boxel-cli/src/commands/parse.ts index 4bb9e768721..cdbd81588ff 100644 --- a/packages/boxel-cli/src/commands/parse.ts +++ b/packages/boxel-cli/src/commands/parse.ts @@ -20,6 +20,7 @@ import { } from '../lib/profile-manager'; import { FG_RED, DIM, RESET } from '../lib/colors'; import { cliLog } from '../lib/cli-log'; +import { findBoxelCliRoot } from '../lib/find-package-root'; import { search } from './search'; /** @@ -55,12 +56,8 @@ const SPEC_TYPE = { const PARSEABLE_GTS_EXTENSIONS = ['.gts', '.gjs', '.ts'] as const; const PARSEABLE_JSON_EXTENSION = '.json'; -/** - * Monorepo layout: this file lives at - * `packages/boxel-cli/src/commands/parse.ts`. Up three levels reaches - * `packages/`, alongside `base`, `host`, `boxel-ui`, etc. - */ -const PACKAGES_PATH = resolve(__dirname, '..', '..', '..'); +const BOXEL_CLI_PATH = findBoxelCliRoot(__dirname); +const PACKAGES_PATH = resolve(BOXEL_CLI_PATH, '..'); const BASE_PKG_PATH = join(PACKAGES_PATH, 'base'); const HOST_PKG_PATH = join(PACKAGES_PATH, 'host'); const BOXEL_UI_PATH = join(PACKAGES_PATH, 'boxel-ui', 'addon', 'src'); @@ -435,14 +432,7 @@ async function runGlintCheck( symlinkSync(NODE_MODULES_PATH, join(tempDir, 'node_modules')); - let emberTscBin = resolve( - __dirname, - '..', - '..', - 'node_modules', - '.bin', - 'ember-tsc', - ); + let emberTscBin = join(BOXEL_CLI_PATH, 'node_modules', '.bin', 'ember-tsc'); let { output, exitedWithError } = await new Promise<{ output: string; diff --git a/packages/boxel-cli/src/commands/test.ts b/packages/boxel-cli/src/commands/test.ts index 812e300cffd..eb26e0e1f40 100644 --- a/packages/boxel-cli/src/commands/test.ts +++ b/packages/boxel-cli/src/commands/test.ts @@ -14,6 +14,7 @@ import { } from '../lib/profile-manager'; import { FG_RED, FG_GREEN, DIM, RESET } from '../lib/colors'; import { cliLog } from '../lib/cli-log'; +import { findBoxelCliRoot } from '../lib/find-package-root'; import { listFiles } from './file/list'; /** @@ -199,7 +200,7 @@ async function runQunitInBrowser(options: QunitRunnerOptions): Promise<{ options.hostDistDir ?? join( findHostDistPackageDir() ?? - resolve(__dirname, '..', '..', '..', 'host'), + join(resolve(findBoxelCliRoot(__dirname), '..'), 'host'), 'dist', ); @@ -475,9 +476,10 @@ function fileExists(path: string): boolean { } function findHostDistPackageDir(): string | undefined { - let packageRoot = resolve(__dirname, '..', '..'); - let workspaceRoot = resolve(packageRoot, '..', '..'); - let hostDir = resolve(packageRoot, '..', 'host'); + let packageRoot = findBoxelCliRoot(__dirname); + let packagesDir = resolve(packageRoot, '..'); + let workspaceRoot = resolve(packagesDir, '..'); + let hostDir = join(packagesDir, 'host'); let rootRepoCheckoutDir = findRootRepoCheckoutDir(workspaceRoot); let rootRepoHostDir = diff --git a/packages/boxel-cli/src/lib/find-package-root.ts b/packages/boxel-cli/src/lib/find-package-root.ts new file mode 100644 index 00000000000..e8b1415a656 --- /dev/null +++ b/packages/boxel-cli/src/lib/find-package-root.ts @@ -0,0 +1,34 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +/** + * Walk up from `__dirname` until we find the `@cardstack/boxel-cli` + * package.json. The single-file esbuild bundle places `__dirname` at + * `boxel-cli/dist`; the ts-node fallback places it inside `src/...`. + * Anchoring to the package.json keeps every downstream path stable + * regardless of which entry mode is active. + */ +export function findBoxelCliRoot(startDir: string): string { + let dir = startDir; + for (;;) { + let candidate = join(dir, 'package.json'); + if (existsSync(candidate)) { + try { + let parsed = JSON.parse(readFileSync(candidate, 'utf8')); + if (parsed?.name === '@cardstack/boxel-cli') { + return dir; + } + } catch { + // ignore unparseable package.json and keep walking + } + } + let parent = dirname(dir); + if (parent === dir) { + throw new Error( + 'Could not locate the @cardstack/boxel-cli package root walking up from ' + + startDir, + ); + } + dir = parent; + } +} From 3d6d8e76e61dc9d5ba950786f9aeee72e64d0f1e Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Tue, 19 May 2026 09:09:55 +0200 Subject: [PATCH 06/11] bin/boxel.js: pass tsconfig path to ts-node fallback The ts-node fallback (used when `dist/` is missing) was previously calling `ts-node.register({ transpileOnly: true })` with no project path. ts-node discovered tsconfig.json from cwd, which worked when the CLI was invoked from inside the monorepo but failed from any other tree (e.g. `/var/folders/.../tmp.XXX/`) because no tsconfig is reachable walking up from there. Pointed ts-node at boxel-cli's own tsconfig.json explicitly so the fallback works regardless of caller cwd. --- packages/boxel-cli/bin/boxel.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/boxel-cli/bin/boxel.js b/packages/boxel-cli/bin/boxel.js index 32d260ac60b..f44b84eca1b 100755 --- a/packages/boxel-cli/bin/boxel.js +++ b/packages/boxel-cli/bin/boxel.js @@ -9,7 +9,14 @@ const distEntry = path.resolve(__dirname, '..', 'dist', 'index.js'); if (fs.existsSync(distEntry)) { require(distEntry); } else { - // Development fallback: run from TypeScript source via ts-node - require('ts-node').register({ transpileOnly: true }); + // Development fallback: run from TypeScript source via ts-node. + // Point ts-node at boxel-cli's own tsconfig.json explicitly so it + // works regardless of the caller's cwd. Without `project`, ts-node + // discovers tsconfig from cwd — fine when invoked from inside the + // monorepo, broken when invoked from /tmp/... or any other tree. + require('ts-node').register({ + transpileOnly: true, + project: path.resolve(__dirname, '..', 'tsconfig.json'), + }); require('../src/index.ts'); } From cb7302416fbb3468bb8fc998f94c4f3d21b59212 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Tue, 19 May 2026 09:10:25 +0200 Subject: [PATCH 07/11] Address review feedback on validator commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from PR review (codex + Copilot): - Lazy-load `@playwright/test` in `boxel test`. The top-level `import { chromium }` made `boxel --help` (and every other subcommand) crash in published installs where the devDependency isn't present. Moved behind an async loader that fires only when the test runner actually runs. - Zero `*.test.gts` files → validator failure, not pass. Previously `boxel test` returned `status: 'passed'` for a realm with no tests, which would let the factory agent mark an Issue done without ever writing one. Now returns `failed` with an explicit errorMessage. - Bounded-poll Spec discovery in `boxel parse`. The realm's search index settles asynchronously, so a `boxel realm push` immediately followed by `boxel parse` could silently miss the freshly-pushed `linkedExamples` and pass when it shouldn't. Wrapped the Spec search in a 30s/250ms retry-with-poll while the result is ok-but-empty. --- packages/boxel-cli/src/commands/parse.ts | 35 +++++++++++++++++++++--- packages/boxel-cli/src/commands/test.ts | 35 ++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/packages/boxel-cli/src/commands/parse.ts b/packages/boxel-cli/src/commands/parse.ts index cdbd81588ff..d1e81a25280 100644 --- a/packages/boxel-cli/src/commands/parse.ts +++ b/packages/boxel-cli/src/commands/parse.ts @@ -98,6 +98,27 @@ interface SpecExampleInfo { exampleUrls: string[]; } +/** + * Bounded-poll an async attempt until `needsRetry` is false or the + * deadline elapses. Used to absorb realm-side indexing latency when + * we search for Specs immediately after a push. + */ +async function retryWithPoll( + attempt: () => Promise, + needsRetry: (result: T) => boolean, + options: { totalWaitMs?: number; pollMs?: number } = {}, +): Promise { + let totalWaitMs = options.totalWaitMs ?? 30_000; + let pollMs = options.pollMs ?? 250; + let deadline = Date.now() + totalWaitMs; + let result = await attempt(); + while (needsRetry(result) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, pollMs)); + result = await attempt(); + } + return result; +} + // --------------------------------------------------------------------------- // Public entry point // --------------------------------------------------------------------------- @@ -268,10 +289,16 @@ async function discoverJsonExampleFiles( realmUrl: string, pm: ProfileManager, ): Promise { - let searchResult = await search( - realmUrl, - { filter: { type: SPEC_TYPE } }, - { profileManager: pm }, + // The realm's source POST returns once writes are durable, but the + // search index settles asynchronously. Right after a `boxel realm + // push`, a search for Spec cards may still see the pre-push state + // and return zero results, which would make us silently skip the + // freshly-pushed linkedExamples. Bounded-poll for up to ~30s while + // the result is OK but empty so the index has a chance to catch up. + let searchResult = await retryWithPoll( + () => + search(realmUrl, { filter: { type: SPEC_TYPE } }, { profileManager: pm }), + (r) => r.ok && (r.data?.length ?? 0) === 0, ); if (!searchResult.ok) { return []; diff --git a/packages/boxel-cli/src/commands/test.ts b/packages/boxel-cli/src/commands/test.ts index eb26e0e1f40..b33a2177979 100644 --- a/packages/boxel-cli/src/commands/test.ts +++ b/packages/boxel-cli/src/commands/test.ts @@ -4,7 +4,6 @@ import { readFileSync, statSync } from 'node:fs'; import { createServer, type Server } from 'node:http'; import { dirname, join, normalize, resolve } from 'node:path'; -import { chromium } from '@playwright/test'; import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; import { @@ -17,6 +16,30 @@ import { cliLog } from '../lib/cli-log'; import { findBoxelCliRoot } from '../lib/find-package-root'; import { listFiles } from './file/list'; +// `@playwright/test` is a devDependency and external in our esbuild +// config, so it's not present in a published-from-npm install. Anything +// loaded at the top of this module would crash `boxel --help` for end +// users who never run `boxel test`. Resolved lazily inside the runner +// instead. +type ChromiumApi = (typeof import('@playwright/test'))['chromium']; + +async function loadChromium(): Promise { + try { + let mod = (await import('@playwright/test')) as { + chromium: ChromiumApi; + }; + return mod.chromium; + } catch (err) { + let message = err instanceof Error ? err.message : String(err); + throw new Error( + `Could not load @playwright/test (${message}). \`boxel test\` ` + + 'is monorepo-only — install Playwright in the boxel-cli package ' + + 'via `pnpm --filter @cardstack/boxel-cli install` and run ' + + '`npx playwright install chromium` once.', + ); + } +} + /** * `boxel test` runs the realm's QUnit test suite by driving a * headless Chromium instance against the host app's compiled test @@ -134,14 +157,21 @@ export async function runTestsForRealm( } if (testFiles.length === 0) { + // A realm with no `*.test.gts` files is treated as a validator + // failure: factory Issues are supposed to ship with tests, and a + // silent "passed" would let an agent mark an Issue done without + // ever writing one. return { - status: 'passed', + status: 'failed', passedCount: 0, failedCount: 0, skippedCount: 0, durationMs: 0, testFiles: [], failures: [], + errorMessage: + 'No `*.test.gts` files found in the realm. ' + + 'Every implementation Issue must ship with at least one test file.', }; } @@ -224,6 +254,7 @@ async function runQunitInBrowser(options: QunitRunnerOptions): Promise<{ }); setHtml(html); + let chromium = await loadChromium(); browser = await chromium.launch({ headless: true }); let page = await browser.newPage(); From fa373eea9fef5b65187b5714cd7f87d8d1f9a519 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Tue, 19 May 2026 09:39:52 +0200 Subject: [PATCH 08/11] Reject unsafe realm-relative paths in lint, parse, test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vendors a `validateRealmRelativePath` helper that rejects paths with URL schemes, leading `/`, backslashes, percent-encoded escapes, and `..` traversal segments. The validator commands previously accepted anything ending in the right extension, so `Cards/../foo.ts` or an absolute URL would reach realm-server URL handling with whatever normalization the layer chose. Mirrors the equivalent gate in `packages/software-factory/src/realm-relative-path.ts`. `boxel test` now also exits non-zero when `runTestsForRealm` returns `status: 'failed'` with an empty `testFiles` array — previously the no-tests early-return swallowed the validator failure. `runGlintCheck` no longer silently continues on a `..`-style path that resolves outside its temp dir; it throws instead. Addresses review comments on #4881. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/boxel-cli/src/commands/lint.ts | 5 ++ packages/boxel-cli/src/commands/parse.ts | 15 +++++- packages/boxel-cli/src/commands/test.ts | 3 ++ .../boxel-cli/src/lib/realm-relative-path.ts | 46 +++++++++++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 packages/boxel-cli/src/lib/realm-relative-path.ts diff --git a/packages/boxel-cli/src/commands/lint.ts b/packages/boxel-cli/src/commands/lint.ts index b46c5f6063f..5044b52c88c 100644 --- a/packages/boxel-cli/src/commands/lint.ts +++ b/packages/boxel-cli/src/commands/lint.ts @@ -8,6 +8,7 @@ import { } from '../lib/profile-manager'; import { FG_RED, FG_YELLOW, DIM, RESET } from '../lib/colors'; import { cliLog } from '../lib/cli-log'; +import { validateRealmRelativePath } from '../lib/realm-relative-path'; import { lint as lintSingleFile, type LintMessage } from './file/lint'; import { listFiles } from './file/list'; @@ -62,6 +63,10 @@ export async function lintRealm( let lintableFiles: string[]; if (options?.path) { let path = options.path; + let pathError = validateRealmRelativePath(path); + if (pathError) { + return emptyErrorResult(pathError); + } if (!LINTABLE_EXTENSIONS.some((ext) => path.endsWith(ext))) { return emptyErrorResult( `Path "${path}" is not lintable — must end with one of ${LINTABLE_EXTENSIONS.join(', ')}`, diff --git a/packages/boxel-cli/src/commands/parse.ts b/packages/boxel-cli/src/commands/parse.ts index d1e81a25280..0c7da4b2fbb 100644 --- a/packages/boxel-cli/src/commands/parse.ts +++ b/packages/boxel-cli/src/commands/parse.ts @@ -21,6 +21,7 @@ import { import { FG_RED, DIM, RESET } from '../lib/colors'; import { cliLog } from '../lib/cli-log'; import { findBoxelCliRoot } from '../lib/find-package-root'; +import { validateRealmRelativePath } from '../lib/realm-relative-path'; import { search } from './search'; /** @@ -141,6 +142,10 @@ export async function parseRealm( if (options?.path) { let path = options.path; + let pathError = validateRealmRelativePath(path); + if (pathError) { + return emptyErrorResult(pathError); + } if (PARSEABLE_GTS_EXTENSIONS.some((ext) => path.endsWith(ext))) { gtsFiles = [path]; } else if (path.endsWith(PARSEABLE_JSON_EXTENSION)) { @@ -412,9 +417,17 @@ async function runGlintCheck( try { for (let file of files) { + let pathError = validateRealmRelativePath(file.path); + if (pathError) { + throw new Error(pathError); + } let normalized = join(tempDir, file.path); let resolved = resolve(normalized); - if (!resolved.startsWith(tempDir + '/')) continue; + if (!resolved.startsWith(tempDir + '/')) { + throw new Error( + `Path "${file.path}" resolves outside the parse workspace and was rejected.`, + ); + } mkdirSync(dirname(resolved), { recursive: true }); writeFileSync(resolved, file.content, 'utf8'); } diff --git a/packages/boxel-cli/src/commands/test.ts b/packages/boxel-cli/src/commands/test.ts index b33a2177979..ea9a3ce33d5 100644 --- a/packages/boxel-cli/src/commands/test.ts +++ b/packages/boxel-cli/src/commands/test.ts @@ -691,6 +691,9 @@ export function registerTestCommand(program: Command): void { if (result.testFiles.length === 0) { console.log(`${DIM}No .test.gts files found in the realm.${RESET}`); + if (result.status !== 'passed') { + process.exit(1); + } return; } diff --git a/packages/boxel-cli/src/lib/realm-relative-path.ts b/packages/boxel-cli/src/lib/realm-relative-path.ts new file mode 100644 index 00000000000..7912bcbb668 --- /dev/null +++ b/packages/boxel-cli/src/lib/realm-relative-path.ts @@ -0,0 +1,46 @@ +/** + * Validate that an agent- or user-supplied `path` is a safe + * realm-relative path. Returns an error message if rejected, or null + * if the path is acceptable. + * + * Rejects: + * - empty / whitespace-only paths + * - absolute URLs with a scheme (`http:`, `file:`, etc.) + * - paths starting with `/` + * - backslash characters (ambiguous across URL/path handling layers; + * e.g. `foo\..\bar` never splits on `/` so the `..` check below + * misses it) + * - percent-encoded traversal segments — decodes once and rejects any + * `..` segment after decoding, so `%2e%2e`, `%2E%2E`, `%2e.`, etc. + * all collapse to a `..` and fail + * - malformed percent-encoded escapes + * + * Mirrors `packages/software-factory/src/realm-relative-path.ts` so + * both the SDK orchestrator and the boxel-cli validators apply the + * same gate before a path reaches realm-server URL handling. + */ +export function validateRealmRelativePath(path: string): string | null { + if (path.trim() === '') { + return `Path must be a non-empty realm-relative file path.`; + } + if (/^[a-z][a-z0-9+.-]*:/i.test(path)) { + return `Path "${path}" must be realm-relative — absolute URLs (with a scheme) are not accepted.`; + } + if (path.startsWith('/')) { + return `Path "${path}" must be realm-relative — paths starting with "/" are not accepted.`; + } + if (path.includes('\\')) { + return `Path "${path}" must not contain backslash characters.`; + } + let decoded: string; + try { + decoded = decodeURIComponent(path); + } catch { + return `Path "${path}" contains an invalid percent-encoded escape.`; + } + let segments = decoded.split('/'); + if (segments.some((seg) => seg === '..')) { + return `Path "${path}" must not contain ".." segments — the path must stay inside the target realm.`; + } + return null; +} From b60e7a70c85ca311d7ec6e995aef4c5362673626 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Tue, 19 May 2026 09:54:42 +0200 Subject: [PATCH 09/11] Drop ts-node fallback from bin/boxel.js The published package always ships `dist/index.js`, so end users never hit the fallback. Monorepo devs who want to run unbuilt source can use `pnpm --filter @cardstack/boxel-cli start` (`ts-node --transpileOnly` on `src/index.ts`), which is what the build/start scripts already use. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/boxel-cli/bin/boxel.js | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/packages/boxel-cli/bin/boxel.js b/packages/boxel-cli/bin/boxel.js index f44b84eca1b..eea78af8f06 100755 --- a/packages/boxel-cli/bin/boxel.js +++ b/packages/boxel-cli/bin/boxel.js @@ -1,22 +1,5 @@ #!/usr/bin/env node const path = require('path'); -const fs = require('fs'); -// Use the built dist version if available, otherwise fall back to ts-node -const distEntry = path.resolve(__dirname, '..', 'dist', 'index.js'); - -if (fs.existsSync(distEntry)) { - require(distEntry); -} else { - // Development fallback: run from TypeScript source via ts-node. - // Point ts-node at boxel-cli's own tsconfig.json explicitly so it - // works regardless of the caller's cwd. Without `project`, ts-node - // discovers tsconfig from cwd — fine when invoked from inside the - // monorepo, broken when invoked from /tmp/... or any other tree. - require('ts-node').register({ - transpileOnly: true, - project: path.resolve(__dirname, '..', 'tsconfig.json'), - }); - require('../src/index.ts'); -} +require(path.resolve(__dirname, '..', 'dist', 'index.js')); From 4f5f81ee2acac557225a63e2c25ae994c1a9c092 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Tue, 19 May 2026 09:57:04 +0200 Subject: [PATCH 10/11] Revert "Drop ts-node fallback from bin/boxel.js" This reverts commit b60e7a70c85ca311d7ec6e995aef4c5362673626. --- packages/boxel-cli/bin/boxel.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/boxel-cli/bin/boxel.js b/packages/boxel-cli/bin/boxel.js index eea78af8f06..f44b84eca1b 100755 --- a/packages/boxel-cli/bin/boxel.js +++ b/packages/boxel-cli/bin/boxel.js @@ -1,5 +1,22 @@ #!/usr/bin/env node const path = require('path'); +const fs = require('fs'); -require(path.resolve(__dirname, '..', 'dist', 'index.js')); +// Use the built dist version if available, otherwise fall back to ts-node +const distEntry = path.resolve(__dirname, '..', 'dist', 'index.js'); + +if (fs.existsSync(distEntry)) { + require(distEntry); +} else { + // Development fallback: run from TypeScript source via ts-node. + // Point ts-node at boxel-cli's own tsconfig.json explicitly so it + // works regardless of the caller's cwd. Without `project`, ts-node + // discovers tsconfig from cwd — fine when invoked from inside the + // monorepo, broken when invoked from /tmp/... or any other tree. + require('ts-node').register({ + transpileOnly: true, + project: path.resolve(__dirname, '..', 'tsconfig.json'), + }); + require('../src/index.ts'); +} From 7b8859ebd5cac3fad8b7c154fd58eb52ea80f553 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Tue, 19 May 2026 09:58:45 +0200 Subject: [PATCH 11/11] Restore bin/boxel.js to match main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the `project:` tsconfig path added in 3d6d8e7 — back to the version on main. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/boxel-cli/bin/boxel.js | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/boxel-cli/bin/boxel.js b/packages/boxel-cli/bin/boxel.js index f44b84eca1b..32d260ac60b 100755 --- a/packages/boxel-cli/bin/boxel.js +++ b/packages/boxel-cli/bin/boxel.js @@ -9,14 +9,7 @@ const distEntry = path.resolve(__dirname, '..', 'dist', 'index.js'); if (fs.existsSync(distEntry)) { require(distEntry); } else { - // Development fallback: run from TypeScript source via ts-node. - // Point ts-node at boxel-cli's own tsconfig.json explicitly so it - // works regardless of the caller's cwd. Without `project`, ts-node - // discovers tsconfig from cwd — fine when invoked from inside the - // monorepo, broken when invoked from /tmp/... or any other tree. - require('ts-node').register({ - transpileOnly: true, - project: path.resolve(__dirname, '..', 'tsconfig.json'), - }); + // Development fallback: run from TypeScript source via ts-node + require('ts-node').register({ transpileOnly: true }); require('../src/index.ts'); }