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