@screenbook/cli 0.0.1
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/LICENSE +21 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +1262 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +53 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1262 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { cli, define } from "gunshi";
|
|
4
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
6
|
+
import { createJiti } from "jiti";
|
|
7
|
+
import { glob } from "tinyglobby";
|
|
8
|
+
import { defineConfig } from "@screenbook/core";
|
|
9
|
+
import { execSync, spawn } from "node:child_process";
|
|
10
|
+
import prompts from "prompts";
|
|
11
|
+
import { minimatch } from "minimatch";
|
|
12
|
+
|
|
13
|
+
//#region src/utils/config.ts
|
|
14
|
+
const CONFIG_FILES = [
|
|
15
|
+
"screenbook.config.ts",
|
|
16
|
+
"screenbook.config.js",
|
|
17
|
+
"screenbook.config.mjs"
|
|
18
|
+
];
|
|
19
|
+
async function loadConfig(configPath) {
|
|
20
|
+
const cwd = process.cwd();
|
|
21
|
+
if (configPath) {
|
|
22
|
+
const absolutePath = resolve(cwd, configPath);
|
|
23
|
+
if (!existsSync(absolutePath)) throw new Error(`Config file not found: ${configPath}`);
|
|
24
|
+
return await importConfig(absolutePath, cwd);
|
|
25
|
+
}
|
|
26
|
+
for (const configFile of CONFIG_FILES) {
|
|
27
|
+
const absolutePath = resolve(cwd, configFile);
|
|
28
|
+
if (existsSync(absolutePath)) return await importConfig(absolutePath, cwd);
|
|
29
|
+
}
|
|
30
|
+
return defineConfig();
|
|
31
|
+
}
|
|
32
|
+
async function importConfig(absolutePath, cwd) {
|
|
33
|
+
const module = await createJiti(cwd).import(absolutePath);
|
|
34
|
+
if (module.default) return module.default;
|
|
35
|
+
throw new Error(`Config file must have a default export: ${absolutePath}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/commands/build.ts
|
|
40
|
+
const buildCommand = define({
|
|
41
|
+
name: "build",
|
|
42
|
+
description: "Build screen metadata JSON from screen.meta.ts files",
|
|
43
|
+
args: {
|
|
44
|
+
config: {
|
|
45
|
+
type: "string",
|
|
46
|
+
short: "c",
|
|
47
|
+
description: "Path to config file"
|
|
48
|
+
},
|
|
49
|
+
outDir: {
|
|
50
|
+
type: "string",
|
|
51
|
+
short: "o",
|
|
52
|
+
description: "Output directory"
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
run: async (ctx) => {
|
|
56
|
+
const config = await loadConfig(ctx.values.config);
|
|
57
|
+
const outDir = ctx.values.outDir ?? config.outDir;
|
|
58
|
+
const cwd = process.cwd();
|
|
59
|
+
console.log("Building screen metadata...");
|
|
60
|
+
const files = await glob(config.metaPattern, {
|
|
61
|
+
cwd,
|
|
62
|
+
ignore: config.ignore
|
|
63
|
+
});
|
|
64
|
+
if (files.length === 0) {
|
|
65
|
+
console.log(`No screen.meta.ts files found matching: ${config.metaPattern}`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
console.log(`Found ${files.length} screen files`);
|
|
69
|
+
const jiti = createJiti(cwd);
|
|
70
|
+
const screens = [];
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
const absolutePath = resolve(cwd, file);
|
|
73
|
+
try {
|
|
74
|
+
const module = await jiti.import(absolutePath);
|
|
75
|
+
if (module.screen) {
|
|
76
|
+
screens.push(module.screen);
|
|
77
|
+
console.log(` ✓ ${module.screen.id}`);
|
|
78
|
+
}
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error(` ✗ Failed to load ${file}:`, error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const outputPath = join(cwd, outDir, "screens.json");
|
|
84
|
+
const outputDir = dirname(outputPath);
|
|
85
|
+
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
|
86
|
+
writeFileSync(outputPath, JSON.stringify(screens, null, 2));
|
|
87
|
+
console.log(`\nGenerated ${outputPath}`);
|
|
88
|
+
const mermaidPath = join(cwd, outDir, "graph.mmd");
|
|
89
|
+
writeFileSync(mermaidPath, generateMermaidGraph(screens));
|
|
90
|
+
console.log(`Generated ${mermaidPath}`);
|
|
91
|
+
const coverage = await generateCoverageData(config, cwd, screens);
|
|
92
|
+
const coveragePath = join(cwd, outDir, "coverage.json");
|
|
93
|
+
writeFileSync(coveragePath, JSON.stringify(coverage, null, 2));
|
|
94
|
+
console.log(`Generated ${coveragePath}`);
|
|
95
|
+
console.log(`\nCoverage: ${coverage.covered}/${coverage.total} (${coverage.percentage}%)`);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
async function generateCoverageData(config, cwd, screens) {
|
|
99
|
+
let routeFiles = [];
|
|
100
|
+
if (config.routesPattern) routeFiles = await glob(config.routesPattern, {
|
|
101
|
+
cwd,
|
|
102
|
+
ignore: config.ignore
|
|
103
|
+
});
|
|
104
|
+
new Set(screens.map((s) => {
|
|
105
|
+
const parts = s.id.split(".");
|
|
106
|
+
return parts.slice(0, -1).join("/") || parts[0];
|
|
107
|
+
}));
|
|
108
|
+
const missing = [];
|
|
109
|
+
for (const routeFile of routeFiles) {
|
|
110
|
+
const routeDir = dirname(routeFile);
|
|
111
|
+
if (!screens.some((s) => {
|
|
112
|
+
const screenDir = s.id.replace(/\./g, "/");
|
|
113
|
+
return routeDir.includes(screenDir) || screenDir.includes(routeDir.replace(/^src\/pages\//, "").replace(/^app\//, ""));
|
|
114
|
+
})) missing.push({
|
|
115
|
+
route: routeFile,
|
|
116
|
+
suggestedPath: join(dirname(routeFile), "screen.meta.ts")
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
const total = routeFiles.length > 0 ? routeFiles.length : screens.length;
|
|
120
|
+
const covered = screens.length;
|
|
121
|
+
const percentage = total > 0 ? Math.round(covered / total * 100) : 100;
|
|
122
|
+
const byOwner = {};
|
|
123
|
+
for (const screen of screens) {
|
|
124
|
+
const owners = screen.owner || ["unassigned"];
|
|
125
|
+
for (const owner of owners) {
|
|
126
|
+
if (!byOwner[owner]) byOwner[owner] = {
|
|
127
|
+
count: 0,
|
|
128
|
+
screens: []
|
|
129
|
+
};
|
|
130
|
+
byOwner[owner].count++;
|
|
131
|
+
byOwner[owner].screens.push(screen.id);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const byTag = {};
|
|
135
|
+
for (const screen of screens) {
|
|
136
|
+
const tags = screen.tags || [];
|
|
137
|
+
for (const tag of tags) byTag[tag] = (byTag[tag] || 0) + 1;
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
total,
|
|
141
|
+
covered,
|
|
142
|
+
percentage,
|
|
143
|
+
missing,
|
|
144
|
+
byOwner,
|
|
145
|
+
byTag,
|
|
146
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function generateMermaidGraph(screens) {
|
|
150
|
+
const lines = ["flowchart TD"];
|
|
151
|
+
for (const screen of screens) {
|
|
152
|
+
const label = screen.title.replace(/"/g, "'");
|
|
153
|
+
lines.push(` ${sanitizeId(screen.id)}["${label}"]`);
|
|
154
|
+
}
|
|
155
|
+
lines.push("");
|
|
156
|
+
for (const screen of screens) if (screen.next) for (const nextId of screen.next) lines.push(` ${sanitizeId(screen.id)} --> ${sanitizeId(nextId)}`);
|
|
157
|
+
return lines.join("\n");
|
|
158
|
+
}
|
|
159
|
+
function sanitizeId(id) {
|
|
160
|
+
return id.replace(/\./g, "_");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
//#endregion
|
|
164
|
+
//#region src/commands/dev.ts
|
|
165
|
+
const devCommand = define({
|
|
166
|
+
name: "dev",
|
|
167
|
+
description: "Start the Screenbook development server",
|
|
168
|
+
args: {
|
|
169
|
+
config: {
|
|
170
|
+
type: "string",
|
|
171
|
+
short: "c",
|
|
172
|
+
description: "Path to config file"
|
|
173
|
+
},
|
|
174
|
+
port: {
|
|
175
|
+
type: "string",
|
|
176
|
+
short: "p",
|
|
177
|
+
description: "Port to run the server on",
|
|
178
|
+
default: "4321"
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
run: async (ctx) => {
|
|
182
|
+
const config = await loadConfig(ctx.values.config);
|
|
183
|
+
const port = ctx.values.port ?? "4321";
|
|
184
|
+
const cwd = process.cwd();
|
|
185
|
+
console.log("Starting Screenbook development server...");
|
|
186
|
+
await buildScreens(config, cwd);
|
|
187
|
+
const uiPackagePath = resolveUiPackage();
|
|
188
|
+
if (!uiPackagePath) {
|
|
189
|
+
console.error("Could not find @screenbook/ui package");
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
const screensJsonPath = join(cwd, config.outDir, "screens.json");
|
|
193
|
+
const coverageJsonPath = join(cwd, config.outDir, "coverage.json");
|
|
194
|
+
const uiScreensDir = join(uiPackagePath, ".screenbook");
|
|
195
|
+
if (!existsSync(uiScreensDir)) mkdirSync(uiScreensDir, { recursive: true });
|
|
196
|
+
if (existsSync(screensJsonPath)) copyFileSync(screensJsonPath, join(uiScreensDir, "screens.json"));
|
|
197
|
+
if (existsSync(coverageJsonPath)) copyFileSync(coverageJsonPath, join(uiScreensDir, "coverage.json"));
|
|
198
|
+
console.log(`\nStarting UI server on http://localhost:${port}`);
|
|
199
|
+
const astroProcess = spawn("npx", [
|
|
200
|
+
"astro",
|
|
201
|
+
"dev",
|
|
202
|
+
"--port",
|
|
203
|
+
port
|
|
204
|
+
], {
|
|
205
|
+
cwd: uiPackagePath,
|
|
206
|
+
stdio: "inherit",
|
|
207
|
+
shell: true
|
|
208
|
+
});
|
|
209
|
+
astroProcess.on("error", (error) => {
|
|
210
|
+
console.error("Failed to start Astro server:", error);
|
|
211
|
+
process.exit(1);
|
|
212
|
+
});
|
|
213
|
+
astroProcess.on("close", (code) => {
|
|
214
|
+
process.exit(code ?? 0);
|
|
215
|
+
});
|
|
216
|
+
process.on("SIGINT", () => {
|
|
217
|
+
astroProcess.kill("SIGINT");
|
|
218
|
+
});
|
|
219
|
+
process.on("SIGTERM", () => {
|
|
220
|
+
astroProcess.kill("SIGTERM");
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
async function buildScreens(config, cwd) {
|
|
225
|
+
const files = await glob(config.metaPattern, {
|
|
226
|
+
cwd,
|
|
227
|
+
ignore: config.ignore
|
|
228
|
+
});
|
|
229
|
+
if (files.length === 0) {
|
|
230
|
+
console.log(`No screen.meta.ts files found matching: ${config.metaPattern}`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
console.log(`Found ${files.length} screen files`);
|
|
234
|
+
const jiti = createJiti(cwd);
|
|
235
|
+
const screens = [];
|
|
236
|
+
for (const file of files) {
|
|
237
|
+
const absolutePath = resolve(cwd, file);
|
|
238
|
+
try {
|
|
239
|
+
const module = await jiti.import(absolutePath);
|
|
240
|
+
if (module.screen) {
|
|
241
|
+
screens.push(module.screen);
|
|
242
|
+
console.log(` ✓ ${module.screen.id}`);
|
|
243
|
+
}
|
|
244
|
+
} catch (error) {
|
|
245
|
+
console.error(` ✗ Failed to load ${file}:`, error);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const outputPath = join(cwd, config.outDir, "screens.json");
|
|
249
|
+
const outputDir = dirname(outputPath);
|
|
250
|
+
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
|
251
|
+
writeFileSync(outputPath, JSON.stringify(screens, null, 2));
|
|
252
|
+
console.log(`\nGenerated ${outputPath}`);
|
|
253
|
+
}
|
|
254
|
+
function resolveUiPackage() {
|
|
255
|
+
try {
|
|
256
|
+
return dirname(createRequire(import.meta.url).resolve("@screenbook/ui/package.json"));
|
|
257
|
+
} catch {
|
|
258
|
+
const possiblePaths = [
|
|
259
|
+
join(process.cwd(), "node_modules", "@screenbook", "ui"),
|
|
260
|
+
join(process.cwd(), "..", "ui"),
|
|
261
|
+
join(process.cwd(), "packages", "ui")
|
|
262
|
+
];
|
|
263
|
+
for (const p of possiblePaths) if (existsSync(join(p, "package.json"))) return p;
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
//#endregion
|
|
269
|
+
//#region src/commands/generate.ts
|
|
270
|
+
const generateCommand = define({
|
|
271
|
+
name: "generate",
|
|
272
|
+
description: "Auto-generate screen.meta.ts files from route files",
|
|
273
|
+
args: {
|
|
274
|
+
config: {
|
|
275
|
+
type: "string",
|
|
276
|
+
short: "c",
|
|
277
|
+
description: "Path to config file"
|
|
278
|
+
},
|
|
279
|
+
dryRun: {
|
|
280
|
+
type: "boolean",
|
|
281
|
+
short: "n",
|
|
282
|
+
description: "Show what would be generated without writing files",
|
|
283
|
+
default: false
|
|
284
|
+
},
|
|
285
|
+
force: {
|
|
286
|
+
type: "boolean",
|
|
287
|
+
short: "f",
|
|
288
|
+
description: "Overwrite existing screen.meta.ts files",
|
|
289
|
+
default: false
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
run: async (ctx) => {
|
|
293
|
+
const config = await loadConfig(ctx.values.config);
|
|
294
|
+
const cwd = process.cwd();
|
|
295
|
+
const dryRun = ctx.values.dryRun ?? false;
|
|
296
|
+
const force = ctx.values.force ?? false;
|
|
297
|
+
if (!config.routesPattern) {
|
|
298
|
+
console.log("Error: routesPattern not configured");
|
|
299
|
+
console.log("");
|
|
300
|
+
console.log("Add routesPattern to your screenbook.config.ts:");
|
|
301
|
+
console.log("");
|
|
302
|
+
console.log(" routesPattern: \"src/pages/**/page.tsx\", // Vite/React");
|
|
303
|
+
console.log(" routesPattern: \"app/**/page.tsx\", // Next.js App Router");
|
|
304
|
+
console.log(" routesPattern: \"src/pages/**/*.vue\", // Vue/Nuxt");
|
|
305
|
+
console.log("");
|
|
306
|
+
process.exit(1);
|
|
307
|
+
}
|
|
308
|
+
console.log("Scanning for route files...");
|
|
309
|
+
console.log("");
|
|
310
|
+
const routeFiles = await glob(config.routesPattern, {
|
|
311
|
+
cwd,
|
|
312
|
+
ignore: config.ignore
|
|
313
|
+
});
|
|
314
|
+
if (routeFiles.length === 0) {
|
|
315
|
+
console.log(`No route files found matching: ${config.routesPattern}`);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
console.log(`Found ${routeFiles.length} route files`);
|
|
319
|
+
console.log("");
|
|
320
|
+
let created = 0;
|
|
321
|
+
let skipped = 0;
|
|
322
|
+
for (const routeFile of routeFiles) {
|
|
323
|
+
const routeDir = dirname(routeFile);
|
|
324
|
+
const metaPath = join(routeDir, "screen.meta.ts");
|
|
325
|
+
const absoluteMetaPath = join(cwd, metaPath);
|
|
326
|
+
if (!force && existsSync(absoluteMetaPath)) {
|
|
327
|
+
skipped++;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const screenMeta = inferScreenMeta(routeDir, config.routesPattern);
|
|
331
|
+
const content = generateScreenMetaContent(screenMeta);
|
|
332
|
+
if (dryRun) {
|
|
333
|
+
console.log(`Would create: ${metaPath}`);
|
|
334
|
+
console.log(` id: "${screenMeta.id}"`);
|
|
335
|
+
console.log(` title: "${screenMeta.title}"`);
|
|
336
|
+
console.log(` route: "${screenMeta.route}"`);
|
|
337
|
+
console.log("");
|
|
338
|
+
} else {
|
|
339
|
+
writeFileSync(absoluteMetaPath, content);
|
|
340
|
+
console.log(`✓ Created: ${metaPath}`);
|
|
341
|
+
}
|
|
342
|
+
created++;
|
|
343
|
+
}
|
|
344
|
+
console.log("");
|
|
345
|
+
if (dryRun) {
|
|
346
|
+
console.log(`Would create ${created} files (${skipped} already exist)`);
|
|
347
|
+
console.log("");
|
|
348
|
+
console.log("Run without --dry-run to create files");
|
|
349
|
+
} else {
|
|
350
|
+
console.log(`Created ${created} files (${skipped} skipped)`);
|
|
351
|
+
if (created > 0) {
|
|
352
|
+
console.log("");
|
|
353
|
+
console.log("Next steps:");
|
|
354
|
+
console.log(" 1. Review and customize the generated screen.meta.ts files");
|
|
355
|
+
console.log(" 2. Run 'screenbook dev' to view your screen catalog");
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
/**
|
|
361
|
+
* Infer screen metadata from the route file path
|
|
362
|
+
*/
|
|
363
|
+
function inferScreenMeta(routeDir, routesPattern) {
|
|
364
|
+
const relativePath = relative(routesPattern.split("*")[0].replace(/\/$/, ""), routeDir);
|
|
365
|
+
if (!relativePath || relativePath === ".") return {
|
|
366
|
+
id: "home",
|
|
367
|
+
title: "Home",
|
|
368
|
+
route: "/"
|
|
369
|
+
};
|
|
370
|
+
const segments = relativePath.split("/").filter((s) => s && !s.startsWith("(") && !s.endsWith(")")).map((s) => s.replace(/^\[\.\.\..*\]$/, "catchall").replace(/^\[(.+)\]$/, "$1"));
|
|
371
|
+
return {
|
|
372
|
+
id: segments.join("."),
|
|
373
|
+
title: (segments[segments.length - 1] || "home").split(/[-_]/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" "),
|
|
374
|
+
route: "/" + relativePath.split("/").filter((s) => s && !s.startsWith("(") && !s.endsWith(")")).map((s) => {
|
|
375
|
+
if (s.startsWith("[...") && s.endsWith("]")) return "*";
|
|
376
|
+
if (s.startsWith("[") && s.endsWith("]")) return `:${s.slice(1, -1)}`;
|
|
377
|
+
return s;
|
|
378
|
+
}).join("/")
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Generate screen.meta.ts file content
|
|
383
|
+
*/
|
|
384
|
+
function generateScreenMetaContent(meta) {
|
|
385
|
+
const inferredTag = meta.id.split(".")[0] || "general";
|
|
386
|
+
return `import { defineScreen } from "@screenbook/core"
|
|
387
|
+
|
|
388
|
+
export const screen = defineScreen({
|
|
389
|
+
id: "${meta.id}",
|
|
390
|
+
title: "${meta.title}",
|
|
391
|
+
route: "${meta.route}",
|
|
392
|
+
|
|
393
|
+
// Team or individual responsible for this screen
|
|
394
|
+
owner: [],
|
|
395
|
+
|
|
396
|
+
// Tags for filtering in the catalog
|
|
397
|
+
tags: ["${inferredTag}"],
|
|
398
|
+
|
|
399
|
+
// APIs/services this screen depends on (for impact analysis)
|
|
400
|
+
// Example: ["UserAPI.getProfile", "PaymentService.checkout"]
|
|
401
|
+
dependsOn: [],
|
|
402
|
+
|
|
403
|
+
// Screen IDs that can navigate to this screen
|
|
404
|
+
entryPoints: [],
|
|
405
|
+
|
|
406
|
+
// Screen IDs this screen can navigate to
|
|
407
|
+
next: [],
|
|
408
|
+
})
|
|
409
|
+
`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
//#endregion
|
|
413
|
+
//#region src/utils/impactAnalysis.ts
|
|
414
|
+
/**
|
|
415
|
+
* Check if a screen's dependsOn matches the API name (supports partial matching).
|
|
416
|
+
* - "InvoiceAPI" matches "InvoiceAPI.getDetail"
|
|
417
|
+
* - "InvoiceAPI.getDetail" matches "InvoiceAPI.getDetail"
|
|
418
|
+
*/
|
|
419
|
+
function matchesDependency(dependency, apiName) {
|
|
420
|
+
if (dependency === apiName) return true;
|
|
421
|
+
if (dependency.startsWith(`${apiName}.`)) return true;
|
|
422
|
+
if (apiName.startsWith(`${dependency}.`)) return true;
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Find screens that directly depend on the given API.
|
|
427
|
+
*/
|
|
428
|
+
function findDirectDependents(screens, apiName) {
|
|
429
|
+
return screens.filter((screen) => screen.dependsOn?.some((dep) => matchesDependency(dep, apiName)));
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Build a navigation graph from `next` field: screenId -> screens it can navigate to.
|
|
433
|
+
*/
|
|
434
|
+
function buildNavigationGraph(screens) {
|
|
435
|
+
const graph = /* @__PURE__ */ new Map();
|
|
436
|
+
for (const screen of screens) {
|
|
437
|
+
if (!screen.next) continue;
|
|
438
|
+
if (!graph.has(screen.id)) graph.set(screen.id, /* @__PURE__ */ new Set());
|
|
439
|
+
for (const nextId of screen.next) graph.get(screen.id).add(nextId);
|
|
440
|
+
}
|
|
441
|
+
return graph;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Find all transitive dependents using BFS.
|
|
445
|
+
* A screen is transitively dependent if it can navigate to a directly dependent screen.
|
|
446
|
+
*/
|
|
447
|
+
function findTransitiveDependents(screens, directDependentIds, maxDepth) {
|
|
448
|
+
new Map(screens.map((s) => [s.id, s]));
|
|
449
|
+
const navigationGraph = buildNavigationGraph(screens);
|
|
450
|
+
const transitive = [];
|
|
451
|
+
const visited = /* @__PURE__ */ new Set();
|
|
452
|
+
for (const screen of screens) {
|
|
453
|
+
if (directDependentIds.has(screen.id)) continue;
|
|
454
|
+
const path = findPathToDirectDependent(screen.id, directDependentIds, navigationGraph, maxDepth, /* @__PURE__ */ new Set());
|
|
455
|
+
if (path && !visited.has(screen.id)) {
|
|
456
|
+
visited.add(screen.id);
|
|
457
|
+
transitive.push({
|
|
458
|
+
screen,
|
|
459
|
+
path
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return transitive;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Find a path from a screen to any directly dependent screen using BFS.
|
|
467
|
+
*/
|
|
468
|
+
function findPathToDirectDependent(startId, targetIds, graph, maxDepth, visited) {
|
|
469
|
+
if (visited.has(startId)) return null;
|
|
470
|
+
const queue = [{
|
|
471
|
+
id: startId,
|
|
472
|
+
path: [startId]
|
|
473
|
+
}];
|
|
474
|
+
const localVisited = new Set([startId]);
|
|
475
|
+
while (queue.length > 0) {
|
|
476
|
+
const current = queue.shift();
|
|
477
|
+
if (current.path.length > maxDepth + 1) continue;
|
|
478
|
+
const neighbors = graph.get(current.id);
|
|
479
|
+
if (!neighbors) continue;
|
|
480
|
+
for (const neighborId of neighbors) {
|
|
481
|
+
if (localVisited.has(neighborId)) continue;
|
|
482
|
+
const newPath = [...current.path, neighborId];
|
|
483
|
+
if (newPath.length > maxDepth + 1) continue;
|
|
484
|
+
if (targetIds.has(neighborId)) return newPath;
|
|
485
|
+
localVisited.add(neighborId);
|
|
486
|
+
queue.push({
|
|
487
|
+
id: neighborId,
|
|
488
|
+
path: newPath
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Analyze the impact of a change to an API on the screen catalog.
|
|
496
|
+
*/
|
|
497
|
+
function analyzeImpact(screens, apiName, maxDepth = 3) {
|
|
498
|
+
const direct = findDirectDependents(screens, apiName);
|
|
499
|
+
const transitive = findTransitiveDependents(screens, new Set(direct.map((s) => s.id)), maxDepth);
|
|
500
|
+
return {
|
|
501
|
+
api: apiName,
|
|
502
|
+
direct,
|
|
503
|
+
transitive,
|
|
504
|
+
totalCount: direct.length + transitive.length
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Format the impact result as text output.
|
|
509
|
+
*/
|
|
510
|
+
function formatImpactText(result) {
|
|
511
|
+
const lines = [];
|
|
512
|
+
lines.push(`Impact Analysis: ${result.api}`);
|
|
513
|
+
lines.push("");
|
|
514
|
+
if (result.direct.length > 0) {
|
|
515
|
+
lines.push(`Direct (${result.direct.length} screen${result.direct.length > 1 ? "s" : ""}):`);
|
|
516
|
+
for (const screen of result.direct) {
|
|
517
|
+
const owner = screen.owner?.length ? ` [${screen.owner.join(", ")}]` : "";
|
|
518
|
+
lines.push(` - ${screen.id} ${screen.route}${owner}`);
|
|
519
|
+
}
|
|
520
|
+
lines.push("");
|
|
521
|
+
}
|
|
522
|
+
if (result.transitive.length > 0) {
|
|
523
|
+
lines.push(`Transitive (${result.transitive.length} screen${result.transitive.length > 1 ? "s" : ""}):`);
|
|
524
|
+
for (const { screen, path } of result.transitive) lines.push(` - ${path.join(" -> ")}`);
|
|
525
|
+
lines.push("");
|
|
526
|
+
}
|
|
527
|
+
if (result.totalCount === 0) {
|
|
528
|
+
lines.push("No screens depend on this API.");
|
|
529
|
+
lines.push("");
|
|
530
|
+
} else lines.push(`Total: ${result.totalCount} screen${result.totalCount > 1 ? "s" : ""} affected`);
|
|
531
|
+
return lines.join("\n");
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Format the impact result as JSON output.
|
|
535
|
+
*/
|
|
536
|
+
function formatImpactJson(result) {
|
|
537
|
+
return JSON.stringify({
|
|
538
|
+
api: result.api,
|
|
539
|
+
summary: {
|
|
540
|
+
directCount: result.direct.length,
|
|
541
|
+
transitiveCount: result.transitive.length,
|
|
542
|
+
totalCount: result.totalCount
|
|
543
|
+
},
|
|
544
|
+
direct: result.direct.map((s) => ({
|
|
545
|
+
id: s.id,
|
|
546
|
+
title: s.title,
|
|
547
|
+
route: s.route,
|
|
548
|
+
owner: s.owner
|
|
549
|
+
})),
|
|
550
|
+
transitive: result.transitive.map(({ screen, path }) => ({
|
|
551
|
+
id: screen.id,
|
|
552
|
+
title: screen.title,
|
|
553
|
+
route: screen.route,
|
|
554
|
+
path
|
|
555
|
+
}))
|
|
556
|
+
}, null, 2);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
//#endregion
|
|
560
|
+
//#region src/commands/impact.ts
|
|
561
|
+
const impactCommand = define({
|
|
562
|
+
name: "impact",
|
|
563
|
+
description: "Analyze which screens depend on a specific API/service",
|
|
564
|
+
args: {
|
|
565
|
+
api: {
|
|
566
|
+
type: "positional",
|
|
567
|
+
description: "API or service name to analyze (e.g., InvoiceAPI.getDetail)",
|
|
568
|
+
required: true
|
|
569
|
+
},
|
|
570
|
+
config: {
|
|
571
|
+
type: "string",
|
|
572
|
+
short: "c",
|
|
573
|
+
description: "Path to config file"
|
|
574
|
+
},
|
|
575
|
+
format: {
|
|
576
|
+
type: "string",
|
|
577
|
+
short: "f",
|
|
578
|
+
description: "Output format: text (default) or json",
|
|
579
|
+
default: "text"
|
|
580
|
+
},
|
|
581
|
+
depth: {
|
|
582
|
+
type: "number",
|
|
583
|
+
short: "d",
|
|
584
|
+
description: "Maximum depth for transitive dependencies",
|
|
585
|
+
default: 3
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
run: async (ctx) => {
|
|
589
|
+
const config = await loadConfig(ctx.values.config);
|
|
590
|
+
const cwd = process.cwd();
|
|
591
|
+
const apiName = ctx.values.api;
|
|
592
|
+
if (!apiName) {
|
|
593
|
+
console.error("Error: API name is required");
|
|
594
|
+
console.error("");
|
|
595
|
+
console.error("Usage: screenbook impact <api-name>");
|
|
596
|
+
console.error("");
|
|
597
|
+
console.error("Examples:");
|
|
598
|
+
console.error(" screenbook impact InvoiceAPI.getDetail");
|
|
599
|
+
console.error(" screenbook impact PaymentService");
|
|
600
|
+
process.exit(1);
|
|
601
|
+
}
|
|
602
|
+
const format = ctx.values.format ?? "text";
|
|
603
|
+
const depth = ctx.values.depth ?? 3;
|
|
604
|
+
const screensPath = join(cwd, config.outDir, "screens.json");
|
|
605
|
+
if (!existsSync(screensPath)) {
|
|
606
|
+
console.error("Error: screens.json not found");
|
|
607
|
+
console.error("");
|
|
608
|
+
console.error("Run 'screenbook build' first to generate the screen catalog.");
|
|
609
|
+
process.exit(1);
|
|
610
|
+
}
|
|
611
|
+
let screens;
|
|
612
|
+
try {
|
|
613
|
+
const content = readFileSync(screensPath, "utf-8");
|
|
614
|
+
screens = JSON.parse(content);
|
|
615
|
+
} catch (error) {
|
|
616
|
+
console.error("Error: Failed to read screens.json");
|
|
617
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
618
|
+
process.exit(1);
|
|
619
|
+
}
|
|
620
|
+
if (screens.length === 0) {
|
|
621
|
+
console.log("No screens found in the catalog.");
|
|
622
|
+
console.log("");
|
|
623
|
+
console.log("Run 'screenbook generate' to create screen.meta.ts files,");
|
|
624
|
+
console.log("then 'screenbook build' to generate the catalog.");
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const result = analyzeImpact(screens, apiName, depth);
|
|
628
|
+
if (format === "json") console.log(formatImpactJson(result));
|
|
629
|
+
else console.log(formatImpactText(result));
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
//#endregion
|
|
634
|
+
//#region src/utils/detectFramework.ts
|
|
635
|
+
const FRAMEWORKS = [
|
|
636
|
+
{
|
|
637
|
+
name: "Next.js (App Router)",
|
|
638
|
+
packages: ["next"],
|
|
639
|
+
configFiles: [
|
|
640
|
+
"next.config.js",
|
|
641
|
+
"next.config.mjs",
|
|
642
|
+
"next.config.ts"
|
|
643
|
+
],
|
|
644
|
+
routesPattern: "app/**/page.tsx",
|
|
645
|
+
metaPattern: "app/**/screen.meta.ts",
|
|
646
|
+
check: (cwd) => existsSync(join(cwd, "app")) || existsSync(join(cwd, "src/app"))
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
name: "Next.js (Pages Router)",
|
|
650
|
+
packages: ["next"],
|
|
651
|
+
configFiles: [
|
|
652
|
+
"next.config.js",
|
|
653
|
+
"next.config.mjs",
|
|
654
|
+
"next.config.ts"
|
|
655
|
+
],
|
|
656
|
+
routesPattern: "pages/**/*.tsx",
|
|
657
|
+
metaPattern: "pages/**/screen.meta.ts",
|
|
658
|
+
check: (cwd) => existsSync(join(cwd, "pages")) || existsSync(join(cwd, "src/pages"))
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
name: "Remix",
|
|
662
|
+
packages: ["@remix-run/react", "remix"],
|
|
663
|
+
configFiles: ["remix.config.js", "vite.config.ts"],
|
|
664
|
+
routesPattern: "app/routes/**/*.tsx",
|
|
665
|
+
metaPattern: "app/routes/**/screen.meta.ts"
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
name: "Nuxt",
|
|
669
|
+
packages: ["nuxt"],
|
|
670
|
+
configFiles: [
|
|
671
|
+
"nuxt.config.ts",
|
|
672
|
+
"nuxt.config.js",
|
|
673
|
+
"nuxt.config.mjs"
|
|
674
|
+
],
|
|
675
|
+
routesPattern: "pages/**/*.vue",
|
|
676
|
+
metaPattern: "pages/**/screen.meta.ts",
|
|
677
|
+
check: (cwd) => {
|
|
678
|
+
if (existsSync(join(cwd, "app/pages"))) return false;
|
|
679
|
+
return existsSync(join(cwd, "pages"));
|
|
680
|
+
}
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
name: "Nuxt 4",
|
|
684
|
+
packages: ["nuxt"],
|
|
685
|
+
configFiles: ["nuxt.config.ts", "nuxt.config.js"],
|
|
686
|
+
routesPattern: "app/pages/**/*.vue",
|
|
687
|
+
metaPattern: "app/pages/**/screen.meta.ts",
|
|
688
|
+
check: (cwd) => existsSync(join(cwd, "app/pages"))
|
|
689
|
+
},
|
|
690
|
+
{
|
|
691
|
+
name: "Astro",
|
|
692
|
+
packages: ["astro"],
|
|
693
|
+
configFiles: [
|
|
694
|
+
"astro.config.mjs",
|
|
695
|
+
"astro.config.js",
|
|
696
|
+
"astro.config.ts",
|
|
697
|
+
"astro.config.cjs"
|
|
698
|
+
],
|
|
699
|
+
routesPattern: "src/pages/**/*.astro",
|
|
700
|
+
metaPattern: "src/pages/**/screen.meta.ts"
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
name: "Vite + Vue",
|
|
704
|
+
packages: ["vite", "vue"],
|
|
705
|
+
configFiles: [
|
|
706
|
+
"vite.config.ts",
|
|
707
|
+
"vite.config.js",
|
|
708
|
+
"vite.config.mjs"
|
|
709
|
+
],
|
|
710
|
+
routesPattern: "src/pages/**/*.vue",
|
|
711
|
+
metaPattern: "src/pages/**/screen.meta.ts",
|
|
712
|
+
check: (cwd) => {
|
|
713
|
+
const packageJson = readPackageJson(cwd);
|
|
714
|
+
if (!packageJson) return false;
|
|
715
|
+
return !hasPackage(packageJson, "react");
|
|
716
|
+
}
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
name: "Vite + React",
|
|
720
|
+
packages: ["vite", "react"],
|
|
721
|
+
configFiles: [
|
|
722
|
+
"vite.config.ts",
|
|
723
|
+
"vite.config.js",
|
|
724
|
+
"vite.config.mjs"
|
|
725
|
+
],
|
|
726
|
+
routesPattern: "src/pages/**/*.tsx",
|
|
727
|
+
metaPattern: "src/pages/**/screen.meta.ts"
|
|
728
|
+
}
|
|
729
|
+
];
|
|
730
|
+
function readPackageJson(cwd) {
|
|
731
|
+
const packageJsonPath = join(cwd, "package.json");
|
|
732
|
+
if (!existsSync(packageJsonPath)) return null;
|
|
733
|
+
try {
|
|
734
|
+
const content = readFileSync(packageJsonPath, "utf-8");
|
|
735
|
+
return JSON.parse(content);
|
|
736
|
+
} catch {
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
function hasPackage(packageJson, packageName) {
|
|
741
|
+
return !!(packageJson.dependencies?.[packageName] || packageJson.devDependencies?.[packageName]);
|
|
742
|
+
}
|
|
743
|
+
function hasConfigFile(cwd, configFiles) {
|
|
744
|
+
return configFiles.some((file) => existsSync(join(cwd, file)));
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Auto-detect the frontend framework in a project directory.
|
|
748
|
+
* Returns framework info if detected, null otherwise.
|
|
749
|
+
*/
|
|
750
|
+
function detectFramework(cwd) {
|
|
751
|
+
const packageJson = readPackageJson(cwd);
|
|
752
|
+
if (!packageJson) return null;
|
|
753
|
+
for (const framework of FRAMEWORKS) {
|
|
754
|
+
if (!framework.packages.some((pkg) => hasPackage(packageJson, pkg))) continue;
|
|
755
|
+
if (!hasConfigFile(cwd, framework.configFiles)) continue;
|
|
756
|
+
if (framework.check && !framework.check(cwd)) continue;
|
|
757
|
+
return {
|
|
758
|
+
name: framework.name,
|
|
759
|
+
routesPattern: framework.routesPattern,
|
|
760
|
+
metaPattern: framework.metaPattern
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Interactive framework selection when auto-detection fails.
|
|
767
|
+
*/
|
|
768
|
+
async function promptFrameworkSelection() {
|
|
769
|
+
const choices = FRAMEWORKS.filter((fw, idx, arr) => arr.findIndex((f) => f.routesPattern === fw.routesPattern) === idx).map((fw) => ({
|
|
770
|
+
title: fw.name,
|
|
771
|
+
value: fw
|
|
772
|
+
}));
|
|
773
|
+
choices.push({
|
|
774
|
+
title: "Other (manual configuration)",
|
|
775
|
+
value: null
|
|
776
|
+
});
|
|
777
|
+
const response = await prompts({
|
|
778
|
+
type: "select",
|
|
779
|
+
name: "framework",
|
|
780
|
+
message: "Select your frontend framework:",
|
|
781
|
+
choices
|
|
782
|
+
});
|
|
783
|
+
if (!response.framework) return null;
|
|
784
|
+
return {
|
|
785
|
+
name: response.framework.name,
|
|
786
|
+
routesPattern: response.framework.routesPattern,
|
|
787
|
+
metaPattern: response.framework.metaPattern
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
//#endregion
|
|
792
|
+
//#region src/commands/init.ts
|
|
793
|
+
function generateConfigTemplate(framework) {
|
|
794
|
+
if (framework) return `import { defineConfig } from "@screenbook/core"
|
|
795
|
+
|
|
796
|
+
export default defineConfig({
|
|
797
|
+
// Auto-detected: ${framework.name}
|
|
798
|
+
metaPattern: "${framework.metaPattern}",
|
|
799
|
+
routesPattern: "${framework.routesPattern}",
|
|
800
|
+
outDir: ".screenbook",
|
|
801
|
+
})
|
|
802
|
+
`;
|
|
803
|
+
return `import { defineConfig } from "@screenbook/core"
|
|
804
|
+
|
|
805
|
+
export default defineConfig({
|
|
806
|
+
// Glob pattern for screen metadata files
|
|
807
|
+
metaPattern: "src/**/screen.meta.ts",
|
|
808
|
+
|
|
809
|
+
// Glob pattern for route files (uncomment and adjust for your framework):
|
|
810
|
+
// routesPattern: "src/pages/**/page.tsx", // Vite/React
|
|
811
|
+
// routesPattern: "app/**/page.tsx", // Next.js App Router
|
|
812
|
+
// routesPattern: "pages/**/*.tsx", // Next.js Pages Router
|
|
813
|
+
// routesPattern: "app/routes/**/*.tsx", // Remix
|
|
814
|
+
// routesPattern: "pages/**/*.vue", // Nuxt
|
|
815
|
+
// routesPattern: "src/pages/**/*.astro", // Astro
|
|
816
|
+
|
|
817
|
+
outDir: ".screenbook",
|
|
818
|
+
})
|
|
819
|
+
`;
|
|
820
|
+
}
|
|
821
|
+
function printValueProposition() {
|
|
822
|
+
console.log("");
|
|
823
|
+
console.log("What Screenbook gives you:");
|
|
824
|
+
console.log(" - Screen catalog with search & filter");
|
|
825
|
+
console.log(" - Navigation graph visualization");
|
|
826
|
+
console.log(" - Impact analysis (API -> affected screens)");
|
|
827
|
+
console.log(" - CI lint for documentation coverage");
|
|
828
|
+
}
|
|
829
|
+
function printNextSteps(hasRoutesPattern) {
|
|
830
|
+
console.log("");
|
|
831
|
+
console.log("Next steps:");
|
|
832
|
+
if (hasRoutesPattern) {
|
|
833
|
+
console.log(" 1. Run 'screenbook generate' to auto-create screen.meta.ts files");
|
|
834
|
+
console.log(" 2. Run 'screenbook dev' to start the UI server");
|
|
835
|
+
} else {
|
|
836
|
+
console.log(" 1. Configure routesPattern in screenbook.config.ts");
|
|
837
|
+
console.log(" 2. Run 'screenbook generate' to auto-create screen.meta.ts files");
|
|
838
|
+
console.log(" 3. Run 'screenbook dev' to start the UI server");
|
|
839
|
+
}
|
|
840
|
+
console.log("");
|
|
841
|
+
console.log("screen.meta.ts files are created alongside your route files:");
|
|
842
|
+
console.log("");
|
|
843
|
+
console.log(" src/pages/dashboard/");
|
|
844
|
+
console.log(" page.tsx # Your route file");
|
|
845
|
+
console.log(" screen.meta.ts # Auto-generated, customize as needed");
|
|
846
|
+
}
|
|
847
|
+
const initCommand = define({
|
|
848
|
+
name: "init",
|
|
849
|
+
description: "Initialize Screenbook in a project",
|
|
850
|
+
args: {
|
|
851
|
+
force: {
|
|
852
|
+
type: "boolean",
|
|
853
|
+
short: "f",
|
|
854
|
+
description: "Overwrite existing files",
|
|
855
|
+
default: false
|
|
856
|
+
},
|
|
857
|
+
skipDetect: {
|
|
858
|
+
type: "boolean",
|
|
859
|
+
description: "Skip framework auto-detection",
|
|
860
|
+
default: false
|
|
861
|
+
}
|
|
862
|
+
},
|
|
863
|
+
run: async (ctx) => {
|
|
864
|
+
const cwd = process.cwd();
|
|
865
|
+
const force = ctx.values.force ?? false;
|
|
866
|
+
const skipDetect = ctx.values.skipDetect ?? false;
|
|
867
|
+
console.log("Initializing Screenbook...");
|
|
868
|
+
console.log("");
|
|
869
|
+
let framework = null;
|
|
870
|
+
if (!skipDetect) {
|
|
871
|
+
framework = detectFramework(cwd);
|
|
872
|
+
if (framework) console.log(` Detected: ${framework.name}`);
|
|
873
|
+
else {
|
|
874
|
+
console.log(" Could not auto-detect framework");
|
|
875
|
+
console.log("");
|
|
876
|
+
framework = await promptFrameworkSelection();
|
|
877
|
+
if (framework) {
|
|
878
|
+
console.log("");
|
|
879
|
+
console.log(` Selected: ${framework.name}`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
const configPath = join(cwd, "screenbook.config.ts");
|
|
884
|
+
if (!force && existsSync(configPath)) console.log(" - screenbook.config.ts already exists (skipped)");
|
|
885
|
+
else {
|
|
886
|
+
writeFileSync(configPath, generateConfigTemplate(framework));
|
|
887
|
+
console.log(" + Created screenbook.config.ts");
|
|
888
|
+
}
|
|
889
|
+
const gitignorePath = join(cwd, ".gitignore");
|
|
890
|
+
const screenbookIgnore = ".screenbook";
|
|
891
|
+
if (existsSync(gitignorePath)) {
|
|
892
|
+
const gitignoreContent = readFileSync(gitignorePath, "utf-8");
|
|
893
|
+
if (!gitignoreContent.includes(screenbookIgnore)) {
|
|
894
|
+
writeFileSync(gitignorePath, `${gitignoreContent.trimEnd()}\n\n# Screenbook\n${screenbookIgnore}\n`);
|
|
895
|
+
console.log(" + Added .screenbook to .gitignore");
|
|
896
|
+
} else console.log(" - .screenbook already in .gitignore (skipped)");
|
|
897
|
+
} else {
|
|
898
|
+
writeFileSync(gitignorePath, `# Screenbook\n${screenbookIgnore}\n`);
|
|
899
|
+
console.log(" + Created .gitignore with .screenbook");
|
|
900
|
+
}
|
|
901
|
+
console.log("");
|
|
902
|
+
console.log("Screenbook initialized successfully!");
|
|
903
|
+
printValueProposition();
|
|
904
|
+
printNextSteps(framework !== null);
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
//#endregion
|
|
909
|
+
//#region src/commands/lint.ts
|
|
910
|
+
const lintCommand = define({
|
|
911
|
+
name: "lint",
|
|
912
|
+
description: "Detect routes without screen.meta.ts files",
|
|
913
|
+
args: { config: {
|
|
914
|
+
type: "string",
|
|
915
|
+
short: "c",
|
|
916
|
+
description: "Path to config file"
|
|
917
|
+
} },
|
|
918
|
+
run: async (ctx) => {
|
|
919
|
+
const config = await loadConfig(ctx.values.config);
|
|
920
|
+
const cwd = process.cwd();
|
|
921
|
+
const adoption = config.adoption ?? { mode: "full" };
|
|
922
|
+
let hasWarnings = false;
|
|
923
|
+
if (!config.routesPattern) {
|
|
924
|
+
console.log("Error: routesPattern not configured");
|
|
925
|
+
console.log("");
|
|
926
|
+
console.log("Add routesPattern to your screenbook.config.ts:");
|
|
927
|
+
console.log("");
|
|
928
|
+
console.log(" routesPattern: \"src/pages/**/page.tsx\", // Vite/React");
|
|
929
|
+
console.log(" routesPattern: \"app/**/page.tsx\", // Next.js App Router");
|
|
930
|
+
console.log(" routesPattern: \"src/pages/**/*.vue\", // Vue/Nuxt");
|
|
931
|
+
console.log("");
|
|
932
|
+
process.exit(1);
|
|
933
|
+
}
|
|
934
|
+
console.log("Linting screen metadata coverage...");
|
|
935
|
+
if (adoption.mode === "progressive") {
|
|
936
|
+
console.log(`Mode: Progressive adoption`);
|
|
937
|
+
if (adoption.includePatterns?.length) console.log(`Checking: ${adoption.includePatterns.join(", ")}`);
|
|
938
|
+
if (adoption.minimumCoverage != null) console.log(`Minimum coverage: ${adoption.minimumCoverage}%`);
|
|
939
|
+
}
|
|
940
|
+
console.log("");
|
|
941
|
+
let routeFiles = await glob(config.routesPattern, {
|
|
942
|
+
cwd,
|
|
943
|
+
ignore: config.ignore
|
|
944
|
+
});
|
|
945
|
+
if (adoption.mode === "progressive" && adoption.includePatterns?.length) routeFiles = routeFiles.filter((file) => adoption.includePatterns.some((pattern) => minimatch(file, pattern)));
|
|
946
|
+
if (routeFiles.length === 0) {
|
|
947
|
+
console.log(`No route files found matching: ${config.routesPattern}`);
|
|
948
|
+
if (adoption.mode === "progressive" && adoption.includePatterns?.length) console.log(`(filtered by includePatterns: ${adoption.includePatterns.join(", ")})`);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const metaFiles = await glob(config.metaPattern, {
|
|
952
|
+
cwd,
|
|
953
|
+
ignore: config.ignore
|
|
954
|
+
});
|
|
955
|
+
const metaDirs = /* @__PURE__ */ new Set();
|
|
956
|
+
for (const metaFile of metaFiles) metaDirs.add(dirname(metaFile));
|
|
957
|
+
const missingMeta = [];
|
|
958
|
+
const covered = [];
|
|
959
|
+
for (const routeFile of routeFiles) {
|
|
960
|
+
const routeDir = dirname(routeFile);
|
|
961
|
+
if (metaDirs.has(routeDir)) covered.push(routeFile);
|
|
962
|
+
else missingMeta.push(routeFile);
|
|
963
|
+
}
|
|
964
|
+
const total = routeFiles.length;
|
|
965
|
+
const coveredCount = covered.length;
|
|
966
|
+
const missingCount = missingMeta.length;
|
|
967
|
+
const coveragePercent = Math.round(coveredCount / total * 100);
|
|
968
|
+
console.log(`Found ${total} route files`);
|
|
969
|
+
console.log(`Coverage: ${coveredCount}/${total} (${coveragePercent}%)`);
|
|
970
|
+
console.log("");
|
|
971
|
+
const minimumCoverage = adoption.minimumCoverage ?? 100;
|
|
972
|
+
const passedCoverage = coveragePercent >= minimumCoverage;
|
|
973
|
+
if (missingCount > 0) {
|
|
974
|
+
console.log(`Missing screen.meta.ts (${missingCount} files):`);
|
|
975
|
+
console.log("");
|
|
976
|
+
for (const file of missingMeta) {
|
|
977
|
+
const suggestedMetaPath = join(dirname(file), "screen.meta.ts");
|
|
978
|
+
console.log(` ✗ ${file}`);
|
|
979
|
+
console.log(` → ${suggestedMetaPath}`);
|
|
980
|
+
}
|
|
981
|
+
console.log("");
|
|
982
|
+
}
|
|
983
|
+
if (!passedCoverage) {
|
|
984
|
+
console.log(`Lint failed: Coverage ${coveragePercent}% is below minimum ${minimumCoverage}%`);
|
|
985
|
+
process.exit(1);
|
|
986
|
+
} else if (missingCount > 0) {
|
|
987
|
+
console.log(`✓ Coverage ${coveragePercent}% meets minimum ${minimumCoverage}%`);
|
|
988
|
+
if (adoption.mode === "progressive") console.log(` Tip: Increase minimumCoverage in config to gradually improve coverage`);
|
|
989
|
+
} else console.log("✓ All routes have screen.meta.ts files");
|
|
990
|
+
const screensPath = join(cwd, config.outDir, "screens.json");
|
|
991
|
+
if (existsSync(screensPath)) try {
|
|
992
|
+
const content = readFileSync(screensPath, "utf-8");
|
|
993
|
+
const orphans = findOrphanScreens(JSON.parse(content));
|
|
994
|
+
if (orphans.length > 0) {
|
|
995
|
+
hasWarnings = true;
|
|
996
|
+
console.log("");
|
|
997
|
+
console.log(`⚠ Orphan screens detected (${orphans.length}):`);
|
|
998
|
+
console.log("");
|
|
999
|
+
console.log(" These screens have no entryPoints and are not");
|
|
1000
|
+
console.log(" referenced in any other screen's 'next' array.");
|
|
1001
|
+
console.log("");
|
|
1002
|
+
for (const orphan of orphans) console.log(` ⚠ ${orphan.id} ${orphan.route}`);
|
|
1003
|
+
console.log("");
|
|
1004
|
+
console.log(" Consider adding entryPoints or removing these screens.");
|
|
1005
|
+
}
|
|
1006
|
+
} catch {}
|
|
1007
|
+
if (hasWarnings) {
|
|
1008
|
+
console.log("");
|
|
1009
|
+
console.log("Lint completed with warnings.");
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
});
|
|
1013
|
+
/**
|
|
1014
|
+
* Find screens that are unreachable (orphans).
|
|
1015
|
+
* A screen is an orphan if:
|
|
1016
|
+
* - It has no entryPoints defined
|
|
1017
|
+
* - AND it's not referenced in any other screen's `next` array
|
|
1018
|
+
*/
|
|
1019
|
+
function findOrphanScreens(screens) {
|
|
1020
|
+
const referencedIds = /* @__PURE__ */ new Set();
|
|
1021
|
+
for (const screen of screens) if (screen.next) for (const nextId of screen.next) referencedIds.add(nextId);
|
|
1022
|
+
const orphans = [];
|
|
1023
|
+
for (const screen of screens) {
|
|
1024
|
+
const hasEntryPoints = screen.entryPoints && screen.entryPoints.length > 0;
|
|
1025
|
+
const isReferenced = referencedIds.has(screen.id);
|
|
1026
|
+
if (!hasEntryPoints && !isReferenced) orphans.push(screen);
|
|
1027
|
+
}
|
|
1028
|
+
return orphans;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
//#endregion
|
|
1032
|
+
//#region src/commands/pr-impact.ts
|
|
1033
|
+
const prImpactCommand = define({
|
|
1034
|
+
name: "pr-impact",
|
|
1035
|
+
description: "Analyze impact of changed files in a PR",
|
|
1036
|
+
args: {
|
|
1037
|
+
base: {
|
|
1038
|
+
type: "string",
|
|
1039
|
+
short: "b",
|
|
1040
|
+
description: "Base branch to compare against (default: main)",
|
|
1041
|
+
default: "main"
|
|
1042
|
+
},
|
|
1043
|
+
config: {
|
|
1044
|
+
type: "string",
|
|
1045
|
+
short: "c",
|
|
1046
|
+
description: "Path to config file"
|
|
1047
|
+
},
|
|
1048
|
+
format: {
|
|
1049
|
+
type: "string",
|
|
1050
|
+
short: "f",
|
|
1051
|
+
description: "Output format: markdown (default) or json",
|
|
1052
|
+
default: "markdown"
|
|
1053
|
+
},
|
|
1054
|
+
depth: {
|
|
1055
|
+
type: "number",
|
|
1056
|
+
short: "d",
|
|
1057
|
+
description: "Maximum depth for transitive dependencies",
|
|
1058
|
+
default: 3
|
|
1059
|
+
}
|
|
1060
|
+
},
|
|
1061
|
+
run: async (ctx) => {
|
|
1062
|
+
const config = await loadConfig(ctx.values.config);
|
|
1063
|
+
const cwd = process.cwd();
|
|
1064
|
+
const baseBranch = ctx.values.base ?? "main";
|
|
1065
|
+
const format = ctx.values.format ?? "markdown";
|
|
1066
|
+
const depth = ctx.values.depth ?? 3;
|
|
1067
|
+
let changedFiles;
|
|
1068
|
+
try {
|
|
1069
|
+
changedFiles = execSync(`git diff --name-only ${baseBranch}...HEAD 2>/dev/null || git diff --name-only ${baseBranch} HEAD`, {
|
|
1070
|
+
cwd,
|
|
1071
|
+
encoding: "utf-8"
|
|
1072
|
+
}).split("\n").map((f) => f.trim()).filter((f) => f.length > 0);
|
|
1073
|
+
} catch {
|
|
1074
|
+
console.error("Error: Failed to get changed files from git");
|
|
1075
|
+
console.error("");
|
|
1076
|
+
console.error("Make sure you are in a git repository and the base branch exists.");
|
|
1077
|
+
console.error(`Base branch: ${baseBranch}`);
|
|
1078
|
+
process.exit(1);
|
|
1079
|
+
}
|
|
1080
|
+
if (changedFiles.length === 0) {
|
|
1081
|
+
console.log("No changed files found.");
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
const apiNames = extractApiNames(changedFiles);
|
|
1085
|
+
if (apiNames.length === 0) {
|
|
1086
|
+
if (format === "markdown") {
|
|
1087
|
+
console.log("## Screenbook Impact Analysis");
|
|
1088
|
+
console.log("");
|
|
1089
|
+
console.log("No API-related changes detected in this PR.");
|
|
1090
|
+
console.log("");
|
|
1091
|
+
console.log(`Changed files: ${changedFiles.length}`);
|
|
1092
|
+
} else console.log(JSON.stringify({
|
|
1093
|
+
apis: [],
|
|
1094
|
+
results: [],
|
|
1095
|
+
changedFiles
|
|
1096
|
+
}, null, 2));
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
const screensPath = join(cwd, config.outDir, "screens.json");
|
|
1100
|
+
if (!existsSync(screensPath)) {
|
|
1101
|
+
console.error("Error: screens.json not found");
|
|
1102
|
+
console.error("");
|
|
1103
|
+
console.error("Run 'screenbook build' first to generate the screen catalog.");
|
|
1104
|
+
process.exit(1);
|
|
1105
|
+
}
|
|
1106
|
+
let screens;
|
|
1107
|
+
try {
|
|
1108
|
+
const content = readFileSync(screensPath, "utf-8");
|
|
1109
|
+
screens = JSON.parse(content);
|
|
1110
|
+
} catch (error) {
|
|
1111
|
+
console.error("Error: Failed to read screens.json");
|
|
1112
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1113
|
+
process.exit(1);
|
|
1114
|
+
}
|
|
1115
|
+
const results = [];
|
|
1116
|
+
for (const apiName of apiNames) {
|
|
1117
|
+
const result = analyzeImpact(screens, apiName, depth);
|
|
1118
|
+
if (result.totalCount > 0) results.push(result);
|
|
1119
|
+
}
|
|
1120
|
+
if (format === "json") console.log(JSON.stringify({
|
|
1121
|
+
changedFiles,
|
|
1122
|
+
detectedApis: apiNames,
|
|
1123
|
+
results: results.map((r) => ({
|
|
1124
|
+
api: r.api,
|
|
1125
|
+
directCount: r.direct.length,
|
|
1126
|
+
transitiveCount: r.transitive.length,
|
|
1127
|
+
totalCount: r.totalCount,
|
|
1128
|
+
direct: r.direct.map((s) => ({
|
|
1129
|
+
id: s.id,
|
|
1130
|
+
title: s.title,
|
|
1131
|
+
route: s.route,
|
|
1132
|
+
owner: s.owner
|
|
1133
|
+
})),
|
|
1134
|
+
transitive: r.transitive.map(({ screen, path }) => ({
|
|
1135
|
+
id: screen.id,
|
|
1136
|
+
title: screen.title,
|
|
1137
|
+
route: screen.route,
|
|
1138
|
+
path
|
|
1139
|
+
}))
|
|
1140
|
+
}))
|
|
1141
|
+
}, null, 2));
|
|
1142
|
+
else console.log(formatMarkdown(changedFiles, apiNames, results));
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
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
|
+
|
|
1226
|
+
//#endregion
|
|
1227
|
+
//#region src/index.ts
|
|
1228
|
+
const mainCommand = define({
|
|
1229
|
+
name: "screenbook",
|
|
1230
|
+
description: "Screen catalog and navigation graph generator",
|
|
1231
|
+
run: () => {
|
|
1232
|
+
console.log("Usage: screenbook <command>");
|
|
1233
|
+
console.log("");
|
|
1234
|
+
console.log("Commands:");
|
|
1235
|
+
console.log(" init Initialize Screenbook in a project");
|
|
1236
|
+
console.log(" generate Auto-generate screen.meta.ts from routes");
|
|
1237
|
+
console.log(" build Build screen metadata JSON");
|
|
1238
|
+
console.log(" dev Start the development server");
|
|
1239
|
+
console.log(" lint Detect routes without screen.meta");
|
|
1240
|
+
console.log(" impact Analyze API dependency impact");
|
|
1241
|
+
console.log(" pr-impact Analyze PR changes impact");
|
|
1242
|
+
console.log("");
|
|
1243
|
+
console.log("Run 'screenbook <command> --help' for more information");
|
|
1244
|
+
}
|
|
1245
|
+
});
|
|
1246
|
+
await cli(process.argv.slice(2), mainCommand, {
|
|
1247
|
+
name: "screenbook",
|
|
1248
|
+
version: "0.0.1",
|
|
1249
|
+
subCommands: {
|
|
1250
|
+
init: initCommand,
|
|
1251
|
+
generate: generateCommand,
|
|
1252
|
+
build: buildCommand,
|
|
1253
|
+
dev: devCommand,
|
|
1254
|
+
lint: lintCommand,
|
|
1255
|
+
impact: impactCommand,
|
|
1256
|
+
"pr-impact": prImpactCommand
|
|
1257
|
+
}
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
//#endregion
|
|
1261
|
+
export { };
|
|
1262
|
+
//# sourceMappingURL=index.mjs.map
|