diff --git a/.changeset/cli-changelog.md b/.changeset/cli-changelog.md new file mode 100644 index 00000000..1aebd473 --- /dev/null +++ b/.changeset/cli-changelog.md @@ -0,0 +1,5 @@ +--- +"@buildinternet/uploads": minor +--- + +Add `uploads changelog` to print recent product updates and a link to https://uploads.sh/changelog. Also available as the MCP `changelog` tool. diff --git a/apps/mcp/src/tools.ts b/apps/mcp/src/tools.ts index 47bf10ca..173f67eb 100644 --- a/apps/mcp/src/tools.ts +++ b/apps/mcp/src/tools.ts @@ -14,11 +14,14 @@ import { buildMarkdown, buildScreenshotKey, + DEFAULT_CHANGELOG_LIMIT, + fetchChangelog, fetchUploadSource, ghAttachmentKeyForMode, ghBranchAttachmentKeyForMode, ghMetadataForBranch, ghMetadataFromTarget, + MAX_CHANGELOG_LIMIT, resolveUploadFilename, type GhTarget, } from "@buildinternet/uploads"; @@ -42,6 +45,7 @@ import { type McpTool, insufficientScopeError, mcpDestroyPublic, + mcpNoAuth, mcpOAuthAny, mcpOAuthDelete, mcpOAuthRead, @@ -1509,6 +1513,33 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { }; }, }, + { + name: "changelog", + title: "Product changelog", + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: true, + }, + securitySchemes: mcpNoAuth, + 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.", + inputSchema: { + type: "object", + properties: { + limit: { + type: "number", + description: `How many entries to return (default ${DEFAULT_CHANGELOG_LIMIT}, max ${MAX_CHANGELOG_LIMIT}).`, + }, + }, + additionalProperties: false, + }, + async handler(args) { + const limit = optPosInt(args, "limit") ?? DEFAULT_CHANGELOG_LIMIT; + if (limit > MAX_CHANGELOG_LIMIT) usage(`limit must be ${MAX_CHANGELOG_LIMIT} or less`); + return fetchChangelog({ limit }); + }, + }, { name: "whoami", title: "Who am I", diff --git a/apps/mcp/test/mcp.test.ts b/apps/mcp/test/mcp.test.ts index 89d3ab25..4ddb013c 100644 --- a/apps/mcp/test/mcp.test.ts +++ b/apps/mcp/test/mcp.test.ts @@ -686,6 +686,7 @@ describe("mcp worker", () => { result: { tools: { name: string }[] }; }; expect(body.result.tools.map((tool) => tool.name).sort()).toEqual([ + "changelog", "comment", "delete", "find_files", diff --git a/apps/web/README.md b/apps/web/README.md index 5b22a9d1..6721b6ef 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -21,6 +21,7 @@ src/layouts/ Shared shells (error pages) src/pages/ Astro pages; g/[id].astro is the on-demand public gallery src/pages/changelog.astro /changelog — platform updates + CLI releases, newest first src/pages/changelog.xml.ts Atom twin of /changelog, served at /changelog.xml +src/pages/changelog.json.ts JSON twin of /changelog, served at /changelog.json src/pages/oembed.ts oEmbed 1.0 JSON endpoint for shareable /f and /g pages src/lib/ Public gallery/file fetch + oEmbed resolution public/_headers Per-path response headers (Link, robots, types) @@ -46,15 +47,15 @@ wrangler.jsonc Hybrid Worker, static assets, skills index, The landing page, `/docs`, and the `/github-screenshots` use-case guide are meant for search engines. Agent discovery docs are public but not listed in the sitemap. -| Path | Indexable | Notes | -| ----------------------------------------------------------- | --------- | -------------------------------------------------------------------------- | -| `/` | yes | Listed in `sitemap.xml`; Link headers advertise catalogs | -| `/docs` | yes | Plain-language setup guide; in `sitemap.xml` | -| `/github-screenshots` | yes | SEO landing: agents uploading media to GitHub; FAQ JSON-LD | -| `/changelog` | yes | Product updates + CLI releases; in `sitemap.xml`; Atom at `/changelog.xml` | -| `/invite` | **no** | Magic-link enrollment; robots + meta + `X-Robots-Tag` | -| `/404`,`/500` | **no** | Status pages | -| `/auth.md`, `/llms.txt`, `/llms-full.txt`, `/.well-known/*` | n/a | Machine-readable; not in sitemap | +| Path | Indexable | Notes | +| ----------------------------------------------------------- | --------- | ----------------------------------------------------------------------------------------------------- | +| `/` | yes | Listed in `sitemap.xml`; Link headers advertise catalogs | +| `/docs` | yes | Plain-language setup guide; in `sitemap.xml` | +| `/github-screenshots` | yes | SEO landing: agents uploading media to GitHub; FAQ JSON-LD | +| `/changelog` | yes | Product updates + CLI releases; in `sitemap.xml`; Atom at `/changelog.xml`; JSON at `/changelog.json` | +| `/invite` | **no** | Magic-link enrollment; robots + meta + `X-Robots-Tag` | +| `/404`,`/500` | **no** | Status pages | +| `/auth.md`, `/llms.txt`, `/llms-full.txt`, `/.well-known/*` | n/a | Machine-readable; not in sitemap | `robots.txt` includes explicit `User-agent` blocks for common AI crawlers and `Content-Signal` preferences (`search=yes`, `ai-input=yes`, `ai-train=no`). diff --git a/apps/web/public/_headers b/apps/web/public/_headers index 1f7f35f7..f29e3fd9 100644 --- a/apps/web/public/_headers +++ b/apps/web/public/_headers @@ -108,3 +108,9 @@ Content-Type: application/atom+xml; charset=utf-8 Cache-Control: public, max-age=300 Access-Control-Allow-Origin: * + +# JSON twin of /changelog — consumed by `uploads changelog` and the MCP tool. +/changelog.json + Content-Type: application/json; charset=utf-8 + Cache-Control: public, max-age=300 + Access-Control-Allow-Origin: * diff --git a/apps/web/public/llms-full.txt b/apps/web/public/llms-full.txt index 26868fa2..631a46f1 100644 --- a/apps/web/public/llms-full.txt +++ b/apps/web/public/llms-full.txt @@ -276,7 +276,7 @@ staged files are promoted into the PR's attachments comment automatically. - Home: https://uploads.sh/ - Docs hub: https://uploads.sh/docs -- Changelog: https://uploads.sh/changelog (Atom feed at https://uploads.sh/changelog.xml) +- Changelog: https://uploads.sh/changelog (Atom at https://uploads.sh/changelog.xml; JSON at https://uploads.sh/changelog.json; `uploads changelog`) - Attach & share: https://uploads.sh/docs/attach-pull-request-images - Galleries: https://uploads.sh/docs/galleries - GitHub App: https://uploads.sh/docs/github-app diff --git a/apps/web/public/llms.txt b/apps/web/public/llms.txt index 924ce6d1..2ee30f5e 100644 --- a/apps/web/public/llms.txt +++ b/apps/web/public/llms.txt @@ -26,7 +26,7 @@ How to call it: - [Home](https://uploads.sh/): product overview and copyable install commands - [Full agent guide (llms-full.txt)](https://uploads.sh/llms-full.txt): install, auth, stage/attach loops, hosted MCP contracts, cautions - [Docs](https://uploads.sh/docs): overview, one-time install, and links to the focused guides below -- [Changelog](https://uploads.sh/changelog): platform updates and CLI releases, newest first (Atom feed at https://uploads.sh/changelog.xml) +- [Changelog](https://uploads.sh/changelog): platform updates and CLI releases, newest first (Atom at https://uploads.sh/changelog.xml; JSON at https://uploads.sh/changelog.json; `uploads changelog` in the CLI) - [Docs: attach & share](https://uploads.sh/docs/attach-pull-request-images): attach media to PRs/issues, stage screenshots before a PR exists, pair a before/after with --state, get a URL for any accepted file (local path or `put --url`), capture a screenshot, annotate with callouts/redactions - [Docs: annotate a screenshot](https://uploads.sh/docs/attach-pull-request-images#annotate): bake boxes, arrows, labels, freeform strokes, and solid redactions into a capture (`uploads screenshot --annotate` or `uploads annotate`) - [Docs: galleries](https://uploads.sh/docs/galleries): create an ordered set of media behind one public link at /g/, add files with uploads put --gallery, and link the gallery to a PR or issue diff --git a/apps/web/src/content/changelog/README.md b/apps/web/src/content/changelog/README.md index eb9b72f4..49be84d1 100644 --- a/apps/web/src/content/changelog/README.md +++ b/apps/web/src/content/changelog/README.md @@ -30,8 +30,9 @@ automatically from `packages/uploads/CHANGELOG.md` — never write those here. Body in plain markdown. Inline images work too, absolute https URLs only. ``` -4. Open a PR. Merge deploys /changelog and /changelog.xml; releases.sh picks - up the new entry on its normal feed sweep. +4. Open a PR. Merge deploys /changelog, /changelog.xml, and /changelog.json; + releases.sh picks up the new entry on its normal feed sweep. The CLI + (`uploads changelog`) reads the JSON twin. Image rules: absolute `https://` URLs, 1 KB–8 MB, png/jpeg/gif/webp/avif — that's what releases.sh mirrors into its own storage. `date` supports full diff --git a/apps/web/src/content/changelog/cli-changelog.md b/apps/web/src/content/changelog/cli-changelog.md new file mode 100644 index 00000000..58eb919f --- /dev/null +++ b/apps/web/src/content/changelog/cli-changelog.md @@ -0,0 +1,15 @@ +--- +title: "Read the changelog from the CLI" +date: 2026-09-10 +tags: [cli] +--- + +`uploads changelog` prints the latest product updates in your terminal, then a +link to the full list at [uploads.sh/changelog](/changelog). Agents get the same +feed from `--json` or the MCP `changelog` tool. + +```bash +uploads changelog +uploads changelog --limit 10 +uploads changelog --json +``` diff --git a/apps/web/src/lib/changelog-feed.test.ts b/apps/web/src/lib/changelog-feed.test.ts index 527fb688..d280735d 100644 --- a/apps/web/src/lib/changelog-feed.test.ts +++ b/apps/web/src/lib/changelog-feed.test.ts @@ -9,6 +9,7 @@ const entries: ChangelogEntry[] = [ title: "A home for screenshots", date: "2026-08-11T00:00:00.000Z", html: '

Now with x

', + markdown: "Now with ![x](https://storage.uploads.sh/changelog/x.png)", tags: ["platform"], image: { url: "https://storage.uploads.sh/default/screenshots/changelog/lead.webp", @@ -21,6 +22,7 @@ const entries: ChangelogEntry[] = [ title: "CLI 0.41.1", date: "2026-08-09T18:00:00.000Z", html: "

Fixes

", + markdown: "Fixes", tags: ["cli"], }, ]; diff --git a/apps/web/src/lib/changelog-json.test.ts b/apps/web/src/lib/changelog-json.test.ts new file mode 100644 index 00000000..59598986 --- /dev/null +++ b/apps/web/src/lib/changelog-json.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import type { ChangelogEntry } from "./changelog"; +import { renderChangelogJson } from "./changelog-json"; + +const entries: ChangelogEntry[] = [ + { + kind: "platform", + id: "screenshots-page", + title: "A home for your screenshots", + date: "2026-08-11T00:00:00.000Z", + html: "

Every screenshot the CLI captures now has a page of its own.

", + markdown: "Every screenshot the CLI captures now has a page of its own.", + tags: ["platform", "web"], + }, + { + kind: "cli", + id: "cli-0-41-1", + title: "CLI 0.41.1", + date: "2026-08-09T18:00:00.000Z", + html: "

Fixes

", + markdown: "### Patch Changes\n\n- 2697e69: Fix `uploads completion zsh`.", + tags: ["cli"], + }, +]; + +describe("renderChangelogJson", () => { + const json = renderChangelogJson(entries); + + it("points at the public changelog and Atom feed", () => { + expect(json.url).toBe("https://uploads.sh/changelog"); + expect(json.feed).toBe("https://uploads.sh/changelog.xml"); + expect(json.entries).toHaveLength(2); + }); + + it("anchors each entry and carries markdown plus a summary", () => { + expect(json.entries[0]).toMatchObject({ + id: "screenshots-page", + kind: "platform", + title: "A home for your screenshots", + url: "https://uploads.sh/changelog#screenshots-page", + tags: ["platform", "web"], + body: "Every screenshot the CLI captures now has a page of its own.", + summary: "Every screenshot the CLI captures now has a page of its own.", + }); + expect(json.entries[1].summary).toBe("Fix uploads completion zsh."); + expect(json.entries[1].body).toContain("2697e69"); + }); + + it("throws on an empty entry list rather than publishing an empty feed", () => { + expect(() => renderChangelogJson([])).toThrow(/empty/i); + }); +}); diff --git a/apps/web/src/lib/changelog-json.ts b/apps/web/src/lib/changelog-json.ts new file mode 100644 index 00000000..a24a5ccf --- /dev/null +++ b/apps/web/src/lib/changelog-json.ts @@ -0,0 +1,47 @@ +/** + * JSON serializer for /changelog.json. Twin of the Atom feed: full markdown + * bodies plus a one-paragraph summary so the CLI and agents can print recent + * updates without scraping HTML. + */ +import { entrySummary, type ChangelogEntry } from "./changelog"; + +const SITE = "https://uploads.sh"; +const PAGE = `${SITE}/changelog`; +const FEED = `${SITE}/changelog.xml`; + +export type ChangelogJsonEntry = { + id: string; + kind: ChangelogEntry["kind"]; + title: string; + date: string; + url: string; + tags: string[]; + summary: string; + body: string; +}; + +export type ChangelogJson = { + url: string; + feed: string; + entries: ChangelogJsonEntry[]; +}; + +export function renderChangelogJson(entries: ChangelogEntry[]): ChangelogJson { + if (entries.length === 0) { + throw new Error("renderChangelogJson: refusing to publish an empty feed"); + } + return { + url: PAGE, + feed: FEED, + entries: entries.map((entry) => ({ + id: entry.id, + kind: entry.kind, + title: entry.title, + date: entry.date, + url: `${PAGE}#${entry.id}`, + tags: entry.tags, + summary: entrySummary(entry.markdown), + body: entry.markdown, + })), + }; +} diff --git a/apps/web/src/lib/changelog.test.ts b/apps/web/src/lib/changelog.test.ts index 8da5ebed..c0a94b37 100644 --- a/apps/web/src/lib/changelog.test.ts +++ b/apps/web/src/lib/changelog.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { type ChangelogEntry, cliAnchorId, + entrySummary, fetchCliReleaseDates, mergeEntries, parseCliChangelog, @@ -92,6 +93,7 @@ describe("mergeEntries", () => { title: "x", date: "2026-08-01T00:00:00.000Z", html: "", + markdown: "", tags: [], ...over, }); @@ -112,3 +114,32 @@ describe("mergeEntries", () => { expect(sorted.map((e) => e.id)).toEqual(["post", "cli"]); }); }); + +describe("entrySummary", () => { + it("takes the first paragraph and strips markdown chrome", () => { + const md = `## Heading + +You can now [point](https://uploads.sh/docs) any workspace at your **own** bucket. + +A second paragraph is ignored.`; + expect(entrySummary(md)).toBe("You can now point any workspace at your own bucket."); + }); + + it("strips a changeset SHA prefix from CLI bullets", () => { + const md = `### Patch Changes + +- 2697e69: Fix \`uploads completion zsh\` producing a script that could not complete anything. +`; + expect(entrySummary(md)).toBe( + "Fix uploads completion zsh producing a script that could not complete anything.", + ); + }); + + it("truncates at a word boundary", () => { + const words = Array.from({ length: 80 }, (_, i) => `word${i}`).join(" "); + const summary = entrySummary(words, 40); + expect(summary.endsWith("…")).toBe(true); + expect(summary.length).toBeLessThanOrEqual(40); + expect(summary).not.toContain("word79"); + }); +}); diff --git a/apps/web/src/lib/changelog.ts b/apps/web/src/lib/changelog.ts index 20644b74..792457f8 100644 --- a/apps/web/src/lib/changelog.ts +++ b/apps/web/src/lib/changelog.ts @@ -22,6 +22,8 @@ export type ChangelogEntry = { date: string; /** Rendered HTML body. */ html: string; + /** Source markdown, for the JSON twin and CLI. */ + markdown: string; tags: string[]; image?: ChangelogImage; }; @@ -68,6 +70,32 @@ export function renderMarkdown(md: string): string { return marked.parse(md, { async: false }) as string; } +const CHANGESET_SHA_PREFIX = /^[0-9a-f]{7,40}:\s*/i; + +/** + * First paragraph of an entry as plain text, for CLI/JSON summaries. + * Strips headings, images, and link markup; truncates at a word boundary. + */ +export function entrySummary(markdown: string, maxChars = 280): string { + const withoutChrome = markdown + .replace(/^#{1,6}\s+.*$/gm, "") + .replace(/!\[[^\]]*]\([^)]+\)/g, "") + .trim(); + const firstBlock = withoutChrome.split(/\n\s*\n/)[0] ?? ""; + const text = firstBlock + .replace(/^[-*+]\s+/gm, "") + .replace(/^\d+\.\s+/gm, "") + .replace(/\[([^\]]+)]\([^)]+\)/g, "$1") + .replace(/[*_`]/g, "") + .replace(/\s+/g, " ") + .trim() + .replace(CHANGESET_SHA_PREFIX, ""); + if (text.length <= maxChars) return text; + const cut = text.slice(0, maxChars - 1); + const atSpace = cut.lastIndexOf(" "); + return `${atSpace > 40 ? cut.slice(0, atSpace) : cut}…`; +} + export function mergeEntries(entries: ChangelogEntry[]): ChangelogEntry[] { return [...entries].sort((a, b) => { const byDate = Date.parse(b.date) - Date.parse(a.date); @@ -80,10 +108,11 @@ export function mergeEntries(entries: ChangelogEntry[]): ChangelogEntry[] { let cached: Promise | null = null; /** - * Both /changelog and /changelog.xml call this during the same build; cache - * the promise so the npm registry fetch (and content-collection load) only - * happens once per build instead of once per route. A rejected build-time - * promise still rejects every caller, so failures still fail the build. + * /changelog, /changelog.xml, and /changelog.json call this during the same + * build; cache the promise so the npm registry fetch (and content-collection + * load) only happens once per build instead of once per route. A rejected + * build-time promise still rejects every caller, so failures still fail the + * build. */ export function loadChangelogEntries(): Promise { cached ??= buildChangelogEntries(); @@ -103,6 +132,7 @@ async function buildChangelogEntries(): Promise { title: post.data.title, date: post.data.date.toISOString(), html: renderMarkdown(post.body ?? ""), + markdown: post.body ?? "", tags: post.data.tags, image: post.data.image, })); @@ -116,6 +146,7 @@ async function buildChangelogEntries(): Promise { title: `CLI ${section.version}`, date: dates[section.version], html: renderMarkdown(section.body), + markdown: section.body, tags: ["cli"], })); diff --git a/apps/web/src/pages/changelog.astro b/apps/web/src/pages/changelog.astro index 33ba5a29..b2c7d1f6 100644 --- a/apps/web/src/pages/changelog.astro +++ b/apps/web/src/pages/changelog.astro @@ -1,7 +1,8 @@ --- // /changelog — merged product stream: hand-written platform updates // (src/content/changelog) interleaved with CLI releases from changesets. -// Fully prerendered, zero client JS. Machine twin: /changelog.xml (Atom). +// Fully prerendered, zero client JS. Machine twins: /changelog.xml (Atom), +// /changelog.json (CLI / MCP). import BaseHead from "../components/BaseHead.astro"; import Footer from "../components/Footer.astro"; import SiteHeader from "../components/SiteHeader.astro"; diff --git a/apps/web/src/pages/changelog.json.ts b/apps/web/src/pages/changelog.json.ts new file mode 100644 index 00000000..d17dcdd5 --- /dev/null +++ b/apps/web/src/pages/changelog.json.ts @@ -0,0 +1,17 @@ +/** + * /changelog.json — JSON twin of /changelog. Prerendered at build time and + * served off the ASSETS binding; headers come from public/_headers. The CLI + * (`uploads changelog`) and MCP `changelog` tool read this. + */ +import type { APIRoute } from "astro"; +import { loadChangelogEntries } from "../lib/changelog"; +import { renderChangelogJson } from "../lib/changelog-json"; + +export const prerender = true; + +export const GET: APIRoute = async () => { + const entries = await loadChangelogEntries(); + return new Response(JSON.stringify(renderChangelogJson(entries), null, 2) + "\n", { + headers: { "content-type": "application/json; charset=utf-8" }, + }); +}; diff --git a/docs/cli.md b/docs/cli.md index 7041c775..8d81eef5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -97,6 +97,7 @@ command to run by hand, rather than overwriting your build. | `install` | Skills + remote MCP (Claude/Codex/Grok; skips missing CLIs) + hooks (Grok/Cursor) | | `hook` | Agent harness handlers (e.g. pre-PR screenshot reminder) | | `update` | Update the CLI, then refresh skills / MCP / hooks | +| `changelog` | Recent product updates, then a link to https://uploads.sh/changelog | | `login` / `logout` | Sign in (browser or enrollment code) / clear saved token | | `whoami` (`status`) | Show the active workspace and token | | `invite` | Invite a teammate to a workspace (workspace admin) | diff --git a/packages/uploads/README.md b/packages/uploads/README.md index bed1e9ea..980797f5 100644 --- a/packages/uploads/README.md +++ b/packages/uploads/README.md @@ -45,7 +45,7 @@ global `uploads` form above. Commands: `attach`, `put`, `screenshot`, `annotate`, `gallery`, `comment`, `list`, `find`, `meta`, `delete`, `usage`, `reconcile`, `purge-expired`, `setup`, `install`, `login`, `whoami` (alias `status`), -`logout`, `invite`, `admin`, `config`, `telemetry`, `report`, `doctor`, `health`, `mcp`, +`logout`, `invite`, `admin`, `config`, `telemetry`, `report`, `doctor`, `health`, `changelog`, `mcp`, `completion`. **Help:** bare `uploads` / `uploads help` / `--help` shows essentials; use diff --git a/packages/uploads/src/changelog.ts b/packages/uploads/src/changelog.ts new file mode 100644 index 00000000..21070cd1 --- /dev/null +++ b/packages/uploads/src/changelog.ts @@ -0,0 +1,303 @@ +/** + * Fetch and format the public uploads.sh changelog for `uploads changelog` + * and the MCP `changelog` tool. + */ +import { UploadsError } from "./errors.js"; +import { packageVersion } from "./package-version.js"; + +export const CHANGELOG_PAGE_URL = "https://uploads.sh/changelog"; +export const CHANGELOG_JSON_URL = "https://uploads.sh/changelog.json"; +export const CHANGELOG_XML_URL = "https://uploads.sh/changelog.xml"; +export const DEFAULT_CHANGELOG_LIMIT = 5; +export const MAX_CHANGELOG_LIMIT = 50; + +const FETCH_TIMEOUT_MS = 8000; + +export type ChangelogKind = "platform" | "cli"; + +export type ChangelogJsonEntry = { + id: string; + kind: ChangelogKind; + title: string; + date: string; + url: string; + tags: string[]; + summary: string; + body: string; +}; + +export type ChangelogDocument = { + url: string; + feed?: string; + entries: ChangelogJsonEntry[]; +}; + +export type FetchChangelogOptions = { + limit?: number; + url?: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; + userAgent?: string; +}; + +function isKind(value: unknown): value is ChangelogKind { + return value === "platform" || value === "cli"; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function asHttpUrl(value: unknown): string | undefined { + const raw = asString(value); + if (!raw) return undefined; + try { + const parsed = new URL(raw); + if (parsed.protocol === "https:" || parsed.protocol === "http:") return raw; + } catch { + return undefined; + } + return undefined; +} + +function parseEntry(raw: unknown): ChangelogJsonEntry | undefined { + if (!raw || typeof raw !== "object") return undefined; + const rec = raw as Record; + const id = asString(rec.id); + const title = asString(rec.title); + const date = asString(rec.date); + const url = asHttpUrl(rec.url); + const summary = asString(rec.summary); + if (!id || !title || !date || !url || !summary) return undefined; + const kind: ChangelogKind = isKind(rec.kind) ? rec.kind : "platform"; + const tags = Array.isArray(rec.tags) + ? rec.tags.filter((t): t is string => typeof t === "string" && t.length > 0) + : []; + return { + id, + kind, + title, + date, + url, + tags, + summary, + body: typeof rec.body === "string" ? rec.body : "", + }; +} + +function decodeXml(value: string): string { + return value + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("&", "&"); +} + +function stripHtml(html: string): string { + return html + .replace(//gi, " ") + .replace(//gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function tagMatch(block: string, tag: string): string | undefined { + const m = block.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)`)); + return m?.[1] === undefined ? undefined : decodeXml(m[1]).trim(); +} + +function attrMatch(block: string, tag: string, attr: string): string | undefined { + const m = block.match(new RegExp(`<${tag}[^>]*\\s${attr}="([^"]+)"`)); + return m?.[1] === undefined ? undefined : decodeXml(m[1]).trim(); +} + +function truncateSummary(text: string, maxChars = 280): string { + if (text.length <= maxChars) return text; + const cut = text.slice(0, maxChars - 1); + const atSpace = cut.lastIndexOf(" "); + return `${atSpace > 40 ? cut.slice(0, atSpace) : cut}…`; +} + +/** Parse the Atom twin at /changelog.xml (fallback when JSON is not deployed yet). */ +export function parseChangelogAtom(xml: string): ChangelogDocument { + const entries: ChangelogJsonEntry[] = []; + const entryRe = /([\s\S]*?)<\/entry>/g; + let match: RegExpExecArray | null; + while ((match = entryRe.exec(xml))) { + const block = match[1] ?? ""; + const title = tagMatch(block, "title"); + const date = tagMatch(block, "updated"); + const url = attrMatch(block, "link", "href") ?? tagMatch(block, "id"); + const idHref = tagMatch(block, "id") ?? url ?? ""; + const hash = idHref.indexOf("#"); + const id = hash >= 0 ? idHref.slice(hash + 1) : undefined; + const tags = [...block.matchAll(/ + decodeXml(m[1] ?? ""), + ); + const html = tagMatch(block, "content") ?? ""; + const plain = stripHtml(html); + if (!title || !date || !url || !id || !plain) continue; + const kind: ChangelogKind = + tags.includes("cli") && !tags.includes("platform") ? "cli" : "platform"; + entries.push({ + id, + kind, + title, + date, + url, + tags, + summary: truncateSummary(plain), + body: plain, + }); + } + if (entries.length === 0) { + throw new UploadsError("changelog Atom feed had no usable entries", "API_ERROR"); + } + return { url: CHANGELOG_PAGE_URL, feed: CHANGELOG_XML_URL, entries }; +} + +export function parseChangelogJson(raw: unknown): ChangelogDocument { + if (!raw || typeof raw !== "object") { + throw new UploadsError("changelog response was not a JSON object", "API_ERROR"); + } + const rec = raw as Record; + if (!Array.isArray(rec.entries)) { + throw new UploadsError("changelog response is missing an entries array", "API_ERROR"); + } + const entries = rec.entries + .map(parseEntry) + .filter((e): e is ChangelogJsonEntry => e !== undefined); + if (rec.entries.length > 0 && entries.length === 0) { + throw new UploadsError("changelog response had no usable entries", "API_ERROR"); + } + const feed = asHttpUrl(rec.feed); + return { + url: asHttpUrl(rec.url) ?? CHANGELOG_PAGE_URL, + ...(feed ? { feed } : {}), + entries, + }; +} + +export function clampChangelogLimit(limit: number | undefined): number { + const n = limit ?? DEFAULT_CHANGELOG_LIMIT; + if (n > MAX_CHANGELOG_LIMIT) return MAX_CHANGELOG_LIMIT; + if (n < 1) return DEFAULT_CHANGELOG_LIMIT; + return n; +} + +export function selectChangelogEntries(doc: ChangelogDocument, limit?: number): ChangelogDocument { + const n = clampChangelogLimit(limit); + return { ...doc, entries: doc.entries.slice(0, n) }; +} + +function formatDate(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + timeZone: "UTC", + }); +} + +function indent(text: string, prefix = " "): string { + return text + .split("\n") + .map((line) => (line.length === 0 ? prefix.trimEnd() : `${prefix}${line}`)) + .join("\n"); +} + +/** Human terminal output: recent titles + summaries, then a link to the page. */ +export function formatChangelogHuman(doc: ChangelogDocument): string { + const lines: string[] = ["What's new", ""]; + if (doc.entries.length === 0) { + lines.push("No changelog entries."); + } else { + for (const entry of doc.entries) { + lines.push(entry.title); + lines.push(`${formatDate(entry.date)} · ${entry.url}`); + lines.push(""); + lines.push(indent(entry.summary)); + lines.push(""); + } + } + lines.push(`See all updates: ${doc.url}`); + return `${lines.join("\n")}\n`; +} + +async function fetchUrl( + fetchImpl: typeof fetch, + url: string, + headers: Record, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetchImpl(url, { signal: controller.signal, headers }); + } catch (err) { + const aborted = err instanceof Error && err.name === "AbortError"; + throw new UploadsError( + aborted + ? `timed out fetching changelog (${timeoutMs}ms)` + : `couldn't reach changelog (${err instanceof Error ? err.message : String(err)})`, + "NETWORK", + ); + } finally { + clearTimeout(timer); + } +} + +export async function fetchChangelog(opts: FetchChangelogOptions = {}): Promise { + const explicitUrl = opts.url; + const fetchImpl = opts.fetchImpl ?? fetch; + const timeoutMs = opts.timeoutMs ?? FETCH_TIMEOUT_MS; + const userAgent = opts.userAgent ?? `uploads/${packageVersion()}`; + const headers = { "user-agent": userAgent }; + + const jsonUrl = explicitUrl ?? CHANGELOG_JSON_URL; + const jsonRes = await fetchUrl( + fetchImpl, + jsonUrl, + { ...headers, accept: "application/json" }, + timeoutMs, + ); + if (jsonRes.ok) { + try { + const payload: unknown = await jsonRes.json(); + return selectChangelogEntries(parseChangelogJson(payload), opts.limit); + } catch (err) { + if (explicitUrl) { + throw err instanceof UploadsError + ? err + : new UploadsError("changelog response was not valid JSON", "API_ERROR", jsonRes.status); + } + } + } else if (explicitUrl) { + throw new UploadsError( + `changelog returned HTTP ${jsonRes.status}; see ${CHANGELOG_PAGE_URL}`, + "API_ERROR", + jsonRes.status, + ); + } + + // JSON twin is new; fall back to the Atom feed that already ships. + const xmlRes = await fetchUrl( + fetchImpl, + CHANGELOG_XML_URL, + { ...headers, accept: "application/atom+xml, application/xml, text/xml" }, + timeoutMs, + ); + if (!xmlRes.ok) { + throw new UploadsError( + `changelog returned HTTP ${xmlRes.status}; see ${CHANGELOG_PAGE_URL}`, + "API_ERROR", + xmlRes.status, + ); + } + const xml = await xmlRes.text(); + return selectChangelogEntries(parseChangelogAtom(xml), opts.limit); +} diff --git a/packages/uploads/src/cli-catalog.ts b/packages/uploads/src/cli-catalog.ts index a08fe0a3..e68bc5e1 100644 --- a/packages/uploads/src/cli-catalog.ts +++ b/packages/uploads/src/cli-catalog.ts @@ -250,6 +250,10 @@ export const ROOT_COMMANDS: readonly CatalogCommand[] = [ summary: "Update the CLI, then refresh the agent skills + MCP registration", essential: true, }, + { + name: "changelog", + summary: "Show recent product updates (and a link to the full changelog)", + }, { name: "login", summary: "Sign in via browser (or an enrollment code) and save credentials", diff --git a/packages/uploads/src/cli.ts b/packages/uploads/src/cli.ts index e99cb602..c659ed4d 100644 --- a/packages/uploads/src/cli.ts +++ b/packages/uploads/src/cli.ts @@ -41,6 +41,7 @@ import { runMcp } from "./commands/mcp.js"; import { runInstall } from "./commands/install.js"; import { runHook } from "./commands/hook.js"; import { runUpdate } from "./commands/update.js"; +import { runChangelog } from "./commands/changelog.js"; import { runCompletion } from "./commands/completion.js"; import { runLogout, runWhoami } from "./commands/session.js"; import { runTelemetry } from "./commands/telemetry.js"; @@ -309,6 +310,10 @@ export async function runCli(argv: string[]): Promise { case "health": code = await runHealth({ apiUrl, json }, cmdArgs, showHelp); break; + case "changelog": + // Public feed — no token, independent of the API origin. + code = await runChangelog(cmdArgs, { json }, showHelp); + break; case "config": code = await runConfig(cmdArgs, { json, envFile: parsed.globals.envFile }, showHelp); break; diff --git a/packages/uploads/src/commands/changelog.ts b/packages/uploads/src/commands/changelog.ts new file mode 100644 index 00000000..e754481c --- /dev/null +++ b/packages/uploads/src/commands/changelog.ts @@ -0,0 +1,68 @@ +import { flagBool, flagInt, parseCommandArgs, UsageError } from "../cli-args.js"; +import { + DEFAULT_CHANGELOG_LIMIT, + MAX_CHANGELOG_LIMIT, + fetchChangelog, + formatChangelogHuman, + type FetchChangelogOptions, +} from "../changelog.js"; +import { writeCommandHelp } from "../cli-style.js"; +import { writeJson, writeStdout } from "../io.js"; + +const CHANGELOG_HELP = `uploads changelog — recent product updates + +Prints the latest updates from uploads.sh, then a link to the full changelog. + +Usage: + uploads changelog [options] + +Options: + --limit Number of entries to show (default: ${DEFAULT_CHANGELOG_LIMIT}, max: ${MAX_CHANGELOG_LIMIT}) + --json JSON on stdout (also accepts global --json) + +Examples: + uploads changelog + uploads changelog --limit 10 + uploads changelog --json +`; + +export interface RunChangelogOptions { + json?: boolean; + fetch?: FetchChangelogOptions["fetchImpl"]; + url?: string; +} + +export async function runChangelog( + args: string[], + opts: RunChangelogOptions = {}, + help = false, +): Promise { + const parsed = parseCommandArgs(args); + if (help || parsed.help) { + writeCommandHelp(CHANGELOG_HELP); + return 0; + } + if (parsed.positionals.length > 0) { + throw new UsageError(`changelog takes no arguments (got ${parsed.positionals[0]})`, { + example: "uploads changelog", + }); + } + + const json = Boolean(opts.json) || flagBool(parsed.flags, "--json"); + const limit = flagInt(parsed.flags, "--limit", "--limit") ?? DEFAULT_CHANGELOG_LIMIT; + if (limit > MAX_CHANGELOG_LIMIT) { + throw new UsageError(`--limit must be ${MAX_CHANGELOG_LIMIT} or less (got ${limit})`, { + example: `uploads changelog --limit ${MAX_CHANGELOG_LIMIT}`, + }); + } + + const doc = await fetchChangelog({ + limit, + fetchImpl: opts.fetch, + url: opts.url, + }); + + if (json) await writeJson(doc); + else await writeStdout(formatChangelogHuman(doc)); + return 0; +} diff --git a/packages/uploads/src/index.ts b/packages/uploads/src/index.ts index 1f020100..9608f160 100644 --- a/packages/uploads/src/index.ts +++ b/packages/uploads/src/index.ts @@ -45,6 +45,21 @@ export { type PutDefaults, } from "./config.js"; export { UploadsError, type UploadsErrorCode } from "./errors.js"; +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"; export { assertFetchableUploadUrl, fetchUploadSource, diff --git a/packages/uploads/src/mcp/output-schemas.ts b/packages/uploads/src/mcp/output-schemas.ts index 667f7174..8a632c84 100644 --- a/packages/uploads/src/mcp/output-schemas.ts +++ b/packages/uploads/src/mcp/output-schemas.ts @@ -324,6 +324,29 @@ export const whoamiResultSchema: JsonSchema = objectSchema( ["ok", "workspace"], ); +const changelogEntrySchema: JsonSchema = objectSchema( + { + id: { type: "string" }, + kind: { type: "string", enum: ["platform", "cli"] }, + title: { type: "string" }, + date: { type: "string" }, + url: { type: "string" }, + tags: { type: "array", items: { type: "string" } }, + summary: { type: "string" }, + body: { type: "string" }, + }, + ["id", "kind", "title", "date", "url", "tags", "summary", "body"], +); + +export const changelogResultSchema: JsonSchema = objectSchema( + { + url: { type: "string" }, + feed: { type: "string" }, + entries: { type: "array", items: changelogEntrySchema }, + }, + ["url", "entries"], +); + export const promoteToolResultSchema: JsonSchema = objectSchema({ // `promotion` is optional (issue #702): a `keys`-only call (no `branch`) // never runs the branch sweep, so there's nothing to report under it. @@ -410,6 +433,7 @@ export const hostedOutputSchemas: Record = { reconcile: reconcileResultSchema, purge_expired: purgeExpiredResultSchema, whoami: whoamiResultSchema, + changelog: changelogResultSchema, }; /** Shared-shape stdio tools. Hosted-only tools (`promote`, `repo_link_status`) omitted. */ @@ -444,6 +468,7 @@ export const stdioOutputSchemas: Record = { reconcile: reconcileResultSchema, purge_expired: purgeExpiredResultSchema, whoami: whoamiResultSchema, + changelog: changelogResultSchema, report: objectSchema( { ok: { type: "boolean" }, diff --git a/packages/uploads/src/mcp/tools.ts b/packages/uploads/src/mcp/tools.ts index cde50c61..31c75af6 100644 --- a/packages/uploads/src/mcp/tools.ts +++ b/packages/uploads/src/mcp/tools.ts @@ -1,6 +1,6 @@ /** * MCP tool set mirroring the CLI commands (put, attach, list, delete, - * usage, reconcile, purge_expired, comment, whoami, doctor). Config is + * usage, reconcile, purge_expired, comment, whoami, doctor, changelog). Config is * resolved fresh per tool call so a * per-call `workspace` argument behaves like the CLI's --workspace flag, and * a missing token surfaces as a tool error rather than a startup failure. @@ -95,6 +95,7 @@ import { validateReportMessage, } from "../report.js"; import { resolveApiUrl } from "../config.js"; +import { DEFAULT_CHANGELOG_LIMIT, MAX_CHANGELOG_LIMIT, fetchChangelog } from "../changelog.js"; function mcpOptimizeOptions( args: ToolArgs, @@ -1765,6 +1766,33 @@ export function createUploadsMcpTools(opts: { return { ...target, ...result }; }, }, + { + name: "changelog", + title: "Product changelog", + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: true, + }, + securitySchemes: mcpNoAuth, + 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.", + inputSchema: { + type: "object", + properties: { + limit: { + type: "number", + description: `How many entries to return (default ${DEFAULT_CHANGELOG_LIMIT}, max ${MAX_CHANGELOG_LIMIT}).`, + }, + }, + additionalProperties: false, + }, + async handler(args) { + const limit = optPosInt(args, "limit") ?? DEFAULT_CHANGELOG_LIMIT; + if (limit > MAX_CHANGELOG_LIMIT) usage(`limit must be ${MAX_CHANGELOG_LIMIT} or less`); + return fetchChangelog({ limit }); + }, + }, { name: "whoami", title: "Who am I", diff --git a/packages/uploads/test/changelog.test.ts b/packages/uploads/test/changelog.test.ts new file mode 100644 index 00000000..d1f818d6 --- /dev/null +++ b/packages/uploads/test/changelog.test.ts @@ -0,0 +1,191 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + CHANGELOG_JSON_URL, + CHANGELOG_PAGE_URL, + CHANGELOG_XML_URL, + fetchChangelog, + formatChangelogHuman, + parseChangelogAtom, + parseChangelogJson, + selectChangelogEntries, +} from "../src/changelog.js"; +import { UploadsError } from "../src/errors.js"; + +const SAMPLE = { + url: CHANGELOG_PAGE_URL, + feed: "https://uploads.sh/changelog.xml", + entries: [ + { + id: "cli-changelog", + kind: "cli" as const, + title: "Read the changelog from the CLI", + date: "2026-09-10T00:00:00.000Z", + url: `${CHANGELOG_PAGE_URL}#cli-changelog`, + tags: ["cli"], + summary: "uploads changelog prints the latest product updates in your terminal.", + body: "`uploads changelog` prints the latest product updates.", + }, + { + id: "byo-bucket", + kind: "platform" as const, + title: "Bring your own bucket", + date: "2026-08-24T00:00:00.000Z", + url: `${CHANGELOG_PAGE_URL}#byo-bucket`, + tags: ["platform"], + summary: "Point any workspace at your own storage bucket.", + body: "You can now point any workspace at your own storage bucket.", + }, + { + id: "older", + kind: "platform" as const, + title: "Older", + date: "2026-08-01T00:00:00.000Z", + url: `${CHANGELOG_PAGE_URL}#older`, + tags: ["platform"], + summary: "An older update.", + body: "An older update.", + }, + ], +}; + +describe("parseChangelogJson", () => { + it("accepts a well-formed document", () => { + const doc = parseChangelogJson(SAMPLE); + expect(doc.url).toBe(CHANGELOG_PAGE_URL); + expect(doc.feed).toBe("https://uploads.sh/changelog.xml"); + expect(doc.entries).toHaveLength(3); + expect(doc.entries[0].title).toBe("Read the changelog from the CLI"); + }); + + it("skips malformed entries and keeps usable ones", () => { + const doc = parseChangelogJson({ + url: CHANGELOG_PAGE_URL, + entries: [SAMPLE.entries[0], { title: "nope" }, SAMPLE.entries[1]], + }); + expect(doc.entries.map((e) => e.id)).toEqual(["cli-changelog", "byo-bucket"]); + }); + + it("throws when every entry is unusable", () => { + expect(() => parseChangelogJson({ entries: [{ title: "nope" }] })).toThrow(UploadsError); + }); +}); + +describe("selectChangelogEntries", () => { + it("clamps to the requested limit", () => { + const doc = selectChangelogEntries(parseChangelogJson(SAMPLE), 2); + expect(doc.entries.map((e) => e.id)).toEqual(["cli-changelog", "byo-bucket"]); + }); +}); + +describe("formatChangelogHuman", () => { + it("prints titles, dates, summaries, and a link to the full page", () => { + const text = formatChangelogHuman(selectChangelogEntries(parseChangelogJson(SAMPLE), 2)); + expect(text).toContain("What's new"); + expect(text).toContain("Read the changelog from the CLI"); + expect(text).toContain("Sep 10, 2026"); + expect(text).toContain(`${CHANGELOG_PAGE_URL}#cli-changelog`); + expect(text).toContain("uploads changelog prints the latest product updates in your terminal."); + expect(text).toContain("Bring your own bucket"); + expect(text).not.toContain("Older"); + expect(text).toMatch(/See all updates: https:\/\/uploads\.sh\/changelog\n$/); + }); + + it("still links to the page when there are no entries", () => { + const text = formatChangelogHuman({ url: CHANGELOG_PAGE_URL, entries: [] }); + expect(text).toContain("No changelog entries."); + expect(text).toContain(`See all updates: ${CHANGELOG_PAGE_URL}`); + }); +}); + +describe("parseChangelogAtom", () => { + it("reads titles, dates, tags, and HTML content from the Atom twin", () => { + const xml = ` + + + https://uploads.sh/changelog#screenshots-page + A home for <your> screenshots + + 2026-08-11T00:00:00.000Z + + <p>Every screenshot the CLI captures now has a page of its own.</p> + +`; + const doc = parseChangelogAtom(xml); + expect(doc.entries).toHaveLength(1); + expect(doc.entries[0]).toMatchObject({ + id: "screenshots-page", + kind: "platform", + title: "A home for screenshots", + url: "https://uploads.sh/changelog#screenshots-page", + summary: "Every screenshot the CLI captures now has a page of its own.", + }); + }); +}); + +describe("fetchChangelog", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("GETs the JSON twin and applies the limit", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + new Response(JSON.stringify(SAMPLE), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + const doc = await fetchChangelog({ limit: 1, fetchImpl: fetchImpl as unknown as typeof fetch }); + expect(fetchImpl).toHaveBeenCalledWith( + CHANGELOG_JSON_URL, + expect.objectContaining({ + headers: expect.objectContaining({ accept: "application/json" }), + }), + ); + expect(doc.entries).toHaveLength(1); + expect(doc.entries[0].id).toBe("cli-changelog"); + }); + + it("maps a non-OK JSON response to API_ERROR when a URL is forced", async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response("nope", { status: 503 })); + await expect( + fetchChangelog({ + url: CHANGELOG_JSON_URL, + fetchImpl: fetchImpl as unknown as typeof fetch, + }), + ).rejects.toMatchObject({ code: "API_ERROR", status: 503 }); + }); + + it("falls back to the Atom feed when JSON is missing", async () => { + const xml = ` + + + https://uploads.sh/changelog#byo-bucket + Bring your own bucket + + 2026-08-24T00:00:00.000Z + + <p>Point any workspace at your own storage bucket.</p> + +`; + const fetchImpl = vi.fn().mockImplementation(async (url: string) => { + if (String(url) === CHANGELOG_JSON_URL) return new Response("not found", { status: 404 }); + if (String(url) === CHANGELOG_XML_URL) { + return new Response(xml, { + status: 200, + headers: { "content-type": "application/atom+xml" }, + }); + } + return new Response("unexpected", { status: 500 }); + }); + const doc = await fetchChangelog({ fetchImpl: fetchImpl as unknown as typeof fetch, limit: 1 }); + expect(doc.entries[0].id).toBe("byo-bucket"); + expect(doc.entries[0].summary).toContain("own storage bucket"); + }); + + it("maps a network failure to NETWORK", async () => { + const fetchImpl = vi.fn().mockRejectedValue(new Error("offline")); + await expect( + fetchChangelog({ fetchImpl: fetchImpl as unknown as typeof fetch }), + ).rejects.toMatchObject({ code: "NETWORK" }); + }); +}); diff --git a/packages/uploads/test/cli-completion.test.ts b/packages/uploads/test/cli-completion.test.ts index dc47b997..cad13337 100644 --- a/packages/uploads/test/cli-completion.test.ts +++ b/packages/uploads/test/cli-completion.test.ts @@ -77,6 +77,10 @@ describe("generateCompletionScript", () => { expect(ROOT_COMMANDS.map((c) => c.name)).toContain("update"); }); + it("includes the changelog command", () => { + expect(ROOT_COMMANDS.map((c) => c.name)).toContain("changelog"); + }); + it("continues every _arguments spec line so zsh keeps one command", () => { const script = generateCompletionScript("zsh"); const block = /^ {2}_arguments -C -s -S \\\n((?: {4}.*\n)+)/m.exec(script); diff --git a/packages/uploads/test/commands-changelog.test.ts b/packages/uploads/test/commands-changelog.test.ts new file mode 100644 index 00000000..04a3b0ff --- /dev/null +++ b/packages/uploads/test/commands-changelog.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CHANGELOG_PAGE_URL } from "../src/changelog.js"; +import { runChangelog } from "../src/commands/changelog.js"; +import { UsageError } from "../src/cli-args.js"; + +const SAMPLE = { + url: CHANGELOG_PAGE_URL, + feed: "https://uploads.sh/changelog.xml", + entries: [ + { + id: "cli-changelog", + kind: "cli", + title: "Read the changelog from the CLI", + date: "2026-09-10T00:00:00.000Z", + url: `${CHANGELOG_PAGE_URL}#cli-changelog`, + tags: ["cli"], + summary: "uploads changelog prints the latest product updates in your terminal.", + body: "`uploads changelog` prints the latest product updates.", + }, + ], +}; + +function fakeFetch(payload: unknown = SAMPLE, status = 200) { + return vi.fn().mockResolvedValue( + new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" }, + }), + ) as unknown as typeof fetch; +} + +function captureStreams() { + const out: string[] = []; + const err: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + out.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + err.push(String(chunk)); + return true; + }); + return { + stdout: () => out.join(""), + stderr: () => err.join(""), + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("uploads changelog", () => { + it("prints recent entries and a link to the website", async () => { + const { stdout } = captureStreams(); + const code = await runChangelog([], { fetch: fakeFetch() }); + expect(code).toBe(0); + expect(stdout()).toContain("What's new"); + expect(stdout()).toContain("Read the changelog from the CLI"); + expect(stdout()).toContain("See all updates: https://uploads.sh/changelog"); + }); + + it("emits JSON when --json is set", async () => { + const { stdout } = captureStreams(); + const code = await runChangelog(["--json"], { fetch: fakeFetch() }); + expect(code).toBe(0); + const parsed = JSON.parse(stdout()) as { url: string; entries: { id: string }[] }; + expect(parsed.url).toBe(CHANGELOG_PAGE_URL); + expect(parsed.entries[0].id).toBe("cli-changelog"); + }); + + it("prints help", async () => { + const { stderr } = captureStreams(); + const code = await runChangelog([], { fetch: fakeFetch() }, true); + expect(code).toBe(0); + expect(stderr()).toMatch(/uploads changelog/); + expect(stderr()).toMatch(/Examples:/); + expect(stderr()).toMatch(/uploads changelog --json/); + }); + + it("rejects unexpected positionals", async () => { + await expect(runChangelog(["search"], { fetch: fakeFetch() })).rejects.toBeInstanceOf( + UsageError, + ); + }); + + it("rejects a limit above the cap", async () => { + await expect(runChangelog(["--limit", "999"], { fetch: fakeFetch() })).rejects.toMatchObject({ + message: expect.stringMatching(/50 or less/), + }); + }); +}); diff --git a/packages/uploads/test/mcp.test.ts b/packages/uploads/test/mcp.test.ts index 3d1a5a3a..7891f9c7 100644 --- a/packages/uploads/test/mcp.test.ts +++ b/packages/uploads/test/mcp.test.ts @@ -503,6 +503,7 @@ describe("tools/list", () => { "reconcile", "purge_expired", "comment", + "changelog", "whoami", "doctor", "report", @@ -550,6 +551,12 @@ describe("tools/list", () => { ]); expect(byName.list._meta.securitySchemes).toEqual([{ type: "oauth2", scopes: ["files:read"] }]); expect(byName.whoami._meta.securitySchemes).toEqual([{ type: "noauth" }]); + expect(byName.changelog._meta.securitySchemes).toEqual([{ type: "noauth" }]); + expect(byName.changelog.annotations).toEqual({ + readOnlyHint: true, + destructiveHint: false, + openWorldHint: true, + }); }); it("advertises inputSchema examples on the complex tools", async () => { @@ -1807,6 +1814,41 @@ describe("config resolution", () => { }); }); + it("changelog returns recent entries and the website URL", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + url: "https://uploads.sh/changelog", + feed: "https://uploads.sh/changelog.xml", + entries: [ + { + id: "cli-changelog", + kind: "cli", + title: "Read the changelog from the CLI", + date: "2026-09-10T00:00:00.000Z", + url: "https://uploads.sh/changelog#cli-changelog", + tags: ["cli"], + summary: "uploads changelog prints recent updates.", + body: "`uploads changelog` prints recent updates.", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + try { + const { server } = serverWith(); + const res = await rpc(server, "tools/call", { name: "changelog", arguments: { limit: 1 } }); + expect(res.result.isError).toBe(false); + expect(res.result.structuredContent).toMatchObject({ + url: "https://uploads.sh/changelog", + entries: [{ id: "cli-changelog", title: "Read the changelog from the CLI" }], + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + it("report rejects short messages", async () => { const { server } = serverWith(); const res = await rpc(server, "tools/call", { diff --git a/skills/uploads-cli/SKILL.md b/skills/uploads-cli/SKILL.md index 5d776806..55f8f487 100644 --- a/skills/uploads-cli/SKILL.md +++ b/skills/uploads-cli/SKILL.md @@ -914,6 +914,8 @@ uploads reconcile # rebuild ledger from storage uploads purge-expired # delete past retentionDays (if set) uploads health # API liveness (no auth) uploads doctor # version + health + auth + workspace + usage +uploads changelog # recent product updates + link to uploads.sh/changelog +uploads changelog --json uploads --version ``` @@ -991,7 +993,8 @@ uploads --api-url http://localhost:8787 doctor `https://agents.uploads.sh/mcp` — the one to reach for when an agent has no local filesystem or git checkout to shell out from (send base64 content, or `contentUrl` when the file is already at a public HTTPS URL). Identity: - `whoami` (workspace + scopes; also confirms the server is up). Metadata: + `whoami` (workspace + scopes; also confirms the server is up). Product + updates: `changelog` (same as `uploads changelog`; no auth). Metadata: `get_metadata` / `set_metadata` / `find_files` / `list_metadata_keys` (same as `meta get` / `meta set` / `find` / `meta keys`|`meta values`). `find_files` accepts optional `name` (filename substring) with or without