transloadit 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.
- package/README.md +73 -8
- package/dist/Transloadit.d.ts +6 -0
- package/dist/Transloadit.d.ts.map +1 -1
- package/dist/Transloadit.js +9 -0
- package/dist/Transloadit.js.map +1 -1
- package/dist/bearerToken.d.ts +2 -0
- package/dist/bearerToken.d.ts.map +1 -1
- package/dist/bearerToken.js +6 -9
- package/dist/bearerToken.js.map +1 -1
- package/dist/cli/commands/assemblies.d.ts.map +1 -1
- package/dist/cli/commands/assemblies.js +6 -4
- package/dist/cli/commands/assemblies.js.map +1 -1
- package/dist/cli/commands/auth.d.ts.map +1 -1
- package/dist/cli/commands/auth.js +2 -1
- package/dist/cli/commands/auth.js.map +1 -1
- package/dist/cli/commands/image.js +3 -3
- package/dist/cli/commands/image.js.map +1 -1
- package/dist/cli/commands/login.d.ts.map +1 -1
- package/dist/cli/commands/login.js +10 -9
- package/dist/cli/commands/login.js.map +1 -1
- package/dist/cli/commands/notifications.js +2 -2
- package/dist/cli/commands/notifications.js.map +1 -1
- package/dist/cli/commands/storage.d.ts.map +1 -1
- package/dist/cli/commands/storage.js +120 -47
- package/dist/cli/commands/storage.js.map +1 -1
- package/dist/cli/commands/upload.d.ts.map +1 -1
- package/dist/cli/commands/upload.js +2 -1
- package/dist/cli/commands/upload.js.map +1 -1
- package/dist/cli/deviceLogin.d.ts.map +1 -1
- package/dist/cli/deviceLogin.js +3 -3
- package/dist/cli/deviceLogin.js.map +1 -1
- package/dist/cli/helpers.d.ts +2 -1
- package/dist/cli/helpers.d.ts.map +1 -1
- package/dist/cli/helpers.js +26 -1
- package/dist/cli/helpers.js.map +1 -1
- package/dist/cli/storagePublic.d.ts +1 -1
- package/dist/cli/storagePublic.d.ts.map +1 -1
- package/dist/cli/storagePublic.js +5 -4
- package/dist/cli/storagePublic.js.map +1 -1
- package/dist/cli/storageReceipts.d.ts +80 -1
- package/dist/cli/storageReceipts.d.ts.map +1 -1
- package/dist/cli/storageReceipts.js +48 -12
- package/dist/cli/storageReceipts.js.map +1 -1
- package/dist/storageAsset.d.ts +44 -0
- package/dist/storageAsset.d.ts.map +1 -0
- package/dist/storageAsset.js +173 -0
- package/dist/storageAsset.js.map +1 -0
- package/dist/storageImage.d.ts +11 -35
- package/dist/storageImage.d.ts.map +1 -1
- package/dist/storageImage.js +21 -141
- package/dist/storageImage.js.map +1 -1
- package/package.json +2 -2
- package/src/Transloadit.ts +22 -0
- package/src/bearerToken.ts +8 -10
- package/src/cli/commands/assemblies.ts +7 -4
- package/src/cli/commands/auth.ts +2 -0
- package/src/cli/commands/image.ts +3 -3
- package/src/cli/commands/login.ts +12 -10
- package/src/cli/commands/notifications.ts +2 -2
- package/src/cli/commands/storage.ts +160 -78
- package/src/cli/commands/upload.ts +2 -1
- package/src/cli/deviceLogin.ts +3 -2
- package/src/cli/helpers.ts +31 -1
- package/src/cli/storagePublic.ts +11 -4
- package/src/cli/storageReceipts.ts +50 -11
- package/src/storageAsset.ts +271 -0
- package/src/storageImage.ts +43 -201
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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 {
|
|
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: '
|
|
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(
|
|
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(
|
|
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
|
|
212
|
+
interface CliStoredAssetReceipt extends StoredAssetReceipt {
|
|
207
213
|
source?: string
|
|
208
214
|
apiOrigin?: string
|
|
209
215
|
}
|
|
210
216
|
|
|
211
|
-
async function
|
|
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
|
|
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
|
|
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,
|
|
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?:
|
|
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,
|
|
295
|
+
const uploaded = new Map<string, CliStoredAssetReceipt>()
|
|
286
296
|
if (this.files.length > 1 && !this.destination.endsWith('/'))
|
|
287
|
-
throw new Error('Multiple
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
331
|
-
|
|
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
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
:
|
|
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,
|
|
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
|
-
|
|
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
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
|
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(
|
|
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.
|
|
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
|
-
:
|
|
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:
|
|
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(
|
|
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(
|
|
129
|
+
this.output.error(formatAPIError(err))
|
|
129
130
|
return 1
|
|
130
131
|
}
|
|
131
132
|
}
|
package/src/cli/deviceLogin.ts
CHANGED
|
@@ -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) {
|
package/src/cli/helpers.ts
CHANGED
|
@@ -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
|
-
|
|
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.`
|
package/src/cli/storagePublic.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { ApiError } from '../ApiError.ts'
|
|
2
|
+
import { formatAPIError } from './helpers.ts'
|
|
2
3
|
|
|
3
|
-
/**
|
|
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
|
|
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
|
-
//
|
|
14
|
-
return
|
|
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
|
}
|