← back to build-pages
server.js
1541 lines
// build-pages — a live build-journal site. Every project Steve ships gets a
// page surfacing the full git history with full diffs, file tree, message
// search, and auto-extracted "ideas / agents / skills" parsed from commit
// bodies. No DB — git is the source of truth, queried on demand.
const express = require('express');
const compression = require('compression');
const path = require('path');
const fs = require('fs');
const { execFile } = require('child_process');
const { promisify } = require('util');
const exec = promisify(execFile);
const PORT = parseInt(process.env.PORT || '9700', 10);
const HOME = process.env.HOME;
// Boot-cached version metadata so /api/version stays sub-1ms. git SHA is
// best-effort — falls back to 'unknown' if git isn't on PATH at startup.
const PKG_VERSION = (() => {
try { return require('./package.json').version || '0.0.0'; }
catch (_) { return '0.0.0'; }
})();
const GIT_SHA = (() => {
try {
return require('child_process')
.execSync('git rev-parse --short HEAD', { cwd: __dirname, stdio: ['ignore', 'pipe', 'ignore'] })
.toString().trim() || 'unknown';
} catch (_) { return 'unknown'; }
})();
const STARTED_AT_ISO = new Date().toISOString();
// CURATED registry: kebab-slug → {name, repo, blurb}. These get a hand-written
// name + blurb and sort first (featured). Everything else is AUTO-DISCOVERED from
// ~/Projects (any top-level dir with a .git). Curated entries override the
// auto-discovered one for the same repo, so the nice copy wins.
const CURATED = {
butlr: {
name: 'Butlr',
repo: path.join(HOME, 'Projects/holdforme'),
blurb: 'We wait on hold so you don\'t have to. Twilio Voice + Whisper transcripts + optional AI agent.',
aliases: ['holdforme'],
},
asseeninmovies: {
name: 'AsSeenInMovies',
repo: path.join(HOME, 'Projects/asseeninmovies'),
blurb: 'IMDb-class catalog with the "SPOTTED" community layer — what fictional decor / fashion / cars are real, and where to buy them.',
aliases: ['asim'],
},
starsofdesign: {
name: 'Stars of Design',
repo: path.join(HOME, 'Projects/starsofdesign'),
blurb: 'A curated directory of the most influential interior designers + their firms + their real DW clients.',
aliases: ['sod'],
},
'build-pages': {
name: 'build-pages',
repo: __dirname,
blurb: 'Live git-as-DB build journal for every shipped project. The page you\'re reading.',
aliases: [],
},
};
// "small-business-builder" → "Small Business Builder" (keeps ALLCAPS acronyms).
function prettyName(slug) {
return String(slug).replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
// Build the live registry: every top-level ~/Projects dir with a .git, plus the
// curated overrides. Curated repos are keyed by path so an entry whose slug ≠
// dirname (e.g. butlr → holdforme) isn't also listed under its raw dirname.
function discoverProjects() {
const base = path.join(HOME, 'Projects');
const out = {};
const curatedRepos = new Set(Object.values(CURATED).map((p) => p.repo));
let dirents = [];
try { dirents = fs.readdirSync(base, { withFileTypes: true }); } catch (_) { /* fall through to curated-only */ }
for (const d of dirents) {
if (!d.isDirectory()) continue;
if (d.name.startsWith('_')) continue; // skip scratch/archive dirs (_overnight, _shared, _archive…)
const repo = path.join(base, d.name);
if (curatedRepos.has(repo)) continue; // a curated entry already covers this repo
if (!fs.existsSync(path.join(repo, '.git'))) continue; // only real git repos
out[d.name] = { name: prettyName(d.name), repo, blurb: '', aliases: [], discovered: true };
}
for (const [slug, p] of Object.entries(CURATED)) out[slug] = { ...p, featured: true };
return out;
}
const PROJECTS = discoverProjects();
// ─────────────────────────────────────────────────────────────────────────────
// git helpers — narrow, single-purpose, all run with cwd=repo. Each returns a
// JSON-friendly shape so the routes are just glue.
// ─────────────────────────────────────────────────────────────────────────────
async function gitRun(repo, args) {
const { stdout } = await exec('git', args, { cwd: repo, maxBuffer: 50 * 1024 * 1024 });
return stdout;
}
// Per-repo cache keyed on HEAD SHA. Probing HEAD is ~5ms; rebuilding the full
// commits+files+facets bundle is 50-80ms. A page hit on an unchanged repo
// drops from ~80ms to ~10ms once warm. The HEAD probe still runs every hit
// so a `git commit` in the source repo shows up on the next refresh.
const _projectCache = new Map();
// With ~540 auto-discovered repos, probing HEAD on every hit would spawn 540 git
// processes per request. So the REQUEST path serves the warm cache untouched for
// BUNDLE_TTL; a background warmer (force=true) is the only thing that re-checks
// HEAD and rebuilds changed repos. Keep BUNDLE_TTL > the warm interval so a
// request never falls through to git.
const BUNDLE_TTL = 100_000;
async function getProjectBundle(slug, p, force = false) {
const cached = _projectCache.get(slug);
if (!force && cached && Date.now() - cached.at < BUNDLE_TTL) return cached;
let head;
try { head = (await gitRun(p.repo, ['rev-parse', 'HEAD'])).trim(); }
catch (_) { return cached || null; } // empty / broken repo — keep any prior bundle
if (cached && cached.head === head) { cached.at = Date.now(); return cached; }
const commits = await listCommits(p.repo);
const facets = extractFacets(commits);
const files = await listFiles(p.repo);
const bundle = { head, commits, facets, files, at: Date.now() };
_projectCache.set(slug, bundle);
return bundle;
}
// Bounded-concurrency fan-out so a fleet-wide sweep never launches 540 git
// processes at once.
async function mapLimit(items, limit, fn) {
const out = new Array(items.length);
let i = 0;
const run = async () => { while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx], idx); } };
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run));
return out;
}
// Background warmer — keeps every bundle warm OFF the request path. Runs at boot
// and on an interval (< BUNDLE_TTL) so user requests only ever read cache.
async function warmAll() {
await mapLimit(Object.entries(PROJECTS), 12, ([slug, p]) => getProjectBundle(slug, p, true).catch(() => null));
}
async function listCommits(repo) {
const fmt = '%H%x01%h%x01%ai%x01%an%x01%s%x01%b%x02';
const raw = await gitRun(repo, ['log', '--no-merges', `--pretty=format:${fmt}`]);
return raw
.split('\x02\n')
.map(s => s.trim())
.filter(Boolean)
.map(line => {
const [hash, shortHash, dateISO, author, subject, body = ''] = line.split('\x01');
return { hash, shortHash, dateISO, author, subject, body: body.replace(/\x02$/, '') };
});
}
async function commitDetail(repo, hash) {
const log = await gitRun(repo, ['log', '-1', '--pretty=format:%H%n%ai%n%an%n%s%n%n%b', hash]);
const lines = log.split('\n');
const out = {
hash: lines[0],
dateISO: lines[1],
author: lines[2],
subject: lines[3],
body: lines.slice(5).join('\n').trim(),
};
out.diff = await gitRun(repo, ['show', '--no-color', '--stat', '--patch', hash]);
out.files = (await gitRun(repo, ['show', '--name-status', '--pretty=format:', hash])).trim().split('\n').filter(Boolean);
return out;
}
async function listFiles(repo) {
const raw = await gitRun(repo, ['ls-files']);
return raw.split('\n').filter(Boolean);
}
async function readBlob(repo, ref, file) {
return gitRun(repo, ['show', `${ref}:${file}`]);
}
// ─────────────────────────────────────────────────────────────────────────────
// Idea / agent / skill extraction. Long commit bodies on Steve's projects often
// embed the design rationale + which sub-agent or skill was used. Parse those
// out so the doc page surfaces them as first-class facets.
// ─────────────────────────────────────────────────────────────────────────────
function extractFacets(commits) {
const agentRe = /\b(claude-code-guide|domain-name-agent|lawyer-build-agent|doctor-agent|secrets-manager|exa-agent|search-specialist|technical-researcher|website-analysis|debate-team-fast|abramstasks|prompt-engineer|backend-architect|frontend-developer|code-reviewer|security-auditor|debugger|database-architect|test-engineer|ai-engineer|architect-reviewer|deployment-engineer|comms-compliance|vp-engineering|vp-operations|vp-dw-commerce|vp-compliance-policy|vp-directories|vp-research-content)\b/gi;
const skillRe = /\/([a-z][a-z0-9-]{2,40})\b/g;
const agents = new Map();
const skills = new Map();
const ideas = [];
for (const c of commits) {
const blob = `${c.subject}\n${c.body}`;
for (const m of blob.matchAll(agentRe)) {
const a = m[1].toLowerCase();
agents.set(a, (agents.get(a) || 0) + 1);
}
for (const m of blob.matchAll(skillRe)) {
const s = m[1];
// Filter out path-y false positives — we want skill names like /loop /secrets, not /tmp /api /lib
if (/^(tmp|api|var|usr|etc|root|home|opt|public|src|lib|node|bin|dist|build|test|tests|css|js|img|files|email|sms|admin)$/.test(s)) continue;
skills.set(s, (skills.get(s) || 0) + 1);
}
// "creative idea" = body lines beginning with a numbered list, dash, or
// containing words like "idea/perfect/polish/scaffold" — surface them.
if (c.body.length > 120) {
ideas.push({ hash: c.shortHash, dateISO: c.dateISO, subject: c.subject, body: c.body });
}
}
const sort = (m) => [...m.entries()].sort((a, b) => b[1] - a[1]).map(([k, v]) => ({ name: k, count: v }));
return { agents: sort(agents), skills: sort(skills), ideas };
}
// ─────────────────────────────────────────────────────────────────────────────
// HTML shell + escaping. The page is static-rendered server-side, then a small
// inline script wires up the client-side search filter (no build step).
// ─────────────────────────────────────────────────────────────────────────────
function esc(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
// Wrap each line of an escaped diff with a class based on its first char so
// CSS can color +/- lines distinctively. `+++/---/@@` are file/hunk headers,
// not change lines. Operates on already-escaped output to avoid double-escape.
function highlightDiff(escDiff) {
return escDiff.split('\n').map(line => {
if (line.startsWith('+++') || line.startsWith('---')) return `<span class="bp-d-h">${line}</span>`;
if (line.startsWith('@@')) return `<span class="bp-d-h2">${line}</span>`;
if (line.startsWith('+')) return `<span class="bp-d-add">${line}</span>`;
if (line.startsWith('-')) return `<span class="bp-d-rm">${line}</span>`;
return line;
}).join('\n');
}
function shell(title, body, headExtra = '', meta = {}) {
const canonical = meta.canonical || 'https://projects.agentabrams.com/';
const desc = meta.desc || 'Live build journals for every project — every commit, every line of design rationale, searchable.';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(title)}</title>
<meta name="description" content="${esc(desc)}">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#faf9f6" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0d0c0a" media="(prefers-color-scheme: dark)">
<link rel="canonical" href="${esc(canonical)}">
<link rel="search" type="application/opensearchdescription+xml" title="build-pages" href="/opensearch.xml">
<meta property="og:title" content="${esc(title)}">
<meta property="og:description" content="${esc(desc)}">
<meta property="og:type" content="website">
<meta property="og:url" content="${esc(canonical)}">
<meta name="twitter:card" content="summary">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%23111'/><text x='16' y='22' font-family='Georgia,serif' font-size='20' fill='%23ffd633' text-anchor='middle'>BP</text></svg>">
<link rel="stylesheet" href="/css/site.css">
${headExtra}
</head>
<body>
<header class="bp-hdr">
<a class="bp-logo" href="/">build-pages</a>
<nav class="bp-nav">
<a href="/">all projects</a>
</nav>
</header>
<main class="bp-main">
${body}
</main>
<footer class="bp-ftr">
<span>git is the source of truth — this page is rendered live from <code>git log</code>.</span>
<span class="bp-ftr-sep">·</span>
<a href="/about">about</a>
<span class="bp-ftr-sep">·</span>
<a href="/privacy">privacy</a>
<span class="bp-ftr-sep">·</span>
<a href="/terms">terms</a>
<span class="bp-ftr-sep">·</span>
<a href="/api">JSON API</a>
<span class="bp-ftr-sep">·</span>
<a href="/sitemap.xml">sitemap</a>
<span class="bp-ftr-sep">·</span>
<a href="/fleet.csv">fleet.csv</a>
</footer>
</body>
</html>`;
}
// ─────────────────────────────────────────────────────────────────────────────
// Routes
// ─────────────────────────────────────────────────────────────────────────────
const app = express();
app.disable('x-powered-by');
app.use(compression());
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=(), payment=()');
// X-Response-Time — machine-readable companion to the home-page footer.
// Surfaces in DevTools and any reverse proxy / log pipeline.
const t0 = process.hrtime.bigint();
res.on('finish', () => { /* finish-only; setHeader after-flush would no-op */ });
const _setHeader = res.setHeader.bind(res);
res.setHeader = function (...args) { return _setHeader(...args); };
res.on('close', () => {});
// Compute on the way out via res.writeHead override — simpler than tracking
// every send path. ms with 1 decimal so sub-millisecond responses show up.
const _writeHead = res.writeHead.bind(res);
res.writeHead = function (...args) {
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
try { res.setHeader('X-Response-Time', ms.toFixed(1) + 'ms'); } catch (_) {}
return _writeHead(...args);
};
next();
});
// Snapshot-path guard — runs before express.static so editor backup files
// (.bak, .bak.<n>, .pre-<tag>, .orig, .rej, ~) can never be served from any
// public mount, even if one accidentally lands in public/. Returns 404 with
// no body so probes don't even learn the path shape.
app.use((req, res, next) => {
if (/(\.bak(\.|$)|\.pre-|\.orig$|\.rej$|~$)/i.test(req.path)) {
return res.status(404).end();
}
next();
});
app.use('/css', express.static(path.join(__dirname, 'public/css'), { maxAge: '1h' }));
// favicon.svg — same brand mark as the data: URI in <head>, but served as
// a real URL so OpenSearch <Image>, Slack/Discord link unfurling, and any
// "where's the icon file" tool can find it.
const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#111"/><text x="16" y="22" font-family="Georgia,serif" font-size="20" fill="#ffd633" text-anchor="middle">BP</text></svg>`;
app.get('/favicon.svg', (_req, res) => {
res.setHeader('Cache-Control', 'public, max-age=86400');
res.type('image/svg+xml').send(FAVICON_SVG);
});
app.get('/favicon.ico', (_req, res) => {
res.setHeader('Cache-Control', 'public, max-age=86400');
res.type('image/svg+xml').send(FAVICON_SVG);
});
// robots.txt — let everything be crawled. /api/* is JSON discovery, public.
app.get('/robots.txt', (_req, res) => {
res.setHeader('Cache-Control', 'public, max-age=86400');
res.type('text/plain').send('User-agent: *\nAllow: /\nSitemap: https://projects.agentabrams.com/sitemap.xml\n');
});
// sitemap.xml — home + every /p/:slug. Commit-detail pages are excluded; with
// 200+ commits across the fleet they'd dwarf the discoverable surface without
// adding crawler value. Last-Modified mirrors the most recent commit per repo.
app.get('/sitemap.xml', async (_req, res, next) => {
try {
const base = 'https://projects.agentabrams.com';
const today = new Date().toISOString().slice(0, 10);
const urls = [
`<url><loc>${base}/</loc><lastmod>${today}</lastmod><changefreq>daily</changefreq><priority>1.0</priority></url>`,
`<url><loc>${base}/about</loc><lastmod>${today}</lastmod><changefreq>monthly</changefreq><priority>0.6</priority></url>`,
`<url><loc>${base}/privacy</loc><lastmod>${today}</lastmod><changefreq>yearly</changefreq><priority>0.3</priority></url>`,
`<url><loc>${base}/terms</loc><lastmod>${today}</lastmod><changefreq>yearly</changefreq><priority>0.3</priority></url>`,
];
for (const slug of Object.keys(PROJECTS)) {
const bundle = await getProjectBundle(slug, PROJECTS[slug]).catch(() => null);
const lastmod = (bundle && bundle.commits[0]) ? bundle.commits[0].dateISO.slice(0, 10) : today;
urls.push(`<url><loc>${base}/p/${slug}</loc><lastmod>${lastmod}</lastmod><changefreq>daily</changefreq><priority>0.8</priority></url>`);
}
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n ${urls.join('\n ')}\n</urlset>\n`;
res.setHeader('Cache-Control', 'public, max-age=21600, stale-while-revalidate=86400');
res.setHeader('Last-Modified', new Date().toUTCString());
res.type('application/xml').send(xml);
} catch (e) { next(e); }
});
app.get('/healthz', (_req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.json({ ok: true, ts: Date.now() });
});
// /api/health — liveness + project count snapshot. Mirrors the asim/sod shape
// so fleet-status.sh can render every site through one digest path. Includes
// system loadavg (1m/5m/15m) so external monitors can catch host-overload
// scenarios (we hit load=71 earlier in this session before noticing manually).
app.get('/api/health', (_req, res) => {
res.setHeader('Cache-Control', 'no-store');
const [l1, l5, l15] = require('os').loadavg();
res.json({
ok: true,
counts: { projects: Object.keys(PROJECTS).length },
uptime_s: Math.floor(process.uptime()),
loadavg: { '1m': +l1.toFixed(2), '5m': +l5.toFixed(2), '15m': +l15.toFixed(2) },
ts: Date.now(),
as_of: new Date().toISOString(),
});
});
// /api/version — deploy fingerprint, sub-1ms (all resolved at boot).
app.get('/api/version', (_req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.json({
name: 'build-pages',
version: PKG_VERSION,
git: GIT_SHA,
node: process.version,
started_at: STARTED_AT_ISO,
});
});
// Index — all projects. Pulls counts from the per-repo bundle cache (shared
// with /p/:slug), so a warm index hit is one HEAD-probe per repo, not a full
// git rev-list scan each.
app.get('/', async (req, res, next) => {
const t0 = Date.now();
try {
// Today-PT window — computed once, applied per project so each card shows
// its own today contribution alongside fleet-wide totals.
const tzPT = 'America/Los_Angeles';
const todayStartMs = startOfDayInTz(todayInTz(tzPT), tzPT);
const todayEndMs = todayStartMs + 86400000;
const entries = await mapLimit(Object.entries(PROJECTS), 16, async ([slug, p]) => {
const bundle = await getProjectBundle(slug, p).catch(() => null);
const count = bundle ? bundle.commits.length : 0;
const latest = bundle && bundle.commits[0] ? bundle.commits[0].dateISO.slice(0, 10) : '';
const latestHash = bundle && bundle.commits[0] ? bundle.commits[0].shortHash : '';
const ideas = bundle ? bundle.facets.ideas.length : 0;
let today = 0;
if (bundle) {
for (const c of bundle.commits) {
const t = new Date(c.dateISO).getTime();
if (t >= todayStartMs && t < todayEndMs) today++;
else if (t < todayStartMs) break; // commits are newest-first
}
}
return { slug, p, count, latest, latestHash, ideas, today };
});
// Featured (curated) first, then alphabetical by name — 540 repos in raw
// readdir order is unusable.
entries.sort((a, b) => (b.p.featured ? 1 : 0) - (a.p.featured ? 1 : 0) || a.p.name.localeCompare(b.p.name));
const totalCommits = entries.reduce((a, e) => a + e.count, 0);
const totalIdeas = entries.reduce((a, e) => a + e.ideas, 0);
// Velocity counters + 7-day sparkline across the whole fleet. Concats
// per-project commit lists so the helper sees every commit in one shot.
const fleetCommits = entries.flatMap(e => _projectCache.get(e.slug)?.commits || []);
// Today-in-PT + yesterday-PT counts — Steve's calendar days, not rolling-24h.
// Lets Steve see "237 today, 47 yesterday" at a glance.
const ptDay = (offsetDays) => {
const tz = 'America/Los_Angeles';
const baseStr = todayInTz(tz);
const dayStartMs = startOfDayInTz(baseStr, tz) + offsetDays * 86400000;
const endMs = dayStartMs + 86400000;
const dateStr = new Date(dayStartMs + 43200000).toLocaleString('sv-SE', { timeZone: tz }).slice(0, 10);
let n = 0;
for (const c of fleetCommits) {
const t = new Date(c.dateISO).getTime();
if (t >= dayStartMs && t < endMs) n++;
}
return { date: dateStr, count: n };
};
const todayPT = ptDay(0);
const yesterdayPT = ptDay(-1);
const delta = todayPT.count - yesterdayPT.count;
const deltaSpan = delta > 0
? `<span class="bp-delta bp-delta-up" title="${delta} more than yesterday">↑${delta}</span>`
: delta < 0
? `<span class="bp-delta bp-delta-dn" title="${Math.abs(delta)} fewer than yesterday">↓${Math.abs(delta)}</span>`
: `<span class="bp-delta bp-delta-eq" title="same as yesterday">—</span>`;
const fleetSpark = sparkline(fleetCommits);
const sparkChars = fleetSpark.spark;
const dayBuckets = fleetSpark.buckets;
const last24 = fleetSpark.last24;
const last7d = fleetSpark.last7d;
const rows = entries.map(({ slug, p, count, latest, latestHash, ideas, today }) => `<li class="bp-card">
<a href="/p/${esc(slug)}"><h2>${esc(p.name)}</h2></a>
<p>${esc(p.blurb)}</p>
<p class="bp-meta">${count} commits · ${ideas} ideas · latest ${latestHash ? `<a href="/p/${esc(slug)}/c/${esc(latestHash)}" title="open commit ${esc(latestHash)}">${esc(latest)}</a>` : esc(latest)}${today > 0 ? ` · <span class="bp-today" title="${today} commits today (PT)">${today} today</span>` : ''}</p>
</li>`).join('\n');
const jsonld = {
'@context': 'https://schema.org',
'@type': 'WebSite',
url: 'https://projects.agentabrams.com/',
name: 'build-pages',
description:'Live build journals for every project — every commit, every line of design rationale, searchable.',
};
const itemList = {
'@context': 'https://schema.org',
'@type': 'ItemList',
itemListElement: entries.map((e, i) => ({
'@type': 'ListItem',
position: i + 1,
url: `https://projects.agentabrams.com/p/${e.slug}`,
name: e.p.name,
})),
};
// Recent across the fleet — first 10 commits date-sorted across all
// projects. Server-rendered (not client-fetched) so the page is useful
// without JS. Same bundle cache, so essentially free.
const recent = [];
for (const e of entries) {
const bundle = await getProjectBundle(e.slug, e.p).catch(() => null);
if (!bundle) continue;
for (const c of bundle.commits.slice(0, 10)) {
recent.push({ slug: e.slug, name: e.p.name, hash: c.shortHash, date: c.dateISO, subject: c.subject });
}
}
recent.sort((a, b) => (b.date < a.date ? -1 : b.date > a.date ? 1 : 0));
const recentRows = recent.slice(0, 10).map(c => `<li>
<a href="/p/${esc(c.slug)}/c/${esc(c.hash)}"><code>${esc(c.hash)}</code></a>
<span class="bp-d">${esc(c.date.slice(0, 10))}</span>
<span class="bp-s"><strong>${esc(c.name)}</strong> — ${esc(c.subject)}</span>
</li>`).join('');
res.send(shell('build-pages — all projects', `
<h1>build journals</h1>
<p class="bp-lede">Every build, every commit, every line. Click into a project to see how it actually got made.</p>
<p class="bp-meta">${entries.length} projects · ${totalCommits} commits · ${totalIdeas} design-rationale ideas surfaced</p>
<p class="bp-meta">today (PT): <strong>${todayPT.count}</strong> ${deltaSpan} · yesterday: <strong>${yesterdayPT.count}</strong> · last 24h: <strong>${last24}</strong> · last 7d: <strong>${last7d}</strong> · 7-day shape <code class="bp-spark" title="${dayBuckets.join(' / ')}">${sparkChars}</code> · ${(() => {
const [l1] = require('os').loadavg();
const cls = l1 < 8 ? 'bp-load-ok' : l1 < 20 ? 'bp-load-warn' : 'bp-load-hot';
return `host load <span class="bp-load ${cls}">${l1.toFixed(1)}</span> (1m)`;
})()}</p>
<section class="bp-section">
<h2>Recent across the fleet</h2>
<ol id="bp-recent" class="bp-commits">${recentRows}</ol>
<p id="bp-recent-meta" class="bp-meta">auto-refreshes every 60s</p>
</section>
<section class="bp-section">
<h2>Deepest rationale</h2>
<p class="bp-meta">Top 5 commits by body length — the design-rationale essays that explain WHY, not just WHAT.</p>
<ol class="bp-commits">${(() => {
const all = [];
for (const e of entries) {
const b = _projectCache.get(e.slug); if (!b) continue;
for (const c of b.commits) {
if (c.body.length >= 200) all.push({ slug: e.slug, name: e.p.name, hash: c.shortHash, date: c.dateISO, subject: c.subject, len: c.body.length });
}
}
all.sort((a, b) => b.len - a.len);
return all.slice(0, 5).map(c => `<li>
<a href="/p/${esc(c.slug)}/c/${esc(c.hash)}"><code>${esc(c.hash)}</code></a>
<span class="bp-d">${esc(c.date.slice(0,10))}</span>
<span class="bp-s"><strong>${esc(c.name)}</strong> · <em>${c.len}c</em> — ${esc(c.subject)}</span>
</li>`).join('');
})()}</ol>
</section>
<section class="bp-section">
<h2>Top skills + agents across the fleet</h2>
<p class="bp-meta">Auto-extracted from commit messages — how often each skill/agent has been invoked across all ${entries.length} build journals.</p>
<div class="bp-grid">
<div>
<h3 style="font-size:15px;margin:0 0 10px;">Skills</h3>
<ul class="bp-chips">${(() => {
const tallies = new Map();
for (const e of entries) {
const b = _projectCache.get(e.slug);
if (!b) continue;
for (const f of b.facets.skills) tallies.set(f.name, (tallies.get(f.name) || 0) + f.count);
}
return [...tallies.entries()].sort((a,b)=>b[1]-a[1]).slice(0,15).map(([n,c]) => `<li><span>/${esc(n)}</span><b>${c}</b></li>`).join('');
})()}</ul>
</div>
<div>
<h3 style="font-size:15px;margin:0 0 10px;">Agents</h3>
<ul class="bp-chips">${(() => {
const tallies = new Map();
for (const e of entries) {
const b = _projectCache.get(e.slug);
if (!b) continue;
for (const f of b.facets.agents) tallies.set(f.name, (tallies.get(f.name) || 0) + f.count);
}
const out = [...tallies.entries()].sort((a,b)=>b[1]-a[1]).slice(0,15);
return out.length ? out.map(([n,c]) => `<li><span>${esc(n)}</span><b>${c}</b></li>`).join('') : '<li class="bp-meta">none detected</li>';
})()}</ul>
</div>
</div>
</section>
<section class="bp-section">
<h2>Search the whole fleet</h2>
<input id="fleet-q" class="bp-q" type="search" placeholder="search every commit across every project…" autocomplete="off" value="${esc(typeof req.query.q === 'string' ? req.query.q : '')}">
<p id="fleet-tally" class="bp-meta">type ≥ 2 characters to search</p>
<ol id="fleet-hits" class="bp-commits"></ol>
</section>
<h2>Projects</h2>
<ul class="bp-cards">${rows}</ul>
<p class="bp-meta" style="text-align:center;margin-top:30px">rendered in ${Date.now() - t0}ms</p>
`, `
<script type="application/ld+json">${JSON.stringify(jsonld)}</script>
<script type="application/ld+json">${JSON.stringify(itemList)}</script>
<script>
const fq = document.getElementById('fleet-q');
const ft = document.getElementById('fleet-tally');
const fh = document.getElementById('fleet-hits');
let pending = null;
async function doFleetSearch() {
const q = fq.value.trim();
if (q.length < 2) { ft.textContent = 'type ≥ 2 characters to search'; fh.innerHTML = ''; return; }
ft.textContent = 'searching…';
const r = await fetch('/api/search?q=' + encodeURIComponent(q) + '&limit=50');
const data = await r.json();
const breakdown = data.by_project
.filter(p => p.count > 0)
.map(p => \`\${p.name} \${p.count}\`)
.join(' · ');
ft.textContent = breakdown
? \`\${data.total} hits — \${breakdown} (showing \${data.shown})\`
: 'no hits';
fh.innerHTML = data.results.map(h => \`<li>
<a href="/p/\${h.slug}/c/\${h.hash}"><code>\${h.hash}</code></a>
<span class="bp-d">\${h.date}</span>
<span class="bp-s"><strong>\${h.project}</strong> — \${h.subject.replace(/</g,'<')}</span>
</li>\`).join('');
}
fq.addEventListener('input', () => {
if (pending) clearTimeout(pending);
pending = setTimeout(doFleetSearch, 150);
const url = new URL(window.location.href);
if (fq.value.trim()) url.searchParams.set('q', fq.value);
else url.searchParams.delete('q');
history.replaceState(null, '', url);
});
// Fire once on load if ?q= was server-rendered into the input.
if (fq.value.trim().length >= 2) doFleetSearch();
// Auto-refresh the "Recent across the fleet" rail every 60s so a fresh
// commit in any project shows up without a full reload. Bails silently
// on fetch failure so a transient network blip doesn't blank the rail.
async function refreshRecent() {
try {
const r = await fetch('/api/recent?limit=10');
if (!r.ok) return;
const data = await r.json();
const ol = document.getElementById('bp-recent');
const meta = document.getElementById('bp-recent-meta');
ol.innerHTML = data.results.map(h => \`<li>
<a href="/p/\${h.slug}/c/\${h.hash}"><code>\${h.hash}</code></a>
<span class="bp-d">\${h.date.slice(0,10)}</span>
<span class="bp-s"><strong>\${h.project}</strong> — \${h.subject.replace(/</g,'<')}</span>
</li>\`).join('');
meta.textContent = 'last refreshed ' + new Date().toLocaleTimeString();
} catch (_) {}
}
setInterval(refreshRecent, 60_000);
</script>
`));
} catch (e) { next(e); }
});
// Project page — commits + facets + search + file tree.
app.get('/p/:slug', async (req, res, next) => {
const p = PROJECTS[req.params.slug];
if (!p) return res.status(404).send(shell('Not found', '<h1>404</h1><p>No such project.</p>'));
const t0 = Date.now();
try {
const { commits, facets, files } = await getProjectBundle(req.params.slug, p);
// Prev/next links across the registry — lets you cycle through journals
// without bouncing back to the home page each time. Wraps at the ends.
const slugs = Object.keys(PROJECTS);
const idx = slugs.indexOf(req.params.slug);
const prevSlug = slugs[(idx - 1 + slugs.length) % slugs.length];
const nextSlug = slugs[(idx + 1) % slugs.length];
const idxData = JSON.stringify(commits.map(c => ({
h: c.shortHash, d: c.dateISO.slice(0, 10), s: c.subject, b: c.body.slice(0, 4000),
})));
const ideasHtml = facets.ideas.slice(0, 50).map(i => `
<details class="bp-idea">
<summary><span class="bp-h">${esc(i.hash)}</span> · <span class="bp-d">${esc(i.dateISO.slice(0, 10))}</span> · ${esc(i.subject)}</summary>
<pre>${esc(i.body)}</pre>
</details>`).join('\n');
const fileLis = files.slice(0, 500).map(f => `<li><a href="/p/${esc(req.params.slug)}/file?path=${encodeURIComponent(f)}">${esc(f)}</a></li>`).join('');
const projectJsonld = {
'@context': 'https://schema.org',
'@type': 'SoftwareSourceCode',
name: p.name,
description: p.blurb,
url: `https://projects.agentabrams.com/p/${req.params.slug}`,
codeRepository:`https://projects.agentabrams.com/p/${req.params.slug}`,
programmingLanguage: 'JavaScript',
dateModified: commits[0] ? new Date(commits[0].dateISO).toISOString() : new Date().toISOString(),
};
res.send(shell(`${p.name} — build journal`, `
<script type="application/ld+json">${JSON.stringify(projectJsonld)}</script>
<h1>${esc(p.name)}</h1>
<p class="bp-lede">${esc(p.blurb)}</p>
<p class="bp-meta">repo: <code>${esc(p.repo.replace(HOME, '~'))}</code> · ${commits.length} commits · ${(() => {
const s = sparkline(commits);
return `<strong>${s.last24}</strong> in last 24h, <strong>${s.last7d}</strong> in last 7d · <code class="bp-spark" title="${s.buckets.join(' / ')}">${s.spark}</code>`;
})()}</p>
<section class="bp-section">
<h2>Search the build</h2>
<input id="q" class="bp-q" type="search" placeholder="filter commits by subject, body, file…" autocomplete="off" value="${esc(typeof req.query.q === 'string' ? req.query.q : '')}">
<p id="bp-tally" class="bp-meta">${commits.length} commits indexed</p>
<ol id="bp-commits" class="bp-commits">
${commits.map(c => `
<li id="${esc(c.shortHash)}" data-h="${esc(c.shortHash)}" data-blob="${esc((c.subject + ' ' + c.body).toLowerCase().slice(0, 1500))}">
<a href="/p/${esc(req.params.slug)}/c/${esc(c.shortHash)}"><code>${esc(c.shortHash)}</code></a>
<span class="bp-d">${esc(c.dateISO.slice(0, 10))}</span>
<span class="bp-s">${esc(c.subject)}</span>
</li>`).join('')}
</ol>
</section>
<section class="bp-section">
<h2>Authors</h2>
<ul class="bp-chips">${(() => {
const t = new Map();
for (const c of commits) {
const a = canonAuthor(c.author);
t.set(a, (t.get(a) || 0) + 1);
}
return [...t.entries()].sort((a, b) => b[1] - a[1]).map(([n, c]) => `<li><span>${esc(n)}</span><b>${c}</b></li>`).join('');
})()}</ul>
</section>
<section class="bp-section bp-grid">
<div>
<h2>Agents used</h2>
<ul class="bp-chips">
${facets.agents.map(a => `<li><span>${esc(a.name)}</span><b>${a.count}</b></li>`).join('') || '<li class="bp-meta">none detected</li>'}
</ul>
</div>
<div>
<h2>Skills used</h2>
<ul class="bp-chips">
${facets.skills.slice(0, 30).map(s => `<li><span>/${esc(s.name)}</span><b>${s.count}</b></li>`).join('') || '<li class="bp-meta">none detected</li>'}
</ul>
</div>
</section>
<section class="bp-section">
<h2>Creative ideas + design notes</h2>
<p class="bp-meta">Commits with substantial prose (≥120 chars) — the rationale behind each move.</p>
${ideasHtml}
</section>
<section class="bp-section">
<h2>File tree</h2>
<p class="bp-meta">${files.length} files tracked. Click any to browse the source at HEAD.</p>
<ul class="bp-files">${fileLis}</ul>
</section>
<section class="bp-section">
<h2>Other build journals</h2>
<p class="bp-meta">
<a href="/p/${esc(prevSlug)}">← ${esc(PROJECTS[prevSlug].name)}</a>
·
<a href="/">all 4 projects</a>
·
<a href="/p/${esc(nextSlug)}">${esc(PROJECTS[nextSlug].name)} →</a>
</p>
</section>
<section class="bp-section">
<h2>Export</h2>
<p class="bp-meta">
<a href="/p/${esc(req.params.slug)}/commits.csv">commits.csv</a> ·
<a href="/p/${esc(req.params.slug)}/feed.atom">feed.atom</a> ·
<a href="/api/projects/${esc(req.params.slug)}">project.json</a> ·
<a href="/api/projects/${esc(req.params.slug)}/commits">commits.json</a>
</p>
</section>
<p class="bp-meta" style="text-align:center;margin-top:30px">rendered in ${Date.now() - t0}ms</p>
`, `
<script>
const DATA = ${idxData};
const q = document.getElementById('q');
const list = document.getElementById('bp-commits');
const tally = document.getElementById('bp-tally');
function applyFilter() {
const term = q.value.toLowerCase().trim();
let shown = 0;
for (const li of list.children) {
const match = !term || li.dataset.blob.includes(term);
li.style.display = match ? '' : 'none';
if (match) shown++;
}
tally.textContent = term ? \`\${shown} / \${DATA.length} match "\${q.value}"\` : \`\${DATA.length} commits indexed\`;
// Reflect search state in URL so a filtered view is shareable. Use
// replaceState so the back button still escapes the project page.
const url = new URL(window.location.href);
if (term) url.searchParams.set('q', q.value); else url.searchParams.delete('q');
history.replaceState(null, '', url);
}
q.addEventListener('input', applyFilter);
// Apply the initial ?q= from the server-rendered value field.
if (q.value) applyFilter();
</script>
` + `
<link rel="alternate" type="application/atom+xml" title="${esc(p.name)} build journal" href="/p/${esc(req.params.slug)}/feed.atom">
`, {
canonical: `https://projects.agentabrams.com/p/${req.params.slug}`,
desc: `${p.name} — ${p.blurb}`,
}));
} catch (e) { next(e); }
});
// Commit detail — full diff + body.
app.get('/p/:slug/c/:hash', async (req, res, next) => {
const p = PROJECTS[req.params.slug];
if (!p) return res.status(404).send(shell('Not found', '<h1>404</h1>'));
try {
const c = await commitDetail(p.repo, req.params.hash);
// Walk-the-history nav — find this commit's position in the cached log and
// expose prev/next (chronologically). Falls through gracefully if the
// hash isn't in the cached list (shouldn't happen, but defensive).
const bundle = await getProjectBundle(req.params.slug, p);
const cIdx = bundle.commits.findIndex(x => c.hash.startsWith(x.shortHash) || x.hash === c.hash);
const prevC = cIdx >= 0 && cIdx < bundle.commits.length - 1 ? bundle.commits[cIdx + 1] : null; // older
const nextC = cIdx > 0 ? bundle.commits[cIdx - 1] : null; // newer
const crumbs = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'build-pages', item: 'https://projects.agentabrams.com/' },
{ '@type': 'ListItem', position: 2, name: p.name, item: `https://projects.agentabrams.com/p/${req.params.slug}` },
{ '@type': 'ListItem', position: 3, name: c.subject.slice(0, 80), item: `https://projects.agentabrams.com/p/${req.params.slug}/c/${req.params.hash}` },
],
};
res.send(shell(`${c.subject} — ${p.name}`, `
<script type="application/ld+json">${JSON.stringify(crumbs)}</script>
<p class="bp-meta"><a href="/p/${esc(req.params.slug)}">← back to ${esc(p.name)}</a></p>
<h1>${esc(c.subject)}</h1>
<p class="bp-meta"><code>${esc(c.hash)}</code> · ${esc(c.dateISO)} · ${esc(c.author)}</p>
${c.body ? `<section class="bp-body"><pre>${esc(c.body)}</pre></section>` : ''}
<h2>Files touched</h2>
<ul class="bp-files">${c.files.map(f => `<li><code>${esc(f)}</code></li>`).join('')}</ul>
<h2>Diff</h2>
<pre class="bp-diff">${highlightDiff(esc(c.diff))}</pre>
<p class="bp-meta">
${prevC ? `<a href="/p/${esc(req.params.slug)}/c/${esc(prevC.shortHash)}">← ${esc(prevC.shortHash)} ${esc(prevC.subject.slice(0, 60))}</a>` : '<span>(oldest)</span>'}
·
<a href="/p/${esc(req.params.slug)}">back to ${esc(p.name)}</a>
·
${nextC ? `<a href="/p/${esc(req.params.slug)}/c/${esc(nextC.shortHash)}">${esc(nextC.subject.slice(0, 60))} ${esc(nextC.shortHash)} →</a>` : '<span>(newest)</span>'}
</p>
`, {
canonical: `https://projects.agentabrams.com/p/${req.params.slug}/c/${req.params.hash}`,
desc: `${c.subject} — commit in ${p.name}`,
}));
} catch (e) { next(e); }
});
// /api/fleet/today — every commit landed on the calendar date passed via
// ?date=YYYY-MM-DD, or today (Pacific Time, Steve's zone) if omitted.
// Different from /api/recent?since=24h because it's a calendar boundary,
// not rolling. ?tz= can override (e.g. ?tz=UTC).
app.get('/api/fleet/today', async (req, res, next) => {
const tz = typeof req.query.tz === 'string' ? req.query.tz : 'America/Los_Angeles';
const dateStr = (typeof req.query.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(req.query.date))
? req.query.date
: todayInTz(tz);
const startMs = startOfDayInTz(dateStr, tz);
const endMs = startMs + 86400000;
try {
const all = [];
for (const [slug, p] of Object.entries(PROJECTS)) {
const bundle = await getProjectBundle(slug, p).catch(() => null);
if (!bundle) continue;
for (const c of bundle.commits) {
const t = new Date(c.dateISO).getTime();
if (t >= startMs && t < endMs) {
all.push({ slug, project: p.name, hash: c.shortHash, date: c.dateISO, author: canonAuthor(c.author), subject: c.subject });
}
}
}
all.sort((a, b) => (b.date < a.date ? -1 : b.date > a.date ? 1 : 0));
res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
res.json({ date: dateStr, tz, total: all.length, results: all });
} catch (e) { next(e); }
});
// /api/fleet/longest — commits with the largest body length across the fleet.
// Long bodies are the "this is why we did it" essays — surfacing them as a
// browsable list makes the design-rationale layer discoverable.
app.get('/api/fleet/longest', async (req, res, next) => {
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
try {
const all = [];
for (const [slug, p] of Object.entries(PROJECTS)) {
const bundle = await getProjectBundle(slug, p).catch(() => null);
if (!bundle) continue;
for (const c of bundle.commits) {
if (c.body.length >= 200) {
all.push({
slug, project: p.name, hash: c.shortHash, date: c.dateISO.slice(0, 10),
subject: c.subject, body_len: c.body.length,
});
}
}
}
all.sort((a, b) => b.body_len - a.body_len);
res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
res.json({ limit, total: all.length, results: all.slice(0, limit) });
} catch (e) { next(e); }
});
// /api/fleet/buckets — daily commit-count buckets for the last N days
// (?days=7, max 90), both per-project and fleet-wide. Useful for charting
// libraries that want raw counts rather than the ASCII sparkline. Each bucket
// is anchored to a calendar day (ISO yyyy-mm-dd) so callers can plot it.
app.get('/api/fleet/buckets', async (req, res, next) => {
const days = Math.min(Math.max(parseInt(req.query.days, 10) || 7, 1), 90);
try {
const fleetCommits = [];
const byProject = [];
for (const [slug, p] of Object.entries(PROJECTS)) {
const bundle = await getProjectBundle(slug, p).catch(() => null);
if (!bundle) continue;
fleetCommits.push(...bundle.commits);
const s = sparkline(bundle.commits, days);
byProject.push({ slug, name: p.name, buckets: s.buckets, spark: s.spark });
}
const fleet = sparkline(fleetCommits, days);
// Label each bucket with its calendar date (oldest first → newest last).
const labels = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today.getTime() - i * 86400000);
labels.push(d.toISOString().slice(0, 10));
}
res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
res.json({
days,
labels,
fleet: { buckets: fleet.buckets, spark: fleet.spark },
by_project: byProject,
});
} catch (e) { next(e); }
});
// /api/fleet/authors — fleet-wide author tallies (post-alias-normalization).
app.get('/api/fleet/authors', async (_req, res, next) => {
try {
const tallies = new Map();
for (const [slug, p] of Object.entries(PROJECTS)) {
const bundle = await getProjectBundle(slug, p).catch(() => null);
if (!bundle) continue;
for (const c of bundle.commits) {
const a = canonAuthor(c.author);
tallies.set(a, (tallies.get(a) || 0) + 1);
}
}
const out = [...tallies.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count);
res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
res.json({ authors: out });
} catch (e) { next(e); }
});
// /api/fleet/all — one-shot aggregate for dashboards that don't want to make
// 5 separate calls. Pulls velocity + top agents + top skills + recent commits
// in parallel. Each piece is already cache-warm via the per-repo bundle, so
// this lands in ~5-15ms after the first hit.
app.get('/api/fleet/all', async (_req, res, next) => {
try {
const now = Date.now();
const dayMs = 24 * 60 * 60 * 1000;
let last24 = 0, last7d = 0;
const recent = [];
const skillTallies = new Map(), agentTallies = new Map();
const byProject = [];
for (const [slug, p] of Object.entries(PROJECTS)) {
const bundle = await getProjectBundle(slug, p).catch(() => null);
if (!bundle) continue;
let d24 = 0, d7 = 0;
for (const c of bundle.commits) {
const age = now - new Date(c.dateISO).getTime();
if (age < dayMs) d24++;
if (age < 7 * dayMs) d7++;
}
last24 += d24;
last7d += d7;
byProject.push({ slug, name: p.name, commits: bundle.commits.length, last_24h: d24, last_7d: d7 });
for (const c of bundle.commits.slice(0, 10)) {
recent.push({ slug, project: p.name, hash: c.shortHash, date: c.dateISO, subject: c.subject });
}
for (const f of bundle.facets.skills) skillTallies.set(f.name, (skillTallies.get(f.name) || 0) + f.count);
for (const f of bundle.facets.agents) agentTallies.set(f.name, (agentTallies.get(f.name) || 0) + f.count);
}
recent.sort((a, b) => (b.date < a.date ? -1 : b.date > a.date ? 1 : 0));
const top = (m) => [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15).map(([name, count]) => ({ name, count }));
res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
res.json({
projects: byProject,
velocity: { last_24h: last24, last_7d: last7d },
recent: recent.slice(0, 10),
top_skills: top(skillTallies),
top_agents: top(agentTallies),
as_of: new Date().toISOString(),
});
} catch (e) { next(e); }
});
// /api/fleet/velocity — commit counts in rolling windows across every project.
// Returns total + per-project breakdown for last-24h and last-7d. Useful for
// "is the work continuing?" dashboards. Same cache lifetime as /api/fleet/*.
app.get('/api/fleet/velocity', async (_req, res, next) => {
try {
const now = Date.now();
const dayMs = 24 * 60 * 60 * 1000;
const out = { last_24h: 0, last_7d: 0, by_project: [] };
for (const [slug, p] of Object.entries(PROJECTS)) {
const bundle = await getProjectBundle(slug, p).catch(() => null);
if (!bundle) continue;
let d24 = 0, d7 = 0;
for (const c of bundle.commits) {
const age = now - new Date(c.dateISO).getTime();
if (age < dayMs) d24++;
if (age < 7 * dayMs) d7++;
}
out.last_24h += d24;
out.last_7d += d7;
out.by_project.push({ slug, name: p.name, last_24h: d24, last_7d: d7 });
}
out.by_project.sort((a, b) => b.last_7d - a.last_7d);
res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
res.json({ ...out, as_of: new Date().toISOString() });
} catch (e) { next(e); }
});
// /api/fleet/skills + /api/fleet/agents — aggregated facet counts across every
// project. Merges per-project tallies into a fleet-wide ranking so the user
// can see "the secrets skill is used 17 times across the fleet" without
// fetching 4 endpoints and summing client-side.
for (const facet of ['agents', 'skills']) {
app.get(`/api/fleet/${facet}`, async (_req, res, next) => {
try {
const tallies = new Map();
for (const [slug, p] of Object.entries(PROJECTS)) {
const bundle = await getProjectBundle(slug, p).catch(() => null);
if (!bundle) continue;
for (const f of bundle.facets[facet]) {
tallies.set(f.name, (tallies.get(f.name) || 0) + f.count);
}
}
const merged = [...tallies.entries()]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
res.json({ [facet]: merged });
} catch (e) { next(e); }
});
}
// /api/recent — most recent N commits across the entire fleet, merged + sorted
// by date desc. Powers a "today across the fleet" rail. Shares the bundle
// cache so it's free after the first warm.
// Render a Unicode block-char sparkline of daily commit counts over the last
// N days for a given commits[] array. Returns { spark, buckets, last24, last7d }.
// Used by both home (fleet-wide) and /p/:slug (per-project) so the rendering
// logic stays in one place.
const _sparkBlocks = ['▁','▂','▃','▄','▅','▆','▇','█'];
function sparkline(commits, days = 7) {
const now = Date.now();
const dayMs = 86400000;
const buckets = new Array(days).fill(0);
let last24 = 0, last7d = 0;
for (const c of commits) {
const age = now - new Date(c.dateISO).getTime();
if (age < dayMs) last24++;
if (age < 7 * dayMs) last7d++;
if (age < days * dayMs && age >= 0) {
const idx = (days - 1) - Math.floor(age / dayMs);
if (idx >= 0 && idx < days) buckets[idx]++;
}
}
const max = Math.max(...buckets, 1);
const spark = buckets.map(n => n === 0 ? ' ' : _sparkBlocks[Math.min(7, Math.floor((n / max) * 7))]).join('');
return { spark, buckets, last24, last7d };
}
// Normalize git author strings. Steve commits as "SteveStudio2", "Steve",
// "Steve Abrams" — same person, different .gitconfig values on different
// machines. Map these to a canonical name so the authors[] tally doesn't
// fragment. Extensible — add more aliases here as new people come in.
const AUTHOR_ALIASES = {
'stevestudio2': 'Steve',
'steve abrams': 'Steve',
'steveabrams': 'Steve',
'steve@designerwallcoverings.com': 'Steve',
};
function canonAuthor(name) {
if (!name) return 'unknown';
const key = name.toLowerCase().trim();
return AUTHOR_ALIASES[key] || name;
}
// Resolve the start-of-day epoch-ms for a YYYY-MM-DD date in the given IANA
// timezone. Steve's wall clock is PT; UTC midnight at PT-evening straddles
// "today" weirdly. This computes the same boundary the user sees on their
// clock, DST-aware via Intl.
function startOfDayInTz(dateStr, tz) {
const probe = new Date(`${dateStr}T00:00:00Z`);
const fmt = probe.toLocaleString('sv-SE', { timeZone: tz, hour12: false }).slice(0, 19).replace(' ', 'T');
const fromTz = new Date(fmt).getTime();
return probe.getTime() + (probe.getTime() - fromTz);
}
function todayInTz(tz) {
return new Date().toLocaleString('sv-SE', { timeZone: tz }).slice(0, 10);
}
// Parse a ?since= query value into a unix ms cutoff (or 0 = no filter).
// Accepts 24h, 7d, 30m, or an ISO date prefix (YYYY-MM-DD…). Returns 0 for
// missing/malformed input so callers can use the result as a min-Date filter
// without separate "is it set" checks.
function parseSince(s) {
if (typeof s !== 'string' || !s.length) return 0;
const rel = s.match(/^(\d+)([dhm])$/);
if (rel) {
const mult = rel[2] === 'd' ? 86400000 : rel[2] === 'h' ? 3600000 : 60000;
return Date.now() - parseInt(rel[1], 10) * mult;
}
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return new Date(s).getTime();
return 0;
}
app.get('/api/recent', async (req, res, next) => {
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 200);
const sinceMs = parseSince(req.query.since);
try {
const all = [];
for (const [slug, p] of Object.entries(PROJECTS)) {
const bundle = await getProjectBundle(slug, p).catch(() => null);
if (!bundle) continue;
for (const c of bundle.commits.slice(0, limit * 4)) {
if (sinceMs && new Date(c.dateISO).getTime() < sinceMs) break;
all.push({
slug, project: p.name,
hash: c.shortHash, date: c.dateISO, subject: c.subject,
});
}
}
all.sort((a, b) => (b.date < a.date ? -1 : b.date > a.date ? 1 : 0));
res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
res.json({
total: all.length,
limit,
since: sinceMs ? new Date(sinceMs).toISOString() : null,
results: all.slice(0, limit),
});
} catch (e) { next(e); }
});
// Cross-project search — case-insensitive substring match on commit
// subject + body across every registered project. Used to power a "search
// the whole fleet" experience on the home page. ?q= required (min 2 chars),
// ?limit= clamps results (default 50, max 200).
app.get('/api/search', async (req, res, next) => {
const q = (typeof req.query.q === 'string' ? req.query.q : '').trim().toLowerCase();
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200);
const sinceMs = parseSince(req.query.since);
if (q.length < 2) return res.json({ q, results: [], note: 'min 2 chars' });
try {
// Phase 1 — pull ALL matches per project (no cap), so the "one project
// monopolizes the page" failure mode is impossible.
const perProject = await Promise.all(
Object.entries(PROJECTS).map(async ([slug, p]) => {
const bundle = await getProjectBundle(slug, p).catch(() => null);
if (!bundle) return { slug, name: p.name, hits: [] };
const hits = bundle.commits.filter(c => {
if (sinceMs && new Date(c.dateISO).getTime() < sinceMs) return false;
return (c.subject + ' ' + c.body).toLowerCase().includes(q);
}).map(c => ({
slug, project: p.name, hash: c.shortHash, date: c.dateISO.slice(0, 10), subject: c.subject,
}));
return { slug, name: p.name, hits };
})
);
// Phase 2 — round-robin merge so the first N hits include something from
// every project that matched at all, then sort by date desc.
const queues = perProject.map(p => [...p.hits]);
const interleaved = [];
while (interleaved.length < limit && queues.some(q => q.length)) {
for (const q of queues) {
if (!q.length) continue;
interleaved.push(q.shift());
if (interleaved.length >= limit) break;
}
}
interleaved.sort((a, b) => (b.date < a.date ? -1 : b.date > a.date ? 1 : 0));
const grandTotal = perProject.reduce((a, p) => a + p.hits.length, 0);
res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
res.json({
q,
total: grandTotal,
shown: interleaved.length,
limit,
by_project: perProject.map(p => ({ slug: p.slug, name: p.name, count: p.hits.length })),
results: interleaved,
});
} catch (e) { next(e); }
});
// Per-project facet endpoints — agents / skills / ideas as machine-readable
// JSON, for external dashboards that want one facet without parsing the whole
// project detail blob. Same bundle cache, sub-1ms when warm.
for (const facet of ['agents', 'skills', 'ideas']) {
app.get(`/api/projects/:slug/${facet}`, async (req, res, next) => {
const p = PROJECTS[req.params.slug];
if (!p) return res.status(404).json({ error: 'no-such-project' });
try {
const bundle = await getProjectBundle(req.params.slug, p);
res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
res.json({ slug: req.params.slug, [facet]: bundle.facets[facet] });
} catch (e) { next(e); }
});
}
// Raw commits JSON — paginated. Supports ?limit=N (default 100, max 500) and
// ?offset=N. The bundle has the full list cached; this just slices it.
app.get('/api/projects/:slug/commits', async (req, res, next) => {
const p = PROJECTS[req.params.slug];
if (!p) return res.status(404).json({ error: 'no-such-project' });
try {
const { commits } = await getProjectBundle(req.params.slug, p);
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
const sinceMs = parseSince(req.query.since);
const filtered = sinceMs
? commits.filter(c => new Date(c.dateISO).getTime() >= sinceMs)
: commits;
res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
res.json({
slug: req.params.slug,
total: filtered.length,
limit, offset,
since: sinceMs ? new Date(sinceMs).toISOString() : null,
commits: filtered.slice(offset, offset + limit).map(c => ({
hash: c.shortHash, date: c.dateISO, author: c.author, subject: c.subject, body: c.body,
})),
});
} catch (e) { next(e); }
});
// Fleet-wide CSV — every commit across every project in one file, sorted by
// date desc. Adds a `project` column so the consumer can slice/filter. Use:
// `curl https://projects.agentabrams.com/fleet.csv > fleet.csv && open fleet.csv`.
app.get('/fleet.csv', async (_req, res, next) => {
try {
const q = (s) => `"${String(s ?? '').replace(/"/g, '""')}"`;
const all = [];
for (const [slug, p] of Object.entries(PROJECTS)) {
const bundle = await getProjectBundle(slug, p).catch(() => null);
if (!bundle) continue;
for (const c of bundle.commits) {
all.push([q(slug), q(c.shortHash), q(c.dateISO), q(c.author), q(c.subject), c.body.length]);
}
}
all.sort((a, b) => (b[2] < a[2] ? -1 : b[2] > a[2] ? 1 : 0));
const rows = ['project,hash,date,author,subject,body_len'].concat(all.map(r => r.join(',')));
res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
res.setHeader('Content-Disposition', 'attachment; filename="fleet-commits.csv"');
res.type('text/csv').send(rows.join('\n') + '\n');
} catch (e) { next(e); }
});
// CSV export — every commit in spreadsheet form. Useful for the "drop this
// into Numbers and slice by date / author" workflow. Header row + RFC-4180
// quoting (double-quote inside double-quoted field).
app.get('/p/:slug/commits.csv', async (req, res, next) => {
const p = PROJECTS[req.params.slug];
if (!p) return res.status(404).type('text/plain').send('no-such-project\n');
try {
const { commits } = await getProjectBundle(req.params.slug, p);
const q = (s) => `"${String(s ?? '').replace(/"/g, '""')}"`;
const rows = ['hash,date,author,subject,body_len'];
for (const c of commits) {
rows.push([q(c.shortHash), q(c.dateISO), q(c.author), q(c.subject), c.body.length].join(','));
}
res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
res.setHeader('Content-Disposition', `attachment; filename="${req.params.slug}-commits.csv"`);
res.type('text/csv').send(rows.join('\n') + '\n');
} catch (e) { next(e); }
});
// Atom feed per project — recent N commits in standards-compliant Atom 1.0.
// Lets external tools subscribe to a project's build journal (e.g. Feedly,
// NetNewsWire). Pulls from the same bundle cache as the HTML page.
app.get('/p/:slug/feed.atom', async (req, res, next) => {
const p = PROJECTS[req.params.slug];
if (!p) return res.status(404).type('application/xml').send('<error>no-such-project</error>');
try {
const { commits } = await getProjectBundle(req.params.slug, p);
const base = 'https://projects.agentabrams.com';
const entries = commits.slice(0, 50).map(c => {
const url = `${base}/p/${req.params.slug}/c/${c.shortHash}`;
const body = c.body ? `\n\n${c.body}` : '';
return ` <entry>
<id>${url}</id>
<title>${esc(c.subject)}</title>
<link href="${url}"/>
<updated>${new Date(c.dateISO).toISOString()}</updated>
<author><name>${esc(c.author)}</name></author>
<content type="text">${esc(c.subject + body)}</content>
</entry>`;
}).join('\n');
const updated = commits[0] ? new Date(commits[0].dateISO).toISOString() : new Date().toISOString();
const feed = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>${base}/p/${req.params.slug}/feed.atom</id>
<title>${esc(p.name)} — build journal</title>
<link rel="self" href="${base}/p/${req.params.slug}/feed.atom"/>
<link href="${base}/p/${req.params.slug}"/>
<updated>${updated}</updated>
<subtitle>${esc(p.blurb)}</subtitle>
${entries}
</feed>
`;
res.setHeader('Cache-Control', 'public, max-age=300, stale-while-revalidate=600');
res.type('application/atom+xml').send(feed);
} catch (e) { next(e); }
});
// File viewer — show any tracked path at HEAD.
app.get('/p/:slug/file', async (req, res, next) => {
const p = PROJECTS[req.params.slug];
if (!p) return res.status(404).send(shell('Not found', '<h1>404</h1>'));
const file = req.query.path;
if (!file || file.includes('..')) return res.status(400).send(shell('Bad path', '<h1>400</h1>'));
try {
const content = await readBlob(p.repo, 'HEAD', file);
res.send(shell(`${file} — ${p.name}`, `
<p class="bp-meta"><a href="/p/${esc(req.params.slug)}">← back to ${esc(p.name)}</a></p>
<h1><code>${esc(file)}</code></h1>
<p class="bp-meta">${content.split('\n').length} lines</p>
<pre class="bp-source">${esc(content)}</pre>
`));
} catch (e) { next(e); }
});
// /api — top-level discovery doc. Lists every endpoint with a one-line
// purpose; mirrors the pattern used by asim/sod. Hit this first when
// orienting external consumers (or yourself two months from now).
app.get('/api', (_req, res) => {
res.setHeader('Cache-Control', 'public, max-age=300, stale-while-revalidate=600');
res.json({
name: 'build-pages API',
base: 'https://projects.agentabrams.com',
endpoints: {
'GET /api/health': 'Liveness probe — JSON {ok, counts.projects, uptime_s, ts, as_of}',
'GET /api/version': 'Deploy fingerprint — JSON {name, version, git, node, started_at}',
'GET /api/projects': 'List every project journal (slug, name, repo, blurb)',
'GET /api/projects/:slug': 'Per-project stats — head SHA, commit_count, top agents/skills, first/last commit',
'GET /api/projects/:slug/commits': 'Paginated raw commits — ?limit=N&offset=N (1-500 default 100)',
'GET /api/projects/:slug/agents': 'Raw agents facet — sub-agent names auto-extracted from commit bodies',
'GET /api/projects/:slug/skills': 'Raw skills facet — /skill-name slugs auto-extracted from commit bodies',
'GET /api/projects/:slug/ideas': 'Raw ideas — commit bodies ≥120 chars (design-rationale + scaffolds)',
'GET /api/search': 'Cross-project commit search — ?q=<term>&limit=N&since=<24h|7d|YYYY-MM-DD>, round-robin + by_project breakdown',
'GET /api/recent': 'Most recent N commits across the whole fleet — date-sorted, ?limit=20&since=<24h|7d|YYYY-MM-DD>',
'GET /api/fleet/agents': 'Fleet-wide agent tallies merged across every project',
'GET /api/fleet/skills': 'Fleet-wide skill tallies merged across every project',
'GET /api/fleet/velocity': 'Commits-per-rolling-window across the fleet — last 24h + last 7d, with per-project breakdown',
'GET /api/fleet/authors': 'Fleet-wide author tallies (post-alias-normalization, so SteveStudio2/Steve/Steve Abrams collapse to one)',
'GET /api/fleet/buckets': 'Daily commit-count buckets for the last N days (?days=7, max 90) — fleet + per-project, with ISO date labels',
'GET /api/fleet/longest': 'Top N commits by body length across the fleet (?limit=20) — the design-rationale essays',
'GET /api/fleet/today': 'Calendar-day commit slice (?date=YYYY-MM-DD or today) — different from /api/recent?since=24h (rolling window)',
'GET /api/fleet/all': 'One-shot dashboard aggregate — velocity + recent + top skills/agents + per-project counts',
'GET /p/:slug': 'Project journal HTML page (?q= deep-link supported)',
'GET /p/:slug/c/:hash': 'Commit detail page — full diff + body',
'GET /p/:slug/feed.atom': 'Atom 1.0 feed — up to 50 recent commits, autodiscovered on project page',
'GET /p/:slug/commits.csv': 'Per-project commits as CSV (hash, date, author, subject, body_len)',
'GET /fleet.csv': 'Fleet-wide commits CSV — every commit across every project, date-sorted desc',
'GET /p/:slug/file?path=<path>': 'View any tracked file at HEAD',
'GET /sitemap.xml': 'XML sitemap — home + every /p/:slug',
'GET /robots.txt': 'Crawler policy (allow all, sitemap advertised)',
'GET /healthz': 'k8s-style liveness probe (no DB) — JSON {ok, ts}',
},
notes: [
'Bundle cache keyed on HEAD SHA — page hits drop from 80ms cold to ~17ms warm.',
'Search is min 2 chars, round-robins across projects so no single project monopolizes the page.',
],
});
});
// JSON API — for the eventual cross-project dashboard.
app.get('/api/projects', (_req, res) => {
res.json({
projects: Object.entries(PROJECTS).map(([slug, p]) => ({ slug, name: p.name, repo: p.repo, blurb: p.blurb })),
});
});
// Per-project stats: commit count, last commit, top agents/skills, file count.
// Reads from the per-repo bundle cache so it shares git work with the HTML page.
app.get('/api/projects/:slug', async (req, res, next) => {
const p = PROJECTS[req.params.slug];
if (!p) return res.status(404).json({ error: 'no-such-project' });
try {
const bundle = await getProjectBundle(req.params.slug, p);
const first = bundle.commits[bundle.commits.length - 1];
const last = bundle.commits[0];
// Author breakdown — who landed how many commits on this project.
// Aliases collapsed via canonAuthor() so multiple git-config names map to
// one person.
const authorTallies = new Map();
for (const c of bundle.commits) {
const a = canonAuthor(c.author);
authorTallies.set(a, (authorTallies.get(a) || 0) + 1);
}
const authors = [...authorTallies.entries()]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
res.json({
slug: req.params.slug,
name: p.name,
blurb: p.blurb,
head: bundle.head.slice(0, 7),
commit_count: bundle.commits.length,
file_count: bundle.files.length,
first_commit: first ? { hash: first.shortHash, date: first.dateISO.slice(0, 10), subject: first.subject } : null,
last_commit: last ? { hash: last.shortHash, date: last.dateISO.slice(0, 10), subject: last.subject } : null,
authors,
agents_top: bundle.facets.agents.slice(0, 10),
skills_top: bundle.facets.skills.slice(0, 10),
ideas_count: bundle.facets.ideas.length,
as_of: new Date(bundle.at).toISOString(),
});
} catch (e) { next(e); }
});
// /opensearch.xml — OpenSearch 1.1 description so browsers (Chrome, Firefox,
// Safari Tech Preview) can register projects.agentabrams.com as a custom
// search engine. Triggered by autodiscovery <link rel="search"> in the shell.
app.get('/opensearch.xml', (_req, res) => {
const base = 'https://projects.agentabrams.com';
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
<ShortName>build-pages</ShortName>
<Description>Search every commit across Steve Abrams' projects.</Description>
<InputEncoding>UTF-8</InputEncoding>
<Image height="32" width="32" type="image/svg+xml">${base}/favicon.svg</Image>
<Url type="text/html" method="get" template="${base}/?q={searchTerms}"/>
<Url type="application/json" method="get" template="${base}/api/search?q={searchTerms}&limit=50"/>
</OpenSearchDescription>
`;
res.setHeader('Cache-Control', 'public, max-age=86400');
res.type('application/opensearchdescription+xml').send(xml);
});
// /search — convention alias that 302s to home with ?q= prefilled. Lets folks
// who guess "/search" land on the right surface.
app.get('/search', (req, res) => {
const q = typeof req.query.q === 'string' ? req.query.q : '';
res.redirect(302, q ? `/?q=${encodeURIComponent(q)}` : '/');
});
// /privacy — what data this site collects. The honest answer: very little.
app.get('/privacy', (_req, res) => {
res.setHeader('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
res.send(shell('Privacy — build-pages', `
<h1>Privacy</h1>
<p class="bp-lede">build-pages is a static read-out of public git history. It collects nothing about you.</p>
<h2>What we don't collect</h2>
<ul>
<li>No accounts. No login. No cookies.</li>
<li>No analytics scripts. No tracking pixels.</li>
<li>No form submissions. The only inputs are search filters that stay in your browser URL.</li>
</ul>
<h2>What the server logs</h2>
<p>Standard nginx + Node access logs (IP, path, user-agent, response code, timing) for ops debugging.
Retained 30 days, not shared.</p>
<h2>What's public by design</h2>
<p>Every commit subject, body, author name + email as recorded in the git repo, file paths, and diffs.
Authors who don't want their email surfaced should set a private one via
<code>git config user.email</code> before committing. We don't redact retrospectively.</p>
<p class="bp-meta"><a href="/">← back to all projects</a></p>
`, '', { canonical: 'https://projects.agentabrams.com/privacy', desc: 'build-pages privacy — no tracking, no accounts, no cookies; standard server logs only.' }));
});
// /terms — minimal use-of-site terms.
app.get('/terms', (_req, res) => {
res.setHeader('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
res.send(shell('Terms — build-pages', `
<h1>Terms of use</h1>
<p class="bp-lede">build-pages is published as-is for transparency about how Steve's projects get built.</p>
<ul>
<li>All content is rendered from public git repos. No warranty of accuracy or completeness.</li>
<li>Don't scrape aggressively. Cache-Control headers tell you when to refetch.</li>
<li>Code snippets and diffs are © their author. Use under the license shown in each repo
(default: not licensed for redistribution unless stated).</li>
<li>No support obligation. Reach Steve at his usual channels for questions.</li>
</ul>
<p class="bp-meta"><a href="/">← back to all projects</a></p>
`, '', { canonical: 'https://projects.agentabrams.com/terms', desc: 'build-pages terms — public-as-is content from git, no support obligation.' }));
});
// /about — short explainer for visitors arriving at projects.agentabrams.com.
app.get('/about', (_req, res) => {
res.setHeader('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
res.send(shell('About — build-pages', `
<h1>About</h1>
<p class="bp-lede">build-pages is a live build journal for every project in Steve Abrams' workshop.</p>
<p>Every project here is a real git repo. The pages you're reading are rendered live from <code>git log</code>
on each refresh — no static export step, no CI build job. New commits appear within seconds of
landing locally.</p>
<h2>What you'll find</h2>
<ul>
<li><strong>Every commit</strong> — full subject, body, author, date, files touched, diff</li>
<li><strong>Search</strong> — across one project (<code>/p/<slug>?q=…</code>) or the whole fleet (<code>/?q=…</code>)</li>
<li><strong>Design rationale</strong> — long commit bodies (≥120 chars) are surfaced as "ideas"</li>
<li><strong>Sub-agents + skills used</strong> — auto-extracted from commit messages</li>
<li><strong>Authors + velocity</strong> — daily commit shape, who pushed what, rolling 24h / 7d counts</li>
<li><strong>Exports</strong> — every project ships .csv, .atom, JSON detail at <a href="/api">/api</a></li>
</ul>
<h2>Pattern</h2>
<p>git is the source of truth. The site reads a per-repo bundle keyed on HEAD SHA — when HEAD moves,
the cache invalidates and the next request rebuilds it. Cold render ~80ms, warm hit ~17ms.</p>
<p class="bp-meta"><a href="/">← back to all projects</a></p>
`, '', {
canonical: 'https://projects.agentabrams.com/about',
desc: 'build-pages — a live, git-rendered build journal for every project Steve ships.',
}));
});
// Branded 404 — every other site in the fleet has one, parity matters.
app.use((req, res) => {
res.status(404).send(shell('Not found — build-pages', `
<h1>404</h1>
<p class="bp-lede">No build journal lives at <code>${esc(req.path)}</code>.</p>
<p><a href="/">← back to all projects</a></p>
`));
});
app.use((err, _req, res, _next) => {
console.error('[build-pages]', err.message);
res.status(500).send(shell('Error', `<h1>500</h1><pre>${esc(err.message)}</pre>`));
});
const LISTEN_HOST = process.env.HOST || '127.0.0.1';
app.listen(PORT, LISTEN_HOST, () => {
console.log(`build-pages listening on http://${LISTEN_HOST}:${PORT} — ${Object.keys(PROJECTS).length} projects`);
// Warm the whole fleet in the background so the first request reads cache, then
// re-warm every 90s (< BUNDLE_TTL) to pick up new commits without a request ever
// touching git across 540 repos.
warmAll().then(() => console.log('build-pages: fleet cache warmed')).catch(() => {});
setInterval(() => warmAll().catch(() => {}), 90_000);
});