transloadit 4.11.1 → 4.13.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.
Files changed (140) hide show
  1. package/README.md +231 -23
  2. package/dist/Transloadit.d.ts +55 -1
  3. package/dist/Transloadit.d.ts.map +1 -1
  4. package/dist/Transloadit.js +143 -2
  5. package/dist/Transloadit.js.map +1 -1
  6. package/dist/alphalib/types/assemblyStatus.d.ts +574 -198
  7. package/dist/alphalib/types/assemblyStatus.d.ts.map +1 -1
  8. package/dist/alphalib/types/assemblyStatus.js +17 -0
  9. package/dist/alphalib/types/assemblyStatus.js.map +1 -1
  10. package/dist/alphalib/types/robots/_index.d.ts +205 -93
  11. package/dist/alphalib/types/robots/_index.d.ts.map +1 -1
  12. package/dist/alphalib/types/robots/_index.js +8 -0
  13. package/dist/alphalib/types/robots/_index.js.map +1 -1
  14. package/dist/alphalib/types/robots/_instructions-primitives.d.ts +4 -4
  15. package/dist/alphalib/types/robots/_instructions-primitives.d.ts.map +1 -1
  16. package/dist/alphalib/types/robots/_instructions-primitives.js +2 -0
  17. package/dist/alphalib/types/robots/_instructions-primitives.js.map +1 -1
  18. package/dist/alphalib/types/robots/ai-chat.d.ts +2 -1
  19. package/dist/alphalib/types/robots/ai-chat.d.ts.map +1 -1
  20. package/dist/alphalib/types/robots/ai-chat.js +6 -5
  21. package/dist/alphalib/types/robots/ai-chat.js.map +1 -1
  22. package/dist/alphalib/types/robots/file-serve.d.ts +12 -0
  23. package/dist/alphalib/types/robots/file-serve.d.ts.map +1 -1
  24. package/dist/alphalib/types/robots/file-serve.js +9 -3
  25. package/dist/alphalib/types/robots/file-serve.js.map +1 -1
  26. package/dist/alphalib/types/robots/transloadit-import.d.ts +195 -0
  27. package/dist/alphalib/types/robots/transloadit-import.d.ts.map +1 -0
  28. package/dist/alphalib/types/robots/transloadit-import.js +108 -0
  29. package/dist/alphalib/types/robots/transloadit-import.js.map +1 -0
  30. package/dist/alphalib/types/robots/transloadit-store.d.ts +441 -0
  31. package/dist/alphalib/types/robots/transloadit-store.d.ts.map +1 -0
  32. package/dist/alphalib/types/robots/transloadit-store.js +69 -0
  33. package/dist/alphalib/types/robots/transloadit-store.js.map +1 -0
  34. package/dist/alphalib/types/robots/video-ondemand.d.ts +8 -8
  35. package/dist/alphalib/types/storageAsset.d.ts +226 -0
  36. package/dist/alphalib/types/storageAsset.d.ts.map +1 -0
  37. package/dist/alphalib/types/storageAsset.js +67 -0
  38. package/dist/alphalib/types/storageAsset.js.map +1 -0
  39. package/dist/alphalib/types/template.d.ts.map +1 -1
  40. package/dist/alphalib/types/template.js +5 -2
  41. package/dist/alphalib/types/template.js.map +1 -1
  42. package/dist/alphalib/types/templateCredential.d.ts +2 -2
  43. package/dist/cli/commands/BaseCommand.d.ts +3 -1
  44. package/dist/cli/commands/BaseCommand.d.ts.map +1 -1
  45. package/dist/cli/commands/BaseCommand.js +3 -2
  46. package/dist/cli/commands/BaseCommand.js.map +1 -1
  47. package/dist/cli/commands/assemblies.d.ts.map +1 -1
  48. package/dist/cli/commands/assemblies.js +9 -4
  49. package/dist/cli/commands/assemblies.js.map +1 -1
  50. package/dist/cli/commands/auth.d.ts +6 -0
  51. package/dist/cli/commands/auth.d.ts.map +1 -1
  52. package/dist/cli/commands/auth.js +21 -2
  53. package/dist/cli/commands/auth.js.map +1 -1
  54. package/dist/cli/commands/image.d.ts +15 -0
  55. package/dist/cli/commands/image.d.ts.map +1 -0
  56. package/dist/cli/commands/image.js +234 -0
  57. package/dist/cli/commands/image.js.map +1 -0
  58. package/dist/cli/commands/index.d.ts.map +1 -1
  59. package/dist/cli/commands/index.js +15 -1
  60. package/dist/cli/commands/index.js.map +1 -1
  61. package/dist/cli/commands/login.d.ts +24 -0
  62. package/dist/cli/commands/login.d.ts.map +1 -0
  63. package/dist/cli/commands/login.js +275 -0
  64. package/dist/cli/commands/login.js.map +1 -0
  65. package/dist/cli/commands/storage.d.ts +58 -0
  66. package/dist/cli/commands/storage.d.ts.map +1 -0
  67. package/dist/cli/commands/storage.js +589 -0
  68. package/dist/cli/commands/storage.js.map +1 -0
  69. package/dist/cli/commands/templates.d.ts.map +1 -1
  70. package/dist/cli/commands/templates.js +10 -6
  71. package/dist/cli/commands/templates.js.map +1 -1
  72. package/dist/cli/deviceLogin.d.ts +11 -0
  73. package/dist/cli/deviceLogin.d.ts.map +1 -0
  74. package/dist/cli/deviceLogin.js +187 -0
  75. package/dist/cli/deviceLogin.js.map +1 -0
  76. package/dist/cli/helpers.d.ts +23 -1
  77. package/dist/cli/helpers.d.ts.map +1 -1
  78. package/dist/cli/helpers.js +159 -25
  79. package/dist/cli/helpers.js.map +1 -1
  80. package/dist/cli/secretInput.d.ts +3 -0
  81. package/dist/cli/secretInput.d.ts.map +1 -0
  82. package/dist/cli/secretInput.js +11 -0
  83. package/dist/cli/secretInput.js.map +1 -0
  84. package/dist/cli/storageCatalog.d.ts +19 -0
  85. package/dist/cli/storageCatalog.d.ts.map +1 -0
  86. package/dist/cli/storageCatalog.js +129 -0
  87. package/dist/cli/storageCatalog.js.map +1 -0
  88. package/dist/cli/storagePublic.d.ts +3 -0
  89. package/dist/cli/storagePublic.d.ts.map +1 -0
  90. package/dist/cli/storagePublic.js +15 -0
  91. package/dist/cli/storagePublic.js.map +1 -0
  92. package/dist/cli/storageReceipts.d.ts +67 -0
  93. package/dist/cli/storageReceipts.d.ts.map +1 -0
  94. package/dist/cli/storageReceipts.js +242 -0
  95. package/dist/cli/storageReceipts.js.map +1 -0
  96. package/dist/cli/storageSnippets.d.ts +11 -0
  97. package/dist/cli/storageSnippets.d.ts.map +1 -0
  98. package/dist/cli/storageSnippets.js +89 -0
  99. package/dist/cli/storageSnippets.js.map +1 -0
  100. package/dist/storageImage.d.ts +40 -0
  101. package/dist/storageImage.d.ts.map +1 -0
  102. package/dist/storageImage.js +181 -0
  103. package/dist/storageImage.js.map +1 -0
  104. package/dist/storagePublicPrefixes.d.ts +62 -0
  105. package/dist/storagePublicPrefixes.d.ts.map +1 -0
  106. package/dist/storagePublicPrefixes.js +35 -0
  107. package/dist/storagePublicPrefixes.js.map +1 -0
  108. package/dist/storageResults.d.ts +31 -0
  109. package/dist/storageResults.d.ts.map +1 -0
  110. package/dist/storageResults.js +79 -0
  111. package/dist/storageResults.js.map +1 -0
  112. package/package.json +8 -3
  113. package/src/Transloadit.ts +251 -4
  114. package/src/alphalib/types/assemblyStatus.ts +17 -0
  115. package/src/alphalib/types/robots/_index.ts +28 -0
  116. package/src/alphalib/types/robots/_instructions-primitives.ts +2 -0
  117. package/src/alphalib/types/robots/ai-chat.ts +6 -5
  118. package/src/alphalib/types/robots/file-serve.ts +11 -3
  119. package/src/alphalib/types/robots/transloadit-import.ts +154 -0
  120. package/src/alphalib/types/robots/transloadit-store.ts +98 -0
  121. package/src/alphalib/types/storageAsset.ts +88 -0
  122. package/src/alphalib/types/template.ts +3 -0
  123. package/src/cli/commands/BaseCommand.ts +4 -2
  124. package/src/cli/commands/assemblies.ts +13 -5
  125. package/src/cli/commands/auth.ts +26 -3
  126. package/src/cli/commands/image.ts +282 -0
  127. package/src/cli/commands/index.ts +27 -1
  128. package/src/cli/commands/login.ts +334 -0
  129. package/src/cli/commands/storage.ts +733 -0
  130. package/src/cli/commands/templates.ts +14 -7
  131. package/src/cli/deviceLogin.ts +226 -0
  132. package/src/cli/helpers.ts +204 -27
  133. package/src/cli/secretInput.ts +12 -0
  134. package/src/cli/storageCatalog.ts +192 -0
  135. package/src/cli/storagePublic.ts +15 -0
  136. package/src/cli/storageReceipts.ts +307 -0
  137. package/src/cli/storageSnippets.ts +96 -0
  138. package/src/storageImage.ts +266 -0
  139. package/src/storagePublicPrefixes.ts +39 -0
  140. package/src/storageResults.ts +119 -0
@@ -0,0 +1,733 @@
1
+ import type { StoredImageReceipt } from '../../storageImage.ts'
2
+
3
+ import { createHash } from 'node:crypto'
4
+ import { createReadStream } from 'node:fs'
5
+ import { basename, posix, resolve } from 'node:path'
6
+
7
+ import { validateStoragePath } from '@transloadit/utils'
8
+ import { Command, Option } from 'clipanion'
9
+ import { z } from 'zod'
10
+
11
+ import { ApiError } from '../../ApiError.ts'
12
+ import { storedAssetSchema } from '../../alphalib/types/storageAsset.ts'
13
+ import InconsistentResponseError from '../../InconsistentResponseError.ts'
14
+ import { normalizeStoragePublicPrefix } from '../../storagePublicPrefixes.ts'
15
+ import { Transloadit } from '../../Transloadit.ts'
16
+ import { noticeCliCredentialSource, quoteCliArgument, resolveCliConfig } from '../helpers.ts'
17
+ import {
18
+ listStorageAssets,
19
+ resolveStorageWorkspace,
20
+ withStorageCatalog,
21
+ } from '../storageCatalog.ts'
22
+ import { storagePublicError } from '../storagePublic.ts'
23
+ import {
24
+ assertStorageCatalogOrigin,
25
+ assertStorageWorkspace,
26
+ defaultStorageCatalog,
27
+ readStorageCatalog,
28
+ storageCatalogDelivery,
29
+ storageTypesPath,
30
+ updateStorageReceipts,
31
+ } from '../storageReceipts.ts'
32
+ import { storageImageConfigAdvice, storageImagePrivateAdvice } from '../storageSnippets.ts'
33
+ import { ensureError } from '../types.ts'
34
+ import { AuthenticatedCommand, UnauthenticatedCommand } from './BaseCommand.ts'
35
+
36
+ abstract class StorageProjectCommand extends AuthenticatedCommand {
37
+ protected get apiOrigin(): string {
38
+ return new URL(this.endpoint ?? this.cliConfig.endpoint ?? 'https://api2.transloadit.com')
39
+ .origin
40
+ }
41
+ receipts = Option.String('--receipts', defaultStorageCatalog, {
42
+ description: 'Committed project catalog with workspace, public prefixes and image receipts',
43
+ })
44
+ workspace = Option.String('--workspace', {
45
+ description:
46
+ 'Explicit workspace override; a different workspace never changes this project catalog',
47
+ })
48
+ }
49
+
50
+ /** Publishes an explicit directory, independently of uploads or local snippet generation. */
51
+ export class StoragePublishCommand extends StorageProjectCommand {
52
+ static override paths = [['storage', 'publish']]
53
+ static override usage = Command.Usage({
54
+ category: 'Storage',
55
+ description: 'Declare a directory public for unsigned Smart CDN delivery',
56
+ })
57
+ prefix = Option.String({ required: true })
58
+ dryRun = Option.Boolean('--dry-run', false, {
59
+ description: 'List matching objects without publishing or changing the catalog',
60
+ })
61
+ protected async run(): Promise<number | undefined> {
62
+ try {
63
+ noticeCliCredentialSource(this.cliConfig, this.output)
64
+ const prefix = normalizeStoragePublicPrefix(this.prefix)
65
+ if (this.dryRun) {
66
+ const catalog = await readStorageCatalog(this.receipts)
67
+ const objects = await withStorageCatalog(
68
+ {
69
+ endpoint: this.endpoint,
70
+ workspace: this.workspace,
71
+ projectWorkspace: catalog?.workspace,
72
+ },
73
+ (client, workspace) => listStorageAssets(client, workspace, prefix),
74
+ 'Publication dry run',
75
+ undefined,
76
+ this.cliConfig,
77
+ )
78
+ this.output.print(
79
+ `Would publish ${prefix} recursively, including future objects. Nothing changed.\n${objects.length === 0 ? 'No stored objects currently match.' : objects.map(({ path, size }) => `${JSON.stringify(path)}\t${size} bytes`).join('\n')}`,
80
+ { prefix, objects },
81
+ )
82
+ return undefined
83
+ }
84
+ await updateStorageReceipts(this.receipts, async (previous, signal) => {
85
+ const workspace = await resolveStorageWorkspace(
86
+ this,
87
+ this.cliConfig,
88
+ previous?.workspace,
89
+ signal,
90
+ )
91
+ if (previous?.workspace === workspace)
92
+ assertStorageCatalogOrigin(previous, this.apiOrigin, this.receipts)
93
+ const result = await this.client
94
+ .publishStoragePrefix(prefix, { signal })
95
+ .catch((cause: unknown) => {
96
+ signal.throwIfAborted()
97
+ throw new Error(storagePublicError(cause, workspace), { cause })
98
+ })
99
+ this.output.print(
100
+ `Published ${result.prefix}. Files under this directory can be served without signatures.`,
101
+ result,
102
+ )
103
+ if (previous !== undefined && previous.workspace !== workspace) {
104
+ this.output.notice(
105
+ `Catalog ${this.receipts} was not changed; it belongs to ${previous.workspace}.`,
106
+ )
107
+ return undefined
108
+ }
109
+ return {
110
+ ...previous,
111
+ apiOrigin: this.apiOrigin,
112
+ workspace,
113
+ delivery:
114
+ previous?.delivery ?? storageCatalogDelivery(this.endpoint ?? this.cliConfig.endpoint),
115
+ public: [...new Set([...(previous?.public ?? []), result.prefix])],
116
+ images: previous?.images ?? {},
117
+ }
118
+ })
119
+ return undefined
120
+ } catch (error) {
121
+ this.output.error(ensureError(error).message)
122
+ return 1
123
+ }
124
+ }
125
+ }
126
+
127
+ /** Revokes a public prefix at the origin without promising to recall cached bytes. */
128
+ export class StorageUnpublishCommand extends StorageProjectCommand {
129
+ static override paths = [['storage', 'unpublish']]
130
+ static override usage = Command.Usage({
131
+ category: 'Storage',
132
+ description: 'Revoke public origin access to a directory',
133
+ })
134
+ prefix = Option.String({ required: true })
135
+ protected async run(): Promise<number | undefined> {
136
+ try {
137
+ noticeCliCredentialSource(this.cliConfig, this.output)
138
+ const prefix = normalizeStoragePublicPrefix(this.prefix)
139
+ await updateStorageReceipts(this.receipts, async (previous, signal) => {
140
+ const workspace = await resolveStorageWorkspace(
141
+ this,
142
+ this.cliConfig,
143
+ previous?.workspace,
144
+ signal,
145
+ )
146
+ if (previous?.workspace === workspace)
147
+ assertStorageCatalogOrigin(previous, this.apiOrigin, this.receipts)
148
+ const result = await this.client
149
+ .unpublishStoragePrefix(prefix, { signal })
150
+ .catch((cause: unknown) => {
151
+ signal.throwIfAborted()
152
+ throw new Error(storagePublicError(cause, workspace), { cause })
153
+ })
154
+ this.output.print(
155
+ `Unpublished ${result.prefix}; already cached or downloaded bytes cannot be recalled.`,
156
+ result,
157
+ )
158
+ if (previous !== undefined && previous.workspace !== workspace) {
159
+ this.output.notice(
160
+ `Catalog ${this.receipts} was not changed; it belongs to ${previous.workspace}.`,
161
+ )
162
+ return undefined
163
+ }
164
+ return {
165
+ ...previous,
166
+ apiOrigin: this.apiOrigin,
167
+ workspace,
168
+ public: (previous?.public ?? []).filter((prefix) => prefix !== result.prefix),
169
+ images: previous?.images ?? {},
170
+ }
171
+ })
172
+ return undefined
173
+ } catch (error) {
174
+ this.output.error(ensureError(error).message)
175
+ return 1
176
+ }
177
+ }
178
+ }
179
+
180
+ /** Lists explicit publication policy using the signed API, not S3 discovery. */
181
+ export class StoragePublicationsCommand extends AuthenticatedCommand {
182
+ static override paths = [['storage', 'publications']]
183
+ static override usage = Command.Usage({
184
+ category: 'Storage',
185
+ description: 'List explicitly public Storage directories',
186
+ })
187
+ protected async run(): Promise<number | undefined> {
188
+ try {
189
+ noticeCliCredentialSource(this.cliConfig, this.output)
190
+ const result = await this.client.listPublicStoragePrefixes()
191
+ this.output.print(
192
+ result.public_prefixes.length === 0
193
+ ? 'No Storage directories are public.'
194
+ : result.public_prefixes.map(({ prefix }) => prefix).join('\n'),
195
+ result,
196
+ )
197
+ return undefined
198
+ } catch (error) {
199
+ this.output.error(storagePublicError(error, this.cliConfig.authWorkspace))
200
+ return 1
201
+ }
202
+ }
203
+ }
204
+
205
+ interface CliStoredImageReceipt extends StoredImageReceipt {
206
+ source?: string
207
+ apiOrigin?: string
208
+ }
209
+
210
+ async function hashImageFile(
211
+ file: string,
212
+ signal: AbortSignal,
213
+ ): Promise<{ md5hash: string; size: number }> {
214
+ const hash = createHash('md5')
215
+ let size = 0
216
+ // A bounded preflight lets the CLI skip an upload. storeImage still independently verifies it.
217
+ for await (const chunk of createReadStream(file, { signal })) {
218
+ hash.update(chunk)
219
+ size += chunk.length
220
+ }
221
+ if (size === 0) throw new Error('Cannot store an empty image')
222
+ signal.throwIfAborted()
223
+ return { md5hash: hash.digest('hex'), size }
224
+ }
225
+
226
+ /** Stores originals in order, checkpointing each verified receipt before the next upload. */
227
+ export class StorageStoreCommand extends StorageProjectCommand {
228
+ static override paths = [['storage', 'store']]
229
+
230
+ static override usage = Command.Usage({
231
+ category: 'Storage',
232
+ description: 'Store original images and save verified metadata for Image',
233
+ details: `
234
+ Uses the CLI's Assembly credentials (environment, .env or ~/.transloadit/credentials).
235
+ Storage writes must be enabled. Existing Storage paths conflict unless --overwrite is explicit.
236
+ --hashed inserts eight MD5 hex digits before the extension. Matching catalog receipts skip
237
+ repeat uploads; changed bytes get a fresh name. --hashed cannot be combined with --overwrite.
238
+ The project catalog binds workspace, published prefixes and image receipts. Each successful
239
+ upload is saved atomically before the next. Do not run two writers against the same catalog.
240
+ The catalog defaults to transloadit.images.json; --receipts selects another file.
241
+ `,
242
+ examples: [
243
+ ['Store a hero image', 'transloadit storage store ./hero.jpg website/hero.jpg'],
244
+ ['Store a content-addressed image', 'transloadit storage store ./hero.jpg website/ --hashed'],
245
+ ['Store a directory of originals', 'transloadit storage store ./images/*.jpg website/'],
246
+ ],
247
+ })
248
+
249
+ files = Option.Rest({ required: 1 })
250
+ destination = Option.String({ required: true })
251
+ hashed = Option.Boolean('--hashed', false, {
252
+ description: 'Add an eight-digit content hash to the filename; reuse matching catalog receipts',
253
+ })
254
+ overwrite = Option.Boolean('--overwrite', false, {
255
+ description: 'Explicitly replace an existing Storage path',
256
+ })
257
+ publicDelivery = Option.Boolean('--public', false, {
258
+ description:
259
+ 'Publish the destination directory recursively, including current and future objects',
260
+ })
261
+ protected async run(): Promise<number | undefined> {
262
+ const file = resolve(this.receipts)
263
+ let stored: { receipt?: CliStoredImageReceipt } = {}
264
+ let destination = this.destination
265
+ let workspace: string | undefined
266
+ let saved = false
267
+ let published: string | undefined
268
+ try {
269
+ noticeCliCredentialSource(this.cliConfig, this.output)
270
+ if (this.hashed && this.overwrite)
271
+ throw new Error(
272
+ '--hashed cannot be combined with --overwrite; changed bytes get a new name',
273
+ )
274
+ const apiOrigin = this.apiOrigin
275
+ const uploaded = new Map<string, CliStoredImageReceipt>()
276
+ if (this.files.length > 1 && !this.destination.endsWith('/'))
277
+ throw new Error('Multiple images need a directory destination ending in /')
278
+ const inputs = this.files.map((input) => ({
279
+ file: input,
280
+ path: this.destination.endsWith('/')
281
+ ? `${this.destination}${basename(input)}`
282
+ : this.destination,
283
+ }))
284
+ for (const input of inputs) {
285
+ if (file === resolve(input.file))
286
+ throw new Error('The receipts file cannot be the input image')
287
+ if (resolve(storageTypesPath(file)) === resolve(input.file))
288
+ throw new Error('The generated declarations file cannot be the input image')
289
+ validateStoragePath(input.path)
290
+ }
291
+ if (!this.hashed && new Set(inputs.map((input) => input.path)).size !== inputs.length)
292
+ throw new Error(
293
+ 'Input image basenames collide in the destination directory; rename them first',
294
+ )
295
+ const publicPrefix = this.publicDelivery
296
+ ? normalizeStoragePublicPrefix(
297
+ this.destination.slice(0, this.destination.lastIndexOf('/') + 1),
298
+ )
299
+ : undefined
300
+ let setupPrinted = false
301
+ for (const input of inputs) {
302
+ let publicImage = false
303
+ let unchanged = false
304
+ destination = input.path
305
+ stored = {}
306
+ saved = false
307
+ await updateStorageReceipts(
308
+ file,
309
+ async (receipts, signal) => {
310
+ workspace ??= await resolveStorageWorkspace(
311
+ this,
312
+ this.cliConfig,
313
+ receipts?.workspace,
314
+ signal,
315
+ )
316
+ assertStorageWorkspace(workspace, receipts?.workspace, this.workspace)
317
+ if (receipts?.workspace === workspace)
318
+ assertStorageCatalogOrigin(receipts, apiOrigin, this.receipts)
319
+ signal.throwIfAborted()
320
+ if (this.hashed) {
321
+ // This pass chooses the path and deduplicates. storeImage independently verifies its
322
+ // upload input; sharing a caller-supplied checksum would need an immutable-file contract.
323
+ const { md5hash, size } = await hashImageFile(input.file, signal)
324
+ const extension = posix.extname(destination)
325
+ destination = `${destination.slice(0, destination.length - extension.length)}.${md5hash.slice(0, 8)}${extension}`
326
+ validateStoragePath(destination)
327
+ // Explicit workspace overrides leave the catalog alone; still deduplicate this batch.
328
+ const candidate =
329
+ uploaded.get(destination) ??
330
+ (receipts?.workspace === workspace && Object.hasOwn(receipts.images, destination)
331
+ ? receipts.images[destination]
332
+ : undefined)
333
+ if (candidate !== undefined) {
334
+ const previous = hashedReceiptSchema.safeParse(candidate)
335
+ if (!previous.success || previous.data.path !== destination)
336
+ throw new Error(
337
+ `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.`,
338
+ )
339
+ if (previous.data.apiOrigin !== apiOrigin)
340
+ throw new Error(
341
+ `Cannot verify that ${JSON.stringify(destination)} was stored at ${apiOrigin}. Use --receipts for a separate catalog for this API environment; nothing uploaded.`,
342
+ )
343
+ if (previous.data.md5hash !== md5hash || previous.data.size !== size)
344
+ throw new Error(
345
+ `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.`,
346
+ )
347
+ stored.receipt = previous.data
348
+ unchanged = true
349
+ }
350
+ }
351
+ stored.receipt ??= await this.client.storeImage(input.file, {
352
+ path: destination,
353
+ signal,
354
+ onReceipt: (receipt, expected, assemblyId) => {
355
+ const sizeMatches = receipt.size === expected.size
356
+ const md5Matches = receipt.md5hash === expected.md5hash
357
+ this.output.debug(
358
+ JSON.stringify({
359
+ assemblyId,
360
+ result: receipt,
361
+ input: expected,
362
+ sizeMatches,
363
+ md5Matches,
364
+ }),
365
+ )
366
+ if (!sizeMatches || !md5Matches) {
367
+ const difference = sizeMatches
368
+ ? 'same size, different MD5'
369
+ : `${expected.size.toLocaleString('en-US')} → ${receipt.size.toLocaleString('en-US')} bytes`
370
+ this.output.warn(
371
+ `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.`,
372
+ )
373
+ }
374
+ },
375
+ ...(this.overwrite ? { overwrite: true } : {}),
376
+ })
377
+ if (stored.receipt.workspace !== workspace)
378
+ throw new Error(
379
+ `The stored receipt belongs to Workspace ${stored.receipt.workspace}, not ${workspace}. The project catalog was not changed; inspect the Assembly before retrying.`,
380
+ )
381
+ if (!unchanged)
382
+ stored.receipt = {
383
+ ...stored.receipt,
384
+ ...(this.hashed ? { source: basename(input.file) } : {}),
385
+ apiOrigin,
386
+ }
387
+ if (this.hashed) uploaded.set(destination, stored.receipt)
388
+ if (receipts !== undefined && receipts.workspace !== workspace) {
389
+ this.output.notice(
390
+ `Catalog ${this.receipts} was not changed; it belongs to ${receipts.workspace}. Use --receipts for a separate catalog.`,
391
+ )
392
+ return undefined
393
+ }
394
+ publicImage = receipts?.public.some((prefix) => destination.startsWith(prefix)) ?? false
395
+ // Replays must not reorder fields or discard application metadata from a saved receipt.
396
+ if (unchanged) return receipts
397
+ return {
398
+ ...receipts,
399
+ workspace,
400
+ apiOrigin,
401
+ delivery:
402
+ receipts?.delivery ??
403
+ storageCatalogDelivery(this.endpoint ?? this.cliConfig.endpoint),
404
+ public: receipts?.public ?? [],
405
+ images: { ...receipts?.images, [stored.receipt.path]: stored.receipt },
406
+ }
407
+ },
408
+ () => {
409
+ saved = true
410
+ },
411
+ )
412
+ if (stored.receipt === undefined) throw new Error('Storage did not return a receipt')
413
+ if (publicPrefix !== undefined && published === undefined) {
414
+ this.output.notice(
415
+ `Publishing ${publicPrefix} recursively: all current and future objects under this prefix will be public.`,
416
+ )
417
+ await updateStorageReceipts(file, async (previous, signal) => {
418
+ if (previous?.workspace === workspace)
419
+ assertStorageCatalogOrigin(previous, apiOrigin, this.receipts)
420
+ const result = await this.client
421
+ .publishStoragePrefix(publicPrefix, { signal })
422
+ .catch((cause: unknown) => {
423
+ signal.throwIfAborted()
424
+ throw new Error(storagePublicError(cause, workspace), { cause })
425
+ })
426
+ published = result.prefix
427
+ if (previous === undefined || previous.workspace !== workspace) return undefined
428
+ return { ...previous, public: [...new Set([...previous.public, result.prefix])] }
429
+ })
430
+ }
431
+ const receipt = stored.receipt
432
+ publicImage ||= published !== undefined && receipt.path.startsWith(published)
433
+ const blur =
434
+ saved && publicImage && receipt.thumbhash !== undefined && receipt.hasAlpha !== true
435
+ // A foreign catalog says nothing about this destination's policy or rendering setup.
436
+ const setupAdvice =
437
+ !saved || setupPrinted
438
+ ? ''
439
+ : `${publicImage ? '' : storageImagePrivateAdvice(receipt.path, this.receipts === defaultStorageCatalog ? undefined : this.receipts)}${await storageImageConfigAdvice()}`
440
+ setupPrinted ||= saved
441
+ const attribute = (value: string): string =>
442
+ value
443
+ .replaceAll('&', '&amp;')
444
+ .replaceAll('"', '&quot;')
445
+ .replaceAll('<', '&lt;')
446
+ .replaceAll('>', '&gt;')
447
+ const src = attribute(receipt.path)
448
+ const alt = attribute(
449
+ basename(receipt.source ?? receipt.path)
450
+ .replace(/\.[^.]+$/, '')
451
+ .replaceAll(/[-_]+/g, ' '),
452
+ )
453
+ const renderAdvice = saved
454
+ ? `Render it with <Image storage src="${src}" alt="${alt}" width={${Math.min(receipt.width, 960)}}${blur ? ' placeholder="blur"' : ''} />\nReplace alt with a description (or an empty string for a decorative image).${setupAdvice}`
455
+ : 'Use --receipts for a separate catalog before rendering images from this workspace.'
456
+ this.output.print(
457
+ `${unchanged ? `Unchanged ${receipt.path}; no upload needed.\n` : ''}${saved ? `Saved ${receipt.path} in ${this.receipts}. Commit this catalog and ${storageTypesPath(this.receipts)}.` : `Stored ${receipt.path}; the different-workspace project catalog was left unchanged.`}\n${renderAdvice}`,
458
+ receipt,
459
+ )
460
+ }
461
+ return undefined
462
+ } catch (error) {
463
+ const failure = ensureError(error)
464
+ if (stored.receipt !== undefined) {
465
+ this.output.error(
466
+ [
467
+ failure.message,
468
+ ...(published === undefined
469
+ ? []
470
+ : [
471
+ `The server prefix ${published} remains public; use storage unpublish deliberately if needed.`,
472
+ ]),
473
+ saved
474
+ ? `Receipt saved in ${this.receipts}. No further files were uploaded. Do not re-upload this object.`
475
+ : 'The object was stored successfully. Do not re-upload; recover the verified receipt below.',
476
+ `Receipt: ${JSON.stringify(stored.receipt)}`,
477
+ ].join('\n'),
478
+ )
479
+ return 1
480
+ }
481
+ const recovery =
482
+ failure instanceof InconsistentResponseError
483
+ ? z
484
+ .object({
485
+ assemblyId: z.string().min(1),
486
+ receiptCheck: z
487
+ .object({
488
+ originalCount: z.number().int().nonnegative(),
489
+ metadataValid: z.boolean(),
490
+ pathMatches: z.boolean().optional(),
491
+ sizeMatches: z.boolean().optional(),
492
+ md5Matches: z.boolean().optional(),
493
+ })
494
+ .optional(),
495
+ })
496
+ .safeParse(failure.cause)
497
+ : undefined
498
+ if (recovery?.success) {
499
+ this.output.debug(JSON.stringify(recovery.data))
500
+ const options = [
501
+ `--receipts ${quoteCliArgument(this.receipts)}`,
502
+ ...(this.endpoint ? [`--endpoint ${quoteCliArgument(this.endpoint)}`] : []),
503
+ ...(this.workspace ? [`--workspace ${quoteCliArgument(this.workspace)}`] : []),
504
+ ].join(' ')
505
+ this.output.error(
506
+ [
507
+ failure.message,
508
+ `Destination: ${JSON.stringify(destination)}`,
509
+ `Assembly ID: ${JSON.stringify(recovery.data.assemblyId)}`,
510
+ ...(recovery.data.receiptCheck === undefined
511
+ ? []
512
+ : [
513
+ 'The Assembly did not return usable receipt metadata. Do not re-upload; inspect Storage and recover its metadata:',
514
+ 'The commands below require the native Storage catalog API. On an older deployment without these routes, inspect the Assembly in Console and restore a verified catalog receipt.',
515
+ // A filename prefix also works for root objects without scanning the workspace.
516
+ `transloadit storage ls ${quoteCliArgument(destination)} ${options}`,
517
+ `transloadit storage receipts sync ${quoteCliArgument(destination)} ${options}`,
518
+ ]),
519
+ ].join('\n'),
520
+ )
521
+ return 1
522
+ }
523
+ if (failure instanceof ApiError && failure.code === 'TRANSLOADIT_STORE_CONFLICT') {
524
+ this.output.error(
525
+ this.hashed
526
+ ? `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.`
527
+ : `Storage destination ${JSON.stringify(destination)} already exists. Choose a fresh name; use --overwrite only if you deliberately want to replace that object.`,
528
+ )
529
+ return 1
530
+ }
531
+ this.output.error(failure.message)
532
+ return 1
533
+ }
534
+ }
535
+ }
536
+
537
+ /** Lists version-pinned metadata through the authenticated native Storage catalog. */
538
+ export class StorageListCommand extends UnauthenticatedCommand {
539
+ static override paths = [['storage', 'ls']]
540
+ static override usage = Command.Usage({
541
+ category: 'Storage',
542
+ description: 'List stored paths and sizes; include asset/version identities with --json',
543
+ details:
544
+ 'Uses an Auth Key with dam:read or dam:write scope and bounded native catalog pages. Verifies the Workspace against the selected credentials. No Assembly, S3 request or original download is needed.',
545
+ examples: [['List website images', 'transloadit storage ls website/']],
546
+ })
547
+
548
+ prefix = Option.String({ required: true })
549
+ workspace = Option.String('--workspace', {
550
+ description: 'Explicit workspace slug (otherwise discovered from this Auth Key)',
551
+ })
552
+ receipts = Option.String('--receipts', defaultStorageCatalog, {
553
+ description: 'Project catalog whose workspace must match the selected credentials',
554
+ })
555
+
556
+ protected async run(): Promise<number | undefined> {
557
+ try {
558
+ const catalog = await readStorageCatalog(this.receipts)
559
+ const objects = await withStorageCatalog(
560
+ {
561
+ endpoint: this.endpoint,
562
+ workspace: this.workspace,
563
+ projectWorkspace: catalog?.workspace,
564
+ },
565
+ (client, workspace) => listStorageAssets(client, workspace, this.prefix),
566
+ 'Storage listing',
567
+ this.output,
568
+ )
569
+ this.output.print(
570
+ objects.length === 0
571
+ ? 'No stored objects match this prefix.'
572
+ : objects
573
+ .map((object) => `${JSON.stringify(object.path)}\t${object.size} bytes`)
574
+ .join('\n'),
575
+ objects,
576
+ )
577
+ return undefined
578
+ } catch (error) {
579
+ this.output.error(ensureError(error).message)
580
+ return 1
581
+ }
582
+ }
583
+ }
584
+
585
+ const uploadEvidenceSchema = storedAssetSchema.extend({
586
+ thumbhash: z.string().max(48).optional(),
587
+ hasAlpha: z.boolean().optional(),
588
+ source: z.string().optional(),
589
+ apiOrigin: z.string().url().optional(),
590
+ })
591
+
592
+ const hashedReceiptSchema = uploadEvidenceSchema.extend({
593
+ md5hash: storedAssetSchema.shape.md5hash.unwrap(),
594
+ width: storedAssetSchema.shape.width.unwrap(),
595
+ height: storedAssetSchema.shape.height.unwrap(),
596
+ })
597
+
598
+ /** Recovers pinned receipts from bounded native catalog pages without downloading originals. */
599
+ export class StorageReceiptsSyncCommand extends UnauthenticatedCommand {
600
+ static override paths = [['storage', 'receipts', 'sync']]
601
+ static override usage = Command.Usage({
602
+ category: 'Storage',
603
+ description: 'Rebuild saved rendering metadata from the Storage catalog',
604
+ details: `
605
+ Uses native catalog pages and dam:write scope to also recover declared public prefixes.
606
+ Adds or refreshes matched paths; never prunes unmatched entries. Local placeholder metadata
607
+ survives only for the same retained version. Every matched image must have catalog dimensions.
608
+ Any failure preserves the previous file. No Assembly, original download or remote write occurs.
609
+ `,
610
+ examples: [['Recover website images', 'transloadit storage receipts sync website/']],
611
+ })
612
+
613
+ prefix = Option.String({ required: true })
614
+ workspace = Option.String('--workspace', {
615
+ description: 'Explicit workspace slug (otherwise discovered from this Auth Key)',
616
+ })
617
+ receipts = Option.String('--receipts', defaultStorageCatalog, {
618
+ description: 'JSON rendering catalog to update atomically (default: transloadit.images.json)',
619
+ })
620
+
621
+ protected async run(): Promise<number | undefined> {
622
+ try {
623
+ let count = 0
624
+ let synced: Record<string, unknown> = {}
625
+ let catalogUpdated = false
626
+ const config = resolveCliConfig()
627
+ await updateStorageReceipts(resolve(this.receipts), async (previous, signal) => {
628
+ let actualWorkspace: string | undefined
629
+ let apiOrigin: string | undefined
630
+ synced = await withStorageCatalog(
631
+ {
632
+ endpoint: this.endpoint,
633
+ workspace: this.workspace,
634
+ projectWorkspace: previous?.workspace,
635
+ signal,
636
+ },
637
+ async (client, workspace, endpoint) => {
638
+ actualWorkspace = workspace
639
+ apiOrigin = endpoint
640
+ if (previous?.workspace === workspace)
641
+ assertStorageCatalogOrigin(previous, endpoint, this.receipts)
642
+ const assets = await listStorageAssets(client, workspace, this.prefix, signal)
643
+ const entries = assets.map((asset) => {
644
+ try {
645
+ validateStoragePath(asset.path)
646
+ } catch (cause) {
647
+ throw new Error(
648
+ `Storage path ${JSON.stringify(asset.path)} is valid for listing but unsupported by the image renderer. Rename it before recovering an image catalog.`,
649
+ { cause },
650
+ )
651
+ }
652
+ if (asset.width === undefined || asset.height === undefined) {
653
+ throw new Error(
654
+ `Storage image ${JSON.stringify(asset.path)} needs positive catalog width and height. Select an image-only prefix and backfill missing dimensions before retrying.`,
655
+ )
656
+ }
657
+ const evidence = uploadEvidenceSchema.safeParse(
658
+ previous?.workspace === workspace && Object.hasOwn(previous.images, asset.path)
659
+ ? previous.images[asset.path]
660
+ : undefined,
661
+ )
662
+ const retained =
663
+ evidence.success &&
664
+ evidence.data.asset_id === asset.asset_id &&
665
+ evidence.data.version_id === asset.version_id
666
+ ? evidence.data
667
+ : {}
668
+ return [asset.path, { ...retained, ...asset, apiOrigin: endpoint }]
669
+ })
670
+ count = entries.length
671
+ return Object.fromEntries(entries)
672
+ },
673
+ 'Storage receipt sync',
674
+ this.output,
675
+ config,
676
+ )
677
+ if (actualWorkspace === undefined) throw new Error('Storage did not identify a workspace')
678
+ if (previous !== undefined && previous.workspace !== actualWorkspace) {
679
+ this.output.notice(
680
+ `Catalog ${this.receipts} was not changed; it belongs to ${previous.workspace}. Use --receipts for a separate catalog.`,
681
+ )
682
+ return undefined
683
+ }
684
+ // Recover policy with the same key/endpoint as catalog reads, never an unrelated bearer
685
+ // token or a folder-name guess. Neither half is committed if this read fails.
686
+ if (config.credentials === undefined) throw new Error('Storage credentials are missing')
687
+ const policyClient = new Transloadit({
688
+ ...config.credentials,
689
+ endpoint: new URL(
690
+ this.endpoint ?? config.credentialsEndpoint ?? 'https://api2.transloadit.com',
691
+ ).origin,
692
+ maxRetries: 0,
693
+ })
694
+ const policy = await policyClient
695
+ .listPublicStoragePrefixes({
696
+ signal: AbortSignal.any([signal, AbortSignal.timeout(60_000)]),
697
+ })
698
+ .catch((error: unknown) => {
699
+ signal.throwIfAborted()
700
+ throw new Error(
701
+ 'Recovery incomplete: could not read the server public prefixes. Check the API endpoint and Auth Key dam:write scope, then retry. The existing catalog was preserved.',
702
+ { cause: error },
703
+ )
704
+ })
705
+ catalogUpdated = true
706
+ if (policy.public_prefixes.length === 0)
707
+ this.output.notice(
708
+ '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.',
709
+ )
710
+ return {
711
+ ...previous,
712
+ apiOrigin,
713
+ workspace: actualWorkspace,
714
+ delivery:
715
+ previous?.delivery ??
716
+ storageCatalogDelivery(this.endpoint ?? config.credentialsEndpoint),
717
+ public: policy.public_prefixes.map(({ prefix }) => prefix),
718
+ images: { ...previous?.images, ...synced },
719
+ }
720
+ })
721
+ this.output.print(
722
+ catalogUpdated
723
+ ? `Synced ${count} rendering receipts and public policy to ${this.receipts}. Unmatched entries were preserved. Commit this file before building.`
724
+ : `Read ${count} rendering receipts. Catalog unchanged because it belongs to another workspace.`,
725
+ synced,
726
+ )
727
+ return undefined
728
+ } catch (error) {
729
+ this.output.error(ensureError(error).message)
730
+ return 1
731
+ }
732
+ }
733
+ }