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