@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 +667 -246
- package/dist/index.mjs.map +1 -1
- package/package.json +53 -52
- package/LICENSE +0 -21
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
|
-
|
|
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
|
-
|
|
432
|
+
logger.warn(`No screen.meta.ts files found matching: ${config.metaPattern}`);
|
|
66
433
|
return;
|
|
67
434
|
}
|
|
68
|
-
|
|
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
|
-
|
|
444
|
+
logger.itemSuccess(module.screen.id);
|
|
78
445
|
}
|
|
79
446
|
} catch (error) {
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
578
|
+
logger.info("Starting Screenbook development server...");
|
|
186
579
|
await buildScreens(config, cwd);
|
|
187
580
|
const uiPackagePath = resolveUiPackage();
|
|
188
581
|
if (!uiPackagePath) {
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
627
|
+
logger.warn(`No screen.meta.ts files found matching: ${config.metaPattern}`);
|
|
231
628
|
return;
|
|
232
629
|
}
|
|
233
|
-
|
|
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
|
-
|
|
639
|
+
logger.itemSuccess(module.screen.id);
|
|
243
640
|
}
|
|
244
641
|
} catch (error) {
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
-
|
|
707
|
+
logger.warn(`No route files found matching: ${config.routesPattern}`);
|
|
316
708
|
return;
|
|
317
709
|
}
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
732
|
+
logger.itemSuccess(`Created: ${logger.path(metaPath)}`);
|
|
341
733
|
}
|
|
342
734
|
created++;
|
|
343
735
|
}
|
|
344
|
-
|
|
736
|
+
logger.blank();
|
|
345
737
|
if (dryRun) {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
742
|
+
logger.done(`Created ${created} files (${skipped} skipped)`);
|
|
351
743
|
if (created > 0) {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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]
|
|
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:
|
|
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)
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
617
|
-
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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")
|
|
629
|
-
else
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
831
|
-
|
|
1217
|
+
logger.blank();
|
|
1218
|
+
logger.log(logger.bold("Next steps:"));
|
|
832
1219
|
if (hasRoutesPattern) {
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
868
|
-
|
|
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)
|
|
1259
|
+
if (framework) logger.itemSuccess(`Detected: ${framework.name}`);
|
|
873
1260
|
else {
|
|
874
|
-
|
|
875
|
-
|
|
1261
|
+
logger.log(" Could not auto-detect framework");
|
|
1262
|
+
logger.blank();
|
|
876
1263
|
framework = await promptFrameworkSelection();
|
|
877
1264
|
if (framework) {
|
|
878
|
-
|
|
879
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
896
|
-
} else
|
|
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
|
-
|
|
1286
|
+
logger.itemSuccess("Created .gitignore with .screenbook");
|
|
900
1287
|
}
|
|
901
|
-
|
|
902
|
-
|
|
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: {
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1327
|
+
logger.info("Linting screen metadata coverage...");
|
|
935
1328
|
if (adoption.mode === "progressive") {
|
|
936
|
-
|
|
937
|
-
if (adoption.includePatterns?.length)
|
|
938
|
-
if (adoption.minimumCoverage != null)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
948
|
-
if (adoption.mode === "progressive" && adoption.includePatterns?.length)
|
|
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
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
-
|
|
975
|
-
|
|
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
|
-
|
|
979
|
-
|
|
1371
|
+
logger.itemError(file);
|
|
1372
|
+
logger.log(` ${logger.dim("→")} ${logger.path(suggestedMetaPath)}`);
|
|
980
1373
|
}
|
|
981
|
-
|
|
1374
|
+
logger.blank();
|
|
982
1375
|
}
|
|
983
1376
|
if (!passedCoverage) {
|
|
984
|
-
|
|
1377
|
+
logger.error(`Lint failed: Coverage ${coveragePercent}% is below minimum ${minimumCoverage}%`);
|
|
985
1378
|
process.exit(1);
|
|
986
1379
|
} else if (missingCount > 0) {
|
|
987
|
-
|
|
988
|
-
if (adoption.mode === "progressive")
|
|
989
|
-
} else
|
|
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
|
|
1386
|
+
const screens = JSON.parse(content);
|
|
1387
|
+
const orphans = findOrphanScreens(screens);
|
|
994
1388
|
if (orphans.length > 0) {
|
|
995
1389
|
hasWarnings = true;
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
for (const orphan of orphans)
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
-
}
|
|
1427
|
+
}
|
|
1007
1428
|
if (hasWarnings) {
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
} else
|
|
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
|
-
|
|
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
|
-
|
|
1112
|
-
|
|
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")
|
|
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
|
|
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
|