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