transloadit 6.4.0 → 6.5.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 (68) hide show
  1. package/README.md +30 -5
  2. package/dist/Transloadit.d.ts.map +1 -1
  3. package/dist/Transloadit.js +61 -5
  4. package/dist/Transloadit.js.map +1 -1
  5. package/dist/alphalib/types/builtinTemplates.d.ts +15 -3
  6. package/dist/alphalib/types/builtinTemplates.d.ts.map +1 -1
  7. package/dist/alphalib/types/builtinTemplates.js +23 -3
  8. package/dist/alphalib/types/builtinTemplates.js.map +1 -1
  9. package/dist/cli/OutputCtl.d.ts +1 -0
  10. package/dist/cli/OutputCtl.d.ts.map +1 -1
  11. package/dist/cli/OutputCtl.js +1 -1
  12. package/dist/cli/OutputCtl.js.map +1 -1
  13. package/dist/cli/commands/BaseCommand.d.ts +3 -2
  14. package/dist/cli/commands/BaseCommand.d.ts.map +1 -1
  15. package/dist/cli/commands/BaseCommand.js +27 -3
  16. package/dist/cli/commands/BaseCommand.js.map +1 -1
  17. package/dist/cli/commands/assemblies.d.ts +21 -1
  18. package/dist/cli/commands/assemblies.d.ts.map +1 -1
  19. package/dist/cli/commands/assemblies.js +584 -91
  20. package/dist/cli/commands/assemblies.js.map +1 -1
  21. package/dist/cli/commands/templates.d.ts +1 -1
  22. package/dist/cli/commands/templates.d.ts.map +1 -1
  23. package/dist/cli/commands/templates.js +57 -67
  24. package/dist/cli/commands/templates.js.map +1 -1
  25. package/dist/cli/errors.d.ts +8 -0
  26. package/dist/cli/errors.d.ts.map +1 -0
  27. package/dist/cli/errors.js +28 -0
  28. package/dist/cli/errors.js.map +1 -0
  29. package/dist/cli/helpers.d.ts.map +1 -1
  30. package/dist/cli/helpers.js +22 -0
  31. package/dist/cli/helpers.js.map +1 -1
  32. package/dist/cli/intentRuntime.d.ts +1 -0
  33. package/dist/cli/intentRuntime.d.ts.map +1 -1
  34. package/dist/cli/intentRuntime.js +9 -3
  35. package/dist/cli/intentRuntime.js.map +1 -1
  36. package/dist/cli/resultFiles.d.ts +1 -0
  37. package/dist/cli/resultFiles.d.ts.map +1 -1
  38. package/dist/cli/resultFiles.js.map +1 -1
  39. package/dist/cli/resultUrls.d.ts +2 -1
  40. package/dist/cli/resultUrls.d.ts.map +1 -1
  41. package/dist/cli/resultUrls.js +6 -3
  42. package/dist/cli/resultUrls.js.map +1 -1
  43. package/dist/cli/stepsInput.d.ts.map +1 -1
  44. package/dist/cli/stepsInput.js +17 -4
  45. package/dist/cli/stepsInput.js.map +1 -1
  46. package/dist/cli/types.d.ts +66 -0
  47. package/dist/cli/types.d.ts.map +1 -1
  48. package/dist/cli/types.js +29 -0
  49. package/dist/cli/types.js.map +1 -1
  50. package/dist/tus.d.ts +7 -2
  51. package/dist/tus.d.ts.map +1 -1
  52. package/dist/tus.js +58 -4
  53. package/dist/tus.js.map +1 -1
  54. package/package.json +1 -1
  55. package/src/Transloadit.ts +65 -4
  56. package/src/alphalib/types/builtinTemplates.ts +33 -3
  57. package/src/cli/OutputCtl.ts +2 -1
  58. package/src/cli/commands/BaseCommand.ts +29 -7
  59. package/src/cli/commands/assemblies.ts +775 -118
  60. package/src/cli/commands/templates.ts +93 -98
  61. package/src/cli/errors.ts +34 -0
  62. package/src/cli/helpers.ts +21 -0
  63. package/src/cli/intentRuntime.ts +10 -3
  64. package/src/cli/resultFiles.ts +1 -0
  65. package/src/cli/resultUrls.ts +11 -3
  66. package/src/cli/stepsInput.ts +19 -4
  67. package/src/cli/types.ts +35 -0
  68. package/src/tus.ts +72 -5
@@ -1,5 +1,6 @@
1
1
  import type { Readable } from 'node:stream'
2
2
 
3
+ import type { AssemblyStatus } from '../../alphalib/types/assemblyStatus.ts'
3
4
  import type { StepsInput } from '../../alphalib/types/template.ts'
4
5
  import type { CreateAssemblyParams, ReplayAssemblyParams } from '../../apiTypes.ts'
5
6
  import type { LintFatalLevel } from '../../lintAssemblyInstructions.ts'
@@ -11,6 +12,7 @@ import type {
11
12
  import type { IOutputCtl } from '../OutputCtl.ts'
12
13
  import type { NormalizedAssemblyResultFile, NormalizedAssemblyResults } from '../resultFiles.ts'
13
14
  import type { ResultUrlRow } from '../resultUrls.ts'
15
+ import type { AssemblySummary } from '../types.ts'
14
16
 
15
17
  import { randomUUID } from 'node:crypto'
16
18
  import EventEmitter from 'node:events'
@@ -24,14 +26,16 @@ import { setTimeout as delay } from 'node:timers/promises'
24
26
  import { promisify } from 'node:util'
25
27
 
26
28
  import { Command, Option } from 'clipanion'
27
- import got from 'got'
29
+ import got, { RequestError } from 'got'
28
30
  import PQueue from 'p-queue'
29
31
  import * as t from 'typanion'
30
32
  import { z } from 'zod'
31
33
 
32
34
  import { formatLintIssue } from '../../alphalib/assembly-linter.lang.en.ts'
33
35
  import { tryCatch } from '../../alphalib/tryCatch.ts'
36
+ import { assemblyStatusResultSchema } from '../../alphalib/types/assemblyStatus.ts'
34
37
  import { ensureUniqueCounterValue } from '../../ensureUniqueCounter.ts'
38
+ import { prepareInputFiles } from '../../inputFiles.ts'
35
39
  import { lintAssemblyInstructions } from '../../lintAssemblyInstructions.ts'
36
40
  import { lintingExamples } from '../docs/assemblyLintingExamples.ts'
37
41
  import {
@@ -49,7 +53,7 @@ import { formatAPIError, readCliInput } from '../helpers.ts'
49
53
  import { normalizeAssemblyResults } from '../resultFiles.ts'
50
54
  import { collectNormalizedResultUrlRows, printResultUrls } from '../resultUrls.ts'
51
55
  import { parseStepsInputJson, readStepsInputFile } from '../stepsInput.ts'
52
- import { ensureError, isErrnoException } from '../types.ts'
56
+ import { assemblySummarySchema, ensureError, isErrnoException } from '../types.ts'
53
57
  import { AuthenticatedCommand, UnauthenticatedCommand } from './BaseCommand.ts'
54
58
 
55
59
  // --- From assemblies.ts: Schemas and interfaces ---
@@ -194,11 +198,7 @@ export async function get(
194
198
  ): Promise<void> {
195
199
  for (const assembly of assemblies) {
196
200
  await delay(1000)
197
- const [err, result] = await tryCatch(client.getAssembly(assembly))
198
- if (err) {
199
- output.error(formatAPIError(err))
200
- throw ensureError(err)
201
- }
201
+ const result = await client.getAssembly(assembly)
202
202
  output.print(result, result)
203
203
  }
204
204
  }
@@ -518,8 +518,10 @@ async function createExistingPathOutputPlan(outputPath: string | undefined): Pro
518
518
  return createOutputPlan(outputPath, stats?.mtime ?? new Date(0))
519
519
  }
520
520
 
521
- function dirProvider(output: string): OutputPlanProvider {
522
- return async (inpath, indir = process.cwd()) => {
521
+ function dirProvider(output: string, transientInputPaths?: Set<string>): OutputPlanProvider {
522
+ const namesByInput = new Map<string, string>()
523
+ const usedNames = new Set<string>()
524
+ return async (inpath, indir) => {
523
525
  if (inpath == null) {
524
526
  return await createExistingPathOutputPlan(output)
525
527
  }
@@ -527,8 +529,22 @@ function dirProvider(output: string): OutputPlanProvider {
527
529
  throw new Error('You must provide an input to output to a directory')
528
530
  }
529
531
 
530
- let relpath = path.relative(indir, inpath)
531
- relpath = relpath.replace(/^(\.\.\/)+/, '')
532
+ const inputKey = path.resolve(inpath)
533
+ let relpath = namesByInput.get(inputKey)
534
+ if (relpath == null) {
535
+ const requestedPath = transientInputPaths?.has(inputKey)
536
+ ? path.basename(inpath)
537
+ : path.relative(indir ?? process.cwd(), inpath).replace(/^(\.\.\/)+/, '')
538
+ const parsed = path.parse(requestedPath)
539
+ relpath = await ensureUniqueCounterValue({
540
+ initialValue: requestedPath,
541
+ isTaken: (candidate) => usedNames.has(candidate),
542
+ reserve: (candidate) => usedNames.add(candidate),
543
+ nextValue: (counter) => path.join(parsed.dir, `${parsed.name}__${counter}${parsed.ext}`),
544
+ scope: usedNames,
545
+ })
546
+ namesByInput.set(inputKey, relpath)
547
+ }
532
548
  const outpath = path.join(output, relpath)
533
549
  return await createExistingPathOutputPlan(outpath)
534
550
  }
@@ -552,6 +568,7 @@ async function downloadResultToFile(
552
568
  resultUrl: string,
553
569
  outPath: string,
554
570
  signal: AbortSignal,
571
+ isCurrent: () => boolean,
555
572
  ): Promise<void> {
556
573
  await fsp.mkdir(path.dirname(outPath), { recursive: true })
557
574
 
@@ -568,6 +585,10 @@ async function downloadResultToFile(
568
585
  throw dlErr
569
586
  }
570
587
 
588
+ if (!isCurrent()) {
589
+ await fsp.rm(tempPath, { force: true })
590
+ return
591
+ }
571
592
  await fsp.rename(tempPath, outPath)
572
593
  }
573
594
 
@@ -608,71 +629,461 @@ async function downloadResultToStdout(resultUrl: string, signal: AbortSignal): P
608
629
  await pipeline(got.stream(resultUrl, { signal }), stdoutStream)
609
630
  }
610
631
 
611
- function sanitizeResultName(value: string): string {
632
+ function sanitizeResultName(value: string, fallback?: string): string {
612
633
  const base = path.basename(value)
613
- return base.replaceAll('\\', '_').replaceAll('/', '_').replaceAll('\u0000', '')
634
+ const name = base.replaceAll('\\', '_').replaceAll('/', '_').replaceAll('\u0000', '')
635
+ if (name === '' || name === '.' || name === '..') {
636
+ if (fallback != null) return sanitizeResultName(fallback)
637
+ throw new Error('Assembly result has an unsafe name')
638
+ }
639
+ return name
640
+ }
641
+
642
+ interface OutputNameSensitivity {
643
+ caseSensitive: boolean
644
+ normalizationSensitive: boolean
645
+ }
646
+
647
+ interface DownloadPathReservations extends OutputNameSensitivity {
648
+ allocations: PQueue
649
+ bundleCommits: PQueue
650
+ directories: Set<string>
651
+ ownersByPath: Map<string, string>
652
+ pathsByOwner: Map<string, string>
614
653
  }
615
654
 
616
- async function ensureUniquePath(targetPath: string, reservedPaths: Set<string>): Promise<string> {
655
+ async function outputNameSensitivity(root: string): Promise<OutputNameSensitivity> {
656
+ await fsp.mkdir(root, { recursive: true })
657
+ const probe = await fsp.mkdtemp(path.join(root, '.transloadit-names-'))
658
+ async function distinguishesNames(first: string, second: string): Promise<boolean> {
659
+ await fsp.writeFile(path.join(probe, first), '')
660
+ const [error] = await tryCatch(fsp.lstat(path.join(probe, second)))
661
+ if (error && (!isErrnoException(error) || error.code !== 'ENOENT')) throw error
662
+ return error != null
663
+ }
664
+ try {
665
+ // Assume this tree shares the root's naming rules; nested filesystems are tracked in #615.
666
+ return {
667
+ caseSensitive: await distinguishesNames('case', 'CASE'),
668
+ normalizationSensitive: await distinguishesNames('\u00e9', 'e\u0301'),
669
+ }
670
+ } finally {
671
+ await fsp.rm(probe, { recursive: true, force: true })
672
+ }
673
+ }
674
+
675
+ function downloadPathKey(target: string, reservedPaths: DownloadPathReservations): string {
676
+ const absolutePath = path.resolve(target)
677
+ const folded = reservedPaths.caseSensitive ? absolutePath : absolutePath.toLowerCase()
678
+ return reservedPaths.normalizationSensitive ? folded : folded.normalize('NFC')
679
+ }
680
+
681
+ function hasReservedFileAncestor(target: string, reservedPaths: DownloadPathReservations): boolean {
682
+ let current = downloadPathKey(target, reservedPaths)
683
+ while (true) {
684
+ if (reservedPaths.ownersByPath.has(current) && !reservedPaths.directories.has(current)) {
685
+ return true
686
+ }
687
+ const parent = path.dirname(current)
688
+ if (parent === current) return false
689
+ current = parent
690
+ }
691
+ }
692
+
693
+ function reserveDownloadPath(
694
+ target: string,
695
+ owner: string,
696
+ reservedPaths: DownloadPathReservations,
697
+ directory = false,
698
+ ): void {
699
+ const key = downloadPathKey(target, reservedPaths)
700
+ reservedPaths.ownersByPath.set(key, owner)
701
+ let current = directory ? key : path.dirname(key)
702
+ while (true) {
703
+ reservedPaths.directories.add(current)
704
+ const parent = path.dirname(current)
705
+ if (parent === current) return
706
+ current = parent
707
+ }
708
+ }
709
+
710
+ async function ensureUniquePath(
711
+ targetPath: string,
712
+ reservedPaths: DownloadPathReservations,
713
+ owner: string,
714
+ directory = false,
715
+ overwriteExistingFile = false,
716
+ ): Promise<string> {
717
+ const selectedPath = reservedPaths.pathsByOwner.get(owner)
718
+ if (selectedPath != null) return selectedPath
617
719
  const parsed = path.parse(targetPath)
618
720
  return await ensureUniqueCounterValue({
619
721
  initialValue: targetPath,
620
- isTaken: async (candidate) => {
621
- if (reservedPaths.has(candidate)) {
622
- return true
623
- }
624
-
625
- const [statErr] = await tryCatch(fsp.stat(candidate))
626
- return statErr == null
627
- },
722
+ isTaken: async (candidate) =>
723
+ await isDownloadPathTaken(
724
+ candidate,
725
+ reservedPaths,
726
+ owner,
727
+ directory,
728
+ overwriteExistingFile && candidate === targetPath,
729
+ ),
628
730
  reserve: (candidate) => {
629
- reservedPaths.add(candidate)
731
+ reserveDownloadPath(candidate, owner, reservedPaths, directory)
732
+ reservedPaths.pathsByOwner.set(owner, candidate)
630
733
  },
631
- nextValue: (counter) => path.join(parsed.dir, `${parsed.name}__${counter}${parsed.ext}`),
734
+ nextValue: (counter) =>
735
+ path.join(
736
+ parsed.dir,
737
+ directory ? `${parsed.base}__${counter}` : `${parsed.name}__${counter}${parsed.ext}`,
738
+ ),
632
739
  scope: reservedPaths,
633
740
  })
634
741
  }
635
742
 
636
- function getResultFileName(file: NormalizedAssemblyResultFile): string {
637
- return sanitizeResultName(file.name)
743
+ const resultPathSchema = z.object({
744
+ meta: z.object({ relative_path: z.string().nullish() }).nullish(),
745
+ })
746
+
747
+ function getResultRelativePath(file: NormalizedAssemblyResultFile): string | null {
748
+ const parsed = resultPathSchema.safeParse(file.file)
749
+ if (!parsed.success) throw new Error('Assembly result has invalid relative path metadata')
750
+ const relativePath = parsed.data.meta?.relative_path
751
+ if (relativePath == null) return null
752
+ const name = sanitizeResultName(file.name)
753
+ // Adaptive video results use an empty directory for the master playlist.
754
+ if (relativePath === '') return name
755
+
756
+ const normalizedPath = relativePath.replaceAll('\\', '/')
757
+ if (
758
+ normalizedPath.includes('\u0000') ||
759
+ path.win32.isAbsolute(normalizedPath) ||
760
+ /^[a-z]:/i.test(normalizedPath) ||
761
+ normalizedPath.split('/').some((part) => part === '' || part === '.' || part === '..')
762
+ ) {
763
+ throw new Error('Assembly result has an unsafe relative path')
764
+ }
765
+
766
+ return path.join(normalizedPath, name)
638
767
  }
639
768
 
640
769
  interface AssemblyDownloadTarget {
770
+ bundleRoot?: string
641
771
  resultUrl: string
642
772
  targetPath: string | null
643
773
  }
644
774
 
775
+ interface BundleDownloadTarget {
776
+ resultUrl: string
777
+ targetPath: string
778
+ }
779
+
780
+ interface StagedBundleDownloadTarget extends BundleDownloadTarget {
781
+ stagingPath: string
782
+ }
783
+
784
+ async function downloadResultBundle(
785
+ targets: BundleDownloadTarget[],
786
+ signal: AbortSignal,
787
+ isCurrent: () => boolean,
788
+ commits: PQueue,
789
+ onDownload: () => void,
790
+ ): Promise<void> {
791
+ const stagingDirs = new Map<string, string>()
792
+ const staged: StagedBundleDownloadTarget[] = []
793
+ try {
794
+ for (const [index, target] of targets.entries()) {
795
+ if (!isCurrent()) return
796
+ const parent = path.dirname(target.targetPath)
797
+ await fsp.mkdir(parent, { recursive: true })
798
+ let stagingDir = stagingDirs.get(parent)
799
+ if (stagingDir == null) {
800
+ // Stage beside each destination so publication also works across nested filesystems.
801
+ stagingDir = await fsp.mkdtemp(path.join(parent, '.transloadit-bundle-'))
802
+ stagingDirs.set(parent, stagingDir)
803
+ }
804
+ const stagingPath = path.join(stagingDir, String(index))
805
+ staged.push({ ...target, stagingPath })
806
+ onDownload()
807
+ await downloadResultToFile(target.resultUrl, stagingPath, signal, () => true)
808
+ }
809
+ await commits.add(async () => {
810
+ if (!isCurrent()) return
811
+ // Finish publication once it starts, even if superseded. A failed newer job must not
812
+ // strand this playlist without its segments, and newer commits must wait for this one.
813
+ for (const { stagingPath, targetPath } of staged) {
814
+ await fsp.mkdir(path.dirname(targetPath), { recursive: true })
815
+ await fsp.rename(stagingPath, targetPath)
816
+ }
817
+ })
818
+ } finally {
819
+ await Promise.all(
820
+ [...stagingDirs.values()].map(
821
+ async (dir) => await fsp.rm(dir, { recursive: true, force: true }),
822
+ ),
823
+ )
824
+ }
825
+ }
826
+
827
+ async function assertNoOutputSymlinks(root: string, target: string): Promise<void> {
828
+ // Reject preexisting descendant symlinks; another process can still race filesystem checks.
829
+ const relativePath = path.relative(root, target)
830
+ // The caller explicitly selected the root; only descendants come from result/input metadata.
831
+ if (relativePath === '') return
832
+ if (
833
+ relativePath.startsWith(`..${path.sep}`) ||
834
+ relativePath === '..' ||
835
+ path.isAbsolute(relativePath)
836
+ ) {
837
+ throw new Error('Assembly result path escapes the output directory')
838
+ }
839
+
840
+ let current = root
841
+ for (const part of relativePath.split(path.sep)) {
842
+ current = path.join(current, part)
843
+ const [error, stats] = await tryCatch(fsp.lstat(current))
844
+ if (error) {
845
+ if (isErrnoException(error) && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return
846
+ throw error
847
+ }
848
+ if (stats.isSymbolicLink()) {
849
+ throw new Error('Assembly result path contains a symbolic link')
850
+ }
851
+ }
852
+ }
853
+
854
+ async function isDownloadPathTaken(
855
+ target: string,
856
+ reservedPaths: DownloadPathReservations,
857
+ owner: string,
858
+ directory = false,
859
+ overwriteExistingFile = false,
860
+ ): Promise<boolean> {
861
+ const key = downloadPathKey(target, reservedPaths)
862
+ if (directory && reservedPaths.directories.has(key)) return false
863
+ const existingOwner = reservedPaths.ownersByPath.get(key)
864
+ if (existingOwner != null) return existingOwner !== owner
865
+ if (reservedPaths.directories.has(key) || hasReservedFileAncestor(target, reservedPaths)) {
866
+ return true
867
+ }
868
+ const [error, stats] = await tryCatch(fsp.lstat(target))
869
+ if (isErrnoException(error) && error.code === 'ENOTDIR') return true
870
+ if (error && (!isErrnoException(error) || error.code !== 'ENOENT')) throw error
871
+ if (error != null) return false
872
+ if (directory) return !stats?.isDirectory()
873
+ return !overwriteExistingFile || !stats?.isFile()
874
+ }
875
+
876
+ async function prepareDownloadDirectory(
877
+ root: string,
878
+ target: string,
879
+ reservedPaths: DownloadPathReservations,
880
+ ): Promise<void> {
881
+ await assertNoOutputSymlinks(root, target)
882
+ if (hasReservedFileAncestor(target, reservedPaths)) {
883
+ throw new Error('Output directory conflicts with a result file')
884
+ }
885
+ await fsp.mkdir(target, { recursive: true })
886
+ }
887
+
645
888
  const STALE_OUTPUT_GRACE_MS = 1000
646
889
 
647
890
  function isMeaningfullyNewer(newer: Date, older: Date): boolean {
648
891
  return newer.getTime() - older.getTime() > STALE_OUTPUT_GRACE_MS
649
892
  }
650
893
 
894
+ interface DirectoryResultFile {
895
+ file: NormalizedAssemblyResultFile
896
+ relativePath: string | null
897
+ }
898
+
899
+ interface DirectoryResultGroup {
900
+ directoryName: string | null
901
+ files: DirectoryResultFile[]
902
+ ownerKey: string | null
903
+ stepName: string
904
+ }
905
+
906
+ const resultSourceSchema = assemblyStatusResultSchema.pick({
907
+ original_basename: true,
908
+ original_id: true,
909
+ original_name: true,
910
+ original_path: true,
911
+ })
912
+
913
+ function groupDirectoryResults(allFiles: NormalizedAssemblyResultFile[]): DirectoryResultGroup[] {
914
+ const steps = new Map<string, DirectoryResultFile[]>()
915
+ for (const file of allFiles) {
916
+ const files = steps.get(file.stepName) ?? []
917
+ files.push({ file, relativePath: getResultRelativePath(file) })
918
+ steps.set(file.stepName, files)
919
+ }
920
+
921
+ const groups: DirectoryResultGroup[] = []
922
+ for (const [stepName, stepFiles] of steps) {
923
+ if (!stepFiles.some((file) => file.relativePath != null)) {
924
+ groups.push({ directoryName: null, files: stepFiles, ownerKey: null, stepName })
925
+ continue
926
+ }
927
+
928
+ const sources = new Map<
929
+ string,
930
+ { identity: string; name: string | null; files: DirectoryResultFile[] }
931
+ >()
932
+ for (const file of stepFiles) {
933
+ const parsed = resultSourceSchema.safeParse(file.file.file)
934
+ const source = parsed.success ? parsed.data : undefined
935
+ const sourceId = JSON.stringify(source?.original_id ?? null)
936
+ const name = source?.original_name ?? source?.original_basename ?? null
937
+ const group = sources.get(sourceId) ?? {
938
+ // original_path can be a shared directory, so the source filename is also required.
939
+ identity: JSON.stringify([source?.original_path ?? null, name]),
940
+ name,
941
+ files: [],
942
+ }
943
+ group.files.push(file)
944
+ sources.set(sourceId, group)
945
+ }
946
+
947
+ const sourceOccurrences = new Map<string, number>()
948
+ for (const { identity, name, files } of sources.values()) {
949
+ const occurrence = sourceOccurrences.get(identity) ?? 0
950
+ sourceOccurrences.set(identity, occurrence + 1)
951
+ // Ordinary files can be renamed independently, even when they share an HLS source.
952
+ for (const linked of [true, false]) {
953
+ const groupFiles = files.filter(({ relativePath }) => (relativePath != null) === linked)
954
+ if (groupFiles.length === 0) continue
955
+ groups.push({
956
+ directoryName:
957
+ sources.size > 1
958
+ ? sanitizeResultName(path.parse(name ?? stepName).name, stepName)
959
+ : null,
960
+ files: groupFiles,
961
+ // Upload IDs change between watch runs. Paths and names keep source ownership stable
962
+ // even when results arrive in a different order.
963
+ ownerKey: JSON.stringify([identity, occurrence, linked]),
964
+ stepName,
965
+ })
966
+ }
967
+ }
968
+ }
969
+ return groups
970
+ }
971
+
651
972
  async function buildDirectoryDownloadTargets({
652
973
  allFiles,
653
974
  baseDir,
654
975
  groupByStep,
976
+ owner,
977
+ outputRoot,
978
+ overwritePath,
655
979
  reservedPaths,
656
980
  }: {
657
981
  allFiles: NormalizedAssemblyResultFile[]
658
982
  baseDir: string
659
983
  groupByStep: boolean
660
- reservedPaths: Set<string>
984
+ owner: string
985
+ outputRoot: string
986
+ overwritePath?: string
987
+ reservedPaths: DownloadPathReservations
661
988
  }): Promise<AssemblyDownloadTarget[]> {
662
- await fsp.mkdir(baseDir, { recursive: true })
989
+ await prepareDownloadDirectory(outputRoot, baseDir, reservedPaths)
663
990
 
664
991
  const targets: AssemblyDownloadTarget[] = []
665
- for (const resultFile of allFiles) {
666
- const targetDir = groupByStep ? path.join(baseDir, resultFile.stepName) : baseDir
667
- await fsp.mkdir(targetDir, { recursive: true })
668
-
669
- targets.push({
670
- resultUrl: resultFile.url,
671
- targetPath: await ensureUniquePath(
672
- path.join(targetDir, getResultFileName(resultFile)),
673
- reservedPaths,
674
- ),
675
- })
992
+ for (const { directoryName, files, ownerKey, stepName } of groupDirectoryResults(allFiles)) {
993
+ const safeStepName = sanitizeResultName(stepName)
994
+ let targetDir = groupByStep ? path.join(baseDir, safeStepName) : baseDir
995
+ if (directoryName != null) targetDir = path.join(targetDir, directoryName)
996
+ const linked = files.some((file) => file.relativePath != null)
997
+ const groupOwner = JSON.stringify([owner, stepName, ownerKey])
998
+ const selectedGroupDir = linked ? reservedPaths.pathsByOwner.get(groupOwner) : undefined
999
+ if (selectedGroupDir != null) targetDir = selectedGroupDir
1000
+ const names = files.map(
1001
+ ({ file, relativePath }) => relativePath ?? sanitizeResultName(file.name),
1002
+ )
1003
+ if (
1004
+ linked &&
1005
+ new Set(names.map((name) => downloadPathKey(path.join(targetDir, name), reservedPaths)))
1006
+ .size !== names.length
1007
+ ) {
1008
+ // Merged Steps can hide distinct bundles sharing one upload (#619). Reject ambiguous
1009
+ // linked paths rather than guessing bundle boundaries and overwriting their contents.
1010
+ throw new Error('Assembly results have duplicate relative paths')
1011
+ }
1012
+
1013
+ for (const name of names) {
1014
+ await assertNoOutputSymlinks(outputRoot, path.join(targetDir, name))
1015
+ }
1016
+
1017
+ if (linked) {
1018
+ // Source folders share this parent, so changing their suffix cannot fix a blocked parent.
1019
+ if (directoryName != null) {
1020
+ await prepareDownloadDirectory(outputRoot, path.dirname(targetDir), reservedPaths)
1021
+ }
1022
+ const initialDir = targetDir
1023
+ // Selection and reservation share the same lock as ordinary result paths. A bundle must
1024
+ // move together on collisions, while later watch updates reuse its owned destinations.
1025
+ targetDir = await ensureUniqueCounterValue({
1026
+ initialValue: initialDir,
1027
+ isTaken: async (candidate) => {
1028
+ if (
1029
+ candidate !== initialDir &&
1030
+ (await isDownloadPathTaken(candidate, reservedPaths, groupOwner))
1031
+ )
1032
+ return true
1033
+ const collisions = await Promise.all(
1034
+ names.map((name) =>
1035
+ isDownloadPathTaken(path.join(candidate, name), reservedPaths, groupOwner),
1036
+ ),
1037
+ )
1038
+ const collides = collisions.some(Boolean)
1039
+ // Moving an already downloaded bundle would leave its previous playlist stale.
1040
+ if (collides && selectedGroupDir != null) {
1041
+ throw new Error('Assembly result path conflicts with an existing download')
1042
+ }
1043
+ return collides
1044
+ },
1045
+ reserve: (candidate) => {
1046
+ if (candidate !== initialDir)
1047
+ reserveDownloadPath(candidate, groupOwner, reservedPaths, true)
1048
+ for (const name of names) {
1049
+ reserveDownloadPath(path.join(candidate, name), groupOwner, reservedPaths)
1050
+ }
1051
+ reservedPaths.pathsByOwner.set(groupOwner, candidate)
1052
+ },
1053
+ nextValue: (counter) => {
1054
+ if (directoryName != null) {
1055
+ return path.join(path.dirname(initialDir), `${directoryName}__${counter}`)
1056
+ }
1057
+ const suffix = groupByStep ? counter : counter - 1
1058
+ return path.join(baseDir, suffix === 0 ? safeStepName : `${safeStepName}__${suffix}`)
1059
+ },
1060
+ scope: reservedPaths,
1061
+ })
1062
+ }
1063
+
1064
+ await prepareDownloadDirectory(outputRoot, targetDir, reservedPaths)
1065
+ const nameOccurrences = new Map<string, number>()
1066
+ for (const [index, { file }] of files.entries()) {
1067
+ const name = names[index]
1068
+ const occurrence = nameOccurrences.get(name) ?? 0
1069
+ nameOccurrences.set(name, occurrence + 1)
1070
+ const targetPath = path.join(targetDir, name)
1071
+ const selectedPath = linked
1072
+ ? targetPath
1073
+ : await ensureUniquePath(
1074
+ targetPath,
1075
+ reservedPaths,
1076
+ JSON.stringify([groupOwner, name, occurrence]),
1077
+ false,
1078
+ overwritePath != null && path.resolve(targetPath) === path.resolve(overwritePath),
1079
+ )
1080
+ await assertNoOutputSymlinks(outputRoot, selectedPath)
1081
+ targets.push({
1082
+ bundleRoot: linked ? targetDir : undefined,
1083
+ resultUrl: file.url,
1084
+ targetPath: selectedPath,
1085
+ })
1086
+ }
676
1087
  }
677
1088
 
678
1089
  return targets
@@ -700,6 +1111,7 @@ async function resolveResultDownloadTargets({
700
1111
  outputPath,
701
1112
  outputRoot,
702
1113
  outputRootIsDirectory,
1114
+ owner,
703
1115
  reservedPaths,
704
1116
  singleAssembly,
705
1117
  }: {
@@ -711,26 +1123,33 @@ async function resolveResultDownloadTargets({
711
1123
  outputPath: string | null
712
1124
  outputRoot: string
713
1125
  outputRootIsDirectory: boolean
714
- reservedPaths: Set<string>
1126
+ owner: string
1127
+ reservedPaths: DownloadPathReservations
715
1128
  singleAssembly?: boolean
716
1129
  }): Promise<AssemblyDownloadTarget[]> {
717
1130
  const { allFiles, entries } = normalizedResults
1131
+ const linked = allFiles.some((file) => getResultRelativePath(file) != null)
1132
+ // Ordinary CLI outputs retain their input/Step layout. HLS uses linked result paths so
1133
+ // playlist references remain reachable; moving those formerly broken outputs is intentional.
1134
+ const keepInputStepLayout =
1135
+ outputMode !== 'directory' && inPath != null && allFiles.length > 1 && !linked
718
1136
  const shouldGroupByInput =
719
- !singleAssembly && inPath != null && (hasDirectoryInput || inputs.length > 1)
1137
+ !singleAssembly &&
1138
+ inPath != null &&
1139
+ (hasDirectoryInput ||
1140
+ (inputs.length > 1 && (allFiles.length > 1 || outputMode === 'directory')))
720
1141
 
721
1142
  const resolveDirectoryBaseDir = (): string => {
722
- if (!shouldGroupByInput || inPath == null) {
723
- return outputRoot
1143
+ if (outputPath != null && (keepInputStepLayout || hasDirectoryInput)) {
1144
+ return allFiles.length === 1 && outputMode !== 'directory'
1145
+ ? path.dirname(outputPath)
1146
+ : path.join(path.dirname(outputPath), path.parse(outputPath).name)
724
1147
  }
725
-
726
- if (hasDirectoryInput && outputPath != null) {
727
- const mappedRelative = path.relative(outputRoot, outputPath)
728
- const mappedDir = path.dirname(mappedRelative)
729
- const mappedStem = path.parse(mappedRelative).name
730
- return path.join(outputRoot, mappedDir === '.' ? '' : mappedDir, mappedStem)
1148
+ if (!linked && outputMode !== 'directory' && inPath != null && outputPath != null) {
1149
+ return path.dirname(outputPath)
731
1150
  }
732
-
733
- return path.join(outputRoot, path.parse(path.basename(inPath)).name)
1151
+ if (!shouldGroupByInput || inPath == null) return outputRoot
1152
+ return path.join(outputRoot, path.parse(path.basename(outputPath ?? inPath)).name)
734
1153
  }
735
1154
 
736
1155
  if (!outputRootIsDirectory) {
@@ -752,27 +1171,36 @@ async function resolveResultDownloadTargets({
752
1171
  allFiles,
753
1172
  baseDir: outputRoot,
754
1173
  groupByStep: inputs.length === 0,
1174
+ owner,
1175
+ outputRoot,
755
1176
  reservedPaths,
756
1177
  })
757
1178
  }
758
1179
 
759
- if (outputMode === 'directory' || outputPath == null || inPath == null) {
760
- return await buildDirectoryDownloadTargets({
761
- allFiles,
762
- baseDir: resolveDirectoryBaseDir(),
763
- groupByStep: inPath == null || entries.length > 1,
1180
+ let baseDir = resolveDirectoryBaseDir()
1181
+ if (path.resolve(baseDir) !== path.resolve(outputRoot)) {
1182
+ // Input grouping directories share the result namespace, including files already reserved
1183
+ // by another Assembly. Reuse existing directories and suffix a conflicting filename.
1184
+ await assertNoOutputSymlinks(outputRoot, baseDir)
1185
+ await prepareDownloadDirectory(outputRoot, path.dirname(baseDir), reservedPaths)
1186
+ baseDir = await ensureUniquePath(
1187
+ baseDir,
764
1188
  reservedPaths,
765
- })
766
- }
767
-
768
- if (allFiles.length === 1) {
769
- return getSingleResultDownloadTarget(allFiles, outputPath)
1189
+ JSON.stringify([owner, 'input-directory']),
1190
+ true,
1191
+ )
770
1192
  }
771
1193
 
772
1194
  return await buildDirectoryDownloadTargets({
773
1195
  allFiles,
774
- baseDir: path.join(path.dirname(outputPath), path.parse(outputPath).name),
775
- groupByStep: true,
1196
+ baseDir,
1197
+ groupByStep: keepInputStepLayout || inPath == null || entries.length > 1,
1198
+ owner,
1199
+ outputRoot,
1200
+ // The mapped single-file destination is also the historical freshness marker. Updating it
1201
+ // lets an unchanged rerun skip processing; differently named results retain collision suffixes.
1202
+ overwritePath:
1203
+ inPath != null && allFiles.length === 1 && !linked ? (outputPath ?? undefined) : undefined,
776
1204
  reservedPaths,
777
1205
  })
778
1206
  }
@@ -841,6 +1269,7 @@ async function materializeAssemblyResults({
841
1269
  abortSignal,
842
1270
  hasDirectoryInput,
843
1271
  inPath,
1272
+ isCurrent,
844
1273
  inputs,
845
1274
  normalizedResults,
846
1275
  outputMode,
@@ -848,12 +1277,14 @@ async function materializeAssemblyResults({
848
1277
  outputRoot,
849
1278
  outputRootIsDirectory,
850
1279
  outputctl,
1280
+ owner,
851
1281
  reservedPaths,
852
1282
  singleAssembly,
853
1283
  }: {
854
1284
  abortSignal: AbortSignal
855
1285
  hasDirectoryInput: boolean
856
1286
  inPath: string | null
1287
+ isCurrent: () => boolean
857
1288
  inputs: string[]
858
1289
  normalizedResults: NormalizedAssemblyResults
859
1290
  outputMode?: 'directory' | 'file'
@@ -861,33 +1292,58 @@ async function materializeAssemblyResults({
861
1292
  outputRoot: string | null
862
1293
  outputRootIsDirectory: boolean
863
1294
  outputctl: IOutputCtl
864
- reservedPaths: Set<string>
1295
+ owner: string
1296
+ reservedPaths: DownloadPathReservations
865
1297
  singleAssembly?: boolean
866
1298
  }): Promise<void> {
867
1299
  if (outputRoot == null) {
868
1300
  return
869
1301
  }
870
1302
 
871
- const targets = await resolveResultDownloadTargets({
872
- hasDirectoryInput,
873
- inPath,
874
- inputs,
875
- normalizedResults,
876
- outputMode,
877
- outputPath,
878
- outputRoot,
879
- outputRootIsDirectory,
880
- reservedPaths,
881
- singleAssembly,
882
- })
1303
+ // Directory creation and filename reservation must share a lock across concurrent jobs.
1304
+ const targets = await reservedPaths.allocations.add(
1305
+ async () =>
1306
+ await resolveResultDownloadTargets({
1307
+ hasDirectoryInput,
1308
+ inPath,
1309
+ inputs,
1310
+ normalizedResults,
1311
+ outputMode,
1312
+ outputPath,
1313
+ outputRoot,
1314
+ outputRootIsDirectory,
1315
+ owner,
1316
+ reservedPaths,
1317
+ singleAssembly,
1318
+ }),
1319
+ )
883
1320
 
884
- for (const { resultUrl, targetPath } of targets) {
885
- outputctl.debug('DOWNLOADING')
886
- const [dlErr] = await tryCatch(
887
- targetPath == null
888
- ? downloadResultToStdout(resultUrl, abortSignal)
889
- : downloadResultToFile(resultUrl, targetPath, abortSignal),
1321
+ const bundles = new Map<string, BundleDownloadTarget[]>()
1322
+ const downloads: Array<() => Promise<void>> = []
1323
+ for (const { bundleRoot, resultUrl, targetPath } of targets) {
1324
+ if (bundleRoot != null && targetPath != null) {
1325
+ const files = bundles.get(bundleRoot) ?? []
1326
+ files.push({ resultUrl, targetPath })
1327
+ bundles.set(bundleRoot, files)
1328
+ continue
1329
+ }
1330
+ downloads.push(async () => {
1331
+ outputctl.debug('DOWNLOADING')
1332
+ if (targetPath == null) await downloadResultToStdout(resultUrl, abortSignal)
1333
+ else await downloadResultToFile(resultUrl, targetPath, abortSignal, isCurrent)
1334
+ })
1335
+ }
1336
+ for (const files of bundles.values()) {
1337
+ downloads.push(
1338
+ async () =>
1339
+ await downloadResultBundle(files, abortSignal, isCurrent, reservedPaths.bundleCommits, () =>
1340
+ outputctl.debug('DOWNLOADING'),
1341
+ ),
890
1342
  )
1343
+ }
1344
+
1345
+ for (const download of downloads) {
1346
+ const [dlErr] = await tryCatch(download())
891
1347
  if (dlErr) {
892
1348
  if (dlErr.name === 'AbortError') {
893
1349
  continue
@@ -1190,7 +1646,8 @@ function dismissStaleJobs(jobEmitter: EventEmitter): MyEventEmitter {
1190
1646
  jobEmitter.on('end', () => Promise.all(pendingChecks).then(() => emitter.emit('end')))
1191
1647
  jobEmitter.on('error', (err: Error) => emitter.emit('error', err))
1192
1648
  jobEmitter.on('job', (job: Job) => {
1193
- if (job.inputPath == null || job.out == null) {
1649
+ // An older download can finish after a watch change and have a misleading newer mtime.
1650
+ if (job.watchEvent || job.inputPath == null || job.out == null) {
1194
1651
  emitter.emit('job', job)
1195
1652
  return
1196
1653
  }
@@ -1318,35 +1775,44 @@ export interface AssembliesCreateOptions {
1318
1775
  watch?: boolean
1319
1776
  recursive?: boolean
1320
1777
  inputs: string[]
1778
+ /** Prepared URL/base64 uploads whose temporary parent paths must not enter the output layout. */
1779
+ transientInputPaths?: string[]
1321
1780
  output?: string | null
1322
1781
  del?: boolean
1323
1782
  reprocessStale?: boolean
1324
1783
  singleAssembly?: boolean
1325
1784
  concurrency?: number
1785
+ printUrls?: boolean
1786
+ urlDownloadTimeoutMs?: number
1326
1787
  }
1327
1788
 
1328
1789
  const DEFAULT_CONCURRENCY = 5
1790
+ const DEFAULT_URL_DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000
1329
1791
 
1330
- // --- Main assembly create function ---
1331
- export async function create(
1792
+ interface PreparedCreateOptions {
1793
+ transientInputPaths?: Set<string>
1794
+ effectiveStepsData: StepsInput | undefined
1795
+ hasDirectoryInput: boolean
1796
+ outstat: StatLike | undefined
1797
+ resolvedOutput: string | null | undefined
1798
+ }
1799
+
1800
+ function isUrlInput(input: string): boolean {
1801
+ return /^https?:\/\//i.test(input)
1802
+ }
1803
+
1804
+ async function prepareCreateOptions(
1332
1805
  outputctl: IOutputCtl,
1333
- client: Transloadit,
1334
1806
  {
1335
1807
  steps,
1336
1808
  stepsData,
1337
- template,
1338
- fields,
1339
1809
  outputMode,
1340
- watch: watchOption,
1341
- recursive,
1342
- inputs,
1343
1810
  output,
1344
- del,
1345
- reprocessStale,
1811
+ inputs,
1346
1812
  singleAssembly,
1347
- concurrency = DEFAULT_CONCURRENCY,
1813
+ transientInputPaths,
1348
1814
  }: AssembliesCreateOptions,
1349
- ): Promise<{ resultUrls: ResultUrlRow[]; results: unknown[]; hasFailures: boolean }> {
1815
+ ): Promise<PreparedCreateOptions> {
1350
1816
  // Quick fix for https://github.com/transloadit/transloadify/issues/13
1351
1817
  // Only default to stdout when output is undefined (not provided), not when explicitly null
1352
1818
  let resolvedOutput = output
@@ -1382,7 +1848,9 @@ export async function create(
1382
1848
  if (!outstat.isDirectory() && inputs.length !== 0 && !singleAssembly) {
1383
1849
  const firstInput = inputs[0]
1384
1850
  if (firstInput) {
1385
- const firstInputStat = await myStat(process.stdin, firstInput)
1851
+ const firstInputStat = isUrlInput(firstInput)
1852
+ ? { isDirectory: () => false }
1853
+ : await myStat(process.stdin, firstInput)
1386
1854
  if (inputs.length > 1 || firstInputStat.isDirectory()) {
1387
1855
  const msg = 'Output must be a directory when specifying multiple inputs'
1388
1856
  outputctl.error(msg)
@@ -1394,11 +1862,106 @@ export async function create(
1394
1862
 
1395
1863
  const inputStats = await Promise.all(
1396
1864
  inputs.map(async (input) => {
1397
- if (input === '-') return null
1865
+ if (input === '-' || isUrlInput(input)) return null
1398
1866
  return await myStat(process.stdin, input)
1399
1867
  }),
1400
1868
  )
1401
1869
  const hasDirectoryInput = inputStats.some((stat) => stat?.isDirectory() === true)
1870
+ return {
1871
+ effectiveStepsData,
1872
+ hasDirectoryInput,
1873
+ outstat,
1874
+ resolvedOutput,
1875
+ transientInputPaths:
1876
+ transientInputPaths == null
1877
+ ? undefined
1878
+ : new Set(transientInputPaths.map((inputPath) => path.resolve(inputPath))),
1879
+ }
1880
+ }
1881
+
1882
+ // --- Main assembly create function ---
1883
+ /** Create Assemblies after materializing URL inputs as temporary local uploads. */
1884
+ export async function create(
1885
+ outputctl: IOutputCtl,
1886
+ client: Transloadit,
1887
+ options: AssembliesCreateOptions,
1888
+ ): ReturnType<typeof createWithLocalInputs> {
1889
+ const preparedOptions = await prepareCreateOptions(outputctl, options)
1890
+ const urlDownloadTimeoutMs =
1891
+ toPositiveInteger(options.urlDownloadTimeoutMs, '--url-download-timeout') ??
1892
+ DEFAULT_URL_DOWNLOAD_TIMEOUT_MS
1893
+ if (!options.inputs.some(isUrlInput))
1894
+ return await createWithLocalInputs(outputctl, client, options, preparedOptions)
1895
+ if (options.watch) throw new Error('--watch requires local inputs')
1896
+
1897
+ const preparedInputs: Array<Awaited<ReturnType<typeof prepareInputFiles>>> = []
1898
+ const localInputs: string[] = []
1899
+ const transientInputPaths = preparedOptions.transientInputPaths ?? new Set<string>()
1900
+ try {
1901
+ for (const [index, input] of options.inputs.entries()) {
1902
+ if (!isUrlInput(input)) {
1903
+ localInputs.push(input)
1904
+ continue
1905
+ }
1906
+ const field = String(index)
1907
+ // Separate staging roots protect case-only filenames and repeated URLs from aliasing.
1908
+ const prepared = await prepareInputFiles({
1909
+ inputFiles: [{ kind: 'url', field, url: input }],
1910
+ allowPrivateUrls: false,
1911
+ // Uploading preserves :original and Template semantics, just as Intents do.
1912
+ urlStrategy: 'download',
1913
+ urlDownloadTimeoutMs,
1914
+ })
1915
+ preparedInputs.push(prepared)
1916
+ const localPath = prepared.files[field]
1917
+ if (localPath == null) throw new Error('URL input could not be prepared')
1918
+ localInputs.push(localPath)
1919
+ transientInputPaths.add(path.resolve(localPath))
1920
+ }
1921
+ return await createWithLocalInputs(
1922
+ outputctl,
1923
+ client,
1924
+ {
1925
+ ...options,
1926
+ inputs: localInputs,
1927
+ },
1928
+ { ...preparedOptions, transientInputPaths },
1929
+ )
1930
+ } finally {
1931
+ await Promise.all(
1932
+ preparedInputs.flatMap((prepared) => prepared.cleanup.map((cleanup) => cleanup())),
1933
+ )
1934
+ }
1935
+ }
1936
+
1937
+ async function createWithLocalInputs(
1938
+ outputctl: IOutputCtl,
1939
+ client: Transloadit,
1940
+ {
1941
+ template,
1942
+ fields,
1943
+ outputMode,
1944
+ watch: watchOption,
1945
+ recursive,
1946
+ inputs,
1947
+ del,
1948
+ reprocessStale,
1949
+ singleAssembly,
1950
+ concurrency = DEFAULT_CONCURRENCY,
1951
+ printUrls = false,
1952
+ }: AssembliesCreateOptions,
1953
+ preparedOptions: PreparedCreateOptions,
1954
+ ): Promise<{
1955
+ assemblies: AssemblySummary[]
1956
+ resultUrls: ResultUrlRow[]
1957
+ results: unknown[]
1958
+ hasFailures: boolean
1959
+ }> {
1960
+ const { effectiveStepsData, hasDirectoryInput, outstat, resolvedOutput } = preparedOptions
1961
+ const nameSensitivity =
1962
+ resolvedOutput != null && outstat?.isDirectory()
1963
+ ? await outputNameSensitivity(resolvedOutput)
1964
+ : { caseSensitive: true, normalizationSensitive: true }
1402
1965
 
1403
1966
  return new Promise((resolve, reject) => {
1404
1967
  const params: CreateAssemblyParams = (
@@ -1414,7 +1977,7 @@ export async function create(
1414
1977
  resolvedOutput == null
1415
1978
  ? nullProvider()
1416
1979
  : outstat?.isDirectory()
1417
- ? dirProvider(resolvedOutput)
1980
+ ? dirProvider(resolvedOutput, preparedOptions.transientInputPaths)
1418
1981
  : fileProvider(resolvedOutput)
1419
1982
 
1420
1983
  const emitter = makeJobEmitter(inputs, {
@@ -1429,8 +1992,16 @@ export async function create(
1429
1992
  // Use p-queue for concurrency management
1430
1993
  const queue = new PQueue({ concurrency })
1431
1994
  const results: unknown[] = []
1995
+ const assemblies: AssemblySummary[] = []
1432
1996
  const resultUrls: ResultUrlRow[] = []
1433
- const reservedResultPaths = new Set<string>()
1997
+ const reservedResultPaths: DownloadPathReservations = {
1998
+ allocations: new PQueue({ concurrency: 1 }),
1999
+ bundleCommits: new PQueue({ concurrency: 1 }),
2000
+ ...nameSensitivity,
2001
+ directories: new Set(),
2002
+ ownersByPath: new Map(),
2003
+ pathsByOwner: new Map(),
2004
+ }
1434
2005
  const latestWatchJobTokenByOutputPath = new Map<string, number>()
1435
2006
  let hasFailures = false
1436
2007
  let nextWatchJobToken = 0
@@ -1480,11 +2051,40 @@ export async function create(
1480
2051
  assembly: Awaited<ReturnType<typeof client.awaitAssemblyCompletion>>
1481
2052
  assemblyId: string
1482
2053
  }> {
1483
- const result = await client.createAssembly(createOptions)
1484
- const assemblyId = result.assembly_id
1485
- if (!assemblyId) throw new Error('No assembly_id in result')
1486
- const assemblyUrl = result.assembly_ssl_url ?? result.assembly_url ?? null
1487
- const assemblyReference = formatAssemblyReference({ assemblyId, assemblyUrl })
2054
+ let assemblyReference: string | undefined
2055
+ function reportCreatedAssembly(rawAssembly: AssemblyStatus): void {
2056
+ const assembly = assemblySummarySchema.parse(rawAssembly)
2057
+ if (assemblyReference || !assembly.assembly_id) return
2058
+ assemblyReference = formatAssemblyReference({
2059
+ assemblyId: assembly.assembly_id,
2060
+ assemblyUrl: assembly.assembly_ssl_url ?? assembly.assembly_url ?? null,
2061
+ })
2062
+ outputctl.notice(assemblyReference)
2063
+ }
2064
+
2065
+ const creation = client.createAssembly({
2066
+ ...createOptions,
2067
+ onAssemblyCreated: reportCreatedAssembly,
2068
+ })
2069
+ const [creationError, result] = await tryCatch(creation)
2070
+ if (creationError) {
2071
+ if (!assemblyReference && creationError instanceof RequestError && creation.assemblyId) {
2072
+ const requestUrl = creationError.options.url
2073
+ assemblyReference = formatAssemblyReference({
2074
+ assemblyId: creation.assemblyId,
2075
+ assemblyUrl:
2076
+ requestUrl instanceof URL ? `${requestUrl.origin}${requestUrl.pathname}` : null,
2077
+ })
2078
+ }
2079
+ if (!assemblyReference) throw creationError
2080
+ throw new Error(`${formatAPIError(creationError)} (${assemblyReference})`, {
2081
+ cause: creationError,
2082
+ })
2083
+ }
2084
+ reportCreatedAssembly(result)
2085
+ const createdSummary = assemblySummarySchema.parse(result)
2086
+ const assemblyId = createdSummary.assembly_id
2087
+ if (!assemblyId || !assemblyReference) throw new Error('No assembly_id in result')
1488
2088
 
1489
2089
  let assembly: Awaited<ReturnType<typeof client.awaitAssemblyCompletion>>
1490
2090
  try {
@@ -1492,16 +2092,50 @@ export async function create(
1492
2092
  signal: abortController.signal,
1493
2093
  onPoll: () => true,
1494
2094
  onAssemblyProgress: (status) => {
1495
- outputctl.debug(`Assembly status: ${status.ok}`)
2095
+ const summary = assemblySummarySchema.parse(status)
2096
+ outputctl.debug(`Assembly status: ${summary.ok ?? 'unknown'}`)
1496
2097
  },
1497
2098
  })
1498
2099
  } catch (err) {
1499
2100
  const error = ensureError(err)
1500
- throw new Error(`${error.message} (${assemblyReference})`, { cause: error })
2101
+ const message = error instanceof RequestError ? formatAPIError(error) : error.message
2102
+ throw new Error(`${message} (${assemblyReference})`, { cause: error })
2103
+ }
2104
+
2105
+ const summary = assemblySummarySchema.parse(assembly)
2106
+ summary.assembly_id = assemblyId
2107
+ summary.assembly_url ??= createdSummary.assembly_url
2108
+ summary.assembly_ssl_url ??= createdSummary.assembly_ssl_url
2109
+ const ignoredErrors = summary.ignored_errors
2110
+ const ignoredErrorCount = Math.max(summary.ignored_error_count, ignoredErrors.length)
2111
+ summary.ignored_error_count = ignoredErrorCount
2112
+ // Preserve stdout file bytes. URL mode combines all summaries with its existing JSON object.
2113
+ if (resolvedOutput !== '-') {
2114
+ if (printUrls) assemblies.push(summary)
2115
+ else outputctl.print(undefined, summary)
2116
+ }
2117
+ if (ignoredErrorCount > 0) {
2118
+ const details = ignoredErrors
2119
+ .map((ignored) => {
2120
+ const code = typeof ignored.error === 'string' ? ignored.error : ignored.error?.code
2121
+ const message =
2122
+ ignored.message ||
2123
+ (typeof ignored.error === 'object' ? ignored.error.message : undefined)
2124
+ return [ignored.step, code, message].filter(Boolean).join(': ')
2125
+ })
2126
+ .filter(Boolean)
2127
+ .slice(0, 5)
2128
+ outputctl.warn(
2129
+ `${ignoredErrorCount} ignored error${ignoredErrorCount === 1 ? '' : 's'} (${assemblyReference})${details.length > 0 ? `: ${details.join('; ')}` : ''}`,
2130
+ )
1501
2131
  }
1502
2132
 
2133
+ // Malformed error fields still signal failure, but only sanitized values enter the message.
1503
2134
  if (assembly.error || (assembly.ok && assembly.ok !== 'ASSEMBLY_COMPLETED')) {
1504
- const msg = `Assembly failed: ${assembly.error || assembly.message} (Status: ${assembly.ok}, ${assemblyReference})`
2135
+ const details =
2136
+ [summary.error ?? summary.ok, summary.message].filter(Boolean).join(': ') ||
2137
+ 'Unknown Assembly error'
2138
+ const msg = `Assembly failed: ${details} (${assemblyReference})`
1505
2139
  throw new Error(msg)
1506
2140
  }
1507
2141
 
@@ -1557,17 +2191,26 @@ export async function create(
1557
2191
  abortSignal: abortController.signal,
1558
2192
  hasDirectoryInput: singleAssemblyMode ? false : hasDirectoryInput,
1559
2193
  inPath,
1560
- inputs: inputPaths,
2194
+ isCurrent: () => !isSupersededWatchJob(outputPlan?.path ?? null, outputToken),
2195
+ inputs: singleAssemblyMode ? inputPaths : inputs,
1561
2196
  normalizedResults,
1562
2197
  outputMode,
1563
2198
  outputPath: outputPlan?.path ?? null,
1564
2199
  outputRoot: resolvedOutput ?? null,
1565
2200
  outputRootIsDirectory,
1566
2201
  outputctl,
2202
+ // Only watch updates reuse destinations; independent Assemblies retain every result.
2203
+ owner: JSON.stringify([
2204
+ inPath,
2205
+ singleAssemblyMode ? inputPaths : [],
2206
+ watchOption ? null : assemblyId,
2207
+ ]),
1567
2208
  reservedPaths: reservedResultPaths,
1568
2209
  singleAssembly: singleAssemblyMode,
1569
2210
  })
1570
2211
 
2212
+ // Only the current watch job may delete its input; a failed newer job must retain it.
2213
+ if (isSupersededWatchJob(outputPlan?.path ?? null, outputToken)) return assembly
1571
2214
  outputctl.debug(`COMPLETED ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`)
1572
2215
 
1573
2216
  if (del) {
@@ -1612,7 +2255,7 @@ export async function create(
1612
2255
  function handleEmitterError(err: Error): void {
1613
2256
  abortController.abort()
1614
2257
  queue.clear()
1615
- outputctl.error(err)
2258
+ outputctl.error(formatAPIError(err))
1616
2259
  reject(err)
1617
2260
  }
1618
2261
 
@@ -1643,7 +2286,7 @@ export async function create(
1643
2286
  })
1644
2287
  ) {
1645
2288
  outputctl.debug(`SKIPPED STALE SINGLE ASSEMBLY ${resolvedOutput ?? 'null'}`)
1646
- resolve({ resultUrls, results: [], hasFailures: false })
2289
+ resolve({ assemblies, resultUrls, results: [], hasFailures: false })
1647
2290
  return
1648
2291
  }
1649
2292
 
@@ -1691,10 +2334,10 @@ export async function create(
1691
2334
  results.push(assembly)
1692
2335
  } catch (err) {
1693
2336
  hasFailures = true
1694
- outputctl.error(err as Error)
2337
+ outputctl.error(formatAPIError(err))
1695
2338
  }
1696
2339
 
1697
- resolve({ resultUrls, results, hasFailures })
2340
+ resolve({ assemblies, resultUrls, results, hasFailures })
1698
2341
  })
1699
2342
  }
1700
2343
 
@@ -1713,13 +2356,13 @@ export async function create(
1713
2356
  })
1714
2357
  .catch((err: unknown) => {
1715
2358
  hasFailures = true
1716
- outputctl.error(err as Error)
2359
+ outputctl.error(formatAPIError(err))
1717
2360
  })
1718
2361
  })
1719
2362
 
1720
2363
  emitter.on('end', async () => {
1721
2364
  await queue.onIdle()
1722
- resolve({ resultUrls, results, hasFailures })
2365
+ resolve({ assemblies, resultUrls, results, hasFailures })
1723
2366
  })
1724
2367
  }
1725
2368
 
@@ -1860,6 +2503,10 @@ export class AssembliesCreateCommand extends AuthenticatedCommand {
1860
2503
 
1861
2504
  concurrency = concurrencyOption()
1862
2505
 
2506
+ urlDownloadTimeoutMs = Option.String('--url-download-timeout', {
2507
+ description: 'Maximum time per URL input download in milliseconds (default: 300000)',
2508
+ })
2509
+
1863
2510
  printUrls = printUrlsOption()
1864
2511
 
1865
2512
  protected async run(): Promise<number | undefined> {
@@ -1895,7 +2542,7 @@ export class AssembliesCreateCommand extends AuthenticatedCommand {
1895
2542
  return 1
1896
2543
  }
1897
2544
 
1898
- const { hasFailures, resultUrls } = await create(this.output, this.client, {
2545
+ const { assemblies, hasFailures, resultUrls } = await create(this.output, this.client, {
1899
2546
  steps: this.steps,
1900
2547
  template: this.template,
1901
2548
  fields: fieldsMap ?? {},
@@ -1907,9 +2554,12 @@ export class AssembliesCreateCommand extends AuthenticatedCommand {
1907
2554
  reprocessStale: this.reprocessStale,
1908
2555
  singleAssembly: this.singleAssembly,
1909
2556
  concurrency: this.concurrency == null ? undefined : Number(this.concurrency),
2557
+ printUrls: this.printUrls,
2558
+ urlDownloadTimeoutMs:
2559
+ this.urlDownloadTimeoutMs == null ? undefined : Number(this.urlDownloadTimeoutMs),
1910
2560
  })
1911
2561
  if (this.printUrls) {
1912
- printResultUrls(this.output, resultUrls)
2562
+ printResultUrls(this.output, resultUrls, assemblies)
1913
2563
  }
1914
2564
  return hasFailures ? 1 : undefined
1915
2565
  }
@@ -1979,6 +2629,10 @@ export class RunCommand extends AuthenticatedCommand {
1979
2629
 
1980
2630
  concurrency = concurrencyOption()
1981
2631
 
2632
+ urlDownloadTimeoutMs = Option.String('--url-download-timeout', {
2633
+ description: 'Maximum time per URL input download in milliseconds (default: 300000)',
2634
+ })
2635
+
1982
2636
  printUrls = printUrlsOption()
1983
2637
 
1984
2638
  protected async run(): Promise<number | undefined> {
@@ -2026,7 +2680,7 @@ export class RunCommand extends AuthenticatedCommand {
2026
2680
  return 1
2027
2681
  }
2028
2682
 
2029
- const { hasFailures, resultUrls } = await create(this.output, this.client, {
2683
+ const { assemblies, hasFailures, resultUrls } = await create(this.output, this.client, {
2030
2684
  stepsData: parseStepsInputJson(JSON.stringify(compiled.instructions.steps), this.output),
2031
2685
  fields: fieldsMap ?? {},
2032
2686
  watch: this.watch,
@@ -2037,9 +2691,12 @@ export class RunCommand extends AuthenticatedCommand {
2037
2691
  reprocessStale: this.reprocessStale,
2038
2692
  singleAssembly: this.singleAssembly,
2039
2693
  concurrency: this.concurrency == null ? undefined : Number(this.concurrency),
2694
+ printUrls: this.printUrls,
2695
+ urlDownloadTimeoutMs:
2696
+ this.urlDownloadTimeoutMs == null ? undefined : Number(this.urlDownloadTimeoutMs),
2040
2697
  })
2041
2698
  if (this.printUrls) {
2042
- printResultUrls(this.output, resultUrls)
2699
+ printResultUrls(this.output, resultUrls, assemblies)
2043
2700
  }
2044
2701
  return hasFailures ? 1 : undefined
2045
2702
  }