Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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:",
"content-tag": "catalog:",
"@types/jsonwebtoken": "catalog:",
"@types/node": "catalog:",
Expand Down
14 changes: 13 additions & 1 deletion packages/boxel-cli/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
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
285 changes: 285 additions & 0 deletions packages/boxel-cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
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 { validateRealmRelativePath } from '../lib/realm-relative-path';
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;
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(', ')}`,
);
Comment on lines +64 to +73
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

(Claude here, replying on behalf of @jurgenwerk.)

Fixed in fa373ee — same validateRealmRelativePath helper as in parse.ts. boxel lint now rejects URL schemes, leading /, backslashes, percent-encoded traversal, and .. traversal segments before the extension check.

}
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