@transloadit/node 6.3.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.
- package/README.md +30 -5
- package/dist/Transloadit.d.ts.map +1 -1
- package/dist/Transloadit.js +61 -5
- package/dist/Transloadit.js.map +1 -1
- package/dist/alphalib/types/builtinTemplates.d.ts +22 -3
- package/dist/alphalib/types/builtinTemplates.d.ts.map +1 -1
- package/dist/alphalib/types/builtinTemplates.js +39 -3
- package/dist/alphalib/types/builtinTemplates.js.map +1 -1
- package/dist/alphalib/types/template.d.ts +18 -0
- package/dist/alphalib/types/template.d.ts.map +1 -1
- package/dist/alphalib/types/template.js +19 -0
- package/dist/alphalib/types/template.js.map +1 -1
- package/dist/cli/OutputCtl.d.ts +1 -0
- package/dist/cli/OutputCtl.d.ts.map +1 -1
- package/dist/cli/OutputCtl.js +1 -1
- package/dist/cli/OutputCtl.js.map +1 -1
- package/dist/cli/commands/BaseCommand.d.ts +3 -2
- package/dist/cli/commands/BaseCommand.d.ts.map +1 -1
- package/dist/cli/commands/BaseCommand.js +27 -3
- package/dist/cli/commands/BaseCommand.js.map +1 -1
- package/dist/cli/commands/assemblies.d.ts +21 -1
- package/dist/cli/commands/assemblies.d.ts.map +1 -1
- package/dist/cli/commands/assemblies.js +584 -91
- package/dist/cli/commands/assemblies.js.map +1 -1
- package/dist/cli/commands/templates.d.ts +1 -1
- package/dist/cli/commands/templates.d.ts.map +1 -1
- package/dist/cli/commands/templates.js +57 -67
- package/dist/cli/commands/templates.js.map +1 -1
- package/dist/cli/errors.d.ts +8 -0
- package/dist/cli/errors.d.ts.map +1 -0
- package/dist/cli/errors.js +28 -0
- package/dist/cli/errors.js.map +1 -0
- package/dist/cli/helpers.d.ts.map +1 -1
- package/dist/cli/helpers.js +22 -0
- package/dist/cli/helpers.js.map +1 -1
- package/dist/cli/intentRuntime.d.ts +1 -0
- package/dist/cli/intentRuntime.d.ts.map +1 -1
- package/dist/cli/intentRuntime.js +9 -3
- package/dist/cli/intentRuntime.js.map +1 -1
- package/dist/cli/resultFiles.d.ts +1 -0
- package/dist/cli/resultFiles.d.ts.map +1 -1
- package/dist/cli/resultFiles.js.map +1 -1
- package/dist/cli/resultUrls.d.ts +2 -1
- package/dist/cli/resultUrls.d.ts.map +1 -1
- package/dist/cli/resultUrls.js +6 -3
- package/dist/cli/resultUrls.js.map +1 -1
- package/dist/cli/stepsInput.d.ts.map +1 -1
- package/dist/cli/stepsInput.js +17 -4
- package/dist/cli/stepsInput.js.map +1 -1
- package/dist/cli/types.d.ts +66 -0
- package/dist/cli/types.d.ts.map +1 -1
- package/dist/cli/types.js +29 -0
- package/dist/cli/types.js.map +1 -1
- package/dist/tus.d.ts +7 -2
- package/dist/tus.d.ts.map +1 -1
- package/dist/tus.js +58 -4
- package/dist/tus.js.map +1 -1
- package/package.json +1 -1
- package/src/Transloadit.ts +65 -4
- package/src/alphalib/types/builtinTemplates.ts +50 -3
- package/src/alphalib/types/template.ts +30 -0
- package/src/cli/OutputCtl.ts +2 -1
- package/src/cli/commands/BaseCommand.ts +29 -7
- package/src/cli/commands/assemblies.ts +775 -118
- package/src/cli/commands/templates.ts +93 -98
- package/src/cli/errors.ts +34 -0
- package/src/cli/helpers.ts +21 -0
- package/src/cli/intentRuntime.ts +10 -3
- package/src/cli/resultFiles.ts +1 -0
- package/src/cli/resultUrls.ts +11 -3
- package/src/cli/stepsInput.ts +19 -4
- package/src/cli/types.ts +35 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
531
|
-
relpath =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
731
|
+
reserveDownloadPath(candidate, owner, reservedPaths, directory)
|
|
732
|
+
reservedPaths.pathsByOwner.set(owner, candidate)
|
|
630
733
|
},
|
|
631
|
-
nextValue: (counter) =>
|
|
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
|
-
|
|
637
|
-
|
|
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
|
-
|
|
984
|
+
owner: string
|
|
985
|
+
outputRoot: string
|
|
986
|
+
overwritePath?: string
|
|
987
|
+
reservedPaths: DownloadPathReservations
|
|
661
988
|
}): Promise<AssemblyDownloadTarget[]> {
|
|
662
|
-
await
|
|
989
|
+
await prepareDownloadDirectory(outputRoot, baseDir, reservedPaths)
|
|
663
990
|
|
|
664
991
|
const targets: AssemblyDownloadTarget[] = []
|
|
665
|
-
for (const
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
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 &&
|
|
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 (
|
|
723
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
|
775
|
-
groupByStep:
|
|
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
|
-
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1331
|
-
|
|
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
|
-
|
|
1345
|
-
reprocessStale,
|
|
1811
|
+
inputs,
|
|
1346
1812
|
singleAssembly,
|
|
1347
|
-
|
|
1813
|
+
transientInputPaths,
|
|
1348
1814
|
}: AssembliesCreateOptions,
|
|
1349
|
-
): Promise<
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|