@buildinternet/uploads 0.52.0 → 0.53.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/README.md CHANGED
@@ -45,7 +45,7 @@ global `uploads` form above.
45
45
 
46
46
  Commands: `attach`, `put`, `screenshot`, `annotate`, `gallery`, `comment`, `list`, `find`, `meta`, `delete`, `usage`,
47
47
  `reconcile`, `purge-expired`, `setup`, `install`, `login`, `whoami` (alias `status`),
48
- `logout`, `invite`, `admin`, `config`, `telemetry`, `report`, `doctor`, `health`, `mcp`,
48
+ `logout`, `invite`, `admin`, `config`, `telemetry`, `report`, `doctor`, `health`, `changelog`, `mcp`,
49
49
  `completion`.
50
50
 
51
51
  **Help:** bare `uploads` / `uploads help` / `--help` shows essentials; use
@@ -0,0 +1,36 @@
1
+ export declare const CHANGELOG_PAGE_URL = "https://uploads.sh/changelog";
2
+ export declare const CHANGELOG_JSON_URL = "https://uploads.sh/changelog.json";
3
+ export declare const CHANGELOG_XML_URL = "https://uploads.sh/changelog.xml";
4
+ export declare const DEFAULT_CHANGELOG_LIMIT = 5;
5
+ export declare const MAX_CHANGELOG_LIMIT = 50;
6
+ export type ChangelogKind = "platform" | "cli";
7
+ export type ChangelogJsonEntry = {
8
+ id: string;
9
+ kind: ChangelogKind;
10
+ title: string;
11
+ date: string;
12
+ url: string;
13
+ tags: string[];
14
+ summary: string;
15
+ body: string;
16
+ };
17
+ export type ChangelogDocument = {
18
+ url: string;
19
+ feed?: string;
20
+ entries: ChangelogJsonEntry[];
21
+ };
22
+ export type FetchChangelogOptions = {
23
+ limit?: number;
24
+ url?: string;
25
+ fetchImpl?: typeof fetch;
26
+ timeoutMs?: number;
27
+ userAgent?: string;
28
+ };
29
+ /** Parse the Atom twin at /changelog.xml (fallback when JSON is not deployed yet). */
30
+ export declare function parseChangelogAtom(xml: string): ChangelogDocument;
31
+ export declare function parseChangelogJson(raw: unknown): ChangelogDocument;
32
+ export declare function clampChangelogLimit(limit: number | undefined): number;
33
+ export declare function selectChangelogEntries(doc: ChangelogDocument, limit?: number): ChangelogDocument;
34
+ /** Human terminal output: recent titles + summaries, then a link to the page. */
35
+ export declare function formatChangelogHuman(doc: ChangelogDocument): string;
36
+ export declare function fetchChangelog(opts?: FetchChangelogOptions): Promise<ChangelogDocument>;
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Fetch and format the public uploads.sh changelog for `uploads changelog`
3
+ * and the MCP `changelog` tool.
4
+ */
5
+ import { UploadsError } from "./errors.js";
6
+ import { packageVersion } from "./package-version.js";
7
+ export const CHANGELOG_PAGE_URL = "https://uploads.sh/changelog";
8
+ export const CHANGELOG_JSON_URL = "https://uploads.sh/changelog.json";
9
+ export const CHANGELOG_XML_URL = "https://uploads.sh/changelog.xml";
10
+ export const DEFAULT_CHANGELOG_LIMIT = 5;
11
+ export const MAX_CHANGELOG_LIMIT = 50;
12
+ const FETCH_TIMEOUT_MS = 8000;
13
+ function isKind(value) {
14
+ return value === "platform" || value === "cli";
15
+ }
16
+ function asString(value) {
17
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
18
+ }
19
+ function asHttpUrl(value) {
20
+ const raw = asString(value);
21
+ if (!raw)
22
+ return undefined;
23
+ try {
24
+ const parsed = new URL(raw);
25
+ if (parsed.protocol === "https:" || parsed.protocol === "http:")
26
+ return raw;
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ return undefined;
32
+ }
33
+ function parseEntry(raw) {
34
+ if (!raw || typeof raw !== "object")
35
+ return undefined;
36
+ const rec = raw;
37
+ const id = asString(rec.id);
38
+ const title = asString(rec.title);
39
+ const date = asString(rec.date);
40
+ const url = asHttpUrl(rec.url);
41
+ const summary = asString(rec.summary);
42
+ if (!id || !title || !date || !url || !summary)
43
+ return undefined;
44
+ const kind = isKind(rec.kind) ? rec.kind : "platform";
45
+ const tags = Array.isArray(rec.tags)
46
+ ? rec.tags.filter((t) => typeof t === "string" && t.length > 0)
47
+ : [];
48
+ return {
49
+ id,
50
+ kind,
51
+ title,
52
+ date,
53
+ url,
54
+ tags,
55
+ summary,
56
+ body: typeof rec.body === "string" ? rec.body : "",
57
+ };
58
+ }
59
+ function decodeXml(value) {
60
+ return value
61
+ .replaceAll("&lt;", "<")
62
+ .replaceAll("&gt;", ">")
63
+ .replaceAll("&quot;", '"')
64
+ .replaceAll("&apos;", "'")
65
+ .replaceAll("&amp;", "&");
66
+ }
67
+ function stripHtml(html) {
68
+ return html
69
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
70
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
71
+ .replace(/<[^>]+>/g, " ")
72
+ .replace(/\s+/g, " ")
73
+ .trim();
74
+ }
75
+ function tagMatch(block, tag) {
76
+ const m = block.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`));
77
+ return m?.[1] === undefined ? undefined : decodeXml(m[1]).trim();
78
+ }
79
+ function attrMatch(block, tag, attr) {
80
+ const m = block.match(new RegExp(`<${tag}[^>]*\\s${attr}="([^"]+)"`));
81
+ return m?.[1] === undefined ? undefined : decodeXml(m[1]).trim();
82
+ }
83
+ function truncateSummary(text, maxChars = 280) {
84
+ if (text.length <= maxChars)
85
+ return text;
86
+ const cut = text.slice(0, maxChars - 1);
87
+ const atSpace = cut.lastIndexOf(" ");
88
+ return `${atSpace > 40 ? cut.slice(0, atSpace) : cut}…`;
89
+ }
90
+ /** Parse the Atom twin at /changelog.xml (fallback when JSON is not deployed yet). */
91
+ export function parseChangelogAtom(xml) {
92
+ const entries = [];
93
+ const entryRe = /<entry>([\s\S]*?)<\/entry>/g;
94
+ let match;
95
+ while ((match = entryRe.exec(xml))) {
96
+ const block = match[1] ?? "";
97
+ const title = tagMatch(block, "title");
98
+ const date = tagMatch(block, "updated");
99
+ const url = attrMatch(block, "link", "href") ?? tagMatch(block, "id");
100
+ const idHref = tagMatch(block, "id") ?? url ?? "";
101
+ const hash = idHref.indexOf("#");
102
+ const id = hash >= 0 ? idHref.slice(hash + 1) : undefined;
103
+ const tags = [...block.matchAll(/<category\s+term="([^"]+)"/g)].map((m) => decodeXml(m[1] ?? ""));
104
+ const html = tagMatch(block, "content") ?? "";
105
+ const plain = stripHtml(html);
106
+ if (!title || !date || !url || !id || !plain)
107
+ continue;
108
+ const kind = tags.includes("cli") && !tags.includes("platform") ? "cli" : "platform";
109
+ entries.push({
110
+ id,
111
+ kind,
112
+ title,
113
+ date,
114
+ url,
115
+ tags,
116
+ summary: truncateSummary(plain),
117
+ body: plain,
118
+ });
119
+ }
120
+ if (entries.length === 0) {
121
+ throw new UploadsError("changelog Atom feed had no usable entries", "API_ERROR");
122
+ }
123
+ return { url: CHANGELOG_PAGE_URL, feed: CHANGELOG_XML_URL, entries };
124
+ }
125
+ export function parseChangelogJson(raw) {
126
+ if (!raw || typeof raw !== "object") {
127
+ throw new UploadsError("changelog response was not a JSON object", "API_ERROR");
128
+ }
129
+ const rec = raw;
130
+ if (!Array.isArray(rec.entries)) {
131
+ throw new UploadsError("changelog response is missing an entries array", "API_ERROR");
132
+ }
133
+ const entries = rec.entries
134
+ .map(parseEntry)
135
+ .filter((e) => e !== undefined);
136
+ if (rec.entries.length > 0 && entries.length === 0) {
137
+ throw new UploadsError("changelog response had no usable entries", "API_ERROR");
138
+ }
139
+ const feed = asHttpUrl(rec.feed);
140
+ return {
141
+ url: asHttpUrl(rec.url) ?? CHANGELOG_PAGE_URL,
142
+ ...(feed ? { feed } : {}),
143
+ entries,
144
+ };
145
+ }
146
+ export function clampChangelogLimit(limit) {
147
+ const n = limit ?? DEFAULT_CHANGELOG_LIMIT;
148
+ if (n > MAX_CHANGELOG_LIMIT)
149
+ return MAX_CHANGELOG_LIMIT;
150
+ if (n < 1)
151
+ return DEFAULT_CHANGELOG_LIMIT;
152
+ return n;
153
+ }
154
+ export function selectChangelogEntries(doc, limit) {
155
+ const n = clampChangelogLimit(limit);
156
+ return { ...doc, entries: doc.entries.slice(0, n) };
157
+ }
158
+ function formatDate(iso) {
159
+ const d = new Date(iso);
160
+ if (Number.isNaN(d.getTime()))
161
+ return iso;
162
+ return d.toLocaleDateString("en-US", {
163
+ year: "numeric",
164
+ month: "short",
165
+ day: "numeric",
166
+ timeZone: "UTC",
167
+ });
168
+ }
169
+ function indent(text, prefix = " ") {
170
+ return text
171
+ .split("\n")
172
+ .map((line) => (line.length === 0 ? prefix.trimEnd() : `${prefix}${line}`))
173
+ .join("\n");
174
+ }
175
+ /** Human terminal output: recent titles + summaries, then a link to the page. */
176
+ export function formatChangelogHuman(doc) {
177
+ const lines = ["What's new", ""];
178
+ if (doc.entries.length === 0) {
179
+ lines.push("No changelog entries.");
180
+ }
181
+ else {
182
+ for (const entry of doc.entries) {
183
+ lines.push(entry.title);
184
+ lines.push(`${formatDate(entry.date)} · ${entry.url}`);
185
+ lines.push("");
186
+ lines.push(indent(entry.summary));
187
+ lines.push("");
188
+ }
189
+ }
190
+ lines.push(`See all updates: ${doc.url}`);
191
+ return `${lines.join("\n")}\n`;
192
+ }
193
+ async function fetchUrl(fetchImpl, url, headers, timeoutMs) {
194
+ const controller = new AbortController();
195
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
196
+ try {
197
+ return await fetchImpl(url, { signal: controller.signal, headers });
198
+ }
199
+ catch (err) {
200
+ const aborted = err instanceof Error && err.name === "AbortError";
201
+ throw new UploadsError(aborted
202
+ ? `timed out fetching changelog (${timeoutMs}ms)`
203
+ : `couldn't reach changelog (${err instanceof Error ? err.message : String(err)})`, "NETWORK");
204
+ }
205
+ finally {
206
+ clearTimeout(timer);
207
+ }
208
+ }
209
+ export async function fetchChangelog(opts = {}) {
210
+ const explicitUrl = opts.url;
211
+ const fetchImpl = opts.fetchImpl ?? fetch;
212
+ const timeoutMs = opts.timeoutMs ?? FETCH_TIMEOUT_MS;
213
+ const userAgent = opts.userAgent ?? `uploads/${packageVersion()}`;
214
+ const headers = { "user-agent": userAgent };
215
+ const jsonUrl = explicitUrl ?? CHANGELOG_JSON_URL;
216
+ const jsonRes = await fetchUrl(fetchImpl, jsonUrl, { ...headers, accept: "application/json" }, timeoutMs);
217
+ if (jsonRes.ok) {
218
+ try {
219
+ const payload = await jsonRes.json();
220
+ return selectChangelogEntries(parseChangelogJson(payload), opts.limit);
221
+ }
222
+ catch (err) {
223
+ if (explicitUrl) {
224
+ throw err instanceof UploadsError
225
+ ? err
226
+ : new UploadsError("changelog response was not valid JSON", "API_ERROR", jsonRes.status);
227
+ }
228
+ }
229
+ }
230
+ else if (explicitUrl) {
231
+ throw new UploadsError(`changelog returned HTTP ${jsonRes.status}; see ${CHANGELOG_PAGE_URL}`, "API_ERROR", jsonRes.status);
232
+ }
233
+ // JSON twin is new; fall back to the Atom feed that already ships.
234
+ const xmlRes = await fetchUrl(fetchImpl, CHANGELOG_XML_URL, { ...headers, accept: "application/atom+xml, application/xml, text/xml" }, timeoutMs);
235
+ if (!xmlRes.ok) {
236
+ throw new UploadsError(`changelog returned HTTP ${xmlRes.status}; see ${CHANGELOG_PAGE_URL}`, "API_ERROR", xmlRes.status);
237
+ }
238
+ const xml = await xmlRes.text();
239
+ return selectChangelogEntries(parseChangelogAtom(xml), opts.limit);
240
+ }
@@ -233,6 +233,10 @@ export const ROOT_COMMANDS = [
233
233
  summary: "Update the CLI, then refresh the agent skills + MCP registration",
234
234
  essential: true,
235
235
  },
236
+ {
237
+ name: "changelog",
238
+ summary: "Show recent product updates (and a link to the full changelog)",
239
+ },
236
240
  {
237
241
  name: "login",
238
242
  summary: "Sign in via browser (or an enrollment code) and save credentials",
package/dist/cli.js CHANGED
@@ -16,6 +16,7 @@ import { runMcp } from "./commands/mcp.js";
16
16
  import { runInstall } from "./commands/install.js";
17
17
  import { runHook } from "./commands/hook.js";
18
18
  import { runUpdate } from "./commands/update.js";
19
+ import { runChangelog } from "./commands/changelog.js";
19
20
  import { runCompletion } from "./commands/completion.js";
20
21
  import { runLogout, runWhoami } from "./commands/session.js";
21
22
  import { runTelemetry } from "./commands/telemetry.js";
@@ -238,6 +239,10 @@ export async function runCli(argv) {
238
239
  case "health":
239
240
  code = await runHealth({ apiUrl, json }, cmdArgs, showHelp);
240
241
  break;
242
+ case "changelog":
243
+ // Public feed — no token, independent of the API origin.
244
+ code = await runChangelog(cmdArgs, { json }, showHelp);
245
+ break;
241
246
  case "config":
242
247
  code = await runConfig(cmdArgs, { json, envFile: parsed.globals.envFile }, showHelp);
243
248
  break;
@@ -0,0 +1,7 @@
1
+ import { type FetchChangelogOptions } from "../changelog.js";
2
+ export interface RunChangelogOptions {
3
+ json?: boolean;
4
+ fetch?: FetchChangelogOptions["fetchImpl"];
5
+ url?: string;
6
+ }
7
+ export declare function runChangelog(args: string[], opts?: RunChangelogOptions, help?: boolean): Promise<number>;
@@ -0,0 +1,49 @@
1
+ import { flagBool, flagInt, parseCommandArgs, UsageError } from "../cli-args.js";
2
+ import { DEFAULT_CHANGELOG_LIMIT, MAX_CHANGELOG_LIMIT, fetchChangelog, formatChangelogHuman, } from "../changelog.js";
3
+ import { writeCommandHelp } from "../cli-style.js";
4
+ import { writeJson, writeStdout } from "../io.js";
5
+ const CHANGELOG_HELP = `uploads changelog — recent product updates
6
+
7
+ Prints the latest updates from uploads.sh, then a link to the full changelog.
8
+
9
+ Usage:
10
+ uploads changelog [options]
11
+
12
+ Options:
13
+ --limit <n> Number of entries to show (default: ${DEFAULT_CHANGELOG_LIMIT}, max: ${MAX_CHANGELOG_LIMIT})
14
+ --json JSON on stdout (also accepts global --json)
15
+
16
+ Examples:
17
+ uploads changelog
18
+ uploads changelog --limit 10
19
+ uploads changelog --json
20
+ `;
21
+ export async function runChangelog(args, opts = {}, help = false) {
22
+ const parsed = parseCommandArgs(args);
23
+ if (help || parsed.help) {
24
+ writeCommandHelp(CHANGELOG_HELP);
25
+ return 0;
26
+ }
27
+ if (parsed.positionals.length > 0) {
28
+ throw new UsageError(`changelog takes no arguments (got ${parsed.positionals[0]})`, {
29
+ example: "uploads changelog",
30
+ });
31
+ }
32
+ const json = Boolean(opts.json) || flagBool(parsed.flags, "--json");
33
+ const limit = flagInt(parsed.flags, "--limit", "--limit") ?? DEFAULT_CHANGELOG_LIMIT;
34
+ if (limit > MAX_CHANGELOG_LIMIT) {
35
+ throw new UsageError(`--limit must be ${MAX_CHANGELOG_LIMIT} or less (got ${limit})`, {
36
+ example: `uploads changelog --limit ${MAX_CHANGELOG_LIMIT}`,
37
+ });
38
+ }
39
+ const doc = await fetchChangelog({
40
+ limit,
41
+ fetchImpl: opts.fetch,
42
+ url: opts.url,
43
+ });
44
+ if (json)
45
+ await writeJson(doc);
46
+ else
47
+ await writeStdout(formatChangelogHuman(doc));
48
+ return 0;
49
+ }
package/dist/commands.js CHANGED
@@ -655,7 +655,7 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
655
655
  target: { kind: target.kind, num: target.num },
656
656
  });
657
657
  const prefixes = ghListPrefixes(ghKeyPrefix(target), ghPrefix, (id) => ghPrivateKeyPrefix(id, target));
658
- const items = await ghMergedList(prefixes, undefined, async (prefix) => (await client.listAll({ prefix, metadata: true })).map(({ key, url, embedUrl, pageUrl, metadata }) => {
658
+ const items = await ghMergedList(prefixes, undefined, async (prefix) => (await client.listAll({ prefix, metadata: true })).map(({ key, url, embedUrl, pageUrl, size, metadata }) => {
659
659
  // The list endpoint returns every metadata key; the comment
660
660
  // renders only these two. Narrowing here keeps both render paths
661
661
  // byte-identical.
@@ -673,6 +673,7 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
673
673
  url,
674
674
  embedUrl,
675
675
  pageUrl,
676
+ ...(size != null ? { size } : {}),
676
677
  ...(path || state
677
678
  ? { meta: { ...(path ? { path } : {}), ...(state ? { state } : {}) } }
678
679
  : {}),
@@ -120,6 +120,10 @@ export interface AttachmentItem {
120
120
  width?: number;
121
121
  height?: number;
122
122
  };
123
+ /** Object size in bytes, when known — drives the non-media file table's Size column. */
124
+ size?: number;
125
+ /** Stored content type, when known — preferred over name-based inference for file classification and the table's Type column. */
126
+ contentType?: string;
123
127
  }
124
128
  /** A public gallery linked to the PR or issue whose managed comment is syncing. */
125
129
  export interface GalleryCommentItem {
@@ -285,6 +285,47 @@ function formatMetaCaption(meta, options, mode) {
285
285
  })
286
286
  .join(" · ");
287
287
  }
288
+ /**
289
+ * Decimal-SI byte size for the non-media file table's Size column: whole
290
+ * bytes below 1000, one decimal place at KB/MB/GB and above. `"—"` when the
291
+ * size is unknown.
292
+ */
293
+ function formatBytes(bytes) {
294
+ if (bytes == null || !Number.isFinite(bytes) || bytes < 0)
295
+ return "—";
296
+ if (bytes < 1000)
297
+ return `${Math.round(bytes)} B`;
298
+ const units = [
299
+ [1e9, "GB"],
300
+ [1e6, "MB"],
301
+ [1e3, "KB"],
302
+ ];
303
+ for (const [threshold, label] of units) {
304
+ if (bytes >= threshold)
305
+ return `${(bytes / threshold).toFixed(1)} ${label}`;
306
+ }
307
+ return `${Math.round(bytes)} B`;
308
+ }
309
+ /**
310
+ * Type label for the non-media file table: uppercase filename extension
311
+ * first, then the content type's subtype, then a bare "FILE" fallback.
312
+ */
313
+ function fileTypeLabel(name, contentType) {
314
+ const dot = name.lastIndexOf(".");
315
+ if (dot !== -1 && dot < name.length - 1)
316
+ return name.slice(dot + 1).toUpperCase();
317
+ if (contentType) {
318
+ const slash = contentType.indexOf("/");
319
+ if (slash !== -1 && slash < contentType.length - 1) {
320
+ return contentType.slice(slash + 1).toUpperCase();
321
+ }
322
+ }
323
+ return "FILE";
324
+ }
325
+ /** Escape `|` so a filename can't break out of a markdown table cell. */
326
+ function escapeTableCell(s) {
327
+ return s.replace(/\|/g, "\\|");
328
+ }
288
329
  /** Resolved pixel width for an image site, or `null` meaning "omit the width
289
330
  * attribute". `"auto"` defers to the caller's per-item heuristic (`autoPx`);
290
331
  * `"full"` always omits; a number always wins. */
@@ -485,6 +526,10 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
485
526
  const consumedByPair = new Set();
486
527
  let inlinedImages = 0;
487
528
  const overflowImages = [];
529
+ // Non-media attachments (PDFs, archives, text/data files) never inline and
530
+ // never overflow into the <details> link list — they render as one table
531
+ // after the image/video section instead (issue #946).
532
+ const fileItems = [];
488
533
  for (let idx = 0; idx < sorted.length; idx++) {
489
534
  if (consumedByPair.has(idx))
490
535
  continue;
@@ -510,8 +555,21 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
510
555
  const stable = item.url;
511
556
  const src = item.embedUrl ?? item.url;
512
557
  const link = item.pageUrl ?? stable; // click-through: file page when known, else raw
513
- const isImage = Boolean(src) && inferContentType(name).startsWith("image/");
514
- const isPosterVideo = Boolean(item.posterUrl) && inferContentType(name).startsWith("video/");
558
+ // "application/octet-stream" is the server's generic fallback for an
559
+ // object stored without an explicit content type — not a real signal —
560
+ // so it defers to the filename the same as an absent `contentType`.
561
+ const effectiveType = item.contentType && item.contentType !== "application/octet-stream"
562
+ ? item.contentType
563
+ : inferContentType(name);
564
+ const isImage = Boolean(src) && effectiveType.startsWith("image/");
565
+ const isPosterVideo = Boolean(item.posterUrl) && effectiveType.startsWith("video/");
566
+ if (!effectiveType.startsWith("image/") && !effectiveType.startsWith("video/")) {
567
+ // Neither an image nor a video by content type — a non-media
568
+ // attachment goes into the file table, never the bullet list or
569
+ // overflow details.
570
+ fileItems.push(item);
571
+ continue;
572
+ }
515
573
  const inlines = isImage || isPosterVideo;
516
574
  if (inlines && inlinedImages >= options.maxInlineImages) {
517
575
  // Cap hit — defer to the collapsed overflow list below rather than
@@ -604,6 +662,23 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
604
662
  lines.push(`- ${name}${cap ? ` · ${cap}` : ""}`);
605
663
  }
606
664
  }
665
+ if (fileItems.length > 0) {
666
+ lines.push("| File | Type | Size |", "| --- | --- | --- |");
667
+ for (const item of fileItems) {
668
+ const name = item.key.slice(item.key.lastIndexOf("/") + 1);
669
+ const escapedName = escapeTableCell(name);
670
+ let fileCell = item.url ? `[${escapedName}](${item.url})` : escapedName;
671
+ if (item.pageUrl)
672
+ fileCell += ` · [page](${item.pageUrl})`;
673
+ const cap = formatMetaCaption(item.meta, options, "markdown");
674
+ if (cap)
675
+ fileCell += ` · ${cap}`;
676
+ const typeLabel = fileTypeLabel(name, item.contentType);
677
+ const sizeLabel = formatBytes(item.size);
678
+ lines.push(`| ${fileCell} | ${typeLabel} | ${sizeLabel} |`);
679
+ }
680
+ lines.push("");
681
+ }
607
682
  if (overflowImages.length > 0) {
608
683
  const n = overflowImages.length;
609
684
  lines.push(`<details><summary>${n} more attachment${n === 1 ? "" : "s"}</summary>`, "");
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey
4
4
  export { BUILTIN_DESTINATIONS, isBuiltinDestination, keyMatchesDestination, resolveDestinationRoot, resolvePutPrefix, type BuiltinDestinationId, } from "./destinations.js";
5
5
  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";
6
6
  export { UploadsError, type UploadsErrorCode } from "./errors.js";
7
+ export { CHANGELOG_JSON_URL, CHANGELOG_PAGE_URL, CHANGELOG_XML_URL, DEFAULT_CHANGELOG_LIMIT, MAX_CHANGELOG_LIMIT, fetchChangelog, formatChangelogHuman, parseChangelogAtom, parseChangelogJson, selectChangelogEntries, type ChangelogDocument, type ChangelogJsonEntry, type FetchChangelogOptions, } from "./changelog.js";
7
8
  export { assertFetchableUploadUrl, fetchUploadSource, filenameFromUploadUrl, resolveUploadFilename, FETCH_UPLOAD_SOURCE_DEFAULT_MAX_BYTES, FETCH_UPLOAD_SOURCE_MAX_REDIRECTS, FETCH_UPLOAD_SOURCE_TIMEOUT_MS, } from "./fetch-upload-source.js";
8
9
  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 MetadataKeysResult, type MetadataValuesResult, type GetMetadataResult, type PatchMetadataOptions, type ResolveGhPrefixOptions, type ResolveGhPrefixResult, } from "./client.js";
9
10
  export { buildCliProvenance } from "./provenance.js";
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey
4
4
  export { BUILTIN_DESTINATIONS, isBuiltinDestination, keyMatchesDestination, resolveDestinationRoot, resolvePutPrefix, } from "./destinations.js";
5
5
  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";
6
6
  export { UploadsError } from "./errors.js";
7
+ export { CHANGELOG_JSON_URL, CHANGELOG_PAGE_URL, CHANGELOG_XML_URL, DEFAULT_CHANGELOG_LIMIT, MAX_CHANGELOG_LIMIT, fetchChangelog, formatChangelogHuman, parseChangelogAtom, parseChangelogJson, selectChangelogEntries, } from "./changelog.js";
7
8
  export { assertFetchableUploadUrl, fetchUploadSource, filenameFromUploadUrl, resolveUploadFilename, FETCH_UPLOAD_SOURCE_DEFAULT_MAX_BYTES, FETCH_UPLOAD_SOURCE_MAX_REDIRECTS, FETCH_UPLOAD_SOURCE_TIMEOUT_MS, } from "./fetch-upload-source.js";
8
9
  export { createUploadsClient, } from "./client.js";
9
10
  export { buildCliProvenance } from "./provenance.js";
@@ -21,6 +21,7 @@ export declare const usageResultSchema: JsonSchema;
21
21
  export declare const reconcileResultSchema: JsonSchema;
22
22
  export declare const purgeExpiredResultSchema: JsonSchema;
23
23
  export declare const whoamiResultSchema: JsonSchema;
24
+ export declare const changelogResultSchema: JsonSchema;
24
25
  export declare const promoteToolResultSchema: JsonSchema;
25
26
  export declare const galleryResultSchema: JsonSchema;
26
27
  export declare const galleryFindResultSchema: JsonSchema;
@@ -248,6 +248,21 @@ export const whoamiResultSchema = objectSchema({
248
248
  signedIn: { type: "boolean" },
249
249
  apiUrl: { type: "string" },
250
250
  }, ["ok", "workspace"]);
251
+ const changelogEntrySchema = objectSchema({
252
+ id: { type: "string" },
253
+ kind: { type: "string", enum: ["platform", "cli"] },
254
+ title: { type: "string" },
255
+ date: { type: "string" },
256
+ url: { type: "string" },
257
+ tags: { type: "array", items: { type: "string" } },
258
+ summary: { type: "string" },
259
+ body: { type: "string" },
260
+ }, ["id", "kind", "title", "date", "url", "tags", "summary", "body"]);
261
+ export const changelogResultSchema = objectSchema({
262
+ url: { type: "string" },
263
+ feed: { type: "string" },
264
+ entries: { type: "array", items: changelogEntrySchema },
265
+ }, ["url", "entries"]);
251
266
  export const promoteToolResultSchema = objectSchema({
252
267
  // `promotion` is optional (issue #702): a `keys`-only call (no `branch`)
253
268
  // never runs the branch sweep, so there's nothing to report under it.
@@ -329,6 +344,7 @@ export const hostedOutputSchemas = {
329
344
  reconcile: reconcileResultSchema,
330
345
  purge_expired: purgeExpiredResultSchema,
331
346
  whoami: whoamiResultSchema,
347
+ changelog: changelogResultSchema,
332
348
  };
333
349
  /** Shared-shape stdio tools. Hosted-only tools (`promote`, `repo_link_status`) omitted. */
334
350
  export const stdioOutputSchemas = {
@@ -362,6 +378,7 @@ export const stdioOutputSchemas = {
362
378
  reconcile: reconcileResultSchema,
363
379
  purge_expired: purgeExpiredResultSchema,
364
380
  whoami: whoamiResultSchema,
381
+ changelog: changelogResultSchema,
365
382
  report: objectSchema({
366
383
  ok: { type: "boolean" },
367
384
  id: { type: "string" },
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * MCP tool set mirroring the CLI commands (put, attach, list, delete,
3
- * usage, reconcile, purge_expired, comment, whoami, doctor). Config is
3
+ * usage, reconcile, purge_expired, comment, whoami, doctor, changelog). Config is
4
4
  * resolved fresh per tool call so a
5
5
  * per-call `workspace` argument behaves like the CLI's --workspace flag, and
6
6
  * a missing token surfaces as a tool error rather than a startup failure.
package/dist/mcp/tools.js CHANGED
@@ -14,6 +14,7 @@ import { appProp, canonicalMetaFromArgs, METADATA_PATH_CUE, metadataArgWithCanon
14
14
  import { batchFailureMessage, mcpDestroyPublic, mcpNoAuth, mcpOAuthAny, mcpOAuthDelete, mcpOAuthRead, mcpOAuthWrite, mcpRead, mcpWriteInternal, mcpWritePublic, stdioOutputSchemas, withOutputSchemas, ToolBatchError, } from "./server.js";
15
15
  import { attachmentFromText, buildReportPayload, parseReportType, REPORT_TYPES, submitReport, validateReportMessage, } from "../report.js";
16
16
  import { resolveApiUrl } from "../config.js";
17
+ import { DEFAULT_CHANGELOG_LIMIT, MAX_CHANGELOG_LIMIT, fetchChangelog } from "../changelog.js";
17
18
  function mcpOptimizeOptions(args, defaults) {
18
19
  const quality = optPosInt(args, "optimizeQuality");
19
20
  if (quality !== undefined && quality > 100)
@@ -1552,6 +1553,33 @@ export function createUploadsMcpTools(opts) {
1552
1553
  return { ...target, ...result };
1553
1554
  },
1554
1555
  },
1556
+ {
1557
+ name: "changelog",
1558
+ title: "Product changelog",
1559
+ annotations: {
1560
+ readOnlyHint: true,
1561
+ destructiveHint: false,
1562
+ openWorldHint: true,
1563
+ },
1564
+ securitySchemes: mcpNoAuth,
1565
+ description: "Read recent uploads.sh product updates (platform and CLI). Returns the latest entries with titles, dates, summaries, and a link to the full changelog at https://uploads.sh/changelog. Same as `uploads changelog`. Use this to discover new features before recommending uploads.sh workflows.",
1566
+ inputSchema: {
1567
+ type: "object",
1568
+ properties: {
1569
+ limit: {
1570
+ type: "number",
1571
+ description: `How many entries to return (default ${DEFAULT_CHANGELOG_LIMIT}, max ${MAX_CHANGELOG_LIMIT}).`,
1572
+ },
1573
+ },
1574
+ additionalProperties: false,
1575
+ },
1576
+ async handler(args) {
1577
+ const limit = optPosInt(args, "limit") ?? DEFAULT_CHANGELOG_LIMIT;
1578
+ if (limit > MAX_CHANGELOG_LIMIT)
1579
+ usage(`limit must be ${MAX_CHANGELOG_LIMIT} or less`);
1580
+ return fetchChangelog({ limit });
1581
+ },
1582
+ },
1555
1583
  {
1556
1584
  name: "whoami",
1557
1585
  title: "Who am I",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.52.0",
3
+ "version": "0.53.0",
4
4
  "mcpName": "sh.uploads/mcp",
5
5
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
6
6
  "type": "module",
@@ -39,7 +39,7 @@
39
39
  "node": ">=22"
40
40
  },
41
41
  "peerDependencies": {
42
- "files-sdk": "^2.2.4"
42
+ "files-sdk": "^2.3.1"
43
43
  },
44
44
  "peerDependenciesMeta": {
45
45
  "files-sdk": {
@@ -49,7 +49,7 @@
49
49
  "devDependencies": {
50
50
  "@types/node": "^26.1.0",
51
51
  "ai": "^6.0.0",
52
- "files-sdk": "^2.2.5",
52
+ "files-sdk": "^2.3.1",
53
53
  "typescript": "^7.0.2",
54
54
  "vitest": "^4.1.10"
55
55
  },