@buildinternet/uploads 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -115,7 +125,7 @@ export function makeGhTarget(pr, issue, repoArg, run) {
115
125
  return { repo, kind: pr !== undefined ? "pull" : "issues", num: (pr ?? issue) };
116
126
  }
117
127
  /** Reads --pr/--issue (+ --repo) into a GhTarget; undefined when neither flag is present. */
118
- function ghTargetFromFlags(flags, run) {
128
+ export function ghTargetFromFlags(flags, run) {
119
129
  return makeGhTarget(flagInt(flags, "--pr", "--pr"), flagInt(flags, "--issue", "--issue"), flagString(flags, "--repo"), run);
120
130
  }
121
131
  /**
@@ -195,7 +205,50 @@ export async function prepareImageForUpload(bytes, filename, opts) {
195
205
  const optimized = await optimizeImageForUpload(currentBytes, currentName, opts.optimize);
196
206
  return { ...optimized, frame: frameMeta };
197
207
  }
198
- function frameOptionsFromFlags(flags) {
208
+ /**
209
+ * Shared bytes-oriented upload tail: frame + optimize the bytes, resolve the
210
+ * object key (gh attachment key wins over an explicit key; extension
211
+ * rewritten post-optimize), put, and build the GitHub embed markdown. Used by
212
+ * the screenshot CLI command, the MCP screenshot tool, and the MCP put
213
+ * tool's contentBase64 path — the three in-memory-bytes call sites.
214
+ * uploadPuts/uploadAttachments loop over file paths with their own bounded
215
+ * concurrency and delegate here per item.
216
+ */
217
+ export async function uploadPreparedImage(client, bytes, sourceName, opts) {
218
+ const prepared = await prepareImageForUpload(bytes, sourceName, {
219
+ frameId: opts.frame.frameId,
220
+ frameUrl: opts.frame.frameUrl,
221
+ frameFit: opts.frame.frameFit,
222
+ optimize: opts.optimize,
223
+ });
224
+ let key = opts.ghTarget ? ghAttachmentKey(opts.ghTarget, prepared.filename) : opts.key;
225
+ if (key && prepared.optimized)
226
+ key = rewriteKeyExtension(key, prepared.filename);
227
+ const result = await client.put(prepared.bytes, {
228
+ filename: prepared.filename,
229
+ key,
230
+ prefix: opts.prefix,
231
+ repo: opts.repo,
232
+ ref: opts.ref,
233
+ contentType: prepared.optimized ? prepared.contentType : opts.contentType,
234
+ deriveRepoFromGit: opts.deriveRepoFromGit,
235
+ dryRun: opts.dryRun,
236
+ provenance: buildCliProvenance({
237
+ sourceName,
238
+ client: opts.provenanceClient,
239
+ optimized: prepared.optimized,
240
+ frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
241
+ keepExif: opts.optimize.keepExif === true,
242
+ }),
243
+ metadata: opts.metadata,
244
+ });
245
+ const markdown = buildMarkdown(urlForGithubEmbed(result.url, result.embedUrl), {
246
+ alt: opts.alt(prepared),
247
+ width: opts.width,
248
+ });
249
+ return { result, prepared, markdown };
250
+ }
251
+ export function frameOptionsFromFlags(flags) {
199
252
  const raw = flagString(flags, "--frame");
200
253
  let frameId;
201
254
  try {
@@ -271,6 +324,9 @@ const ATTACH_HELP = `uploads attach <file...> [options]
271
324
  Upload one or more stable PR/issue attachments and maintain a single GitHub
272
325
  comment. With no target, uses the pull request for the current branch.
273
326
 
327
+ Multiple files upload in parallel (bounded concurrency). One bad file does not
328
+ block the rest; JSON includes uploads + failures (exit 1 when any failed).
329
+
274
330
  Attachments are public and their repo/number/filename keys are predictable.
275
331
  Private/internal GitHub repository visibility does not restrict access; upload
276
332
  only media that is safe at a public URL.
@@ -310,6 +366,148 @@ Examples:
310
366
  uploads attach ./artifact.zip --issue 45 --no-comment
311
367
  uploads attach ./shot.png --meta app=myapp --meta page=settings
312
368
  `;
369
+ /**
370
+ * Prepare + put each path as a PR/issue attachment with bounded concurrency.
371
+ * Per-file errors collect in `failures` (does not throw). `firstError` is the
372
+ * original cause of the first failure — for rethrowing single-file CLI paths.
373
+ */
374
+ export async function uploadAttachments(opts) {
375
+ if (opts.files.some((f) => f === "-")) {
376
+ throw new UsageError("attach does not support stdin; pass one or more file paths");
377
+ }
378
+ const slots = await mapBounded(opts.files, opts.concurrency ?? UPLOAD_BATCH_CONCURRENCY, async (file) => {
379
+ try {
380
+ const sourceName = basename(file);
381
+ const prepared = await prepareImageForUpload(readFileArg(file), sourceName, {
382
+ ...opts.frame,
383
+ optimize: opts.optimize,
384
+ });
385
+ const result = await opts.client.put(prepared.bytes, {
386
+ filename: prepared.filename,
387
+ key: ghAttachmentKey(opts.target, prepared.filename),
388
+ contentType: prepared.optimized ? prepared.contentType : opts.contentType,
389
+ provenance: buildCliProvenance({
390
+ sourceName,
391
+ client: opts.provenanceClient,
392
+ optimized: prepared.optimized,
393
+ frameId: prepared.frame?.framed ? prepared.frame.frameId : undefined,
394
+ keepExif: opts.optimize.keepExif === true,
395
+ }),
396
+ metadata: opts.metadata,
397
+ });
398
+ return {
399
+ ok: true,
400
+ upload: {
401
+ ...result,
402
+ file,
403
+ markdown: buildMarkdown(urlForGithubEmbed(result.url, result.embedUrl), {
404
+ alt: sourceName,
405
+ }),
406
+ optimize: {
407
+ optimized: prepared.optimized,
408
+ skippedReason: prepared.skippedReason,
409
+ originalBytes: prepared.originalBytes,
410
+ outputBytes: prepared.outputBytes,
411
+ filename: prepared.filename,
412
+ },
413
+ frame: prepared.frame,
414
+ },
415
+ };
416
+ }
417
+ catch (err) {
418
+ return { ok: false, file, err };
419
+ }
420
+ });
421
+ const uploads = [];
422
+ const failures = [];
423
+ let firstError;
424
+ for (const slot of slots) {
425
+ if (slot.ok)
426
+ uploads.push(slot.upload);
427
+ else {
428
+ firstError ??= slot.err;
429
+ failures.push({ file: slot.file, error: errorDetail(slot.err) });
430
+ }
431
+ }
432
+ return { uploads, failures, firstError };
433
+ }
434
+ function errorDetail(err) {
435
+ if (err instanceof UploadsError)
436
+ return { message: err.message, code: err.code, status: err.status };
437
+ return { message: err instanceof Error ? err.message : String(err) };
438
+ }
439
+ /**
440
+ * Prepare + put each path with put-style key resolution and bounded concurrency.
441
+ * Same partial-failure shape as uploadAttachments.
442
+ */
443
+ export async function uploadPuts(opts) {
444
+ if (opts.files.length > 1 && opts.files.some((f) => f === "-")) {
445
+ throw new UsageError("stdin (-) cannot be combined with multiple file arguments");
446
+ }
447
+ if (opts.files.length > 1 && opts.explicitKey) {
448
+ throw new UsageError("--key cannot be combined with multiple files");
449
+ }
450
+ if (opts.files.length > 1 && opts.nameOverride) {
451
+ throw new UsageError("--name cannot be combined with multiple files");
452
+ }
453
+ const slots = await mapBounded(opts.files, opts.concurrency ?? UPLOAD_BATCH_CONCURRENCY, async (file) => {
454
+ try {
455
+ const sourceName = opts.nameOverride ??
456
+ (file === "-"
457
+ ? opts.explicitKey
458
+ ? basename(opts.explicitKey)
459
+ : "stdin.bin"
460
+ : basename(file));
461
+ const { result, prepared, markdown } = await uploadPreparedImage(opts.client, readFileArg(file), sourceName, {
462
+ frame: opts.frame,
463
+ optimize: opts.optimize,
464
+ ghTarget: opts.ghTarget,
465
+ key: opts.explicitKey,
466
+ prefix: opts.prefix,
467
+ repo: opts.repo,
468
+ ref: opts.ref,
469
+ deriveRepoFromGit: opts.deriveRepoFromGit,
470
+ contentType: opts.contentType,
471
+ dryRun: opts.dryRun,
472
+ metadata: opts.metadata,
473
+ provenanceClient: opts.provenanceClient,
474
+ alt: () => opts.alt ?? basename(sourceName),
475
+ width: opts.width,
476
+ });
477
+ return {
478
+ ok: true,
479
+ upload: {
480
+ ...result,
481
+ file,
482
+ markdown,
483
+ optimize: {
484
+ optimized: prepared.optimized,
485
+ skippedReason: prepared.skippedReason,
486
+ originalBytes: prepared.originalBytes,
487
+ outputBytes: prepared.outputBytes,
488
+ filename: prepared.filename,
489
+ },
490
+ frame: prepared.frame,
491
+ },
492
+ };
493
+ }
494
+ catch (err) {
495
+ return { ok: false, file, err };
496
+ }
497
+ });
498
+ const uploads = [];
499
+ const failures = [];
500
+ let firstError;
501
+ for (const slot of slots) {
502
+ if (slot.ok)
503
+ uploads.push(slot.upload);
504
+ else {
505
+ firstError ??= slot.err;
506
+ failures.push({ file: slot.file, error: errorDetail(slot.err) });
507
+ }
508
+ }
509
+ return { uploads, failures, firstError };
510
+ }
313
511
  export async function runAttach(ctx, args, help = false, run = execRunner) {
314
512
  const parsed = parseCommandArgs(args);
315
513
  if (help || parsed.help) {
@@ -339,53 +537,28 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
339
537
  const metadata = { ...metaExtras, ...ghMetadataFromTarget(target) };
340
538
  if (Object.keys(metadata).length > 0)
341
539
  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
- });
540
+ const logHuman = !ctx.quiet && !ctx.json;
541
+ if (logHuman) {
542
+ const n = parsed.positionals.length;
543
+ process.stderr.write(`>> uploading ${n} file${n === 1 ? "" : "s"}\n`);
544
+ }
545
+ const { uploads, failures, firstError } = await uploadAttachments({
546
+ client: ctx.client,
547
+ target,
548
+ files: parsed.positionals,
549
+ contentType: contentTypeOverride,
550
+ optimize: optimizeOpts,
551
+ frame: frameOpts,
552
+ metadata,
553
+ });
554
+ // Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
555
+ if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
556
+ throw firstError instanceof Error ? firstError : new Error(String(firstError));
385
557
  }
386
558
  let comment;
387
559
  let commentError;
388
- if (!parsed.flags.has("--no-comment")) {
560
+ // Skip comment refresh when every upload failed — nothing new from this batch.
561
+ if (!parsed.flags.has("--no-comment") && uploads.length > 0) {
389
562
  try {
390
563
  comment = await syncAttachmentsComment(ctx.client, target, run);
391
564
  }
@@ -395,22 +568,33 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
395
568
  }
396
569
  }
397
570
  if (ctx.json) {
398
- await writeJson({ target, uploads: results, comment, commentError });
571
+ await writeJson({ target, uploads, failures, comment, commentError });
399
572
  }
400
573
  else {
401
- for (const result of results) {
574
+ for (const result of uploads) {
575
+ if (logHuman) {
576
+ if (result.frame?.framed) {
577
+ process.stderr.write(`>> ${basename(result.file)}: framed with ${result.frame.frameId}\n`);
578
+ }
579
+ const note = formatOptimizeNote(result.optimize);
580
+ if (note)
581
+ process.stderr.write(`>> ${basename(result.file)}: ${note}\n`);
582
+ writeReplacedNote(result.replaced, false);
583
+ }
402
584
  const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
403
585
  await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${result.markdown}\n`);
404
586
  }
587
+ for (const failure of failures) {
588
+ process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
589
+ }
405
590
  if (!ctx.quiet && comment)
406
591
  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) {
592
+ if (!ctx.quiet && uploads.length > 0) {
409
593
  const ref = ghMetadataFromTarget(target)["gh.ref"];
410
594
  process.stderr.write(`>> find these later: uploads find gh.ref=${ref}\n`);
411
595
  }
412
596
  }
413
- return 0;
597
+ return failures.length === 0 ? 0 : 1;
414
598
  }
415
599
  export async function runPut(ctx, args, help = false, run = execRunner) {
416
600
  if (help) {
@@ -422,11 +606,12 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
422
606
  writeCommandHelp(PUT_HELP);
423
607
  return 0;
424
608
  }
425
- const fileArg = parsed.positionals[0];
426
- if (!fileArg) {
609
+ const files = parsed.positionals;
610
+ if (files.length === 0) {
427
611
  writeCommandHelp(PUT_HELP);
428
612
  return 2;
429
613
  }
614
+ const multi = files.length > 1;
430
615
  const keyHint = flagString(parsed.flags, "--key");
431
616
  const destFlag = flagString(parsed.flags, "--destination");
432
617
  const prefixFlag = flagString(parsed.flags, "--prefix");
@@ -451,6 +636,15 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
451
636
  }
452
637
  if (wantComment && !ghTarget)
453
638
  throw new UsageError("--comment requires --pr or --issue");
639
+ if (multi) {
640
+ if (keyHint)
641
+ throw new UsageError("--key cannot be combined with multiple files");
642
+ if (nameFlag !== undefined)
643
+ throw new UsageError("--name cannot be combined with multiple files");
644
+ if (files.some((f) => f === "-")) {
645
+ throw new UsageError("stdin (-) cannot be combined with multiple file arguments");
646
+ }
647
+ }
454
648
  if (ghTarget) {
455
649
  if (keyHint) {
456
650
  throw new UsageError("--key cannot be combined with --pr/--issue; use --name <leaf> to set a clean filename on the stable path");
@@ -486,8 +680,6 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
486
680
  catch (err) {
487
681
  throw new UsageError(err instanceof Error ? err.message : String(err));
488
682
  }
489
- const bytes = readFileArg(fileArg);
490
- const sourceName = nameFlag ?? (fileArg === "-" ? (keyHint ? basename(keyHint) : "stdin.bin") : basename(fileArg));
491
683
  const format = ctx.json
492
684
  ? "json"
493
685
  : (() => {
@@ -501,14 +693,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
501
693
  const defaults = resolvePutDefaults({ envFile: ctx.envFile });
502
694
  const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
503
695
  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
696
  const contentTypeOverride = flagString(parsed.flags, "--content-type");
511
- const alt = flagString(parsed.flags, "--alt") ?? basename(sourceName);
697
+ const altFlag = flagString(parsed.flags, "--alt");
512
698
  const widthRaw = flagString(parsed.flags, "--width");
513
699
  const width = widthRaw && /^\d+$/.test(widthRaw) && Number(widthRaw) > 0
514
700
  ? Number.parseInt(widthRaw, 10)
@@ -517,14 +703,6 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
517
703
  throw new UsageError(`invalid --width: ${widthRaw}`);
518
704
  })()
519
705
  : 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
706
  const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true;
529
707
  // gh.* metadata: explicit --pr/--issue target wins over --meta; otherwise
530
708
  // best-effort auto resolution (on by default) where --meta wins. --no-git,
@@ -560,66 +738,144 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
560
738
  }
561
739
  }
562
740
  }
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,
741
+ const logHuman = !ctx.quiet && format === "human";
742
+ if (logHuman) {
743
+ if (multi) {
744
+ process.stderr.write(`>> ${dryRun ? "dry run for" : "uploading"} ${files.length} files\n`);
745
+ }
746
+ else {
747
+ const fileArg = files[0];
748
+ process.stderr.write(`>> ${dryRun ? "dry run" : "uploading"} ${fileArg === "-" ? "stdin" : fileArg}\n`);
749
+ }
750
+ if (attachedRef)
751
+ process.stderr.write(`>> attached to ${attachedRef}\n`);
752
+ }
753
+ const { uploads, failures, firstError } = await uploadPuts({
754
+ client: ctx.client,
755
+ files,
756
+ nameOverride: nameFlag,
757
+ explicitKey: keyHint,
758
+ ghTarget,
572
759
  prefix: resolvedPrefix ?? defaults.prefix,
573
760
  repo: flagString(parsed.flags, "--repo") ?? defaults.repo,
574
761
  ref: flagString(parsed.flags, "--ref") ?? defaults.ref,
575
- contentType: prepared.optimized ? prepared.contentType : contentTypeOverride,
576
762
  deriveRepoFromGit: !noGit,
763
+ contentType: contentTypeOverride,
577
764
  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
- }),
765
+ optimize: optimizeOpts,
766
+ frame: frameOpts,
584
767
  metadata,
768
+ alt: altFlag,
769
+ width,
585
770
  });
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) {
771
+ // Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
772
+ if (uploads.length === 0 && failures.length > 0 && !multi) {
773
+ throw firstError instanceof Error ? firstError : new Error(String(firstError));
774
+ }
775
+ const galleriesByKey = new Map();
776
+ let galleryHadError = false;
777
+ if (galleryId && uploads.length > 0) {
778
+ for (const upload of uploads) {
779
+ const alt = altFlag ?? basename(upload.file === "-" ? upload.optimize.filename : upload.file);
780
+ try {
781
+ // Gallery mutations use optimistic versions. Re-fetch before each add.
782
+ const current = await ctx.client.getGallery(galleryId);
783
+ const item = await ctx.client.addGalleryItem(galleryId, upload.key, {
784
+ expectedVersion: current.version,
785
+ altText: alt,
786
+ });
787
+ galleriesByKey.set(upload.key, { id: galleryId, url: current.url, item });
788
+ }
789
+ catch (err) {
790
+ galleryHadError = true;
791
+ galleriesByKey.set(upload.key, { id: galleryId, error: errorDetail(err) });
792
+ }
793
+ }
794
+ }
795
+ let comment;
796
+ let commentError;
797
+ if (wantComment && ghTarget && uploads.length > 0) {
592
798
  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 };
799
+ comment = await syncAttachmentsComment(ctx.client, ghTarget, run);
800
+ if (logHuman)
801
+ process.stderr.write(`>> attachments comment ${comment.action}\n`);
601
802
  }
602
803
  catch (err) {
603
- gallery = { id: galleryId, error: galleryError(err) };
804
+ commentError = err instanceof Error ? err.message : String(err);
805
+ process.stderr.write(`warning: upload succeeded but the GitHub comment failed (is gh installed and authenticated?): ${commentError}\n`);
604
806
  }
605
807
  }
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") {
808
+ // --- multi-file output ---
809
+ if (multi) {
810
+ if (format === "json") {
811
+ await writeJson({
812
+ uploads: uploads.map((u) => ({
813
+ ...u,
814
+ gallery: galleriesByKey.get(u.key),
815
+ ...(dryRun ? { dryRun: true } : {}),
816
+ })),
817
+ failures,
818
+ comment,
819
+ commentError,
820
+ });
821
+ }
822
+ else {
823
+ for (const result of uploads) {
824
+ if (logHuman) {
825
+ if (result.frame?.framed) {
826
+ process.stderr.write(`>> ${basename(result.file)}: framed with ${result.frame.frameId}\n`);
827
+ }
828
+ const note = formatOptimizeNote(result.optimize);
829
+ if (note)
830
+ process.stderr.write(`>> ${basename(result.file)}: ${note}\n`);
831
+ writeReplacedNote(result.replaced, false, dryRun);
832
+ process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n`);
833
+ }
834
+ const gallery = galleriesByKey.get(result.key);
835
+ if (format === "url")
836
+ await writeStdout(`${result.url}\n`);
837
+ else if (format === "markdown")
838
+ await writeStdout(`${result.markdown}\n`);
839
+ else {
840
+ const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
841
+ await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${result.markdown}${gallery?.url ? `\nGALLERY: ${gallery.url}` : ""}\n`);
842
+ }
843
+ if (gallery?.error) {
844
+ process.stderr.write(`warning: upload succeeded but adding ${result.key} to gallery ${gallery.id} failed: ${gallery.error.message}\n`);
845
+ }
846
+ }
847
+ for (const failure of failures) {
848
+ process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
849
+ }
850
+ }
851
+ return failures.length === 0 && !galleryHadError ? 0 : 1;
852
+ }
853
+ // --- single-file output (flat JSON shape, back-compat) ---
854
+ const result = uploads[0];
855
+ const gallery = galleriesByKey.get(result.key);
856
+ if (logHuman) {
857
+ if (result.frame?.framed) {
858
+ process.stderr.write(`>> framed with ${result.frame.frameId}\n`);
859
+ }
860
+ const note = formatOptimizeNote(result.optimize);
861
+ if (note)
862
+ process.stderr.write(`>> ${note}\n`);
863
+ writeReplacedNote(result.replaced, ctx.quiet, dryRun);
614
864
  process.stderr.write(`>> key: ${result.key}${dryRun ? " (dry run — not uploaded)" : ""}\n\n`);
615
865
  }
616
866
  switch (format) {
617
867
  case "json":
618
868
  await writeJson({
619
- ...result,
620
- markdown,
621
- optimize: optimizeMeta,
622
- frame: prepared.frame,
869
+ workspace: result.workspace,
870
+ key: result.key,
871
+ url: result.url,
872
+ embedUrl: result.embedUrl,
873
+ size: result.size,
874
+ contentType: result.contentType,
875
+ replaced: result.replaced,
876
+ markdown: result.markdown,
877
+ optimize: result.optimize,
878
+ frame: result.frame,
623
879
  gallery,
624
880
  ...(dryRun ? { dryRun: true } : {}),
625
881
  });
@@ -628,11 +884,11 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
628
884
  await writeStdout(`${result.url}\n`);
629
885
  break;
630
886
  case "markdown":
631
- await writeStdout(`${markdown}\n`);
887
+ await writeStdout(`${result.markdown}\n`);
632
888
  break;
633
889
  default: {
634
890
  const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
635
- await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${markdown}${gallery?.url ? `\nGALLERY: ${gallery.url}` : ""}\n`);
891
+ await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${result.markdown}${gallery?.url ? `\nGALLERY: ${gallery.url}` : ""}\n`);
636
892
  }
637
893
  }
638
894
  if (gallery?.url && format !== "human") {
@@ -641,25 +897,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
641
897
  if (gallery?.error) {
642
898
  process.stderr.write(`warning: upload succeeded but adding it to gallery ${gallery.id} failed: ${gallery.error.message}\n`);
643
899
  }
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
900
  return gallery?.error ? 1 : 0;
657
901
  }
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
902
  // --- galleries ---
664
903
  const GALLERY_HELP = `uploads gallery <command> [args]
665
904
 
@@ -850,7 +1089,7 @@ export async function runGallery(ctx, args, help = false) {
850
1089
  }));
851
1090
  }
852
1091
  catch (err) {
853
- failures.push({ objectKey, error: galleryError(err) });
1092
+ failures.push({ objectKey, error: errorDetail(err) });
854
1093
  }
855
1094
  }
856
1095
  const output = { galleryId: id, galleryUrl: galleryUrl ?? null, added, failures };
@@ -1223,8 +1462,52 @@ Examples:
1223
1462
  uploads --workspace acme --env-file .env doctor
1224
1463
  uploads doctor --json
1225
1464
  `;
1465
+ /**
1466
+ * Best-effort local-browser detection for doctor. fs scans only, no browser
1467
+ * launch. Guarded for non-Node runtimes as a precaution for any future
1468
+ * non-Node consumer of this module — apps/mcp today only imports
1469
+ * `buildMarkdown`/`buildScreenshotKey` from the package root, not
1470
+ * `buildDoctorReport`, so this guard isn't exercised on that path currently.
1471
+ */
1472
+ async function detectBrowserForDoctor(detectRoots) {
1473
+ if (typeof process === "undefined" || !process.versions?.node) {
1474
+ return {
1475
+ supported: false,
1476
+ found: false,
1477
+ autoBackend: "remote",
1478
+ candidates: [],
1479
+ note: "browser detection is not supported in this runtime",
1480
+ };
1481
+ }
1482
+ try {
1483
+ const { detectLocalBrowser } = await import("./screenshot-local.js");
1484
+ const { candidates, winner } = detectLocalBrowser(detectRoots);
1485
+ return {
1486
+ supported: true,
1487
+ found: Boolean(winner),
1488
+ autoBackend: winner ? "local" : "remote",
1489
+ candidates: candidates.map((c) => ({
1490
+ source: c.source,
1491
+ kind: c.kind,
1492
+ executablePath: c.executablePath,
1493
+ })),
1494
+ winner: winner
1495
+ ? { source: winner.source, kind: winner.kind, executablePath: winner.executablePath }
1496
+ : undefined,
1497
+ };
1498
+ }
1499
+ catch (err) {
1500
+ return {
1501
+ supported: true,
1502
+ found: false,
1503
+ autoBackend: "remote",
1504
+ candidates: [],
1505
+ note: err instanceof Error ? err.message : String(err),
1506
+ };
1507
+ }
1508
+ }
1226
1509
  /** Doctor's health + auth + workspace checks, shared by the CLI and the MCP tool. */
1227
- export async function buildDoctorReport(config, client) {
1510
+ export async function buildDoctorReport(config, client, detectRoots) {
1228
1511
  const mismatch = workspaceMismatch(config);
1229
1512
  const hints = [];
1230
1513
  if (mismatch)
@@ -1232,7 +1515,12 @@ export async function buildDoctorReport(config, client) {
1232
1515
  if (config.apiUrl.includes("localhost") || config.apiUrl.includes("127.0.0.1")) {
1233
1516
  hints.push("local API uses dev KV — prod tokens won't work unless minted with --local");
1234
1517
  }
1235
- const health = await client.health();
1518
+ // Independent checks fs-based browser detection doesn't depend on the
1519
+ // network health probe (or vice versa).
1520
+ const [browser, health] = await Promise.all([
1521
+ detectBrowserForDoctor(detectRoots),
1522
+ client.health(),
1523
+ ]);
1236
1524
  let authOk = false;
1237
1525
  let authError;
1238
1526
  try {
@@ -1280,6 +1568,7 @@ export async function buildDoctorReport(config, client) {
1280
1568
  usage,
1281
1569
  warning: mismatch,
1282
1570
  hints,
1571
+ browser,
1283
1572
  };
1284
1573
  }
1285
1574
  export async function runDoctor(ctx, args, help = false) {
@@ -1304,6 +1593,14 @@ export async function runDoctor(ctx, args, help = false) {
1304
1593
  ? `usage: ${formatByteSize(report.usage.bytes ?? 0)}, ${formatCount(report.usage.objects ?? 0)} objects, ${formatCount(report.usage.uploadsInPeriod ?? 0)} uploads this period`
1305
1594
  : `usage: failed — ${report.usage.error ?? "unknown"}`);
1306
1595
  }
1596
+ if (report.browser.supported) {
1597
+ lines.push(report.browser.found
1598
+ ? `browser: found (${report.browser.winner?.source}/${report.browser.winner?.kind}) — screenshot --via auto uses local`
1599
+ : `browser: none found — screenshot --via auto uses remote`);
1600
+ }
1601
+ else {
1602
+ lines.push(`browser: ${report.browser.note ?? "not supported in this runtime"}`);
1603
+ }
1307
1604
  if (report.warning)
1308
1605
  lines.push(`warning: ${report.warning}`);
1309
1606
  for (const h of report.hints)