@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.
- package/cli/dist/valbuild-cli-cli.cjs.dev.js +1471 -118
- package/cli/dist/valbuild-cli-cli.cjs.prod.js +1471 -118
- package/cli/dist/valbuild-cli-cli.esm.js +1469 -118
- package/package.json +6 -4
- package/src/__fixtures__/basic/val.config.ts +2 -2
- package/src/__fixtures__/basic/val.modules.ts +16 -0
- package/src/__fixtures__/debug-snapshot/.val/patches/11111111-1111-4111-8111-111111111111/patch.json +19 -0
- package/src/__fixtures__/debug-snapshot/.val/patches/22222222-2222-4222-8222-222222222222/patch.json +20 -0
- package/src/__fixtures__/debug-snapshot/.val/patches/head/patch.json +20 -0
- package/src/__fixtures__/debug-snapshot/content/projects.val.ts +23 -0
- package/src/__fixtures__/debug-snapshot/content/summary.ts +6 -0
- package/src/__fixtures__/debug-snapshot/content/tags.val.ts +10 -0
- package/src/__fixtures__/debug-snapshot/content/unrelated.val.ts +5 -0
- package/src/__fixtures__/debug-snapshot/tsconfig.json +12 -0
- package/src/__fixtures__/debug-snapshot/val.config.ts +5 -0
- package/src/__fixtures__/debug-snapshot/val.modules.ts +8 -0
- package/src/cli.ts +89 -2
- package/src/debug/context.ts +173 -0
- package/src/debug/importGraph.ts +126 -0
- package/src/debug/moduleClosure.ts +167 -0
- package/src/debug/report.ts +80 -0
- package/src/debug/snapshot.ts +497 -0
- package/src/debug/snapshotRoundTrip.test.ts +95 -0
- package/src/debug.test.ts +107 -0
- package/src/debug.ts +120 -0
- package/src/deleteUnappliablePatches.ts +139 -0
- package/src/listUnusedFiles.ts +16 -4
- package/src/runValidation.test.ts +6 -6
- package/src/runValidation.ts +40 -15
- package/src/utils/evalValConfigFile.ts +13 -5
- package/src/utils/sourcePathToFileLocation.ts +184 -0
- 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
|
|
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
|
|
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
|
-
|
|
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/
|
|
315
|
+
if (!relativeFilePath.startsWith("public/")) {
|
|
299
316
|
return {
|
|
300
317
|
success: false,
|
|
301
|
-
errorMessage: `File path must be within the public/
|
|
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
|
-
|
|
514
|
+
fs__default$1["default"].mkdirSync(path__default["default"].dirname(fileName), {
|
|
500
515
|
recursive: true
|
|
501
516
|
});
|
|
502
|
-
|
|
517
|
+
fs__default$1["default"].writeFileSync(fileName, typeof data === "string" ? data : new Uint8Array(data), encoding);
|
|
503
518
|
},
|
|
504
|
-
rmFile:
|
|
519
|
+
rmFile: fs__default$1["default"].rmSync,
|
|
505
520
|
readBuffer: fileName => {
|
|
506
521
|
try {
|
|
507
|
-
return
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
const
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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 (
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
1197
|
+
var _valConfigFile$files;
|
|
830
1198
|
const projectRoot = root ? path__default["default"].resolve(root) : process.cwd();
|
|
831
|
-
const
|
|
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 (
|
|
990
|
-
return
|
|
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
|
-
|
|
1447
|
+
fs__default$1["default"].mkdirSync(path__default["default"].dirname(filePath), {
|
|
1074
1448
|
recursive: true
|
|
1075
1449
|
});
|
|
1076
|
-
|
|
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(" ")}"`);
|