@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.
@@ -164,8 +164,17 @@ 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
  },
173
+ {
174
+ name: "ingest",
175
+ usage: "ingest (--pr <n> | --issue <n>)",
176
+ summary: "Mirror GitHub-native user-attachments media from a PR/issue into the workspace",
177
+ },
169
178
  {
170
179
  name: "list",
171
180
  summary: "List objects (--meta k=v filters by queryable metadata)",
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import { formatRootHelp, formatUnknownCommand, wantsFullHelp } from "./cli-help.
6
6
  import { commandSummary, suggestCommand } from "./cli-suggest.js";
7
7
  import { writeJson } from "./io.js";
8
8
  import { colorEnabled, createStyle } from "./cli-style.js";
9
- import { runPut, runAttach, runStaged, runList, runFind, runMeta, runDelete, runHealth, runDoctor, runComment, runGithub, runUsage, runReconcile, runPurgeExpired, runGallery, } from "./commands.js";
9
+ import { runPut, runAttach, runStaged, runList, runFind, runMeta, runDelete, runHealth, runDoctor, runComment, runGithub, runIngest, runUsage, runReconcile, runPurgeExpired, runGallery, } from "./commands.js";
10
10
  import { runConfig } from "./commands/config.js";
11
11
  import { runSetup } from "./commands/setup.js";
12
12
  import { runLogin } from "./commands/login.js";
@@ -308,7 +308,8 @@ export async function runCli(argv) {
308
308
  case "purge-expired":
309
309
  case "doctor":
310
310
  case "comment":
311
- case "github": {
311
+ case "github":
312
+ case "ingest": {
312
313
  const ctx = createContext(parsed.globals, !showHelp, cmdArgs);
313
314
  switch (parsed.command) {
314
315
  case "attach":
@@ -332,6 +333,9 @@ export async function runCli(argv) {
332
333
  case "github":
333
334
  code = await runGithub(ctx, cmdArgs, showHelp);
334
335
  break;
336
+ case "ingest":
337
+ code = await runIngest(ctx, cmdArgs, showHelp);
338
+ break;
335
339
  case "list":
336
340
  code = await runList(ctx, cmdArgs, showHelp);
337
341
  break;
package/dist/client.d.ts CHANGED
@@ -256,7 +256,42 @@ export type GithubCommentResult = {
256
256
  fixUrl?: string;
257
257
  required?: string[];
258
258
  };
259
- /** `POST /v1/:workspace/github/promote` request/response (server contract, PR #310). */
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
+ };
294
+ /** `POST /v1/workspaces/:workspace/github/promote` request/response (server contract, PR #310). */
260
295
  export interface PromoteBranchAttachmentsOptions {
261
296
  repo: string;
262
297
  num: number;
@@ -270,7 +305,7 @@ export interface PromoteBranchAttachmentsResult {
270
305
  promoted: string[];
271
306
  skipped: PromoteSkip[];
272
307
  }
273
- /** `GET`/`POST /v1/:workspace/github/link` result (server contract, phase 4b). */
308
+ /** `GET`/`POST /v1/workspaces/:workspace/github/link` result (server contract, phase 4b). */
274
309
  export interface GithubLinkResult {
275
310
  repo: string;
276
311
  linked: boolean;
@@ -289,14 +324,14 @@ export interface GithubLinkClaimResult extends GithubLinkResult {
289
324
  */
290
325
  reason?: "not_authorized";
291
326
  }
292
- /** `DELETE /v1/:workspace/github/link` result (issue #318, self-serve unlink). */
327
+ /** `DELETE /v1/workspaces/:workspace/github/link` result (issue #318, self-serve unlink). */
293
328
  export interface GithubLinkUnlinkResult {
294
329
  repo: string;
295
330
  unlinked: boolean;
296
331
  reason?: "not_linked";
297
332
  }
298
333
  /**
299
- * `GET /v1/:workspace/github/repo-link` result (issue #398). Deliberately
334
+ * `GET /v1/workspaces/:workspace/github/repo-link` result (issue #398). Deliberately
300
335
  * minimal relative to `GithubLinkResult`: never names the owning workspace
301
336
  * when it isn't this one — "self"/"other"/"none" is all the stage-time
302
337
  * warning needs, and anything richer would leak cross-tenant info to a
@@ -305,10 +340,31 @@ export interface GithubLinkUnlinkResult {
305
340
  export interface GithubRepoLinkResult {
306
341
  binding: "self" | "other" | "none";
307
342
  }
343
+ /**
344
+ * `POST /v1/workspaces/:workspace/github/ingest` result (Task 6, manual/
345
+ * backfill entry point for GitHub-native `user-attachments` media). `repo`
346
+ * comes back lowercased by the server.
347
+ */
348
+ export interface IngestGithubResult {
349
+ repo: string;
350
+ kind: "pull" | "issues";
351
+ num: number;
352
+ /** Object keys newly mirrored into the workspace this call. */
353
+ ingested: string[];
354
+ /** Object keys whose previously-detached ledger row was un-detached. */
355
+ reattached: string[];
356
+ /** Object keys detached because they're no longer referenced. */
357
+ detached: string[];
358
+ /** Attachment URLs skipped, with the reason. */
359
+ skipped: {
360
+ url: string;
361
+ reason: string;
362
+ }[];
363
+ }
308
364
  export interface HealthResult {
309
365
  ok: boolean;
310
366
  }
311
- /** `GET /v1/:workspace/github/health` result (issue #293 follow-up). */
367
+ /** `GET /v1/workspaces/:workspace/github/health` result (issue #293 follow-up). */
312
368
  export interface GithubHealthResult {
313
369
  configured: boolean;
314
370
  ok: boolean;
@@ -546,10 +602,12 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
546
602
  /** `PATCH /v1/:workspace/files/:key` — merge `set`/`delete`; returns the merged map. */
547
603
  patchMetadata(key: string, opts: PatchMetadataOptions): Promise<GetMetadataResult>;
548
604
  /**
549
- * `GET /v1/:workspace/files?meta.<k>=<v>&…&name=…` — ANDed equality filter
550
- * over queryable metadata and/or a case-insensitive filename substring.
551
- * At least one of non-empty `filters` or `opts.name` is required.
552
- * `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.
553
611
  */
554
612
  findFiles(filters?: Record<string, string>, opts?: FindFilesOptions): Promise<FindFilesResult>;
555
613
  /** `GET /v1/:workspace/files/facets` — workspace metadata key vocabulary. */
@@ -585,6 +643,26 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
585
643
  kind: "pull" | "issues";
586
644
  resync?: boolean;
587
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>;
588
666
  /**
589
667
  * Promote a workspace's branch-staged attachments into a PR's stable
590
668
  * attachment prefix (server contract, PR #310 — degrade-safe callers
@@ -617,6 +695,18 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
617
695
  * `UploadsError` (status 404) on an older/self-hosted server without
618
696
  * this route — callers treat that as "unknown", not "broken".
619
697
  */
698
+ /**
699
+ * `POST /v1/workspaces/:workspace/github/ingest` (Task 6) — manual/
700
+ * backfill mirror of a PR/issue's `github.com/user-attachments` media
701
+ * into the workspace. Only ever had the canonical `/v1/workspaces`
702
+ * route (no old bearer-only alias) — unlike the other `github/*` client
703
+ * methods above, which moved onto it in issue #613.
704
+ */
705
+ ingestGithub(input: {
706
+ repo: string;
707
+ kind: "pull" | "issues";
708
+ num: number;
709
+ }): Promise<IngestGithubResult>;
620
710
  githubHealth(): Promise<GithubHealthResult>;
621
711
  /**
622
712
  * Self-serve unlink (issue #318): removes `repo`'s binding, but only if
package/dist/client.js CHANGED
@@ -186,14 +186,18 @@ export function mintWorkspaceToken(apiUrl, accessToken, input) {
186
186
  function encodeKeyPath(key) {
187
187
  return key.split("/").map(encodeURIComponent).join("/");
188
188
  }
189
- function filesBase(config) {
190
- 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`;
191
195
  }
192
196
  function usageBase(config) {
193
- return `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/usage`;
197
+ return `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/usage`;
194
198
  }
195
199
  function galleriesBase(config) {
196
- return `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/galleries`;
200
+ return `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/galleries`;
197
201
  }
198
202
  function mapApiError(status, error, code, requiredScope, existingUrl) {
199
203
  const normalized = error.toLowerCase();
@@ -306,18 +310,30 @@ export function createUploadsClient(config) {
306
310
  if (opts.metadata)
307
311
  params.set("metadata", "1");
308
312
  const qs = params.toString();
309
- 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}` : ""}`);
310
319
  return {
311
- ...page,
312
- items: page.items.map((item) => ({
313
- ...item,
314
- embedUrl: resolveEmbedUrl(item.url, item.embedUrl),
315
- })),
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
+ }),
316
329
  };
317
330
  }
318
331
  async function getGallery(id) {
319
332
  return request("GET", `${galleriesBase(config)}/${encodeURIComponent(id)}`);
320
333
  }
334
+ // Per-process cache for resolveGhPrefix, keyed by repo+branch+target — see
335
+ // that method's doc.
336
+ const resolveGhPrefixCache = new Map();
321
337
  return {
322
338
  async put(body, opts) {
323
339
  const key = opts.key ??
@@ -332,7 +348,7 @@ export function createUploadsClient(config) {
332
348
  const contentType = opts.contentType ?? inferContentType(opts.filename);
333
349
  if (opts.dryRun) {
334
350
  const qs = opts.replace ? "dryRun=1&replace=1" : "dryRun=1";
335
- const preview = await request("PUT", `${filesBase(config)}/${encodeKeyPath(key)}?${qs}`);
351
+ const preview = await request("PUT", `${canonicalFilesBase(config)}/${encodeKeyPath(key)}?${qs}`);
336
352
  if (preview.url == null) {
337
353
  throw new UploadsError("workspace has no publicBaseUrl (cannot resolve a public URL)", "NO_PUBLIC_URL");
338
354
  }
@@ -364,7 +380,7 @@ export function createUploadsClient(config) {
364
380
  headers[`X-Uploads-Meta-${k}`] = v;
365
381
  }
366
382
  }
367
- const result = await request("PUT", `${filesBase(config)}/${encodeKeyPath(key)}`, {
383
+ const result = await request("PUT", `${canonicalFilesBase(config)}/${encodeKeyPath(key)}`, {
368
384
  body,
369
385
  headers,
370
386
  });
@@ -391,24 +407,26 @@ export function createUploadsClient(config) {
391
407
  return items;
392
408
  },
393
409
  async delete(key) {
394
- return request("DELETE", `${filesBase(config)}/${encodeKeyPath(key)}`);
410
+ return request("DELETE", `${canonicalFilesBase(config)}/${encodeKeyPath(key)}`);
395
411
  },
396
412
  /** `GET /v1/:workspace/files/:key?metadata=1` — the object's queryable metadata. */
397
413
  async getMetadata(key) {
398
- return request("GET", `${filesBase(config)}/${encodeKeyPath(key)}?metadata=1`);
414
+ return request("GET", `${canonicalFilesBase(config)}/${encodeKeyPath(key)}?metadata=1`);
399
415
  },
400
416
  /** `PATCH /v1/:workspace/files/:key` — merge `set`/`delete`; returns the merged map. */
401
417
  async patchMetadata(key, opts) {
402
- return request("PATCH", `${filesBase(config)}/${encodeKeyPath(key)}`, {
418
+ return request("PATCH", `${canonicalFilesBase(config)}/${encodeKeyPath(key)}`, {
403
419
  body: new TextEncoder().encode(JSON.stringify(opts)),
404
420
  headers: { "Content-Type": "application/json" },
405
421
  });
406
422
  },
407
423
  /**
408
- * `GET /v1/:workspace/files?meta.<k>=<v>&…&name=…` — ANDed equality filter
409
- * over queryable metadata and/or a case-insensitive filename substring.
410
- * At least one of non-empty `filters` or `opts.name` is required.
411
- * `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.
412
430
  */
413
431
  async findFiles(filters = {}, opts = {}) {
414
432
  const params = new URLSearchParams();
@@ -420,18 +438,19 @@ export function createUploadsClient(config) {
420
438
  params.set("prefix", opts.prefix);
421
439
  if (opts.limit != null)
422
440
  params.set("limit", String(opts.limit));
423
- 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 };
424
443
  },
425
444
  /** `GET /v1/:workspace/files/facets` — workspace metadata key vocabulary. */
426
445
  async listMetadataKeys() {
427
- return request("GET", `${filesBase(config)}/facets`);
446
+ return request("GET", `${canonicalFilesBase(config)}/facets`);
428
447
  },
429
448
  /** `GET /v1/:workspace/files/facets?key=` — distinct values for one key. */
430
449
  async listMetadataValues(key) {
431
- return request("GET", `${filesBase(config)}/facets?${new URLSearchParams({ key })}`);
450
+ return request("GET", `${canonicalFilesBase(config)}/facets?${new URLSearchParams({ key })}`);
432
451
  },
433
452
  async head(key) {
434
- const result = await request("GET", `${filesBase(config)}/${encodeKeyPath(key)}`);
453
+ const result = await request("GET", `${canonicalFilesBase(config)}/${encodeKeyPath(key)}`);
435
454
  return { ...result, embedUrl: resolveEmbedUrl(result.url, result.embedUrl) };
436
455
  },
437
456
  async createGallery(opts) {
@@ -499,7 +518,54 @@ export function createUploadsClient(config) {
499
518
  * the field.
500
519
  */
501
520
  async upsertGithubComment(opts) {
502
- return request("POST", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/comment`, {
521
+ return request("POST", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/comment`, {
522
+ body: new TextEncoder().encode(JSON.stringify(opts)),
523
+ headers: { "Content-Type": "application/json" },
524
+ });
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`, {
503
569
  body: new TextEncoder().encode(JSON.stringify(opts)),
504
570
  headers: { "Content-Type": "application/json" },
505
571
  });
@@ -511,7 +577,7 @@ export function createUploadsClient(config) {
511
577
  * that doesn't have this route yet, as "nothing promoted").
512
578
  */
513
579
  async promoteBranchAttachments(opts) {
514
- return request("POST", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/promote`, {
580
+ return request("POST", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/promote`, {
515
581
  body: new TextEncoder().encode(JSON.stringify(opts)),
516
582
  headers: { "Content-Type": "application/json" },
517
583
  });
@@ -520,7 +586,7 @@ export function createUploadsClient(config) {
520
586
  * `UploadsError` (status 404) on an older/self-hosted server without this
521
587
  * route — callers treat that as "bindings unsupported". */
522
588
  async githubLinkStatus(repo) {
523
- return request("GET", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/link?repo=${encodeURIComponent(repo)}`);
589
+ return request("GET", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/link?repo=${encodeURIComponent(repo)}`);
524
590
  },
525
591
  /**
526
592
  * Explicitly claim `repo` for this workspace (first-claim-wins — see
@@ -530,7 +596,7 @@ export function createUploadsClient(config) {
530
596
  * server without this route.
531
597
  */
532
598
  async githubLinkClaim(repo) {
533
- return request("POST", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/link`, {
599
+ return request("POST", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/link`, {
534
600
  body: new TextEncoder().encode(JSON.stringify({ repo })),
535
601
  headers: { "Content-Type": "application/json" },
536
602
  });
@@ -543,15 +609,31 @@ export function createUploadsClient(config) {
543
609
  * caller (the stage warning) treats ANY failure here as "stay silent".
544
610
  */
545
611
  async githubRepoLinkStatus(repo) {
546
- return request("GET", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/repo-link?repo=${encodeURIComponent(repo)}`);
612
+ return request("GET", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/repo-link?repo=${encodeURIComponent(repo)}`);
547
613
  },
548
614
  /**
549
615
  * GitHub App configuration + webhook event subscription check. Throws
550
616
  * `UploadsError` (status 404) on an older/self-hosted server without
551
617
  * this route — callers treat that as "unknown", not "broken".
552
618
  */
619
+ /**
620
+ * `POST /v1/workspaces/:workspace/github/ingest` (Task 6) — manual/
621
+ * backfill mirror of a PR/issue's `github.com/user-attachments` media
622
+ * into the workspace. Only ever had the canonical `/v1/workspaces`
623
+ * route (no old bearer-only alias) — unlike the other `github/*` client
624
+ * methods above, which moved onto it in issue #613.
625
+ */
626
+ async ingestGithub(input) {
627
+ const body = input.kind === "pull"
628
+ ? { repo: input.repo, pr: input.num }
629
+ : { repo: input.repo, issue: input.num };
630
+ return request("POST", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/ingest`, {
631
+ body: new TextEncoder().encode(JSON.stringify(body)),
632
+ headers: { "Content-Type": "application/json" },
633
+ });
634
+ },
553
635
  async githubHealth() {
554
- return request("GET", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/health`);
636
+ return request("GET", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/health`);
555
637
  },
556
638
  /**
557
639
  * Self-serve unlink (issue #318): removes `repo`'s binding, but only if
@@ -561,7 +643,7 @@ export function createUploadsClient(config) {
561
643
  * older/self-hosted server without this route.
562
644
  */
563
645
  async githubLinkUnlink(repo) {
564
- return request("DELETE", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/link?repo=${encodeURIComponent(repo)}`);
646
+ return request("DELETE", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/link?repo=${encodeURIComponent(repo)}`);
565
647
  },
566
648
  async health() {
567
649
  return request("GET", `${config.apiUrl}/health`, { auth: false });
@@ -2,12 +2,12 @@ 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";
10
+ import { deriveRepoSlugFromGit } from "../keys.js";
11
11
  import { safeCaptureFacts } from "../capture-facts.js";
12
12
  import { parseMetaFlags, validateMetaMap } from "../metadata.js";
13
13
  import { mergeDerivedMeta } from "../metadata-vocab.js";
@@ -360,7 +360,11 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
360
360
  // Explicit input (--meta plus the dedicated flags) wins over capture facts.
361
361
  const explicitMeta = { ...metaExtras, ...stateAppMetaFromFlags(parsed.flags) };
362
362
  const deriveMeta = derivedMetaEnabled(parsed.flags, putDefaults);
363
- const withFacts = mergeDerivedMeta(explicitMeta, deriveMeta ? safeCaptureFacts(target, viewport, colorScheme) : {});
363
+ const repoSlug = deriveMeta && !noGit ? deriveRepoSlugFromGit(run) : undefined;
364
+ const withFacts = mergeDerivedMeta(explicitMeta, {
365
+ ...(deriveMeta ? safeCaptureFacts(target, viewport, colorScheme) : {}),
366
+ ...(repoSlug ? { repo: repoSlug } : {}),
367
+ });
364
368
  let metadata = withFacts;
365
369
  if (ghTarget) {
366
370
  metadata = { ...withFacts, ...ghMetadataFromTargetWithTitle(ghTarget, run) };
@@ -443,15 +447,28 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
443
447
  }
444
448
  const repo = flagString(parsed.flags, "--repo") ?? putDefaults.repo;
445
449
  const ref = flagString(parsed.flags, "--ref") ?? putDefaults.ref;
446
- const branchKey = stagingTarget !== undefined
447
- ? ghBranchAttachmentKey(stagingTarget.repo, stagingTarget.branch, captured.filename)
448
- : 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;
449
464
  const alt = altFlag ?? basename(captured.filename);
450
465
  const { result, prepared, markdown } = await uploadPreparedImage(ctx.client, finalPng, captured.filename, {
451
466
  frame: frameOpts,
452
467
  optimize: optimizeOpts,
453
468
  ghTarget,
454
- key: keyHint ?? branchKey,
469
+ ghBranchTarget: stagingTarget,
470
+ ghPrefix,
471
+ key: keyHint,
455
472
  prefix: resolvedPrefix ?? putDefaults.prefix,
456
473
  repo,
457
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;
@@ -457,6 +501,7 @@ export declare function runFind(ctx: CliContext, args: string[], help?: boolean)
457
501
  export declare function runMeta(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
458
502
  export declare function runDelete(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
459
503
  export declare function runComment(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
504
+ export declare function runIngest(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
460
505
  export declare function runGithub(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
461
506
  export declare function runUsage(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
462
507
  export declare function runReconcile(ctx: CliContext, args: string[], help?: boolean): Promise<number>;