@screenbook/cli 0.0.1 → 0.1.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,6 +6,7 @@ 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";
@@ -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 {
@@ -295,28 +694,21 @@ const generateCommand = define({
295
694
  const dryRun = ctx.values.dryRun ?? false;
296
695
  const force = ctx.values.force ?? false;
297
696
  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("");
697
+ logger.errorWithHelp(ERRORS.ROUTES_PATTERN_MISSING);
306
698
  process.exit(1);
307
699
  }
308
- console.log("Scanning for route files...");
309
- console.log("");
700
+ logger.info("Scanning for route files...");
701
+ logger.blank();
310
702
  const routeFiles = await glob(config.routesPattern, {
311
703
  cwd,
312
704
  ignore: config.ignore
313
705
  });
314
706
  if (routeFiles.length === 0) {
315
- console.log(`No route files found matching: ${config.routesPattern}`);
707
+ logger.warn(`No route files found matching: ${config.routesPattern}`);
316
708
  return;
317
709
  }
318
- console.log(`Found ${routeFiles.length} route files`);
319
- console.log("");
710
+ logger.log(`Found ${routeFiles.length} route files`);
711
+ logger.blank();
320
712
  let created = 0;
321
713
  let skipped = 0;
322
714
  for (const routeFile of routeFiles) {
@@ -330,29 +722,29 @@ const generateCommand = define({
330
722
  const screenMeta = inferScreenMeta(routeDir, config.routesPattern);
331
723
  const content = generateScreenMetaContent(screenMeta);
332
724
  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("");
725
+ logger.step(`Would create: ${logger.path(metaPath)}`);
726
+ logger.log(` ${logger.dim(`id: "${screenMeta.id}"`)}`);
727
+ logger.log(` ${logger.dim(`title: "${screenMeta.title}"`)}`);
728
+ logger.log(` ${logger.dim(`route: "${screenMeta.route}"`)}`);
729
+ logger.blank();
338
730
  } else {
339
731
  writeFileSync(absoluteMetaPath, content);
340
- console.log(`✓ Created: ${metaPath}`);
732
+ logger.itemSuccess(`Created: ${logger.path(metaPath)}`);
341
733
  }
342
734
  created++;
343
735
  }
344
- console.log("");
736
+ logger.blank();
345
737
  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");
738
+ logger.info(`Would create ${created} files (${skipped} already exist)`);
739
+ logger.blank();
740
+ logger.log(`Run without ${logger.code("--dry-run")} to create files`);
349
741
  } else {
350
- console.log(`Created ${created} files (${skipped} skipped)`);
742
+ logger.done(`Created ${created} files (${skipped} skipped)`);
351
743
  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");
744
+ logger.blank();
745
+ logger.log(logger.bold("Next steps:"));
746
+ logger.log(" 1. Review and customize the generated screen.meta.ts files");
747
+ logger.log(` 2. Run ${logger.code("screenbook dev")} to view your screen catalog`);
356
748
  }
357
749
  }
358
750
  }
@@ -361,7 +753,7 @@ const generateCommand = define({
361
753
  * Infer screen metadata from the route file path
362
754
  */
363
755
  function inferScreenMeta(routeDir, routesPattern) {
364
- const relativePath = relative(routesPattern.split("*")[0].replace(/\/$/, ""), routeDir);
756
+ const relativePath = relative(routesPattern.split("*")[0]?.replace(/\/$/, "") ?? "", routeDir);
365
757
  if (!relativePath || relativePath === ".") return {
366
758
  id: "home",
367
759
  title: "Home",
@@ -371,11 +763,11 @@ function inferScreenMeta(routeDir, routesPattern) {
371
763
  return {
372
764
  id: segments.join("."),
373
765
  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) => {
766
+ route: `/${relativePath.split("/").filter((s) => s && !s.startsWith("(") && !s.endsWith(")")).map((s) => {
375
767
  if (s.startsWith("[...") && s.endsWith("]")) return "*";
376
768
  if (s.startsWith("[") && s.endsWith("]")) return `:${s.slice(1, -1)}`;
377
769
  return s;
378
- }).join("/")
770
+ }).join("/")}`
379
771
  };
380
772
  }
381
773
  /**
@@ -436,7 +828,7 @@ function buildNavigationGraph(screens) {
436
828
  for (const screen of screens) {
437
829
  if (!screen.next) continue;
438
830
  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);
831
+ for (const nextId of screen.next) graph.get(screen.id)?.add(nextId);
440
832
  }
441
833
  return graph;
442
834
  }
@@ -474,6 +866,7 @@ function findPathToDirectDependent(startId, targetIds, graph, maxDepth, visited)
474
866
  const localVisited = new Set([startId]);
475
867
  while (queue.length > 0) {
476
868
  const current = queue.shift();
869
+ if (!current) break;
477
870
  if (current.path.length > maxDepth + 1) continue;
478
871
  const neighbors = graph.get(current.id);
479
872
  if (!neighbors) continue;
@@ -521,7 +914,7 @@ function formatImpactText(result) {
521
914
  }
522
915
  if (result.transitive.length > 0) {
523
916
  lines.push(`Transitive (${result.transitive.length} screen${result.transitive.length > 1 ? "s" : ""}):`);
524
- for (const { screen, path } of result.transitive) lines.push(` - ${path.join(" -> ")}`);
917
+ for (const { path } of result.transitive) lines.push(` - ${path.join(" -> ")}`);
525
918
  lines.push("");
526
919
  }
527
920
  if (result.totalCount === 0) {
@@ -590,22 +983,14 @@ const impactCommand = define({
590
983
  const cwd = process.cwd();
591
984
  const apiName = ctx.values.api;
592
985
  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");
986
+ logger.errorWithHelp(ERRORS.API_NAME_REQUIRED);
600
987
  process.exit(1);
601
988
  }
602
989
  const format = ctx.values.format ?? "text";
603
990
  const depth = ctx.values.depth ?? 3;
604
991
  const screensPath = join(cwd, config.outDir, "screens.json");
605
992
  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.");
993
+ logger.errorWithHelp(ERRORS.SCREENS_NOT_FOUND);
609
994
  process.exit(1);
610
995
  }
611
996
  let screens;
@@ -613,20 +998,22 @@ const impactCommand = define({
613
998
  const content = readFileSync(screensPath, "utf-8");
614
999
  screens = JSON.parse(content);
615
1000
  } catch (error) {
616
- console.error("Error: Failed to read screens.json");
617
- console.error(error instanceof Error ? error.message : String(error));
1001
+ logger.errorWithHelp({
1002
+ ...ERRORS.SCREENS_PARSE_ERROR,
1003
+ message: error instanceof Error ? error.message : String(error)
1004
+ });
618
1005
  process.exit(1);
619
1006
  }
620
1007
  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.");
1008
+ logger.warn("No screens found in the catalog.");
1009
+ logger.blank();
1010
+ logger.log("Run 'screenbook generate' to create screen.meta.ts files,");
1011
+ logger.log("then 'screenbook build' to generate the catalog.");
625
1012
  return;
626
1013
  }
627
1014
  const result = analyzeImpact(screens, apiName, depth);
628
- if (format === "json") console.log(formatImpactJson(result));
629
- else console.log(formatImpactText(result));
1015
+ if (format === "json") logger.log(formatImpactJson(result));
1016
+ else logger.log(formatImpactText(result));
630
1017
  }
631
1018
  });
632
1019
 
@@ -819,30 +1206,30 @@ export default defineConfig({
819
1206
  `;
820
1207
  }
821
1208
  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");
1209
+ logger.blank();
1210
+ logger.log(logger.bold("What Screenbook gives you:"));
1211
+ logger.log(" - Screen catalog with search & filter");
1212
+ logger.log(" - Navigation graph visualization");
1213
+ logger.log(" - Impact analysis (API -> affected screens)");
1214
+ logger.log(" - CI lint for documentation coverage");
828
1215
  }
829
1216
  function printNextSteps(hasRoutesPattern) {
830
- console.log("");
831
- console.log("Next steps:");
1217
+ logger.blank();
1218
+ logger.log(logger.bold("Next steps:"));
832
1219
  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");
1220
+ logger.log(` 1. Run ${logger.code("screenbook generate")} to auto-create screen.meta.ts files`);
1221
+ logger.log(` 2. Run ${logger.code("screenbook dev")} to start the UI server`);
835
1222
  } 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");
1223
+ logger.log(" 1. Configure routesPattern in screenbook.config.ts");
1224
+ logger.log(` 2. Run ${logger.code("screenbook generate")} to auto-create screen.meta.ts files`);
1225
+ logger.log(` 3. Run ${logger.code("screenbook dev")} to start the UI server`);
839
1226
  }
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");
1227
+ logger.blank();
1228
+ logger.log("screen.meta.ts files are created alongside your route files:");
1229
+ logger.blank();
1230
+ logger.log(logger.dim(" src/pages/dashboard/"));
1231
+ logger.log(logger.dim(" page.tsx # Your route file"));
1232
+ logger.log(logger.dim(" screen.meta.ts # Auto-generated, customize as needed"));
846
1233
  }
847
1234
  const initCommand = define({
848
1235
  name: "init",
@@ -864,27 +1251,27 @@ const initCommand = define({
864
1251
  const cwd = process.cwd();
865
1252
  const force = ctx.values.force ?? false;
866
1253
  const skipDetect = ctx.values.skipDetect ?? false;
867
- console.log("Initializing Screenbook...");
868
- console.log("");
1254
+ logger.info("Initializing Screenbook...");
1255
+ logger.blank();
869
1256
  let framework = null;
870
1257
  if (!skipDetect) {
871
1258
  framework = detectFramework(cwd);
872
- if (framework) console.log(` Detected: ${framework.name}`);
1259
+ if (framework) logger.itemSuccess(`Detected: ${framework.name}`);
873
1260
  else {
874
- console.log(" Could not auto-detect framework");
875
- console.log("");
1261
+ logger.log(" Could not auto-detect framework");
1262
+ logger.blank();
876
1263
  framework = await promptFrameworkSelection();
877
1264
  if (framework) {
878
- console.log("");
879
- console.log(` Selected: ${framework.name}`);
1265
+ logger.blank();
1266
+ logger.itemSuccess(`Selected: ${framework.name}`);
880
1267
  }
881
1268
  }
882
1269
  }
883
1270
  const configPath = join(cwd, "screenbook.config.ts");
884
- if (!force && existsSync(configPath)) console.log(" - screenbook.config.ts already exists (skipped)");
1271
+ if (!force && existsSync(configPath)) logger.log(` ${logger.dim("-")} screenbook.config.ts already exists ${logger.dim("(skipped)")}`);
885
1272
  else {
886
1273
  writeFileSync(configPath, generateConfigTemplate(framework));
887
- console.log(" + Created screenbook.config.ts");
1274
+ logger.itemSuccess("Created screenbook.config.ts");
888
1275
  }
889
1276
  const gitignorePath = join(cwd, ".gitignore");
890
1277
  const screenbookIgnore = ".screenbook";
@@ -892,14 +1279,14 @@ const initCommand = define({
892
1279
  const gitignoreContent = readFileSync(gitignorePath, "utf-8");
893
1280
  if (!gitignoreContent.includes(screenbookIgnore)) {
894
1281
  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)");
1282
+ logger.itemSuccess("Added .screenbook to .gitignore");
1283
+ } else logger.log(` ${logger.dim("-")} .screenbook already in .gitignore ${logger.dim("(skipped)")}`);
897
1284
  } else {
898
1285
  writeFileSync(gitignorePath, `# Screenbook\n${screenbookIgnore}\n`);
899
- console.log(" + Created .gitignore with .screenbook");
1286
+ logger.itemSuccess("Created .gitignore with .screenbook");
900
1287
  }
901
- console.log("");
902
- console.log("Screenbook initialized successfully!");
1288
+ logger.blank();
1289
+ logger.done("Screenbook initialized successfully!");
903
1290
  printValueProposition();
904
1291
  printNextSteps(framework !== null);
905
1292
  }
@@ -910,42 +1297,48 @@ const initCommand = define({
910
1297
  const lintCommand = define({
911
1298
  name: "lint",
912
1299
  description: "Detect routes without screen.meta.ts files",
913
- args: { config: {
914
- type: "string",
915
- short: "c",
916
- description: "Path to config file"
917
- } },
1300
+ args: {
1301
+ config: {
1302
+ type: "string",
1303
+ short: "c",
1304
+ description: "Path to config file"
1305
+ },
1306
+ allowCycles: {
1307
+ type: "boolean",
1308
+ description: "Suppress circular navigation warnings",
1309
+ default: false
1310
+ },
1311
+ strict: {
1312
+ type: "boolean",
1313
+ short: "s",
1314
+ description: "Fail on disallowed cycles",
1315
+ default: false
1316
+ }
1317
+ },
918
1318
  run: async (ctx) => {
919
1319
  const config = await loadConfig(ctx.values.config);
920
1320
  const cwd = process.cwd();
921
1321
  const adoption = config.adoption ?? { mode: "full" };
922
1322
  let hasWarnings = false;
923
1323
  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("");
1324
+ logger.errorWithHelp(ERRORS.ROUTES_PATTERN_MISSING);
932
1325
  process.exit(1);
933
1326
  }
934
- console.log("Linting screen metadata coverage...");
1327
+ logger.info("Linting screen metadata coverage...");
935
1328
  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}%`);
1329
+ logger.log(`Mode: Progressive adoption`);
1330
+ if (adoption.includePatterns?.length) logger.log(`Checking: ${adoption.includePatterns.join(", ")}`);
1331
+ if (adoption.minimumCoverage != null) logger.log(`Minimum coverage: ${adoption.minimumCoverage}%`);
939
1332
  }
940
- console.log("");
1333
+ logger.blank();
941
1334
  let routeFiles = await glob(config.routesPattern, {
942
1335
  cwd,
943
1336
  ignore: config.ignore
944
1337
  });
945
- if (adoption.mode === "progressive" && adoption.includePatterns?.length) routeFiles = routeFiles.filter((file) => adoption.includePatterns.some((pattern) => minimatch(file, pattern)));
1338
+ if (adoption.mode === "progressive" && adoption.includePatterns?.length) routeFiles = routeFiles.filter((file) => adoption.includePatterns?.some((pattern) => minimatch(file, pattern)));
946
1339
  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(", ")})`);
1340
+ logger.warn(`No route files found matching: ${config.routesPattern}`);
1341
+ if (adoption.mode === "progressive" && adoption.includePatterns?.length) logger.log(` ${logger.dim(`(filtered by includePatterns: ${adoption.includePatterns.join(", ")})`)}`);
949
1342
  return;
950
1343
  }
951
1344
  const metaFiles = await glob(config.metaPattern, {
@@ -965,48 +1358,76 @@ const lintCommand = define({
965
1358
  const coveredCount = covered.length;
966
1359
  const missingCount = missingMeta.length;
967
1360
  const coveragePercent = Math.round(coveredCount / total * 100);
968
- console.log(`Found ${total} route files`);
969
- console.log(`Coverage: ${coveredCount}/${total} (${coveragePercent}%)`);
970
- console.log("");
1361
+ logger.log(`Found ${total} route files`);
1362
+ logger.log(`Coverage: ${coveredCount}/${total} (${coveragePercent}%)`);
1363
+ logger.blank();
971
1364
  const minimumCoverage = adoption.minimumCoverage ?? 100;
972
1365
  const passedCoverage = coveragePercent >= minimumCoverage;
973
1366
  if (missingCount > 0) {
974
- console.log(`Missing screen.meta.ts (${missingCount} files):`);
975
- console.log("");
1367
+ logger.log(`Missing screen.meta.ts (${missingCount} files):`);
1368
+ logger.blank();
976
1369
  for (const file of missingMeta) {
977
1370
  const suggestedMetaPath = join(dirname(file), "screen.meta.ts");
978
- console.log(` ✗ ${file}`);
979
- console.log(` → ${suggestedMetaPath}`);
1371
+ logger.itemError(file);
1372
+ logger.log(` ${logger.dim("")} ${logger.path(suggestedMetaPath)}`);
980
1373
  }
981
- console.log("");
1374
+ logger.blank();
982
1375
  }
983
1376
  if (!passedCoverage) {
984
- console.log(`Lint failed: Coverage ${coveragePercent}% is below minimum ${minimumCoverage}%`);
1377
+ logger.error(`Lint failed: Coverage ${coveragePercent}% is below minimum ${minimumCoverage}%`);
985
1378
  process.exit(1);
986
1379
  } 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");
1380
+ logger.success(`Coverage ${coveragePercent}% meets minimum ${minimumCoverage}%`);
1381
+ if (adoption.mode === "progressive") logger.log(` ${logger.dim("Tip:")} Increase minimumCoverage in config to gradually improve coverage`);
1382
+ } else logger.done("All routes have screen.meta.ts files");
990
1383
  const screensPath = join(cwd, config.outDir, "screens.json");
991
1384
  if (existsSync(screensPath)) try {
992
1385
  const content = readFileSync(screensPath, "utf-8");
993
- const orphans = findOrphanScreens(JSON.parse(content));
1386
+ const screens = JSON.parse(content);
1387
+ const orphans = findOrphanScreens(screens);
994
1388
  if (orphans.length > 0) {
995
1389
  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.");
1390
+ logger.blank();
1391
+ logger.warn(`Orphan screens detected (${orphans.length}):`);
1392
+ logger.blank();
1393
+ logger.log(" These screens have no entryPoints and are not");
1394
+ logger.log(" referenced in any other screen's 'next' array.");
1395
+ logger.blank();
1396
+ for (const orphan of orphans) logger.itemWarn(`${orphan.id} ${logger.dim(orphan.route)}`);
1397
+ logger.blank();
1398
+ logger.log(` ${logger.dim("Consider adding entryPoints or removing these screens.")}`);
1399
+ }
1400
+ if (!ctx.values.allowCycles) {
1401
+ const cycleResult = detectCycles(screens);
1402
+ if (cycleResult.hasCycles) {
1403
+ hasWarnings = true;
1404
+ logger.blank();
1405
+ logger.warn(getCycleSummary(cycleResult));
1406
+ logger.log(formatCycleWarnings(cycleResult.cycles));
1407
+ logger.blank();
1408
+ if (cycleResult.disallowedCycles.length > 0) {
1409
+ logger.log(` ${logger.dim("Use 'allowCycles: true' in screen.meta.ts to allow intentional cycles.")}`);
1410
+ if (ctx.values.strict) {
1411
+ logger.blank();
1412
+ logger.errorWithHelp(ERRORS.CYCLES_DETECTED(cycleResult.disallowedCycles.length));
1413
+ process.exit(1);
1414
+ }
1415
+ }
1416
+ }
1417
+ }
1418
+ } catch (error) {
1419
+ if (error instanceof SyntaxError) {
1420
+ logger.warn("Failed to parse screens.json - file may be corrupted");
1421
+ logger.log(` ${logger.dim("Run 'screenbook build' to regenerate.")}`);
1422
+ hasWarnings = true;
1423
+ } else if (error instanceof Error) {
1424
+ logger.warn(`Failed to analyze screens.json: ${error.message}`);
1425
+ hasWarnings = true;
1005
1426
  }
1006
- } catch {}
1427
+ }
1007
1428
  if (hasWarnings) {
1008
- console.log("");
1009
- console.log("Lint completed with warnings.");
1429
+ logger.blank();
1430
+ logger.warn("Lint completed with warnings.");
1010
1431
  }
1011
1432
  }
1012
1433
  });
@@ -1028,6 +1449,89 @@ function findOrphanScreens(screens) {
1028
1449
  return orphans;
1029
1450
  }
1030
1451
 
1452
+ //#endregion
1453
+ //#region src/utils/prImpact.ts
1454
+ /**
1455
+ * Extract potential API names from changed file paths.
1456
+ * Looks for common API file patterns.
1457
+ */
1458
+ function extractApiNames(files) {
1459
+ const apis = /* @__PURE__ */ new Set();
1460
+ for (const file of files) {
1461
+ const fileName = basename(file, ".ts").replace(/\.tsx?$/, "").replace(/\.js$/, "").replace(/\.jsx?$/, "");
1462
+ const dirName = basename(dirname(file));
1463
+ if (file.includes("/api/") || file.includes("/apis/") || file.includes("/services/")) {
1464
+ if (fileName.endsWith("API") || fileName.endsWith("Api") || fileName.endsWith("Service")) apis.add(fileName);
1465
+ }
1466
+ if (file.includes("/services/") && (fileName === "index" || fileName === dirName)) {
1467
+ const serviceName = `${capitalize(dirName)}Service`;
1468
+ apis.add(serviceName);
1469
+ }
1470
+ if (file.includes("/api/") || file.includes("/apis/")) {
1471
+ if (!fileName.endsWith("API") && !fileName.endsWith("Api")) {
1472
+ const apiName = `${capitalize(fileName)}API`;
1473
+ apis.add(apiName);
1474
+ }
1475
+ }
1476
+ if (fileName.toLowerCase().includes("api") || fileName.toLowerCase().includes("service")) apis.add(fileName);
1477
+ }
1478
+ return Array.from(apis).sort();
1479
+ }
1480
+ function capitalize(str) {
1481
+ return str.charAt(0).toUpperCase() + str.slice(1);
1482
+ }
1483
+ /**
1484
+ * Format the results as Markdown for PR comments.
1485
+ */
1486
+ function formatMarkdown(changedFiles, detectedApis, results) {
1487
+ const lines = [];
1488
+ lines.push("## Screenbook Impact Analysis");
1489
+ lines.push("");
1490
+ if (results.length === 0) {
1491
+ lines.push("No screen impacts detected from the API changes in this PR.");
1492
+ lines.push("");
1493
+ lines.push("<details>");
1494
+ lines.push("<summary>Detected APIs (no screen dependencies)</summary>");
1495
+ lines.push("");
1496
+ for (const api of detectedApis) lines.push(`- \`${api}\``);
1497
+ lines.push("");
1498
+ lines.push("</details>");
1499
+ return lines.join("\n");
1500
+ }
1501
+ const totalScreens = results.reduce((sum, r) => sum + r.direct.length, 0) + results.reduce((sum, r) => sum + r.transitive.length, 0);
1502
+ lines.push(`**${totalScreens} screen${totalScreens > 1 ? "s" : ""} affected** by changes to ${results.length} API${results.length > 1 ? "s" : ""}`);
1503
+ lines.push("");
1504
+ for (const result of results) {
1505
+ lines.push(`### ${result.api}`);
1506
+ lines.push("");
1507
+ if (result.direct.length > 0) {
1508
+ lines.push(`**Direct dependencies** (${result.direct.length}):`);
1509
+ lines.push("");
1510
+ lines.push("| Screen | Route | Owner |");
1511
+ lines.push("|--------|-------|-------|");
1512
+ for (const screen of result.direct) {
1513
+ const owner = screen.owner?.join(", ") ?? "-";
1514
+ lines.push(`| ${screen.id} | \`${screen.route}\` | ${owner} |`);
1515
+ }
1516
+ lines.push("");
1517
+ }
1518
+ if (result.transitive.length > 0) {
1519
+ lines.push(`**Transitive dependencies** (${result.transitive.length}):`);
1520
+ lines.push("");
1521
+ for (const { path } of result.transitive) lines.push(`- ${path.join(" → ")}`);
1522
+ lines.push("");
1523
+ }
1524
+ }
1525
+ lines.push("<details>");
1526
+ lines.push(`<summary>Changed files (${changedFiles.length})</summary>`);
1527
+ lines.push("");
1528
+ for (const file of changedFiles.slice(0, 20)) lines.push(`- \`${file}\``);
1529
+ if (changedFiles.length > 20) lines.push(`- ... and ${changedFiles.length - 20} more`);
1530
+ lines.push("");
1531
+ lines.push("</details>");
1532
+ return lines.join("\n");
1533
+ }
1534
+
1031
1535
  //#endregion
1032
1536
  //#region src/commands/pr-impact.ts
1033
1537
  const prImpactCommand = define({
@@ -1071,25 +1575,22 @@ const prImpactCommand = define({
1071
1575
  encoding: "utf-8"
1072
1576
  }).split("\n").map((f) => f.trim()).filter((f) => f.length > 0);
1073
1577
  } 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}`);
1578
+ logger.errorWithHelp(ERRORS.GIT_CHANGED_FILES_ERROR(baseBranch));
1078
1579
  process.exit(1);
1079
1580
  }
1080
1581
  if (changedFiles.length === 0) {
1081
- console.log("No changed files found.");
1582
+ logger.info("No changed files found.");
1082
1583
  return;
1083
1584
  }
1084
1585
  const apiNames = extractApiNames(changedFiles);
1085
1586
  if (apiNames.length === 0) {
1086
1587
  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({
1588
+ logger.log("## Screenbook Impact Analysis");
1589
+ logger.blank();
1590
+ logger.log("No API-related changes detected in this PR.");
1591
+ logger.blank();
1592
+ logger.log(`Changed files: ${changedFiles.length}`);
1593
+ } else logger.log(JSON.stringify({
1093
1594
  apis: [],
1094
1595
  results: [],
1095
1596
  changedFiles
@@ -1098,9 +1599,7 @@ const prImpactCommand = define({
1098
1599
  }
1099
1600
  const screensPath = join(cwd, config.outDir, "screens.json");
1100
1601
  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.");
1602
+ logger.errorWithHelp(ERRORS.SCREENS_NOT_FOUND);
1104
1603
  process.exit(1);
1105
1604
  }
1106
1605
  let screens;
@@ -1108,8 +1607,10 @@ const prImpactCommand = define({
1108
1607
  const content = readFileSync(screensPath, "utf-8");
1109
1608
  screens = JSON.parse(content);
1110
1609
  } catch (error) {
1111
- console.error("Error: Failed to read screens.json");
1112
- console.error(error instanceof Error ? error.message : String(error));
1610
+ logger.errorWithHelp({
1611
+ ...ERRORS.SCREENS_PARSE_ERROR,
1612
+ message: error instanceof Error ? error.message : String(error)
1613
+ });
1113
1614
  process.exit(1);
1114
1615
  }
1115
1616
  const results = [];
@@ -1117,7 +1618,7 @@ const prImpactCommand = define({
1117
1618
  const result = analyzeImpact(screens, apiName, depth);
1118
1619
  if (result.totalCount > 0) results.push(result);
1119
1620
  }
1120
- if (format === "json") console.log(JSON.stringify({
1621
+ if (format === "json") logger.log(JSON.stringify({
1121
1622
  changedFiles,
1122
1623
  detectedApis: apiNames,
1123
1624
  results: results.map((r) => ({
@@ -1139,89 +1640,9 @@ const prImpactCommand = define({
1139
1640
  }))
1140
1641
  }))
1141
1642
  }, null, 2));
1142
- else console.log(formatMarkdown(changedFiles, apiNames, results));
1643
+ else logger.log(formatMarkdown(changedFiles, apiNames, results));
1143
1644
  }
1144
1645
  });
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
1646
 
1226
1647
  //#endregion
1227
1648
  //#region src/index.ts