@buildinternet/uploads 0.13.1 → 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.
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`
@@ -67,6 +68,20 @@ export function resolveCurrentPullRequest(repo, run = execRunner) {
67
68
  }
68
69
  throw new UsageError("could not infer a pull request for the current branch — pass --pr <num> or --issue <num>");
69
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
+ }
70
85
  /**
71
86
  * Classify a bare PR/issue number via the GitHub API so the default `put`
72
87
  * path can stamp the right `gh.kind`. Returns undefined on any failure (gh
@@ -91,22 +106,90 @@ export function classifyGhNumber(repo, num, run = execRunner) {
91
106
  }
92
107
  return undefined;
93
108
  }
109
+ /**
110
+ * Best-effort PR/issue title lookup via local `gh`. Returns undefined on any
111
+ * failure (gh missing, unauthenticated, network, 404) — mirrors
112
+ * `resolveCurrentPullRequest`/`classifyGhNumber`'s degrade-don't-throw
113
+ * pattern. A title is a nice-to-have annotation, never a blocker: callers
114
+ * must never let this failure abort an upload.
115
+ */
116
+ export function resolveGhTitle(target, run = execRunner) {
117
+ try {
118
+ const out = run("gh", [
119
+ target.kind === "pull" ? "pr" : "issue",
120
+ "view",
121
+ String(target.num),
122
+ "--repo",
123
+ target.repo,
124
+ "--json",
125
+ "title",
126
+ "--jq",
127
+ ".title",
128
+ ]).trim();
129
+ return out.length > 0 ? out : undefined;
130
+ }
131
+ catch {
132
+ // gh missing / unauthenticated / not found / network — caller skips
133
+ return undefined;
134
+ }
135
+ }
136
+ /**
137
+ * `ghMetadataFromTarget`'s 4 pairs, plus a best-effort `gh.title` (issue #267)
138
+ * when `resolveGhTitle` yields one that also satisfies the metadata-value
139
+ * rule every other pair follows (1-512 printable ASCII — `metadata.ts`'s
140
+ * `META_VALUE_MAX`/`isMetaValueSafe`). Truncated to `META_VALUE_MAX` first;
141
+ * a title left empty or unsafe by truncation (e.g. non-ASCII — real titles
142
+ * often contain emoji or curly quotes) is silently omitted rather than
143
+ * sanitized, matching `resolveGhTitle`'s own "degrade, don't fail the
144
+ * upload" contract.
145
+ */
146
+ export function ghMetadataFromTargetWithTitle(target, run = execRunner) {
147
+ const base = ghMetadataFromTarget(target);
148
+ const title = resolveGhTitle(target, run);
149
+ if (title === undefined)
150
+ return base;
151
+ const truncated = title.length > META_VALUE_MAX ? title.slice(0, META_VALUE_MAX) : title;
152
+ return isMetaValueSafe(truncated) ? { ...base, "gh.title": truncated } : base;
153
+ }
94
154
  /**
95
155
  * 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).
156
+ * `--paginate` follows Link headers and merges every page into one array, so the
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.
97
165
  */
98
- function findManagedComment(target, run) {
99
- const raw = run("gh", ["api", `repos/${target.repo}/issues/${target.num}/comments?per_page=100`]);
166
+ function findManagedComment(target, run, marker) {
167
+ const raw = run("gh", [
168
+ "api",
169
+ `repos/${target.repo}/issues/${target.num}/comments?per_page=100`,
170
+ "--paginate",
171
+ ]);
100
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;
101
178
  return comments.find((c) => typeof c.body === "string" && c.body.includes(ATTACHMENTS_MARKER));
102
179
  }
103
180
  /**
104
181
  * Create the managed attachments comment, or edit it in place if it already
105
182
  * exists. Never touches any other comment. Body is passed via stdin
106
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.
107
190
  */
108
- export function upsertAttachmentsComment(target, body, run = execRunner) {
109
- const existing = findManagedComment(target, run);
191
+ export function upsertAttachmentsComment(target, body, run = execRunner, marker = ATTACHMENTS_MARKER) {
192
+ const existing = findManagedComment(target, run, marker);
110
193
  if (existing) {
111
194
  run("gh", [
112
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
@@ -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";
@@ -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);
@@ -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({
@@ -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
  },
@@ -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.1",
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,