@buildinternet/uploads 0.14.0 → 0.16.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,5 +1,5 @@
1
- import { readFileSync } from "node:fs";
2
- import { basename } from "node:path";
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { basename, extname } from "node:path";
3
3
  import { mapBounded } from "./async.js";
4
4
  import { createUploadsClient, } from "./client.js";
5
5
  import { parseCommandArgs, flagString, flagBool, flagInt, flagValues, UsageError, } from "./cli-args.js";
@@ -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, ghMetadataFromTargetWithTitle, 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";
@@ -130,6 +130,72 @@ export function makeGhTarget(pr, issue, repoArg, run) {
130
130
  export function ghTargetFromFlags(flags, run) {
131
131
  return makeGhTarget(flagInt(flags, "--pr", "--pr"), flagInt(flags, "--issue", "--issue"), flagString(flags, "--repo"), run);
132
132
  }
133
+ /**
134
+ * Extensions that mark a `--branch` value as almost certainly a filename that
135
+ * got swallowed by the optional-value lookahead (e.g. `--branch shot.png`
136
+ * with no other file args). Branch names legitimately contain dots (e.g.
137
+ * `release/1.2`, `v1.2.3`), so this only matches known media/document
138
+ * extensions, never bare dotted segments.
139
+ */
140
+ const BRANCH_LIKE_FILE_EXTENSIONS = new Set([
141
+ ".png",
142
+ ".jpg",
143
+ ".jpeg",
144
+ ".gif",
145
+ ".webp",
146
+ ".bmp",
147
+ ".svg",
148
+ ".ico",
149
+ ".tif",
150
+ ".tiff",
151
+ ".heic",
152
+ ".avif",
153
+ ".mp4",
154
+ ".mov",
155
+ ".avi",
156
+ ".webm",
157
+ ".mkv",
158
+ ".pdf",
159
+ ]);
160
+ /**
161
+ * True when `value` looks like a filename that was mistakenly consumed as the
162
+ * `--branch` value: it names a file that exists on disk, or its extension is
163
+ * a known media/document type. Ordinary branch names (including dotted ones
164
+ * like `v1.2` or `release/1.2`) never match either check.
165
+ */
166
+ function looksLikeFileNotBranch(value) {
167
+ if (existsSync(value))
168
+ return true;
169
+ return BRANCH_LIKE_FILE_EXTENSIONS.has(extname(value).toLowerCase());
170
+ }
171
+ /**
172
+ * Reads `--branch [name]` — an optional-value flag: `--branch` alone resolves
173
+ * the current git branch (`resolveCurrentBranch`); `--branch feature/x` uses
174
+ * the given name verbatim. Returns undefined when the flag is absent at all
175
+ * (distinct from an empty/whitespace value, which is rejected). Throws
176
+ * UsageError if `--branch` is given more than once, or if the value looks
177
+ * like a filename accidentally swallowed by the optional-value lookahead
178
+ * (e.g. `uploads attach --branch shot.png` with no other file args) — see
179
+ * `looksLikeFileNotBranch`.
180
+ */
181
+ export function branchFromFlags(flags, run) {
182
+ if (!flags.has("--branch"))
183
+ return undefined;
184
+ const raw = flags.get("--branch");
185
+ if (Array.isArray(raw))
186
+ throw new UsageError("--branch may only be given once");
187
+ if (raw === true)
188
+ return resolveCurrentBranch(run);
189
+ if (typeof raw === "string" && raw.trim().length > 0) {
190
+ if (looksLikeFileNotBranch(raw)) {
191
+ throw new UsageError(`"${raw}" looks like a file, not a branch name — did you mean ` +
192
+ `"uploads attach ${raw} --branch" (auto-detect the current branch), ` +
193
+ `or "uploads attach --branch <name> ${raw}" (explicit branch name)?`);
194
+ }
195
+ return raw;
196
+ }
197
+ throw new UsageError("--branch requires a non-empty branch name");
198
+ }
133
199
  /**
134
200
  * Best-effort GitHub target for the default put path (no --pr/--issue). A
135
201
  * numeric --ref is classified as pull vs issue; otherwise the current branch's
@@ -278,22 +344,53 @@ export function frameOptionsFromFlags(flags) {
278
344
  export function commentViaSuffix(via) {
279
345
  return via === "bot" ? " (uploads-sh[bot])" : " (via gh)";
280
346
  }
281
- export async function syncAttachmentsComment(client, target, run) {
347
+ /**
348
+ * Thrown by `syncAttachmentsComment` when the server declines with
349
+ * `not_authorized` (issue #297 baseline control) — this repo is bound to a
350
+ * different workspace, or unbound and unclaimable by the communal `default`
351
+ * workspace. Deliberately not caught by the generic "bot endpoint
352
+ * unreachable" fallback below: falling back to gh here would let the
353
+ * human's own credentials post anyway, defeating the point of the
354
+ * server-side gate.
355
+ */
356
+ export class GithubCommentAuthorizationError extends Error {
357
+ }
358
+ export async function syncAttachmentsComment(client, target, run, workspace) {
359
+ let bot;
282
360
  try {
283
- const bot = await client.upsertGithubComment({
361
+ bot = await client.upsertGithubComment({
284
362
  repo: target.repo,
285
363
  num: target.num,
286
364
  kind: target.kind,
287
365
  });
288
- if (bot.posted)
289
- return { action: bot.action, count: bot.count, via: "bot" };
290
366
  }
291
367
  catch {
292
368
  // Endpoint absent/unreachable (self-hosted, network, older worker) — fall
293
369
  // through to the gh path below.
370
+ bot = undefined;
371
+ }
372
+ if (bot) {
373
+ if (bot.posted)
374
+ return { action: bot.action, count: bot.count, via: "bot" };
375
+ if (bot.reason === "not_authorized") {
376
+ throw new GithubCommentAuthorizationError(`${bot.message ?? `${target.repo} is not authorized for this workspace.`}\n` +
377
+ `Run \`uploads github link --status --repo ${target.repo}\` to see who owns the ` +
378
+ `binding, use that workspace instead, or post the comment manually with gh.`);
379
+ }
380
+ // Installed-but-unapproved is a fixable misconfiguration, not a silent
381
+ // degrade: tell the user (and how to fix it) before falling back to gh.
382
+ if (bot.reason === "forbidden" && bot.message) {
383
+ process.stderr.write(`note: ${bot.message}${bot.fixUrl ? `\n ${bot.fixUrl}` : ""}\n` +
384
+ `Posting via local gh in the meantime.\n`);
385
+ }
294
386
  }
295
387
  // gh fallback: gather from this workspace's own data and post via local `gh`.
296
- const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url, embedUrl }) => ({ key, url, embedUrl }));
388
+ // Note (issue #304): this CLI process has no server-side WorkspaceRecord in
389
+ // scope, so it cannot honor a workspace's githubCommentLinkToFilePage=false
390
+ // — it always links to the file page here, matching the default. This only
391
+ // diverges from the bot-posted comment for a workspace that both sets the
392
+ // flag false and falls through to this gh-fallback path.
393
+ const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url, embedUrl, pageUrl }) => ({ key, url, embedUrl, pageUrl }));
297
394
  const galleries = [];
298
395
  let cursor;
299
396
  do {
@@ -329,8 +426,9 @@ export async function syncAttachmentsComment(client, target, run) {
329
426
  }));
330
427
  if (items.length === 0 && previewGalleries.length === 0)
331
428
  return { action: "skipped", count: 0, via: "gh" };
332
- const body = attachmentsCommentBody(items, previewGalleries);
333
- const { created } = upsertAttachmentsComment(target, body, run);
429
+ const marker = attachmentsMarker(workspace);
430
+ const body = attachmentsCommentBody(items, previewGalleries, marker);
431
+ const { created } = upsertAttachmentsComment(target, body, run, marker);
334
432
  return {
335
433
  action: created ? "created" : "updated",
336
434
  count: items.length + previewGalleries.length,
@@ -357,9 +455,37 @@ URL and every embed hot-swap. Human mode prints ">> replaced existing object
357
455
  Still images are optimized to WebP by default (same as put). Use --no-optimize
358
456
  to upload originals. Optional --frame wraps images in device/browser chrome.
359
457
 
458
+ Branch staging (pre-PR): --branch [name] stages files against a git branch
459
+ before a pull request exists, e.g. for a coding agent working a branch that
460
+ hasn't opened a PR yet. Key: gh/<owner>/<repo>/branch/<branch>/<filename>
461
+ ("/" in the branch name sanitizes to "-", e.g. feature/x -> feature-x).
462
+ With no value, --branch resolves the current git branch. Staged files are
463
+ public like every other attachment — same public-URL caveat applies. There is
464
+ no managed comment for a branch (no PR/issue to comment on yet); --branch
465
+ never runs the comment sync and cannot combine with --pr/--issue/--comment.
466
+
467
+ Promotion: once a PR exists, staged files for the current branch are picked
468
+ up automatically the first time you attach to that PR (a plain "uploads
469
+ attach <file> --pr <num>", or the inferred-PR default with no target flags) —
470
+ they're copied into the PR's attachment prefix before the managed comment is
471
+ built, so they show up in the same run. Pass --no-promote to skip that. If
472
+ you'd rather promote without attaching a new file (e.g. right after
473
+ "gh pr create" with nothing new to upload), run "uploads attach --promote"
474
+ with no file arguments — it resolves the PR the same way, promotes, and
475
+ refreshes the comment; it exits 0 even if nothing was staged. --promote only
476
+ takes effect with zero files and cannot combine with --branch/--issue/
477
+ --no-promote. Promotion never applies to issues. Staged files stay findable
478
+ with "uploads find gh.branch=<branch>" either way.
479
+
360
480
  Options:
361
481
  --pr <num> Attach to this pull request
362
482
  --issue <num> Attach to this issue
483
+ --branch [name] Stage against a branch, pre-PR (default: current git branch);
484
+ not with --pr/--issue/--comment
485
+ --promote No files: promote branch-staged attachments into the
486
+ resolved PR and refresh the comment; not with
487
+ --branch/--issue/--no-promote
488
+ --no-promote Skip auto-promoting branch-staged attachments (default path only)
363
489
  --repo <owner/repo> Repository (default: gh/git inference)
364
490
  --no-comment Upload only; don't create/update the managed comment
365
491
  --content-type <mime> Override Content-Type (applied to every file; ignored when optimize rewrites)
@@ -373,7 +499,8 @@ Options:
373
499
  --workspace, -w <name> Override workspace
374
500
  --meta <k=v> Extra queryable metadata (repeatable; value may contain "=").
375
501
  gh.repo/gh.kind/gh.number/gh.ref are always set from the resolved
376
- target a --meta pair with the same key is overridden by it.
502
+ target (or gh.repo/gh.kind/gh.branch/gh.staged-at with --branch)
503
+ a --meta pair with the same key is overridden by it.
377
504
  Because attach always sends its own gh.* pairs, re-attaching to
378
505
  the same key always replaces that file's entire metadata set
379
506
  (never preserves) — use "uploads meta set" to add to it instead.
@@ -384,13 +511,17 @@ Examples:
384
511
  uploads attach ./shot.png --pr 123 --repo myorg/myapp
385
512
  uploads attach ./artifact.zip --issue 45 --no-comment
386
513
  uploads attach ./shot.png --meta app=myapp --meta page=settings
514
+ uploads attach ./shot.png --branch
515
+ uploads attach ./shot.png --branch feature/new-settings
516
+ uploads attach --promote
387
517
  `;
388
518
  /**
389
- * Prepare + put each path as a PR/issue attachment with bounded concurrency.
390
- * Per-file errors collect in `failures` (does not throw). `firstError` is the
519
+ * Shared prepare + put loop for both PR/issue attach (`uploadAttachments`)
520
+ * and branch-staged attach (`uploadBranchAttachments`) bounded concurrency,
521
+ * per-file errors collect in `failures` (does not throw). `firstError` is the
391
522
  * original cause of the first failure — for rethrowing single-file CLI paths.
392
523
  */
393
- export async function uploadAttachments(opts) {
524
+ async function uploadAttachmentBatch(opts) {
394
525
  if (opts.files.some((f) => f === "-")) {
395
526
  throw new UsageError("attach does not support stdin; pass one or more file paths");
396
527
  }
@@ -403,7 +534,7 @@ export async function uploadAttachments(opts) {
403
534
  });
404
535
  const result = await opts.client.put(prepared.bytes, {
405
536
  filename: prepared.filename,
406
- key: ghAttachmentKey(opts.target, prepared.filename),
537
+ key: opts.keyFor(prepared.filename),
407
538
  contentType: prepared.optimized ? prepared.contentType : opts.contentType,
408
539
  provenance: buildCliProvenance({
409
540
  sourceName,
@@ -450,6 +581,30 @@ export async function uploadAttachments(opts) {
450
581
  }
451
582
  return { uploads, failures, firstError };
452
583
  }
584
+ /**
585
+ * Prepare + put each path as a PR/issue attachment with bounded concurrency.
586
+ * Per-file errors collect in `failures` (does not throw). `firstError` is the
587
+ * original cause of the first failure — for rethrowing single-file CLI paths.
588
+ */
589
+ export async function uploadAttachments(opts) {
590
+ return uploadAttachmentBatch({
591
+ ...opts,
592
+ keyFor: (filename) => ghAttachmentKey(opts.target, filename),
593
+ });
594
+ }
595
+ /**
596
+ * Prepare + put each path as a branch-staged attachment (pre-PR) with
597
+ * bounded concurrency. Same shape as `uploadAttachments`, keyed under
598
+ * `gh/<owner>/<repo>/branch/<branch>/<filename>` instead of a PR/issue
599
+ * number. Never syncs the managed comment — callers must not call
600
+ * `syncAttachmentsComment` for a branch target.
601
+ */
602
+ export async function uploadBranchAttachments(opts) {
603
+ return uploadAttachmentBatch({
604
+ ...opts,
605
+ keyFor: (filename) => ghBranchAttachmentKey(opts.target.repo, opts.target.branch, filename),
606
+ });
607
+ }
453
608
  function errorDetail(err) {
454
609
  if (err instanceof UploadsError)
455
610
  return { message: err.message, code: err.code, status: err.status };
@@ -527,18 +682,72 @@ export async function uploadPuts(opts) {
527
682
  }
528
683
  return { uploads, failures, firstError };
529
684
  }
685
+ /**
686
+ * Best-effort call to `POST /v1/:workspace/github/promote` (server contract,
687
+ * PR #310). Degrade-safe like `syncAttachmentsComment`'s bot path: an older
688
+ * or self-hosted worker without this route (404), a forbidden token (403),
689
+ * or a network error all collapse to "nothing promoted" — the caller must
690
+ * never let this fail the attach. Returns undefined on any failure.
691
+ */
692
+ async function attemptPromoteBranch(client, target, branch) {
693
+ try {
694
+ return await client.promoteBranchAttachments({ repo: target.repo, num: target.num, branch });
695
+ }
696
+ catch {
697
+ return undefined;
698
+ }
699
+ }
700
+ /** Human-mode note for a promotion that actually promoted something. */
701
+ function promotionNote(promotion, branch) {
702
+ const n = promotion.promoted.length;
703
+ const branchSuffix = branch ? ` from branch ${branch}` : "";
704
+ return `>> promoted ${n} staged attachment${n === 1 ? "" : "s"}${branchSuffix}\n`;
705
+ }
530
706
  export async function runAttach(ctx, args, help = false, run = execRunner) {
531
707
  const parsed = parseCommandArgs(args);
532
708
  if (help || parsed.help) {
533
709
  writeCommandHelp(ATTACH_HELP);
534
710
  return 0;
535
711
  }
712
+ if (parsed.flags.has("--no-comment") && typeof parsed.flags.get("--no-comment") === "string") {
713
+ throw new UsageError("--no-comment takes no value — place it after the file arguments");
714
+ }
715
+ if (parsed.flags.has("--promote") && typeof parsed.flags.get("--promote") === "string") {
716
+ throw new UsageError("--promote takes no value — place it after the file arguments");
717
+ }
718
+ if (parsed.flags.has("--no-promote") && typeof parsed.flags.get("--no-promote") === "string") {
719
+ throw new UsageError("--no-promote takes no value — place it after the file arguments");
720
+ }
721
+ if (parsed.flags.has("--promote")) {
722
+ if (parsed.positionals.length > 0) {
723
+ throw new UsageError("--promote takes no file arguments — attaching a file to a PR already auto-promotes " +
724
+ "staged files; use `uploads attach <file> --pr <num>` instead");
725
+ }
726
+ if (parsed.flags.has("--branch"))
727
+ throw new UsageError("--promote cannot be combined with --branch");
728
+ if (parsed.flags.has("--issue"))
729
+ throw new UsageError("--promote cannot be combined with --issue");
730
+ if (parsed.flags.has("--no-promote"))
731
+ throw new UsageError("--promote cannot be combined with --no-promote");
732
+ return runAttachPromoteOnly(ctx, parsed, run);
733
+ }
734
+ // Validate --branch (including the filename-lookahead guard) before the
735
+ // zero-positionals bailout below — otherwise `uploads attach --branch
736
+ // shot.png` (where shot.png is swallowed as the branch value, leaving no
737
+ // file args) would silently print help instead of a clear UsageError.
738
+ const branchArg = branchFromFlags(parsed.flags, run);
536
739
  if (parsed.positionals.length === 0) {
537
740
  writeCommandHelp(ATTACH_HELP);
538
741
  return 2;
539
742
  }
540
- if (parsed.flags.has("--no-comment") && typeof parsed.flags.get("--no-comment") === "string") {
541
- throw new UsageError("--no-comment takes no value — place it after the file arguments");
743
+ if (branchArg !== undefined) {
744
+ if (parsed.flags.has("--pr"))
745
+ throw new UsageError("--branch cannot be combined with --pr");
746
+ if (parsed.flags.has("--issue"))
747
+ throw new UsageError("--branch cannot be combined with --issue");
748
+ if (parsed.flags.has("--comment"))
749
+ throw new UsageError("--branch cannot be combined with --comment");
750
+ return runAttachBranch(ctx, parsed, branchArg, run);
542
751
  }
543
752
  const explicitTarget = ghTargetFromFlags(parsed.flags, run);
544
753
  const target = explicitTarget ??
@@ -574,12 +783,33 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
574
783
  if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
575
784
  throw firstError instanceof Error ? firstError : new Error(String(firstError));
576
785
  }
786
+ // Auto-promote: before the comment sync, best-effort promote this
787
+ // workspace's own branch-staged attachments (from an earlier `attach
788
+ // --branch` while the PR didn't exist yet) into this PR's attachment
789
+ // prefix, so the comment gather below sees them in the same invocation.
790
+ // Never for issues (branch staging only ever targets a future PR), never
791
+ // with --no-promote, and silently skipped (no client call at all) when the
792
+ // current git branch can't be resolved (detached HEAD, not a repo) — this
793
+ // must never fail the attach itself.
794
+ let promotion;
795
+ let promotedBranch;
796
+ if (target.kind === "pull" && !parsed.flags.has("--no-promote")) {
797
+ try {
798
+ promotedBranch = resolveCurrentBranch(run);
799
+ }
800
+ catch {
801
+ promotedBranch = undefined;
802
+ }
803
+ if (promotedBranch !== undefined) {
804
+ promotion = await attemptPromoteBranch(ctx.client, target, promotedBranch);
805
+ }
806
+ }
577
807
  let comment;
578
808
  let commentError;
579
809
  // Skip comment refresh when every upload failed — nothing new from this batch.
580
810
  if (!parsed.flags.has("--no-comment") && uploads.length > 0) {
581
811
  try {
582
- comment = await syncAttachmentsComment(ctx.client, target, run);
812
+ comment = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
583
813
  }
584
814
  catch (err) {
585
815
  commentError = err instanceof Error ? err.message : String(err);
@@ -587,7 +817,14 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
587
817
  }
588
818
  }
589
819
  if (ctx.json) {
590
- await writeJson({ target, uploads, failures, comment, commentError });
820
+ await writeJson({
821
+ target,
822
+ uploads,
823
+ failures,
824
+ comment,
825
+ commentError,
826
+ promotion: promotion ?? null,
827
+ });
591
828
  }
592
829
  else {
593
830
  for (const result of uploads) {
@@ -606,6 +843,9 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
606
843
  for (const failure of failures) {
607
844
  process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
608
845
  }
846
+ if (!ctx.quiet && promotion && promotion.promoted.length > 0) {
847
+ process.stderr.write(promotionNote(promotion, promotedBranch));
848
+ }
609
849
  if (!ctx.quiet && comment)
610
850
  process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
611
851
  if (!ctx.quiet && uploads.length > 0) {
@@ -615,6 +855,114 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
615
855
  }
616
856
  return failures.length === 0 ? 0 : 1;
617
857
  }
858
+ /**
859
+ * `attach --branch` path: stages files under
860
+ * `gh/<owner>/<repo>/branch/<branch>/<filename>` instead of a PR/issue
861
+ * number. Never syncs the managed comment (there is no PR/issue to comment
862
+ * on yet, and the comment-gatherer only lists PR/issue prefixes anyway —
863
+ * branch-staged keys are invisible to it by construction).
864
+ */
865
+ async function runAttachBranch(ctx, parsed, branch, run) {
866
+ const repo = resolveRepo(flagString(parsed.flags, "--repo"), run);
867
+ const defaults = resolvePutDefaults({ envFile: ctx.envFile });
868
+ const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
869
+ const frameOpts = frameOptionsFromFlags(parsed.flags);
870
+ const contentTypeOverride = flagString(parsed.flags, "--content-type");
871
+ const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
872
+ const metadata = { ...metaExtras, ...ghMetadataForBranch(repo, branch) };
873
+ validateMetaMap(metadata);
874
+ const logHuman = !ctx.quiet && !ctx.json;
875
+ if (logHuman) {
876
+ const n = parsed.positionals.length;
877
+ process.stderr.write(`>> uploading ${n} file${n === 1 ? "" : "s"} (staged for branch ${branch})\n`);
878
+ }
879
+ const target = { repo, branch };
880
+ const { uploads, failures, firstError } = await uploadBranchAttachments({
881
+ client: ctx.client,
882
+ target,
883
+ files: parsed.positionals,
884
+ contentType: contentTypeOverride,
885
+ optimize: optimizeOpts,
886
+ frame: frameOpts,
887
+ metadata,
888
+ });
889
+ // Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
890
+ if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
891
+ throw firstError instanceof Error ? firstError : new Error(String(firstError));
892
+ }
893
+ if (ctx.json) {
894
+ await writeJson({ target, uploads, failures });
895
+ }
896
+ else {
897
+ for (const result of uploads) {
898
+ if (logHuman) {
899
+ if (result.frame?.framed) {
900
+ process.stderr.write(`>> ${basename(result.file)}: framed with ${result.frame.frameId}\n`);
901
+ }
902
+ const note = formatOptimizeNote(result.optimize);
903
+ if (note)
904
+ process.stderr.write(`>> ${basename(result.file)}: ${note}\n`);
905
+ writeReplacedNote(result.replaced, false);
906
+ }
907
+ const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
908
+ await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${result.markdown}\n`);
909
+ }
910
+ for (const failure of failures) {
911
+ process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
912
+ }
913
+ if (!ctx.quiet && uploads.length > 0) {
914
+ process.stderr.write(`>> find these later: uploads find gh.branch=${branch.toLowerCase()}\n`);
915
+ }
916
+ }
917
+ return failures.length === 0 ? 0 : 1;
918
+ }
919
+ /**
920
+ * `attach --promote` with zero file arguments: resolve the PR target (same
921
+ * resolution as the default `runAttach` path), promote this workspace's
922
+ * branch-staged attachments into it, then run the comment sync — useful
923
+ * right after `gh pr create` when the PR was opened without a fresh attach
924
+ * (auto-promotion on the default path only fires when you attach a file).
925
+ * Unlike the default path's best-effort branch resolution, this is an
926
+ * explicit user action: `resolveCurrentBranch` throwing (detached HEAD, not
927
+ * a repo) propagates as a UsageError instead of silently skipping. Always
928
+ * exits 0 — an empty staging prefix is success, not a failure.
929
+ */
930
+ async function runAttachPromoteOnly(ctx, parsed, run) {
931
+ const explicitTarget = ghTargetFromFlags(parsed.flags, run);
932
+ const target = explicitTarget ??
933
+ resolveCurrentPullRequest(resolveRepo(flagString(parsed.flags, "--repo"), run), run);
934
+ const branch = resolveCurrentBranch(run);
935
+ const promotion = await attemptPromoteBranch(ctx.client, target, branch);
936
+ let comment;
937
+ let commentError;
938
+ if (!parsed.flags.has("--no-comment")) {
939
+ try {
940
+ comment = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
941
+ }
942
+ catch (err) {
943
+ commentError = err instanceof Error ? err.message : String(err);
944
+ process.stderr.write(`warning: promotion succeeded but the GitHub comment failed (is gh installed and authenticated?): ${commentError}\n`);
945
+ }
946
+ }
947
+ if (ctx.json) {
948
+ await writeJson({
949
+ target,
950
+ uploads: [],
951
+ failures: [],
952
+ comment,
953
+ commentError,
954
+ promotion: promotion ?? null,
955
+ });
956
+ }
957
+ else {
958
+ if (!ctx.quiet && promotion && promotion.promoted.length > 0) {
959
+ process.stderr.write(promotionNote(promotion, branch));
960
+ }
961
+ if (!ctx.quiet && comment)
962
+ process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
963
+ }
964
+ return 0;
965
+ }
618
966
  export async function runPut(ctx, args, help = false, run = execRunner) {
619
967
  if (help) {
620
968
  writeCommandHelp(PUT_HELP);
@@ -815,7 +1163,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
815
1163
  let commentError;
816
1164
  if (wantComment && ghTarget && uploads.length > 0) {
817
1165
  try {
818
- comment = await syncAttachmentsComment(ctx.client, ghTarget, run);
1166
+ comment = await syncAttachmentsComment(ctx.client, ghTarget, run, ctx.config.workspace);
819
1167
  if (logHuman)
820
1168
  process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
821
1169
  }
@@ -1346,6 +1694,11 @@ App is installed on the repo; otherwise via your local gh auth. Finds its own
1346
1694
  prior comment via a hidden marker and edits it in place; never touches other
1347
1695
  comments or the description.
1348
1696
 
1697
+ If this repo is bound to a different workspace (or unbound and you're on the
1698
+ communal "default" workspace), the bot post is declined and this command
1699
+ fails rather than silently falling back to gh — see \`uploads github link
1700
+ --status\`.
1701
+
1349
1702
  Examples:
1350
1703
  uploads --env-file .env comment --pr 123
1351
1704
  uploads comment --issue 45 --repo buildinternet/uploads
@@ -1359,7 +1712,7 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
1359
1712
  const target = ghTargetFromFlags(parsed.flags, run);
1360
1713
  if (!target)
1361
1714
  throw new UsageError("comment requires --pr or --issue");
1362
- const result = await syncAttachmentsComment(ctx.client, target, run);
1715
+ const result = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
1363
1716
  if (ctx.json) {
1364
1717
  await writeJson({ ...target, ...result });
1365
1718
  }
@@ -1371,6 +1724,138 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
1371
1724
  }
1372
1725
  return 0;
1373
1726
  }
1727
+ // --- github link ---
1728
+ const GITHUB_HELP = `uploads github link [--repo <owner/name>] [--status] [--workspace <name>]
1729
+ uploads github unlink [--repo <owner/name>] [--workspace <name>]
1730
+ uploads github doctor [--workspace <name>]
1731
+
1732
+ Claim, inspect, or release this workspace's binding to a GitHub repo (see the
1733
+ managed attachments comment / webhook auto-promotion, which use this
1734
+ binding). First-claim-wins: claiming an already-bound repo never steals it
1735
+ from whichever workspace claimed it first — the command reports who owns it,
1736
+ and how to get it released, instead.
1737
+
1738
+ --repo defaults the same way as --pr/--issue elsewhere (gh repo view, then
1739
+ the git remote). --status only inspects the current binding (files:read);
1740
+ without it, "link" claims the repo (files:write). "unlink" releases a
1741
+ binding this workspace owns — it 403s (via the server) if another workspace
1742
+ owns it; an operator can reassign or remove that binding instead.
1743
+
1744
+ \`doctor\` checks the GitHub App itself: whether it's configured on the
1745
+ server, and whether it's subscribed to the webhook events uploads.sh's
1746
+ handler needs (issues, pull_request — see docs/github-app). A missing
1747
+ subscription is the classic silent failure: the App's ping stays green
1748
+ while webhook auto-promotion and title-cache invalidation quietly do
1749
+ nothing.
1750
+
1751
+ Examples:
1752
+ uploads github link
1753
+ uploads github link --repo buildinternet/uploads
1754
+ uploads github link --status
1755
+ uploads github unlink --repo buildinternet/uploads
1756
+ uploads github doctor
1757
+ `;
1758
+ function formatGithubDoctor(result) {
1759
+ if (!result.configured) {
1760
+ return `github app: not configured on this server${result.hint ? ` — ${result.hint}` : ""}\n`;
1761
+ }
1762
+ if (result.events === null) {
1763
+ return `github app: configured, but health check failed${result.hint ? ` — ${result.hint}` : ""}\n`;
1764
+ }
1765
+ if (result.ok) {
1766
+ return `github app: ok — subscribed to ${result.requiredEvents.join(", ")}\n`;
1767
+ }
1768
+ return (`github app: missing webhook event subscription(s): ${result.missingEvents.join(", ")}\n` +
1769
+ (result.hint ? ` ${result.hint}\n` : ""));
1770
+ }
1771
+ function formatGithubLink(repo, result) {
1772
+ return result.workspace
1773
+ ? `${repo} is bound to workspace "${result.workspace}"${result.source ? ` (${result.source})` : ""}\n`
1774
+ : `${repo} is not bound to any workspace\n`;
1775
+ }
1776
+ async function runGithubDoctor(ctx) {
1777
+ let result;
1778
+ try {
1779
+ result = await ctx.client.githubHealth();
1780
+ }
1781
+ catch (err) {
1782
+ if (err instanceof UploadsError && err.status === 404) {
1783
+ throw new UsageError("server does not support the GitHub App health check yet (404) — upgrade the uploads.sh API/self-hosted worker");
1784
+ }
1785
+ throw err;
1786
+ }
1787
+ if (ctx.json) {
1788
+ await writeJson(result);
1789
+ }
1790
+ else {
1791
+ await writeStdout(formatGithubDoctor(result));
1792
+ }
1793
+ return result.ok ? 0 : 1;
1794
+ }
1795
+ async function runGithubLink(ctx, repo, statusOnly) {
1796
+ let result;
1797
+ try {
1798
+ result = statusOnly
1799
+ ? await ctx.client.githubLinkStatus(repo)
1800
+ : await ctx.client.githubLinkClaim(repo);
1801
+ }
1802
+ catch (err) {
1803
+ if (err instanceof UploadsError && err.status === 404) {
1804
+ throw new UsageError("server does not support repo bindings yet (404) — upgrade the uploads.sh API/self-hosted worker");
1805
+ }
1806
+ throw err;
1807
+ }
1808
+ if (ctx.json) {
1809
+ await writeJson(result);
1810
+ return 0;
1811
+ }
1812
+ if (!statusOnly && result.claimed === false) {
1813
+ process.stderr.write(`note: ${repo} is already bound to a different workspace ("${result.workspace}") — first-claim-wins, not overwritten. Run "uploads github unlink --repo ${repo}" from that workspace, or ask an operator to reassign it.\n`);
1814
+ }
1815
+ await writeStdout(formatGithubLink(repo, result));
1816
+ return 0;
1817
+ }
1818
+ async function runGithubUnlink(ctx, repo) {
1819
+ let result;
1820
+ try {
1821
+ result = await ctx.client.githubLinkUnlink(repo);
1822
+ }
1823
+ catch (err) {
1824
+ if (err instanceof UploadsError && err.status === 404) {
1825
+ throw new UsageError("server does not support repo bindings yet (404) — upgrade the uploads.sh API/self-hosted worker");
1826
+ }
1827
+ if (err instanceof UploadsError && err.status === 403) {
1828
+ throw new UsageError(`${repo} is bound to a different workspace — ask an operator to reassign or remove it (${err.message})`);
1829
+ }
1830
+ throw err;
1831
+ }
1832
+ if (ctx.json) {
1833
+ await writeJson(result);
1834
+ return 0;
1835
+ }
1836
+ await writeStdout(result.unlinked
1837
+ ? `unlinked ${repo}\n`
1838
+ : `${repo} was not bound to any workspace — nothing to unlink\n`);
1839
+ return 0;
1840
+ }
1841
+ export async function runGithub(ctx, args, help = false, run = execRunner) {
1842
+ const parsed = parseCommandArgs(args);
1843
+ const action = parsed.positionals[0];
1844
+ if (help || parsed.help || !action) {
1845
+ writeCommandHelp(GITHUB_HELP);
1846
+ return help || parsed.help ? 0 : 2;
1847
+ }
1848
+ if (action !== "link" && action !== "unlink" && action !== "doctor") {
1849
+ throw new UsageError(`unknown github subcommand: ${action}`);
1850
+ }
1851
+ if (action === "doctor")
1852
+ return runGithubDoctor(ctx);
1853
+ const repo = resolveRepo(flagString(parsed.flags, "--repo"), run);
1854
+ if (action === "unlink")
1855
+ return runGithubUnlink(ctx, repo);
1856
+ const statusOnly = flagBool(parsed.flags, "--status");
1857
+ return runGithubLink(ctx, repo, statusOnly);
1858
+ }
1374
1859
  // --- usage / reconcile / purge ---
1375
1860
  const USAGE_HELP = `uploads usage [--workspace <name>]
1376
1861