Make ir/ the unit of versioned downstream consumption so consuming repos can pin a version, inspect it, and follow changes at their own pace. - T03 ir/manifest.json: per-version inventory + diff anchor with deterministic sha256-over-canonicalised-JSON hashes; no-churn generatedAt; manifest schema. - T07 ir/INDEX.md: human-readable catalog generated by make ir. - T04 .whynot-design.lock sync-point format + lock schema. - T05 npx @whynot/design drift: consumer drift-check (bin entry), exit 0/2/3, --json/--update/--manifest/--version/--lock. - T06 CONSUMING.md guide + examples/consumer-fixture/ runnable demo; README + MultiFrameworkSupport cross-links; fix README version pin (@0.3.0 not @v0.3.0). - T09 CONSUMER_CONTRACT_PARITY.md design-only note (live-UI parity deferred). T02 (publish) and T08 (showcase, blocked on WP-0002 T11) remain wait. Repo stays in dev mode; no outward publish performed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
208 lines
8.6 KiB
JavaScript
Executable File
208 lines
8.6 KiB
JavaScript
Executable File
#!/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 <path> Diff against an explicit ir/manifest.json (default: the installed package's)
|
|
--version <x.y.z> Assert the resolved manifest is this version (guards against the wrong install)
|
|
--lock <path> 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());
|