#!/usr/bin/env node // ============================================================= // whynot-design CLI — consumer drift-check (WHYNOT-WP-0003 · T05) // // Runs IN A CONSUMING REPO: npx @whynot/design drift // // Compares the consumer's adopted sync-point (.whynot-design.lock) against the // installed package's ir/manifest.json (or an explicit --manifest), and reports // added / changed / removed components + token changes. Read-only against the // package; the only file it writes is .whynot-design.lock (and only on --update). // // This is the DOWNSTREAM mirror of the upstream adapter drift // (adapters/ADAPTER_CONTRACT.md) — same report shape, same exit codes: // 0 in sync 2 usage/config error 3 drift detected // ============================================================= import { readFileSync, writeFileSync, existsSync } from "node:fs"; import { join, dirname, resolve, isAbsolute } from "node:path"; import { fileURLToPath } from "node:url"; const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const EXIT = { OK: 0, USAGE: 2, DRIFT: 3 }; function fail(msg) { process.stderr.write(`whynot-design: ${msg}\n`); process.exit(EXIT.USAGE); } function readJson(path, label) { if (!existsSync(path)) fail(`${label} not found at ${path}`); try { return JSON.parse(readFileSync(path, "utf8")); } catch (e) { fail(`${label} at ${path} is not valid JSON: ${e.message}`); } } function parseArgs(argv) { const args = { _: [], flags: {} }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === "--json" || a === "--update") args.flags[a.slice(2)] = true; else if (a === "--lock" || a === "--manifest" || a === "--version") args.flags[a.slice(2)] = argv[++i]; else if (a.startsWith("--")) fail(`unknown flag ${a}`); else args._.push(a); } return args; } function resolvePath(p, base) { return isAbsolute(p) ? p : resolve(base, p); } // ---------- drift core ---------- // Compare an adopted lock against a target manifest. Pure; reused for --json, // the human report, and --update. Mirrors the adapter drift component shape. function computeDrift(lock, manifest) { const adopted = lock.manifestHashes.components || {}; const current = Object.fromEntries(manifest.components.map((c) => [c.name, c.hash])); const groupOf = Object.fromEntries(manifest.components.map((c) => [c.name, c.group])); const names = [...new Set([...Object.keys(adopted), ...Object.keys(current)])].sort(); const components = []; for (const name of names) { if (!(name in adopted)) components.push({ name, group: groupOf[name], status: "added" }); else if (!(name in current)) components.push({ name, status: "removed" }); else if (adopted[name] !== current[name]) components.push({ name, group: groupOf[name], status: "changed", from: adopted[name], to: current[name] }); else components.push({ name, group: groupOf[name], status: "ok" }); } const tokensChanged = lock.manifestHashes.tokens !== manifest.tokensHash; const drifted = tokensChanged || components.some((c) => c.status !== "ok"); return { tool: "@whynot/design drift", generatedAt: new Date().toISOString(), adopted: { designVersion: lock.designVersion, adoptedAt: lock.adoptedAt }, target: { designVersion: manifest.designVersion, generatedAt: manifest.generatedAt }, schemaVersionMismatch: lock.manifestSchemaVersion !== manifest.schemaVersion ? { adopted: lock.manifestSchemaVersion, target: manifest.schemaVersion } : null, tokens: { status: tokensChanged ? "changed" : "ok" }, components, drift: drifted, }; } function lockFromManifest(manifest) { return { designVersion: manifest.designVersion, adoptedAt: new Date().toISOString(), manifestSchemaVersion: manifest.schemaVersion, manifestHashes: { tokens: manifest.tokensHash, components: Object.fromEntries(manifest.components.map((c) => [c.name, c.hash])), }, }; } function printHuman(report) { const out = []; out.push(`whynot-design drift`); out.push(` adopted: ${report.adopted.designVersion} (${report.adopted.adoptedAt})`); out.push(` target: ${report.target.designVersion} (${report.target.generatedAt})`); if (report.schemaVersionMismatch) { out.push(` ! manifest schemaVersion differs (${report.schemaVersionMismatch.adopted} → ${report.schemaVersionMismatch.target}); hashes may not be directly comparable.`); } out.push(""); const added = report.components.filter((c) => c.status === "added"); const changed = report.components.filter((c) => c.status === "changed"); const removed = report.components.filter((c) => c.status === "removed"); out.push(`Tokens: ${report.tokens.status === "changed" ? "changed" : "unchanged"}`); out.push(`Components: +${added.length} added · ~${changed.length} changed · -${removed.length} removed · ${report.components.length} total`); if (added.length) out.push(` + ${added.map((c) => c.name).join(", ")}`); if (changed.length) out.push(` ~ ${changed.map((c) => c.name).join(", ")}`); if (removed.length) out.push(` - ${removed.map((c) => c.name).join(", ")}`); out.push(""); if (report.drift) { out.push(`Drift detected vs your adopted sync-point.`); out.push(`Adopt this version: npx @whynot/design drift --update`); } else { out.push(`In sync with ${report.target.designVersion}. No drift.`); } process.stdout.write(out.join("\n") + "\n"); } // ---------- drift command ---------- function cmdDrift(args) { const cwd = process.cwd(); const lockPath = resolvePath(args.flags.lock || ".whynot-design.lock", cwd); const manifestPath = args.flags.manifest ? resolvePath(args.flags.manifest, cwd) : join(PKG_ROOT, "ir", "manifest.json"); const manifest = readJson(manifestPath, "ir/manifest.json"); if (!Array.isArray(manifest.components) || typeof manifest.tokensHash !== "string") { fail(`${manifestPath} is not a valid ir/manifest.json`); } if (args.flags.version && manifest.designVersion !== args.flags.version) { fail(`--version ${args.flags.version} does not match the resolved manifest (designVersion ${manifest.designVersion}). Install that version or point --manifest at it.`); } // First adopt: no lock yet. --update bootstraps it; otherwise guide the user. if (!existsSync(lockPath)) { if (args.flags.update) { writeFileSync(lockPath, JSON.stringify(lockFromManifest(manifest), null, 2) + "\n"); if (args.flags.json) process.stdout.write(JSON.stringify({ adopted: manifest.designVersion, created: true }, null, 2) + "\n"); else process.stdout.write(`Adopted ${manifest.designVersion} as the initial sync-point → ${lockPath}\n`); return EXIT.OK; } fail(`no .whynot-design.lock found. Adopt the installed version first:\n npx @whynot/design drift --update`); } const lock = readJson(lockPath, ".whynot-design.lock"); if (!lock.manifestHashes || !lock.manifestHashes.components) { fail(`${lockPath} is missing manifestHashes.components`); } const report = computeDrift(lock, manifest); if (args.flags.update) { writeFileSync(lockPath, JSON.stringify(lockFromManifest(manifest), null, 2) + "\n"); if (args.flags.json) process.stdout.write(JSON.stringify({ ...report, updated: true }, null, 2) + "\n"); else { printHuman(report); process.stdout.write(`\nAdopted ${manifest.designVersion} → ${lockPath}\n`); } return EXIT.OK; // --update reconciles, so it always lands in sync } if (args.flags.json) process.stdout.write(JSON.stringify(report, null, 2) + "\n"); else printHuman(report); return report.drift ? EXIT.DRIFT : EXIT.OK; } // ---------- dispatch ---------- function main() { const argv = process.argv.slice(2); const args = parseArgs(argv); const cmd = args._[0]; if (!cmd || cmd === "help" || args.flags.help) { process.stdout.write(`whynot-design — consumer-side design-system tooling Usage: npx @whynot/design drift [options] Report changes since your adopted sync-point Options: --update Adopt the target version as the new sync-point (writes .whynot-design.lock) --json Machine-readable output --manifest Diff against an explicit ir/manifest.json (default: the installed package's) --version Assert the resolved manifest is this version (guards against the wrong install) --lock Path to the consumer lock (default: ./.whynot-design.lock) Exit codes: 0 in sync · 2 usage/config error · 3 drift detected `); return EXIT.OK; } if (cmd === "drift") return cmdDrift(args); fail(`unknown command '${cmd}'. Try: npx @whynot/design help`); } process.exit(main());