@transloadit/node 6.8.0 → 6.9.1

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.
Files changed (67) hide show
  1. package/README.md +73 -8
  2. package/dist/Transloadit.d.ts +6 -0
  3. package/dist/Transloadit.d.ts.map +1 -1
  4. package/dist/Transloadit.js +9 -0
  5. package/dist/Transloadit.js.map +1 -1
  6. package/dist/bearerToken.d.ts +2 -0
  7. package/dist/bearerToken.d.ts.map +1 -1
  8. package/dist/bearerToken.js +6 -9
  9. package/dist/bearerToken.js.map +1 -1
  10. package/dist/cli/commands/assemblies.d.ts.map +1 -1
  11. package/dist/cli/commands/assemblies.js +6 -4
  12. package/dist/cli/commands/assemblies.js.map +1 -1
  13. package/dist/cli/commands/auth.d.ts.map +1 -1
  14. package/dist/cli/commands/auth.js +2 -1
  15. package/dist/cli/commands/auth.js.map +1 -1
  16. package/dist/cli/commands/image.js +3 -3
  17. package/dist/cli/commands/image.js.map +1 -1
  18. package/dist/cli/commands/login.d.ts.map +1 -1
  19. package/dist/cli/commands/login.js +10 -9
  20. package/dist/cli/commands/login.js.map +1 -1
  21. package/dist/cli/commands/notifications.js +2 -2
  22. package/dist/cli/commands/notifications.js.map +1 -1
  23. package/dist/cli/commands/storage.d.ts.map +1 -1
  24. package/dist/cli/commands/storage.js +120 -47
  25. package/dist/cli/commands/storage.js.map +1 -1
  26. package/dist/cli/commands/upload.d.ts.map +1 -1
  27. package/dist/cli/commands/upload.js +2 -1
  28. package/dist/cli/commands/upload.js.map +1 -1
  29. package/dist/cli/deviceLogin.d.ts.map +1 -1
  30. package/dist/cli/deviceLogin.js +3 -3
  31. package/dist/cli/deviceLogin.js.map +1 -1
  32. package/dist/cli/helpers.d.ts +2 -1
  33. package/dist/cli/helpers.d.ts.map +1 -1
  34. package/dist/cli/helpers.js +26 -1
  35. package/dist/cli/helpers.js.map +1 -1
  36. package/dist/cli/storagePublic.d.ts +1 -1
  37. package/dist/cli/storagePublic.d.ts.map +1 -1
  38. package/dist/cli/storagePublic.js +5 -4
  39. package/dist/cli/storagePublic.js.map +1 -1
  40. package/dist/cli/storageReceipts.d.ts +80 -1
  41. package/dist/cli/storageReceipts.d.ts.map +1 -1
  42. package/dist/cli/storageReceipts.js +48 -12
  43. package/dist/cli/storageReceipts.js.map +1 -1
  44. package/dist/storageAsset.d.ts +44 -0
  45. package/dist/storageAsset.d.ts.map +1 -0
  46. package/dist/storageAsset.js +173 -0
  47. package/dist/storageAsset.js.map +1 -0
  48. package/dist/storageImage.d.ts +11 -35
  49. package/dist/storageImage.d.ts.map +1 -1
  50. package/dist/storageImage.js +21 -141
  51. package/dist/storageImage.js.map +1 -1
  52. package/package.json +2 -2
  53. package/src/Transloadit.ts +22 -0
  54. package/src/bearerToken.ts +8 -10
  55. package/src/cli/commands/assemblies.ts +7 -4
  56. package/src/cli/commands/auth.ts +2 -0
  57. package/src/cli/commands/image.ts +3 -3
  58. package/src/cli/commands/login.ts +12 -10
  59. package/src/cli/commands/notifications.ts +2 -2
  60. package/src/cli/commands/storage.ts +160 -78
  61. package/src/cli/commands/upload.ts +2 -1
  62. package/src/cli/deviceLogin.ts +3 -2
  63. package/src/cli/helpers.ts +31 -1
  64. package/src/cli/storagePublic.ts +11 -4
  65. package/src/cli/storageReceipts.ts +50 -11
  66. package/src/storageAsset.ts +271 -0
  67. package/src/storageImage.ts +43 -201
@@ -1,4 +1,4 @@
1
- import type { StoredImageReceipt } from '../../storageImage.ts'
1
+ import type { StoredAssetReceipt } from '../../storageAsset.ts'
2
2
 
3
3
  import { createHash } from 'node:crypto'
4
4
  import { createReadStream } from 'node:fs'
@@ -14,7 +14,12 @@ import InconsistentResponseError from '../../InconsistentResponseError.ts'
14
14
  import { storageImagePlaceholderSchema } from '../../storageImage.ts'
15
15
  import { normalizeStoragePublicPrefix } from '../../storagePublicPrefixes.ts'
16
16
  import { Transloadit } from '../../Transloadit.ts'
17
- import { noticeCliCredentialSource, quoteCliArgument, resolveCliConfig } from '../helpers.ts'
17
+ import {
18
+ formatAPIError,
19
+ noticeCliCredentialSource,
20
+ quoteCliArgument,
21
+ resolveCliConfig,
22
+ } from '../helpers.ts'
18
23
  import {
19
24
  listStorageAssets,
20
25
  resolveStorageWorkspace,
@@ -25,6 +30,7 @@ import {
25
30
  assertStorageCatalogOrigin,
26
31
  assertStorageWorkspace,
27
32
  defaultStorageCatalog,
33
+ isStorageImageReceipt,
28
34
  readStorageCatalog,
29
35
  storageCatalogDelivery,
30
36
  storageTypesPath,
@@ -40,7 +46,7 @@ abstract class StorageProjectCommand extends AuthenticatedCommand {
40
46
  .origin
41
47
  }
42
48
  receipts = Option.String('--receipts', defaultStorageCatalog, {
43
- description: 'Committed project catalog with workspace, public prefixes and image receipts',
49
+ description: 'Project catalog with workspace, public prefixes and asset receipts',
44
50
  })
45
51
  workspace = Option.String('--workspace', {
46
52
  description:
@@ -119,7 +125,7 @@ export class StoragePublishCommand extends StorageProjectCommand {
119
125
  })
120
126
  return undefined
121
127
  } catch (error) {
122
- this.output.error(ensureError(error).message)
128
+ this.output.error(formatAPIError(error))
123
129
  return 1
124
130
  }
125
131
  }
@@ -172,7 +178,7 @@ export class StorageUnpublishCommand extends StorageProjectCommand {
172
178
  })
173
179
  return undefined
174
180
  } catch (error) {
175
- this.output.error(ensureError(error).message)
181
+ this.output.error(formatAPIError(error))
176
182
  return 1
177
183
  }
178
184
  }
@@ -203,12 +209,12 @@ export class StoragePublicationsCommand extends AuthenticatedCommand {
203
209
  }
204
210
  }
205
211
 
206
- interface CliStoredImageReceipt extends StoredImageReceipt {
212
+ interface CliStoredAssetReceipt extends StoredAssetReceipt {
207
213
  source?: string
208
214
  apiOrigin?: string
209
215
  }
210
216
 
211
- async function hashImageFile(
217
+ async function hashAssetFile(
212
218
  file: string,
213
219
  signal: AbortSignal,
214
220
  ): Promise<{ md5hash: string; size: number }> {
@@ -219,7 +225,7 @@ async function hashImageFile(
219
225
  hash.update(chunk)
220
226
  size += chunk.length
221
227
  }
222
- if (size === 0) throw new Error('Cannot store an empty image')
228
+ if (size === 0) throw new Error('Cannot store an empty file')
223
229
  signal.throwIfAborted()
224
230
  return { md5hash: hash.digest('hex'), size }
225
231
  }
@@ -230,7 +236,7 @@ export class StorageStoreCommand extends StorageProjectCommand {
230
236
 
231
237
  static override usage = Command.Usage({
232
238
  category: 'Storage',
233
- description: 'Store original images and save verified metadata for Image',
239
+ description: 'Store original files and save verified, version-pinned asset receipts',
234
240
  details: `
235
241
  Uses the CLI's Assembly credentials (environment, .env or ~/.transloadit/credentials).
236
242
  Storage writes must be enabled. Existing Storage paths conflict unless --overwrite is explicit.
@@ -239,7 +245,7 @@ export class StorageStoreCommand extends StorageProjectCommand {
239
245
  --json includes uploaded for each receipt: false means this file reused a verified receipt.
240
246
  --placeholder blur requests server metadata on new uploads. Successful extraction adds
241
247
  metadata usage of 20% of the file bytes. Missing placeholders never retry a completed upload.
242
- The project catalog binds workspace, published prefixes and image receipts. Each successful
248
+ The project catalog binds workspace, public prefixes, images and other files. Each successful
243
249
  upload is saved atomically before the next. Do not run two writers against the same catalog.
244
250
  The catalog defaults to transloadit.images.json; --receipts selects another file.
245
251
  `,
@@ -247,6 +253,10 @@ export class StorageStoreCommand extends StorageProjectCommand {
247
253
  ['Store a hero image', 'transloadit storage store ./hero.jpg website/hero.jpg'],
248
254
  ['Store a content-addressed image', 'transloadit storage store ./hero.jpg website/ --hashed'],
249
255
  ['Store a directory of originals', 'transloadit storage store ./images/*.jpg website/'],
256
+ [
257
+ 'Store mixed media',
258
+ 'transloadit storage store ./photo.jpg ./clip.mp4 ./guide.pdf website/media/',
259
+ ],
250
260
  ],
251
261
  })
252
262
 
@@ -267,7 +277,7 @@ export class StorageStoreCommand extends StorageProjectCommand {
267
277
  })
268
278
  protected async run(): Promise<number | undefined> {
269
279
  const file = resolve(this.receipts)
270
- let stored: { receipt?: CliStoredImageReceipt } = {}
280
+ let stored: { receipt?: CliStoredAssetReceipt } = {}
271
281
  let destination = this.destination
272
282
  let workspace: string | undefined
273
283
  let saved = false
@@ -282,9 +292,9 @@ export class StorageStoreCommand extends StorageProjectCommand {
282
292
  '--hashed cannot be combined with --overwrite; changed bytes get a new name',
283
293
  )
284
294
  const apiOrigin = this.apiOrigin
285
- const uploaded = new Map<string, CliStoredImageReceipt>()
295
+ const uploaded = new Map<string, CliStoredAssetReceipt>()
286
296
  if (this.files.length > 1 && !this.destination.endsWith('/'))
287
- throw new Error('Multiple images need a directory destination ending in /')
297
+ throw new Error('Multiple files need a directory destination ending in /')
288
298
  const inputs = this.files.map((input) => ({
289
299
  file: input,
290
300
  path: this.destination.endsWith('/')
@@ -293,14 +303,14 @@ export class StorageStoreCommand extends StorageProjectCommand {
293
303
  }))
294
304
  for (const input of inputs) {
295
305
  if (file === resolve(input.file))
296
- throw new Error('The receipts file cannot be the input image')
306
+ throw new Error('The receipts file cannot be the input file')
297
307
  if (resolve(storageTypesPath(file)) === resolve(input.file))
298
- throw new Error('The generated declarations file cannot be the input image')
308
+ throw new Error('The generated declarations file cannot be the input file')
299
309
  validateStoragePath(input.path)
300
310
  }
301
311
  if (!this.hashed && new Set(inputs.map((input) => input.path)).size !== inputs.length)
302
312
  throw new Error(
303
- 'Input image basenames collide in the destination directory; rename them first',
313
+ 'Input file basenames collide in the destination directory; rename them first',
304
314
  )
305
315
  const publicPrefix = this.publicDelivery
306
316
  ? normalizeStoragePublicPrefix(
@@ -311,6 +321,7 @@ export class StorageStoreCommand extends StorageProjectCommand {
311
321
  for (const input of inputs) {
312
322
  let publicImage = false
313
323
  let unchanged = false
324
+ let uploadFailure: Error | undefined
314
325
  destination = input.path
315
326
  stored = {}
316
327
  saved = false
@@ -327,24 +338,36 @@ export class StorageStoreCommand extends StorageProjectCommand {
327
338
  if (receipts?.workspace === workspace)
328
339
  assertStorageCatalogOrigin(receipts, apiOrigin, this.receipts)
329
340
  signal.throwIfAborted()
330
- if (this.hashed) {
331
- // This pass chooses the path and deduplicates. storeImage independently verifies its
341
+ if (
342
+ !this.overwrite &&
343
+ (this.hashed ||
344
+ uploaded.has(destination) ||
345
+ (receipts?.workspace === workspace &&
346
+ (Object.hasOwn(receipts.images, destination) ||
347
+ Object.hasOwn(receipts.files ?? {}, destination))))
348
+ ) {
349
+ // Hash only to choose a hashed path or compare an existing receipt. storeAsset verifies its
332
350
  // upload input; sharing a caller-supplied checksum would need an immutable-file contract.
333
- const { md5hash, size } = await hashImageFile(input.file, signal)
334
- const extension = posix.extname(destination)
335
- destination = `${destination.slice(0, destination.length - extension.length)}.${md5hash.slice(0, 8)}${extension}`
351
+ const { md5hash, size } = await hashAssetFile(input.file, signal)
352
+ if (this.hashed) {
353
+ const extension = posix.extname(destination)
354
+ destination = `${destination.slice(0, destination.length - extension.length)}.${md5hash.slice(0, 8)}${extension}`
355
+ }
336
356
  validateStoragePath(destination)
337
357
  // Explicit workspace overrides leave the catalog alone; still deduplicate this batch.
338
358
  const candidate =
339
359
  uploaded.get(destination) ??
340
360
  (receipts?.workspace === workspace && Object.hasOwn(receipts.images, destination)
341
361
  ? receipts.images[destination]
342
- : undefined)
362
+ : receipts?.workspace === workspace &&
363
+ Object.hasOwn(receipts.files ?? {}, destination)
364
+ ? receipts.files?.[destination]
365
+ : undefined)
343
366
  if (candidate !== undefined) {
344
367
  const previous = hashedReceiptSchema.safeParse(candidate)
345
368
  if (!previous.success || previous.data.path !== destination)
346
369
  throw new Error(
347
- `Catalog receipt for ${JSON.stringify(destination)} is incomplete or does not match this file. Run transloadit storage receipts sync ${quoteCliArgument(destination)} --receipts ${quoteCliArgument(this.receipts)}${this.endpoint === undefined ? '' : ` --endpoint ${quoteCliArgument(this.endpoint)}`} to recover version identity from the same API environment, then retry; nothing uploaded.`,
370
+ `Catalog receipt for ${JSON.stringify(destination)} is incomplete or does not match this file. Run transloadit storage receipts sync ${quoteCliArgument(destination)} --receipts ${quoteCliArgument(this.receipts)}${this.endpoint === undefined ? '' : ` --endpoint ${quoteCliArgument(this.endpoint)}`} to recover version identity and any available checksum from the same API environment. If the checksum remains unavailable, ${this.hashed ? 'choose another destination basename' : 'choose a fresh name or use --overwrite only to deliberately replace it'}; sync cannot generate a missing checksum. Nothing uploaded.`,
348
371
  )
349
372
  if (previous.data.apiOrigin !== apiOrigin)
350
373
  throw new Error(
@@ -352,39 +375,60 @@ export class StorageStoreCommand extends StorageProjectCommand {
352
375
  )
353
376
  if (previous.data.md5hash !== md5hash || previous.data.size !== size)
354
377
  throw new Error(
355
- `Stored bytes for ${JSON.stringify(destination)} differ from this file. An older deployment may have transformed the upload, or the short hashes collided. Restoring the same receipt will not help; choose another destination basename. Nothing uploaded or replaced.`,
378
+ this.hashed
379
+ ? `Stored bytes for ${JSON.stringify(destination)} differ from this file. An older deployment may have transformed the upload, or the short hashes collided. Restoring the same receipt will not help; choose another destination basename. Nothing uploaded or replaced.`
380
+ : `Stored bytes for ${JSON.stringify(destination)} differ from this file. Choose a fresh name or use --overwrite only to deliberately replace it. Nothing uploaded or replaced.`,
356
381
  )
357
382
  stored.receipt = previous.data
358
383
  unchanged = true
359
384
  }
360
385
  }
361
- stored.receipt ??= await this.client.storeImage(input.file, {
362
- path: destination,
363
- ...(placeholder === undefined ? {} : { placeholder }),
364
- signal,
365
- onReceipt: (receipt, expected, assemblyId) => {
366
- const sizeMatches = receipt.size === expected.size
367
- const md5Matches = receipt.md5hash === expected.md5hash
368
- this.output.debug(
369
- JSON.stringify({
370
- assemblyId,
371
- result: receipt,
372
- input: expected,
373
- sizeMatches,
374
- md5Matches,
375
- }),
376
- )
377
- if (!sizeMatches || !md5Matches) {
378
- const difference = sizeMatches
379
- ? 'same size, different MD5'
380
- : `${expected.size.toLocaleString('en-US')} → ${receipt.size.toLocaleString('en-US')} bytes`
381
- this.output.warn(
382
- `Stored bytes differ from ${input.file} (${difference}); the workspace plan may have transformed the upload (for example, a Community-plan watermark on older deployments). The receipt describes the stored image.`,
386
+ stored.receipt ??= await this.client
387
+ .storeAsset(input.file, {
388
+ path: destination,
389
+ ...(placeholder === undefined ? {} : { placeholder }),
390
+ signal,
391
+ onReceipt: (receipt, expected, assemblyId) => {
392
+ // Receipt identity is validated before byte fidelity; retain an actual write even
393
+ // when the server transformed the original and storeAsset subsequently rejects.
394
+ stored.receipt = receipt
395
+ const sizeMatches = receipt.size === expected.size
396
+ const md5Matches = receipt.md5hash === expected.md5hash
397
+ this.output.debug(
398
+ JSON.stringify({
399
+ assemblyId,
400
+ result: receipt,
401
+ input: expected,
402
+ sizeMatches,
403
+ md5Matches,
404
+ }),
383
405
  )
384
- }
385
- },
386
- ...(this.overwrite ? { overwrite: true } : {}),
387
- })
406
+ if (!sizeMatches || !md5Matches) {
407
+ const difference = sizeMatches
408
+ ? 'same size, different MD5'
409
+ : `${expected.size.toLocaleString('en-US')} → ${receipt.size.toLocaleString('en-US')} bytes`
410
+ this.output.warn(
411
+ `Stored bytes differ from ${input.file} (${difference}); the workspace plan may have transformed the upload (for example, a Community-plan watermark on older deployments). The receipt describes the stored file.`,
412
+ )
413
+ }
414
+ },
415
+ ...(this.overwrite ? { overwrite: true } : {}),
416
+ })
417
+ .catch((error: unknown) => {
418
+ // The error owns recovery evidence; the optional observer need not have run.
419
+ const completed = z
420
+ .object({
421
+ receipt: storedAssetSchema.extend({
422
+ md5hash: storedAssetSchema.shape.md5hash.unwrap(),
423
+ }),
424
+ })
425
+ .safeParse(error instanceof InconsistentResponseError ? error.cause : undefined)
426
+ if (completed.success && completed.data.receipt.path === destination)
427
+ stored.receipt = completed.data.receipt
428
+ if (stored.receipt === undefined) throw error
429
+ uploadFailure = ensureError(error)
430
+ return stored.receipt
431
+ })
388
432
  if (stored.receipt.workspace !== workspace)
389
433
  throw new Error(
390
434
  `The stored receipt belongs to Workspace ${stored.receipt.workspace}, not ${workspace}. The project catalog was not changed; inspect the Assembly before retrying.`,
@@ -395,7 +439,7 @@ export class StorageStoreCommand extends StorageProjectCommand {
395
439
  ...(this.hashed ? { source: basename(input.file) } : {}),
396
440
  apiOrigin,
397
441
  }
398
- if (this.hashed) uploaded.set(destination, stored.receipt)
442
+ uploaded.set(destination, stored.receipt)
399
443
  if (receipts !== undefined && receipts.workspace !== workspace) {
400
444
  this.output.notice(
401
445
  `Catalog ${this.receipts} was not changed; it belongs to ${receipts.workspace}. Use --receipts for a separate catalog.`,
@@ -405,6 +449,16 @@ export class StorageStoreCommand extends StorageProjectCommand {
405
449
  publicImage = receipts?.public.some((prefix) => destination.startsWith(prefix)) ?? false
406
450
  // Replays must not reorder fields or discard application metadata from a saved receipt.
407
451
  if (unchanged) return receipts
452
+ let images = { ...receipts?.images }
453
+ let files = { ...receipts?.files }
454
+ // Updating an existing key preserves catalog order; remove only a former category.
455
+ if (isStorageImageReceipt(stored.receipt)) {
456
+ delete files[destination]
457
+ images = { ...images, [destination]: stored.receipt }
458
+ } else {
459
+ delete images[destination]
460
+ files = { ...files, [destination]: stored.receipt }
461
+ }
408
462
  return {
409
463
  ...receipts,
410
464
  workspace,
@@ -413,7 +467,8 @@ export class StorageStoreCommand extends StorageProjectCommand {
413
467
  receipts?.delivery ??
414
468
  storageCatalogDelivery(this.endpoint ?? this.cliConfig.endpoint),
415
469
  public: receipts?.public ?? [],
416
- images: { ...receipts?.images, [stored.receipt.path]: stored.receipt },
470
+ images,
471
+ ...(Object.keys(files).length > 0 || receipts?.files !== undefined ? { files } : {}),
417
472
  }
418
473
  },
419
474
  () => {
@@ -421,6 +476,7 @@ export class StorageStoreCommand extends StorageProjectCommand {
421
476
  },
422
477
  )
423
478
  if (stored.receipt === undefined) throw new Error('Storage did not return a receipt')
479
+ if (uploadFailure) throw uploadFailure
424
480
  if (publicPrefix !== undefined && published === undefined) {
425
481
  this.output.notice(
426
482
  `Publishing ${publicPrefix} recursively: all current and future objects under this prefix will be public.`,
@@ -440,6 +496,13 @@ export class StorageStoreCommand extends StorageProjectCommand {
440
496
  })
441
497
  }
442
498
  const receipt = stored.receipt
499
+ if (!isStorageImageReceipt(receipt)) {
500
+ this.output.print(
501
+ `${unchanged ? `Unchanged ${receipt.path}; no upload needed.\n` : ''}${saved ? `Saved ${receipt.path} in ${this.receipts}.` : `Stored ${receipt.path}; the different-workspace catalog was left unchanged.`}\nUse the asset/version receipt for original delivery or a Smart CDN preview.`,
502
+ { ...receipt, uploaded: !unchanged },
503
+ )
504
+ continue
505
+ }
443
506
  if (placeholder === 'blur' && receipt.thumbhash === undefined) {
444
507
  this.output.notice(
445
508
  unchanged
@@ -486,7 +549,7 @@ export class StorageStoreCommand extends StorageProjectCommand {
486
549
  if (stored.receipt !== undefined) {
487
550
  this.output.error(
488
551
  [
489
- failure.message,
552
+ formatAPIError(failure),
490
553
  ...(published === undefined
491
554
  ? []
492
555
  : [
@@ -526,7 +589,7 @@ export class StorageStoreCommand extends StorageProjectCommand {
526
589
  ].join(' ')
527
590
  this.output.error(
528
591
  [
529
- failure.message,
592
+ formatAPIError(failure),
530
593
  `Destination: ${JSON.stringify(destination)}`,
531
594
  `Assembly ID: ${JSON.stringify(recovery.data.assemblyId)}`,
532
595
  ...(recovery.data.receiptCheck === undefined
@@ -544,13 +607,16 @@ export class StorageStoreCommand extends StorageProjectCommand {
544
607
  }
545
608
  if (failure instanceof ApiError && failure.code === 'TRANSLOADIT_STORE_CONFLICT') {
546
609
  this.output.error(
547
- this.hashed
548
- ? `Storage destination ${JSON.stringify(destination)} already exists, but no matching catalog receipt proves its contents. Restore its catalog receipt or choose another destination basename; the object was not replaced.`
549
- : `Storage destination ${JSON.stringify(destination)} already exists. Choose a fresh name; use --overwrite only if you deliberately want to replace that object.`,
610
+ formatAPIError(
611
+ failure,
612
+ this.hashed
613
+ ? `Storage destination ${JSON.stringify(destination)} already exists, but no matching catalog receipt proves its contents. Restore its catalog receipt or choose another destination basename; the object was not replaced.`
614
+ : `Storage destination ${JSON.stringify(destination)} already exists. Choose a fresh name; use --overwrite only if you deliberately want to replace that object.`,
615
+ ),
550
616
  )
551
617
  return 1
552
618
  }
553
- this.output.error(failure.message)
619
+ this.output.error(formatAPIError(failure))
554
620
  return 1
555
621
  }
556
622
  }
@@ -598,7 +664,7 @@ export class StorageListCommand extends UnauthenticatedCommand {
598
664
  )
599
665
  return undefined
600
666
  } catch (error) {
601
- this.output.error(ensureError(error).message)
667
+ this.output.error(formatAPIError(error))
602
668
  return 1
603
669
  }
604
670
  }
@@ -613,8 +679,6 @@ const uploadEvidenceSchema = storedAssetSchema.extend({
613
679
 
614
680
  const hashedReceiptSchema = uploadEvidenceSchema.extend({
615
681
  md5hash: storedAssetSchema.shape.md5hash.unwrap(),
616
- width: storedAssetSchema.shape.width.unwrap(),
617
- height: storedAssetSchema.shape.height.unwrap(),
618
682
  })
619
683
 
620
684
  /** Recovers pinned receipts from bounded native catalog pages without downloading originals. */
@@ -626,7 +690,9 @@ export class StorageReceiptsSyncCommand extends UnauthenticatedCommand {
626
690
  details: `
627
691
  Uses native catalog pages and dam:write scope to also recover declared public prefixes.
628
692
  Adds or refreshes matched paths; never prunes unmatched entries. Local placeholder metadata
629
- survives only for the same retained version. Every matched image must have catalog dimensions.
693
+ survives only for the same retained version. Known image MIME types with dimensions enter
694
+ images; other media and unknown MIME types enter files in the same catalog.
695
+ Mixed prefixes require this CLI version or newer.
630
696
  Any failure preserves the previous file. No Assembly, original download or remote write occurs.
631
697
  `,
632
698
  examples: [['Recover website images', 'transloadit storage receipts sync website/']],
@@ -663,23 +729,13 @@ export class StorageReceiptsSyncCommand extends UnauthenticatedCommand {
663
729
  assertStorageCatalogOrigin(previous, endpoint, this.receipts)
664
730
  const assets = await listStorageAssets(client, workspace, this.prefix, signal)
665
731
  const entries = assets.map((asset) => {
666
- try {
667
- validateStoragePath(asset.path)
668
- } catch (cause) {
669
- throw new Error(
670
- `Storage path ${JSON.stringify(asset.path)} is valid for listing but unsupported by the image renderer. Rename it before recovering an image catalog.`,
671
- { cause },
672
- )
673
- }
674
- if (asset.width === undefined || asset.height === undefined) {
675
- throw new Error(
676
- `Storage image ${JSON.stringify(asset.path)} needs positive catalog width and height. Select an image-only prefix and backfill missing dimensions before retrying.`,
677
- )
678
- }
679
732
  const evidence = uploadEvidenceSchema.safeParse(
680
733
  previous?.workspace === workspace && Object.hasOwn(previous.images, asset.path)
681
734
  ? previous.images[asset.path]
682
- : undefined,
735
+ : previous?.workspace === workspace &&
736
+ Object.hasOwn(previous.files ?? {}, asset.path)
737
+ ? previous.files?.[asset.path]
738
+ : undefined,
683
739
  )
684
740
  const retained =
685
741
  evidence.success &&
@@ -693,6 +749,18 @@ export class StorageReceiptsSyncCommand extends UnauthenticatedCommand {
693
749
  apiOrigin: endpoint,
694
750
  }
695
751
  if (asset.has_alpha !== undefined) delete merged.hasAlpha
752
+ // Listing already validates native paths. Only Viewer images use path-based delivery;
753
+ // generic files use asset/version IDs and may contain renderer delimiters such as |.
754
+ if (isStorageImageReceipt(merged)) {
755
+ try {
756
+ validateStoragePath(asset.path)
757
+ } catch (cause) {
758
+ throw new Error(
759
+ `Storage path ${JSON.stringify(asset.path)} is valid for listing but unsupported by the image renderer. Rename it before recovering an image catalog.`,
760
+ { cause },
761
+ )
762
+ }
763
+ }
696
764
  return [asset.path, merged]
697
765
  })
698
766
  count = entries.length
@@ -731,6 +799,17 @@ export class StorageReceiptsSyncCommand extends UnauthenticatedCommand {
731
799
  )
732
800
  })
733
801
  catalogUpdated = true
802
+ const images = new Map(Object.entries(previous?.images ?? {}))
803
+ const files = new Map(Object.entries(previous?.files ?? {}))
804
+ for (const [path, receipt] of Object.entries(synced)) {
805
+ if (isStorageImageReceipt(receipt)) {
806
+ files.delete(path)
807
+ images.set(path, receipt)
808
+ } else {
809
+ images.delete(path)
810
+ files.set(path, receipt)
811
+ }
812
+ }
734
813
  if (policy.public_prefixes.length === 0)
735
814
  this.output.notice(
736
815
  'No public prefixes are declared on the server. For public delivery, deliberately publish a directory with storage publish; otherwise configure authorize for private images. Sync never publishes files.',
@@ -743,7 +822,10 @@ export class StorageReceiptsSyncCommand extends UnauthenticatedCommand {
743
822
  previous?.delivery ??
744
823
  storageCatalogDelivery(this.endpoint ?? config.credentialsEndpoint),
745
824
  public: policy.public_prefixes.map(({ prefix }) => prefix),
746
- images: { ...previous?.images, ...synced },
825
+ images: Object.fromEntries(images),
826
+ ...(files.size > 0 || previous?.files !== undefined
827
+ ? { files: Object.fromEntries(files) }
828
+ : {}),
747
829
  }
748
830
  })
749
831
  this.output.print(
@@ -754,7 +836,7 @@ export class StorageReceiptsSyncCommand extends UnauthenticatedCommand {
754
836
  )
755
837
  return undefined
756
838
  } catch (error) {
757
- this.output.error(ensureError(error).message)
839
+ this.output.error(formatAPIError(error))
758
840
  return 1
759
841
  }
760
842
  }
@@ -6,6 +6,7 @@ import fs from 'node:fs'
6
6
  import { Command, Option } from 'clipanion'
7
7
 
8
8
  import { sendTusRequest } from '../../tus.ts'
9
+ import { formatAPIError } from '../helpers.ts'
9
10
  import { UnauthenticatedCommand } from './BaseCommand.ts'
10
11
 
11
12
  export interface UploadOptions {
@@ -125,7 +126,7 @@ export class UploadCommand extends UnauthenticatedCommand {
125
126
  })
126
127
  return undefined
127
128
  } catch (err) {
128
- this.output.error(err instanceof Error ? err.message : String(err))
129
+ this.output.error(formatAPIError(err))
129
130
  return 1
130
131
  }
131
132
  }
@@ -179,7 +179,7 @@ export async function deviceLogin(
179
179
  error.success &&
180
180
  error.data.error === 'CLI_DEVICE_AUTHORIZATION_DENIED'
181
181
  )
182
- throw new Error('The login was denied in the browser.')
182
+ throw new Error('The login was denied in the browser.', { cause: token.body })
183
183
  if (token.statusCode === 429 || (error.success && error.data.error === 'slow_down')) {
184
184
  const seconds = Number(token.headers['retry-after'])
185
185
  // Longer waits are pointless after the authorization deadline and can overflow Node's
@@ -191,10 +191,11 @@ export async function deviceLogin(
191
191
  continue
192
192
  }
193
193
  if (error.success && error.data.error === 'CLI_DEVICE_AUTHORIZATION_NOT_FOUND')
194
- throw new Error(expiredMessage)
194
+ throw new Error(expiredMessage, { cause: token.body })
195
195
  if (token.statusCode < 200 || token.statusCode >= 300)
196
196
  throw new Error(
197
197
  'Browser login was refused. Run transloadit auth login again; nothing was saved.',
198
+ { cause: token.body },
198
199
  )
199
200
  const pending = pendingSchema.safeParse(token.body)
200
201
  if (pending.success) {
@@ -6,6 +6,7 @@ import fs from 'node:fs'
6
6
  import fsp from 'node:fs/promises'
7
7
  import { homedir, userInfo } from 'node:os'
8
8
  import path from 'node:path'
9
+ import { stripVTControlCharacters } from 'node:util'
9
10
 
10
11
  import { parse as parseDotenv } from 'dotenv'
11
12
  import { ParseError, RequestError } from 'got'
@@ -536,7 +537,36 @@ export async function readCliInput(options: ReadCliInputOptions): Promise<CliInp
536
537
  return { content: null, isStdin: false }
537
538
  }
538
539
 
539
- export function formatAPIError(err: unknown): string {
540
+ const apiReasonSchema = z.object({ reason: z.string().trim().min(1) })
541
+
542
+ function sanitizeAPIErrorText(message: string): string {
543
+ const normalized = stripVTControlCharacters(Buffer.from(message).toString('utf8'))
544
+ .replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ' ')
545
+ .trim()
546
+ const characters = [...normalized]
547
+ return characters.length > 2000 ? `${characters.slice(0, 1999).join('')}…` : normalized
548
+ }
549
+
550
+ /** Render bounded, terminal-safe CLI diagnostics without changing SDK errors. */
551
+ export function formatAPIError(error: unknown, message = formatAPIErrorMessage(error)): string {
552
+ const text = sanitizeAPIErrorText(message)
553
+ const visited = new Set<unknown>()
554
+ let current = error
555
+ while (current != null && !visited.has(current)) {
556
+ visited.add(current)
557
+ const parsed = apiReasonSchema.safeParse(
558
+ current instanceof RequestError ? current.response?.body : current,
559
+ )
560
+ if (parsed.success) {
561
+ const reason = sanitizeAPIErrorText(parsed.data.reason)
562
+ return text.includes(reason) ? text : `${text} ${reason}`
563
+ }
564
+ current = current instanceof Error ? current.cause : undefined
565
+ }
566
+ return text
567
+ }
568
+
569
+ function formatAPIErrorMessage(err: unknown): string {
540
570
  if (err instanceof RequestError) {
541
571
  // ParseError messages contain response fragments and signed request URLs.
542
572
  if (err instanceof ParseError) return `${err.code}: The API returned invalid JSON.`
@@ -1,15 +1,22 @@
1
1
  import { ApiError } from '../ApiError.ts'
2
+ import { formatAPIError } from './helpers.ts'
2
3
 
3
- /** Actionable CLI advice without printing remote response bodies or credentials. */
4
+ /** Keep Storage advice alongside sanitized text reasons, without dumping response bodies. */
4
5
  export function storagePublicError(error: unknown, workspace?: string): string {
5
6
  if (error instanceof ApiError && error.code === 'STORAGE_PUBLIC_PREFIX_NEEDS_SMART_CDN_KEY') {
6
7
  const slug =
7
8
  workspace !== undefined && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(workspace)
8
9
  ? workspace
9
10
  : '<workspace>'
10
- return `Enable Smart CDN on an Auth Key at https://transloadit.com/c/${slug}/template-credentials/ and retry. The same key can serve Assemblies and Smart CDN.`
11
+ return formatAPIError(
12
+ error,
13
+ `Enable Smart CDN on an Auth Key at https://transloadit.com/c/${slug}/template-credentials/ and retry. The same key can serve Assemblies and Smart CDN.`,
14
+ )
11
15
  }
12
16
  if (error instanceof TypeError) return error.message
13
- // Keep unrecognized server errors out of CLI output; only known codes select specific advice.
14
- return 'Could not update or list public Storage prefixes. Check the API endpoint and Auth Key dam:write scope, then retry.'
17
+ // Unknown codes keep generic advice; only the textual reason is added by the shared formatter.
18
+ return formatAPIError(
19
+ error,
20
+ 'Could not update or list public Storage prefixes. Check the API endpoint and Auth Key dam:write scope, then retry.',
21
+ )
15
22
  }