@screenbook/cli 0.0.1 → 1.0.0

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/dist/index.mjs CHANGED
@@ -6,12 +6,13 @@ import { basename, dirname, join, relative, resolve } from "node:path";
6
6
  import { createJiti } from "jiti";
7
7
  import { glob } from "tinyglobby";
8
8
  import { defineConfig } from "@screenbook/core";
9
+ import pc from "picocolors";
9
10
  import { execSync, spawn } from "node:child_process";
10
11
  import prompts from "prompts";
11
12
  import { minimatch } from "minimatch";
12
13
 
13
14
  //#region src/utils/config.ts
14
- const CONFIG_FILES = [
15
+ const CONFIG_FILES$1 = [
15
16
  "screenbook.config.ts",
16
17
  "screenbook.config.js",
17
18
  "screenbook.config.mjs"
@@ -23,7 +24,7 @@ async function loadConfig(configPath) {
23
24
  if (!existsSync(absolutePath)) throw new Error(`Config file not found: ${configPath}`);
24
25
  return await importConfig(absolutePath, cwd);
25
26
  }
26
- for (const configFile of CONFIG_FILES) {
27
+ for (const configFile of CONFIG_FILES$1) {
27
28
  const absolutePath = resolve(cwd, configFile);
28
29
  if (existsSync(absolutePath)) return await importConfig(absolutePath, cwd);
29
30
  }
@@ -35,6 +36,361 @@ async function importConfig(absolutePath, cwd) {
35
36
  throw new Error(`Config file must have a default export: ${absolutePath}`);
36
37
  }
37
38
 
39
+ //#endregion
40
+ //#region src/utils/cycleDetection.ts
41
+ var Color = /* @__PURE__ */ function(Color$1) {
42
+ Color$1[Color$1["White"] = 0] = "White";
43
+ Color$1[Color$1["Gray"] = 1] = "Gray";
44
+ Color$1[Color$1["Black"] = 2] = "Black";
45
+ return Color$1;
46
+ }(Color || {});
47
+ /**
48
+ * Detect circular navigation dependencies in screen definitions.
49
+ * Uses DFS with coloring algorithm: O(V + E) complexity.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * const screens = [
54
+ * { id: "A", next: ["B"] },
55
+ * { id: "B", next: ["C"] },
56
+ * { id: "C", next: ["A"] }, // Creates cycle A → B → C → A
57
+ * ]
58
+ * const result = detectCycles(screens)
59
+ * // result.hasCycles === true
60
+ * // result.cycles[0].cycle === ["A", "B", "C", "A"]
61
+ * ```
62
+ */
63
+ function detectCycles(screens) {
64
+ const screenMap = /* @__PURE__ */ new Map();
65
+ const duplicateIds = [];
66
+ for (const screen of screens) {
67
+ if (!screen.id || typeof screen.id !== "string") continue;
68
+ if (screenMap.has(screen.id)) duplicateIds.push(screen.id);
69
+ screenMap.set(screen.id, screen);
70
+ }
71
+ const color = /* @__PURE__ */ new Map();
72
+ const parent = /* @__PURE__ */ new Map();
73
+ const cycles = [];
74
+ for (const id of screenMap.keys()) color.set(id, Color.White);
75
+ for (const id of screenMap.keys()) if (color.get(id) === Color.White) dfs(id, null);
76
+ function dfs(nodeId, parentId) {
77
+ color.set(nodeId, Color.Gray);
78
+ parent.set(nodeId, parentId);
79
+ const neighbors = screenMap.get(nodeId)?.next ?? [];
80
+ for (const neighborId of neighbors) {
81
+ const neighborColor = color.get(neighborId);
82
+ if (neighborColor === Color.Gray) {
83
+ const cyclePath = reconstructCycle(nodeId, neighborId);
84
+ const allowed = isCycleAllowed(cyclePath, screenMap);
85
+ cycles.push({
86
+ cycle: cyclePath,
87
+ allowed
88
+ });
89
+ } else if (neighborColor === Color.White) {
90
+ if (screenMap.has(neighborId)) dfs(neighborId, nodeId);
91
+ }
92
+ }
93
+ color.set(nodeId, Color.Black);
94
+ }
95
+ /**
96
+ * Reconstruct cycle path from back edge
97
+ */
98
+ function reconstructCycle(from, to) {
99
+ const path = [];
100
+ let current = from;
101
+ const visited = /* @__PURE__ */ new Set();
102
+ const maxIterations = screenMap.size + 1;
103
+ while (current && current !== to && !visited.has(current) && path.length < maxIterations) {
104
+ visited.add(current);
105
+ path.unshift(current);
106
+ current = parent.get(current);
107
+ }
108
+ path.unshift(to);
109
+ path.push(to);
110
+ return path;
111
+ }
112
+ const disallowedCycles = cycles.filter((c) => !c.allowed);
113
+ return {
114
+ hasCycles: cycles.length > 0,
115
+ cycles,
116
+ disallowedCycles,
117
+ duplicateIds
118
+ };
119
+ }
120
+ /**
121
+ * Check if any screen in the cycle has allowCycles: true
122
+ */
123
+ function isCycleAllowed(cyclePath, screenMap) {
124
+ const uniqueNodes = cyclePath.slice(0, -1);
125
+ for (const nodeId of uniqueNodes) if (screenMap.get(nodeId)?.allowCycles === true) return true;
126
+ return false;
127
+ }
128
+ /**
129
+ * Format cycle information for console output
130
+ *
131
+ * @example
132
+ * ```
133
+ * Cycle 1: A → B → C → A
134
+ * Cycle 2 (allowed): D → E → D
135
+ * ```
136
+ */
137
+ function formatCycleWarnings(cycles) {
138
+ if (cycles.length === 0) return "";
139
+ const lines = [];
140
+ for (let i = 0; i < cycles.length; i++) {
141
+ const cycle = cycles[i];
142
+ if (!cycle) continue;
143
+ const cycleStr = cycle.cycle.join(" → ");
144
+ const allowedSuffix = cycle.allowed ? " (allowed)" : "";
145
+ lines.push(` Cycle ${i + 1}${allowedSuffix}: ${cycleStr}`);
146
+ }
147
+ return lines.join("\n");
148
+ }
149
+ /**
150
+ * Get a summary of cycle detection results
151
+ */
152
+ function getCycleSummary(result) {
153
+ if (!result.hasCycles) return "No circular navigation detected";
154
+ const total = result.cycles.length;
155
+ const disallowed = result.disallowedCycles.length;
156
+ const allowed = total - disallowed;
157
+ if (disallowed === 0) return `${total} circular navigation${total > 1 ? "s" : ""} detected (all allowed)`;
158
+ if (allowed === 0) return `${total} circular navigation${total > 1 ? "s" : ""} detected`;
159
+ return `${total} circular navigation${total > 1 ? "s" : ""} detected (${disallowed} not allowed, ${allowed} allowed)`;
160
+ }
161
+
162
+ //#endregion
163
+ //#region src/utils/errors.ts
164
+ /**
165
+ * Centralized error definitions for consistent error messages across CLI commands
166
+ */
167
+ const ERRORS = {
168
+ ROUTES_PATTERN_MISSING: {
169
+ title: "routesPattern not configured",
170
+ suggestion: "Add routesPattern to your screenbook.config.ts to specify where your route files are located.",
171
+ example: `import { defineConfig } from "@screenbook/core"
172
+
173
+ export default defineConfig({
174
+ routesPattern: "src/pages/**/page.tsx", // Adjust for your framework
175
+ })`
176
+ },
177
+ CONFIG_NOT_FOUND: {
178
+ title: "Configuration file not found",
179
+ suggestion: "Run 'screenbook init' to create a screenbook.config.ts file, or create one manually.",
180
+ example: `import { defineConfig } from "@screenbook/core"
181
+
182
+ export default defineConfig({
183
+ metaPattern: "src/**/screen.meta.ts",
184
+ })`
185
+ },
186
+ SCREENS_NOT_FOUND: {
187
+ title: "screens.json not found",
188
+ suggestion: "Run 'screenbook build' first to generate the screen catalog.",
189
+ message: "If you haven't set up Screenbook yet, run 'screenbook init' to get started."
190
+ },
191
+ SCREENS_PARSE_ERROR: {
192
+ title: "Failed to parse screens.json",
193
+ suggestion: "The screens.json file may be corrupted. Try running 'screenbook build' to regenerate it."
194
+ },
195
+ META_FILE_LOAD_ERROR: (filePath) => ({
196
+ title: `Failed to load ${filePath}`,
197
+ suggestion: "Check the file for syntax errors or missing exports. The file should export a 'screen' object.",
198
+ example: `import { defineScreen } from "@screenbook/core"
199
+
200
+ export const screen = defineScreen({
201
+ id: "example.screen",
202
+ title: "Example Screen",
203
+ route: "/example",
204
+ })`
205
+ }),
206
+ API_NAME_REQUIRED: {
207
+ title: "API name is required",
208
+ suggestion: "Provide the API name as an argument.",
209
+ example: `screenbook impact UserAPI.getProfile
210
+ screenbook impact "PaymentAPI.*" # Use quotes for patterns`
211
+ },
212
+ GIT_CHANGED_FILES_ERROR: (baseBranch) => ({
213
+ title: "Failed to get changed files from git",
214
+ message: `Make sure you are in a git repository and the base branch '${baseBranch}' exists.`,
215
+ suggestion: `Verify the base branch exists with: git branch -a | grep ${baseBranch}`
216
+ }),
217
+ GIT_NOT_REPOSITORY: {
218
+ title: "Not a git repository",
219
+ suggestion: "This command requires a git repository. Initialize one with 'git init' or navigate to an existing repository."
220
+ },
221
+ SERVER_START_FAILED: (error) => ({
222
+ title: "Failed to start development server",
223
+ message: error,
224
+ suggestion: "Check if the port is already in use or if there are any dependency issues."
225
+ }),
226
+ VALIDATION_FAILED: (errorCount) => ({
227
+ title: `Validation failed with ${errorCount} error${errorCount === 1 ? "" : "s"}`,
228
+ suggestion: "Fix the validation errors above. Screen references must point to existing screens."
229
+ }),
230
+ LINT_MISSING_META: (missingCount, totalRoutes) => ({
231
+ title: `${missingCount} route${missingCount === 1 ? "" : "s"} missing screen.meta.ts`,
232
+ message: `Found ${totalRoutes} route file${totalRoutes === 1 ? "" : "s"}, but ${missingCount} ${missingCount === 1 ? "is" : "are"} missing colocated screen.meta.ts.`,
233
+ suggestion: "Add screen.meta.ts files next to your route files, or run 'screenbook generate' to create them."
234
+ }),
235
+ CYCLES_DETECTED: (cycleCount) => ({
236
+ title: `${cycleCount} circular navigation${cycleCount === 1 ? "" : "s"} detected`,
237
+ suggestion: "Review the navigation flow. Use 'allowCycles: true' in screen.meta.ts to allow intentional cycles, or use --allow-cycles to suppress all warnings.",
238
+ example: `// Allow a specific screen to be part of cycles
239
+ export const screen = defineScreen({
240
+ id: "billing.invoice.detail",
241
+ next: ["billing.invoices"],
242
+ allowCycles: true, // This cycle is intentional
243
+ })`
244
+ })
245
+ };
246
+
247
+ //#endregion
248
+ //#region src/utils/logger.ts
249
+ /**
250
+ * Logger utility for consistent, color-coded CLI output
251
+ */
252
+ const logger = {
253
+ success: (msg) => {
254
+ console.log(`${pc.green("✓")} ${msg}`);
255
+ },
256
+ error: (msg) => {
257
+ console.error(`${pc.red("✗")} ${pc.red(`Error: ${msg}`)}`);
258
+ },
259
+ warn: (msg) => {
260
+ console.log(`${pc.yellow("⚠")} ${pc.yellow(`Warning: ${msg}`)}`);
261
+ },
262
+ info: (msg) => {
263
+ console.log(`${pc.cyan("ℹ")} ${msg}`);
264
+ },
265
+ errorWithHelp: (options) => {
266
+ const { title, message, suggestion, example } = options;
267
+ console.error();
268
+ console.error(`${pc.red("✗")} ${pc.red(`Error: ${title}`)}`);
269
+ if (message) {
270
+ console.error();
271
+ console.error(` ${message}`);
272
+ }
273
+ if (suggestion) {
274
+ console.error();
275
+ console.error(` ${pc.cyan("Suggestion:")} ${suggestion}`);
276
+ }
277
+ if (example) {
278
+ console.error();
279
+ console.error(` ${pc.dim("Example:")}`);
280
+ for (const line of example.split("\n")) console.error(` ${pc.dim(line)}`);
281
+ }
282
+ console.error();
283
+ },
284
+ step: (msg) => {
285
+ console.log(`${pc.dim("→")} ${msg}`);
286
+ },
287
+ done: (msg) => {
288
+ console.log(`${pc.green("✓")} ${pc.green(msg)}`);
289
+ },
290
+ itemSuccess: (msg) => {
291
+ console.log(` ${pc.green("✓")} ${msg}`);
292
+ },
293
+ itemError: (msg) => {
294
+ console.log(` ${pc.red("✗")} ${msg}`);
295
+ },
296
+ itemWarn: (msg) => {
297
+ console.log(` ${pc.yellow("⚠")} ${msg}`);
298
+ },
299
+ log: (msg) => {
300
+ console.log(msg);
301
+ },
302
+ blank: () => {
303
+ console.log();
304
+ },
305
+ bold: (msg) => pc.bold(msg),
306
+ dim: (msg) => pc.dim(msg),
307
+ code: (msg) => pc.cyan(msg),
308
+ path: (msg) => pc.underline(msg),
309
+ highlight: (msg) => pc.cyan(pc.bold(msg)),
310
+ green: (msg) => pc.green(msg),
311
+ red: (msg) => pc.red(msg),
312
+ yellow: (msg) => pc.yellow(msg)
313
+ };
314
+
315
+ //#endregion
316
+ //#region src/utils/validation.ts
317
+ /**
318
+ * Validate screen references (next and entryPoints)
319
+ */
320
+ function validateScreenReferences(screens) {
321
+ const screenIds = new Set(screens.map((s) => s.id));
322
+ const errors = [];
323
+ for (const screen of screens) {
324
+ if (screen.next) {
325
+ for (const nextId of screen.next) if (!screenIds.has(nextId)) errors.push({
326
+ screenId: screen.id,
327
+ field: "next",
328
+ invalidRef: nextId,
329
+ suggestion: findSimilar(nextId, screenIds)
330
+ });
331
+ }
332
+ if (screen.entryPoints) {
333
+ for (const entryId of screen.entryPoints) if (!screenIds.has(entryId)) errors.push({
334
+ screenId: screen.id,
335
+ field: "entryPoints",
336
+ invalidRef: entryId,
337
+ suggestion: findSimilar(entryId, screenIds)
338
+ });
339
+ }
340
+ }
341
+ return {
342
+ valid: errors.length === 0,
343
+ errors
344
+ };
345
+ }
346
+ /**
347
+ * Find similar screen ID using Levenshtein distance
348
+ */
349
+ function findSimilar(target, candidates) {
350
+ let bestMatch;
351
+ let bestDistance = Number.POSITIVE_INFINITY;
352
+ const maxDistance = Math.ceil(target.length * .4);
353
+ for (const candidate of candidates) {
354
+ const distance = levenshteinDistance(target, candidate);
355
+ if (distance < bestDistance && distance <= maxDistance) {
356
+ bestDistance = distance;
357
+ bestMatch = candidate;
358
+ }
359
+ }
360
+ return bestMatch;
361
+ }
362
+ /**
363
+ * Calculate Levenshtein distance between two strings
364
+ */
365
+ function levenshteinDistance(a, b) {
366
+ const matrix = Array.from({ length: a.length + 1 }, () => Array.from({ length: b.length + 1 }, () => 0));
367
+ const get = (i, j) => matrix[i]?.[j] ?? 0;
368
+ const set = (i, j, value) => {
369
+ const row = matrix[i];
370
+ if (row) row[j] = value;
371
+ };
372
+ for (let i = 0; i <= a.length; i++) set(i, 0, i);
373
+ for (let j = 0; j <= b.length; j++) set(0, j, j);
374
+ for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
375
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
376
+ set(i, j, Math.min(get(i - 1, j) + 1, get(i, j - 1) + 1, get(i - 1, j - 1) + cost));
377
+ }
378
+ return get(a.length, b.length);
379
+ }
380
+ /**
381
+ * Format validation errors for console output
382
+ */
383
+ function formatValidationErrors(errors) {
384
+ const lines = [];
385
+ for (const error of errors) {
386
+ lines.push(` Screen "${error.screenId}"`);
387
+ lines.push(` → ${error.field} references non-existent screen "${error.invalidRef}"`);
388
+ if (error.suggestion) lines.push(` Did you mean "${error.suggestion}"?`);
389
+ lines.push("");
390
+ }
391
+ return lines.join("\n");
392
+ }
393
+
38
394
  //#endregion
39
395
  //#region src/commands/build.ts
40
396
  const buildCommand = define({
@@ -50,22 +406,33 @@ const buildCommand = define({
50
406
  type: "string",
51
407
  short: "o",
52
408
  description: "Output directory"
409
+ },
410
+ strict: {
411
+ type: "boolean",
412
+ short: "s",
413
+ description: "Fail on validation errors and disallowed cycles",
414
+ default: false
415
+ },
416
+ allowCycles: {
417
+ type: "boolean",
418
+ description: "Suppress all circular navigation warnings",
419
+ default: false
53
420
  }
54
421
  },
55
422
  run: async (ctx) => {
56
423
  const config = await loadConfig(ctx.values.config);
57
424
  const outDir = ctx.values.outDir ?? config.outDir;
58
425
  const cwd = process.cwd();
59
- console.log("Building screen metadata...");
426
+ logger.info("Building screen metadata...");
60
427
  const files = await glob(config.metaPattern, {
61
428
  cwd,
62
429
  ignore: config.ignore
63
430
  });
64
431
  if (files.length === 0) {
65
- console.log(`No screen.meta.ts files found matching: ${config.metaPattern}`);
432
+ logger.warn(`No screen.meta.ts files found matching: ${config.metaPattern}`);
66
433
  return;
67
434
  }
68
- console.log(`Found ${files.length} screen files`);
435
+ logger.info(`Found ${files.length} screen files`);
69
436
  const jiti = createJiti(cwd);
70
437
  const screens = [];
71
438
  for (const file of files) {
@@ -74,25 +441,51 @@ const buildCommand = define({
74
441
  const module = await jiti.import(absolutePath);
75
442
  if (module.screen) {
76
443
  screens.push(module.screen);
77
- console.log(` ✓ ${module.screen.id}`);
444
+ logger.itemSuccess(module.screen.id);
78
445
  }
79
446
  } catch (error) {
80
- console.error(`Failed to load ${file}:`, error);
447
+ logger.itemError(`Failed to load ${file}`);
448
+ if (error instanceof Error) logger.log(` ${logger.dim(error.message)}`);
449
+ }
450
+ }
451
+ const validation = validateScreenReferences(screens);
452
+ if (!validation.valid) {
453
+ logger.blank();
454
+ logger.warn("Invalid screen references found:");
455
+ logger.log(formatValidationErrors(validation.errors));
456
+ if (ctx.values.strict) {
457
+ logger.errorWithHelp(ERRORS.VALIDATION_FAILED(validation.errors.length));
458
+ process.exit(1);
459
+ }
460
+ }
461
+ if (!ctx.values.allowCycles) {
462
+ const cycleResult = detectCycles(screens);
463
+ if (cycleResult.hasCycles) {
464
+ logger.blank();
465
+ logger.warn(getCycleSummary(cycleResult));
466
+ logger.log(formatCycleWarnings(cycleResult.cycles));
467
+ if (ctx.values.strict && cycleResult.disallowedCycles.length > 0) {
468
+ logger.blank();
469
+ logger.errorWithHelp(ERRORS.CYCLES_DETECTED(cycleResult.disallowedCycles.length));
470
+ process.exit(1);
471
+ }
81
472
  }
82
473
  }
83
474
  const outputPath = join(cwd, outDir, "screens.json");
84
475
  const outputDir = dirname(outputPath);
85
476
  if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
86
477
  writeFileSync(outputPath, JSON.stringify(screens, null, 2));
87
- console.log(`\nGenerated ${outputPath}`);
478
+ logger.blank();
479
+ logger.success(`Generated ${logger.path(outputPath)}`);
88
480
  const mermaidPath = join(cwd, outDir, "graph.mmd");
89
481
  writeFileSync(mermaidPath, generateMermaidGraph(screens));
90
- console.log(`Generated ${mermaidPath}`);
482
+ logger.success(`Generated ${logger.path(mermaidPath)}`);
91
483
  const coverage = await generateCoverageData(config, cwd, screens);
92
484
  const coveragePath = join(cwd, outDir, "coverage.json");
93
485
  writeFileSync(coveragePath, JSON.stringify(coverage, null, 2));
94
- console.log(`Generated ${coveragePath}`);
95
- console.log(`\nCoverage: ${coverage.covered}/${coverage.total} (${coverage.percentage}%)`);
486
+ logger.success(`Generated ${logger.path(coveragePath)}`);
487
+ logger.blank();
488
+ logger.done(`Coverage: ${coverage.covered}/${coverage.total} (${coverage.percentage}%)`);
96
489
  }
97
490
  });
98
491
  async function generateCoverageData(config, cwd, screens) {
@@ -182,11 +575,14 @@ const devCommand = define({
182
575
  const config = await loadConfig(ctx.values.config);
183
576
  const port = ctx.values.port ?? "4321";
184
577
  const cwd = process.cwd();
185
- console.log("Starting Screenbook development server...");
578
+ logger.info("Starting Screenbook development server...");
186
579
  await buildScreens(config, cwd);
187
580
  const uiPackagePath = resolveUiPackage();
188
581
  if (!uiPackagePath) {
189
- console.error("Could not find @screenbook/ui package");
582
+ logger.errorWithHelp({
583
+ title: "Could not find @screenbook/ui package",
584
+ suggestion: "Make sure @screenbook/ui is installed. Run 'npm install @screenbook/ui' or 'pnpm add @screenbook/ui'."
585
+ });
190
586
  process.exit(1);
191
587
  }
192
588
  const screensJsonPath = join(cwd, config.outDir, "screens.json");
@@ -195,7 +591,8 @@ const devCommand = define({
195
591
  if (!existsSync(uiScreensDir)) mkdirSync(uiScreensDir, { recursive: true });
196
592
  if (existsSync(screensJsonPath)) copyFileSync(screensJsonPath, join(uiScreensDir, "screens.json"));
197
593
  if (existsSync(coverageJsonPath)) copyFileSync(coverageJsonPath, join(uiScreensDir, "coverage.json"));
198
- console.log(`\nStarting UI server on http://localhost:${port}`);
594
+ logger.blank();
595
+ logger.info(`Starting UI server on ${logger.highlight(`http://localhost:${port}`)}`);
199
596
  const astroProcess = spawn("npx", [
200
597
  "astro",
201
598
  "dev",
@@ -207,7 +604,7 @@ const devCommand = define({
207
604
  shell: true
208
605
  });
209
606
  astroProcess.on("error", (error) => {
210
- console.error("Failed to start Astro server:", error);
607
+ logger.errorWithHelp(ERRORS.SERVER_START_FAILED(error.message));
211
608
  process.exit(1);
212
609
  });
213
610
  astroProcess.on("close", (code) => {
@@ -227,10 +624,10 @@ async function buildScreens(config, cwd) {
227
624
  ignore: config.ignore
228
625
  });
229
626
  if (files.length === 0) {
230
- console.log(`No screen.meta.ts files found matching: ${config.metaPattern}`);
627
+ logger.warn(`No screen.meta.ts files found matching: ${config.metaPattern}`);
231
628
  return;
232
629
  }
233
- console.log(`Found ${files.length} screen files`);
630
+ logger.info(`Found ${files.length} screen files`);
234
631
  const jiti = createJiti(cwd);
235
632
  const screens = [];
236
633
  for (const file of files) {
@@ -239,17 +636,19 @@ async function buildScreens(config, cwd) {
239
636
  const module = await jiti.import(absolutePath);
240
637
  if (module.screen) {
241
638
  screens.push(module.screen);
242
- console.log(` ✓ ${module.screen.id}`);
639
+ logger.itemSuccess(module.screen.id);
243
640
  }
244
641
  } catch (error) {
245
- console.error(`Failed to load ${file}:`, error);
642
+ logger.itemError(`Failed to load ${file}`);
643
+ if (error instanceof Error) logger.log(` ${logger.dim(error.message)}`);
246
644
  }
247
645
  }
248
646
  const outputPath = join(cwd, config.outDir, "screens.json");
249
647
  const outputDir = dirname(outputPath);
250
648
  if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
251
649
  writeFileSync(outputPath, JSON.stringify(screens, null, 2));
252
- console.log(`\nGenerated ${outputPath}`);
650
+ logger.blank();
651
+ logger.success(`Generated ${logger.path(outputPath)}`);
253
652
  }
254
653
  function resolveUiPackage() {
255
654
  try {
@@ -265,6 +664,267 @@ function resolveUiPackage() {
265
664
  }
266
665
  }
267
666
 
667
+ //#endregion
668
+ //#region src/commands/doctor.ts
669
+ const CONFIG_FILES = [
670
+ "screenbook.config.ts",
671
+ "screenbook.config.js",
672
+ "screenbook.config.mjs"
673
+ ];
674
+ const doctorCommand = define({
675
+ name: "doctor",
676
+ description: "Diagnose common issues with Screenbook setup",
677
+ args: { verbose: {
678
+ type: "boolean",
679
+ short: "v",
680
+ description: "Show detailed output",
681
+ default: false
682
+ } },
683
+ run: async (ctx) => {
684
+ const cwd = process.cwd();
685
+ const verbose = ctx.values.verbose;
686
+ logger.log("");
687
+ logger.log(logger.bold("Screenbook Doctor"));
688
+ logger.log("─────────────────");
689
+ logger.log("");
690
+ const results = [];
691
+ results.push(await checkConfigFile(cwd));
692
+ results.push(await checkDependencies(cwd));
693
+ const config = await loadConfig();
694
+ results.push(await checkMetaPattern(cwd, config.metaPattern, config.ignore));
695
+ results.push(await checkRoutesPattern(cwd, config.routesPattern, config.ignore));
696
+ results.push(await checkBuildOutput(cwd, config.outDir));
697
+ results.push(await checkVersionCompatibility(cwd));
698
+ results.push(await checkGitRepository(cwd));
699
+ displayResults(results, verbose);
700
+ }
701
+ });
702
+ async function checkConfigFile(cwd) {
703
+ for (const configFile of CONFIG_FILES) if (existsSync(resolve(cwd, configFile))) return {
704
+ name: "Config file",
705
+ status: "pass",
706
+ message: `Found: ${configFile}`
707
+ };
708
+ return {
709
+ name: "Config file",
710
+ status: "warn",
711
+ message: "No config file found (using defaults)",
712
+ suggestion: "Run 'screenbook init' to create a config file"
713
+ };
714
+ }
715
+ async function checkDependencies(cwd) {
716
+ const packageJsonPath = join(cwd, "package.json");
717
+ if (!existsSync(packageJsonPath)) return {
718
+ name: "Dependencies",
719
+ status: "fail",
720
+ message: "package.json not found",
721
+ suggestion: "Run 'npm init' or 'pnpm init' to create package.json"
722
+ };
723
+ try {
724
+ const content = readFileSync(packageJsonPath, "utf-8");
725
+ const pkg = JSON.parse(content);
726
+ const allDeps = {
727
+ ...pkg.dependencies,
728
+ ...pkg.devDependencies
729
+ };
730
+ const coreVersion = allDeps["@screenbook/core"];
731
+ const cliVersion = allDeps["@screenbook/cli"];
732
+ if (!coreVersion && !cliVersion) return {
733
+ name: "Dependencies",
734
+ status: "fail",
735
+ message: "Screenbook packages not installed",
736
+ suggestion: "Run 'pnpm add -D @screenbook/core @screenbook/cli' to install"
737
+ };
738
+ if (!coreVersion) return {
739
+ name: "Dependencies",
740
+ status: "warn",
741
+ message: "@screenbook/core not found in dependencies",
742
+ suggestion: "Run 'pnpm add -D @screenbook/core' to install"
743
+ };
744
+ if (!cliVersion) return {
745
+ name: "Dependencies",
746
+ status: "warn",
747
+ message: "@screenbook/cli not found in dependencies",
748
+ suggestion: "Run 'pnpm add -D @screenbook/cli' to install"
749
+ };
750
+ return {
751
+ name: "Dependencies",
752
+ status: "pass",
753
+ message: `@screenbook/core@${coreVersion}, @screenbook/cli@${cliVersion}`
754
+ };
755
+ } catch {
756
+ return {
757
+ name: "Dependencies",
758
+ status: "fail",
759
+ message: "Failed to read package.json",
760
+ suggestion: "Ensure package.json is valid JSON"
761
+ };
762
+ }
763
+ }
764
+ async function checkMetaPattern(cwd, metaPattern, ignore) {
765
+ try {
766
+ const files = await glob(metaPattern, {
767
+ cwd,
768
+ ignore
769
+ });
770
+ if (files.length === 0) return {
771
+ name: "Screen meta files",
772
+ status: "warn",
773
+ message: `No files matching: ${metaPattern}`,
774
+ suggestion: "Run 'screenbook generate' to create screen.meta.ts files"
775
+ };
776
+ return {
777
+ name: "Screen meta files",
778
+ status: "pass",
779
+ message: `Found ${files.length} screen.meta.ts file${files.length > 1 ? "s" : ""}`
780
+ };
781
+ } catch {
782
+ return {
783
+ name: "Screen meta files",
784
+ status: "fail",
785
+ message: `Invalid pattern: ${metaPattern}`,
786
+ suggestion: "Check metaPattern in your config file"
787
+ };
788
+ }
789
+ }
790
+ async function checkRoutesPattern(cwd, routesPattern, ignore) {
791
+ if (!routesPattern) return {
792
+ name: "Routes pattern",
793
+ status: "warn",
794
+ message: "routesPattern not configured",
795
+ suggestion: "Set routesPattern in config to enable 'lint' and 'generate' commands"
796
+ };
797
+ try {
798
+ const files = await glob(routesPattern, {
799
+ cwd,
800
+ ignore
801
+ });
802
+ if (files.length === 0) return {
803
+ name: "Routes pattern",
804
+ status: "warn",
805
+ message: `No files matching: ${routesPattern}`,
806
+ suggestion: "Check routesPattern in your config file"
807
+ };
808
+ return {
809
+ name: "Routes pattern",
810
+ status: "pass",
811
+ message: `Found ${files.length} route file${files.length > 1 ? "s" : ""}`
812
+ };
813
+ } catch {
814
+ return {
815
+ name: "Routes pattern",
816
+ status: "fail",
817
+ message: `Invalid pattern: ${routesPattern}`,
818
+ suggestion: "Check routesPattern in your config file"
819
+ };
820
+ }
821
+ }
822
+ async function checkBuildOutput(cwd, outDir) {
823
+ const screensJsonPath = join(cwd, outDir, "screens.json");
824
+ if (!existsSync(screensJsonPath)) return {
825
+ name: "Build output",
826
+ status: "fail",
827
+ message: `screens.json not found in ${outDir}/`,
828
+ suggestion: "Run 'screenbook build' to generate metadata"
829
+ };
830
+ try {
831
+ const content = readFileSync(screensJsonPath, "utf-8");
832
+ const screens = JSON.parse(content);
833
+ return {
834
+ name: "Build output",
835
+ status: "pass",
836
+ message: `screens.json contains ${screens.length} screen${screens.length > 1 ? "s" : ""}`
837
+ };
838
+ } catch {
839
+ return {
840
+ name: "Build output",
841
+ status: "fail",
842
+ message: "screens.json is corrupted",
843
+ suggestion: "Run 'screenbook build' to regenerate"
844
+ };
845
+ }
846
+ }
847
+ async function checkVersionCompatibility(cwd) {
848
+ const packageJsonPath = join(cwd, "package.json");
849
+ if (!existsSync(packageJsonPath)) return {
850
+ name: "Version compatibility",
851
+ status: "warn",
852
+ message: "Cannot check - package.json not found"
853
+ };
854
+ try {
855
+ const content = readFileSync(packageJsonPath, "utf-8");
856
+ const pkg = JSON.parse(content);
857
+ const allDeps = {
858
+ ...pkg.dependencies,
859
+ ...pkg.devDependencies
860
+ };
861
+ const coreVersion = allDeps["@screenbook/core"];
862
+ const cliVersion = allDeps["@screenbook/cli"];
863
+ if (!coreVersion || !cliVersion) return {
864
+ name: "Version compatibility",
865
+ status: "warn",
866
+ message: "Cannot check - packages not installed"
867
+ };
868
+ const extractMajor = (version) => {
869
+ return version.replace(/^[\^~>=<]+/, "").split(".")[0] ?? "0";
870
+ };
871
+ if (extractMajor(coreVersion) !== extractMajor(cliVersion)) return {
872
+ name: "Version compatibility",
873
+ status: "warn",
874
+ message: `Major version mismatch: core@${coreVersion} vs cli@${cliVersion}`,
875
+ suggestion: "Update packages to matching major versions"
876
+ };
877
+ return {
878
+ name: "Version compatibility",
879
+ status: "pass",
880
+ message: "Package versions are compatible"
881
+ };
882
+ } catch {
883
+ return {
884
+ name: "Version compatibility",
885
+ status: "fail",
886
+ message: "Failed to read package.json"
887
+ };
888
+ }
889
+ }
890
+ async function checkGitRepository(cwd) {
891
+ if (!existsSync(join(cwd, ".git"))) return {
892
+ name: "Git repository",
893
+ status: "warn",
894
+ message: "Not a git repository",
895
+ suggestion: "Run 'git init' to enable 'pr-impact' command for PR analysis"
896
+ };
897
+ return {
898
+ name: "Git repository",
899
+ status: "pass",
900
+ message: "Git repository detected"
901
+ };
902
+ }
903
+ function displayResults(results, verbose) {
904
+ let passCount = 0;
905
+ let failCount = 0;
906
+ let warnCount = 0;
907
+ for (const result of results) {
908
+ const icon = result.status === "pass" ? logger.green("✓") : result.status === "fail" ? logger.red("✗") : logger.yellow("⚠");
909
+ const statusColor = result.status === "pass" ? logger.green : result.status === "fail" ? logger.red : logger.yellow;
910
+ logger.log(`${icon} ${statusColor(result.name)}: ${result.message}`);
911
+ if (result.suggestion && (result.status !== "pass" || verbose)) logger.log(` ${logger.dim("→")} ${result.suggestion}`);
912
+ if (result.status === "pass") passCount++;
913
+ else if (result.status === "fail") failCount++;
914
+ else warnCount++;
915
+ }
916
+ logger.log("");
917
+ const summary = [];
918
+ if (passCount > 0) summary.push(logger.green(`${passCount} passed`));
919
+ if (failCount > 0) summary.push(logger.red(`${failCount} failed`));
920
+ if (warnCount > 0) summary.push(logger.yellow(`${warnCount} warnings`));
921
+ logger.log(`Summary: ${summary.join(", ")}`);
922
+ if (failCount > 0) {
923
+ logger.log("");
924
+ logger.log(logger.dim("Run the suggested commands above to fix the issues."));
925
+ }
926
+ }
927
+
268
928
  //#endregion
269
929
  //#region src/commands/generate.ts
270
930
  const generateCommand = define({
@@ -287,6 +947,12 @@ const generateCommand = define({
287
947
  short: "f",
288
948
  description: "Overwrite existing screen.meta.ts files",
289
949
  default: false
950
+ },
951
+ interactive: {
952
+ type: "boolean",
953
+ short: "i",
954
+ description: "Interactively confirm or modify each screen",
955
+ default: false
290
956
  }
291
957
  },
292
958
  run: async (ctx) => {
@@ -294,29 +960,23 @@ const generateCommand = define({
294
960
  const cwd = process.cwd();
295
961
  const dryRun = ctx.values.dryRun ?? false;
296
962
  const force = ctx.values.force ?? false;
963
+ const interactive = ctx.values.interactive ?? false;
297
964
  if (!config.routesPattern) {
298
- console.log("Error: routesPattern not configured");
299
- console.log("");
300
- console.log("Add routesPattern to your screenbook.config.ts:");
301
- console.log("");
302
- console.log(" routesPattern: \"src/pages/**/page.tsx\", // Vite/React");
303
- console.log(" routesPattern: \"app/**/page.tsx\", // Next.js App Router");
304
- console.log(" routesPattern: \"src/pages/**/*.vue\", // Vue/Nuxt");
305
- console.log("");
965
+ logger.errorWithHelp(ERRORS.ROUTES_PATTERN_MISSING);
306
966
  process.exit(1);
307
967
  }
308
- console.log("Scanning for route files...");
309
- console.log("");
968
+ logger.info("Scanning for route files...");
969
+ logger.blank();
310
970
  const routeFiles = await glob(config.routesPattern, {
311
971
  cwd,
312
972
  ignore: config.ignore
313
973
  });
314
974
  if (routeFiles.length === 0) {
315
- console.log(`No route files found matching: ${config.routesPattern}`);
975
+ logger.warn(`No route files found matching: ${config.routesPattern}`);
316
976
  return;
317
977
  }
318
- console.log(`Found ${routeFiles.length} route files`);
319
- console.log("");
978
+ logger.log(`Found ${routeFiles.length} route files`);
979
+ logger.blank();
320
980
  let created = 0;
321
981
  let skipped = 0;
322
982
  for (const routeFile of routeFiles) {
@@ -324,44 +984,141 @@ const generateCommand = define({
324
984
  const metaPath = join(routeDir, "screen.meta.ts");
325
985
  const absoluteMetaPath = join(cwd, metaPath);
326
986
  if (!force && existsSync(absoluteMetaPath)) {
987
+ if (!interactive) {
988
+ skipped++;
989
+ continue;
990
+ }
991
+ logger.itemWarn(`Exists: ${logger.path(metaPath)} (use --force to overwrite)`);
327
992
  skipped++;
328
993
  continue;
329
994
  }
330
995
  const screenMeta = inferScreenMeta(routeDir, config.routesPattern);
331
- const content = generateScreenMetaContent(screenMeta);
332
- if (dryRun) {
333
- console.log(`Would create: ${metaPath}`);
334
- console.log(` id: "${screenMeta.id}"`);
335
- console.log(` title: "${screenMeta.title}"`);
336
- console.log(` route: "${screenMeta.route}"`);
337
- console.log("");
996
+ if (interactive) {
997
+ const result = await promptForScreen(routeFile, screenMeta);
998
+ if (result.skip) {
999
+ logger.itemWarn(`Skipped: ${logger.path(metaPath)}`);
1000
+ skipped++;
1001
+ continue;
1002
+ }
1003
+ const content = generateScreenMetaContent(result.meta, {
1004
+ owner: result.owner,
1005
+ tags: result.tags
1006
+ });
1007
+ if (dryRun) {
1008
+ logger.step(`Would create: ${logger.path(metaPath)}`);
1009
+ logger.log(` ${logger.dim(`id: "${result.meta.id}"`)}`);
1010
+ logger.log(` ${logger.dim(`title: "${result.meta.title}"`)}`);
1011
+ logger.log(` ${logger.dim(`route: "${result.meta.route}"`)}`);
1012
+ if (result.owner.length > 0) logger.log(` ${logger.dim(`owner: [${result.owner.map((o) => `"${o}"`).join(", ")}]`)}`);
1013
+ if (result.tags.length > 0) logger.log(` ${logger.dim(`tags: [${result.tags.map((t) => `"${t}"`).join(", ")}]`)}`);
1014
+ logger.blank();
1015
+ } else {
1016
+ writeFileSync(absoluteMetaPath, content);
1017
+ logger.itemSuccess(`Created: ${logger.path(metaPath)}`);
1018
+ }
338
1019
  } else {
339
- writeFileSync(absoluteMetaPath, content);
340
- console.log(`✓ Created: ${metaPath}`);
1020
+ const content = generateScreenMetaContent(screenMeta);
1021
+ if (dryRun) {
1022
+ logger.step(`Would create: ${logger.path(metaPath)}`);
1023
+ logger.log(` ${logger.dim(`id: "${screenMeta.id}"`)}`);
1024
+ logger.log(` ${logger.dim(`title: "${screenMeta.title}"`)}`);
1025
+ logger.log(` ${logger.dim(`route: "${screenMeta.route}"`)}`);
1026
+ logger.blank();
1027
+ } else {
1028
+ writeFileSync(absoluteMetaPath, content);
1029
+ logger.itemSuccess(`Created: ${logger.path(metaPath)}`);
1030
+ }
341
1031
  }
342
1032
  created++;
343
1033
  }
344
- console.log("");
1034
+ logger.blank();
345
1035
  if (dryRun) {
346
- console.log(`Would create ${created} files (${skipped} already exist)`);
347
- console.log("");
348
- console.log("Run without --dry-run to create files");
1036
+ logger.info(`Would create ${created} files (${skipped} already exist)`);
1037
+ logger.blank();
1038
+ logger.log(`Run without ${logger.code("--dry-run")} to create files`);
349
1039
  } else {
350
- console.log(`Created ${created} files (${skipped} skipped)`);
1040
+ logger.done(`Created ${created} files (${skipped} skipped)`);
351
1041
  if (created > 0) {
352
- console.log("");
353
- console.log("Next steps:");
354
- console.log(" 1. Review and customize the generated screen.meta.ts files");
355
- console.log(" 2. Run 'screenbook dev' to view your screen catalog");
1042
+ logger.blank();
1043
+ logger.log(logger.bold("Next steps:"));
1044
+ logger.log(" 1. Review and customize the generated screen.meta.ts files");
1045
+ logger.log(` 2. Run ${logger.code("screenbook dev")} to view your screen catalog`);
356
1046
  }
357
1047
  }
358
1048
  }
359
1049
  });
360
1050
  /**
1051
+ * Parse comma-separated string into array
1052
+ */
1053
+ function parseCommaSeparated(input) {
1054
+ if (!input.trim()) return [];
1055
+ return input.split(",").map((s) => s.trim()).filter(Boolean);
1056
+ }
1057
+ /**
1058
+ * Prompt user for screen metadata in interactive mode
1059
+ */
1060
+ async function promptForScreen(routeFile, inferred) {
1061
+ logger.blank();
1062
+ logger.info(`Found: ${logger.path(routeFile)}`);
1063
+ logger.blank();
1064
+ logger.log(` ${logger.dim("ID:")} ${inferred.id} ${logger.dim("(inferred)")}`);
1065
+ logger.log(` ${logger.dim("Title:")} ${inferred.title} ${logger.dim("(inferred)")}`);
1066
+ logger.log(` ${logger.dim("Route:")} ${inferred.route} ${logger.dim("(inferred)")}`);
1067
+ logger.blank();
1068
+ const response = await prompts([
1069
+ {
1070
+ type: "confirm",
1071
+ name: "proceed",
1072
+ message: "Generate this screen?",
1073
+ initial: true
1074
+ },
1075
+ {
1076
+ type: (prev) => prev ? "text" : null,
1077
+ name: "id",
1078
+ message: "ID",
1079
+ initial: inferred.id
1080
+ },
1081
+ {
1082
+ type: (_prev, values) => values.proceed ? "text" : null,
1083
+ name: "title",
1084
+ message: "Title",
1085
+ initial: inferred.title
1086
+ },
1087
+ {
1088
+ type: (_prev, values) => values.proceed ? "text" : null,
1089
+ name: "owner",
1090
+ message: "Owner (comma-separated)",
1091
+ initial: ""
1092
+ },
1093
+ {
1094
+ type: (_prev, values) => values.proceed ? "text" : null,
1095
+ name: "tags",
1096
+ message: "Tags (comma-separated)",
1097
+ initial: inferred.id.split(".")[0] || ""
1098
+ }
1099
+ ]);
1100
+ if (!response.proceed) return {
1101
+ skip: true,
1102
+ meta: inferred,
1103
+ owner: [],
1104
+ tags: []
1105
+ };
1106
+ return {
1107
+ skip: false,
1108
+ meta: {
1109
+ id: response.id || inferred.id,
1110
+ title: response.title || inferred.title,
1111
+ route: inferred.route
1112
+ },
1113
+ owner: parseCommaSeparated(response.owner || ""),
1114
+ tags: parseCommaSeparated(response.tags || "")
1115
+ };
1116
+ }
1117
+ /**
361
1118
  * Infer screen metadata from the route file path
362
1119
  */
363
1120
  function inferScreenMeta(routeDir, routesPattern) {
364
- const relativePath = relative(routesPattern.split("*")[0].replace(/\/$/, ""), routeDir);
1121
+ const relativePath = relative(routesPattern.split("*")[0]?.replace(/\/$/, "") ?? "", routeDir);
365
1122
  if (!relativePath || relativePath === ".") return {
366
1123
  id: "home",
367
1124
  title: "Home",
@@ -371,18 +1128,21 @@ function inferScreenMeta(routeDir, routesPattern) {
371
1128
  return {
372
1129
  id: segments.join("."),
373
1130
  title: (segments[segments.length - 1] || "home").split(/[-_]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" "),
374
- route: "/" + relativePath.split("/").filter((s) => s && !s.startsWith("(") && !s.endsWith(")")).map((s) => {
1131
+ route: `/${relativePath.split("/").filter((s) => s && !s.startsWith("(") && !s.endsWith(")")).map((s) => {
375
1132
  if (s.startsWith("[...") && s.endsWith("]")) return "*";
376
1133
  if (s.startsWith("[") && s.endsWith("]")) return `:${s.slice(1, -1)}`;
377
1134
  return s;
378
- }).join("/")
1135
+ }).join("/")}`
379
1136
  };
380
1137
  }
381
1138
  /**
382
1139
  * Generate screen.meta.ts file content
383
1140
  */
384
- function generateScreenMetaContent(meta) {
385
- const inferredTag = meta.id.split(".")[0] || "general";
1141
+ function generateScreenMetaContent(meta, options) {
1142
+ const owner = options?.owner ?? [];
1143
+ const tags = options?.tags && options.tags.length > 0 ? options.tags : [meta.id.split(".")[0] || "general"];
1144
+ const ownerStr = owner.length > 0 ? `[${owner.map((o) => `"${o}"`).join(", ")}]` : "[]";
1145
+ const tagsStr = `[${tags.map((t) => `"${t}"`).join(", ")}]`;
386
1146
  return `import { defineScreen } from "@screenbook/core"
387
1147
 
388
1148
  export const screen = defineScreen({
@@ -391,10 +1151,10 @@ export const screen = defineScreen({
391
1151
  route: "${meta.route}",
392
1152
 
393
1153
  // Team or individual responsible for this screen
394
- owner: [],
1154
+ owner: ${ownerStr},
395
1155
 
396
1156
  // Tags for filtering in the catalog
397
- tags: ["${inferredTag}"],
1157
+ tags: ${tagsStr},
398
1158
 
399
1159
  // APIs/services this screen depends on (for impact analysis)
400
1160
  // Example: ["UserAPI.getProfile", "PaymentService.checkout"]
@@ -436,7 +1196,7 @@ function buildNavigationGraph(screens) {
436
1196
  for (const screen of screens) {
437
1197
  if (!screen.next) continue;
438
1198
  if (!graph.has(screen.id)) graph.set(screen.id, /* @__PURE__ */ new Set());
439
- for (const nextId of screen.next) graph.get(screen.id).add(nextId);
1199
+ for (const nextId of screen.next) graph.get(screen.id)?.add(nextId);
440
1200
  }
441
1201
  return graph;
442
1202
  }
@@ -474,6 +1234,7 @@ function findPathToDirectDependent(startId, targetIds, graph, maxDepth, visited)
474
1234
  const localVisited = new Set([startId]);
475
1235
  while (queue.length > 0) {
476
1236
  const current = queue.shift();
1237
+ if (!current) break;
477
1238
  if (current.path.length > maxDepth + 1) continue;
478
1239
  const neighbors = graph.get(current.id);
479
1240
  if (!neighbors) continue;
@@ -521,7 +1282,7 @@ function formatImpactText(result) {
521
1282
  }
522
1283
  if (result.transitive.length > 0) {
523
1284
  lines.push(`Transitive (${result.transitive.length} screen${result.transitive.length > 1 ? "s" : ""}):`);
524
- for (const { screen, path } of result.transitive) lines.push(` - ${path.join(" -> ")}`);
1285
+ for (const { path } of result.transitive) lines.push(` - ${path.join(" -> ")}`);
525
1286
  lines.push("");
526
1287
  }
527
1288
  if (result.totalCount === 0) {
@@ -590,22 +1351,14 @@ const impactCommand = define({
590
1351
  const cwd = process.cwd();
591
1352
  const apiName = ctx.values.api;
592
1353
  if (!apiName) {
593
- console.error("Error: API name is required");
594
- console.error("");
595
- console.error("Usage: screenbook impact <api-name>");
596
- console.error("");
597
- console.error("Examples:");
598
- console.error(" screenbook impact InvoiceAPI.getDetail");
599
- console.error(" screenbook impact PaymentService");
1354
+ logger.errorWithHelp(ERRORS.API_NAME_REQUIRED);
600
1355
  process.exit(1);
601
1356
  }
602
1357
  const format = ctx.values.format ?? "text";
603
1358
  const depth = ctx.values.depth ?? 3;
604
1359
  const screensPath = join(cwd, config.outDir, "screens.json");
605
1360
  if (!existsSync(screensPath)) {
606
- console.error("Error: screens.json not found");
607
- console.error("");
608
- console.error("Run 'screenbook build' first to generate the screen catalog.");
1361
+ logger.errorWithHelp(ERRORS.SCREENS_NOT_FOUND);
609
1362
  process.exit(1);
610
1363
  }
611
1364
  let screens;
@@ -613,20 +1366,22 @@ const impactCommand = define({
613
1366
  const content = readFileSync(screensPath, "utf-8");
614
1367
  screens = JSON.parse(content);
615
1368
  } catch (error) {
616
- console.error("Error: Failed to read screens.json");
617
- console.error(error instanceof Error ? error.message : String(error));
1369
+ logger.errorWithHelp({
1370
+ ...ERRORS.SCREENS_PARSE_ERROR,
1371
+ message: error instanceof Error ? error.message : String(error)
1372
+ });
618
1373
  process.exit(1);
619
1374
  }
620
1375
  if (screens.length === 0) {
621
- console.log("No screens found in the catalog.");
622
- console.log("");
623
- console.log("Run 'screenbook generate' to create screen.meta.ts files,");
624
- console.log("then 'screenbook build' to generate the catalog.");
1376
+ logger.warn("No screens found in the catalog.");
1377
+ logger.blank();
1378
+ logger.log("Run 'screenbook generate' to create screen.meta.ts files,");
1379
+ logger.log("then 'screenbook build' to generate the catalog.");
625
1380
  return;
626
1381
  }
627
1382
  const result = analyzeImpact(screens, apiName, depth);
628
- if (format === "json") console.log(formatImpactJson(result));
629
- else console.log(formatImpactText(result));
1383
+ if (format === "json") logger.log(formatImpactJson(result));
1384
+ else logger.log(formatImpactText(result));
630
1385
  }
631
1386
  });
632
1387
 
@@ -819,30 +1574,30 @@ export default defineConfig({
819
1574
  `;
820
1575
  }
821
1576
  function printValueProposition() {
822
- console.log("");
823
- console.log("What Screenbook gives you:");
824
- console.log(" - Screen catalog with search & filter");
825
- console.log(" - Navigation graph visualization");
826
- console.log(" - Impact analysis (API -> affected screens)");
827
- console.log(" - CI lint for documentation coverage");
1577
+ logger.blank();
1578
+ logger.log(logger.bold("What Screenbook gives you:"));
1579
+ logger.log(" - Screen catalog with search & filter");
1580
+ logger.log(" - Navigation graph visualization");
1581
+ logger.log(" - Impact analysis (API -> affected screens)");
1582
+ logger.log(" - CI lint for documentation coverage");
828
1583
  }
829
1584
  function printNextSteps(hasRoutesPattern) {
830
- console.log("");
831
- console.log("Next steps:");
1585
+ logger.blank();
1586
+ logger.log(logger.bold("Next steps:"));
832
1587
  if (hasRoutesPattern) {
833
- console.log(" 1. Run 'screenbook generate' to auto-create screen.meta.ts files");
834
- console.log(" 2. Run 'screenbook dev' to start the UI server");
1588
+ logger.log(` 1. Run ${logger.code("screenbook generate")} to auto-create screen.meta.ts files`);
1589
+ logger.log(` 2. Run ${logger.code("screenbook dev")} to start the UI server`);
835
1590
  } else {
836
- console.log(" 1. Configure routesPattern in screenbook.config.ts");
837
- console.log(" 2. Run 'screenbook generate' to auto-create screen.meta.ts files");
838
- console.log(" 3. Run 'screenbook dev' to start the UI server");
1591
+ logger.log(" 1. Configure routesPattern in screenbook.config.ts");
1592
+ logger.log(` 2. Run ${logger.code("screenbook generate")} to auto-create screen.meta.ts files`);
1593
+ logger.log(` 3. Run ${logger.code("screenbook dev")} to start the UI server`);
839
1594
  }
840
- console.log("");
841
- console.log("screen.meta.ts files are created alongside your route files:");
842
- console.log("");
843
- console.log(" src/pages/dashboard/");
844
- console.log(" page.tsx # Your route file");
845
- console.log(" screen.meta.ts # Auto-generated, customize as needed");
1595
+ logger.blank();
1596
+ logger.log("screen.meta.ts files are created alongside your route files:");
1597
+ logger.blank();
1598
+ logger.log(logger.dim(" src/pages/dashboard/"));
1599
+ logger.log(logger.dim(" page.tsx # Your route file"));
1600
+ logger.log(logger.dim(" screen.meta.ts # Auto-generated, customize as needed"));
846
1601
  }
847
1602
  const initCommand = define({
848
1603
  name: "init",
@@ -864,27 +1619,27 @@ const initCommand = define({
864
1619
  const cwd = process.cwd();
865
1620
  const force = ctx.values.force ?? false;
866
1621
  const skipDetect = ctx.values.skipDetect ?? false;
867
- console.log("Initializing Screenbook...");
868
- console.log("");
1622
+ logger.info("Initializing Screenbook...");
1623
+ logger.blank();
869
1624
  let framework = null;
870
1625
  if (!skipDetect) {
871
1626
  framework = detectFramework(cwd);
872
- if (framework) console.log(` Detected: ${framework.name}`);
1627
+ if (framework) logger.itemSuccess(`Detected: ${framework.name}`);
873
1628
  else {
874
- console.log(" Could not auto-detect framework");
875
- console.log("");
1629
+ logger.log(" Could not auto-detect framework");
1630
+ logger.blank();
876
1631
  framework = await promptFrameworkSelection();
877
1632
  if (framework) {
878
- console.log("");
879
- console.log(` Selected: ${framework.name}`);
1633
+ logger.blank();
1634
+ logger.itemSuccess(`Selected: ${framework.name}`);
880
1635
  }
881
1636
  }
882
1637
  }
883
1638
  const configPath = join(cwd, "screenbook.config.ts");
884
- if (!force && existsSync(configPath)) console.log(" - screenbook.config.ts already exists (skipped)");
1639
+ if (!force && existsSync(configPath)) logger.log(` ${logger.dim("-")} screenbook.config.ts already exists ${logger.dim("(skipped)")}`);
885
1640
  else {
886
1641
  writeFileSync(configPath, generateConfigTemplate(framework));
887
- console.log(" + Created screenbook.config.ts");
1642
+ logger.itemSuccess("Created screenbook.config.ts");
888
1643
  }
889
1644
  const gitignorePath = join(cwd, ".gitignore");
890
1645
  const screenbookIgnore = ".screenbook";
@@ -892,14 +1647,14 @@ const initCommand = define({
892
1647
  const gitignoreContent = readFileSync(gitignorePath, "utf-8");
893
1648
  if (!gitignoreContent.includes(screenbookIgnore)) {
894
1649
  writeFileSync(gitignorePath, `${gitignoreContent.trimEnd()}\n\n# Screenbook\n${screenbookIgnore}\n`);
895
- console.log(" + Added .screenbook to .gitignore");
896
- } else console.log(" - .screenbook already in .gitignore (skipped)");
1650
+ logger.itemSuccess("Added .screenbook to .gitignore");
1651
+ } else logger.log(` ${logger.dim("-")} .screenbook already in .gitignore ${logger.dim("(skipped)")}`);
897
1652
  } else {
898
1653
  writeFileSync(gitignorePath, `# Screenbook\n${screenbookIgnore}\n`);
899
- console.log(" + Created .gitignore with .screenbook");
1654
+ logger.itemSuccess("Created .gitignore with .screenbook");
900
1655
  }
901
- console.log("");
902
- console.log("Screenbook initialized successfully!");
1656
+ logger.blank();
1657
+ logger.done("Screenbook initialized successfully!");
903
1658
  printValueProposition();
904
1659
  printNextSteps(framework !== null);
905
1660
  }
@@ -910,42 +1665,48 @@ const initCommand = define({
910
1665
  const lintCommand = define({
911
1666
  name: "lint",
912
1667
  description: "Detect routes without screen.meta.ts files",
913
- args: { config: {
914
- type: "string",
915
- short: "c",
916
- description: "Path to config file"
917
- } },
1668
+ args: {
1669
+ config: {
1670
+ type: "string",
1671
+ short: "c",
1672
+ description: "Path to config file"
1673
+ },
1674
+ allowCycles: {
1675
+ type: "boolean",
1676
+ description: "Suppress circular navigation warnings",
1677
+ default: false
1678
+ },
1679
+ strict: {
1680
+ type: "boolean",
1681
+ short: "s",
1682
+ description: "Fail on disallowed cycles",
1683
+ default: false
1684
+ }
1685
+ },
918
1686
  run: async (ctx) => {
919
1687
  const config = await loadConfig(ctx.values.config);
920
1688
  const cwd = process.cwd();
921
1689
  const adoption = config.adoption ?? { mode: "full" };
922
1690
  let hasWarnings = false;
923
1691
  if (!config.routesPattern) {
924
- console.log("Error: routesPattern not configured");
925
- console.log("");
926
- console.log("Add routesPattern to your screenbook.config.ts:");
927
- console.log("");
928
- console.log(" routesPattern: \"src/pages/**/page.tsx\", // Vite/React");
929
- console.log(" routesPattern: \"app/**/page.tsx\", // Next.js App Router");
930
- console.log(" routesPattern: \"src/pages/**/*.vue\", // Vue/Nuxt");
931
- console.log("");
1692
+ logger.errorWithHelp(ERRORS.ROUTES_PATTERN_MISSING);
932
1693
  process.exit(1);
933
1694
  }
934
- console.log("Linting screen metadata coverage...");
1695
+ logger.info("Linting screen metadata coverage...");
935
1696
  if (adoption.mode === "progressive") {
936
- console.log(`Mode: Progressive adoption`);
937
- if (adoption.includePatterns?.length) console.log(`Checking: ${adoption.includePatterns.join(", ")}`);
938
- if (adoption.minimumCoverage != null) console.log(`Minimum coverage: ${adoption.minimumCoverage}%`);
1697
+ logger.log(`Mode: Progressive adoption`);
1698
+ if (adoption.includePatterns?.length) logger.log(`Checking: ${adoption.includePatterns.join(", ")}`);
1699
+ if (adoption.minimumCoverage != null) logger.log(`Minimum coverage: ${adoption.minimumCoverage}%`);
939
1700
  }
940
- console.log("");
1701
+ logger.blank();
941
1702
  let routeFiles = await glob(config.routesPattern, {
942
1703
  cwd,
943
1704
  ignore: config.ignore
944
1705
  });
945
- if (adoption.mode === "progressive" && adoption.includePatterns?.length) routeFiles = routeFiles.filter((file) => adoption.includePatterns.some((pattern) => minimatch(file, pattern)));
1706
+ if (adoption.mode === "progressive" && adoption.includePatterns?.length) routeFiles = routeFiles.filter((file) => adoption.includePatterns?.some((pattern) => minimatch(file, pattern)));
946
1707
  if (routeFiles.length === 0) {
947
- console.log(`No route files found matching: ${config.routesPattern}`);
948
- if (adoption.mode === "progressive" && adoption.includePatterns?.length) console.log(`(filtered by includePatterns: ${adoption.includePatterns.join(", ")})`);
1708
+ logger.warn(`No route files found matching: ${config.routesPattern}`);
1709
+ if (adoption.mode === "progressive" && adoption.includePatterns?.length) logger.log(` ${logger.dim(`(filtered by includePatterns: ${adoption.includePatterns.join(", ")})`)}`);
949
1710
  return;
950
1711
  }
951
1712
  const metaFiles = await glob(config.metaPattern, {
@@ -965,48 +1726,76 @@ const lintCommand = define({
965
1726
  const coveredCount = covered.length;
966
1727
  const missingCount = missingMeta.length;
967
1728
  const coveragePercent = Math.round(coveredCount / total * 100);
968
- console.log(`Found ${total} route files`);
969
- console.log(`Coverage: ${coveredCount}/${total} (${coveragePercent}%)`);
970
- console.log("");
1729
+ logger.log(`Found ${total} route files`);
1730
+ logger.log(`Coverage: ${coveredCount}/${total} (${coveragePercent}%)`);
1731
+ logger.blank();
971
1732
  const minimumCoverage = adoption.minimumCoverage ?? 100;
972
1733
  const passedCoverage = coveragePercent >= minimumCoverage;
973
1734
  if (missingCount > 0) {
974
- console.log(`Missing screen.meta.ts (${missingCount} files):`);
975
- console.log("");
1735
+ logger.log(`Missing screen.meta.ts (${missingCount} files):`);
1736
+ logger.blank();
976
1737
  for (const file of missingMeta) {
977
1738
  const suggestedMetaPath = join(dirname(file), "screen.meta.ts");
978
- console.log(` ✗ ${file}`);
979
- console.log(` → ${suggestedMetaPath}`);
1739
+ logger.itemError(file);
1740
+ logger.log(` ${logger.dim("")} ${logger.path(suggestedMetaPath)}`);
980
1741
  }
981
- console.log("");
1742
+ logger.blank();
982
1743
  }
983
1744
  if (!passedCoverage) {
984
- console.log(`Lint failed: Coverage ${coveragePercent}% is below minimum ${minimumCoverage}%`);
1745
+ logger.error(`Lint failed: Coverage ${coveragePercent}% is below minimum ${minimumCoverage}%`);
985
1746
  process.exit(1);
986
1747
  } else if (missingCount > 0) {
987
- console.log(`✓ Coverage ${coveragePercent}% meets minimum ${minimumCoverage}%`);
988
- if (adoption.mode === "progressive") console.log(` Tip: Increase minimumCoverage in config to gradually improve coverage`);
989
- } else console.log("All routes have screen.meta.ts files");
1748
+ logger.success(`Coverage ${coveragePercent}% meets minimum ${minimumCoverage}%`);
1749
+ if (adoption.mode === "progressive") logger.log(` ${logger.dim("Tip:")} Increase minimumCoverage in config to gradually improve coverage`);
1750
+ } else logger.done("All routes have screen.meta.ts files");
990
1751
  const screensPath = join(cwd, config.outDir, "screens.json");
991
1752
  if (existsSync(screensPath)) try {
992
1753
  const content = readFileSync(screensPath, "utf-8");
993
- const orphans = findOrphanScreens(JSON.parse(content));
1754
+ const screens = JSON.parse(content);
1755
+ const orphans = findOrphanScreens(screens);
994
1756
  if (orphans.length > 0) {
995
1757
  hasWarnings = true;
996
- console.log("");
997
- console.log(`⚠ Orphan screens detected (${orphans.length}):`);
998
- console.log("");
999
- console.log(" These screens have no entryPoints and are not");
1000
- console.log(" referenced in any other screen's 'next' array.");
1001
- console.log("");
1002
- for (const orphan of orphans) console.log(` ⚠ ${orphan.id} ${orphan.route}`);
1003
- console.log("");
1004
- console.log(" Consider adding entryPoints or removing these screens.");
1758
+ logger.blank();
1759
+ logger.warn(`Orphan screens detected (${orphans.length}):`);
1760
+ logger.blank();
1761
+ logger.log(" These screens have no entryPoints and are not");
1762
+ logger.log(" referenced in any other screen's 'next' array.");
1763
+ logger.blank();
1764
+ for (const orphan of orphans) logger.itemWarn(`${orphan.id} ${logger.dim(orphan.route)}`);
1765
+ logger.blank();
1766
+ logger.log(` ${logger.dim("Consider adding entryPoints or removing these screens.")}`);
1767
+ }
1768
+ if (!ctx.values.allowCycles) {
1769
+ const cycleResult = detectCycles(screens);
1770
+ if (cycleResult.hasCycles) {
1771
+ hasWarnings = true;
1772
+ logger.blank();
1773
+ logger.warn(getCycleSummary(cycleResult));
1774
+ logger.log(formatCycleWarnings(cycleResult.cycles));
1775
+ logger.blank();
1776
+ if (cycleResult.disallowedCycles.length > 0) {
1777
+ logger.log(` ${logger.dim("Use 'allowCycles: true' in screen.meta.ts to allow intentional cycles.")}`);
1778
+ if (ctx.values.strict) {
1779
+ logger.blank();
1780
+ logger.errorWithHelp(ERRORS.CYCLES_DETECTED(cycleResult.disallowedCycles.length));
1781
+ process.exit(1);
1782
+ }
1783
+ }
1784
+ }
1005
1785
  }
1006
- } catch {}
1786
+ } catch (error) {
1787
+ if (error instanceof SyntaxError) {
1788
+ logger.warn("Failed to parse screens.json - file may be corrupted");
1789
+ logger.log(` ${logger.dim("Run 'screenbook build' to regenerate.")}`);
1790
+ hasWarnings = true;
1791
+ } else if (error instanceof Error) {
1792
+ logger.warn(`Failed to analyze screens.json: ${error.message}`);
1793
+ hasWarnings = true;
1794
+ }
1795
+ }
1007
1796
  if (hasWarnings) {
1008
- console.log("");
1009
- console.log("Lint completed with warnings.");
1797
+ logger.blank();
1798
+ logger.warn("Lint completed with warnings.");
1010
1799
  }
1011
1800
  }
1012
1801
  });
@@ -1028,6 +1817,89 @@ function findOrphanScreens(screens) {
1028
1817
  return orphans;
1029
1818
  }
1030
1819
 
1820
+ //#endregion
1821
+ //#region src/utils/prImpact.ts
1822
+ /**
1823
+ * Extract potential API names from changed file paths.
1824
+ * Looks for common API file patterns.
1825
+ */
1826
+ function extractApiNames(files) {
1827
+ const apis = /* @__PURE__ */ new Set();
1828
+ for (const file of files) {
1829
+ const fileName = basename(file, ".ts").replace(/\.tsx?$/, "").replace(/\.js$/, "").replace(/\.jsx?$/, "");
1830
+ const dirName = basename(dirname(file));
1831
+ if (file.includes("/api/") || file.includes("/apis/") || file.includes("/services/")) {
1832
+ if (fileName.endsWith("API") || fileName.endsWith("Api") || fileName.endsWith("Service")) apis.add(fileName);
1833
+ }
1834
+ if (file.includes("/services/") && (fileName === "index" || fileName === dirName)) {
1835
+ const serviceName = `${capitalize(dirName)}Service`;
1836
+ apis.add(serviceName);
1837
+ }
1838
+ if (file.includes("/api/") || file.includes("/apis/")) {
1839
+ if (!fileName.endsWith("API") && !fileName.endsWith("Api")) {
1840
+ const apiName = `${capitalize(fileName)}API`;
1841
+ apis.add(apiName);
1842
+ }
1843
+ }
1844
+ if (fileName.toLowerCase().includes("api") || fileName.toLowerCase().includes("service")) apis.add(fileName);
1845
+ }
1846
+ return Array.from(apis).sort();
1847
+ }
1848
+ function capitalize(str) {
1849
+ return str.charAt(0).toUpperCase() + str.slice(1);
1850
+ }
1851
+ /**
1852
+ * Format the results as Markdown for PR comments.
1853
+ */
1854
+ function formatMarkdown(changedFiles, detectedApis, results) {
1855
+ const lines = [];
1856
+ lines.push("## Screenbook Impact Analysis");
1857
+ lines.push("");
1858
+ if (results.length === 0) {
1859
+ lines.push("No screen impacts detected from the API changes in this PR.");
1860
+ lines.push("");
1861
+ lines.push("<details>");
1862
+ lines.push("<summary>Detected APIs (no screen dependencies)</summary>");
1863
+ lines.push("");
1864
+ for (const api of detectedApis) lines.push(`- \`${api}\``);
1865
+ lines.push("");
1866
+ lines.push("</details>");
1867
+ return lines.join("\n");
1868
+ }
1869
+ const totalScreens = results.reduce((sum, r) => sum + r.direct.length, 0) + results.reduce((sum, r) => sum + r.transitive.length, 0);
1870
+ lines.push(`**${totalScreens} screen${totalScreens > 1 ? "s" : ""} affected** by changes to ${results.length} API${results.length > 1 ? "s" : ""}`);
1871
+ lines.push("");
1872
+ for (const result of results) {
1873
+ lines.push(`### ${result.api}`);
1874
+ lines.push("");
1875
+ if (result.direct.length > 0) {
1876
+ lines.push(`**Direct dependencies** (${result.direct.length}):`);
1877
+ lines.push("");
1878
+ lines.push("| Screen | Route | Owner |");
1879
+ lines.push("|--------|-------|-------|");
1880
+ for (const screen of result.direct) {
1881
+ const owner = screen.owner?.join(", ") ?? "-";
1882
+ lines.push(`| ${screen.id} | \`${screen.route}\` | ${owner} |`);
1883
+ }
1884
+ lines.push("");
1885
+ }
1886
+ if (result.transitive.length > 0) {
1887
+ lines.push(`**Transitive dependencies** (${result.transitive.length}):`);
1888
+ lines.push("");
1889
+ for (const { path } of result.transitive) lines.push(`- ${path.join(" → ")}`);
1890
+ lines.push("");
1891
+ }
1892
+ }
1893
+ lines.push("<details>");
1894
+ lines.push(`<summary>Changed files (${changedFiles.length})</summary>`);
1895
+ lines.push("");
1896
+ for (const file of changedFiles.slice(0, 20)) lines.push(`- \`${file}\``);
1897
+ if (changedFiles.length > 20) lines.push(`- ... and ${changedFiles.length - 20} more`);
1898
+ lines.push("");
1899
+ lines.push("</details>");
1900
+ return lines.join("\n");
1901
+ }
1902
+
1031
1903
  //#endregion
1032
1904
  //#region src/commands/pr-impact.ts
1033
1905
  const prImpactCommand = define({
@@ -1071,25 +1943,22 @@ const prImpactCommand = define({
1071
1943
  encoding: "utf-8"
1072
1944
  }).split("\n").map((f) => f.trim()).filter((f) => f.length > 0);
1073
1945
  } catch {
1074
- console.error("Error: Failed to get changed files from git");
1075
- console.error("");
1076
- console.error("Make sure you are in a git repository and the base branch exists.");
1077
- console.error(`Base branch: ${baseBranch}`);
1946
+ logger.errorWithHelp(ERRORS.GIT_CHANGED_FILES_ERROR(baseBranch));
1078
1947
  process.exit(1);
1079
1948
  }
1080
1949
  if (changedFiles.length === 0) {
1081
- console.log("No changed files found.");
1950
+ logger.info("No changed files found.");
1082
1951
  return;
1083
1952
  }
1084
1953
  const apiNames = extractApiNames(changedFiles);
1085
1954
  if (apiNames.length === 0) {
1086
1955
  if (format === "markdown") {
1087
- console.log("## Screenbook Impact Analysis");
1088
- console.log("");
1089
- console.log("No API-related changes detected in this PR.");
1090
- console.log("");
1091
- console.log(`Changed files: ${changedFiles.length}`);
1092
- } else console.log(JSON.stringify({
1956
+ logger.log("## Screenbook Impact Analysis");
1957
+ logger.blank();
1958
+ logger.log("No API-related changes detected in this PR.");
1959
+ logger.blank();
1960
+ logger.log(`Changed files: ${changedFiles.length}`);
1961
+ } else logger.log(JSON.stringify({
1093
1962
  apis: [],
1094
1963
  results: [],
1095
1964
  changedFiles
@@ -1098,9 +1967,7 @@ const prImpactCommand = define({
1098
1967
  }
1099
1968
  const screensPath = join(cwd, config.outDir, "screens.json");
1100
1969
  if (!existsSync(screensPath)) {
1101
- console.error("Error: screens.json not found");
1102
- console.error("");
1103
- console.error("Run 'screenbook build' first to generate the screen catalog.");
1970
+ logger.errorWithHelp(ERRORS.SCREENS_NOT_FOUND);
1104
1971
  process.exit(1);
1105
1972
  }
1106
1973
  let screens;
@@ -1108,8 +1975,10 @@ const prImpactCommand = define({
1108
1975
  const content = readFileSync(screensPath, "utf-8");
1109
1976
  screens = JSON.parse(content);
1110
1977
  } catch (error) {
1111
- console.error("Error: Failed to read screens.json");
1112
- console.error(error instanceof Error ? error.message : String(error));
1978
+ logger.errorWithHelp({
1979
+ ...ERRORS.SCREENS_PARSE_ERROR,
1980
+ message: error instanceof Error ? error.message : String(error)
1981
+ });
1113
1982
  process.exit(1);
1114
1983
  }
1115
1984
  const results = [];
@@ -1117,7 +1986,7 @@ const prImpactCommand = define({
1117
1986
  const result = analyzeImpact(screens, apiName, depth);
1118
1987
  if (result.totalCount > 0) results.push(result);
1119
1988
  }
1120
- if (format === "json") console.log(JSON.stringify({
1989
+ if (format === "json") logger.log(JSON.stringify({
1121
1990
  changedFiles,
1122
1991
  detectedApis: apiNames,
1123
1992
  results: results.map((r) => ({
@@ -1139,89 +2008,9 @@ const prImpactCommand = define({
1139
2008
  }))
1140
2009
  }))
1141
2010
  }, null, 2));
1142
- else console.log(formatMarkdown(changedFiles, apiNames, results));
2011
+ else logger.log(formatMarkdown(changedFiles, apiNames, results));
1143
2012
  }
1144
2013
  });
1145
- /**
1146
- * Extract potential API names from changed file paths.
1147
- * Looks for common API file patterns.
1148
- */
1149
- function extractApiNames(files) {
1150
- const apis = /* @__PURE__ */ new Set();
1151
- for (const file of files) {
1152
- const fileName = basename(file, ".ts").replace(/\.tsx?$/, "").replace(/\.js$/, "").replace(/\.jsx?$/, "");
1153
- const dirName = basename(dirname(file));
1154
- if (file.includes("/api/") || file.includes("/apis/") || file.includes("/services/")) {
1155
- if (fileName.endsWith("API") || fileName.endsWith("Api") || fileName.endsWith("Service")) apis.add(fileName);
1156
- }
1157
- if (file.includes("/services/") && (fileName === "index" || fileName === dirName)) {
1158
- const serviceName = capitalize(dirName) + "Service";
1159
- apis.add(serviceName);
1160
- }
1161
- if (file.includes("/api/") || file.includes("/apis/")) {
1162
- if (!fileName.endsWith("API") && !fileName.endsWith("Api")) {
1163
- const apiName = capitalize(fileName) + "API";
1164
- apis.add(apiName);
1165
- }
1166
- }
1167
- if (fileName.toLowerCase().includes("api") || fileName.toLowerCase().includes("service")) apis.add(fileName);
1168
- }
1169
- return Array.from(apis).sort();
1170
- }
1171
- function capitalize(str) {
1172
- return str.charAt(0).toUpperCase() + str.slice(1);
1173
- }
1174
- /**
1175
- * Format the results as Markdown for PR comments.
1176
- */
1177
- function formatMarkdown(changedFiles, detectedApis, results) {
1178
- const lines = [];
1179
- lines.push("## Screenbook Impact Analysis");
1180
- lines.push("");
1181
- if (results.length === 0) {
1182
- lines.push("No screen impacts detected from the API changes in this PR.");
1183
- lines.push("");
1184
- lines.push("<details>");
1185
- lines.push("<summary>Detected APIs (no screen dependencies)</summary>");
1186
- lines.push("");
1187
- for (const api of detectedApis) lines.push(`- \`${api}\``);
1188
- lines.push("");
1189
- lines.push("</details>");
1190
- return lines.join("\n");
1191
- }
1192
- const totalScreens = results.reduce((sum, r) => sum + r.direct.length, 0) + results.reduce((sum, r) => sum + r.transitive.length, 0);
1193
- lines.push(`**${totalScreens} screen${totalScreens > 1 ? "s" : ""} affected** by changes to ${results.length} API${results.length > 1 ? "s" : ""}`);
1194
- lines.push("");
1195
- for (const result of results) {
1196
- lines.push(`### ${result.api}`);
1197
- lines.push("");
1198
- if (result.direct.length > 0) {
1199
- lines.push(`**Direct dependencies** (${result.direct.length}):`);
1200
- lines.push("");
1201
- lines.push("| Screen | Route | Owner |");
1202
- lines.push("|--------|-------|-------|");
1203
- for (const screen of result.direct) {
1204
- const owner = screen.owner?.join(", ") ?? "-";
1205
- lines.push(`| ${screen.id} | \`${screen.route}\` | ${owner} |`);
1206
- }
1207
- lines.push("");
1208
- }
1209
- if (result.transitive.length > 0) {
1210
- lines.push(`**Transitive dependencies** (${result.transitive.length}):`);
1211
- lines.push("");
1212
- for (const { screen, path } of result.transitive) lines.push(`- ${path.join(" → ")}`);
1213
- lines.push("");
1214
- }
1215
- }
1216
- lines.push("<details>");
1217
- lines.push(`<summary>Changed files (${changedFiles.length})</summary>`);
1218
- lines.push("");
1219
- for (const file of changedFiles.slice(0, 20)) lines.push(`- \`${file}\``);
1220
- if (changedFiles.length > 20) lines.push(`- ... and ${changedFiles.length - 20} more`);
1221
- lines.push("");
1222
- lines.push("</details>");
1223
- return lines.join("\n");
1224
- }
1225
2014
 
1226
2015
  //#endregion
1227
2016
  //#region src/index.ts
@@ -1239,6 +2028,7 @@ const mainCommand = define({
1239
2028
  console.log(" lint Detect routes without screen.meta");
1240
2029
  console.log(" impact Analyze API dependency impact");
1241
2030
  console.log(" pr-impact Analyze PR changes impact");
2031
+ console.log(" doctor Diagnose common setup issues");
1242
2032
  console.log("");
1243
2033
  console.log("Run 'screenbook <command> --help' for more information");
1244
2034
  }
@@ -1253,7 +2043,8 @@ await cli(process.argv.slice(2), mainCommand, {
1253
2043
  dev: devCommand,
1254
2044
  lint: lintCommand,
1255
2045
  impact: impactCommand,
1256
- "pr-impact": prImpactCommand
2046
+ "pr-impact": prImpactCommand,
2047
+ doctor: doctorCommand
1257
2048
  }
1258
2049
  });
1259
2050