@buildinternet/uploads 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { UploadsClientConfig } from "./config.js";
2
+ /** Scoped-operator-token permission scope. */
3
+ export type TokenScope = "files:read" | "files:write" | "files:delete" | "operator:read" | "operator:write" | "workspace:invite" | "workspace:manage";
2
4
  /** Allowlisted object provenance (maps to X-Uploads-Meta-* on put). */
3
5
  export type ProvenanceInput = {
4
6
  client?: string;
@@ -168,6 +170,17 @@ export interface FindGalleriesByReferenceOptions {
168
170
  limit?: number;
169
171
  cursor?: string;
170
172
  }
173
+ /** Reasons the bot did not post; the CLI treats any of them as "fall back to gh". */
174
+ export type GithubCommentDeclineReason = "app_unconfigured" | "not_installed" | "forbidden" | "unavailable";
175
+ export type GithubCommentResult = {
176
+ posted: true;
177
+ action: "created" | "updated" | "skipped";
178
+ count: number;
179
+ commentUrl?: string;
180
+ } | {
181
+ posted: false;
182
+ reason: GithubCommentDeclineReason;
183
+ };
171
184
  export interface HealthResult {
172
185
  ok: boolean;
173
186
  }
@@ -212,7 +225,7 @@ export interface EnrollmentExchangeResult {
212
225
  apiUrl?: string;
213
226
  workspace: string;
214
227
  token: string;
215
- scopes?: Array<"files:read" | "files:write" | "files:delete">;
228
+ scopes?: Array<TokenScope>;
216
229
  expiresAt?: string;
217
230
  }
218
231
  export interface EnrollmentCreateResult {
@@ -229,9 +242,16 @@ export declare function createEnrollment(apiUrl: string, adminToken: string, inp
229
242
  email?: string;
230
243
  enrollmentSeconds?: number;
231
244
  tokenExpiresInSeconds?: number;
232
- scopes?: Array<"files:read" | "files:write" | "files:delete">;
245
+ scopes?: Array<TokenScope>;
233
246
  }): Promise<EnrollmentCreateResult>;
234
- /** Static OAuth client id allowlisted by the auth worker's `validateClient`. */
247
+ /**
248
+ * OAuth client id for the device flow. Registered server-side as a managed
249
+ * official `oauth_client` row (seeded by apps/auth migration
250
+ * 20260719000000_seed_cli_oauth_client.sql — issue #251): public PKCE client,
251
+ * no secret, device-code grant only. The auth worker's device endpoints
252
+ * validate this id against that table, so operators can disable it from
253
+ * /admin/oauth. The literal must match the seeded row's client_id.
254
+ */
235
255
  export declare const DEVICE_CLIENT_ID = "uploads-cli";
236
256
  /**
237
257
  * User-Agent for device-flow requests. Stored on the Better Auth session row
@@ -302,7 +322,7 @@ export declare function createWorkspaceRequest(apiUrl: string, accessToken: stri
302
322
  export interface MintTokenResult {
303
323
  token: string;
304
324
  workspace: string;
305
- scopes: Array<"files:read" | "files:write" | "files:delete">;
325
+ scopes: Array<TokenScope>;
306
326
  label: string | null;
307
327
  expiresAt: string | null;
308
328
  }
@@ -332,7 +352,7 @@ export declare function createWorkspaceInvite(apiUrl: string, accessToken: strin
332
352
  */
333
353
  export declare function mintWorkspaceToken(apiUrl: string, accessToken: string, input: {
334
354
  workspace: string;
335
- scopes?: Array<"files:read" | "files:write" | "files:delete">;
355
+ scopes?: Array<TokenScope>;
336
356
  label?: string;
337
357
  ttlSeconds?: number;
338
358
  }): Promise<MintTokenResult>;
@@ -387,6 +407,11 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
387
407
  id: string;
388
408
  }>;
389
409
  findGalleriesByReference(opts: FindGalleriesByReferenceOptions): Promise<GalleryListResult>;
410
+ upsertGithubComment(opts: {
411
+ repo: string;
412
+ num: number;
413
+ kind: "pull" | "issues";
414
+ }): Promise<GithubCommentResult>;
390
415
  health(): Promise<HealthResult>;
391
416
  /** Workspace storage / upload counters (+ limits when configured). */
392
417
  usage(): Promise<UsageResult>;
package/dist/client.js CHANGED
@@ -37,7 +37,14 @@ export function createEnrollment(apiUrl, adminToken, input) {
37
37
  // D5). Better Auth's `device.code`/`device.token` endpoints take
38
38
  // `application/json` bodies, NOT the RFC's form-encoding — the JSON shapes
39
39
  // below are what the worker expects.
40
- /** Static OAuth client id allowlisted by the auth worker's `validateClient`. */
40
+ /**
41
+ * OAuth client id for the device flow. Registered server-side as a managed
42
+ * official `oauth_client` row (seeded by apps/auth migration
43
+ * 20260719000000_seed_cli_oauth_client.sql — issue #251): public PKCE client,
44
+ * no secret, device-code grant only. The auth worker's device endpoints
45
+ * validate this id against that table, so operators can disable it from
46
+ * /admin/oauth. The literal must match the seeded row's client_id.
47
+ */
41
48
  export const DEVICE_CLIENT_ID = "uploads-cli";
42
49
  /**
43
50
  * User-Agent for device-flow requests. Stored on the Better Auth session row
@@ -445,6 +452,12 @@ export function createUploadsClient(config) {
445
452
  params.set("cursor", opts.cursor);
446
453
  return request("GET", galleriesBase(config) + "/by-reference?" + params);
447
454
  },
455
+ async upsertGithubComment(opts) {
456
+ return request("POST", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/comment`, {
457
+ body: new TextEncoder().encode(JSON.stringify(opts)),
458
+ headers: { "Content-Type": "application/json" },
459
+ });
460
+ },
448
461
  async health() {
449
462
  return request("GET", `${config.apiUrl}/health`, { auth: false });
450
463
  },
@@ -2,12 +2,11 @@ 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, uploadPreparedImage, } from "../commands.js";
5
+ import { 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 { ghMetadataFromTarget } from "../github.js";
10
- import { execRunner } from "../github-gh.js";
9
+ import { execRunner, ghMetadataFromTargetWithTitle } from "../github-gh.js";
11
10
  import { parseMetaFlags, validateMetaMap } from "../metadata.js";
12
11
  import { writeJson, writeStdout } from "../io.js";
13
12
  import { assertHideSelector, captureScreenshot, parseViewport, parseWaitUntil, } from "../screenshot.js";
@@ -67,7 +66,9 @@ Options:
67
66
  --keep-exif Keep EXIF/XMP/ICC when optimizing
68
67
  --pr <num> Attach to a pull request (stable URL, no hash)
69
68
  --issue <num> Attach to an issue
70
- --comment With --pr/--issue: update the managed attachments comment
69
+ --comment With --pr/--issue: update the managed attachments comment.
70
+ Posts as uploads-sh[bot] when the GitHub App is installed;
71
+ otherwise via local gh.
71
72
  --gallery <id> Add the uploaded object to this public gallery
72
73
  --meta <k=v> Queryable custom metadata (repeatable)
73
74
  --workspace, -w <name> Override workspace
@@ -214,7 +215,7 @@ captureImpl = captureScreenshot) {
214
215
  const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
215
216
  let metadata = metaExtras;
216
217
  if (ghTarget) {
217
- metadata = { ...metaExtras, ...ghMetadataFromTarget(ghTarget) };
218
+ metadata = { ...metaExtras, ...ghMetadataFromTargetWithTitle(ghTarget, run) };
218
219
  validateMetaMap(metadata);
219
220
  }
220
221
  else if (Object.keys(metaExtras).length > 0) {
@@ -296,7 +297,7 @@ captureImpl = captureScreenshot) {
296
297
  try {
297
298
  comment = await syncAttachmentsComment(ctx.client, ghTarget, run);
298
299
  if (logHuman)
299
- process.stderr.write(`>> attachments comment ${comment.action}\n`);
300
+ process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
300
301
  }
301
302
  catch (err) {
302
303
  commentError = err instanceof Error ? err.message : String(err);
@@ -89,13 +89,22 @@ export declare function frameOptionsFromFlags(flags: CommandFlags["flags"]): {
89
89
  };
90
90
  /**
91
91
  * List every attachment under the target's prefix and create/update the
92
- * managed comment. Throws on gh failure callers decide whether that is
93
- * fatal (`comment` command) or a warning (`put --comment`).
92
+ * managed comment. Prefers the server-side bot endpoint (`uploads-sh[bot]`,
93
+ * rendered from this workspace's own data); any failure to post that way —
94
+ * not installed, declined, self-hosted 404, network error — falls through to
95
+ * the local-`gh` path so self-hosters keep working unchanged. Throws on gh
96
+ * failure — callers decide whether that is fatal (`comment` command) or a
97
+ * warning (`put --comment`).
94
98
  */
95
- export declare function syncAttachmentsComment(client: UploadsClient, target: GhTarget, run: CommandRunner): Promise<{
99
+ export interface AttachmentsCommentResult {
96
100
  action: "created" | "updated" | "skipped";
97
101
  count: number;
98
- }>;
102
+ /** Who posted the comment: the GitHub App bot, or the local `gh` fallback. */
103
+ via: "bot" | "gh";
104
+ }
105
+ /** Human-mode suffix noting who posted the managed comment. */
106
+ export declare function commentViaSuffix(via: AttachmentsCommentResult["via"]): string;
107
+ export declare function syncAttachmentsComment(client: UploadsClient, target: GhTarget, run: CommandRunner): Promise<AttachmentsCommentResult>;
99
108
  export type AttachUploadItem = PutResult & {
100
109
  file: string;
101
110
  markdown: string;
package/dist/commands.js CHANGED
@@ -10,7 +10,7 @@ import { UploadsError } from "./errors.js";
10
10
  import { writeJson, writeStdout } from "./io.js";
11
11
  import { parseMetaFlags, validateMetaMap } from "./metadata.js";
12
12
  import { ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, attachmentsCommentBody, normalizeGithubCoordinate, } from "./github.js";
13
- import { resolveRepo, resolveCurrentPullRequest, classifyGhNumber, execRunner, upsertAttachmentsComment, } from "./github-gh.js";
13
+ import { resolveRepo, resolveCurrentPullRequest, 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";
@@ -89,7 +89,9 @@ Options:
89
89
  --format human|url|markdown|json
90
90
  --pr <num> Attach to a pull request: key gh/<owner>/<repo>/pull/<num>/<name> (stable URL, no hash)
91
91
  --issue <num> Attach to an issue: key gh/<owner>/<repo>/issues/<num>/<name>
92
- --comment With --pr/--issue: update one managed comment with attachments and linked galleries via local gh auth
92
+ --comment With --pr/--issue: update one managed comment with
93
+ attachments and linked galleries. Posts as uploads-sh[bot]
94
+ when the GitHub App is installed; otherwise via local gh.
93
95
  --gallery <id> Add the uploaded object(s) to this public gallery
94
96
  --meta <k=v> Queryable custom metadata (repeatable; value may contain "="): key ^[a-z][a-z0-9._-]{0,63}$, value 1-512 printable ASCII, max 24 pairs
95
97
  Re-uploading to an existing key WITH --meta replaces that file's
@@ -272,12 +274,25 @@ export function frameOptionsFromFlags(flags) {
272
274
  throw new UsageError("--frame-url requires --frame");
273
275
  return { frameId, frameUrl, frameFit };
274
276
  }
275
- /**
276
- * List every attachment under the target's prefix and create/update the
277
- * managed comment. Throws on gh failure callers decide whether that is
278
- * fatal (`comment` command) or a warning (`put --comment`).
279
- */
277
+ /** Human-mode suffix noting who posted the managed comment. */
278
+ export function commentViaSuffix(via) {
279
+ return via === "bot" ? " (uploads-sh[bot])" : " (via gh)";
280
+ }
280
281
  export async function syncAttachmentsComment(client, target, run) {
282
+ try {
283
+ const bot = await client.upsertGithubComment({
284
+ repo: target.repo,
285
+ num: target.num,
286
+ kind: target.kind,
287
+ });
288
+ if (bot.posted)
289
+ return { action: bot.action, count: bot.count, via: "bot" };
290
+ }
291
+ catch {
292
+ // Endpoint absent/unreachable (self-hosted, network, older worker) — fall
293
+ // through to the gh path below.
294
+ }
295
+ // gh fallback: gather from this workspace's own data and post via local `gh`.
281
296
  const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url, embedUrl }) => ({ key, url, embedUrl }));
282
297
  const galleries = [];
283
298
  let cursor;
@@ -313,10 +328,14 @@ export async function syncAttachmentsComment(client, target, run) {
313
328
  }
314
329
  }));
315
330
  if (items.length === 0 && previewGalleries.length === 0)
316
- return { action: "skipped", count: 0 };
331
+ return { action: "skipped", count: 0, via: "gh" };
317
332
  const body = attachmentsCommentBody(items, previewGalleries);
318
333
  const { created } = upsertAttachmentsComment(target, body, run);
319
- return { action: created ? "created" : "updated", count: items.length + previewGalleries.length };
334
+ return {
335
+ action: created ? "created" : "updated",
336
+ count: items.length + previewGalleries.length,
337
+ via: "gh",
338
+ };
320
339
  }
321
340
  // --- attach ---
322
341
  const ATTACH_HELP = `uploads attach <file...> [options]
@@ -532,9 +551,9 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
532
551
  // target pairs always win over a same-named --meta extra (documented above).
533
552
  // Validate the merged map (not just the extras) so the 24-key/8KB caps are
534
553
  // enforced client-side even when extras alone are under the cap but extras
535
- // + the 4 gh.* pairs push the merged map over it.
554
+ // + the gh.* pairs push the merged map over it.
536
555
  const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
537
- const metadata = { ...metaExtras, ...ghMetadataFromTarget(target) };
556
+ const metadata = { ...metaExtras, ...ghMetadataFromTargetWithTitle(target, run) };
538
557
  if (Object.keys(metadata).length > 0)
539
558
  validateMetaMap(metadata);
540
559
  const logHuman = !ctx.quiet && !ctx.json;
@@ -588,7 +607,7 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
588
607
  process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
589
608
  }
590
609
  if (!ctx.quiet && comment)
591
- process.stderr.write(`>> attachments comment ${comment.action}\n`);
610
+ process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
592
611
  if (!ctx.quiet && uploads.length > 0) {
593
612
  const ref = ghMetadataFromTarget(target)["gh.ref"];
594
613
  process.stderr.write(`>> find these later: uploads find gh.ref=${ref}\n`);
@@ -711,7 +730,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
711
730
  let metadata = userMeta;
712
731
  let attachedRef;
713
732
  if (ghTarget) {
714
- const merged = { ...userMeta, ...ghMetadataFromTarget(ghTarget) };
733
+ const merged = { ...userMeta, ...ghMetadataFromTargetWithTitle(ghTarget, run) };
715
734
  validateMetaMap(merged); // enforce 24-key/8KB caps on the merged map (matches attach)
716
735
  metadata = merged;
717
736
  attachedRef = merged["gh.ref"];
@@ -723,7 +742,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
723
742
  if (autoEnabled) {
724
743
  const autoTarget = resolveAutoGhTarget(flagString(parsed.flags, "--repo") ?? defaults.repo, flagString(parsed.flags, "--ref") ?? defaults.ref, run);
725
744
  if (autoTarget) {
726
- const autoMeta = ghMetadataFromTarget(autoTarget);
745
+ const autoMeta = ghMetadataFromTargetWithTitle(autoTarget, run);
727
746
  const merged = { ...autoMeta, ...userMeta };
728
747
  // Auto resolution must never fail the upload: if merging the gh.* pairs
729
748
  // would exceed the metadata caps, drop them and upload with --meta only.
@@ -798,7 +817,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
798
817
  try {
799
818
  comment = await syncAttachmentsComment(ctx.client, ghTarget, run);
800
819
  if (logHuman)
801
- process.stderr.write(`>> attachments comment ${comment.action}\n`);
820
+ process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
802
821
  }
803
822
  catch (err) {
804
823
  commentError = err instanceof Error ? err.message : String(err);
@@ -1322,7 +1341,8 @@ export async function runDelete(ctx, args, help = false) {
1322
1341
  const COMMENT_HELP = `uploads comment (--pr <num> | --issue <num>) [--repo <owner/name>] [--workspace <name>]
1323
1342
 
1324
1343
  Create or update the managed attachments comment on a GitHub PR or issue,
1325
- listing everything uploaded for it. Uses your local gh auth. Finds its own
1344
+ listing everything uploaded for it. Posts as uploads-sh[bot] when the GitHub
1345
+ App is installed on the repo; otherwise via your local gh auth. Finds its own
1326
1346
  prior comment via a hidden marker and edits it in place; never touches other
1327
1347
  comments or the description.
1328
1348
 
@@ -1344,9 +1364,10 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
1344
1364
  await writeJson({ ...target, ...result });
1345
1365
  }
1346
1366
  else if (!ctx.quiet) {
1367
+ const via = commentViaSuffix(result.via);
1347
1368
  process.stderr.write(result.action === "skipped"
1348
1369
  ? `no attachments under ${ghKeyPrefix(target)} — nothing to do\n`
1349
- : `${result.action} attachments comment on ${target.repo}#${target.num} (${result.count} file${result.count === 1 ? "" : "s"})\n`);
1370
+ : `${result.action} attachments comment on ${target.repo}#${target.num} (${result.count} file${result.count === 1 ? "" : "s"})${via}\n`);
1350
1371
  }
1351
1372
  return 0;
1352
1373
  }
@@ -16,6 +16,25 @@ export declare function resolveCurrentPullRequest(repo: string, run?: CommandRun
16
16
  * uploads without metadata.
17
17
  */
18
18
  export declare function classifyGhNumber(repo: string, num: number, run?: CommandRunner): GhTarget | undefined;
19
+ /**
20
+ * Best-effort PR/issue title lookup via local `gh`. Returns undefined on any
21
+ * failure (gh missing, unauthenticated, network, 404) — mirrors
22
+ * `resolveCurrentPullRequest`/`classifyGhNumber`'s degrade-don't-throw
23
+ * pattern. A title is a nice-to-have annotation, never a blocker: callers
24
+ * must never let this failure abort an upload.
25
+ */
26
+ export declare function resolveGhTitle(target: GhTarget, run?: CommandRunner): string | undefined;
27
+ /**
28
+ * `ghMetadataFromTarget`'s 4 pairs, plus a best-effort `gh.title` (issue #267)
29
+ * when `resolveGhTitle` yields one that also satisfies the metadata-value
30
+ * rule every other pair follows (1-512 printable ASCII — `metadata.ts`'s
31
+ * `META_VALUE_MAX`/`isMetaValueSafe`). Truncated to `META_VALUE_MAX` first;
32
+ * a title left empty or unsafe by truncation (e.g. non-ASCII — real titles
33
+ * often contain emoji or curly quotes) is silently omitted rather than
34
+ * sanitized, matching `resolveGhTitle`'s own "degrade, don't fail the
35
+ * upload" contract.
36
+ */
37
+ export declare function ghMetadataFromTargetWithTitle(target: GhTarget, run?: CommandRunner): Record<string, string>;
19
38
  /**
20
39
  * Create the managed attachments comment, or edit it in place if it already
21
40
  * exists. Never touches any other comment. Body is passed via stdin
package/dist/github-gh.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { UsageError } from "./cli-args.js";
3
- import { ATTACHMENTS_MARKER, isValidRepo, parseRepoFromRemoteUrl, } from "./github.js";
3
+ import { ATTACHMENTS_MARKER, ghMetadataFromTarget, isValidRepo, parseRepoFromRemoteUrl, } from "./github.js";
4
+ import { META_VALUE_MAX, isMetaValueSafe } from "./metadata.js";
4
5
  export const execRunner = (cmd, args, input) => execFileSync(cmd, args, { encoding: "utf8", input, stdio: ["pipe", "pipe", "pipe"] });
5
6
  /**
6
7
  * Resolve "owner/name". Order: explicit --repo (validated) → `gh repo view`
@@ -91,12 +92,62 @@ export function classifyGhNumber(repo, num, run = execRunner) {
91
92
  }
92
93
  return undefined;
93
94
  }
95
+ /**
96
+ * Best-effort PR/issue title lookup via local `gh`. Returns undefined on any
97
+ * failure (gh missing, unauthenticated, network, 404) — mirrors
98
+ * `resolveCurrentPullRequest`/`classifyGhNumber`'s degrade-don't-throw
99
+ * pattern. A title is a nice-to-have annotation, never a blocker: callers
100
+ * must never let this failure abort an upload.
101
+ */
102
+ export function resolveGhTitle(target, run = execRunner) {
103
+ try {
104
+ const out = run("gh", [
105
+ target.kind === "pull" ? "pr" : "issue",
106
+ "view",
107
+ String(target.num),
108
+ "--repo",
109
+ target.repo,
110
+ "--json",
111
+ "title",
112
+ "--jq",
113
+ ".title",
114
+ ]).trim();
115
+ return out.length > 0 ? out : undefined;
116
+ }
117
+ catch {
118
+ // gh missing / unauthenticated / not found / network — caller skips
119
+ return undefined;
120
+ }
121
+ }
122
+ /**
123
+ * `ghMetadataFromTarget`'s 4 pairs, plus a best-effort `gh.title` (issue #267)
124
+ * when `resolveGhTitle` yields one that also satisfies the metadata-value
125
+ * rule every other pair follows (1-512 printable ASCII — `metadata.ts`'s
126
+ * `META_VALUE_MAX`/`isMetaValueSafe`). Truncated to `META_VALUE_MAX` first;
127
+ * a title left empty or unsafe by truncation (e.g. non-ASCII — real titles
128
+ * often contain emoji or curly quotes) is silently omitted rather than
129
+ * sanitized, matching `resolveGhTitle`'s own "degrade, don't fail the
130
+ * upload" contract.
131
+ */
132
+ export function ghMetadataFromTargetWithTitle(target, run = execRunner) {
133
+ const base = ghMetadataFromTarget(target);
134
+ const title = resolveGhTitle(target, run);
135
+ if (title === undefined)
136
+ return base;
137
+ const truncated = title.length > META_VALUE_MAX ? title.slice(0, META_VALUE_MAX) : title;
138
+ return isMetaValueSafe(truncated) ? { ...base, "gh.title": truncated } : base;
139
+ }
94
140
  /**
95
141
  * PR comments live on the issues endpoint, so one path covers PRs and issues.
96
- * Only the first 100 comments are searched (accepted v1 limitation).
142
+ * `--paginate` follows Link headers and merges every page into one array, so the
143
+ * marker comment is found even on threads past 100 comments.
97
144
  */
98
145
  function findManagedComment(target, run) {
99
- const raw = run("gh", ["api", `repos/${target.repo}/issues/${target.num}/comments?per_page=100`]);
146
+ const raw = run("gh", [
147
+ "api",
148
+ `repos/${target.repo}/issues/${target.num}/comments?per_page=100`,
149
+ "--paginate",
150
+ ]);
100
151
  const comments = JSON.parse(raw);
101
152
  return comments.find((c) => typeof c.body === "string" && c.body.includes(ATTACHMENTS_MARKER));
102
153
  }
package/dist/mcp/tools.js CHANGED
@@ -3,9 +3,9 @@ import { buildDoctorReport, makeGhTarget, syncAttachmentsComment, uploadAttachme
3
3
  import { resolveFrameId } from "../frame.js";
4
4
  import { resolveConfig, resolvePutDefaults, } from "../config.js";
5
5
  import { resolvePutPrefix } from "../destinations.js";
6
- import { ghKeyPrefix, ghMetadataFromTarget } from "../github.js";
6
+ import { ghKeyPrefix } from "../github.js";
7
7
  import { validateMetaMap } from "../metadata.js";
8
- import { execRunner, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
8
+ import { execRunner, ghMetadataFromTargetWithTitle, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
9
9
  import { METADATA_DESCRIPTION, metadataProp, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
10
10
  import { batchFailureMessage, ToolBatchError } from "./server.js";
11
11
  import { attachmentFromText, buildReportPayload, parseReportType, REPORT_TYPES, submitReport, validateReportMessage, } from "../report.js";
@@ -830,10 +830,10 @@ export function createUploadsMcpTools(opts) {
830
830
  // explicit target pairs always win over a same-named metadata extra
831
831
  // (mirrors runAttach in ../commands.js). Validate the merged map (not
832
832
  // just the extras) so the 24-key/8KB caps are enforced client-side —
833
- // extras alone might pass while extras + the 4 gh.* pairs exceed the
833
+ // extras alone might pass while extras + the gh.* pairs exceed the
834
834
  // cap, which would otherwise only be caught server-side after upload.
835
835
  const metaExtras = optStringRecord(args, "metadata") ?? {};
836
- const metadata = { ...metaExtras, ...ghMetadataFromTarget(target) };
836
+ const metadata = { ...metaExtras, ...ghMetadataFromTargetWithTitle(target, run) };
837
837
  if (Object.keys(metadata).length > 0)
838
838
  validateMetaMap(metadata);
839
839
  const { uploads, failures } = await uploadAttachments({
@@ -8,6 +8,14 @@ export declare const META_MAX_KEYS = 24;
8
8
  export declare const META_MAX_TOTAL_BYTES = 8192;
9
9
  /** Throws `UsageError` with a readable message if `key`/`value` violate the metadata rules. */
10
10
  export declare function validateMetaEntry(key: string, value: string): void;
11
+ /**
12
+ * Non-throwing version of `validateMetaEntry`'s value check (length +
13
+ * printable-ASCII), for callers that want to silently drop a value that
14
+ * doesn't fit rather than reject the whole request — e.g. a best-effort
15
+ * `gh.title` derived from a real-world PR/issue title, which may contain
16
+ * unicode (emoji, curly quotes) that the metadata value rule disallows.
17
+ */
18
+ export declare function isMetaValueSafe(value: string): boolean;
11
19
  /**
12
20
  * Split `k=v` on the FIRST "=" (so values may themselves contain "="), then
13
21
  * validate the pair. Throws `UsageError` on malformed input.
package/dist/metadata.js CHANGED
@@ -38,6 +38,16 @@ export function validateMetaEntry(key, value) {
38
38
  throw new UsageError(`invalid metadata value for key "${key}": must be 1-${META_VALUE_MAX} printable ASCII characters`);
39
39
  }
40
40
  }
41
+ /**
42
+ * Non-throwing version of `validateMetaEntry`'s value check (length +
43
+ * printable-ASCII), for callers that want to silently drop a value that
44
+ * doesn't fit rather than reject the whole request — e.g. a best-effort
45
+ * `gh.title` derived from a real-world PR/issue title, which may contain
46
+ * unicode (emoji, curly quotes) that the metadata value rule disallows.
47
+ */
48
+ export function isMetaValueSafe(value) {
49
+ return value.length >= 1 && value.length <= META_VALUE_MAX && VALUE_SAFE_RE.test(value);
50
+ }
41
51
  /**
42
52
  * Split `k=v` on the FIRST "=" (so values may themselves contain "="), then
43
53
  * validate the pair. Throws `UsageError` on malformed input.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.13.0",
3
+ "version": "0.14.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,