transloadit 4.7.3 → 4.7.6

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 (152) hide show
  1. package/README.md +897 -5
  2. package/dist/Transloadit.d.ts +13 -3
  3. package/dist/Transloadit.d.ts.map +1 -1
  4. package/dist/Transloadit.js +22 -2
  5. package/dist/Transloadit.js.map +1 -1
  6. package/dist/alphalib/types/assembliesGet.d.ts +5 -0
  7. package/dist/alphalib/types/assembliesGet.d.ts.map +1 -1
  8. package/dist/alphalib/types/assemblyReplay.d.ts +5 -0
  9. package/dist/alphalib/types/assemblyReplay.d.ts.map +1 -1
  10. package/dist/alphalib/types/assemblyReplayNotification.d.ts +5 -0
  11. package/dist/alphalib/types/assemblyReplayNotification.d.ts.map +1 -1
  12. package/dist/alphalib/types/assemblyStatus.d.ts +25 -25
  13. package/dist/alphalib/types/assemblyStatus.d.ts.map +1 -1
  14. package/dist/alphalib/types/assemblyStatus.js +4 -1
  15. package/dist/alphalib/types/assemblyStatus.js.map +1 -1
  16. package/dist/alphalib/types/bill.d.ts +5 -0
  17. package/dist/alphalib/types/bill.d.ts.map +1 -1
  18. package/dist/alphalib/types/builtinTemplates.d.ts +83 -0
  19. package/dist/alphalib/types/builtinTemplates.d.ts.map +1 -0
  20. package/dist/alphalib/types/builtinTemplates.js +19 -0
  21. package/dist/alphalib/types/builtinTemplates.js.map +1 -0
  22. package/dist/alphalib/types/robots/ai-chat.d.ts.map +1 -1
  23. package/dist/alphalib/types/robots/ai-chat.js +1 -0
  24. package/dist/alphalib/types/robots/ai-chat.js.map +1 -1
  25. package/dist/alphalib/types/skillFrontmatter.d.ts +29 -0
  26. package/dist/alphalib/types/skillFrontmatter.d.ts.map +1 -0
  27. package/dist/alphalib/types/skillFrontmatter.js +19 -0
  28. package/dist/alphalib/types/skillFrontmatter.js.map +1 -0
  29. package/dist/alphalib/types/template.d.ts +36 -0
  30. package/dist/alphalib/types/template.d.ts.map +1 -1
  31. package/dist/alphalib/types/template.js +10 -0
  32. package/dist/alphalib/types/template.js.map +1 -1
  33. package/dist/alphalib/types/templateCredential.d.ts +10 -0
  34. package/dist/alphalib/types/templateCredential.d.ts.map +1 -1
  35. package/dist/bearerToken.d.ts +31 -0
  36. package/dist/bearerToken.d.ts.map +1 -0
  37. package/dist/bearerToken.js +158 -0
  38. package/dist/bearerToken.js.map +1 -0
  39. package/dist/cli/commands/assemblies.d.ts +8 -2
  40. package/dist/cli/commands/assemblies.d.ts.map +1 -1
  41. package/dist/cli/commands/assemblies.js +566 -411
  42. package/dist/cli/commands/assemblies.js.map +1 -1
  43. package/dist/cli/commands/auth.d.ts +1 -4
  44. package/dist/cli/commands/auth.d.ts.map +1 -1
  45. package/dist/cli/commands/auth.js +7 -123
  46. package/dist/cli/commands/auth.js.map +1 -1
  47. package/dist/cli/commands/index.d.ts.map +1 -1
  48. package/dist/cli/commands/index.js +5 -0
  49. package/dist/cli/commands/index.js.map +1 -1
  50. package/dist/cli/commands/templates.d.ts.map +1 -1
  51. package/dist/cli/commands/templates.js +4 -14
  52. package/dist/cli/commands/templates.js.map +1 -1
  53. package/dist/cli/fileProcessingOptions.d.ts +35 -0
  54. package/dist/cli/fileProcessingOptions.d.ts.map +1 -0
  55. package/dist/cli/fileProcessingOptions.js +182 -0
  56. package/dist/cli/fileProcessingOptions.js.map +1 -0
  57. package/dist/cli/generateIntentDocs.d.ts +2 -0
  58. package/dist/cli/generateIntentDocs.d.ts.map +1 -0
  59. package/dist/cli/generateIntentDocs.js +321 -0
  60. package/dist/cli/generateIntentDocs.js.map +1 -0
  61. package/dist/cli/intentCommandSpecs.d.ts +36 -0
  62. package/dist/cli/intentCommandSpecs.d.ts.map +1 -0
  63. package/dist/cli/intentCommandSpecs.js +181 -0
  64. package/dist/cli/intentCommandSpecs.js.map +1 -0
  65. package/dist/cli/intentCommands.d.ts +13 -0
  66. package/dist/cli/intentCommands.d.ts.map +1 -0
  67. package/dist/cli/intentCommands.js +368 -0
  68. package/dist/cli/intentCommands.js.map +1 -0
  69. package/dist/cli/intentFields.d.ts +25 -0
  70. package/dist/cli/intentFields.d.ts.map +1 -0
  71. package/dist/cli/intentFields.js +298 -0
  72. package/dist/cli/intentFields.js.map +1 -0
  73. package/dist/cli/intentInputPolicy.d.ts +10 -0
  74. package/dist/cli/intentInputPolicy.d.ts.map +1 -0
  75. package/dist/cli/intentInputPolicy.js +2 -0
  76. package/dist/cli/intentInputPolicy.js.map +1 -0
  77. package/dist/cli/intentRuntime.d.ts +114 -0
  78. package/dist/cli/intentRuntime.d.ts.map +1 -0
  79. package/dist/cli/intentRuntime.js +464 -0
  80. package/dist/cli/intentRuntime.js.map +1 -0
  81. package/dist/cli/resultFiles.d.ts +19 -0
  82. package/dist/cli/resultFiles.d.ts.map +1 -0
  83. package/dist/cli/resultFiles.js +66 -0
  84. package/dist/cli/resultFiles.js.map +1 -0
  85. package/dist/cli/resultUrls.d.ts +19 -0
  86. package/dist/cli/resultUrls.d.ts.map +1 -0
  87. package/dist/cli/resultUrls.js +36 -0
  88. package/dist/cli/resultUrls.js.map +1 -0
  89. package/dist/cli/semanticIntents/imageDescribe.d.ts +43 -0
  90. package/dist/cli/semanticIntents/imageDescribe.d.ts.map +1 -0
  91. package/dist/cli/semanticIntents/imageDescribe.js +188 -0
  92. package/dist/cli/semanticIntents/imageDescribe.js.map +1 -0
  93. package/dist/cli/semanticIntents/index.d.ts +18 -0
  94. package/dist/cli/semanticIntents/index.d.ts.map +1 -0
  95. package/dist/cli/semanticIntents/index.js +18 -0
  96. package/dist/cli/semanticIntents/index.js.map +1 -0
  97. package/dist/cli/semanticIntents/markdownPdf.d.ts +4 -0
  98. package/dist/cli/semanticIntents/markdownPdf.d.ts.map +1 -0
  99. package/dist/cli/semanticIntents/markdownPdf.js +93 -0
  100. package/dist/cli/semanticIntents/markdownPdf.js.map +1 -0
  101. package/dist/cli/semanticIntents/parsing.d.ts +11 -0
  102. package/dist/cli/semanticIntents/parsing.d.ts.map +1 -0
  103. package/dist/cli/semanticIntents/parsing.js +29 -0
  104. package/dist/cli/semanticIntents/parsing.js.map +1 -0
  105. package/dist/cli/stepsInput.d.ts +4 -0
  106. package/dist/cli/stepsInput.d.ts.map +1 -0
  107. package/dist/cli/stepsInput.js +23 -0
  108. package/dist/cli/stepsInput.js.map +1 -0
  109. package/dist/cli.d.ts +1 -1
  110. package/dist/cli.d.ts.map +1 -1
  111. package/dist/cli.js +5 -4
  112. package/dist/cli.js.map +1 -1
  113. package/dist/ensureUniqueCounter.d.ts +8 -0
  114. package/dist/ensureUniqueCounter.d.ts.map +1 -0
  115. package/dist/ensureUniqueCounter.js +48 -0
  116. package/dist/ensureUniqueCounter.js.map +1 -0
  117. package/dist/inputFiles.d.ts +9 -0
  118. package/dist/inputFiles.d.ts.map +1 -1
  119. package/dist/inputFiles.js +177 -26
  120. package/dist/inputFiles.js.map +1 -1
  121. package/dist/robots.js +1 -1
  122. package/dist/robots.js.map +1 -1
  123. package/package.json +9 -7
  124. package/src/Transloadit.ts +35 -3
  125. package/src/alphalib/types/assemblyStatus.ts +4 -1
  126. package/src/alphalib/types/builtinTemplates.ts +24 -0
  127. package/src/alphalib/types/robots/ai-chat.ts +1 -0
  128. package/src/alphalib/types/skillFrontmatter.ts +24 -0
  129. package/src/alphalib/types/template.ts +14 -0
  130. package/src/bearerToken.ts +208 -0
  131. package/src/cli/commands/assemblies.ts +825 -505
  132. package/src/cli/commands/auth.ts +9 -151
  133. package/src/cli/commands/index.ts +6 -3
  134. package/src/cli/commands/templates.ts +6 -17
  135. package/src/cli/fileProcessingOptions.ts +294 -0
  136. package/src/cli/generateIntentDocs.ts +419 -0
  137. package/src/cli/intentCommandSpecs.ts +282 -0
  138. package/src/cli/intentCommands.ts +525 -0
  139. package/src/cli/intentFields.ts +403 -0
  140. package/src/cli/intentInputPolicy.ts +11 -0
  141. package/src/cli/intentRuntime.ts +734 -0
  142. package/src/cli/resultFiles.ts +105 -0
  143. package/src/cli/resultUrls.ts +72 -0
  144. package/src/cli/semanticIntents/imageDescribe.ts +254 -0
  145. package/src/cli/semanticIntents/index.ts +48 -0
  146. package/src/cli/semanticIntents/markdownPdf.ts +120 -0
  147. package/src/cli/semanticIntents/parsing.ts +56 -0
  148. package/src/cli/stepsInput.ts +32 -0
  149. package/src/cli.ts +5 -4
  150. package/src/ensureUniqueCounter.ts +75 -0
  151. package/src/inputFiles.ts +277 -26
  152. package/src/robots.ts +1 -1
@@ -1,12 +1,13 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import EventEmitter from 'node:events'
2
3
  import fs from 'node:fs'
3
4
  import fsp from 'node:fs/promises'
4
5
  import path from 'node:path'
5
6
  import process from 'node:process'
6
- import type { Readable, Writable } from 'node:stream'
7
+ import type { Readable } from 'node:stream'
8
+ import { Writable } from 'node:stream'
7
9
  import { pipeline } from 'node:stream/promises'
8
10
  import { setTimeout as delay } from 'node:timers/promises'
9
- import tty from 'node:tty'
10
11
  import { promisify } from 'node:util'
11
12
  import { Command, Option } from 'clipanion'
12
13
  import got from 'got'
@@ -15,15 +16,31 @@ import * as t from 'typanion'
15
16
  import { z } from 'zod'
16
17
  import { formatLintIssue } from '../../alphalib/assembly-linter.lang.en.ts'
17
18
  import { tryCatch } from '../../alphalib/tryCatch.ts'
18
- import type { Steps, StepsInput } from '../../alphalib/types/template.ts'
19
- import { stepsSchema } from '../../alphalib/types/template.ts'
19
+ import type { StepsInput } from '../../alphalib/types/template.ts'
20
20
  import type { CreateAssemblyParams, ReplayAssemblyParams } from '../../apiTypes.ts'
21
+ import { ensureUniqueCounterValue } from '../../ensureUniqueCounter.ts'
21
22
  import type { LintFatalLevel } from '../../lintAssemblyInstructions.ts'
22
23
  import { lintAssemblyInstructions } from '../../lintAssemblyInstructions.ts'
23
24
  import type { CreateAssemblyOptions, Transloadit } from '../../Transloadit.ts'
24
25
  import { lintingExamples } from '../docs/assemblyLintingExamples.ts'
25
- import { createReadStream, formatAPIError, readCliInput, streamToBuffer } from '../helpers.ts'
26
+ import {
27
+ concurrencyOption,
28
+ deleteAfterProcessingOption,
29
+ inputPathsOption,
30
+ printUrlsOption,
31
+ recursiveOption,
32
+ reprocessStaleOption,
33
+ singleAssemblyOption,
34
+ validateSharedFileProcessingOptions,
35
+ watchOption,
36
+ } from '../fileProcessingOptions.ts'
37
+ import { formatAPIError, readCliInput } from '../helpers.ts'
26
38
  import type { IOutputCtl } from '../OutputCtl.ts'
39
+ import type { NormalizedAssemblyResultFile, NormalizedAssemblyResults } from '../resultFiles.ts'
40
+ import { normalizeAssemblyResults } from '../resultFiles.ts'
41
+ import type { ResultUrlRow } from '../resultUrls.ts'
42
+ import { collectNormalizedResultUrlRows, printResultUrls } from '../resultUrls.ts'
43
+ import { readStepsInputFile } from '../stepsInput.ts'
27
44
  import { ensureError, isErrnoException } from '../types.ts'
28
45
  import { AuthenticatedCommand, UnauthenticatedCommand } from './BaseCommand.ts'
29
46
 
@@ -61,6 +78,30 @@ export interface AssemblyLintOptions {
61
78
  json?: boolean
62
79
  }
63
80
 
81
+ function parseTemplateFieldAssignments(
82
+ output: IOutputCtl,
83
+ fields: string[] | undefined,
84
+ ): Record<string, string> | undefined {
85
+ if (fields == null || fields.length === 0) {
86
+ return undefined
87
+ }
88
+
89
+ const fieldsMap: Record<string, string> = {}
90
+ for (const field of fields) {
91
+ const eqIndex = field.indexOf('=')
92
+ if (eqIndex === -1) {
93
+ output.error(`invalid argument for --field: '${field}'`)
94
+ return undefined
95
+ }
96
+
97
+ const key = field.slice(0, eqIndex)
98
+ const value = field.slice(eqIndex + 1)
99
+ fieldsMap[key] = value
100
+ }
101
+
102
+ return fieldsMap
103
+ }
104
+
64
105
  const AssemblySchema = z.object({
65
106
  id: z.string(),
66
107
  })
@@ -148,13 +189,7 @@ export async function replay(
148
189
  ): Promise<void> {
149
190
  if (steps) {
150
191
  try {
151
- const buf = await streamToBuffer(createReadStream(steps))
152
- const parsed: unknown = JSON.parse(buf.toString())
153
- const validated = stepsSchema.safeParse(parsed)
154
- if (!validated.success) {
155
- throw new Error(`Invalid steps format: ${validated.error.message}`)
156
- }
157
- await apiCall(validated.data)
192
+ await apiCall(await readStepsInputFile(steps))
158
193
  } catch (err) {
159
194
  const error = ensureError(err)
160
195
  output.error(error.message)
@@ -163,14 +198,13 @@ export async function replay(
163
198
  await apiCall()
164
199
  }
165
200
 
166
- async function apiCall(stepsOverride?: Steps): Promise<void> {
201
+ async function apiCall(stepsOverride?: StepsInput): Promise<void> {
167
202
  const promises = assemblies.map(async (assembly) => {
168
203
  const [err] = await tryCatch(
169
204
  client.replayAssembly(assembly, {
170
205
  reparse_template: reparse ? 1 : 0,
171
206
  fields,
172
207
  notify_url,
173
- // Steps (validated) is assignable to StepsInput at runtime; cast for TS
174
208
  steps: stepsOverride as ReplayAssemblyParams['steps'],
175
209
  }),
176
210
  )
@@ -298,49 +332,44 @@ async function getNodeWatch(): Promise<NodeWatchFn> {
298
332
  const stdinWithPath = process.stdin as unknown as { path: string }
299
333
  stdinWithPath.path = '/dev/stdin'
300
334
 
301
- interface OutStream extends Writable {
335
+ interface OutputPlan {
336
+ mtime: Date
302
337
  path?: string
303
- mtime?: Date
304
338
  }
305
339
 
306
340
  interface Job {
307
- in: Readable | null
308
- out: OutStream | null
341
+ inputPath: string | null
342
+ out: OutputPlan | null
343
+ watchEvent?: boolean
309
344
  }
310
345
 
311
- type OutstreamProvider = (inpath: string | null, indir?: string) => Promise<OutStream | null>
312
-
313
- interface StreamRegistry {
314
- [key: string]: OutStream | undefined
315
- }
346
+ type OutputPlanProvider = (inpath: string | null, indir?: string) => Promise<OutputPlan | null>
316
347
 
317
348
  interface JobEmitterOptions {
349
+ allowOutputCollisions?: boolean
318
350
  recursive?: boolean
319
- outstreamProvider: OutstreamProvider
320
- streamRegistry: StreamRegistry
351
+ outputPlanProvider: OutputPlanProvider
352
+ singleAssembly?: boolean
321
353
  watch?: boolean
322
354
  reprocessStale?: boolean
323
355
  }
324
356
 
325
357
  interface ReaddirJobEmitterOptions {
326
358
  dir: string
327
- streamRegistry: StreamRegistry
328
359
  recursive?: boolean
329
- outstreamProvider: OutstreamProvider
360
+ outputPlanProvider: OutputPlanProvider
330
361
  topdir?: string
331
362
  }
332
363
 
333
364
  interface SingleJobEmitterOptions {
334
365
  file: string
335
- streamRegistry: StreamRegistry
336
- outstreamProvider: OutstreamProvider
366
+ outputPlanProvider: OutputPlanProvider
337
367
  }
338
368
 
339
369
  interface WatchJobEmitterOptions {
340
370
  file: string
341
- streamRegistry: StreamRegistry
342
371
  recursive?: boolean
343
- outstreamProvider: OutstreamProvider
372
+ outputPlanProvider: OutputPlanProvider
344
373
  }
345
374
 
346
375
  interface StatLike {
@@ -360,12 +389,49 @@ async function myStat(
360
389
  return await fsp.stat(filepath)
361
390
  }
362
391
 
363
- function dirProvider(output: string): OutstreamProvider {
392
+ function getJobInputPath(filepath: string): string {
393
+ const normalizedFile = path.normalize(filepath)
394
+ if (normalizedFile === '-') {
395
+ return stdinWithPath.path
396
+ }
397
+
398
+ return normalizedFile
399
+ }
400
+
401
+ function createInputUploadStream(filepath: string): Readable {
402
+ const instream = fs.createReadStream(filepath)
403
+ // Attach a no-op error handler to prevent unhandled errors if stream is destroyed
404
+ // before being consumed (e.g., due to output collision detection)
405
+ instream.on('error', () => {})
406
+ return instream
407
+ }
408
+
409
+ function createOutputPlan(pathname: string | undefined, mtime: Date): OutputPlan {
410
+ if (pathname == null) {
411
+ return {
412
+ mtime,
413
+ }
414
+ }
415
+
416
+ return {
417
+ mtime,
418
+ path: pathname,
419
+ }
420
+ }
421
+
422
+ async function createExistingPathOutputPlan(outputPath: string | undefined): Promise<OutputPlan> {
423
+ if (outputPath == null) {
424
+ return createOutputPlan(undefined, new Date(0))
425
+ }
426
+
427
+ const [, stats] = await tryCatch(fsp.stat(outputPath))
428
+ return createOutputPlan(outputPath, stats?.mtime ?? new Date(0))
429
+ }
430
+
431
+ function dirProvider(output: string): OutputPlanProvider {
364
432
  return async (inpath, indir = process.cwd()) => {
365
- // Inputless assemblies can still write into a directory, but output paths are derived from
366
- // assembly results rather than an input file path (handled later).
367
433
  if (inpath == null) {
368
- return null
434
+ return await createExistingPathOutputPlan(output)
369
435
  }
370
436
  if (inpath === '-') {
371
437
  throw new Error('You must provide an input to output to a directory')
@@ -374,41 +440,372 @@ function dirProvider(output: string): OutstreamProvider {
374
440
  let relpath = path.relative(indir, inpath)
375
441
  relpath = relpath.replace(/^(\.\.\/)+/, '')
376
442
  const outpath = path.join(output, relpath)
377
- const outdir = path.dirname(outpath)
378
-
379
- await fsp.mkdir(outdir, { recursive: true })
380
- const [, stats] = await tryCatch(fsp.stat(outpath))
381
- const mtime = stats?.mtime ?? new Date(0)
382
- const outstream = fs.createWriteStream(outpath) as OutStream
383
- // Attach a no-op error handler to prevent unhandled errors if stream is destroyed
384
- // before being consumed (e.g., due to output collision detection)
385
- outstream.on('error', () => {})
386
- outstream.mtime = mtime
387
- return outstream
443
+ return await createExistingPathOutputPlan(outpath)
388
444
  }
389
445
  }
390
446
 
391
- function fileProvider(output: string): OutstreamProvider {
392
- const dirExistsP = fsp.mkdir(path.dirname(output), { recursive: true })
447
+ function fileProvider(output: string): OutputPlanProvider {
393
448
  return async (_inpath) => {
394
- await dirExistsP
395
- if (output === '-') return process.stdout as OutStream
396
-
397
- const [, stats] = await tryCatch(fsp.stat(output))
398
- const mtime = stats?.mtime ?? new Date(0)
399
- const outstream = fs.createWriteStream(output) as OutStream
400
- // Attach a no-op error handler to prevent unhandled errors if stream is destroyed
401
- // before being consumed (e.g., due to output collision detection)
402
- outstream.on('error', () => {})
403
- outstream.mtime = mtime
404
- return outstream
449
+ if (output === '-') {
450
+ return await createExistingPathOutputPlan(undefined)
451
+ }
452
+
453
+ return await createExistingPathOutputPlan(output)
405
454
  }
406
455
  }
407
456
 
408
- function nullProvider(): OutstreamProvider {
457
+ function nullProvider(): OutputPlanProvider {
409
458
  return async (_inpath) => null
410
459
  }
411
460
 
461
+ async function downloadResultToFile(
462
+ resultUrl: string,
463
+ outPath: string,
464
+ signal: AbortSignal,
465
+ ): Promise<void> {
466
+ await fsp.mkdir(path.dirname(outPath), { recursive: true })
467
+
468
+ const tempPath = path.join(
469
+ path.dirname(outPath),
470
+ `.${path.basename(outPath)}.${randomUUID()}.tmp`,
471
+ )
472
+ const outStream = fs.createWriteStream(tempPath)
473
+ outStream.on('error', () => {})
474
+
475
+ const [dlErr] = await tryCatch(pipeline(got.stream(resultUrl, { signal }), outStream))
476
+ if (dlErr) {
477
+ await fsp.rm(tempPath, { force: true })
478
+ throw dlErr
479
+ }
480
+
481
+ await fsp.rename(tempPath, outPath)
482
+ }
483
+
484
+ async function downloadResultToStdout(resultUrl: string, signal: AbortSignal): Promise<void> {
485
+ const stdoutStream = new Writable({
486
+ write(chunk, _encoding, callback) {
487
+ let settled = false
488
+
489
+ const finish = (err?: Error | null) => {
490
+ if (settled) return
491
+ settled = true
492
+ process.stdout.off('drain', onDrain)
493
+ process.stdout.off('error', onError)
494
+ callback(err ?? undefined)
495
+ }
496
+
497
+ const onDrain = () => finish()
498
+ const onError = (err: Error) => finish(err)
499
+
500
+ process.stdout.once('error', onError)
501
+
502
+ try {
503
+ if (process.stdout.write(chunk)) {
504
+ finish()
505
+ return
506
+ }
507
+
508
+ process.stdout.once('drain', onDrain)
509
+ } catch (err) {
510
+ finish(ensureError(err))
511
+ }
512
+ },
513
+ final(callback) {
514
+ callback()
515
+ },
516
+ })
517
+
518
+ await pipeline(got.stream(resultUrl, { signal }), stdoutStream)
519
+ }
520
+
521
+ function sanitizeResultName(value: string): string {
522
+ const base = path.basename(value)
523
+ return base.replaceAll('\\', '_').replaceAll('/', '_').replaceAll('\u0000', '')
524
+ }
525
+
526
+ async function ensureUniquePath(targetPath: string, reservedPaths: Set<string>): Promise<string> {
527
+ const parsed = path.parse(targetPath)
528
+ return await ensureUniqueCounterValue({
529
+ initialValue: targetPath,
530
+ isTaken: async (candidate) => {
531
+ if (reservedPaths.has(candidate)) {
532
+ return true
533
+ }
534
+
535
+ const [statErr] = await tryCatch(fsp.stat(candidate))
536
+ return statErr == null
537
+ },
538
+ reserve: (candidate) => {
539
+ reservedPaths.add(candidate)
540
+ },
541
+ nextValue: (counter) => path.join(parsed.dir, `${parsed.name}__${counter}${parsed.ext}`),
542
+ scope: reservedPaths,
543
+ })
544
+ }
545
+
546
+ function getResultFileName(file: NormalizedAssemblyResultFile): string {
547
+ return sanitizeResultName(file.name)
548
+ }
549
+
550
+ interface AssemblyDownloadTarget {
551
+ resultUrl: string
552
+ targetPath: string | null
553
+ }
554
+
555
+ const STALE_OUTPUT_GRACE_MS = 1000
556
+
557
+ function isMeaningfullyNewer(newer: Date, older: Date): boolean {
558
+ return newer.getTime() - older.getTime() > STALE_OUTPUT_GRACE_MS
559
+ }
560
+
561
+ async function buildDirectoryDownloadTargets({
562
+ allFiles,
563
+ baseDir,
564
+ groupByStep,
565
+ reservedPaths,
566
+ }: {
567
+ allFiles: NormalizedAssemblyResultFile[]
568
+ baseDir: string
569
+ groupByStep: boolean
570
+ reservedPaths: Set<string>
571
+ }): Promise<AssemblyDownloadTarget[]> {
572
+ await fsp.mkdir(baseDir, { recursive: true })
573
+
574
+ const targets: AssemblyDownloadTarget[] = []
575
+ for (const resultFile of allFiles) {
576
+ const targetDir = groupByStep ? path.join(baseDir, resultFile.stepName) : baseDir
577
+ await fsp.mkdir(targetDir, { recursive: true })
578
+
579
+ targets.push({
580
+ resultUrl: resultFile.url,
581
+ targetPath: await ensureUniquePath(
582
+ path.join(targetDir, getResultFileName(resultFile)),
583
+ reservedPaths,
584
+ ),
585
+ })
586
+ }
587
+
588
+ return targets
589
+ }
590
+
591
+ function getSingleResultDownloadTarget(
592
+ allFiles: NormalizedAssemblyResultFile[],
593
+ targetPath: string | null,
594
+ ): AssemblyDownloadTarget[] {
595
+ const first = allFiles[0]
596
+ const resultUrl = first?.url ?? null
597
+ if (resultUrl == null) {
598
+ return []
599
+ }
600
+
601
+ return [{ resultUrl, targetPath }]
602
+ }
603
+
604
+ async function resolveResultDownloadTargets({
605
+ hasDirectoryInput,
606
+ inPath,
607
+ inputs,
608
+ normalizedResults,
609
+ outputMode,
610
+ outputPath,
611
+ outputRoot,
612
+ outputRootIsDirectory,
613
+ reservedPaths,
614
+ singleAssembly,
615
+ }: {
616
+ hasDirectoryInput: boolean
617
+ inPath: string | null
618
+ inputs: string[]
619
+ normalizedResults: NormalizedAssemblyResults
620
+ outputMode?: 'directory' | 'file'
621
+ outputPath: string | null
622
+ outputRoot: string
623
+ outputRootIsDirectory: boolean
624
+ reservedPaths: Set<string>
625
+ singleAssembly?: boolean
626
+ }): Promise<AssemblyDownloadTarget[]> {
627
+ const { allFiles, entries } = normalizedResults
628
+ const shouldGroupByInput =
629
+ !singleAssembly && inPath != null && (hasDirectoryInput || inputs.length > 1)
630
+
631
+ const resolveDirectoryBaseDir = (): string => {
632
+ if (!shouldGroupByInput || inPath == null) {
633
+ return outputRoot
634
+ }
635
+
636
+ if (hasDirectoryInput && outputPath != null) {
637
+ const mappedRelative = path.relative(outputRoot, outputPath)
638
+ const mappedDir = path.dirname(mappedRelative)
639
+ const mappedStem = path.parse(mappedRelative).name
640
+ return path.join(outputRoot, mappedDir === '.' ? '' : mappedDir, mappedStem)
641
+ }
642
+
643
+ return path.join(outputRoot, path.parse(path.basename(inPath)).name)
644
+ }
645
+
646
+ if (!outputRootIsDirectory) {
647
+ if (allFiles.length > 1) {
648
+ if (outputPath == null) {
649
+ throw new Error('stdout can only receive a single result file')
650
+ }
651
+
652
+ throw new Error('file outputs can only receive a single result file')
653
+ }
654
+
655
+ return getSingleResultDownloadTarget(allFiles, outputPath)
656
+ }
657
+
658
+ if (singleAssembly) {
659
+ return await buildDirectoryDownloadTargets({
660
+ allFiles,
661
+ baseDir: outputRoot,
662
+ groupByStep: false,
663
+ reservedPaths,
664
+ })
665
+ }
666
+
667
+ if (outputMode === 'directory' || outputPath == null || inPath == null) {
668
+ return await buildDirectoryDownloadTargets({
669
+ allFiles,
670
+ baseDir: resolveDirectoryBaseDir(),
671
+ groupByStep: entries.length > 1,
672
+ reservedPaths,
673
+ })
674
+ }
675
+
676
+ if (allFiles.length === 1) {
677
+ return getSingleResultDownloadTarget(allFiles, outputPath)
678
+ }
679
+
680
+ return await buildDirectoryDownloadTargets({
681
+ allFiles,
682
+ baseDir: path.join(path.dirname(outputPath), path.parse(outputPath).name),
683
+ groupByStep: true,
684
+ reservedPaths,
685
+ })
686
+ }
687
+
688
+ async function shouldSkipStaleOutput({
689
+ inputPaths,
690
+ outputPath,
691
+ outputPlanMtime,
692
+ outputRootIsDirectory,
693
+ reprocessStale,
694
+ singleInputReference = 'output-plan',
695
+ }: {
696
+ inputPaths: string[]
697
+ outputPath: string | null
698
+ outputPlanMtime: Date
699
+ outputRootIsDirectory: boolean
700
+ reprocessStale?: boolean
701
+ singleInputReference?: 'input' | 'output-plan'
702
+ }): Promise<boolean> {
703
+ if (reprocessStale || outputPath == null || outputRootIsDirectory) {
704
+ return false
705
+ }
706
+
707
+ if (inputPaths.length === 0 || inputPaths.some((inputPath) => inputPath === stdinWithPath.path)) {
708
+ return false
709
+ }
710
+
711
+ const [outputErr, outputStat] = await tryCatch(fsp.stat(outputPath))
712
+ if (outputErr != null || outputStat == null) {
713
+ return false
714
+ }
715
+
716
+ if (inputPaths.length === 1) {
717
+ if (singleInputReference === 'output-plan') {
718
+ return isMeaningfullyNewer(outputStat.mtime, outputPlanMtime)
719
+ }
720
+
721
+ const [inputErr, inputStat] = await tryCatch(fsp.stat(inputPaths[0]))
722
+ if (inputErr != null || inputStat == null) {
723
+ return false
724
+ }
725
+
726
+ return isMeaningfullyNewer(outputStat.mtime, inputStat.mtime)
727
+ }
728
+
729
+ const inputStats = await Promise.all(
730
+ inputPaths.map(async (inputPath) => {
731
+ const [inputErr, inputStat] = await tryCatch(fsp.stat(inputPath))
732
+ if (inputErr != null || inputStat == null) {
733
+ return null
734
+ }
735
+ return inputStat
736
+ }),
737
+ )
738
+
739
+ if (inputStats.some((inputStat) => inputStat == null)) {
740
+ return false
741
+ }
742
+
743
+ return inputStats.every((inputStat) => {
744
+ return inputStat != null && isMeaningfullyNewer(outputStat.mtime, inputStat.mtime)
745
+ })
746
+ }
747
+
748
+ async function materializeAssemblyResults({
749
+ abortSignal,
750
+ hasDirectoryInput,
751
+ inPath,
752
+ inputs,
753
+ normalizedResults,
754
+ outputMode,
755
+ outputPath,
756
+ outputRoot,
757
+ outputRootIsDirectory,
758
+ outputctl,
759
+ reservedPaths,
760
+ singleAssembly,
761
+ }: {
762
+ abortSignal: AbortSignal
763
+ hasDirectoryInput: boolean
764
+ inPath: string | null
765
+ inputs: string[]
766
+ normalizedResults: NormalizedAssemblyResults
767
+ outputMode?: 'directory' | 'file'
768
+ outputPath: string | null
769
+ outputRoot: string | null
770
+ outputRootIsDirectory: boolean
771
+ outputctl: IOutputCtl
772
+ reservedPaths: Set<string>
773
+ singleAssembly?: boolean
774
+ }): Promise<void> {
775
+ if (outputRoot == null) {
776
+ return
777
+ }
778
+
779
+ const targets = await resolveResultDownloadTargets({
780
+ hasDirectoryInput,
781
+ inPath,
782
+ inputs,
783
+ normalizedResults,
784
+ outputMode,
785
+ outputPath,
786
+ outputRoot,
787
+ outputRootIsDirectory,
788
+ reservedPaths,
789
+ singleAssembly,
790
+ })
791
+
792
+ for (const { resultUrl, targetPath } of targets) {
793
+ outputctl.debug('DOWNLOADING')
794
+ const [dlErr] = await tryCatch(
795
+ targetPath == null
796
+ ? downloadResultToStdout(resultUrl, abortSignal)
797
+ : downloadResultToFile(resultUrl, targetPath, abortSignal),
798
+ )
799
+ if (dlErr) {
800
+ if (dlErr.name === 'AbortError') {
801
+ continue
802
+ }
803
+ outputctl.error(dlErr.message)
804
+ throw dlErr
805
+ }
806
+ }
807
+ }
808
+
412
809
  class MyEventEmitter extends EventEmitter {
413
810
  protected hasEnded: boolean
414
811
 
@@ -428,29 +825,25 @@ class MyEventEmitter extends EventEmitter {
428
825
  }
429
826
 
430
827
  class ReaddirJobEmitter extends MyEventEmitter {
431
- constructor({
432
- dir,
433
- streamRegistry,
434
- recursive,
435
- outstreamProvider,
436
- topdir = dir,
437
- }: ReaddirJobEmitterOptions) {
828
+ constructor({ dir, recursive, outputPlanProvider, topdir = dir }: ReaddirJobEmitterOptions) {
438
829
  super()
439
830
 
440
831
  process.nextTick(() => {
441
- this.processDirectory({ dir, streamRegistry, recursive, outstreamProvider, topdir }).catch(
442
- (err) => {
443
- this.emit('error', err)
444
- },
445
- )
832
+ this.processDirectory({
833
+ dir,
834
+ recursive,
835
+ outputPlanProvider,
836
+ topdir,
837
+ }).catch((err) => {
838
+ this.emit('error', err)
839
+ })
446
840
  })
447
841
  }
448
842
 
449
843
  private async processDirectory({
450
844
  dir,
451
- streamRegistry,
452
845
  recursive,
453
- outstreamProvider,
846
+ outputPlanProvider,
454
847
  topdir,
455
848
  }: ReaddirJobEmitterOptions & { topdir: string }): Promise<void> {
456
849
  const files = await fsp.readdir(dir)
@@ -459,9 +852,7 @@ class ReaddirJobEmitter extends MyEventEmitter {
459
852
 
460
853
  for (const filename of files) {
461
854
  const file = path.normalize(path.join(dir, filename))
462
- pendingOperations.push(
463
- this.processFile({ file, streamRegistry, recursive, outstreamProvider, topdir }),
464
- )
855
+ pendingOperations.push(this.processFile({ file, recursive, outputPlanProvider, topdir }))
465
856
  }
466
857
 
467
858
  await Promise.all(pendingOperations)
@@ -470,15 +861,13 @@ class ReaddirJobEmitter extends MyEventEmitter {
470
861
 
471
862
  private async processFile({
472
863
  file,
473
- streamRegistry,
474
864
  recursive = false,
475
- outstreamProvider,
865
+ outputPlanProvider,
476
866
  topdir,
477
867
  }: {
478
868
  file: string
479
- streamRegistry: StreamRegistry
480
869
  recursive?: boolean
481
- outstreamProvider: OutstreamProvider
870
+ outputPlanProvider: OutputPlanProvider
482
871
  topdir: string
483
872
  }): Promise<void> {
484
873
  const stats = await fsp.stat(file)
@@ -488,9 +877,8 @@ class ReaddirJobEmitter extends MyEventEmitter {
488
877
  await new Promise<void>((resolve, reject) => {
489
878
  const subdirEmitter = new ReaddirJobEmitter({
490
879
  dir: file,
491
- streamRegistry,
492
880
  recursive,
493
- outstreamProvider,
881
+ outputPlanProvider,
494
882
  topdir,
495
883
  })
496
884
  subdirEmitter.on('job', (job: Job) => this.emit('job', job))
@@ -499,67 +887,51 @@ class ReaddirJobEmitter extends MyEventEmitter {
499
887
  })
500
888
  }
501
889
  } else {
502
- const existing = streamRegistry[file]
503
- if (existing) existing.end()
504
- const outstream = await outstreamProvider(file, topdir)
505
- streamRegistry[file] = outstream ?? undefined
506
- const instream = fs.createReadStream(file)
507
- // Attach a no-op error handler to prevent unhandled errors if stream is destroyed
508
- // before being consumed (e.g., due to output collision detection)
509
- instream.on('error', () => {})
510
- this.emit('job', { in: instream, out: outstream })
890
+ const outputPlan = await outputPlanProvider(file, topdir)
891
+ this.emit('job', { inputPath: getJobInputPath(file), out: outputPlan })
511
892
  }
512
893
  }
513
894
  }
514
895
 
515
896
  class SingleJobEmitter extends MyEventEmitter {
516
- constructor({ file, streamRegistry, outstreamProvider }: SingleJobEmitterOptions) {
897
+ constructor({ file, outputPlanProvider }: SingleJobEmitterOptions) {
517
898
  super()
518
899
 
519
900
  const normalizedFile = path.normalize(file)
520
- const existing = streamRegistry[normalizedFile]
521
- if (existing) existing.end()
522
- outstreamProvider(normalizedFile).then((outstream) => {
523
- streamRegistry[normalizedFile] = outstream ?? undefined
524
-
525
- let instream: Readable | null
526
- if (normalizedFile === '-') {
527
- if (tty.isatty(process.stdin.fd)) {
528
- instream = null
529
- } else {
530
- instream = process.stdin
531
- }
532
- } else {
533
- instream = fs.createReadStream(normalizedFile)
534
- // Attach a no-op error handler to prevent unhandled errors if stream is destroyed
535
- // before being consumed (e.g., due to output collision detection)
536
- instream.on('error', () => {})
537
- }
538
-
539
- process.nextTick(() => {
540
- this.emit('job', { in: instream, out: outstream })
541
- this.emit('end')
901
+ outputPlanProvider(normalizedFile)
902
+ .then((outputPlan) => {
903
+ process.nextTick(() => {
904
+ this.emit('job', { inputPath: getJobInputPath(normalizedFile), out: outputPlan })
905
+ this.emit('end')
906
+ })
907
+ })
908
+ .catch((err: unknown) => {
909
+ process.nextTick(() => {
910
+ this.emit('error', ensureError(err))
911
+ })
542
912
  })
543
- })
544
913
  }
545
914
  }
546
915
 
547
916
  class InputlessJobEmitter extends MyEventEmitter {
548
- constructor({
549
- outstreamProvider,
550
- }: { streamRegistry: StreamRegistry; outstreamProvider: OutstreamProvider }) {
917
+ constructor({ outputPlanProvider }: { outputPlanProvider: OutputPlanProvider }) {
551
918
  super()
552
919
 
553
920
  process.nextTick(() => {
554
- outstreamProvider(null).then((outstream) => {
555
- try {
556
- this.emit('job', { in: null, out: outstream })
557
- } catch (err) {
558
- this.emit('error', err)
559
- }
921
+ outputPlanProvider(null)
922
+ .then((outputPlan) => {
923
+ try {
924
+ this.emit('job', { inputPath: null, out: outputPlan })
925
+ } catch (err) {
926
+ this.emit('error', ensureError(err))
927
+ return
928
+ }
560
929
 
561
- this.emit('end')
562
- })
930
+ this.emit('end')
931
+ })
932
+ .catch((err: unknown) => {
933
+ this.emit('error', ensureError(err))
934
+ })
563
935
  })
564
936
  }
565
937
  }
@@ -574,10 +946,10 @@ class NullJobEmitter extends MyEventEmitter {
574
946
  class WatchJobEmitter extends MyEventEmitter {
575
947
  private watcher: NodeWatcher | null = null
576
948
 
577
- constructor({ file, streamRegistry, recursive, outstreamProvider }: WatchJobEmitterOptions) {
949
+ constructor({ file, recursive, outputPlanProvider }: WatchJobEmitterOptions) {
578
950
  super()
579
951
 
580
- this.init({ file, streamRegistry, recursive, outstreamProvider }).catch((err) => {
952
+ this.init({ file, recursive, outputPlanProvider }).catch((err) => {
581
953
  this.emit('error', err)
582
954
  })
583
955
 
@@ -597,9 +969,8 @@ class WatchJobEmitter extends MyEventEmitter {
597
969
 
598
970
  private async init({
599
971
  file,
600
- streamRegistry,
601
972
  recursive,
602
- outstreamProvider,
973
+ outputPlanProvider,
603
974
  }: WatchJobEmitterOptions): Promise<void> {
604
975
  const stats = await fsp.stat(file)
605
976
  const topdir = stats.isDirectory() ? file : undefined
@@ -614,7 +985,7 @@ class WatchJobEmitter extends MyEventEmitter {
614
985
  this.watcher.on('close', () => this.emit('end'))
615
986
  this.watcher.on('change', (_evt: string, filename: string) => {
616
987
  const normalizedFile = path.normalize(filename)
617
- this.handleChange(normalizedFile, topdir, streamRegistry, outstreamProvider).catch((err) => {
988
+ this.handleChange(normalizedFile, topdir, outputPlanProvider).catch((err) => {
618
989
  this.emit('error', err)
619
990
  })
620
991
  })
@@ -623,23 +994,17 @@ class WatchJobEmitter extends MyEventEmitter {
623
994
  private async handleChange(
624
995
  normalizedFile: string,
625
996
  topdir: string | undefined,
626
- streamRegistry: StreamRegistry,
627
- outstreamProvider: OutstreamProvider,
997
+ outputPlanProvider: OutputPlanProvider,
628
998
  ): Promise<void> {
629
999
  const stats = await fsp.stat(normalizedFile)
630
1000
  if (stats.isDirectory()) return
631
1001
 
632
- const existing = streamRegistry[normalizedFile]
633
- if (existing) existing.end()
634
-
635
- const outstream = await outstreamProvider(normalizedFile, topdir)
636
- streamRegistry[normalizedFile] = outstream ?? undefined
637
-
638
- const instream = fs.createReadStream(normalizedFile)
639
- // Attach a no-op error handler to prevent unhandled errors if stream is destroyed
640
- // before being consumed (e.g., due to output collision detection)
641
- instream.on('error', () => {})
642
- this.emit('job', { in: instream, out: outstream })
1002
+ const outputPlan = await outputPlanProvider(normalizedFile, topdir)
1003
+ this.emit('job', {
1004
+ inputPath: getJobInputPath(normalizedFile),
1005
+ out: outputPlan,
1006
+ watchEvent: true,
1007
+ })
643
1008
  }
644
1009
  }
645
1010
 
@@ -697,12 +1062,21 @@ function detectConflicts(jobEmitter: EventEmitter): MyEventEmitter {
697
1062
  jobEmitter.on('end', () => emitter.emit('end'))
698
1063
  jobEmitter.on('error', (err: Error) => emitter.emit('error', err))
699
1064
  jobEmitter.on('job', (job: Job) => {
700
- if (job.in == null || job.out == null) {
1065
+ if (job.watchEvent) {
1066
+ emitter.emit('job', job)
1067
+ return
1068
+ }
1069
+
1070
+ if (job.inputPath == null || job.out == null) {
1071
+ emitter.emit('job', job)
1072
+ return
1073
+ }
1074
+ const inPath = job.inputPath
1075
+ const outPath = job.out.path
1076
+ if (outPath == null) {
701
1077
  emitter.emit('job', job)
702
1078
  return
703
1079
  }
704
- const inPath = (job.in as fs.ReadStream).path as string
705
- const outPath = job.out.path as string
706
1080
  if (Object.hasOwn(outfileAssociations, outPath) && outfileAssociations[outPath] !== inPath) {
707
1081
  emitter.emit(
708
1082
  'error',
@@ -724,12 +1098,12 @@ function dismissStaleJobs(jobEmitter: EventEmitter): MyEventEmitter {
724
1098
  jobEmitter.on('end', () => Promise.all(pendingChecks).then(() => emitter.emit('end')))
725
1099
  jobEmitter.on('error', (err: Error) => emitter.emit('error', err))
726
1100
  jobEmitter.on('job', (job: Job) => {
727
- if (job.in == null || job.out == null) {
1101
+ if (job.inputPath == null || job.out == null) {
728
1102
  emitter.emit('job', job)
729
1103
  return
730
1104
  }
731
1105
 
732
- const inPath = (job.in as fs.ReadStream).path as string
1106
+ const inPath = job.inputPath
733
1107
  const checkPromise = fsp
734
1108
  .stat(inPath)
735
1109
  .then((stats) => {
@@ -747,12 +1121,23 @@ function dismissStaleJobs(jobEmitter: EventEmitter): MyEventEmitter {
747
1121
  return emitter
748
1122
  }
749
1123
 
1124
+ function passthroughJobs(jobEmitter: EventEmitter): MyEventEmitter {
1125
+ const emitter = new MyEventEmitter()
1126
+
1127
+ jobEmitter.on('end', () => emitter.emit('end'))
1128
+ jobEmitter.on('error', (err: Error) => emitter.emit('error', err))
1129
+ jobEmitter.on('job', (job: Job) => emitter.emit('job', job))
1130
+
1131
+ return emitter
1132
+ }
1133
+
750
1134
  function makeJobEmitter(
751
1135
  inputs: string[],
752
1136
  {
1137
+ allowOutputCollisions,
753
1138
  recursive,
754
- outstreamProvider,
755
- streamRegistry,
1139
+ outputPlanProvider,
1140
+ singleAssembly,
756
1141
  watch: watchOption,
757
1142
  reprocessStale,
758
1143
  }: JobEmitterOptions,
@@ -765,35 +1150,43 @@ function makeJobEmitter(
765
1150
  async function processInputs(): Promise<void> {
766
1151
  for (const input of inputs) {
767
1152
  if (input === '-') {
768
- emitterFns.push(
769
- () => new SingleJobEmitter({ file: input, outstreamProvider, streamRegistry }),
770
- )
1153
+ emitterFns.push(() => new SingleJobEmitter({ file: input, outputPlanProvider }))
771
1154
  watcherFns.push(() => new NullJobEmitter())
772
1155
  } else {
773
1156
  const stats = await fsp.stat(input)
774
1157
  if (stats.isDirectory()) {
775
1158
  emitterFns.push(
776
1159
  () =>
777
- new ReaddirJobEmitter({ dir: input, recursive, outstreamProvider, streamRegistry }),
1160
+ new ReaddirJobEmitter({
1161
+ dir: input,
1162
+ recursive,
1163
+ outputPlanProvider,
1164
+ }),
778
1165
  )
779
1166
  watcherFns.push(
780
1167
  () =>
781
- new WatchJobEmitter({ file: input, recursive, outstreamProvider, streamRegistry }),
1168
+ new WatchJobEmitter({
1169
+ file: input,
1170
+ recursive,
1171
+ outputPlanProvider,
1172
+ }),
782
1173
  )
783
1174
  } else {
784
- emitterFns.push(
785
- () => new SingleJobEmitter({ file: input, outstreamProvider, streamRegistry }),
786
- )
1175
+ emitterFns.push(() => new SingleJobEmitter({ file: input, outputPlanProvider }))
787
1176
  watcherFns.push(
788
1177
  () =>
789
- new WatchJobEmitter({ file: input, recursive, outstreamProvider, streamRegistry }),
1178
+ new WatchJobEmitter({
1179
+ file: input,
1180
+ recursive,
1181
+ outputPlanProvider,
1182
+ }),
790
1183
  )
791
1184
  }
792
1185
  }
793
1186
  }
794
1187
 
795
1188
  if (inputs.length === 0) {
796
- emitterFns.push(() => new InputlessJobEmitter({ outstreamProvider, streamRegistry }))
1189
+ emitterFns.push(() => new InputlessJobEmitter({ outputPlanProvider }))
797
1190
  }
798
1191
 
799
1192
  startEmitting()
@@ -818,14 +1211,18 @@ function makeJobEmitter(
818
1211
  emitter.emit('error', err)
819
1212
  })
820
1213
 
821
- const stalefilter = reprocessStale ? (x: EventEmitter) => x as MyEventEmitter : dismissStaleJobs
822
- return stalefilter(detectConflicts(emitter))
1214
+ const conflictFilter = allowOutputCollisions ? passthroughJobs : detectConflicts
1215
+ const staleFilter = reprocessStale || singleAssembly ? passthroughJobs : dismissStaleJobs
1216
+
1217
+ return staleFilter(conflictFilter(emitter))
823
1218
  }
824
1219
 
825
1220
  export interface AssembliesCreateOptions {
826
1221
  steps?: string
1222
+ stepsData?: StepsInput
827
1223
  template?: string
828
1224
  fields?: Record<string, string>
1225
+ outputMode?: 'directory' | 'file'
829
1226
  watch?: boolean
830
1227
  recursive?: boolean
831
1228
  inputs: string[]
@@ -844,8 +1241,10 @@ export async function create(
844
1241
  client: Transloadit,
845
1242
  {
846
1243
  steps,
1244
+ stepsData,
847
1245
  template,
848
1246
  fields,
1247
+ outputMode,
849
1248
  watch: watchOption,
850
1249
  recursive,
851
1250
  inputs,
@@ -855,35 +1254,18 @@ export async function create(
855
1254
  singleAssembly,
856
1255
  concurrency = DEFAULT_CONCURRENCY,
857
1256
  }: AssembliesCreateOptions,
858
- ): Promise<{ results: unknown[]; hasFailures: boolean }> {
1257
+ ): Promise<{ resultUrls: ResultUrlRow[]; results: unknown[]; hasFailures: boolean }> {
859
1258
  // Quick fix for https://github.com/transloadit/transloadify/issues/13
860
1259
  // Only default to stdout when output is undefined (not provided), not when explicitly null
861
1260
  let resolvedOutput = output
862
1261
  if (resolvedOutput === undefined && !process.stdout.isTTY) resolvedOutput = '-'
863
1262
 
864
1263
  // Read steps file async before entering the Promise constructor
865
- // We use StepsInput (the input type) rather than Steps (the transformed output type)
1264
+ // We use StepsInput (the input type) rather than the transformed output type
866
1265
  // to avoid zod adding default values that the API may reject
867
- let stepsData: StepsInput | undefined
1266
+ let effectiveStepsData = stepsData
868
1267
  if (steps) {
869
- const stepsContent = await fsp.readFile(steps, 'utf8')
870
- const parsed: unknown = JSON.parse(stepsContent)
871
- // Basic structural validation: must be an object with step names as keys
872
- if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
873
- throw new Error('Invalid steps format: expected an object with step names as keys')
874
- }
875
- // Validate each step has a robot field
876
- for (const [stepName, step] of Object.entries(parsed)) {
877
- if (step == null || typeof step !== 'object' || Array.isArray(step)) {
878
- throw new Error(`Invalid steps format: step '${stepName}' must be an object`)
879
- }
880
- if (!('robot' in step) || typeof (step as Record<string, unknown>).robot !== 'string') {
881
- throw new Error(
882
- `Invalid steps format: step '${stepName}' must have a 'robot' string property`,
883
- )
884
- }
885
- }
886
- stepsData = parsed as StepsInput
1268
+ effectiveStepsData = await readStepsInputFile(steps)
887
1269
  }
888
1270
 
889
1271
  // Determine output stat async before entering the Promise constructor
@@ -891,9 +1273,19 @@ export async function create(
891
1273
  if (resolvedOutput != null) {
892
1274
  const [err, stat] = await tryCatch(myStat(process.stdout, resolvedOutput))
893
1275
  if (err && (!isErrnoException(err) || err.code !== 'ENOENT')) throw err
894
- outstat = stat ?? { isDirectory: () => false }
1276
+ outstat =
1277
+ stat ??
1278
+ ({
1279
+ isDirectory: () => outputMode === 'directory',
1280
+ } satisfies StatLike)
1281
+
1282
+ if (outputMode === 'directory' && stat != null && !stat.isDirectory()) {
1283
+ const msg = 'Output must be a directory for this command'
1284
+ outputctl.error(msg)
1285
+ throw new Error(msg)
1286
+ }
895
1287
 
896
- if (!outstat.isDirectory() && inputs.length !== 0) {
1288
+ if (!outstat.isDirectory() && inputs.length !== 0 && !singleAssembly) {
897
1289
  const firstInput = inputs[0]
898
1290
  if (firstInput) {
899
1291
  const firstInputStat = await myStat(process.stdin, firstInput)
@@ -906,333 +1298,294 @@ export async function create(
906
1298
  }
907
1299
  }
908
1300
 
1301
+ const inputStats = await Promise.all(
1302
+ inputs.map(async (input) => {
1303
+ if (input === '-') return null
1304
+ return await myStat(process.stdin, input)
1305
+ }),
1306
+ )
1307
+ const hasDirectoryInput = inputStats.some((stat) => stat?.isDirectory() === true)
1308
+
909
1309
  return new Promise((resolve, reject) => {
910
1310
  const params: CreateAssemblyParams = (
911
- stepsData ? { steps: stepsData as CreateAssemblyParams['steps'] } : { template_id: template }
1311
+ effectiveStepsData
1312
+ ? { steps: effectiveStepsData as CreateAssemblyParams['steps'] }
1313
+ : { template_id: template }
912
1314
  ) as CreateAssemblyParams
913
1315
  if (fields) {
914
1316
  params.fields = fields
915
1317
  }
916
1318
 
917
- const outstreamProvider: OutstreamProvider =
1319
+ const outputPlanProvider: OutputPlanProvider =
918
1320
  resolvedOutput == null
919
1321
  ? nullProvider()
920
1322
  : outstat?.isDirectory()
921
1323
  ? dirProvider(resolvedOutput)
922
1324
  : fileProvider(resolvedOutput)
923
- const streamRegistry: StreamRegistry = {}
924
1325
 
925
1326
  const emitter = makeJobEmitter(inputs, {
1327
+ allowOutputCollisions: singleAssembly,
1328
+ outputPlanProvider,
926
1329
  recursive,
927
1330
  watch: watchOption,
928
- outstreamProvider,
929
- streamRegistry,
1331
+ singleAssembly,
930
1332
  reprocessStale,
931
1333
  })
932
1334
 
933
1335
  // Use p-queue for concurrency management
934
1336
  const queue = new PQueue({ concurrency })
935
1337
  const results: unknown[] = []
1338
+ const resultUrls: ResultUrlRow[] = []
1339
+ const reservedResultPaths = new Set<string>()
1340
+ const latestWatchJobTokenByOutputPath = new Map<string, number>()
936
1341
  let hasFailures = false
1342
+ let nextWatchJobToken = 0
937
1343
  // AbortController to cancel all in-flight createAssembly calls when an error occurs
938
1344
  const abortController = new AbortController()
1345
+ const outputRootIsDirectory = Boolean(resolvedOutput != null && outstat?.isDirectory())
939
1346
 
940
- // Helper to process a single assembly job
941
- async function processAssemblyJob(
942
- inPath: string | null,
943
- outPath: string | null,
944
- outMtime: Date | undefined,
945
- ): Promise<unknown> {
946
- outputctl.debug(`PROCESSING JOB ${inPath ?? 'null'} ${outPath ?? 'null'}`)
947
-
948
- // Create fresh streams for this job
949
- const inStream = inPath ? fs.createReadStream(inPath) : null
950
- inStream?.on('error', () => {})
951
-
952
- let superceded = false
953
- // When writing to a file path (non-directory output), we treat finish as a supersede signal.
954
- // Directory-output multi-download mode does not use a single shared outstream.
955
- const markSupersededOnFinish = (stream: OutStream) => {
956
- stream.on('finish', () => {
957
- superceded = true
958
- })
1347
+ function reserveWatchJobToken(outputPath: string | null): number | null {
1348
+ if (!watchOption || outputPath == null) {
1349
+ return null
1350
+ }
1351
+
1352
+ const token = ++nextWatchJobToken
1353
+ latestWatchJobTokenByOutputPath.set(outputPath, token)
1354
+ return token
1355
+ }
1356
+
1357
+ function isSupersededWatchJob(outputPath: string | null, token: number | null): boolean {
1358
+ if (!watchOption || outputPath == null || token == null) {
1359
+ return false
959
1360
  }
960
1361
 
1362
+ return latestWatchJobTokenByOutputPath.get(outputPath) !== token
1363
+ }
1364
+
1365
+ function createAssemblyOptions({
1366
+ files,
1367
+ uploads,
1368
+ }: {
1369
+ files?: Record<string, string>
1370
+ uploads?: Record<string, Readable>
1371
+ } = {}): CreateAssemblyOptions {
961
1372
  const createOptions: CreateAssemblyOptions = {
962
1373
  params,
963
1374
  signal: abortController.signal,
964
1375
  }
965
- if (inStream != null) {
966
- createOptions.uploads = { in: inStream }
1376
+ if (files != null && Object.keys(files).length > 0) {
1377
+ createOptions.files = files
967
1378
  }
1379
+ if (uploads != null && Object.keys(uploads).length > 0) {
1380
+ createOptions.uploads = uploads
1381
+ }
1382
+ return createOptions
1383
+ }
968
1384
 
1385
+ async function awaitCompletedAssembly(createOptions: CreateAssemblyOptions): Promise<{
1386
+ assembly: Awaited<ReturnType<typeof client.awaitAssemblyCompletion>>
1387
+ assemblyId: string
1388
+ }> {
969
1389
  const result = await client.createAssembly(createOptions)
970
- if (superceded) return undefined
971
-
972
1390
  const assemblyId = result.assembly_id
973
1391
  if (!assemblyId) throw new Error('No assembly_id in result')
974
1392
 
975
1393
  const assembly = await client.awaitAssemblyCompletion(assemblyId, {
976
1394
  signal: abortController.signal,
977
- onPoll: () => {
978
- if (superceded) return false
979
- return true
980
- },
1395
+ onPoll: () => true,
981
1396
  onAssemblyProgress: (status) => {
982
1397
  outputctl.debug(`Assembly status: ${status.ok}`)
983
1398
  },
984
1399
  })
985
1400
 
986
- if (superceded) return undefined
987
-
988
1401
  if (assembly.error || (assembly.ok && assembly.ok !== 'ASSEMBLY_COMPLETED')) {
989
1402
  const msg = `Assembly failed: ${assembly.error || assembly.message} (Status: ${assembly.ok})`
990
1403
  outputctl.error(msg)
991
1404
  throw new Error(msg)
992
1405
  }
993
1406
 
1407
+ return { assembly, assemblyId }
1408
+ }
1409
+
1410
+ async function executeAssemblyLifecycle({
1411
+ createOptions,
1412
+ inPath,
1413
+ inputPaths,
1414
+ outputPlan,
1415
+ outputToken,
1416
+ singleAssemblyMode,
1417
+ }: {
1418
+ createOptions: CreateAssemblyOptions
1419
+ inPath: string | null
1420
+ inputPaths: string[]
1421
+ outputPlan: OutputPlan | null
1422
+ outputToken: number | null
1423
+ singleAssemblyMode?: boolean
1424
+ }): Promise<unknown> {
1425
+ outputctl.debug(`PROCESSING JOB ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`)
1426
+
1427
+ const { assembly, assemblyId } = await awaitCompletedAssembly(createOptions)
994
1428
  if (!assembly.results) throw new Error('No results in assembly')
1429
+ const normalizedResults = normalizeAssemblyResults(assembly.results)
995
1430
 
996
- const outIsDirectory = Boolean(resolvedOutput != null && outstat?.isDirectory())
997
- const entries = Object.entries(assembly.results)
998
- const allFiles: Array<{
999
- stepName: string
1000
- file: { name?: string; basename?: string; ext?: string; ssl_url?: string; url?: string }
1001
- }> = []
1002
- for (const [stepName, stepResults] of entries) {
1003
- for (const file of stepResults as Array<{
1004
- name?: string
1005
- basename?: string
1006
- ext?: string
1007
- ssl_url?: string
1008
- url?: string
1009
- }>) {
1010
- allFiles.push({ stepName, file })
1011
- }
1431
+ if (isSupersededWatchJob(outputPlan?.path ?? null, outputToken)) {
1432
+ outputctl.debug(
1433
+ `SKIPPED SUPERSEDED WATCH RESULT ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`,
1434
+ )
1435
+ return assembly
1012
1436
  }
1013
1437
 
1014
- const getFileUrl = (file: { ssl_url?: string; url?: string }): string | null =>
1015
- file.ssl_url ?? file.url ?? null
1016
-
1017
- const sanitizeName = (value: string): string => {
1018
- const base = path.basename(value)
1019
- return base.replaceAll('\\', '_').replaceAll('/', '_').replaceAll('\u0000', '')
1438
+ if (
1439
+ !singleAssemblyMode &&
1440
+ !watchOption &&
1441
+ (await shouldSkipStaleOutput({
1442
+ inputPaths,
1443
+ outputPath: outputPlan?.path ?? null,
1444
+ outputPlanMtime: outputPlan?.mtime ?? new Date(0),
1445
+ outputRootIsDirectory,
1446
+ reprocessStale,
1447
+ }))
1448
+ ) {
1449
+ outputctl.debug(`SKIPPED STALE RESULT ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`)
1450
+ return assembly
1020
1451
  }
1021
1452
 
1022
- const ensureUniquePath = async (targetPath: string): Promise<string> => {
1023
- const parsed = path.parse(targetPath)
1024
- let candidate = targetPath
1025
- let counter = 1
1026
- while (true) {
1027
- const [statErr] = await tryCatch(fsp.stat(candidate))
1028
- if (statErr) return candidate
1029
- candidate = path.join(parsed.dir, `${parsed.name}__${counter}${parsed.ext}`)
1030
- counter += 1
1031
- }
1032
- }
1453
+ resultUrls.push(...collectNormalizedResultUrlRows({ assemblyId, normalizedResults }))
1454
+
1455
+ await materializeAssemblyResults({
1456
+ abortSignal: abortController.signal,
1457
+ hasDirectoryInput: singleAssemblyMode ? false : hasDirectoryInput,
1458
+ inPath,
1459
+ inputs: inputPaths,
1460
+ normalizedResults,
1461
+ outputMode,
1462
+ outputPath: outputPlan?.path ?? null,
1463
+ outputRoot: resolvedOutput ?? null,
1464
+ outputRootIsDirectory,
1465
+ outputctl,
1466
+ reservedPaths: reservedResultPaths,
1467
+ singleAssembly: singleAssemblyMode,
1468
+ })
1033
1469
 
1034
- if (resolvedOutput != null && !superceded) {
1035
- // Directory output:
1036
- // - For single-result, input-backed jobs, preserve existing behavior (write to mapped file path).
1037
- // - Otherwise (multi-result or inputless), download all results into a directory structure.
1038
- if (outIsDirectory && (inPath == null || allFiles.length !== 1 || outPath == null)) {
1039
- let baseDir = resolvedOutput
1040
- if (inPath != null) {
1041
- let relpath = path.relative(process.cwd(), inPath)
1042
- relpath = relpath.replace(/^(\.\.\/)+/, '')
1043
- baseDir = path.join(resolvedOutput, path.dirname(relpath), path.parse(relpath).name)
1044
- }
1045
- await fsp.mkdir(baseDir, { recursive: true })
1046
-
1047
- for (const { stepName, file } of allFiles) {
1048
- const resultUrl = getFileUrl(file)
1049
- if (!resultUrl) continue
1050
-
1051
- const stepDir = path.join(baseDir, stepName)
1052
- await fsp.mkdir(stepDir, { recursive: true })
1053
-
1054
- const rawName =
1055
- file.name ??
1056
- (file.basename && file.ext ? `${file.basename}.${file.ext}` : undefined) ??
1057
- `${stepName}_result`
1058
- const safeName = sanitizeName(rawName)
1059
- const targetPath = await ensureUniquePath(path.join(stepDir, safeName))
1060
-
1061
- outputctl.debug('DOWNLOADING')
1062
- const outStream = fs.createWriteStream(targetPath) as OutStream
1063
- outStream.on('error', () => {})
1064
- const [dlErr] = await tryCatch(
1065
- pipeline(got.stream(resultUrl, { signal: abortController.signal }), outStream),
1066
- )
1067
- if (dlErr) {
1068
- if (dlErr.name === 'AbortError') continue
1069
- outputctl.error(dlErr.message)
1070
- throw dlErr
1071
- }
1072
- }
1073
- } else if (!outIsDirectory && outPath != null) {
1074
- const first = allFiles[0]
1075
- const resultUrl = first ? getFileUrl(first.file) : null
1076
- if (resultUrl) {
1077
- outputctl.debug('DOWNLOADING')
1078
- const outStream = fs.createWriteStream(outPath) as OutStream
1079
- outStream.on('error', () => {})
1080
- outStream.mtime = outMtime
1081
- markSupersededOnFinish(outStream)
1082
-
1083
- const [dlErr] = await tryCatch(
1084
- pipeline(got.stream(resultUrl, { signal: abortController.signal }), outStream),
1085
- )
1086
- if (dlErr) {
1087
- if (dlErr.name !== 'AbortError') {
1088
- outputctl.error(dlErr.message)
1089
- throw dlErr
1090
- }
1091
- }
1092
- }
1093
- } else if (outIsDirectory && outPath != null) {
1094
- // Single-result, input-backed job: preserve existing file mapping in outdir.
1095
- const first = allFiles[0]
1096
- const resultUrl = first ? getFileUrl(first.file) : null
1097
- if (resultUrl) {
1098
- outputctl.debug('DOWNLOADING')
1099
- const outStream = fs.createWriteStream(outPath) as OutStream
1100
- outStream.on('error', () => {})
1101
- outStream.mtime = outMtime
1102
- markSupersededOnFinish(outStream)
1103
-
1104
- const [dlErr] = await tryCatch(
1105
- pipeline(got.stream(resultUrl, { signal: abortController.signal }), outStream),
1106
- )
1107
- if (dlErr) {
1108
- if (dlErr.name !== 'AbortError') {
1109
- outputctl.error(dlErr.message)
1110
- throw dlErr
1111
- }
1112
- }
1470
+ outputctl.debug(`COMPLETED ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`)
1471
+
1472
+ if (del) {
1473
+ for (const inputPath of inputPaths) {
1474
+ if (inputPath === stdinWithPath.path) {
1475
+ continue
1113
1476
  }
1477
+ await fsp.unlink(inputPath)
1114
1478
  }
1115
1479
  }
1480
+ return assembly
1481
+ }
1116
1482
 
1117
- outputctl.debug(`COMPLETED ${inPath ?? 'null'} ${outPath ?? 'null'}`)
1483
+ // Helper to process a single assembly job
1484
+ async function processAssemblyJob(
1485
+ inPath: string | null,
1486
+ outputPlan: OutputPlan | null,
1487
+ outputToken: number | null,
1488
+ ): Promise<unknown> {
1489
+ const files =
1490
+ inPath != null && inPath !== stdinWithPath.path
1491
+ ? {
1492
+ in: inPath,
1493
+ }
1494
+ : undefined
1495
+ const uploads =
1496
+ inPath === stdinWithPath.path
1497
+ ? {
1498
+ in: createInputUploadStream(inPath),
1499
+ }
1500
+ : undefined
1501
+
1502
+ return await executeAssemblyLifecycle({
1503
+ createOptions: createAssemblyOptions({ files, uploads }),
1504
+ inPath,
1505
+ inputPaths: inPath == null ? [] : [inPath],
1506
+ outputPlan,
1507
+ outputToken,
1508
+ })
1509
+ }
1118
1510
 
1119
- if (del && inPath) {
1120
- await fsp.unlink(inPath)
1121
- }
1122
- return assembly
1511
+ function handleEmitterError(err: Error): void {
1512
+ abortController.abort()
1513
+ queue.clear()
1514
+ outputctl.error(err)
1515
+ reject(err)
1123
1516
  }
1124
1517
 
1125
- if (singleAssembly) {
1126
- // Single-assembly mode: collect file paths, then create one assembly with all inputs
1127
- // We close streams immediately to avoid exhausting file descriptors with many files
1518
+ function runSingleAssemblyEmitter(): void {
1128
1519
  const collectedPaths: string[] = []
1520
+ let inputlessOutputPlan: OutputPlan | null = null
1129
1521
 
1130
1522
  emitter.on('job', (job: Job) => {
1131
- if (job.in != null) {
1132
- const inPath = (job.in as fs.ReadStream).path as string
1523
+ if (job.inputPath != null) {
1524
+ const inPath = job.inputPath
1133
1525
  outputctl.debug(`COLLECTING JOB ${inPath}`)
1134
1526
  collectedPaths.push(inPath)
1135
- // Close the stream immediately to avoid file descriptor exhaustion
1136
- ;(job.in as fs.ReadStream).destroy()
1137
- outputctl.debug(`STREAM CLOSED ${inPath}`)
1527
+ return
1138
1528
  }
1139
- })
1140
1529
 
1141
- emitter.on('error', (err: Error) => {
1142
- abortController.abort()
1143
- queue.clear()
1144
- outputctl.error(err)
1145
- reject(err)
1530
+ inputlessOutputPlan = job.out ?? null
1146
1531
  })
1147
1532
 
1148
1533
  emitter.on('end', async () => {
1149
- if (collectedPaths.length === 0) {
1150
- resolve({ results: [], hasFailures: false })
1534
+ if (
1535
+ await shouldSkipStaleOutput({
1536
+ inputPaths: collectedPaths,
1537
+ outputPath: resolvedOutput ?? null,
1538
+ outputPlanMtime: new Date(0),
1539
+ outputRootIsDirectory,
1540
+ reprocessStale,
1541
+ singleInputReference: 'input',
1542
+ })
1543
+ ) {
1544
+ outputctl.debug(`SKIPPED STALE SINGLE ASSEMBLY ${resolvedOutput ?? 'null'}`)
1545
+ resolve({ resultUrls, results: [], hasFailures: false })
1151
1546
  return
1152
1547
  }
1153
1548
 
1154
- // Build uploads object, creating fresh streams for each file
1549
+ // Preserve original basenames/extensions for filesystem uploads so the backend
1550
+ // can infer types like Markdown correctly.
1551
+ const files: Record<string, string> = {}
1155
1552
  const uploads: Record<string, Readable> = {}
1156
1553
  const inputPaths: string[] = []
1157
1554
  for (const inPath of collectedPaths) {
1158
1555
  const basename = path.basename(inPath)
1159
- let key = basename
1160
- let counter = 1
1161
- while (key in uploads) {
1162
- key = `${path.parse(basename).name}_${counter}${path.parse(basename).ext}`
1163
- counter++
1556
+ const collection = inPath === stdinWithPath.path ? uploads : files
1557
+ const key = await ensureUniqueCounterValue({
1558
+ initialValue: basename,
1559
+ isTaken: (candidate) => candidate in collection,
1560
+ nextValue: (counter) =>
1561
+ `${path.parse(basename).name}_${counter}${path.parse(basename).ext}`,
1562
+ reserve: () => {},
1563
+ scope: collection,
1564
+ })
1565
+ if (inPath === stdinWithPath.path) {
1566
+ uploads[key] = createInputUploadStream(inPath)
1567
+ } else {
1568
+ files[key] = inPath
1164
1569
  }
1165
- uploads[key] = fs.createReadStream(inPath)
1166
1570
  inputPaths.push(inPath)
1167
1571
  }
1168
1572
 
1169
- outputctl.debug(`Creating single assembly with ${Object.keys(uploads).length} files`)
1573
+ outputctl.debug(
1574
+ `Creating single assembly with ${Object.keys(files).length + Object.keys(uploads).length} files`,
1575
+ )
1170
1576
 
1171
1577
  try {
1172
1578
  const assembly = await queue.add(async () => {
1173
- const createOptions: CreateAssemblyOptions = {
1174
- params,
1175
- signal: abortController.signal,
1176
- }
1177
- if (Object.keys(uploads).length > 0) {
1178
- createOptions.uploads = uploads
1179
- }
1180
-
1181
- const result = await client.createAssembly(createOptions)
1182
- const assemblyId = result.assembly_id
1183
- if (!assemblyId) throw new Error('No assembly_id in result')
1184
-
1185
- const asm = await client.awaitAssemblyCompletion(assemblyId, {
1186
- signal: abortController.signal,
1187
- onAssemblyProgress: (status) => {
1188
- outputctl.debug(`Assembly status: ${status.ok}`)
1189
- },
1579
+ return await executeAssemblyLifecycle({
1580
+ createOptions: createAssemblyOptions({ files, uploads }),
1581
+ inPath: null,
1582
+ inputPaths,
1583
+ outputPlan:
1584
+ inputlessOutputPlan ??
1585
+ (resolvedOutput == null ? null : createOutputPlan(resolvedOutput, new Date(0))),
1586
+ outputToken: null,
1587
+ singleAssemblyMode: true,
1190
1588
  })
1191
-
1192
- if (asm.error || (asm.ok && asm.ok !== 'ASSEMBLY_COMPLETED')) {
1193
- const msg = `Assembly failed: ${asm.error || asm.message} (Status: ${asm.ok})`
1194
- outputctl.error(msg)
1195
- throw new Error(msg)
1196
- }
1197
-
1198
- // Download all results
1199
- if (asm.results && resolvedOutput != null) {
1200
- for (const [stepName, stepResults] of Object.entries(asm.results)) {
1201
- for (const stepResult of stepResults) {
1202
- const resultUrl =
1203
- (stepResult as { ssl_url?: string; url?: string }).ssl_url ?? stepResult.url
1204
- if (!resultUrl) continue
1205
-
1206
- let outPath: string
1207
- if (outstat?.isDirectory()) {
1208
- outPath = path.join(resolvedOutput, stepResult.name || `${stepName}_result`)
1209
- } else {
1210
- outPath = resolvedOutput
1211
- }
1212
-
1213
- outputctl.debug(`DOWNLOADING ${stepResult.name} to ${outPath}`)
1214
- const [dlErr] = await tryCatch(
1215
- pipeline(
1216
- got.stream(resultUrl, { signal: abortController.signal }),
1217
- fs.createWriteStream(outPath),
1218
- ),
1219
- )
1220
- if (dlErr) {
1221
- if (dlErr.name === 'AbortError') continue
1222
- outputctl.error(dlErr.message)
1223
- throw dlErr
1224
- }
1225
- }
1226
- }
1227
- }
1228
-
1229
- // Delete input files if requested
1230
- if (del) {
1231
- for (const inPath of inputPaths) {
1232
- await fsp.unlink(inPath)
1233
- }
1234
- }
1235
- return asm
1236
1589
  })
1237
1590
  results.push(assembly)
1238
1591
  } catch (err) {
@@ -1240,30 +1593,19 @@ export async function create(
1240
1593
  outputctl.error(err as Error)
1241
1594
  }
1242
1595
 
1243
- resolve({ results, hasFailures })
1596
+ resolve({ resultUrls, results, hasFailures })
1244
1597
  })
1245
- } else {
1246
- // Default mode: one assembly per file with p-queue concurrency limiting
1247
- emitter.on('job', (job: Job) => {
1248
- const inPath = job.in
1249
- ? (((job.in as fs.ReadStream).path as string | undefined) ?? null)
1250
- : null
1251
- const outPath = job.out?.path ?? null
1252
- const outMtime = job.out?.mtime
1253
- outputctl.debug(`GOT JOB ${inPath ?? 'null'} ${outPath ?? 'null'}`)
1254
-
1255
- // Close the original streams immediately - we'll create fresh ones when processing
1256
- if (job.in != null) {
1257
- ;(job.in as fs.ReadStream).destroy()
1258
- }
1259
- if (job.out != null) {
1260
- job.out.destroy()
1261
- }
1598
+ }
1262
1599
 
1263
- // Add job to queue - p-queue handles concurrency automatically
1600
+ function runPerFileEmitter(): void {
1601
+ emitter.on('job', (job: Job) => {
1602
+ const inPath = job.inputPath
1603
+ const outputPlan = job.out
1604
+ const outputToken = reserveWatchJobToken(outputPlan?.path ?? null)
1605
+ outputctl.debug(`GOT JOB ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`)
1264
1606
  queue
1265
1607
  .add(async () => {
1266
- const result = await processAssemblyJob(inPath, outPath, outMtime)
1608
+ const result = await processAssemblyJob(inPath, outputPlan, outputToken)
1267
1609
  if (result !== undefined) {
1268
1610
  results.push(result)
1269
1611
  }
@@ -1274,19 +1616,19 @@ export async function create(
1274
1616
  })
1275
1617
  })
1276
1618
 
1277
- emitter.on('error', (err: Error) => {
1278
- abortController.abort()
1279
- queue.clear()
1280
- outputctl.error(err)
1281
- reject(err)
1282
- })
1283
-
1284
1619
  emitter.on('end', async () => {
1285
- // Wait for all queued jobs to complete
1286
1620
  await queue.onIdle()
1287
- resolve({ results, hasFailures })
1621
+ resolve({ resultUrls, results, hasFailures })
1288
1622
  })
1289
1623
  }
1624
+
1625
+ emitter.on('error', handleEmitterError)
1626
+
1627
+ if (singleAssembly) {
1628
+ runSingleAssemblyEmitter()
1629
+ } else {
1630
+ runPerFileEmitter()
1631
+ }
1290
1632
  })
1291
1633
  }
1292
1634
 
@@ -1330,9 +1672,7 @@ export class AssembliesCreateCommand extends AuthenticatedCommand {
1330
1672
  description: 'Specify a template to use for these assemblies',
1331
1673
  })
1332
1674
 
1333
- inputs = Option.Array('--input,-i', {
1334
- description: 'Provide an input file or a directory',
1335
- })
1675
+ inputs = inputPathsOption()
1336
1676
 
1337
1677
  outputPath = Option.String('--output,-o', {
1338
1678
  description: 'Specify an output file or directory',
@@ -1342,30 +1682,19 @@ export class AssembliesCreateCommand extends AuthenticatedCommand {
1342
1682
  description: 'Set a template field (KEY=VAL)',
1343
1683
  })
1344
1684
 
1345
- watch = Option.Boolean('--watch,-w', false, {
1346
- description: 'Watch inputs for changes',
1347
- })
1685
+ watch = watchOption()
1348
1686
 
1349
- recursive = Option.Boolean('--recursive,-r', false, {
1350
- description: 'Enumerate input directories recursively',
1351
- })
1687
+ recursive = recursiveOption()
1352
1688
 
1353
- deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, {
1354
- description: 'Delete input files after they are processed',
1355
- })
1689
+ deleteAfterProcessing = deleteAfterProcessingOption()
1356
1690
 
1357
- reprocessStale = Option.Boolean('--reprocess-stale', false, {
1358
- description: 'Process inputs even if output is newer',
1359
- })
1691
+ reprocessStale = reprocessStaleOption()
1360
1692
 
1361
- singleAssembly = Option.Boolean('--single-assembly', false, {
1362
- description: 'Pass all input files to a single assembly instead of one assembly per file',
1363
- })
1693
+ singleAssembly = singleAssemblyOption()
1364
1694
 
1365
- concurrency = Option.String('--concurrency,-c', {
1366
- description: 'Maximum number of concurrent assemblies (default: 5)',
1367
- validator: t.isNumber(),
1368
- })
1695
+ concurrency = concurrencyOption()
1696
+
1697
+ printUrls = printUrlsOption()
1369
1698
 
1370
1699
  protected async run(): Promise<number | undefined> {
1371
1700
  if (!this.steps && !this.template) {
@@ -1378,10 +1707,6 @@ export class AssembliesCreateCommand extends AuthenticatedCommand {
1378
1707
  }
1379
1708
 
1380
1709
  const inputList = this.inputs ?? []
1381
- if (inputList.length === 0 && this.watch) {
1382
- this.output.error('assemblies create --watch requires at least one input')
1383
- return 1
1384
- }
1385
1710
 
1386
1711
  // Default to stdin only for `--steps` mode (common "pipe a file into a one-off assembly" use case).
1387
1712
  // For `--template` mode, templates may be inputless or use /http/import, so stdin should be explicit (`--input -`).
@@ -1389,27 +1714,26 @@ export class AssembliesCreateCommand extends AuthenticatedCommand {
1389
1714
  inputList.push('-')
1390
1715
  }
1391
1716
 
1392
- const fieldsMap: Record<string, string> = {}
1393
- for (const field of this.fields ?? []) {
1394
- const eqIndex = field.indexOf('=')
1395
- if (eqIndex === -1) {
1396
- this.output.error(`invalid argument for --field: '${field}'`)
1397
- return 1
1398
- }
1399
- const key = field.slice(0, eqIndex)
1400
- const value = field.slice(eqIndex + 1)
1401
- fieldsMap[key] = value
1717
+ const fieldsMap = parseTemplateFieldAssignments(this.output, this.fields)
1718
+ if (this.fields != null && fieldsMap == null) {
1719
+ return 1
1402
1720
  }
1403
1721
 
1404
- if (this.singleAssembly && this.watch) {
1405
- this.output.error('--single-assembly cannot be used with --watch')
1722
+ const sharedValidationError = validateSharedFileProcessingOptions({
1723
+ explicitInputCount: this.inputs?.length ?? 0,
1724
+ singleAssembly: this.singleAssembly,
1725
+ watch: this.watch,
1726
+ watchRequiresInputsMessage: 'assemblies create --watch requires at least one input',
1727
+ })
1728
+ if (sharedValidationError != null) {
1729
+ this.output.error(sharedValidationError)
1406
1730
  return 1
1407
1731
  }
1408
1732
 
1409
- const { hasFailures } = await create(this.output, this.client, {
1733
+ const { hasFailures, resultUrls } = await create(this.output, this.client, {
1410
1734
  steps: this.steps,
1411
1735
  template: this.template,
1412
- fields: fieldsMap,
1736
+ fields: fieldsMap ?? {},
1413
1737
  watch: this.watch,
1414
1738
  recursive: this.recursive,
1415
1739
  inputs: inputList,
@@ -1417,8 +1741,11 @@ export class AssembliesCreateCommand extends AuthenticatedCommand {
1417
1741
  del: this.deleteAfterProcessing,
1418
1742
  reprocessStale: this.reprocessStale,
1419
1743
  singleAssembly: this.singleAssembly,
1420
- concurrency: this.concurrency,
1744
+ concurrency: this.concurrency == null ? undefined : Number(this.concurrency),
1421
1745
  })
1746
+ if (this.printUrls) {
1747
+ printResultUrls(this.output, resultUrls)
1748
+ }
1422
1749
  return hasFailures ? 1 : undefined
1423
1750
  }
1424
1751
  }
@@ -1567,20 +1894,13 @@ export class AssembliesReplayCommand extends AuthenticatedCommand {
1567
1894
  assemblyIds = Option.Rest({ required: 1 })
1568
1895
 
1569
1896
  protected async run(): Promise<number | undefined> {
1570
- const fieldsMap: Record<string, string> = {}
1571
- for (const field of this.fields ?? []) {
1572
- const eqIndex = field.indexOf('=')
1573
- if (eqIndex === -1) {
1574
- this.output.error(`invalid argument for --field: '${field}'`)
1575
- return 1
1576
- }
1577
- const key = field.slice(0, eqIndex)
1578
- const value = field.slice(eqIndex + 1)
1579
- fieldsMap[key] = value
1897
+ const fieldsMap = parseTemplateFieldAssignments(this.output, this.fields)
1898
+ if (this.fields != null && fieldsMap == null) {
1899
+ return 1
1580
1900
  }
1581
1901
 
1582
1902
  await replay(this.output, this.client, {
1583
- fields: fieldsMap,
1903
+ fields: fieldsMap ?? {},
1584
1904
  reparse: this.reparseTemplate,
1585
1905
  steps: this.steps,
1586
1906
  notify_url: this.notifyUrl,