@buildinternet/uploads 0.38.0 → 0.40.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.
@@ -166,6 +166,11 @@ export const ROOT_COMMANDS = [
166
166
  { name: "doctor", summary: "Check the GitHub App's webhook event subscriptions" },
167
167
  ],
168
168
  },
169
+ {
170
+ name: "ingest",
171
+ usage: "ingest (--pr <n> | --issue <n>)",
172
+ summary: "Mirror GitHub-native user-attachments media from a PR/issue into the workspace",
173
+ },
169
174
  {
170
175
  name: "list",
171
176
  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,7 @@ 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/promote` request/response (server contract, PR #310). */
260
260
  export interface PromoteBranchAttachmentsOptions {
261
261
  repo: string;
262
262
  num: number;
@@ -270,7 +270,7 @@ export interface PromoteBranchAttachmentsResult {
270
270
  promoted: string[];
271
271
  skipped: PromoteSkip[];
272
272
  }
273
- /** `GET`/`POST /v1/:workspace/github/link` result (server contract, phase 4b). */
273
+ /** `GET`/`POST /v1/workspaces/:workspace/github/link` result (server contract, phase 4b). */
274
274
  export interface GithubLinkResult {
275
275
  repo: string;
276
276
  linked: boolean;
@@ -289,14 +289,14 @@ export interface GithubLinkClaimResult extends GithubLinkResult {
289
289
  */
290
290
  reason?: "not_authorized";
291
291
  }
292
- /** `DELETE /v1/:workspace/github/link` result (issue #318, self-serve unlink). */
292
+ /** `DELETE /v1/workspaces/:workspace/github/link` result (issue #318, self-serve unlink). */
293
293
  export interface GithubLinkUnlinkResult {
294
294
  repo: string;
295
295
  unlinked: boolean;
296
296
  reason?: "not_linked";
297
297
  }
298
298
  /**
299
- * `GET /v1/:workspace/github/repo-link` result (issue #398). Deliberately
299
+ * `GET /v1/workspaces/:workspace/github/repo-link` result (issue #398). Deliberately
300
300
  * minimal relative to `GithubLinkResult`: never names the owning workspace
301
301
  * when it isn't this one — "self"/"other"/"none" is all the stage-time
302
302
  * warning needs, and anything richer would leak cross-tenant info to a
@@ -305,10 +305,31 @@ export interface GithubLinkUnlinkResult {
305
305
  export interface GithubRepoLinkResult {
306
306
  binding: "self" | "other" | "none";
307
307
  }
308
+ /**
309
+ * `POST /v1/workspaces/:workspace/github/ingest` result (Task 6, manual/
310
+ * backfill entry point for GitHub-native `user-attachments` media). `repo`
311
+ * comes back lowercased by the server.
312
+ */
313
+ export interface IngestGithubResult {
314
+ repo: string;
315
+ kind: "pull" | "issues";
316
+ num: number;
317
+ /** Object keys newly mirrored into the workspace this call. */
318
+ ingested: string[];
319
+ /** Object keys whose previously-detached ledger row was un-detached. */
320
+ reattached: string[];
321
+ /** Object keys detached because they're no longer referenced. */
322
+ detached: string[];
323
+ /** Attachment URLs skipped, with the reason. */
324
+ skipped: {
325
+ url: string;
326
+ reason: string;
327
+ }[];
328
+ }
308
329
  export interface HealthResult {
309
330
  ok: boolean;
310
331
  }
311
- /** `GET /v1/:workspace/github/health` result (issue #293 follow-up). */
332
+ /** `GET /v1/workspaces/:workspace/github/health` result (issue #293 follow-up). */
312
333
  export interface GithubHealthResult {
313
334
  configured: boolean;
314
335
  ok: boolean;
@@ -617,6 +638,18 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
617
638
  * `UploadsError` (status 404) on an older/self-hosted server without
618
639
  * this route — callers treat that as "unknown", not "broken".
619
640
  */
641
+ /**
642
+ * `POST /v1/workspaces/:workspace/github/ingest` (Task 6) — manual/
643
+ * backfill mirror of a PR/issue's `github.com/user-attachments` media
644
+ * into the workspace. Only ever had the canonical `/v1/workspaces`
645
+ * route (no old bearer-only alias) — unlike the other `github/*` client
646
+ * methods above, which moved onto it in issue #613.
647
+ */
648
+ ingestGithub(input: {
649
+ repo: string;
650
+ kind: "pull" | "issues";
651
+ num: number;
652
+ }): Promise<IngestGithubResult>;
620
653
  githubHealth(): Promise<GithubHealthResult>;
621
654
  /**
622
655
  * Self-serve unlink (issue #318): removes `repo`'s binding, but only if
package/dist/client.js CHANGED
@@ -186,14 +186,19 @@ 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.
189
194
  function filesBase(config) {
190
195
  return `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/files`;
191
196
  }
192
197
  function usageBase(config) {
193
- return `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/usage`;
198
+ return `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/usage`;
194
199
  }
195
200
  function galleriesBase(config) {
196
- return `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/galleries`;
201
+ return `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/galleries`;
197
202
  }
198
203
  function mapApiError(status, error, code, requiredScope, existingUrl) {
199
204
  const normalized = error.toLowerCase();
@@ -499,7 +504,7 @@ export function createUploadsClient(config) {
499
504
  * the field.
500
505
  */
501
506
  async upsertGithubComment(opts) {
502
- return request("POST", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/comment`, {
507
+ return request("POST", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/comment`, {
503
508
  body: new TextEncoder().encode(JSON.stringify(opts)),
504
509
  headers: { "Content-Type": "application/json" },
505
510
  });
@@ -511,7 +516,7 @@ export function createUploadsClient(config) {
511
516
  * that doesn't have this route yet, as "nothing promoted").
512
517
  */
513
518
  async promoteBranchAttachments(opts) {
514
- return request("POST", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/promote`, {
519
+ return request("POST", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/promote`, {
515
520
  body: new TextEncoder().encode(JSON.stringify(opts)),
516
521
  headers: { "Content-Type": "application/json" },
517
522
  });
@@ -520,7 +525,7 @@ export function createUploadsClient(config) {
520
525
  * `UploadsError` (status 404) on an older/self-hosted server without this
521
526
  * route — callers treat that as "bindings unsupported". */
522
527
  async githubLinkStatus(repo) {
523
- return request("GET", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/link?repo=${encodeURIComponent(repo)}`);
528
+ return request("GET", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/link?repo=${encodeURIComponent(repo)}`);
524
529
  },
525
530
  /**
526
531
  * Explicitly claim `repo` for this workspace (first-claim-wins — see
@@ -530,7 +535,7 @@ export function createUploadsClient(config) {
530
535
  * server without this route.
531
536
  */
532
537
  async githubLinkClaim(repo) {
533
- return request("POST", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/link`, {
538
+ return request("POST", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/link`, {
534
539
  body: new TextEncoder().encode(JSON.stringify({ repo })),
535
540
  headers: { "Content-Type": "application/json" },
536
541
  });
@@ -543,15 +548,31 @@ export function createUploadsClient(config) {
543
548
  * caller (the stage warning) treats ANY failure here as "stay silent".
544
549
  */
545
550
  async githubRepoLinkStatus(repo) {
546
- return request("GET", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/repo-link?repo=${encodeURIComponent(repo)}`);
551
+ return request("GET", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/repo-link?repo=${encodeURIComponent(repo)}`);
547
552
  },
548
553
  /**
549
554
  * GitHub App configuration + webhook event subscription check. Throws
550
555
  * `UploadsError` (status 404) on an older/self-hosted server without
551
556
  * this route — callers treat that as "unknown", not "broken".
552
557
  */
558
+ /**
559
+ * `POST /v1/workspaces/:workspace/github/ingest` (Task 6) — manual/
560
+ * backfill mirror of a PR/issue's `github.com/user-attachments` media
561
+ * into the workspace. Only ever had the canonical `/v1/workspaces`
562
+ * route (no old bearer-only alias) — unlike the other `github/*` client
563
+ * methods above, which moved onto it in issue #613.
564
+ */
565
+ async ingestGithub(input) {
566
+ const body = input.kind === "pull"
567
+ ? { repo: input.repo, pr: input.num }
568
+ : { repo: input.repo, issue: input.num };
569
+ return request("POST", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/ingest`, {
570
+ body: new TextEncoder().encode(JSON.stringify(body)),
571
+ headers: { "Content-Type": "application/json" },
572
+ });
573
+ },
553
574
  async githubHealth() {
554
- return request("GET", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/health`);
575
+ return request("GET", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/health`);
555
576
  },
556
577
  /**
557
578
  * Self-serve unlink (issue #318): removes `repo`'s binding, but only if
@@ -561,7 +582,7 @@ export function createUploadsClient(config) {
561
582
  * older/self-hosted server without this route.
562
583
  */
563
584
  async githubLinkUnlink(repo) {
564
- return request("DELETE", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/link?repo=${encodeURIComponent(repo)}`);
585
+ return request("DELETE", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/link?repo=${encodeURIComponent(repo)}`);
565
586
  },
566
587
  async health() {
567
588
  return request("GET", `${config.apiUrl}/health`, { auth: false });
@@ -8,6 +8,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
10
  import { ghBranchAttachmentKey } from "../github.js";
11
+ import { deriveRepoSlugFromGit } from "../keys.js";
11
12
  import { safeCaptureFacts } from "../capture-facts.js";
12
13
  import { parseMetaFlags, validateMetaMap } from "../metadata.js";
13
14
  import { mergeDerivedMeta } from "../metadata-vocab.js";
@@ -360,7 +361,11 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
360
361
  // Explicit input (--meta plus the dedicated flags) wins over capture facts.
361
362
  const explicitMeta = { ...metaExtras, ...stateAppMetaFromFlags(parsed.flags) };
362
363
  const deriveMeta = derivedMetaEnabled(parsed.flags, putDefaults);
363
- const withFacts = mergeDerivedMeta(explicitMeta, deriveMeta ? safeCaptureFacts(target, viewport, colorScheme) : {});
364
+ const repoSlug = deriveMeta && !noGit ? deriveRepoSlugFromGit(run) : undefined;
365
+ const withFacts = mergeDerivedMeta(explicitMeta, {
366
+ ...(deriveMeta ? safeCaptureFacts(target, viewport, colorScheme) : {}),
367
+ ...(repoSlug ? { repo: repoSlug } : {}),
368
+ });
364
369
  let metadata = withFacts;
365
370
  if (ghTarget) {
366
371
  metadata = { ...withFacts, ...ghMetadataFromTargetWithTitle(ghTarget, run) };
@@ -457,6 +457,7 @@ export declare function runFind(ctx: CliContext, args: string[], help?: boolean)
457
457
  export declare function runMeta(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
458
458
  export declare function runDelete(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
459
459
  export declare function runComment(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
460
+ export declare function runIngest(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
460
461
  export declare function runGithub(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
461
462
  export declare function runUsage(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
462
463
  export declare function runReconcile(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
package/dist/commands.js CHANGED
@@ -15,7 +15,7 @@ import { mergeDerivedMeta, nearMissMetaWarnings, validateStateValue } from "./me
15
15
  import { mergeSidecarMeta } from "./sidecar.js";
16
16
  import { ghAttachmentKey, ghBranchAttachmentKey, ghBranchKeyPrefix, ghKeyPrefix, ghMetadataFromTarget, parseGhKey, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, AUTO_RENDER_OPTIONS, GH_FALLBACK_AUTHOR_NOTE, normalizeGithubCoordinate, } from "./github.js";
17
17
  import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, resolveDefaultBranch, classifyGhNumber, execRunner, timedExecRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
18
- import { deriveRepoFromGit } from "./keys.js";
18
+ import { deriveRepoFromGit, deriveRepoSlugFromGit } from "./keys.js";
19
19
  import { resolvePutPrefix } from "./destinations.js";
20
20
  import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
21
21
  import { applyFrame, resolveFrameId } from "./frame.js";
@@ -899,7 +899,7 @@ export async function uploadPuts(opts) {
899
899
  return { uploads, failures, firstError, sentMetadata };
900
900
  }
901
901
  /**
902
- * Best-effort call to `POST /v1/:workspace/github/promote` (server contract,
902
+ * Best-effort call to `POST /v1/workspaces/:workspace/github/promote` (server contract,
903
903
  * PR #310). Degrade-safe like `syncAttachmentsComment`'s bot path: an older
904
904
  * or self-hosted worker without this route (404), a forbidden token (403),
905
905
  * or a network error all collapse to "nothing promoted" — the caller must
@@ -1699,6 +1699,23 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1699
1699
  }
1700
1700
  }
1701
1701
  }
1702
+ // Derived `repo` (spec: 2026-08-11-screenshots-project-grouping-design.md):
1703
+ // the capturing repo, on every layout including gh/staging. mergeDerivedMeta
1704
+ // keeps explicit --meta repo= wins and never breaks the caps.
1705
+ //
1706
+ // `metadata === undefined` means "leave whatever's already stored for this
1707
+ // key untouched" (client.ts only sends X-Uploads-Meta-* when the map is
1708
+ // defined; a defined map is a full replace server-side). A bare `uploads
1709
+ // put` with no --meta/gh/staging context leaves `metadata` undefined by
1710
+ // design — never synthesize `{ repo }` there just because a repo happens
1711
+ // to be derivable, or a plain re-upload of an existing key would silently
1712
+ // wipe everything already stored on it. Only fold repo in when `metadata`
1713
+ // is already a defined object for another reason.
1714
+ if (!noGit && derivedMetaEnabled(parsed.flags, defaults) && metadata !== undefined) {
1715
+ const slug = deriveRepoSlugFromGit(run);
1716
+ if (slug)
1717
+ metadata = mergeDerivedMeta(metadata, { repo: slug });
1718
+ }
1702
1719
  // Bare-put nudge (issue #393): only relevant when staging didn't take over
1703
1720
  // — once `stagingTarget` resolves, staging IS the upgrade the nudge used to
1704
1721
  // point at, so this is skipped entirely rather than firing redundantly.
@@ -2551,6 +2568,48 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
2551
2568
  }
2552
2569
  return 0;
2553
2570
  }
2571
+ // --- ingest ---
2572
+ const INGEST_HELP = `uploads ingest — mirror GitHub-native attachments from a PR/issue into the workspace
2573
+
2574
+ Usage:
2575
+ uploads ingest --pr <n> [--repo owner/name]
2576
+ uploads ingest --issue <n> [--repo owner/name]
2577
+
2578
+ Scans the PR/issue description and comments for github.com/user-attachments
2579
+ media, mirrors new ones into the workspace (indexed, not added to the managed
2580
+ comment), and detaches ones no longer referenced. Works on any repo linked to
2581
+ the workspace; the .uploads.yml ingestGithubAttachments knob only gates the
2582
+ automatic webhook path.
2583
+
2584
+ Examples:
2585
+ uploads ingest --pr 123
2586
+ uploads ingest --issue 45 --repo acme/app --format json
2587
+ `;
2588
+ export async function runIngest(ctx, args, help = false, run = execRunner) {
2589
+ const parsed = parseCommandArgs(args);
2590
+ if (help || parsed.help) {
2591
+ writeCommandHelp(INGEST_HELP);
2592
+ return 0;
2593
+ }
2594
+ const target = ghTargetFromFlags(parsed.flags, run);
2595
+ if (!target) {
2596
+ throw new UsageError("--pr or --issue required");
2597
+ }
2598
+ const result = await ctx.client.ingestGithub(target);
2599
+ const jsonMode = ctx.json || flagString(parsed.flags, "--format") === "json";
2600
+ if (jsonMode) {
2601
+ await writeJson(result);
2602
+ return 0;
2603
+ }
2604
+ if (!ctx.quiet) {
2605
+ let line = `Ingested ${result.ingested.length}, re-attached ${result.reattached.length}, detached ${result.detached.length}, skipped ${result.skipped.length}\n`;
2606
+ for (const skip of result.skipped) {
2607
+ line += ` skipped: ${skip.url} (${skip.reason})\n`;
2608
+ }
2609
+ process.stderr.write(line);
2610
+ }
2611
+ return 0;
2612
+ }
2554
2613
  // --- github link ---
2555
2614
  const GITHUB_HELP = `uploads github link [--repo <owner/name>] [--status] [--workspace <name>]
2556
2615
  uploads github unlink [--repo <owner/name>] [--workspace <name>]
@@ -2570,7 +2629,8 @@ owns it; an operator can reassign or remove that binding instead.
2570
2629
 
2571
2630
  \`doctor\` checks the GitHub App itself: whether it's configured on the
2572
2631
  server, and whether it's subscribed to the webhook events uploads.sh's
2573
- handler needs (issues, pull_request see docs/github-app). A missing
2632
+ handler needs (required: issues, pull_request; recommended: issue_comment
2633
+ see docs/github-app). A missing
2574
2634
  subscription is the classic silent failure: the App's ping stays green
2575
2635
  while webhook auto-promotion and title-cache invalidation quietly do
2576
2636
  nothing.
@@ -2590,7 +2650,7 @@ function recommendedNoteLine(result) {
2590
2650
  const missing = missingRecommendedEventsOf(result);
2591
2651
  if (missing.length === 0)
2592
2652
  return "";
2593
- return `note: not subscribed to ${missing.join(", ")} (recommended) — enables bot-comment self-healing; subscribe under the App's Permissions & events\n`;
2653
+ return `note: not subscribed to ${missing.join(", ")} (recommended) — enables bot-comment self-healing and comment-attachment ingestion; subscribe under the App's Permissions & events\n`;
2594
2654
  }
2595
2655
  function formatGithubDoctor(result) {
2596
2656
  if (!result.configured) {
@@ -2600,7 +2660,18 @@ function formatGithubDoctor(result) {
2600
2660
  return `github app: configured, but health check failed${result.hint ? ` — ${result.hint}` : ""}\n`;
2601
2661
  }
2602
2662
  if (result.ok) {
2603
- return (`github app: ok subscribed to ${result.requiredEvents.join(", ")}\n` +
2663
+ // The ACTUAL subscribed list, not just the required subset — printing
2664
+ // requiredEvents here once misdiagnosed a live App as "not subscribed to
2665
+ // issue_comment" when it was (2026-08-11). `events` is non-null on every
2666
+ // ok result (the null case returns above); the requiredEvents fallback
2667
+ // only guards a malformed payload from an older server.
2668
+ const subscribed = Array.isArray(result.events)
2669
+ ? [...result.events].sort()
2670
+ : [...result.requiredEvents];
2671
+ const allPresent = missingRecommendedEventsOf(result).length === 0;
2672
+ return (`github app: ok — subscribed to ${subscribed.join(", ")}` +
2673
+ (allPresent ? " (all required + recommended events)" : "") +
2674
+ "\n" +
2604
2675
  recommendedNoteLine(result));
2605
2676
  }
2606
2677
  return (`github app: missing webhook event subscription(s): ${result.missingEvents.join(", ")}\n` +
@@ -17,6 +17,7 @@ export interface RepoCommentConfig {
17
17
  metaState?: boolean;
18
18
  linkToFilePage?: boolean;
19
19
  note?: string;
20
+ ingestGithubAttachments?: boolean;
20
21
  }
21
22
  export interface WorkspaceCommentDefaults {
22
23
  imageWidth?: "full" | number;
@@ -24,6 +25,7 @@ export interface WorkspaceCommentDefaults {
24
25
  showMetadata?: boolean;
25
26
  linkToFilePage?: boolean;
26
27
  note?: string;
28
+ ingestGithubAttachments?: boolean;
27
29
  }
28
30
  export interface ResolvedCommentOptions {
29
31
  imageWidth: "auto" | "full" | number;
@@ -32,6 +34,7 @@ export interface ResolvedCommentOptions {
32
34
  metaState: boolean;
33
35
  linkToFilePage: boolean;
34
36
  note: string | null;
37
+ ingestGithubAttachments: boolean;
35
38
  }
36
39
  export type OptionSource = "repo" | "workspace" | "auto";
37
40
  export declare const AUTO_COMMENT_OPTIONS: ResolvedCommentOptions;
@@ -20,6 +20,7 @@ export const AUTO_COMMENT_OPTIONS = {
20
20
  metaState: true,
21
21
  linkToFilePage: true,
22
22
  note: null,
23
+ ingestGithubAttachments: false,
23
24
  };
24
25
  export const NOTE_MAX_CHARS = 500;
25
26
  const WIDTH_MIN = 160;
@@ -71,6 +72,14 @@ export function parseRepoCommentConfig(text, format) {
71
72
  else
72
73
  warnings.push(`linkToFilePage: expected a boolean; dropped`);
73
74
  }
75
+ // ingestGithubAttachments: boolean
76
+ if ("ingestGithubAttachments" in c) {
77
+ const v = c.ingestGithubAttachments;
78
+ if (typeof v === "boolean")
79
+ config.ingestGithubAttachments = v;
80
+ else
81
+ warnings.push(`ingestGithubAttachments: expected a boolean; dropped`);
82
+ }
74
83
  // meta.path / meta.state: booleans nested under `meta`
75
84
  if ("meta" in c) {
76
85
  const v = c.meta;
@@ -123,6 +132,9 @@ export function resolveCommentOptions(repo, ws) {
123
132
  : {}),
124
133
  ...(ws?.linkToFilePage !== undefined ? { linkToFilePage: ws.linkToFilePage } : {}),
125
134
  ...(ws?.note ? { note: ws.note } : {}),
135
+ ...(ws?.ingestGithubAttachments !== undefined
136
+ ? { ingestGithubAttachments: ws.ingestGithubAttachments }
137
+ : {}),
126
138
  };
127
139
  const options = { ...AUTO_COMMENT_OPTIONS };
128
140
  const source = Object.fromEntries(Object.keys(AUTO_COMMENT_OPTIONS).map((k) => [k, "auto"]));
@@ -133,6 +145,7 @@ export function resolveCommentOptions(repo, ws) {
133
145
  "metaPath",
134
146
  "metaState",
135
147
  "linkToFilePage",
148
+ "ingestGithubAttachments",
136
149
  ]) {
137
150
  if (cfg[key] !== undefined && source[key] === "auto") {
138
151
  options[key] = cfg[key];
package/dist/keys.d.ts CHANGED
@@ -8,6 +8,12 @@ export declare function sha256Short(bytes: Uint8Array): Promise<string>;
8
8
  * every existing caller.
9
9
  */
10
10
  export declare function deriveRepoFromGit(run?: (cmd: string, args: string[], input?: string) => string): string | undefined;
11
+ /**
12
+ * Full `owner/name` slug (lowercase, matching the `gh.repo` convention) from
13
+ * the cwd's git remote — the derived `repo` metadata value. Unlike
14
+ * `deriveRepoFromGit` above (key-segment name only), this keeps the owner.
15
+ */
16
+ export declare function deriveRepoSlugFromGit(run?: (cmd: string, args: string[], input?: string) => string): string | undefined;
11
17
  export declare function buildScreenshotKey(opts: {
12
18
  filename: string;
13
19
  fileBytes: Uint8Array;
package/dist/keys.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execSync } from "node:child_process";
2
+ import { parseRepoFromRemoteUrl } from "./github.js";
2
3
  export function sanitizeKeySegment(s) {
3
4
  return s.replace(/[^A-Za-z0-9._-]/g, "-");
4
5
  }
@@ -28,6 +29,22 @@ export function deriveRepoFromGit(run) {
28
29
  return undefined;
29
30
  }
30
31
  }
32
+ /**
33
+ * Full `owner/name` slug (lowercase, matching the `gh.repo` convention) from
34
+ * the cwd's git remote — the derived `repo` metadata value. Unlike
35
+ * `deriveRepoFromGit` above (key-segment name only), this keeps the owner.
36
+ */
37
+ export function deriveRepoSlugFromGit(run) {
38
+ try {
39
+ const url = run
40
+ ? run("git", ["config", "--get", "remote.origin.url"])
41
+ : execSync("git config --get remote.origin.url", { encoding: "utf8" });
42
+ return parseRepoFromRemoteUrl(url)?.toLowerCase();
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ }
31
48
  export async function buildScreenshotKey(opts) {
32
49
  const dot = opts.filename.lastIndexOf(".");
33
50
  const ext = dot >= 0 ? opts.filename.slice(dot + 1) : "";
package/dist/mcp/tools.js CHANGED
@@ -5,6 +5,7 @@ import { resolveConfig, resolvePutDefaults, } from "../config.js";
5
5
  import { resolvePutPrefix } from "../destinations.js";
6
6
  import { ghBranchAttachmentKey, ghKeyPrefix } from "../github.js";
7
7
  import { safeCaptureFacts } from "../capture-facts.js";
8
+ import { deriveRepoSlugFromGit } from "../keys.js";
8
9
  import { validateMetaMap } from "../metadata.js";
9
10
  import { mergeDerivedMeta } from "../metadata-vocab.js";
10
11
  import { execRunner, ghMetadataFromTargetWithTitle, resolveCurrentBranch, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
@@ -430,7 +431,23 @@ export function createUploadsMcpTools(opts) {
430
431
  repoArg: optString(args, "repo") ?? defaults.repo,
431
432
  run,
432
433
  });
433
- const putMetadata = stagingTarget ? mergeStagingMeta(metadata, stagingTarget) : metadata;
434
+ // Derived `repo` metadata (spec: 2026-08-11-screenshots-project-grouping-design.md).
435
+ // Same derivation the CLI does; MCP always derives (no --no-auto), so
436
+ // this is only suppressed by noGit. metadataProp's contract: omitting
437
+ // `metadata` means "leave what's already stored for this key
438
+ // untouched" — an object (even {}) triggers a full replace. So only
439
+ // fold the derived repo into a defined object when the caller already
440
+ // supplied metadata, or branch staging is building one below anyway
441
+ // (mergeStagingMeta always returns a defined object); never
442
+ // synthesize metadata on a bare re-upload just to add repo, or a
443
+ // no-metadata put would silently wipe everything already stored.
444
+ const repoSlug = !noGit ? deriveRepoSlugFromGit(run) : undefined;
445
+ const metadataWithRepo = repoSlug !== undefined && (metadata !== undefined || stagingTarget !== undefined)
446
+ ? mergeDerivedMeta(metadata ?? {}, { repo: repoSlug })
447
+ : metadata;
448
+ const putMetadata = stagingTarget
449
+ ? mergeStagingMeta(metadataWithRepo, stagingTarget)
450
+ : metadataWithRepo;
434
451
  const putShared = {
435
452
  client,
436
453
  ghTarget: target,
@@ -737,9 +754,15 @@ export function createUploadsMcpTools(opts) {
737
754
  // Keep undefined when nothing at all was supplied or derived, so the
738
755
  // "omit to leave stored metadata untouched" contract still holds.
739
756
  const captureDerived = safeCaptureFacts(targetArg, viewport, colorSchemeArg);
740
- const metadataBase = metadata === undefined && Object.keys(captureDerived).length === 0
757
+ // Derived `repo` metadata (spec: 2026-08-11-screenshots-project-grouping-design.md).
758
+ // Same derivation the CLI does, suppressed by noGit.
759
+ const repoSlug = !noGit ? deriveRepoSlugFromGit(run) : undefined;
760
+ const captureDerivedWithRepo = repoSlug
761
+ ? { ...captureDerived, repo: repoSlug }
762
+ : captureDerived;
763
+ const metadataBase = metadata === undefined && Object.keys(captureDerivedWithRepo).length === 0
741
764
  ? undefined
742
- : mergeDerivedMeta(metadata ?? {}, captureDerived);
765
+ : mergeDerivedMeta(metadata ?? {}, captureDerivedWithRepo);
743
766
  // gh.* metadata: explicit pr/issue target wins; staging wins the same
744
767
  // way (matches attach --branch/bare put); otherwise capture-derived +
745
768
  // explicit only.
@@ -1,4 +1,4 @@
1
- export declare const CANONICAL_META_KEYS: readonly ["url", "path", "env", "theme", "viewport", "device", "software", "captured", "state", "app"];
1
+ export declare const CANONICAL_META_KEYS: readonly ["url", "path", "env", "theme", "viewport", "device", "software", "captured", "state", "app", "repo"];
2
2
  export type CanonicalMetaKey = (typeof CANONICAL_META_KEYS)[number];
3
3
  /** Closed enum for `state` — the highest-value hand-supplied search facet. */
4
4
  export declare const META_STATE_VALUES: readonly ["before", "after", "empty", "error", "loading"];
@@ -18,6 +18,7 @@ export const CANONICAL_META_KEYS = [
18
18
  "captured",
19
19
  "state",
20
20
  "app",
21
+ "repo",
21
22
  ];
22
23
  const CANONICAL_KEY_SET = new Set(CANONICAL_META_KEYS);
23
24
  /** Closed enum for `state` — the highest-value hand-supplied search facet. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.38.0",
3
+ "version": "0.40.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,
@@ -38,7 +38,7 @@
38
38
  "node": ">=22"
39
39
  },
40
40
  "peerDependencies": {
41
- "files-sdk": "^2.1.0"
41
+ "files-sdk": "^2.2.3"
42
42
  },
43
43
  "peerDependenciesMeta": {
44
44
  "files-sdk": {
@@ -48,7 +48,7 @@
48
48
  "devDependencies": {
49
49
  "@types/node": "^26.1.0",
50
50
  "ai": "^6.0.0",
51
- "files-sdk": "^2.1.0",
51
+ "files-sdk": "^2.2.3",
52
52
  "typescript": "^7.0.2",
53
53
  "vitest": "^4.1.10"
54
54
  },