@valbuild/cli 0.87.5 → 0.89.0

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/src/validate.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  getPersonalAccessTokenPath,
7
7
  parsePersonalAccessTokenFile,
8
8
  uploadRemoteFile,
9
+ Service,
9
10
  } from "@valbuild/server";
10
11
  import {
11
12
  DEFAULT_CONTENT_HOST,
@@ -19,6 +20,11 @@ import {
19
20
  SourcePath,
20
21
  ValidationFix,
21
22
  } from "@valbuild/core";
23
+ import {
24
+ filterRoutesByPatterns,
25
+ validateRoutePatterns,
26
+ type SerializedRegExpPattern,
27
+ } from "@valbuild/shared/internal";
22
28
  import { glob } from "fast-glob";
23
29
  import picocolors from "picocolors";
24
30
  import fs from "fs/promises";
@@ -26,6 +32,611 @@ import { evalValConfigFile } from "./utils/evalValConfigFile";
26
32
  import { getFileExt } from "./utils/getFileExt";
27
33
 
28
34
  const textEncoder = new TextEncoder();
35
+
36
+ // Types for handler system
37
+ type ValModule = Awaited<ReturnType<Service["get"]>>;
38
+
39
+ type ValidationError = {
40
+ message: string;
41
+ value?: unknown;
42
+ fixes?: ValidationFix[];
43
+ };
44
+
45
+ type FixHandlerContext = {
46
+ sourcePath: SourcePath;
47
+ validationError: ValidationError;
48
+ valModule: ValModule;
49
+ projectRoot: string;
50
+ fix: boolean;
51
+ service: Service;
52
+ valFiles: string[];
53
+ moduleFilePath: ModuleFilePath;
54
+ file: string;
55
+ // Shared state
56
+ remoteFiles: Record<
57
+ SourcePath,
58
+ { ref: string; metadata?: Record<string, unknown> }
59
+ >;
60
+ publicProjectId?: string;
61
+ remoteFileBuckets?: string[];
62
+ remoteFilesCounter: number;
63
+ valRemoteHost: string;
64
+ contentHostUrl: string;
65
+ valConfigFile?: { project?: string };
66
+ };
67
+
68
+ type FixHandlerResult = {
69
+ success: boolean;
70
+ errorMessage?: string;
71
+ shouldApplyPatch?: boolean;
72
+ // Updated shared state
73
+ publicProjectId?: string;
74
+ remoteFileBuckets?: string[];
75
+ remoteFilesCounter?: number;
76
+ };
77
+
78
+ type FixHandler = (ctx: FixHandlerContext) => Promise<FixHandlerResult>;
79
+
80
+ // Handler functions
81
+ async function handleFileMetadata(
82
+ ctx: FixHandlerContext,
83
+ ): Promise<FixHandlerResult> {
84
+ const [, modulePath] = Internal.splitModuleFilePathAndModulePath(
85
+ ctx.sourcePath,
86
+ );
87
+
88
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
89
+ return {
90
+ success: false,
91
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`,
92
+ };
93
+ }
94
+
95
+ const fileSource = Internal.resolvePath(
96
+ modulePath,
97
+ ctx.valModule.source,
98
+ ctx.valModule.schema,
99
+ );
100
+
101
+ let filePath: string | null = null;
102
+ try {
103
+ filePath = path.join(
104
+ ctx.projectRoot,
105
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
+ (fileSource.source as any)?.[FILE_REF_PROP],
107
+ );
108
+ await fs.access(filePath);
109
+ } catch {
110
+ if (filePath) {
111
+ return {
112
+ success: false,
113
+ errorMessage: `File ${filePath} does not exist`,
114
+ };
115
+ } else {
116
+ return {
117
+ success: false,
118
+ errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`,
119
+ };
120
+ }
121
+ }
122
+
123
+ return { success: true, shouldApplyPatch: true };
124
+ }
125
+
126
+ async function handleKeyOfCheck(
127
+ ctx: FixHandlerContext,
128
+ ): Promise<FixHandlerResult> {
129
+ if (
130
+ !ctx.validationError.value ||
131
+ typeof ctx.validationError.value !== "object" ||
132
+ !("key" in ctx.validationError.value) ||
133
+ !("sourcePath" in ctx.validationError.value)
134
+ ) {
135
+ return {
136
+ success: false,
137
+ errorMessage: `Unexpected error in ${ctx.sourcePath}: ${ctx.validationError.message} (Expected value to be an object with 'key' and 'sourcePath' properties - this is likely a bug in Val)`,
138
+ };
139
+ }
140
+
141
+ const { key, sourcePath } = ctx.validationError.value as {
142
+ key: unknown;
143
+ sourcePath: unknown;
144
+ };
145
+
146
+ if (typeof key !== "string") {
147
+ return {
148
+ success: false,
149
+ errorMessage: `Unexpected error in ${sourcePath}: ${ctx.validationError.message} (Expected value property 'key' to be a string - this is likely a bug in Val)`,
150
+ };
151
+ }
152
+
153
+ if (typeof sourcePath !== "string") {
154
+ return {
155
+ success: false,
156
+ errorMessage: `Unexpected error in ${sourcePath}: ${ctx.validationError.message} (Expected value property 'sourcePath' to be a string - this is likely a bug in Val)`,
157
+ };
158
+ }
159
+
160
+ const res = await checkKeyIsValid(key, sourcePath, ctx.service);
161
+ if (res.error) {
162
+ return {
163
+ success: false,
164
+ errorMessage: res.message,
165
+ };
166
+ }
167
+
168
+ return { success: true };
169
+ }
170
+
171
+ async function handleRemoteFileUpload(
172
+ ctx: FixHandlerContext,
173
+ ): Promise<FixHandlerResult> {
174
+ if (!ctx.fix) {
175
+ return {
176
+ success: false,
177
+ errorMessage: `Remote file ${ctx.sourcePath} needs to be uploaded (use --fix to upload)`,
178
+ };
179
+ }
180
+
181
+ const [, modulePath] = Internal.splitModuleFilePathAndModulePath(
182
+ ctx.sourcePath,
183
+ );
184
+
185
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
186
+ return {
187
+ success: false,
188
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`,
189
+ };
190
+ }
191
+
192
+ const resolvedRemoteFileAtSourcePath = Internal.resolvePath(
193
+ modulePath,
194
+ ctx.valModule.source,
195
+ ctx.valModule.schema,
196
+ );
197
+
198
+ let filePath: string | null = null;
199
+ try {
200
+ filePath = path.join(
201
+ ctx.projectRoot,
202
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
203
+ (resolvedRemoteFileAtSourcePath.source as any)?.[FILE_REF_PROP],
204
+ );
205
+ await fs.access(filePath);
206
+ } catch {
207
+ if (filePath) {
208
+ return {
209
+ success: false,
210
+ errorMessage: `File ${filePath} does not exist`,
211
+ };
212
+ } else {
213
+ return {
214
+ success: false,
215
+ errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`,
216
+ };
217
+ }
218
+ }
219
+
220
+ const patFile = getPersonalAccessTokenPath(ctx.projectRoot);
221
+ try {
222
+ await fs.access(patFile);
223
+ } catch {
224
+ return {
225
+ success: false,
226
+ errorMessage: `File: ${path.join(ctx.projectRoot, ctx.file)} has remote images that are not uploaded and you are not logged in.\n\nFix this error by logging in:\n\t"npx val login"\n`,
227
+ };
228
+ }
229
+
230
+ const parsedPatFile = parsePersonalAccessTokenFile(
231
+ await fs.readFile(patFile, "utf-8"),
232
+ );
233
+ if (!parsedPatFile.success) {
234
+ return {
235
+ success: false,
236
+ errorMessage: `Error parsing personal access token file: ${parsedPatFile.error}. You need to login again.`,
237
+ };
238
+ }
239
+ const { pat } = parsedPatFile.data;
240
+
241
+ if (ctx.remoteFiles[ctx.sourcePath]) {
242
+ console.log(
243
+ picocolors.yellow("⚠"),
244
+ `Remote file ${filePath} already uploaded`,
245
+ );
246
+ return { success: true };
247
+ }
248
+
249
+ if (!resolvedRemoteFileAtSourcePath.schema) {
250
+ return {
251
+ success: false,
252
+ errorMessage: `Cannot upload remote file: schema not found for ${ctx.sourcePath}`,
253
+ };
254
+ }
255
+
256
+ const actualRemoteFileSource = resolvedRemoteFileAtSourcePath.source;
257
+ const fileSourceMetadata = Internal.isFile(actualRemoteFileSource)
258
+ ? actualRemoteFileSource.metadata
259
+ : undefined;
260
+ const resolveRemoteFileSchema = resolvedRemoteFileAtSourcePath.schema;
261
+
262
+ if (!resolveRemoteFileSchema) {
263
+ return {
264
+ success: false,
265
+ errorMessage: `Could not resolve schema for remote file: ${ctx.sourcePath}`,
266
+ };
267
+ }
268
+
269
+ let publicProjectId = ctx.publicProjectId;
270
+ let remoteFileBuckets = ctx.remoteFileBuckets;
271
+ let remoteFilesCounter = ctx.remoteFilesCounter;
272
+
273
+ if (!publicProjectId || !remoteFileBuckets) {
274
+ let projectName = process.env.VAL_PROJECT;
275
+ if (!projectName) {
276
+ projectName = ctx.valConfigFile?.project;
277
+ }
278
+ if (!projectName) {
279
+ return {
280
+ success: false,
281
+ errorMessage:
282
+ "Project name not found. Set VAL_PROJECT environment variable or add project name to val.config",
283
+ };
284
+ }
285
+ const settingsRes = await getSettings(projectName, { pat });
286
+ if (!settingsRes.success) {
287
+ return {
288
+ success: false,
289
+ errorMessage: `Could not get public project id: ${settingsRes.message}.`,
290
+ };
291
+ }
292
+ publicProjectId = settingsRes.data.publicProjectId;
293
+ remoteFileBuckets = settingsRes.data.remoteFileBuckets.map((b) => b.bucket);
294
+ }
295
+
296
+ if (!publicProjectId) {
297
+ return {
298
+ success: false,
299
+ errorMessage: "Could not get public project id",
300
+ };
301
+ }
302
+
303
+ if (!ctx.valConfigFile?.project) {
304
+ return {
305
+ success: false,
306
+ errorMessage: `Could not get project. Check that your val.config has the 'project' field set, or set it using the VAL_PROJECT environment variable`,
307
+ };
308
+ }
309
+
310
+ if (
311
+ resolveRemoteFileSchema.type !== "image" &&
312
+ resolveRemoteFileSchema.type !== "file"
313
+ ) {
314
+ return {
315
+ success: false,
316
+ errorMessage: `The schema is the remote is neither image nor file: ${ctx.sourcePath}`,
317
+ };
318
+ }
319
+
320
+ remoteFilesCounter += 1;
321
+ const bucket =
322
+ remoteFileBuckets[remoteFilesCounter % remoteFileBuckets.length];
323
+
324
+ if (!bucket) {
325
+ return {
326
+ success: false,
327
+ errorMessage: `Internal error: could not allocate a bucket for the remote file located at ${ctx.sourcePath}`,
328
+ };
329
+ }
330
+
331
+ let fileBuffer: Buffer;
332
+ try {
333
+ fileBuffer = await fs.readFile(filePath);
334
+ } catch (e) {
335
+ return {
336
+ success: false,
337
+ errorMessage: `Error reading file: ${e}`,
338
+ };
339
+ }
340
+
341
+ const relativeFilePath = path
342
+ .relative(ctx.projectRoot, filePath)
343
+ .split(path.sep)
344
+ .join("/") as `public/val/${string}`;
345
+
346
+ if (!relativeFilePath.startsWith("public/val/")) {
347
+ return {
348
+ success: false,
349
+ errorMessage: `File path must be within the public/val/ directory (e.g. public/val/path/to/file.txt). Got: ${relativeFilePath}`,
350
+ };
351
+ }
352
+
353
+ const fileHash = Internal.remote.getFileHash(fileBuffer);
354
+ const coreVersion = Internal.VERSION.core || "unknown";
355
+ const fileExt = getFileExt(filePath);
356
+ const schema = resolveRemoteFileSchema as
357
+ | SerializedImageSchema
358
+ | SerializedFileSchema;
359
+ const metadata = fileSourceMetadata;
360
+ const ref = Internal.remote.createRemoteRef(ctx.valRemoteHost, {
361
+ publicProjectId,
362
+ coreVersion,
363
+ bucket,
364
+ validationHash: Internal.remote.getValidationHash(
365
+ coreVersion,
366
+ schema,
367
+ fileExt,
368
+ metadata,
369
+ fileHash,
370
+ textEncoder,
371
+ ),
372
+ fileHash,
373
+ filePath: relativeFilePath,
374
+ });
375
+
376
+ console.log(picocolors.yellow("⚠"), `Uploading remote file: '${ref}'...`);
377
+
378
+ const remoteFileUpload = await uploadRemoteFile(
379
+ ctx.contentHostUrl,
380
+ ctx.valConfigFile.project,
381
+ bucket,
382
+ fileHash,
383
+ fileExt,
384
+ fileBuffer,
385
+ { pat },
386
+ );
387
+
388
+ if (!remoteFileUpload.success) {
389
+ return {
390
+ success: false,
391
+ errorMessage: `Could not upload remote file: '${ref}'. Error: ${remoteFileUpload.error}`,
392
+ };
393
+ }
394
+
395
+ console.log(
396
+ picocolors.green("✔"),
397
+ `Completed upload of remote file: '${ref}'`,
398
+ );
399
+
400
+ ctx.remoteFiles[ctx.sourcePath] = {
401
+ ref,
402
+ metadata: fileSourceMetadata,
403
+ };
404
+
405
+ return {
406
+ success: true,
407
+ shouldApplyPatch: true,
408
+ publicProjectId,
409
+ remoteFileBuckets,
410
+ remoteFilesCounter,
411
+ };
412
+ }
413
+
414
+ async function handleRemoteFileDownload(
415
+ ctx: FixHandlerContext,
416
+ ): Promise<FixHandlerResult> {
417
+ if (ctx.fix) {
418
+ console.log(
419
+ picocolors.yellow("⚠"),
420
+ `Downloading remote file in ${ctx.sourcePath}...`,
421
+ );
422
+ return { success: true, shouldApplyPatch: true };
423
+ } else {
424
+ return {
425
+ success: false,
426
+ errorMessage: `Remote file ${ctx.sourcePath} needs to be downloaded (use --fix to download)`,
427
+ };
428
+ }
429
+ }
430
+
431
+ async function handleRemoteFileCheck(): Promise<FixHandlerResult> {
432
+ // Skip - no action needed
433
+ return { success: true, shouldApplyPatch: true };
434
+ }
435
+
436
+ // Helper function
437
+ async function checkKeyIsValid(
438
+ key: string,
439
+ sourcePath: string,
440
+ service: Service,
441
+ ): Promise<{ error: false } | { error: true; message: string }> {
442
+ const [moduleFilePath, modulePath] =
443
+ Internal.splitModuleFilePathAndModulePath(sourcePath as SourcePath);
444
+ const keyOfModule = await service.get(moduleFilePath, modulePath, {
445
+ source: true,
446
+ schema: false,
447
+ validate: false,
448
+ });
449
+
450
+ const keyOfModuleSource = keyOfModule.source;
451
+ const keyOfModuleSchema = keyOfModule.schema;
452
+ if (keyOfModuleSchema && keyOfModuleSchema.type !== "record") {
453
+ return {
454
+ error: true,
455
+ message: `Expected key at ${sourcePath} to be of type 'record'`,
456
+ };
457
+ }
458
+ if (
459
+ keyOfModuleSource &&
460
+ typeof keyOfModuleSource === "object" &&
461
+ key in keyOfModuleSource
462
+ ) {
463
+ return { error: false };
464
+ }
465
+ if (!keyOfModuleSource || typeof keyOfModuleSource !== "object") {
466
+ return {
467
+ error: true,
468
+ message: `Expected ${sourcePath} to be a truthy object`,
469
+ };
470
+ }
471
+ const alternatives = findSimilar(key, Object.keys(keyOfModuleSource));
472
+ return {
473
+ error: true,
474
+ message: `Key '${key}' does not exist in ${sourcePath}. Closest match: '${alternatives[0].target}'. Other similar: ${alternatives
475
+ .slice(1, 4)
476
+ .map((a) => `'${a.target}'`)
477
+ .join(", ")}${alternatives.length > 4 ? ", ..." : ""}`,
478
+ };
479
+ }
480
+
481
+ /**
482
+ * Check if a route is valid by scanning all router modules
483
+ * and validating against include/exclude patterns
484
+ */
485
+ async function checkRouteIsValid(
486
+ route: string,
487
+ include: SerializedRegExpPattern | undefined,
488
+ exclude: SerializedRegExpPattern | undefined,
489
+ service: Service,
490
+ valFiles: string[],
491
+ ): Promise<{ error: false } | { error: true; message: string }> {
492
+ // 1. Scan all val files to find modules with routers
493
+ const routerModules: Record<string, Record<string, unknown>> = {};
494
+
495
+ for (const file of valFiles) {
496
+ const moduleFilePath = `/${file}` as ModuleFilePath;
497
+ const valModule = await service.get(moduleFilePath, "" as ModulePath, {
498
+ source: true,
499
+ schema: true,
500
+ validate: false,
501
+ });
502
+
503
+ // Check if this module has a router defined
504
+ if (valModule.schema?.type === "record" && valModule.schema.router) {
505
+ if (valModule.source && typeof valModule.source === "object") {
506
+ routerModules[moduleFilePath] = valModule.source as Record<
507
+ string,
508
+ unknown
509
+ >;
510
+ }
511
+ }
512
+ }
513
+
514
+ // 2. Check if route exists in any router module
515
+ let foundInModule: string | null = null;
516
+ for (const [moduleFilePath, source] of Object.entries(routerModules)) {
517
+ if (route in source) {
518
+ foundInModule = moduleFilePath;
519
+ break;
520
+ }
521
+ }
522
+
523
+ if (!foundInModule) {
524
+ // Route not found in any router module
525
+ let allRoutes = Object.values(routerModules).flatMap((source) =>
526
+ Object.keys(source),
527
+ );
528
+
529
+ if (allRoutes.length === 0) {
530
+ return {
531
+ error: true,
532
+ message: `Route '${route}' could not be validated: No router modules found in the project. Use s.record(...).router(...) to define router modules.`,
533
+ };
534
+ }
535
+
536
+ // Filter routes by include/exclude patterns for suggestions
537
+ allRoutes = filterRoutesByPatterns(allRoutes, include, exclude);
538
+
539
+ const alternatives = findSimilar(route, allRoutes);
540
+
541
+ return {
542
+ error: true,
543
+ message: `Route '${route}' does not exist in any router module. ${
544
+ alternatives.length > 0
545
+ ? `Closest match: '${alternatives[0].target}'. Other similar: ${alternatives
546
+ .slice(1, 4)
547
+ .map((a) => `'${a.target}'`)
548
+ .join(", ")}${alternatives.length > 4 ? ", ..." : ""}`
549
+ : "No similar routes found."
550
+ }`,
551
+ };
552
+ }
553
+
554
+ // 3. Validate against include/exclude patterns
555
+ const patternValidation = validateRoutePatterns(route, include, exclude);
556
+ if (!patternValidation.valid) {
557
+ return {
558
+ error: true,
559
+ message: patternValidation.message,
560
+ };
561
+ }
562
+
563
+ return { error: false };
564
+ }
565
+
566
+ /**
567
+ * Handler for router:check-route validation fix
568
+ */
569
+ async function handleRouteCheck(
570
+ ctx: FixHandlerContext,
571
+ ): Promise<FixHandlerResult> {
572
+ const { sourcePath, validationError, service, valFiles } = ctx;
573
+
574
+ // Extract route and patterns from validation error value
575
+ const value = validationError.value as
576
+ | {
577
+ route: unknown;
578
+ include?: { source: string; flags: string };
579
+ exclude?: { source: string; flags: string };
580
+ }
581
+ | undefined;
582
+
583
+ if (!value || typeof value.route !== "string") {
584
+ return {
585
+ success: false,
586
+ errorMessage: `Invalid route value in validation error: ${JSON.stringify(value)}`,
587
+ };
588
+ }
589
+
590
+ const route = value.route;
591
+
592
+ // Check if the route is valid
593
+ const result = await checkRouteIsValid(
594
+ route,
595
+ value.include,
596
+ value.exclude,
597
+ service,
598
+ valFiles,
599
+ );
600
+
601
+ if (result.error) {
602
+ return {
603
+ success: false,
604
+ errorMessage: `${sourcePath}: ${result.message}`,
605
+ };
606
+ }
607
+
608
+ // Route is valid - no fix needed
609
+ console.log(
610
+ picocolors.green("✓"),
611
+ `Route '${route}' is valid in`,
612
+ sourcePath,
613
+ );
614
+ return { success: true };
615
+ }
616
+
617
+ // Fix handler registry
618
+ const currentFixHandlers: Record<ValidationFix, FixHandler> = {
619
+ "image:check-metadata": handleFileMetadata,
620
+ "image:add-metadata": handleFileMetadata,
621
+ "file:check-metadata": handleFileMetadata,
622
+ "file:add-metadata": handleFileMetadata,
623
+ "keyof:check-keys": handleKeyOfCheck,
624
+ "router:check-route": handleRouteCheck,
625
+ "image:upload-remote": handleRemoteFileUpload,
626
+ "file:upload-remote": handleRemoteFileUpload,
627
+ "image:download-remote": handleRemoteFileDownload,
628
+ "file:download-remote": handleRemoteFileDownload,
629
+ "image:check-remote": handleRemoteFileCheck,
630
+ "file:check-remote": handleRemoteFileCheck,
631
+ };
632
+ const deprecatedFixHandlers: Record<string, FixHandler> = {
633
+ "image:replace-metadata": handleFileMetadata,
634
+ };
635
+ const fixHandlers: Record<string, FixHandler> = {
636
+ ...deprecatedFixHandlers,
637
+ ...currentFixHandlers,
638
+ };
639
+
29
640
  export async function validate({
30
641
  root,
31
642
  fix,
@@ -45,48 +656,6 @@ export async function validate({
45
656
  ),
46
657
  );
47
658
  const service = await createService(projectRoot, {});
48
- const checkKeyIsValid = async (
49
- key: string,
50
- sourcePath: string,
51
- ): Promise<{ error: false } | { error: true; message: string }> => {
52
- const [moduleFilePath, modulePath] =
53
- Internal.splitModuleFilePathAndModulePath(sourcePath as SourcePath);
54
- const keyOfModule = await service.get(moduleFilePath, modulePath, {
55
- source: true,
56
- schema: false,
57
- validate: false,
58
- });
59
-
60
- const keyOfModuleSource = keyOfModule.source;
61
- const keyOfModuleSchema = keyOfModule.schema;
62
- if (keyOfModuleSchema && keyOfModuleSchema.type !== "record") {
63
- return {
64
- error: true,
65
- message: `Expected key at ${sourcePath} to be of type 'record'`,
66
- };
67
- }
68
- if (
69
- keyOfModuleSource &&
70
- typeof keyOfModuleSource === "object" &&
71
- key in keyOfModuleSource
72
- ) {
73
- return { error: false };
74
- }
75
- if (!keyOfModuleSource || typeof keyOfModuleSource !== "object") {
76
- return {
77
- error: true,
78
- message: `Expected ${sourcePath} to be a truthy object`,
79
- };
80
- }
81
- const alternatives = findSimilar(key, Object.keys(keyOfModuleSource));
82
- return {
83
- error: true,
84
- message: `Key '${key}' does not exist in ${sourcePath}. Closest match: '${alternatives[0].target}'. Other similar: ${alternatives
85
- .slice(1, 4)
86
- .map((a) => `'${a.target}'`)
87
- .join(", ")}${alternatives.length > 4 ? ", ..." : ""}`,
88
- };
89
- };
90
659
  let prettier;
91
660
  try {
92
661
  prettier = (await import("prettier")).default;
@@ -102,7 +671,7 @@ export async function validate({
102
671
  let errors = 0;
103
672
  console.log(picocolors.greenBright(`Found ${valFiles.length} files...`));
104
673
  let publicProjectId: string | undefined;
105
- let didFix = false; // TODO: ugly
674
+ let didFix = false;
106
675
  async function validateFile(file: string): Promise<number> {
107
676
  const moduleFilePath = `/${file}` as ModuleFilePath; // TODO: check if this always works? (Windows?)
108
677
  const start = Date.now();
@@ -115,7 +684,7 @@ export async function validate({
115
684
  SourcePath,
116
685
  { ref: string; metadata?: Record<string, unknown> }
117
686
  > = {};
118
- let remoteFileBuckets: string[] | null = null;
687
+ let remoteFileBuckets: string[] | undefined = undefined;
119
688
  let remoteFilesCounter = 0;
120
689
  if (!valModule.errors) {
121
690
  console.log(
@@ -133,376 +702,73 @@ export async function validate({
133
702
  valModule.errors.validation,
134
703
  )) {
135
704
  for (const v of validationErrors) {
136
- if (v.fixes && v.fixes.length > 0) {
137
- if (
138
- v.fixes.includes(
139
- "image:replace-metadata" as ValidationFix, // TODO: we can remove this now - we needed before because we changed the name of the fix from replace-metadata to check-metadata
140
- ) ||
141
- v.fixes.includes("image:check-metadata") ||
142
- v.fixes.includes("image:add-metadata") ||
143
- v.fixes.includes("file:check-metadata") ||
144
- v.fixes.includes("file:add-metadata")
145
- ) {
146
- const [, modulePath] =
147
- Internal.splitModuleFilePathAndModulePath(
148
- sourcePath as SourcePath,
149
- );
150
- if (valModule.source && valModule.schema) {
151
- const fileSource = Internal.resolvePath(
152
- modulePath,
153
- valModule.source,
154
- valModule.schema,
155
- );
156
- let filePath: string | null = null;
157
- try {
158
- filePath = path.join(
159
- projectRoot,
160
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
161
- (fileSource.source as any)?.[FILE_REF_PROP],
162
- );
163
- await fs.access(filePath);
164
- } catch {
165
- if (filePath) {
166
- console.log(
167
- picocolors.red("✘"),
168
- `File ${filePath} does not exist`,
169
- );
170
- } else {
171
- console.log(
172
- picocolors.red("✘"),
173
- `Expected file to be defined at: ${sourcePath} but no file was found`,
174
- );
175
- }
176
- errors += 1;
177
- continue;
178
- }
179
- }
180
- } else if (v.fixes.includes("keyof:check-keys")) {
181
- if (
182
- v.value &&
183
- typeof v.value === "object" &&
184
- "key" in v.value &&
185
- "sourcePath" in v.value
186
- ) {
187
- const { key, sourcePath } = v.value;
188
- if (typeof key !== "string") {
189
- console.log(
190
- picocolors.red("✘"),
191
- "Unexpected error in",
192
- `${sourcePath}:`,
193
- v.message,
194
- " (Expected value property 'key' to be a string - this is likely a bug in Val)",
195
- );
196
- errors += 1;
197
- } else if (typeof sourcePath !== "string") {
198
- console.log(
199
- picocolors.red("✘"),
200
- "Unexpected error in",
201
- `${sourcePath}:`,
202
- v.message,
203
- " (Expected value property 'sourcePath' to be a string - this is likely a bug in Val)",
204
- );
205
- errors += 1;
206
- } else {
207
- const res = await checkKeyIsValid(key, sourcePath);
208
- if (res.error) {
209
- console.log(picocolors.red("✘"), res.message);
210
- errors += 1;
211
- }
212
- }
213
- } else {
214
- console.log(
215
- picocolors.red("✘"),
216
- "Unexpected error in",
217
- `${sourcePath}:`,
218
- v.message,
219
- " (Expected value to be an object with 'key' and 'sourcePath' properties - this is likely a bug in Val)",
220
- );
221
- errors += 1;
222
- }
223
- } else if (
224
- v.fixes.includes("image:upload-remote") ||
225
- v.fixes.includes("file:upload-remote")
226
- ) {
227
- if (!fix) {
228
- console.log(
229
- picocolors.red("✘"),
230
- `Remote file ${sourcePath} needs to be uploaded (use --fix to upload)`,
231
- );
232
- errors += 1;
233
- continue;
234
- }
235
- const [, modulePath] =
236
- Internal.splitModuleFilePathAndModulePath(
237
- sourcePath as SourcePath,
238
- );
239
- if (valModule.source && valModule.schema) {
240
- const resolvedRemoteFileAtSourcePath = Internal.resolvePath(
241
- modulePath,
242
- valModule.source,
243
- valModule.schema,
244
- );
245
- let filePath: string | null = null;
246
- try {
247
- filePath = path.join(
248
- projectRoot,
249
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
250
- (resolvedRemoteFileAtSourcePath.source as any)?.[
251
- FILE_REF_PROP
252
- ],
253
- );
254
- await fs.access(filePath);
255
- } catch {
256
- if (filePath) {
257
- console.log(
258
- picocolors.red("✘"),
259
- `File ${filePath} does not exist`,
260
- );
261
- } else {
262
- console.log(
263
- picocolors.red("✘"),
264
- `Expected file to be defined at: ${sourcePath} but no file was found`,
265
- );
266
- }
267
- errors += 1;
268
- continue;
269
- }
270
- const patFile = getPersonalAccessTokenPath(projectRoot);
271
- try {
272
- await fs.access(patFile);
273
- } catch {
274
- // TODO: display this error only once:
275
- console.log(
276
- picocolors.red("✘"),
277
- `File: ${path.join(projectRoot, file)} has remote images that are not uploaded and you are not logged in.\n\nFix this error by logging in:\n\t"npx val login"\n`,
278
- );
279
- errors += 1;
280
- continue;
281
- }
282
-
283
- const parsedPatFile = parsePersonalAccessTokenFile(
284
- await fs.readFile(patFile, "utf-8"),
285
- );
286
- if (!parsedPatFile.success) {
287
- console.log(
288
- picocolors.red("✘"),
289
- `Error parsing personal access token file: ${parsedPatFile.error}. You need to login again.`,
290
- );
291
- errors += 1;
292
- continue;
293
- }
294
- const { pat } = parsedPatFile.data;
295
-
296
- if (remoteFiles[sourcePath as SourcePath]) {
297
- console.log(
298
- picocolors.yellow("⚠"),
299
- `Remote file ${filePath} already uploaded`,
300
- );
301
- continue;
302
- }
303
- // TODO: parallelize uploading files
304
- if (!resolvedRemoteFileAtSourcePath.schema) {
305
- console.log(
306
- picocolors.red("✘"),
307
- `Cannot upload remote file: schema not found for ${sourcePath}`,
308
- );
309
- errors += 1;
310
- continue;
311
- }
312
-
313
- const actualRemoteFileSource =
314
- resolvedRemoteFileAtSourcePath.source;
315
- const fileSourceMetadata = Internal.isFile(
316
- actualRemoteFileSource,
317
- )
318
- ? actualRemoteFileSource.metadata
319
- : undefined;
320
- const resolveRemoteFileSchema =
321
- resolvedRemoteFileAtSourcePath.schema;
322
- if (!resolveRemoteFileSchema) {
323
- console.log(
324
- picocolors.red("✘"),
325
- `Could not resolve schema for remote file: ${sourcePath}`,
326
- );
327
- errors += 1;
328
- continue;
329
- }
330
- if (!publicProjectId || !remoteFileBuckets) {
331
- let projectName = process.env.VAL_PROJECT;
332
- if (!projectName) {
333
- projectName = valConfigFile?.project;
334
- }
335
- if (!projectName) {
336
- console.log(
337
- picocolors.red("✘"),
338
- "Project name not found. Set VAL_PROJECT environment variable or add project name to val.config",
339
- );
340
- errors += 1;
341
- continue;
342
- }
343
- const settingsRes = await getSettings(projectName, {
344
- pat,
345
- });
346
- if (!settingsRes.success) {
347
- console.log(
348
- picocolors.red("✘"),
349
- `Could not get public project id: ${settingsRes.message}.`,
350
- );
351
- errors += 1;
352
- continue;
353
- }
354
- publicProjectId = settingsRes.data.publicProjectId;
355
- remoteFileBuckets =
356
- settingsRes.data.remoteFileBuckets.map((b) => b.bucket);
357
- }
358
- if (!publicProjectId) {
359
- console.log(
360
- picocolors.red("✘"),
361
- "Could not get public project id",
362
- );
363
- errors += 1;
364
- continue;
365
- }
366
- if (!valConfigFile?.project) {
367
- console.log(
368
- picocolors.red("✘"),
369
- `Could not get project. Check that your val.config has the 'project' field set, or set it using the VAL_PROJECT environment variable`,
370
- );
371
- errors += 1;
372
- continue;
373
- }
374
- if (
375
- resolveRemoteFileSchema.type !== "image" &&
376
- resolveRemoteFileSchema.type !== "file"
377
- ) {
378
- console.log(
379
- picocolors.red("✘"),
380
- `The schema is the remote is neither image nor file: ${sourcePath}`,
381
- );
382
- }
383
- remoteFilesCounter += 1;
384
- const bucket =
385
- remoteFileBuckets[
386
- remoteFilesCounter % remoteFileBuckets.length
387
- ];
388
- if (!bucket) {
389
- console.log(
390
- picocolors.red("✘"),
391
- `Internal error: could not allocate a bucket for the remote file located at ${sourcePath}`,
392
- );
393
- errors += 1;
394
- continue;
395
- }
396
- let fileBuffer: Buffer;
397
- try {
398
- fileBuffer = await fs.readFile(filePath);
399
- } catch (e) {
400
- console.log(
401
- picocolors.red("✘"),
402
- `Error reading file: ${e}`,
403
- );
404
- errors += 1;
405
- continue;
406
- }
407
- const relativeFilePath = path
408
- .relative(projectRoot, filePath)
409
- .split(path.sep)
410
- .join("/") as `public/val/${string}`;
411
- if (!relativeFilePath.startsWith("public/val/")) {
412
- console.log(
413
- picocolors.red("✘"),
414
- `File path must be within the public/val/ directory (e.g. public/val/path/to/file.txt). Got: ${relativeFilePath}`,
415
- );
416
- errors += 1;
417
- continue;
418
- }
419
-
420
- const fileHash = Internal.remote.getFileHash(fileBuffer);
421
- const coreVersion = Internal.VERSION.core || "unknown";
422
- const fileExt = getFileExt(filePath);
423
- const schema = resolveRemoteFileSchema as
424
- | SerializedImageSchema
425
- | SerializedFileSchema;
426
- const metadata = fileSourceMetadata;
427
- const ref = Internal.remote.createRemoteRef(valRemoteHost, {
428
- publicProjectId,
429
- coreVersion,
430
- bucket,
431
- validationHash: Internal.remote.getValidationHash(
432
- coreVersion,
433
- schema,
434
- fileExt,
435
- metadata,
436
- fileHash,
437
- textEncoder,
438
- ),
439
- fileHash,
440
- filePath: relativeFilePath,
441
- });
442
- console.log(
443
- picocolors.yellow("⚠"),
444
- `Uploading remote file: '${ref}'...`,
445
- );
446
-
447
- const remoteFileUpload = await uploadRemoteFile(
448
- contentHostUrl,
449
- valConfigFile.project,
450
- bucket,
451
- fileHash,
452
- fileExt,
453
- fileBuffer,
454
- { pat },
455
- );
456
- if (!remoteFileUpload.success) {
457
- console.log(
458
- picocolors.red("✘"),
459
- `Could not upload remote file: '${ref}'. Error: ${remoteFileUpload.error}`,
460
- );
461
- errors += 1;
462
- continue;
463
- }
464
- console.log(
465
- picocolors.green("✔"),
466
- `Completed upload of remote file: '${ref}'`,
467
- );
468
- remoteFiles[sourcePath as SourcePath] = {
469
- ref,
470
- metadata: fileSourceMetadata,
471
- };
472
- }
473
- } else if (
474
- v.fixes.includes("image:download-remote") ||
475
- v.fixes.includes("file:download-remote")
476
- ) {
477
- if (fix) {
478
- console.log(
479
- picocolors.yellow("⚠"),
480
- `Downloading remote file in ${sourcePath}...`,
481
- );
482
- } else {
483
- console.log(
484
- picocolors.red("✘"),
485
- `Remote file ${sourcePath} needs to be downloaded (use --fix to download)`,
486
- );
487
- errors += 1;
488
- continue;
489
- }
490
- } else if (
491
- v.fixes.includes("image:check-remote") ||
492
- v.fixes.includes("file:check-remote")
493
- ) {
494
- // skip
495
- } else {
496
- console.log(
497
- picocolors.red("✘"),
498
- "Unknown fix",
499
- v.fixes,
500
- "for",
501
- sourcePath,
502
- );
503
- errors += 1;
504
- continue;
505
- }
705
+ if (!v.fixes || v.fixes.length === 0) {
706
+ // No fixes available - just report error
707
+ errors += 1;
708
+ console.log(
709
+ picocolors.red("✘"),
710
+ "Got error in",
711
+ `${sourcePath}:`,
712
+ v.message,
713
+ );
714
+ continue;
715
+ }
716
+
717
+ // Find and execute appropriate handler
718
+ const fixType = v.fixes[0]; // Take first fix
719
+ const handler = fixHandlers[fixType];
720
+
721
+ if (!handler) {
722
+ console.log(
723
+ picocolors.red("✘"),
724
+ "Unknown fix",
725
+ v.fixes,
726
+ "for",
727
+ sourcePath,
728
+ );
729
+ errors += 1;
730
+ continue;
731
+ }
732
+
733
+ // Execute handler
734
+ const result = await handler({
735
+ sourcePath: sourcePath as SourcePath,
736
+ validationError: v,
737
+ valModule,
738
+ projectRoot,
739
+ fix: !!fix,
740
+ service,
741
+ valFiles,
742
+ moduleFilePath,
743
+ file,
744
+ remoteFiles,
745
+ publicProjectId,
746
+ remoteFileBuckets,
747
+ remoteFilesCounter,
748
+ valRemoteHost,
749
+ contentHostUrl,
750
+ valConfigFile: valConfigFile ?? undefined,
751
+ });
752
+
753
+ // Update shared state from handler result
754
+ if (result.publicProjectId !== undefined) {
755
+ publicProjectId = result.publicProjectId;
756
+ }
757
+ if (result.remoteFileBuckets !== undefined) {
758
+ remoteFileBuckets = result.remoteFileBuckets;
759
+ }
760
+ if (result.remoteFilesCounter !== undefined) {
761
+ remoteFilesCounter = result.remoteFilesCounter;
762
+ }
763
+
764
+ if (!result.success) {
765
+ console.log(picocolors.red("✘"), result.errorMessage);
766
+ errors += 1;
767
+ continue;
768
+ }
769
+
770
+ // Apply patch if needed
771
+ if (result.shouldApplyPatch) {
506
772
  const fixPatch = await createFixPatch(
507
773
  { projectRoot, remoteHost: valRemoteHost },
508
774
  !!fix,
@@ -512,6 +778,7 @@ export async function validate({
512
778
  valModule.source,
513
779
  valModule.schema,
514
780
  );
781
+
515
782
  if (fix && fixPatch?.patch && fixPatch?.patch.length > 0) {
516
783
  await service.patch(moduleFilePath, fixPatch.patch);
517
784
  didFix = true;
@@ -522,6 +789,7 @@ export async function validate({
522
789
  sourcePath,
523
790
  );
524
791
  }
792
+
525
793
  fixPatch?.remainingErrors?.forEach((e) => {
526
794
  errors += 1;
527
795
  console.log(
@@ -533,14 +801,6 @@ export async function validate({
533
801
  e.message,
534
802
  );
535
803
  });
536
- } else {
537
- errors += 1;
538
- console.log(
539
- picocolors.red("✘"),
540
- "Got error in",
541
- `${sourcePath}:`,
542
- v.message,
543
- );
544
804
  }
545
805
  }
546
806
  }