Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
281df90
Add Phase 1 interactive runbook for software factory
jurgenwerk May 15, 2026
65baddc
Add `boxel lint` top-level command
jurgenwerk May 15, 2026
f58a6dd
Add `boxel parse` top-level command
jurgenwerk May 15, 2026
72429e2
Add `boxel test` top-level command
jurgenwerk May 15, 2026
eb7317d
Update Phase 1 runbook: validator CLIs are landed
jurgenwerk May 15, 2026
82b8698
Add software-factory-scheduling skill
jurgenwerk May 15, 2026
ffb5cc4
Rewrite software-factory-operations skill for interactive flow
jurgenwerk May 15, 2026
29c7c40
Rewrite software-factory-bootstrap skill for interactive flow
jurgenwerk May 15, 2026
267dc4d
Fix-ups from first interactive test run
jurgenwerk May 15, 2026
a1df95c
Default to dev `boxel` CLI in skills (don't expect it on PATH)
jurgenwerk May 15, 2026
eb69d34
Fix boxel realm push/pull/sync command syntax in skills
jurgenwerk May 15, 2026
cac3f05
Fix `boxel realm create` realm-name argument guidance
jurgenwerk May 15, 2026
0c71451
Fix validator input shapes + loader.import typing in skills
jurgenwerk May 15, 2026
55f7e48
Put validation artifact cards back into the Phase 1 flow
jurgenwerk May 15, 2026
8259edf
Collapse runbook to single prompt + add bootstrap-seed Issue
jurgenwerk May 15, 2026
0845c88
Drop sticky-note specifics from the runbook
jurgenwerk May 15, 2026
39d88cf
Rename interactive-runbook.md → runbook.md
jurgenwerk May 15, 2026
b06dfe1
Make the validator-iteration loop explicit
jurgenwerk May 15, 2026
622dc9f
Add validator-loop bail-out limits
jurgenwerk May 15, 2026
2e31989
Drop spec-tracking content from runbook
jurgenwerk May 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/boxel-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
"@cardstack/local-types": "workspace:*",
"@cardstack/postgres": "workspace:*",
"@cardstack/runtime-common": "workspace:*",
"@glint/ember-tsc": "catalog:",
"@playwright/test": "catalog:",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move Playwright to runtime deps or lazy-load the test command

@playwright/test is added under devDependencies, but the CLI eagerly imports ./commands/test during startup and that module has a top-level import { chromium } from '@playwright/test'. In normal npm/global installs, dev dependencies are not installed, so even unrelated commands (for example boxel --help or boxel profile list) will fail at process start with a module-resolution error before argument parsing. This needs either a runtime dependency or deferred import inside the test command path.

Useful? React with 👍 / 👎.

"content-tag": "catalog:",
"@types/jsonwebtoken": "catalog:",
"@types/node": "catalog:",
Expand Down
6 changes: 6 additions & 0 deletions packages/boxel-cli/src/build-program.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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';
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';

Expand Down Expand Up @@ -85,9 +88,12 @@ Environment variables (for 'add'):
);

registerFileCommand(program);
registerLintCommand(program);
registerParseCommand(program);
registerRealmCommand(program);
registerRunCommand(program);
registerSearchCommand(program);
registerTestCommand(program);
registerReadTranspiledCommand(program);
registerConsolidateWorkspacesCommand(program);

Expand Down
280 changes: 280 additions & 0 deletions packages/boxel-cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
@@ -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<LintRealmResult> {
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 <realm-url>', '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 };
Loading
Loading