diff --git a/README.md b/README.md
index bd97961..b4faea5 100644
--- a/README.md
+++ b/README.md
@@ -52,10 +52,13 @@ One-time setup:
the URLs from `SITE_URL`, nothing loaded from another origin, the deploy's triggers and
permissions, exact docs dependency versions, no docs committed, and the docs transforms
(titles, sidebar, links).
-- **The site build**: the docs build against uptide-dev/uptide@main and
- `scripts/check-links.mjs` finds no broken internal link or anchor in landing + docs.
+- **The site build**: the docs build against uptide-dev/uptide@main, the landing page's
+ version and packs are rendered from that checkout and its terminal blocks checked against
+ their public sources (`scripts/landing-data.mjs`), and `scripts/check-links.mjs` finds no
+ broken internal link or anchor in landing + docs.
-`scripts/assemble-site.mjs` and `scripts/check-links.mjs` belong to this repository. The rest
+`scripts/assemble-site.mjs`, `scripts/landing-data.mjs` and `scripts/check-links.mjs` belong to
+this repository. The rest
of `scripts/` (`dco.mjs`, `bots.mjs`, `public-tree.mjs`, `private-material.mjs`) and
`.github/workflows/dco.yml` are copies from uptide-dev/uptide, where they are tested. Change
them there first, then copy them here.
diff --git a/docs-site/README.md b/docs-site/README.md
index 3e522a5..eb62fed 100644
--- a/docs-site/README.md
+++ b/docs-site/README.md
@@ -53,8 +53,8 @@ square corners, 1px rules and no shadows. Code blocks are Expressive Code with o
on `#131210` in both themes. `src/components/SiteTitle.astro` is the landing page's mark and
wordmark, linking to `/`. `public/favicon.svg` is a link to `site/favicon.svg`.
-In light mode the dim text color is `#6A675F` rather than the landing page's `#76736B`, which
-falls just short of 4.5:1 for small text on the page background.
+In light mode the dim text color is `#6A675F`, the same as the landing page: 4.5:1 or better for
+small text on the page background.
## Versions
diff --git a/docs-site/src/styles/uptide.css b/docs-site/src/styles/uptide.css
index e709ccb..f3d0794 100644
--- a/docs-site/src/styles/uptide.css
+++ b/docs-site/src/styles/uptide.css
@@ -65,7 +65,7 @@
--uptide-line: #D5D2C8;
--uptide-text: #22211E;
--uptide-body: #57554F;
- /* The landing page's dim (#76736B) is a shade short of 4.5:1 on the page for small text. */
+ /* The same dim as the landing page: 4.5:1 or better for small text on the page. */
--uptide-dim: #6A675F;
--uptide-accent: #1D63C4;
diff --git a/docs/screenshots/site-404/404-dark.png b/docs/screenshots/site-404/404-dark.png
new file mode 100644
index 0000000..a168e79
Binary files /dev/null and b/docs/screenshots/site-404/404-dark.png differ
diff --git a/docs/screenshots/site-404/404-light.png b/docs/screenshots/site-404/404-light.png
new file mode 100644
index 0000000..77f504a
Binary files /dev/null and b/docs/screenshots/site-404/404-light.png differ
diff --git a/docs/screenshots/site-landing/desktop-dark.png b/docs/screenshots/site-landing/desktop-dark.png
index d2ec6b1..83769de 100644
Binary files a/docs/screenshots/site-landing/desktop-dark.png and b/docs/screenshots/site-landing/desktop-dark.png differ
diff --git a/docs/screenshots/site-landing/desktop-light.png b/docs/screenshots/site-landing/desktop-light.png
index 74ced15..ad1b720 100644
Binary files a/docs/screenshots/site-landing/desktop-light.png and b/docs/screenshots/site-landing/desktop-light.png differ
diff --git a/docs/screenshots/site-landing/hero-dark.png b/docs/screenshots/site-landing/hero-dark.png
new file mode 100644
index 0000000..bea939c
Binary files /dev/null and b/docs/screenshots/site-landing/hero-dark.png differ
diff --git a/docs/screenshots/site-landing/hero-light.png b/docs/screenshots/site-landing/hero-light.png
new file mode 100644
index 0000000..a3ed120
Binary files /dev/null and b/docs/screenshots/site-landing/hero-light.png differ
diff --git a/docs/screenshots/site-landing/mobile-dark.png b/docs/screenshots/site-landing/mobile-dark.png
index cef2e4c..51a6cdc 100644
Binary files a/docs/screenshots/site-landing/mobile-dark.png and b/docs/screenshots/site-landing/mobile-dark.png differ
diff --git a/docs/screenshots/site-landing/mobile-light.png b/docs/screenshots/site-landing/mobile-light.png
index 17f2e6d..95235af 100644
Binary files a/docs/screenshots/site-landing/mobile-light.png and b/docs/screenshots/site-landing/mobile-light.png differ
diff --git a/docs/screenshots/site-landing/packs-dark.png b/docs/screenshots/site-landing/packs-dark.png
new file mode 100644
index 0000000..c720285
Binary files /dev/null and b/docs/screenshots/site-landing/packs-dark.png differ
diff --git a/docs/screenshots/site-landing/packs-light.png b/docs/screenshots/site-landing/packs-light.png
new file mode 100644
index 0000000..9312eec
Binary files /dev/null and b/docs/screenshots/site-landing/packs-light.png differ
diff --git a/scripts/assemble-site.mjs b/scripts/assemble-site.mjs
index d365cb2..3839fbc 100644
--- a/scripts/assemble-site.mjs
+++ b/scripts/assemble-site.mjs
@@ -2,21 +2,45 @@
//
// node scripts/assemble-site.mjs [out] (default: _site)
//
-// site/ goes to the root as it is, and the built docs (docs-site/dist, from `npm run build`
-// in docs-site/) go under docs/. The landing page's %SITE_URL% placeholders are written in by
-// the Pages workflow afterwards, on the copy, never on site/ itself.
-import { cpSync, existsSync, rmSync } from 'node:fs';
+// site/ goes to the root (404.html included: Pages serves it for every missing path), and the
+// built docs (docs-site/dist, from `npm run build` in docs-site/) go under docs/. On the copy
+// of the landing page, never on site/ itself:
+// - the data-uptide slots (version, packs) are filled from the uptide-dev/uptide checkout the
+// docs build made, cross-checked against its README (scripts/landing-data.mjs);
+// - every
block must match its public source in that checkout.
+// The %SITE_URL% placeholders are written in by the Pages workflow afterwards.
+import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
+import { checkSources, fill, readPacks, readVersion, slots } from './landing-data.mjs';
const root = resolve(fileURLToPath(new URL('..', import.meta.url)));
const out = resolve(process.argv[2] ?? join(root, '_site'));
const docs = join(root, 'docs-site', 'dist');
+const sourceFile = join(root, 'docs-site', 'src', 'generated', 'source.json');
-if (!existsSync(join(docs, 'index.html')))
- throw new Error('docs-site/dist/index.html not found: run `npm run build` in docs-site/ first');
+if (!existsSync(join(docs, 'index.html')) || !existsSync(sourceFile))
+ throw new Error('docs-site/dist or its source.json not found: run `npm run build` in docs-site/ first');
+
+const source = JSON.parse(readFileSync(sourceFile, 'utf8'));
+const checkout = resolve(root, 'docs-site', source.checkout);
rmSync(out, { recursive: true, force: true });
cpSync(join(root, 'site'), out, { recursive: true, dereference: true });
cpSync(docs, join(out, 'docs'), { recursive: true, dereference: true });
-console.log(`site: ${out} (landing at /, docs at /docs/)`);
+
+const page = join(out, 'index.html');
+const html = readFileSync(page, 'utf8');
+const problems = checkSources(html, checkout);
+if (problems.length) {
+ console.error(`site/index.html no longer matches uptide-dev/uptide@${source.sha.slice(0, 7)}:\n ${problems.join('\n ')}`);
+ process.exit(1);
+}
+const packs = readPacks(checkout);
+const version = readVersion(checkout);
+writeFileSync(page, fill(html, slots({ version, packs })));
+
+console.log(
+ `site: ${out} (landing at /, docs at /docs/), uptide ${version} @ ${source.sha.slice(0, 7)}: ` +
+ `${packs.filter((p) => p.status === 'verified').length} verified packs (${packs.map((p) => p.package).join(', ')})`,
+);
diff --git a/scripts/landing-data.mjs b/scripts/landing-data.mjs
new file mode 100644
index 0000000..8362f9d
--- /dev/null
+++ b/scripts/landing-data.mjs
@@ -0,0 +1,170 @@
+// What the landing page says about Uptide, from the uptide-dev/uptide checkout the docs build
+// makes (docs-site/scripts/fetch-docs.mjs), so the page and the repository never disagree.
+//
+// - The packs: each pack's verification.json (what `uptide pack test --write` recorded) and
+// ground-truth.json, rendered into the page's data-uptide slots. The version range lives in
+// the pack's TypeScript metadata, so it is read from the README's generated packs table
+// (scripts/docs-packs.ts in uptide, kept current by its CI), and every other column of that
+// table must equal what this module computes from the JSON, or the build fails.
+// - The version: packages/cli/package.json.
+// - Terminal output: every
on the page must match its source in the
+// checkout, so a number on the page is always one a public run printed.
+import { existsSync, readFileSync, readdirSync } from 'node:fs';
+import { join } from 'node:path';
+
+const PACKS = 'packages/core/src/packs';
+const START = '';
+const END = '';
+
+const escape = (s) =>
+ String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+const unescape = (s) =>
+ s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
+
+/** The same rounding as uptide's README table. */
+export const percent = (n) => `${Math.round(n * 100)}%`;
+
+/** The README's generated packs table, by package: { range, precision, recall, repos, status }. */
+export function readmePacks(readme) {
+ const start = readme.indexOf(START);
+ const end = readme.indexOf(END);
+ if (start < 0 || end < start) throw new Error(`README.md: no ${START} … ${END} packs table`);
+ const rows = new Map();
+ for (const line of readme.slice(start + START.length, end).split('\n')) {
+ const cells = line.split('|').slice(1, -1).map((c) => c.trim());
+ const name = /^`([^`]+)`$/.exec(cells[0] ?? '');
+ if (!name || cells.length !== 6) continue;
+ const [, range, precision, recall, repos, status] = cells;
+ rows.set(name[1], { range, precision, recall, repos, status });
+ }
+ if (!rows.size) throw new Error('README.md: the packs table has no rows');
+ return rows;
+}
+
+/** Every pack in the checkout, in the README's order, cross-checked against the README. */
+export function readPacks(checkout) {
+ const dir = join(checkout, PACKS);
+ const fromJson = new Map();
+ for (const name of readdirSync(dir).sort()) {
+ const verification = join(dir, name, 'verification.json');
+ const truth = join(dir, name, 'ground-truth.json');
+ if (!existsSync(verification) || !existsSync(truth)) continue;
+ const v = JSON.parse(readFileSync(verification, 'utf8'));
+ const t = JSON.parse(readFileSync(truth, 'utf8'));
+ const repos = t.repos.filter((r) => r.repo).map((r) => r.repo);
+ fromJson.set(t.package, {
+ package: t.package,
+ status: v.status,
+ precision: v.breaking.precision,
+ recall: v.breaking.recall,
+ falsePositives: v.breaking.falsePositives,
+ repos,
+ });
+ }
+ if (!fromJson.size) throw new Error(`${PACKS}: no pack with verification.json and ground-truth.json`);
+
+ const readme = readmePacks(readFileSync(join(checkout, 'README.md'), 'utf8'));
+ const problems = [];
+ for (const name of fromJson.keys()) if (!readme.has(name)) problems.push(`${name}: in ${PACKS} but not in the README table`);
+ for (const name of readme.keys()) if (!fromJson.has(name)) problems.push(`${name}: in the README table but not in ${PACKS}`);
+ for (const [name, row] of readme) {
+ const pack = fromJson.get(name);
+ if (!pack) continue;
+ const ours = { precision: percent(pack.precision), recall: percent(pack.recall), repos: pack.repos.join(', '), status: pack.status };
+ for (const [key, value] of Object.entries(ours))
+ if (row[key] !== value) problems.push(`${name}: ${key} is "${value}" from the JSON but "${row[key]}" in the README`);
+ }
+ if (problems.length)
+ throw new Error(`The landing page and uptide's README would disagree:\n ${problems.join('\n ')}`);
+
+ return [...readme.keys()].map((name) => ({ ...fromJson.get(name), range: readme.get(name).range }));
+}
+
+export function readVersion(checkout) {
+ return JSON.parse(readFileSync(join(checkout, 'packages/cli/package.json'), 'utf8')).version;
+}
+
+/** The HTML for each data-uptide slot on the landing page. */
+export function slots({ version, packs }) {
+ const verified = packs.filter((p) => p.status === 'verified');
+ const names = verified.map((p) => `${escape(p.package)} ${escape(p.range)}`);
+ const list = names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names.join('');
+ return {
+ version: escape(version),
+ 'packs-heading': `${verified.length} verified ${verified.length === 1 ? 'pack' : 'packs'}`,
+ 'packs-list': `${list}.`,
+ 'packs-rows': packs
+ .map((p) => {
+ const repos = p.repos
+ .map((r) => `${escape(r)}`)
+ .join(', ');
+ return `
${escape(p.package)}
${escape(p.range)}
${percent(p.precision)}
${percent(p.recall)}
${repos}
${escape(p.status)}
`;
+ })
+ .join('\n '),
+ };
+}
+
+const SLOT = /(<(\w+)\b[^>]*\sdata-uptide="([\w-]+)"[^>]*>)([\s\S]*?)(<\/\2>)/g;
+
+/** The page with every data-uptide slot filled; fails on a slot it does not know or one left out. */
+export function fill(html, values) {
+ const seen = new Set();
+ const out = html.replace(SLOT, (_, open, _tag, key, _fallback, close) => {
+ if (!(key in values)) throw new Error(`data-uptide="${key}": no such value`);
+ seen.add(key);
+ return `${open}${values[key]}${close}`;
+ });
+ const missing = Object.keys(values).filter((k) => !seen.has(k));
+ if (missing.length) throw new Error(`the page has no data-uptide slot for: ${missing.join(', ')}`);
+ return out;
+}
+
+/** The code blocks of a Markdown file, each as an array of lines. */
+function fencedBlocks(markdown) {
+ const blocks = [];
+ let current = null;
+ for (const line of markdown.split('\n')) {
+ if (/^\s*```/.test(line)) {
+ if (current) blocks.push(current);
+ current = current ? null : [];
+ } else if (current) current.push(line.trimEnd());
+ }
+ return blocks;
+}
+
+const contains = (block, lines) =>
+ block.some((_, i) => lines.every((line, j) => block[i + j] === line));
+
+const flat = (s) => s.replace(/\s+/g, ' ').trim();
+
+/**
+ * Problems with the page's terminal output: each
must be a run of
+ * consecutive lines of a code block in that file of the checkout, or, with
+ * data-match="text", appear in it word for word (whitespace aside), as uptide's own test
+ * checks its privacy statement.
+ */
+export function checkSources(html, checkout) {
+ const problems = [];
+ let count = 0;
+ for (const [, attrs, inner] of html.matchAll(/
]*\sdata-source="[^"]*"[^>]*)>([\s\S]*?)<\/pre>/g)) {
+ count++;
+ const source = /data-source="([^"]*)"/.exec(attrs)[1];
+ const mode = /data-match="([^"]*)"/.exec(attrs)?.[1] ?? 'lines';
+ const file = join(checkout, source);
+ if (!existsSync(file)) {
+ problems.push(`${source}: not in the checkout`);
+ continue;
+ }
+ // Diff lines are block spans with no newline between them.
+ const text = unescape(inner.replace(/<\/span>(?= block to check');
+ return problems;
+}
diff --git a/site/404.html b/site/404.html
new file mode 100644
index 0000000..2ae0c89
--- /dev/null
+++ b/site/404.html
@@ -0,0 +1,67 @@
+
+
+
+
+
+Not found · Uptide
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/site/README.md b/site/README.md
index 788d24c..9692b48 100644
--- a/site/README.md
+++ b/site/README.md
@@ -13,6 +13,7 @@ npx serve site # or any static file server; open http://localhost:3000
| File | What it is |
| --- | --- |
| `index.html` | The page. Design tokens are the same as the list/check HTML report ([`packages/cli/src/html/assets.ts`](https://github.com/uptide-dev/uptide/blob/main/packages/cli/src/html/assets.ts) in uptide-dev/uptide); light and dark follow the system. |
+| `404.html` | The page GitHub Pages serves for every missing path. Every URL in it is absolute. |
| `favicon.svg` | The logo mark in its two blues (`#7CC4FF` wave, `#3E7FD9` line), the same in both themes. |
| `og.png` | Social preview, 1280×640. Used for `og:image` and `twitter:image`, and meant for the repository's social preview setting. |
| `og-image.html` | Source of `og.png`. Render it at 1280×640 with a headless browser and save a PNG screenshot. |
@@ -29,15 +30,25 @@ that one value.
## Where the numbers come from
-Every number on the page is printed output, never edited:
-
-- `list` on [supabase/supabase](https://github.com/supabase/supabase) at commit
- `9d1661dec1548ec5bf3ffb4f551376a7650c213e`, run with uptide 0.4.0 from npm (`npx uptide@latest list`) on 2026-10-06 from the
- repository root (`npx uptide list`). Rows are trimmed. Latest versions and advisories come
- from the registry at run time, so a rerun prints different numbers: when you refresh the
- page, rerun and replace the block and the four tiles together, and update the footnote.
-- `check` and `fix` on [`fixtures/repos/storefront`](https://github.com/uptide-dev/uptide/tree/main/fixtures/repos/storefront):
- the same blocks as the uptide README's "Before and after". If those numbers change, change
- them in both repositories.
-- Timings in the hero note: `list` on the storefront fixture (about a second) and on the
- supabase commit above (20 seconds).
+Every number on the page traces to a public run or a public file in
+[uptide-dev/uptide](https://github.com/uptide-dev/uptide), and the deploy checks it against the
+checkout the docs build makes (`scripts/landing-data.mjs`, run by `scripts/assemble-site.mjs`):
+
+- **Version and packs** are not written in `index.html`. Elements marked `data-uptide="…"`
+ hold a fallback, and the deploy fills them: the version from `packages/cli/package.json`,
+ and the packs (count, list, and the table with precision, recall and ground-truth
+ repositories) from each pack's `verification.json` and `ground-truth.json`. The version range
+ is read from the README's generated packs table, and every other column of that table must
+ equal what the JSON gives, so the page and the README cannot disagree: if they would, the
+ deploy fails.
+- **Terminal output** is the storefront fixture's `check` and `fix` runs as the uptide README
+ and docs print them. Each `
` names its source (`data-source="docs/commands/check.md"`)
+ and must be a run of consecutive lines of a code block there, so a block that drifts from
+ the published run fails the deploy. When uptide's docs change a run, copy the new lines in.
+- **The privacy block** is the statement `uptide --help` prints, checked word for word
+ against `docs/privacy.md` (`data-match="text"`, as uptide's own test checks it).
+- **Everything else** (Node 20, the default models, the `$1` cost cap, exit codes) is from
+ uptide's `package.json` and docs.
+
+Opening `index.html` straight from `site/` shows the fallbacks; see the repository README for
+building it as deployed.
diff --git a/site/index.html b/site/index.html
index 2658ea0..e2b45d4 100644
--- a/site/index.html
+++ b/site/index.html
@@ -39,7 +39,7 @@
@font-face{font-family:'IBM Plex Mono';font-weight:600;font-display:swap;src:url(fonts/ibm-plex-mono-latin-600-normal.woff2) format('woff2')}
/* Design tokens: the same values as the list/check HTML report (packages/cli/src/html/assets.ts). */
-:root{color-scheme:light;--bg:#F4F2EC;--surface:#FAF9F5;--line:#D5D2C8;--text:#22211E;--body:#57554F;--dim:#76736B;--accent:#1D63C4;--ok:#28724A;--warn:#996014;--bad:#B63C37;
+:root{color-scheme:light;--bg:#F4F2EC;--surface:#FAF9F5;--line:#D5D2C8;--text:#22211E;--body:#57554F;--dim:#6A675F;--accent:#1D63C4;--ok:#28724A;--warn:#996014;--bad:#B63C37;
--display:'Space Grotesk',system-ui,sans-serif;--sans:'Instrument Sans',system-ui,-apple-system,sans-serif;--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,monospace;--gx:40px;
/* The logo's two blues, the same in both themes. */
--logo-wave:#7CC4FF;--logo-line:#3E7FD9;
@@ -139,13 +139,6 @@
.output figure{margin:0;display:flex;flex-direction:column;gap:14px;min-width:0}
.output figcaption{font-size:15.5px;line-height:1.65;color:var(--body);max-width:760px}
.output figcaption b{color:var(--text);font-weight:600}
-.fn{font:12px/1.7 var(--mono);color:var(--dim)}
-.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid var(--line);border-bottom:0;background:var(--bg)}
-.stat{padding:20px 24px;border-right:1px solid var(--line)}
-.stat:last-child{border-right:0}
-.stat strong{display:block;font:500 40px/1.1 var(--display);letter-spacing:-.05em;margin-bottom:6px;font-variant-numeric:tabular-nums}
-.stat.warn strong{color:var(--warn)}
-.stat .label{font-size:10.5px}
/* risk table and privacy block */
.risk{border-top:1px solid var(--line)}
@@ -153,6 +146,13 @@
.risk b{font:600 11.5px var(--mono);letter-spacing:.12em;text-transform:uppercase;padding-top:3px}
.risk .lo{color:var(--ok)} .risk .me{color:var(--warn)} .risk .hi{color:var(--bad)}
.quote{margin:0 var(--gx) 32px}
+.packs{margin:0 var(--gx);padding:8px 0 20px;display:flex;flex-direction:column;gap:12px;max-width:900px}
+.packs p{font-size:15.5px;line-height:1.65;color:var(--body)}
+.scroll{margin:0 var(--gx) 80px;overflow-x:auto}
+.scroll table.leaves{width:100%;margin:0}
+table.leaves.wide td{white-space:nowrap}
+table.leaves.wide td:nth-child(3),table.leaves.wide td:nth-child(4),table.leaves.wide th:nth-child(3),table.leaves.wide th:nth-child(4){text-align:right;font-variant-numeric:tabular-nums}
+table.leaves.wide td:nth-child(5){white-space:normal;min-width:220px}
.callout{margin:0 var(--gx) 72px;padding-top:18px;border-top:1px solid var(--text);font-size:15.5px;line-height:1.65;color:var(--body);max-width:900px}
.callout b{color:var(--text)}
table.leaves{width:calc(100% - 2 * var(--gx));margin:0 var(--gx) 80px;border-collapse:collapse;font-size:15px}
@@ -178,14 +178,12 @@
.head{padding:64px var(--gx) 28px}
.row{grid-template-columns:1fr;gap:8px}
.cols{grid-template-columns:1fr}
- .stats{grid-template-columns:repeat(2,minmax(0,1fr))}
- .stat:nth-child(2){border-right:0}
- .stat:nth-child(-n+2){border-bottom:1px solid var(--line)}
- table.leaves,table.leaves tbody,table.leaves tr,table.leaves td{display:block;width:auto}
- table.leaves{width:auto}
- table.leaves thead{display:none}
- table.leaves tr{border-top:1px solid var(--line);padding:10px 0}
- table.leaves td,table.leaves td:first-child{border:0;padding:4px 0;white-space:normal}
+ table.leaves:not(.wide),table.leaves:not(.wide) tbody,table.leaves:not(.wide) tr,table.leaves:not(.wide) td{display:block;width:auto}
+ table.leaves:not(.wide){width:auto}
+ table.leaves:not(.wide) thead{display:none}
+ table.leaves:not(.wide) tr{border-top:1px solid var(--line);padding:10px 0}
+ table.leaves:not(.wide) td,table.leaves:not(.wide) td:first-child{border:0;padding:4px 0;white-space:normal}
+ .scroll{margin-bottom:64px}
}
@media (max-width:560px){
.bar .btn span{display:none}
@@ -196,8 +194,6 @@
.actions .btn{flex:1}
.out pre{font-size:11.5px;padding:14px}
.diff pre{padding:12px 0}.diff .l{padding:0 14px}
- .stat{padding:16px}
- .stat strong{font-size:30px}
}
@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
@media print{.bar{position:static}.copy{display:none}}
@@ -219,7 +215,7 @@
ContributeDocs
-
+ Star on GitHub
@@ -231,30 +227,24 @@
-
Open source · Apache-2.0
+
Open source · Apache-2.0 · latest on npm
Migrations you can merge.
Uptide is an open-source CLI that shows which dependency upgrades matter in your repo, what each one breaks in your code, and opens a verified migration branch or PR. It runs locally on your repo. Nothing to sign up for.
npx uptide list --html --open
-
No API key. No account. About a second on a small repo, 20 seconds on the supabase monorepo. Node 20 or newer.
Migrates on a new branch in an isolated clone of your local HEAD, in two passes:
-
Deterministic rules first. The dependency's pack (zod, stripe) rewrites every site it covers, the same way on every run. No LLM, no key.
+
Deterministic rules first. When the dependency has a verified pack, it rewrites every site it covers, the same way on every run. No LLM, no key.
Then AI, only for what rules can't do. It needs your API key in the environment: ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY (experimental). Without one, the rules still run and the remaining sites are listed for you; a dependency with no pack has no rules, so it needs the key.
Then it verifies with your compiler, the related tests, and your linter and formatter. With --pr --yes it pushes the branch and opens a draft PR once verification passes; without them it never pushes.
@@ -313,77 +303,38 @@
list, check, fix, pr.
03 / Real output
Real runs. Numbers as printed.
-
list on a large public monorepo, then check and fix on fixtures/repos/storefront, a small pnpm workspace in the repository written to exercise Uptide: two packages on zod 3 and stripe 14, with a few tests.
+
check and fix on fixtures/repos/storefront, a small pnpm workspace in the repository written to exercise Uptide: two packages on zod 3 and stripe 14, with a few tests. Every block below is a run published in the uptide README and docs, and the site build checks it against them.
-
- list on supabase/supabase. No install, no compilation, no LLM.1
-
$ npx uptide list
-uptide list · supabase · pnpm · 20s
-
-273 outdated 116 major 100 minor 57 patch 15 groups 53 tooling
-
-PRIORITIESmost urgent first · advisories checked for 377 packages
- Urgent 12 · advisories, deprecations
- @modelcontextprotocol/* 1 advisory (1 high), fixed in 1.31.0 (minor, same major)
- npx uptide check --group modelcontextprotocol --target @modelcontextprotocol/sdk@1.31.0
- simple-git dev · 3 advisories (1 critical), needs 4.0.1 (major)
- npx uptide check simple-git --target simple-git@4.0.1
- smol-toml 1 advisory (1 moderate), fixed in 1.9.0 (minor, same major)
- npx uptide check smol-toml --target smol-toml@1.9.0
- sharp dev · 1 advisory (1 high), fixed in 0.35.5 (patch, same major)
- npx uptide check sharp --target sharp@0.35.5
- katex 1 advisory (1 low), fixed in 0.18.2 (minor, same major)
- npx uptide check katex --target katex@0.18.2
- + 7 more · --all
- Worth planning 73 · unsupported, drift, blocking, majors behind · --all
-
-GROUPSupgrade together
-@sentry/* 3 packages → 11.x npx uptide check --group sentry
- @sentry/nextjs 10.59.0 → 11.4.0 major 59 files 91 calls
- @sentry/react 10.59.0 → 11.4.0 major 5 files 8 calls
- @sentry/tanstackstart-react 10.59.0 → 11.4.0 major 3 files 2 calls
-ai + @ai-sdk/* 5 packages → ai 7 · @ai-sdk/amazon-bedrock 5 · @ai-sdk/* 4 · @ai-sdk/mcp 2
- npx uptide check --group ai
- @ai-sdk/openai 2.0.2, 4.0.41 → 4.0.86 2 majors behind 2 files 1 call
- 2 versions in 2 workspaces
- ai 5.0.52, 7.0.59 → 7.0.130 2 majors behind 53 files 75 calls
- 2 versions in 2 workspaces
-…
-
-
-
-
Before: check. What breaks, where, and who can fix it.
fixtures/repos/storefrontuptide check
-
$ npx uptide check zod stripe
+
$ npx uptide check zod stripe
uptide check · storefront · pnpm · 8.5s
zod 3.25.76 → 4.6.5 major · latest on npm verified✗ 28 breaking in 7 files 24 auto-fixable · 4 need the agent (LLM)
stripe (packages/api) 14.25.0 → 23.0.0 9 majors behind · latest on npm verified✗ 4 breaking in 3 files 1 auto-fixable · 3 need the agent (LLM)
zod 28 breaking · compiled against 4.6.5: 28 new type errors
+ compiled 11 of 11 files in 2 workspaces with the repo's TypeScript 5.9.3✗ New error API (required_error → error) 24 sites auto-fixable
✗ ZodTypeDef removed 1 fix, 3 errors needs the agent (LLM)
✗ .ip() removed monitoring.ts:6 needs the agent (LLM)
! 15 deprecated calls (.uuid, .datetime, .email, ...) 8 auto-fixable
stripe 4 breaking · compiled against 23.0.0: 4 new type errors
+ compiled 7 of 7 files in 1 workspace with the repo's TypeScript 5.9.3✗ Test fixture casts widened renewal.test.ts:7 auto-fixable
✗ Subscription billing period moved to items 2 sites needs the agent (LLM)
✗ apiVersion no longer matches the SDK client.ts:9 needs the agent (LLM)
- 1 API change since 2023-10-16 affects your code
+ 1 API change since 2023-10-16 affects your code
+
+Next
+ npx uptide fix zod migrate on a new branch, verify, no push
+ npx uptide fix stripe migrate on a new branch, verify, no push
+ npx uptide plan --only 'zod,stripe' the order to upgrade in, with the effort
+ npx uptide check zod stripe --details every site and reason
@@ -391,7 +342,7 @@
Real runs. Numbers as printed.
After: fix, one dependency at a time, each on its own verified branch.
No compiler sees this one: zod 4 words its default messages differently, and a test that asserted the old wording failed after the migration. Uptide updated it and says so in the pull request, so you can check that nothing else reads that text.
packages/shared/src/monitoring.test.tsdiff
-
- expect(parsed.error?.issues[0]?.message).toBe('Required');+ expect(parsed.error?.issues[0]?.message).toBe('Invalid input: expected string, received undefined');
+
- expect(parsed.error?.issues[0]?.message).toBe('Required');+ expect(parsed.error?.issues[0]?.message).toBe('Invalid input: expected string, received undefined');
- renewsAt: new Date(subscription.current_period_end * 1000),+ renewsAt: new Date(period.end * 1000),
+
- renewsAt: new Date(subscription.current_period_end * 1000),+ renewsAt: new Date(period.end * 1000),
The one type error left in both runs is the fixture's own, there on purpose: errors a repository already had are subtracted, never blamed on the upgrade.
-
1 supabase/supabase at commit 9d1661dec1548ec5bf3ffb4f551376a7650c213e, run with uptide 0.4.0 from npm (npx uptide@latest list) on 2026-10-06. Rows are trimmed, values are as printed. Latest versions and advisories come from the npm registry at run time, so a later run prints different numbers.
@@ -447,7 +397,7 @@
The compiler decides. The diff explains.
Verified packs and generic mode
-
zod 3 → 4 and stripe have verified packs: rules written for that dependency, a guide for the agent, behavior checks, and ground truth from a real upgrade that the pack is scored against.
+
A verified pack holds rules written for that dependency, a guide for the agent, behavior checks, and ground truth from public repositories that made the upgrade, which the pack is scored against. The packs below are verified.
Every other dependency runs in generic mode. A finding is breaking only when your compiler or the runtime probe confirms it, or when it is a require() of an ESM-only package or an import of a removed export. Everything else is listed as unconfirmed under --details. A generic fix uses the agent alone, under the same verification, and its pull request says no pack covers the package.
@@ -468,6 +418,16 @@
Why stripe is High on purpose
Types and tests pass, but a new Stripe API version changes what Stripe sends. Server API changes are never rewritten by rule, and the pull request asks what only you can answer, such as which item's period counts when a subscription has several.
+
+
Verified packs
+
Precision and recall are for breaking findings on each pack's ground truth, as uptide pack test recorded them in the pack's verification.json. A pack is verified with two or more public repositories and no false positive. This table is built from those files on every deploy, the same way as the uptide README.
Privacy:
Analysis runs locally. Code snippets go to your chosen LLM provider (Anthropic, OpenAI or Gemini), only
for assisted fixes in `uptide fix`, and only with your own API key from ANTHROPIC_API_KEY,
OPENAI_API_KEY or GEMINI_API_KEY: for each
site the rules cannot migrate, the finding, the enclosing function and the compiler
error. `uptide fix --no-llm` turns assisted fixes off. No account.
+ `check` and `verify` load the `typescript` package your repository installs (resolved from its
+ node_modules) to compile with, and never run your repository's scripts or other code outside
+ `fix`'s verification step.
Anonymous telemetry is off by default and asks for consent in an interactive terminal.
Set UPTIDE_TELEMETRY=0 to disable it. No IP, code, paths or repo names are collected.
Other network use: your npm registry for package metadata and tarballs, npm's advisory
@@ -556,7 +519,7 @@
What works now.
Package managers
npm, pnpm and Yarn with the node-modules linker. Not Yarn Plug'n'Play.
Monorepos
Workspaces and pnpm catalogs. One row per package across workspaces, with version drift called out.
Languages
TypeScript, and JavaScript in the project (allowJs). In JavaScript the compiler type-checks (checkJs or // @ts-check) the compiler decides; elsewhere a site is breaking only when the runtime confirms it.
Every other dependency: list, check, and fix with the agent.
GitHub Action
For version-bot pull requests: detects zod and stripe upgrades and keeps one findings comment. With fix enabled, it pushes one verified commit to the bot's branch. Setup.
diff --git a/site/og.png b/site/og.png
index 93a5172..3814bfd 100644
Binary files a/site/og.png and b/site/og.png differ
diff --git a/test/landing.test.mjs b/test/landing.test.mjs
new file mode 100644
index 0000000..b472125
--- /dev/null
+++ b/test/landing.test.mjs
@@ -0,0 +1,136 @@
+// The landing page's numbers come from uptide-dev/uptide at build time (scripts/landing-data.mjs),
+// its 404 page is deployed with it, and its colors match the docs site.
+import assert from 'node:assert/strict';
+import { mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { test } from 'node:test';
+import { checkSources, fill, readPacks, readVersion, slots } from '../scripts/landing-data.mjs';
+
+const root = new URL('../', import.meta.url);
+const read = (path) => readFileSync(new URL(path, root), 'utf8');
+const page = read('site/index.html');
+const notFound = read('site/404.html');
+/** What a reader sees: the body without terminal blocks, scripts and markup. */
+const prose = (html) =>
+ html
+ .slice(html.indexOf(''))
+ .replace(/<(pre|script|svg)\b[\s\S]*?<\/\1>/g, '')
+ .replace(/<[^>]+>/g, ' ');
+
+test('light dim is #6A675F everywhere, as on the docs site', () => {
+ const files = ['site/index.html', 'site/404.html', 'site/og-image.html', 'docs-site/src/styles/uptide.css'];
+ for (const file of files) assert.doesNotMatch(read(file), /#76736B|118,\s*115,\s*107/i, file);
+ assert.match(page, /--dim:#6A675F/);
+ assert.match(read('docs-site/src/styles/uptide.css'), /--uptide-dim: #6A675F;/);
+});
+
+test('the 404 page: wordmark to /, one line, links to / and /docs/, both themes, no em dash', () => {
+ assert.match(notFound, /]*>[\s\S]*?uptide\s*<\/a>/);
+ assert.match(notFound, /