@buildinternet/uploads 0.10.1 → 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";
@@ -15,8 +16,14 @@ import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
15
16
  import { applyFrame, resolveFrameId } from "./frame.js";
16
17
  import { buildCliProvenance } from "./provenance.js";
17
18
  import { formatByteSize } from "./format-bytes.js";
19
+ import { formatUsageHuman } from "./format-usage.js";
18
20
  import { packageVersion } from "./package-version.js";
19
- import { writeCommandHelp } from "./cli-style.js";
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;
26
+ export { formatUsageHuman } from "./format-usage.js";
20
27
  /** Read a local file (or `-` for stdin). Missing path → FILE_NOT_FOUND (exit 2). */
21
28
  export function readFileArg(fileArg) {
22
29
  try {
@@ -30,9 +37,13 @@ export function readFileArg(fileArg) {
30
37
  }
31
38
  }
32
39
  // --- put ---
33
- const PUT_HELP = `uploads put <file> [options]
40
+ const PUT_HELP = `uploads put <file...> [options]
34
41
 
35
- 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).
36
47
 
37
48
  Still images (PNG/JPEG/…) are optimized to WebP by default (long edge capped,
38
49
  high quality; EXIF stripped) so GitHub embeds stay lean. Original bytes are kept
@@ -55,13 +66,13 @@ Human/json output includes durable url and (when dual-host applies) embedUrl.
55
66
  MARKDOWN prefers embedUrl for GitHub. Override: UPLOADS_EMBED_PUBLIC_BASE_URL.
56
67
 
57
68
  Options:
58
- --key <key> Object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>)
59
- --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
60
71
  --destination <id> Typed root: screenshots | gh | f (sets --prefix)
61
72
  --prefix <path> Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX)
62
73
  --repo <owner/repo> Repo segment (default: git remote, or UPLOADS_DEFAULT_REPO)
63
74
  --ref <id> PR/issue/branch segment (default: today, or UPLOADS_DEFAULT_REF)
64
- --alt <text> Alt text (default: filename)
75
+ --alt <text> Alt text (default: each file's name; with multiple files applies to all)
65
76
  --width <px> <img width=…> markdown (or UPLOADS_DEFAULT_WIDTH)
66
77
  --content-type <mime> Override Content-Type (ignored when optimize rewrites the body)
67
78
  --frame <id> Device/browser frame before optimize (phone|browser|iphone-16-pro)
@@ -79,18 +90,19 @@ Options:
79
90
  --pr <num> Attach to a pull request: key gh/<owner>/<repo>/pull/<num>/<name> (stable URL, no hash)
80
91
  --issue <num> Attach to an issue: key gh/<owner>/<repo>/issues/<num>/<name>
81
92
  --comment With --pr/--issue: update one managed comment with attachments and linked galleries via local gh auth
82
- --gallery <id> Add the uploaded object to this public gallery
93
+ --gallery <id> Add the uploaded object(s) to this public gallery
83
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
84
95
  Re-uploading to an existing key WITH --meta replaces that file's
85
96
  entire metadata set; without --meta the existing metadata is
86
97
  preserved. Use "uploads meta set" to edit individual keys.
87
98
  --dry-run Print key + public URL without uploading; reports if the key would replace an existing object. Not with --comment/--gallery
88
99
 
89
- 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).
90
101
  Scripted formats (json|url|markdown) also print failures on stdout.
91
102
 
92
103
  Examples:
93
104
  uploads put ./shot.png --repo myorg/myapp --ref 1722 --alt "New cards" --width 700
105
+ uploads put ./before.png ./after.png
94
106
  uploads put ./mobile.png --frame phone
95
107
  uploads put ./ui.png --frame browser --frame-url "https://app.example/settings"
96
108
  uploads put ./shot.png --destination screenshots
@@ -269,6 +281,9 @@ const ATTACH_HELP = `uploads attach <file...> [options]
269
281
  Upload one or more stable PR/issue attachments and maintain a single GitHub
270
282
  comment. With no target, uses the pull request for the current branch.
271
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
+
272
287
  Attachments are public and their repo/number/filename keys are predictable.
273
288
  Private/internal GitHub repository visibility does not restrict access; upload
274
289
  only media that is safe at a public URL.
@@ -308,6 +323,163 @@ Examples:
308
323
  uploads attach ./artifact.zip --issue 45 --no-comment
309
324
  uploads attach ./shot.png --meta app=myapp --meta page=settings
310
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
+ }
311
483
  export async function runAttach(ctx, args, help = false, run = execRunner) {
312
484
  const parsed = parseCommandArgs(args);
313
485
  if (help || parsed.help) {
@@ -337,53 +509,28 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
337
509
  const metadata = { ...metaExtras, ...ghMetadataFromTarget(target) };
338
510
  if (Object.keys(metadata).length > 0)
339
511
  validateMetaMap(metadata);
340
- const results = [];
341
- for (const file of parsed.positionals) {
342
- if (file === "-")
343
- throw new UsageError("attach does not support stdin; pass one or more file paths");
344
- const sourceName = basename(file);
345
- if (!ctx.quiet && !ctx.json)
346
- process.stderr.write(`>> uploading ${file}\n`);
347
- const prepared = await prepareImageForUpload(readFileArg(file), sourceName, {
348
- ...frameOpts,
349
- optimize: optimizeOpts,
350
- });
351
- if (prepared.frame?.framed && !ctx.quiet && !ctx.json) {
352
- process.stderr.write(`>> framed with ${prepared.frame.frameId}\n`);
353
- }
354
- const note = formatOptimizeNote(prepared);
355
- if (note && !ctx.quiet && !ctx.json)
356
- process.stderr.write(`>> ${note}\n`);
357
- const result = await ctx.client.put(prepared.bytes, {
358
- filename: prepared.filename,
359
- key: ghAttachmentKey(target, prepared.filename),
360
- contentType: prepared.optimized ? prepared.contentType : contentTypeOverride,
361
- provenance: buildCliProvenance({
362
- sourceName,
363
- optimized: prepared.optimized,
364
- frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
365
- keepExif: optimizeOpts.keepExif === true,
366
- }),
367
- metadata,
368
- });
369
- writeReplacedNote(result.replaced, ctx.quiet || ctx.json);
370
- const embedSrc = urlForGithubEmbed(result.url, result.embedUrl);
371
- results.push({
372
- ...result,
373
- markdown: buildMarkdown(embedSrc, { alt: sourceName }),
374
- optimize: {
375
- optimized: prepared.optimized,
376
- skippedReason: prepared.skippedReason,
377
- originalBytes: prepared.originalBytes,
378
- outputBytes: prepared.outputBytes,
379
- filename: prepared.filename,
380
- },
381
- frame: prepared.frame,
382
- });
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));
383
529
  }
384
530
  let comment;
385
531
  let commentError;
386
- 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) {
387
534
  try {
388
535
  comment = await syncAttachmentsComment(ctx.client, target, run);
389
536
  }
@@ -393,22 +540,33 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
393
540
  }
394
541
  }
395
542
  if (ctx.json) {
396
- await writeJson({ target, uploads: results, comment, commentError });
543
+ await writeJson({ target, uploads, failures, comment, commentError });
397
544
  }
398
545
  else {
399
- 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
+ }
400
556
  const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
401
557
  await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${result.markdown}\n`);
402
558
  }
559
+ for (const failure of failures) {
560
+ process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
561
+ }
403
562
  if (!ctx.quiet && comment)
404
563
  process.stderr.write(`>> attachments comment ${comment.action}\n`);
405
- // attach auto-writes gh.* metadata; point the user at how to find it later.
406
- if (!ctx.quiet) {
564
+ if (!ctx.quiet && uploads.length > 0) {
407
565
  const ref = ghMetadataFromTarget(target)["gh.ref"];
408
566
  process.stderr.write(`>> find these later: uploads find gh.ref=${ref}\n`);
409
567
  }
410
568
  }
411
- return 0;
569
+ return failures.length === 0 ? 0 : 1;
412
570
  }
413
571
  export async function runPut(ctx, args, help = false, run = execRunner) {
414
572
  if (help) {
@@ -420,11 +578,12 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
420
578
  writeCommandHelp(PUT_HELP);
421
579
  return 0;
422
580
  }
423
- const fileArg = parsed.positionals[0];
424
- if (!fileArg) {
581
+ const files = parsed.positionals;
582
+ if (files.length === 0) {
425
583
  writeCommandHelp(PUT_HELP);
426
584
  return 2;
427
585
  }
586
+ const multi = files.length > 1;
428
587
  const keyHint = flagString(parsed.flags, "--key");
429
588
  const destFlag = flagString(parsed.flags, "--destination");
430
589
  const prefixFlag = flagString(parsed.flags, "--prefix");
@@ -449,6 +608,15 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
449
608
  }
450
609
  if (wantComment && !ghTarget)
451
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
+ }
452
620
  if (ghTarget) {
453
621
  if (keyHint) {
454
622
  throw new UsageError("--key cannot be combined with --pr/--issue; use --name <leaf> to set a clean filename on the stable path");
@@ -484,8 +652,6 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
484
652
  catch (err) {
485
653
  throw new UsageError(err instanceof Error ? err.message : String(err));
486
654
  }
487
- const bytes = readFileArg(fileArg);
488
- const sourceName = nameFlag ?? (fileArg === "-" ? (keyHint ? basename(keyHint) : "stdin.bin") : basename(fileArg));
489
655
  const format = ctx.json
490
656
  ? "json"
491
657
  : (() => {
@@ -499,14 +665,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
499
665
  const defaults = resolvePutDefaults({ envFile: ctx.envFile });
500
666
  const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
501
667
  const frameOpts = frameOptionsFromFlags(parsed.flags);
502
- // Optimize even on --dry-run so the preview key extension/hash match a real put.
503
- const prepared = await prepareImageForUpload(bytes, sourceName, {
504
- ...frameOpts,
505
- optimize: optimizeOpts,
506
- });
507
- const filename = prepared.filename;
508
668
  const contentTypeOverride = flagString(parsed.flags, "--content-type");
509
- const alt = flagString(parsed.flags, "--alt") ?? basename(sourceName);
669
+ const altFlag = flagString(parsed.flags, "--alt");
510
670
  const widthRaw = flagString(parsed.flags, "--width");
511
671
  const width = widthRaw && /^\d+$/.test(widthRaw) && Number(widthRaw) > 0
512
672
  ? Number.parseInt(widthRaw, 10)
@@ -515,14 +675,6 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
515
675
  throw new UsageError(`invalid --width: ${widthRaw}`);
516
676
  })()
517
677
  : defaults.width;
518
- if (!ctx.quiet && format === "human") {
519
- process.stderr.write(`>> ${dryRun ? "dry run" : "uploading"} ${fileArg === "-" ? "stdin" : fileArg}\n`);
520
- if (prepared.frame?.framed)
521
- process.stderr.write(`>> framed with ${prepared.frame.frameId}\n`);
522
- const note = formatOptimizeNote(prepared);
523
- if (note)
524
- process.stderr.write(`>> ${note}\n`);
525
- }
526
678
  const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true;
527
679
  // gh.* metadata: explicit --pr/--issue target wins over --meta; otherwise
528
680
  // best-effort auto resolution (on by default) where --meta wins. --no-git,
@@ -558,66 +710,144 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
558
710
  }
559
711
  }
560
712
  }
561
- if (attachedRef && !ctx.quiet && format === "human") {
562
- process.stderr.write(`>> attached to ${attachedRef}\n`);
563
- }
564
- let key = ghTarget ? ghAttachmentKey(ghTarget, filename) : keyHint;
565
- if (key && prepared.optimized)
566
- key = rewriteKeyExtension(key, filename);
567
- const result = await ctx.client.put(prepared.bytes, {
568
- filename,
569
- 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,
570
731
  prefix: resolvedPrefix ?? defaults.prefix,
571
732
  repo: flagString(parsed.flags, "--repo") ?? defaults.repo,
572
733
  ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
573
- contentType: prepared.optimized ? prepared.contentType : contentTypeOverride,
574
734
  deriveRepoFromGit: !noGit,
735
+ contentType: contentTypeOverride,
575
736
  dryRun,
576
- provenance: buildCliProvenance({
577
- sourceName,
578
- optimized: prepared.optimized,
579
- frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
580
- keepExif: optimizeOpts.keepExif === true,
581
- }),
737
+ optimize: optimizeOpts,
738
+ frame: frameOpts,
582
739
  metadata,
740
+ alt: altFlag,
741
+ width,
583
742
  });
584
- if (format === "human")
585
- writeReplacedNote(result.replaced, ctx.quiet, dryRun);
586
- const embedSrc = urlForGithubEmbed(result.url, result.embedUrl);
587
- const markdown = buildMarkdown(embedSrc, { alt, width });
588
- let gallery;
589
- 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) {
590
770
  try {
591
- // Gallery mutations use optimistic versions. Fetch immediately before this
592
- // mutation so `put --gallery` composes safely with other CLI writers.
593
- const current = await ctx.client.getGallery(galleryId);
594
- const item = await ctx.client.addGalleryItem(galleryId, result.key, {
595
- expectedVersion: current.version,
596
- altText: alt,
597
- });
598
- 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`);
599
774
  }
600
775
  catch (err) {
601
- 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`);
602
778
  }
603
779
  }
604
- const optimizeMeta = {
605
- optimized: prepared.optimized,
606
- skippedReason: prepared.skippedReason,
607
- originalBytes: prepared.originalBytes,
608
- outputBytes: prepared.outputBytes,
609
- filename: prepared.filename,
610
- };
611
- 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);
612
836
  process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n\n`);
613
837
  }
614
838
  switch (format) {
615
839
  case "json":
616
840
  await writeJson({
617
- ...result,
618
- markdown,
619
- optimize: optimizeMeta,
620
- 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,
621
851
  gallery,
622
852
  ...(dryRun ? { dryRun: true } : {}),
623
853
  });
@@ -626,11 +856,11 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
626
856
  await writeStdout(`${result.url}\n`);
627
857
  break;
628
858
  case "markdown":
629
- await writeStdout(`${markdown}\n`);
859
+ await writeStdout(`${result.markdown}\n`);
630
860
  break;
631
861
  default: {
632
862
  const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
633
- 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`);
634
864
  }
635
865
  }
636
866
  if (gallery?.url && format !== "human") {
@@ -639,25 +869,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
639
869
  if (gallery?.error) {
640
870
  process.stderr.write(`warning: upload succeeded but adding it to gallery ${gallery.id} failed: ${gallery.error.message}\n`);
641
871
  }
642
- if (wantComment && ghTarget) {
643
- try {
644
- const sync = await syncAttachmentsComment(ctx.client, ghTarget, run);
645
- if (!ctx.quiet && format === "human") {
646
- process.stderr.write(`>> attachments comment ${sync.action}\n`);
647
- }
648
- }
649
- catch (err) {
650
- // Upload already succeeded; the comment is best-effort by design.
651
- process.stderr.write(`warning: upload succeeded but the GitHub comment failed (is gh installed and authenticated?): ${err instanceof Error ? err.message : String(err)}\n`);
652
- }
653
- }
654
872
  return gallery?.error ? 1 : 0;
655
873
  }
656
- function galleryError(err) {
657
- if (err instanceof UploadsError)
658
- return { message: err.message, code: err.code, status: err.status };
659
- return { message: err instanceof Error ? err.message : String(err) };
660
- }
661
874
  // --- galleries ---
662
875
  const GALLERY_HELP = `uploads gallery <command> [args]
663
876
 
@@ -848,7 +1061,7 @@ export async function runGallery(ctx, args, help = false) {
848
1061
  }));
849
1062
  }
850
1063
  catch (err) {
851
- failures.push({ objectKey, error: galleryError(err) });
1064
+ failures.push({ objectKey, error: errorDetail(err) });
852
1065
  }
853
1066
  }
854
1067
  const output = { galleryId: id, galleryUrl: galleryUrl ?? null, added, failures };
@@ -1112,12 +1325,20 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
1112
1325
  // --- usage / reconcile / purge ---
1113
1326
  const USAGE_HELP = `uploads usage [--workspace <name>]
1114
1327
 
1115
- Show workspace storage and monthly upload counters (and limits when set).
1328
+ Show workspace storage and monthly upload counters.
1329
+
1330
+ When the API reports workspace quotas (typical on uploads.sh cloud /
1331
+ self-serve plans), human output includes progress bars toward those caps.
1332
+ Self-hosted or unlimited operator workspaces get usage totals only, plus a
1333
+ short unmetered note — no invented limits.
1116
1334
 
1117
1335
  Examples:
1118
1336
  uploads --env-file .env usage
1119
1337
  uploads usage --json
1120
1338
  `;
1339
+ function formatCount(n) {
1340
+ return Number.isFinite(n) ? Math.trunc(n).toLocaleString("en-US") : String(n);
1341
+ }
1121
1342
  export async function runUsage(ctx, args, help = false) {
1122
1343
  if (help || parseCommandArgs(args).help) {
1123
1344
  writeCommandHelp(USAGE_HELP);
@@ -1128,14 +1349,7 @@ export async function runUsage(ctx, args, help = false) {
1128
1349
  await writeJson(result);
1129
1350
  return 0;
1130
1351
  }
1131
- const lines = [
1132
- `workspace: ${result.workspace}`,
1133
- `bytes: ${result.bytes}${result.maxStorageBytes != null ? ` / ${result.maxStorageBytes} (${result.storageRemainingBytes} remaining)` : ""}`,
1134
- `objects: ${result.objects}`,
1135
- `uploads: ${result.uploadsInPeriod} this period (${result.periodStart})${result.maxUploadsPerPeriod != null ? ` / ${result.maxUploadsPerPeriod} (${result.uploadsRemaining} remaining)` : ""}`,
1136
- `updated: ${result.updatedAt}`,
1137
- ];
1138
- await writeStdout(lines.join("\n") + "\n");
1352
+ await writeStdout(formatUsageHuman(result, { color: colorEnabled(process.stdout) }).join("\n") + "\n");
1139
1353
  return 0;
1140
1354
  }
1141
1355
  const RECONCILE_HELP = `uploads reconcile [--workspace <name>]
@@ -1298,7 +1512,7 @@ export async function runDoctor(ctx, args, help = false) {
1298
1512
  ];
1299
1513
  if (report.usage) {
1300
1514
  lines.push(report.usage.ok
1301
- ? `usage: ${report.usage.bytes} bytes, ${report.usage.objects} objects, ${report.usage.uploadsInPeriod} uploads this period`
1515
+ ? `usage: ${formatByteSize(report.usage.bytes ?? 0)}, ${formatCount(report.usage.objects ?? 0)} objects, ${formatCount(report.usage.uploadsInPeriod ?? 0)} uploads this period`
1302
1516
  : `usage: failed — ${report.usage.error ?? "unknown"}`);
1303
1517
  }
1304
1518
  if (report.warning)