@buildinternet/uploads 0.38.0 → 0.41.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
@@ -13,9 +13,9 @@ import { imageFactsFromBytes } from "./image-facts.js";
13
13
  import { parseMetaFlags, validateMetaMap } from "./metadata.js";
14
14
  import { mergeDerivedMeta, nearMissMetaWarnings, validateStateValue } from "./metadata-vocab.js";
15
15
  import { mergeSidecarMeta } from "./sidecar.js";
16
- import { ghAttachmentKey, ghBranchAttachmentKey, ghBranchKeyPrefix, ghKeyPrefix, ghMetadataFromTarget, parseGhKey, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, AUTO_RENDER_OPTIONS, GH_FALLBACK_AUTHOR_NOTE, normalizeGithubCoordinate, } from "./github.js";
16
+ import { ghAttachmentKeyForMode, ghBranchAttachmentKeyForMode, ghBranchKeyPrefix, ghKeyPrefix, ghPrivateKeyPrefix, ghPrivateBranchKeyPrefix, ghMetadataFromTarget, parseGhKey, parseGhPrivateKey, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, AUTO_RENDER_OPTIONS, GH_FALLBACK_AUTHOR_NOTE, normalizeGithubCoordinate, } from "./github.js";
17
17
  import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, resolveDefaultBranch, classifyGhNumber, execRunner, timedExecRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
18
- import { deriveRepoFromGit } from "./keys.js";
18
+ import { deriveRepoFromGit, deriveRepoSlugFromGit } from "./keys.js";
19
19
  import { resolvePutPrefix } from "./destinations.js";
20
20
  import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
21
21
  import { applyFrame, resolveFrameId } from "./frame.js";
@@ -28,6 +28,56 @@ import { colorEnabled, writeCommandHelp } from "./cli-style.js";
28
28
  export const UPLOAD_BATCH_CONCURRENCY = 8;
29
29
  /** @deprecated Use UPLOAD_BATCH_CONCURRENCY. */
30
30
  export const ATTACH_CONCURRENCY = UPLOAD_BATCH_CONCURRENCY;
31
+ /**
32
+ * Fail-open wrapper around `client.resolveGhPrefix` (issue #631): resolves to
33
+ * `{ mode: "plain" }` on ANY failure — an HTTP/network error (already handled
34
+ * inside `resolveGhPrefix` itself), or a self-hosted/older server or test
35
+ * double that lacks the method entirely (the outer try/catch here). Never
36
+ * blocks an upload or a read-back, and never logs — this is not an error.
37
+ * Call once per command invocation and thread the resolved mode through
38
+ * (uploadPuts/uploadAttachments/uploadBranchAttachments each do this once
39
+ * internally, ahead of their per-file loop); `resolveGhPrefix` itself also
40
+ * caches per-process by repo+branch+target, so repeat callers in the same
41
+ * process (e.g. attach's promote + comment-sync + upload, all for the same
42
+ * target) cost one request total.
43
+ */
44
+ export async function resolveGhPrefixSafe(client, opts) {
45
+ try {
46
+ return await client.resolveGhPrefix(opts);
47
+ }
48
+ catch {
49
+ return { mode: "plain" };
50
+ }
51
+ }
52
+ /**
53
+ * The list of prefixes to fan a multi-prefix list/gather across (issue
54
+ * #631): the plain prefix plus every active private prefix, if any — a
55
+ * repo's history can be split across the plain shape and MULTIPLE private
56
+ * prefixes (e.g. a prefix rotation, or the repo went private after some
57
+ * files were uploaded), not just the currently-resolved one. Falls back to
58
+ * `[prefixId]` when the server omits `activePrefixIds` (optional field — an
59
+ * older/self-hosted worker), so a private repo is never listed as zero
60
+ * private prefixes. Collapses to `[plainPrefix]` in plain mode, so callers
61
+ * that special-case a single-prefix array stay byte-identical to pre-#631.
62
+ */
63
+ export function ghListPrefixes(plainPrefix, ghPrefix, privatePrefixFor) {
64
+ if (ghPrefix.mode !== "private")
65
+ return [plainPrefix];
66
+ return [plainPrefix, ...(ghPrefix.activePrefixIds ?? [ghPrefix.prefixId]).map(privatePrefixFor)];
67
+ }
68
+ /**
69
+ * Merge-list helper for a multi-prefix fan-out: runs `fetchItems` per prefix
70
+ * concurrently and concatenates in prefix order. Encodes the first-prefix-
71
+ * only cursor rule once — a cursor is opaque and scoped to the prefix it was
72
+ * minted against, so a multi-prefix merge only ever hands it to the FIRST
73
+ * prefix; every other prefix always starts from its own beginning (undefined
74
+ * cursor), or a cursor minted for one prefix's keyspace would get replayed
75
+ * against a different one.
76
+ */
77
+ export async function ghMergedList(prefixes, cursor, fetchItems) {
78
+ const pages = await Promise.all(prefixes.map((prefix, i) => fetchItems(prefix, i === 0 ? cursor : undefined)));
79
+ return pages.flat();
80
+ }
31
81
  export { formatUsageHuman } from "./format-usage.js";
32
82
  /** Read a local file (or `-` for stdin). Missing path → FILE_NOT_FOUND (exit 2). */
33
83
  export function readFileArg(fileArg) {
@@ -385,10 +435,11 @@ export async function uploadPreparedImage(client, bytes, sourceName, opts) {
385
435
  frameFit: opts.frame.frameFit,
386
436
  optimize: opts.optimize,
387
437
  });
438
+ const ghMode = opts.ghPrefix ?? { mode: "plain" };
388
439
  let key = opts.ghTarget
389
- ? ghAttachmentKey(opts.ghTarget, prepared.filename)
440
+ ? ghAttachmentKeyForMode(ghMode, opts.ghTarget, prepared.filename)
390
441
  : opts.ghBranchTarget
391
- ? ghBranchAttachmentKey(opts.ghBranchTarget.repo, opts.ghBranchTarget.branch, prepared.filename)
442
+ ? ghBranchAttachmentKeyForMode(ghMode, opts.ghBranchTarget.repo, opts.ghBranchTarget.branch, prepared.filename)
392
443
  : opts.key;
393
444
  if (key && prepared.optimized)
394
445
  key = rewriteKeyExtension(key, prepared.filename);
@@ -510,9 +561,21 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
510
561
  // always links to the file page and always shows metadata here, matching the
511
562
  // defaults. This only diverges from the bot-posted comment for a workspace
512
563
  // that both sets one of those flags false and falls through to this path.
513
- const items = (await client.listAll({ prefix: ghKeyPrefix(target), metadata: true })).map(({ key, url, embedUrl, pageUrl, metadata }) => {
514
- // The list endpoint returns every metadata key; the comment renders only
515
- // these two. Narrowing here keeps both render paths byte-identical.
564
+ //
565
+ // Also list every active private prefix for this repo (issue #631): a
566
+ // private repo's attachments can live under a randomized prefix instead of
567
+ // the plain one. `resolveGhPrefixSafe` is fail-open (any resolve failure,
568
+ // including a client that lacks the method entirely) — plain-only listing,
569
+ // silently, matching pre-#631 behavior exactly.
570
+ const ghPrefix = await resolveGhPrefixSafe(client, {
571
+ repo: target.repo,
572
+ target: { kind: target.kind, num: target.num },
573
+ });
574
+ const prefixes = ghListPrefixes(ghKeyPrefix(target), ghPrefix, (id) => ghPrivateKeyPrefix(id, target));
575
+ const items = await ghMergedList(prefixes, undefined, async (prefix) => (await client.listAll({ prefix, metadata: true })).map(({ key, url, embedUrl, pageUrl, metadata }) => {
576
+ // The list endpoint returns every metadata key; the comment
577
+ // renders only these two. Narrowing here keeps both render paths
578
+ // byte-identical.
516
579
  const path = metadata?.path;
517
580
  const state = metadata?.state;
518
581
  return {
@@ -524,7 +587,7 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
524
587
  ? { meta: { ...(path ? { path } : {}), ...(state ? { state } : {}) } }
525
588
  : {}),
526
589
  };
527
- });
590
+ }));
528
591
  const galleries = [];
529
592
  let cursor;
530
593
  do {
@@ -791,9 +854,14 @@ async function uploadAttachmentBatch(opts) {
791
854
  * original cause of the first failure — for rethrowing single-file CLI paths.
792
855
  */
793
856
  export async function uploadAttachments(opts) {
857
+ // Resolved once for the whole batch (issue #631) — never per file.
858
+ const ghPrefix = await resolveGhPrefixSafe(opts.client, {
859
+ repo: opts.target.repo,
860
+ target: { kind: opts.target.kind, num: opts.target.num },
861
+ });
794
862
  return uploadAttachmentBatch({
795
863
  ...opts,
796
- keyFor: (filename) => ghAttachmentKey(opts.target, filename),
864
+ keyFor: (filename) => ghAttachmentKeyForMode(ghPrefix, opts.target, filename),
797
865
  });
798
866
  }
799
867
  /**
@@ -804,9 +872,14 @@ export async function uploadAttachments(opts) {
804
872
  * `syncAttachmentsComment` for a branch target.
805
873
  */
806
874
  export async function uploadBranchAttachments(opts) {
875
+ // Resolved once for the whole batch (issue #631) — never per file.
876
+ const ghPrefix = await resolveGhPrefixSafe(opts.client, {
877
+ repo: opts.target.repo,
878
+ branch: opts.target.branch,
879
+ });
807
880
  return uploadAttachmentBatch({
808
881
  ...opts,
809
- keyFor: (filename) => ghBranchAttachmentKey(opts.target.repo, opts.target.branch, filename),
882
+ keyFor: (filename) => ghBranchAttachmentKeyForMode(ghPrefix, opts.target.repo, opts.target.branch, filename),
810
883
  });
811
884
  }
812
885
  function errorDetail(err) {
@@ -828,6 +901,18 @@ export async function uploadPuts(opts) {
828
901
  if (opts.files.length > 1 && opts.nameOverride) {
829
902
  throw new UsageError("--name cannot be combined with multiple files");
830
903
  }
904
+ // Resolved once for the whole batch (issue #631) — never per file.
905
+ const ghPrefix = opts.ghTarget
906
+ ? await resolveGhPrefixSafe(opts.client, {
907
+ repo: opts.ghTarget.repo,
908
+ target: { kind: opts.ghTarget.kind, num: opts.ghTarget.num },
909
+ })
910
+ : opts.ghBranchTarget
911
+ ? await resolveGhPrefixSafe(opts.client, {
912
+ repo: opts.ghBranchTarget.repo,
913
+ branch: opts.ghBranchTarget.branch,
914
+ })
915
+ : undefined;
831
916
  const slots = await mapBounded(opts.files, opts.concurrency ?? UPLOAD_BATCH_CONCURRENCY, async (file) => {
832
917
  try {
833
918
  const sourceName = opts.nameOverride ??
@@ -845,6 +930,7 @@ export async function uploadPuts(opts) {
845
930
  optimize: opts.optimize,
846
931
  ghTarget: opts.ghTarget,
847
932
  ghBranchTarget: opts.ghBranchTarget,
933
+ ghPrefix,
848
934
  key: opts.explicitKey,
849
935
  prefix: opts.prefix,
850
936
  repo: opts.repo,
@@ -899,7 +985,7 @@ export async function uploadPuts(opts) {
899
985
  return { uploads, failures, firstError, sentMetadata };
900
986
  }
901
987
  /**
902
- * Best-effort call to `POST /v1/:workspace/github/promote` (server contract,
988
+ * Best-effort call to `POST /v1/workspaces/:workspace/github/promote` (server contract,
903
989
  * PR #310). Degrade-safe like `syncAttachmentsComment`'s bot path: an older
904
990
  * or self-hosted worker without this route (404), a forbidden token (403),
905
991
  * or a network error all collapse to "nothing promoted" — the caller must
@@ -1447,18 +1533,28 @@ async function resolveStagedBinding(client, repo) {
1447
1533
  */
1448
1534
  export async function resolveStaged(opts) {
1449
1535
  const { client, repo, branch } = opts;
1450
- const prefix = ghBranchKeyPrefix(repo, branch);
1451
- const [list, binding] = await Promise.all([
1452
- client.list({ prefix, metadata: true }),
1536
+ const plainPrefix = ghBranchKeyPrefix(repo, branch);
1537
+ // Also list every active private prefix, if any (issue #631) — mirrors
1538
+ // syncAttachmentsComment's gh-fallback gather above: a repo's staged
1539
+ // history can be split across the plain shape and MULTIPLE private
1540
+ // prefixes (e.g. a prefix rotation, or the repo went private after some
1541
+ // files were staged), not just the currently-resolved one. Fail-open: any
1542
+ // resolve failure degrades to plain-only, byte-identical to pre-#631.
1543
+ const ghPrefix = await resolveGhPrefixSafe(client, { repo, branch });
1544
+ const prefixes = ghListPrefixes(plainPrefix, ghPrefix, (id) => ghPrivateBranchKeyPrefix(id));
1545
+ const [files, binding] = await Promise.all([
1546
+ ghMergedList(prefixes, undefined, async (prefix) => {
1547
+ const list = await client.list({ prefix, metadata: true });
1548
+ return list.items.map((item) => ({
1549
+ key: item.key,
1550
+ filename: item.key.slice(prefix.length),
1551
+ size: item.size,
1552
+ stagedAt: item.metadata?.["gh.staged-at"],
1553
+ url: item.url,
1554
+ }));
1555
+ }),
1453
1556
  resolveStagedBinding(client, repo),
1454
1557
  ]);
1455
- const files = list.items.map((item) => ({
1456
- key: item.key,
1457
- filename: item.key.slice(prefix.length),
1458
- size: item.size,
1459
- stagedAt: item.metadata?.["gh.staged-at"],
1460
- url: item.url,
1461
- }));
1462
1558
  return { repo, branch, files, binding };
1463
1559
  }
1464
1560
  const STAGED_HELP = `uploads staged [--branch <name>] [--repo <owner/name>] [--format json] [--workspace <name>]
@@ -1699,6 +1795,23 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1699
1795
  }
1700
1796
  }
1701
1797
  }
1798
+ // Derived `repo` (spec: 2026-08-11-screenshots-project-grouping-design.md):
1799
+ // the capturing repo, on every layout including gh/staging. mergeDerivedMeta
1800
+ // keeps explicit --meta repo= wins and never breaks the caps.
1801
+ //
1802
+ // `metadata === undefined` means "leave whatever's already stored for this
1803
+ // key untouched" (client.ts only sends X-Uploads-Meta-* when the map is
1804
+ // defined; a defined map is a full replace server-side). A bare `uploads
1805
+ // put` with no --meta/gh/staging context leaves `metadata` undefined by
1806
+ // design — never synthesize `{ repo }` there just because a repo happens
1807
+ // to be derivable, or a plain re-upload of an existing key would silently
1808
+ // wipe everything already stored on it. Only fold repo in when `metadata`
1809
+ // is already a defined object for another reason.
1810
+ if (!noGit && derivedMetaEnabled(parsed.flags, defaults) && metadata !== undefined) {
1811
+ const slug = deriveRepoSlugFromGit(run);
1812
+ if (slug)
1813
+ metadata = mergeDerivedMeta(metadata, { repo: slug });
1814
+ }
1702
1815
  // Bare-put nudge (issue #393): only relevant when staging didn't take over
1703
1816
  // — once `stagingTarget` resolves, staging IS the upgrade the nudge used to
1704
1817
  // point at, so this is skipped entirely rather than firing redundantly.
@@ -2219,16 +2332,31 @@ export async function runList(ctx, args, help = false, run = execRunner) {
2219
2332
  const prefixFlag = flagString(parsed.flags, "--prefix");
2220
2333
  let prefix = prefixFlag ?? (defaults.prefix ? `${defaults.prefix}/` : undefined);
2221
2334
  const ghTarget = ghTargetFromFlags(parsed.flags, run);
2335
+ // Also list every active private prefix, if any (issue #631) — mirrors
2336
+ // syncAttachmentsComment's gh-fallback gather: a repo's attachment
2337
+ // history can be split across the plain shape and MULTIPLE private
2338
+ // prefixes, not just the currently-resolved one. `prefixes` stays
2339
+ // undefined outside --pr/--issue (unchanged behavior); when defined it
2340
+ // collapses to just `[prefix]` in plain mode, so the single-request path
2341
+ // below is byte-identical to pre-#631 output there.
2342
+ let prefixes;
2222
2343
  if (ghTarget) {
2223
2344
  if (prefixFlag)
2224
2345
  throw new UsageError("--prefix cannot be combined with --pr/--issue");
2225
2346
  prefix = ghKeyPrefix(ghTarget);
2347
+ const ghPrefix = await resolveGhPrefixSafe(ctx.client, {
2348
+ repo: ghTarget.repo,
2349
+ target: { kind: ghTarget.kind, num: ghTarget.num },
2350
+ });
2351
+ prefixes = ghListPrefixes(prefix, ghPrefix, (id) => ghPrivateKeyPrefix(id, ghTarget));
2226
2352
  }
2227
2353
  const limit = flagInt(parsed.flags, "--limit", "--limit");
2228
2354
  const cursor = flagString(parsed.flags, "--cursor");
2229
2355
  if (flagBool(parsed.flags, "--all")) {
2230
2356
  // --all may start from a caller-provided --cursor and drains from there.
2231
- const items = await ctx.client.listAll({ prefix, limit, cursor });
2357
+ const items = prefixes && prefixes.length > 1
2358
+ ? await ghMergedList(prefixes, cursor, (p, c) => ctx.client.listAll({ prefix: p, limit, cursor: c }))
2359
+ : await ctx.client.listAll({ prefix, limit, cursor });
2232
2360
  if (ctx.json)
2233
2361
  await writeJson({ items, cursor: null });
2234
2362
  else
@@ -2236,7 +2364,16 @@ export async function runList(ctx, args, help = false, run = execRunner) {
2236
2364
  await writeStdout(`${item.key}${item.url ? ` ${item.url}` : ""}\n`);
2237
2365
  return 0;
2238
2366
  }
2239
- const result = await ctx.client.list({ prefix, limit, cursor });
2367
+ // Merged multi-prefix pages don't have a meaningful combined cursor
2368
+ // dropped (null) only when there's more than one prefix to merge; the
2369
+ // single-prefix path (every non-private call, plus every call before
2370
+ // #631) is untouched. Same first-prefix-only cursor guard as --all above.
2371
+ const result = prefixes && prefixes.length > 1
2372
+ ? {
2373
+ items: await ghMergedList(prefixes, cursor, async (p, c) => (await ctx.client.list({ prefix: p, limit, cursor: c })).items),
2374
+ cursor: null,
2375
+ }
2376
+ : await ctx.client.list({ prefix, limit, cursor });
2240
2377
  if (ctx.json)
2241
2378
  await writeJson(result);
2242
2379
  else {
@@ -2434,10 +2571,37 @@ const COMMENT_RENDERED_META_KEYS = ["path", "state"];
2434
2571
  * metadata tweak, not an explicit comment command); any failure degrades to
2435
2572
  * a stderr hint instead of failing the metadata write that already landed.
2436
2573
  */
2574
+ /**
2575
+ * Resolve the `{repo, kind, num}` target for a key, whether plain
2576
+ * (`parseGhKey`) or private-prefixed (issue #631, `parseGhPrivateKey` —
2577
+ * cannot recover the repo from the key alone, since the randomized prefix
2578
+ * deliberately omits it). For a private key, reads `gh.repo` metadata — the
2579
+ * attach/put that created this key already wrote it — via the same metadata
2580
+ * client call `meta get` uses. Fail-open: any read failure, or a key that
2581
+ * isn't gh-managed at all, resolves to undefined (nothing to resync).
2582
+ */
2583
+ async function resolveGhTargetForResync(client, key) {
2584
+ const plain = parseGhKey(key);
2585
+ if (plain)
2586
+ return plain;
2587
+ const priv = parseGhPrivateKey(key);
2588
+ if (!priv)
2589
+ return undefined;
2590
+ try {
2591
+ const { metadata } = await client.getMetadata(key);
2592
+ const repo = metadata["gh.repo"];
2593
+ if (!repo)
2594
+ return undefined;
2595
+ return { repo, kind: priv.kind, num: priv.num };
2596
+ }
2597
+ catch {
2598
+ return undefined;
2599
+ }
2600
+ }
2437
2601
  async function resyncCommentAfterMetaSet(ctx, key, touchedKeys) {
2438
2602
  if (!touchedKeys.some((k) => COMMENT_RENDERED_META_KEYS.includes(k)))
2439
2603
  return;
2440
- const target = parseGhKey(key);
2604
+ const target = await resolveGhTargetForResync(ctx.client, key);
2441
2605
  if (!target)
2442
2606
  return;
2443
2607
  try {
@@ -2551,10 +2715,53 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
2551
2715
  }
2552
2716
  return 0;
2553
2717
  }
2718
+ // --- ingest ---
2719
+ const INGEST_HELP = `uploads ingest — mirror GitHub-native attachments from a PR/issue into the workspace
2720
+
2721
+ Usage:
2722
+ uploads ingest --pr <n> [--repo owner/name]
2723
+ uploads ingest --issue <n> [--repo owner/name]
2724
+
2725
+ Scans the PR/issue description and comments for github.com/user-attachments
2726
+ media, mirrors new ones into the workspace (indexed, not added to the managed
2727
+ comment), and detaches ones no longer referenced. Works on any repo linked to
2728
+ the workspace; the .uploads.yml ingestGithubAttachments knob only gates the
2729
+ automatic webhook path.
2730
+
2731
+ Examples:
2732
+ uploads ingest --pr 123
2733
+ uploads ingest --issue 45 --repo acme/app --format json
2734
+ `;
2735
+ export async function runIngest(ctx, args, help = false, run = execRunner) {
2736
+ const parsed = parseCommandArgs(args);
2737
+ if (help || parsed.help) {
2738
+ writeCommandHelp(INGEST_HELP);
2739
+ return 0;
2740
+ }
2741
+ const target = ghTargetFromFlags(parsed.flags, run);
2742
+ if (!target) {
2743
+ throw new UsageError("--pr or --issue required");
2744
+ }
2745
+ const result = await ctx.client.ingestGithub(target);
2746
+ const jsonMode = ctx.json || flagString(parsed.flags, "--format") === "json";
2747
+ if (jsonMode) {
2748
+ await writeJson(result);
2749
+ return 0;
2750
+ }
2751
+ if (!ctx.quiet) {
2752
+ let line = `Ingested ${result.ingested.length}, re-attached ${result.reattached.length}, detached ${result.detached.length}, skipped ${result.skipped.length}\n`;
2753
+ for (const skip of result.skipped) {
2754
+ line += ` skipped: ${skip.url} (${skip.reason})\n`;
2755
+ }
2756
+ process.stderr.write(line);
2757
+ }
2758
+ return 0;
2759
+ }
2554
2760
  // --- github link ---
2555
2761
  const GITHUB_HELP = `uploads github link [--repo <owner/name>] [--status] [--workspace <name>]
2556
2762
  uploads github unlink [--repo <owner/name>] [--workspace <name>]
2557
2763
  uploads github doctor [--workspace <name>]
2764
+ uploads github rotate-prefix [--repo <owner/name>] [--branch <name> | --repo-level] [--workspace <name>]
2558
2765
 
2559
2766
  Claim, inspect, or release this workspace's binding to a GitHub repo (see the
2560
2767
  managed attachments comment / webhook auto-promotion, which use this
@@ -2570,17 +2777,28 @@ owns it; an operator can reassign or remove that binding instead.
2570
2777
 
2571
2778
  \`doctor\` checks the GitHub App itself: whether it's configured on the
2572
2779
  server, and whether it's subscribed to the webhook events uploads.sh's
2573
- handler needs (issues, pull_request see docs/github-app). A missing
2780
+ handler needs (required: issues, pull_request; recommended: issue_comment
2781
+ see docs/github-app). A missing
2574
2782
  subscription is the classic silent failure: the App's ping stays green
2575
2783
  while webhook auto-promotion and title-cache invalidation quietly do
2576
2784
  nothing.
2577
2785
 
2786
+ \`rotate-prefix\` mints a fresh randomized URL prefix for a private repo's
2787
+ attachments and moves everything under the old one to it, so the old URLs
2788
+ 404 at origin immediately (see docs/private-attachments.md). --branch
2789
+ defaults to the current git branch; --repo-level rotates the id shared by
2790
+ issue attachments and ingested assets instead of a branch's id. Rotation
2791
+ is an explicit action — an unauthorized caller gets an error, not a silent
2792
+ no-op.
2793
+
2578
2794
  Examples:
2579
2795
  uploads github link
2580
2796
  uploads github link --repo buildinternet/uploads
2581
2797
  uploads github link --status
2582
2798
  uploads github unlink --repo buildinternet/uploads
2583
2799
  uploads github doctor
2800
+ uploads github rotate-prefix --branch feature-x
2801
+ uploads github rotate-prefix --repo-level
2584
2802
  `;
2585
2803
  /** Older servers' health payload predates recommendedEvents/missingRecommendedEvents — treat as no recommendations rather than crashing. */
2586
2804
  function missingRecommendedEventsOf(result) {
@@ -2590,7 +2808,7 @@ function recommendedNoteLine(result) {
2590
2808
  const missing = missingRecommendedEventsOf(result);
2591
2809
  if (missing.length === 0)
2592
2810
  return "";
2593
- return `note: not subscribed to ${missing.join(", ")} (recommended) — enables bot-comment self-healing; subscribe under the App's Permissions & events\n`;
2811
+ return `note: not subscribed to ${missing.join(", ")} (recommended) — enables bot-comment self-healing and comment-attachment ingestion; subscribe under the App's Permissions & events\n`;
2594
2812
  }
2595
2813
  function formatGithubDoctor(result) {
2596
2814
  if (!result.configured) {
@@ -2600,7 +2818,18 @@ function formatGithubDoctor(result) {
2600
2818
  return `github app: configured, but health check failed${result.hint ? ` — ${result.hint}` : ""}\n`;
2601
2819
  }
2602
2820
  if (result.ok) {
2603
- return (`github app: ok subscribed to ${result.requiredEvents.join(", ")}\n` +
2821
+ // The ACTUAL subscribed list, not just the required subset — printing
2822
+ // requiredEvents here once misdiagnosed a live App as "not subscribed to
2823
+ // issue_comment" when it was (2026-08-11). `events` is non-null on every
2824
+ // ok result (the null case returns above); the requiredEvents fallback
2825
+ // only guards a malformed payload from an older server.
2826
+ const subscribed = Array.isArray(result.events)
2827
+ ? [...result.events].sort()
2828
+ : [...result.requiredEvents];
2829
+ const allPresent = missingRecommendedEventsOf(result).length === 0;
2830
+ return (`github app: ok — subscribed to ${subscribed.join(", ")}` +
2831
+ (allPresent ? " (all required + recommended events)" : "") +
2832
+ "\n" +
2604
2833
  recommendedNoteLine(result));
2605
2834
  }
2606
2835
  return (`github app: missing webhook event subscription(s): ${result.missingEvents.join(", ")}\n` +
@@ -2689,6 +2918,34 @@ async function runGithubUnlink(ctx, repo) {
2689
2918
  : `${repo} was not bound to any workspace — nothing to unlink\n`);
2690
2919
  return 0;
2691
2920
  }
2921
+ function formatGithubRotatePrefix(repo, branchLabel, result) {
2922
+ if (!result.rotated) {
2923
+ return `nothing to rotate for ${repo} (${branchLabel}): ${result.reason}\n`;
2924
+ }
2925
+ return `rotated ${repo} (${branchLabel}): moved ${result.moved} object${result.moved === 1 ? "" : "s"} to a new prefix (${result.prefixId})\n`;
2926
+ }
2927
+ async function runGithubRotatePrefix(ctx, repo, branch, repoLevel) {
2928
+ let result;
2929
+ try {
2930
+ result = await ctx.client.rotateGhPrefix(repoLevel ? { repo, repoLevel: true } : { repo, branch });
2931
+ }
2932
+ catch (err) {
2933
+ if (err instanceof UploadsError && err.status === 404) {
2934
+ throw new UsageError("server does not support private-prefix rotation yet (404) — upgrade the uploads.sh API/self-hosted worker");
2935
+ }
2936
+ if (err instanceof UploadsError && err.status === 403) {
2937
+ throw new UsageError(`not authorized to rotate ${repo}'s attachment prefix (${err.message})`);
2938
+ }
2939
+ throw err;
2940
+ }
2941
+ if (ctx.json) {
2942
+ await writeJson(result);
2943
+ return result.rotated ? 0 : 1;
2944
+ }
2945
+ const branchLabel = repoLevel ? "repo-level" : (branch ?? "");
2946
+ await writeStdout(formatGithubRotatePrefix(repo, branchLabel, result));
2947
+ return result.rotated ? 0 : 1;
2948
+ }
2692
2949
  export async function runGithub(ctx, args, help = false, run = execRunner) {
2693
2950
  const parsed = parseCommandArgs(args);
2694
2951
  const action = parsed.positionals[0];
@@ -2696,14 +2953,24 @@ export async function runGithub(ctx, args, help = false, run = execRunner) {
2696
2953
  writeCommandHelp(GITHUB_HELP);
2697
2954
  return help || parsed.help ? 0 : 2;
2698
2955
  }
2699
- if (action !== "link" && action !== "unlink" && action !== "doctor") {
2700
- throw new UsageError(`unknown github subcommand: ${action} (expected link or doctor)`, {
2701
- example: "uploads github link",
2702
- });
2956
+ if (action !== "link" &&
2957
+ action !== "unlink" &&
2958
+ action !== "doctor" &&
2959
+ action !== "rotate-prefix") {
2960
+ throw new UsageError(`unknown github subcommand: ${action} (expected link, unlink, doctor, or rotate-prefix)`, { example: "uploads github link" });
2703
2961
  }
2704
2962
  if (action === "doctor")
2705
2963
  return runGithubDoctor(ctx);
2706
2964
  const repo = resolveRepo(flagString(parsed.flags, "--repo"), run);
2965
+ if (action === "rotate-prefix") {
2966
+ const repoLevel = flagBool(parsed.flags, "--repo-level");
2967
+ const branchFlag = flagString(parsed.flags, "--branch");
2968
+ if (repoLevel && branchFlag !== undefined) {
2969
+ throw new UsageError("pass either --branch or --repo-level, not both");
2970
+ }
2971
+ const branch = repoLevel ? undefined : (branchFlag ?? resolveCurrentBranch(run));
2972
+ return runGithubRotatePrefix(ctx, repo, branch, repoLevel);
2973
+ }
2707
2974
  if (action === "unlink")
2708
2975
  return runGithubUnlink(ctx, repo);
2709
2976
  const statusOnly = flagBool(parsed.flags, "--status");
@@ -17,6 +17,7 @@ export interface RepoCommentConfig {
17
17
  metaState?: boolean;
18
18
  linkToFilePage?: boolean;
19
19
  note?: string;
20
+ ingestGithubAttachments?: boolean;
20
21
  }
21
22
  export interface WorkspaceCommentDefaults {
22
23
  imageWidth?: "full" | number;
@@ -24,6 +25,7 @@ export interface WorkspaceCommentDefaults {
24
25
  showMetadata?: boolean;
25
26
  linkToFilePage?: boolean;
26
27
  note?: string;
28
+ ingestGithubAttachments?: boolean;
27
29
  }
28
30
  export interface ResolvedCommentOptions {
29
31
  imageWidth: "auto" | "full" | number;
@@ -32,6 +34,7 @@ export interface ResolvedCommentOptions {
32
34
  metaState: boolean;
33
35
  linkToFilePage: boolean;
34
36
  note: string | null;
37
+ ingestGithubAttachments: boolean;
35
38
  }
36
39
  export type OptionSource = "repo" | "workspace" | "auto";
37
40
  export declare const AUTO_COMMENT_OPTIONS: ResolvedCommentOptions;
@@ -20,6 +20,7 @@ export const AUTO_COMMENT_OPTIONS = {
20
20
  metaState: true,
21
21
  linkToFilePage: true,
22
22
  note: null,
23
+ ingestGithubAttachments: false,
23
24
  };
24
25
  export const NOTE_MAX_CHARS = 500;
25
26
  const WIDTH_MIN = 160;
@@ -71,6 +72,14 @@ export function parseRepoCommentConfig(text, format) {
71
72
  else
72
73
  warnings.push(`linkToFilePage: expected a boolean; dropped`);
73
74
  }
75
+ // ingestGithubAttachments: boolean
76
+ if ("ingestGithubAttachments" in c) {
77
+ const v = c.ingestGithubAttachments;
78
+ if (typeof v === "boolean")
79
+ config.ingestGithubAttachments = v;
80
+ else
81
+ warnings.push(`ingestGithubAttachments: expected a boolean; dropped`);
82
+ }
74
83
  // meta.path / meta.state: booleans nested under `meta`
75
84
  if ("meta" in c) {
76
85
  const v = c.meta;
@@ -123,6 +132,9 @@ export function resolveCommentOptions(repo, ws) {
123
132
  : {}),
124
133
  ...(ws?.linkToFilePage !== undefined ? { linkToFilePage: ws.linkToFilePage } : {}),
125
134
  ...(ws?.note ? { note: ws.note } : {}),
135
+ ...(ws?.ingestGithubAttachments !== undefined
136
+ ? { ingestGithubAttachments: ws.ingestGithubAttachments }
137
+ : {}),
126
138
  };
127
139
  const options = { ...AUTO_COMMENT_OPTIONS };
128
140
  const source = Object.fromEntries(Object.keys(AUTO_COMMENT_OPTIONS).map((k) => [k, "auto"]));
@@ -133,6 +145,7 @@ export function resolveCommentOptions(repo, ws) {
133
145
  "metaPath",
134
146
  "metaState",
135
147
  "linkToFilePage",
148
+ "ingestGithubAttachments",
136
149
  ]) {
137
150
  if (cfg[key] !== undefined && source[key] === "auto") {
138
151
  options[key] = cfg[key];
package/dist/github.d.ts CHANGED
@@ -19,8 +19,47 @@ export declare function normalizeGithubCoordinate(value: string): GithubCoordina
19
19
  * Inverse of `ghKeyPrefix`: parse the PR/issue coordinate back out of a
20
20
  * stable attachment key (`gh/<owner>/<name>/<kind>/<num>/<filename>`), or
21
21
  * undefined for any other key shape.
22
+ *
23
+ * A real GitHub owner CAN be named `private`, so the strict private-repo
24
+ * shape (`gh/private/<32-hex-id>/...`, see `parseGhPrivateKey`) is checked
25
+ * first and rejected here — otherwise a private-prefixed key would
26
+ * misparse as an ordinary key with owner "private". The accepted ambiguity:
27
+ * a key whose second segment is NOT 32-lowercase-hex (e.g.
28
+ * `gh/private/realrepo/pull/5/x.png`) still parses here as owner "private",
29
+ * repo "private/realrepo" — that's an ordinary public-repo key for a repo
30
+ * actually named "private", not a private-prefix key.
22
31
  */
23
32
  export declare function parseGhKey(key: string): GhTarget | undefined;
33
+ /** Literal root under which every private-repo attachment key lives. */
34
+ export declare const GH_PRIVATE_ROOT = "gh/private/";
35
+ /**
36
+ * Private-repo key prefix: `gh/private/<32-hex-id>/<kind>/<num>/`.
37
+ * Deliberately omits the repo (unlike `ghKeyPrefix`) — the id is a random,
38
+ * unguessable per-repo prefix rather than an owner/name path, so callers
39
+ * that need the repo back must read `gh.repo` metadata (see
40
+ * `parseGhPrivateKey`, which cannot recover it from the key alone).
41
+ */
42
+ export declare function ghPrivateKeyPrefix(prefixId: string, target: GhTarget): string;
43
+ /** Private-repo attachment key: `ghPrivateKeyPrefix` + the sanitized filename. */
44
+ export declare function ghPrivateAttachmentKey(prefixId: string, target: GhTarget, filename: string): string;
45
+ /**
46
+ * Private-repo branch-staged key prefix: `gh/private/<32-hex-id>/branch/`.
47
+ * Unlike `ghBranchKeyPrefix`, there is deliberately NO branch-name segment —
48
+ * the branch name itself is not embedded in a private-repo key.
49
+ */
50
+ export declare function ghPrivateBranchKeyPrefix(prefixId: string): string;
51
+ /** Private-repo branch-staged attachment key: `ghPrivateBranchKeyPrefix` + the sanitized filename. */
52
+ export declare function ghPrivateBranchAttachmentKey(prefixId: string, filename: string): string;
53
+ /**
54
+ * Inverse of `ghPrivateKeyPrefix`: parse the prefix id/kind/number back out
55
+ * of a private-repo attachment key, or undefined for any other key shape.
56
+ * Cannot recover the repo — callers that need it read `gh.repo` metadata.
57
+ */
58
+ export declare function parseGhPrivateKey(key: string): {
59
+ prefixId: string;
60
+ kind: GhTargetKind;
61
+ num: number;
62
+ } | undefined;
24
63
  export declare function ghKeyPrefix(target: GhTarget): string;
25
64
  /**
26
65
  * Stable attachment key: same filename → same key → same public URL, so
@@ -37,6 +76,29 @@ export declare function ghAttachmentKey(target: GhTarget, filename: string): str
37
76
  export declare function ghBranchKeyPrefix(repo: string, branch: string): string;
38
77
  /** Branch-staged attachment key: `ghBranchKeyPrefix` + the sanitized filename. */
39
78
  export declare function ghBranchAttachmentKey(repo: string, branch: string, filename: string): string;
79
+ /**
80
+ * Structural stand-in for `ResolveGhPrefixResult` (defined in client.ts) —
81
+ * kept local so these key builders don't need to import client types just
82
+ * to own the plain-vs-private branch.
83
+ */
84
+ export type GhKeyMode = {
85
+ mode: "plain";
86
+ } | {
87
+ mode: "private";
88
+ prefixId: string;
89
+ };
90
+ /**
91
+ * Mode-owning attachment key builder: collapses the
92
+ * `mode === "private" ? ghPrivateAttachmentKey(...) : ghAttachmentKey(...)`
93
+ * ternary repeated across call sites into one place.
94
+ */
95
+ export declare function ghAttachmentKeyForMode(mode: GhKeyMode, target: GhTarget, filename: string): string;
96
+ /**
97
+ * Mode-owning branch-staged attachment key builder. The private form
98
+ * ignores `repo`/`branch` (a private-repo key has no branch-name segment,
99
+ * see `ghPrivateBranchKeyPrefix`) and uses the prefix id instead.
100
+ */
101
+ export declare function ghBranchAttachmentKeyForMode(mode: GhKeyMode, repo: string, branch: string, filename: string): string;
40
102
  /**
41
103
  * `gh.*` metadata for a branch-staged attach: `gh.repo`, `gh.kind=branch`,
42
104
  * `gh.branch` (lowercased), and `gh.staged-at` (ISO 8601 UTC, no fractional