transloadit 4.7.4 → 4.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +888 -5
  2. package/dist/Transloadit.d.ts +3 -3
  3. package/dist/Transloadit.d.ts.map +1 -1
  4. package/dist/Transloadit.js +2 -2
  5. package/dist/Transloadit.js.map +1 -1
  6. package/dist/alphalib/types/assembliesGet.d.ts +5 -0
  7. package/dist/alphalib/types/assembliesGet.d.ts.map +1 -1
  8. package/dist/alphalib/types/assemblyReplay.d.ts +5 -0
  9. package/dist/alphalib/types/assemblyReplay.d.ts.map +1 -1
  10. package/dist/alphalib/types/assemblyReplayNotification.d.ts +5 -0
  11. package/dist/alphalib/types/assemblyReplayNotification.d.ts.map +1 -1
  12. package/dist/alphalib/types/assemblyStatus.d.ts +25 -25
  13. package/dist/alphalib/types/assemblyStatus.d.ts.map +1 -1
  14. package/dist/alphalib/types/assemblyStatus.js +4 -1
  15. package/dist/alphalib/types/assemblyStatus.js.map +1 -1
  16. package/dist/alphalib/types/bill.d.ts +5 -0
  17. package/dist/alphalib/types/bill.d.ts.map +1 -1
  18. package/dist/alphalib/types/builtinTemplates.d.ts +83 -0
  19. package/dist/alphalib/types/builtinTemplates.d.ts.map +1 -0
  20. package/dist/alphalib/types/builtinTemplates.js +19 -0
  21. package/dist/alphalib/types/builtinTemplates.js.map +1 -0
  22. package/dist/alphalib/types/robots/ai-chat.d.ts.map +1 -1
  23. package/dist/alphalib/types/robots/ai-chat.js +1 -0
  24. package/dist/alphalib/types/robots/ai-chat.js.map +1 -1
  25. package/dist/alphalib/types/skillFrontmatter.d.ts +29 -0
  26. package/dist/alphalib/types/skillFrontmatter.d.ts.map +1 -0
  27. package/dist/alphalib/types/skillFrontmatter.js +19 -0
  28. package/dist/alphalib/types/skillFrontmatter.js.map +1 -0
  29. package/dist/alphalib/types/template.d.ts +36 -0
  30. package/dist/alphalib/types/template.d.ts.map +1 -1
  31. package/dist/alphalib/types/template.js +10 -0
  32. package/dist/alphalib/types/template.js.map +1 -1
  33. package/dist/alphalib/types/templateCredential.d.ts +10 -0
  34. package/dist/alphalib/types/templateCredential.d.ts.map +1 -1
  35. package/dist/cli/commands/assemblies.d.ts +8 -2
  36. package/dist/cli/commands/assemblies.d.ts.map +1 -1
  37. package/dist/cli/commands/assemblies.js +566 -411
  38. package/dist/cli/commands/assemblies.js.map +1 -1
  39. package/dist/cli/commands/index.d.ts.map +1 -1
  40. package/dist/cli/commands/index.js +5 -0
  41. package/dist/cli/commands/index.js.map +1 -1
  42. package/dist/cli/commands/templates.d.ts.map +1 -1
  43. package/dist/cli/commands/templates.js +4 -14
  44. package/dist/cli/commands/templates.js.map +1 -1
  45. package/dist/cli/fileProcessingOptions.d.ts +35 -0
  46. package/dist/cli/fileProcessingOptions.d.ts.map +1 -0
  47. package/dist/cli/fileProcessingOptions.js +182 -0
  48. package/dist/cli/fileProcessingOptions.js.map +1 -0
  49. package/dist/cli/generateIntentDocs.d.ts +2 -0
  50. package/dist/cli/generateIntentDocs.d.ts.map +1 -0
  51. package/dist/cli/generateIntentDocs.js +321 -0
  52. package/dist/cli/generateIntentDocs.js.map +1 -0
  53. package/dist/cli/intentCommandSpecs.d.ts +36 -0
  54. package/dist/cli/intentCommandSpecs.d.ts.map +1 -0
  55. package/dist/cli/intentCommandSpecs.js +181 -0
  56. package/dist/cli/intentCommandSpecs.js.map +1 -0
  57. package/dist/cli/intentCommands.d.ts +13 -0
  58. package/dist/cli/intentCommands.d.ts.map +1 -0
  59. package/dist/cli/intentCommands.js +368 -0
  60. package/dist/cli/intentCommands.js.map +1 -0
  61. package/dist/cli/intentFields.d.ts +25 -0
  62. package/dist/cli/intentFields.d.ts.map +1 -0
  63. package/dist/cli/intentFields.js +298 -0
  64. package/dist/cli/intentFields.js.map +1 -0
  65. package/dist/cli/intentInputPolicy.d.ts +10 -0
  66. package/dist/cli/intentInputPolicy.d.ts.map +1 -0
  67. package/dist/cli/intentInputPolicy.js +2 -0
  68. package/dist/cli/intentInputPolicy.js.map +1 -0
  69. package/dist/cli/intentRuntime.d.ts +114 -0
  70. package/dist/cli/intentRuntime.d.ts.map +1 -0
  71. package/dist/cli/intentRuntime.js +464 -0
  72. package/dist/cli/intentRuntime.js.map +1 -0
  73. package/dist/cli/resultFiles.d.ts +19 -0
  74. package/dist/cli/resultFiles.d.ts.map +1 -0
  75. package/dist/cli/resultFiles.js +66 -0
  76. package/dist/cli/resultFiles.js.map +1 -0
  77. package/dist/cli/resultUrls.d.ts +19 -0
  78. package/dist/cli/resultUrls.d.ts.map +1 -0
  79. package/dist/cli/resultUrls.js +36 -0
  80. package/dist/cli/resultUrls.js.map +1 -0
  81. package/dist/cli/semanticIntents/imageDescribe.d.ts +43 -0
  82. package/dist/cli/semanticIntents/imageDescribe.d.ts.map +1 -0
  83. package/dist/cli/semanticIntents/imageDescribe.js +188 -0
  84. package/dist/cli/semanticIntents/imageDescribe.js.map +1 -0
  85. package/dist/cli/semanticIntents/index.d.ts +18 -0
  86. package/dist/cli/semanticIntents/index.d.ts.map +1 -0
  87. package/dist/cli/semanticIntents/index.js +18 -0
  88. package/dist/cli/semanticIntents/index.js.map +1 -0
  89. package/dist/cli/semanticIntents/markdownPdf.d.ts +4 -0
  90. package/dist/cli/semanticIntents/markdownPdf.d.ts.map +1 -0
  91. package/dist/cli/semanticIntents/markdownPdf.js +93 -0
  92. package/dist/cli/semanticIntents/markdownPdf.js.map +1 -0
  93. package/dist/cli/semanticIntents/parsing.d.ts +11 -0
  94. package/dist/cli/semanticIntents/parsing.d.ts.map +1 -0
  95. package/dist/cli/semanticIntents/parsing.js +29 -0
  96. package/dist/cli/semanticIntents/parsing.js.map +1 -0
  97. package/dist/cli/stepsInput.d.ts +4 -0
  98. package/dist/cli/stepsInput.d.ts.map +1 -0
  99. package/dist/cli/stepsInput.js +23 -0
  100. package/dist/cli/stepsInput.js.map +1 -0
  101. package/dist/cli.d.ts +1 -1
  102. package/dist/cli.d.ts.map +1 -1
  103. package/dist/cli.js +5 -4
  104. package/dist/cli.js.map +1 -1
  105. package/dist/ensureUniqueCounter.d.ts +8 -0
  106. package/dist/ensureUniqueCounter.d.ts.map +1 -0
  107. package/dist/ensureUniqueCounter.js +48 -0
  108. package/dist/ensureUniqueCounter.js.map +1 -0
  109. package/dist/inputFiles.d.ts +9 -0
  110. package/dist/inputFiles.d.ts.map +1 -1
  111. package/dist/inputFiles.js +177 -26
  112. package/dist/inputFiles.js.map +1 -1
  113. package/dist/robots.js +1 -1
  114. package/dist/robots.js.map +1 -1
  115. package/package.json +9 -7
  116. package/src/Transloadit.ts +3 -3
  117. package/src/alphalib/types/assemblyStatus.ts +4 -1
  118. package/src/alphalib/types/builtinTemplates.ts +24 -0
  119. package/src/alphalib/types/robots/ai-chat.ts +1 -0
  120. package/src/alphalib/types/skillFrontmatter.ts +24 -0
  121. package/src/alphalib/types/template.ts +14 -0
  122. package/src/cli/commands/assemblies.ts +825 -505
  123. package/src/cli/commands/index.ts +6 -3
  124. package/src/cli/commands/templates.ts +6 -17
  125. package/src/cli/fileProcessingOptions.ts +294 -0
  126. package/src/cli/generateIntentDocs.ts +419 -0
  127. package/src/cli/intentCommandSpecs.ts +282 -0
  128. package/src/cli/intentCommands.ts +525 -0
  129. package/src/cli/intentFields.ts +403 -0
  130. package/src/cli/intentInputPolicy.ts +11 -0
  131. package/src/cli/intentRuntime.ts +734 -0
  132. package/src/cli/resultFiles.ts +105 -0
  133. package/src/cli/resultUrls.ts +72 -0
  134. package/src/cli/semanticIntents/imageDescribe.ts +254 -0
  135. package/src/cli/semanticIntents/index.ts +48 -0
  136. package/src/cli/semanticIntents/markdownPdf.ts +120 -0
  137. package/src/cli/semanticIntents/parsing.ts +56 -0
  138. package/src/cli/stepsInput.ts +32 -0
  139. package/src/cli.ts +5 -4
  140. package/src/ensureUniqueCounter.ts +75 -0
  141. package/src/inputFiles.ts +277 -26
  142. package/src/robots.ts +1 -1
@@ -1,11 +1,12 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import EventEmitter from 'node:events';
2
3
  import fs from 'node:fs';
3
4
  import fsp from 'node:fs/promises';
4
5
  import path from 'node:path';
5
6
  import process from 'node:process';
7
+ import { Writable } from 'node:stream';
6
8
  import { pipeline } from 'node:stream/promises';
7
9
  import { setTimeout as delay } from 'node:timers/promises';
8
- import tty from 'node:tty';
9
10
  import { promisify } from 'node:util';
10
11
  import { Command, Option } from 'clipanion';
11
12
  import got from 'got';
@@ -14,12 +15,33 @@ import * as t from 'typanion';
14
15
  import { z } from 'zod';
15
16
  import { formatLintIssue } from "../../alphalib/assembly-linter.lang.en.js";
16
17
  import { tryCatch } from "../../alphalib/tryCatch.js";
17
- import { stepsSchema } from "../../alphalib/types/template.js";
18
+ import { ensureUniqueCounterValue } from "../../ensureUniqueCounter.js";
18
19
  import { lintAssemblyInstructions } from "../../lintAssemblyInstructions.js";
19
20
  import { lintingExamples } from "../docs/assemblyLintingExamples.js";
20
- import { createReadStream, formatAPIError, readCliInput, streamToBuffer } from "../helpers.js";
21
+ import { concurrencyOption, deleteAfterProcessingOption, inputPathsOption, printUrlsOption, recursiveOption, reprocessStaleOption, singleAssemblyOption, validateSharedFileProcessingOptions, watchOption, } from "../fileProcessingOptions.js";
22
+ import { formatAPIError, readCliInput } from "../helpers.js";
23
+ import { normalizeAssemblyResults } from "../resultFiles.js";
24
+ import { collectNormalizedResultUrlRows, printResultUrls } from "../resultUrls.js";
25
+ import { readStepsInputFile } from "../stepsInput.js";
21
26
  import { ensureError, isErrnoException } from "../types.js";
22
27
  import { AuthenticatedCommand, UnauthenticatedCommand } from "./BaseCommand.js";
28
+ function parseTemplateFieldAssignments(output, fields) {
29
+ if (fields == null || fields.length === 0) {
30
+ return undefined;
31
+ }
32
+ const fieldsMap = {};
33
+ for (const field of fields) {
34
+ const eqIndex = field.indexOf('=');
35
+ if (eqIndex === -1) {
36
+ output.error(`invalid argument for --field: '${field}'`);
37
+ return undefined;
38
+ }
39
+ const key = field.slice(0, eqIndex);
40
+ const value = field.slice(eqIndex + 1);
41
+ fieldsMap[key] = value;
42
+ }
43
+ return fieldsMap;
44
+ }
23
45
  const AssemblySchema = z.object({
24
46
  id: z.string(),
25
47
  });
@@ -84,13 +106,7 @@ export { deleteAssemblies as delete };
84
106
  export async function replay(output, client, { fields, reparse, steps, notify_url, assemblies }) {
85
107
  if (steps) {
86
108
  try {
87
- const buf = await streamToBuffer(createReadStream(steps));
88
- const parsed = JSON.parse(buf.toString());
89
- const validated = stepsSchema.safeParse(parsed);
90
- if (!validated.success) {
91
- throw new Error(`Invalid steps format: ${validated.error.message}`);
92
- }
93
- await apiCall(validated.data);
109
+ await apiCall(await readStepsInputFile(steps));
94
110
  }
95
111
  catch (err) {
96
112
  const error = ensureError(err);
@@ -106,7 +122,6 @@ export async function replay(output, client, { fields, reparse, steps, notify_ur
106
122
  reparse_template: reparse ? 1 : 0,
107
123
  fields,
108
124
  notify_url,
109
- // Steps (validated) is assignable to StepsInput at runtime; cast for TS
110
125
  steps: stepsOverride,
111
126
  }));
112
127
  if (err) {
@@ -221,12 +236,42 @@ async function myStat(stdioStream, filepath) {
221
236
  }
222
237
  return await fsp.stat(filepath);
223
238
  }
239
+ function getJobInputPath(filepath) {
240
+ const normalizedFile = path.normalize(filepath);
241
+ if (normalizedFile === '-') {
242
+ return stdinWithPath.path;
243
+ }
244
+ return normalizedFile;
245
+ }
246
+ function createInputUploadStream(filepath) {
247
+ const instream = fs.createReadStream(filepath);
248
+ // Attach a no-op error handler to prevent unhandled errors if stream is destroyed
249
+ // before being consumed (e.g., due to output collision detection)
250
+ instream.on('error', () => { });
251
+ return instream;
252
+ }
253
+ function createOutputPlan(pathname, mtime) {
254
+ if (pathname == null) {
255
+ return {
256
+ mtime,
257
+ };
258
+ }
259
+ return {
260
+ mtime,
261
+ path: pathname,
262
+ };
263
+ }
264
+ async function createExistingPathOutputPlan(outputPath) {
265
+ if (outputPath == null) {
266
+ return createOutputPlan(undefined, new Date(0));
267
+ }
268
+ const [, stats] = await tryCatch(fsp.stat(outputPath));
269
+ return createOutputPlan(outputPath, stats?.mtime ?? new Date(0));
270
+ }
224
271
  function dirProvider(output) {
225
272
  return async (inpath, indir = process.cwd()) => {
226
- // Inputless assemblies can still write into a directory, but output paths are derived from
227
- // assembly results rather than an input file path (handled later).
228
273
  if (inpath == null) {
229
- return null;
274
+ return await createExistingPathOutputPlan(output);
230
275
  }
231
276
  if (inpath === '-') {
232
277
  throw new Error('You must provide an input to output to a directory');
@@ -234,37 +279,229 @@ function dirProvider(output) {
234
279
  let relpath = path.relative(indir, inpath);
235
280
  relpath = relpath.replace(/^(\.\.\/)+/, '');
236
281
  const outpath = path.join(output, relpath);
237
- const outdir = path.dirname(outpath);
238
- await fsp.mkdir(outdir, { recursive: true });
239
- const [, stats] = await tryCatch(fsp.stat(outpath));
240
- const mtime = stats?.mtime ?? new Date(0);
241
- const outstream = fs.createWriteStream(outpath);
242
- // Attach a no-op error handler to prevent unhandled errors if stream is destroyed
243
- // before being consumed (e.g., due to output collision detection)
244
- outstream.on('error', () => { });
245
- outstream.mtime = mtime;
246
- return outstream;
282
+ return await createExistingPathOutputPlan(outpath);
247
283
  };
248
284
  }
249
285
  function fileProvider(output) {
250
- const dirExistsP = fsp.mkdir(path.dirname(output), { recursive: true });
251
286
  return async (_inpath) => {
252
- await dirExistsP;
253
- if (output === '-')
254
- return process.stdout;
255
- const [, stats] = await tryCatch(fsp.stat(output));
256
- const mtime = stats?.mtime ?? new Date(0);
257
- const outstream = fs.createWriteStream(output);
258
- // Attach a no-op error handler to prevent unhandled errors if stream is destroyed
259
- // before being consumed (e.g., due to output collision detection)
260
- outstream.on('error', () => { });
261
- outstream.mtime = mtime;
262
- return outstream;
287
+ if (output === '-') {
288
+ return await createExistingPathOutputPlan(undefined);
289
+ }
290
+ return await createExistingPathOutputPlan(output);
263
291
  };
264
292
  }
265
293
  function nullProvider() {
266
294
  return async (_inpath) => null;
267
295
  }
296
+ async function downloadResultToFile(resultUrl, outPath, signal) {
297
+ await fsp.mkdir(path.dirname(outPath), { recursive: true });
298
+ const tempPath = path.join(path.dirname(outPath), `.${path.basename(outPath)}.${randomUUID()}.tmp`);
299
+ const outStream = fs.createWriteStream(tempPath);
300
+ outStream.on('error', () => { });
301
+ const [dlErr] = await tryCatch(pipeline(got.stream(resultUrl, { signal }), outStream));
302
+ if (dlErr) {
303
+ await fsp.rm(tempPath, { force: true });
304
+ throw dlErr;
305
+ }
306
+ await fsp.rename(tempPath, outPath);
307
+ }
308
+ async function downloadResultToStdout(resultUrl, signal) {
309
+ const stdoutStream = new Writable({
310
+ write(chunk, _encoding, callback) {
311
+ let settled = false;
312
+ const finish = (err) => {
313
+ if (settled)
314
+ return;
315
+ settled = true;
316
+ process.stdout.off('drain', onDrain);
317
+ process.stdout.off('error', onError);
318
+ callback(err ?? undefined);
319
+ };
320
+ const onDrain = () => finish();
321
+ const onError = (err) => finish(err);
322
+ process.stdout.once('error', onError);
323
+ try {
324
+ if (process.stdout.write(chunk)) {
325
+ finish();
326
+ return;
327
+ }
328
+ process.stdout.once('drain', onDrain);
329
+ }
330
+ catch (err) {
331
+ finish(ensureError(err));
332
+ }
333
+ },
334
+ final(callback) {
335
+ callback();
336
+ },
337
+ });
338
+ await pipeline(got.stream(resultUrl, { signal }), stdoutStream);
339
+ }
340
+ function sanitizeResultName(value) {
341
+ const base = path.basename(value);
342
+ return base.replaceAll('\\', '_').replaceAll('/', '_').replaceAll('\u0000', '');
343
+ }
344
+ async function ensureUniquePath(targetPath, reservedPaths) {
345
+ const parsed = path.parse(targetPath);
346
+ return await ensureUniqueCounterValue({
347
+ initialValue: targetPath,
348
+ isTaken: async (candidate) => {
349
+ if (reservedPaths.has(candidate)) {
350
+ return true;
351
+ }
352
+ const [statErr] = await tryCatch(fsp.stat(candidate));
353
+ return statErr == null;
354
+ },
355
+ reserve: (candidate) => {
356
+ reservedPaths.add(candidate);
357
+ },
358
+ nextValue: (counter) => path.join(parsed.dir, `${parsed.name}__${counter}${parsed.ext}`),
359
+ scope: reservedPaths,
360
+ });
361
+ }
362
+ function getResultFileName(file) {
363
+ return sanitizeResultName(file.name);
364
+ }
365
+ const STALE_OUTPUT_GRACE_MS = 1000;
366
+ function isMeaningfullyNewer(newer, older) {
367
+ return newer.getTime() - older.getTime() > STALE_OUTPUT_GRACE_MS;
368
+ }
369
+ async function buildDirectoryDownloadTargets({ allFiles, baseDir, groupByStep, reservedPaths, }) {
370
+ await fsp.mkdir(baseDir, { recursive: true });
371
+ const targets = [];
372
+ for (const resultFile of allFiles) {
373
+ const targetDir = groupByStep ? path.join(baseDir, resultFile.stepName) : baseDir;
374
+ await fsp.mkdir(targetDir, { recursive: true });
375
+ targets.push({
376
+ resultUrl: resultFile.url,
377
+ targetPath: await ensureUniquePath(path.join(targetDir, getResultFileName(resultFile)), reservedPaths),
378
+ });
379
+ }
380
+ return targets;
381
+ }
382
+ function getSingleResultDownloadTarget(allFiles, targetPath) {
383
+ const first = allFiles[0];
384
+ const resultUrl = first?.url ?? null;
385
+ if (resultUrl == null) {
386
+ return [];
387
+ }
388
+ return [{ resultUrl, targetPath }];
389
+ }
390
+ async function resolveResultDownloadTargets({ hasDirectoryInput, inPath, inputs, normalizedResults, outputMode, outputPath, outputRoot, outputRootIsDirectory, reservedPaths, singleAssembly, }) {
391
+ const { allFiles, entries } = normalizedResults;
392
+ const shouldGroupByInput = !singleAssembly && inPath != null && (hasDirectoryInput || inputs.length > 1);
393
+ const resolveDirectoryBaseDir = () => {
394
+ if (!shouldGroupByInput || inPath == null) {
395
+ return outputRoot;
396
+ }
397
+ if (hasDirectoryInput && outputPath != null) {
398
+ const mappedRelative = path.relative(outputRoot, outputPath);
399
+ const mappedDir = path.dirname(mappedRelative);
400
+ const mappedStem = path.parse(mappedRelative).name;
401
+ return path.join(outputRoot, mappedDir === '.' ? '' : mappedDir, mappedStem);
402
+ }
403
+ return path.join(outputRoot, path.parse(path.basename(inPath)).name);
404
+ };
405
+ if (!outputRootIsDirectory) {
406
+ if (allFiles.length > 1) {
407
+ if (outputPath == null) {
408
+ throw new Error('stdout can only receive a single result file');
409
+ }
410
+ throw new Error('file outputs can only receive a single result file');
411
+ }
412
+ return getSingleResultDownloadTarget(allFiles, outputPath);
413
+ }
414
+ if (singleAssembly) {
415
+ return await buildDirectoryDownloadTargets({
416
+ allFiles,
417
+ baseDir: outputRoot,
418
+ groupByStep: false,
419
+ reservedPaths,
420
+ });
421
+ }
422
+ if (outputMode === 'directory' || outputPath == null || inPath == null) {
423
+ return await buildDirectoryDownloadTargets({
424
+ allFiles,
425
+ baseDir: resolveDirectoryBaseDir(),
426
+ groupByStep: entries.length > 1,
427
+ reservedPaths,
428
+ });
429
+ }
430
+ if (allFiles.length === 1) {
431
+ return getSingleResultDownloadTarget(allFiles, outputPath);
432
+ }
433
+ return await buildDirectoryDownloadTargets({
434
+ allFiles,
435
+ baseDir: path.join(path.dirname(outputPath), path.parse(outputPath).name),
436
+ groupByStep: true,
437
+ reservedPaths,
438
+ });
439
+ }
440
+ async function shouldSkipStaleOutput({ inputPaths, outputPath, outputPlanMtime, outputRootIsDirectory, reprocessStale, singleInputReference = 'output-plan', }) {
441
+ if (reprocessStale || outputPath == null || outputRootIsDirectory) {
442
+ return false;
443
+ }
444
+ if (inputPaths.length === 0 || inputPaths.some((inputPath) => inputPath === stdinWithPath.path)) {
445
+ return false;
446
+ }
447
+ const [outputErr, outputStat] = await tryCatch(fsp.stat(outputPath));
448
+ if (outputErr != null || outputStat == null) {
449
+ return false;
450
+ }
451
+ if (inputPaths.length === 1) {
452
+ if (singleInputReference === 'output-plan') {
453
+ return isMeaningfullyNewer(outputStat.mtime, outputPlanMtime);
454
+ }
455
+ const [inputErr, inputStat] = await tryCatch(fsp.stat(inputPaths[0]));
456
+ if (inputErr != null || inputStat == null) {
457
+ return false;
458
+ }
459
+ return isMeaningfullyNewer(outputStat.mtime, inputStat.mtime);
460
+ }
461
+ const inputStats = await Promise.all(inputPaths.map(async (inputPath) => {
462
+ const [inputErr, inputStat] = await tryCatch(fsp.stat(inputPath));
463
+ if (inputErr != null || inputStat == null) {
464
+ return null;
465
+ }
466
+ return inputStat;
467
+ }));
468
+ if (inputStats.some((inputStat) => inputStat == null)) {
469
+ return false;
470
+ }
471
+ return inputStats.every((inputStat) => {
472
+ return inputStat != null && isMeaningfullyNewer(outputStat.mtime, inputStat.mtime);
473
+ });
474
+ }
475
+ async function materializeAssemblyResults({ abortSignal, hasDirectoryInput, inPath, inputs, normalizedResults, outputMode, outputPath, outputRoot, outputRootIsDirectory, outputctl, reservedPaths, singleAssembly, }) {
476
+ if (outputRoot == null) {
477
+ return;
478
+ }
479
+ const targets = await resolveResultDownloadTargets({
480
+ hasDirectoryInput,
481
+ inPath,
482
+ inputs,
483
+ normalizedResults,
484
+ outputMode,
485
+ outputPath,
486
+ outputRoot,
487
+ outputRootIsDirectory,
488
+ reservedPaths,
489
+ singleAssembly,
490
+ });
491
+ for (const { resultUrl, targetPath } of targets) {
492
+ outputctl.debug('DOWNLOADING');
493
+ const [dlErr] = await tryCatch(targetPath == null
494
+ ? downloadResultToStdout(resultUrl, abortSignal)
495
+ : downloadResultToFile(resultUrl, targetPath, abortSignal));
496
+ if (dlErr) {
497
+ if (dlErr.name === 'AbortError') {
498
+ continue;
499
+ }
500
+ outputctl.error(dlErr.message);
501
+ throw dlErr;
502
+ }
503
+ }
504
+ }
268
505
  class MyEventEmitter extends EventEmitter {
269
506
  hasEnded;
270
507
  constructor() {
@@ -282,34 +519,38 @@ class MyEventEmitter extends EventEmitter {
282
519
  }
283
520
  }
284
521
  class ReaddirJobEmitter extends MyEventEmitter {
285
- constructor({ dir, streamRegistry, recursive, outstreamProvider, topdir = dir, }) {
522
+ constructor({ dir, recursive, outputPlanProvider, topdir = dir }) {
286
523
  super();
287
524
  process.nextTick(() => {
288
- this.processDirectory({ dir, streamRegistry, recursive, outstreamProvider, topdir }).catch((err) => {
525
+ this.processDirectory({
526
+ dir,
527
+ recursive,
528
+ outputPlanProvider,
529
+ topdir,
530
+ }).catch((err) => {
289
531
  this.emit('error', err);
290
532
  });
291
533
  });
292
534
  }
293
- async processDirectory({ dir, streamRegistry, recursive, outstreamProvider, topdir, }) {
535
+ async processDirectory({ dir, recursive, outputPlanProvider, topdir, }) {
294
536
  const files = await fsp.readdir(dir);
295
537
  const pendingOperations = [];
296
538
  for (const filename of files) {
297
539
  const file = path.normalize(path.join(dir, filename));
298
- pendingOperations.push(this.processFile({ file, streamRegistry, recursive, outstreamProvider, topdir }));
540
+ pendingOperations.push(this.processFile({ file, recursive, outputPlanProvider, topdir }));
299
541
  }
300
542
  await Promise.all(pendingOperations);
301
543
  this.emit('end');
302
544
  }
303
- async processFile({ file, streamRegistry, recursive = false, outstreamProvider, topdir, }) {
545
+ async processFile({ file, recursive = false, outputPlanProvider, topdir, }) {
304
546
  const stats = await fsp.stat(file);
305
547
  if (stats.isDirectory()) {
306
548
  if (recursive) {
307
549
  await new Promise((resolve, reject) => {
308
550
  const subdirEmitter = new ReaddirJobEmitter({
309
551
  dir: file,
310
- streamRegistry,
311
552
  recursive,
312
- outstreamProvider,
553
+ outputPlanProvider,
313
554
  topdir,
314
555
  });
315
556
  subdirEmitter.on('job', (job) => this.emit('job', job));
@@ -319,62 +560,46 @@ class ReaddirJobEmitter extends MyEventEmitter {
319
560
  }
320
561
  }
321
562
  else {
322
- const existing = streamRegistry[file];
323
- if (existing)
324
- existing.end();
325
- const outstream = await outstreamProvider(file, topdir);
326
- streamRegistry[file] = outstream ?? undefined;
327
- const instream = fs.createReadStream(file);
328
- // Attach a no-op error handler to prevent unhandled errors if stream is destroyed
329
- // before being consumed (e.g., due to output collision detection)
330
- instream.on('error', () => { });
331
- this.emit('job', { in: instream, out: outstream });
563
+ const outputPlan = await outputPlanProvider(file, topdir);
564
+ this.emit('job', { inputPath: getJobInputPath(file), out: outputPlan });
332
565
  }
333
566
  }
334
567
  }
335
568
  class SingleJobEmitter extends MyEventEmitter {
336
- constructor({ file, streamRegistry, outstreamProvider }) {
569
+ constructor({ file, outputPlanProvider }) {
337
570
  super();
338
571
  const normalizedFile = path.normalize(file);
339
- const existing = streamRegistry[normalizedFile];
340
- if (existing)
341
- existing.end();
342
- outstreamProvider(normalizedFile).then((outstream) => {
343
- streamRegistry[normalizedFile] = outstream ?? undefined;
344
- let instream;
345
- if (normalizedFile === '-') {
346
- if (tty.isatty(process.stdin.fd)) {
347
- instream = null;
348
- }
349
- else {
350
- instream = process.stdin;
351
- }
352
- }
353
- else {
354
- instream = fs.createReadStream(normalizedFile);
355
- // Attach a no-op error handler to prevent unhandled errors if stream is destroyed
356
- // before being consumed (e.g., due to output collision detection)
357
- instream.on('error', () => { });
358
- }
572
+ outputPlanProvider(normalizedFile)
573
+ .then((outputPlan) => {
359
574
  process.nextTick(() => {
360
- this.emit('job', { in: instream, out: outstream });
575
+ this.emit('job', { inputPath: getJobInputPath(normalizedFile), out: outputPlan });
361
576
  this.emit('end');
362
577
  });
578
+ })
579
+ .catch((err) => {
580
+ process.nextTick(() => {
581
+ this.emit('error', ensureError(err));
582
+ });
363
583
  });
364
584
  }
365
585
  }
366
586
  class InputlessJobEmitter extends MyEventEmitter {
367
- constructor({ outstreamProvider, }) {
587
+ constructor({ outputPlanProvider }) {
368
588
  super();
369
589
  process.nextTick(() => {
370
- outstreamProvider(null).then((outstream) => {
590
+ outputPlanProvider(null)
591
+ .then((outputPlan) => {
371
592
  try {
372
- this.emit('job', { in: null, out: outstream });
593
+ this.emit('job', { inputPath: null, out: outputPlan });
373
594
  }
374
595
  catch (err) {
375
- this.emit('error', err);
596
+ this.emit('error', ensureError(err));
597
+ return;
376
598
  }
377
599
  this.emit('end');
600
+ })
601
+ .catch((err) => {
602
+ this.emit('error', ensureError(err));
378
603
  });
379
604
  });
380
605
  }
@@ -387,9 +612,9 @@ class NullJobEmitter extends MyEventEmitter {
387
612
  }
388
613
  class WatchJobEmitter extends MyEventEmitter {
389
614
  watcher = null;
390
- constructor({ file, streamRegistry, recursive, outstreamProvider }) {
615
+ constructor({ file, recursive, outputPlanProvider }) {
391
616
  super();
392
- this.init({ file, streamRegistry, recursive, outstreamProvider }).catch((err) => {
617
+ this.init({ file, recursive, outputPlanProvider }).catch((err) => {
393
618
  this.emit('error', err);
394
619
  });
395
620
  // Clean up watcher on process exit signals
@@ -404,7 +629,7 @@ class WatchJobEmitter extends MyEventEmitter {
404
629
  this.watcher = null;
405
630
  }
406
631
  }
407
- async init({ file, streamRegistry, recursive, outstreamProvider, }) {
632
+ async init({ file, recursive, outputPlanProvider, }) {
408
633
  const stats = await fsp.stat(file);
409
634
  const topdir = stats.isDirectory() ? file : undefined;
410
635
  const watchFn = await getNodeWatch();
@@ -416,25 +641,21 @@ class WatchJobEmitter extends MyEventEmitter {
416
641
  this.watcher.on('close', () => this.emit('end'));
417
642
  this.watcher.on('change', (_evt, filename) => {
418
643
  const normalizedFile = path.normalize(filename);
419
- this.handleChange(normalizedFile, topdir, streamRegistry, outstreamProvider).catch((err) => {
644
+ this.handleChange(normalizedFile, topdir, outputPlanProvider).catch((err) => {
420
645
  this.emit('error', err);
421
646
  });
422
647
  });
423
648
  }
424
- async handleChange(normalizedFile, topdir, streamRegistry, outstreamProvider) {
649
+ async handleChange(normalizedFile, topdir, outputPlanProvider) {
425
650
  const stats = await fsp.stat(normalizedFile);
426
651
  if (stats.isDirectory())
427
652
  return;
428
- const existing = streamRegistry[normalizedFile];
429
- if (existing)
430
- existing.end();
431
- const outstream = await outstreamProvider(normalizedFile, topdir);
432
- streamRegistry[normalizedFile] = outstream ?? undefined;
433
- const instream = fs.createReadStream(normalizedFile);
434
- // Attach a no-op error handler to prevent unhandled errors if stream is destroyed
435
- // before being consumed (e.g., due to output collision detection)
436
- instream.on('error', () => { });
437
- this.emit('job', { in: instream, out: outstream });
653
+ const outputPlan = await outputPlanProvider(normalizedFile, topdir);
654
+ this.emit('job', {
655
+ inputPath: getJobInputPath(normalizedFile),
656
+ out: outputPlan,
657
+ watchEvent: true,
658
+ });
438
659
  }
439
660
  }
440
661
  class MergedJobEmitter extends MyEventEmitter {
@@ -484,12 +705,20 @@ function detectConflicts(jobEmitter) {
484
705
  jobEmitter.on('end', () => emitter.emit('end'));
485
706
  jobEmitter.on('error', (err) => emitter.emit('error', err));
486
707
  jobEmitter.on('job', (job) => {
487
- if (job.in == null || job.out == null) {
708
+ if (job.watchEvent) {
488
709
  emitter.emit('job', job);
489
710
  return;
490
711
  }
491
- const inPath = job.in.path;
712
+ if (job.inputPath == null || job.out == null) {
713
+ emitter.emit('job', job);
714
+ return;
715
+ }
716
+ const inPath = job.inputPath;
492
717
  const outPath = job.out.path;
718
+ if (outPath == null) {
719
+ emitter.emit('job', job);
720
+ return;
721
+ }
493
722
  if (Object.hasOwn(outfileAssociations, outPath) && outfileAssociations[outPath] !== inPath) {
494
723
  emitter.emit('error', new Error(`Output collision between '${inPath}' and '${outfileAssociations[outPath]}'`));
495
724
  }
@@ -506,11 +735,11 @@ function dismissStaleJobs(jobEmitter) {
506
735
  jobEmitter.on('end', () => Promise.all(pendingChecks).then(() => emitter.emit('end')));
507
736
  jobEmitter.on('error', (err) => emitter.emit('error', err));
508
737
  jobEmitter.on('job', (job) => {
509
- if (job.in == null || job.out == null) {
738
+ if (job.inputPath == null || job.out == null) {
510
739
  emitter.emit('job', job);
511
740
  return;
512
741
  }
513
- const inPath = job.in.path;
742
+ const inPath = job.inputPath;
514
743
  const checkPromise = fsp
515
744
  .stat(inPath)
516
745
  .then((stats) => {
@@ -526,30 +755,49 @@ function dismissStaleJobs(jobEmitter) {
526
755
  });
527
756
  return emitter;
528
757
  }
529
- function makeJobEmitter(inputs, { recursive, outstreamProvider, streamRegistry, watch: watchOption, reprocessStale, }) {
758
+ function passthroughJobs(jobEmitter) {
759
+ const emitter = new MyEventEmitter();
760
+ jobEmitter.on('end', () => emitter.emit('end'));
761
+ jobEmitter.on('error', (err) => emitter.emit('error', err));
762
+ jobEmitter.on('job', (job) => emitter.emit('job', job));
763
+ return emitter;
764
+ }
765
+ function makeJobEmitter(inputs, { allowOutputCollisions, recursive, outputPlanProvider, singleAssembly, watch: watchOption, reprocessStale, }) {
530
766
  const emitter = new EventEmitter();
531
767
  const emitterFns = [];
532
768
  const watcherFns = [];
533
769
  async function processInputs() {
534
770
  for (const input of inputs) {
535
771
  if (input === '-') {
536
- emitterFns.push(() => new SingleJobEmitter({ file: input, outstreamProvider, streamRegistry }));
772
+ emitterFns.push(() => new SingleJobEmitter({ file: input, outputPlanProvider }));
537
773
  watcherFns.push(() => new NullJobEmitter());
538
774
  }
539
775
  else {
540
776
  const stats = await fsp.stat(input);
541
777
  if (stats.isDirectory()) {
542
- emitterFns.push(() => new ReaddirJobEmitter({ dir: input, recursive, outstreamProvider, streamRegistry }));
543
- watcherFns.push(() => new WatchJobEmitter({ file: input, recursive, outstreamProvider, streamRegistry }));
778
+ emitterFns.push(() => new ReaddirJobEmitter({
779
+ dir: input,
780
+ recursive,
781
+ outputPlanProvider,
782
+ }));
783
+ watcherFns.push(() => new WatchJobEmitter({
784
+ file: input,
785
+ recursive,
786
+ outputPlanProvider,
787
+ }));
544
788
  }
545
789
  else {
546
- emitterFns.push(() => new SingleJobEmitter({ file: input, outstreamProvider, streamRegistry }));
547
- watcherFns.push(() => new WatchJobEmitter({ file: input, recursive, outstreamProvider, streamRegistry }));
790
+ emitterFns.push(() => new SingleJobEmitter({ file: input, outputPlanProvider }));
791
+ watcherFns.push(() => new WatchJobEmitter({
792
+ file: input,
793
+ recursive,
794
+ outputPlanProvider,
795
+ }));
548
796
  }
549
797
  }
550
798
  }
551
799
  if (inputs.length === 0) {
552
- emitterFns.push(() => new InputlessJobEmitter({ outstreamProvider, streamRegistry }));
800
+ emitterFns.push(() => new InputlessJobEmitter({ outputPlanProvider }));
553
801
  }
554
802
  startEmitting();
555
803
  }
@@ -565,38 +813,24 @@ function makeJobEmitter(inputs, { recursive, outstreamProvider, streamRegistry,
565
813
  processInputs().catch((err) => {
566
814
  emitter.emit('error', err);
567
815
  });
568
- const stalefilter = reprocessStale ? (x) => x : dismissStaleJobs;
569
- return stalefilter(detectConflicts(emitter));
816
+ const conflictFilter = allowOutputCollisions ? passthroughJobs : detectConflicts;
817
+ const staleFilter = reprocessStale || singleAssembly ? passthroughJobs : dismissStaleJobs;
818
+ return staleFilter(conflictFilter(emitter));
570
819
  }
571
820
  const DEFAULT_CONCURRENCY = 5;
572
821
  // --- Main assembly create function ---
573
- export async function create(outputctl, client, { steps, template, fields, watch: watchOption, recursive, inputs, output, del, reprocessStale, singleAssembly, concurrency = DEFAULT_CONCURRENCY, }) {
822
+ export async function create(outputctl, client, { steps, stepsData, template, fields, outputMode, watch: watchOption, recursive, inputs, output, del, reprocessStale, singleAssembly, concurrency = DEFAULT_CONCURRENCY, }) {
574
823
  // Quick fix for https://github.com/transloadit/transloadify/issues/13
575
824
  // Only default to stdout when output is undefined (not provided), not when explicitly null
576
825
  let resolvedOutput = output;
577
826
  if (resolvedOutput === undefined && !process.stdout.isTTY)
578
827
  resolvedOutput = '-';
579
828
  // Read steps file async before entering the Promise constructor
580
- // We use StepsInput (the input type) rather than Steps (the transformed output type)
829
+ // We use StepsInput (the input type) rather than the transformed output type
581
830
  // to avoid zod adding default values that the API may reject
582
- let stepsData;
831
+ let effectiveStepsData = stepsData;
583
832
  if (steps) {
584
- const stepsContent = await fsp.readFile(steps, 'utf8');
585
- const parsed = JSON.parse(stepsContent);
586
- // Basic structural validation: must be an object with step names as keys
587
- if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
588
- throw new Error('Invalid steps format: expected an object with step names as keys');
589
- }
590
- // Validate each step has a robot field
591
- for (const [stepName, step] of Object.entries(parsed)) {
592
- if (step == null || typeof step !== 'object' || Array.isArray(step)) {
593
- throw new Error(`Invalid steps format: step '${stepName}' must be an object`);
594
- }
595
- if (!('robot' in step) || typeof step.robot !== 'string') {
596
- throw new Error(`Invalid steps format: step '${stepName}' must have a 'robot' string property`);
597
- }
598
- }
599
- stepsData = parsed;
833
+ effectiveStepsData = await readStepsInputFile(steps);
600
834
  }
601
835
  // Determine output stat async before entering the Promise constructor
602
836
  let outstat;
@@ -604,8 +838,17 @@ export async function create(outputctl, client, { steps, template, fields, watch
604
838
  const [err, stat] = await tryCatch(myStat(process.stdout, resolvedOutput));
605
839
  if (err && (!isErrnoException(err) || err.code !== 'ENOENT'))
606
840
  throw err;
607
- outstat = stat ?? { isDirectory: () => false };
608
- if (!outstat.isDirectory() && inputs.length !== 0) {
841
+ outstat =
842
+ stat ??
843
+ {
844
+ isDirectory: () => outputMode === 'directory',
845
+ };
846
+ if (outputMode === 'directory' && stat != null && !stat.isDirectory()) {
847
+ const msg = 'Output must be a directory for this command';
848
+ outputctl.error(msg);
849
+ throw new Error(msg);
850
+ }
851
+ if (!outstat.isDirectory() && inputs.length !== 0 && !singleAssembly) {
609
852
  const firstInput = inputs[0];
610
853
  if (firstInput) {
611
854
  const firstInputStat = await myStat(process.stdin, firstInput);
@@ -617,276 +860,223 @@ export async function create(outputctl, client, { steps, template, fields, watch
617
860
  }
618
861
  }
619
862
  }
863
+ const inputStats = await Promise.all(inputs.map(async (input) => {
864
+ if (input === '-')
865
+ return null;
866
+ return await myStat(process.stdin, input);
867
+ }));
868
+ const hasDirectoryInput = inputStats.some((stat) => stat?.isDirectory() === true);
620
869
  return new Promise((resolve, reject) => {
621
- const params = (stepsData ? { steps: stepsData } : { template_id: template });
870
+ const params = (effectiveStepsData
871
+ ? { steps: effectiveStepsData }
872
+ : { template_id: template });
622
873
  if (fields) {
623
874
  params.fields = fields;
624
875
  }
625
- const outstreamProvider = resolvedOutput == null
876
+ const outputPlanProvider = resolvedOutput == null
626
877
  ? nullProvider()
627
878
  : outstat?.isDirectory()
628
879
  ? dirProvider(resolvedOutput)
629
880
  : fileProvider(resolvedOutput);
630
- const streamRegistry = {};
631
881
  const emitter = makeJobEmitter(inputs, {
882
+ allowOutputCollisions: singleAssembly,
883
+ outputPlanProvider,
632
884
  recursive,
633
885
  watch: watchOption,
634
- outstreamProvider,
635
- streamRegistry,
886
+ singleAssembly,
636
887
  reprocessStale,
637
888
  });
638
889
  // Use p-queue for concurrency management
639
890
  const queue = new PQueue({ concurrency });
640
891
  const results = [];
892
+ const resultUrls = [];
893
+ const reservedResultPaths = new Set();
894
+ const latestWatchJobTokenByOutputPath = new Map();
641
895
  let hasFailures = false;
896
+ let nextWatchJobToken = 0;
642
897
  // AbortController to cancel all in-flight createAssembly calls when an error occurs
643
898
  const abortController = new AbortController();
644
- // Helper to process a single assembly job
645
- async function processAssemblyJob(inPath, outPath, outMtime) {
646
- outputctl.debug(`PROCESSING JOB ${inPath ?? 'null'} ${outPath ?? 'null'}`);
647
- // Create fresh streams for this job
648
- const inStream = inPath ? fs.createReadStream(inPath) : null;
649
- inStream?.on('error', () => { });
650
- let superceded = false;
651
- // When writing to a file path (non-directory output), we treat finish as a supersede signal.
652
- // Directory-output multi-download mode does not use a single shared outstream.
653
- const markSupersededOnFinish = (stream) => {
654
- stream.on('finish', () => {
655
- superceded = true;
656
- });
657
- };
899
+ const outputRootIsDirectory = Boolean(resolvedOutput != null && outstat?.isDirectory());
900
+ function reserveWatchJobToken(outputPath) {
901
+ if (!watchOption || outputPath == null) {
902
+ return null;
903
+ }
904
+ const token = ++nextWatchJobToken;
905
+ latestWatchJobTokenByOutputPath.set(outputPath, token);
906
+ return token;
907
+ }
908
+ function isSupersededWatchJob(outputPath, token) {
909
+ if (!watchOption || outputPath == null || token == null) {
910
+ return false;
911
+ }
912
+ return latestWatchJobTokenByOutputPath.get(outputPath) !== token;
913
+ }
914
+ function createAssemblyOptions({ files, uploads, } = {}) {
658
915
  const createOptions = {
659
916
  params,
660
917
  signal: abortController.signal,
661
918
  };
662
- if (inStream != null) {
663
- createOptions.uploads = { in: inStream };
919
+ if (files != null && Object.keys(files).length > 0) {
920
+ createOptions.files = files;
921
+ }
922
+ if (uploads != null && Object.keys(uploads).length > 0) {
923
+ createOptions.uploads = uploads;
664
924
  }
925
+ return createOptions;
926
+ }
927
+ async function awaitCompletedAssembly(createOptions) {
665
928
  const result = await client.createAssembly(createOptions);
666
- if (superceded)
667
- return undefined;
668
929
  const assemblyId = result.assembly_id;
669
930
  if (!assemblyId)
670
931
  throw new Error('No assembly_id in result');
671
932
  const assembly = await client.awaitAssemblyCompletion(assemblyId, {
672
933
  signal: abortController.signal,
673
- onPoll: () => {
674
- if (superceded)
675
- return false;
676
- return true;
677
- },
934
+ onPoll: () => true,
678
935
  onAssemblyProgress: (status) => {
679
936
  outputctl.debug(`Assembly status: ${status.ok}`);
680
937
  },
681
938
  });
682
- if (superceded)
683
- return undefined;
684
939
  if (assembly.error || (assembly.ok && assembly.ok !== 'ASSEMBLY_COMPLETED')) {
685
940
  const msg = `Assembly failed: ${assembly.error || assembly.message} (Status: ${assembly.ok})`;
686
941
  outputctl.error(msg);
687
942
  throw new Error(msg);
688
943
  }
944
+ return { assembly, assemblyId };
945
+ }
946
+ async function executeAssemblyLifecycle({ createOptions, inPath, inputPaths, outputPlan, outputToken, singleAssemblyMode, }) {
947
+ outputctl.debug(`PROCESSING JOB ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`);
948
+ const { assembly, assemblyId } = await awaitCompletedAssembly(createOptions);
689
949
  if (!assembly.results)
690
950
  throw new Error('No results in assembly');
691
- const outIsDirectory = Boolean(resolvedOutput != null && outstat?.isDirectory());
692
- const entries = Object.entries(assembly.results);
693
- const allFiles = [];
694
- for (const [stepName, stepResults] of entries) {
695
- for (const file of stepResults) {
696
- allFiles.push({ stepName, file });
697
- }
951
+ const normalizedResults = normalizeAssemblyResults(assembly.results);
952
+ if (isSupersededWatchJob(outputPlan?.path ?? null, outputToken)) {
953
+ outputctl.debug(`SKIPPED SUPERSEDED WATCH RESULT ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`);
954
+ return assembly;
698
955
  }
699
- const getFileUrl = (file) => file.ssl_url ?? file.url ?? null;
700
- const sanitizeName = (value) => {
701
- const base = path.basename(value);
702
- return base.replaceAll('\\', '_').replaceAll('/', '_').replaceAll('\u0000', '');
703
- };
704
- const ensureUniquePath = async (targetPath) => {
705
- const parsed = path.parse(targetPath);
706
- let candidate = targetPath;
707
- let counter = 1;
708
- while (true) {
709
- const [statErr] = await tryCatch(fsp.stat(candidate));
710
- if (statErr)
711
- return candidate;
712
- candidate = path.join(parsed.dir, `${parsed.name}__${counter}${parsed.ext}`);
713
- counter += 1;
714
- }
715
- };
716
- if (resolvedOutput != null && !superceded) {
717
- // Directory output:
718
- // - For single-result, input-backed jobs, preserve existing behavior (write to mapped file path).
719
- // - Otherwise (multi-result or inputless), download all results into a directory structure.
720
- if (outIsDirectory && (inPath == null || allFiles.length !== 1 || outPath == null)) {
721
- let baseDir = resolvedOutput;
722
- if (inPath != null) {
723
- let relpath = path.relative(process.cwd(), inPath);
724
- relpath = relpath.replace(/^(\.\.\/)+/, '');
725
- baseDir = path.join(resolvedOutput, path.dirname(relpath), path.parse(relpath).name);
726
- }
727
- await fsp.mkdir(baseDir, { recursive: true });
728
- for (const { stepName, file } of allFiles) {
729
- const resultUrl = getFileUrl(file);
730
- if (!resultUrl)
731
- continue;
732
- const stepDir = path.join(baseDir, stepName);
733
- await fsp.mkdir(stepDir, { recursive: true });
734
- const rawName = file.name ??
735
- (file.basename && file.ext ? `${file.basename}.${file.ext}` : undefined) ??
736
- `${stepName}_result`;
737
- const safeName = sanitizeName(rawName);
738
- const targetPath = await ensureUniquePath(path.join(stepDir, safeName));
739
- outputctl.debug('DOWNLOADING');
740
- const outStream = fs.createWriteStream(targetPath);
741
- outStream.on('error', () => { });
742
- const [dlErr] = await tryCatch(pipeline(got.stream(resultUrl, { signal: abortController.signal }), outStream));
743
- if (dlErr) {
744
- if (dlErr.name === 'AbortError')
745
- continue;
746
- outputctl.error(dlErr.message);
747
- throw dlErr;
748
- }
749
- }
750
- }
751
- else if (!outIsDirectory && outPath != null) {
752
- const first = allFiles[0];
753
- const resultUrl = first ? getFileUrl(first.file) : null;
754
- if (resultUrl) {
755
- outputctl.debug('DOWNLOADING');
756
- const outStream = fs.createWriteStream(outPath);
757
- outStream.on('error', () => { });
758
- outStream.mtime = outMtime;
759
- markSupersededOnFinish(outStream);
760
- const [dlErr] = await tryCatch(pipeline(got.stream(resultUrl, { signal: abortController.signal }), outStream));
761
- if (dlErr) {
762
- if (dlErr.name !== 'AbortError') {
763
- outputctl.error(dlErr.message);
764
- throw dlErr;
765
- }
766
- }
767
- }
768
- }
769
- else if (outIsDirectory && outPath != null) {
770
- // Single-result, input-backed job: preserve existing file mapping in outdir.
771
- const first = allFiles[0];
772
- const resultUrl = first ? getFileUrl(first.file) : null;
773
- if (resultUrl) {
774
- outputctl.debug('DOWNLOADING');
775
- const outStream = fs.createWriteStream(outPath);
776
- outStream.on('error', () => { });
777
- outStream.mtime = outMtime;
778
- markSupersededOnFinish(outStream);
779
- const [dlErr] = await tryCatch(pipeline(got.stream(resultUrl, { signal: abortController.signal }), outStream));
780
- if (dlErr) {
781
- if (dlErr.name !== 'AbortError') {
782
- outputctl.error(dlErr.message);
783
- throw dlErr;
784
- }
785
- }
956
+ if (!singleAssemblyMode &&
957
+ !watchOption &&
958
+ (await shouldSkipStaleOutput({
959
+ inputPaths,
960
+ outputPath: outputPlan?.path ?? null,
961
+ outputPlanMtime: outputPlan?.mtime ?? new Date(0),
962
+ outputRootIsDirectory,
963
+ reprocessStale,
964
+ }))) {
965
+ outputctl.debug(`SKIPPED STALE RESULT ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`);
966
+ return assembly;
967
+ }
968
+ resultUrls.push(...collectNormalizedResultUrlRows({ assemblyId, normalizedResults }));
969
+ await materializeAssemblyResults({
970
+ abortSignal: abortController.signal,
971
+ hasDirectoryInput: singleAssemblyMode ? false : hasDirectoryInput,
972
+ inPath,
973
+ inputs: inputPaths,
974
+ normalizedResults,
975
+ outputMode,
976
+ outputPath: outputPlan?.path ?? null,
977
+ outputRoot: resolvedOutput ?? null,
978
+ outputRootIsDirectory,
979
+ outputctl,
980
+ reservedPaths: reservedResultPaths,
981
+ singleAssembly: singleAssemblyMode,
982
+ });
983
+ outputctl.debug(`COMPLETED ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`);
984
+ if (del) {
985
+ for (const inputPath of inputPaths) {
986
+ if (inputPath === stdinWithPath.path) {
987
+ continue;
786
988
  }
989
+ await fsp.unlink(inputPath);
787
990
  }
788
991
  }
789
- outputctl.debug(`COMPLETED ${inPath ?? 'null'} ${outPath ?? 'null'}`);
790
- if (del && inPath) {
791
- await fsp.unlink(inPath);
792
- }
793
992
  return assembly;
794
993
  }
795
- if (singleAssembly) {
796
- // Single-assembly mode: collect file paths, then create one assembly with all inputs
797
- // We close streams immediately to avoid exhausting file descriptors with many files
994
+ // Helper to process a single assembly job
995
+ async function processAssemblyJob(inPath, outputPlan, outputToken) {
996
+ const files = inPath != null && inPath !== stdinWithPath.path
997
+ ? {
998
+ in: inPath,
999
+ }
1000
+ : undefined;
1001
+ const uploads = inPath === stdinWithPath.path
1002
+ ? {
1003
+ in: createInputUploadStream(inPath),
1004
+ }
1005
+ : undefined;
1006
+ return await executeAssemblyLifecycle({
1007
+ createOptions: createAssemblyOptions({ files, uploads }),
1008
+ inPath,
1009
+ inputPaths: inPath == null ? [] : [inPath],
1010
+ outputPlan,
1011
+ outputToken,
1012
+ });
1013
+ }
1014
+ function handleEmitterError(err) {
1015
+ abortController.abort();
1016
+ queue.clear();
1017
+ outputctl.error(err);
1018
+ reject(err);
1019
+ }
1020
+ function runSingleAssemblyEmitter() {
798
1021
  const collectedPaths = [];
1022
+ let inputlessOutputPlan = null;
799
1023
  emitter.on('job', (job) => {
800
- if (job.in != null) {
801
- const inPath = job.in.path;
1024
+ if (job.inputPath != null) {
1025
+ const inPath = job.inputPath;
802
1026
  outputctl.debug(`COLLECTING JOB ${inPath}`);
803
1027
  collectedPaths.push(inPath);
804
- job.in.destroy();
805
- outputctl.debug(`STREAM CLOSED ${inPath}`);
1028
+ return;
806
1029
  }
807
- });
808
- emitter.on('error', (err) => {
809
- abortController.abort();
810
- queue.clear();
811
- outputctl.error(err);
812
- reject(err);
1030
+ inputlessOutputPlan = job.out ?? null;
813
1031
  });
814
1032
  emitter.on('end', async () => {
815
- if (collectedPaths.length === 0) {
816
- resolve({ results: [], hasFailures: false });
1033
+ if (await shouldSkipStaleOutput({
1034
+ inputPaths: collectedPaths,
1035
+ outputPath: resolvedOutput ?? null,
1036
+ outputPlanMtime: new Date(0),
1037
+ outputRootIsDirectory,
1038
+ reprocessStale,
1039
+ singleInputReference: 'input',
1040
+ })) {
1041
+ outputctl.debug(`SKIPPED STALE SINGLE ASSEMBLY ${resolvedOutput ?? 'null'}`);
1042
+ resolve({ resultUrls, results: [], hasFailures: false });
817
1043
  return;
818
1044
  }
819
- // Build uploads object, creating fresh streams for each file
1045
+ // Preserve original basenames/extensions for filesystem uploads so the backend
1046
+ // can infer types like Markdown correctly.
1047
+ const files = {};
820
1048
  const uploads = {};
821
1049
  const inputPaths = [];
822
1050
  for (const inPath of collectedPaths) {
823
1051
  const basename = path.basename(inPath);
824
- let key = basename;
825
- let counter = 1;
826
- while (key in uploads) {
827
- key = `${path.parse(basename).name}_${counter}${path.parse(basename).ext}`;
828
- counter++;
1052
+ const collection = inPath === stdinWithPath.path ? uploads : files;
1053
+ const key = await ensureUniqueCounterValue({
1054
+ initialValue: basename,
1055
+ isTaken: (candidate) => candidate in collection,
1056
+ nextValue: (counter) => `${path.parse(basename).name}_${counter}${path.parse(basename).ext}`,
1057
+ reserve: () => { },
1058
+ scope: collection,
1059
+ });
1060
+ if (inPath === stdinWithPath.path) {
1061
+ uploads[key] = createInputUploadStream(inPath);
1062
+ }
1063
+ else {
1064
+ files[key] = inPath;
829
1065
  }
830
- uploads[key] = fs.createReadStream(inPath);
831
1066
  inputPaths.push(inPath);
832
1067
  }
833
- outputctl.debug(`Creating single assembly with ${Object.keys(uploads).length} files`);
1068
+ outputctl.debug(`Creating single assembly with ${Object.keys(files).length + Object.keys(uploads).length} files`);
834
1069
  try {
835
1070
  const assembly = await queue.add(async () => {
836
- const createOptions = {
837
- params,
838
- signal: abortController.signal,
839
- };
840
- if (Object.keys(uploads).length > 0) {
841
- createOptions.uploads = uploads;
842
- }
843
- const result = await client.createAssembly(createOptions);
844
- const assemblyId = result.assembly_id;
845
- if (!assemblyId)
846
- throw new Error('No assembly_id in result');
847
- const asm = await client.awaitAssemblyCompletion(assemblyId, {
848
- signal: abortController.signal,
849
- onAssemblyProgress: (status) => {
850
- outputctl.debug(`Assembly status: ${status.ok}`);
851
- },
1071
+ return await executeAssemblyLifecycle({
1072
+ createOptions: createAssemblyOptions({ files, uploads }),
1073
+ inPath: null,
1074
+ inputPaths,
1075
+ outputPlan: inputlessOutputPlan ??
1076
+ (resolvedOutput == null ? null : createOutputPlan(resolvedOutput, new Date(0))),
1077
+ outputToken: null,
1078
+ singleAssemblyMode: true,
852
1079
  });
853
- if (asm.error || (asm.ok && asm.ok !== 'ASSEMBLY_COMPLETED')) {
854
- const msg = `Assembly failed: ${asm.error || asm.message} (Status: ${asm.ok})`;
855
- outputctl.error(msg);
856
- throw new Error(msg);
857
- }
858
- // Download all results
859
- if (asm.results && resolvedOutput != null) {
860
- for (const [stepName, stepResults] of Object.entries(asm.results)) {
861
- for (const stepResult of stepResults) {
862
- const resultUrl = stepResult.ssl_url ?? stepResult.url;
863
- if (!resultUrl)
864
- continue;
865
- let outPath;
866
- if (outstat?.isDirectory()) {
867
- outPath = path.join(resolvedOutput, stepResult.name || `${stepName}_result`);
868
- }
869
- else {
870
- outPath = resolvedOutput;
871
- }
872
- outputctl.debug(`DOWNLOADING ${stepResult.name} to ${outPath}`);
873
- const [dlErr] = await tryCatch(pipeline(got.stream(resultUrl, { signal: abortController.signal }), fs.createWriteStream(outPath)));
874
- if (dlErr) {
875
- if (dlErr.name === 'AbortError')
876
- continue;
877
- outputctl.error(dlErr.message);
878
- throw dlErr;
879
- }
880
- }
881
- }
882
- }
883
- // Delete input files if requested
884
- if (del) {
885
- for (const inPath of inputPaths) {
886
- await fsp.unlink(inPath);
887
- }
888
- }
889
- return asm;
890
1080
  });
891
1081
  results.push(assembly);
892
1082
  }
@@ -894,30 +1084,18 @@ export async function create(outputctl, client, { steps, template, fields, watch
894
1084
  hasFailures = true;
895
1085
  outputctl.error(err);
896
1086
  }
897
- resolve({ results, hasFailures });
1087
+ resolve({ resultUrls, results, hasFailures });
898
1088
  });
899
1089
  }
900
- else {
901
- // Default mode: one assembly per file with p-queue concurrency limiting
1090
+ function runPerFileEmitter() {
902
1091
  emitter.on('job', (job) => {
903
- const inPath = job.in
904
- ? (job.in.path ?? null)
905
- : null;
906
- const outPath = job.out?.path ?? null;
907
- const outMtime = job.out?.mtime;
908
- outputctl.debug(`GOT JOB ${inPath ?? 'null'} ${outPath ?? 'null'}`);
909
- // Close the original streams immediately - we'll create fresh ones when processing
910
- if (job.in != null) {
911
- ;
912
- job.in.destroy();
913
- }
914
- if (job.out != null) {
915
- job.out.destroy();
916
- }
917
- // Add job to queue - p-queue handles concurrency automatically
1092
+ const inPath = job.inputPath;
1093
+ const outputPlan = job.out;
1094
+ const outputToken = reserveWatchJobToken(outputPlan?.path ?? null);
1095
+ outputctl.debug(`GOT JOB ${inPath ?? 'null'} ${outputPlan?.path ?? 'null'}`);
918
1096
  queue
919
1097
  .add(async () => {
920
- const result = await processAssemblyJob(inPath, outPath, outMtime);
1098
+ const result = await processAssemblyJob(inPath, outputPlan, outputToken);
921
1099
  if (result !== undefined) {
922
1100
  results.push(result);
923
1101
  }
@@ -927,18 +1105,18 @@ export async function create(outputctl, client, { steps, template, fields, watch
927
1105
  outputctl.error(err);
928
1106
  });
929
1107
  });
930
- emitter.on('error', (err) => {
931
- abortController.abort();
932
- queue.clear();
933
- outputctl.error(err);
934
- reject(err);
935
- });
936
1108
  emitter.on('end', async () => {
937
- // Wait for all queued jobs to complete
938
1109
  await queue.onIdle();
939
- resolve({ results, hasFailures });
1110
+ resolve({ resultUrls, results, hasFailures });
940
1111
  });
941
1112
  }
1113
+ emitter.on('error', handleEmitterError);
1114
+ if (singleAssembly) {
1115
+ runSingleAssemblyEmitter();
1116
+ }
1117
+ else {
1118
+ runPerFileEmitter();
1119
+ }
942
1120
  });
943
1121
  }
944
1122
  // --- Command classes ---
@@ -977,34 +1155,20 @@ export class AssembliesCreateCommand extends AuthenticatedCommand {
977
1155
  template = Option.String('--template,-t', {
978
1156
  description: 'Specify a template to use for these assemblies',
979
1157
  });
980
- inputs = Option.Array('--input,-i', {
981
- description: 'Provide an input file or a directory',
982
- });
1158
+ inputs = inputPathsOption();
983
1159
  outputPath = Option.String('--output,-o', {
984
1160
  description: 'Specify an output file or directory',
985
1161
  });
986
1162
  fields = Option.Array('--field,-f', {
987
1163
  description: 'Set a template field (KEY=VAL)',
988
1164
  });
989
- watch = Option.Boolean('--watch,-w', false, {
990
- description: 'Watch inputs for changes',
991
- });
992
- recursive = Option.Boolean('--recursive,-r', false, {
993
- description: 'Enumerate input directories recursively',
994
- });
995
- deleteAfterProcessing = Option.Boolean('--delete-after-processing,-d', false, {
996
- description: 'Delete input files after they are processed',
997
- });
998
- reprocessStale = Option.Boolean('--reprocess-stale', false, {
999
- description: 'Process inputs even if output is newer',
1000
- });
1001
- singleAssembly = Option.Boolean('--single-assembly', false, {
1002
- description: 'Pass all input files to a single assembly instead of one assembly per file',
1003
- });
1004
- concurrency = Option.String('--concurrency,-c', {
1005
- description: 'Maximum number of concurrent assemblies (default: 5)',
1006
- validator: t.isNumber(),
1007
- });
1165
+ watch = watchOption();
1166
+ recursive = recursiveOption();
1167
+ deleteAfterProcessing = deleteAfterProcessingOption();
1168
+ reprocessStale = reprocessStaleOption();
1169
+ singleAssembly = singleAssemblyOption();
1170
+ concurrency = concurrencyOption();
1171
+ printUrls = printUrlsOption();
1008
1172
  async run() {
1009
1173
  if (!this.steps && !this.template) {
1010
1174
  this.output.error('assemblies create requires exactly one of either --steps or --template');
@@ -1015,34 +1179,29 @@ export class AssembliesCreateCommand extends AuthenticatedCommand {
1015
1179
  return 1;
1016
1180
  }
1017
1181
  const inputList = this.inputs ?? [];
1018
- if (inputList.length === 0 && this.watch) {
1019
- this.output.error('assemblies create --watch requires at least one input');
1020
- return 1;
1021
- }
1022
1182
  // Default to stdin only for `--steps` mode (common "pipe a file into a one-off assembly" use case).
1023
1183
  // For `--template` mode, templates may be inputless or use /http/import, so stdin should be explicit (`--input -`).
1024
1184
  if (this.steps && inputList.length === 0 && !process.stdin.isTTY) {
1025
1185
  inputList.push('-');
1026
1186
  }
1027
- const fieldsMap = {};
1028
- for (const field of this.fields ?? []) {
1029
- const eqIndex = field.indexOf('=');
1030
- if (eqIndex === -1) {
1031
- this.output.error(`invalid argument for --field: '${field}'`);
1032
- return 1;
1033
- }
1034
- const key = field.slice(0, eqIndex);
1035
- const value = field.slice(eqIndex + 1);
1036
- fieldsMap[key] = value;
1187
+ const fieldsMap = parseTemplateFieldAssignments(this.output, this.fields);
1188
+ if (this.fields != null && fieldsMap == null) {
1189
+ return 1;
1037
1190
  }
1038
- if (this.singleAssembly && this.watch) {
1039
- this.output.error('--single-assembly cannot be used with --watch');
1191
+ const sharedValidationError = validateSharedFileProcessingOptions({
1192
+ explicitInputCount: this.inputs?.length ?? 0,
1193
+ singleAssembly: this.singleAssembly,
1194
+ watch: this.watch,
1195
+ watchRequiresInputsMessage: 'assemblies create --watch requires at least one input',
1196
+ });
1197
+ if (sharedValidationError != null) {
1198
+ this.output.error(sharedValidationError);
1040
1199
  return 1;
1041
1200
  }
1042
- const { hasFailures } = await create(this.output, this.client, {
1201
+ const { hasFailures, resultUrls } = await create(this.output, this.client, {
1043
1202
  steps: this.steps,
1044
1203
  template: this.template,
1045
- fields: fieldsMap,
1204
+ fields: fieldsMap ?? {},
1046
1205
  watch: this.watch,
1047
1206
  recursive: this.recursive,
1048
1207
  inputs: inputList,
@@ -1050,8 +1209,11 @@ export class AssembliesCreateCommand extends AuthenticatedCommand {
1050
1209
  del: this.deleteAfterProcessing,
1051
1210
  reprocessStale: this.reprocessStale,
1052
1211
  singleAssembly: this.singleAssembly,
1053
- concurrency: this.concurrency,
1212
+ concurrency: this.concurrency == null ? undefined : Number(this.concurrency),
1054
1213
  });
1214
+ if (this.printUrls) {
1215
+ printResultUrls(this.output, resultUrls);
1216
+ }
1055
1217
  return hasFailures ? 1 : undefined;
1056
1218
  }
1057
1219
  }
@@ -1176,19 +1338,12 @@ export class AssembliesReplayCommand extends AuthenticatedCommand {
1176
1338
  });
1177
1339
  assemblyIds = Option.Rest({ required: 1 });
1178
1340
  async run() {
1179
- const fieldsMap = {};
1180
- for (const field of this.fields ?? []) {
1181
- const eqIndex = field.indexOf('=');
1182
- if (eqIndex === -1) {
1183
- this.output.error(`invalid argument for --field: '${field}'`);
1184
- return 1;
1185
- }
1186
- const key = field.slice(0, eqIndex);
1187
- const value = field.slice(eqIndex + 1);
1188
- fieldsMap[key] = value;
1341
+ const fieldsMap = parseTemplateFieldAssignments(this.output, this.fields);
1342
+ if (this.fields != null && fieldsMap == null) {
1343
+ return 1;
1189
1344
  }
1190
1345
  await replay(this.output, this.client, {
1191
- fields: fieldsMap,
1346
+ fields: fieldsMap ?? {},
1192
1347
  reparse: this.reparseTemplate,
1193
1348
  steps: this.steps,
1194
1349
  notify_url: this.notifyUrl,