@valbuild/cli 0.102.0 → 0.103.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/cli/dist/valbuild-cli-cli.cjs.dev.js +13 -550
- package/cli/dist/valbuild-cli-cli.cjs.prod.js +13 -550
- package/cli/dist/valbuild-cli-cli.esm.js +12 -549
- package/package.json +5 -5
- package/src/__fixtures__/basic/content/basic-image-from-galleries.val.ts +2 -2
- package/src/__fixtures__/basic/content/basic-image-from-gallery.val.ts +1 -1
- package/src/__fixtures__/basic/content/basic-image.val.ts +3 -5
- package/src/listUnusedFiles.ts +15 -22
- package/src/runValidation.test.ts +5 -5
- package/src/runValidation.ts +25 -771
- package/src/utils/sourcePathToFileLocation.ts +1 -1
- package/src/utils/getFileExt.ts +0 -4
package/src/runValidation.ts
CHANGED
|
@@ -1,791 +1,45 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import {
|
|
3
|
+
createDefaultValFSHost,
|
|
3
4
|
createFixPatch,
|
|
4
5
|
createService,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
fixHandlers,
|
|
7
|
+
type FixHandlerContext,
|
|
8
|
+
type IValFSHost,
|
|
9
|
+
type IValRemote,
|
|
10
|
+
type ValidationEvent,
|
|
11
|
+
type ValidationError,
|
|
12
|
+
type ValModule,
|
|
10
13
|
} from "@valbuild/server";
|
|
11
14
|
import {
|
|
12
|
-
FILE_REF_PROP,
|
|
13
|
-
Internal,
|
|
14
15
|
type Json,
|
|
15
16
|
ModuleFilePath,
|
|
16
17
|
ModulePath,
|
|
17
|
-
SerializedFileSchema,
|
|
18
|
-
SerializedImageSchema,
|
|
19
18
|
SourcePath,
|
|
20
|
-
ValidationFix,
|
|
21
19
|
} from "@valbuild/core";
|
|
22
20
|
import {
|
|
23
21
|
resolveSchemaSourceFixes,
|
|
24
22
|
type SchemaSourceSnapshot,
|
|
25
23
|
} from "@valbuild/shared/internal";
|
|
26
|
-
import { getFileExt } from "./utils/getFileExt";
|
|
27
|
-
import ts from "typescript";
|
|
28
|
-
import nodeFs from "fs";
|
|
29
|
-
|
|
30
|
-
export type { IValFSHost };
|
|
31
|
-
|
|
32
|
-
export type IValRemote = {
|
|
33
|
-
remoteHost: string;
|
|
34
|
-
getSettings(
|
|
35
|
-
projectName: string,
|
|
36
|
-
options: { pat: string },
|
|
37
|
-
): Promise<
|
|
38
|
-
| {
|
|
39
|
-
success: true;
|
|
40
|
-
data: {
|
|
41
|
-
publicProjectId: string;
|
|
42
|
-
remoteFileBuckets: { bucket: string }[];
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
| { success: false; message: string }
|
|
46
|
-
>;
|
|
47
|
-
uploadFile(
|
|
48
|
-
project: string,
|
|
49
|
-
bucket: string,
|
|
50
|
-
fileHash: string,
|
|
51
|
-
fileExt: string | undefined,
|
|
52
|
-
fileBuffer: Buffer,
|
|
53
|
-
options: { pat: string },
|
|
54
|
-
): Promise<{ success: true } | { success: false; error: string }>;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const textEncoder = new TextEncoder();
|
|
58
|
-
|
|
59
|
-
// Types for handler system
|
|
60
|
-
export type ValModule = Awaited<ReturnType<Service["get"]>>;
|
|
61
|
-
|
|
62
|
-
export type ValidationError = {
|
|
63
|
-
message: string;
|
|
64
|
-
value?: unknown;
|
|
65
|
-
fixes?: ValidationFix[];
|
|
66
|
-
// True when the error is about an object/record key rather than its value.
|
|
67
|
-
keyError?: boolean;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
export type FixHandlerContext = {
|
|
71
|
-
sourcePath: SourcePath;
|
|
72
|
-
validationError: ValidationError;
|
|
73
|
-
valModule: ValModule;
|
|
74
|
-
projectRoot: string;
|
|
75
|
-
fix: boolean;
|
|
76
|
-
service: Service;
|
|
77
|
-
valFiles: string[];
|
|
78
|
-
moduleFilePath: ModuleFilePath;
|
|
79
|
-
file: string;
|
|
80
|
-
fs: IValFSHost;
|
|
81
|
-
// Shared state
|
|
82
|
-
remoteFiles: Record<
|
|
83
|
-
SourcePath,
|
|
84
|
-
{ ref: string; metadata?: Record<string, unknown> }
|
|
85
|
-
>;
|
|
86
|
-
publicProjectId?: string;
|
|
87
|
-
remoteFileBuckets?: string[];
|
|
88
|
-
remoteFilesCounter: number;
|
|
89
|
-
remote: IValRemote;
|
|
90
|
-
project: string | undefined;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
export type FixHandlerResult = {
|
|
94
|
-
success: boolean;
|
|
95
|
-
errorMessage?: string;
|
|
96
|
-
shouldApplyPatch?: boolean;
|
|
97
|
-
// The handler repaired the source itself (it could not be expressed as a
|
|
98
|
-
// patch, so `shouldApplyPatch` does not apply): count it as fixed.
|
|
99
|
-
appliedFix?: boolean;
|
|
100
|
-
// The handler did nothing because `--fix` was off, but the error IS fixable:
|
|
101
|
-
// report it as such instead of as a plain validation error.
|
|
102
|
-
fixableErrorMessage?: string;
|
|
103
|
-
// Updated shared state
|
|
104
|
-
publicProjectId?: string;
|
|
105
|
-
remoteFileBuckets?: string[];
|
|
106
|
-
remoteFilesCounter?: number;
|
|
107
|
-
// Events to emit
|
|
108
|
-
events?: ValidationEvent[];
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
export type FixHandler = (ctx: FixHandlerContext) => Promise<FixHandlerResult>;
|
|
112
|
-
|
|
113
|
-
export type ValidationEvent =
|
|
114
|
-
| { type: "file-valid"; file: string; durationMs: number }
|
|
115
|
-
| {
|
|
116
|
-
type: "file-error-count";
|
|
117
|
-
file: string;
|
|
118
|
-
errorCount: number;
|
|
119
|
-
durationMs: number;
|
|
120
|
-
}
|
|
121
|
-
| {
|
|
122
|
-
type: "validation-error";
|
|
123
|
-
sourcePath: string;
|
|
124
|
-
message: string;
|
|
125
|
-
keyError?: boolean;
|
|
126
|
-
}
|
|
127
|
-
| {
|
|
128
|
-
type: "validation-fixable-error";
|
|
129
|
-
sourcePath: string;
|
|
130
|
-
message: string;
|
|
131
|
-
fixable: boolean;
|
|
132
|
-
keyError?: boolean;
|
|
133
|
-
}
|
|
134
|
-
| {
|
|
135
|
-
type: "unknown-fix";
|
|
136
|
-
sourcePath: string;
|
|
137
|
-
fixes: string[];
|
|
138
|
-
keyError?: boolean;
|
|
139
|
-
}
|
|
140
|
-
| { type: "unregistered-module"; file: string }
|
|
141
|
-
| { type: "fix-applied"; file: string; sourcePath: string }
|
|
142
|
-
| { type: "fatal-error"; file: string; message: string }
|
|
143
|
-
| { type: "remote-uploading"; ref: string }
|
|
144
|
-
| { type: "remote-uploaded"; ref: string }
|
|
145
|
-
| { type: "remote-already-uploaded"; filePath: string }
|
|
146
|
-
| { type: "remote-downloading"; sourcePath: string }
|
|
147
|
-
| { type: "summary-errors"; count: number }
|
|
148
|
-
| { type: "summary-success" };
|
|
149
|
-
|
|
150
|
-
// Handler functions
|
|
151
|
-
export async function handleFileMetadata(
|
|
152
|
-
ctx: FixHandlerContext,
|
|
153
|
-
): Promise<FixHandlerResult> {
|
|
154
|
-
const [, modulePath] = Internal.splitModuleFilePathAndModulePath(
|
|
155
|
-
ctx.sourcePath,
|
|
156
|
-
);
|
|
157
|
-
|
|
158
|
-
if (!ctx.valModule.source || !ctx.valModule.schema) {
|
|
159
|
-
return {
|
|
160
|
-
success: false,
|
|
161
|
-
errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`,
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const fileSource = Internal.resolvePath(
|
|
166
|
-
modulePath,
|
|
167
|
-
ctx.valModule.source,
|
|
168
|
-
ctx.valModule.schema,
|
|
169
|
-
);
|
|
170
|
-
|
|
171
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
172
|
-
const fileRefProp = (fileSource.source as any)?.[FILE_REF_PROP];
|
|
173
|
-
if (!fileRefProp) {
|
|
174
|
-
return {
|
|
175
|
-
success: false,
|
|
176
|
-
errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`,
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const filePath = path.join(ctx.projectRoot, fileRefProp);
|
|
181
|
-
if (!ctx.fs.fileExists(filePath)) {
|
|
182
|
-
return {
|
|
183
|
-
success: false,
|
|
184
|
-
errorMessage: `File ${filePath} does not exist`,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return { success: true, shouldApplyPatch: true };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Shared upload core used by both the single-field (handleRemoteFileUpload)
|
|
192
|
-
// and gallery (handleRemoteGalleryFileUpload) handlers. The two differ only in
|
|
193
|
-
// how they derive the local file ref, metadata and serialized image/file
|
|
194
|
-
// schema; everything from auth through upload is identical.
|
|
195
|
-
async function uploadRemoteFileCore(
|
|
196
|
-
ctx: FixHandlerContext,
|
|
197
|
-
fileRef: string,
|
|
198
|
-
metadata: Record<string, unknown> | undefined,
|
|
199
|
-
schema: SerializedImageSchema | SerializedFileSchema,
|
|
200
|
-
): Promise<FixHandlerResult> {
|
|
201
|
-
const filePath = path.join(ctx.projectRoot, fileRef);
|
|
202
|
-
if (!ctx.fs.fileExists(filePath)) {
|
|
203
|
-
return {
|
|
204
|
-
success: false,
|
|
205
|
-
errorMessage: `File ${filePath} does not exist`,
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const patFile = getPersonalAccessTokenPath(ctx.projectRoot);
|
|
210
|
-
if (!ctx.fs.fileExists(patFile)) {
|
|
211
|
-
return {
|
|
212
|
-
success: false,
|
|
213
|
-
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`,
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const patFileContent = ctx.fs.readFile(patFile);
|
|
218
|
-
if (patFileContent === undefined) {
|
|
219
|
-
return {
|
|
220
|
-
success: false,
|
|
221
|
-
errorMessage: `Could not read personal access token file at ${patFile}`,
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const parsedPatFile = parsePersonalAccessTokenFile(patFileContent);
|
|
226
|
-
if (!parsedPatFile.success) {
|
|
227
|
-
return {
|
|
228
|
-
success: false,
|
|
229
|
-
errorMessage: `Error parsing personal access token file: ${parsedPatFile.error}. You need to login again.`,
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
|
-
const { pat } = parsedPatFile.data;
|
|
233
|
-
|
|
234
|
-
if (ctx.remoteFiles[ctx.sourcePath]) {
|
|
235
|
-
return {
|
|
236
|
-
success: true,
|
|
237
|
-
events: [{ type: "remote-already-uploaded", filePath }],
|
|
238
|
-
};
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const projectName = ctx.project;
|
|
242
|
-
let publicProjectId = ctx.publicProjectId;
|
|
243
|
-
let remoteFileBuckets = ctx.remoteFileBuckets;
|
|
244
|
-
let remoteFilesCounter = ctx.remoteFilesCounter;
|
|
245
|
-
|
|
246
|
-
if (!publicProjectId || !remoteFileBuckets) {
|
|
247
|
-
if (!projectName) {
|
|
248
|
-
return {
|
|
249
|
-
success: false,
|
|
250
|
-
errorMessage:
|
|
251
|
-
"Project name not found. Add project name to val.config or set the VAL_PROJECT environment variable",
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
const settingsRes = await ctx.remote.getSettings(projectName, { pat });
|
|
255
|
-
if (!settingsRes.success) {
|
|
256
|
-
return {
|
|
257
|
-
success: false,
|
|
258
|
-
errorMessage: `Could not get public project id: ${settingsRes.message}.`,
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
publicProjectId = settingsRes.data.publicProjectId;
|
|
262
|
-
remoteFileBuckets = settingsRes.data.remoteFileBuckets.map((b) => b.bucket);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
if (!publicProjectId) {
|
|
266
|
-
return {
|
|
267
|
-
success: false,
|
|
268
|
-
errorMessage: "Could not get public project id",
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
if (!projectName) {
|
|
273
|
-
return {
|
|
274
|
-
success: false,
|
|
275
|
-
errorMessage: `Could not get project. Check that your val.config has the 'project' field set, or set it using the VAL_PROJECT environment variable`,
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
remoteFilesCounter += 1;
|
|
280
|
-
const bucket =
|
|
281
|
-
remoteFileBuckets[remoteFilesCounter % remoteFileBuckets.length];
|
|
282
|
-
|
|
283
|
-
if (!bucket) {
|
|
284
|
-
return {
|
|
285
|
-
success: false,
|
|
286
|
-
errorMessage: `Internal error: could not allocate a bucket for the remote file located at ${ctx.sourcePath}`,
|
|
287
|
-
};
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const fileBuffer = ctx.fs.readBuffer(filePath);
|
|
291
|
-
if (fileBuffer === undefined) {
|
|
292
|
-
return {
|
|
293
|
-
success: false,
|
|
294
|
-
errorMessage: `Error reading file: ${filePath}`,
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const relativeFilePath = path
|
|
299
|
-
.relative(ctx.projectRoot, filePath)
|
|
300
|
-
.split(path.sep)
|
|
301
|
-
.join("/") as `public/${string}`;
|
|
302
|
-
|
|
303
|
-
if (!relativeFilePath.startsWith("public/")) {
|
|
304
|
-
return {
|
|
305
|
-
success: false,
|
|
306
|
-
errorMessage: `File path must be within the public/ directory (e.g. public/path/to/file.txt). Got: ${relativeFilePath}`,
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const fileHash = Internal.remote.getFileHash(fileBuffer);
|
|
311
|
-
const coreVersion = Internal.VERSION.core || "unknown";
|
|
312
|
-
const fileExt = getFileExt(filePath);
|
|
313
|
-
const ref = Internal.remote.createRemoteRef(ctx.remote.remoteHost, {
|
|
314
|
-
publicProjectId,
|
|
315
|
-
coreVersion,
|
|
316
|
-
bucket,
|
|
317
|
-
validationHash: Internal.remote.getValidationHash(
|
|
318
|
-
coreVersion,
|
|
319
|
-
schema,
|
|
320
|
-
fileExt,
|
|
321
|
-
metadata,
|
|
322
|
-
fileHash,
|
|
323
|
-
textEncoder,
|
|
324
|
-
),
|
|
325
|
-
fileHash,
|
|
326
|
-
filePath: relativeFilePath,
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
const remoteFileUpload = await ctx.remote.uploadFile(
|
|
330
|
-
projectName,
|
|
331
|
-
bucket,
|
|
332
|
-
fileHash,
|
|
333
|
-
fileExt,
|
|
334
|
-
fileBuffer,
|
|
335
|
-
{ pat },
|
|
336
|
-
);
|
|
337
|
-
|
|
338
|
-
if (!remoteFileUpload.success) {
|
|
339
|
-
return {
|
|
340
|
-
success: false,
|
|
341
|
-
errorMessage: `Could not upload remote file: '${ref}'. Error: ${remoteFileUpload.error}`,
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
ctx.remoteFiles[ctx.sourcePath] = {
|
|
346
|
-
ref,
|
|
347
|
-
metadata,
|
|
348
|
-
};
|
|
349
|
-
|
|
350
|
-
return {
|
|
351
|
-
success: true,
|
|
352
|
-
shouldApplyPatch: true,
|
|
353
|
-
publicProjectId,
|
|
354
|
-
remoteFileBuckets,
|
|
355
|
-
remoteFilesCounter,
|
|
356
|
-
events: [
|
|
357
|
-
{ type: "remote-uploading", ref },
|
|
358
|
-
{ type: "remote-uploaded", ref },
|
|
359
|
-
],
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
export async function handleRemoteFileUpload(
|
|
364
|
-
ctx: FixHandlerContext,
|
|
365
|
-
): Promise<FixHandlerResult> {
|
|
366
|
-
if (!ctx.fix) {
|
|
367
|
-
return {
|
|
368
|
-
success: false,
|
|
369
|
-
// No sourcePath in the message: the reported location already points at it.
|
|
370
|
-
errorMessage: `Remote file needs to be uploaded (use --fix to upload)`,
|
|
371
|
-
};
|
|
372
|
-
}
|
|
373
24
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
392
|
-
const fileRefProp = (resolvedRemoteFileAtSourcePath.source as any)?.[
|
|
393
|
-
FILE_REF_PROP
|
|
394
|
-
];
|
|
395
|
-
if (!fileRefProp) {
|
|
396
|
-
return {
|
|
397
|
-
success: false,
|
|
398
|
-
errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`,
|
|
399
|
-
};
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const resolveRemoteFileSchema = resolvedRemoteFileAtSourcePath.schema;
|
|
403
|
-
if (!resolveRemoteFileSchema) {
|
|
404
|
-
return {
|
|
405
|
-
success: false,
|
|
406
|
-
errorMessage: `Cannot upload remote file: schema not found for ${ctx.sourcePath}`,
|
|
407
|
-
};
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
if (
|
|
411
|
-
resolveRemoteFileSchema.type !== "image" &&
|
|
412
|
-
resolveRemoteFileSchema.type !== "file"
|
|
413
|
-
) {
|
|
414
|
-
return {
|
|
415
|
-
success: false,
|
|
416
|
-
errorMessage: `The schema is the remote is neither image nor file: ${ctx.sourcePath}`,
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
const actualRemoteFileSource = resolvedRemoteFileAtSourcePath.source;
|
|
421
|
-
const fileSourceMetadata = Internal.isFile(actualRemoteFileSource)
|
|
422
|
-
? actualRemoteFileSource.metadata
|
|
423
|
-
: undefined;
|
|
424
|
-
|
|
425
|
-
return uploadRemoteFileCore(
|
|
426
|
-
ctx,
|
|
427
|
-
fileRefProp,
|
|
428
|
-
fileSourceMetadata,
|
|
429
|
-
resolveRemoteFileSchema,
|
|
430
|
-
);
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// Gallery (s.images({ remote: true }) / s.files({ remote: true })) upload.
|
|
434
|
-
// Unlike a single image/file field, a gallery entry is keyed by its local file
|
|
435
|
-
// path and the value is bare metadata (no FileSource), so we derive the file
|
|
436
|
-
// ref from the key and synthesize the image/file schema from the record's
|
|
437
|
-
// media options.
|
|
438
|
-
export async function handleRemoteGalleryFileUpload(
|
|
439
|
-
ctx: FixHandlerContext,
|
|
440
|
-
): Promise<FixHandlerResult> {
|
|
441
|
-
if (!ctx.fix) {
|
|
442
|
-
return {
|
|
443
|
-
success: false,
|
|
444
|
-
// No sourcePath in the message: the reported location already points at it.
|
|
445
|
-
errorMessage: `Remote file needs to be uploaded (use --fix to upload)`,
|
|
446
|
-
};
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
const fix = ctx.validationError.fixes?.[0];
|
|
450
|
-
const mediaType = fix === "files:upload-remote" ? "file" : "image";
|
|
451
|
-
|
|
452
|
-
// The gallery entry is keyed by its local file path; validateMediaKey carries
|
|
453
|
-
// that key as the error value.
|
|
454
|
-
const fileRef = ctx.validationError.value;
|
|
455
|
-
if (typeof fileRef !== "string") {
|
|
456
|
-
return {
|
|
457
|
-
success: false,
|
|
458
|
-
errorMessage: `Expected a local file path for gallery entry at ${ctx.sourcePath}`,
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
const [, modulePath] = Internal.splitModuleFilePathAndModulePath(
|
|
463
|
-
ctx.sourcePath,
|
|
464
|
-
);
|
|
465
|
-
|
|
466
|
-
if (!ctx.valModule.source || !ctx.valModule.schema) {
|
|
467
|
-
return {
|
|
468
|
-
success: false,
|
|
469
|
-
errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`,
|
|
470
|
-
};
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
const resolved = Internal.resolvePath(
|
|
474
|
-
modulePath,
|
|
475
|
-
ctx.valModule.source,
|
|
476
|
-
ctx.valModule.schema,
|
|
477
|
-
);
|
|
478
|
-
const entrySource = resolved.source;
|
|
479
|
-
const metadata =
|
|
480
|
-
entrySource &&
|
|
481
|
-
typeof entrySource === "object" &&
|
|
482
|
-
!Array.isArray(entrySource)
|
|
483
|
-
? (entrySource as Record<string, unknown>)
|
|
484
|
-
: undefined;
|
|
485
|
-
|
|
486
|
-
// The gallery item schema is an ObjectSchema, not an image/file schema, so
|
|
487
|
-
// synthesize the serialized image/file schema (matching how single fields
|
|
488
|
-
// serialize) for the remote ref's validation hash.
|
|
489
|
-
//
|
|
490
|
-
// `accept`/`directory` come from the RECORD that holds the entry, resolved
|
|
491
|
-
// from the entry's PARENT path - not from the module root, which is only the
|
|
492
|
-
// record when the gallery is the whole module. A nested gallery
|
|
493
|
-
// (s.object({ gallery: s.images(...) })) would otherwise synthesize a schema
|
|
494
|
-
// with no options and bake a validation hash into the remote ref that can
|
|
495
|
-
// never validate, so a mismatch fails fast instead of uploading.
|
|
496
|
-
const [, parentModulePath] = Internal.splitModuleFilePathAndModulePath(
|
|
497
|
-
Internal.parentOfSourcePath(ctx.sourcePath),
|
|
498
|
-
);
|
|
499
|
-
const recordSchema = Internal.resolvePath(
|
|
500
|
-
parentModulePath,
|
|
501
|
-
ctx.valModule.source,
|
|
502
|
-
ctx.valModule.schema,
|
|
503
|
-
).schema;
|
|
504
|
-
if (recordSchema?.type !== "record") {
|
|
505
|
-
return {
|
|
506
|
-
success: false,
|
|
507
|
-
errorMessage: `Expected a gallery record at the parent of ${ctx.sourcePath}, got ${
|
|
508
|
-
recordSchema?.type ?? "nothing"
|
|
509
|
-
}`,
|
|
510
|
-
};
|
|
511
|
-
}
|
|
512
|
-
const { accept, directory } = recordSchema;
|
|
513
|
-
const schema: SerializedImageSchema | SerializedFileSchema =
|
|
514
|
-
mediaType === "image"
|
|
515
|
-
? {
|
|
516
|
-
type: "image",
|
|
517
|
-
opt: false,
|
|
518
|
-
options: {
|
|
519
|
-
...(accept ? { accept } : {}),
|
|
520
|
-
...(directory ? { directory } : {}),
|
|
521
|
-
},
|
|
522
|
-
}
|
|
523
|
-
: {
|
|
524
|
-
type: "file",
|
|
525
|
-
opt: false,
|
|
526
|
-
options: {
|
|
527
|
-
...(accept ? { accept } : {}),
|
|
528
|
-
},
|
|
529
|
-
};
|
|
530
|
-
|
|
531
|
-
return uploadRemoteFileCore(ctx, fileRef, metadata, schema);
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
export async function handleRemoteFileDownload(
|
|
535
|
-
ctx: FixHandlerContext,
|
|
536
|
-
): Promise<FixHandlerResult> {
|
|
537
|
-
if (ctx.fix) {
|
|
538
|
-
return {
|
|
539
|
-
success: true,
|
|
540
|
-
shouldApplyPatch: true,
|
|
541
|
-
events: [{ type: "remote-downloading", sourcePath: ctx.sourcePath }],
|
|
542
|
-
};
|
|
543
|
-
} else {
|
|
544
|
-
return {
|
|
545
|
-
success: false,
|
|
546
|
-
errorMessage: `Remote file ${ctx.sourcePath} needs to be downloaded (use --fix to download)`,
|
|
547
|
-
};
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
export async function handleRemoteFileCheck(): Promise<FixHandlerResult> {
|
|
552
|
-
// Skip - no action needed
|
|
553
|
-
return { success: true, shouldApplyPatch: true };
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
export async function handleUniqueFolderCheck(
|
|
557
|
-
ctx: FixHandlerContext,
|
|
558
|
-
): Promise<FixHandlerResult> {
|
|
559
|
-
const value = ctx.validationError.value as
|
|
560
|
-
| { directory: string; type: string }
|
|
561
|
-
| undefined;
|
|
562
|
-
if (!value || typeof value.directory !== "string") {
|
|
563
|
-
return {
|
|
564
|
-
success: false,
|
|
565
|
-
errorMessage: `Unexpected value in unique folder check for ${ctx.sourcePath}`,
|
|
566
|
-
};
|
|
567
|
-
}
|
|
568
|
-
const { directory } = value;
|
|
569
|
-
const conflicts: string[] = [];
|
|
570
|
-
for (const file of ctx.valFiles) {
|
|
571
|
-
const otherModuleFilePath = `/${file}` as ModuleFilePath;
|
|
572
|
-
if (otherModuleFilePath === ctx.moduleFilePath) continue;
|
|
573
|
-
const otherModule = await ctx.service.get(
|
|
574
|
-
otherModuleFilePath,
|
|
575
|
-
"" as ModulePath,
|
|
576
|
-
{ validate: false },
|
|
577
|
-
);
|
|
578
|
-
const schema = otherModule.schema as
|
|
579
|
-
| { type?: string; directory?: string; mediaType?: string }
|
|
580
|
-
| undefined;
|
|
581
|
-
if (
|
|
582
|
-
schema?.type === "record" &&
|
|
583
|
-
schema.directory === directory &&
|
|
584
|
-
schema.mediaType
|
|
585
|
-
) {
|
|
586
|
-
conflicts.push(otherModuleFilePath);
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
if (conflicts.length > 0) {
|
|
590
|
-
return {
|
|
591
|
-
success: false,
|
|
592
|
-
errorMessage: `Gallery directory '${directory}' in ${ctx.moduleFilePath} is also used by: ${conflicts.join(", ")}. Each gallery must use a unique directory.`,
|
|
593
|
-
};
|
|
594
|
-
}
|
|
595
|
-
return { success: true };
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// Maps a gallery key to its on-disk local path. Remote galleries key uploaded
|
|
599
|
-
// entries by a remote URL while keeping the file on disk; everything else is
|
|
600
|
-
// already a local path and is returned unchanged.
|
|
601
|
-
function remoteKeyToLocalPath(key: string): string {
|
|
602
|
-
const remoteRefRes = Internal.remote.splitRemoteRef(key);
|
|
603
|
-
if (remoteRefRes.status === "success") {
|
|
604
|
-
return `/${remoteRefRes.filePath}`;
|
|
605
|
-
}
|
|
606
|
-
return key;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
export async function handleCheckAllFiles(
|
|
610
|
-
ctx: FixHandlerContext,
|
|
611
|
-
): Promise<FixHandlerResult> {
|
|
612
|
-
const value = ctx.validationError.value as
|
|
613
|
-
| { directory: string; type: string }
|
|
614
|
-
| undefined;
|
|
615
|
-
if (!value || typeof value.directory !== "string") {
|
|
616
|
-
return {
|
|
617
|
-
success: false,
|
|
618
|
-
errorMessage: `Unexpected value in check-all-files for ${ctx.sourcePath}`,
|
|
619
|
-
};
|
|
620
|
-
}
|
|
621
|
-
const { directory } = value;
|
|
622
|
-
|
|
623
|
-
const source = ctx.valModule.source;
|
|
624
|
-
if (!source || typeof source !== "object" || Array.isArray(source)) {
|
|
625
|
-
return {
|
|
626
|
-
success: false,
|
|
627
|
-
errorMessage: `Could not get source for ${ctx.sourcePath}`,
|
|
628
|
-
};
|
|
629
|
-
}
|
|
630
|
-
// Gallery entries are keyed by their file path. Remote galleries key uploaded
|
|
631
|
-
// entries by a remote URL, but the file is kept on disk at its local path, so
|
|
632
|
-
// normalize remote-URL keys back to that local path for the on-disk checks.
|
|
633
|
-
const trackedFiles = new Set(
|
|
634
|
-
Object.keys(source as Record<string, unknown>).map(remoteKeyToLocalPath),
|
|
635
|
-
);
|
|
636
|
-
|
|
637
|
-
// Check that all tracked files exist on disk
|
|
638
|
-
const missingTrackedFiles = [...trackedFiles].filter((f) => {
|
|
639
|
-
return !ctx.fs.fileExists(path.join(ctx.projectRoot, f));
|
|
640
|
-
});
|
|
641
|
-
if (missingTrackedFiles.length > 0) {
|
|
642
|
-
if (!ctx.fix) {
|
|
643
|
-
return {
|
|
644
|
-
success: false,
|
|
645
|
-
errorMessage: `Gallery in ${ctx.moduleFilePath} has tracked files that do not exist on disk: ${missingTrackedFiles.join(", ")}. Add the files or remove them from the gallery.`,
|
|
646
|
-
};
|
|
647
|
-
}
|
|
648
|
-
// fix: true — let createFixPatch remove the missing entries
|
|
649
|
-
return { success: true, shouldApplyPatch: true };
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
const dirPath = path.join(ctx.projectRoot, directory);
|
|
653
|
-
|
|
654
|
-
const filesInDir: string[] = [];
|
|
655
|
-
try {
|
|
656
|
-
const entries = ctx.fs.readDirectory(dirPath, undefined, undefined, [
|
|
657
|
-
"**/*",
|
|
658
|
-
]);
|
|
659
|
-
for (const entry of entries) {
|
|
660
|
-
const relPath =
|
|
661
|
-
"/" + path.relative(ctx.projectRoot, entry).split(path.sep).join("/");
|
|
662
|
-
filesInDir.push(relPath);
|
|
663
|
-
}
|
|
664
|
-
} catch {
|
|
665
|
-
// directory doesn't exist — no untracked files possible
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
const untrackedFiles = filesInDir.filter((f) => !trackedFiles.has(f));
|
|
669
|
-
if (untrackedFiles.length > 0) {
|
|
670
|
-
return {
|
|
671
|
-
success: false,
|
|
672
|
-
errorMessage: `Gallery in ${ctx.moduleFilePath} has files not tracked: ${untrackedFiles.join(", ")}. Add these files to the gallery or remove them from the directory.`,
|
|
673
|
-
};
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
// All files accounted for — trigger metadata verification via createFixPatch
|
|
677
|
-
return { success: true, shouldApplyPatch: true };
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
export async function handleJsonValuesExtractEntry(
|
|
681
|
-
ctx: FixHandlerContext,
|
|
682
|
-
): Promise<FixHandlerResult> {
|
|
683
|
-
const [, modulePath] = Internal.splitModuleFilePathAndModulePath(
|
|
684
|
-
ctx.sourcePath,
|
|
685
|
-
);
|
|
686
|
-
const parts = Internal.splitModulePath(modulePath);
|
|
687
|
-
// Root-only by contract (see findNestedJsonValuesRecords): the entry is a
|
|
688
|
-
// direct child of the module's root record/router, so the path is one segment.
|
|
689
|
-
if (parts.length !== 1) {
|
|
690
|
-
return {
|
|
691
|
-
success: false,
|
|
692
|
-
errorMessage: `Cannot extract .jsonValues() entry at ${ctx.sourcePath}: expected a root record entry`,
|
|
693
|
-
};
|
|
694
|
-
}
|
|
695
|
-
const entryKey = parts[0];
|
|
696
|
-
const source = ctx.valModule.source;
|
|
697
|
-
if (source === null || typeof source !== "object" || Array.isArray(source)) {
|
|
698
|
-
return {
|
|
699
|
-
success: false,
|
|
700
|
-
errorMessage: `Could not get source for ${ctx.moduleFilePath}`,
|
|
701
|
-
};
|
|
702
|
-
}
|
|
703
|
-
const content = (source as Record<string, Json>)[entryKey];
|
|
704
|
-
if (content === undefined) {
|
|
705
|
-
return {
|
|
706
|
-
success: false,
|
|
707
|
-
errorMessage: `Could not find .jsonValues() entry '${entryKey}' in ${ctx.moduleFilePath}`,
|
|
708
|
-
};
|
|
709
|
-
}
|
|
710
|
-
if (!ctx.fix) {
|
|
711
|
-
return {
|
|
712
|
-
success: true,
|
|
713
|
-
fixableErrorMessage: ctx.validationError.message,
|
|
714
|
-
};
|
|
715
|
-
}
|
|
716
|
-
try {
|
|
717
|
-
extractJsonValuesEntry(
|
|
718
|
-
ctx.moduleFilePath,
|
|
719
|
-
ctx.projectRoot,
|
|
720
|
-
entryKey,
|
|
721
|
-
content,
|
|
722
|
-
ctx.service.sourceFileHandler,
|
|
723
|
-
);
|
|
724
|
-
} catch (err) {
|
|
725
|
-
return {
|
|
726
|
-
success: false,
|
|
727
|
-
errorMessage: err instanceof Error ? err.message : String(err),
|
|
728
|
-
};
|
|
729
|
-
}
|
|
730
|
-
return { success: true, appliedFix: true };
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
// Fix handler registry. `keyof:check-keys` and `router:check-route` are
|
|
734
|
-
// resolved upfront by the shared resolveSchemaSourceFixes — they never reach
|
|
735
|
-
// this registry, so they're excluded from the key set.
|
|
736
|
-
export const currentFixHandlers: Record<
|
|
737
|
-
Exclude<ValidationFix, "keyof:check-keys" | "router:check-route">,
|
|
738
|
-
FixHandler
|
|
739
|
-
> = {
|
|
740
|
-
"image:check-metadata": handleFileMetadata,
|
|
741
|
-
"image:add-metadata": handleFileMetadata,
|
|
742
|
-
"file:check-metadata": handleFileMetadata,
|
|
743
|
-
"file:add-metadata": handleFileMetadata,
|
|
744
|
-
"image:upload-remote": handleRemoteFileUpload,
|
|
745
|
-
"file:upload-remote": handleRemoteFileUpload,
|
|
746
|
-
"images:upload-remote": handleRemoteGalleryFileUpload,
|
|
747
|
-
"files:upload-remote": handleRemoteGalleryFileUpload,
|
|
748
|
-
"image:download-remote": handleRemoteFileDownload,
|
|
749
|
-
"file:download-remote": handleRemoteFileDownload,
|
|
750
|
-
"image:check-remote": handleRemoteFileCheck,
|
|
751
|
-
"images:check-remote": handleRemoteFileCheck,
|
|
752
|
-
"file:check-remote": handleRemoteFileCheck,
|
|
753
|
-
"files:check-remote": handleRemoteFileCheck,
|
|
754
|
-
"images:check-unique-folder": handleUniqueFolderCheck,
|
|
755
|
-
"files:check-unique-folder": handleUniqueFolderCheck,
|
|
756
|
-
"images:check-all-files": handleCheckAllFiles,
|
|
757
|
-
"files:check-all-files": handleCheckAllFiles,
|
|
758
|
-
"jsonValues:extract-entry": handleJsonValuesExtractEntry,
|
|
759
|
-
};
|
|
760
|
-
const deprecatedFixHandlers: Record<string, FixHandler> = {
|
|
761
|
-
"image:replace-metadata": handleFileMetadata,
|
|
762
|
-
};
|
|
763
|
-
export const fixHandlers: Record<string, FixHandler> = {
|
|
764
|
-
...deprecatedFixHandlers,
|
|
765
|
-
...currentFixHandlers,
|
|
25
|
+
/**
|
|
26
|
+
* Driving `val validate` over a project.
|
|
27
|
+
*
|
|
28
|
+
* The fix handlers themselves live in `@valbuild/server` (`fixHandlers.ts`), so
|
|
29
|
+
* that the language server runs exactly the same ones and an editor fix cannot
|
|
30
|
+
* drift from a CLI fix. What is left here is the part that is genuinely the
|
|
31
|
+
* CLI's: walking every module, deciding what counts as reportable, and emitting
|
|
32
|
+
* the event stream the terminal renders.
|
|
33
|
+
*/
|
|
34
|
+
export type {
|
|
35
|
+
FixHandlerContext,
|
|
36
|
+
IValFSHost,
|
|
37
|
+
IValRemote,
|
|
38
|
+
ValidationEvent,
|
|
39
|
+
ValidationError,
|
|
40
|
+
ValModule,
|
|
766
41
|
};
|
|
767
|
-
|
|
768
|
-
export function createDefaultValFSHost(): IValFSHost {
|
|
769
|
-
return {
|
|
770
|
-
...ts.sys,
|
|
771
|
-
writeFile: (fileName, data, encoding) => {
|
|
772
|
-
nodeFs.mkdirSync(path.dirname(fileName), { recursive: true });
|
|
773
|
-
nodeFs.writeFileSync(
|
|
774
|
-
fileName,
|
|
775
|
-
typeof data === "string" ? data : new Uint8Array(data),
|
|
776
|
-
encoding,
|
|
777
|
-
);
|
|
778
|
-
},
|
|
779
|
-
rmFile: nodeFs.rmSync,
|
|
780
|
-
readBuffer: (fileName) => {
|
|
781
|
-
try {
|
|
782
|
-
return nodeFs.readFileSync(fileName);
|
|
783
|
-
} catch {
|
|
784
|
-
return undefined;
|
|
785
|
-
}
|
|
786
|
-
},
|
|
787
|
-
};
|
|
788
|
-
}
|
|
42
|
+
export { createDefaultValFSHost, fixHandlers };
|
|
789
43
|
|
|
790
44
|
export async function* runValidation({
|
|
791
45
|
root,
|