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
@@ -152,9 +152,11 @@ async function deleteTemplates(
152
152
  // Export with `delete` alias for external consumers
153
153
  export { deleteTemplates as delete }
154
154
 
155
- const TemplateIdSchema = z.object({
156
- id: z.string(),
157
- })
155
+ const TemplateIdSchema = z
156
+ .object({
157
+ id: z.string(),
158
+ })
159
+ .passthrough()
158
160
 
159
161
  const INCLUDE_BUILTIN_VALUES = ['all', 'latest', 'exclusively-all', 'exclusively-latest'] as const
160
162
 
@@ -190,11 +192,11 @@ async function list(
190
192
  const parsed = TemplateIdSchema.safeParse(item)
191
193
  if (!parsed.success) continue
192
194
 
193
- let template: unknown = item
195
+ let template = parsed.data
194
196
  if (includeContent) {
195
197
  try {
196
198
  const full = await client.getTemplate(parsed.data.id)
197
- template = { ...item, content: full.content }
199
+ template = { ...template, content: full.content }
198
200
  } catch (err) {
199
201
  output.error(formatAPIError(err))
200
202
  }
@@ -203,8 +205,13 @@ async function list(
203
205
  if (fields == null) {
204
206
  output.print(parsed.data.id, template)
205
207
  } else {
206
- const templateRecord = template as Record<string, unknown>
207
- output.print(fields.map((field) => templateRecord[field]).join(' '), template)
208
+ const selected = fields.filter((field) => Object.hasOwn(template, field))
209
+ output.print(
210
+ fields
211
+ .map((field) => (Object.hasOwn(template, field) ? template[field] : undefined))
212
+ .join(' '),
213
+ Object.fromEntries(selected.map((field) => [field, template[field]])),
214
+ )
208
215
  }
209
216
  }
210
217
 
@@ -0,0 +1,226 @@
1
+ import type { CliKeySecretCredentials } from './helpers.ts'
2
+ import type { IOutputCtl } from './OutputCtl.ts'
3
+
4
+ import { hostname } from 'node:os'
5
+ import { setTimeout as delay } from 'node:timers/promises'
6
+
7
+ import { execa } from 'execa'
8
+ import got from 'got'
9
+ import { z } from 'zod'
10
+
11
+ import { cliSignatureAlgorithmSchema } from './helpers.ts'
12
+
13
+ const deviceSchema = z.object({
14
+ ok: z.literal('CLI_DEVICE_AUTHORIZATION_CREATED'),
15
+ device_code: z.string().min(1).max(4096),
16
+ user_code: z
17
+ .string()
18
+ .regex(/^[BCDFGHJKLMNPQRSTVWXZ23456789]{4}-[BCDFGHJKLMNPQRSTVWXZ23456789]{4}$/),
19
+ verification_url: z.string().url(),
20
+ // The agreed device contract is bounded to 15 minutes; reject incompatible server responses.
21
+ expires_in: z.number().int().positive().max(900),
22
+ interval: z.number().int().positive().max(900),
23
+ })
24
+ const authorizedSchema = z.object({
25
+ ok: z.literal('CLI_DEVICE_AUTHORIZED'),
26
+ signature_algo: cliSignatureAlgorithmSchema.nullable(),
27
+ workspace: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/),
28
+ auth_key: z
29
+ .string()
30
+ .min(1)
31
+ .max(4096)
32
+ .regex(/^[^\r\n\0]+$/),
33
+ auth_secret: z
34
+ .string()
35
+ .min(1)
36
+ .max(4096)
37
+ .regex(/^[^\r\n\0]+$/),
38
+ auth_key_id: z
39
+ .string()
40
+ .regex(/^[a-zA-Z0-9_-]{1,128}$/)
41
+ .optional(),
42
+ description: z
43
+ .string()
44
+ .max(512)
45
+ .regex(/^[^\r\n\0]*$/)
46
+ .optional(),
47
+ })
48
+ const pendingSchema = z.object({
49
+ ok: z.literal('CLI_DEVICE_AUTHORIZATION_PENDING'),
50
+ expires_in: z.number().int().nonnegative(),
51
+ })
52
+ const errorSchema = z.object({ error: z.string() })
53
+ const expiredMessage =
54
+ 'Device authorization expired or was already used. Run transloadit auth login again.'
55
+
56
+ /** Browser-approved credentials; the one-time device code never leaves this module. */
57
+ export interface DeviceLoginCredentials extends CliKeySecretCredentials {
58
+ workspace: string
59
+ authKeyId?: string
60
+ description?: string
61
+ }
62
+
63
+ /** Obtain a combined Auth Key using the API's single-use device authorization contract. */
64
+ export async function deviceLogin(
65
+ endpoint: string,
66
+ output: IOutputCtl,
67
+ noBrowser: boolean,
68
+ ): Promise<DeviceLoginCredentials> {
69
+ const cancellation = new AbortController()
70
+ const cancel = (): void => cancellation.abort()
71
+ process.once('SIGINT', cancel)
72
+ let expired: AbortSignal | undefined
73
+ let heartbeat: ReturnType<typeof setInterval> | undefined
74
+ try {
75
+ const response = await got
76
+ .post(`${endpoint}/cli/device_authorizations`, {
77
+ form: { client: 'transloadit-cli', hostname: hostname() },
78
+ responseType: 'json',
79
+ retry: { limit: 0 },
80
+ followRedirect: false,
81
+ timeout: { request: 10_000 },
82
+ signal: cancellation.signal,
83
+ })
84
+ .catch((cause: unknown) => {
85
+ throw new Error(
86
+ 'Could not start browser login. Check the API endpoint and retry; use auth login --stdin for an existing Auth Key.',
87
+ { cause },
88
+ )
89
+ })
90
+ const parsed = deviceSchema.safeParse(response.body)
91
+ if (!parsed.success)
92
+ throw new Error('The API returned an invalid device authorization; nothing was saved')
93
+ const device = parsed.data
94
+ const target = new URL(device.verification_url)
95
+ if (
96
+ target.username ||
97
+ target.password ||
98
+ target.hash ||
99
+ (target.protocol !== 'https:' &&
100
+ !(
101
+ target.protocol === 'http:' &&
102
+ ['localhost', '127.0.0.1', '[::1]'].includes(target.hostname)
103
+ ))
104
+ )
105
+ throw new Error('The API returned an unsafe verification URL; nothing was opened or saved')
106
+ expired = AbortSignal.timeout(device.expires_in * 1000)
107
+ const signal = AbortSignal.any([cancellation.signal, expired])
108
+ const deadline = performance.now() + device.expires_in * 1000
109
+ heartbeat = setInterval(() => {
110
+ const minutes = Math.ceil((deadline - performance.now()) / 60_000)
111
+ if (signal.aborted || minutes <= 0) return
112
+ output.notice(
113
+ `Still waiting for approval, ${minutes} minute${minutes === 1 ? '' : 's'} left. Use the verification URL printed above.`,
114
+ )
115
+ }, 60_000)
116
+ heartbeat.unref()
117
+ output.print(`Enter code ${device.user_code} at ${target.href}`, {
118
+ user_code: device.user_code,
119
+ verification_url: target.href,
120
+ })
121
+ const opener =
122
+ process.platform === 'darwin'
123
+ ? 'open'
124
+ : process.platform === 'linux'
125
+ ? 'xdg-open'
126
+ : process.platform === 'win32'
127
+ ? 'cmd'
128
+ : undefined
129
+ // On platforms without an opener, the printed URL is the manual approval path.
130
+ if (!noBrowser && opener !== undefined) {
131
+ // xdg-open may live as long as the browser; launching it must not delay or cancel polling.
132
+ // cmd parses metacharacters even with shell:false. A quoted, single-pass env expansion
133
+ // keeps &, %, and ! in the URL out of command syntax; disable delayed expansion/AutoRun.
134
+ const browser = execa(
135
+ opener,
136
+ opener === 'cmd'
137
+ ? ['/d', '/v:off', '/c', 'start', '""', '"%TRANSLOADIT_BROWSER_URL%"']
138
+ : [target.href],
139
+ {
140
+ shell: false,
141
+ ...(opener === 'cmd'
142
+ ? { windowsVerbatimArguments: true, env: { TRANSLOADIT_BROWSER_URL: target.href } }
143
+ : {}),
144
+ stdio: 'ignore',
145
+ detached: true,
146
+ cleanup: false,
147
+ },
148
+ )
149
+ browser.unref()
150
+ void browser.catch(() => {
151
+ // A wrapper's later failure cannot prove whether its page opened or invalidate login.
152
+ output.warn(
153
+ 'The browser opener reported an error. If the page did not open, use the verification URL printed above.',
154
+ )
155
+ })
156
+ }
157
+ let intervalMs = device.interval * 1000
158
+ while (true) {
159
+ await delay(intervalMs, undefined, { signal })
160
+ const token = await got
161
+ .post(`${endpoint}/cli/device_authorizations/token`, {
162
+ form: { device_code: device.device_code },
163
+ responseType: 'json',
164
+ retry: { limit: 0 },
165
+ followRedirect: false,
166
+ throwHttpErrors: false,
167
+ timeout: { request: 10_000 },
168
+ signal,
169
+ })
170
+ .catch((cause: unknown) => {
171
+ throw new Error(
172
+ 'Could not finish browser login. Check connectivity and run transloadit auth login again.',
173
+ { cause },
174
+ )
175
+ })
176
+ const error = errorSchema.safeParse(token.body)
177
+ if (
178
+ token.statusCode === 403 &&
179
+ error.success &&
180
+ error.data.error === 'CLI_DEVICE_AUTHORIZATION_DENIED'
181
+ )
182
+ throw new Error('The login was denied in the browser.')
183
+ if (token.statusCode === 429 || (error.success && error.data.error === 'slow_down')) {
184
+ const seconds = Number(token.headers['retry-after'])
185
+ // Longer waits are pointless after the authorization deadline and can overflow Node's
186
+ // timer range into 1 ms, accidentally hammering an already rate-limited API.
187
+ intervalMs = Math.min(
188
+ device.expires_in * 1000,
189
+ Math.max(intervalMs + 5000, Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 0),
190
+ )
191
+ continue
192
+ }
193
+ if (error.success && error.data.error === 'CLI_DEVICE_AUTHORIZATION_NOT_FOUND')
194
+ throw new Error(expiredMessage)
195
+ if (token.statusCode < 200 || token.statusCode >= 300)
196
+ throw new Error(
197
+ 'Browser login was refused. Run transloadit auth login again; nothing was saved.',
198
+ )
199
+ const pending = pendingSchema.safeParse(token.body)
200
+ if (pending.success) {
201
+ if (pending.data.expires_in === 0) throw new Error(expiredMessage)
202
+ continue
203
+ }
204
+ const authorized = authorizedSchema.safeParse(token.body)
205
+ if (!authorized.success)
206
+ throw new Error('The API returned an invalid login result; nothing was saved')
207
+ signal.throwIfAborted()
208
+ return {
209
+ authKey: authorized.data.auth_key,
210
+ authSecret: authorized.data.auth_secret,
211
+ signatureAlgorithm: authorized.data.signature_algo ?? undefined,
212
+ workspace: authorized.data.workspace,
213
+ authKeyId: authorized.data.auth_key_id,
214
+ description: authorized.data.description,
215
+ }
216
+ }
217
+ } catch (cause) {
218
+ if (cancellation.signal.aborted)
219
+ throw new Error('Login canceled; nothing was saved.', { cause })
220
+ if (expired?.aborted) throw new Error(expiredMessage, { cause })
221
+ throw cause
222
+ } finally {
223
+ clearInterval(heartbeat)
224
+ process.off('SIGINT', cancel)
225
+ }
226
+ }
@@ -1,15 +1,24 @@
1
1
  import type { Readable } from 'node:stream'
2
2
 
3
+ import type { IOutputCtl } from './OutputCtl.ts'
4
+
3
5
  import fs from 'node:fs'
4
6
  import fsp from 'node:fs/promises'
5
- import { homedir } from 'node:os'
7
+ import { homedir, userInfo } from 'node:os'
6
8
  import path from 'node:path'
7
9
 
8
10
  import { parse as parseDotenv } from 'dotenv'
11
+ import { z } from 'zod'
9
12
 
10
13
  import { isAPIError } from './types.ts'
11
14
 
12
- export type CliKeySecretCredentials = { authKey: string; authSecret: string }
15
+ /** API signing algorithms supported by CLI credentials and device authorization. */
16
+ export const cliSignatureAlgorithmSchema = z.enum(['sha1', 'sha256', 'sha384', 'sha512'])
17
+ export type CliKeySecretCredentials = {
18
+ authKey: string
19
+ authSecret: string
20
+ signatureAlgorithm?: z.infer<typeof cliSignatureAlgorithmSchema>
21
+ }
13
22
  export type CliAuthToken = { authToken: string }
14
23
  export type CliAuth = CliKeySecretCredentials | CliAuthToken
15
24
  type CliEnvSource = {
@@ -20,7 +29,9 @@ type CliEnvSource = {
20
29
  let loadedProjectDotenvPath: string | undefined
21
30
  let projectDotenvInjectedValues: Record<string, string> | undefined
22
31
  let projectDotenvPreviousValues: Record<string, string | undefined> | undefined
23
- let shellEnvBeforeProjectDotenv: Record<string, string | undefined> | undefined
32
+ let shellEnvBeforeProjectDotenv:
33
+ | { values: Record<string, string | undefined>; homeDirectory?: string }
34
+ | undefined
24
35
 
25
36
  type LoadCliEnvSourcesResult = {
26
37
  loadError?: string
@@ -30,8 +41,17 @@ type LoadCliEnvSourcesResult = {
30
41
 
31
42
  export type ResolvedCliConfig = {
32
43
  auth?: CliAuth
44
+ authSource?: string
45
+ authWorkspace?: string
46
+ authWorkspaceVerified?: boolean
33
47
  credentials?: CliKeySecretCredentials
48
+ credentialsSource?: string
34
49
  credentialsEndpoint?: string
50
+ credentialsWorkspace?: string
51
+ credentialsWorkspaceVerified?: boolean
52
+ credentialsAuthKeyId?: string
53
+ credentialsDescription?: string
54
+ credentialsLoginMethod?: string
35
55
  endpoint?: string
36
56
  loadError?: string
37
57
  }
@@ -41,13 +61,38 @@ function normalizeEnvValue(value: string | undefined): string | undefined {
41
61
  return trimmed ? trimmed : undefined
42
62
  }
43
63
 
44
- function getConfiguredCredentialsFilePath(): string {
45
- const configuredPath = normalizeEnvValue(process.env.TRANSLOADIT_CREDENTIALS_FILE)
64
+ function credentialHomeDirectory(): string {
65
+ // Node trusts HOME verbatim; an empty or relative default must never put secrets in a repo.
66
+ try {
67
+ const home = homedir()
68
+ if (path.isAbsolute(home)) return home
69
+ const fallback = userInfo().homedir
70
+ if (path.isAbsolute(fallback)) return fallback
71
+ } catch (error) {
72
+ throw new Error(
73
+ 'Cannot determine a safe home directory. Set TRANSLOADIT_CREDENTIALS_FILE to an explicit path.',
74
+ { cause: error },
75
+ )
76
+ }
77
+ throw new Error(
78
+ 'Cannot determine a safe home directory. Set TRANSLOADIT_CREDENTIALS_FILE to an explicit path.',
79
+ )
80
+ }
81
+
82
+ /** Login and its env scaffold accept only a shell path override; ordinary reads retain merged lookup. */
83
+ export function getConfiguredCredentialsFilePath(source: 'shell' | 'merged' = 'merged'): string {
84
+ const values = source === 'shell' ? getShellEnvValues() : process.env
85
+ const configuredPath = normalizeEnvValue(values.TRANSLOADIT_CREDENTIALS_FILE)
46
86
  if (configuredPath != null) {
47
87
  return path.resolve(configuredPath)
48
88
  }
49
89
 
50
- return path.join(homedir(), '.transloadit', 'credentials')
90
+ // HOME/USERPROFILE from project dotenv must not redirect the login's default destination.
91
+ const shellHome =
92
+ source === 'shell' && loadedProjectDotenvPath === getProjectDotenvPath()
93
+ ? shellEnvBeforeProjectDotenv?.homeDirectory
94
+ : undefined
95
+ return path.join(shellHome ?? credentialHomeDirectory(), '.transloadit', 'credentials')
51
96
  }
52
97
 
53
98
  function getProjectDotenvPath(): string {
@@ -112,7 +157,15 @@ export function loadProjectDotenvIntoProcessEnv(): string | undefined {
112
157
  const projectDotenvPath = getProjectDotenvPath()
113
158
  if (loadedProjectDotenvPath !== projectDotenvPath) {
114
159
  restoreProjectDotenvFromProcessEnv()
115
- shellEnvBeforeProjectDotenv = { ...process.env }
160
+ shellEnvBeforeProjectDotenv = {
161
+ values: { ...process.env },
162
+ // An explicit file makes OS home discovery unnecessary (e.g. an unmapped container UID).
163
+ // Otherwise capture it before dotenv can replace HOME and redirect a newly saved login.
164
+ homeDirectory:
165
+ normalizeEnvValue(process.env.TRANSLOADIT_CREDENTIALS_FILE) == null
166
+ ? credentialHomeDirectory()
167
+ : undefined,
168
+ }
116
169
  loadedProjectDotenvPath = projectDotenvPath
117
170
  }
118
171
 
@@ -144,7 +197,7 @@ export function loadProjectDotenvIntoProcessEnv(): string | undefined {
144
197
 
145
198
  function getShellEnvValues(): Record<string, string | undefined> {
146
199
  if (loadedProjectDotenvPath === getProjectDotenvPath() && shellEnvBeforeProjectDotenv != null) {
147
- return shellEnvBeforeProjectDotenv
200
+ return shellEnvBeforeProjectDotenv.values
148
201
  }
149
202
 
150
203
  return { ...process.env }
@@ -214,12 +267,23 @@ function getSourceValue(source: CliEnvSource, keys: string[]): string | undefine
214
267
  return undefined
215
268
  }
216
269
 
217
- function getSourceCredentials(source: CliEnvSource): CliKeySecretCredentials | undefined {
270
+ function getSourceCredentials(
271
+ source: CliEnvSource,
272
+ ): CliKeySecretCredentials | { loadError: string } | undefined {
218
273
  const authKey = getSourceValue(source, ['TRANSLOADIT_KEY', 'TRANSLOADIT_AUTH_KEY'])
219
274
  const authSecret = getSourceValue(source, ['TRANSLOADIT_SECRET', 'TRANSLOADIT_AUTH_SECRET'])
220
275
  if (authKey == null || authSecret == null) return undefined
221
276
 
222
- return { authKey, authSecret }
277
+ const algorithm = cliSignatureAlgorithmSchema
278
+ .optional()
279
+ .safeParse(getSourceValue(source, ['TRANSLOADIT_SIGNATURE_ALGORITHM']))
280
+ if (!algorithm.success)
281
+ return { loadError: 'Unsupported TRANSLOADIT_SIGNATURE_ALGORITHM in CLI credentials' }
282
+ return {
283
+ authKey,
284
+ authSecret,
285
+ ...(algorithm.data === undefined ? {} : { signatureAlgorithm: algorithm.data }),
286
+ }
223
287
  }
224
288
 
225
289
  function getSourceAuthToken(source: CliEnvSource): CliAuthToken | undefined {
@@ -240,31 +304,127 @@ function resolveEndpointForSource(
240
304
  return getSourceValue(source, ['TRANSLOADIT_ENDPOINT'])
241
305
  }
242
306
 
243
- export function resolveCliConfig(): ResolvedCliConfig {
307
+ function isSavedLoginSource(source: CliEnvSource): boolean {
308
+ return (
309
+ source.name === 'credentialsFile' &&
310
+ getConfiguredCredentialsFilePath() === getConfiguredCredentialsFilePath('shell')
311
+ )
312
+ }
313
+
314
+ function hasVerifiedWorkspace(source: CliEnvSource, shell: CliEnvSource): boolean {
315
+ // A project-selected file or changed endpoint cannot inherit the login-time ownership proof.
316
+ return (
317
+ isSavedLoginSource(source) &&
318
+ getSourceValue(source, ['TRANSLOADIT_WORKSPACE_VERIFIED']) === 'true' &&
319
+ resolveEndpointForSource(source, shell) === getSourceValue(source, ['TRANSLOADIT_ENDPOINT'])
320
+ )
321
+ }
322
+
323
+ function credentialSourceName(source: CliEnvSource, shell: CliEnvSource, auth: CliAuth): string {
324
+ if (source.name === 'credentialsFile')
325
+ return isSavedLoginSource(source) ? 'saved login' : 'project-selected credentials file'
326
+ const fields =
327
+ 'authToken' in auth
328
+ ? [['TRANSLOADIT_AUTH_TOKEN']]
329
+ : [
330
+ ['TRANSLOADIT_KEY', 'TRANSLOADIT_AUTH_KEY'],
331
+ ['TRANSLOADIT_SECRET', 'TRANSLOADIT_AUTH_SECRET'],
332
+ ]
333
+ const fromShell = fields.filter((aliases) => {
334
+ const name = aliases.find((name) => normalizeEnvValue(source.values[name]) !== undefined)
335
+ return (
336
+ name !== undefined &&
337
+ normalizeEnvValue(shell.values[name]) === normalizeEnvValue(source.values[name])
338
+ )
339
+ }).length
340
+ if (fromShell === fields.length) return 'shell environment'
341
+ return fromShell === 0 ? 'project .env' : 'shell environment + project .env'
342
+ }
343
+
344
+ /** Quotes a value in the CLI's copyable POSIX-shell commands without expanding user input. */
345
+ export function quoteCliArgument(value: string): string {
346
+ return /^[a-zA-Z0-9_./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`
347
+ }
348
+
349
+ /** Names credential overrides without exposing values or claiming verified ownership. */
350
+ export function noticeCliCredentialSource(
351
+ config: ResolvedCliConfig,
352
+ output: Pick<IOutputCtl, 'notice'> | undefined,
353
+ kind: 'auth' | 'credentials' = 'auth',
354
+ ): void {
355
+ const source = kind === 'auth' ? config.authSource : config.credentialsSource
356
+ if (source === undefined || source === 'saved login') return
357
+ const workspace = kind === 'auth' ? config.authWorkspace : config.credentialsWorkspace
358
+ const label =
359
+ workspace !== undefined && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(workspace)
360
+ ? `workspace declared as ${workspace}`
361
+ : 'workspace not declared'
362
+ output?.notice(
363
+ `Credentials: ${source} (${label}). This override takes precedence over the saved login.`,
364
+ )
365
+ }
366
+
367
+ /** Resolve one already-read saved credential snapshot, without consulting the environment again. */
368
+ export function parseCliLoginSnapshot(contents: string): ResolvedCliConfig {
369
+ return resolveLoginSource({ name: 'credentialsFile', values: parseDotenv(contents) })
370
+ }
371
+
372
+ function resolveLoginSource(source: CliEnvSource): ResolvedCliConfig {
373
+ const credentials = getSourceCredentials(source)
374
+ if (credentials !== undefined && 'loadError' in credentials) return credentials
375
+ const endpoint = getSourceValue(source, ['TRANSLOADIT_ENDPOINT'])
376
+ return {
377
+ auth: credentials,
378
+ authSource: 'saved login',
379
+ authWorkspace: getSourceValue(source, ['TRANSLOADIT_WORKSPACE']),
380
+ authWorkspaceVerified: getSourceValue(source, ['TRANSLOADIT_WORKSPACE_VERIFIED']) === 'true',
381
+ credentials,
382
+ credentialsSource: 'saved login',
383
+ credentialsWorkspace: getSourceValue(source, ['TRANSLOADIT_WORKSPACE']),
384
+ credentialsWorkspaceVerified:
385
+ getSourceValue(source, ['TRANSLOADIT_WORKSPACE_VERIFIED']) === 'true',
386
+ credentialsAuthKeyId: getSourceValue(source, ['TRANSLOADIT_AUTH_KEY_ID']),
387
+ credentialsDescription: getSourceValue(source, ['TRANSLOADIT_AUTH_KEY_DESCRIPTION']),
388
+ credentialsLoginMethod: getSourceValue(source, ['TRANSLOADIT_LOGIN_METHOD']),
389
+ credentialsEndpoint: endpoint,
390
+ endpoint,
391
+ }
392
+ }
393
+
394
+ export function resolveCliConfig(source: 'all' | 'login' = 'all'): ResolvedCliConfig {
395
+ if (source === 'login') {
396
+ // Keep login's key, workspace, algorithm and endpoint together, independent of project dotenv.
397
+ const saved = readEnvFile(getConfiguredCredentialsFilePath('shell'))
398
+ if (!saved?.ok) return saved === null ? {} : { loadError: saved.error }
399
+ return resolveLoginSource(saved.source)
400
+ }
244
401
  const { loadError, shellEnvSource, sources } = loadCliEnvSources()
245
402
  let auth: CliAuth | undefined
246
403
  let authSource: CliEnvSource | undefined
247
404
  let credentials: CliKeySecretCredentials | undefined
248
405
  let credentialsSource: CliEnvSource | undefined
406
+ let credentialsError: string | undefined
249
407
 
250
408
  for (const source of sources) {
251
- if (auth == null) {
252
- const authToken = getSourceAuthToken(source)
253
- if (authToken != null) {
254
- auth = authToken
255
- authSource = source
256
- } else {
257
- const sourceCredentials = getSourceCredentials(source)
258
- if (sourceCredentials != null) {
259
- auth = sourceCredentials
260
- authSource = source
261
- }
262
- }
409
+ if (auth != null && credentials != null) break
410
+ const authToken = getSourceAuthToken(source)
411
+ if (auth == null && authToken != null) {
412
+ auth = authToken
413
+ authSource = source
414
+ }
415
+ const sourceCredentials = getSourceCredentials(source)
416
+ if (sourceCredentials !== undefined && 'loadError' in sourceCredentials) {
417
+ // Signing failure must not discard a valid bearer token or silently choose a different key.
418
+ credentialsError = sourceCredentials.loadError
419
+ break
420
+ }
421
+ if (auth == null && sourceCredentials != null) {
422
+ auth = sourceCredentials
423
+ authSource = source
263
424
  }
264
425
 
265
426
  if (credentials != null) continue
266
427
 
267
- const sourceCredentials = getSourceCredentials(source)
268
428
  if (sourceCredentials != null) {
269
429
  credentials = sourceCredentials
270
430
  credentialsSource = source
@@ -272,15 +432,32 @@ export function resolveCliConfig(): ResolvedCliConfig {
272
432
  }
273
433
 
274
434
  return {
275
- ...(auth != null ? { auth } : {}),
435
+ ...(auth != null && authSource != null
436
+ ? {
437
+ auth,
438
+ authSource: credentialSourceName(authSource, shellEnvSource, auth),
439
+ authWorkspace: getSourceValue(authSource, ['TRANSLOADIT_WORKSPACE']),
440
+ authWorkspaceVerified: hasVerifiedWorkspace(authSource, shellEnvSource),
441
+ }
442
+ : {}),
276
443
  ...(credentials != null ? { credentials } : {}),
277
444
  ...(authSource != null
278
445
  ? { endpoint: resolveEndpointForSource(authSource, shellEnvSource) }
279
446
  : {}),
280
447
  ...(credentialsSource != null
281
- ? { credentialsEndpoint: resolveEndpointForSource(credentialsSource, shellEnvSource) }
448
+ ? {
449
+ credentialsEndpoint: resolveEndpointForSource(credentialsSource, shellEnvSource),
450
+ credentialsSource:
451
+ credentials === undefined
452
+ ? undefined
453
+ : credentialSourceName(credentialsSource, shellEnvSource, credentials),
454
+ credentialsWorkspace: getSourceValue(credentialsSource, ['TRANSLOADIT_WORKSPACE']),
455
+ credentialsWorkspaceVerified: hasVerifiedWorkspace(credentialsSource, shellEnvSource),
456
+ }
457
+ : {}),
458
+ ...(credentialsError != null || loadError != null
459
+ ? { loadError: credentialsError ?? loadError }
282
460
  : {}),
283
- ...(loadError != null ? { loadError } : {}),
284
461
  }
285
462
  }
286
463
 
@@ -0,0 +1,12 @@
1
+ import { parse } from 'dotenv'
2
+
3
+ /** Preserve opaque punctuation and literal escapes when serializing a CLI credential file. */
4
+ export function quoteCredential(value: string): string {
5
+ for (const quote of ["'", '"', '`']) {
6
+ const quoted = `${quote}${value}${quote}`
7
+ if (parse(`value=${quoted}`).value === value) return quoted
8
+ }
9
+ throw new Error(
10
+ 'This credential cannot be represented safely in a dotenv file; nothing was saved',
11
+ )
12
+ }