@buildinternet/uploads 0.14.0 → 0.15.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.
@@ -25,6 +25,7 @@ export const PUT_LIKE_FLAGS = [
25
25
  "--ref",
26
26
  "--pr",
27
27
  "--issue",
28
+ "--branch",
28
29
  "--comment",
29
30
  "--no-comment",
30
31
  "--format",
@@ -75,6 +76,7 @@ export const SCREENSHOT_FLAGS = [
75
76
  "--no-git",
76
77
  "--pr",
77
78
  "--issue",
79
+ "--branch",
78
80
  "--comment",
79
81
  "--gallery",
80
82
  "--meta",
@@ -131,6 +133,11 @@ export const ROOT_COMMANDS = [
131
133
  name: "comment",
132
134
  summary: "Create/update a PR/issue attachments comment (via gh)",
133
135
  },
136
+ {
137
+ name: "github",
138
+ summary: "Claim/inspect this workspace's binding to a GitHub repo",
139
+ subcommands: [{ name: "link", summary: "Claim or inspect the repo binding" }],
140
+ },
134
141
  {
135
142
  name: "list",
136
143
  summary: "List objects (--meta k=v filters by queryable metadata)",
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { UploadsError } from "./errors.js";
4
4
  import { commandWorkspace, flagString, isHelpFlag, parseArgv, parseCommandArgs, UsageError, } from "./cli-args.js";
5
5
  import { formatRootHelp, wantsFullHelp } from "./cli-help.js";
6
6
  import { colorEnabled, createStyle } from "./cli-style.js";
7
- import { runPut, runAttach, runList, runFind, runMeta, runDelete, runHealth, runDoctor, runComment, runUsage, runReconcile, runPurgeExpired, runGallery, } from "./commands.js";
7
+ import { runPut, runAttach, runList, runFind, runMeta, runDelete, runHealth, runDoctor, runComment, runGithub, runUsage, runReconcile, runPurgeExpired, runGallery, } from "./commands.js";
8
8
  import { runConfig } from "./commands/config.js";
9
9
  import { runSetup } from "./commands/setup.js";
10
10
  import { runLogin } from "./commands/login.js";
@@ -278,7 +278,8 @@ export async function runCli(argv) {
278
278
  case "reconcile":
279
279
  case "purge-expired":
280
280
  case "doctor":
281
- case "comment": {
281
+ case "comment":
282
+ case "github": {
282
283
  const ctx = createContext(parsed.globals, !showHelp, cmdArgs);
283
284
  switch (parsed.command) {
284
285
  case "attach":
@@ -296,6 +297,9 @@ export async function runCli(argv) {
296
297
  case "comment":
297
298
  code = await runComment(ctx, cmdArgs, showHelp);
298
299
  break;
300
+ case "github":
301
+ code = await runGithub(ctx, cmdArgs, showHelp);
302
+ break;
299
303
  case "list":
300
304
  code = await runList(ctx, cmdArgs, showHelp);
301
305
  break;
package/dist/client.d.ts CHANGED
@@ -73,6 +73,8 @@ export interface ListItem {
73
73
  key: string;
74
74
  url: string | null;
75
75
  embedUrl?: string | null;
76
+ /** Canonical `/f/` page URL when the API provides it. Absent on older API deployments. */
77
+ pageUrl?: string;
76
78
  size?: number;
77
79
  uploaded?: string;
78
80
  }
@@ -180,7 +182,36 @@ export type GithubCommentResult = {
180
182
  } | {
181
183
  posted: false;
182
184
  reason: GithubCommentDeclineReason;
185
+ message?: string;
186
+ fixUrl?: string;
187
+ required?: string[];
183
188
  };
189
+ /** `POST /v1/:workspace/github/promote` request/response (server contract, PR #310). */
190
+ export interface PromoteBranchAttachmentsOptions {
191
+ repo: string;
192
+ num: number;
193
+ branch: string;
194
+ }
195
+ export interface PromoteSkip {
196
+ key: string;
197
+ reason: string;
198
+ }
199
+ export interface PromoteBranchAttachmentsResult {
200
+ promoted: string[];
201
+ skipped: PromoteSkip[];
202
+ }
203
+ /** `GET`/`POST /v1/:workspace/github/link` result (server contract, phase 4b). */
204
+ export interface GithubLinkResult {
205
+ repo: string;
206
+ linked: boolean;
207
+ workspace: string | null;
208
+ source: string | null;
209
+ createdAt: string | null;
210
+ }
211
+ /** POST-only: whether THIS call's workspace ended up owning the binding. */
212
+ export interface GithubLinkClaimResult extends GithubLinkResult {
213
+ claimed: boolean;
214
+ }
184
215
  export interface HealthResult {
185
216
  ok: boolean;
186
217
  }
@@ -412,6 +443,25 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
412
443
  num: number;
413
444
  kind: "pull" | "issues";
414
445
  }): Promise<GithubCommentResult>;
446
+ /**
447
+ * Promote a workspace's branch-staged attachments into a PR's stable
448
+ * attachment prefix (server contract, PR #310 — degrade-safe callers
449
+ * treat any failure, including a 404 from an older/self-hosted worker
450
+ * that doesn't have this route yet, as "nothing promoted").
451
+ */
452
+ promoteBranchAttachments(opts: PromoteBranchAttachmentsOptions): Promise<PromoteBranchAttachmentsResult>;
453
+ /** Current binding for `repo`, or `{ linked: false }` if unclaimed. Throws
454
+ * `UploadsError` (status 404) on an older/self-hosted server without this
455
+ * route — callers treat that as "bindings unsupported". */
456
+ githubLinkStatus(repo: string): Promise<GithubLinkResult>;
457
+ /**
458
+ * Explicitly claim `repo` for this workspace (first-claim-wins — see
459
+ * github-repo-links.ts server-side). `claimed: false` in the result means
460
+ * the repo is already bound to a DIFFERENT workspace; this call never
461
+ * steals it. Throws `UploadsError` (status 404) on an older/self-hosted
462
+ * server without this route.
463
+ */
464
+ githubLinkClaim(repo: string): Promise<GithubLinkClaimResult>;
415
465
  health(): Promise<HealthResult>;
416
466
  /** Workspace storage / upload counters (+ limits when configured). */
417
467
  usage(): Promise<UsageResult>;
package/dist/client.js CHANGED
@@ -458,6 +458,37 @@ export function createUploadsClient(config) {
458
458
  headers: { "Content-Type": "application/json" },
459
459
  });
460
460
  },
461
+ /**
462
+ * Promote a workspace's branch-staged attachments into a PR's stable
463
+ * attachment prefix (server contract, PR #310 — degrade-safe callers
464
+ * treat any failure, including a 404 from an older/self-hosted worker
465
+ * that doesn't have this route yet, as "nothing promoted").
466
+ */
467
+ async promoteBranchAttachments(opts) {
468
+ return request("POST", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/promote`, {
469
+ body: new TextEncoder().encode(JSON.stringify(opts)),
470
+ headers: { "Content-Type": "application/json" },
471
+ });
472
+ },
473
+ /** Current binding for `repo`, or `{ linked: false }` if unclaimed. Throws
474
+ * `UploadsError` (status 404) on an older/self-hosted server without this
475
+ * route — callers treat that as "bindings unsupported". */
476
+ async githubLinkStatus(repo) {
477
+ return request("GET", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/link?repo=${encodeURIComponent(repo)}`);
478
+ },
479
+ /**
480
+ * Explicitly claim `repo` for this workspace (first-claim-wins — see
481
+ * github-repo-links.ts server-side). `claimed: false` in the result means
482
+ * the repo is already bound to a DIFFERENT workspace; this call never
483
+ * steals it. Throws `UploadsError` (status 404) on an older/self-hosted
484
+ * server without this route.
485
+ */
486
+ async githubLinkClaim(repo) {
487
+ return request("POST", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/link`, {
488
+ body: new TextEncoder().encode(JSON.stringify({ repo })),
489
+ headers: { "Content-Type": "application/json" },
490
+ });
491
+ },
461
492
  async health() {
462
493
  return request("GET", `${config.apiUrl}/health`, { auth: false });
463
494
  },
@@ -2,11 +2,12 @@ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
3
  import { flagBool, flagInt, flagString, flagValues, parseCommandArgs, UsageError, } from "../cli-args.js";
4
4
  import { writeCommandHelp } from "../cli-style.js";
5
- import { frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, } from "../commands.js";
5
+ import { branchFromFlags, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, } 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
- import { execRunner, ghMetadataFromTargetWithTitle } from "../github-gh.js";
9
+ import { execRunner, ghMetadataFromTargetWithTitle, resolveRepo, } from "../github-gh.js";
10
+ import { ghBranchAttachmentKey, ghMetadataForBranch } from "../github.js";
10
11
  import { parseMetaFlags, validateMetaMap } from "../metadata.js";
11
12
  import { writeJson, writeStdout } from "../io.js";
12
13
  import { assertHideSelector, captureScreenshot, parseViewport, parseWaitUntil, } from "../screenshot.js";
@@ -66,6 +67,11 @@ Options:
66
67
  --keep-exif Keep EXIF/XMP/ICC when optimizing
67
68
  --pr <num> Attach to a pull request (stable URL, no hash)
68
69
  --issue <num> Attach to an issue
70
+ --branch [name] Stage against a branch, pre-PR (default: current git branch):
71
+ key gh/<owner>/<repo>/branch/<branch>/<name>; not with
72
+ --pr/--issue/--comment/--key/--ref/--prefix. No managed
73
+ comment exists yet — promoting into the PR's comment once
74
+ one opens ships in a later phase.
69
75
  --comment With --pr/--issue: update the managed attachments comment.
70
76
  Posts as uploads-sh[bot] when the GitHub App is installed;
71
77
  otherwise via local gh.
@@ -84,6 +90,7 @@ Examples:
84
90
  uploads screenshot http://localhost:3000 --via local --full-page
85
91
  uploads screenshot https://uploads.sh --pr 128 --comment
86
92
  uploads screenshot ./card.html --no-upload --out ./card.png
93
+ uploads screenshot https://app.example/settings --branch
87
94
  `;
88
95
  function colorSchemeFromFlags(flags) {
89
96
  const dark = flagBool(flags, "--dark");
@@ -164,9 +171,13 @@ captureImpl = captureScreenshot) {
164
171
  const destFlag = flagString(parsed.flags, "--destination");
165
172
  const prefixFlag = flagString(parsed.flags, "--prefix");
166
173
  const ghTarget = ghTargetFromFlags(parsed.flags, run);
174
+ const branchArg = branchFromFlags(parsed.flags, run);
167
175
  const wantComment = parsed.flags.has("--comment");
168
176
  const galleryId = flagString(parsed.flags, "--gallery");
169
177
  const dryRun = flagBool(parsed.flags, "--dry-run");
178
+ if (branchArg !== undefined && ghTarget) {
179
+ throw new UsageError("--branch cannot be combined with --pr/--issue");
180
+ }
170
181
  if (wantComment && !ghTarget)
171
182
  throw new UsageError("--comment requires --pr or --issue");
172
183
  if (ghTarget) {
@@ -177,6 +188,16 @@ captureImpl = captureScreenshot) {
177
188
  if (prefixFlag)
178
189
  throw new UsageError("--prefix cannot be combined with --pr/--issue");
179
190
  }
191
+ if (branchArg !== undefined) {
192
+ if (wantComment)
193
+ throw new UsageError("--branch cannot be combined with --comment");
194
+ if (keyHint)
195
+ throw new UsageError("--key cannot be combined with --branch");
196
+ if (flagString(parsed.flags, "--ref"))
197
+ throw new UsageError("--ref cannot be combined with --branch");
198
+ if (prefixFlag)
199
+ throw new UsageError("--prefix cannot be combined with --branch");
200
+ }
180
201
  if (dryRun) {
181
202
  if (wantComment)
182
203
  throw new UsageError("--dry-run cannot be combined with --comment");
@@ -185,13 +206,14 @@ captureImpl = captureScreenshot) {
185
206
  if (noUpload)
186
207
  throw new UsageError("--dry-run cannot be combined with --no-upload");
187
208
  }
209
+ const branchRepo = branchArg !== undefined ? resolveRepo(flagString(parsed.flags, "--repo"), run) : undefined;
188
210
  let resolvedPrefix;
189
211
  try {
190
212
  resolvedPrefix = resolvePutPrefix({
191
213
  destination: destFlag,
192
214
  prefix: prefixFlag,
193
215
  key: keyHint,
194
- ghAttachment: Boolean(ghTarget),
216
+ ghAttachment: Boolean(ghTarget) || branchArg !== undefined,
195
217
  });
196
218
  }
197
219
  catch (err) {
@@ -218,6 +240,10 @@ captureImpl = captureScreenshot) {
218
240
  metadata = { ...metaExtras, ...ghMetadataFromTargetWithTitle(ghTarget, run) };
219
241
  validateMetaMap(metadata);
220
242
  }
243
+ else if (branchArg !== undefined) {
244
+ metadata = { ...metaExtras, ...ghMetadataForBranch(branchRepo, branchArg) };
245
+ validateMetaMap(metadata);
246
+ }
221
247
  else if (Object.keys(metaExtras).length > 0) {
222
248
  validateMetaMap(metaExtras);
223
249
  }
@@ -260,12 +286,15 @@ captureImpl = captureScreenshot) {
260
286
  }
261
287
  const repo = flagString(parsed.flags, "--repo") ?? putDefaults.repo;
262
288
  const ref = flagString(parsed.flags, "--ref") ?? putDefaults.ref;
289
+ const branchKey = branchArg !== undefined
290
+ ? ghBranchAttachmentKey(branchRepo, branchArg, captured.filename)
291
+ : undefined;
263
292
  const alt = altFlag ?? basename(captured.filename);
264
293
  const { result, prepared, markdown } = await uploadPreparedImage(ctx.client, captured.png, captured.filename, {
265
294
  frame: frameOpts,
266
295
  optimize: optimizeOpts,
267
296
  ghTarget,
268
- key: keyHint,
297
+ key: keyHint ?? branchKey,
269
298
  prefix: resolvedPrefix ?? putDefaults.prefix,
270
299
  repo,
271
300
  ref,
@@ -295,7 +324,7 @@ captureImpl = captureScreenshot) {
295
324
  let commentError;
296
325
  if (wantComment && ghTarget) {
297
326
  try {
298
- comment = await syncAttachmentsComment(ctx.client, ghTarget, run);
327
+ comment = await syncAttachmentsComment(ctx.client, ghTarget, run, ctx.config.workspace);
299
328
  if (logHuman)
300
329
  process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
301
330
  }
@@ -28,6 +28,14 @@ export declare function readFileArg(fileArg: string): Uint8Array;
28
28
  export declare function makeGhTarget(pr: number | undefined, issue: number | undefined, repoArg: string | undefined, run: CommandRunner): GhTarget | undefined;
29
29
  /** Reads --pr/--issue (+ --repo) into a GhTarget; undefined when neither flag is present. */
30
30
  export declare function ghTargetFromFlags(flags: CommandFlags["flags"], run: CommandRunner): GhTarget | undefined;
31
+ /**
32
+ * Reads `--branch [name]` — an optional-value flag: `--branch` alone resolves
33
+ * the current git branch (`resolveCurrentBranch`); `--branch feature/x` uses
34
+ * the given name verbatim. Returns undefined when the flag is absent at all
35
+ * (distinct from an empty/whitespace value, which is rejected). Throws
36
+ * UsageError if `--branch` is given more than once.
37
+ */
38
+ export declare function branchFromFlags(flags: CommandFlags["flags"], run: CommandRunner): string | undefined;
31
39
  /** Shared put/attach optimize flags + UPLOADS_NO_OPTIMIZE default. */
32
40
  export declare function optimizeOptionsFromFlags(flags: CommandFlags["flags"], defaults: PutDefaults): OptimizeImageOptions;
33
41
  export type PreparedUpload = OptimizeImageResult & {
@@ -104,7 +112,7 @@ export interface AttachmentsCommentResult {
104
112
  }
105
113
  /** Human-mode suffix noting who posted the managed comment. */
106
114
  export declare function commentViaSuffix(via: AttachmentsCommentResult["via"]): string;
107
- export declare function syncAttachmentsComment(client: UploadsClient, target: GhTarget, run: CommandRunner): Promise<AttachmentsCommentResult>;
115
+ export declare function syncAttachmentsComment(client: UploadsClient, target: GhTarget, run: CommandRunner, workspace?: string): Promise<AttachmentsCommentResult>;
108
116
  export type AttachUploadItem = PutResult & {
109
117
  file: string;
110
118
  markdown: string;
@@ -150,6 +158,37 @@ export declare function uploadAttachments(opts: {
150
158
  failures: AttachFailure[];
151
159
  firstError?: unknown;
152
160
  }>;
161
+ /** A branch to stage attachments against pre-PR (`uploads attach --branch`). */
162
+ export interface BranchTarget {
163
+ repo: string;
164
+ branch: string;
165
+ }
166
+ /**
167
+ * Prepare + put each path as a branch-staged attachment (pre-PR) with
168
+ * bounded concurrency. Same shape as `uploadAttachments`, keyed under
169
+ * `gh/<owner>/<repo>/branch/<branch>/<filename>` instead of a PR/issue
170
+ * number. Never syncs the managed comment — callers must not call
171
+ * `syncAttachmentsComment` for a branch target.
172
+ */
173
+ export declare function uploadBranchAttachments(opts: {
174
+ client: UploadsClient;
175
+ target: BranchTarget;
176
+ files: readonly string[];
177
+ contentType?: string;
178
+ optimize: OptimizeImageOptions;
179
+ frame: {
180
+ frameId?: string;
181
+ frameUrl?: string;
182
+ frameFit?: "cover" | "contain";
183
+ };
184
+ metadata?: Record<string, string>;
185
+ provenanceClient?: string;
186
+ concurrency?: number;
187
+ }): Promise<{
188
+ uploads: AttachUploadItem[];
189
+ failures: AttachFailure[];
190
+ firstError?: unknown;
191
+ }>;
153
192
  export type PutUploadItem = PutResult & {
154
193
  file: string;
155
194
  markdown: string;
@@ -199,6 +238,7 @@ export declare function runFind(ctx: CliContext, args: string[], help?: boolean)
199
238
  export declare function runMeta(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
200
239
  export declare function runDelete(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
201
240
  export declare function runComment(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
241
+ export declare function runGithub(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
202
242
  export declare function runUsage(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
203
243
  export declare function runReconcile(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
204
244
  export declare function runPurgeExpired(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
package/dist/commands.js CHANGED
@@ -9,8 +9,8 @@ import { urlForGithubEmbed } from "./public-urls.js";
9
9
  import { UploadsError } from "./errors.js";
10
10
  import { writeJson, writeStdout } from "./io.js";
11
11
  import { parseMetaFlags, validateMetaMap } from "./metadata.js";
12
- import { ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, attachmentsCommentBody, normalizeGithubCoordinate, } from "./github.js";
13
- import { resolveRepo, resolveCurrentPullRequest, classifyGhNumber, execRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
12
+ import { ghAttachmentKey, ghBranchAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, normalizeGithubCoordinate, } from "./github.js";
13
+ import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, classifyGhNumber, execRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
14
14
  import { resolvePutPrefix } from "./destinations.js";
15
15
  import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
16
16
  import { applyFrame, resolveFrameId } from "./frame.js";
@@ -130,6 +130,25 @@ export function makeGhTarget(pr, issue, repoArg, run) {
130
130
  export function ghTargetFromFlags(flags, run) {
131
131
  return makeGhTarget(flagInt(flags, "--pr", "--pr"), flagInt(flags, "--issue", "--issue"), flagString(flags, "--repo"), run);
132
132
  }
133
+ /**
134
+ * Reads `--branch [name]` — an optional-value flag: `--branch` alone resolves
135
+ * the current git branch (`resolveCurrentBranch`); `--branch feature/x` uses
136
+ * the given name verbatim. Returns undefined when the flag is absent at all
137
+ * (distinct from an empty/whitespace value, which is rejected). Throws
138
+ * UsageError if `--branch` is given more than once.
139
+ */
140
+ export function branchFromFlags(flags, run) {
141
+ if (!flags.has("--branch"))
142
+ return undefined;
143
+ const raw = flags.get("--branch");
144
+ if (Array.isArray(raw))
145
+ throw new UsageError("--branch may only be given once");
146
+ if (raw === true)
147
+ return resolveCurrentBranch(run);
148
+ if (typeof raw === "string" && raw.trim().length > 0)
149
+ return raw;
150
+ throw new UsageError("--branch requires a non-empty branch name");
151
+ }
133
152
  /**
134
153
  * Best-effort GitHub target for the default put path (no --pr/--issue). A
135
154
  * numeric --ref is classified as pull vs issue; otherwise the current branch's
@@ -278,7 +297,7 @@ export function frameOptionsFromFlags(flags) {
278
297
  export function commentViaSuffix(via) {
279
298
  return via === "bot" ? " (uploads-sh[bot])" : " (via gh)";
280
299
  }
281
- export async function syncAttachmentsComment(client, target, run) {
300
+ export async function syncAttachmentsComment(client, target, run, workspace) {
282
301
  try {
283
302
  const bot = await client.upsertGithubComment({
284
303
  repo: target.repo,
@@ -287,13 +306,24 @@ export async function syncAttachmentsComment(client, target, run) {
287
306
  });
288
307
  if (bot.posted)
289
308
  return { action: bot.action, count: bot.count, via: "bot" };
309
+ // Installed-but-unapproved is a fixable misconfiguration, not a silent
310
+ // degrade: tell the user (and how to fix it) before falling back to gh.
311
+ if (bot.reason === "forbidden" && bot.message) {
312
+ process.stderr.write(`note: ${bot.message}${bot.fixUrl ? `\n ${bot.fixUrl}` : ""}\n` +
313
+ `Posting via local gh in the meantime.\n`);
314
+ }
290
315
  }
291
316
  catch {
292
317
  // Endpoint absent/unreachable (self-hosted, network, older worker) — fall
293
318
  // through to the gh path below.
294
319
  }
295
320
  // gh fallback: gather from this workspace's own data and post via local `gh`.
296
- const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url, embedUrl }) => ({ key, url, embedUrl }));
321
+ // Note (issue #304): this CLI process has no server-side WorkspaceRecord in
322
+ // scope, so it cannot honor a workspace's githubCommentLinkToFilePage=false
323
+ // — it always links to the file page here, matching the default. This only
324
+ // diverges from the bot-posted comment for a workspace that both sets the
325
+ // flag false and falls through to this gh-fallback path.
326
+ const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url, embedUrl, pageUrl }) => ({ key, url, embedUrl, pageUrl }));
297
327
  const galleries = [];
298
328
  let cursor;
299
329
  do {
@@ -329,8 +359,9 @@ export async function syncAttachmentsComment(client, target, run) {
329
359
  }));
330
360
  if (items.length === 0 && previewGalleries.length === 0)
331
361
  return { action: "skipped", count: 0, via: "gh" };
332
- const body = attachmentsCommentBody(items, previewGalleries);
333
- const { created } = upsertAttachmentsComment(target, body, run);
362
+ const marker = attachmentsMarker(workspace);
363
+ const body = attachmentsCommentBody(items, previewGalleries, marker);
364
+ const { created } = upsertAttachmentsComment(target, body, run, marker);
334
365
  return {
335
366
  action: created ? "created" : "updated",
336
367
  count: items.length + previewGalleries.length,
@@ -357,9 +388,37 @@ URL and every embed hot-swap. Human mode prints ">> replaced existing object
357
388
  Still images are optimized to WebP by default (same as put). Use --no-optimize
358
389
  to upload originals. Optional --frame wraps images in device/browser chrome.
359
390
 
391
+ Branch staging (pre-PR): --branch [name] stages files against a git branch
392
+ before a pull request exists, e.g. for a coding agent working a branch that
393
+ hasn't opened a PR yet. Key: gh/<owner>/<repo>/branch/<branch>/<filename>
394
+ ("/" in the branch name sanitizes to "-", e.g. feature/x -> feature-x).
395
+ With no value, --branch resolves the current git branch. Staged files are
396
+ public like every other attachment — same public-URL caveat applies. There is
397
+ no managed comment for a branch (no PR/issue to comment on yet); --branch
398
+ never runs the comment sync and cannot combine with --pr/--issue/--comment.
399
+
400
+ Promotion: once a PR exists, staged files for the current branch are picked
401
+ up automatically the first time you attach to that PR (a plain "uploads
402
+ attach <file> --pr <num>", or the inferred-PR default with no target flags) —
403
+ they're copied into the PR's attachment prefix before the managed comment is
404
+ built, so they show up in the same run. Pass --no-promote to skip that. If
405
+ you'd rather promote without attaching a new file (e.g. right after
406
+ "gh pr create" with nothing new to upload), run "uploads attach --promote"
407
+ with no file arguments — it resolves the PR the same way, promotes, and
408
+ refreshes the comment; it exits 0 even if nothing was staged. --promote only
409
+ takes effect with zero files and cannot combine with --branch/--issue/
410
+ --no-promote. Promotion never applies to issues. Staged files stay findable
411
+ with "uploads find gh.branch=<branch>" either way.
412
+
360
413
  Options:
361
414
  --pr <num> Attach to this pull request
362
415
  --issue <num> Attach to this issue
416
+ --branch [name] Stage against a branch, pre-PR (default: current git branch);
417
+ not with --pr/--issue/--comment
418
+ --promote No files: promote branch-staged attachments into the
419
+ resolved PR and refresh the comment; not with
420
+ --branch/--issue/--no-promote
421
+ --no-promote Skip auto-promoting branch-staged attachments (default path only)
363
422
  --repo <owner/repo> Repository (default: gh/git inference)
364
423
  --no-comment Upload only; don't create/update the managed comment
365
424
  --content-type <mime> Override Content-Type (applied to every file; ignored when optimize rewrites)
@@ -373,7 +432,8 @@ Options:
373
432
  --workspace, -w <name> Override workspace
374
433
  --meta <k=v> Extra queryable metadata (repeatable; value may contain "=").
375
434
  gh.repo/gh.kind/gh.number/gh.ref are always set from the resolved
376
- target a --meta pair with the same key is overridden by it.
435
+ target (or gh.repo/gh.kind/gh.branch/gh.staged-at with --branch)
436
+ a --meta pair with the same key is overridden by it.
377
437
  Because attach always sends its own gh.* pairs, re-attaching to
378
438
  the same key always replaces that file's entire metadata set
379
439
  (never preserves) — use "uploads meta set" to add to it instead.
@@ -384,13 +444,17 @@ Examples:
384
444
  uploads attach ./shot.png --pr 123 --repo myorg/myapp
385
445
  uploads attach ./artifact.zip --issue 45 --no-comment
386
446
  uploads attach ./shot.png --meta app=myapp --meta page=settings
447
+ uploads attach ./shot.png --branch
448
+ uploads attach ./shot.png --branch feature/new-settings
449
+ uploads attach --promote
387
450
  `;
388
451
  /**
389
- * Prepare + put each path as a PR/issue attachment with bounded concurrency.
390
- * Per-file errors collect in `failures` (does not throw). `firstError` is the
452
+ * Shared prepare + put loop for both PR/issue attach (`uploadAttachments`)
453
+ * and branch-staged attach (`uploadBranchAttachments`) bounded concurrency,
454
+ * per-file errors collect in `failures` (does not throw). `firstError` is the
391
455
  * original cause of the first failure — for rethrowing single-file CLI paths.
392
456
  */
393
- export async function uploadAttachments(opts) {
457
+ async function uploadAttachmentBatch(opts) {
394
458
  if (opts.files.some((f) => f === "-")) {
395
459
  throw new UsageError("attach does not support stdin; pass one or more file paths");
396
460
  }
@@ -403,7 +467,7 @@ export async function uploadAttachments(opts) {
403
467
  });
404
468
  const result = await opts.client.put(prepared.bytes, {
405
469
  filename: prepared.filename,
406
- key: ghAttachmentKey(opts.target, prepared.filename),
470
+ key: opts.keyFor(prepared.filename),
407
471
  contentType: prepared.optimized ? prepared.contentType : opts.contentType,
408
472
  provenance: buildCliProvenance({
409
473
  sourceName,
@@ -450,6 +514,30 @@ export async function uploadAttachments(opts) {
450
514
  }
451
515
  return { uploads, failures, firstError };
452
516
  }
517
+ /**
518
+ * Prepare + put each path as a PR/issue attachment with bounded concurrency.
519
+ * Per-file errors collect in `failures` (does not throw). `firstError` is the
520
+ * original cause of the first failure — for rethrowing single-file CLI paths.
521
+ */
522
+ export async function uploadAttachments(opts) {
523
+ return uploadAttachmentBatch({
524
+ ...opts,
525
+ keyFor: (filename) => ghAttachmentKey(opts.target, filename),
526
+ });
527
+ }
528
+ /**
529
+ * Prepare + put each path as a branch-staged attachment (pre-PR) with
530
+ * bounded concurrency. Same shape as `uploadAttachments`, keyed under
531
+ * `gh/<owner>/<repo>/branch/<branch>/<filename>` instead of a PR/issue
532
+ * number. Never syncs the managed comment — callers must not call
533
+ * `syncAttachmentsComment` for a branch target.
534
+ */
535
+ export async function uploadBranchAttachments(opts) {
536
+ return uploadAttachmentBatch({
537
+ ...opts,
538
+ keyFor: (filename) => ghBranchAttachmentKey(opts.target.repo, opts.target.branch, filename),
539
+ });
540
+ }
453
541
  function errorDetail(err) {
454
542
  if (err instanceof UploadsError)
455
543
  return { message: err.message, code: err.code, status: err.status };
@@ -527,18 +615,68 @@ export async function uploadPuts(opts) {
527
615
  }
528
616
  return { uploads, failures, firstError };
529
617
  }
618
+ /**
619
+ * Best-effort call to `POST /v1/:workspace/github/promote` (server contract,
620
+ * PR #310). Degrade-safe like `syncAttachmentsComment`'s bot path: an older
621
+ * or self-hosted worker without this route (404), a forbidden token (403),
622
+ * or a network error all collapse to "nothing promoted" — the caller must
623
+ * never let this fail the attach. Returns undefined on any failure.
624
+ */
625
+ async function attemptPromoteBranch(client, target, branch) {
626
+ try {
627
+ return await client.promoteBranchAttachments({ repo: target.repo, num: target.num, branch });
628
+ }
629
+ catch {
630
+ return undefined;
631
+ }
632
+ }
633
+ /** Human-mode note for a promotion that actually promoted something. */
634
+ function promotionNote(promotion, branch) {
635
+ const n = promotion.promoted.length;
636
+ const branchSuffix = branch ? ` from branch ${branch}` : "";
637
+ return `>> promoted ${n} staged attachment${n === 1 ? "" : "s"}${branchSuffix}\n`;
638
+ }
530
639
  export async function runAttach(ctx, args, help = false, run = execRunner) {
531
640
  const parsed = parseCommandArgs(args);
532
641
  if (help || parsed.help) {
533
642
  writeCommandHelp(ATTACH_HELP);
534
643
  return 0;
535
644
  }
645
+ if (parsed.flags.has("--no-comment") && typeof parsed.flags.get("--no-comment") === "string") {
646
+ throw new UsageError("--no-comment takes no value — place it after the file arguments");
647
+ }
648
+ if (parsed.flags.has("--promote") && typeof parsed.flags.get("--promote") === "string") {
649
+ throw new UsageError("--promote takes no value — place it after the file arguments");
650
+ }
651
+ if (parsed.flags.has("--no-promote") && typeof parsed.flags.get("--no-promote") === "string") {
652
+ throw new UsageError("--no-promote takes no value — place it after the file arguments");
653
+ }
654
+ if (parsed.flags.has("--promote")) {
655
+ if (parsed.positionals.length > 0) {
656
+ throw new UsageError("--promote takes no file arguments — attaching a file to a PR already auto-promotes " +
657
+ "staged files; use `uploads attach <file> --pr <num>` instead");
658
+ }
659
+ if (parsed.flags.has("--branch"))
660
+ throw new UsageError("--promote cannot be combined with --branch");
661
+ if (parsed.flags.has("--issue"))
662
+ throw new UsageError("--promote cannot be combined with --issue");
663
+ if (parsed.flags.has("--no-promote"))
664
+ throw new UsageError("--promote cannot be combined with --no-promote");
665
+ return runAttachPromoteOnly(ctx, parsed, run);
666
+ }
536
667
  if (parsed.positionals.length === 0) {
537
668
  writeCommandHelp(ATTACH_HELP);
538
669
  return 2;
539
670
  }
540
- if (parsed.flags.has("--no-comment") && typeof parsed.flags.get("--no-comment") === "string") {
541
- throw new UsageError("--no-comment takes no value — place it after the file arguments");
671
+ const branchArg = branchFromFlags(parsed.flags, run);
672
+ if (branchArg !== undefined) {
673
+ if (parsed.flags.has("--pr"))
674
+ throw new UsageError("--branch cannot be combined with --pr");
675
+ if (parsed.flags.has("--issue"))
676
+ throw new UsageError("--branch cannot be combined with --issue");
677
+ if (parsed.flags.has("--comment"))
678
+ throw new UsageError("--branch cannot be combined with --comment");
679
+ return runAttachBranch(ctx, parsed, branchArg, run);
542
680
  }
543
681
  const explicitTarget = ghTargetFromFlags(parsed.flags, run);
544
682
  const target = explicitTarget ??
@@ -574,12 +712,33 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
574
712
  if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
575
713
  throw firstError instanceof Error ? firstError : new Error(String(firstError));
576
714
  }
715
+ // Auto-promote: before the comment sync, best-effort promote this
716
+ // workspace's own branch-staged attachments (from an earlier `attach
717
+ // --branch` while the PR didn't exist yet) into this PR's attachment
718
+ // prefix, so the comment gather below sees them in the same invocation.
719
+ // Never for issues (branch staging only ever targets a future PR), never
720
+ // with --no-promote, and silently skipped (no client call at all) when the
721
+ // current git branch can't be resolved (detached HEAD, not a repo) — this
722
+ // must never fail the attach itself.
723
+ let promotion;
724
+ let promotedBranch;
725
+ if (target.kind === "pull" && !parsed.flags.has("--no-promote")) {
726
+ try {
727
+ promotedBranch = resolveCurrentBranch(run);
728
+ }
729
+ catch {
730
+ promotedBranch = undefined;
731
+ }
732
+ if (promotedBranch !== undefined) {
733
+ promotion = await attemptPromoteBranch(ctx.client, target, promotedBranch);
734
+ }
735
+ }
577
736
  let comment;
578
737
  let commentError;
579
738
  // Skip comment refresh when every upload failed — nothing new from this batch.
580
739
  if (!parsed.flags.has("--no-comment") && uploads.length > 0) {
581
740
  try {
582
- comment = await syncAttachmentsComment(ctx.client, target, run);
741
+ comment = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
583
742
  }
584
743
  catch (err) {
585
744
  commentError = err instanceof Error ? err.message : String(err);
@@ -587,7 +746,14 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
587
746
  }
588
747
  }
589
748
  if (ctx.json) {
590
- await writeJson({ target, uploads, failures, comment, commentError });
749
+ await writeJson({
750
+ target,
751
+ uploads,
752
+ failures,
753
+ comment,
754
+ commentError,
755
+ promotion: promotion ?? null,
756
+ });
591
757
  }
592
758
  else {
593
759
  for (const result of uploads) {
@@ -606,6 +772,9 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
606
772
  for (const failure of failures) {
607
773
  process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
608
774
  }
775
+ if (!ctx.quiet && promotion && promotion.promoted.length > 0) {
776
+ process.stderr.write(promotionNote(promotion, promotedBranch));
777
+ }
609
778
  if (!ctx.quiet && comment)
610
779
  process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
611
780
  if (!ctx.quiet && uploads.length > 0) {
@@ -615,6 +784,114 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
615
784
  }
616
785
  return failures.length === 0 ? 0 : 1;
617
786
  }
787
+ /**
788
+ * `attach --branch` path: stages files under
789
+ * `gh/<owner>/<repo>/branch/<branch>/<filename>` instead of a PR/issue
790
+ * number. Never syncs the managed comment (there is no PR/issue to comment
791
+ * on yet, and the comment-gatherer only lists PR/issue prefixes anyway —
792
+ * branch-staged keys are invisible to it by construction).
793
+ */
794
+ async function runAttachBranch(ctx, parsed, branch, run) {
795
+ const repo = resolveRepo(flagString(parsed.flags, "--repo"), run);
796
+ const defaults = resolvePutDefaults({ envFile: ctx.envFile });
797
+ const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
798
+ const frameOpts = frameOptionsFromFlags(parsed.flags);
799
+ const contentTypeOverride = flagString(parsed.flags, "--content-type");
800
+ const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
801
+ const metadata = { ...metaExtras, ...ghMetadataForBranch(repo, branch) };
802
+ validateMetaMap(metadata);
803
+ const logHuman = !ctx.quiet && !ctx.json;
804
+ if (logHuman) {
805
+ const n = parsed.positionals.length;
806
+ process.stderr.write(`>> uploading ${n} file${n === 1 ? "" : "s"} (staged for branch ${branch})\n`);
807
+ }
808
+ const target = { repo, branch };
809
+ const { uploads, failures, firstError } = await uploadBranchAttachments({
810
+ client: ctx.client,
811
+ target,
812
+ files: parsed.positionals,
813
+ contentType: contentTypeOverride,
814
+ optimize: optimizeOpts,
815
+ frame: frameOpts,
816
+ metadata,
817
+ });
818
+ // Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
819
+ if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
820
+ throw firstError instanceof Error ? firstError : new Error(String(firstError));
821
+ }
822
+ if (ctx.json) {
823
+ await writeJson({ target, uploads, failures });
824
+ }
825
+ else {
826
+ for (const result of uploads) {
827
+ if (logHuman) {
828
+ if (result.frame?.framed) {
829
+ process.stderr.write(`>> ${basename(result.file)}: framed with ${result.frame.frameId}\n`);
830
+ }
831
+ const note = formatOptimizeNote(result.optimize);
832
+ if (note)
833
+ process.stderr.write(`>> ${basename(result.file)}: ${note}\n`);
834
+ writeReplacedNote(result.replaced, false);
835
+ }
836
+ const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
837
+ await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${result.markdown}\n`);
838
+ }
839
+ for (const failure of failures) {
840
+ process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
841
+ }
842
+ if (!ctx.quiet && uploads.length > 0) {
843
+ process.stderr.write(`>> find these later: uploads find gh.branch=${branch.toLowerCase()}\n`);
844
+ }
845
+ }
846
+ return failures.length === 0 ? 0 : 1;
847
+ }
848
+ /**
849
+ * `attach --promote` with zero file arguments: resolve the PR target (same
850
+ * resolution as the default `runAttach` path), promote this workspace's
851
+ * branch-staged attachments into it, then run the comment sync — useful
852
+ * right after `gh pr create` when the PR was opened without a fresh attach
853
+ * (auto-promotion on the default path only fires when you attach a file).
854
+ * Unlike the default path's best-effort branch resolution, this is an
855
+ * explicit user action: `resolveCurrentBranch` throwing (detached HEAD, not
856
+ * a repo) propagates as a UsageError instead of silently skipping. Always
857
+ * exits 0 — an empty staging prefix is success, not a failure.
858
+ */
859
+ async function runAttachPromoteOnly(ctx, parsed, run) {
860
+ const explicitTarget = ghTargetFromFlags(parsed.flags, run);
861
+ const target = explicitTarget ??
862
+ resolveCurrentPullRequest(resolveRepo(flagString(parsed.flags, "--repo"), run), run);
863
+ const branch = resolveCurrentBranch(run);
864
+ const promotion = await attemptPromoteBranch(ctx.client, target, branch);
865
+ let comment;
866
+ let commentError;
867
+ if (!parsed.flags.has("--no-comment")) {
868
+ try {
869
+ comment = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
870
+ }
871
+ catch (err) {
872
+ commentError = err instanceof Error ? err.message : String(err);
873
+ process.stderr.write(`warning: promotion succeeded but the GitHub comment failed (is gh installed and authenticated?): ${commentError}\n`);
874
+ }
875
+ }
876
+ if (ctx.json) {
877
+ await writeJson({
878
+ target,
879
+ uploads: [],
880
+ failures: [],
881
+ comment,
882
+ commentError,
883
+ promotion: promotion ?? null,
884
+ });
885
+ }
886
+ else {
887
+ if (!ctx.quiet && promotion && promotion.promoted.length > 0) {
888
+ process.stderr.write(promotionNote(promotion, branch));
889
+ }
890
+ if (!ctx.quiet && comment)
891
+ process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
892
+ }
893
+ return 0;
894
+ }
618
895
  export async function runPut(ctx, args, help = false, run = execRunner) {
619
896
  if (help) {
620
897
  writeCommandHelp(PUT_HELP);
@@ -815,7 +1092,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
815
1092
  let commentError;
816
1093
  if (wantComment && ghTarget && uploads.length > 0) {
817
1094
  try {
818
- comment = await syncAttachmentsComment(ctx.client, ghTarget, run);
1095
+ comment = await syncAttachmentsComment(ctx.client, ghTarget, run, ctx.config.workspace);
819
1096
  if (logHuman)
820
1097
  process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
821
1098
  }
@@ -1359,7 +1636,7 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
1359
1636
  const target = ghTargetFromFlags(parsed.flags, run);
1360
1637
  if (!target)
1361
1638
  throw new UsageError("comment requires --pr or --issue");
1362
- const result = await syncAttachmentsComment(ctx.client, target, run);
1639
+ const result = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
1363
1640
  if (ctx.json) {
1364
1641
  await writeJson({ ...target, ...result });
1365
1642
  }
@@ -1371,6 +1648,62 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
1371
1648
  }
1372
1649
  return 0;
1373
1650
  }
1651
+ // --- github link ---
1652
+ const GITHUB_HELP = `uploads github link [--repo <owner/name>] [--status] [--workspace <name>]
1653
+
1654
+ Claim or inspect this workspace's binding to a GitHub repo (see the managed
1655
+ attachments comment / webhook auto-promotion, which use this binding).
1656
+ First-claim-wins: claiming an already-bound repo never steals it from
1657
+ whichever workspace claimed it first — the command reports who owns it
1658
+ instead.
1659
+
1660
+ --repo defaults the same way as --pr/--issue elsewhere (gh repo view, then
1661
+ the git remote). --status only inspects the current binding (files:read);
1662
+ without it, the command claims the repo (files:write).
1663
+
1664
+ Examples:
1665
+ uploads github link
1666
+ uploads github link --repo buildinternet/uploads
1667
+ uploads github link --status
1668
+ `;
1669
+ function formatGithubLink(repo, result) {
1670
+ return result.workspace
1671
+ ? `${repo} is bound to workspace "${result.workspace}"${result.source ? ` (${result.source})` : ""}\n`
1672
+ : `${repo} is not bound to any workspace\n`;
1673
+ }
1674
+ export async function runGithub(ctx, args, help = false, run = execRunner) {
1675
+ const parsed = parseCommandArgs(args);
1676
+ const action = parsed.positionals[0];
1677
+ if (help || parsed.help || !action) {
1678
+ writeCommandHelp(GITHUB_HELP);
1679
+ return help || parsed.help ? 0 : 2;
1680
+ }
1681
+ if (action !== "link")
1682
+ throw new UsageError(`unknown github subcommand: ${action}`);
1683
+ const repo = resolveRepo(flagString(parsed.flags, "--repo"), run);
1684
+ const statusOnly = flagBool(parsed.flags, "--status");
1685
+ let result;
1686
+ try {
1687
+ result = statusOnly
1688
+ ? await ctx.client.githubLinkStatus(repo)
1689
+ : await ctx.client.githubLinkClaim(repo);
1690
+ }
1691
+ catch (err) {
1692
+ if (err instanceof UploadsError && err.status === 404) {
1693
+ throw new UsageError("server does not support repo bindings yet (404) — upgrade the uploads.sh API/self-hosted worker");
1694
+ }
1695
+ throw err;
1696
+ }
1697
+ if (ctx.json) {
1698
+ await writeJson(result);
1699
+ return 0;
1700
+ }
1701
+ if (!statusOnly && result.claimed === false) {
1702
+ process.stderr.write(`note: ${repo} is already bound to a different workspace ("${result.workspace}") — first-claim-wins, not overwritten\n`);
1703
+ }
1704
+ await writeStdout(formatGithubLink(repo, result));
1705
+ return 0;
1706
+ }
1374
1707
  // --- usage / reconcile / purge ---
1375
1708
  const USAGE_HELP = `uploads usage [--workspace <name>]
1376
1709
 
@@ -9,6 +9,8 @@ export declare const execRunner: CommandRunner;
9
9
  export declare function resolveRepo(explicit: string | undefined, run?: CommandRunner): string;
10
10
  /** Resolve the pull request associated with the current branch. */
11
11
  export declare function resolveCurrentPullRequest(repo: string, run?: CommandRunner): GhTarget;
12
+ /** Resolve the current git branch (`--branch` with no value). Throws UsageError on detached HEAD or outside a git repo. */
13
+ export declare function resolveCurrentBranch(run?: CommandRunner): string;
12
14
  /**
13
15
  * Classify a bare PR/issue number via the GitHub API so the default `put`
14
16
  * path can stamp the right `gh.kind`. Returns undefined on any failure (gh
@@ -39,7 +41,13 @@ export declare function ghMetadataFromTargetWithTitle(target: GhTarget, run?: Co
39
41
  * Create the managed attachments comment, or edit it in place if it already
40
42
  * exists. Never touches any other comment. Body is passed via stdin
41
43
  * (`-F body=@-`) so it is never shell-interpolated.
44
+ *
45
+ * `marker` identifies which comment to hunt for (see `findManagedComment`);
46
+ * `body` is expected to already carry that same marker as its first line
47
+ * (built via `attachmentsCommentBody(items, galleries, marker)`), so patching
48
+ * an adopted legacy comment migrates it to the namespaced marker in place.
49
+ * Defaults to the shared legacy marker for backward compatibility.
42
50
  */
43
- export declare function upsertAttachmentsComment(target: GhTarget, body: string, run?: CommandRunner): {
51
+ export declare function upsertAttachmentsComment(target: GhTarget, body: string, run?: CommandRunner, marker?: string): {
44
52
  created: boolean;
45
53
  };
package/dist/github-gh.js CHANGED
@@ -68,6 +68,20 @@ export function resolveCurrentPullRequest(repo, run = execRunner) {
68
68
  }
69
69
  throw new UsageError("could not infer a pull request for the current branch — pass --pr <num> or --issue <num>");
70
70
  }
71
+ /** Resolve the current git branch (`--branch` with no value). Throws UsageError on detached HEAD or outside a git repo. */
72
+ export function resolveCurrentBranch(run = execRunner) {
73
+ let branch;
74
+ try {
75
+ branch = run("git", ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
76
+ }
77
+ catch {
78
+ throw new UsageError("could not determine the current git branch — pass --branch <name> or run inside a git repo");
79
+ }
80
+ if (branch === "" || branch === "HEAD") {
81
+ throw new UsageError("could not determine the current branch (detached HEAD) — pass --branch <name>");
82
+ }
83
+ return branch;
84
+ }
71
85
  /**
72
86
  * Classify a bare PR/issue number via the GitHub API so the default `put`
73
87
  * path can stamp the right `gh.kind`. Returns undefined on any failure (gh
@@ -141,23 +155,41 @@ export function ghMetadataFromTargetWithTitle(target, run = execRunner) {
141
155
  * PR comments live on the issues endpoint, so one path covers PRs and issues.
142
156
  * `--paginate` follows Link headers and merges every page into one array, so the
143
157
  * marker comment is found even on threads past 100 comments.
158
+ *
159
+ * Hunts for `marker` (the namespaced, per-workspace marker) first; when none
160
+ * is found, falls back to a comment carrying the shared legacy
161
+ * `ATTACHMENTS_MARKER` (pre-4b, unnamespaced) so it can be adopted and
162
+ * migrated in place. When `marker` IS the legacy marker (no workspace to
163
+ * namespace with) this collapses to a single hunt, unchanged from pre-4b
164
+ * behavior.
144
165
  */
145
- function findManagedComment(target, run) {
166
+ function findManagedComment(target, run, marker) {
146
167
  const raw = run("gh", [
147
168
  "api",
148
169
  `repos/${target.repo}/issues/${target.num}/comments?per_page=100`,
149
170
  "--paginate",
150
171
  ]);
151
172
  const comments = JSON.parse(raw);
173
+ const namespacedHit = comments.find((c) => typeof c.body === "string" && c.body.includes(marker));
174
+ if (namespacedHit)
175
+ return namespacedHit;
176
+ if (marker === ATTACHMENTS_MARKER)
177
+ return undefined;
152
178
  return comments.find((c) => typeof c.body === "string" && c.body.includes(ATTACHMENTS_MARKER));
153
179
  }
154
180
  /**
155
181
  * Create the managed attachments comment, or edit it in place if it already
156
182
  * exists. Never touches any other comment. Body is passed via stdin
157
183
  * (`-F body=@-`) so it is never shell-interpolated.
184
+ *
185
+ * `marker` identifies which comment to hunt for (see `findManagedComment`);
186
+ * `body` is expected to already carry that same marker as its first line
187
+ * (built via `attachmentsCommentBody(items, galleries, marker)`), so patching
188
+ * an adopted legacy comment migrates it to the namespaced marker in place.
189
+ * Defaults to the shared legacy marker for backward compatibility.
158
190
  */
159
- export function upsertAttachmentsComment(target, body, run = execRunner) {
160
- const existing = findManagedComment(target, run);
191
+ export function upsertAttachmentsComment(target, body, run = execRunner, marker = ATTACHMENTS_MARKER) {
192
+ const existing = findManagedComment(target, run, marker);
161
193
  if (existing) {
162
194
  run("gh", [
163
195
  "api",
package/dist/github.d.ts CHANGED
@@ -22,6 +22,26 @@ export declare function ghKeyPrefix(target: GhTarget): string;
22
22
  * (unlike buildScreenshotKey).
23
23
  */
24
24
  export declare function ghAttachmentKey(target: GhTarget, filename: string): string;
25
+ /**
26
+ * Branch-staged attachment key prefix: `gh/<owner>/<repo>/branch/<branch>/`.
27
+ * Pre-PR staging (Phase 1 of branch-staged attachments) — no PR/issue number
28
+ * exists yet. `branch` goes through `sanitizeKeySegment` like every other key
29
+ * segment, so e.g. `feature/x` becomes `feature-x`.
30
+ */
31
+ export declare function ghBranchKeyPrefix(repo: string, branch: string): string;
32
+ /** Branch-staged attachment key: `ghBranchKeyPrefix` + the sanitized filename. */
33
+ export declare function ghBranchAttachmentKey(repo: string, branch: string, filename: string): string;
34
+ /**
35
+ * `gh.*` metadata for a branch-staged attach: `gh.repo`, `gh.kind=branch`,
36
+ * `gh.branch` (lowercased), and `gh.staged-at` (ISO 8601 UTC, no fractional
37
+ * seconds). No `gh.number`/`gh.ref`/`gh.title` — there is no PR/issue yet.
38
+ * Throws `UsageError` when the lowercased branch name fails the metadata
39
+ * value rule (printable ASCII, 1-512 chars) — unlike `gh.title`'s best-effort
40
+ * degrade, a branch name the caller explicitly chose (via `--branch` or the
41
+ * current git branch) is worth failing loudly on rather than silently
42
+ * dropping from the metadata set.
43
+ */
44
+ export declare function ghMetadataForBranch(repo: string, branch: string, now?: Date): Record<string, string>;
25
45
  /**
26
46
  * The four `gh.*` queryable-metadata pairs `uploads attach` writes
27
47
  * automatically (`.context/2026-07-13-file-metadata-design.md`). `gh.kind`
@@ -36,11 +56,25 @@ export declare function ghAttachmentKey(target: GhTarget, filename: string): str
36
56
  export declare function ghMetadataFromTarget(target: GhTarget): Record<string, string>;
37
57
  /** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
38
58
  export declare const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
59
+ /**
60
+ * Per-workspace marker (`<!-- uploads.sh:attachments ws=<workspace> -->`) so
61
+ * two workspaces managing the same repo don't clobber each other's comment.
62
+ * Falls back to the shared legacy marker when `workspace` is missing or does
63
+ * not look like a safe slug — degrade, don't guess or risk breaking the
64
+ * comment's HTML.
65
+ */
66
+ export declare function attachmentsMarker(workspace?: string): string;
67
+ /** Max attachments embedded as inline `<img>` tags before the rest collapse
68
+ * into a `<details>` link list. Keeps very large threads from becoming a wall
69
+ * of images. */
70
+ export declare const MAX_INLINE_ATTACHMENT_IMAGES = 16;
39
71
  export interface AttachmentItem {
40
72
  key: string;
41
73
  url: string | null;
42
74
  /** Prefer for `<img src>` on GitHub (Camo-friendly host). Falls back to `url`. */
43
75
  embedUrl?: string | null;
76
+ /** Canonical `/f/` file-page URL (server-computed). Preferred click-through target; falls back to `url`. */
77
+ pageUrl?: string | null;
44
78
  }
45
79
  /** A public gallery linked to the PR or issue whose managed comment is syncing. */
46
80
  export interface GalleryCommentItem {
@@ -70,4 +104,4 @@ export declare function attachmentImageWidth(filename: string): number;
70
104
  * Render the one marker-owned GitHub comment. When there are no galleries this
71
105
  * intentionally preserves the legacy attachment-only body byte-for-byte.
72
106
  */
73
- export declare function attachmentsCommentBody(items: AttachmentItem[], galleries?: GalleryCommentItem[]): string;
107
+ export declare function attachmentsCommentBody(items: AttachmentItem[], galleries?: GalleryCommentItem[], marker?: string): string;
package/dist/github.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { inferContentType } from "./embed.js";
2
2
  import { sanitizeKeySegment } from "./keys.js";
3
+ import { UsageError } from "./cli-args.js";
4
+ import { isMetaValueSafe } from "./metadata.js";
3
5
  const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
4
6
  export function isValidRepo(repo) {
5
7
  return REPO_RE.test(repo);
@@ -63,6 +65,42 @@ export function ghKeyPrefix(target) {
63
65
  export function ghAttachmentKey(target, filename) {
64
66
  return `${ghKeyPrefix(target)}${sanitizeKeySegment(filename)}`;
65
67
  }
68
+ /**
69
+ * Branch-staged attachment key prefix: `gh/<owner>/<repo>/branch/<branch>/`.
70
+ * Pre-PR staging (Phase 1 of branch-staged attachments) — no PR/issue number
71
+ * exists yet. `branch` goes through `sanitizeKeySegment` like every other key
72
+ * segment, so e.g. `feature/x` becomes `feature-x`.
73
+ */
74
+ export function ghBranchKeyPrefix(repo, branch) {
75
+ const [owner, name] = repo.split("/");
76
+ return `gh/${sanitizeKeySegment(owner)}/${sanitizeKeySegment(name)}/branch/${sanitizeKeySegment(branch)}/`;
77
+ }
78
+ /** Branch-staged attachment key: `ghBranchKeyPrefix` + the sanitized filename. */
79
+ export function ghBranchAttachmentKey(repo, branch, filename) {
80
+ return `${ghBranchKeyPrefix(repo, branch)}${sanitizeKeySegment(filename)}`;
81
+ }
82
+ /**
83
+ * `gh.*` metadata for a branch-staged attach: `gh.repo`, `gh.kind=branch`,
84
+ * `gh.branch` (lowercased), and `gh.staged-at` (ISO 8601 UTC, no fractional
85
+ * seconds). No `gh.number`/`gh.ref`/`gh.title` — there is no PR/issue yet.
86
+ * Throws `UsageError` when the lowercased branch name fails the metadata
87
+ * value rule (printable ASCII, 1-512 chars) — unlike `gh.title`'s best-effort
88
+ * degrade, a branch name the caller explicitly chose (via `--branch` or the
89
+ * current git branch) is worth failing loudly on rather than silently
90
+ * dropping from the metadata set.
91
+ */
92
+ export function ghMetadataForBranch(repo, branch, now = new Date()) {
93
+ const branchLower = branch.toLowerCase();
94
+ if (!isMetaValueSafe(branchLower)) {
95
+ throw new UsageError(`invalid --branch: "${branch}" must be printable ASCII to stage as gh.branch metadata`);
96
+ }
97
+ return {
98
+ "gh.repo": repo.toLowerCase(),
99
+ "gh.kind": "branch",
100
+ "gh.branch": branchLower,
101
+ "gh.staged-at": now.toISOString().replace(/\.\d{3}Z$/, "Z"),
102
+ };
103
+ }
66
104
  /**
67
105
  * The four `gh.*` queryable-metadata pairs `uploads attach` writes
68
106
  * automatically (`.context/2026-07-13-file-metadata-design.md`). `gh.kind`
@@ -85,6 +123,28 @@ export function ghMetadataFromTarget(target) {
85
123
  }
86
124
  /** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
87
125
  export const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
126
+ /** Workspace slugs are `[a-z0-9-]`-ish; only markers built from a slug matching
127
+ * this are trusted as a distinct namespace — anything else (empty, unsafe
128
+ * chars) degrades to the shared legacy marker rather than emitting untrusted
129
+ * text into comment HTML. */
130
+ const WORKSPACE_SLUG_RE = /^[a-z0-9-]{1,64}$/;
131
+ /**
132
+ * Per-workspace marker (`<!-- uploads.sh:attachments ws=<workspace> -->`) so
133
+ * two workspaces managing the same repo don't clobber each other's comment.
134
+ * Falls back to the shared legacy marker when `workspace` is missing or does
135
+ * not look like a safe slug — degrade, don't guess or risk breaking the
136
+ * comment's HTML.
137
+ */
138
+ export function attachmentsMarker(workspace) {
139
+ if (workspace && WORKSPACE_SLUG_RE.test(workspace)) {
140
+ return `<!-- uploads.sh:attachments ws=${workspace} -->`;
141
+ }
142
+ return ATTACHMENTS_MARKER;
143
+ }
144
+ /** Max attachments embedded as inline `<img>` tags before the rest collapse
145
+ * into a `<details>` link list. Keeps very large threads from becoming a wall
146
+ * of images. */
147
+ export const MAX_INLINE_ATTACHMENT_IMAGES = 16;
88
148
  /** Default max width for images in the managed attachments comment (HTML img). */
89
149
  export const ATTACHMENT_IMAGE_WIDTH_DEFAULT = 400;
90
150
  /** Portrait / device mockups — keep phones readable, not full-column. */
@@ -116,10 +176,10 @@ function escapeHtmlText(s) {
116
176
  * Render the one marker-owned GitHub comment. When there are no galleries this
117
177
  * intentionally preserves the legacy attachment-only body byte-for-byte.
118
178
  */
119
- export function attachmentsCommentBody(items, galleries = []) {
179
+ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMENTS_MARKER) {
120
180
  const sorted = items.toSorted((a, b) => a.key.localeCompare(b.key));
121
181
  const sortedGalleries = galleries.toSorted((a, b) => a.title.localeCompare(b.title) || a.url.localeCompare(b.url));
122
- const lines = [ATTACHMENTS_MARKER];
182
+ const lines = [marker];
123
183
  if (sortedGalleries.length > 0) {
124
184
  lines.push("### 🖼️ Galleries", "");
125
185
  for (const gallery of sortedGalleries) {
@@ -136,27 +196,48 @@ export function attachmentsCommentBody(items, galleries = []) {
136
196
  }
137
197
  if (sorted.length > 0 || sortedGalleries.length === 0)
138
198
  lines.push("### 📎 Attachments", "");
199
+ let inlinedImages = 0;
200
+ const overflowImages = [];
139
201
  for (const item of sorted) {
140
202
  const name = item.key.slice(item.key.lastIndexOf("/") + 1);
141
203
  const stable = item.url;
142
204
  const src = item.embedUrl ?? item.url;
143
- if (src && inferContentType(name).startsWith("image/")) {
205
+ const link = item.pageUrl ?? stable; // click-through: file page when known, else raw
206
+ const isImage = Boolean(src) && inferContentType(name).startsWith("image/");
207
+ if (isImage && inlinedImages >= MAX_INLINE_ATTACHMENT_IMAGES) {
208
+ // Cap hit — defer to the collapsed overflow list below rather than
209
+ // embedding every remaining image inline.
210
+ overflowImages.push(item);
211
+ continue;
212
+ }
213
+ if (isImage) {
214
+ inlinedImages++;
144
215
  // Markdown ![]() has no width control — phone frames become full-column giants.
145
- // img src uses embed host when available (Camo revalidates); click-through keeps stable URL.
216
+ // img src uses embed host when available (Camo revalidates); click-through prefers the file page.
146
217
  const w = attachmentImageWidth(name);
147
218
  const alt = escapeHtmlAttr(name);
148
- const href = escapeHtmlAttr(stable ?? src);
219
+ const href = escapeHtmlAttr(link ?? src);
149
220
  const imgSrc = escapeHtmlAttr(src);
150
221
  lines.push(`<a href="${href}"><img width="${w}" alt="${alt}" src="${imgSrc}"></a>`);
151
222
  lines.push("");
152
223
  }
153
- else if (stable) {
154
- lines.push(`- [${name}](${stable})`);
224
+ else if (link) {
225
+ lines.push(`- [${name}](${link})`);
155
226
  }
156
227
  else {
157
228
  lines.push(`- ${name}`);
158
229
  }
159
230
  }
231
+ if (overflowImages.length > 0) {
232
+ const n = overflowImages.length;
233
+ lines.push(`<details><summary>${n} more attachment${n === 1 ? "" : "s"}</summary>`, "");
234
+ for (const item of overflowImages) {
235
+ const name = item.key.slice(item.key.lastIndexOf("/") + 1);
236
+ const link = item.pageUrl ?? item.url;
237
+ lines.push(link ? `- [${name}](${link})` : `- ${name}`);
238
+ }
239
+ lines.push("", "</details>", "");
240
+ }
160
241
  lines.push('<sub>Maintained by <a href="https://uploads.sh">uploads.sh</a> — re-uploading a file with the same name updates it everywhere it is embedded.</sub>');
161
242
  return lines.join("\n");
162
243
  }
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export { UploadsError, type UploadsErrorCode } from "./errors.js";
7
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 GetMetadataResult, type PatchMetadataOptions, } 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, ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, isValidRepo, parseRepoFromRemoteUrl, type AttachmentItem, type GhTarget, type GhTargetKind, } from "./github.js";
10
+ export { ATTACHMENTS_MARKER, attachmentsCommentBody, ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghBranchAttachmentKey, ghBranchKeyPrefix, ghMetadataForBranch, isValidRepo, parseRepoFromRemoteUrl, type AttachmentItem, type GhTarget, type GhTargetKind, } 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,7 @@ 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, ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, isValidRepo, parseRepoFromRemoteUrl, } from "./github.js";
10
+ export { ATTACHMENTS_MARKER, attachmentsCommentBody, ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghBranchAttachmentKey, ghBranchKeyPrefix, ghMetadataForBranch, isValidRepo, parseRepoFromRemoteUrl, } from "./github.js";
11
11
  export { DEFAULT_OPTIMIZE_MAX_EDGE, DEFAULT_OPTIMIZE_QUALITY, optimizeImageForUpload, rewriteKeyExtension, withImageExtension, } from "./optimize.js";
12
12
  export { FRAME_PRESETS, applyFrame, listFramePresets, resolveFrameId, } from "./frame.js";
13
13
  export { execRunner, resolveRepo, upsertAttachmentsComment, } from "./github-gh.js";
package/dist/mcp/tools.js CHANGED
@@ -123,11 +123,11 @@ export function createUploadsMcpTools(opts) {
123
123
  });
124
124
  return { config, client: clientFactory(config) };
125
125
  }
126
- const syncComment = async (client, target) => {
126
+ const syncComment = async (client, target, workspace) => {
127
127
  let comment;
128
128
  let commentError;
129
129
  try {
130
- comment = await syncAttachmentsComment(client, target, run);
130
+ comment = await syncAttachmentsComment(client, target, run, workspace);
131
131
  }
132
132
  catch (err) {
133
133
  // Uploads already succeeded; the comment is best-effort by design.
@@ -402,7 +402,7 @@ export function createUploadsMcpTools(opts) {
402
402
  catch (err) {
403
403
  usage(err instanceof Error ? err.message : String(err));
404
404
  }
405
- const { client } = clientFor(args);
405
+ const { config, client } = clientFor(args);
406
406
  const defaults = resolvePutDefaults({ envFile: globals.envFile });
407
407
  const frameOpts = mcpFrameOptions(args);
408
408
  const optimizeOpts = mcpOptimizeOptions(args, defaults);
@@ -436,7 +436,7 @@ export function createUploadsMcpTools(opts) {
436
436
  throw new ToolBatchError(batchFailureMessage(failures), { uploads, failures });
437
437
  }
438
438
  if (wantComment && target && uploads.length > 0) {
439
- const { comment, commentError } = await syncComment(client, target);
439
+ const { comment, commentError } = await syncComment(client, target, config.workspace);
440
440
  return { uploads, failures, comment, commentError };
441
441
  }
442
442
  return { uploads, failures };
@@ -469,7 +469,7 @@ export function createUploadsMcpTools(opts) {
469
469
  filename: prepared.filename,
470
470
  };
471
471
  if (wantComment && target) {
472
- const { comment, commentError } = await syncComment(client, target);
472
+ const { comment, commentError } = await syncComment(client, target, config.workspace);
473
473
  return { ...result, markdown, optimize, frame: prepared.frame, comment, commentError };
474
474
  }
475
475
  return {
@@ -504,7 +504,7 @@ export function createUploadsMcpTools(opts) {
504
504
  ...(dryRun ? { dryRun: true } : {}),
505
505
  };
506
506
  if (wantComment && target) {
507
- const { comment, commentError } = await syncComment(client, target);
507
+ const { comment, commentError } = await syncComment(client, target, config.workspace);
508
508
  return { ...flat, comment, commentError };
509
509
  }
510
510
  return flat;
@@ -761,7 +761,7 @@ export function createUploadsMcpTools(opts) {
761
761
  ...(dryRun ? { dryRun: true } : {}),
762
762
  };
763
763
  if (wantComment && target) {
764
- const { comment, commentError } = await syncComment(client, target);
764
+ const { comment, commentError } = await syncComment(client, target, config.workspace);
765
765
  return { ...flat, comment, commentError };
766
766
  }
767
767
  return flat;
@@ -821,7 +821,7 @@ export function createUploadsMcpTools(opts) {
821
821
  const explicitTarget = ghTargetFromArgs(args, run);
822
822
  const target = explicitTarget ??
823
823
  resolveCurrentPullRequest(resolveRepo(optString(args, "repo"), run), run);
824
- const { client } = clientFor(args);
824
+ const { config, client } = clientFor(args);
825
825
  const contentType = optString(args, "contentType");
826
826
  const defaults = resolvePutDefaults({ envFile: globals.envFile });
827
827
  const frameOpts = mcpFrameOptions(args);
@@ -856,7 +856,7 @@ export function createUploadsMcpTools(opts) {
856
856
  }
857
857
  if (optBool(args, "noComment"))
858
858
  return { target, uploads, failures };
859
- const { comment, commentError } = await syncComment(client, target);
859
+ const { comment, commentError } = await syncComment(client, target, config.workspace);
860
860
  return { target, uploads, failures, comment, commentError };
861
861
  },
862
862
  },
@@ -1062,8 +1062,8 @@ export function createUploadsMcpTools(opts) {
1062
1062
  const target = ghTargetFromArgs(args, run);
1063
1063
  if (!target)
1064
1064
  usage("comment requires pr or issue");
1065
- const { client } = clientFor(args);
1066
- const result = await syncAttachmentsComment(client, target, run);
1065
+ const { config, client } = clientFor(args);
1066
+ const result = await syncAttachmentsComment(client, target, run, config.workspace);
1067
1067
  return { ...target, ...result };
1068
1068
  },
1069
1069
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.14.0",
3
+ "version": "0.15.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,