@cardstack/boxel-cli 0.2.0-unstable.298 → 0.2.0-unstable.425

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardstack/boxel-cli",
3
- "version": "0.2.0-unstable.298",
3
+ "version": "0.2.0-unstable.425",
4
4
  "license": "MIT",
5
5
  "description": "CLI tools for Boxel workspace management",
6
6
  "main": "./dist/index.js",
@@ -36,6 +36,8 @@
36
36
  "p-limit": "^7.3.0"
37
37
  },
38
38
  "devDependencies": {
39
+ "@glint/ember-tsc": "^1.5.0",
40
+ "@playwright/test": "^1.54.0",
39
41
  "content-tag": "^4.0.0",
40
42
  "@types/jsonwebtoken": "9.0.10",
41
43
  "@types/node": "^24.3.0",
@@ -51,9 +53,9 @@
51
53
  "typescript": "~5.9.3",
52
54
  "vite": "^6.3.2",
53
55
  "vitest": "^2.1.9",
56
+ "@cardstack/postgres": "0.0.0",
54
57
  "@cardstack/local-types": "0.0.0",
55
- "@cardstack/runtime-common": "1.0.0",
56
- "@cardstack/postgres": "0.0.0"
58
+ "@cardstack/runtime-common": "1.0.0"
57
59
  },
58
60
  "publishConfig": {
59
61
  "access": "public",
@@ -1,11 +1,14 @@
1
1
  import { Command } from 'commander';
2
2
  import { profileCommand } from './commands/profile';
3
3
  import { registerConsolidateWorkspacesCommand } from './commands/consolidate-workspaces';
4
+ import { registerLintCommand } from './commands/lint';
5
+ import { registerParseCommand } from './commands/parse';
4
6
  import { registerReadTranspiledCommand } from './commands/read-transpiled';
5
7
  import { registerRealmCommand } from './commands/realm/index';
6
8
  import { registerFileCommand } from './commands/file/index';
7
9
  import { registerRunCommand } from './commands/run-command';
8
10
  import { registerSearchCommand } from './commands/search';
11
+ import { registerTestCommand } from './commands/test';
9
12
  import { setQuiet } from './lib/cli-log';
10
13
  import { warnIfMisplacedLocalRealmDirs } from './lib/realm-local-paths';
11
14
 
@@ -85,9 +88,12 @@ Environment variables (for 'add'):
85
88
  );
86
89
 
87
90
  registerFileCommand(program);
91
+ registerLintCommand(program);
92
+ registerParseCommand(program);
88
93
  registerRealmCommand(program);
89
94
  registerRunCommand(program);
90
95
  registerSearchCommand(program);
96
+ registerTestCommand(program);
91
97
  registerReadTranspiledCommand(program);
92
98
  registerConsolidateWorkspacesCommand(program);
93
99
 
@@ -0,0 +1,285 @@
1
+ import type { Command } from 'commander';
2
+ import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
3
+ import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type';
4
+ import {
5
+ getProfileManager,
6
+ NO_ACTIVE_PROFILE_ERROR,
7
+ type ProfileManager,
8
+ } from '../lib/profile-manager';
9
+ import { FG_RED, FG_YELLOW, DIM, RESET } from '../lib/colors';
10
+ import { cliLog } from '../lib/cli-log';
11
+ import { validateRealmRelativePath } from '../lib/realm-relative-path';
12
+ import { lint as lintSingleFile, type LintMessage } from './file/lint';
13
+ import { listFiles } from './file/list';
14
+
15
+ const LINTABLE_EXTENSIONS = ['.gts', '.gjs', '.ts', '.js'] as const;
16
+
17
+ export interface LintRealmViolation {
18
+ rule: string | null;
19
+ file: string;
20
+ line: number;
21
+ column: number;
22
+ message: string;
23
+ severity: 'error' | 'warning';
24
+ }
25
+
26
+ export interface LintRealmResult {
27
+ status: 'passed' | 'failed' | 'error';
28
+ filesChecked: number;
29
+ filesWithErrors: number;
30
+ errorCount: number;
31
+ warningCount: number;
32
+ durationMs: number;
33
+ lintableFiles: string[];
34
+ violations: LintRealmViolation[];
35
+ errorMessage?: string;
36
+ }
37
+
38
+ export interface LintRealmOptions {
39
+ /** Optional realm-relative path. When set, lints only that file. */
40
+ path?: string;
41
+ profileManager?: ProfileManager;
42
+ }
43
+
44
+ /**
45
+ * Lint every lintable file (`.gts`, `.gjs`, `.ts`, `.js`) in a realm,
46
+ * or a single file when `options.path` is set. Source is fetched from
47
+ * the realm; the realm's `_lint` endpoint runs ESLint + Prettier with
48
+ * the `@cardstack/boxel` rules.
49
+ */
50
+ export async function lintRealm(
51
+ realmUrl: string,
52
+ options?: LintRealmOptions,
53
+ ): Promise<LintRealmResult> {
54
+ let pm = options?.profileManager ?? getProfileManager();
55
+ let active = pm.getActiveProfile();
56
+ if (!active) {
57
+ return emptyErrorResult(NO_ACTIVE_PROFILE_ERROR);
58
+ }
59
+
60
+ let normalizedRealmUrl = ensureTrailingSlash(realmUrl);
61
+ let startedAt = Date.now();
62
+
63
+ let lintableFiles: string[];
64
+ if (options?.path) {
65
+ let path = options.path;
66
+ let pathError = validateRealmRelativePath(path);
67
+ if (pathError) {
68
+ return emptyErrorResult(pathError);
69
+ }
70
+ if (!LINTABLE_EXTENSIONS.some((ext) => path.endsWith(ext))) {
71
+ return emptyErrorResult(
72
+ `Path "${path}" is not lintable — must end with one of ${LINTABLE_EXTENSIONS.join(', ')}`,
73
+ );
74
+ }
75
+ lintableFiles = [path];
76
+ } else {
77
+ let listResult = await listFiles(normalizedRealmUrl, {
78
+ profileManager: pm,
79
+ });
80
+ if (listResult.error) {
81
+ return emptyErrorResult(
82
+ `Failed to list realm files: ${listResult.error}`,
83
+ );
84
+ }
85
+ lintableFiles = listResult.filenames.filter((f) =>
86
+ LINTABLE_EXTENSIONS.some((ext) => f.endsWith(ext)),
87
+ );
88
+ }
89
+
90
+ if (lintableFiles.length === 0) {
91
+ return {
92
+ status: 'passed',
93
+ filesChecked: 0,
94
+ filesWithErrors: 0,
95
+ errorCount: 0,
96
+ warningCount: 0,
97
+ durationMs: Date.now() - startedAt,
98
+ lintableFiles: [],
99
+ violations: [],
100
+ };
101
+ }
102
+
103
+ let violations: LintRealmViolation[] = [];
104
+ let filesWithErrors = 0;
105
+ let errorCount = 0;
106
+ let warningCount = 0;
107
+
108
+ for (let file of lintableFiles) {
109
+ let source: string;
110
+ try {
111
+ let readUrl = new URL(file, normalizedRealmUrl).href;
112
+ let response = await pm.authedRealmFetch(readUrl, {
113
+ method: 'GET',
114
+ headers: { Accept: SupportedMimeType.CardSource },
115
+ });
116
+ if (!response.ok) {
117
+ let body = await response.text().catch(() => '(no body)');
118
+ recordReadError(
119
+ file,
120
+ `HTTP ${response.status}: ${body.slice(0, 300)}`,
121
+ violations,
122
+ );
123
+ filesWithErrors += 1;
124
+ errorCount += 1;
125
+ continue;
126
+ }
127
+ source = await response.text();
128
+ } catch (err) {
129
+ recordReadError(
130
+ file,
131
+ err instanceof Error ? err.message : String(err),
132
+ violations,
133
+ );
134
+ filesWithErrors += 1;
135
+ errorCount += 1;
136
+ continue;
137
+ }
138
+
139
+ let result = await lintSingleFile(normalizedRealmUrl, source, file, {
140
+ profileManager: pm,
141
+ });
142
+
143
+ if (!result.ok) {
144
+ recordReadError(file, result.error ?? 'lint failed', violations);
145
+ filesWithErrors += 1;
146
+ errorCount += 1;
147
+ continue;
148
+ }
149
+
150
+ let fileHasError = false;
151
+ for (let msg of result.messages ?? []) {
152
+ let severity: 'error' | 'warning' =
153
+ msg.severity === 2 ? 'error' : 'warning';
154
+ violations.push({
155
+ rule: msg.ruleId,
156
+ file,
157
+ line: msg.line,
158
+ column: msg.column,
159
+ message: msg.message,
160
+ severity,
161
+ });
162
+ if (severity === 'error') {
163
+ errorCount += 1;
164
+ fileHasError = true;
165
+ } else {
166
+ warningCount += 1;
167
+ }
168
+ }
169
+ if (fileHasError) filesWithErrors += 1;
170
+ }
171
+
172
+ return {
173
+ status: errorCount === 0 ? 'passed' : 'failed',
174
+ filesChecked: lintableFiles.length,
175
+ filesWithErrors,
176
+ errorCount,
177
+ warningCount,
178
+ durationMs: Date.now() - startedAt,
179
+ lintableFiles,
180
+ violations,
181
+ };
182
+ }
183
+
184
+ function recordReadError(
185
+ file: string,
186
+ detail: string,
187
+ violations: LintRealmViolation[],
188
+ ): void {
189
+ violations.push({
190
+ rule: 'lint-error',
191
+ file,
192
+ line: 0,
193
+ column: 0,
194
+ message: detail,
195
+ severity: 'error',
196
+ });
197
+ }
198
+
199
+ function emptyErrorResult(message: string): LintRealmResult {
200
+ return {
201
+ status: 'error',
202
+ filesChecked: 0,
203
+ filesWithErrors: 0,
204
+ errorCount: 0,
205
+ warningCount: 0,
206
+ durationMs: 0,
207
+ lintableFiles: [],
208
+ violations: [],
209
+ errorMessage: message,
210
+ };
211
+ }
212
+
213
+ interface LintCliOptions {
214
+ realm: string;
215
+ json?: boolean;
216
+ }
217
+
218
+ export function registerLintCommand(program: Command): void {
219
+ program
220
+ .command('lint')
221
+ .description(
222
+ '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.',
223
+ )
224
+ .argument(
225
+ '[path]',
226
+ 'Optional realm-relative file path. When omitted, lints every lintable file in the realm.',
227
+ )
228
+ .requiredOption('--realm <realm-url>', 'The realm URL to lint against')
229
+ .option('--json', 'Output structured JSON result')
230
+ .action(async (path: string | undefined, opts: LintCliOptions) => {
231
+ let result: LintRealmResult;
232
+ try {
233
+ result = await lintRealm(opts.realm, path ? { path } : {});
234
+ } catch (err) {
235
+ console.error(
236
+ `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
237
+ );
238
+ process.exit(1);
239
+ }
240
+
241
+ if (opts.json) {
242
+ cliLog.output(JSON.stringify(result, null, 2));
243
+ if (result.status !== 'passed') {
244
+ process.exit(1);
245
+ }
246
+ return;
247
+ }
248
+
249
+ if (result.errorMessage) {
250
+ console.error(`${FG_RED}Error:${RESET} ${result.errorMessage}`);
251
+ process.exit(1);
252
+ }
253
+
254
+ if (result.violations.length === 0) {
255
+ console.log(
256
+ `${DIM}No lint issues found (${result.filesChecked} file(s) checked).${RESET}`,
257
+ );
258
+ return;
259
+ }
260
+
261
+ let currentFile: string | undefined;
262
+ for (let v of result.violations) {
263
+ if (v.file !== currentFile) {
264
+ currentFile = v.file;
265
+ console.log(`\n${DIM}${v.file}${RESET}`);
266
+ }
267
+ let color = v.severity === 'error' ? FG_RED : FG_YELLOW;
268
+ let rule = v.rule ? ` (${v.rule})` : '';
269
+ console.log(
270
+ ` ${color}${v.severity}${RESET} ${v.line}:${v.column} ${v.message}${DIM}${rule}${RESET}`,
271
+ );
272
+ }
273
+
274
+ console.log(
275
+ `\n${DIM}${result.errorCount} error(s), ${result.warningCount} warning(s) across ${result.filesChecked} file(s)${RESET}`,
276
+ );
277
+
278
+ if (result.errorCount > 0) {
279
+ process.exit(1);
280
+ }
281
+ });
282
+ }
283
+
284
+ // Re-export for callers that want the type alongside the function.
285
+ export type { LintMessage };