@valbuild/cli 0.97.3 → 0.97.4

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
@@ -5,24 +5,39 @@ import pc from 'picocolors';
5
5
  import fs from 'fs/promises';
6
6
  import { glob } from 'fast-glob';
7
7
  import { Internal, FILE_REF_PROP, DEFAULT_VAL_REMOTE_HOST, DEFAULT_CONTENT_HOST, VAL_EXTENSION } from '@valbuild/core';
8
- import { createService, createFixPatch, getPersonalAccessTokenPath, parsePersonalAccessTokenFile, getSettings, uploadRemoteFile } from '@valbuild/server';
8
+ import { createService, createFixPatch, getPersonalAccessTokenPath, parsePersonalAccessTokenFile, getModulePathRange, createModulePathMap, getSettings, uploadRemoteFile, loadValModules, safeReadGit, ValOpsHttp, ValOpsFS, formatPatchSourceError } from '@valbuild/server';
9
9
  import vm from 'node:vm';
10
10
  import ts from 'typescript';
11
11
  import z from 'zod';
12
12
  import { createRequire } from 'node:module';
13
13
  import { resolveSchemaSourceFixes } from '@valbuild/shared/internal';
14
- import nodeFs from 'fs';
14
+ import fs$1 from 'fs';
15
+ import JSZip from 'jszip';
16
+ import readline from 'readline';
15
17
 
16
18
  function error(message) {
17
19
  console.error(chalk.red("❌Error: ") + message);
18
20
  }
21
+ function info(message, opts = {}) {
22
+ if (opts.isCodeSnippet) {
23
+ console.log(chalk.cyanBright("$ > ") + chalk.cyan(message));
24
+ return;
25
+ }
26
+ if (opts.isGood) {
27
+ console.log(chalk.green("✅: ") + message);
28
+ return;
29
+ }
30
+ console.log(chalk.blue("️ℹ️ : ") + message);
31
+ }
19
32
 
20
33
  const ValConfigSchema = z.object({
21
34
  project: z.string().optional(),
22
35
  root: z.string().optional(),
23
36
  files: z.object({
24
- directory: z.string().refine(val => val.startsWith("/public/val"), {
25
- message: "files.directory must start with '/public/val'"
37
+ directory: z.string().refine(val => (val === "/public" || val.startsWith("/public/") && !val.endsWith("/")) &&
38
+ // Reject path traversal so the directory cannot escape /public
39
+ !val.split("/").some(segment => segment === "." || segment === ".."), {
40
+ message: "files.directory must start with '/public', must not end with '/' and must not contain '.' or '..' segments"
26
41
  })
27
42
  }).optional(),
28
43
  gitCommit: z.string().optional(),
@@ -263,10 +278,10 @@ async function handleRemoteFileUpload(ctx) {
263
278
  };
264
279
  }
265
280
  const relativeFilePath = path.relative(ctx.projectRoot, filePath).split(path.sep).join("/");
266
- if (!relativeFilePath.startsWith("public/val/")) {
281
+ if (!relativeFilePath.startsWith("public/")) {
267
282
  return {
268
283
  success: false,
269
- errorMessage: `File path must be within the public/val/ directory (e.g. public/val/path/to/file.txt). Got: ${relativeFilePath}`
284
+ errorMessage: `File path must be within the public/ directory (e.g. public/path/to/file.txt). Got: ${relativeFilePath}`
270
285
  };
271
286
  }
272
287
  const fileHash = Internal.remote.getFileHash(fileBuffer);
@@ -350,8 +365,6 @@ async function handleUniqueFolderCheck(ctx) {
350
365
  const otherModuleFilePath = `/${file}`;
351
366
  if (otherModuleFilePath === ctx.moduleFilePath) continue;
352
367
  const otherModule = await ctx.service.get(otherModuleFilePath, "", {
353
- source: false,
354
- schema: true,
355
368
  validate: false
356
369
  });
357
370
  const schema = otherModule.schema;
@@ -464,15 +477,15 @@ function createDefaultValFSHost() {
464
477
  return {
465
478
  ...ts.sys,
466
479
  writeFile: (fileName, data, encoding) => {
467
- nodeFs.mkdirSync(path.dirname(fileName), {
480
+ fs$1.mkdirSync(path.dirname(fileName), {
468
481
  recursive: true
469
482
  });
470
- nodeFs.writeFileSync(fileName, typeof data === "string" ? data : new Uint8Array(data), encoding);
483
+ fs$1.writeFileSync(fileName, typeof data === "string" ? data : new Uint8Array(data), encoding);
471
484
  },
472
- rmFile: nodeFs.rmSync,
485
+ rmFile: fs$1.rmSync,
473
486
  readBuffer: fileName => {
474
487
  try {
475
- return nodeFs.readFileSync(fileName);
488
+ return fs$1.readFileSync(fileName);
476
489
  } catch {
477
490
  return undefined;
478
491
  }
@@ -488,21 +501,23 @@ async function* runValidation({
488
501
  fs
489
502
  }) {
490
503
  const projectRoot = path.resolve(root);
491
- const service = await createService(projectRoot, {}, fs);
504
+ const service = await createService(projectRoot, fs);
505
+
506
+ // Modules registered in the project's val.modules. Files found on disk that
507
+ // are not registered here are not validated (a warning is emitted instead).
508
+ const registered = new Set(service.getModuleFilePaths());
492
509
  let errors = 0;
493
510
 
494
511
  // Build a single schema/source snapshot up front so the shared resolver
495
512
  // can resolve keyof:check-keys / router:check-route references that span
496
- // multiple val files.
513
+ // multiple val files. Use the full registry so cross-module references
514
+ // resolve even against modules not in the validated subset.
497
515
  const snapshot = {
498
516
  schemas: {},
499
517
  sources: {}
500
518
  };
501
- for (const file of valFiles) {
502
- const moduleFilePath = `/${file}`;
519
+ for (const moduleFilePath of registered) {
503
520
  const valModule = await service.get(moduleFilePath, "", {
504
- source: true,
505
- schema: true,
506
521
  validate: false
507
522
  });
508
523
  if (valModule.schema) {
@@ -514,10 +529,15 @@ async function* runValidation({
514
529
  }
515
530
  async function* validateFile(file) {
516
531
  const moduleFilePath = `/${file}`; // TODO: check if this always works? (Windows?)
532
+ if (!registered.has(moduleFilePath)) {
533
+ yield {
534
+ type: "unregistered-module",
535
+ file
536
+ };
537
+ return;
538
+ }
517
539
  const start = Date.now();
518
540
  const valModule = await service.get(moduleFilePath, "", {
519
- source: true,
520
- schema: true,
521
541
  validate: true
522
542
  });
523
543
  const remoteFiles = {};
@@ -548,7 +568,10 @@ async function* runValidation({
548
568
  yield {
549
569
  type: "validation-error",
550
570
  sourcePath,
551
- message: v.message
571
+ message: v.message,
572
+ ...(v.keyError ? {
573
+ keyError: true
574
+ } : {})
552
575
  };
553
576
  continue;
554
577
  }
@@ -560,7 +583,10 @@ async function* runValidation({
560
583
  yield {
561
584
  type: "unknown-fix",
562
585
  sourcePath,
563
- fixes: v.fixes
586
+ fixes: v.fixes,
587
+ ...(v.keyError ? {
588
+ keyError: true
589
+ } : {})
564
590
  };
565
591
  fileErrors += 1;
566
592
  continue;
@@ -604,7 +630,10 @@ async function* runValidation({
604
630
  yield {
605
631
  type: "validation-error",
606
632
  sourcePath,
607
- message: result.errorMessage ?? "Unknown error"
633
+ message: result.errorMessage ?? "Unknown error",
634
+ ...(v.keyError ? {
635
+ keyError: true
636
+ } : {})
608
637
  };
609
638
  fileErrors += 1;
610
639
  continue;
@@ -630,16 +659,24 @@ async function* runValidation({
630
659
  type: "validation-fixable-error",
631
660
  sourcePath,
632
661
  message: v.message,
633
- fixable: true
662
+ fixable: true,
663
+ ...(v.keyError ? {
664
+ keyError: true
665
+ } : {})
634
666
  };
635
667
  }
636
668
  for (const e of (fixPatch === null || fixPatch === void 0 ? void 0 : fixPatch.remainingErrors) ?? []) {
637
669
  fileErrors += 1;
638
670
  yield {
639
671
  type: "validation-fixable-error",
640
- sourcePath,
672
+ // Gallery checks expand into per-entry errors that point at
673
+ // the individual entry; fall back to the record sourcePath.
674
+ sourcePath: e.sourcePath ?? sourcePath,
641
675
  message: e.message,
642
- fixable: !!(e.fixes && e.fixes.length)
676
+ fixable: !!(e.fixes && e.fixes.length),
677
+ ...(e.keyError ? {
678
+ keyError: true
679
+ } : {})
643
680
  };
644
681
  }
645
682
  }
@@ -695,108 +732,441 @@ async function* runValidation({
695
732
  }
696
733
  }
697
734
 
735
+ /**
736
+ * Resolves a validation `sourcePath` to its individual location parts: the
737
+ * relative file path and the 1-indexed line/column of the offending literal.
738
+ * Returns `undefined` when the location cannot be resolved (the caller should
739
+ * then fall back to the raw `sourcePath`).
740
+ */
741
+ function sourcePathToLocationParts(sourcePath, projectRoot, cache, target = "value") {
742
+ const resolved = resolveRange(sourcePath, projectRoot, cache, target);
743
+ if (!resolved) {
744
+ return undefined;
745
+ }
746
+ // TS line/character are 0-indexed; editors/terminals expect 1-indexed.
747
+ const {
748
+ relativeFile,
749
+ range
750
+ } = resolved;
751
+ return {
752
+ relativeFile,
753
+ line: range.start.line + 1,
754
+ character: range.start.character + 1
755
+ };
756
+ }
757
+
758
+ /**
759
+ * Renders a Rust-style code frame for the offending `sourcePath`: the line
760
+ * above, the offending line, and the line below, with red carets underlining
761
+ * the offending span. Returns `undefined` when the location cannot be resolved
762
+ * (the caller should then skip the frame).
763
+ */
764
+ function sourcePathToCodeFrame(sourcePath, projectRoot, cache, target = "value") {
765
+ const resolved = resolveRange(sourcePath, projectRoot, cache, target);
766
+ if (!resolved) {
767
+ return undefined;
768
+ }
769
+ const {
770
+ lines,
771
+ range
772
+ } = resolved;
773
+ const startLine = range.start.line;
774
+ const firstLine = Math.max(0, startLine - 1);
775
+ const lastLine = Math.min(lines.length - 1, startLine + 1);
776
+ const gutterWidth = String(lastLine + 1).length;
777
+ const out = [];
778
+ for (let i = firstLine; i <= lastLine; i++) {
779
+ const lineText = lines[i] ?? "";
780
+ const gutter = pc.dim(`${String(i + 1).padStart(gutterWidth, " ")} | `);
781
+ out.push(`${gutter}${lineText}`);
782
+ if (i === startLine) {
783
+ const caretStart = range.start.character;
784
+ const sameLine = range.end.line === range.start.line;
785
+ const caretEnd = sameLine ? range.end.character : lineText.length;
786
+ const caretCount = Math.max(1, caretEnd - caretStart);
787
+ const emptyGutter = pc.dim(`${" ".repeat(gutterWidth)} | `);
788
+ out.push(`${emptyGutter}${" ".repeat(caretStart)}${pc.red("^".repeat(caretCount))}`);
789
+ }
790
+ }
791
+ return out.join("\n");
792
+ }
793
+ function resolveRange(sourcePath, projectRoot, cache, target) {
794
+ const [moduleFilePath, modulePath] = Internal.splitModuleFilePathAndModulePath(sourcePath);
795
+ const cached = getCachedFile(moduleFilePath, projectRoot, cache);
796
+ if (!cached || !cached.map) {
797
+ return undefined;
798
+ }
799
+ const range = getModulePathRange(modulePath, cached.map, target);
800
+ if (!range) {
801
+ return undefined;
802
+ }
803
+ return {
804
+ relativeFile: moduleFilePath.replace(/^\//, ""),
805
+ lines: cached.lines,
806
+ // Defensive: a bad range must degrade to an odd-looking frame, never a
807
+ // crash - the caret math below does `" ".repeat(start.character)`, which
808
+ // throws a RangeError on a negative count.
809
+ range: {
810
+ start: clampPosition(range.start),
811
+ end: clampPosition(range.end)
812
+ }
813
+ };
814
+ }
815
+ function clampPosition(position) {
816
+ return {
817
+ line: Math.max(0, position.line),
818
+ character: Math.max(0, position.character)
819
+ };
820
+ }
821
+ function getCachedFile(moduleFilePath, projectRoot, cache) {
822
+ const existing = cache.get(moduleFilePath);
823
+ if (existing !== undefined) {
824
+ return existing;
825
+ }
826
+ const filePath = path.join(projectRoot, moduleFilePath);
827
+ let fileContent;
828
+ try {
829
+ fileContent = fs$1.readFileSync(filePath, "utf-8");
830
+ } catch {
831
+ cache.set(moduleFilePath, null);
832
+ return null;
833
+ }
834
+ const sourceFile = ts.createSourceFile(filePath, fileContent, ts.ScriptTarget.ES2015);
835
+ const entry = {
836
+ lines: fileContent.split(/\r?\n/),
837
+ map: createModulePathMap(sourceFile)
838
+ };
839
+ cache.set(moduleFilePath, entry);
840
+ return entry;
841
+ }
842
+
698
843
  async function validate({
699
844
  root,
700
- fix
845
+ fix,
846
+ watch
701
847
  }) {
702
848
  const projectRoot = root ? path.resolve(root) : process.cwd();
703
- const valConfigFile = (await evalValConfigFile(projectRoot, "val.config.ts")) || (await evalValConfigFile(projectRoot, "val.config.js"));
704
- const resolvedValConfigFile = valConfigFile ? {
705
- ...valConfigFile,
706
- project: process.env.VAL_PROJECT || valConfigFile.project
707
- } : process.env.VAL_PROJECT ? {
708
- project: process.env.VAL_PROJECT
709
- } : undefined;
710
- console.log(pc.greenBright(`Validating project${resolvedValConfigFile !== null && resolvedValConfigFile !== void 0 && resolvedValConfigFile.project ? ` '${pc.inverse(resolvedValConfigFile.project)}'` : ""}...`));
711
- const valFiles = await glob("**/*.val.{js,ts}", {
712
- ignore: ["node_modules/**"],
713
- cwd: projectRoot
714
- });
715
- console.log(pc.greenBright(`Found ${valFiles.length} files...`));
716
849
  let prettier;
717
850
  try {
718
- prettier = (await import('prettier')).default;
851
+ prettier = await import('prettier');
719
852
  } catch {
720
853
  console.log("Prettier not found, skipping formatting");
721
854
  }
722
- const fixedFiles = new Set();
723
- let totalErrors = 0;
724
- for await (const event of runValidation({
725
- root: projectRoot,
726
- fix: !!fix,
727
- valFiles,
728
- project: resolvedValConfigFile === null || resolvedValConfigFile === void 0 ? void 0 : resolvedValConfigFile.project,
729
- remote: {
730
- remoteHost: process.env.VAL_REMOTE_HOST || DEFAULT_VAL_REMOTE_HOST,
731
- getSettings: (projectName, options) => getSettings(projectName, options),
732
- uploadFile: (project, bucket, fileHash, fileExt, fileBuffer, options) => uploadRemoteFile(process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST, project, bucket, fileHash, fileExt ?? "", fileBuffer, options)
733
- },
734
- fs: createDefaultValFSHost()
735
- })) {
736
- switch (event.type) {
737
- case "file-valid":
738
- console.log(pc.green("✔"), event.file, "is valid (" + event.durationMs + "ms)");
739
- break;
740
- case "file-error-count":
741
- console.log(pc.red("✘"), `${event.file} contains ${event.errorCount} error${event.errorCount > 1 ? "s" : ""}`, " (" + event.durationMs + "ms)");
742
- totalErrors += event.errorCount;
743
- break;
744
- case "validation-error":
745
- console.log(pc.red("✘"), "Got error in", `${event.sourcePath}:`, event.message);
746
- break;
747
- case "validation-fixable-error":
748
- console.log(event.fixable ? pc.yellow("⚠") : pc.red("✘"), `Got ${event.fixable ? "fixable " : ""}error in`, `${event.sourcePath}:`, event.message);
749
- break;
750
- case "unknown-fix":
751
- console.log(pc.red("✘"), "Unknown fix", event.fixes, "for", event.sourcePath);
752
- break;
753
- case "fix-applied":
754
- console.log(pc.yellow("⚠"), "Applied fix for", event.sourcePath);
755
- fixedFiles.add(event.file);
756
- break;
757
- case "fatal-error":
758
- console.log(pc.red("✘"), event.file, "is invalid:", event.message);
759
- break;
760
- case "remote-uploading":
761
- console.log(pc.yellow("⚠"), `Uploading remote file: '${event.ref}'...`);
762
- break;
763
- case "remote-uploaded":
764
- console.log(pc.green("✔"), `Completed upload of remote file: '${event.ref}'`);
765
- break;
766
- case "remote-already-uploaded":
767
- console.log(pc.yellow("⚠"), `Remote file ${event.filePath} already uploaded`);
768
- break;
769
- case "remote-downloading":
770
- console.log(pc.yellow("⚠"), `Downloading remote file in ${event.sourcePath}...`);
771
- break;
855
+
856
+ // Runs a single validation pass over the project and returns the number of
857
+ // errors found. Re-reads config and val files each call so it always reflects
858
+ // the latest state on disk (used both for one-shot and watch mode).
859
+ async function runOnce() {
860
+ const valConfigFile = (await evalValConfigFile(projectRoot, "val.config.ts")) || (await evalValConfigFile(projectRoot, "val.config.js"));
861
+ const resolvedValConfigFile = valConfigFile ? {
862
+ ...valConfigFile,
863
+ project: process.env.VAL_PROJECT || valConfigFile.project
864
+ } : process.env.VAL_PROJECT ? {
865
+ project: process.env.VAL_PROJECT
866
+ } : undefined;
867
+ console.log(pc.greenBright(`Validating project${resolvedValConfigFile !== null && resolvedValConfigFile !== void 0 && resolvedValConfigFile.project ? ` '${pc.inverse(resolvedValConfigFile.project)}'` : ""}...`));
868
+ const valFiles = await glob("**/*.val.{js,ts}", {
869
+ ignore: ["node_modules/**"],
870
+ cwd: projectRoot
871
+ });
872
+ console.log(pc.greenBright(`Found ${valFiles.length} files...`));
873
+ const fixedFiles = new Set();
874
+ let totalErrors = 0;
875
+
876
+ // Caches each val file's parsed source so files are read/parsed at most once
877
+ // per pass when resolving sourcePaths to file locations and code frames.
878
+ const sourceFileCache = new Map();
879
+
880
+ // Diagnostics are buffered per module (keyed by relative file path) so we can
881
+ // render them grouped and prioritised after the run, rather than streaming
882
+ // them out interleaved. Transient progress (remote/fix-applied) still streams
883
+ // live below.
884
+ const reports = new Map();
885
+ const valid = [];
886
+ const skipped = [];
887
+
888
+ // Relative file path (no leading slash), matching the code frame's
889
+ // relativeFile so headers, diagnostics and frames all agree.
890
+ const relFile = file => file.replace(/^\//, "");
891
+ // The module a sourcePath/file belongs to is the part before the `?p=...`.
892
+ const moduleOf = sourcePathOrFile => relFile(sourcePathOrFile.split("?")[0]);
893
+ const reportFor = file => {
894
+ const key = relFile(file);
895
+ let report = reports.get(key);
896
+ if (!report) {
897
+ report = {
898
+ file: key,
899
+ durationMs: 0,
900
+ diagnostics: []
901
+ };
902
+ reports.set(key, report);
903
+ }
904
+ return report;
905
+ };
906
+ for await (const event of runValidation({
907
+ root: projectRoot,
908
+ fix: !!fix,
909
+ valFiles,
910
+ project: resolvedValConfigFile === null || resolvedValConfigFile === void 0 ? void 0 : resolvedValConfigFile.project,
911
+ remote: {
912
+ remoteHost: process.env.VAL_REMOTE_HOST || DEFAULT_VAL_REMOTE_HOST,
913
+ getSettings: (projectName, options) => getSettings(projectName, options),
914
+ uploadFile: (project, bucket, fileHash, fileExt, fileBuffer, options) => uploadRemoteFile(process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST, project, bucket, fileHash, fileExt ?? "", fileBuffer, options)
915
+ },
916
+ fs: createDefaultValFSHost()
917
+ })) {
918
+ switch (event.type) {
919
+ case "file-valid":
920
+ valid.push({
921
+ file: relFile(event.file),
922
+ durationMs: event.durationMs
923
+ });
924
+ break;
925
+ case "file-error-count":
926
+ reportFor(event.file).durationMs = event.durationMs;
927
+ totalErrors += event.errorCount;
928
+ break;
929
+ case "validation-error":
930
+ reportFor(moduleOf(event.sourcePath)).diagnostics.push({
931
+ severity: "error",
932
+ sourcePath: event.sourcePath,
933
+ message: event.message,
934
+ ...(event.keyError ? {
935
+ keyError: true
936
+ } : {})
937
+ });
938
+ break;
939
+ case "validation-fixable-error":
940
+ reportFor(moduleOf(event.sourcePath)).diagnostics.push({
941
+ severity: event.fixable ? "fixable" : "error",
942
+ sourcePath: event.sourcePath,
943
+ message: event.message,
944
+ ...(event.keyError ? {
945
+ keyError: true
946
+ } : {})
947
+ });
948
+ break;
949
+ case "unknown-fix":
950
+ reportFor(moduleOf(event.sourcePath)).diagnostics.push({
951
+ severity: "error",
952
+ sourcePath: event.sourcePath,
953
+ message: `Unknown fix: ${event.fixes.join(", ")}`,
954
+ ...(event.keyError ? {
955
+ keyError: true
956
+ } : {})
957
+ });
958
+ break;
959
+ case "unregistered-module":
960
+ skipped.push(event.file);
961
+ break;
962
+ case "fatal-error":
963
+ // No sourcePath for fatal errors; group by file, render message only.
964
+ reportFor(event.file).diagnostics.push({
965
+ severity: "error",
966
+ sourcePath: event.file,
967
+ message: event.message
968
+ });
969
+ break;
970
+ case "fix-applied":
971
+ console.log(pc.yellow("⚠"), "Applied fix for", event.sourcePath);
972
+ fixedFiles.add(event.file);
973
+ break;
974
+ case "remote-uploading":
975
+ console.log(pc.yellow("⚠"), `Uploading remote file: '${event.ref}'...`);
976
+ break;
977
+ case "remote-uploaded":
978
+ console.log(pc.green("✔"), `Completed upload of remote file: '${event.ref}'`);
979
+ break;
980
+ case "remote-already-uploaded":
981
+ console.log(pc.yellow("⚠"), `Remote file ${event.filePath} already uploaded`);
982
+ break;
983
+ case "remote-downloading":
984
+ console.log(pc.yellow("⚠"), `Downloading remote file in ${event.sourcePath}...`);
985
+ break;
986
+ }
772
987
  }
773
- }
774
988
 
775
- // Run prettier on files that had fixes applied
776
- if (prettier) {
777
- for (const file of fixedFiles) {
778
- const filePath = path.join(projectRoot, file);
779
- const fileContent = await fs.readFile(filePath, "utf-8");
780
- const formattedContent = await prettier.format(fileContent, {
781
- filepath: filePath
782
- });
783
- await fs.writeFile(filePath, formattedContent);
989
+ // Renders a module's diagnostics under a single left "│" gutter bar, with the
990
+ // file name on top and blank gutter lines for air. Output flows least- to
991
+ // most-actionable top-to-bottom, so fixable diagnostics are shown last (at the
992
+ // bottom, nearest the prompt).
993
+ const renderModule = report => {
994
+ const bar = pc.dim("│");
995
+ const diagnostics = [...report.diagnostics].sort((a, b) => a.severity === b.severity ? 0 : a.severity === "fixable" ? 1 : -1);
996
+ const fixableCount = diagnostics.filter(d => d.severity === "fixable").length;
997
+ const total = diagnostics.length;
998
+ const hasError = fixableCount < total;
999
+ const symbol = hasError ? pc.red("✘") : pc.yellow("⚠");
1000
+ let label;
1001
+ if (fixableCount === total) {
1002
+ label = `${fixableCount} fixable`;
1003
+ } else if (fixableCount > 0) {
1004
+ label = `${total} error${total > 1 ? "s" : ""} (${fixableCount} fixable)`;
1005
+ } else {
1006
+ label = `${total} error${total > 1 ? "s" : ""}`;
1007
+ }
1008
+ console.log(`${pc.bold(report.file)} ${symbol} ${label} ${pc.dim(`(${report.durationMs}ms)`)}`);
1009
+ for (const d of diagnostics) {
1010
+ const target = d.keyError ? "key" : "value";
1011
+ const dsym = d.severity === "fixable" ? pc.yellow("⚠") : pc.red("✘");
1012
+ const parts = sourcePathToLocationParts(d.sourcePath, projectRoot, sourceFileCache, target);
1013
+ console.log(bar);
1014
+ if (parts) {
1015
+ // `file:line:col` (no key/value label) so VS Code's terminal links it.
1016
+ console.log(`${bar} ${dsym} ${parts.relativeFile}:${parts.line}:${parts.character}`);
1017
+ console.log(`${bar} ${d.message}`);
1018
+ } else {
1019
+ console.log(`${bar} ${dsym} ${d.message}`);
1020
+ }
1021
+ const frame = sourcePathToCodeFrame(d.sourcePath, projectRoot, sourceFileCache, target);
1022
+ if (frame !== undefined) {
1023
+ console.log(bar);
1024
+ for (const frameLine of frame.split("\n")) {
1025
+ console.log(`${bar} ${frameLine}`);
1026
+ }
1027
+ }
1028
+ if (d.severity === "fixable") {
1029
+ console.log(`${bar} ${pc.dim("→ run with --fix to apply")}`);
1030
+ }
1031
+ }
1032
+ console.log(bar);
1033
+ console.log("");
1034
+ };
1035
+
1036
+ // Run prettier on files that had fixes applied
1037
+ if (prettier) {
1038
+ for (const file of fixedFiles) {
1039
+ const filePath = path.join(projectRoot, file);
1040
+ const fileContent = await fs.readFile(filePath, "utf-8");
1041
+ const formattedContent = await prettier.format(fileContent, {
1042
+ filepath: filePath
1043
+ });
1044
+ await fs.writeFile(filePath, formattedContent);
1045
+ }
1046
+ }
1047
+
1048
+ // Render the grouped report least- to most-actionable, top-to-bottom, so the
1049
+ // most important things end up at the bottom nearest the prompt: valid files
1050
+ // and skipped modules first, then error-only modules, then fixable modules.
1051
+ const allReports = [...reports.values()];
1052
+ const fixableModules = allReports.filter(r => r.diagnostics.some(d => d.severity === "fixable"));
1053
+ const errorModules = allReports.filter(r => !r.diagnostics.some(d => d.severity === "fixable"));
1054
+ for (const v of valid) {
1055
+ console.log(pc.green("✔"), pc.dim(`${v.file} valid (${v.durationMs}ms)`));
1056
+ }
1057
+ for (const file of skipped) {
1058
+ console.log(pc.yellow("⚠"), pc.dim(`/${file} is not registered in val.modules - skipping`));
1059
+ }
1060
+ if (allReports.length > 0) {
1061
+ console.log("");
784
1062
  }
1063
+ for (const report of [...errorModules, ...fixableModules]) {
1064
+ renderModule(report);
1065
+ }
1066
+ const fixableTotal = allReports.reduce((n, r) => n + r.diagnostics.filter(d => d.severity === "fixable").length, 0);
1067
+ if (totalErrors > 0) {
1068
+ let summary = `${totalErrors} error${totalErrors > 1 ? "s" : ""}`;
1069
+ if (fixableTotal > 0) {
1070
+ summary += ` (${fixableTotal} fixable)`;
1071
+ }
1072
+ summary += ` across ${allReports.length} file${allReports.length > 1 ? "s" : ""}`;
1073
+ if (valid.length > 0) {
1074
+ summary += ` · ${valid.length} valid`;
1075
+ }
1076
+ if (skipped.length > 0) {
1077
+ summary += ` · ${skipped.length} skipped`;
1078
+ }
1079
+ console.log(pc.red("✘"), summary);
1080
+ } else {
1081
+ let summary = "No validation errors found";
1082
+ if (valid.length > 0) {
1083
+ summary += ` · ${valid.length} valid`;
1084
+ }
1085
+ if (skipped.length > 0) {
1086
+ summary += ` · ${skipped.length} skipped`;
1087
+ }
1088
+ console.log(pc.green("✔"), summary);
1089
+ }
1090
+ return totalErrors;
785
1091
  }
786
- if (totalErrors > 0) {
787
- console.log(pc.red("✘"), "Got", totalErrors, "error" + (totalErrors > 1 ? "s" : ""));
788
- process.exit(1);
789
- } else {
790
- console.log(pc.green("✔"), "No validation errors found");
1092
+ if (!watch) {
1093
+ const totalErrors = await runOnce();
1094
+ if (totalErrors > 0) {
1095
+ process.exit(1);
1096
+ }
1097
+ return;
791
1098
  }
1099
+ await watchAndValidate(projectRoot, runOnce);
1100
+ }
1101
+
1102
+ // Directory names anywhere in the path that should never be watched.
1103
+ const WATCH_IGNORED = /(^|[\\/])(node_modules|\.git|dist)([\\/]|$)/;
1104
+ function isRelevantValFile(filePath) {
1105
+ const base = path.basename(filePath);
1106
+ return base === "val.modules.ts" || base === "val.modules.js" || base === "val.config.ts" || base === "val.config.js" || /\.val\.(ts|js)$/.test(base);
1107
+ }
1108
+ async function watchAndValidate(projectRoot, runOnce) {
1109
+ // Initial pass.
1110
+ await runOnce();
1111
+ const watchingMessage = pc.dim("Watching for changes... (Ctrl+C to exit)");
1112
+ console.log(watchingMessage);
1113
+
1114
+ // chokidar 5 is ESM-only; load it dynamically (like prettier above).
1115
+ const {
1116
+ watch
1117
+ } = await import('chokidar');
1118
+ let running = false;
1119
+ let pending = false;
1120
+ let debounceTimer;
1121
+ const triggerRun = async () => {
1122
+ if (running) {
1123
+ pending = true;
1124
+ return;
1125
+ }
1126
+ running = true;
1127
+ // Clear the screen (and scrollback) so only the latest result shows.
1128
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
1129
+ console.log(pc.cyanBright("Re-validating..."));
1130
+ try {
1131
+ await runOnce();
1132
+ } catch (err) {
1133
+ console.error(err);
1134
+ }
1135
+ console.log(watchingMessage);
1136
+ running = false;
1137
+ if (pending) {
1138
+ pending = false;
1139
+ void triggerRun();
1140
+ }
1141
+ };
1142
+ const watcher = watch(projectRoot, {
1143
+ ignoreInitial: true,
1144
+ ignored: watchedPath => WATCH_IGNORED.test(watchedPath)
1145
+ });
1146
+ watcher.on("all", (_event, changedPath) => {
1147
+ if (!isRelevantValFile(changedPath)) {
1148
+ return;
1149
+ }
1150
+ if (debounceTimer) {
1151
+ clearTimeout(debounceTimer);
1152
+ }
1153
+ debounceTimer = setTimeout(() => void triggerRun(), 150);
1154
+ });
1155
+ process.on("SIGINT", () => {
1156
+ void watcher.close().then(() => process.exit(0));
1157
+ });
792
1158
  }
793
1159
 
794
1160
  async function listUnusedFiles({
795
1161
  root
796
1162
  }) {
797
- const managedDir = "public/val";
1163
+ var _valConfigFile$files;
798
1164
  const projectRoot = root ? path.resolve(root) : process.cwd();
799
- const service = await createService(projectRoot, {});
1165
+ const valConfigFile = (await evalValConfigFile(projectRoot, "val.config.ts")) || (await evalValConfigFile(projectRoot, "val.config.js"));
1166
+ // Strip the leading "/" so it is relative to the project root (e.g. "public/val").
1167
+ const managedDir = ((valConfigFile === null || valConfigFile === void 0 || (_valConfigFile$files = valConfigFile.files) === null || _valConfigFile$files === void 0 ? void 0 : _valConfigFile$files.directory) ?? "/public/val").replace(/^\//, "");
1168
+ const service = await createService(projectRoot);
1169
+ const registered = new Set(service.getModuleFilePaths());
800
1170
  const valFiles = await glob("**/*.val.{js,ts}", {
801
1171
  ignore: ["node_modules/**"],
802
1172
  cwd: projectRoot
@@ -804,10 +1174,12 @@ async function listUnusedFiles({
804
1174
  const filesUsedByVal = [];
805
1175
  async function pushFilesUsedByVal(file) {
806
1176
  const moduleId = `/${file}`; // TODO: check if this always works? (Windows?)
1177
+ if (!registered.has(moduleId)) {
1178
+ // Not registered in val.modules - skip (e.g. reusable schema fragments).
1179
+ return;
1180
+ }
807
1181
  const valModule = await service.get(moduleId, "", {
808
- validate: true,
809
- source: true,
810
- schema: true
1182
+ validate: true
811
1183
  });
812
1184
  // TODO: not sure using validation is the best way to do this, but it works currently.
813
1185
  if (valModule.errors) {
@@ -954,8 +1326,8 @@ function tryGetGitConfig(root) {
954
1326
  let lastDir = null;
955
1327
  while (currentDir !== lastDir) {
956
1328
  const gitConfigPath = path.join(currentDir, ".git", "config");
957
- if (nodeFs.existsSync(gitConfigPath)) {
958
- return nodeFs.readFileSync(gitConfigPath, "utf-8");
1329
+ if (fs$1.existsSync(gitConfigPath)) {
1330
+ return fs$1.readFileSync(gitConfigPath, "utf-8");
959
1331
  }
960
1332
  lastDir = currentDir;
961
1333
  currentDir = path.dirname(currentDir);
@@ -1038,13 +1410,909 @@ async function pollForConfirmation(token) {
1038
1410
  process.exit(1);
1039
1411
  }
1040
1412
  function saveToken(result, filePath) {
1041
- nodeFs.mkdirSync(path.dirname(filePath), {
1413
+ fs$1.mkdirSync(path.dirname(filePath), {
1042
1414
  recursive: true
1043
1415
  });
1044
- nodeFs.writeFileSync(filePath, JSON.stringify(result, null, 2));
1416
+ fs$1.writeFileSync(filePath, JSON.stringify(result, null, 2));
1045
1417
  console.log(pc.green(`Token for ${pc.cyan(result.profile.email)} saved to ${pc.cyan(filePath)}`));
1046
1418
  }
1047
1419
 
1420
+ /**
1421
+ * Everything the debug commands need in order to talk to the same ops the
1422
+ * running app talks to.
1423
+ *
1424
+ * Mirrors how the app itself decides between fs and http mode in
1425
+ * `initHandlerOptions` (packages/server/src/ValRouter.ts): a project name plus
1426
+ * credentials means the patches live in the hosted content service, otherwise
1427
+ * they are on disk under `<root>/.val`.
1428
+ */
1429
+
1430
+ class DebugContextError extends Error {}
1431
+ async function createDebugContext(options) {
1432
+ var _config$files;
1433
+ const projectRoot = options.root ? path.resolve(options.root) : process.cwd();
1434
+ if (!fs$1.existsSync(projectRoot)) {
1435
+ throw new DebugContextError(`Project root does not exist: ${projectRoot}`);
1436
+ }
1437
+ const config = (await evalValConfigFile(projectRoot, "val.config.ts")) || (await evalValConfigFile(projectRoot, "val.config.js"));
1438
+ if (!config) {
1439
+ throw new DebugContextError(`Could not find val.config.ts nor val.config.js in: ${projectRoot}`);
1440
+ }
1441
+ const valModules = loadValModules(projectRoot);
1442
+ const contentUrl = process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST;
1443
+ const filesDirectory = ((_config$files = config.files) === null || _config$files === void 0 ? void 0 : _config$files.directory) ?? "/public/val";
1444
+ const project = config.project || process.env.VAL_PROJECT || null;
1445
+ const git = await safeReadGit(projectRoot);
1446
+ const branch = options.branch || config.gitBranch || process.env.VAL_GIT_BRANCH || git.branch || null;
1447
+ const commit = options.commit || config.gitCommit || process.env.VAL_GIT_COMMIT || git.commit || null;
1448
+ const wantsRemote = options.remote || !!process.env.VAL_API_KEY;
1449
+ const auth = wantsRemote ? readAuth(projectRoot) : null;
1450
+ if (wantsRemote) {
1451
+ if (!project) {
1452
+ throw new DebugContextError("Cannot read remote patches: no project is configured.\n" + "Set 'project' in val.config, or the VAL_PROJECT env var.");
1453
+ }
1454
+ if (!auth) {
1455
+ throw new DebugContextError("Cannot read remote patches: you are not logged in.\n\n\tnpx val login\n\n" + "(or set the VAL_API_KEY env var)");
1456
+ }
1457
+ if (!branch) {
1458
+ throw new DebugContextError("Could not determine the branch. Pass --branch, or set VAL_GIT_BRANCH.");
1459
+ }
1460
+ if (!commit) {
1461
+ throw new DebugContextError("Could not determine the commit. Pass --commit, or set VAL_GIT_COMMIT.\n" + "This must be the commit the app was deployed from: it is the commit the module sources are read at.");
1462
+ }
1463
+ return {
1464
+ projectRoot,
1465
+ valModules,
1466
+ config,
1467
+ mode: "http",
1468
+ project,
1469
+ branch,
1470
+ commit,
1471
+ authKind: "pat" in auth ? "pat" : "api-key",
1472
+ contentUrl,
1473
+ filesDirectory,
1474
+ serverOps: new ValOpsHttp(contentUrl, project, commit, branch, auth, valModules, {
1475
+ root: config.root,
1476
+ config
1477
+ })
1478
+ };
1479
+ }
1480
+ return {
1481
+ projectRoot,
1482
+ valModules,
1483
+ config,
1484
+ mode: "fs",
1485
+ project,
1486
+ branch,
1487
+ commit,
1488
+ authKind: "none",
1489
+ contentUrl,
1490
+ filesDirectory,
1491
+ serverOps: new ValOpsFS(contentUrl, projectRoot, valModules, {
1492
+ config
1493
+ })
1494
+ };
1495
+ }
1496
+
1497
+ /**
1498
+ * The personal access token written by `val login`, falling back to the api key
1499
+ * the app itself uses. Reading the pat file is the same dance as
1500
+ * `resolveRemoteFile` in runValidation.ts.
1501
+ */
1502
+ function readAuth(projectRoot) {
1503
+ const patFile = getPersonalAccessTokenPath(projectRoot);
1504
+ if (fs$1.existsSync(patFile)) {
1505
+ const contents = fs$1.readFileSync(patFile, "utf-8");
1506
+ const parsed = parsePersonalAccessTokenFile(contents);
1507
+ if (parsed.success) {
1508
+ return {
1509
+ pat: parsed.data.pat
1510
+ };
1511
+ }
1512
+ throw new DebugContextError(`Could not parse the personal access token at ${patFile}: ${parsed.error}.\n` + `Log in again:\n\n\tnpx val login`);
1513
+ }
1514
+ const apiKey = process.env.VAL_API_KEY;
1515
+ if (apiKey) {
1516
+ return {
1517
+ apiKey
1518
+ };
1519
+ }
1520
+ return null;
1521
+ }
1522
+
1523
+ /**
1524
+ * The set of modules a snapshot has to carry so that it both evaluates and
1525
+ * validates the same way the customer's project does.
1526
+ *
1527
+ * Starts from the modules the pending patches touch and closes over the
1528
+ * cross-module references a schema can hold:
1529
+ * - `keyOf` points at another module through its `path` (a SourcePath)
1530
+ * - `image`/`file` point at a gallery module through `referencedModule`
1531
+ * - route validation cross-references *every* router module, so if any
1532
+ * included module has a route or router we need all of them
1533
+ */
1534
+ function resolveModuleClosure(patchedModules, serializedSchemas) {
1535
+ const included = new Map();
1536
+ const addReason = (moduleFilePath, reason) => {
1537
+ const existing = included.get(moduleFilePath);
1538
+ if (existing) {
1539
+ existing.push(reason);
1540
+ return false;
1541
+ }
1542
+ included.set(moduleFilePath, [reason]);
1543
+ return true;
1544
+ };
1545
+ const queue = [];
1546
+ for (const moduleFilePath of patchedModules) {
1547
+ if (addReason(moduleFilePath, {
1548
+ type: "patched"
1549
+ })) {
1550
+ queue.push(moduleFilePath);
1551
+ }
1552
+ }
1553
+ let needsAllRouterModules = false;
1554
+ while (queue.length > 0) {
1555
+ const moduleFilePath = queue.shift();
1556
+ if (moduleFilePath === undefined) {
1557
+ continue;
1558
+ }
1559
+ const schema = serializedSchemas[moduleFilePath];
1560
+ if (!schema) {
1561
+ continue;
1562
+ }
1563
+ const refs = collectSchemaReferences(schema);
1564
+ if (refs.hasRouteOrRouter) {
1565
+ needsAllRouterModules = true;
1566
+ }
1567
+ for (const keyOfPath of refs.keyOfSourcePaths) {
1568
+ const [referenced] = Internal.splitModuleFilePathAndModulePath(keyOfPath);
1569
+ if (addReason(referenced, {
1570
+ type: "keyOf",
1571
+ from: moduleFilePath
1572
+ })) {
1573
+ queue.push(referenced);
1574
+ }
1575
+ }
1576
+ for (const referencedModule of refs.referencedModules) {
1577
+ // referencedModule is a module file path written in the schema, so it is
1578
+ // only trustworthy insofar as it names a module we know about.
1579
+ const referenced = Object.keys(serializedSchemas).find(candidate => candidate === referencedModule);
1580
+ if (referenced === undefined) {
1581
+ continue;
1582
+ }
1583
+ if (addReason(referenced, {
1584
+ type: "referencedModule",
1585
+ from: moduleFilePath
1586
+ })) {
1587
+ queue.push(referenced);
1588
+ }
1589
+ }
1590
+ }
1591
+ if (needsAllRouterModules) {
1592
+ for (const [moduleFilePathS, schema] of Object.entries(serializedSchemas)) {
1593
+ if (collectSchemaReferences(schema).isRouterModule) {
1594
+ addReason(moduleFilePathS, {
1595
+ type: "router"
1596
+ });
1597
+ }
1598
+ }
1599
+ }
1600
+ return included;
1601
+ }
1602
+ function collectSchemaReferences(schema) {
1603
+ const refs = {
1604
+ keyOfSourcePaths: [],
1605
+ referencedModules: [],
1606
+ hasRouteOrRouter: false,
1607
+ isRouterModule: false
1608
+ };
1609
+ const visit = (node, isRoot) => {
1610
+ switch (node.type) {
1611
+ case "keyOf":
1612
+ refs.keyOfSourcePaths.push(node.path);
1613
+ break;
1614
+ case "route":
1615
+ refs.hasRouteOrRouter = true;
1616
+ break;
1617
+ case "file":
1618
+ case "image":
1619
+ if (node.referencedModule) {
1620
+ refs.referencedModules.push(node.referencedModule);
1621
+ }
1622
+ break;
1623
+ case "record":
1624
+ if (node.router) {
1625
+ refs.hasRouteOrRouter = true;
1626
+ if (isRoot) {
1627
+ refs.isRouterModule = true;
1628
+ }
1629
+ }
1630
+ visit(node.item, false);
1631
+ if (node.key) {
1632
+ visit(node.key, false);
1633
+ }
1634
+ if (node.alt) {
1635
+ visit(node.alt, false);
1636
+ }
1637
+ break;
1638
+ case "array":
1639
+ visit(node.item, false);
1640
+ break;
1641
+ case "object":
1642
+ for (const item of Object.values(node.items)) {
1643
+ visit(item, false);
1644
+ }
1645
+ break;
1646
+ case "union":
1647
+ if (typeof node.key !== "string") {
1648
+ visit(node.key, false);
1649
+ }
1650
+ for (const item of node.items) {
1651
+ visit(item, false);
1652
+ }
1653
+ break;
1654
+ }
1655
+ };
1656
+ visit(schema, true);
1657
+ return refs;
1658
+ }
1659
+
1660
+ const RESOLVE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".cjs", ".mjs"];
1661
+ /**
1662
+ * Walks the relative-import graph of `entryFiles` and reads every project file
1663
+ * it reaches, using `readFile` so the contents come from the same revision as
1664
+ * the modules themselves (the deployed commit in http mode).
1665
+ *
1666
+ * `loadValModules` evaluates val.modules.ts and everything it imports in a vm,
1667
+ * so a snapshot that is missing an imported file (a shared schema fragment, or
1668
+ * val.config itself) cannot be replayed at all.
1669
+ */
1670
+ async function collectImportedProjectFiles(entryFiles, readFile) {
1671
+ const files = {};
1672
+ const unresolved = [];
1673
+ const seen = new Set();
1674
+ const queue = [];
1675
+ for (const entry of entryFiles) {
1676
+ seen.add(entry.path);
1677
+ queue.push(entry);
1678
+ }
1679
+ while (queue.length > 0) {
1680
+ const current = queue.shift();
1681
+ if (current === undefined) {
1682
+ continue;
1683
+ }
1684
+ const dir = path.posix.dirname(current.path);
1685
+ for (const specifier of readImportSpecifiers(current.path, current.contents)) {
1686
+ if (!specifier.startsWith(".")) {
1687
+ unresolved.push({
1688
+ from: current.path,
1689
+ specifier
1690
+ });
1691
+ continue;
1692
+ }
1693
+ const base = path.posix.resolve(dir, specifier);
1694
+ const resolved = await resolveProjectFile(base, readFile);
1695
+ if (!resolved) {
1696
+ unresolved.push({
1697
+ from: current.path,
1698
+ specifier
1699
+ });
1700
+ continue;
1701
+ }
1702
+ if (seen.has(resolved.path)) {
1703
+ continue;
1704
+ }
1705
+ seen.add(resolved.path);
1706
+ files[resolved.path] = resolved.contents;
1707
+ queue.push(resolved);
1708
+ }
1709
+ }
1710
+ return {
1711
+ files,
1712
+ unresolved
1713
+ };
1714
+ }
1715
+ async function resolveProjectFile(base, readFile) {
1716
+ const candidates = [base, ...RESOLVE_EXTENSIONS.map(ext => base + ext), ...RESOLVE_EXTENSIONS.map(ext => path.posix.join(base, "index" + ext))];
1717
+ for (const candidate of candidates) {
1718
+ const contents = await readFile(candidate);
1719
+ if (contents !== null) {
1720
+ return {
1721
+ path: candidate,
1722
+ contents
1723
+ };
1724
+ }
1725
+ }
1726
+ return null;
1727
+ }
1728
+
1729
+ /**
1730
+ * Every module specifier in the file: static imports/exports and the dynamic
1731
+ * `import()` calls val.modules.ts is built out of.
1732
+ */
1733
+ function readImportSpecifiers(filePath, contents) {
1734
+ const sourceFile = ts.createSourceFile(filePath, contents, ts.ScriptTarget.ES2020, true);
1735
+ const specifiers = [];
1736
+ const visit = node => {
1737
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
1738
+ specifiers.push(node.moduleSpecifier.text);
1739
+ } else if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require") && node.arguments.length > 0) {
1740
+ const arg = node.arguments[0];
1741
+ if (ts.isStringLiteral(arg)) {
1742
+ specifiers.push(arg.text);
1743
+ }
1744
+ }
1745
+ ts.forEachChild(node, visit);
1746
+ };
1747
+ visit(sourceFile);
1748
+ return specifiers;
1749
+ }
1750
+
1751
+ /** Long strings (base64 payloads) are elided so a snapshot stays attachable. */
1752
+ const MAX_PATCH_STRING_LENGTH = 4096;
1753
+ async function buildSnapshot(ctx, options = {}) {
1754
+ const {
1755
+ serverOps
1756
+ } = ctx;
1757
+ const patchesRes = await serverOps.fetchPatches({
1758
+ patchIds: undefined,
1759
+ excludePatchOps: false
1760
+ });
1761
+ if (patchesRes.error) {
1762
+ throw new Error(`Could not fetch patches: ${patchesRes.error.message}`);
1763
+ }
1764
+ const orderedPatches = patchesRes.patches;
1765
+ const analysis = {
1766
+ ...serverOps.analyzePatches(orderedPatches),
1767
+ ...patchesRes
1768
+ };
1769
+ const prepared = await serverOps.prepare(analysis, {
1770
+ continueOnError: true
1771
+ });
1772
+ const serializedSchemas = await serverOps.getSerializedSchemas();
1773
+ const patchedModules = Object.keys(analysis.patchesByModule).map(moduleFilePathS => moduleFilePathS);
1774
+ const closure = resolveModuleClosure(patchedModules, serializedSchemas);
1775
+
1776
+ // Read every included module at the revision the ops point at. prepare()
1777
+ // already read the patched ones, so reuse those rather than fetching twice.
1778
+ const moduleTexts = {};
1779
+ const moduleProvenance = {};
1780
+ for (const moduleFilePath of closure.keys()) {
1781
+ const fromPrepare = prepared.previousSourceFiles[moduleFilePath];
1782
+ if (fromPrepare !== undefined) {
1783
+ moduleTexts[moduleFilePath] = fromPrepare;
1784
+ moduleProvenance[moduleFilePath] = "ops";
1785
+ continue;
1786
+ }
1787
+ const res = await serverOps.readProjectFile(moduleFilePath);
1788
+ if (res.error) {
1789
+ const local = readLocalFile(ctx.projectRoot, moduleFilePath);
1790
+ if (local !== null) {
1791
+ moduleTexts[moduleFilePath] = local;
1792
+ moduleProvenance[moduleFilePath] = "local";
1793
+ } else {
1794
+ moduleProvenance[moduleFilePath] = "missing";
1795
+ console.warn(`Could not read module ${moduleFilePath}: ${res.error.message}`);
1796
+ }
1797
+ continue;
1798
+ }
1799
+ moduleTexts[moduleFilePath] = res.data;
1800
+ moduleProvenance[moduleFilePath] = "ops";
1801
+ }
1802
+
1803
+ // Files the modules import, at the same revision, falling back to local disk.
1804
+ const readProjectFileOrLocal = async projectRelativePath => {
1805
+ const res = await serverOps.readProjectFile(projectRelativePath);
1806
+ if (!res.error) {
1807
+ return res.data;
1808
+ }
1809
+ return readLocalFile(ctx.projectRoot, projectRelativePath);
1810
+ };
1811
+ const imported = await collectImportedProjectFiles(Object.entries(moduleTexts).map(([p, contents]) => ({
1812
+ path: p,
1813
+ contents
1814
+ })), readProjectFileOrLocal);
1815
+ const entries = {};
1816
+ for (const [projectPath, contents] of Object.entries(moduleTexts)) {
1817
+ entries[toSnapshotPath(projectPath)] = contents;
1818
+ }
1819
+ for (const [projectPath, contents] of Object.entries(imported.files)) {
1820
+ entries[toSnapshotPath(projectPath)] = contents;
1821
+ }
1822
+
1823
+ // getCompilerOptions() throws without one of these at the root, so a snapshot
1824
+ // without it cannot be loaded at all.
1825
+ const tsConfig = readLocalFile(ctx.projectRoot, "/tsconfig.json") ?? readLocalFile(ctx.projectRoot, "/jsconfig.json");
1826
+ if (tsConfig === null) {
1827
+ throw new Error(`Could not read tsconfig.json nor jsconfig.json in ${ctx.projectRoot}. ` + `A snapshot cannot be replayed without one.`);
1828
+ }
1829
+ entries["tsconfig.json"] = tsConfig;
1830
+ const originalValModules = readLocalFile(ctx.projectRoot, "/val.modules.ts") ?? readLocalFile(ctx.projectRoot, "/val.modules.js");
1831
+ if (originalValModules !== null) {
1832
+ entries["val.modules.original.ts"] = originalValModules;
1833
+ }
1834
+ entries["val.modules.ts"] = generateValModules(Object.keys(moduleTexts).sort());
1835
+ const patches = toSnapshotPatches(orderedPatches);
1836
+ const elidedPatchValues = [];
1837
+ for (const patch of patches) {
1838
+ const elided = elideLongStrings(patch.patch);
1839
+ patch.patch = elided.value;
1840
+ for (const p of elided.elided) {
1841
+ elidedPatchValues.push({
1842
+ patchId: patch.patchId,
1843
+ path: p
1844
+ });
1845
+ }
1846
+ entries[`.val/patches/${patch.parentPatchId ?? "head"}/patch.json`] = JSON.stringify(toFsPatch(patch), null, 2);
1847
+ }
1848
+ let includesBinaryFiles = false;
1849
+ if (options.includeFiles) {
1850
+ includesBinaryFiles = await writeBinaryFiles(ctx, analysis.fileLastUpdatedByPatchId, patches, entries);
1851
+ }
1852
+ const validation = await validateSnapshotSources(ctx, analysis);
1853
+ const report = {
1854
+ unappliablePatches: prepared.unappliablePatches,
1855
+ appliedPatches: prepared.appliedPatches,
1856
+ triedPatches: prepared.triedPatches,
1857
+ skippedPatches: prepared.skippedPatches,
1858
+ sourceFilePatchErrors: Object.fromEntries(Object.entries(prepared.sourceFilePatchErrors).map(([key, errors]) => [key, errors.map(formatPatchSourceError)])),
1859
+ binaryFilePatchErrors: prepared.binaryFilePatchErrors,
1860
+ hasErrors: prepared.hasErrors,
1861
+ validationErrors: validation
1862
+ };
1863
+ const manifest = {
1864
+ generatedAt: new Date().toISOString(),
1865
+ mode: ctx.mode,
1866
+ project: ctx.project,
1867
+ branch: ctx.branch,
1868
+ commit: ctx.commit,
1869
+ baseSha: await serverOps.getBaseSha(),
1870
+ filesDirectory: ctx.filesDirectory,
1871
+ authKind: ctx.authKind,
1872
+ versions: {
1873
+ core: getVersions().coreVersion,
1874
+ next: getVersions().nextVersion,
1875
+ project: readProjectValVersions(ctx.projectRoot),
1876
+ node: process.version,
1877
+ platform: `${process.platform}-${process.arch}`
1878
+ },
1879
+ modules: Array.from(closure.entries()).map(([moduleFilePath, reasons]) => ({
1880
+ moduleFilePath,
1881
+ reasons,
1882
+ source: moduleProvenance[moduleFilePath] ?? "missing"
1883
+ })),
1884
+ patchCount: patches.length,
1885
+ unappliablePatchCount: Object.keys(prepared.unappliablePatches).length,
1886
+ patchChainSynthesised: ctx.mode === "http",
1887
+ unresolvedImports: imported.unresolved,
1888
+ elidedPatchValues,
1889
+ includesBinaryFiles
1890
+ };
1891
+ entries["manifest.json"] = JSON.stringify(manifest, null, 2);
1892
+ entries["report.json"] = JSON.stringify(report, null, 2);
1893
+ entries["README.md"] = renderReadme(manifest);
1894
+ return {
1895
+ manifest,
1896
+ report,
1897
+ entries
1898
+ };
1899
+ }
1900
+
1901
+ /**
1902
+ * Validation errors as the studio would compute them: patches applied to the
1903
+ * evaluated json, then the schemas run over the result.
1904
+ */
1905
+ async function validateSnapshotSources(ctx, analysis) {
1906
+ const {
1907
+ serverOps
1908
+ } = ctx;
1909
+ const schemas = await serverOps.getSchemas();
1910
+ const validationRes = await serverOps.validateSources(schemas, (await serverOps.getSourcesWithPatchesApplied(analysis)).sources, analysis.patchesByModule);
1911
+ return validationRes.errors;
1912
+ }
1913
+ function toSnapshotPatches(orderedPatches) {
1914
+ return orderedPatches.map((patch, i) => ({
1915
+ patchId: patch.patchId,
1916
+ path: patch.path,
1917
+ createdAt: patch.createdAt,
1918
+ authorId: patch.authorId,
1919
+ baseSha: patch.baseSha,
1920
+ appliedAt: patch.appliedAt,
1921
+ parentPatchId: i === 0 ? null : orderedPatches[i - 1].patchId,
1922
+ patch: patch.patch
1923
+ }));
1924
+ }
1925
+
1926
+ /**
1927
+ * The shape ValOpsFS writes per patch, so an unzipped snapshot is a patch store
1928
+ * a plain ValOpsFS can read. The directory is named after the PARENT, and
1929
+ * createPatchChain walks the linked list from "head".
1930
+ */
1931
+ function toFsPatch(patch) {
1932
+ return {
1933
+ patch: patch.patch,
1934
+ patchId: patch.patchId,
1935
+ parentRef: patch.parentPatchId === null ? {
1936
+ type: "head",
1937
+ headBaseSha: patch.baseSha
1938
+ } : {
1939
+ type: "patch",
1940
+ patchId: patch.parentPatchId
1941
+ },
1942
+ path: patch.path,
1943
+ authorId: patch.authorId,
1944
+ sessionId: null,
1945
+ baseSha: patch.baseSha,
1946
+ coreVersion: null,
1947
+ createdAt: patch.createdAt
1948
+ };
1949
+ }
1950
+ async function writeBinaryFiles(ctx, fileLastUpdatedByPatchId, patches, entries) {
1951
+ const parentByPatchId = new Map(patches.map(p => [p.patchId, p.parentPatchId ?? "head"]));
1952
+ let wrote = false;
1953
+ for (const [filePath, data] of Object.entries(fileLastUpdatedByPatchId)) {
1954
+ if (data.isDelete) {
1955
+ continue;
1956
+ }
1957
+ const parentPatchId = parentByPatchId.get(data.patchId);
1958
+ if (parentPatchId === undefined) {
1959
+ continue;
1960
+ }
1961
+ const buffer = await ctx.serverOps.getBase64EncodedBinaryFileFromPatch(filePath, data.patchId, data.remote);
1962
+ if (!buffer) {
1963
+ continue;
1964
+ }
1965
+ // Base64 so the snapshot stays a text-only entry map; the replay decodes it.
1966
+ entries[`.val/patches/${parentPatchId}/files${filePath}/${path.posix.basename(filePath)}.base64`] = buffer.toString("base64");
1967
+ wrote = true;
1968
+ }
1969
+ return wrote;
1970
+ }
1971
+ function generateValModules(moduleFilePaths) {
1972
+ const imports = moduleFilePaths.map(moduleFilePath => {
1973
+ const withoutExt = moduleFilePath.replace(/\.(ts|js|tsx|jsx)$/, "");
1974
+ return ` { def: () => import(".${withoutExt}") },`;
1975
+ }).join("\n");
1976
+ return `// GENERATED by \`val debug\`: trimmed to the modules this snapshot carries.
1977
+ // The project's original is kept as val.modules.original.ts.
1978
+ import { modules } from "@valbuild/next";
1979
+ import { config } from "./val.config";
1980
+
1981
+ export default modules(config, [
1982
+ ${imports}
1983
+ ]);
1984
+ `;
1985
+ }
1986
+ function renderReadme(manifest) {
1987
+ return `# Val debug snapshot
1988
+
1989
+ Captured ${manifest.generatedAt} from project \`${manifest.project ?? "(fs mode)"}\`,
1990
+ branch \`${manifest.branch ?? "?"}\`, commit \`${manifest.commit ?? "?"}\`.
1991
+
1992
+ - @valbuild/core: \`${manifest.versions.core ?? "?"}\`
1993
+ - @valbuild/next: \`${manifest.versions.next ?? "?"}\`
1994
+ - ${manifest.patchCount} pending patches, ${manifest.unappliablePatchCount} of which could not be applied.
1995
+
1996
+ ## Replaying it
1997
+
1998
+ This directory is a minimal Val project: the modules the patches touch (plus the
1999
+ ones they reference), a generated \`val.modules.ts\`, and the patch chain under
2000
+ \`.val/patches\`. Unzip it into \`debug/\` in the val repo, check out the version
2001
+ above, and run:
2002
+
2003
+ \`\`\`bash
2004
+ pnpm debug:replay debug/<this-directory>
2005
+ \`\`\`
2006
+
2007
+ That applies the patches the same way \`/save\` does and validates the result, then
2008
+ diffs what it finds against \`report.json\` (captured at the time of the bug).
2009
+
2010
+ ## Notes
2011
+
2012
+ ${manifest.patchChainSynthesised ? "- The content api does not return `parentRef`, so the patch chain was rebuilt from the order the api returned.\n" : ""}${manifest.unresolvedImports.length > 0 ? `- ${manifest.unresolvedImports.length} import specifier(s) could not be resolved to a project file - see manifest.json. Bare package imports are expected; a tsconfig path alias means the snapshot may not evaluate.\n` : ""}${manifest.elidedPatchValues.length > 0 ? `- ${manifest.elidedPatchValues.length} long patch value(s) were elided - see manifest.json.\n` : ""}${manifest.includesBinaryFiles ? "- Binary files are included, base64 encoded with a `.base64` suffix.\n" : "- Binary files are NOT included (source patching does not need them). Re-run with `--include-files` if you need them.\n"}
2013
+ This snapshot contains unpublished content. Treat it as customer data.
2014
+ `;
2015
+ }
2016
+ function toSnapshotPath(projectRelativePath) {
2017
+ return projectRelativePath.replace(/^\//, "");
2018
+ }
2019
+
2020
+ /** The @valbuild/* versions the project depends on, so we know what to check out. */
2021
+ function readProjectValVersions(projectRoot) {
2022
+ const contents = readLocalFile(projectRoot, "/package.json");
2023
+ if (contents === null) {
2024
+ return {};
2025
+ }
2026
+ let parsed;
2027
+ try {
2028
+ parsed = JSON.parse(contents);
2029
+ } catch {
2030
+ return {};
2031
+ }
2032
+ if (parsed === null || typeof parsed !== "object") {
2033
+ return {};
2034
+ }
2035
+ const versions = {};
2036
+ for (const field of ["dependencies", "devDependencies"]) {
2037
+ const deps = parsed[field];
2038
+ if (deps === null || typeof deps !== "object") {
2039
+ continue;
2040
+ }
2041
+ for (const [name, version] of Object.entries(deps)) {
2042
+ if (name.startsWith("@valbuild/") && typeof version === "string") {
2043
+ versions[name] = version;
2044
+ }
2045
+ }
2046
+ }
2047
+ return versions;
2048
+ }
2049
+ function readLocalFile(projectRoot, projectRelativePath) {
2050
+ const absPath = path.join(projectRoot, projectRelativePath);
2051
+ try {
2052
+ return fs$1.readFileSync(absPath, "utf-8");
2053
+ } catch {
2054
+ return null;
2055
+ }
2056
+ }
2057
+
2058
+ /**
2059
+ * Replaces oversized strings (base64 file payloads that were not swapped for a
2060
+ * hash) with a marker, so a snapshot stays small enough to attach.
2061
+ */
2062
+ function elideLongStrings(value) {
2063
+ const elided = [];
2064
+ const walk = (node, atPath) => {
2065
+ if (typeof node === "string") {
2066
+ if (node.length > MAX_PATCH_STRING_LENGTH) {
2067
+ elided.push(atPath);
2068
+ return `<elided ${node.length} chars by val debug>`;
2069
+ }
2070
+ return node;
2071
+ }
2072
+ if (Array.isArray(node)) {
2073
+ return node.map((item, i) => walk(item, atPath.concat(i.toString())));
2074
+ }
2075
+ if (node !== null && typeof node === "object") {
2076
+ return Object.fromEntries(Object.entries(node).map(([key, item]) => [key, walk(item, atPath.concat(key))]));
2077
+ }
2078
+ return node;
2079
+ };
2080
+ return {
2081
+ value: walk(value, []),
2082
+ elided
2083
+ };
2084
+ }
2085
+
2086
+ /**
2087
+ * Prints the pending patches grouped by module, marking the ones that could not
2088
+ * be applied. This is the same information `/save` returns on a 400, except it
2089
+ * lists all of them rather than the first per module.
2090
+ */
2091
+ function printPatchReport(patches, prepared, options = {}) {
2092
+ const byModule = new Map();
2093
+ for (const patch of patches) {
2094
+ const existing = byModule.get(patch.path);
2095
+ if (existing) {
2096
+ existing.push(patch);
2097
+ } else {
2098
+ byModule.set(patch.path, [patch]);
2099
+ }
2100
+ }
2101
+ const moduleFilePaths = Array.from(byModule.keys()).sort();
2102
+ for (const moduleFilePath of moduleFilePaths) {
2103
+ const modulePatches = byModule.get(moduleFilePath) ?? [];
2104
+ const unappliableHere = modulePatches.filter(patch => prepared.unappliablePatches[patch.patchId]);
2105
+ const header = `${moduleFilePath} ${pc.dim(`(${modulePatches.length} patch${modulePatches.length === 1 ? "" : "es"})`)}`;
2106
+ console.log(unappliableHere.length > 0 ? pc.red(header) : pc.green(header));
2107
+ for (const patch of modulePatches) {
2108
+ const failure = prepared.unappliablePatches[patch.patchId];
2109
+ if (!failure && !options.verbose) {
2110
+ continue;
2111
+ }
2112
+ const who = patch.authorId ?? "unknown author";
2113
+ const line = ` ${patch.patchId} ${patch.createdAt} ${who}`;
2114
+ if (failure) {
2115
+ console.log(pc.red(line));
2116
+ for (const messageLine of failure.message.split("\n")) {
2117
+ console.log(pc.red(` ${messageLine}`));
2118
+ }
2119
+ } else {
2120
+ console.log(pc.dim(line));
2121
+ }
2122
+ }
2123
+ }
2124
+ const unappliableCount = Object.keys(prepared.unappliablePatches).length;
2125
+ console.log("");
2126
+ if (unappliableCount === 0) {
2127
+ console.log(pc.green(`${patches.length} pending patch${patches.length === 1 ? "" : "es"}, all appliable.`));
2128
+ } else {
2129
+ console.log(pc.red(`${patches.length} pending patch${patches.length === 1 ? "" : "es"}, ` + `${unappliableCount} of which cannot be applied across ${moduleFilePaths.length} module(s).`));
2130
+ console.log(pc.dim("Publishing is blocked until these are removed. See: val delete-unappliable-patches --dry-run"));
2131
+ }
2132
+ }
2133
+
2134
+ async function debug(options) {
2135
+ let ctx;
2136
+ try {
2137
+ ctx = await createDebugContext(options);
2138
+ } catch (err) {
2139
+ if (err instanceof DebugContextError) {
2140
+ return error(err.message);
2141
+ }
2142
+ throw err;
2143
+ }
2144
+ console.log(pc.dim(`Project: ${ctx.project ?? "(fs mode)"} branch: ${ctx.branch ?? "?"} commit: ${ctx.commit ?? "?"}`));
2145
+ console.log(pc.yellow("The snapshot includes unpublished content. Only share it with Val developers."));
2146
+ console.log("");
2147
+ const snapshot = await buildSnapshot(ctx, {
2148
+ includeFiles: options.includeFiles
2149
+ });
2150
+ printPatchReport(snapshot.manifest.modules.length > 0 ? await readPatchMetadata(ctx) : [], snapshot.report, {
2151
+ verbose: options.verbose
2152
+ });
2153
+ const outPath = path.resolve(options.out ?? `./val-debug-${sanitize(ctx.branch ?? "nobranch")}-${(ctx.commit ?? "nocommit").slice(0, 8)}-${timestamp()}.zip`);
2154
+ await writeZip(outPath, snapshot.entries);
2155
+ console.log("");
2156
+ console.log(`Snapshot written to ${pc.cyan(outPath)}`);
2157
+ for (const line of [`unzip ${path.basename(outPath)} -d debug/<name>`, `pnpm debug:replay debug/<name>`]) {
2158
+ console.log(pc.dim(` ${line}`));
2159
+ }
2160
+ if (snapshot.manifest.unresolvedImports.length > 0) {
2161
+ console.log("");
2162
+ console.log(pc.yellow(`${snapshot.manifest.unresolvedImports.length} import(s) could not be resolved to a project file. ` + `Bare package imports are expected; a tsconfig path alias means the snapshot may not evaluate. See manifest.json.`));
2163
+ }
2164
+ }
2165
+
2166
+ /**
2167
+ * Patch metadata for the printed report. Fetched without ops so the (large)
2168
+ * patch bodies are not pulled a second time.
2169
+ */
2170
+ async function readPatchMetadata(ctx) {
2171
+ const res = await ctx.serverOps.fetchPatches({
2172
+ patchIds: undefined,
2173
+ excludePatchOps: true
2174
+ });
2175
+ if (res.error) {
2176
+ return [];
2177
+ }
2178
+ return res.patches.map(patch => ({
2179
+ patchId: patch.patchId,
2180
+ path: patch.path,
2181
+ createdAt: patch.createdAt,
2182
+ authorId: patch.authorId
2183
+ }));
2184
+ }
2185
+ async function writeZip(outPath, entries) {
2186
+ const zip = new JSZip();
2187
+ for (const [entryPath, contents] of Object.entries(entries)) {
2188
+ zip.file(entryPath, contents);
2189
+ }
2190
+ const buffer = await zip.generateAsync({
2191
+ type: "nodebuffer",
2192
+ compression: "DEFLATE"
2193
+ });
2194
+ fs$1.mkdirSync(path.dirname(outPath), {
2195
+ recursive: true
2196
+ });
2197
+ fs$1.writeFileSync(outPath, buffer);
2198
+ }
2199
+ function timestamp() {
2200
+ return new Date().toISOString().replace(/[:.]/g, "-").replace(/Z$/, "");
2201
+ }
2202
+ function sanitize(value) {
2203
+ return value.replace(/[^a-zA-Z0-9._-]/g, "_");
2204
+ }
2205
+
2206
+ /**
2207
+ * Removes the pending patches that cannot be applied, which is what unblocks a
2208
+ * publish that fails with "Failed to create commit".
2209
+ *
2210
+ * Deliberately a separate command from `val debug`: capturing a snapshot must
2211
+ * always be read-only, and deleting destroys the evidence - so take the snapshot
2212
+ * first.
2213
+ */
2214
+ async function deleteUnappliablePatches(options) {
2215
+ let ctx;
2216
+ try {
2217
+ ctx = await createDebugContext(options);
2218
+ } catch (err) {
2219
+ if (err instanceof DebugContextError) {
2220
+ return error(err.message);
2221
+ }
2222
+ throw err;
2223
+ }
2224
+ console.log(pc.dim(`Project: ${ctx.project ?? "(fs mode)"} branch: ${ctx.branch ?? "?"} commit: ${ctx.commit ?? "?"}`));
2225
+ const first = await analyse(ctx);
2226
+ if (first === null) {
2227
+ return;
2228
+ }
2229
+ printPatchReport(first.metadata, first.prepared, {
2230
+ verbose: options.verbose
2231
+ });
2232
+ const unappliablePatchIds = Object.keys(first.prepared.unappliablePatches).map(patchId => patchId);
2233
+ if (unappliablePatchIds.length === 0) {
2234
+ return;
2235
+ }
2236
+ if (options.dryRun) {
2237
+ console.log("");
2238
+ info("Dry run: nothing was deleted.");
2239
+ return;
2240
+ }
2241
+ if (!options.yes) {
2242
+ console.log("");
2243
+ const confirmed = await confirm(`Delete ${unappliablePatchIds.length} patch(es)? The changes they contain are lost. [y/N] `);
2244
+ if (!confirmed) {
2245
+ info("Aborted, nothing was deleted.");
2246
+ return;
2247
+ }
2248
+ }
2249
+ const deleteRes = await ctx.serverOps.deletePatches(unappliablePatchIds);
2250
+ if (deleteRes.errors && Object.keys(deleteRes.errors).length > 0) {
2251
+ for (const [patchId, err] of Object.entries(deleteRes.errors)) {
2252
+ error(`Could not delete ${patchId}: ${err.message}`);
2253
+ }
2254
+ return;
2255
+ }
2256
+ info(`Deleted ${unappliablePatchIds.length} patch(es).`, {
2257
+ isGood: true
2258
+ });
2259
+ console.log("");
2260
+ console.log(pc.dim("Re-checking the remaining chain..."));
2261
+ const second = await analyse(ctx);
2262
+ if (second === null) {
2263
+ return;
2264
+ }
2265
+ const stillUnappliable = Object.keys(second.prepared.unappliablePatches);
2266
+ if (stillUnappliable.length === 0) {
2267
+ info(`The remaining ${second.metadata.length} patch(es) all apply. Publishing should work now.`, {
2268
+ isGood: true
2269
+ });
2270
+ return;
2271
+ }
2272
+ printPatchReport(second.metadata, second.prepared, {
2273
+ verbose: options.verbose
2274
+ });
2275
+ error(`${stillUnappliable.length} patch(es) still cannot be applied. Run the command again to remove them too.`);
2276
+ }
2277
+ async function analyse(ctx) {
2278
+ const patchesRes = await ctx.serverOps.fetchPatches({
2279
+ patchIds: undefined,
2280
+ excludePatchOps: false
2281
+ });
2282
+ if (patchesRes.error) {
2283
+ error(`Could not fetch patches: ${patchesRes.error.message}`);
2284
+ return null;
2285
+ }
2286
+ const analysis = {
2287
+ ...ctx.serverOps.analyzePatches(patchesRes.patches),
2288
+ ...patchesRes
2289
+ };
2290
+ const prepared = await ctx.serverOps.prepare(analysis, {
2291
+ continueOnError: true
2292
+ });
2293
+ return {
2294
+ prepared,
2295
+ metadata: patchesRes.patches.map(patch => ({
2296
+ patchId: patch.patchId,
2297
+ path: patch.path,
2298
+ createdAt: patch.createdAt,
2299
+ authorId: patch.authorId
2300
+ }))
2301
+ };
2302
+ }
2303
+ function confirm(question) {
2304
+ const rl = readline.createInterface({
2305
+ input: process.stdin,
2306
+ output: process.stdout
2307
+ });
2308
+ return new Promise(resolve => {
2309
+ rl.question(question, answer => {
2310
+ rl.close();
2311
+ resolve(answer.trim().toLowerCase() === "y");
2312
+ });
2313
+ });
2314
+ }
2315
+
1048
2316
  async function main() {
1049
2317
  const {
1050
2318
  input,
@@ -1063,12 +2331,15 @@ async function main() {
1063
2331
  files
1064
2332
  connect
1065
2333
  versions
1066
-
2334
+ debug
2335
+ delete-unappliable-patches
2336
+
1067
2337
  Command: validate
1068
2338
  Description: val-idate val modules
1069
2339
  Options:
1070
2340
  --root [root], -r [root] Set project root directory (default process.cwd())
1071
2341
  --fix [fix] Attempt to fix validation errors
2342
+ --watch, -w Re-validate on changes to val.config, val.modules and *.val files
1072
2343
 
1073
2344
 
1074
2345
  Command: login
@@ -1084,10 +2355,38 @@ async function main() {
1084
2355
 
1085
2356
  Command: list-unused-files
1086
2357
  Description: EXPERIMENTAL.
1087
- List files that are in public/val but not in use by any Val module.
2358
+ List files that are in the configured files directory (files.directory, default public/val) but not in use by any Val module.
1088
2359
  This is useful for cleaning up unused files.
1089
2360
  Options:
1090
2361
  --root [root], -r [root] Set project root directory (default process.cwd())
2362
+
2363
+ Command: debug
2364
+ Description: Create a self-contained snapshot (the pending patches plus the modules they
2365
+ touch or reference) that Val developers can replay to reproduce validation and save
2366
+ errors. Read-only.
2367
+ Options:
2368
+ --root [root], -r [root] Set project root directory (default process.cwd())
2369
+ --out [file] Output zip (default ./val-debug-<branch>-<commit>-<timestamp>.zip)
2370
+ --remote Read the patches from the hosted project instead of <root>/.val
2371
+ (requires "val login"; implied by VAL_API_KEY)
2372
+ --commit [sha] Commit to read module sources at (default VAL_GIT_COMMIT, else git HEAD)
2373
+ --branch [name] Branch (default VAL_GIT_BRANCH, else the current git branch)
2374
+ --include-files Also download the binary files the patches reference
2375
+ --verbose List every pending patch, not just the failing ones
2376
+
2377
+ Command: delete-unappliable-patches
2378
+ Description: Delete the pending patches that cannot be applied. This is what unblocks a
2379
+ publish failing with "Failed to create commit". Capture a "val debug" snapshot first:
2380
+ deleting discards the changes those patches contain.
2381
+ Options:
2382
+ --root [root], -r [root] Set project root directory (default process.cwd())
2383
+ --remote Operate on the hosted project instead of <root>/.val
2384
+ (requires "val login"; implied by VAL_API_KEY)
2385
+ --commit [sha] Commit to read module sources at (default VAL_GIT_COMMIT, else git HEAD)
2386
+ --branch [name] Branch (default VAL_GIT_BRANCH, else the current git branch)
2387
+ --dry-run Only list what would be deleted
2388
+ --yes Do not ask for confirmation
2389
+ --verbose List every pending patch, not just the failing ones
1091
2390
  `, {
1092
2391
  flags: {
1093
2392
  port: {
@@ -1102,11 +2401,39 @@ async function main() {
1102
2401
  fix: {
1103
2402
  type: "boolean"
1104
2403
  },
2404
+ watch: {
2405
+ type: "boolean",
2406
+ alias: "w"
2407
+ },
1105
2408
  noEslint: {
1106
2409
  type: "boolean"
1107
2410
  },
1108
2411
  managedDir: {
1109
2412
  type: "string"
2413
+ },
2414
+ out: {
2415
+ type: "string"
2416
+ },
2417
+ commit: {
2418
+ type: "string"
2419
+ },
2420
+ branch: {
2421
+ type: "string"
2422
+ },
2423
+ includeFiles: {
2424
+ type: "boolean"
2425
+ },
2426
+ remote: {
2427
+ type: "boolean"
2428
+ },
2429
+ dryRun: {
2430
+ type: "boolean"
2431
+ },
2432
+ yes: {
2433
+ type: "boolean"
2434
+ },
2435
+ verbose: {
2436
+ type: "boolean"
1110
2437
  }
1111
2438
  },
1112
2439
  hardRejection: false
@@ -1128,6 +2455,26 @@ async function main() {
1128
2455
  });
1129
2456
  case "versions":
1130
2457
  return versions();
2458
+ case "debug":
2459
+ return debug({
2460
+ root: flags.root,
2461
+ out: flags.out,
2462
+ commit: flags.commit,
2463
+ branch: flags.branch,
2464
+ remote: flags.remote,
2465
+ includeFiles: flags.includeFiles,
2466
+ verbose: flags.verbose
2467
+ });
2468
+ case "delete-unappliable-patches":
2469
+ return deleteUnappliablePatches({
2470
+ root: flags.root,
2471
+ commit: flags.commit,
2472
+ branch: flags.branch,
2473
+ remote: flags.remote,
2474
+ dryRun: flags.dryRun,
2475
+ yes: flags.yes,
2476
+ verbose: flags.verbose
2477
+ });
1131
2478
  case "login":
1132
2479
  return login({
1133
2480
  root: flags.root
@@ -1144,9 +2491,13 @@ async function main() {
1144
2491
  if (flags.managedDir) {
1145
2492
  return error(`Command "validate" does not support --managedDir flag`);
1146
2493
  }
2494
+ if (flags.watch && flags.fix) {
2495
+ return error(`Command "validate" does not support --watch together with --fix`);
2496
+ }
1147
2497
  return validate({
1148
2498
  root: flags.root,
1149
- fix: flags.fix
2499
+ fix: flags.fix,
2500
+ watch: flags.watch
1150
2501
  });
1151
2502
  default:
1152
2503
  return error(`Unknown command "${input.join(" ")}"`);