@schemasentry/cli 0.1.0 → 0.2.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.
Files changed (2) hide show
  1. package/dist/index.js +776 -97
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -3,7 +3,8 @@
3
3
  // src/index.ts
4
4
  import { Command } from "commander";
5
5
  import { readFile } from "fs/promises";
6
- import path from "path";
6
+ import { readFileSync } from "fs";
7
+ import path4 from "path";
7
8
  import { stableStringify as stableStringify2 } from "@schemasentry/core";
8
9
 
9
10
  // src/report.ts
@@ -11,47 +12,290 @@ import {
11
12
  stableStringify,
12
13
  validateSchema
13
14
  } from "@schemasentry/core";
14
- var buildReport = (manifest, data) => {
15
- const manifestRoutes = manifest.routes ?? {};
15
+
16
+ // src/coverage.ts
17
+ var buildCoverageResult = (input2, data) => {
18
+ const manifestRoutes = input2.expectedTypesByRoute ?? {};
16
19
  const dataRoutes = data.routes ?? {};
17
- const allRoutes = /* @__PURE__ */ new Set([
18
- ...Object.keys(manifestRoutes),
19
- ...Object.keys(dataRoutes)
20
+ const derivedRequiredRoutes = Object.entries(manifestRoutes).filter(([, types]) => (types ?? []).length > 0).map(([route]) => route);
21
+ const requiredRoutes = /* @__PURE__ */ new Set([
22
+ ...input2.requiredRoutes ?? [],
23
+ ...derivedRequiredRoutes
20
24
  ]);
21
- const routes = Array.from(allRoutes).sort().map((route) => {
25
+ const allRoutes = Array.from(
26
+ /* @__PURE__ */ new Set([
27
+ ...Object.keys(manifestRoutes),
28
+ ...requiredRoutes,
29
+ ...Object.keys(dataRoutes)
30
+ ])
31
+ ).sort();
32
+ const issuesByRoute = {};
33
+ const summary = {
34
+ missingRoutes: 0,
35
+ missingTypes: 0,
36
+ unlistedRoutes: 0
37
+ };
38
+ for (const route of allRoutes) {
39
+ const issues = [];
22
40
  const expectedTypes = manifestRoutes[route] ?? [];
23
41
  const nodes = dataRoutes[route] ?? [];
24
42
  const foundTypes = nodes.map((node) => node["@type"]).filter((type) => typeof type === "string");
25
- const validation = validateSchema(nodes);
26
- const issues = [...validation.issues];
27
- if (expectedTypes.length > 0) {
28
- if (nodes.length === 0) {
43
+ if (requiredRoutes.has(route) && nodes.length === 0) {
44
+ issues.push({
45
+ path: `routes["${route}"]`,
46
+ message: "No schema blocks found for route",
47
+ severity: "error",
48
+ ruleId: "coverage.missing_route"
49
+ });
50
+ summary.missingRoutes += 1;
51
+ }
52
+ for (const expectedType of expectedTypes) {
53
+ if (!foundTypes.includes(expectedType)) {
29
54
  issues.push({
30
- path: `routes["${route}"]`,
31
- message: "No schema blocks found for route",
55
+ path: `routes["${route}"].types`,
56
+ message: `Missing expected schema type '${expectedType}'`,
32
57
  severity: "error",
33
- ruleId: "coverage.missing_route"
58
+ ruleId: "coverage.missing_type"
34
59
  });
35
- }
36
- for (const expectedType of expectedTypes) {
37
- if (!foundTypes.includes(expectedType)) {
38
- issues.push({
39
- path: `routes["${route}"].types`,
40
- message: `Missing expected schema type '${expectedType}'`,
41
- severity: "error",
42
- ruleId: "coverage.missing_type"
43
- });
44
- }
60
+ summary.missingTypes += 1;
45
61
  }
46
62
  }
47
- if (!manifestRoutes[route] && nodes.length > 0) {
63
+ if (!manifestRoutes[route] && !requiredRoutes.has(route) && nodes.length > 0) {
48
64
  issues.push({
49
65
  path: `routes["${route}"]`,
50
66
  message: "Route has schema but is missing from manifest",
51
67
  severity: "warn",
52
68
  ruleId: "coverage.unlisted_route"
53
69
  });
70
+ summary.unlistedRoutes += 1;
71
+ }
72
+ if (issues.length > 0) {
73
+ issuesByRoute[route] = issues;
74
+ }
75
+ }
76
+ return {
77
+ allRoutes,
78
+ issuesByRoute,
79
+ summary
80
+ };
81
+ };
82
+
83
+ // src/report.ts
84
+ var buildReport = (manifest, data, options = {}) => {
85
+ const manifestRoutes = manifest.routes ?? {};
86
+ const dataRoutes = data.routes ?? {};
87
+ const coverage = buildCoverageResult(
88
+ { expectedTypesByRoute: manifestRoutes },
89
+ data
90
+ );
91
+ const routes = coverage.allRoutes.map((route) => {
92
+ const expectedTypes = manifestRoutes[route] ?? [];
93
+ const nodes = dataRoutes[route] ?? [];
94
+ const foundTypes = nodes.map((node) => node["@type"]).filter((type) => typeof type === "string");
95
+ const validation = validateSchema(nodes, options);
96
+ const issues = [
97
+ ...validation.issues,
98
+ ...coverage.issuesByRoute[route] ?? []
99
+ ];
100
+ const errorCount = issues.filter((issue) => issue.severity === "error").length;
101
+ const warnCount = issues.filter((issue) => issue.severity === "warn").length;
102
+ const score = Math.max(0, validation.score - errorCount * 5 - warnCount * 2);
103
+ return {
104
+ route,
105
+ ok: errorCount === 0,
106
+ score,
107
+ issues,
108
+ expectedTypes,
109
+ foundTypes
110
+ };
111
+ });
112
+ const summaryErrors = routes.reduce(
113
+ (count, route) => count + route.issues.filter((i) => i.severity === "error").length,
114
+ 0
115
+ );
116
+ const summaryWarnings = routes.reduce(
117
+ (count, route) => count + route.issues.filter((i) => i.severity === "warn").length,
118
+ 0
119
+ );
120
+ const summaryScore = routes.length === 0 ? 0 : Math.round(
121
+ routes.reduce((total, route) => total + route.score, 0) / routes.length
122
+ );
123
+ return {
124
+ ok: summaryErrors === 0,
125
+ summary: {
126
+ routes: routes.length,
127
+ errors: summaryErrors,
128
+ warnings: summaryWarnings,
129
+ score: summaryScore,
130
+ coverage: coverage.summary
131
+ },
132
+ routes
133
+ };
134
+ };
135
+
136
+ // src/init.ts
137
+ import { promises as fs } from "fs";
138
+ import path from "path";
139
+ import { SCHEMA_CONTEXT } from "@schemasentry/core";
140
+ var DEFAULT_ANSWERS = {
141
+ siteName: "Acme Corp",
142
+ siteUrl: "https://acme.com",
143
+ authorName: "Jane Doe"
144
+ };
145
+ var getDefaultAnswers = () => ({ ...DEFAULT_ANSWERS });
146
+ var buildManifest = (scannedRoutes = []) => {
147
+ const routes = {
148
+ "/": ["Organization", "WebSite"],
149
+ "/blog/[slug]": ["Article"]
150
+ };
151
+ for (const route of scannedRoutes) {
152
+ if (!routes[route]) {
153
+ routes[route] = ["WebPage"];
54
154
  }
155
+ }
156
+ return { routes };
157
+ };
158
+ var formatDate = (value) => value.toISOString().slice(0, 10);
159
+ var buildData = (answers, options = {}) => {
160
+ const { siteName, siteUrl, authorName } = answers;
161
+ const normalizedSiteUrl = normalizeUrl(siteUrl);
162
+ const today = options.today ?? /* @__PURE__ */ new Date();
163
+ const date = formatDate(today);
164
+ const logoUrl = new URL("/logo.png", normalizedSiteUrl).toString();
165
+ const articleUrl = new URL("/blog/hello-world", normalizedSiteUrl).toString();
166
+ const imageUrl = new URL("/blog/hello-world.png", normalizedSiteUrl).toString();
167
+ const routes = {
168
+ "/": [
169
+ {
170
+ "@context": SCHEMA_CONTEXT,
171
+ "@type": "Organization",
172
+ name: siteName,
173
+ url: normalizedSiteUrl,
174
+ logo: logoUrl,
175
+ description: `Official website of ${siteName}`
176
+ },
177
+ {
178
+ "@context": SCHEMA_CONTEXT,
179
+ "@type": "WebSite",
180
+ name: siteName,
181
+ url: normalizedSiteUrl,
182
+ description: `Learn more about ${siteName}`
183
+ }
184
+ ],
185
+ "/blog/[slug]": [
186
+ {
187
+ "@context": SCHEMA_CONTEXT,
188
+ "@type": "Article",
189
+ headline: "Hello World",
190
+ author: {
191
+ "@type": "Person",
192
+ name: authorName
193
+ },
194
+ datePublished: date,
195
+ dateModified: date,
196
+ description: `An introduction to ${siteName}`,
197
+ image: imageUrl,
198
+ url: articleUrl
199
+ }
200
+ ]
201
+ };
202
+ for (const route of options.scannedRoutes ?? []) {
203
+ if (routes[route]) {
204
+ continue;
205
+ }
206
+ routes[route] = [
207
+ {
208
+ "@context": SCHEMA_CONTEXT,
209
+ "@type": "WebPage",
210
+ name: routeToName(route),
211
+ url: new URL(route, normalizedSiteUrl).toString()
212
+ }
213
+ ];
214
+ }
215
+ return { routes };
216
+ };
217
+ var writeInitFiles = async (options) => {
218
+ const {
219
+ manifestPath,
220
+ dataPath,
221
+ overwriteManifest,
222
+ overwriteData,
223
+ answers,
224
+ today,
225
+ scannedRoutes
226
+ } = options;
227
+ const manifest = buildManifest(scannedRoutes ?? []);
228
+ const data = buildData(answers, { today, scannedRoutes });
229
+ const manifestResult = await writeJsonFile(
230
+ manifestPath,
231
+ manifest,
232
+ overwriteManifest
233
+ );
234
+ const dataResult = await writeJsonFile(dataPath, data, overwriteData);
235
+ return {
236
+ manifest: manifestResult,
237
+ data: dataResult
238
+ };
239
+ };
240
+ var writeJsonFile = async (filePath, payload, overwrite) => {
241
+ const exists = await fileExists(filePath);
242
+ if (exists && !overwrite) {
243
+ return "skipped";
244
+ }
245
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
246
+ const json = JSON.stringify(payload, null, 2);
247
+ await fs.writeFile(filePath, `${json}
248
+ `, "utf8");
249
+ return exists ? "overwritten" : "created";
250
+ };
251
+ var fileExists = async (filePath) => {
252
+ try {
253
+ await fs.access(filePath);
254
+ return true;
255
+ } catch {
256
+ return false;
257
+ }
258
+ };
259
+ var normalizeUrl = (value) => {
260
+ try {
261
+ return new URL(value).toString();
262
+ } catch {
263
+ return new URL(`https://${value}`).toString();
264
+ }
265
+ };
266
+ var routeToName = (route) => {
267
+ if (route === "/") {
268
+ return "Home";
269
+ }
270
+ const cleaned = route.replace(/\[|\]/g, "").split("/").filter(Boolean).join(" ");
271
+ return cleaned.split(/\s+/).map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)).join(" ");
272
+ };
273
+
274
+ // src/audit.ts
275
+ import {
276
+ validateSchema as validateSchema2
277
+ } from "@schemasentry/core";
278
+ var buildAuditReport = (data, options = {}) => {
279
+ const dataRoutes = data.routes ?? {};
280
+ const manifestRoutes = options.manifest?.routes ?? {};
281
+ const coverageEnabled = Boolean(options.manifest || options.requiredRoutes?.length);
282
+ const coverage = coverageEnabled ? buildCoverageResult(
283
+ {
284
+ expectedTypesByRoute: manifestRoutes,
285
+ requiredRoutes: options.requiredRoutes
286
+ },
287
+ data
288
+ ) : null;
289
+ const allRoutes = coverageEnabled ? coverage?.allRoutes ?? [] : Object.keys(dataRoutes).sort();
290
+ const routes = allRoutes.map((route) => {
291
+ const expectedTypes = coverageEnabled ? manifestRoutes[route] ?? [] : [];
292
+ const nodes = dataRoutes[route] ?? [];
293
+ const foundTypes = nodes.map((node) => node["@type"]).filter((type) => typeof type === "string");
294
+ const validation = validateSchema2(nodes, options);
295
+ const issues = [
296
+ ...validation.issues,
297
+ ...coverage?.issuesByRoute[route] ?? []
298
+ ];
55
299
  const errorCount = issues.filter((issue) => issue.severity === "error").length;
56
300
  const warnCount = issues.filter((issue) => issue.severity === "warn").length;
57
301
  const score = Math.max(0, validation.score - errorCount * 5 - warnCount * 2);
@@ -81,15 +325,223 @@ var buildReport = (manifest, data) => {
81
325
  routes: routes.length,
82
326
  errors: summaryErrors,
83
327
  warnings: summaryWarnings,
84
- score: summaryScore
328
+ score: summaryScore,
329
+ ...coverageEnabled ? { coverage: coverage?.summary } : {}
85
330
  },
86
331
  routes
87
332
  };
88
333
  };
89
334
 
335
+ // src/summary.ts
336
+ var formatDuration = (durationMs) => {
337
+ if (!Number.isFinite(durationMs) || durationMs < 0) {
338
+ return "0ms";
339
+ }
340
+ if (durationMs < 1e3) {
341
+ return `${Math.round(durationMs)}ms`;
342
+ }
343
+ const seconds = durationMs / 1e3;
344
+ return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`;
345
+ };
346
+ var formatSummaryLine = (label, stats) => {
347
+ const parts = [
348
+ `Routes: ${stats.routes}`,
349
+ `Errors: ${stats.errors}`,
350
+ `Warnings: ${stats.warnings}`,
351
+ `Score: ${stats.score}`,
352
+ `Duration: ${formatDuration(stats.durationMs)}`
353
+ ];
354
+ if (stats.coverage) {
355
+ parts.push(
356
+ `Coverage: missing_routes=${stats.coverage.missingRoutes} missing_types=${stats.coverage.missingTypes} unlisted_routes=${stats.coverage.unlistedRoutes}`
357
+ );
358
+ }
359
+ return `${label} | ${parts.join(" | ")}`;
360
+ };
361
+
362
+ // src/config.ts
363
+ import { promises as fs2 } from "fs";
364
+ import path2 from "path";
365
+ var ConfigError = class extends Error {
366
+ constructor(code, message, suggestion) {
367
+ super(message);
368
+ this.code = code;
369
+ this.suggestion = suggestion;
370
+ }
371
+ };
372
+ var DEFAULT_CONFIG_PATH = "schema-sentry.config.json";
373
+ var loadConfig = async (options) => {
374
+ const cwd = options.cwd ?? process.cwd();
375
+ const explicit = Boolean(options.configPath);
376
+ const resolvedPath = path2.resolve(cwd, options.configPath ?? DEFAULT_CONFIG_PATH);
377
+ const exists = await fileExists2(resolvedPath);
378
+ if (!exists) {
379
+ if (explicit) {
380
+ throw new ConfigError(
381
+ "config.not_found",
382
+ `Config not found at ${resolvedPath}`,
383
+ "Provide a valid path or remove --config."
384
+ );
385
+ }
386
+ return null;
387
+ }
388
+ let raw;
389
+ try {
390
+ raw = await fs2.readFile(resolvedPath, "utf8");
391
+ } catch (error) {
392
+ throw new ConfigError(
393
+ "config.read_failed",
394
+ `Failed to read config at ${resolvedPath}`,
395
+ "Check file permissions or re-create the config file."
396
+ );
397
+ }
398
+ let parsed;
399
+ try {
400
+ parsed = JSON.parse(raw);
401
+ } catch (error) {
402
+ throw new ConfigError(
403
+ "config.invalid_json",
404
+ "Config is not valid JSON",
405
+ "Check the JSON syntax or regenerate the file."
406
+ );
407
+ }
408
+ if (!isConfig(parsed)) {
409
+ throw new ConfigError(
410
+ "config.invalid_shape",
411
+ "Config must be a JSON object with optional boolean 'recommended'",
412
+ 'Example: { "recommended": false }'
413
+ );
414
+ }
415
+ return parsed;
416
+ };
417
+ var resolveRecommended = (cliOverride, config) => cliOverride ?? config?.recommended ?? true;
418
+ var isConfig = (value) => {
419
+ if (!value || typeof value !== "object") {
420
+ return false;
421
+ }
422
+ const config = value;
423
+ if (config.recommended !== void 0 && typeof config.recommended !== "boolean") {
424
+ return false;
425
+ }
426
+ return true;
427
+ };
428
+ var fileExists2 = async (filePath) => {
429
+ try {
430
+ await fs2.access(filePath);
431
+ return true;
432
+ } catch {
433
+ return false;
434
+ }
435
+ };
436
+
437
+ // src/routes.ts
438
+ import { promises as fs3 } from "fs";
439
+ import path3 from "path";
440
+ var scanRoutes = async (options) => {
441
+ const rootDir = options.rootDir;
442
+ const routes = /* @__PURE__ */ new Set();
443
+ if (options.includeApp !== false) {
444
+ const appDir = path3.join(rootDir, "app");
445
+ if (await dirExists(appDir)) {
446
+ const files = await walkDir(appDir);
447
+ for (const file of files) {
448
+ if (!isAppPageFile(file)) {
449
+ continue;
450
+ }
451
+ const route = toAppRoute(appDir, file);
452
+ if (route) {
453
+ routes.add(route);
454
+ }
455
+ }
456
+ }
457
+ }
458
+ if (options.includePages !== false) {
459
+ const pagesDir = path3.join(rootDir, "pages");
460
+ if (await dirExists(pagesDir)) {
461
+ const files = await walkDir(pagesDir);
462
+ for (const file of files) {
463
+ if (!isPagesFile(file)) {
464
+ continue;
465
+ }
466
+ const route = toPagesRoute(pagesDir, file);
467
+ if (route) {
468
+ routes.add(route);
469
+ }
470
+ }
471
+ }
472
+ }
473
+ return Array.from(routes).sort();
474
+ };
475
+ var isAppPageFile = (filePath) => /\/page\.(t|j)sx?$/.test(filePath) || /\/page\.mdx$/.test(filePath);
476
+ var isPagesFile = (filePath) => /\.(t|j)sx?$/.test(filePath) || /\.mdx$/.test(filePath);
477
+ var toAppRoute = (appDir, filePath) => {
478
+ const relative = path3.relative(appDir, filePath);
479
+ const segments = relative.split(path3.sep);
480
+ if (segments.length === 0) {
481
+ return null;
482
+ }
483
+ segments.pop();
484
+ const routeSegments = segments.filter((segment) => segment.length > 0).filter((segment) => !isGroupSegment(segment)).filter((segment) => !isParallelSegment(segment));
485
+ if (routeSegments.length === 0) {
486
+ return "/";
487
+ }
488
+ return `/${routeSegments.join("/")}`;
489
+ };
490
+ var toPagesRoute = (pagesDir, filePath) => {
491
+ const relative = path3.relative(pagesDir, filePath);
492
+ const segments = relative.split(path3.sep);
493
+ if (segments.length === 0) {
494
+ return null;
495
+ }
496
+ if (segments[0] === "api") {
497
+ return null;
498
+ }
499
+ const fileName = segments.pop();
500
+ if (!fileName) {
501
+ return null;
502
+ }
503
+ const baseName = fileName.replace(/\.[^/.]+$/, "");
504
+ if (baseName.startsWith("_")) {
505
+ return null;
506
+ }
507
+ const filtered = segments.filter((segment) => segment.length > 0);
508
+ if (baseName !== "index") {
509
+ filtered.push(baseName);
510
+ }
511
+ if (filtered.length === 0) {
512
+ return "/";
513
+ }
514
+ return `/${filtered.join("/")}`;
515
+ };
516
+ var isGroupSegment = (segment) => segment.startsWith("(") && segment.endsWith(")");
517
+ var isParallelSegment = (segment) => segment.startsWith("@");
518
+ var dirExists = async (dirPath) => {
519
+ try {
520
+ const stat = await fs3.stat(dirPath);
521
+ return stat.isDirectory();
522
+ } catch {
523
+ return false;
524
+ }
525
+ };
526
+ var walkDir = async (dirPath) => {
527
+ const entries = await fs3.readdir(dirPath, { withFileTypes: true });
528
+ const files = [];
529
+ for (const entry of entries) {
530
+ const resolved = path3.join(dirPath, entry.name);
531
+ if (entry.isDirectory()) {
532
+ files.push(...await walkDir(resolved));
533
+ } else if (entry.isFile()) {
534
+ files.push(resolved);
535
+ }
536
+ }
537
+ return files;
538
+ };
539
+
90
540
  // src/index.ts
541
+ import { createInterface } from "readline/promises";
542
+ import { stdin as input, stdout as output } from "process";
91
543
  var program = new Command();
92
- program.name("schemasentry").description("Schema Sentry CLI").version("0.1.0");
544
+ program.name("schemasentry").description("Schema Sentry CLI").version(resolveCliVersion());
93
545
  program.command("validate").description("Validate schema coverage and rules").option(
94
546
  "-m, --manifest <path>",
95
547
  "Path to manifest JSON",
@@ -98,23 +550,19 @@ program.command("validate").description("Validate schema coverage and rules").op
98
550
  "-d, --data <path>",
99
551
  "Path to schema data JSON",
100
552
  "schema-sentry.data.json"
101
- ).action(async (options) => {
102
- const manifestPath = path.resolve(process.cwd(), options.manifest);
103
- const dataPath = path.resolve(process.cwd(), options.data);
553
+ ).option("-c, --config <path>", "Path to config JSON").option("--recommended", "Enable recommended field checks").option("--no-recommended", "Disable recommended field checks").action(async (options) => {
554
+ const start = Date.now();
555
+ const recommended = await resolveRecommendedOption(options.config);
556
+ const manifestPath = path4.resolve(process.cwd(), options.manifest);
557
+ const dataPath = path4.resolve(process.cwd(), options.data);
104
558
  let raw;
105
559
  try {
106
560
  raw = await readFile(manifestPath, "utf8");
107
561
  } catch (error) {
108
- console.error(
109
- stableStringify2({
110
- ok: false,
111
- errors: [
112
- {
113
- code: "manifest.not_found",
114
- message: `Manifest not found at ${manifestPath}`
115
- }
116
- ]
117
- })
562
+ printCliError(
563
+ "manifest.not_found",
564
+ `Manifest not found at ${manifestPath}`,
565
+ "Run `schemasentry init` to generate starter files."
118
566
  );
119
567
  process.exit(1);
120
568
  return;
@@ -123,31 +571,19 @@ program.command("validate").description("Validate schema coverage and rules").op
123
571
  try {
124
572
  manifest = JSON.parse(raw);
125
573
  } catch (error) {
126
- console.error(
127
- stableStringify2({
128
- ok: false,
129
- errors: [
130
- {
131
- code: "manifest.invalid_json",
132
- message: "Manifest is not valid JSON"
133
- }
134
- ]
135
- })
574
+ printCliError(
575
+ "manifest.invalid_json",
576
+ "Manifest is not valid JSON",
577
+ "Check the JSON syntax or regenerate with `schemasentry init`."
136
578
  );
137
579
  process.exit(1);
138
580
  return;
139
581
  }
140
582
  if (!isManifest(manifest)) {
141
- console.error(
142
- stableStringify2({
143
- ok: false,
144
- errors: [
145
- {
146
- code: "manifest.invalid_shape",
147
- message: "Manifest must contain a 'routes' object with string array values"
148
- }
149
- ]
150
- })
583
+ printCliError(
584
+ "manifest.invalid_shape",
585
+ "Manifest must contain a 'routes' object with string array values",
586
+ "Ensure each route maps to an array of schema type names."
151
587
  );
152
588
  process.exit(1);
153
589
  return;
@@ -156,16 +592,10 @@ program.command("validate").description("Validate schema coverage and rules").op
156
592
  try {
157
593
  dataRaw = await readFile(dataPath, "utf8");
158
594
  } catch (error) {
159
- console.error(
160
- stableStringify2({
161
- ok: false,
162
- errors: [
163
- {
164
- code: "data.not_found",
165
- message: `Schema data not found at ${dataPath}`
166
- }
167
- ]
168
- })
595
+ printCliError(
596
+ "data.not_found",
597
+ `Schema data not found at ${dataPath}`,
598
+ "Run `schemasentry init` to generate starter files."
169
599
  );
170
600
  process.exit(1);
171
601
  return;
@@ -174,41 +604,153 @@ program.command("validate").description("Validate schema coverage and rules").op
174
604
  try {
175
605
  data = JSON.parse(dataRaw);
176
606
  } catch (error) {
177
- console.error(
178
- stableStringify2({
179
- ok: false,
180
- errors: [
181
- {
182
- code: "data.invalid_json",
183
- message: "Schema data is not valid JSON"
184
- }
185
- ]
186
- })
607
+ printCliError(
608
+ "data.invalid_json",
609
+ "Schema data is not valid JSON",
610
+ "Check the JSON syntax or regenerate with `schemasentry init`."
187
611
  );
188
612
  process.exit(1);
189
613
  return;
190
614
  }
191
615
  if (!isSchemaData(data)) {
192
- console.error(
193
- stableStringify2({
194
- ok: false,
195
- errors: [
196
- {
197
- code: "data.invalid_shape",
198
- message: "Schema data must contain a 'routes' object with array values"
199
- }
200
- ]
201
- })
616
+ printCliError(
617
+ "data.invalid_shape",
618
+ "Schema data must contain a 'routes' object with array values",
619
+ "Ensure each route maps to an array of JSON-LD blocks."
202
620
  );
203
621
  process.exit(1);
204
622
  return;
205
623
  }
206
- const report = buildReport(manifest, data);
624
+ const report = buildReport(manifest, data, { recommended });
207
625
  console.log(formatReportOutput(report));
626
+ printValidateSummary(report, Date.now() - start);
208
627
  process.exit(report.ok ? 0 : 1);
209
628
  });
210
- program.parse();
211
- var isManifest = (value) => {
629
+ program.command("init").description("Interactive setup wizard").option(
630
+ "-m, --manifest <path>",
631
+ "Path to manifest JSON",
632
+ "schema-sentry.manifest.json"
633
+ ).option(
634
+ "-d, --data <path>",
635
+ "Path to schema data JSON",
636
+ "schema-sentry.data.json"
637
+ ).option("-y, --yes", "Use defaults and skip prompts").option("-f, --force", "Overwrite existing files").option("--scan", "Scan the filesystem for routes and add WebPage entries").option("--root <path>", "Project root for scanning", ".").action(async (options) => {
638
+ const manifestPath = path4.resolve(process.cwd(), options.manifest);
639
+ const dataPath = path4.resolve(process.cwd(), options.data);
640
+ const force = options.force ?? false;
641
+ const useDefaults = options.yes ?? false;
642
+ const answers = useDefaults ? getDefaultAnswers() : await promptAnswers();
643
+ const scannedRoutes = options.scan ? await scanRoutes({ rootDir: path4.resolve(process.cwd(), options.root ?? ".") }) : [];
644
+ if (options.scan && scannedRoutes.length === 0) {
645
+ console.error("No routes found during scan.");
646
+ }
647
+ const [overwriteManifest, overwriteData] = await resolveOverwrites({
648
+ manifestPath,
649
+ dataPath,
650
+ force,
651
+ interactive: !useDefaults
652
+ });
653
+ const result = await writeInitFiles({
654
+ manifestPath,
655
+ dataPath,
656
+ overwriteManifest,
657
+ overwriteData,
658
+ answers,
659
+ scannedRoutes
660
+ });
661
+ printInitSummary({ manifestPath, dataPath, result });
662
+ });
663
+ program.command("audit").description("Analyze schema health and report issues").option(
664
+ "-d, --data <path>",
665
+ "Path to schema data JSON",
666
+ "schema-sentry.data.json"
667
+ ).option("-m, --manifest <path>", "Path to manifest JSON (optional)").option("--scan", "Scan the filesystem for routes").option("--root <path>", "Project root for scanning", ".").option("-c, --config <path>", "Path to config JSON").option("--recommended", "Enable recommended field checks").option("--no-recommended", "Disable recommended field checks").action(async (options) => {
668
+ const start = Date.now();
669
+ const recommended = await resolveRecommendedOption(options.config);
670
+ const dataPath = path4.resolve(process.cwd(), options.data);
671
+ let dataRaw;
672
+ try {
673
+ dataRaw = await readFile(dataPath, "utf8");
674
+ } catch (error) {
675
+ printCliError(
676
+ "data.not_found",
677
+ `Schema data not found at ${dataPath}`,
678
+ "Run `schemasentry init` to generate starter files."
679
+ );
680
+ process.exit(1);
681
+ return;
682
+ }
683
+ let data;
684
+ try {
685
+ data = JSON.parse(dataRaw);
686
+ } catch (error) {
687
+ printCliError(
688
+ "data.invalid_json",
689
+ "Schema data is not valid JSON",
690
+ "Check the JSON syntax or regenerate with `schemasentry init`."
691
+ );
692
+ process.exit(1);
693
+ return;
694
+ }
695
+ if (!isSchemaData(data)) {
696
+ printCliError(
697
+ "data.invalid_shape",
698
+ "Schema data must contain a 'routes' object with array values",
699
+ "Ensure each route maps to an array of JSON-LD blocks."
700
+ );
701
+ process.exit(1);
702
+ return;
703
+ }
704
+ let manifest;
705
+ if (options.manifest) {
706
+ const manifestPath = path4.resolve(process.cwd(), options.manifest);
707
+ let manifestRaw;
708
+ try {
709
+ manifestRaw = await readFile(manifestPath, "utf8");
710
+ } catch (error) {
711
+ printCliError(
712
+ "manifest.not_found",
713
+ `Manifest not found at ${manifestPath}`,
714
+ "Run `schemasentry init` to generate starter files."
715
+ );
716
+ process.exit(1);
717
+ return;
718
+ }
719
+ try {
720
+ manifest = JSON.parse(manifestRaw);
721
+ } catch (error) {
722
+ printCliError(
723
+ "manifest.invalid_json",
724
+ "Manifest is not valid JSON",
725
+ "Check the JSON syntax or regenerate with `schemasentry init`."
726
+ );
727
+ process.exit(1);
728
+ return;
729
+ }
730
+ if (!isManifest(manifest)) {
731
+ printCliError(
732
+ "manifest.invalid_shape",
733
+ "Manifest must contain a 'routes' object with string array values",
734
+ "Ensure each route maps to an array of schema type names."
735
+ );
736
+ process.exit(1);
737
+ return;
738
+ }
739
+ }
740
+ const requiredRoutes = options.scan ? await scanRoutes({ rootDir: path4.resolve(process.cwd(), options.root ?? ".") }) : [];
741
+ if (options.scan && requiredRoutes.length === 0) {
742
+ console.error("No routes found during scan.");
743
+ }
744
+ const report = buildAuditReport(data, {
745
+ recommended,
746
+ manifest,
747
+ requiredRoutes: requiredRoutes.length > 0 ? requiredRoutes : void 0
748
+ });
749
+ console.log(formatReportOutput(report));
750
+ printAuditSummary(report, Boolean(manifest), Date.now() - start);
751
+ process.exit(report.ok ? 0 : 1);
752
+ });
753
+ function isManifest(value) {
212
754
  if (!value || typeof value !== "object") {
213
755
  return false;
214
756
  }
@@ -222,8 +764,8 @@ var isManifest = (value) => {
222
764
  }
223
765
  }
224
766
  return true;
225
- };
226
- var isSchemaData = (value) => {
767
+ }
768
+ function isSchemaData(value) {
227
769
  if (!value || typeof value !== "object") {
228
770
  return false;
229
771
  }
@@ -237,5 +779,142 @@ var isSchemaData = (value) => {
237
779
  }
238
780
  }
239
781
  return true;
240
- };
241
- var formatReportOutput = (report) => stableStringify2(report);
782
+ }
783
+ function formatReportOutput(report) {
784
+ return stableStringify2(report);
785
+ }
786
+ function printCliError(code, message, suggestion) {
787
+ console.error(
788
+ stableStringify2({
789
+ ok: false,
790
+ errors: [
791
+ {
792
+ code,
793
+ message,
794
+ ...suggestion !== void 0 ? { suggestion } : {}
795
+ }
796
+ ]
797
+ })
798
+ );
799
+ }
800
+ async function resolveRecommendedOption(configPath) {
801
+ const override = getRecommendedOverride(process.argv);
802
+ try {
803
+ const config = await loadConfig({ configPath });
804
+ return resolveRecommended(override, config);
805
+ } catch (error) {
806
+ if (error instanceof ConfigError) {
807
+ printCliError(error.code, error.message, error.suggestion);
808
+ process.exit(1);
809
+ return true;
810
+ }
811
+ throw error;
812
+ }
813
+ }
814
+ function getRecommendedOverride(argv) {
815
+ if (argv.includes("--recommended")) {
816
+ return true;
817
+ }
818
+ if (argv.includes("--no-recommended")) {
819
+ return false;
820
+ }
821
+ return void 0;
822
+ }
823
+ function resolveCliVersion() {
824
+ try {
825
+ const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
826
+ const parsed = JSON.parse(raw);
827
+ return parsed.version ?? "0.0.0";
828
+ } catch {
829
+ return "0.0.0";
830
+ }
831
+ }
832
+ program.parse();
833
+ function printValidateSummary(report, durationMs) {
834
+ console.error(
835
+ formatSummaryLine("validate", {
836
+ ...report.summary,
837
+ durationMs,
838
+ coverage: report.summary.coverage
839
+ })
840
+ );
841
+ }
842
+ function printAuditSummary(report, coverageEnabled, durationMs) {
843
+ console.error(
844
+ formatSummaryLine("audit", {
845
+ ...report.summary,
846
+ durationMs,
847
+ coverage: report.summary.coverage
848
+ })
849
+ );
850
+ if (!coverageEnabled) {
851
+ console.error("Coverage checks skipped (no manifest provided).");
852
+ }
853
+ }
854
+ async function promptAnswers() {
855
+ const defaults = getDefaultAnswers();
856
+ const rl = createInterface({ input, output });
857
+ try {
858
+ const siteName = await ask(rl, "Site name", defaults.siteName);
859
+ const siteUrl = await ask(rl, "Base URL", defaults.siteUrl);
860
+ const authorName = await ask(rl, "Primary author name", defaults.authorName);
861
+ return { siteName, siteUrl, authorName };
862
+ } finally {
863
+ rl.close();
864
+ }
865
+ }
866
+ async function ask(rl, question, fallback) {
867
+ const answer = (await rl.question(`${question} (${fallback}): `)).trim();
868
+ return answer.length > 0 ? answer : fallback;
869
+ }
870
+ async function resolveOverwrites(options) {
871
+ const { manifestPath, dataPath, force, interactive } = options;
872
+ if (force) {
873
+ return [true, true];
874
+ }
875
+ const manifestExists = await fileExists(manifestPath);
876
+ const dataExists = await fileExists(dataPath);
877
+ if (!interactive) {
878
+ return [false, false];
879
+ }
880
+ const rl = createInterface({ input, output });
881
+ try {
882
+ const overwriteManifest = manifestExists ? await confirm(rl, `Manifest exists at ${manifestPath}. Overwrite?`, false) : false;
883
+ const overwriteData = dataExists ? await confirm(rl, `Data file exists at ${dataPath}. Overwrite?`, false) : false;
884
+ return [overwriteManifest, overwriteData];
885
+ } finally {
886
+ rl.close();
887
+ }
888
+ }
889
+ async function confirm(rl, question, defaultValue) {
890
+ const hint = defaultValue ? "Y/n" : "y/N";
891
+ const answer = (await rl.question(`${question} (${hint}): `)).trim().toLowerCase();
892
+ if (!answer) {
893
+ return defaultValue;
894
+ }
895
+ return answer === "y" || answer === "yes";
896
+ }
897
+ function printInitSummary(options) {
898
+ const { manifestPath, dataPath, result } = options;
899
+ const created = [];
900
+ if (result.manifest !== "skipped") {
901
+ created.push(`${manifestPath} (${result.manifest})`);
902
+ }
903
+ if (result.data !== "skipped") {
904
+ created.push(`${dataPath} (${result.data})`);
905
+ }
906
+ if (created.length > 0) {
907
+ console.log("Schema Sentry init complete.");
908
+ console.log(`Created ${created.length} file(s):`);
909
+ created.forEach((entry) => console.log(`- ${entry}`));
910
+ }
911
+ if (result.manifest === "skipped" || result.data === "skipped") {
912
+ console.log("Some files were skipped. Use --force to overwrite.");
913
+ }
914
+ if (result.manifest === "skipped" && result.data === "skipped") {
915
+ console.log("No files were written.");
916
+ }
917
+ if (created.length > 0) {
918
+ console.log("Next: run `schemasentry validate` to verify your setup.");
919
+ }
920
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@schemasentry/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for Schema Sentry validation and reporting.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "commander": "^12.0.0",
36
- "@schemasentry/core": "0.1.0"
36
+ "@schemasentry/core": "0.2.0"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "tsup src/index.ts --format esm --dts --clean --tsconfig tsconfig.build.json",