@valbuild/cli 0.97.7 → 0.99.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.
@@ -181,45 +181,17 @@ export async function handleFileMetadata(
181
181
  return { success: true, shouldApplyPatch: true };
182
182
  }
183
183
 
184
- export async function handleRemoteFileUpload(
184
+ // Shared upload core used by both the single-field (handleRemoteFileUpload)
185
+ // and gallery (handleRemoteGalleryFileUpload) handlers. The two differ only in
186
+ // how they derive the local file ref, metadata and serialized image/file
187
+ // schema; everything from auth through upload is identical.
188
+ async function uploadRemoteFileCore(
185
189
  ctx: FixHandlerContext,
190
+ fileRef: string,
191
+ metadata: Record<string, unknown> | undefined,
192
+ schema: SerializedImageSchema | SerializedFileSchema,
186
193
  ): Promise<FixHandlerResult> {
187
- if (!ctx.fix) {
188
- return {
189
- success: false,
190
- errorMessage: `Remote file ${ctx.sourcePath} needs to be uploaded (use --fix to upload)`,
191
- };
192
- }
193
-
194
- const [, modulePath] = Internal.splitModuleFilePathAndModulePath(
195
- ctx.sourcePath,
196
- );
197
-
198
- if (!ctx.valModule.source || !ctx.valModule.schema) {
199
- return {
200
- success: false,
201
- errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`,
202
- };
203
- }
204
-
205
- const resolvedRemoteFileAtSourcePath = Internal.resolvePath(
206
- modulePath,
207
- ctx.valModule.source,
208
- ctx.valModule.schema,
209
- );
210
-
211
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
212
- const fileRefProp = (resolvedRemoteFileAtSourcePath.source as any)?.[
213
- FILE_REF_PROP
214
- ];
215
- if (!fileRefProp) {
216
- return {
217
- success: false,
218
- errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`,
219
- };
220
- }
221
-
222
- const filePath = path.join(ctx.projectRoot, fileRefProp);
194
+ const filePath = path.join(ctx.projectRoot, fileRef);
223
195
  if (!ctx.fs.fileExists(filePath)) {
224
196
  return {
225
197
  success: false,
@@ -259,26 +231,6 @@ export async function handleRemoteFileUpload(
259
231
  };
260
232
  }
261
233
 
262
- if (!resolvedRemoteFileAtSourcePath.schema) {
263
- return {
264
- success: false,
265
- errorMessage: `Cannot upload remote file: schema not found for ${ctx.sourcePath}`,
266
- };
267
- }
268
-
269
- const actualRemoteFileSource = resolvedRemoteFileAtSourcePath.source;
270
- const fileSourceMetadata = Internal.isFile(actualRemoteFileSource)
271
- ? actualRemoteFileSource.metadata
272
- : undefined;
273
- const resolveRemoteFileSchema = resolvedRemoteFileAtSourcePath.schema;
274
-
275
- if (!resolveRemoteFileSchema) {
276
- return {
277
- success: false,
278
- errorMessage: `Could not resolve schema for remote file: ${ctx.sourcePath}`,
279
- };
280
- }
281
-
282
234
  const projectName = ctx.project;
283
235
  let publicProjectId = ctx.publicProjectId;
284
236
  let remoteFileBuckets = ctx.remoteFileBuckets;
@@ -317,16 +269,6 @@ export async function handleRemoteFileUpload(
317
269
  };
318
270
  }
319
271
 
320
- if (
321
- resolveRemoteFileSchema.type !== "image" &&
322
- resolveRemoteFileSchema.type !== "file"
323
- ) {
324
- return {
325
- success: false,
326
- errorMessage: `The schema is the remote is neither image nor file: ${ctx.sourcePath}`,
327
- };
328
- }
329
-
330
272
  remoteFilesCounter += 1;
331
273
  const bucket =
332
274
  remoteFileBuckets[remoteFilesCounter % remoteFileBuckets.length];
@@ -361,10 +303,6 @@ export async function handleRemoteFileUpload(
361
303
  const fileHash = Internal.remote.getFileHash(fileBuffer);
362
304
  const coreVersion = Internal.VERSION.core || "unknown";
363
305
  const fileExt = getFileExt(filePath);
364
- const schema = resolveRemoteFileSchema as
365
- | SerializedImageSchema
366
- | SerializedFileSchema;
367
- const metadata = fileSourceMetadata;
368
306
  const ref = Internal.remote.createRemoteRef(ctx.remote.remoteHost, {
369
307
  publicProjectId,
370
308
  coreVersion,
@@ -399,7 +337,7 @@ export async function handleRemoteFileUpload(
399
337
 
400
338
  ctx.remoteFiles[ctx.sourcePath] = {
401
339
  ref,
402
- metadata: fileSourceMetadata,
340
+ metadata,
403
341
  };
404
342
 
405
343
  return {
@@ -415,6 +353,177 @@ export async function handleRemoteFileUpload(
415
353
  };
416
354
  }
417
355
 
356
+ export async function handleRemoteFileUpload(
357
+ ctx: FixHandlerContext,
358
+ ): Promise<FixHandlerResult> {
359
+ if (!ctx.fix) {
360
+ return {
361
+ success: false,
362
+ // No sourcePath in the message: the reported location already points at it.
363
+ errorMessage: `Remote file needs to be uploaded (use --fix to upload)`,
364
+ };
365
+ }
366
+
367
+ const [, modulePath] = Internal.splitModuleFilePathAndModulePath(
368
+ ctx.sourcePath,
369
+ );
370
+
371
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
372
+ return {
373
+ success: false,
374
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`,
375
+ };
376
+ }
377
+
378
+ const resolvedRemoteFileAtSourcePath = Internal.resolvePath(
379
+ modulePath,
380
+ ctx.valModule.source,
381
+ ctx.valModule.schema,
382
+ );
383
+
384
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
385
+ const fileRefProp = (resolvedRemoteFileAtSourcePath.source as any)?.[
386
+ FILE_REF_PROP
387
+ ];
388
+ if (!fileRefProp) {
389
+ return {
390
+ success: false,
391
+ errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`,
392
+ };
393
+ }
394
+
395
+ const resolveRemoteFileSchema = resolvedRemoteFileAtSourcePath.schema;
396
+ if (!resolveRemoteFileSchema) {
397
+ return {
398
+ success: false,
399
+ errorMessage: `Cannot upload remote file: schema not found for ${ctx.sourcePath}`,
400
+ };
401
+ }
402
+
403
+ if (
404
+ resolveRemoteFileSchema.type !== "image" &&
405
+ resolveRemoteFileSchema.type !== "file"
406
+ ) {
407
+ return {
408
+ success: false,
409
+ errorMessage: `The schema is the remote is neither image nor file: ${ctx.sourcePath}`,
410
+ };
411
+ }
412
+
413
+ const actualRemoteFileSource = resolvedRemoteFileAtSourcePath.source;
414
+ const fileSourceMetadata = Internal.isFile(actualRemoteFileSource)
415
+ ? actualRemoteFileSource.metadata
416
+ : undefined;
417
+
418
+ return uploadRemoteFileCore(
419
+ ctx,
420
+ fileRefProp,
421
+ fileSourceMetadata,
422
+ resolveRemoteFileSchema,
423
+ );
424
+ }
425
+
426
+ // Gallery (s.images({ remote: true }) / s.files({ remote: true })) upload.
427
+ // Unlike a single image/file field, a gallery entry is keyed by its local file
428
+ // path and the value is bare metadata (no FileSource), so we derive the file
429
+ // ref from the key and synthesize the image/file schema from the record's
430
+ // media options.
431
+ export async function handleRemoteGalleryFileUpload(
432
+ ctx: FixHandlerContext,
433
+ ): Promise<FixHandlerResult> {
434
+ if (!ctx.fix) {
435
+ return {
436
+ success: false,
437
+ // No sourcePath in the message: the reported location already points at it.
438
+ errorMessage: `Remote file needs to be uploaded (use --fix to upload)`,
439
+ };
440
+ }
441
+
442
+ const fix = ctx.validationError.fixes?.[0];
443
+ const mediaType = fix === "files:upload-remote" ? "file" : "image";
444
+
445
+ // The gallery entry is keyed by its local file path; validateMediaKey carries
446
+ // that key as the error value.
447
+ const fileRef = ctx.validationError.value;
448
+ if (typeof fileRef !== "string") {
449
+ return {
450
+ success: false,
451
+ errorMessage: `Expected a local file path for gallery entry at ${ctx.sourcePath}`,
452
+ };
453
+ }
454
+
455
+ const [, modulePath] = Internal.splitModuleFilePathAndModulePath(
456
+ ctx.sourcePath,
457
+ );
458
+
459
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
460
+ return {
461
+ success: false,
462
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`,
463
+ };
464
+ }
465
+
466
+ const resolved = Internal.resolvePath(
467
+ modulePath,
468
+ ctx.valModule.source,
469
+ ctx.valModule.schema,
470
+ );
471
+ const entrySource = resolved.source;
472
+ const metadata =
473
+ entrySource &&
474
+ typeof entrySource === "object" &&
475
+ !Array.isArray(entrySource)
476
+ ? (entrySource as Record<string, unknown>)
477
+ : undefined;
478
+
479
+ // The gallery item schema is an ObjectSchema, not an image/file schema, so
480
+ // synthesize the serialized image/file schema (matching how single fields
481
+ // serialize) for the remote ref's validation hash.
482
+ //
483
+ // `accept`/`directory` come from the RECORD that holds the entry, resolved
484
+ // from the entry's PARENT path - not from the module root, which is only the
485
+ // record when the gallery is the whole module. A nested gallery
486
+ // (s.object({ gallery: s.images(...) })) would otherwise synthesize a schema
487
+ // with no options and bake a validation hash into the remote ref that can
488
+ // never validate, so a mismatch fails fast instead of uploading.
489
+ const [, parentModulePath] = Internal.splitModuleFilePathAndModulePath(
490
+ Internal.parentOfSourcePath(ctx.sourcePath),
491
+ );
492
+ const recordSchema = Internal.resolvePath(
493
+ parentModulePath,
494
+ ctx.valModule.source,
495
+ ctx.valModule.schema,
496
+ ).schema;
497
+ if (recordSchema?.type !== "record") {
498
+ return {
499
+ success: false,
500
+ errorMessage: `Expected a gallery record at the parent of ${ctx.sourcePath}, got ${
501
+ recordSchema?.type ?? "nothing"
502
+ }`,
503
+ };
504
+ }
505
+ const { accept, directory } = recordSchema;
506
+ const schema: SerializedImageSchema | SerializedFileSchema =
507
+ mediaType === "image"
508
+ ? {
509
+ type: "image",
510
+ opt: false,
511
+ options: {
512
+ ...(accept ? { accept } : {}),
513
+ ...(directory ? { directory } : {}),
514
+ },
515
+ }
516
+ : {
517
+ type: "file",
518
+ opt: false,
519
+ options: {
520
+ ...(accept ? { accept } : {}),
521
+ },
522
+ };
523
+
524
+ return uploadRemoteFileCore(ctx, fileRef, metadata, schema);
525
+ }
526
+
418
527
  export async function handleRemoteFileDownload(
419
528
  ctx: FixHandlerContext,
420
529
  ): Promise<FixHandlerResult> {
@@ -479,6 +588,17 @@ export async function handleUniqueFolderCheck(
479
588
  return { success: true };
480
589
  }
481
590
 
591
+ // Maps a gallery key to its on-disk local path. Remote galleries key uploaded
592
+ // entries by a remote URL while keeping the file on disk; everything else is
593
+ // already a local path and is returned unchanged.
594
+ function remoteKeyToLocalPath(key: string): string {
595
+ const remoteRefRes = Internal.remote.splitRemoteRef(key);
596
+ if (remoteRefRes.status === "success") {
597
+ return `/${remoteRefRes.filePath}`;
598
+ }
599
+ return key;
600
+ }
601
+
482
602
  export async function handleCheckAllFiles(
483
603
  ctx: FixHandlerContext,
484
604
  ): Promise<FixHandlerResult> {
@@ -500,7 +620,12 @@ export async function handleCheckAllFiles(
500
620
  errorMessage: `Could not get source for ${ctx.sourcePath}`,
501
621
  };
502
622
  }
503
- const trackedFiles = new Set(Object.keys(source as Record<string, unknown>));
623
+ // Gallery entries are keyed by their file path. Remote galleries key uploaded
624
+ // entries by a remote URL, but the file is kept on disk at its local path, so
625
+ // normalize remote-URL keys back to that local path for the on-disk checks.
626
+ const trackedFiles = new Set(
627
+ Object.keys(source as Record<string, unknown>).map(remoteKeyToLocalPath),
628
+ );
504
629
 
505
630
  // Check that all tracked files exist on disk
506
631
  const missingTrackedFiles = [...trackedFiles].filter((f) => {
@@ -558,6 +683,8 @@ export const currentFixHandlers: Record<
558
683
  "file:add-metadata": handleFileMetadata,
559
684
  "image:upload-remote": handleRemoteFileUpload,
560
685
  "file:upload-remote": handleRemoteFileUpload,
686
+ "images:upload-remote": handleRemoteGalleryFileUpload,
687
+ "files:upload-remote": handleRemoteGalleryFileUpload,
561
688
  "image:download-remote": handleRemoteFileDownload,
562
689
  "file:download-remote": handleRemoteFileDownload,
563
690
  "image:check-remote": handleRemoteFileCheck,
@@ -1,100 +1,6 @@
1
- import path from "path";
2
- import fs from "fs/promises";
3
- import vm from "node:vm";
4
- import ts from "typescript"; // TODO: make this dependency optional (only required if the file is val.config.ts not val.config.js)
5
- import z from "zod";
6
- import { ValConfig } from "@valbuild/core";
7
- import { createRequire } from "node:module";
8
-
9
- const ValConfigSchema = z.object({
10
- project: z.string().optional(),
11
- root: z.string().optional(),
12
- files: z
13
- .object({
14
- directory: z.string().refine(
15
- (val): val is `/public` | `/public/${string}` =>
16
- (val === "/public" ||
17
- (val.startsWith("/public/") && !val.endsWith("/"))) &&
18
- // Reject path traversal so the directory cannot escape /public
19
- !val
20
- .split("/")
21
- .some((segment) => segment === "." || segment === ".."),
22
- {
23
- message:
24
- "files.directory must start with '/public', must not end with '/' and must not contain '.' or '..' segments",
25
- },
26
- ),
27
- })
28
- .optional(),
29
- gitCommit: z.string().optional(),
30
- gitBranch: z.string().optional(),
31
- defaultTheme: z.union([z.literal("light"), z.literal("dark")]).optional(),
32
- ai: z
33
- .object({
34
- commitMessages: z
35
- .object({
36
- disabled: z.boolean().optional(),
37
- })
38
- .optional(),
39
- })
40
- .optional(),
41
- });
42
-
43
- export async function evalValConfigFile(
44
- projectRoot: string,
45
- configFileName: string,
46
- ): Promise<ValConfig | null> {
47
- const valConfigPath = path.join(projectRoot, configFileName);
48
-
49
- let code: string | null = null;
50
- try {
51
- code = await fs.readFile(valConfigPath, "utf-8");
52
- } catch {
53
- //
54
- }
55
- if (!code) {
56
- return null;
57
- }
58
-
59
- const transpiled = ts.transpileModule(code, {
60
- compilerOptions: {
61
- target: ts.ScriptTarget.ES2020,
62
- module: ts.ModuleKind.CommonJS,
63
- esModuleInterop: true,
64
- },
65
- fileName: valConfigPath,
66
- });
67
-
68
- const projectRootRequire = createRequire(valConfigPath);
69
- const exportsObj = {};
70
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
- const sandbox: Record<string, any> = {
72
- exports: exportsObj,
73
- module: { exports: exportsObj },
74
- require: projectRootRequire, // NOTE: this is a security risk, but this code is running in the users own environment at the CLI level
75
- __filename: valConfigPath,
76
- __dirname: projectRoot,
77
- console,
78
- process,
79
- };
80
- sandbox.global = sandbox;
81
-
82
- const context = vm.createContext(sandbox);
83
- const script = new vm.Script(transpiled.outputText, {
84
- filename: valConfigPath,
85
- });
86
- script.runInContext(context);
87
- const valConfig = sandbox.module.exports.config;
88
- if (!valConfig) {
89
- throw Error(
90
- `Val config file at path: '${valConfigPath}' must export a config object. Got: ${valConfig}`,
91
- );
92
- }
93
- const result = ValConfigSchema.safeParse(valConfig);
94
- if (!result.success) {
95
- throw Error(
96
- `Val config file at path: '${valConfigPath}' has invalid schema: ${result.error.message}`,
97
- );
98
- }
99
- return result.data;
100
- }
1
+ /**
2
+ * Moved to `@valbuild/server` so that the CLI and
3
+ * `@valbuild/language-server` resolve project config through the same code.
4
+ * Re-exported here to keep existing CLI imports working.
5
+ */
6
+ export { evalValConfigFile, findAndEvalValConfigFile } from "@valbuild/server";
package/src/validate.ts CHANGED
@@ -4,7 +4,7 @@ import fs from "fs/promises";
4
4
  import { glob } from "fast-glob";
5
5
  import { DEFAULT_CONTENT_HOST, DEFAULT_VAL_REMOTE_HOST } from "@valbuild/core";
6
6
  import { getSettings, uploadRemoteFile } from "@valbuild/server";
7
- import { evalValConfigFile } from "./utils/evalValConfigFile";
7
+ import { findAndEvalValConfigFile } from "./utils/evalValConfigFile";
8
8
  import { createDefaultValFSHost, runValidation } from "./runValidation";
9
9
  import {
10
10
  sourcePathToCodeFrame,
@@ -49,9 +49,7 @@ export async function validate({
49
49
  // errors found. Re-reads config and val files each call so it always reflects
50
50
  // the latest state on disk (used both for one-shot and watch mode).
51
51
  async function runOnce(): Promise<number> {
52
- const valConfigFile =
53
- (await evalValConfigFile(projectRoot, "val.config.ts")) ||
54
- (await evalValConfigFile(projectRoot, "val.config.js"));
52
+ const valConfigFile = await findAndEvalValConfigFile(projectRoot);
55
53
 
56
54
  const resolvedValConfigFile = valConfigFile
57
55
  ? {