@buildinternet/uploads 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/commands.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
- import { createUploadsClient } from "./client.js";
3
+ import { mapBounded } from "./async.js";
4
+ import { createUploadsClient, } from "./client.js";
4
5
  import { parseCommandArgs, flagString, flagBool, flagInt, flagValues, UsageError, } from "./cli-args.js";
5
6
  import { resolvePutDefaults, workspaceMismatch, workspaceFromToken, } from "./config.js";
6
7
  import { buildMarkdown } from "./embed.js";
@@ -18,6 +19,10 @@ import { formatByteSize } from "./format-bytes.js";
18
19
  import { formatUsageHuman } from "./format-usage.js";
19
20
  import { packageVersion } from "./package-version.js";
20
21
  import { colorEnabled, writeCommandHelp } from "./cli-style.js";
22
+ /** Parallel fan-out for multi-file put/attach (matches files-sdk bulk default). */
23
+ export const UPLOAD_BATCH_CONCURRENCY = 8;
24
+ /** @deprecated Use UPLOAD_BATCH_CONCURRENCY. */
25
+ export const ATTACH_CONCURRENCY = UPLOAD_BATCH_CONCURRENCY;
21
26
  export { formatUsageHuman } from "./format-usage.js";
22
27
  /** Read a local file (or `-` for stdin). Missing path → FILE_NOT_FOUND (exit 2). */
23
28
  export function readFileArg(fileArg) {
@@ -32,9 +37,13 @@ export function readFileArg(fileArg) {
32
37
  }
33
38
  }
34
39
  // --- put ---
35
- const PUT_HELP = `uploads put <file> [options]
40
+ const PUT_HELP = `uploads put <file...> [options]
36
41
 
37
- Upload an image for GitHub embeds. Use "-" for stdin.
42
+ Upload one or more images for GitHub embeds. Use "-" for stdin (single file only).
43
+
44
+ Multiple files upload in parallel (bounded concurrency). One bad file does not
45
+ block the rest; multi-file JSON is { uploads, failures } (exit 1 when any failed).
46
+ Single-file JSON stays a flat object (back-compat).
38
47
 
39
48
  Still images (PNG/JPEG/…) are optimized to WebP by default (long edge capped,
40
49
  high quality; EXIF stripped) so GitHub embeds stay lean. Original bytes are kept
@@ -57,13 +66,13 @@ Human/json output includes durable url and (when dual-host applies) embedUrl.
57
66
  MARKDOWN prefers embedUrl for GitHub. Override: UPLOADS_EMBED_PUBLIC_BASE_URL.
58
67
 
59
68
  Options:
60
- --key <key> Object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>)
61
- --name <leaf> Clean key leaf + default alt (no '/'); keeps --pr/default path. Not with --key
69
+ --key <key> Object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>). Single file only
70
+ --name <leaf> Clean key leaf + default alt (no '/'); keeps --pr/default path. Single file only. Not with --key
62
71
  --destination <id> Typed root: screenshots | gh | f (sets --prefix)
63
72
  --prefix <path> Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX)
64
73
  --repo <owner/repo> Repo segment (default: git remote, or UPLOADS_DEFAULT_REPO)
65
74
  --ref <id> PR/issue/branch segment (default: today, or UPLOADS_DEFAULT_REF)
66
- --alt <text> Alt text (default: filename)
75
+ --alt <text> Alt text (default: each file's name; with multiple files applies to all)
67
76
  --width <px> <img width=…> markdown (or UPLOADS_DEFAULT_WIDTH)
68
77
  --content-type <mime> Override Content-Type (ignored when optimize rewrites the body)
69
78
  --frame <id> Device/browser frame before optimize (phone|browser|iphone-16-pro)
@@ -81,18 +90,19 @@ Options:
81
90
  --pr <num> Attach to a pull request: key gh/<owner>/<repo>/pull/<num>/<name> (stable URL, no hash)
82
91
  --issue <num> Attach to an issue: key gh/<owner>/<repo>/issues/<num>/<name>
83
92
  --comment With --pr/--issue: update one managed comment with attachments and linked galleries via local gh auth
84
- --gallery <id> Add the uploaded object to this public gallery
93
+ --gallery <id> Add the uploaded object(s) to this public gallery
85
94
  --meta <k=v> Queryable custom metadata (repeatable; value may contain "="): key ^[a-z][a-z0-9._-]{0,63}$, value 1-512 printable ASCII, max 24 pairs
86
95
  Re-uploading to an existing key WITH --meta replaces that file's
87
96
  entire metadata set; without --meta the existing metadata is
88
97
  preserved. Use "uploads meta set" to edit individual keys.
89
98
  --dry-run Print key + public URL without uploading; reports if the key would replace an existing object. Not with --comment/--gallery
90
99
 
91
- Exit codes: 0 ok · 2 usage/token/file · 3 auth/policy · 4 network · 1 other.
100
+ Exit codes: 0 ok · 2 usage/token/file · 3 auth/policy · 4 network · 1 other (incl. partial multi-file failure).
92
101
  Scripted formats (json|url|markdown) also print failures on stdout.
93
102
 
94
103
  Examples:
95
104
  uploads put ./shot.png --repo myorg/myapp --ref 1722 --alt "New cards" --width 700
105
+ uploads put ./before.png ./after.png
96
106
  uploads put ./mobile.png --frame phone
97
107
  uploads put ./ui.png --frame browser --frame-url "https://app.example/settings"
98
108
  uploads put ./shot.png --destination screenshots
@@ -271,6 +281,9 @@ const ATTACH_HELP = `uploads attach <file...> [options]
271
281
  Upload one or more stable PR/issue attachments and maintain a single GitHub
272
282
  comment. With no target, uses the pull request for the current branch.
273
283
 
284
+ Multiple files upload in parallel (bounded concurrency). One bad file does not
285
+ block the rest; JSON includes uploads + failures (exit 1 when any failed).
286
+
274
287
  Attachments are public and their repo/number/filename keys are predictable.
275
288
  Private/internal GitHub repository visibility does not restrict access; upload
276
289
  only media that is safe at a public URL.
@@ -310,6 +323,163 @@ Examples:
310
323
  uploads attach ./artifact.zip --issue 45 --no-comment
311
324
  uploads attach ./shot.png --meta app=myapp --meta page=settings
312
325
  `;
326
+ /**
327
+ * Prepare + put each path as a PR/issue attachment with bounded concurrency.
328
+ * Per-file errors collect in `failures` (does not throw). `firstError` is the
329
+ * original cause of the first failure — for rethrowing single-file CLI paths.
330
+ */
331
+ export async function uploadAttachments(opts) {
332
+ if (opts.files.some((f) => f === "-")) {
333
+ throw new UsageError("attach does not support stdin; pass one or more file paths");
334
+ }
335
+ const slots = await mapBounded(opts.files, opts.concurrency ?? UPLOAD_BATCH_CONCURRENCY, async (file) => {
336
+ try {
337
+ const sourceName = basename(file);
338
+ const prepared = await prepareImageForUpload(readFileArg(file), sourceName, {
339
+ ...opts.frame,
340
+ optimize: opts.optimize,
341
+ });
342
+ const result = await opts.client.put(prepared.bytes, {
343
+ filename: prepared.filename,
344
+ key: ghAttachmentKey(opts.target, prepared.filename),
345
+ contentType: prepared.optimized ? prepared.contentType : opts.contentType,
346
+ provenance: buildCliProvenance({
347
+ sourceName,
348
+ client: opts.provenanceClient,
349
+ optimized: prepared.optimized,
350
+ frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
351
+ keepExif: opts.optimize.keepExif === true,
352
+ }),
353
+ metadata: opts.metadata,
354
+ });
355
+ return {
356
+ ok: true,
357
+ upload: {
358
+ ...result,
359
+ file,
360
+ markdown: buildMarkdown(urlForGithubEmbed(result.url, result.embedUrl), {
361
+ alt: sourceName,
362
+ }),
363
+ optimize: {
364
+ optimized: prepared.optimized,
365
+ skippedReason: prepared.skippedReason,
366
+ originalBytes: prepared.originalBytes,
367
+ outputBytes: prepared.outputBytes,
368
+ filename: prepared.filename,
369
+ },
370
+ frame: prepared.frame,
371
+ },
372
+ };
373
+ }
374
+ catch (err) {
375
+ return { ok: false, file, err };
376
+ }
377
+ });
378
+ const uploads = [];
379
+ const failures = [];
380
+ let firstError;
381
+ for (const slot of slots) {
382
+ if (slot.ok)
383
+ uploads.push(slot.upload);
384
+ else {
385
+ firstError ??= slot.err;
386
+ failures.push({ file: slot.file, error: errorDetail(slot.err) });
387
+ }
388
+ }
389
+ return { uploads, failures, firstError };
390
+ }
391
+ function errorDetail(err) {
392
+ if (err instanceof UploadsError)
393
+ return { message: err.message, code: err.code, status: err.status };
394
+ return { message: err instanceof Error ? err.message : String(err) };
395
+ }
396
+ /**
397
+ * Prepare + put each path with put-style key resolution and bounded concurrency.
398
+ * Same partial-failure shape as uploadAttachments.
399
+ */
400
+ export async function uploadPuts(opts) {
401
+ if (opts.files.length > 1 && opts.files.some((f) => f === "-")) {
402
+ throw new UsageError("stdin (-) cannot be combined with multiple file arguments");
403
+ }
404
+ if (opts.files.length > 1 && opts.explicitKey) {
405
+ throw new UsageError("--key cannot be combined with multiple files");
406
+ }
407
+ if (opts.files.length > 1 && opts.nameOverride) {
408
+ throw new UsageError("--name cannot be combined with multiple files");
409
+ }
410
+ const slots = await mapBounded(opts.files, opts.concurrency ?? UPLOAD_BATCH_CONCURRENCY, async (file) => {
411
+ try {
412
+ const sourceName = opts.nameOverride ??
413
+ (file === "-"
414
+ ? opts.explicitKey
415
+ ? basename(opts.explicitKey)
416
+ : "stdin.bin"
417
+ : basename(file));
418
+ const prepared = await prepareImageForUpload(readFileArg(file), sourceName, {
419
+ ...opts.frame,
420
+ optimize: opts.optimize,
421
+ });
422
+ let key = opts.ghTarget
423
+ ? ghAttachmentKey(opts.ghTarget, prepared.filename)
424
+ : opts.explicitKey;
425
+ if (key && prepared.optimized)
426
+ key = rewriteKeyExtension(key, prepared.filename);
427
+ const result = await opts.client.put(prepared.bytes, {
428
+ filename: prepared.filename,
429
+ key,
430
+ prefix: opts.prefix,
431
+ repo: opts.repo,
432
+ ref: opts.ref,
433
+ contentType: prepared.optimized ? prepared.contentType : opts.contentType,
434
+ deriveRepoFromGit: opts.deriveRepoFromGit,
435
+ dryRun: opts.dryRun,
436
+ provenance: buildCliProvenance({
437
+ sourceName,
438
+ client: opts.provenanceClient,
439
+ optimized: prepared.optimized,
440
+ frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
441
+ keepExif: opts.optimize.keepExif === true,
442
+ }),
443
+ metadata: opts.metadata,
444
+ });
445
+ const alt = opts.alt ?? basename(sourceName);
446
+ return {
447
+ ok: true,
448
+ upload: {
449
+ ...result,
450
+ file,
451
+ markdown: buildMarkdown(urlForGithubEmbed(result.url, result.embedUrl), {
452
+ alt,
453
+ width: opts.width,
454
+ }),
455
+ optimize: {
456
+ optimized: prepared.optimized,
457
+ skippedReason: prepared.skippedReason,
458
+ originalBytes: prepared.originalBytes,
459
+ outputBytes: prepared.outputBytes,
460
+ filename: prepared.filename,
461
+ },
462
+ frame: prepared.frame,
463
+ },
464
+ };
465
+ }
466
+ catch (err) {
467
+ return { ok: false, file, err };
468
+ }
469
+ });
470
+ const uploads = [];
471
+ const failures = [];
472
+ let firstError;
473
+ for (const slot of slots) {
474
+ if (slot.ok)
475
+ uploads.push(slot.upload);
476
+ else {
477
+ firstError ??= slot.err;
478
+ failures.push({ file: slot.file, error: errorDetail(slot.err) });
479
+ }
480
+ }
481
+ return { uploads, failures, firstError };
482
+ }
313
483
  export async function runAttach(ctx, args, help = false, run = execRunner) {
314
484
  const parsed = parseCommandArgs(args);
315
485
  if (help || parsed.help) {
@@ -339,53 +509,28 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
339
509
  const metadata = { ...metaExtras, ...ghMetadataFromTarget(target) };
340
510
  if (Object.keys(metadata).length > 0)
341
511
  validateMetaMap(metadata);
342
- const results = [];
343
- for (const file of parsed.positionals) {
344
- if (file === "-")
345
- throw new UsageError("attach does not support stdin; pass one or more file paths");
346
- const sourceName = basename(file);
347
- if (!ctx.quiet && !ctx.json)
348
- process.stderr.write(`>> uploading ${file}\n`);
349
- const prepared = await prepareImageForUpload(readFileArg(file), sourceName, {
350
- ...frameOpts,
351
- optimize: optimizeOpts,
352
- });
353
- if (prepared.frame?.framed && !ctx.quiet && !ctx.json) {
354
- process.stderr.write(`>> framed with ${prepared.frame.frameId}\n`);
355
- }
356
- const note = formatOptimizeNote(prepared);
357
- if (note && !ctx.quiet && !ctx.json)
358
- process.stderr.write(`>> ${note}\n`);
359
- const result = await ctx.client.put(prepared.bytes, {
360
- filename: prepared.filename,
361
- key: ghAttachmentKey(target, prepared.filename),
362
- contentType: prepared.optimized ? prepared.contentType : contentTypeOverride,
363
- provenance: buildCliProvenance({
364
- sourceName,
365
- optimized: prepared.optimized,
366
- frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
367
- keepExif: optimizeOpts.keepExif === true,
368
- }),
369
- metadata,
370
- });
371
- writeReplacedNote(result.replaced, ctx.quiet || ctx.json);
372
- const embedSrc = urlForGithubEmbed(result.url, result.embedUrl);
373
- results.push({
374
- ...result,
375
- markdown: buildMarkdown(embedSrc, { alt: sourceName }),
376
- optimize: {
377
- optimized: prepared.optimized,
378
- skippedReason: prepared.skippedReason,
379
- originalBytes: prepared.originalBytes,
380
- outputBytes: prepared.outputBytes,
381
- filename: prepared.filename,
382
- },
383
- frame: prepared.frame,
384
- });
512
+ const logHuman = !ctx.quiet && !ctx.json;
513
+ if (logHuman) {
514
+ const n = parsed.positionals.length;
515
+ process.stderr.write(`>> uploading ${n} file${n === 1 ? "" : "s"}\n`);
516
+ }
517
+ const { uploads, failures, firstError } = await uploadAttachments({
518
+ client: ctx.client,
519
+ target,
520
+ files: parsed.positionals,
521
+ contentType: contentTypeOverride,
522
+ optimize: optimizeOpts,
523
+ frame: frameOpts,
524
+ metadata,
525
+ });
526
+ // Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
527
+ if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
528
+ throw firstError instanceof Error ? firstError : new Error(String(firstError));
385
529
  }
386
530
  let comment;
387
531
  let commentError;
388
- if (!parsed.flags.has("--no-comment")) {
532
+ // Skip comment refresh when every upload failed — nothing new from this batch.
533
+ if (!parsed.flags.has("--no-comment") && uploads.length > 0) {
389
534
  try {
390
535
  comment = await syncAttachmentsComment(ctx.client, target, run);
391
536
  }
@@ -395,22 +540,33 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
395
540
  }
396
541
  }
397
542
  if (ctx.json) {
398
- await writeJson({ target, uploads: results, comment, commentError });
543
+ await writeJson({ target, uploads, failures, comment, commentError });
399
544
  }
400
545
  else {
401
- for (const result of results) {
546
+ for (const result of uploads) {
547
+ if (logHuman) {
548
+ if (result.frame?.framed) {
549
+ process.stderr.write(`>> ${basename(result.file)}: framed with ${result.frame.frameId}\n`);
550
+ }
551
+ const note = formatOptimizeNote(result.optimize);
552
+ if (note)
553
+ process.stderr.write(`>> ${basename(result.file)}: ${note}\n`);
554
+ writeReplacedNote(result.replaced, false);
555
+ }
402
556
  const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
403
557
  await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${result.markdown}\n`);
404
558
  }
559
+ for (const failure of failures) {
560
+ process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
561
+ }
405
562
  if (!ctx.quiet && comment)
406
563
  process.stderr.write(`>> attachments comment ${comment.action}\n`);
407
- // attach auto-writes gh.* metadata; point the user at how to find it later.
408
- if (!ctx.quiet) {
564
+ if (!ctx.quiet && uploads.length > 0) {
409
565
  const ref = ghMetadataFromTarget(target)["gh.ref"];
410
566
  process.stderr.write(`>> find these later: uploads find gh.ref=${ref}\n`);
411
567
  }
412
568
  }
413
- return 0;
569
+ return failures.length === 0 ? 0 : 1;
414
570
  }
415
571
  export async function runPut(ctx, args, help = false, run = execRunner) {
416
572
  if (help) {
@@ -422,11 +578,12 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
422
578
  writeCommandHelp(PUT_HELP);
423
579
  return 0;
424
580
  }
425
- const fileArg = parsed.positionals[0];
426
- if (!fileArg) {
581
+ const files = parsed.positionals;
582
+ if (files.length === 0) {
427
583
  writeCommandHelp(PUT_HELP);
428
584
  return 2;
429
585
  }
586
+ const multi = files.length > 1;
430
587
  const keyHint = flagString(parsed.flags, "--key");
431
588
  const destFlag = flagString(parsed.flags, "--destination");
432
589
  const prefixFlag = flagString(parsed.flags, "--prefix");
@@ -451,6 +608,15 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
451
608
  }
452
609
  if (wantComment && !ghTarget)
453
610
  throw new UsageError("--comment requires --pr or --issue");
611
+ if (multi) {
612
+ if (keyHint)
613
+ throw new UsageError("--key cannot be combined with multiple files");
614
+ if (nameFlag !== undefined)
615
+ throw new UsageError("--name cannot be combined with multiple files");
616
+ if (files.some((f) => f === "-")) {
617
+ throw new UsageError("stdin (-) cannot be combined with multiple file arguments");
618
+ }
619
+ }
454
620
  if (ghTarget) {
455
621
  if (keyHint) {
456
622
  throw new UsageError("--key cannot be combined with --pr/--issue; use --name <leaf> to set a clean filename on the stable path");
@@ -486,8 +652,6 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
486
652
  catch (err) {
487
653
  throw new UsageError(err instanceof Error ? err.message : String(err));
488
654
  }
489
- const bytes = readFileArg(fileArg);
490
- const sourceName = nameFlag ?? (fileArg === "-" ? (keyHint ? basename(keyHint) : "stdin.bin") : basename(fileArg));
491
655
  const format = ctx.json
492
656
  ? "json"
493
657
  : (() => {
@@ -501,14 +665,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
501
665
  const defaults = resolvePutDefaults({ envFile: ctx.envFile });
502
666
  const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
503
667
  const frameOpts = frameOptionsFromFlags(parsed.flags);
504
- // Optimize even on --dry-run so the preview key extension/hash match a real put.
505
- const prepared = await prepareImageForUpload(bytes, sourceName, {
506
- ...frameOpts,
507
- optimize: optimizeOpts,
508
- });
509
- const filename = prepared.filename;
510
668
  const contentTypeOverride = flagString(parsed.flags, "--content-type");
511
- const alt = flagString(parsed.flags, "--alt") ?? basename(sourceName);
669
+ const altFlag = flagString(parsed.flags, "--alt");
512
670
  const widthRaw = flagString(parsed.flags, "--width");
513
671
  const width = widthRaw && /^\d+$/.test(widthRaw) && Number(widthRaw) > 0
514
672
  ? Number.parseInt(widthRaw, 10)
@@ -517,14 +675,6 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
517
675
  throw new UsageError(`invalid --width: ${widthRaw}`);
518
676
  })()
519
677
  : defaults.width;
520
- if (!ctx.quiet && format === "human") {
521
- process.stderr.write(`>> ${dryRun ? "dry run" : "uploading"} ${fileArg === "-" ? "stdin" : fileArg}\n`);
522
- if (prepared.frame?.framed)
523
- process.stderr.write(`>> framed with ${prepared.frame.frameId}\n`);
524
- const note = formatOptimizeNote(prepared);
525
- if (note)
526
- process.stderr.write(`>> ${note}\n`);
527
- }
528
678
  const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true;
529
679
  // gh.* metadata: explicit --pr/--issue target wins over --meta; otherwise
530
680
  // best-effort auto resolution (on by default) where --meta wins. --no-git,
@@ -560,66 +710,144 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
560
710
  }
561
711
  }
562
712
  }
563
- if (attachedRef && !ctx.quiet && format === "human") {
564
- process.stderr.write(`>> attached to ${attachedRef}\n`);
565
- }
566
- let key = ghTarget ? ghAttachmentKey(ghTarget, filename) : keyHint;
567
- if (key && prepared.optimized)
568
- key = rewriteKeyExtension(key, filename);
569
- const result = await ctx.client.put(prepared.bytes, {
570
- filename,
571
- key,
713
+ const logHuman = !ctx.quiet && format === "human";
714
+ if (logHuman) {
715
+ if (multi) {
716
+ process.stderr.write(`>> ${dryRun ? "dry run for" : "uploading"} ${files.length} files\n`);
717
+ }
718
+ else {
719
+ const fileArg = files[0];
720
+ process.stderr.write(`>> ${dryRun ? "dry run" : "uploading"} ${fileArg === "-" ? "stdin" : fileArg}\n`);
721
+ }
722
+ if (attachedRef)
723
+ process.stderr.write(`>> attached to ${attachedRef}\n`);
724
+ }
725
+ const { uploads, failures, firstError } = await uploadPuts({
726
+ client: ctx.client,
727
+ files,
728
+ nameOverride: nameFlag,
729
+ explicitKey: keyHint,
730
+ ghTarget,
572
731
  prefix: resolvedPrefix ?? defaults.prefix,
573
732
  repo: flagString(parsed.flags, "--repo") ?? defaults.repo,
574
733
  ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
575
- contentType: prepared.optimized ? prepared.contentType : contentTypeOverride,
576
734
  deriveRepoFromGit: !noGit,
735
+ contentType: contentTypeOverride,
577
736
  dryRun,
578
- provenance: buildCliProvenance({
579
- sourceName,
580
- optimized: prepared.optimized,
581
- frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
582
- keepExif: optimizeOpts.keepExif === true,
583
- }),
737
+ optimize: optimizeOpts,
738
+ frame: frameOpts,
584
739
  metadata,
740
+ alt: altFlag,
741
+ width,
585
742
  });
586
- if (format === "human")
587
- writeReplacedNote(result.replaced, ctx.quiet, dryRun);
588
- const embedSrc = urlForGithubEmbed(result.url, result.embedUrl);
589
- const markdown = buildMarkdown(embedSrc, { alt, width });
590
- let gallery;
591
- if (galleryId) {
743
+ // Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
744
+ if (uploads.length === 0 && failures.length > 0 && !multi) {
745
+ throw firstError instanceof Error ? firstError : new Error(String(firstError));
746
+ }
747
+ const galleriesByKey = new Map();
748
+ let galleryHadError = false;
749
+ if (galleryId && uploads.length > 0) {
750
+ for (const upload of uploads) {
751
+ const alt = altFlag ?? basename(upload.file === "-" ? upload.optimize.filename : upload.file);
752
+ try {
753
+ // Gallery mutations use optimistic versions. Re-fetch before each add.
754
+ const current = await ctx.client.getGallery(galleryId);
755
+ const item = await ctx.client.addGalleryItem(galleryId, upload.key, {
756
+ expectedVersion: current.version,
757
+ altText: alt,
758
+ });
759
+ galleriesByKey.set(upload.key, { id: galleryId, url: current.url, item });
760
+ }
761
+ catch (err) {
762
+ galleryHadError = true;
763
+ galleriesByKey.set(upload.key, { id: galleryId, error: errorDetail(err) });
764
+ }
765
+ }
766
+ }
767
+ let comment;
768
+ let commentError;
769
+ if (wantComment && ghTarget && uploads.length > 0) {
592
770
  try {
593
- // Gallery mutations use optimistic versions. Fetch immediately before this
594
- // mutation so `put --gallery` composes safely with other CLI writers.
595
- const current = await ctx.client.getGallery(galleryId);
596
- const item = await ctx.client.addGalleryItem(galleryId, result.key, {
597
- expectedVersion: current.version,
598
- altText: alt,
599
- });
600
- gallery = { id: galleryId, url: current.url, item };
771
+ comment = await syncAttachmentsComment(ctx.client, ghTarget, run);
772
+ if (logHuman)
773
+ process.stderr.write(`>> attachments comment ${comment.action}\n`);
601
774
  }
602
775
  catch (err) {
603
- gallery = { id: galleryId, error: galleryError(err) };
776
+ commentError = err instanceof Error ? err.message : String(err);
777
+ process.stderr.write(`warning: upload succeeded but the GitHub comment failed (is gh installed and authenticated?): ${commentError}\n`);
604
778
  }
605
779
  }
606
- const optimizeMeta = {
607
- optimized: prepared.optimized,
608
- skippedReason: prepared.skippedReason,
609
- originalBytes: prepared.originalBytes,
610
- outputBytes: prepared.outputBytes,
611
- filename: prepared.filename,
612
- };
613
- if (!ctx.quiet && format === "human") {
780
+ // --- multi-file output ---
781
+ if (multi) {
782
+ if (format === "json") {
783
+ await writeJson({
784
+ uploads: uploads.map((u) => ({
785
+ ...u,
786
+ gallery: galleriesByKey.get(u.key),
787
+ ...(dryRun ? { dryRun: true } : {}),
788
+ })),
789
+ failures,
790
+ comment,
791
+ commentError,
792
+ });
793
+ }
794
+ else {
795
+ for (const result of uploads) {
796
+ if (logHuman) {
797
+ if (result.frame?.framed) {
798
+ process.stderr.write(`>> ${basename(result.file)}: framed with ${result.frame.frameId}\n`);
799
+ }
800
+ const note = formatOptimizeNote(result.optimize);
801
+ if (note)
802
+ process.stderr.write(`>> ${basename(result.file)}: ${note}\n`);
803
+ writeReplacedNote(result.replaced, false, dryRun);
804
+ process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n`);
805
+ }
806
+ const gallery = galleriesByKey.get(result.key);
807
+ if (format === "url")
808
+ await writeStdout(`${result.url}\n`);
809
+ else if (format === "markdown")
810
+ await writeStdout(`${result.markdown}\n`);
811
+ else {
812
+ const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
813
+ await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${result.markdown}${gallery?.url ? `\nGALLERY: ${gallery.url}` : ""}\n`);
814
+ }
815
+ if (gallery?.error) {
816
+ process.stderr.write(`warning: upload succeeded but adding ${result.key} to gallery ${gallery.id} failed: ${gallery.error.message}\n`);
817
+ }
818
+ }
819
+ for (const failure of failures) {
820
+ process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
821
+ }
822
+ }
823
+ return failures.length === 0 && !galleryHadError ? 0 : 1;
824
+ }
825
+ // --- single-file output (flat JSON shape, back-compat) ---
826
+ const result = uploads[0];
827
+ const gallery = galleriesByKey.get(result.key);
828
+ if (logHuman) {
829
+ if (result.frame?.framed) {
830
+ process.stderr.write(`>> framed with ${result.frame.frameId}\n`);
831
+ }
832
+ const note = formatOptimizeNote(result.optimize);
833
+ if (note)
834
+ process.stderr.write(`>> ${note}\n`);
835
+ writeReplacedNote(result.replaced, ctx.quiet, dryRun);
614
836
  process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n\n`);
615
837
  }
616
838
  switch (format) {
617
839
  case "json":
618
840
  await writeJson({
619
- ...result,
620
- markdown,
621
- optimize: optimizeMeta,
622
- frame: prepared.frame,
841
+ workspace: result.workspace,
842
+ key: result.key,
843
+ url: result.url,
844
+ embedUrl: result.embedUrl,
845
+ size: result.size,
846
+ contentType: result.contentType,
847
+ replaced: result.replaced,
848
+ markdown: result.markdown,
849
+ optimize: result.optimize,
850
+ frame: result.frame,
623
851
  gallery,
624
852
  ...(dryRun ? { dryRun: true } : {}),
625
853
  });
@@ -628,11 +856,11 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
628
856
  await writeStdout(`${result.url}\n`);
629
857
  break;
630
858
  case "markdown":
631
- await writeStdout(`${markdown}\n`);
859
+ await writeStdout(`${result.markdown}\n`);
632
860
  break;
633
861
  default: {
634
862
  const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
635
- await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${markdown}${gallery?.url ? `\nGALLERY: ${gallery.url}` : ""}\n`);
863
+ await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${result.markdown}${gallery?.url ? `\nGALLERY: ${gallery.url}` : ""}\n`);
636
864
  }
637
865
  }
638
866
  if (gallery?.url && format !== "human") {
@@ -641,25 +869,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
641
869
  if (gallery?.error) {
642
870
  process.stderr.write(`warning: upload succeeded but adding it to gallery ${gallery.id} failed: ${gallery.error.message}\n`);
643
871
  }
644
- if (wantComment && ghTarget) {
645
- try {
646
- const sync = await syncAttachmentsComment(ctx.client, ghTarget, run);
647
- if (!ctx.quiet && format === "human") {
648
- process.stderr.write(`>> attachments comment ${sync.action}\n`);
649
- }
650
- }
651
- catch (err) {
652
- // Upload already succeeded; the comment is best-effort by design.
653
- process.stderr.write(`warning: upload succeeded but the GitHub comment failed (is gh installed and authenticated?): ${err instanceof Error ? err.message : String(err)}\n`);
654
- }
655
- }
656
872
  return gallery?.error ? 1 : 0;
657
873
  }
658
- function galleryError(err) {
659
- if (err instanceof UploadsError)
660
- return { message: err.message, code: err.code, status: err.status };
661
- return { message: err instanceof Error ? err.message : String(err) };
662
- }
663
874
  // --- galleries ---
664
875
  const GALLERY_HELP = `uploads gallery <command> [args]
665
876
 
@@ -850,7 +1061,7 @@ export async function runGallery(ctx, args, help = false) {
850
1061
  }));
851
1062
  }
852
1063
  catch (err) {
853
- failures.push({ objectKey, error: galleryError(err) });
1064
+ failures.push({ objectKey, error: errorDetail(err) });
854
1065
  }
855
1066
  }
856
1067
  const output = { galleryId: id, galleryUrl: galleryUrl ?? null, added, failures };