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
@@ -9,13 +9,15 @@ import { pipeline } from 'node:stream/promises';
9
9
  import { setTimeout as delay } from 'node:timers/promises';
10
10
  import { promisify } from 'node:util';
11
11
  import { Command, Option } from 'clipanion';
12
- import got from 'got';
12
+ import got, { RequestError } from 'got';
13
13
  import PQueue from 'p-queue';
14
14
  import * as t from 'typanion';
15
15
  import { z } from 'zod';
16
16
  import { formatLintIssue } from "../../alphalib/assembly-linter.lang.en.js";
17
17
  import { tryCatch } from "../../alphalib/tryCatch.js";
18
+ import { assemblyStatusResultSchema } from "../../alphalib/types/assemblyStatus.js";
18
19
  import { ensureUniqueCounterValue } from "../../ensureUniqueCounter.js";
20
+ import { prepareInputFiles } from "../../inputFiles.js";
19
21
  import { lintAssemblyInstructions } from "../../lintAssemblyInstructions.js";
20
22
  import { lintingExamples } from "../docs/assemblyLintingExamples.js";
21
23
  import { concurrencyOption, deleteAfterProcessingOption, inputPathsOption, printUrlsOption, recursiveOption, reprocessStaleOption, singleAssemblyOption, validateSharedFileProcessingOptions, watchOption, } from "../fileProcessingOptions.js";
@@ -23,7 +25,7 @@ import { formatAPIError, readCliInput } from "../helpers.js";
23
25
  import { normalizeAssemblyResults } from "../resultFiles.js";
24
26
  import { collectNormalizedResultUrlRows, printResultUrls } from "../resultUrls.js";
25
27
  import { parseStepsInputJson, readStepsInputFile } from "../stepsInput.js";
26
- import { ensureError, isErrnoException } from "../types.js";
28
+ import { assemblySummarySchema, ensureError, isErrnoException } from "../types.js";
27
29
  import { AuthenticatedCommand, UnauthenticatedCommand } from "./BaseCommand.js";
28
30
  function formatAssemblyReference({ assemblyId, assemblyUrl, }) {
29
31
  if (assemblyUrl != null) {
@@ -95,11 +97,7 @@ export function list(output, client, { before, after, fields, keywords, pagesize
95
97
  export async function get(output, client, { assemblies }) {
96
98
  for (const assembly of assemblies) {
97
99
  await delay(1000);
98
- const [err, result] = await tryCatch(client.getAssembly(assembly));
99
- if (err) {
100
- output.error(formatAPIError(err));
101
- throw ensureError(err);
102
- }
100
+ const result = await client.getAssembly(assembly);
103
101
  output.print(result, result);
104
102
  }
105
103
  }
@@ -317,16 +315,32 @@ async function createExistingPathOutputPlan(outputPath) {
317
315
  const [, stats] = await tryCatch(fsp.stat(outputPath));
318
316
  return createOutputPlan(outputPath, stats?.mtime ?? new Date(0));
319
317
  }
320
- function dirProvider(output) {
321
- return async (inpath, indir = process.cwd()) => {
318
+ function dirProvider(output, transientInputPaths) {
319
+ const namesByInput = new Map();
320
+ const usedNames = new Set();
321
+ return async (inpath, indir) => {
322
322
  if (inpath == null) {
323
323
  return await createExistingPathOutputPlan(output);
324
324
  }
325
325
  if (inpath === '-') {
326
326
  throw new Error('You must provide an input to output to a directory');
327
327
  }
328
- let relpath = path.relative(indir, inpath);
329
- relpath = relpath.replace(/^(\.\.\/)+/, '');
328
+ const inputKey = path.resolve(inpath);
329
+ let relpath = namesByInput.get(inputKey);
330
+ if (relpath == null) {
331
+ const requestedPath = transientInputPaths?.has(inputKey)
332
+ ? path.basename(inpath)
333
+ : path.relative(indir ?? process.cwd(), inpath).replace(/^(\.\.\/)+/, '');
334
+ const parsed = path.parse(requestedPath);
335
+ relpath = await ensureUniqueCounterValue({
336
+ initialValue: requestedPath,
337
+ isTaken: (candidate) => usedNames.has(candidate),
338
+ reserve: (candidate) => usedNames.add(candidate),
339
+ nextValue: (counter) => path.join(parsed.dir, `${parsed.name}__${counter}${parsed.ext}`),
340
+ scope: usedNames,
341
+ });
342
+ namesByInput.set(inputKey, relpath);
343
+ }
330
344
  const outpath = path.join(output, relpath);
331
345
  return await createExistingPathOutputPlan(outpath);
332
346
  };
@@ -342,7 +356,7 @@ function fileProvider(output) {
342
356
  function nullProvider() {
343
357
  return async (_inpath) => null;
344
358
  }
345
- async function downloadResultToFile(resultUrl, outPath, signal) {
359
+ async function downloadResultToFile(resultUrl, outPath, signal, isCurrent) {
346
360
  await fsp.mkdir(path.dirname(outPath), { recursive: true });
347
361
  const tempPath = path.join(path.dirname(outPath), `.${path.basename(outPath)}.${randomUUID()}.tmp`);
348
362
  const outStream = fs.createWriteStream(tempPath);
@@ -352,6 +366,10 @@ async function downloadResultToFile(resultUrl, outPath, signal) {
352
366
  await fsp.rm(tempPath, { force: true });
353
367
  throw dlErr;
354
368
  }
369
+ if (!isCurrent()) {
370
+ await fsp.rm(tempPath, { force: true });
371
+ return;
372
+ }
355
373
  await fsp.rename(tempPath, outPath);
356
374
  }
357
375
  async function downloadResultToStdout(resultUrl, signal) {
@@ -386,45 +404,336 @@ async function downloadResultToStdout(resultUrl, signal) {
386
404
  });
387
405
  await pipeline(got.stream(resultUrl, { signal }), stdoutStream);
388
406
  }
389
- function sanitizeResultName(value) {
407
+ function sanitizeResultName(value, fallback) {
390
408
  const base = path.basename(value);
391
- return base.replaceAll('\\', '_').replaceAll('/', '_').replaceAll('\u0000', '');
409
+ const name = base.replaceAll('\\', '_').replaceAll('/', '_').replaceAll('\u0000', '');
410
+ if (name === '' || name === '.' || name === '..') {
411
+ if (fallback != null)
412
+ return sanitizeResultName(fallback);
413
+ throw new Error('Assembly result has an unsafe name');
414
+ }
415
+ return name;
392
416
  }
393
- async function ensureUniquePath(targetPath, reservedPaths) {
417
+ async function outputNameSensitivity(root) {
418
+ await fsp.mkdir(root, { recursive: true });
419
+ const probe = await fsp.mkdtemp(path.join(root, '.transloadit-names-'));
420
+ async function distinguishesNames(first, second) {
421
+ await fsp.writeFile(path.join(probe, first), '');
422
+ const [error] = await tryCatch(fsp.lstat(path.join(probe, second)));
423
+ if (error && (!isErrnoException(error) || error.code !== 'ENOENT'))
424
+ throw error;
425
+ return error != null;
426
+ }
427
+ try {
428
+ // Assume this tree shares the root's naming rules; nested filesystems are tracked in #615.
429
+ return {
430
+ caseSensitive: await distinguishesNames('case', 'CASE'),
431
+ normalizationSensitive: await distinguishesNames('\u00e9', 'e\u0301'),
432
+ };
433
+ }
434
+ finally {
435
+ await fsp.rm(probe, { recursive: true, force: true });
436
+ }
437
+ }
438
+ function downloadPathKey(target, reservedPaths) {
439
+ const absolutePath = path.resolve(target);
440
+ const folded = reservedPaths.caseSensitive ? absolutePath : absolutePath.toLowerCase();
441
+ return reservedPaths.normalizationSensitive ? folded : folded.normalize('NFC');
442
+ }
443
+ function hasReservedFileAncestor(target, reservedPaths) {
444
+ let current = downloadPathKey(target, reservedPaths);
445
+ while (true) {
446
+ if (reservedPaths.ownersByPath.has(current) && !reservedPaths.directories.has(current)) {
447
+ return true;
448
+ }
449
+ const parent = path.dirname(current);
450
+ if (parent === current)
451
+ return false;
452
+ current = parent;
453
+ }
454
+ }
455
+ function reserveDownloadPath(target, owner, reservedPaths, directory = false) {
456
+ const key = downloadPathKey(target, reservedPaths);
457
+ reservedPaths.ownersByPath.set(key, owner);
458
+ let current = directory ? key : path.dirname(key);
459
+ while (true) {
460
+ reservedPaths.directories.add(current);
461
+ const parent = path.dirname(current);
462
+ if (parent === current)
463
+ return;
464
+ current = parent;
465
+ }
466
+ }
467
+ async function ensureUniquePath(targetPath, reservedPaths, owner, directory = false, overwriteExistingFile = false) {
468
+ const selectedPath = reservedPaths.pathsByOwner.get(owner);
469
+ if (selectedPath != null)
470
+ return selectedPath;
394
471
  const parsed = path.parse(targetPath);
395
472
  return await ensureUniqueCounterValue({
396
473
  initialValue: targetPath,
397
- isTaken: async (candidate) => {
398
- if (reservedPaths.has(candidate)) {
399
- return true;
400
- }
401
- const [statErr] = await tryCatch(fsp.stat(candidate));
402
- return statErr == null;
403
- },
474
+ isTaken: async (candidate) => await isDownloadPathTaken(candidate, reservedPaths, owner, directory, overwriteExistingFile && candidate === targetPath),
404
475
  reserve: (candidate) => {
405
- reservedPaths.add(candidate);
476
+ reserveDownloadPath(candidate, owner, reservedPaths, directory);
477
+ reservedPaths.pathsByOwner.set(owner, candidate);
406
478
  },
407
- nextValue: (counter) => path.join(parsed.dir, `${parsed.name}__${counter}${parsed.ext}`),
479
+ nextValue: (counter) => path.join(parsed.dir, directory ? `${parsed.base}__${counter}` : `${parsed.name}__${counter}${parsed.ext}`),
408
480
  scope: reservedPaths,
409
481
  });
410
482
  }
411
- function getResultFileName(file) {
412
- return sanitizeResultName(file.name);
483
+ const resultPathSchema = z.object({
484
+ meta: z.object({ relative_path: z.string().nullish() }).nullish(),
485
+ });
486
+ function getResultRelativePath(file) {
487
+ const parsed = resultPathSchema.safeParse(file.file);
488
+ if (!parsed.success)
489
+ throw new Error('Assembly result has invalid relative path metadata');
490
+ const relativePath = parsed.data.meta?.relative_path;
491
+ if (relativePath == null)
492
+ return null;
493
+ const name = sanitizeResultName(file.name);
494
+ // Adaptive video results use an empty directory for the master playlist.
495
+ if (relativePath === '')
496
+ return name;
497
+ const normalizedPath = relativePath.replaceAll('\\', '/');
498
+ if (normalizedPath.includes('\u0000') ||
499
+ path.win32.isAbsolute(normalizedPath) ||
500
+ /^[a-z]:/i.test(normalizedPath) ||
501
+ normalizedPath.split('/').some((part) => part === '' || part === '.' || part === '..')) {
502
+ throw new Error('Assembly result has an unsafe relative path');
503
+ }
504
+ return path.join(normalizedPath, name);
505
+ }
506
+ async function downloadResultBundle(targets, signal, isCurrent, commits, onDownload) {
507
+ const stagingDirs = new Map();
508
+ const staged = [];
509
+ try {
510
+ for (const [index, target] of targets.entries()) {
511
+ if (!isCurrent())
512
+ return;
513
+ const parent = path.dirname(target.targetPath);
514
+ await fsp.mkdir(parent, { recursive: true });
515
+ let stagingDir = stagingDirs.get(parent);
516
+ if (stagingDir == null) {
517
+ // Stage beside each destination so publication also works across nested filesystems.
518
+ stagingDir = await fsp.mkdtemp(path.join(parent, '.transloadit-bundle-'));
519
+ stagingDirs.set(parent, stagingDir);
520
+ }
521
+ const stagingPath = path.join(stagingDir, String(index));
522
+ staged.push({ ...target, stagingPath });
523
+ onDownload();
524
+ await downloadResultToFile(target.resultUrl, stagingPath, signal, () => true);
525
+ }
526
+ await commits.add(async () => {
527
+ if (!isCurrent())
528
+ return;
529
+ // Finish publication once it starts, even if superseded. A failed newer job must not
530
+ // strand this playlist without its segments, and newer commits must wait for this one.
531
+ for (const { stagingPath, targetPath } of staged) {
532
+ await fsp.mkdir(path.dirname(targetPath), { recursive: true });
533
+ await fsp.rename(stagingPath, targetPath);
534
+ }
535
+ });
536
+ }
537
+ finally {
538
+ await Promise.all([...stagingDirs.values()].map(async (dir) => await fsp.rm(dir, { recursive: true, force: true })));
539
+ }
540
+ }
541
+ async function assertNoOutputSymlinks(root, target) {
542
+ // Reject preexisting descendant symlinks; another process can still race filesystem checks.
543
+ const relativePath = path.relative(root, target);
544
+ // The caller explicitly selected the root; only descendants come from result/input metadata.
545
+ if (relativePath === '')
546
+ return;
547
+ if (relativePath.startsWith(`..${path.sep}`) ||
548
+ relativePath === '..' ||
549
+ path.isAbsolute(relativePath)) {
550
+ throw new Error('Assembly result path escapes the output directory');
551
+ }
552
+ let current = root;
553
+ for (const part of relativePath.split(path.sep)) {
554
+ current = path.join(current, part);
555
+ const [error, stats] = await tryCatch(fsp.lstat(current));
556
+ if (error) {
557
+ if (isErrnoException(error) && (error.code === 'ENOENT' || error.code === 'ENOTDIR'))
558
+ return;
559
+ throw error;
560
+ }
561
+ if (stats.isSymbolicLink()) {
562
+ throw new Error('Assembly result path contains a symbolic link');
563
+ }
564
+ }
565
+ }
566
+ async function isDownloadPathTaken(target, reservedPaths, owner, directory = false, overwriteExistingFile = false) {
567
+ const key = downloadPathKey(target, reservedPaths);
568
+ if (directory && reservedPaths.directories.has(key))
569
+ return false;
570
+ const existingOwner = reservedPaths.ownersByPath.get(key);
571
+ if (existingOwner != null)
572
+ return existingOwner !== owner;
573
+ if (reservedPaths.directories.has(key) || hasReservedFileAncestor(target, reservedPaths)) {
574
+ return true;
575
+ }
576
+ const [error, stats] = await tryCatch(fsp.lstat(target));
577
+ if (isErrnoException(error) && error.code === 'ENOTDIR')
578
+ return true;
579
+ if (error && (!isErrnoException(error) || error.code !== 'ENOENT'))
580
+ throw error;
581
+ if (error != null)
582
+ return false;
583
+ if (directory)
584
+ return !stats?.isDirectory();
585
+ return !overwriteExistingFile || !stats?.isFile();
586
+ }
587
+ async function prepareDownloadDirectory(root, target, reservedPaths) {
588
+ await assertNoOutputSymlinks(root, target);
589
+ if (hasReservedFileAncestor(target, reservedPaths)) {
590
+ throw new Error('Output directory conflicts with a result file');
591
+ }
592
+ await fsp.mkdir(target, { recursive: true });
413
593
  }
414
594
  const STALE_OUTPUT_GRACE_MS = 1000;
415
595
  function isMeaningfullyNewer(newer, older) {
416
596
  return newer.getTime() - older.getTime() > STALE_OUTPUT_GRACE_MS;
417
597
  }
418
- async function buildDirectoryDownloadTargets({ allFiles, baseDir, groupByStep, reservedPaths, }) {
419
- await fsp.mkdir(baseDir, { recursive: true });
598
+ const resultSourceSchema = assemblyStatusResultSchema.pick({
599
+ original_basename: true,
600
+ original_id: true,
601
+ original_name: true,
602
+ original_path: true,
603
+ });
604
+ function groupDirectoryResults(allFiles) {
605
+ const steps = new Map();
606
+ for (const file of allFiles) {
607
+ const files = steps.get(file.stepName) ?? [];
608
+ files.push({ file, relativePath: getResultRelativePath(file) });
609
+ steps.set(file.stepName, files);
610
+ }
611
+ const groups = [];
612
+ for (const [stepName, stepFiles] of steps) {
613
+ if (!stepFiles.some((file) => file.relativePath != null)) {
614
+ groups.push({ directoryName: null, files: stepFiles, ownerKey: null, stepName });
615
+ continue;
616
+ }
617
+ const sources = new Map();
618
+ for (const file of stepFiles) {
619
+ const parsed = resultSourceSchema.safeParse(file.file.file);
620
+ const source = parsed.success ? parsed.data : undefined;
621
+ const sourceId = JSON.stringify(source?.original_id ?? null);
622
+ const name = source?.original_name ?? source?.original_basename ?? null;
623
+ const group = sources.get(sourceId) ?? {
624
+ // original_path can be a shared directory, so the source filename is also required.
625
+ identity: JSON.stringify([source?.original_path ?? null, name]),
626
+ name,
627
+ files: [],
628
+ };
629
+ group.files.push(file);
630
+ sources.set(sourceId, group);
631
+ }
632
+ const sourceOccurrences = new Map();
633
+ for (const { identity, name, files } of sources.values()) {
634
+ const occurrence = sourceOccurrences.get(identity) ?? 0;
635
+ sourceOccurrences.set(identity, occurrence + 1);
636
+ // Ordinary files can be renamed independently, even when they share an HLS source.
637
+ for (const linked of [true, false]) {
638
+ const groupFiles = files.filter(({ relativePath }) => (relativePath != null) === linked);
639
+ if (groupFiles.length === 0)
640
+ continue;
641
+ groups.push({
642
+ directoryName: sources.size > 1
643
+ ? sanitizeResultName(path.parse(name ?? stepName).name, stepName)
644
+ : null,
645
+ files: groupFiles,
646
+ // Upload IDs change between watch runs. Paths and names keep source ownership stable
647
+ // even when results arrive in a different order.
648
+ ownerKey: JSON.stringify([identity, occurrence, linked]),
649
+ stepName,
650
+ });
651
+ }
652
+ }
653
+ }
654
+ return groups;
655
+ }
656
+ async function buildDirectoryDownloadTargets({ allFiles, baseDir, groupByStep, owner, outputRoot, overwritePath, reservedPaths, }) {
657
+ await prepareDownloadDirectory(outputRoot, baseDir, reservedPaths);
420
658
  const targets = [];
421
- for (const resultFile of allFiles) {
422
- const targetDir = groupByStep ? path.join(baseDir, resultFile.stepName) : baseDir;
423
- await fsp.mkdir(targetDir, { recursive: true });
424
- targets.push({
425
- resultUrl: resultFile.url,
426
- targetPath: await ensureUniquePath(path.join(targetDir, getResultFileName(resultFile)), reservedPaths),
427
- });
659
+ for (const { directoryName, files, ownerKey, stepName } of groupDirectoryResults(allFiles)) {
660
+ const safeStepName = sanitizeResultName(stepName);
661
+ let targetDir = groupByStep ? path.join(baseDir, safeStepName) : baseDir;
662
+ if (directoryName != null)
663
+ targetDir = path.join(targetDir, directoryName);
664
+ const linked = files.some((file) => file.relativePath != null);
665
+ const groupOwner = JSON.stringify([owner, stepName, ownerKey]);
666
+ const selectedGroupDir = linked ? reservedPaths.pathsByOwner.get(groupOwner) : undefined;
667
+ if (selectedGroupDir != null)
668
+ targetDir = selectedGroupDir;
669
+ const names = files.map(({ file, relativePath }) => relativePath ?? sanitizeResultName(file.name));
670
+ if (linked &&
671
+ new Set(names.map((name) => downloadPathKey(path.join(targetDir, name), reservedPaths)))
672
+ .size !== names.length) {
673
+ // Merged Steps can hide distinct bundles sharing one upload (#619). Reject ambiguous
674
+ // linked paths rather than guessing bundle boundaries and overwriting their contents.
675
+ throw new Error('Assembly results have duplicate relative paths');
676
+ }
677
+ for (const name of names) {
678
+ await assertNoOutputSymlinks(outputRoot, path.join(targetDir, name));
679
+ }
680
+ if (linked) {
681
+ // Source folders share this parent, so changing their suffix cannot fix a blocked parent.
682
+ if (directoryName != null) {
683
+ await prepareDownloadDirectory(outputRoot, path.dirname(targetDir), reservedPaths);
684
+ }
685
+ const initialDir = targetDir;
686
+ // Selection and reservation share the same lock as ordinary result paths. A bundle must
687
+ // move together on collisions, while later watch updates reuse its owned destinations.
688
+ targetDir = await ensureUniqueCounterValue({
689
+ initialValue: initialDir,
690
+ isTaken: async (candidate) => {
691
+ if (candidate !== initialDir &&
692
+ (await isDownloadPathTaken(candidate, reservedPaths, groupOwner)))
693
+ return true;
694
+ const collisions = await Promise.all(names.map((name) => isDownloadPathTaken(path.join(candidate, name), reservedPaths, groupOwner)));
695
+ const collides = collisions.some(Boolean);
696
+ // Moving an already downloaded bundle would leave its previous playlist stale.
697
+ if (collides && selectedGroupDir != null) {
698
+ throw new Error('Assembly result path conflicts with an existing download');
699
+ }
700
+ return collides;
701
+ },
702
+ reserve: (candidate) => {
703
+ if (candidate !== initialDir)
704
+ reserveDownloadPath(candidate, groupOwner, reservedPaths, true);
705
+ for (const name of names) {
706
+ reserveDownloadPath(path.join(candidate, name), groupOwner, reservedPaths);
707
+ }
708
+ reservedPaths.pathsByOwner.set(groupOwner, candidate);
709
+ },
710
+ nextValue: (counter) => {
711
+ if (directoryName != null) {
712
+ return path.join(path.dirname(initialDir), `${directoryName}__${counter}`);
713
+ }
714
+ const suffix = groupByStep ? counter : counter - 1;
715
+ return path.join(baseDir, suffix === 0 ? safeStepName : `${safeStepName}__${suffix}`);
716
+ },
717
+ scope: reservedPaths,
718
+ });
719
+ }
720
+ await prepareDownloadDirectory(outputRoot, targetDir, reservedPaths);
721
+ const nameOccurrences = new Map();
722
+ for (const [index, { file }] of files.entries()) {
723
+ const name = names[index];
724
+ const occurrence = nameOccurrences.get(name) ?? 0;
725
+ nameOccurrences.set(name, occurrence + 1);
726
+ const targetPath = path.join(targetDir, name);
727
+ const selectedPath = linked
728
+ ? targetPath
729
+ : await ensureUniquePath(targetPath, reservedPaths, JSON.stringify([groupOwner, name, occurrence]), false, overwritePath != null && path.resolve(targetPath) === path.resolve(overwritePath));
730
+ await assertNoOutputSymlinks(outputRoot, selectedPath);
731
+ targets.push({
732
+ bundleRoot: linked ? targetDir : undefined,
733
+ resultUrl: file.url,
734
+ targetPath: selectedPath,
735
+ });
736
+ }
428
737
  }
429
738
  return targets;
430
739
  }
@@ -436,20 +745,28 @@ function getSingleResultDownloadTarget(allFiles, targetPath) {
436
745
  }
437
746
  return [{ resultUrl, targetPath }];
438
747
  }
439
- async function resolveResultDownloadTargets({ hasDirectoryInput, inPath, inputs, normalizedResults, outputMode, outputPath, outputRoot, outputRootIsDirectory, reservedPaths, singleAssembly, }) {
748
+ async function resolveResultDownloadTargets({ hasDirectoryInput, inPath, inputs, normalizedResults, outputMode, outputPath, outputRoot, outputRootIsDirectory, owner, reservedPaths, singleAssembly, }) {
440
749
  const { allFiles, entries } = normalizedResults;
441
- const shouldGroupByInput = !singleAssembly && inPath != null && (hasDirectoryInput || inputs.length > 1);
750
+ const linked = allFiles.some((file) => getResultRelativePath(file) != null);
751
+ // Ordinary CLI outputs retain their input/Step layout. HLS uses linked result paths so
752
+ // playlist references remain reachable; moving those formerly broken outputs is intentional.
753
+ const keepInputStepLayout = outputMode !== 'directory' && inPath != null && allFiles.length > 1 && !linked;
754
+ const shouldGroupByInput = !singleAssembly &&
755
+ inPath != null &&
756
+ (hasDirectoryInput ||
757
+ (inputs.length > 1 && (allFiles.length > 1 || outputMode === 'directory')));
442
758
  const resolveDirectoryBaseDir = () => {
443
- if (!shouldGroupByInput || inPath == null) {
444
- return outputRoot;
759
+ if (outputPath != null && (keepInputStepLayout || hasDirectoryInput)) {
760
+ return allFiles.length === 1 && outputMode !== 'directory'
761
+ ? path.dirname(outputPath)
762
+ : path.join(path.dirname(outputPath), path.parse(outputPath).name);
445
763
  }
446
- if (hasDirectoryInput && outputPath != null) {
447
- const mappedRelative = path.relative(outputRoot, outputPath);
448
- const mappedDir = path.dirname(mappedRelative);
449
- const mappedStem = path.parse(mappedRelative).name;
450
- return path.join(outputRoot, mappedDir === '.' ? '' : mappedDir, mappedStem);
764
+ if (!linked && outputMode !== 'directory' && inPath != null && outputPath != null) {
765
+ return path.dirname(outputPath);
451
766
  }
452
- return path.join(outputRoot, path.parse(path.basename(inPath)).name);
767
+ if (!shouldGroupByInput || inPath == null)
768
+ return outputRoot;
769
+ return path.join(outputRoot, path.parse(path.basename(outputPath ?? inPath)).name);
453
770
  };
454
771
  if (!outputRootIsDirectory) {
455
772
  if (allFiles.length > 1) {
@@ -465,24 +782,28 @@ async function resolveResultDownloadTargets({ hasDirectoryInput, inPath, inputs,
465
782
  allFiles,
466
783
  baseDir: outputRoot,
467
784
  groupByStep: inputs.length === 0,
785
+ owner,
786
+ outputRoot,
468
787
  reservedPaths,
469
788
  });
470
789
  }
471
- if (outputMode === 'directory' || outputPath == null || inPath == null) {
472
- return await buildDirectoryDownloadTargets({
473
- allFiles,
474
- baseDir: resolveDirectoryBaseDir(),
475
- groupByStep: inPath == null || entries.length > 1,
476
- reservedPaths,
477
- });
478
- }
479
- if (allFiles.length === 1) {
480
- return getSingleResultDownloadTarget(allFiles, outputPath);
790
+ let baseDir = resolveDirectoryBaseDir();
791
+ if (path.resolve(baseDir) !== path.resolve(outputRoot)) {
792
+ // Input grouping directories share the result namespace, including files already reserved
793
+ // by another Assembly. Reuse existing directories and suffix a conflicting filename.
794
+ await assertNoOutputSymlinks(outputRoot, baseDir);
795
+ await prepareDownloadDirectory(outputRoot, path.dirname(baseDir), reservedPaths);
796
+ baseDir = await ensureUniquePath(baseDir, reservedPaths, JSON.stringify([owner, 'input-directory']), true);
481
797
  }
482
798
  return await buildDirectoryDownloadTargets({
483
799
  allFiles,
484
- baseDir: path.join(path.dirname(outputPath), path.parse(outputPath).name),
485
- groupByStep: true,
800
+ baseDir,
801
+ groupByStep: keepInputStepLayout || inPath == null || entries.length > 1,
802
+ owner,
803
+ outputRoot,
804
+ // The mapped single-file destination is also the historical freshness marker. Updating it
805
+ // lets an unchanged rerun skip processing; differently named results retain collision suffixes.
806
+ overwritePath: inPath != null && allFiles.length === 1 && !linked ? (outputPath ?? undefined) : undefined,
486
807
  reservedPaths,
487
808
  });
488
809
  }
@@ -521,11 +842,12 @@ async function shouldSkipStaleOutput({ inputPaths, outputPath, outputPlanMtime,
521
842
  return inputStat != null && isMeaningfullyNewer(outputStat.mtime, inputStat.mtime);
522
843
  });
523
844
  }
524
- async function materializeAssemblyResults({ abortSignal, hasDirectoryInput, inPath, inputs, normalizedResults, outputMode, outputPath, outputRoot, outputRootIsDirectory, outputctl, reservedPaths, singleAssembly, }) {
845
+ async function materializeAssemblyResults({ abortSignal, hasDirectoryInput, inPath, isCurrent, inputs, normalizedResults, outputMode, outputPath, outputRoot, outputRootIsDirectory, outputctl, owner, reservedPaths, singleAssembly, }) {
525
846
  if (outputRoot == null) {
526
847
  return;
527
848
  }
528
- const targets = await resolveResultDownloadTargets({
849
+ // Directory creation and filename reservation must share a lock across concurrent jobs.
850
+ const targets = await reservedPaths.allocations.add(async () => await resolveResultDownloadTargets({
529
851
  hasDirectoryInput,
530
852
  inPath,
531
853
  inputs,
@@ -534,14 +856,32 @@ async function materializeAssemblyResults({ abortSignal, hasDirectoryInput, inPa
534
856
  outputPath,
535
857
  outputRoot,
536
858
  outputRootIsDirectory,
859
+ owner,
537
860
  reservedPaths,
538
861
  singleAssembly,
539
- });
540
- for (const { resultUrl, targetPath } of targets) {
541
- outputctl.debug('DOWNLOADING');
542
- const [dlErr] = await tryCatch(targetPath == null
543
- ? downloadResultToStdout(resultUrl, abortSignal)
544
- : downloadResultToFile(resultUrl, targetPath, abortSignal));
862
+ }));
863
+ const bundles = new Map();
864
+ const downloads = [];
865
+ for (const { bundleRoot, resultUrl, targetPath } of targets) {
866
+ if (bundleRoot != null && targetPath != null) {
867
+ const files = bundles.get(bundleRoot) ?? [];
868
+ files.push({ resultUrl, targetPath });
869
+ bundles.set(bundleRoot, files);
870
+ continue;
871
+ }
872
+ downloads.push(async () => {
873
+ outputctl.debug('DOWNLOADING');
874
+ if (targetPath == null)
875
+ await downloadResultToStdout(resultUrl, abortSignal);
876
+ else
877
+ await downloadResultToFile(resultUrl, targetPath, abortSignal, isCurrent);
878
+ });
879
+ }
880
+ for (const files of bundles.values()) {
881
+ downloads.push(async () => await downloadResultBundle(files, abortSignal, isCurrent, reservedPaths.bundleCommits, () => outputctl.debug('DOWNLOADING')));
882
+ }
883
+ for (const download of downloads) {
884
+ const [dlErr] = await tryCatch(download());
545
885
  if (dlErr) {
546
886
  if (dlErr.name === 'AbortError') {
547
887
  continue;
@@ -784,7 +1124,8 @@ function dismissStaleJobs(jobEmitter) {
784
1124
  jobEmitter.on('end', () => Promise.all(pendingChecks).then(() => emitter.emit('end')));
785
1125
  jobEmitter.on('error', (err) => emitter.emit('error', err));
786
1126
  jobEmitter.on('job', (job) => {
787
- if (job.inputPath == null || job.out == null) {
1127
+ // An older download can finish after a watch change and have a misleading newer mtime.
1128
+ if (job.watchEvent || job.inputPath == null || job.out == null) {
788
1129
  emitter.emit('job', job);
789
1130
  return;
790
1131
  }
@@ -867,8 +1208,11 @@ function makeJobEmitter(inputs, { allowOutputCollisions, recursive, outputPlanPr
867
1208
  return staleFilter(conflictFilter(emitter));
868
1209
  }
869
1210
  const DEFAULT_CONCURRENCY = 5;
870
- // --- Main assembly create function ---
871
- export async function create(outputctl, client, { steps, stepsData, template, fields, outputMode, watch: watchOption, recursive, inputs, output, del, reprocessStale, singleAssembly, concurrency = DEFAULT_CONCURRENCY, }) {
1211
+ const DEFAULT_URL_DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000;
1212
+ function isUrlInput(input) {
1213
+ return /^https?:\/\//i.test(input);
1214
+ }
1215
+ async function prepareCreateOptions(outputctl, { steps, stepsData, outputMode, output, inputs, singleAssembly, transientInputPaths, }) {
872
1216
  // Quick fix for https://github.com/transloadit/transloadify/issues/13
873
1217
  // Only default to stdout when output is undefined (not provided), not when explicitly null
874
1218
  let resolvedOutput = output;
@@ -901,7 +1245,9 @@ export async function create(outputctl, client, { steps, stepsData, template, fi
901
1245
  if (!outstat.isDirectory() && inputs.length !== 0 && !singleAssembly) {
902
1246
  const firstInput = inputs[0];
903
1247
  if (firstInput) {
904
- const firstInputStat = await myStat(process.stdin, firstInput);
1248
+ const firstInputStat = isUrlInput(firstInput)
1249
+ ? { isDirectory: () => false }
1250
+ : await myStat(process.stdin, firstInput);
905
1251
  if (inputs.length > 1 || firstInputStat.isDirectory()) {
906
1252
  const msg = 'Output must be a directory when specifying multiple inputs';
907
1253
  outputctl.error(msg);
@@ -911,11 +1257,70 @@ export async function create(outputctl, client, { steps, stepsData, template, fi
911
1257
  }
912
1258
  }
913
1259
  const inputStats = await Promise.all(inputs.map(async (input) => {
914
- if (input === '-')
1260
+ if (input === '-' || isUrlInput(input))
915
1261
  return null;
916
1262
  return await myStat(process.stdin, input);
917
1263
  }));
918
1264
  const hasDirectoryInput = inputStats.some((stat) => stat?.isDirectory() === true);
1265
+ return {
1266
+ effectiveStepsData,
1267
+ hasDirectoryInput,
1268
+ outstat,
1269
+ resolvedOutput,
1270
+ transientInputPaths: transientInputPaths == null
1271
+ ? undefined
1272
+ : new Set(transientInputPaths.map((inputPath) => path.resolve(inputPath))),
1273
+ };
1274
+ }
1275
+ // --- Main assembly create function ---
1276
+ /** Create Assemblies after materializing URL inputs as temporary local uploads. */
1277
+ export async function create(outputctl, client, options) {
1278
+ const preparedOptions = await prepareCreateOptions(outputctl, options);
1279
+ const urlDownloadTimeoutMs = toPositiveInteger(options.urlDownloadTimeoutMs, '--url-download-timeout') ??
1280
+ DEFAULT_URL_DOWNLOAD_TIMEOUT_MS;
1281
+ if (!options.inputs.some(isUrlInput))
1282
+ return await createWithLocalInputs(outputctl, client, options, preparedOptions);
1283
+ if (options.watch)
1284
+ throw new Error('--watch requires local inputs');
1285
+ const preparedInputs = [];
1286
+ const localInputs = [];
1287
+ const transientInputPaths = preparedOptions.transientInputPaths ?? new Set();
1288
+ try {
1289
+ for (const [index, input] of options.inputs.entries()) {
1290
+ if (!isUrlInput(input)) {
1291
+ localInputs.push(input);
1292
+ continue;
1293
+ }
1294
+ const field = String(index);
1295
+ // Separate staging roots protect case-only filenames and repeated URLs from aliasing.
1296
+ const prepared = await prepareInputFiles({
1297
+ inputFiles: [{ kind: 'url', field, url: input }],
1298
+ allowPrivateUrls: false,
1299
+ // Uploading preserves :original and Template semantics, just as Intents do.
1300
+ urlStrategy: 'download',
1301
+ urlDownloadTimeoutMs,
1302
+ });
1303
+ preparedInputs.push(prepared);
1304
+ const localPath = prepared.files[field];
1305
+ if (localPath == null)
1306
+ throw new Error('URL input could not be prepared');
1307
+ localInputs.push(localPath);
1308
+ transientInputPaths.add(path.resolve(localPath));
1309
+ }
1310
+ return await createWithLocalInputs(outputctl, client, {
1311
+ ...options,
1312
+ inputs: localInputs,
1313
+ }, { ...preparedOptions, transientInputPaths });
1314
+ }
1315
+ finally {
1316
+ await Promise.all(preparedInputs.flatMap((prepared) => prepared.cleanup.map((cleanup) => cleanup())));
1317
+ }
1318
+ }
1319
+ async function createWithLocalInputs(outputctl, client, { template, fields, outputMode, watch: watchOption, recursive, inputs, del, reprocessStale, singleAssembly, concurrency = DEFAULT_CONCURRENCY, printUrls = false, }, preparedOptions) {
1320
+ const { effectiveStepsData, hasDirectoryInput, outstat, resolvedOutput } = preparedOptions;
1321
+ const nameSensitivity = resolvedOutput != null && outstat?.isDirectory()
1322
+ ? await outputNameSensitivity(resolvedOutput)
1323
+ : { caseSensitive: true, normalizationSensitive: true };
919
1324
  return new Promise((resolve, reject) => {
920
1325
  const params = (effectiveStepsData
921
1326
  ? { steps: effectiveStepsData }
@@ -926,7 +1331,7 @@ export async function create(outputctl, client, { steps, stepsData, template, fi
926
1331
  const outputPlanProvider = resolvedOutput == null
927
1332
  ? nullProvider()
928
1333
  : outstat?.isDirectory()
929
- ? dirProvider(resolvedOutput)
1334
+ ? dirProvider(resolvedOutput, preparedOptions.transientInputPaths)
930
1335
  : fileProvider(resolvedOutput);
931
1336
  const emitter = makeJobEmitter(inputs, {
932
1337
  allowOutputCollisions: singleAssembly,
@@ -939,8 +1344,16 @@ export async function create(outputctl, client, { steps, stepsData, template, fi
939
1344
  // Use p-queue for concurrency management
940
1345
  const queue = new PQueue({ concurrency });
941
1346
  const results = [];
1347
+ const assemblies = [];
942
1348
  const resultUrls = [];
943
- const reservedResultPaths = new Set();
1349
+ const reservedResultPaths = {
1350
+ allocations: new PQueue({ concurrency: 1 }),
1351
+ bundleCommits: new PQueue({ concurrency: 1 }),
1352
+ ...nameSensitivity,
1353
+ directories: new Set(),
1354
+ ownersByPath: new Map(),
1355
+ pathsByOwner: new Map(),
1356
+ };
944
1357
  const latestWatchJobTokenByOutputPath = new Map();
945
1358
  let hasFailures = false;
946
1359
  let nextWatchJobToken = 0;
@@ -975,28 +1388,88 @@ export async function create(outputctl, client, { steps, stepsData, template, fi
975
1388
  return createOptions;
976
1389
  }
977
1390
  async function awaitCompletedAssembly(createOptions) {
978
- const result = await client.createAssembly(createOptions);
979
- const assemblyId = result.assembly_id;
980
- if (!assemblyId)
1391
+ let assemblyReference;
1392
+ function reportCreatedAssembly(rawAssembly) {
1393
+ const assembly = assemblySummarySchema.parse(rawAssembly);
1394
+ if (assemblyReference || !assembly.assembly_id)
1395
+ return;
1396
+ assemblyReference = formatAssemblyReference({
1397
+ assemblyId: assembly.assembly_id,
1398
+ assemblyUrl: assembly.assembly_ssl_url ?? assembly.assembly_url ?? null,
1399
+ });
1400
+ outputctl.notice(assemblyReference);
1401
+ }
1402
+ const creation = client.createAssembly({
1403
+ ...createOptions,
1404
+ onAssemblyCreated: reportCreatedAssembly,
1405
+ });
1406
+ const [creationError, result] = await tryCatch(creation);
1407
+ if (creationError) {
1408
+ if (!assemblyReference && creationError instanceof RequestError && creation.assemblyId) {
1409
+ const requestUrl = creationError.options.url;
1410
+ assemblyReference = formatAssemblyReference({
1411
+ assemblyId: creation.assemblyId,
1412
+ assemblyUrl: requestUrl instanceof URL ? `${requestUrl.origin}${requestUrl.pathname}` : null,
1413
+ });
1414
+ }
1415
+ if (!assemblyReference)
1416
+ throw creationError;
1417
+ throw new Error(`${formatAPIError(creationError)} (${assemblyReference})`, {
1418
+ cause: creationError,
1419
+ });
1420
+ }
1421
+ reportCreatedAssembly(result);
1422
+ const createdSummary = assemblySummarySchema.parse(result);
1423
+ const assemblyId = createdSummary.assembly_id;
1424
+ if (!assemblyId || !assemblyReference)
981
1425
  throw new Error('No assembly_id in result');
982
- const assemblyUrl = result.assembly_ssl_url ?? result.assembly_url ?? null;
983
- const assemblyReference = formatAssemblyReference({ assemblyId, assemblyUrl });
984
1426
  let assembly;
985
1427
  try {
986
1428
  assembly = await client.awaitAssemblyCompletion(assemblyId, {
987
1429
  signal: abortController.signal,
988
1430
  onPoll: () => true,
989
1431
  onAssemblyProgress: (status) => {
990
- outputctl.debug(`Assembly status: ${status.ok}`);
1432
+ const summary = assemblySummarySchema.parse(status);
1433
+ outputctl.debug(`Assembly status: ${summary.ok ?? 'unknown'}`);
991
1434
  },
992
1435
  });
993
1436
  }
994
1437
  catch (err) {
995
1438
  const error = ensureError(err);
996
- throw new Error(`${error.message} (${assemblyReference})`, { cause: error });
1439
+ const message = error instanceof RequestError ? formatAPIError(error) : error.message;
1440
+ throw new Error(`${message} (${assemblyReference})`, { cause: error });
997
1441
  }
1442
+ const summary = assemblySummarySchema.parse(assembly);
1443
+ summary.assembly_id = assemblyId;
1444
+ summary.assembly_url ??= createdSummary.assembly_url;
1445
+ summary.assembly_ssl_url ??= createdSummary.assembly_ssl_url;
1446
+ const ignoredErrors = summary.ignored_errors;
1447
+ const ignoredErrorCount = Math.max(summary.ignored_error_count, ignoredErrors.length);
1448
+ summary.ignored_error_count = ignoredErrorCount;
1449
+ // Preserve stdout file bytes. URL mode combines all summaries with its existing JSON object.
1450
+ if (resolvedOutput !== '-') {
1451
+ if (printUrls)
1452
+ assemblies.push(summary);
1453
+ else
1454
+ outputctl.print(undefined, summary);
1455
+ }
1456
+ if (ignoredErrorCount > 0) {
1457
+ const details = ignoredErrors
1458
+ .map((ignored) => {
1459
+ const code = typeof ignored.error === 'string' ? ignored.error : ignored.error?.code;
1460
+ const message = ignored.message ||
1461
+ (typeof ignored.error === 'object' ? ignored.error.message : undefined);
1462
+ return [ignored.step, code, message].filter(Boolean).join(': ');
1463
+ })
1464
+ .filter(Boolean)
1465
+ .slice(0, 5);
1466
+ outputctl.warn(`${ignoredErrorCount} ignored error${ignoredErrorCount === 1 ? '' : 's'} (${assemblyReference})${details.length > 0 ? `: ${details.join('; ')}` : ''}`);
1467
+ }
1468
+ // Malformed error fields still signal failure, but only sanitized values enter the message.
998
1469
  if (assembly.error || (assembly.ok && assembly.ok !== 'ASSEMBLY_COMPLETED')) {
999
- const msg = `Assembly failed: ${assembly.error || assembly.message} (Status: ${assembly.ok}, ${assemblyReference})`;
1470
+ const details = [summary.error ?? summary.ok, summary.message].filter(Boolean).join(': ') ||
1471
+ 'Unknown Assembly error';
1472
+ const msg = `Assembly failed: ${details} (${assemblyReference})`;
1000
1473
  throw new Error(msg);
1001
1474
  }
1002
1475
  return { assembly, assemblyId };
@@ -1028,16 +1501,26 @@ export async function create(outputctl, client, { steps, stepsData, template, fi
1028
1501
  abortSignal: abortController.signal,
1029
1502
  hasDirectoryInput: singleAssemblyMode ? false : hasDirectoryInput,
1030
1503
  inPath,
1031
- inputs: inputPaths,
1504
+ isCurrent: () => !isSupersededWatchJob(outputPlan?.path ?? null, outputToken),
1505
+ inputs: singleAssemblyMode ? inputPaths : inputs,
1032
1506
  normalizedResults,
1033
1507
  outputMode,
1034
1508
  outputPath: outputPlan?.path ?? null,
1035
1509
  outputRoot: resolvedOutput ?? null,
1036
1510
  outputRootIsDirectory,
1037
1511
  outputctl,
1512
+ // Only watch updates reuse destinations; independent Assemblies retain every result.
1513
+ owner: JSON.stringify([
1514
+ inPath,
1515
+ singleAssemblyMode ? inputPaths : [],
1516
+ watchOption ? null : assemblyId,
1517
+ ]),
1038
1518
  reservedPaths: reservedResultPaths,
1039
1519
  singleAssembly: singleAssemblyMode,
1040
1520
  });
1521
+ // Only the current watch job may delete its input; a failed newer job must retain it.
1522
+ if (isSupersededWatchJob(outputPlan?.path ?? null, outputToken))
1523
+ return assembly;
1041
1524
  outputctl.debug(`COMPLETED ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`);
1042
1525
  if (del) {
1043
1526
  for (const inputPath of inputPaths) {
@@ -1072,7 +1555,7 @@ export async function create(outputctl, client, { steps, stepsData, template, fi
1072
1555
  function handleEmitterError(err) {
1073
1556
  abortController.abort();
1074
1557
  queue.clear();
1075
- outputctl.error(err);
1558
+ outputctl.error(formatAPIError(err));
1076
1559
  reject(err);
1077
1560
  }
1078
1561
  function runSingleAssemblyEmitter() {
@@ -1097,7 +1580,7 @@ export async function create(outputctl, client, { steps, stepsData, template, fi
1097
1580
  singleInputReference: 'input',
1098
1581
  })) {
1099
1582
  outputctl.debug(`SKIPPED STALE SINGLE ASSEMBLY ${resolvedOutput ?? 'null'}`);
1100
- resolve({ resultUrls, results: [], hasFailures: false });
1583
+ resolve({ assemblies, resultUrls, results: [], hasFailures: false });
1101
1584
  return;
1102
1585
  }
1103
1586
  // Preserve original basenames/extensions for filesystem uploads so the backend
@@ -1140,9 +1623,9 @@ export async function create(outputctl, client, { steps, stepsData, template, fi
1140
1623
  }
1141
1624
  catch (err) {
1142
1625
  hasFailures = true;
1143
- outputctl.error(err);
1626
+ outputctl.error(formatAPIError(err));
1144
1627
  }
1145
- resolve({ resultUrls, results, hasFailures });
1628
+ resolve({ assemblies, resultUrls, results, hasFailures });
1146
1629
  });
1147
1630
  }
1148
1631
  function runPerFileEmitter() {
@@ -1160,12 +1643,12 @@ export async function create(outputctl, client, { steps, stepsData, template, fi
1160
1643
  })
1161
1644
  .catch((err) => {
1162
1645
  hasFailures = true;
1163
- outputctl.error(err);
1646
+ outputctl.error(formatAPIError(err));
1164
1647
  });
1165
1648
  });
1166
1649
  emitter.on('end', async () => {
1167
1650
  await queue.onIdle();
1168
- resolve({ resultUrls, results, hasFailures });
1651
+ resolve({ assemblies, resultUrls, results, hasFailures });
1169
1652
  });
1170
1653
  }
1171
1654
  emitter.on('error', handleEmitterError);
@@ -1276,6 +1759,9 @@ export class AssembliesCreateCommand extends AuthenticatedCommand {
1276
1759
  reprocessStale = reprocessStaleOption();
1277
1760
  singleAssembly = singleAssemblyOption();
1278
1761
  concurrency = concurrencyOption();
1762
+ urlDownloadTimeoutMs = Option.String('--url-download-timeout', {
1763
+ description: 'Maximum time per URL input download in milliseconds (default: 300000)',
1764
+ });
1279
1765
  printUrls = printUrlsOption();
1280
1766
  async run() {
1281
1767
  if (!this.steps && !this.template) {
@@ -1305,7 +1791,7 @@ export class AssembliesCreateCommand extends AuthenticatedCommand {
1305
1791
  this.output.error(sharedValidationError);
1306
1792
  return 1;
1307
1793
  }
1308
- const { hasFailures, resultUrls } = await create(this.output, this.client, {
1794
+ const { assemblies, hasFailures, resultUrls } = await create(this.output, this.client, {
1309
1795
  steps: this.steps,
1310
1796
  template: this.template,
1311
1797
  fields: fieldsMap ?? {},
@@ -1317,9 +1803,11 @@ export class AssembliesCreateCommand extends AuthenticatedCommand {
1317
1803
  reprocessStale: this.reprocessStale,
1318
1804
  singleAssembly: this.singleAssembly,
1319
1805
  concurrency: this.concurrency == null ? undefined : Number(this.concurrency),
1806
+ printUrls: this.printUrls,
1807
+ urlDownloadTimeoutMs: this.urlDownloadTimeoutMs == null ? undefined : Number(this.urlDownloadTimeoutMs),
1320
1808
  });
1321
1809
  if (this.printUrls) {
1322
- printResultUrls(this.output, resultUrls);
1810
+ printResultUrls(this.output, resultUrls, assemblies);
1323
1811
  }
1324
1812
  return hasFailures ? 1 : undefined;
1325
1813
  }
@@ -1371,6 +1859,9 @@ export class RunCommand extends AuthenticatedCommand {
1371
1859
  reprocessStale = reprocessStaleOption();
1372
1860
  singleAssembly = singleAssemblyOption();
1373
1861
  concurrency = concurrencyOption();
1862
+ urlDownloadTimeoutMs = Option.String('--url-download-timeout', {
1863
+ description: 'Maximum time per URL input download in milliseconds (default: 300000)',
1864
+ });
1374
1865
  printUrls = printUrlsOption();
1375
1866
  async run() {
1376
1867
  const inputList = this.inputs ?? [];
@@ -1405,7 +1896,7 @@ export class RunCommand extends AuthenticatedCommand {
1405
1896
  this.output.error(formatAPIError(error));
1406
1897
  return 1;
1407
1898
  }
1408
- const { hasFailures, resultUrls } = await create(this.output, this.client, {
1899
+ const { assemblies, hasFailures, resultUrls } = await create(this.output, this.client, {
1409
1900
  stepsData: parseStepsInputJson(JSON.stringify(compiled.instructions.steps), this.output),
1410
1901
  fields: fieldsMap ?? {},
1411
1902
  watch: this.watch,
@@ -1416,9 +1907,11 @@ export class RunCommand extends AuthenticatedCommand {
1416
1907
  reprocessStale: this.reprocessStale,
1417
1908
  singleAssembly: this.singleAssembly,
1418
1909
  concurrency: this.concurrency == null ? undefined : Number(this.concurrency),
1910
+ printUrls: this.printUrls,
1911
+ urlDownloadTimeoutMs: this.urlDownloadTimeoutMs == null ? undefined : Number(this.urlDownloadTimeoutMs),
1419
1912
  });
1420
1913
  if (this.printUrls) {
1421
- printResultUrls(this.output, resultUrls);
1914
+ printResultUrls(this.output, resultUrls, assemblies);
1422
1915
  }
1423
1916
  return hasFailures ? 1 : undefined;
1424
1917
  }