@buildinternet/uploads 0.13.1 → 0.15.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
@@ -9,8 +9,8 @@ import { urlForGithubEmbed } from "./public-urls.js";
9
9
  import { UploadsError } from "./errors.js";
10
10
  import { writeJson, writeStdout } from "./io.js";
11
11
  import { parseMetaFlags, validateMetaMap } from "./metadata.js";
12
- import { ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, attachmentsCommentBody, normalizeGithubCoordinate, } from "./github.js";
13
- import { resolveRepo, resolveCurrentPullRequest, classifyGhNumber, execRunner, upsertAttachmentsComment, } from "./github-gh.js";
12
+ import { ghAttachmentKey, ghBranchAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, normalizeGithubCoordinate, } from "./github.js";
13
+ import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, classifyGhNumber, execRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
14
14
  import { resolvePutPrefix } from "./destinations.js";
15
15
  import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
16
16
  import { applyFrame, resolveFrameId } from "./frame.js";
@@ -89,7 +89,9 @@ Options:
89
89
  --format human|url|markdown|json
90
90
  --pr <num> Attach to a pull request: key gh/<owner>/<repo>/pull/<num>/<name> (stable URL, no hash)
91
91
  --issue <num> Attach to an issue: key gh/<owner>/<repo>/issues/<num>/<name>
92
- --comment With --pr/--issue: update one managed comment with attachments and linked galleries via local gh auth
92
+ --comment With --pr/--issue: update one managed comment with
93
+ attachments and linked galleries. Posts as uploads-sh[bot]
94
+ when the GitHub App is installed; otherwise via local gh.
93
95
  --gallery <id> Add the uploaded object(s) to this public gallery
94
96
  --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
95
97
  Re-uploading to an existing key WITH --meta replaces that file's
@@ -128,6 +130,25 @@ export function makeGhTarget(pr, issue, repoArg, run) {
128
130
  export function ghTargetFromFlags(flags, run) {
129
131
  return makeGhTarget(flagInt(flags, "--pr", "--pr"), flagInt(flags, "--issue", "--issue"), flagString(flags, "--repo"), run);
130
132
  }
133
+ /**
134
+ * Reads `--branch [name]` — an optional-value flag: `--branch` alone resolves
135
+ * the current git branch (`resolveCurrentBranch`); `--branch feature/x` uses
136
+ * the given name verbatim. Returns undefined when the flag is absent at all
137
+ * (distinct from an empty/whitespace value, which is rejected). Throws
138
+ * UsageError if `--branch` is given more than once.
139
+ */
140
+ export function branchFromFlags(flags, run) {
141
+ if (!flags.has("--branch"))
142
+ return undefined;
143
+ const raw = flags.get("--branch");
144
+ if (Array.isArray(raw))
145
+ throw new UsageError("--branch may only be given once");
146
+ if (raw === true)
147
+ return resolveCurrentBranch(run);
148
+ if (typeof raw === "string" && raw.trim().length > 0)
149
+ return raw;
150
+ throw new UsageError("--branch requires a non-empty branch name");
151
+ }
131
152
  /**
132
153
  * Best-effort GitHub target for the default put path (no --pr/--issue). A
133
154
  * numeric --ref is classified as pull vs issue; otherwise the current branch's
@@ -272,13 +293,37 @@ export function frameOptionsFromFlags(flags) {
272
293
  throw new UsageError("--frame-url requires --frame");
273
294
  return { frameId, frameUrl, frameFit };
274
295
  }
275
- /**
276
- * List every attachment under the target's prefix and create/update the
277
- * managed comment. Throws on gh failure callers decide whether that is
278
- * fatal (`comment` command) or a warning (`put --comment`).
279
- */
280
- export async function syncAttachmentsComment(client, target, run) {
281
- const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url, embedUrl }) => ({ key, url, embedUrl }));
296
+ /** Human-mode suffix noting who posted the managed comment. */
297
+ export function commentViaSuffix(via) {
298
+ return via === "bot" ? " (uploads-sh[bot])" : " (via gh)";
299
+ }
300
+ export async function syncAttachmentsComment(client, target, run, workspace) {
301
+ try {
302
+ const bot = await client.upsertGithubComment({
303
+ repo: target.repo,
304
+ num: target.num,
305
+ kind: target.kind,
306
+ });
307
+ if (bot.posted)
308
+ return { action: bot.action, count: bot.count, via: "bot" };
309
+ // Installed-but-unapproved is a fixable misconfiguration, not a silent
310
+ // degrade: tell the user (and how to fix it) before falling back to gh.
311
+ if (bot.reason === "forbidden" && bot.message) {
312
+ process.stderr.write(`note: ${bot.message}${bot.fixUrl ? `\n ${bot.fixUrl}` : ""}\n` +
313
+ `Posting via local gh in the meantime.\n`);
314
+ }
315
+ }
316
+ catch {
317
+ // Endpoint absent/unreachable (self-hosted, network, older worker) — fall
318
+ // through to the gh path below.
319
+ }
320
+ // gh fallback: gather from this workspace's own data and post via local `gh`.
321
+ // Note (issue #304): this CLI process has no server-side WorkspaceRecord in
322
+ // scope, so it cannot honor a workspace's githubCommentLinkToFilePage=false
323
+ // — it always links to the file page here, matching the default. This only
324
+ // diverges from the bot-posted comment for a workspace that both sets the
325
+ // flag false and falls through to this gh-fallback path.
326
+ const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url, embedUrl, pageUrl }) => ({ key, url, embedUrl, pageUrl }));
282
327
  const galleries = [];
283
328
  let cursor;
284
329
  do {
@@ -313,10 +358,15 @@ export async function syncAttachmentsComment(client, target, run) {
313
358
  }
314
359
  }));
315
360
  if (items.length === 0 && previewGalleries.length === 0)
316
- return { action: "skipped", count: 0 };
317
- const body = attachmentsCommentBody(items, previewGalleries);
318
- const { created } = upsertAttachmentsComment(target, body, run);
319
- return { action: created ? "created" : "updated", count: items.length + previewGalleries.length };
361
+ return { action: "skipped", count: 0, via: "gh" };
362
+ const marker = attachmentsMarker(workspace);
363
+ const body = attachmentsCommentBody(items, previewGalleries, marker);
364
+ const { created } = upsertAttachmentsComment(target, body, run, marker);
365
+ return {
366
+ action: created ? "created" : "updated",
367
+ count: items.length + previewGalleries.length,
368
+ via: "gh",
369
+ };
320
370
  }
321
371
  // --- attach ---
322
372
  const ATTACH_HELP = `uploads attach <file...> [options]
@@ -338,9 +388,37 @@ URL and every embed hot-swap. Human mode prints ">> replaced existing object
338
388
  Still images are optimized to WebP by default (same as put). Use --no-optimize
339
389
  to upload originals. Optional --frame wraps images in device/browser chrome.
340
390
 
391
+ Branch staging (pre-PR): --branch [name] stages files against a git branch
392
+ before a pull request exists, e.g. for a coding agent working a branch that
393
+ hasn't opened a PR yet. Key: gh/<owner>/<repo>/branch/<branch>/<filename>
394
+ ("/" in the branch name sanitizes to "-", e.g. feature/x -> feature-x).
395
+ With no value, --branch resolves the current git branch. Staged files are
396
+ public like every other attachment — same public-URL caveat applies. There is
397
+ no managed comment for a branch (no PR/issue to comment on yet); --branch
398
+ never runs the comment sync and cannot combine with --pr/--issue/--comment.
399
+
400
+ Promotion: once a PR exists, staged files for the current branch are picked
401
+ up automatically the first time you attach to that PR (a plain "uploads
402
+ attach <file> --pr <num>", or the inferred-PR default with no target flags) —
403
+ they're copied into the PR's attachment prefix before the managed comment is
404
+ built, so they show up in the same run. Pass --no-promote to skip that. If
405
+ you'd rather promote without attaching a new file (e.g. right after
406
+ "gh pr create" with nothing new to upload), run "uploads attach --promote"
407
+ with no file arguments — it resolves the PR the same way, promotes, and
408
+ refreshes the comment; it exits 0 even if nothing was staged. --promote only
409
+ takes effect with zero files and cannot combine with --branch/--issue/
410
+ --no-promote. Promotion never applies to issues. Staged files stay findable
411
+ with "uploads find gh.branch=<branch>" either way.
412
+
341
413
  Options:
342
414
  --pr <num> Attach to this pull request
343
415
  --issue <num> Attach to this issue
416
+ --branch [name] Stage against a branch, pre-PR (default: current git branch);
417
+ not with --pr/--issue/--comment
418
+ --promote No files: promote branch-staged attachments into the
419
+ resolved PR and refresh the comment; not with
420
+ --branch/--issue/--no-promote
421
+ --no-promote Skip auto-promoting branch-staged attachments (default path only)
344
422
  --repo <owner/repo> Repository (default: gh/git inference)
345
423
  --no-comment Upload only; don't create/update the managed comment
346
424
  --content-type <mime> Override Content-Type (applied to every file; ignored when optimize rewrites)
@@ -354,7 +432,8 @@ Options:
354
432
  --workspace, -w <name> Override workspace
355
433
  --meta <k=v> Extra queryable metadata (repeatable; value may contain "=").
356
434
  gh.repo/gh.kind/gh.number/gh.ref are always set from the resolved
357
- target a --meta pair with the same key is overridden by it.
435
+ target (or gh.repo/gh.kind/gh.branch/gh.staged-at with --branch)
436
+ a --meta pair with the same key is overridden by it.
358
437
  Because attach always sends its own gh.* pairs, re-attaching to
359
438
  the same key always replaces that file's entire metadata set
360
439
  (never preserves) — use "uploads meta set" to add to it instead.
@@ -365,13 +444,17 @@ Examples:
365
444
  uploads attach ./shot.png --pr 123 --repo myorg/myapp
366
445
  uploads attach ./artifact.zip --issue 45 --no-comment
367
446
  uploads attach ./shot.png --meta app=myapp --meta page=settings
447
+ uploads attach ./shot.png --branch
448
+ uploads attach ./shot.png --branch feature/new-settings
449
+ uploads attach --promote
368
450
  `;
369
451
  /**
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
452
+ * Shared prepare + put loop for both PR/issue attach (`uploadAttachments`)
453
+ * and branch-staged attach (`uploadBranchAttachments`) bounded concurrency,
454
+ * per-file errors collect in `failures` (does not throw). `firstError` is the
372
455
  * original cause of the first failure — for rethrowing single-file CLI paths.
373
456
  */
374
- export async function uploadAttachments(opts) {
457
+ async function uploadAttachmentBatch(opts) {
375
458
  if (opts.files.some((f) => f === "-")) {
376
459
  throw new UsageError("attach does not support stdin; pass one or more file paths");
377
460
  }
@@ -384,7 +467,7 @@ export async function uploadAttachments(opts) {
384
467
  });
385
468
  const result = await opts.client.put(prepared.bytes, {
386
469
  filename: prepared.filename,
387
- key: ghAttachmentKey(opts.target, prepared.filename),
470
+ key: opts.keyFor(prepared.filename),
388
471
  contentType: prepared.optimized ? prepared.contentType : opts.contentType,
389
472
  provenance: buildCliProvenance({
390
473
  sourceName,
@@ -431,6 +514,30 @@ export async function uploadAttachments(opts) {
431
514
  }
432
515
  return { uploads, failures, firstError };
433
516
  }
517
+ /**
518
+ * Prepare + put each path as a PR/issue attachment with bounded concurrency.
519
+ * Per-file errors collect in `failures` (does not throw). `firstError` is the
520
+ * original cause of the first failure — for rethrowing single-file CLI paths.
521
+ */
522
+ export async function uploadAttachments(opts) {
523
+ return uploadAttachmentBatch({
524
+ ...opts,
525
+ keyFor: (filename) => ghAttachmentKey(opts.target, filename),
526
+ });
527
+ }
528
+ /**
529
+ * Prepare + put each path as a branch-staged attachment (pre-PR) with
530
+ * bounded concurrency. Same shape as `uploadAttachments`, keyed under
531
+ * `gh/<owner>/<repo>/branch/<branch>/<filename>` instead of a PR/issue
532
+ * number. Never syncs the managed comment — callers must not call
533
+ * `syncAttachmentsComment` for a branch target.
534
+ */
535
+ export async function uploadBranchAttachments(opts) {
536
+ return uploadAttachmentBatch({
537
+ ...opts,
538
+ keyFor: (filename) => ghBranchAttachmentKey(opts.target.repo, opts.target.branch, filename),
539
+ });
540
+ }
434
541
  function errorDetail(err) {
435
542
  if (err instanceof UploadsError)
436
543
  return { message: err.message, code: err.code, status: err.status };
@@ -508,18 +615,68 @@ export async function uploadPuts(opts) {
508
615
  }
509
616
  return { uploads, failures, firstError };
510
617
  }
618
+ /**
619
+ * Best-effort call to `POST /v1/:workspace/github/promote` (server contract,
620
+ * PR #310). Degrade-safe like `syncAttachmentsComment`'s bot path: an older
621
+ * or self-hosted worker without this route (404), a forbidden token (403),
622
+ * or a network error all collapse to "nothing promoted" — the caller must
623
+ * never let this fail the attach. Returns undefined on any failure.
624
+ */
625
+ async function attemptPromoteBranch(client, target, branch) {
626
+ try {
627
+ return await client.promoteBranchAttachments({ repo: target.repo, num: target.num, branch });
628
+ }
629
+ catch {
630
+ return undefined;
631
+ }
632
+ }
633
+ /** Human-mode note for a promotion that actually promoted something. */
634
+ function promotionNote(promotion, branch) {
635
+ const n = promotion.promoted.length;
636
+ const branchSuffix = branch ? ` from branch ${branch}` : "";
637
+ return `>> promoted ${n} staged attachment${n === 1 ? "" : "s"}${branchSuffix}\n`;
638
+ }
511
639
  export async function runAttach(ctx, args, help = false, run = execRunner) {
512
640
  const parsed = parseCommandArgs(args);
513
641
  if (help || parsed.help) {
514
642
  writeCommandHelp(ATTACH_HELP);
515
643
  return 0;
516
644
  }
645
+ if (parsed.flags.has("--no-comment") && typeof parsed.flags.get("--no-comment") === "string") {
646
+ throw new UsageError("--no-comment takes no value — place it after the file arguments");
647
+ }
648
+ if (parsed.flags.has("--promote") && typeof parsed.flags.get("--promote") === "string") {
649
+ throw new UsageError("--promote takes no value — place it after the file arguments");
650
+ }
651
+ if (parsed.flags.has("--no-promote") && typeof parsed.flags.get("--no-promote") === "string") {
652
+ throw new UsageError("--no-promote takes no value — place it after the file arguments");
653
+ }
654
+ if (parsed.flags.has("--promote")) {
655
+ if (parsed.positionals.length > 0) {
656
+ throw new UsageError("--promote takes no file arguments — attaching a file to a PR already auto-promotes " +
657
+ "staged files; use `uploads attach <file> --pr <num>` instead");
658
+ }
659
+ if (parsed.flags.has("--branch"))
660
+ throw new UsageError("--promote cannot be combined with --branch");
661
+ if (parsed.flags.has("--issue"))
662
+ throw new UsageError("--promote cannot be combined with --issue");
663
+ if (parsed.flags.has("--no-promote"))
664
+ throw new UsageError("--promote cannot be combined with --no-promote");
665
+ return runAttachPromoteOnly(ctx, parsed, run);
666
+ }
517
667
  if (parsed.positionals.length === 0) {
518
668
  writeCommandHelp(ATTACH_HELP);
519
669
  return 2;
520
670
  }
521
- if (parsed.flags.has("--no-comment") && typeof parsed.flags.get("--no-comment") === "string") {
522
- throw new UsageError("--no-comment takes no value — place it after the file arguments");
671
+ const branchArg = branchFromFlags(parsed.flags, run);
672
+ if (branchArg !== undefined) {
673
+ if (parsed.flags.has("--pr"))
674
+ throw new UsageError("--branch cannot be combined with --pr");
675
+ if (parsed.flags.has("--issue"))
676
+ throw new UsageError("--branch cannot be combined with --issue");
677
+ if (parsed.flags.has("--comment"))
678
+ throw new UsageError("--branch cannot be combined with --comment");
679
+ return runAttachBranch(ctx, parsed, branchArg, run);
523
680
  }
524
681
  const explicitTarget = ghTargetFromFlags(parsed.flags, run);
525
682
  const target = explicitTarget ??
@@ -532,9 +689,9 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
532
689
  // target pairs always win over a same-named --meta extra (documented above).
533
690
  // Validate the merged map (not just the extras) so the 24-key/8KB caps are
534
691
  // enforced client-side even when extras alone are under the cap but extras
535
- // + the 4 gh.* pairs push the merged map over it.
692
+ // + the gh.* pairs push the merged map over it.
536
693
  const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
537
- const metadata = { ...metaExtras, ...ghMetadataFromTarget(target) };
694
+ const metadata = { ...metaExtras, ...ghMetadataFromTargetWithTitle(target, run) };
538
695
  if (Object.keys(metadata).length > 0)
539
696
  validateMetaMap(metadata);
540
697
  const logHuman = !ctx.quiet && !ctx.json;
@@ -555,12 +712,33 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
555
712
  if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
556
713
  throw firstError instanceof Error ? firstError : new Error(String(firstError));
557
714
  }
715
+ // Auto-promote: before the comment sync, best-effort promote this
716
+ // workspace's own branch-staged attachments (from an earlier `attach
717
+ // --branch` while the PR didn't exist yet) into this PR's attachment
718
+ // prefix, so the comment gather below sees them in the same invocation.
719
+ // Never for issues (branch staging only ever targets a future PR), never
720
+ // with --no-promote, and silently skipped (no client call at all) when the
721
+ // current git branch can't be resolved (detached HEAD, not a repo) — this
722
+ // must never fail the attach itself.
723
+ let promotion;
724
+ let promotedBranch;
725
+ if (target.kind === "pull" && !parsed.flags.has("--no-promote")) {
726
+ try {
727
+ promotedBranch = resolveCurrentBranch(run);
728
+ }
729
+ catch {
730
+ promotedBranch = undefined;
731
+ }
732
+ if (promotedBranch !== undefined) {
733
+ promotion = await attemptPromoteBranch(ctx.client, target, promotedBranch);
734
+ }
735
+ }
558
736
  let comment;
559
737
  let commentError;
560
738
  // Skip comment refresh when every upload failed — nothing new from this batch.
561
739
  if (!parsed.flags.has("--no-comment") && uploads.length > 0) {
562
740
  try {
563
- comment = await syncAttachmentsComment(ctx.client, target, run);
741
+ comment = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
564
742
  }
565
743
  catch (err) {
566
744
  commentError = err instanceof Error ? err.message : String(err);
@@ -568,7 +746,14 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
568
746
  }
569
747
  }
570
748
  if (ctx.json) {
571
- await writeJson({ target, uploads, failures, comment, commentError });
749
+ await writeJson({
750
+ target,
751
+ uploads,
752
+ failures,
753
+ comment,
754
+ commentError,
755
+ promotion: promotion ?? null,
756
+ });
572
757
  }
573
758
  else {
574
759
  for (const result of uploads) {
@@ -587,8 +772,11 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
587
772
  for (const failure of failures) {
588
773
  process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
589
774
  }
775
+ if (!ctx.quiet && promotion && promotion.promoted.length > 0) {
776
+ process.stderr.write(promotionNote(promotion, promotedBranch));
777
+ }
590
778
  if (!ctx.quiet && comment)
591
- process.stderr.write(`>> attachments comment ${comment.action}\n`);
779
+ process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
592
780
  if (!ctx.quiet && uploads.length > 0) {
593
781
  const ref = ghMetadataFromTarget(target)["gh.ref"];
594
782
  process.stderr.write(`>> find these later: uploads find gh.ref=${ref}\n`);
@@ -596,6 +784,114 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
596
784
  }
597
785
  return failures.length === 0 ? 0 : 1;
598
786
  }
787
+ /**
788
+ * `attach --branch` path: stages files under
789
+ * `gh/<owner>/<repo>/branch/<branch>/<filename>` instead of a PR/issue
790
+ * number. Never syncs the managed comment (there is no PR/issue to comment
791
+ * on yet, and the comment-gatherer only lists PR/issue prefixes anyway —
792
+ * branch-staged keys are invisible to it by construction).
793
+ */
794
+ async function runAttachBranch(ctx, parsed, branch, run) {
795
+ const repo = resolveRepo(flagString(parsed.flags, "--repo"), run);
796
+ const defaults = resolvePutDefaults({ envFile: ctx.envFile });
797
+ const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
798
+ const frameOpts = frameOptionsFromFlags(parsed.flags);
799
+ const contentTypeOverride = flagString(parsed.flags, "--content-type");
800
+ const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
801
+ const metadata = { ...metaExtras, ...ghMetadataForBranch(repo, branch) };
802
+ validateMetaMap(metadata);
803
+ const logHuman = !ctx.quiet && !ctx.json;
804
+ if (logHuman) {
805
+ const n = parsed.positionals.length;
806
+ process.stderr.write(`>> uploading ${n} file${n === 1 ? "" : "s"} (staged for branch ${branch})\n`);
807
+ }
808
+ const target = { repo, branch };
809
+ const { uploads, failures, firstError } = await uploadBranchAttachments({
810
+ client: ctx.client,
811
+ target,
812
+ files: parsed.positionals,
813
+ contentType: contentTypeOverride,
814
+ optimize: optimizeOpts,
815
+ frame: frameOpts,
816
+ metadata,
817
+ });
818
+ // Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
819
+ if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
820
+ throw firstError instanceof Error ? firstError : new Error(String(firstError));
821
+ }
822
+ if (ctx.json) {
823
+ await writeJson({ target, uploads, failures });
824
+ }
825
+ else {
826
+ for (const result of uploads) {
827
+ if (logHuman) {
828
+ if (result.frame?.framed) {
829
+ process.stderr.write(`>> ${basename(result.file)}: framed with ${result.frame.frameId}\n`);
830
+ }
831
+ const note = formatOptimizeNote(result.optimize);
832
+ if (note)
833
+ process.stderr.write(`>> ${basename(result.file)}: ${note}\n`);
834
+ writeReplacedNote(result.replaced, false);
835
+ }
836
+ const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
837
+ await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${result.markdown}\n`);
838
+ }
839
+ for (const failure of failures) {
840
+ process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
841
+ }
842
+ if (!ctx.quiet && uploads.length > 0) {
843
+ process.stderr.write(`>> find these later: uploads find gh.branch=${branch.toLowerCase()}\n`);
844
+ }
845
+ }
846
+ return failures.length === 0 ? 0 : 1;
847
+ }
848
+ /**
849
+ * `attach --promote` with zero file arguments: resolve the PR target (same
850
+ * resolution as the default `runAttach` path), promote this workspace's
851
+ * branch-staged attachments into it, then run the comment sync — useful
852
+ * right after `gh pr create` when the PR was opened without a fresh attach
853
+ * (auto-promotion on the default path only fires when you attach a file).
854
+ * Unlike the default path's best-effort branch resolution, this is an
855
+ * explicit user action: `resolveCurrentBranch` throwing (detached HEAD, not
856
+ * a repo) propagates as a UsageError instead of silently skipping. Always
857
+ * exits 0 — an empty staging prefix is success, not a failure.
858
+ */
859
+ async function runAttachPromoteOnly(ctx, parsed, run) {
860
+ const explicitTarget = ghTargetFromFlags(parsed.flags, run);
861
+ const target = explicitTarget ??
862
+ resolveCurrentPullRequest(resolveRepo(flagString(parsed.flags, "--repo"), run), run);
863
+ const branch = resolveCurrentBranch(run);
864
+ const promotion = await attemptPromoteBranch(ctx.client, target, branch);
865
+ let comment;
866
+ let commentError;
867
+ if (!parsed.flags.has("--no-comment")) {
868
+ try {
869
+ comment = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
870
+ }
871
+ catch (err) {
872
+ commentError = err instanceof Error ? err.message : String(err);
873
+ process.stderr.write(`warning: promotion succeeded but the GitHub comment failed (is gh installed and authenticated?): ${commentError}\n`);
874
+ }
875
+ }
876
+ if (ctx.json) {
877
+ await writeJson({
878
+ target,
879
+ uploads: [],
880
+ failures: [],
881
+ comment,
882
+ commentError,
883
+ promotion: promotion ?? null,
884
+ });
885
+ }
886
+ else {
887
+ if (!ctx.quiet && promotion && promotion.promoted.length > 0) {
888
+ process.stderr.write(promotionNote(promotion, branch));
889
+ }
890
+ if (!ctx.quiet && comment)
891
+ process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
892
+ }
893
+ return 0;
894
+ }
599
895
  export async function runPut(ctx, args, help = false, run = execRunner) {
600
896
  if (help) {
601
897
  writeCommandHelp(PUT_HELP);
@@ -711,7 +1007,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
711
1007
  let metadata = userMeta;
712
1008
  let attachedRef;
713
1009
  if (ghTarget) {
714
- const merged = { ...userMeta, ...ghMetadataFromTarget(ghTarget) };
1010
+ const merged = { ...userMeta, ...ghMetadataFromTargetWithTitle(ghTarget, run) };
715
1011
  validateMetaMap(merged); // enforce 24-key/8KB caps on the merged map (matches attach)
716
1012
  metadata = merged;
717
1013
  attachedRef = merged["gh.ref"];
@@ -723,7 +1019,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
723
1019
  if (autoEnabled) {
724
1020
  const autoTarget = resolveAutoGhTarget(flagString(parsed.flags, "--repo") ?? defaults.repo, flagString(parsed.flags, "--ref") ?? defaults.ref, run);
725
1021
  if (autoTarget) {
726
- const autoMeta = ghMetadataFromTarget(autoTarget);
1022
+ const autoMeta = ghMetadataFromTargetWithTitle(autoTarget, run);
727
1023
  const merged = { ...autoMeta, ...userMeta };
728
1024
  // Auto resolution must never fail the upload: if merging the gh.* pairs
729
1025
  // would exceed the metadata caps, drop them and upload with --meta only.
@@ -796,9 +1092,9 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
796
1092
  let commentError;
797
1093
  if (wantComment && ghTarget && uploads.length > 0) {
798
1094
  try {
799
- comment = await syncAttachmentsComment(ctx.client, ghTarget, run);
1095
+ comment = await syncAttachmentsComment(ctx.client, ghTarget, run, ctx.config.workspace);
800
1096
  if (logHuman)
801
- process.stderr.write(`>> attachments comment ${comment.action}\n`);
1097
+ process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
802
1098
  }
803
1099
  catch (err) {
804
1100
  commentError = err instanceof Error ? err.message : String(err);
@@ -1322,7 +1618,8 @@ export async function runDelete(ctx, args, help = false) {
1322
1618
  const COMMENT_HELP = `uploads comment (--pr <num> | --issue <num>) [--repo <owner/name>] [--workspace <name>]
1323
1619
 
1324
1620
  Create or update the managed attachments comment on a GitHub PR or issue,
1325
- listing everything uploaded for it. Uses your local gh auth. Finds its own
1621
+ listing everything uploaded for it. Posts as uploads-sh[bot] when the GitHub
1622
+ App is installed on the repo; otherwise via your local gh auth. Finds its own
1326
1623
  prior comment via a hidden marker and edits it in place; never touches other
1327
1624
  comments or the description.
1328
1625
 
@@ -1339,15 +1636,72 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
1339
1636
  const target = ghTargetFromFlags(parsed.flags, run);
1340
1637
  if (!target)
1341
1638
  throw new UsageError("comment requires --pr or --issue");
1342
- const result = await syncAttachmentsComment(ctx.client, target, run);
1639
+ const result = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
1343
1640
  if (ctx.json) {
1344
1641
  await writeJson({ ...target, ...result });
1345
1642
  }
1346
1643
  else if (!ctx.quiet) {
1644
+ const via = commentViaSuffix(result.via);
1347
1645
  process.stderr.write(result.action === "skipped"
1348
1646
  ? `no attachments under ${ghKeyPrefix(target)} — nothing to do\n`
1349
- : `${result.action} attachments comment on ${target.repo}#${target.num} (${result.count} file${result.count === 1 ? "" : "s"})\n`);
1647
+ : `${result.action} attachments comment on ${target.repo}#${target.num} (${result.count} file${result.count === 1 ? "" : "s"})${via}\n`);
1648
+ }
1649
+ return 0;
1650
+ }
1651
+ // --- github link ---
1652
+ const GITHUB_HELP = `uploads github link [--repo <owner/name>] [--status] [--workspace <name>]
1653
+
1654
+ Claim or inspect this workspace's binding to a GitHub repo (see the managed
1655
+ attachments comment / webhook auto-promotion, which use this binding).
1656
+ First-claim-wins: claiming an already-bound repo never steals it from
1657
+ whichever workspace claimed it first — the command reports who owns it
1658
+ instead.
1659
+
1660
+ --repo defaults the same way as --pr/--issue elsewhere (gh repo view, then
1661
+ the git remote). --status only inspects the current binding (files:read);
1662
+ without it, the command claims the repo (files:write).
1663
+
1664
+ Examples:
1665
+ uploads github link
1666
+ uploads github link --repo buildinternet/uploads
1667
+ uploads github link --status
1668
+ `;
1669
+ function formatGithubLink(repo, result) {
1670
+ return result.workspace
1671
+ ? `${repo} is bound to workspace "${result.workspace}"${result.source ? ` (${result.source})` : ""}\n`
1672
+ : `${repo} is not bound to any workspace\n`;
1673
+ }
1674
+ export async function runGithub(ctx, args, help = false, run = execRunner) {
1675
+ const parsed = parseCommandArgs(args);
1676
+ const action = parsed.positionals[0];
1677
+ if (help || parsed.help || !action) {
1678
+ writeCommandHelp(GITHUB_HELP);
1679
+ return help || parsed.help ? 0 : 2;
1680
+ }
1681
+ if (action !== "link")
1682
+ throw new UsageError(`unknown github subcommand: ${action}`);
1683
+ const repo = resolveRepo(flagString(parsed.flags, "--repo"), run);
1684
+ const statusOnly = flagBool(parsed.flags, "--status");
1685
+ let result;
1686
+ try {
1687
+ result = statusOnly
1688
+ ? await ctx.client.githubLinkStatus(repo)
1689
+ : await ctx.client.githubLinkClaim(repo);
1690
+ }
1691
+ catch (err) {
1692
+ if (err instanceof UploadsError && err.status === 404) {
1693
+ throw new UsageError("server does not support repo bindings yet (404) — upgrade the uploads.sh API/self-hosted worker");
1694
+ }
1695
+ throw err;
1696
+ }
1697
+ if (ctx.json) {
1698
+ await writeJson(result);
1699
+ return 0;
1700
+ }
1701
+ if (!statusOnly && result.claimed === false) {
1702
+ process.stderr.write(`note: ${repo} is already bound to a different workspace ("${result.workspace}") — first-claim-wins, not overwritten\n`);
1350
1703
  }
1704
+ await writeStdout(formatGithubLink(repo, result));
1351
1705
  return 0;
1352
1706
  }
1353
1707
  // --- usage / reconcile / purge ---
@@ -9,6 +9,8 @@ export declare const execRunner: CommandRunner;
9
9
  export declare function resolveRepo(explicit: string | undefined, run?: CommandRunner): string;
10
10
  /** Resolve the pull request associated with the current branch. */
11
11
  export declare function resolveCurrentPullRequest(repo: string, run?: CommandRunner): GhTarget;
12
+ /** Resolve the current git branch (`--branch` with no value). Throws UsageError on detached HEAD or outside a git repo. */
13
+ export declare function resolveCurrentBranch(run?: CommandRunner): string;
12
14
  /**
13
15
  * Classify a bare PR/issue number via the GitHub API so the default `put`
14
16
  * path can stamp the right `gh.kind`. Returns undefined on any failure (gh
@@ -16,11 +18,36 @@ export declare function resolveCurrentPullRequest(repo: string, run?: CommandRun
16
18
  * uploads without metadata.
17
19
  */
18
20
  export declare function classifyGhNumber(repo: string, num: number, run?: CommandRunner): GhTarget | undefined;
21
+ /**
22
+ * Best-effort PR/issue title lookup via local `gh`. Returns undefined on any
23
+ * failure (gh missing, unauthenticated, network, 404) — mirrors
24
+ * `resolveCurrentPullRequest`/`classifyGhNumber`'s degrade-don't-throw
25
+ * pattern. A title is a nice-to-have annotation, never a blocker: callers
26
+ * must never let this failure abort an upload.
27
+ */
28
+ export declare function resolveGhTitle(target: GhTarget, run?: CommandRunner): string | undefined;
29
+ /**
30
+ * `ghMetadataFromTarget`'s 4 pairs, plus a best-effort `gh.title` (issue #267)
31
+ * when `resolveGhTitle` yields one that also satisfies the metadata-value
32
+ * rule every other pair follows (1-512 printable ASCII — `metadata.ts`'s
33
+ * `META_VALUE_MAX`/`isMetaValueSafe`). Truncated to `META_VALUE_MAX` first;
34
+ * a title left empty or unsafe by truncation (e.g. non-ASCII — real titles
35
+ * often contain emoji or curly quotes) is silently omitted rather than
36
+ * sanitized, matching `resolveGhTitle`'s own "degrade, don't fail the
37
+ * upload" contract.
38
+ */
39
+ export declare function ghMetadataFromTargetWithTitle(target: GhTarget, run?: CommandRunner): Record<string, string>;
19
40
  /**
20
41
  * Create the managed attachments comment, or edit it in place if it already
21
42
  * exists. Never touches any other comment. Body is passed via stdin
22
43
  * (`-F body=@-`) so it is never shell-interpolated.
44
+ *
45
+ * `marker` identifies which comment to hunt for (see `findManagedComment`);
46
+ * `body` is expected to already carry that same marker as its first line
47
+ * (built via `attachmentsCommentBody(items, galleries, marker)`), so patching
48
+ * an adopted legacy comment migrates it to the namespaced marker in place.
49
+ * Defaults to the shared legacy marker for backward compatibility.
23
50
  */
24
- export declare function upsertAttachmentsComment(target: GhTarget, body: string, run?: CommandRunner): {
51
+ export declare function upsertAttachmentsComment(target: GhTarget, body: string, run?: CommandRunner, marker?: string): {
25
52
  created: boolean;
26
53
  };