@buildinternet/uploads 0.40.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.
@@ -164,6 +164,10 @@ export const ROOT_COMMANDS = [
164
164
  subcommands: [
165
165
  { name: "link", summary: "Claim or inspect the repo binding" },
166
166
  { name: "doctor", summary: "Check the GitHub App's webhook event subscriptions" },
167
+ {
168
+ name: "rotate-prefix",
169
+ summary: "Rotate a private repo's randomized attachment URL prefix",
170
+ },
167
171
  ],
168
172
  },
169
173
  {
package/dist/client.d.ts CHANGED
@@ -256,6 +256,41 @@ export type GithubCommentResult = {
256
256
  fixUrl?: string;
257
257
  required?: string[];
258
258
  };
259
+ /** `POST /v1/workspaces/:workspace/github/private-prefix` request (server contract, issue #631/#613). */
260
+ export interface ResolveGhPrefixOptions {
261
+ repo: string;
262
+ branch?: string;
263
+ target?: {
264
+ kind: "pull" | "issues";
265
+ num: number;
266
+ };
267
+ }
268
+ /** `POST /v1/workspaces/:workspace/github/private-prefix` response (server contract, issue #631/#613). */
269
+ export type ResolveGhPrefixResult = {
270
+ mode: "plain";
271
+ } | {
272
+ mode: "private";
273
+ prefixId: string;
274
+ activePrefixIds?: string[];
275
+ };
276
+ /** `POST /v1/workspaces/:workspace/github/private-prefix/rotate` request (server contract, issue #631/#613). */
277
+ export interface RotateGhPrefixOptions {
278
+ repo: string;
279
+ /** Mutually exclusive with `repoLevel`: rotate one branch's id. */
280
+ branch?: string;
281
+ /** Mutually exclusive with `branch`: rotate the repo-level id shared by
282
+ * issue attachments and ingested assets. */
283
+ repoLevel?: boolean;
284
+ }
285
+ /** `POST /v1/workspaces/:workspace/github/private-prefix/rotate` response (server contract, issue #631/#613). */
286
+ export type RotateGhPrefixResult = {
287
+ rotated: false;
288
+ reason: string;
289
+ } | {
290
+ rotated: true;
291
+ prefixId: string;
292
+ moved: number;
293
+ };
259
294
  /** `POST /v1/workspaces/:workspace/github/promote` request/response (server contract, PR #310). */
260
295
  export interface PromoteBranchAttachmentsOptions {
261
296
  repo: string;
@@ -567,10 +602,12 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
567
602
  /** `PATCH /v1/:workspace/files/:key` — merge `set`/`delete`; returns the merged map. */
568
603
  patchMetadata(key: string, opts: PatchMetadataOptions): Promise<GetMetadataResult>;
569
604
  /**
570
- * `GET /v1/:workspace/files?meta.<k>=<v>&…&name=…` — ANDed equality filter
571
- * over queryable metadata and/or a case-insensitive filename substring.
572
- * At least one of non-empty `filters` or `opts.name` is required.
573
- * `filters` must be pre-validated when present (see `metadata.ts`).
605
+ * `GET /v1/workspaces/:workspace/files/search?meta.<k>=<v>&…&name=…` —
606
+ * ANDed equality filter over queryable metadata and/or a case-insensitive
607
+ * filename substring. At least one of non-empty `filters` or `opts.name`
608
+ * is required. `filters` must be pre-validated when present (see
609
+ * `metadata.ts`). The canonical search route is non-paginated (server cap
610
+ * 100, narrowable via `limit`), so `cursor` is always null.
574
611
  */
575
612
  findFiles(filters?: Record<string, string>, opts?: FindFilesOptions): Promise<FindFilesResult>;
576
613
  /** `GET /v1/:workspace/files/facets` — workspace metadata key vocabulary. */
@@ -606,6 +643,26 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
606
643
  kind: "pull" | "issues";
607
644
  resync?: boolean;
608
645
  }): Promise<GithubCommentResult>;
646
+ /**
647
+ * Resolve the GitHub-key mode (plain vs. randomized private prefix, issue
648
+ * #631) a caller should stage/list attachments under for `repo`. Fail-open:
649
+ * ANY failure — a 404 from an older/self-hosted server, a network error, a
650
+ * non-2xx response, or a malformed body — resolves to `{ mode: "plain" }`
651
+ * silently (no stderr noise), never throws. Cached per-process, keyed by
652
+ * repo+branch+target, so repeated calls for the same coordinate (e.g. the
653
+ * gh-fallback comment gather re-checking on every sync) cost one request.
654
+ */
655
+ resolveGhPrefix(opts: ResolveGhPrefixOptions): Promise<ResolveGhPrefixResult>;
656
+ /**
657
+ * Rotate the active private-repo attachment prefix for `opts.repo` +
658
+ * (`opts.branch` or `opts.repoLevel`) (issue #631). Unlike
659
+ * `resolveGhPrefix`, this is NOT fail-open: it's an explicit, caller-
660
+ * initiated action, so a failure (including a 404 from an older/self-
661
+ * hosted server without this route) throws `UploadsError` — the CLI
662
+ * command decides how to present that, rather than this method silently
663
+ * degrading to a shape that would look like success.
664
+ */
665
+ rotateGhPrefix(opts: RotateGhPrefixOptions): Promise<RotateGhPrefixResult>;
609
666
  /**
610
667
  * Promote a workspace's branch-staged attachments into a PR's stable
611
668
  * attachment prefix (server contract, PR #310 — degrade-safe callers
package/dist/client.js CHANGED
@@ -186,13 +186,12 @@ export function mintWorkspaceToken(apiUrl, accessToken, input) {
186
186
  function encodeKeyPath(key) {
187
187
  return key.split("/").map(encodeURIComponent).join("/");
188
188
  }
189
- // Stays on the legacy `/v1/:workspace/files` wildcard for now (issue #613):
190
- // the canonical files vertical has no upload PUT, POST /sign, or GET/PATCH
191
- // /:key metadata yet, and its list/search adopted the session response shape
192
- // rather than the bearer one. Move this once the canonical vertical grows
193
- // those routes and the bearer list shape is reconciled.
194
- function filesBase(config) {
195
- return `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/files`;
189
+ // Canonical files surface (issue #613): every file operation now goes to
190
+ // `/v1/workspaces/:workspace/files`. Per-key ops share handlers with the
191
+ // legacy wildcard (#636); list/find/facets adapt the canonical envelopes in
192
+ // their wrappers below (#613 shape reconciliation).
193
+ function canonicalFilesBase(config) {
194
+ return `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/files`;
196
195
  }
197
196
  function usageBase(config) {
198
197
  return `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/usage`;
@@ -311,18 +310,30 @@ export function createUploadsClient(config) {
311
310
  if (opts.metadata)
312
311
  params.set("metadata", "1");
313
312
  const qs = params.toString();
314
- const page = await request("GET", `${filesBase(config)}${qs ? `?${qs}` : ""}`);
313
+ // Canonical list envelope is `{files, prefixes, cursor}` with queryable
314
+ // metadata always hydrated (issue #613 — the session shape won the
315
+ // reconciliation). This client keeps its historical `{items, cursor}`
316
+ // contract: rename the array and honor `opts.metadata` by stripping the
317
+ // hydrated maps when the caller didn't ask for them.
318
+ const page = await request("GET", `${canonicalFilesBase(config)}${qs ? `?${qs}` : ""}`);
315
319
  return {
316
- ...page,
317
- items: page.items.map((item) => ({
318
- ...item,
319
- embedUrl: resolveEmbedUrl(item.url, item.embedUrl),
320
- })),
320
+ cursor: page.cursor,
321
+ items: page.files.map((item) => {
322
+ const { metadata, ...rest } = item;
323
+ return {
324
+ ...rest,
325
+ ...(opts.metadata && metadata !== undefined ? { metadata } : {}),
326
+ embedUrl: resolveEmbedUrl(item.url, item.embedUrl),
327
+ };
328
+ }),
321
329
  };
322
330
  }
323
331
  async function getGallery(id) {
324
332
  return request("GET", `${galleriesBase(config)}/${encodeURIComponent(id)}`);
325
333
  }
334
+ // Per-process cache for resolveGhPrefix, keyed by repo+branch+target — see
335
+ // that method's doc.
336
+ const resolveGhPrefixCache = new Map();
326
337
  return {
327
338
  async put(body, opts) {
328
339
  const key = opts.key ??
@@ -337,7 +348,7 @@ export function createUploadsClient(config) {
337
348
  const contentType = opts.contentType ?? inferContentType(opts.filename);
338
349
  if (opts.dryRun) {
339
350
  const qs = opts.replace ? "dryRun=1&replace=1" : "dryRun=1";
340
- const preview = await request("PUT", `${filesBase(config)}/${encodeKeyPath(key)}?${qs}`);
351
+ const preview = await request("PUT", `${canonicalFilesBase(config)}/${encodeKeyPath(key)}?${qs}`);
341
352
  if (preview.url == null) {
342
353
  throw new UploadsError("workspace has no publicBaseUrl (cannot resolve a public URL)", "NO_PUBLIC_URL");
343
354
  }
@@ -369,7 +380,7 @@ export function createUploadsClient(config) {
369
380
  headers[`X-Uploads-Meta-${k}`] = v;
370
381
  }
371
382
  }
372
- const result = await request("PUT", `${filesBase(config)}/${encodeKeyPath(key)}`, {
383
+ const result = await request("PUT", `${canonicalFilesBase(config)}/${encodeKeyPath(key)}`, {
373
384
  body,
374
385
  headers,
375
386
  });
@@ -396,24 +407,26 @@ export function createUploadsClient(config) {
396
407
  return items;
397
408
  },
398
409
  async delete(key) {
399
- return request("DELETE", `${filesBase(config)}/${encodeKeyPath(key)}`);
410
+ return request("DELETE", `${canonicalFilesBase(config)}/${encodeKeyPath(key)}`);
400
411
  },
401
412
  /** `GET /v1/:workspace/files/:key?metadata=1` — the object's queryable metadata. */
402
413
  async getMetadata(key) {
403
- return request("GET", `${filesBase(config)}/${encodeKeyPath(key)}?metadata=1`);
414
+ return request("GET", `${canonicalFilesBase(config)}/${encodeKeyPath(key)}?metadata=1`);
404
415
  },
405
416
  /** `PATCH /v1/:workspace/files/:key` — merge `set`/`delete`; returns the merged map. */
406
417
  async patchMetadata(key, opts) {
407
- return request("PATCH", `${filesBase(config)}/${encodeKeyPath(key)}`, {
418
+ return request("PATCH", `${canonicalFilesBase(config)}/${encodeKeyPath(key)}`, {
408
419
  body: new TextEncoder().encode(JSON.stringify(opts)),
409
420
  headers: { "Content-Type": "application/json" },
410
421
  });
411
422
  },
412
423
  /**
413
- * `GET /v1/:workspace/files?meta.<k>=<v>&…&name=…` — ANDed equality filter
414
- * over queryable metadata and/or a case-insensitive filename substring.
415
- * At least one of non-empty `filters` or `opts.name` is required.
416
- * `filters` must be pre-validated when present (see `metadata.ts`).
424
+ * `GET /v1/workspaces/:workspace/files/search?meta.<k>=<v>&…&name=…` —
425
+ * ANDed equality filter over queryable metadata and/or a case-insensitive
426
+ * filename substring. At least one of non-empty `filters` or `opts.name`
427
+ * is required. `filters` must be pre-validated when present (see
428
+ * `metadata.ts`). The canonical search route is non-paginated (server cap
429
+ * 100, narrowable via `limit`), so `cursor` is always null.
417
430
  */
418
431
  async findFiles(filters = {}, opts = {}) {
419
432
  const params = new URLSearchParams();
@@ -425,18 +438,19 @@ export function createUploadsClient(config) {
425
438
  params.set("prefix", opts.prefix);
426
439
  if (opts.limit != null)
427
440
  params.set("limit", String(opts.limit));
428
- return request("GET", `${filesBase(config)}?${params.toString()}`);
441
+ const result = await request("GET", `${canonicalFilesBase(config)}/search?${params.toString()}`);
442
+ return { items: result.items, cursor: null, truncated: result.truncated };
429
443
  },
430
444
  /** `GET /v1/:workspace/files/facets` — workspace metadata key vocabulary. */
431
445
  async listMetadataKeys() {
432
- return request("GET", `${filesBase(config)}/facets`);
446
+ return request("GET", `${canonicalFilesBase(config)}/facets`);
433
447
  },
434
448
  /** `GET /v1/:workspace/files/facets?key=` — distinct values for one key. */
435
449
  async listMetadataValues(key) {
436
- return request("GET", `${filesBase(config)}/facets?${new URLSearchParams({ key })}`);
450
+ return request("GET", `${canonicalFilesBase(config)}/facets?${new URLSearchParams({ key })}`);
437
451
  },
438
452
  async head(key) {
439
- const result = await request("GET", `${filesBase(config)}/${encodeKeyPath(key)}`);
453
+ const result = await request("GET", `${canonicalFilesBase(config)}/${encodeKeyPath(key)}`);
440
454
  return { ...result, embedUrl: resolveEmbedUrl(result.url, result.embedUrl) };
441
455
  },
442
456
  async createGallery(opts) {
@@ -509,6 +523,53 @@ export function createUploadsClient(config) {
509
523
  headers: { "Content-Type": "application/json" },
510
524
  });
511
525
  },
526
+ /**
527
+ * Resolve the GitHub-key mode (plain vs. randomized private prefix, issue
528
+ * #631) a caller should stage/list attachments under for `repo`. Fail-open:
529
+ * ANY failure — a 404 from an older/self-hosted server, a network error, a
530
+ * non-2xx response, or a malformed body — resolves to `{ mode: "plain" }`
531
+ * silently (no stderr noise), never throws. Cached per-process, keyed by
532
+ * repo+branch+target, so repeated calls for the same coordinate (e.g. the
533
+ * gh-fallback comment gather re-checking on every sync) cost one request.
534
+ */
535
+ async resolveGhPrefix(opts) {
536
+ const cacheKey = JSON.stringify([
537
+ opts.repo.toLowerCase(),
538
+ opts.branch ?? "",
539
+ opts.target ?? null,
540
+ ]);
541
+ const cached = resolveGhPrefixCache.get(cacheKey);
542
+ if (cached)
543
+ return cached;
544
+ const resolved = await (async () => {
545
+ try {
546
+ return await request("POST", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/private-prefix`, {
547
+ body: new TextEncoder().encode(JSON.stringify(opts)),
548
+ headers: { "Content-Type": "application/json" },
549
+ });
550
+ }
551
+ catch {
552
+ return { mode: "plain" };
553
+ }
554
+ })();
555
+ resolveGhPrefixCache.set(cacheKey, resolved);
556
+ return resolved;
557
+ },
558
+ /**
559
+ * Rotate the active private-repo attachment prefix for `opts.repo` +
560
+ * (`opts.branch` or `opts.repoLevel`) (issue #631). Unlike
561
+ * `resolveGhPrefix`, this is NOT fail-open: it's an explicit, caller-
562
+ * initiated action, so a failure (including a 404 from an older/self-
563
+ * hosted server without this route) throws `UploadsError` — the CLI
564
+ * command decides how to present that, rather than this method silently
565
+ * degrading to a shape that would look like success.
566
+ */
567
+ async rotateGhPrefix(opts) {
568
+ return request("POST", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/private-prefix/rotate`, {
569
+ body: new TextEncoder().encode(JSON.stringify(opts)),
570
+ headers: { "Content-Type": "application/json" },
571
+ });
572
+ },
512
573
  /**
513
574
  * Promote a workspace's branch-staged attachments into a PR's stable
514
575
  * attachment prefix (server contract, PR #310 — degrade-safe callers
@@ -2,12 +2,11 @@ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
3
  import { extractDashValue, flagBool, flagInt, flagString, flagValues, parseCommandArgs, UsageError, } from "../cli-args.js";
4
4
  import { writeCommandHelp } from "../cli-style.js";
5
- import { branchFromFlags, derivedMetaEnabled, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, stateAppMetaFromFlags, warnNearMissMeta, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, resolvePutStagingTarget, putStagingNoteText, resolveStageBindingWarning, mergeStagingMeta, writeReplacedNote, } from "../commands.js";
5
+ import { branchFromFlags, derivedMetaEnabled, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, stateAppMetaFromFlags, warnNearMissMeta, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, resolvePutStagingTarget, putStagingNoteText, resolveStageBindingWarning, mergeStagingMeta, writeReplacedNote, resolveGhPrefixSafe, } from "../commands.js";
6
6
  import { resolvePutDefaults } from "../config.js";
7
7
  import { loadDefaultsRaw, resolveScreenshotDefaults } from "../config-file.js";
8
8
  import { resolvePutPrefix } from "../destinations.js";
9
9
  import { execRunner, ghMetadataFromTargetWithTitle, resolveRepo, } from "../github-gh.js";
10
- import { ghBranchAttachmentKey } from "../github.js";
11
10
  import { deriveRepoSlugFromGit } from "../keys.js";
12
11
  import { safeCaptureFacts } from "../capture-facts.js";
13
12
  import { parseMetaFlags, validateMetaMap } from "../metadata.js";
@@ -448,15 +447,28 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
448
447
  }
449
448
  const repo = flagString(parsed.flags, "--repo") ?? putDefaults.repo;
450
449
  const ref = flagString(parsed.flags, "--ref") ?? putDefaults.ref;
451
- const branchKey = stagingTarget !== undefined
452
- ? ghBranchAttachmentKey(stagingTarget.repo, stagingTarget.branch, captured.filename)
453
- : undefined;
450
+ // Resolved once (issue #631), only when it's actually needed for the
451
+ // upload about to happen (never for the noUpload/no-target bailouts
452
+ // above) — never per file (screenshot only ever uploads one).
453
+ const ghPrefix = ghTarget
454
+ ? await resolveGhPrefixSafe(ctx.client, {
455
+ repo: ghTarget.repo,
456
+ target: { kind: ghTarget.kind, num: ghTarget.num },
457
+ })
458
+ : stagingTarget !== undefined
459
+ ? await resolveGhPrefixSafe(ctx.client, {
460
+ repo: stagingTarget.repo,
461
+ branch: stagingTarget.branch,
462
+ })
463
+ : undefined;
454
464
  const alt = altFlag ?? basename(captured.filename);
455
465
  const { result, prepared, markdown } = await uploadPreparedImage(ctx.client, finalPng, captured.filename, {
456
466
  frame: frameOpts,
457
467
  optimize: optimizeOpts,
458
468
  ghTarget,
459
- key: keyHint ?? branchKey,
469
+ ghBranchTarget: stagingTarget,
470
+ ghPrefix,
471
+ key: keyHint,
460
472
  prefix: resolvedPrefix ?? putDefaults.prefix,
461
473
  repo,
462
474
  ref,
@@ -1,4 +1,4 @@
1
- import { type PutResult, type UploadsClient } from "./client.js";
1
+ import { type PutResult, type ResolveGhPrefixOptions, type ResolveGhPrefixResult, type UploadsClient } from "./client.js";
2
2
  import { type CommandFlags } from "./cli-args.js";
3
3
  import { type ResolvedConfig } from "./config.js";
4
4
  import { type GhTarget } from "./github.js";
@@ -11,6 +11,42 @@ import type { DetectRoots } from "./screenshot-local.js";
11
11
  export declare const UPLOAD_BATCH_CONCURRENCY = 8;
12
12
  /** @deprecated Use UPLOAD_BATCH_CONCURRENCY. */
13
13
  export declare const ATTACH_CONCURRENCY = 8;
14
+ /**
15
+ * Fail-open wrapper around `client.resolveGhPrefix` (issue #631): resolves to
16
+ * `{ mode: "plain" }` on ANY failure — an HTTP/network error (already handled
17
+ * inside `resolveGhPrefix` itself), or a self-hosted/older server or test
18
+ * double that lacks the method entirely (the outer try/catch here). Never
19
+ * blocks an upload or a read-back, and never logs — this is not an error.
20
+ * Call once per command invocation and thread the resolved mode through
21
+ * (uploadPuts/uploadAttachments/uploadBranchAttachments each do this once
22
+ * internally, ahead of their per-file loop); `resolveGhPrefix` itself also
23
+ * caches per-process by repo+branch+target, so repeat callers in the same
24
+ * process (e.g. attach's promote + comment-sync + upload, all for the same
25
+ * target) cost one request total.
26
+ */
27
+ export declare function resolveGhPrefixSafe(client: UploadsClient, opts: ResolveGhPrefixOptions): Promise<ResolveGhPrefixResult>;
28
+ /**
29
+ * The list of prefixes to fan a multi-prefix list/gather across (issue
30
+ * #631): the plain prefix plus every active private prefix, if any — a
31
+ * repo's history can be split across the plain shape and MULTIPLE private
32
+ * prefixes (e.g. a prefix rotation, or the repo went private after some
33
+ * files were uploaded), not just the currently-resolved one. Falls back to
34
+ * `[prefixId]` when the server omits `activePrefixIds` (optional field — an
35
+ * older/self-hosted worker), so a private repo is never listed as zero
36
+ * private prefixes. Collapses to `[plainPrefix]` in plain mode, so callers
37
+ * that special-case a single-prefix array stay byte-identical to pre-#631.
38
+ */
39
+ export declare function ghListPrefixes(plainPrefix: string, ghPrefix: ResolveGhPrefixResult, privatePrefixFor: (prefixId: string) => string): string[];
40
+ /**
41
+ * Merge-list helper for a multi-prefix fan-out: runs `fetchItems` per prefix
42
+ * concurrently and concatenates in prefix order. Encodes the first-prefix-
43
+ * only cursor rule once — a cursor is opaque and scoped to the prefix it was
44
+ * minted against, so a multi-prefix merge only ever hands it to the FIRST
45
+ * prefix; every other prefix always starts from its own beginning (undefined
46
+ * cursor), or a cursor minted for one prefix's keyspace would get replayed
47
+ * against a different one.
48
+ */
49
+ export declare function ghMergedList<T>(prefixes: readonly string[], cursor: string | undefined, fetchItems: (prefix: string, cursor: string | undefined) => Promise<T[]>): Promise<T[]>;
14
50
  export { formatUsageHuman } from "./format-usage.js";
15
51
  export interface CliContext {
16
52
  config: ResolvedConfig;
@@ -90,6 +126,14 @@ export interface UploadPreparedImageOptions {
90
126
  * --branch` for the same filename via `ghBranchAttachmentKey`.
91
127
  */
92
128
  ghBranchTarget?: BranchTarget;
129
+ /**
130
+ * Resolved GitHub-key mode (issue #631), from a single upstream
131
+ * `resolveGhPrefixSafe` call — never resolved here. Passed straight to
132
+ * `ghAttachmentKeyForMode`/`ghBranchAttachmentKeyForMode`, which own the
133
+ * plain-vs-private branch. Ignored when neither `ghTarget` nor
134
+ * `ghBranchTarget` is set.
135
+ */
136
+ ghPrefix?: ResolveGhPrefixResult;
93
137
  key?: string;
94
138
  prefix?: string;
95
139
  repo?: string;
package/dist/commands.js CHANGED
@@ -13,7 +13,7 @@ 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
18
  import { deriveRepoFromGit, deriveRepoSlugFromGit } from "./keys.js";
19
19
  import { resolvePutPrefix } from "./destinations.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,
@@ -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>]
@@ -2236,16 +2332,31 @@ export async function runList(ctx, args, help = false, run = execRunner) {
2236
2332
  const prefixFlag = flagString(parsed.flags, "--prefix");
2237
2333
  let prefix = prefixFlag ?? (defaults.prefix ? `${defaults.prefix}/` : undefined);
2238
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;
2239
2343
  if (ghTarget) {
2240
2344
  if (prefixFlag)
2241
2345
  throw new UsageError("--prefix cannot be combined with --pr/--issue");
2242
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));
2243
2352
  }
2244
2353
  const limit = flagInt(parsed.flags, "--limit", "--limit");
2245
2354
  const cursor = flagString(parsed.flags, "--cursor");
2246
2355
  if (flagBool(parsed.flags, "--all")) {
2247
2356
  // --all may start from a caller-provided --cursor and drains from there.
2248
- 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 });
2249
2360
  if (ctx.json)
2250
2361
  await writeJson({ items, cursor: null });
2251
2362
  else
@@ -2253,7 +2364,16 @@ export async function runList(ctx, args, help = false, run = execRunner) {
2253
2364
  await writeStdout(`${item.key}${item.url ? ` ${item.url}` : ""}\n`);
2254
2365
  return 0;
2255
2366
  }
2256
- 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 });
2257
2377
  if (ctx.json)
2258
2378
  await writeJson(result);
2259
2379
  else {
@@ -2451,10 +2571,37 @@ const COMMENT_RENDERED_META_KEYS = ["path", "state"];
2451
2571
  * metadata tweak, not an explicit comment command); any failure degrades to
2452
2572
  * a stderr hint instead of failing the metadata write that already landed.
2453
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
+ }
2454
2601
  async function resyncCommentAfterMetaSet(ctx, key, touchedKeys) {
2455
2602
  if (!touchedKeys.some((k) => COMMENT_RENDERED_META_KEYS.includes(k)))
2456
2603
  return;
2457
- const target = parseGhKey(key);
2604
+ const target = await resolveGhTargetForResync(ctx.client, key);
2458
2605
  if (!target)
2459
2606
  return;
2460
2607
  try {
@@ -2614,6 +2761,7 @@ export async function runIngest(ctx, args, help = false, run = execRunner) {
2614
2761
  const GITHUB_HELP = `uploads github link [--repo <owner/name>] [--status] [--workspace <name>]
2615
2762
  uploads github unlink [--repo <owner/name>] [--workspace <name>]
2616
2763
  uploads github doctor [--workspace <name>]
2764
+ uploads github rotate-prefix [--repo <owner/name>] [--branch <name> | --repo-level] [--workspace <name>]
2617
2765
 
2618
2766
  Claim, inspect, or release this workspace's binding to a GitHub repo (see the
2619
2767
  managed attachments comment / webhook auto-promotion, which use this
@@ -2635,12 +2783,22 @@ subscription is the classic silent failure: the App's ping stays green
2635
2783
  while webhook auto-promotion and title-cache invalidation quietly do
2636
2784
  nothing.
2637
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
+
2638
2794
  Examples:
2639
2795
  uploads github link
2640
2796
  uploads github link --repo buildinternet/uploads
2641
2797
  uploads github link --status
2642
2798
  uploads github unlink --repo buildinternet/uploads
2643
2799
  uploads github doctor
2800
+ uploads github rotate-prefix --branch feature-x
2801
+ uploads github rotate-prefix --repo-level
2644
2802
  `;
2645
2803
  /** Older servers' health payload predates recommendedEvents/missingRecommendedEvents — treat as no recommendations rather than crashing. */
2646
2804
  function missingRecommendedEventsOf(result) {
@@ -2760,6 +2918,34 @@ async function runGithubUnlink(ctx, repo) {
2760
2918
  : `${repo} was not bound to any workspace — nothing to unlink\n`);
2761
2919
  return 0;
2762
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
+ }
2763
2949
  export async function runGithub(ctx, args, help = false, run = execRunner) {
2764
2950
  const parsed = parseCommandArgs(args);
2765
2951
  const action = parsed.positionals[0];
@@ -2767,14 +2953,24 @@ export async function runGithub(ctx, args, help = false, run = execRunner) {
2767
2953
  writeCommandHelp(GITHUB_HELP);
2768
2954
  return help || parsed.help ? 0 : 2;
2769
2955
  }
2770
- if (action !== "link" && action !== "unlink" && action !== "doctor") {
2771
- throw new UsageError(`unknown github subcommand: ${action} (expected link or doctor)`, {
2772
- example: "uploads github link",
2773
- });
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" });
2774
2961
  }
2775
2962
  if (action === "doctor")
2776
2963
  return runGithubDoctor(ctx);
2777
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
+ }
2778
2974
  if (action === "unlink")
2779
2975
  return runGithubUnlink(ctx, repo);
2780
2976
  const statusOnly = flagBool(parsed.flags, "--status");
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
package/dist/github.js CHANGED
@@ -57,14 +57,78 @@ export function normalizeGithubCoordinate(value) {
57
57
  * Inverse of `ghKeyPrefix`: parse the PR/issue coordinate back out of a
58
58
  * stable attachment key (`gh/<owner>/<name>/<kind>/<num>/<filename>`), or
59
59
  * undefined for any other key shape.
60
+ *
61
+ * A real GitHub owner CAN be named `private`, so the strict private-repo
62
+ * shape (`gh/private/<32-hex-id>/...`, see `parseGhPrivateKey`) is checked
63
+ * first and rejected here — otherwise a private-prefixed key would
64
+ * misparse as an ordinary key with owner "private". The accepted ambiguity:
65
+ * a key whose second segment is NOT 32-lowercase-hex (e.g.
66
+ * `gh/private/realrepo/pull/5/x.png`) still parses here as owner "private",
67
+ * repo "private/realrepo" — that's an ordinary public-repo key for a repo
68
+ * actually named "private", not a private-prefix key.
60
69
  */
61
70
  export function parseGhKey(key) {
71
+ if (parseGhPrivateKey(key))
72
+ return undefined;
62
73
  const match = /^gh\/([^/]+)\/([^/]+)\/(pull|issues)\/([1-9][0-9]*)\/./.exec(key);
63
74
  if (!match)
64
75
  return undefined;
65
76
  const [, owner, name, kind, num] = match;
66
77
  return { repo: `${owner}/${name}`, kind: kind, num: Number(num) };
67
78
  }
79
+ /** Literal root under which every private-repo attachment key lives. */
80
+ export const GH_PRIVATE_ROOT = "gh/private/";
81
+ /** Strict shape for a randomized private-repo prefix id: 32 lowercase hex chars. */
82
+ const PRIVATE_PREFIX_ID_RE = /^[0-9a-f]{32}$/;
83
+ /**
84
+ * Guard every private-key builder against a malformed `prefixId` — a
85
+ * caller bug here must never silently produce a guessable-ish key.
86
+ */
87
+ function assertPrivatePrefixId(prefixId) {
88
+ if (!PRIVATE_PREFIX_ID_RE.test(prefixId)) {
89
+ throw new Error(`invalid private prefix id: "${prefixId}" must be 32 lowercase hex characters`);
90
+ }
91
+ }
92
+ /**
93
+ * Private-repo key prefix: `gh/private/<32-hex-id>/<kind>/<num>/`.
94
+ * Deliberately omits the repo (unlike `ghKeyPrefix`) — the id is a random,
95
+ * unguessable per-repo prefix rather than an owner/name path, so callers
96
+ * that need the repo back must read `gh.repo` metadata (see
97
+ * `parseGhPrivateKey`, which cannot recover it from the key alone).
98
+ */
99
+ export function ghPrivateKeyPrefix(prefixId, target) {
100
+ assertPrivatePrefixId(prefixId);
101
+ return `${GH_PRIVATE_ROOT}${prefixId}/${target.kind}/${target.num}/`;
102
+ }
103
+ /** Private-repo attachment key: `ghPrivateKeyPrefix` + the sanitized filename. */
104
+ export function ghPrivateAttachmentKey(prefixId, target, filename) {
105
+ return `${ghPrivateKeyPrefix(prefixId, target)}${sanitizeKeySegment(filename)}`;
106
+ }
107
+ /**
108
+ * Private-repo branch-staged key prefix: `gh/private/<32-hex-id>/branch/`.
109
+ * Unlike `ghBranchKeyPrefix`, there is deliberately NO branch-name segment —
110
+ * the branch name itself is not embedded in a private-repo key.
111
+ */
112
+ export function ghPrivateBranchKeyPrefix(prefixId) {
113
+ assertPrivatePrefixId(prefixId);
114
+ return `${GH_PRIVATE_ROOT}${prefixId}/branch/`;
115
+ }
116
+ /** Private-repo branch-staged attachment key: `ghPrivateBranchKeyPrefix` + the sanitized filename. */
117
+ export function ghPrivateBranchAttachmentKey(prefixId, filename) {
118
+ return `${ghPrivateBranchKeyPrefix(prefixId)}${sanitizeKeySegment(filename)}`;
119
+ }
120
+ /**
121
+ * Inverse of `ghPrivateKeyPrefix`: parse the prefix id/kind/number back out
122
+ * of a private-repo attachment key, or undefined for any other key shape.
123
+ * Cannot recover the repo — callers that need it read `gh.repo` metadata.
124
+ */
125
+ export function parseGhPrivateKey(key) {
126
+ const match = /^gh\/private\/([0-9a-f]{32})\/(pull|issues)\/([1-9][0-9]*)\/./.exec(key);
127
+ if (!match)
128
+ return undefined;
129
+ const [, prefixId, kind, num] = match;
130
+ return { prefixId, kind: kind, num: Number(num) };
131
+ }
68
132
  export function ghKeyPrefix(target) {
69
133
  const [owner, name] = target.repo.split("/");
70
134
  return `gh/${sanitizeKeySegment(owner)}/${sanitizeKeySegment(name)}/${target.kind}/${target.num}/`;
@@ -91,6 +155,26 @@ export function ghBranchKeyPrefix(repo, branch) {
91
155
  export function ghBranchAttachmentKey(repo, branch, filename) {
92
156
  return `${ghBranchKeyPrefix(repo, branch)}${sanitizeKeySegment(filename)}`;
93
157
  }
158
+ /**
159
+ * Mode-owning attachment key builder: collapses the
160
+ * `mode === "private" ? ghPrivateAttachmentKey(...) : ghAttachmentKey(...)`
161
+ * ternary repeated across call sites into one place.
162
+ */
163
+ export function ghAttachmentKeyForMode(mode, target, filename) {
164
+ return mode.mode === "private"
165
+ ? ghPrivateAttachmentKey(mode.prefixId, target, filename)
166
+ : ghAttachmentKey(target, filename);
167
+ }
168
+ /**
169
+ * Mode-owning branch-staged attachment key builder. The private form
170
+ * ignores `repo`/`branch` (a private-repo key has no branch-name segment,
171
+ * see `ghPrivateBranchKeyPrefix`) and uses the prefix id instead.
172
+ */
173
+ export function ghBranchAttachmentKeyForMode(mode, repo, branch, filename) {
174
+ return mode.mode === "private"
175
+ ? ghPrivateBranchAttachmentKey(mode.prefixId, filename)
176
+ : ghBranchAttachmentKey(repo, branch, filename);
177
+ }
94
178
  /**
95
179
  * `gh.*` metadata for a branch-staged attach: `gh.repo`, `gh.kind=branch`,
96
180
  * `gh.branch` (lowercased), and `gh.staged-at` (ISO 8601 UTC, no fractional
package/dist/index.d.ts CHANGED
@@ -4,10 +4,10 @@ export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey
4
4
  export { BUILTIN_DESTINATIONS, isBuiltinDestination, keyMatchesDestination, resolveDestinationRoot, resolvePutPrefix, type BuiltinDestinationId, } from "./destinations.js";
5
5
  export { DEFAULT_API_URL, DEFAULT_WORKSPACE, UPLOADS_CONFIG_KEYS, defaultConfigPath, resolveConfigPath, loadConfigFile, loadEnvFile, resolveApiUrl, resolveConfig, describeConfigSources, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, workspaceFromToken, workspaceMismatch, type UploadsClientConfig, type ResolvedConfig, type WorkspaceSource, type ConfigValueSource, type ConfigSources, type UploadsConfigKey, type UploadsConfigValues, type PutDefaults, } from "./config.js";
6
6
  export { UploadsError, type UploadsErrorCode } from "./errors.js";
7
- export { createUploadsClient, type UploadsClient, type PutOptions, type ProvenanceInput, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type GalleryItem, type Gallery, type GallerySummary, type GalleryListOptions, type GalleryListResult, type CreateGalleryOptions, type AddGalleryItemOptions, type DeleteGalleryOptions, type HealthResult, type UsageResult, type ReconcileResult, type PurgeExpiredResult, type PurgeExpiredResponse, type FindFilesOptions, type FindFilesItem, type FindFilesResult, type MetadataKeysResult, type MetadataValuesResult, type GetMetadataResult, type PatchMetadataOptions, } from "./client.js";
7
+ export { createUploadsClient, type UploadsClient, type PutOptions, type ProvenanceInput, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type GalleryItem, type Gallery, type GallerySummary, type GalleryListOptions, type GalleryListResult, type CreateGalleryOptions, type AddGalleryItemOptions, type DeleteGalleryOptions, type HealthResult, type UsageResult, type ReconcileResult, type PurgeExpiredResult, type PurgeExpiredResponse, type FindFilesOptions, type FindFilesItem, type FindFilesResult, type MetadataKeysResult, type MetadataValuesResult, type GetMetadataResult, type PatchMetadataOptions, type ResolveGhPrefixOptions, type ResolveGhPrefixResult, } from "./client.js";
8
8
  export { buildCliProvenance } from "./provenance.js";
9
9
  export { META_KEY_RE, META_VALUE_MAX, META_MAX_KEYS, META_MAX_TOTAL_BYTES, validateMetaEntry, parseMetaPair, parseMetaFlags, } from "./metadata.js";
10
- export { ATTACHMENTS_MARKER, attachmentsCommentBody, GH_FALLBACK_AUTHOR_NOTE, ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghBranchAttachmentKey, ghBranchKeyPrefix, ghMetadataForBranch, isValidRepo, parseRepoFromRemoteUrl, type AttachmentItem, type GhTarget, type GhTargetKind, } from "./github.js";
10
+ export { ATTACHMENTS_MARKER, attachmentsCommentBody, GH_FALLBACK_AUTHOR_NOTE, ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghBranchAttachmentKey, ghBranchKeyPrefix, ghMetadataForBranch, isValidRepo, parseRepoFromRemoteUrl, GH_PRIVATE_ROOT, ghPrivateKeyPrefix, ghPrivateAttachmentKey, ghPrivateBranchKeyPrefix, ghPrivateBranchAttachmentKey, ghAttachmentKeyForMode, ghBranchAttachmentKeyForMode, parseGhKey, parseGhPrivateKey, type AttachmentItem, type GhTarget, type GhTargetKind, type GhKeyMode, } from "./github.js";
11
11
  export { DEFAULT_OPTIMIZE_MAX_EDGE, DEFAULT_OPTIMIZE_QUALITY, optimizeImageForUpload, rewriteKeyExtension, withImageExtension, type OptimizeImageOptions, type OptimizeImageResult, type OptimizeOutputFormat, } from "./optimize.js";
12
12
  export { FRAME_PRESETS, applyFrame, listFramePresets, resolveFrameId, type FrameFit, type FrameOptions, type FrameResult, } from "./frame.js";
13
13
  export { execRunner, resolveRepo, upsertAttachmentsComment, type CommandRunner, } from "./github-gh.js";
package/dist/index.js CHANGED
@@ -7,7 +7,11 @@ export { UploadsError } from "./errors.js";
7
7
  export { createUploadsClient, } from "./client.js";
8
8
  export { buildCliProvenance } from "./provenance.js";
9
9
  export { META_KEY_RE, META_VALUE_MAX, META_MAX_KEYS, META_MAX_TOTAL_BYTES, validateMetaEntry, parseMetaPair, parseMetaFlags, } from "./metadata.js";
10
- export { ATTACHMENTS_MARKER, attachmentsCommentBody, GH_FALLBACK_AUTHOR_NOTE, ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghBranchAttachmentKey, ghBranchKeyPrefix, ghMetadataForBranch, isValidRepo, parseRepoFromRemoteUrl, } from "./github.js";
10
+ export { ATTACHMENTS_MARKER, attachmentsCommentBody, GH_FALLBACK_AUTHOR_NOTE, ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghBranchAttachmentKey, ghBranchKeyPrefix, ghMetadataForBranch, isValidRepo, parseRepoFromRemoteUrl,
11
+ // Private-repo randomized-prefix builders (issue #631) — needed by the
12
+ // hosted MCP (apps/mcp), which builds keys in-process rather than via
13
+ // the CLI's own commands.ts.
14
+ GH_PRIVATE_ROOT, ghPrivateKeyPrefix, ghPrivateAttachmentKey, ghPrivateBranchKeyPrefix, ghPrivateBranchAttachmentKey, ghAttachmentKeyForMode, ghBranchAttachmentKeyForMode, parseGhKey, parseGhPrivateKey, } from "./github.js";
11
15
  export { DEFAULT_OPTIMIZE_MAX_EDGE, DEFAULT_OPTIMIZE_QUALITY, optimizeImageForUpload, rewriteKeyExtension, withImageExtension, } from "./optimize.js";
12
16
  export { FRAME_PRESETS, applyFrame, listFramePresets, resolveFrameId, } from "./frame.js";
13
17
  export { execRunner, resolveRepo, upsertAttachmentsComment, } from "./github-gh.js";
package/dist/mcp/tools.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { createUploadsClient } from "../client.js";
2
- import { buildDoctorReport, makeGhTarget, mergeStagingMeta, resolvePutStagingTarget, resolveStaged, syncAttachmentsComment, uploadAttachments, uploadPreparedImage, uploadPuts, } from "../commands.js";
2
+ import { buildDoctorReport, ghListPrefixes, ghMergedList, makeGhTarget, mergeStagingMeta, resolveGhPrefixSafe, resolvePutStagingTarget, resolveStaged, syncAttachmentsComment, uploadAttachments, uploadPreparedImage, uploadPuts, } from "../commands.js";
3
3
  import { resolveFrameId } from "../frame.js";
4
4
  import { resolveConfig, resolvePutDefaults, } from "../config.js";
5
5
  import { resolvePutPrefix } from "../destinations.js";
6
- import { ghBranchAttachmentKey, ghKeyPrefix } from "../github.js";
6
+ import { ghKeyPrefix, ghPrivateKeyPrefix } from "../github.js";
7
7
  import { safeCaptureFacts } from "../capture-facts.js";
8
8
  import { deriveRepoSlugFromGit } from "../keys.js";
9
9
  import { validateMetaMap } from "../metadata.js";
@@ -801,14 +801,26 @@ export function createUploadsMcpTools(opts) {
801
801
  }
802
802
  throw err;
803
803
  }
804
- const branchKey = stagingTarget
805
- ? ghBranchAttachmentKey(stagingTarget.repo, stagingTarget.branch, captured.filename)
806
- : undefined;
804
+ // Resolved once (issue #631), only now that upload is actually
805
+ // about to happen — never per file (screenshot uploads exactly one).
806
+ const ghPrefix = target
807
+ ? await resolveGhPrefixSafe(client, {
808
+ repo: target.repo,
809
+ target: { kind: target.kind, num: target.num },
810
+ })
811
+ : stagingTarget
812
+ ? await resolveGhPrefixSafe(client, {
813
+ repo: stagingTarget.repo,
814
+ branch: stagingTarget.branch,
815
+ })
816
+ : undefined;
807
817
  const { result, prepared, markdown } = await uploadPreparedImage(client, captured.png, captured.filename, {
808
818
  frame: frameOpts,
809
819
  optimize: optimizeOpts,
810
820
  ghTarget: target,
811
- key: keyArg ?? branchKey,
821
+ ghBranchTarget: stagingTarget,
822
+ ghPrefix,
823
+ key: keyArg,
812
824
  prefix: resolvedPrefix ?? defaults.prefix,
813
825
  repo: optString(args, "repo") ?? defaults.repo,
814
826
  ref: refArg ?? defaults.ref,
@@ -980,16 +992,35 @@ export function createUploadsMcpTools(opts) {
980
992
  const prefixArg = optString(args, "prefix");
981
993
  let prefix = prefixArg ?? (defaults.prefix ? `${defaults.prefix}/` : undefined);
982
994
  const target = ghTargetFromArgs(args, run);
995
+ const { client } = clientFor(args);
996
+ // Also list every active private prefix, if any (issue #631) —
997
+ // mirrors syncAttachmentsComment's gh-fallback gather: a repo's
998
+ // attachment history can be split across the plain shape and
999
+ // MULTIPLE private prefixes, not just the currently-resolved one.
1000
+ // `prefixes` stays undefined outside pr/issue (unchanged behavior);
1001
+ // collapses to `[prefix]` in plain mode, so the single-request path
1002
+ // below is byte-identical to pre-#631.
1003
+ let prefixes;
983
1004
  if (target) {
984
1005
  if (prefixArg)
985
1006
  usage("prefix cannot be combined with pr/issue");
986
1007
  prefix = ghKeyPrefix(target);
1008
+ const ghPrefix = await resolveGhPrefixSafe(client, {
1009
+ repo: target.repo,
1010
+ target: { kind: target.kind, num: target.num },
1011
+ });
1012
+ prefixes = ghListPrefixes(prefix, ghPrefix, (id) => ghPrivateKeyPrefix(id, target));
987
1013
  }
988
1014
  const limit = optPosInt(args, "limit");
989
1015
  const cursor = optString(args, "cursor");
990
- const { client } = clientFor(args);
991
1016
  if (optBool(args, "all")) {
992
- const items = await client.listAll({ prefix, limit, cursor });
1017
+ const items = prefixes && prefixes.length > 1
1018
+ ? await ghMergedList(prefixes, cursor, (p, c) => client.listAll({ prefix: p, limit, cursor: c }))
1019
+ : await client.listAll({ prefix, limit, cursor });
1020
+ return { items, cursor: null };
1021
+ }
1022
+ if (prefixes && prefixes.length > 1) {
1023
+ const items = await ghMergedList(prefixes, cursor, async (p, c) => (await client.list({ prefix: p, limit, cursor: c })).items);
993
1024
  return { items, cursor: null };
994
1025
  }
995
1026
  return client.list({ prefix, limit, cursor });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,