#!/usr/bin/env node // Report what the latest designbook integration changed, into RecentChanges.md. // // node scripts/designbook-sync.mjs # working tree vs HEAD // node scripts/designbook-sync.mjs --range main..HEAD # a committed range // // Driven by `make designbook-sync`. The designbook is the Claude Design project — // the upstream source of truth for the *language*. Its local mirror lives IN this // repo at designbook/ and is pulled via the `/design-sync` skill (the DesignSync // tool), component by component — NOT by this script. This script only inspects // what that sync changed and writes a deterministic RecentChanges.md snapshot. // See designbook/README.md and DesignSystemIntroduction.md §1/§5. import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); // Pathspecs the report covers: the designbook mirror plus the surfaces derived // from it (tokens, CSS, the CSS-embedded _styles.js, and the example pages). const DESIGN_PATHSPECS = ["designbook", "tokens", "src/styles", "src/elements", "examples"]; const LAYER_ORDER = ["Designbook", "Tokens", "Styles", "Components", "Examples", "Other"]; const layerOf = (p) => p.startsWith("designbook/") ? "Designbook" : p.startsWith("tokens/") ? "Tokens" : p.startsWith("src/styles/") ? "Styles" : p.startsWith("src/elements/") ? "Components" : p.startsWith("examples/") ? "Examples" : "Other"; const args = process.argv.slice(2); const argVal = (name) => { const i = args.indexOf(name); return i !== -1 ? args[i + 1] : null; }; const range = argVal("--range"); const nowIso = () => new Date().toISOString().replace(/\.\d+Z$/, "Z"); // Trailing-newline trim only — never .trim(), which would eat the leading status // column of the first `git status --porcelain` line. const git = (gitArgs) => execFileSync("git", gitArgs, { cwd: repoRoot, encoding: "utf8" }).replace(/\n+$/, ""); // ---- Sync marker: designbook/.design-sync.json ---- // Records when /design-sync last pulled (lastSyncAt) and the latest known cloud // project timestamp (remoteUpdatedAt). `make designbook-sync` reports lastSyncAt; // when remoteUpdatedAt is newer, the local mirror is OUTDATED and we warn. // --mark-synced [--remote-updated ] [--project ] [--project-name ] // run this right after /design-sync (sets lastSyncAt = now) // --remote-updated // record that the cloud project changed (e.g. from DesignSync.list_projects) const markerPath = resolve(repoRoot, "designbook/.design-sync.json"); const readMarker = () => existsSync(markerPath) ? JSON.parse(readFileSync(markerPath, "utf8")) : null; const writeMarker = (m) => writeFileSync(markerPath, JSON.stringify(m, null, 2) + "\n"); if (args.includes("--mark-synced")) { const now = nowIso(); const m = readMarker() || {}; m.lastSyncAt = now; m.remoteUpdatedAt = argVal("--remote-updated") || now; if (argVal("--project")) m.projectId = argVal("--project"); if (argVal("--project-name")) m.projectName = argVal("--project-name"); writeMarker(m); console.log(`Recorded /design-sync at ${now}.`); } else if (argVal("--remote-updated")) { const m = readMarker() || {}; m.remoteUpdatedAt = argVal("--remote-updated"); writeMarker(m); console.log(`Recorded remote designbook update at ${m.remoteUpdatedAt}.`); } const marker = readMarker(); const stale = marker?.lastSyncAt && marker?.remoteUpdatedAt && new Date(marker.remoteUpdatedAt) > new Date(marker.lastSyncAt); function syncStatusLines() { if (!marker?.lastSyncAt) { return [ "- Last /design-sync: never recorded", "", "> WARNING — no /design-sync has been recorded, so the local designbook/ may not", "> reflect the Claude Design project. Run `/design-sync` in Claude Code, then", "> `node scripts/designbook-sync.mjs --mark-synced`.", ]; } const out = [`- Last /design-sync: ${marker.lastSyncAt}`]; if (stale) { out.push( "", `> WARNING — the Claude Design project changed at ${marker.remoteUpdatedAt}, after the`, "> last /design-sync. The local designbook/ is OUTDATED — run `/design-sync` to refresh it.", ); } return out; } // ---- Collect changes ---- let changes; let source; if (range) { source = `range ${range}`; const numstat = git(["diff", "--numstat", range, "--", ...DESIGN_PATHSPECS]); const status = git(["diff", "--name-status", range, "--", ...DESIGN_PATHSPECS]); const counts = numstatMap(numstat); changes = (status ? status.split("\n") : []).map((line) => { const [code, ...rest] = line.split("\t"); const path = rest[rest.length - 1]; // rename → new path return { path, status: statusWord(code), ...(counts.get(path) || {}) }; }); } else { source = "working tree (uncommitted)"; // Intent-to-add so freshly-synced (untracked) files show up in the numstat diff. try { git(["add", "-N", "--", ...DESIGN_PATHSPECS]); } catch { /* nothing to add */ } const counts = numstatMap(git(["diff", "--numstat", "HEAD", "--", ...DESIGN_PATHSPECS])); const porcelain = git(["status", "--porcelain", "--", ...DESIGN_PATHSPECS]); changes = (porcelain ? porcelain.split("\n") : []).map((line) => { let path = line.slice(3); if (path.includes(" -> ")) path = path.split(" -> ")[1]; return { path, status: statusWord(line.slice(0, 2)), ...(counts.get(path) || {}) }; }); } // The sync marker is bookkeeping, not design content — keep it out of the report. changes = changes.filter((c) => c.path !== "designbook/.design-sync.json"); function numstatMap(out) { const m = new Map(); for (const line of out ? out.split("\n") : []) { const [added, removed, ...rest] = line.split("\t"); m.set(rest.join("\t"), { added, removed }); } return m; } function statusWord(code) { const c = code.replace(/\s/g, ""); return c.includes("D") ? "deleted" : c.includes("R") ? "renamed" : c.includes("A") || c.includes("?") ? "added" : "modified"; } // ---- Render RecentChanges.md ---- const lines = [ "# Recent Changes", "", "Snapshot of the last designbook integration. Regenerated by `make designbook-sync`.", "", `- Generated: ${nowIso()}`, `- Compared: ${source}`, ...syncStatusLines(), "", "> This file is overwritten on every run — a snapshot, not a log. Fold notable entries", "> into `CHANGELOG.md` under `## [Unreleased]` before releasing; that is the file CI", "> enforces (`pnpm check`). The designbook itself is synced via `/design-sync`, not this script.", "", ]; if (changes.length === 0) { lines.push("## No changes", "", "The design surface is unchanged since the last sync."); } else { lines.push("## Changed files", ""); for (const layer of LAYER_ORDER) { const group = changes.filter((c) => layerOf(c.path) === layer); if (!group.length) continue; lines.push(`### ${layer}`); for (const c of group.sort((a, b) => a.path.localeCompare(b.path))) { const tag = c.status.toUpperCase().padEnd(8); const delta = c.status === "deleted" ? "" : c.added != null && c.added !== "-" ? ` (+${c.added} / -${c.removed})` : c.added === "-" ? " (binary)" : ""; lines.push(`- \`${tag}\` ${c.path}${delta}`); } lines.push(""); } if (changes.some((c) => c.path === "src/elements/_styles.js")) { lines.push("> `src/elements/_styles.js` was regenerated from `components.css` — do not edit it by hand.", ""); } } writeFileSync(resolve(repoRoot, "RecentChanges.md"), lines.join("\n").replace(/\n+$/, "\n")); console.log(`Wrote RecentChanges.md — ${changes.length} changed file(s).`); // Surface sync freshness on stdout — this is what tells the user whether the // snapshot reflects the latest design. if (!marker?.lastSyncAt) { console.warn("WARNING: no /design-sync recorded — run /design-sync, then `node scripts/designbook-sync.mjs --mark-synced`."); } else { console.log(`Last /design-sync: ${marker.lastSyncAt}`); if (stale) { console.warn(`WARNING: Claude Design project changed at ${marker.remoteUpdatedAt} — local designbook/ is OUTDATED. Run /design-sync.`); } }