@valbuild/cli 0.98.0 → 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.
@@ -100,32 +100,13 @@ async function handleFileMetadata(ctx) {
100
100
  shouldApplyPatch: true
101
101
  };
102
102
  }
103
- async function handleRemoteFileUpload(ctx) {
104
- var _resolvedRemoteFileAt;
105
- if (!ctx.fix) {
106
- return {
107
- success: false,
108
- errorMessage: `Remote file ${ctx.sourcePath} needs to be uploaded (use --fix to upload)`
109
- };
110
- }
111
- const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
112
- if (!ctx.valModule.source || !ctx.valModule.schema) {
113
- return {
114
- success: false,
115
- errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
116
- };
117
- }
118
- const resolvedRemoteFileAtSourcePath = core.Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
119
103
 
120
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
121
- const fileRefProp = (_resolvedRemoteFileAt = resolvedRemoteFileAtSourcePath.source) === null || _resolvedRemoteFileAt === void 0 ? void 0 : _resolvedRemoteFileAt[core.FILE_REF_PROP];
122
- if (!fileRefProp) {
123
- return {
124
- success: false,
125
- errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`
126
- };
127
- }
128
- const filePath = path__default["default"].join(ctx.projectRoot, fileRefProp);
104
+ // Shared upload core used by both the single-field (handleRemoteFileUpload)
105
+ // and gallery (handleRemoteGalleryFileUpload) handlers. The two differ only in
106
+ // how they derive the local file ref, metadata and serialized image/file
107
+ // schema; everything from auth through upload is identical.
108
+ async function uploadRemoteFileCore(ctx, fileRef, metadata, schema) {
109
+ const filePath = path__default["default"].join(ctx.projectRoot, fileRef);
129
110
  if (!ctx.fs.fileExists(filePath)) {
130
111
  return {
131
112
  success: false,
@@ -165,21 +146,6 @@ async function handleRemoteFileUpload(ctx) {
165
146
  }]
166
147
  };
167
148
  }
168
- if (!resolvedRemoteFileAtSourcePath.schema) {
169
- return {
170
- success: false,
171
- errorMessage: `Cannot upload remote file: schema not found for ${ctx.sourcePath}`
172
- };
173
- }
174
- const actualRemoteFileSource = resolvedRemoteFileAtSourcePath.source;
175
- const fileSourceMetadata = core.Internal.isFile(actualRemoteFileSource) ? actualRemoteFileSource.metadata : undefined;
176
- const resolveRemoteFileSchema = resolvedRemoteFileAtSourcePath.schema;
177
- if (!resolveRemoteFileSchema) {
178
- return {
179
- success: false,
180
- errorMessage: `Could not resolve schema for remote file: ${ctx.sourcePath}`
181
- };
182
- }
183
149
  const projectName = ctx.project;
184
150
  let publicProjectId = ctx.publicProjectId;
185
151
  let remoteFileBuckets = ctx.remoteFileBuckets;
@@ -215,12 +181,6 @@ async function handleRemoteFileUpload(ctx) {
215
181
  errorMessage: `Could not get project. Check that your val.config has the 'project' field set, or set it using the VAL_PROJECT environment variable`
216
182
  };
217
183
  }
218
- if (resolveRemoteFileSchema.type !== "image" && resolveRemoteFileSchema.type !== "file") {
219
- return {
220
- success: false,
221
- errorMessage: `The schema is the remote is neither image nor file: ${ctx.sourcePath}`
222
- };
223
- }
224
184
  remoteFilesCounter += 1;
225
185
  const bucket = remoteFileBuckets[remoteFilesCounter % remoteFileBuckets.length];
226
186
  if (!bucket) {
@@ -246,8 +206,6 @@ async function handleRemoteFileUpload(ctx) {
246
206
  const fileHash = core.Internal.remote.getFileHash(fileBuffer);
247
207
  const coreVersion = core.Internal.VERSION.core || "unknown";
248
208
  const fileExt = getFileExt(filePath);
249
- const schema = resolveRemoteFileSchema;
250
- const metadata = fileSourceMetadata;
251
209
  const ref = core.Internal.remote.createRemoteRef(ctx.remote.remoteHost, {
252
210
  publicProjectId,
253
211
  coreVersion,
@@ -267,7 +225,7 @@ async function handleRemoteFileUpload(ctx) {
267
225
  }
268
226
  ctx.remoteFiles[ctx.sourcePath] = {
269
227
  ref,
270
- metadata: fileSourceMetadata
228
+ metadata
271
229
  };
272
230
  return {
273
231
  success: true,
@@ -284,6 +242,131 @@ async function handleRemoteFileUpload(ctx) {
284
242
  }]
285
243
  };
286
244
  }
245
+ async function handleRemoteFileUpload(ctx) {
246
+ var _resolvedRemoteFileAt;
247
+ if (!ctx.fix) {
248
+ return {
249
+ success: false,
250
+ // No sourcePath in the message: the reported location already points at it.
251
+ errorMessage: `Remote file needs to be uploaded (use --fix to upload)`
252
+ };
253
+ }
254
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
255
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
256
+ return {
257
+ success: false,
258
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
259
+ };
260
+ }
261
+ const resolvedRemoteFileAtSourcePath = core.Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
262
+
263
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
264
+ const fileRefProp = (_resolvedRemoteFileAt = resolvedRemoteFileAtSourcePath.source) === null || _resolvedRemoteFileAt === void 0 ? void 0 : _resolvedRemoteFileAt[core.FILE_REF_PROP];
265
+ if (!fileRefProp) {
266
+ return {
267
+ success: false,
268
+ errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`
269
+ };
270
+ }
271
+ const resolveRemoteFileSchema = resolvedRemoteFileAtSourcePath.schema;
272
+ if (!resolveRemoteFileSchema) {
273
+ return {
274
+ success: false,
275
+ errorMessage: `Cannot upload remote file: schema not found for ${ctx.sourcePath}`
276
+ };
277
+ }
278
+ if (resolveRemoteFileSchema.type !== "image" && resolveRemoteFileSchema.type !== "file") {
279
+ return {
280
+ success: false,
281
+ errorMessage: `The schema is the remote is neither image nor file: ${ctx.sourcePath}`
282
+ };
283
+ }
284
+ const actualRemoteFileSource = resolvedRemoteFileAtSourcePath.source;
285
+ const fileSourceMetadata = core.Internal.isFile(actualRemoteFileSource) ? actualRemoteFileSource.metadata : undefined;
286
+ return uploadRemoteFileCore(ctx, fileRefProp, fileSourceMetadata, resolveRemoteFileSchema);
287
+ }
288
+
289
+ // Gallery (s.images({ remote: true }) / s.files({ remote: true })) upload.
290
+ // Unlike a single image/file field, a gallery entry is keyed by its local file
291
+ // path and the value is bare metadata (no FileSource), so we derive the file
292
+ // ref from the key and synthesize the image/file schema from the record's
293
+ // media options.
294
+ async function handleRemoteGalleryFileUpload(ctx) {
295
+ var _ctx$validationError$;
296
+ if (!ctx.fix) {
297
+ return {
298
+ success: false,
299
+ // No sourcePath in the message: the reported location already points at it.
300
+ errorMessage: `Remote file needs to be uploaded (use --fix to upload)`
301
+ };
302
+ }
303
+ const fix = (_ctx$validationError$ = ctx.validationError.fixes) === null || _ctx$validationError$ === void 0 ? void 0 : _ctx$validationError$[0];
304
+ const mediaType = fix === "files:upload-remote" ? "file" : "image";
305
+
306
+ // The gallery entry is keyed by its local file path; validateMediaKey carries
307
+ // that key as the error value.
308
+ const fileRef = ctx.validationError.value;
309
+ if (typeof fileRef !== "string") {
310
+ return {
311
+ success: false,
312
+ errorMessage: `Expected a local file path for gallery entry at ${ctx.sourcePath}`
313
+ };
314
+ }
315
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
316
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
317
+ return {
318
+ success: false,
319
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
320
+ };
321
+ }
322
+ const resolved = core.Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
323
+ const entrySource = resolved.source;
324
+ const metadata = entrySource && typeof entrySource === "object" && !Array.isArray(entrySource) ? entrySource : undefined;
325
+
326
+ // The gallery item schema is an ObjectSchema, not an image/file schema, so
327
+ // synthesize the serialized image/file schema (matching how single fields
328
+ // serialize) for the remote ref's validation hash.
329
+ //
330
+ // `accept`/`directory` come from the RECORD that holds the entry, resolved
331
+ // from the entry's PARENT path - not from the module root, which is only the
332
+ // record when the gallery is the whole module. A nested gallery
333
+ // (s.object({ gallery: s.images(...) })) would otherwise synthesize a schema
334
+ // with no options and bake a validation hash into the remote ref that can
335
+ // never validate, so a mismatch fails fast instead of uploading.
336
+ const [, parentModulePath] = core.Internal.splitModuleFilePathAndModulePath(core.Internal.parentOfSourcePath(ctx.sourcePath));
337
+ const recordSchema = core.Internal.resolvePath(parentModulePath, ctx.valModule.source, ctx.valModule.schema).schema;
338
+ if ((recordSchema === null || recordSchema === void 0 ? void 0 : recordSchema.type) !== "record") {
339
+ return {
340
+ success: false,
341
+ errorMessage: `Expected a gallery record at the parent of ${ctx.sourcePath}, got ${(recordSchema === null || recordSchema === void 0 ? void 0 : recordSchema.type) ?? "nothing"}`
342
+ };
343
+ }
344
+ const {
345
+ accept,
346
+ directory
347
+ } = recordSchema;
348
+ const schema = mediaType === "image" ? {
349
+ type: "image",
350
+ opt: false,
351
+ options: {
352
+ ...(accept ? {
353
+ accept
354
+ } : {}),
355
+ ...(directory ? {
356
+ directory
357
+ } : {})
358
+ }
359
+ } : {
360
+ type: "file",
361
+ opt: false,
362
+ options: {
363
+ ...(accept ? {
364
+ accept
365
+ } : {})
366
+ }
367
+ };
368
+ return uploadRemoteFileCore(ctx, fileRef, metadata, schema);
369
+ }
287
370
  async function handleRemoteFileDownload(ctx) {
288
371
  if (ctx.fix) {
289
372
  return {
@@ -341,6 +424,17 @@ async function handleUniqueFolderCheck(ctx) {
341
424
  success: true
342
425
  };
343
426
  }
427
+
428
+ // Maps a gallery key to its on-disk local path. Remote galleries key uploaded
429
+ // entries by a remote URL while keeping the file on disk; everything else is
430
+ // already a local path and is returned unchanged.
431
+ function remoteKeyToLocalPath(key) {
432
+ const remoteRefRes = core.Internal.remote.splitRemoteRef(key);
433
+ if (remoteRefRes.status === "success") {
434
+ return `/${remoteRefRes.filePath}`;
435
+ }
436
+ return key;
437
+ }
344
438
  async function handleCheckAllFiles(ctx) {
345
439
  const value = ctx.validationError.value;
346
440
  if (!value || typeof value.directory !== "string") {
@@ -359,7 +453,10 @@ async function handleCheckAllFiles(ctx) {
359
453
  errorMessage: `Could not get source for ${ctx.sourcePath}`
360
454
  };
361
455
  }
362
- const trackedFiles = new Set(Object.keys(source));
456
+ // Gallery entries are keyed by their file path. Remote galleries key uploaded
457
+ // entries by a remote URL, but the file is kept on disk at its local path, so
458
+ // normalize remote-URL keys back to that local path for the on-disk checks.
459
+ const trackedFiles = new Set(Object.keys(source).map(remoteKeyToLocalPath));
363
460
 
364
461
  // Check that all tracked files exist on disk
365
462
  const missingTrackedFiles = [...trackedFiles].filter(f => {
@@ -414,6 +511,8 @@ const currentFixHandlers = {
414
511
  "file:add-metadata": handleFileMetadata,
415
512
  "image:upload-remote": handleRemoteFileUpload,
416
513
  "file:upload-remote": handleRemoteFileUpload,
514
+ "images:upload-remote": handleRemoteGalleryFileUpload,
515
+ "files:upload-remote": handleRemoteGalleryFileUpload,
417
516
  "image:download-remote": handleRemoteFileDownload,
418
517
  "file:download-remote": handleRemoteFileDownload,
419
518
  "image:check-remote": handleRemoteFileCheck,
@@ -100,32 +100,13 @@ async function handleFileMetadata(ctx) {
100
100
  shouldApplyPatch: true
101
101
  };
102
102
  }
103
- async function handleRemoteFileUpload(ctx) {
104
- var _resolvedRemoteFileAt;
105
- if (!ctx.fix) {
106
- return {
107
- success: false,
108
- errorMessage: `Remote file ${ctx.sourcePath} needs to be uploaded (use --fix to upload)`
109
- };
110
- }
111
- const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
112
- if (!ctx.valModule.source || !ctx.valModule.schema) {
113
- return {
114
- success: false,
115
- errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
116
- };
117
- }
118
- const resolvedRemoteFileAtSourcePath = core.Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
119
103
 
120
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
121
- const fileRefProp = (_resolvedRemoteFileAt = resolvedRemoteFileAtSourcePath.source) === null || _resolvedRemoteFileAt === void 0 ? void 0 : _resolvedRemoteFileAt[core.FILE_REF_PROP];
122
- if (!fileRefProp) {
123
- return {
124
- success: false,
125
- errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`
126
- };
127
- }
128
- const filePath = path__default["default"].join(ctx.projectRoot, fileRefProp);
104
+ // Shared upload core used by both the single-field (handleRemoteFileUpload)
105
+ // and gallery (handleRemoteGalleryFileUpload) handlers. The two differ only in
106
+ // how they derive the local file ref, metadata and serialized image/file
107
+ // schema; everything from auth through upload is identical.
108
+ async function uploadRemoteFileCore(ctx, fileRef, metadata, schema) {
109
+ const filePath = path__default["default"].join(ctx.projectRoot, fileRef);
129
110
  if (!ctx.fs.fileExists(filePath)) {
130
111
  return {
131
112
  success: false,
@@ -165,21 +146,6 @@ async function handleRemoteFileUpload(ctx) {
165
146
  }]
166
147
  };
167
148
  }
168
- if (!resolvedRemoteFileAtSourcePath.schema) {
169
- return {
170
- success: false,
171
- errorMessage: `Cannot upload remote file: schema not found for ${ctx.sourcePath}`
172
- };
173
- }
174
- const actualRemoteFileSource = resolvedRemoteFileAtSourcePath.source;
175
- const fileSourceMetadata = core.Internal.isFile(actualRemoteFileSource) ? actualRemoteFileSource.metadata : undefined;
176
- const resolveRemoteFileSchema = resolvedRemoteFileAtSourcePath.schema;
177
- if (!resolveRemoteFileSchema) {
178
- return {
179
- success: false,
180
- errorMessage: `Could not resolve schema for remote file: ${ctx.sourcePath}`
181
- };
182
- }
183
149
  const projectName = ctx.project;
184
150
  let publicProjectId = ctx.publicProjectId;
185
151
  let remoteFileBuckets = ctx.remoteFileBuckets;
@@ -215,12 +181,6 @@ async function handleRemoteFileUpload(ctx) {
215
181
  errorMessage: `Could not get project. Check that your val.config has the 'project' field set, or set it using the VAL_PROJECT environment variable`
216
182
  };
217
183
  }
218
- if (resolveRemoteFileSchema.type !== "image" && resolveRemoteFileSchema.type !== "file") {
219
- return {
220
- success: false,
221
- errorMessage: `The schema is the remote is neither image nor file: ${ctx.sourcePath}`
222
- };
223
- }
224
184
  remoteFilesCounter += 1;
225
185
  const bucket = remoteFileBuckets[remoteFilesCounter % remoteFileBuckets.length];
226
186
  if (!bucket) {
@@ -246,8 +206,6 @@ async function handleRemoteFileUpload(ctx) {
246
206
  const fileHash = core.Internal.remote.getFileHash(fileBuffer);
247
207
  const coreVersion = core.Internal.VERSION.core || "unknown";
248
208
  const fileExt = getFileExt(filePath);
249
- const schema = resolveRemoteFileSchema;
250
- const metadata = fileSourceMetadata;
251
209
  const ref = core.Internal.remote.createRemoteRef(ctx.remote.remoteHost, {
252
210
  publicProjectId,
253
211
  coreVersion,
@@ -267,7 +225,7 @@ async function handleRemoteFileUpload(ctx) {
267
225
  }
268
226
  ctx.remoteFiles[ctx.sourcePath] = {
269
227
  ref,
270
- metadata: fileSourceMetadata
228
+ metadata
271
229
  };
272
230
  return {
273
231
  success: true,
@@ -284,6 +242,131 @@ async function handleRemoteFileUpload(ctx) {
284
242
  }]
285
243
  };
286
244
  }
245
+ async function handleRemoteFileUpload(ctx) {
246
+ var _resolvedRemoteFileAt;
247
+ if (!ctx.fix) {
248
+ return {
249
+ success: false,
250
+ // No sourcePath in the message: the reported location already points at it.
251
+ errorMessage: `Remote file needs to be uploaded (use --fix to upload)`
252
+ };
253
+ }
254
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
255
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
256
+ return {
257
+ success: false,
258
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
259
+ };
260
+ }
261
+ const resolvedRemoteFileAtSourcePath = core.Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
262
+
263
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
264
+ const fileRefProp = (_resolvedRemoteFileAt = resolvedRemoteFileAtSourcePath.source) === null || _resolvedRemoteFileAt === void 0 ? void 0 : _resolvedRemoteFileAt[core.FILE_REF_PROP];
265
+ if (!fileRefProp) {
266
+ return {
267
+ success: false,
268
+ errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`
269
+ };
270
+ }
271
+ const resolveRemoteFileSchema = resolvedRemoteFileAtSourcePath.schema;
272
+ if (!resolveRemoteFileSchema) {
273
+ return {
274
+ success: false,
275
+ errorMessage: `Cannot upload remote file: schema not found for ${ctx.sourcePath}`
276
+ };
277
+ }
278
+ if (resolveRemoteFileSchema.type !== "image" && resolveRemoteFileSchema.type !== "file") {
279
+ return {
280
+ success: false,
281
+ errorMessage: `The schema is the remote is neither image nor file: ${ctx.sourcePath}`
282
+ };
283
+ }
284
+ const actualRemoteFileSource = resolvedRemoteFileAtSourcePath.source;
285
+ const fileSourceMetadata = core.Internal.isFile(actualRemoteFileSource) ? actualRemoteFileSource.metadata : undefined;
286
+ return uploadRemoteFileCore(ctx, fileRefProp, fileSourceMetadata, resolveRemoteFileSchema);
287
+ }
288
+
289
+ // Gallery (s.images({ remote: true }) / s.files({ remote: true })) upload.
290
+ // Unlike a single image/file field, a gallery entry is keyed by its local file
291
+ // path and the value is bare metadata (no FileSource), so we derive the file
292
+ // ref from the key and synthesize the image/file schema from the record's
293
+ // media options.
294
+ async function handleRemoteGalleryFileUpload(ctx) {
295
+ var _ctx$validationError$;
296
+ if (!ctx.fix) {
297
+ return {
298
+ success: false,
299
+ // No sourcePath in the message: the reported location already points at it.
300
+ errorMessage: `Remote file needs to be uploaded (use --fix to upload)`
301
+ };
302
+ }
303
+ const fix = (_ctx$validationError$ = ctx.validationError.fixes) === null || _ctx$validationError$ === void 0 ? void 0 : _ctx$validationError$[0];
304
+ const mediaType = fix === "files:upload-remote" ? "file" : "image";
305
+
306
+ // The gallery entry is keyed by its local file path; validateMediaKey carries
307
+ // that key as the error value.
308
+ const fileRef = ctx.validationError.value;
309
+ if (typeof fileRef !== "string") {
310
+ return {
311
+ success: false,
312
+ errorMessage: `Expected a local file path for gallery entry at ${ctx.sourcePath}`
313
+ };
314
+ }
315
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
316
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
317
+ return {
318
+ success: false,
319
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
320
+ };
321
+ }
322
+ const resolved = core.Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
323
+ const entrySource = resolved.source;
324
+ const metadata = entrySource && typeof entrySource === "object" && !Array.isArray(entrySource) ? entrySource : undefined;
325
+
326
+ // The gallery item schema is an ObjectSchema, not an image/file schema, so
327
+ // synthesize the serialized image/file schema (matching how single fields
328
+ // serialize) for the remote ref's validation hash.
329
+ //
330
+ // `accept`/`directory` come from the RECORD that holds the entry, resolved
331
+ // from the entry's PARENT path - not from the module root, which is only the
332
+ // record when the gallery is the whole module. A nested gallery
333
+ // (s.object({ gallery: s.images(...) })) would otherwise synthesize a schema
334
+ // with no options and bake a validation hash into the remote ref that can
335
+ // never validate, so a mismatch fails fast instead of uploading.
336
+ const [, parentModulePath] = core.Internal.splitModuleFilePathAndModulePath(core.Internal.parentOfSourcePath(ctx.sourcePath));
337
+ const recordSchema = core.Internal.resolvePath(parentModulePath, ctx.valModule.source, ctx.valModule.schema).schema;
338
+ if ((recordSchema === null || recordSchema === void 0 ? void 0 : recordSchema.type) !== "record") {
339
+ return {
340
+ success: false,
341
+ errorMessage: `Expected a gallery record at the parent of ${ctx.sourcePath}, got ${(recordSchema === null || recordSchema === void 0 ? void 0 : recordSchema.type) ?? "nothing"}`
342
+ };
343
+ }
344
+ const {
345
+ accept,
346
+ directory
347
+ } = recordSchema;
348
+ const schema = mediaType === "image" ? {
349
+ type: "image",
350
+ opt: false,
351
+ options: {
352
+ ...(accept ? {
353
+ accept
354
+ } : {}),
355
+ ...(directory ? {
356
+ directory
357
+ } : {})
358
+ }
359
+ } : {
360
+ type: "file",
361
+ opt: false,
362
+ options: {
363
+ ...(accept ? {
364
+ accept
365
+ } : {})
366
+ }
367
+ };
368
+ return uploadRemoteFileCore(ctx, fileRef, metadata, schema);
369
+ }
287
370
  async function handleRemoteFileDownload(ctx) {
288
371
  if (ctx.fix) {
289
372
  return {
@@ -341,6 +424,17 @@ async function handleUniqueFolderCheck(ctx) {
341
424
  success: true
342
425
  };
343
426
  }
427
+
428
+ // Maps a gallery key to its on-disk local path. Remote galleries key uploaded
429
+ // entries by a remote URL while keeping the file on disk; everything else is
430
+ // already a local path and is returned unchanged.
431
+ function remoteKeyToLocalPath(key) {
432
+ const remoteRefRes = core.Internal.remote.splitRemoteRef(key);
433
+ if (remoteRefRes.status === "success") {
434
+ return `/${remoteRefRes.filePath}`;
435
+ }
436
+ return key;
437
+ }
344
438
  async function handleCheckAllFiles(ctx) {
345
439
  const value = ctx.validationError.value;
346
440
  if (!value || typeof value.directory !== "string") {
@@ -359,7 +453,10 @@ async function handleCheckAllFiles(ctx) {
359
453
  errorMessage: `Could not get source for ${ctx.sourcePath}`
360
454
  };
361
455
  }
362
- const trackedFiles = new Set(Object.keys(source));
456
+ // Gallery entries are keyed by their file path. Remote galleries key uploaded
457
+ // entries by a remote URL, but the file is kept on disk at its local path, so
458
+ // normalize remote-URL keys back to that local path for the on-disk checks.
459
+ const trackedFiles = new Set(Object.keys(source).map(remoteKeyToLocalPath));
363
460
 
364
461
  // Check that all tracked files exist on disk
365
462
  const missingTrackedFiles = [...trackedFiles].filter(f => {
@@ -414,6 +511,8 @@ const currentFixHandlers = {
414
511
  "file:add-metadata": handleFileMetadata,
415
512
  "image:upload-remote": handleRemoteFileUpload,
416
513
  "file:upload-remote": handleRemoteFileUpload,
514
+ "images:upload-remote": handleRemoteGalleryFileUpload,
515
+ "files:upload-remote": handleRemoteGalleryFileUpload,
417
516
  "image:download-remote": handleRemoteFileDownload,
418
517
  "file:download-remote": handleRemoteFileDownload,
419
518
  "image:check-remote": handleRemoteFileCheck,
@@ -68,32 +68,13 @@ async function handleFileMetadata(ctx) {
68
68
  shouldApplyPatch: true
69
69
  };
70
70
  }
71
- async function handleRemoteFileUpload(ctx) {
72
- var _resolvedRemoteFileAt;
73
- if (!ctx.fix) {
74
- return {
75
- success: false,
76
- errorMessage: `Remote file ${ctx.sourcePath} needs to be uploaded (use --fix to upload)`
77
- };
78
- }
79
- const [, modulePath] = Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
80
- if (!ctx.valModule.source || !ctx.valModule.schema) {
81
- return {
82
- success: false,
83
- errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
84
- };
85
- }
86
- const resolvedRemoteFileAtSourcePath = Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
87
71
 
88
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
89
- const fileRefProp = (_resolvedRemoteFileAt = resolvedRemoteFileAtSourcePath.source) === null || _resolvedRemoteFileAt === void 0 ? void 0 : _resolvedRemoteFileAt[FILE_REF_PROP];
90
- if (!fileRefProp) {
91
- return {
92
- success: false,
93
- errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`
94
- };
95
- }
96
- const filePath = path.join(ctx.projectRoot, fileRefProp);
72
+ // Shared upload core used by both the single-field (handleRemoteFileUpload)
73
+ // and gallery (handleRemoteGalleryFileUpload) handlers. The two differ only in
74
+ // how they derive the local file ref, metadata and serialized image/file
75
+ // schema; everything from auth through upload is identical.
76
+ async function uploadRemoteFileCore(ctx, fileRef, metadata, schema) {
77
+ const filePath = path.join(ctx.projectRoot, fileRef);
97
78
  if (!ctx.fs.fileExists(filePath)) {
98
79
  return {
99
80
  success: false,
@@ -133,21 +114,6 @@ async function handleRemoteFileUpload(ctx) {
133
114
  }]
134
115
  };
135
116
  }
136
- if (!resolvedRemoteFileAtSourcePath.schema) {
137
- return {
138
- success: false,
139
- errorMessage: `Cannot upload remote file: schema not found for ${ctx.sourcePath}`
140
- };
141
- }
142
- const actualRemoteFileSource = resolvedRemoteFileAtSourcePath.source;
143
- const fileSourceMetadata = Internal.isFile(actualRemoteFileSource) ? actualRemoteFileSource.metadata : undefined;
144
- const resolveRemoteFileSchema = resolvedRemoteFileAtSourcePath.schema;
145
- if (!resolveRemoteFileSchema) {
146
- return {
147
- success: false,
148
- errorMessage: `Could not resolve schema for remote file: ${ctx.sourcePath}`
149
- };
150
- }
151
117
  const projectName = ctx.project;
152
118
  let publicProjectId = ctx.publicProjectId;
153
119
  let remoteFileBuckets = ctx.remoteFileBuckets;
@@ -183,12 +149,6 @@ async function handleRemoteFileUpload(ctx) {
183
149
  errorMessage: `Could not get project. Check that your val.config has the 'project' field set, or set it using the VAL_PROJECT environment variable`
184
150
  };
185
151
  }
186
- if (resolveRemoteFileSchema.type !== "image" && resolveRemoteFileSchema.type !== "file") {
187
- return {
188
- success: false,
189
- errorMessage: `The schema is the remote is neither image nor file: ${ctx.sourcePath}`
190
- };
191
- }
192
152
  remoteFilesCounter += 1;
193
153
  const bucket = remoteFileBuckets[remoteFilesCounter % remoteFileBuckets.length];
194
154
  if (!bucket) {
@@ -214,8 +174,6 @@ async function handleRemoteFileUpload(ctx) {
214
174
  const fileHash = Internal.remote.getFileHash(fileBuffer);
215
175
  const coreVersion = Internal.VERSION.core || "unknown";
216
176
  const fileExt = getFileExt(filePath);
217
- const schema = resolveRemoteFileSchema;
218
- const metadata = fileSourceMetadata;
219
177
  const ref = Internal.remote.createRemoteRef(ctx.remote.remoteHost, {
220
178
  publicProjectId,
221
179
  coreVersion,
@@ -235,7 +193,7 @@ async function handleRemoteFileUpload(ctx) {
235
193
  }
236
194
  ctx.remoteFiles[ctx.sourcePath] = {
237
195
  ref,
238
- metadata: fileSourceMetadata
196
+ metadata
239
197
  };
240
198
  return {
241
199
  success: true,
@@ -252,6 +210,131 @@ async function handleRemoteFileUpload(ctx) {
252
210
  }]
253
211
  };
254
212
  }
213
+ async function handleRemoteFileUpload(ctx) {
214
+ var _resolvedRemoteFileAt;
215
+ if (!ctx.fix) {
216
+ return {
217
+ success: false,
218
+ // No sourcePath in the message: the reported location already points at it.
219
+ errorMessage: `Remote file needs to be uploaded (use --fix to upload)`
220
+ };
221
+ }
222
+ const [, modulePath] = Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
223
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
224
+ return {
225
+ success: false,
226
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
227
+ };
228
+ }
229
+ const resolvedRemoteFileAtSourcePath = Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
230
+
231
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
232
+ const fileRefProp = (_resolvedRemoteFileAt = resolvedRemoteFileAtSourcePath.source) === null || _resolvedRemoteFileAt === void 0 ? void 0 : _resolvedRemoteFileAt[FILE_REF_PROP];
233
+ if (!fileRefProp) {
234
+ return {
235
+ success: false,
236
+ errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`
237
+ };
238
+ }
239
+ const resolveRemoteFileSchema = resolvedRemoteFileAtSourcePath.schema;
240
+ if (!resolveRemoteFileSchema) {
241
+ return {
242
+ success: false,
243
+ errorMessage: `Cannot upload remote file: schema not found for ${ctx.sourcePath}`
244
+ };
245
+ }
246
+ if (resolveRemoteFileSchema.type !== "image" && resolveRemoteFileSchema.type !== "file") {
247
+ return {
248
+ success: false,
249
+ errorMessage: `The schema is the remote is neither image nor file: ${ctx.sourcePath}`
250
+ };
251
+ }
252
+ const actualRemoteFileSource = resolvedRemoteFileAtSourcePath.source;
253
+ const fileSourceMetadata = Internal.isFile(actualRemoteFileSource) ? actualRemoteFileSource.metadata : undefined;
254
+ return uploadRemoteFileCore(ctx, fileRefProp, fileSourceMetadata, resolveRemoteFileSchema);
255
+ }
256
+
257
+ // Gallery (s.images({ remote: true }) / s.files({ remote: true })) upload.
258
+ // Unlike a single image/file field, a gallery entry is keyed by its local file
259
+ // path and the value is bare metadata (no FileSource), so we derive the file
260
+ // ref from the key and synthesize the image/file schema from the record's
261
+ // media options.
262
+ async function handleRemoteGalleryFileUpload(ctx) {
263
+ var _ctx$validationError$;
264
+ if (!ctx.fix) {
265
+ return {
266
+ success: false,
267
+ // No sourcePath in the message: the reported location already points at it.
268
+ errorMessage: `Remote file needs to be uploaded (use --fix to upload)`
269
+ };
270
+ }
271
+ const fix = (_ctx$validationError$ = ctx.validationError.fixes) === null || _ctx$validationError$ === void 0 ? void 0 : _ctx$validationError$[0];
272
+ const mediaType = fix === "files:upload-remote" ? "file" : "image";
273
+
274
+ // The gallery entry is keyed by its local file path; validateMediaKey carries
275
+ // that key as the error value.
276
+ const fileRef = ctx.validationError.value;
277
+ if (typeof fileRef !== "string") {
278
+ return {
279
+ success: false,
280
+ errorMessage: `Expected a local file path for gallery entry at ${ctx.sourcePath}`
281
+ };
282
+ }
283
+ const [, modulePath] = Internal.splitModuleFilePathAndModulePath(ctx.sourcePath);
284
+ if (!ctx.valModule.source || !ctx.valModule.schema) {
285
+ return {
286
+ success: false,
287
+ errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`
288
+ };
289
+ }
290
+ const resolved = Internal.resolvePath(modulePath, ctx.valModule.source, ctx.valModule.schema);
291
+ const entrySource = resolved.source;
292
+ const metadata = entrySource && typeof entrySource === "object" && !Array.isArray(entrySource) ? entrySource : undefined;
293
+
294
+ // The gallery item schema is an ObjectSchema, not an image/file schema, so
295
+ // synthesize the serialized image/file schema (matching how single fields
296
+ // serialize) for the remote ref's validation hash.
297
+ //
298
+ // `accept`/`directory` come from the RECORD that holds the entry, resolved
299
+ // from the entry's PARENT path - not from the module root, which is only the
300
+ // record when the gallery is the whole module. A nested gallery
301
+ // (s.object({ gallery: s.images(...) })) would otherwise synthesize a schema
302
+ // with no options and bake a validation hash into the remote ref that can
303
+ // never validate, so a mismatch fails fast instead of uploading.
304
+ const [, parentModulePath] = Internal.splitModuleFilePathAndModulePath(Internal.parentOfSourcePath(ctx.sourcePath));
305
+ const recordSchema = Internal.resolvePath(parentModulePath, ctx.valModule.source, ctx.valModule.schema).schema;
306
+ if ((recordSchema === null || recordSchema === void 0 ? void 0 : recordSchema.type) !== "record") {
307
+ return {
308
+ success: false,
309
+ errorMessage: `Expected a gallery record at the parent of ${ctx.sourcePath}, got ${(recordSchema === null || recordSchema === void 0 ? void 0 : recordSchema.type) ?? "nothing"}`
310
+ };
311
+ }
312
+ const {
313
+ accept,
314
+ directory
315
+ } = recordSchema;
316
+ const schema = mediaType === "image" ? {
317
+ type: "image",
318
+ opt: false,
319
+ options: {
320
+ ...(accept ? {
321
+ accept
322
+ } : {}),
323
+ ...(directory ? {
324
+ directory
325
+ } : {})
326
+ }
327
+ } : {
328
+ type: "file",
329
+ opt: false,
330
+ options: {
331
+ ...(accept ? {
332
+ accept
333
+ } : {})
334
+ }
335
+ };
336
+ return uploadRemoteFileCore(ctx, fileRef, metadata, schema);
337
+ }
255
338
  async function handleRemoteFileDownload(ctx) {
256
339
  if (ctx.fix) {
257
340
  return {
@@ -309,6 +392,17 @@ async function handleUniqueFolderCheck(ctx) {
309
392
  success: true
310
393
  };
311
394
  }
395
+
396
+ // Maps a gallery key to its on-disk local path. Remote galleries key uploaded
397
+ // entries by a remote URL while keeping the file on disk; everything else is
398
+ // already a local path and is returned unchanged.
399
+ function remoteKeyToLocalPath(key) {
400
+ const remoteRefRes = Internal.remote.splitRemoteRef(key);
401
+ if (remoteRefRes.status === "success") {
402
+ return `/${remoteRefRes.filePath}`;
403
+ }
404
+ return key;
405
+ }
312
406
  async function handleCheckAllFiles(ctx) {
313
407
  const value = ctx.validationError.value;
314
408
  if (!value || typeof value.directory !== "string") {
@@ -327,7 +421,10 @@ async function handleCheckAllFiles(ctx) {
327
421
  errorMessage: `Could not get source for ${ctx.sourcePath}`
328
422
  };
329
423
  }
330
- const trackedFiles = new Set(Object.keys(source));
424
+ // Gallery entries are keyed by their file path. Remote galleries key uploaded
425
+ // entries by a remote URL, but the file is kept on disk at its local path, so
426
+ // normalize remote-URL keys back to that local path for the on-disk checks.
427
+ const trackedFiles = new Set(Object.keys(source).map(remoteKeyToLocalPath));
331
428
 
332
429
  // Check that all tracked files exist on disk
333
430
  const missingTrackedFiles = [...trackedFiles].filter(f => {
@@ -382,6 +479,8 @@ const currentFixHandlers = {
382
479
  "file:add-metadata": handleFileMetadata,
383
480
  "image:upload-remote": handleRemoteFileUpload,
384
481
  "file:upload-remote": handleRemoteFileUpload,
482
+ "images:upload-remote": handleRemoteGalleryFileUpload,
483
+ "files:upload-remote": handleRemoteGalleryFileUpload,
385
484
  "image:download-remote": handleRemoteFileDownload,
386
485
  "file:download-remote": handleRemoteFileDownload,
387
486
  "image:check-remote": handleRemoteFileCheck,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@valbuild/cli",
3
3
  "private": false,
4
- "version": "0.98.0",
4
+ "version": "0.99.0",
5
5
  "description": "Val CLI tools",
6
6
  "repository": {
7
7
  "type": "git",
@@ -27,11 +27,11 @@
27
27
  "open": "^9.1.0",
28
28
  "picocolors": "^1.1.1",
29
29
  "zod": "^4.3.5",
30
- "@valbuild/core": "0.97.7",
30
+ "@valbuild/core": "0.99.0",
31
31
  "@valbuild/eslint-plugin": "0.93.0",
32
- "@valbuild/language-server": "0.98.0",
33
- "@valbuild/server": "0.98.0",
34
- "@valbuild/shared": "0.97.7"
32
+ "@valbuild/language-server": "0.99.0",
33
+ "@valbuild/server": "0.99.0",
34
+ "@valbuild/shared": "0.99.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "prettier": "*",
@@ -0,0 +1,20 @@
1
+ import { c, s } from "../val.config";
2
+
3
+ export default c.define(
4
+ "/content/basic-gallery-remote-existing.val.ts",
5
+ s.images({
6
+ directory: "/public/val/images-remote2",
7
+ accept: "image/*",
8
+ remote: true,
9
+ }),
10
+
11
+ {
12
+ "https://remote.val.build/file/p/proj123/b/01/v/1.0.0/h/abc1/f/def4/p/public/val/images-remote2/image.png":
13
+ {
14
+ width: 1,
15
+ height: 1,
16
+ mimeType: "image/png",
17
+ alt: null,
18
+ },
19
+ },
20
+ );
@@ -0,0 +1,19 @@
1
+ import { c, s } from "../val.config";
2
+
3
+ export default c.define(
4
+ "/content/basic-gallery-remote.val.ts",
5
+ s.images({
6
+ directory: "/public/val/images-remote",
7
+ accept: "image/*",
8
+ remote: true,
9
+ }),
10
+
11
+ {
12
+ "/public/val/images-remote/image.png": {
13
+ width: 1,
14
+ height: 1,
15
+ mimeType: "image/png",
16
+ alt: null,
17
+ },
18
+ },
19
+ );
@@ -1,5 +1,7 @@
1
1
  import { initVal } from "@valbuild/core";
2
2
 
3
- const { s, c, config } = initVal();
3
+ // NOTE: initVal() must be called with an options object for `config` to be
4
+ // defined - the CLI requires val.config to export a config object.
5
+ const { s, c, config } = initVal({});
4
6
 
5
7
  export { s, c, config };
@@ -13,4 +13,6 @@ export default modules(config, [
13
13
  { def: () => import("./content/basic-gallery-fail-on-non-unique-dir.val") },
14
14
  { def: () => import("./content/basic-gallery-missing-tracked.val") },
15
15
  { def: () => import("./content/basic-gallery-wrong-metadata.val") },
16
+ { def: () => import("./content/basic-gallery-remote.val") },
17
+ { def: () => import("./content/basic-gallery-remote-existing.val") },
16
18
  ]);
@@ -383,4 +383,105 @@ describe("runValidation", () => {
383
383
  service.dispose();
384
384
  }
385
385
  });
386
+
387
+ test("reports upload-remote error for local entry in a remote gallery", async () => {
388
+ const events: ValidationEvent[] = [];
389
+
390
+ for await (const event of runValidation({
391
+ root: tmpDir,
392
+ fix: false,
393
+ valFiles: ["content/basic-gallery-remote.val.ts"],
394
+ project: undefined,
395
+ remote: mockRemote,
396
+ fs: createDefaultValFSHost(),
397
+ })) {
398
+ events.push(event);
399
+ }
400
+
401
+ expect(events.at(-1)).toEqual({
402
+ type: "summary-errors",
403
+ count: expect.any(Number),
404
+ });
405
+ const errors = events.filter((e) => e.type === "validation-error");
406
+ expect(
407
+ errors.some(
408
+ (e) =>
409
+ "message" in e &&
410
+ (e.message as string).includes("needs to be uploaded"),
411
+ ),
412
+ ).toBe(true);
413
+ });
414
+
415
+ test("uploads local entry in a remote gallery and rewrites the key when fix is true", async () => {
416
+ const uploadRemote: IValRemote = {
417
+ remoteHost: DEFAULT_VAL_REMOTE_HOST,
418
+ getSettings: async () => ({
419
+ success: true,
420
+ data: {
421
+ publicProjectId: "pubproj",
422
+ remoteFileBuckets: [{ bucket: "01" }],
423
+ },
424
+ }),
425
+ uploadFile: async () => ({ success: true }),
426
+ };
427
+ // Upload requires a personal access token on disk.
428
+ fs.mkdirSync(path.join(tmpDir, ".val"), { recursive: true });
429
+ fs.writeFileSync(
430
+ path.join(tmpDir, ".val", "pat.json"),
431
+ JSON.stringify({ pat: "test-pat" }),
432
+ );
433
+
434
+ const events: ValidationEvent[] = [];
435
+ for await (const event of runValidation({
436
+ root: tmpDir,
437
+ fix: true,
438
+ valFiles: ["content/basic-gallery-remote.val.ts"],
439
+ project: "test/project",
440
+ remote: uploadRemote,
441
+ fs: createDefaultValFSHost(),
442
+ })) {
443
+ events.push(event);
444
+ }
445
+
446
+ expect(events.some((e) => e.type === "remote-uploaded")).toBe(true);
447
+ expect(events.some((e) => e.type === "fix-applied")).toBe(true);
448
+
449
+ const service = await createService(tmpDir, createDefaultValFSHost());
450
+ try {
451
+ const result = await service.get(
452
+ "/content/basic-gallery-remote.val.ts" as ModuleFilePath,
453
+ "" as ModulePath,
454
+ { validate: false },
455
+ );
456
+ const source = result.source as Record<string, unknown>;
457
+ // The local-path key is replaced by a remote URL key (file kept on disk).
458
+ expect(source).not.toHaveProperty("/public/val/images-remote/image.png");
459
+ const keys = Object.keys(source);
460
+ expect(keys).toHaveLength(1);
461
+ expect(keys[0]).toContain("pubproj");
462
+ expect(keys[0]).toContain("public/val/images-remote/image.png");
463
+ } finally {
464
+ service.dispose();
465
+ }
466
+ });
467
+
468
+ test("does not flag a kept-on-disk file behind a remote gallery key", async () => {
469
+ const events: ValidationEvent[] = [];
470
+
471
+ for await (const event of runValidation({
472
+ root: tmpDir,
473
+ fix: false,
474
+ valFiles: ["content/basic-gallery-remote-existing.val.ts"],
475
+ project: undefined,
476
+ remote: mockRemote,
477
+ fs: createDefaultValFSHost(),
478
+ })) {
479
+ events.push(event);
480
+ }
481
+
482
+ // The file kept on disk under the remote URL key must not be reported as
483
+ // untracked, and the remote URL key must not be reported as missing.
484
+ expect(events.at(-1)).toEqual({ type: "summary-success" });
485
+ expect(events.filter((e) => e.type === "validation-error")).toHaveLength(0);
486
+ });
386
487
  });
@@ -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,