@buildinternet/uploads 0.1.1 → 0.3.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.
@@ -10,6 +10,8 @@ export const UPLOADS_CONFIG_KEYS = [
10
10
  "UPLOADS_DEFAULT_REF",
11
11
  "UPLOADS_DEFAULT_WIDTH",
12
12
  "UPLOADS_NO_GIT",
13
+ "UPLOADS_NO_OPTIMIZE",
14
+ "UPLOADS_KEEP_EXIF",
13
15
  ];
14
16
  const PUT_DEFAULT_KEY_MAP = {
15
17
  prefix: "UPLOADS_DEFAULT_PREFIX",
@@ -17,7 +19,15 @@ const PUT_DEFAULT_KEY_MAP = {
17
19
  ref: "UPLOADS_DEFAULT_REF",
18
20
  width: "UPLOADS_DEFAULT_WIDTH",
19
21
  noGit: "UPLOADS_NO_GIT",
22
+ noOptimize: "UPLOADS_NO_OPTIMIZE",
23
+ keepExif: "UPLOADS_KEEP_EXIF",
20
24
  };
25
+ function isTruthyConfigFlag(value) {
26
+ if (!value)
27
+ return false;
28
+ const v = value.toLowerCase();
29
+ return v === "1" || v === "true" || v === "yes";
30
+ }
21
31
  export function putDefaultsToConfigValues(defaults) {
22
32
  const out = {};
23
33
  if (defaults.prefix)
@@ -30,6 +40,10 @@ export function putDefaultsToConfigValues(defaults) {
30
40
  out.UPLOADS_DEFAULT_WIDTH = String(defaults.width);
31
41
  if (defaults.noGit)
32
42
  out.UPLOADS_NO_GIT = "1";
43
+ if (defaults.noOptimize)
44
+ out.UPLOADS_NO_OPTIMIZE = "1";
45
+ if (defaults.keepExif)
46
+ out.UPLOADS_KEEP_EXIF = "1";
33
47
  return out;
34
48
  }
35
49
  function parsePutDefaultsFromRaw(raw) {
@@ -45,9 +59,12 @@ function parsePutDefaultsFromRaw(raw) {
45
59
  if (Number.isFinite(n) && n > 0)
46
60
  out.width = n;
47
61
  }
48
- if (raw.UPLOADS_NO_GIT === "1" || raw.UPLOADS_NO_GIT?.toLowerCase() === "true") {
62
+ if (isTruthyConfigFlag(raw.UPLOADS_NO_GIT))
49
63
  out.noGit = true;
50
- }
64
+ if (isTruthyConfigFlag(raw.UPLOADS_NO_OPTIMIZE))
65
+ out.noOptimize = true;
66
+ if (isTruthyConfigFlag(raw.UPLOADS_KEEP_EXIF))
67
+ out.keepExif = true;
51
68
  return out;
52
69
  }
53
70
  function parsePutDefaultsFromEnv() {
@@ -62,6 +79,10 @@ function parsePutDefaultsFromEnv() {
62
79
  raw.UPLOADS_DEFAULT_WIDTH = process.env.UPLOADS_DEFAULT_WIDTH;
63
80
  if (process.env.UPLOADS_NO_GIT)
64
81
  raw.UPLOADS_NO_GIT = process.env.UPLOADS_NO_GIT;
82
+ if (process.env.UPLOADS_NO_OPTIMIZE)
83
+ raw.UPLOADS_NO_OPTIMIZE = process.env.UPLOADS_NO_OPTIMIZE;
84
+ if (process.env.UPLOADS_KEEP_EXIF)
85
+ raw.UPLOADS_KEEP_EXIF = process.env.UPLOADS_KEEP_EXIF;
65
86
  return parsePutDefaultsFromRaw(raw);
66
87
  }
67
88
  /** XDG default shared across buildinternet skills (github-screenshots, uploads, …). */
@@ -127,6 +148,10 @@ export function mergePutDefaults(...layers) {
127
148
  out.width = layer.width;
128
149
  if (layer.noGit != null)
129
150
  out.noGit = layer.noGit;
151
+ if (layer.noOptimize != null)
152
+ out.noOptimize = layer.noOptimize;
153
+ if (layer.keepExif != null)
154
+ out.keepExif = layer.keepExif;
130
155
  }
131
156
  return out;
132
157
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Typed destination roots for put/attach. Matches the API allowlist defaults
3
+ * (`f/`, `screenshots/`, `gh/`) — see apps/api `key-policy.ts`.
4
+ */
5
+ export declare const BUILTIN_DESTINATIONS: {
6
+ readonly f: "f";
7
+ readonly screenshots: "screenshots";
8
+ readonly gh: "gh";
9
+ };
10
+ export type BuiltinDestinationId = keyof typeof BUILTIN_DESTINATIONS;
11
+ export declare function isBuiltinDestination(id: string): id is BuiltinDestinationId;
12
+ /** Root segment for a known destination, or throws with a usage-friendly message. */
13
+ export declare function resolveDestinationRoot(id: string): string;
14
+ /** True when `key` is under the destination root. */
15
+ export declare function keyMatchesDestination(key: string, destinationId: string): boolean;
16
+ /**
17
+ * Resolve CLI/MCP destination flags into a put `prefix`. Throws plain Errors
18
+ * (callers wrap as UsageError / MCP usage errors).
19
+ */
20
+ export declare function resolvePutPrefix(opts: {
21
+ destination?: string;
22
+ prefix?: string;
23
+ key?: string;
24
+ /** When true (PR/issue attach), destination must be `gh` or omitted. */
25
+ ghAttachment?: boolean;
26
+ }): string | undefined;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Typed destination roots for put/attach. Matches the API allowlist defaults
3
+ * (`f/`, `screenshots/`, `gh/`) — see apps/api `key-policy.ts`.
4
+ */
5
+ export const BUILTIN_DESTINATIONS = {
6
+ f: "f",
7
+ screenshots: "screenshots",
8
+ gh: "gh",
9
+ };
10
+ export function isBuiltinDestination(id) {
11
+ return Object.hasOwn(BUILTIN_DESTINATIONS, id);
12
+ }
13
+ /** Root segment for a known destination, or throws with a usage-friendly message. */
14
+ export function resolveDestinationRoot(id) {
15
+ if (!isBuiltinDestination(id)) {
16
+ const known = Object.keys(BUILTIN_DESTINATIONS).join(", ");
17
+ throw new Error(`unknown destination: ${id} (known: ${known})`);
18
+ }
19
+ return BUILTIN_DESTINATIONS[id];
20
+ }
21
+ /** True when `key` is under the destination root. */
22
+ export function keyMatchesDestination(key, destinationId) {
23
+ if (!isBuiltinDestination(destinationId))
24
+ return false;
25
+ const root = BUILTIN_DESTINATIONS[destinationId];
26
+ return key === root || key.startsWith(`${root}/`);
27
+ }
28
+ /**
29
+ * Resolve CLI/MCP destination flags into a put `prefix`. Throws plain Errors
30
+ * (callers wrap as UsageError / MCP usage errors).
31
+ */
32
+ export function resolvePutPrefix(opts) {
33
+ const { destination, prefix, key, ghAttachment } = opts;
34
+ if (!destination)
35
+ return prefix;
36
+ const root = resolveDestinationRoot(destination);
37
+ if (ghAttachment && destination !== "gh") {
38
+ throw new Error("destination with pr/issue must be gh (or omit it)");
39
+ }
40
+ if (key && !keyMatchesDestination(key, destination)) {
41
+ throw new Error(`key must start with destination root "${root}/"`);
42
+ }
43
+ if (prefix && prefix.replace(/\/+$/, "") !== root) {
44
+ throw new Error(`prefix (${prefix}) conflicts with destination ${destination} (root ${root})`);
45
+ }
46
+ return root;
47
+ }
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type UploadsErrorCode = "MISSING_TOKEN" | "NO_PUBLIC_URL" | "NOT_FOUND" | "UNAUTHORIZED" | "INVALID_KEY" | "API_ERROR" | "NETWORK" | "USAGE";
1
+ export type UploadsErrorCode = "MISSING_TOKEN" | "NO_PUBLIC_URL" | "NOT_FOUND" | "UNAUTHORIZED" | "INVALID_KEY" | "KEY_POLICY" | "STORAGE_QUOTA" | "UPLOAD_BUDGET" | "API_ERROR" | "NETWORK" | "USAGE";
2
2
  export declare class UploadsError extends Error {
3
3
  readonly code: UploadsErrorCode;
4
4
  readonly status?: number;
@@ -0,0 +1,36 @@
1
+ export type FrameFit = "cover" | "contain";
2
+ export interface FrameOptions {
3
+ id: string;
4
+ fit?: FrameFit;
5
+ /** Address bar text for procedural `browser`. */
6
+ browserUrl?: string;
7
+ fetchImpl?: typeof fetch;
8
+ cacheDir?: string;
9
+ }
10
+ export interface FrameResult {
11
+ bytes: Uint8Array;
12
+ filename: string;
13
+ contentType: string;
14
+ framed: boolean;
15
+ frameId: string;
16
+ skippedReason?: string;
17
+ }
18
+ type FramePreset = {
19
+ kind: "procedural";
20
+ label: string;
21
+ } | {
22
+ kind: "remote";
23
+ label: string;
24
+ /** Directory URL with frame.png, mask.png, template.json */
25
+ assetBase: string;
26
+ };
27
+ export declare const FRAME_PRESETS: Record<string, FramePreset>;
28
+ export declare function listFramePresets(): Array<{
29
+ id: string;
30
+ label: string;
31
+ kind: string;
32
+ }>;
33
+ export declare function resolveFrameId(raw: string | undefined): string | undefined;
34
+ /** Apply a named frame. Non-images pass through. Output PNG for the optimize step. */
35
+ export declare function applyFrame(bytes: Uint8Array, filename: string, opts: FrameOptions): Promise<FrameResult>;
36
+ export {};
package/dist/frame.js ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Optional device/browser frames for put/attach (default off).
3
+ *
4
+ * - `phone` / `browser` — procedural (no third-party assets)
5
+ * - `iphone-16-pro` — fetches frame+mask from device-frames-media once,
6
+ * cached under ~/.cache/uploads/frames (not bundled in the npm package)
7
+ *
8
+ * @see https://github.com/jonnyjackson26/device-frames-media
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+ import sharp from "sharp";
15
+ const DEVICE_FRAMES_BASE = "https://raw.githubusercontent.com/jonnyjackson26/device-frames-media/main/device-frames-output";
16
+ export const FRAME_PRESETS = {
17
+ phone: { kind: "procedural", label: "Generic phone bezel" },
18
+ browser: { kind: "procedural", label: "Generic browser chrome" },
19
+ "iphone-16-pro": {
20
+ kind: "remote",
21
+ label: "iPhone 16 Pro (community frame art)",
22
+ assetBase: `${DEVICE_FRAMES_BASE}/Apple%20iPhone/16%20Pro/Black%20Titanium`,
23
+ },
24
+ };
25
+ export function listFramePresets() {
26
+ return Object.entries(FRAME_PRESETS).map(([id, p]) => ({
27
+ id,
28
+ label: p.label,
29
+ kind: p.kind,
30
+ }));
31
+ }
32
+ export function resolveFrameId(raw) {
33
+ if (!raw?.trim())
34
+ return undefined;
35
+ const id = raw.trim().toLowerCase();
36
+ if (!(id in FRAME_PRESETS)) {
37
+ throw new Error(`unknown frame "${raw}" (known: ${Object.keys(FRAME_PRESETS).join(", ")})`);
38
+ }
39
+ return id;
40
+ }
41
+ function cacheDirDefault() {
42
+ return join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "uploads", "frames");
43
+ }
44
+ async function cachedFetch(url, cacheDir, fetchImpl) {
45
+ const name = `${createHash("sha256").update(url).digest("hex").slice(0, 16)}-${url.split("/").pop() ?? "bin"}`;
46
+ const path = join(cacheDir, name);
47
+ if (existsSync(path))
48
+ return readFileSync(path);
49
+ mkdirSync(cacheDir, { recursive: true });
50
+ const res = await fetchImpl(url);
51
+ if (!res.ok)
52
+ throw new Error(`frame download failed (${res.status}): ${url}`);
53
+ const buf = Buffer.from(await res.arrayBuffer());
54
+ writeFileSync(path, buf);
55
+ return buf;
56
+ }
57
+ function scaleRect(r, s) {
58
+ return {
59
+ x: Math.round(r.x * s),
60
+ y: Math.round(r.y * s),
61
+ width: Math.round(r.width * s),
62
+ height: Math.round(r.height * s),
63
+ };
64
+ }
65
+ async function compositeDevice(screenshot, framePng, maskPng, screen, frameSize, fit) {
66
+ const fitted = await sharp(screenshot)
67
+ .rotate()
68
+ .resize(screen.width, screen.height, {
69
+ fit,
70
+ position: "centre",
71
+ background: { r: 0, g: 0, b: 0, alpha: 1 },
72
+ })
73
+ .ensureAlpha()
74
+ .png()
75
+ .toBuffer();
76
+ let layer = await sharp({
77
+ create: {
78
+ width: frameSize.width,
79
+ height: frameSize.height,
80
+ channels: 4,
81
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
82
+ },
83
+ })
84
+ .composite([{ input: fitted, left: screen.x, top: screen.y }])
85
+ .png()
86
+ .toBuffer();
87
+ if (maskPng) {
88
+ layer = await sharp(layer)
89
+ .composite([{ input: maskPng, blend: "dest-in" }])
90
+ .png()
91
+ .toBuffer();
92
+ }
93
+ return sharp(layer)
94
+ .composite([{ input: framePng, left: 0, top: 0 }])
95
+ .png()
96
+ .toBuffer();
97
+ }
98
+ async function proceduralPhone(screenshot, fit) {
99
+ const screenW = 390;
100
+ const screenH = 844;
101
+ const bezel = 14;
102
+ const top = 36;
103
+ const bottom = 18;
104
+ const outerW = screenW + bezel * 2;
105
+ const outerH = screenH + top + bottom;
106
+ const fitted = await sharp(screenshot)
107
+ .rotate()
108
+ .resize(screenW, screenH, {
109
+ fit,
110
+ position: "centre",
111
+ background: { r: 0, g: 0, b: 0, alpha: 1 },
112
+ })
113
+ .png()
114
+ .toBuffer();
115
+ const roundMask = await sharp(Buffer.from(`<svg width="${screenW}" height="${screenH}" xmlns="http://www.w3.org/2000/svg"><rect width="${screenW}" height="${screenH}" rx="36" fill="white"/></svg>`))
116
+ .png()
117
+ .toBuffer();
118
+ const screen = await sharp(fitted)
119
+ .ensureAlpha()
120
+ .composite([{ input: roundMask, blend: "dest-in" }])
121
+ .png()
122
+ .toBuffer();
123
+ const shell = await sharp(Buffer.from(`<svg width="${outerW}" height="${outerH}" xmlns="http://www.w3.org/2000/svg">
124
+ <rect x="1" y="1" width="${outerW - 2}" height="${outerH - 2}" rx="48" fill="#1c1c1e" stroke="#3a3a3c" stroke-width="2"/>
125
+ <rect x="${bezel + 70}" y="12" width="${screenW - 140}" height="22" rx="11" fill="#0a0a0a"/>
126
+ <circle cx="${outerW / 2}" cy="${outerH - 10}" r="4" fill="#3a3a3c"/>
127
+ </svg>`))
128
+ .png()
129
+ .toBuffer();
130
+ return sharp(shell)
131
+ .composite([{ input: screen, left: bezel, top }])
132
+ .png()
133
+ .toBuffer();
134
+ }
135
+ async function proceduralBrowser(screenshot, fit, url) {
136
+ const chromeH = 72;
137
+ const pad = 12;
138
+ const meta = await sharp(screenshot).rotate().metadata();
139
+ const srcW = meta.width ?? 800;
140
+ const srcH = meta.height ?? 600;
141
+ const scale = Math.min(1, 1200 / srcW, 800 / srcH);
142
+ const contentW = Math.max(320, Math.round(srcW * scale));
143
+ const contentH = Math.max(200, Math.round(srcH * scale));
144
+ const outerW = contentW + pad * 2;
145
+ const outerH = contentH + chromeH + pad;
146
+ const fitted = await sharp(screenshot)
147
+ .rotate()
148
+ .resize(contentW, contentH, {
149
+ fit,
150
+ position: "centre",
151
+ background: { r: 255, g: 255, b: 255, alpha: 1 },
152
+ })
153
+ .png()
154
+ .toBuffer();
155
+ const safe = url
156
+ .slice(0, 80)
157
+ .replace(/&/g, "&amp;")
158
+ .replace(/</g, "&lt;")
159
+ .replace(/>/g, "&gt;")
160
+ .replace(/"/g, "&quot;");
161
+ const chrome = await sharp(Buffer.from(`<svg width="${outerW}" height="${outerH}" xmlns="http://www.w3.org/2000/svg">
162
+ <rect x="0.5" y="0.5" width="${outerW - 1}" height="${outerH - 1}" rx="12" fill="#f0f0f2" stroke="#c7c7cc"/>
163
+ <circle cx="22" cy="22" r="6" fill="#ff5f57"/>
164
+ <circle cx="42" cy="22" r="6" fill="#febc2e"/>
165
+ <circle cx="62" cy="22" r="6" fill="#28c840"/>
166
+ <rect x="84" y="12" width="${Math.max(120, outerW - 100)}" height="28" rx="8" fill="#fff" stroke="#d1d1d6"/>
167
+ <text x="96" y="31" font-family="system-ui,sans-serif" font-size="12" fill="#6e6e73">${safe}</text>
168
+ <rect x="${pad}" y="${chromeH}" width="${contentW}" height="${contentH}" fill="#fff"/>
169
+ </svg>`))
170
+ .png()
171
+ .toBuffer();
172
+ return sharp(chrome)
173
+ .composite([{ input: fitted, left: pad, top: chromeH }])
174
+ .png()
175
+ .toBuffer();
176
+ }
177
+ function asPngName(filename) {
178
+ const base = filename.includes("/") ? filename.slice(filename.lastIndexOf("/") + 1) : filename;
179
+ const dot = base.lastIndexOf(".");
180
+ return `${dot >= 0 ? base.slice(0, dot) : base}.png`;
181
+ }
182
+ function skip(bytes, filename, frameId, reason, contentType = "application/octet-stream") {
183
+ return { bytes, filename, contentType, framed: false, frameId, skippedReason: reason };
184
+ }
185
+ /** Apply a named frame. Non-images pass through. Output PNG for the optimize step. */
186
+ export async function applyFrame(bytes, filename, opts) {
187
+ const id = opts.id.toLowerCase();
188
+ const preset = FRAME_PRESETS[id];
189
+ if (!preset)
190
+ throw new Error(`unknown frame "${opts.id}"`);
191
+ try {
192
+ const meta = await sharp(bytes, { failOn: "none" }).metadata();
193
+ if (!meta.format || meta.format === "svg")
194
+ return skip(bytes, filename, id, "not_image");
195
+ if ((meta.pages ?? 1) > 1) {
196
+ return skip(bytes, filename, id, "animated", meta.format === "gif" ? "image/gif" : "image/webp");
197
+ }
198
+ }
199
+ catch {
200
+ return skip(bytes, filename, id, "not_image");
201
+ }
202
+ const fit = opts.fit ?? "cover";
203
+ let out;
204
+ if (preset.kind === "procedural") {
205
+ out =
206
+ id === "browser"
207
+ ? await proceduralBrowser(bytes, fit, opts.browserUrl ?? "https://app.example")
208
+ : await proceduralPhone(bytes, fit);
209
+ }
210
+ else {
211
+ const fetchImpl = opts.fetchImpl ?? fetch;
212
+ const cacheDir = opts.cacheDir ?? cacheDirDefault();
213
+ const tpl = JSON.parse((await cachedFetch(`${preset.assetBase}/template.json`, cacheDir, fetchImpl)).toString("utf8"));
214
+ const framePng = await cachedFetch(`${preset.assetBase}/frame.png`, cacheDir, fetchImpl);
215
+ let maskPng;
216
+ try {
217
+ maskPng = await cachedFetch(`${preset.assetBase}/mask.png`, cacheDir, fetchImpl);
218
+ }
219
+ catch {
220
+ /* optional */
221
+ }
222
+ // Keep remote frames light for PR embeds (display width is capped separately).
223
+ const maxEdge = 1000;
224
+ const s = Math.min(1, maxEdge / Math.max(tpl.frameSize.width, tpl.frameSize.height));
225
+ const frameSize = {
226
+ width: Math.round(tpl.frameSize.width * s),
227
+ height: Math.round(tpl.frameSize.height * s),
228
+ };
229
+ const screen = scaleRect(tpl.screen, s);
230
+ const frame = s < 1
231
+ ? await sharp(framePng).resize(frameSize.width, frameSize.height).png().toBuffer()
232
+ : framePng;
233
+ const mask = maskPng && s < 1
234
+ ? await sharp(maskPng).resize(frameSize.width, frameSize.height).png().toBuffer()
235
+ : maskPng;
236
+ out = await compositeDevice(bytes, frame, mask, screen, frameSize, fit);
237
+ }
238
+ return {
239
+ bytes: new Uint8Array(out),
240
+ filename: asPngName(filename),
241
+ contentType: "image/png",
242
+ framed: true,
243
+ frameId: id,
244
+ };
245
+ }
package/dist/github.d.ts CHANGED
@@ -21,4 +21,15 @@ export interface AttachmentItem {
21
21
  key: string;
22
22
  url: string | null;
23
23
  }
24
+ /** Default max width for images in the managed attachments comment (HTML img). */
25
+ export declare const ATTACHMENT_IMAGE_WIDTH_DEFAULT = 400;
26
+ /** Portrait / device mockups — keep phones readable, not full-column. */
27
+ export declare const ATTACHMENT_IMAGE_WIDTH_PORTRAIT = 280;
28
+ /** Wide UI / browser chrome. */
29
+ export declare const ATTACHMENT_IMAGE_WIDTH_WIDE = 640;
30
+ /**
31
+ * Pick a display width for GitHub comment embeds. Filenames are a weak but
32
+ * practical signal (we don't re-fetch dimensions when rebuilding the comment).
33
+ */
34
+ export declare function attachmentImageWidth(filename: string): number;
24
35
  export declare function attachmentsCommentBody(items: AttachmentItem[]): string;
package/dist/github.js CHANGED
@@ -24,13 +24,43 @@ export function ghAttachmentKey(target, filename) {
24
24
  }
25
25
  /** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
26
26
  export const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
27
+ /** Default max width for images in the managed attachments comment (HTML img). */
28
+ export const ATTACHMENT_IMAGE_WIDTH_DEFAULT = 400;
29
+ /** Portrait / device mockups — keep phones readable, not full-column. */
30
+ export const ATTACHMENT_IMAGE_WIDTH_PORTRAIT = 280;
31
+ /** Wide UI / browser chrome. */
32
+ export const ATTACHMENT_IMAGE_WIDTH_WIDE = 640;
33
+ /**
34
+ * Pick a display width for GitHub comment embeds. Filenames are a weak but
35
+ * practical signal (we don't re-fetch dimensions when rebuilding the comment).
36
+ */
37
+ export function attachmentImageWidth(filename) {
38
+ const n = filename.toLowerCase();
39
+ if (/(?:^|[-_.])(browser|desktop|dashboard|wide)(?:[-_.]|$)/.test(n)) {
40
+ return ATTACHMENT_IMAGE_WIDTH_WIDE;
41
+ }
42
+ if (/(?:^|[-_.])(phone|iphone|ipad|pixel|android|mobile|device)(?:[-_.]|$)/.test(n) ||
43
+ /iphone|pixel-?\d/.test(n)) {
44
+ return ATTACHMENT_IMAGE_WIDTH_PORTRAIT;
45
+ }
46
+ return ATTACHMENT_IMAGE_WIDTH_DEFAULT;
47
+ }
48
+ function escapeHtmlAttr(s) {
49
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
50
+ }
27
51
  export function attachmentsCommentBody(items) {
28
52
  const sorted = items.toSorted((a, b) => a.key.localeCompare(b.key));
29
53
  const lines = [ATTACHMENTS_MARKER, "### 📎 Attachments", ""];
30
54
  for (const item of sorted) {
31
55
  const name = item.key.slice(item.key.lastIndexOf("/") + 1);
32
56
  if (item.url && inferContentType(name).startsWith("image/")) {
33
- lines.push(`![${name}](${item.url})`);
57
+ // Markdown ![]() has no width control — phone frames become full-column giants.
58
+ // Link to the asset so a click opens the full image (no "open in new tab" hunt).
59
+ const w = attachmentImageWidth(name);
60
+ const alt = escapeHtmlAttr(name);
61
+ const href = escapeHtmlAttr(item.url);
62
+ lines.push(`<a href="${href}"><img width="${w}" alt="${alt}" src="${href}"></a>`);
63
+ lines.push("");
34
64
  }
35
65
  else if (item.url) {
36
66
  lines.push(`- [${name}](${item.url})`);
@@ -39,6 +69,6 @@ export function attachmentsCommentBody(items) {
39
69
  lines.push(`- ${name}`);
40
70
  }
41
71
  }
42
- lines.push("", "<sub>Maintained by uploads.sh — re-uploading a file with the same name updates it everywhere it is embedded.</sub>");
72
+ 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>');
43
73
  return lines.join("\n");
44
74
  }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  export { inferContentType, buildMarkdown } from "./embed.js";
2
2
  export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey } from "./keys.js";
3
+ export { BUILTIN_DESTINATIONS, isBuiltinDestination, keyMatchesDestination, resolveDestinationRoot, resolvePutPrefix, type BuiltinDestinationId, } from "./destinations.js";
3
4
  export { DEFAULT_API_URL, DEFAULT_WORKSPACE, UPLOADS_CONFIG_KEYS, defaultConfigPath, resolveConfigPath, loadConfigFile, loadEnvFile, resolveApiUrl, resolveConfig, describeConfigSources, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, workspaceFromToken, workspaceMismatch, type UploadsClientConfig, type ResolvedConfig, type WorkspaceSource, type ConfigValueSource, type ConfigSources, type UploadsConfigKey, type UploadsConfigValues, type PutDefaults, } from "./config.js";
4
5
  export { UploadsError, type UploadsErrorCode } from "./errors.js";
5
- export { createUploadsClient, type UploadsClient, type PutOptions, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type HealthResult, } from "./client.js";
6
+ export { createUploadsClient, type UploadsClient, type PutOptions, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type HealthResult, type UsageResult, type ReconcileResult, type PurgeExpiredResult, type PurgeExpiredResponse, } from "./client.js";
6
7
  export { ATTACHMENTS_MARKER, attachmentsCommentBody, ghAttachmentKey, ghKeyPrefix, isValidRepo, parseRepoFromRemoteUrl, type AttachmentItem, type GhTarget, type GhTargetKind, } from "./github.js";
8
+ export { DEFAULT_OPTIMIZE_MAX_EDGE, DEFAULT_OPTIMIZE_QUALITY, optimizeImageForUpload, rewriteKeyExtension, withImageExtension, type OptimizeImageOptions, type OptimizeImageResult, type OptimizeOutputFormat, } from "./optimize.js";
9
+ export { FRAME_PRESETS, applyFrame, listFramePresets, resolveFrameId, type FrameFit, type FrameOptions, type FrameResult, } from "./frame.js";
7
10
  export { execRunner, resolveRepo, upsertAttachmentsComment, type CommandRunner, } from "./github-gh.js";
package/dist/index.js CHANGED
@@ -1,7 +1,10 @@
1
1
  export { inferContentType, buildMarkdown } from "./embed.js";
2
2
  export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey } from "./keys.js";
3
+ export { BUILTIN_DESTINATIONS, isBuiltinDestination, keyMatchesDestination, resolveDestinationRoot, resolvePutPrefix, } from "./destinations.js";
3
4
  export { DEFAULT_API_URL, DEFAULT_WORKSPACE, UPLOADS_CONFIG_KEYS, defaultConfigPath, resolveConfigPath, loadConfigFile, loadEnvFile, resolveApiUrl, resolveConfig, describeConfigSources, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, workspaceFromToken, workspaceMismatch, } from "./config.js";
4
5
  export { UploadsError } from "./errors.js";
5
6
  export { createUploadsClient, } from "./client.js";
6
7
  export { ATTACHMENTS_MARKER, attachmentsCommentBody, ghAttachmentKey, ghKeyPrefix, isValidRepo, parseRepoFromRemoteUrl, } from "./github.js";
8
+ export { DEFAULT_OPTIMIZE_MAX_EDGE, DEFAULT_OPTIMIZE_QUALITY, optimizeImageForUpload, rewriteKeyExtension, withImageExtension, } from "./optimize.js";
9
+ export { FRAME_PRESETS, applyFrame, listFramePresets, resolveFrameId, } from "./frame.js";
7
10
  export { execRunner, resolveRepo, upsertAttachmentsComment, } from "./github-gh.js";
package/dist/io.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ /** Backpressure-aware stdout helpers shared by the CLI commands and the stdio MCP transport. */
2
+ export declare function writeStdout(text: string): Promise<void>;
3
+ export declare function writeJson(value: unknown): Promise<void>;
package/dist/io.js ADDED
@@ -0,0 +1,9 @@
1
+ /** Backpressure-aware stdout helpers shared by the CLI commands and the stdio MCP transport. */
2
+ export async function writeStdout(text) {
3
+ if (!process.stdout.write(text)) {
4
+ await new Promise((resolve) => process.stdout.once("drain", resolve));
5
+ }
6
+ }
7
+ export async function writeJson(value) {
8
+ await writeStdout(JSON.stringify(value, null, 2) + "\n");
9
+ }
@@ -0,0 +1,4 @@
1
+ export type ToolArgs = Record<string, unknown>;
2
+ export declare function usage(msg: string): never;
3
+ export declare function optString(args: ToolArgs, name: string): string | undefined;
4
+ export declare function optPosInt(args: ToolArgs, name: string): number | undefined;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Argument helpers shared by the stdio MCP tool set (./tools.ts) and the
3
+ * remote worker's tool set (apps/mcp). Runtime-agnostic — usable from
4
+ * Workers as well as Node.
5
+ */
6
+ import { UploadsError } from "../errors.js";
7
+ export function usage(msg) {
8
+ throw new UploadsError(msg, "USAGE");
9
+ }
10
+ export function optString(args, name) {
11
+ const v = args[name];
12
+ if (v === undefined || v === null)
13
+ return undefined;
14
+ if (typeof v !== "string")
15
+ usage(`${name} must be a string`);
16
+ return v;
17
+ }
18
+ export function optPosInt(args, name) {
19
+ const v = args[name];
20
+ if (v === undefined || v === null)
21
+ return undefined;
22
+ if (typeof v !== "number" || !Number.isInteger(v) || v <= 0) {
23
+ usage(`${name} must be a positive integer`);
24
+ }
25
+ return v;
26
+ }
@@ -0,0 +1,19 @@
1
+ export { optPosInt, optString, usage, type ToolArgs } from "./args.js";
2
+ export interface McpTool {
3
+ name: string;
4
+ description: string;
5
+ /** Hand-written JSON Schema for the tool's arguments. */
6
+ inputSchema: Record<string, unknown>;
7
+ handler: (args: Record<string, unknown>) => Promise<unknown>;
8
+ }
9
+ export interface McpServer {
10
+ /** Handle one JSON-RPC line. Undefined for notifications / client responses. */
11
+ handleLine(line: string): Promise<string | undefined>;
12
+ }
13
+ export declare function createMcpServer(opts: {
14
+ serverInfo: {
15
+ name: string;
16
+ version: string;
17
+ };
18
+ tools: McpTool[];
19
+ }): McpServer;