@valbuild/cli 0.97.3 → 0.97.5

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 (32) hide show
  1. package/cli/dist/valbuild-cli-cli.cjs.dev.js +1471 -118
  2. package/cli/dist/valbuild-cli-cli.cjs.prod.js +1471 -118
  3. package/cli/dist/valbuild-cli-cli.esm.js +1469 -118
  4. package/package.json +6 -4
  5. package/src/__fixtures__/basic/val.config.ts +2 -2
  6. package/src/__fixtures__/basic/val.modules.ts +16 -0
  7. package/src/__fixtures__/debug-snapshot/.val/patches/11111111-1111-4111-8111-111111111111/patch.json +19 -0
  8. package/src/__fixtures__/debug-snapshot/.val/patches/22222222-2222-4222-8222-222222222222/patch.json +20 -0
  9. package/src/__fixtures__/debug-snapshot/.val/patches/head/patch.json +20 -0
  10. package/src/__fixtures__/debug-snapshot/content/projects.val.ts +23 -0
  11. package/src/__fixtures__/debug-snapshot/content/summary.ts +6 -0
  12. package/src/__fixtures__/debug-snapshot/content/tags.val.ts +10 -0
  13. package/src/__fixtures__/debug-snapshot/content/unrelated.val.ts +5 -0
  14. package/src/__fixtures__/debug-snapshot/tsconfig.json +12 -0
  15. package/src/__fixtures__/debug-snapshot/val.config.ts +5 -0
  16. package/src/__fixtures__/debug-snapshot/val.modules.ts +8 -0
  17. package/src/cli.ts +89 -2
  18. package/src/debug/context.ts +173 -0
  19. package/src/debug/importGraph.ts +126 -0
  20. package/src/debug/moduleClosure.ts +167 -0
  21. package/src/debug/report.ts +80 -0
  22. package/src/debug/snapshot.ts +497 -0
  23. package/src/debug/snapshotRoundTrip.test.ts +95 -0
  24. package/src/debug.test.ts +107 -0
  25. package/src/debug.ts +120 -0
  26. package/src/deleteUnappliablePatches.ts +139 -0
  27. package/src/listUnusedFiles.ts +16 -4
  28. package/src/runValidation.test.ts +6 -6
  29. package/src/runValidation.ts +40 -15
  30. package/src/utils/evalValConfigFile.ts +13 -5
  31. package/src/utils/sourcePathToFileLocation.ts +184 -0
  32. package/src/validate.ts +415 -154
package/src/debug.ts ADDED
@@ -0,0 +1,120 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import JSZip from "jszip";
4
+ import pc from "picocolors";
5
+ import { createDebugContext, DebugContextError } from "./debug/context";
6
+ import { buildSnapshot } from "./debug/snapshot";
7
+ import { printPatchReport } from "./debug/report";
8
+ import { error } from "./logger";
9
+
10
+ export async function debug(options: {
11
+ root?: string;
12
+ out?: string;
13
+ commit?: string;
14
+ branch?: string;
15
+ remote?: boolean;
16
+ includeFiles?: boolean;
17
+ verbose?: boolean;
18
+ }): Promise<void> {
19
+ let ctx;
20
+ try {
21
+ ctx = await createDebugContext(options);
22
+ } catch (err) {
23
+ if (err instanceof DebugContextError) {
24
+ return error(err.message);
25
+ }
26
+ throw err;
27
+ }
28
+
29
+ console.log(
30
+ pc.dim(
31
+ `Project: ${ctx.project ?? "(fs mode)"} branch: ${ctx.branch ?? "?"} commit: ${ctx.commit ?? "?"}`,
32
+ ),
33
+ );
34
+ console.log(
35
+ pc.yellow(
36
+ "The snapshot includes unpublished content. Only share it with Val developers.",
37
+ ),
38
+ );
39
+ console.log("");
40
+
41
+ const snapshot = await buildSnapshot(ctx, {
42
+ includeFiles: options.includeFiles,
43
+ });
44
+
45
+ printPatchReport(
46
+ snapshot.manifest.modules.length > 0 ? await readPatchMetadata(ctx) : [],
47
+ snapshot.report,
48
+ { verbose: options.verbose },
49
+ );
50
+
51
+ const outPath = path.resolve(
52
+ options.out ??
53
+ `./val-debug-${sanitize(ctx.branch ?? "nobranch")}-${(ctx.commit ?? "nocommit").slice(0, 8)}-${timestamp()}.zip`,
54
+ );
55
+ await writeZip(outPath, snapshot.entries);
56
+
57
+ console.log("");
58
+ console.log(`Snapshot written to ${pc.cyan(outPath)}`);
59
+ for (const line of [
60
+ `unzip ${path.basename(outPath)} -d debug/<name>`,
61
+ `pnpm debug:replay debug/<name>`,
62
+ ]) {
63
+ console.log(pc.dim(` ${line}`));
64
+ }
65
+ if (snapshot.manifest.unresolvedImports.length > 0) {
66
+ console.log("");
67
+ console.log(
68
+ pc.yellow(
69
+ `${snapshot.manifest.unresolvedImports.length} import(s) could not be resolved to a project file. ` +
70
+ `Bare package imports are expected; a tsconfig path alias means the snapshot may not evaluate. See manifest.json.`,
71
+ ),
72
+ );
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Patch metadata for the printed report. Fetched without ops so the (large)
78
+ * patch bodies are not pulled a second time.
79
+ */
80
+ async function readPatchMetadata(
81
+ ctx: Awaited<ReturnType<typeof createDebugContext>>,
82
+ ) {
83
+ const res = await ctx.serverOps.fetchPatches({
84
+ patchIds: undefined,
85
+ excludePatchOps: true,
86
+ });
87
+ if (res.error) {
88
+ return [];
89
+ }
90
+ return res.patches.map((patch) => ({
91
+ patchId: patch.patchId,
92
+ path: patch.path,
93
+ createdAt: patch.createdAt,
94
+ authorId: patch.authorId,
95
+ }));
96
+ }
97
+
98
+ async function writeZip(
99
+ outPath: string,
100
+ entries: Record<string, string>,
101
+ ): Promise<void> {
102
+ const zip = new JSZip();
103
+ for (const [entryPath, contents] of Object.entries(entries)) {
104
+ zip.file(entryPath, contents);
105
+ }
106
+ const buffer = await zip.generateAsync({
107
+ type: "nodebuffer",
108
+ compression: "DEFLATE",
109
+ });
110
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
111
+ fs.writeFileSync(outPath, buffer);
112
+ }
113
+
114
+ function timestamp(): string {
115
+ return new Date().toISOString().replace(/[:.]/g, "-").replace(/Z$/, "");
116
+ }
117
+
118
+ function sanitize(value: string): string {
119
+ return value.replace(/[^a-zA-Z0-9._-]/g, "_");
120
+ }
@@ -0,0 +1,139 @@
1
+ import readline from "readline";
2
+ import pc from "picocolors";
3
+ import { PatchId } from "@valbuild/core";
4
+ import { OrderedPatches, PatchAnalysis } from "@valbuild/server";
5
+ import { createDebugContext, DebugContextError } from "./debug/context";
6
+ import { printPatchReport } from "./debug/report";
7
+ import { error, info } from "./logger";
8
+
9
+ /**
10
+ * Removes the pending patches that cannot be applied, which is what unblocks a
11
+ * publish that fails with "Failed to create commit".
12
+ *
13
+ * Deliberately a separate command from `val debug`: capturing a snapshot must
14
+ * always be read-only, and deleting destroys the evidence - so take the snapshot
15
+ * first.
16
+ */
17
+ export async function deleteUnappliablePatches(options: {
18
+ root?: string;
19
+ commit?: string;
20
+ branch?: string;
21
+ remote?: boolean;
22
+ dryRun?: boolean;
23
+ yes?: boolean;
24
+ verbose?: boolean;
25
+ }): Promise<void> {
26
+ let ctx;
27
+ try {
28
+ ctx = await createDebugContext(options);
29
+ } catch (err) {
30
+ if (err instanceof DebugContextError) {
31
+ return error(err.message);
32
+ }
33
+ throw err;
34
+ }
35
+ console.log(
36
+ pc.dim(
37
+ `Project: ${ctx.project ?? "(fs mode)"} branch: ${ctx.branch ?? "?"} commit: ${ctx.commit ?? "?"}`,
38
+ ),
39
+ );
40
+
41
+ const first = await analyse(ctx);
42
+ if (first === null) {
43
+ return;
44
+ }
45
+ printPatchReport(first.metadata, first.prepared, {
46
+ verbose: options.verbose,
47
+ });
48
+ const unappliablePatchIds = Object.keys(
49
+ first.prepared.unappliablePatches,
50
+ ).map((patchId) => patchId as PatchId);
51
+ if (unappliablePatchIds.length === 0) {
52
+ return;
53
+ }
54
+ if (options.dryRun) {
55
+ console.log("");
56
+ info("Dry run: nothing was deleted.");
57
+ return;
58
+ }
59
+ if (!options.yes) {
60
+ console.log("");
61
+ const confirmed = await confirm(
62
+ `Delete ${unappliablePatchIds.length} patch(es)? The changes they contain are lost. [y/N] `,
63
+ );
64
+ if (!confirmed) {
65
+ info("Aborted, nothing was deleted.");
66
+ return;
67
+ }
68
+ }
69
+
70
+ const deleteRes = await ctx.serverOps.deletePatches(unappliablePatchIds);
71
+ if (deleteRes.errors && Object.keys(deleteRes.errors).length > 0) {
72
+ for (const [patchId, err] of Object.entries(deleteRes.errors)) {
73
+ error(`Could not delete ${patchId}: ${err.message}`);
74
+ }
75
+ return;
76
+ }
77
+ info(`Deleted ${unappliablePatchIds.length} patch(es).`, { isGood: true });
78
+
79
+ console.log("");
80
+ console.log(pc.dim("Re-checking the remaining chain..."));
81
+ const second = await analyse(ctx);
82
+ if (second === null) {
83
+ return;
84
+ }
85
+ const stillUnappliable = Object.keys(second.prepared.unappliablePatches);
86
+ if (stillUnappliable.length === 0) {
87
+ info(
88
+ `The remaining ${second.metadata.length} patch(es) all apply. Publishing should work now.`,
89
+ { isGood: true },
90
+ );
91
+ return;
92
+ }
93
+ printPatchReport(second.metadata, second.prepared, {
94
+ verbose: options.verbose,
95
+ });
96
+ error(
97
+ `${stillUnappliable.length} patch(es) still cannot be applied. Run the command again to remove them too.`,
98
+ );
99
+ }
100
+
101
+ async function analyse(ctx: Awaited<ReturnType<typeof createDebugContext>>) {
102
+ const patchesRes = await ctx.serverOps.fetchPatches({
103
+ patchIds: undefined,
104
+ excludePatchOps: false,
105
+ });
106
+ if (patchesRes.error) {
107
+ error(`Could not fetch patches: ${patchesRes.error.message}`);
108
+ return null;
109
+ }
110
+ const analysis: PatchAnalysis & OrderedPatches = {
111
+ ...ctx.serverOps.analyzePatches(patchesRes.patches),
112
+ ...patchesRes,
113
+ };
114
+ const prepared = await ctx.serverOps.prepare(analysis, {
115
+ continueOnError: true,
116
+ });
117
+ return {
118
+ prepared,
119
+ metadata: patchesRes.patches.map((patch) => ({
120
+ patchId: patch.patchId,
121
+ path: patch.path,
122
+ createdAt: patch.createdAt,
123
+ authorId: patch.authorId,
124
+ })),
125
+ };
126
+ }
127
+
128
+ function confirm(question: string): Promise<boolean> {
129
+ const rl = readline.createInterface({
130
+ input: process.stdin,
131
+ output: process.stdout,
132
+ });
133
+ return new Promise((resolve) => {
134
+ rl.question(question, (answer) => {
135
+ rl.close();
136
+ resolve(answer.trim().toLowerCase() === "y");
137
+ });
138
+ });
139
+ }
@@ -8,12 +8,22 @@ import {
8
8
  import { createService } from "@valbuild/server";
9
9
  import { glob } from "fast-glob";
10
10
  import path from "path";
11
+ import { evalValConfigFile } from "./utils/evalValConfigFile";
11
12
 
12
13
  export async function listUnusedFiles({ root }: { root?: string }) {
13
- const managedDir = "public/val";
14
14
  const projectRoot = root ? path.resolve(root) : process.cwd();
15
15
 
16
- const service = await createService(projectRoot, {});
16
+ const valConfigFile =
17
+ (await evalValConfigFile(projectRoot, "val.config.ts")) ||
18
+ (await evalValConfigFile(projectRoot, "val.config.js"));
19
+ // Strip the leading "/" so it is relative to the project root (e.g. "public/val").
20
+ const managedDir = (valConfigFile?.files?.directory ?? "/public/val").replace(
21
+ /^\//,
22
+ "",
23
+ );
24
+
25
+ const service = await createService(projectRoot);
26
+ const registered = new Set<ModuleFilePath>(service.getModuleFilePaths());
17
27
 
18
28
  const valFiles: string[] = await glob("**/*.val.{js,ts}", {
19
29
  ignore: ["node_modules/**"],
@@ -23,10 +33,12 @@ export async function listUnusedFiles({ root }: { root?: string }) {
23
33
  const filesUsedByVal: string[] = [];
24
34
  async function pushFilesUsedByVal(file: string) {
25
35
  const moduleId = `/${file}` as ModuleFilePath; // TODO: check if this always works? (Windows?)
36
+ if (!registered.has(moduleId)) {
37
+ // Not registered in val.modules - skip (e.g. reusable schema fragments).
38
+ return;
39
+ }
26
40
  const valModule = await service.get(moduleId, "" as ModulePath, {
27
41
  validate: true,
28
- source: true,
29
- schema: true,
30
42
  });
31
43
  // TODO: not sure using validation is the best way to do this, but it works currently.
32
44
  if (valModule.errors) {
@@ -265,12 +265,12 @@ describe("runValidation", () => {
265
265
  next = await gen.next();
266
266
  }
267
267
 
268
- const service = await createService(tmpDir, {}, createDefaultValFSHost());
268
+ const service = await createService(tmpDir, createDefaultValFSHost());
269
269
  try {
270
270
  const result = await service.get(
271
271
  "/content/basic-gallery-missing-tracked.val.ts" as ModuleFilePath,
272
272
  "" as ModulePath,
273
- { source: true, schema: true, validate: true },
273
+ { validate: true },
274
274
  );
275
275
  expect(result.source).not.toHaveProperty(
276
276
  "/public/val/images4/missing.png",
@@ -322,12 +322,12 @@ describe("runValidation", () => {
322
322
  next = await gen.next();
323
323
  }
324
324
 
325
- const service = await createService(tmpDir, {}, createDefaultValFSHost());
325
+ const service = await createService(tmpDir, createDefaultValFSHost());
326
326
  try {
327
327
  const result = await service.get(
328
328
  "/content/basic-gallery-wrong-metadata.val.ts" as ModuleFilePath,
329
329
  "" as ModulePath,
330
- { source: true, schema: true, validate: true },
330
+ { validate: true },
331
331
  );
332
332
  expect(result.source).toMatchObject({
333
333
  "/public/val/images3/image.png": {
@@ -356,12 +356,12 @@ describe("runValidation", () => {
356
356
  next = await gen.next();
357
357
  }
358
358
 
359
- const service = await createService(tmpDir, {}, createDefaultValFSHost());
359
+ const service = await createService(tmpDir, createDefaultValFSHost());
360
360
  try {
361
361
  const result = await service.get(
362
362
  "/content/basic-image.val.ts" as ModuleFilePath,
363
363
  "" as ModulePath,
364
- { source: true, schema: true, validate: true },
364
+ { validate: true },
365
365
  );
366
366
  // The schema always emits image:check-metadata when metadata exists
367
367
  // (actual metadata verification happens in the fix handler).
@@ -62,6 +62,8 @@ export type ValidationError = {
62
62
  message: string;
63
63
  value?: unknown;
64
64
  fixes?: ValidationFix[];
65
+ // True when the error is about an object/record key rather than its value.
66
+ keyError?: boolean;
65
67
  };
66
68
 
67
69
  export type FixHandlerContext = {
@@ -109,14 +111,26 @@ export type ValidationEvent =
109
111
  errorCount: number;
110
112
  durationMs: number;
111
113
  }
112
- | { type: "validation-error"; sourcePath: string; message: string }
114
+ | {
115
+ type: "validation-error";
116
+ sourcePath: string;
117
+ message: string;
118
+ keyError?: boolean;
119
+ }
113
120
  | {
114
121
  type: "validation-fixable-error";
115
122
  sourcePath: string;
116
123
  message: string;
117
124
  fixable: boolean;
125
+ keyError?: boolean;
118
126
  }
119
- | { type: "unknown-fix"; sourcePath: string; fixes: string[] }
127
+ | {
128
+ type: "unknown-fix";
129
+ sourcePath: string;
130
+ fixes: string[];
131
+ keyError?: boolean;
132
+ }
133
+ | { type: "unregistered-module"; file: string }
120
134
  | { type: "fix-applied"; file: string; sourcePath: string }
121
135
  | { type: "fatal-error"; file: string; message: string }
122
136
  | { type: "remote-uploading"; ref: string }
@@ -335,12 +349,12 @@ export async function handleRemoteFileUpload(
335
349
  const relativeFilePath = path
336
350
  .relative(ctx.projectRoot, filePath)
337
351
  .split(path.sep)
338
- .join("/") as `public/val/${string}`;
352
+ .join("/") as `public/${string}`;
339
353
 
340
- if (!relativeFilePath.startsWith("public/val/")) {
354
+ if (!relativeFilePath.startsWith("public/")) {
341
355
  return {
342
356
  success: false,
343
- errorMessage: `File path must be within the public/val/ directory (e.g. public/val/path/to/file.txt). Got: ${relativeFilePath}`,
357
+ errorMessage: `File path must be within the public/ directory (e.g. public/path/to/file.txt). Got: ${relativeFilePath}`,
344
358
  };
345
359
  }
346
360
 
@@ -443,7 +457,7 @@ export async function handleUniqueFolderCheck(
443
457
  const otherModule = await ctx.service.get(
444
458
  otherModuleFilePath,
445
459
  "" as ModulePath,
446
- { source: false, schema: true, validate: false },
460
+ { validate: false },
447
461
  );
448
462
  const schema = otherModule.schema as
449
463
  | { type?: string; directory?: string; mediaType?: string }
@@ -602,19 +616,21 @@ export async function* runValidation({
602
616
  }): AsyncGenerator<ValidationEvent> {
603
617
  const projectRoot = path.resolve(root);
604
618
 
605
- const service = await createService(projectRoot, {}, fs);
619
+ const service = await createService(projectRoot, fs);
620
+
621
+ // Modules registered in the project's val.modules. Files found on disk that
622
+ // are not registered here are not validated (a warning is emitted instead).
623
+ const registered = new Set<ModuleFilePath>(service.getModuleFilePaths());
606
624
 
607
625
  let errors = 0;
608
626
 
609
627
  // Build a single schema/source snapshot up front so the shared resolver
610
628
  // can resolve keyof:check-keys / router:check-route references that span
611
- // multiple val files.
629
+ // multiple val files. Use the full registry so cross-module references
630
+ // resolve even against modules not in the validated subset.
612
631
  const snapshot: SchemaSourceSnapshot = { schemas: {}, sources: {} };
613
- for (const file of valFiles) {
614
- const moduleFilePath = `/${file}` as ModuleFilePath;
632
+ for (const moduleFilePath of registered) {
615
633
  const valModule = await service.get(moduleFilePath, "" as ModulePath, {
616
- source: true,
617
- schema: true,
618
634
  validate: false,
619
635
  });
620
636
  if (valModule.schema) {
@@ -627,10 +643,12 @@ export async function* runValidation({
627
643
 
628
644
  async function* validateFile(file: string): AsyncGenerator<ValidationEvent> {
629
645
  const moduleFilePath = `/${file}` as ModuleFilePath; // TODO: check if this always works? (Windows?)
646
+ if (!registered.has(moduleFilePath)) {
647
+ yield { type: "unregistered-module", file };
648
+ return;
649
+ }
630
650
  const start = Date.now();
631
651
  const valModule = await service.get(moduleFilePath, "" as ModulePath, {
632
- source: true,
633
- schema: true,
634
652
  validate: true,
635
653
  });
636
654
  const remoteFiles: Record<
@@ -670,6 +688,7 @@ export async function* runValidation({
670
688
  type: "validation-error",
671
689
  sourcePath,
672
690
  message: v.message,
691
+ ...(v.keyError ? { keyError: true } : {}),
673
692
  };
674
693
  continue;
675
694
  }
@@ -683,6 +702,7 @@ export async function* runValidation({
683
702
  type: "unknown-fix",
684
703
  sourcePath,
685
704
  fixes: v.fixes,
705
+ ...(v.keyError ? { keyError: true } : {}),
686
706
  };
687
707
  fileErrors += 1;
688
708
  continue;
@@ -728,6 +748,7 @@ export async function* runValidation({
728
748
  type: "validation-error",
729
749
  sourcePath,
730
750
  message: result.errorMessage ?? "Unknown error",
751
+ ...(v.keyError ? { keyError: true } : {}),
731
752
  };
732
753
  fileErrors += 1;
733
754
  continue;
@@ -760,6 +781,7 @@ export async function* runValidation({
760
781
  sourcePath,
761
782
  message: v.message,
762
783
  fixable: true,
784
+ ...(v.keyError ? { keyError: true } : {}),
763
785
  };
764
786
  }
765
787
 
@@ -767,9 +789,12 @@ export async function* runValidation({
767
789
  fileErrors += 1;
768
790
  yield {
769
791
  type: "validation-fixable-error",
770
- sourcePath,
792
+ // Gallery checks expand into per-entry errors that point at
793
+ // the individual entry; fall back to the record sourcePath.
794
+ sourcePath: e.sourcePath ?? sourcePath,
771
795
  message: e.message,
772
796
  fixable: !!(e.fixes && e.fixes.length),
797
+ ...(e.keyError ? { keyError: true } : {}),
773
798
  };
774
799
  }
775
800
  }
@@ -11,11 +11,19 @@ const ValConfigSchema = z.object({
11
11
  root: z.string().optional(),
12
12
  files: z
13
13
  .object({
14
- directory: z
15
- .string()
16
- .refine((val): val is `/public/val` => val.startsWith("/public/val"), {
17
- message: "files.directory must start with '/public/val'",
18
- }),
14
+ directory: z.string().refine(
15
+ (val): val is `/public` | `/public/${string}` =>
16
+ (val === "/public" ||
17
+ (val.startsWith("/public/") && !val.endsWith("/"))) &&
18
+ // Reject path traversal so the directory cannot escape /public
19
+ !val
20
+ .split("/")
21
+ .some((segment) => segment === "." || segment === ".."),
22
+ {
23
+ message:
24
+ "files.directory must start with '/public', must not end with '/' and must not contain '.' or '..' segments",
25
+ },
26
+ ),
19
27
  })
20
28
  .optional(),
21
29
  gitCommit: z.string().optional(),