The script that produced the existing vendored copies was deleted with the monorepo layout in 2026-06 and recovered from catalog's history (e84dffd^:scripts/sync-design-system.mjs). It could not be restored unchanged: it walks its source directory and copies everything, and this repo's root is no longer a directory holding only the design system. Run verbatim it vendored 109 files instead of 27, STATE.md and the whole of .git/ among them - and STATE.md is gitignored precisely because this repo's remote is public. The boundary is an explicit allowlist (DELIVERED_FILES) rather than a dist/ directory: one definition, read directly by the tests, with no copy step to go stale. The allowlist's own failure mode - a new design-system file forgotten from the list ships nothing, and --check stays green because it hashes a consumer's tree against that consumer's own MANIFEST - is closed by scanning the source for unlisted root-level *.css, fonts/* and schemas/* and refusing to run. tests/sync-source-boundary.test.mjs pins both directions. It was red on the verbatim script for the right reason (STATE.md present in the target, with tokens.css as the known-positive control) before the boundary was written. --target is the consumer's repo root; the script appends playground/vendor/playground-design-system/ itself. --check verified read-only against both consumers: MANIFEST OK (27 files) for ms-ai-architect and llm-security alike. No re-sync was run, so both MANIFEST.json files are untouched and the stale source_commit is still theirs to inherit at the first real re-sync. README and docs/vendoring-and-re-sync.md now publish the command, and warn that a plain diff/cmp against this repo reports all nine stylesheets as drifted because the sync injects a generated-header line the source does not have - 17/27 raw, 26/27 with line 1 stripped. Found by ms-ai-architect, re-measured here.
342 lines
12 KiB
JavaScript
342 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* sync-design-system.mjs
|
|
*
|
|
* Vendors this design system into a consumer's
|
|
* playground/vendor/playground-design-system/ tree.
|
|
*
|
|
* Usage:
|
|
* node scripts/sync-design-system.mjs <consumer-name> --target <consumer-repo> [--source <dir>] [--force]
|
|
* node scripts/sync-design-system.mjs <consumer-name> --target <consumer-repo> --check
|
|
*
|
|
* Each consumer keeps its own pinned copy so it stays standalone.
|
|
* MANIFEST.json records SHA-256 per file + source commit + sync date.
|
|
* Drift detection refuses overwrite if a vendored file was modified
|
|
* locally after sync; pass --force to overwrite anyway.
|
|
*
|
|
* Source boundary: only the files named in DELIVERED_FILES are vendored.
|
|
* This repo's root holds the design system *and* the repo apparatus
|
|
* (STATE.md, .git/, docs/, playground-examples/, LICENSE, SECURITY.md).
|
|
* STATE.md is gitignored because this repo's remote is public; copying it
|
|
* into a consumer would publish it. Walking the source tree, which is what
|
|
* this script did while the system lived in its own directory inside the
|
|
* marketplace monorepo, is therefore no longer safe.
|
|
*
|
|
* No npm dependencies. Node 16.7+.
|
|
*/
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { promises as fs } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { execSync } from 'node:child_process';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
const SOURCE_LABEL = 'playground-design-system';
|
|
const GENERATED_HEADER = '/* Code generated by sync-design-system.mjs; DO NOT EDIT. */\n';
|
|
|
|
/**
|
|
* The delivered system: exactly what a consumer vendors. Everything else in
|
|
* this repo is apparatus and stays here.
|
|
*
|
|
* Adding a file to the design system means adding it here. Forgetting to is
|
|
* caught by assertSourceBoundary() below rather than shipping silently, since
|
|
* --check hashes a consumer's tree against its own MANIFEST and would stay
|
|
* green forever on a file that was never copied.
|
|
*/
|
|
export const DELIVERED_FILES = [
|
|
'CHANGELOG.md',
|
|
'README.md',
|
|
'base.css',
|
|
'components-tier2.css',
|
|
'components-tier3-supplement.css',
|
|
'components-tier3.css',
|
|
'components-tier4-project-view.css',
|
|
'components.css',
|
|
'fonts.css',
|
|
'fonts/Inter-Bold.woff2',
|
|
'fonts/Inter-Medium.woff2',
|
|
'fonts/Inter-Regular.woff2',
|
|
'fonts/Inter-SemiBold.woff2',
|
|
'fonts/JetBrainsMono-Medium.woff2',
|
|
'fonts/JetBrainsMono-Regular.woff2',
|
|
'fonts/JetBrainsMono-SemiBold.woff2',
|
|
'fonts/LICENSE-Inter.txt',
|
|
'fonts/LICENSE-JetBrainsMono.txt',
|
|
'fonts/LICENSE-SourceSerif4.md',
|
|
'fonts/LICENSES.md',
|
|
'fonts/SourceSerif4-Regular.woff2',
|
|
'fonts/SourceSerif4-Semibold.woff2',
|
|
'print.css',
|
|
'schemas/finding.schema.json',
|
|
'schemas/okr-set.schema.json',
|
|
'schemas/ros-threat.schema.json',
|
|
'tokens.css',
|
|
];
|
|
|
|
// Where an added design-system file would plausibly land. Scanned against the
|
|
// allowlist so an omission is loud. Deliberately narrow: root-level *.md and
|
|
// the repo apparatus are excluded by design, not by oversight.
|
|
const SCANNED_DIRS = ['fonts', 'schemas'];
|
|
|
|
function parseArgs(argv) {
|
|
const args = { plugin: null, force: false, source: null, target: null, check: false };
|
|
const rest = argv.slice(2);
|
|
for (let i = 0; i < rest.length; i++) {
|
|
const a = rest[i];
|
|
if (a === '--force') args.force = true;
|
|
else if (a === '--check') args.check = true;
|
|
else if (a === '--source' || a === '--target') {
|
|
const v = rest[++i];
|
|
if (!v) throw new Error(`${a} requires a directory argument`);
|
|
args[a === '--source' ? 'source' : 'target'] = v;
|
|
} else if (a.startsWith('--source=')) args.source = a.slice('--source='.length);
|
|
else if (a.startsWith('--target=')) args.target = a.slice('--target='.length);
|
|
else if (a.startsWith('--')) {
|
|
throw new Error(`Unknown flag: ${a}`);
|
|
} else if (!args.plugin) {
|
|
args.plugin = a;
|
|
} else {
|
|
throw new Error(`Unexpected positional arg: ${a}`);
|
|
}
|
|
}
|
|
if (!args.plugin) {
|
|
throw new Error('Missing consumer name. Usage: node scripts/sync-design-system.mjs <consumer-name> --target <consumer-repo> [--source <dir>] [--check] [--force]');
|
|
}
|
|
if (!args.target) {
|
|
throw new Error('Missing --target. The marketplace no longer holds plugins/<name>, so the consumer repo root must be given explicitly.');
|
|
}
|
|
return args;
|
|
}
|
|
|
|
// The consumer (vendor target) repo root. The vendored tree goes below it at
|
|
// playground/vendor/playground-design-system/.
|
|
function resolvePluginDir(args) {
|
|
return path.resolve(args.target);
|
|
}
|
|
|
|
async function sha256(filePath) {
|
|
const buf = await fs.readFile(filePath);
|
|
return createHash('sha256').update(buf).digest('hex');
|
|
}
|
|
|
|
async function walk(dir, base = dir) {
|
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
const out = [];
|
|
for (const e of entries) {
|
|
const full = path.join(dir, e.name);
|
|
if (e.isDirectory()) {
|
|
out.push(...(await walk(full, base)));
|
|
} else if (e.isFile()) {
|
|
out.push(path.relative(base, full));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function readJsonIfExists(p) {
|
|
try {
|
|
return JSON.parse(await fs.readFile(p, 'utf8'));
|
|
} catch (e) {
|
|
if (e.code === 'ENOENT') return null;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Both directions of the boundary: every delivered file must exist in the
|
|
* source, and no design-system file may exist in the source without being
|
|
* delivered.
|
|
*/
|
|
async function assertSourceBoundary(sourceDir) {
|
|
const delivered = new Set(DELIVERED_FILES);
|
|
|
|
const missing = [];
|
|
for (const rel of DELIVERED_FILES) {
|
|
try {
|
|
await fs.access(path.join(sourceDir, rel));
|
|
} catch {
|
|
missing.push(rel);
|
|
}
|
|
}
|
|
if (missing.length) {
|
|
throw new Error(
|
|
`Source is missing ${missing.length} delivered file(s):\n` +
|
|
missing.map(f => ` - ${f}`).join('\n'),
|
|
);
|
|
}
|
|
|
|
const candidates = [];
|
|
for (const e of await fs.readdir(sourceDir, { withFileTypes: true })) {
|
|
if (e.isFile() && e.name.endsWith('.css')) candidates.push(e.name);
|
|
}
|
|
for (const dir of SCANNED_DIRS) {
|
|
const full = path.join(sourceDir, dir);
|
|
try {
|
|
for (const rel of await walk(full)) candidates.push(path.join(dir, rel));
|
|
} catch (e) {
|
|
if (e.code !== 'ENOENT') throw e;
|
|
}
|
|
}
|
|
|
|
const unlisted = candidates.filter(rel => !delivered.has(rel)).sort();
|
|
if (unlisted.length) {
|
|
throw new Error(
|
|
`Source holds ${unlisted.length} design-system file(s) the allowlist does not name:\n` +
|
|
unlisted.map(f => ` - ${f}`).join('\n') +
|
|
'\nAdd them to DELIVERED_FILES, or move them out of the delivered tree.',
|
|
);
|
|
}
|
|
}
|
|
|
|
async function copyDelivered(sourceDir, targetDir) {
|
|
for (const rel of DELIVERED_FILES) {
|
|
const dest = path.join(targetDir, rel);
|
|
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
await fs.copyFile(path.join(sourceDir, rel), dest);
|
|
}
|
|
}
|
|
|
|
async function detectDrift(targetDir, prevManifest) {
|
|
if (!prevManifest || !prevManifest.files) return [];
|
|
const drifted = [];
|
|
for (const [rel, prevHash] of Object.entries(prevManifest.files)) {
|
|
const tgt = path.join(targetDir, rel);
|
|
try {
|
|
const cur = await sha256(tgt);
|
|
if (cur !== prevHash) drifted.push(rel);
|
|
} catch (e) {
|
|
if (e.code === 'ENOENT') drifted.push(`${rel} (missing)`);
|
|
else throw e;
|
|
}
|
|
}
|
|
return drifted;
|
|
}
|
|
|
|
async function injectGeneratedHeader(targetDir, files) {
|
|
for (const rel of files) {
|
|
if (!rel.endsWith('.css')) continue;
|
|
const p = path.join(targetDir, rel);
|
|
const content = await fs.readFile(p, 'utf8');
|
|
if (content.startsWith(GENERATED_HEADER)) continue;
|
|
await fs.writeFile(p, GENERATED_HEADER + content, 'utf8');
|
|
}
|
|
}
|
|
|
|
async function buildManifest(targetDir, files, sourceCommit, sourceLabel) {
|
|
const fileHashes = {};
|
|
for (const rel of files.sort()) {
|
|
fileHashes[rel] = await sha256(path.join(targetDir, rel));
|
|
}
|
|
return {
|
|
generated_by: 'scripts/sync-design-system.mjs',
|
|
do_not_edit: true,
|
|
source: sourceLabel,
|
|
source_commit: sourceCommit,
|
|
sync_date: new Date().toISOString(),
|
|
file_count: files.length,
|
|
files: fileHashes,
|
|
};
|
|
}
|
|
|
|
function getCurrentCommit(cwd) {
|
|
try {
|
|
return execSync('git rev-parse HEAD', {
|
|
cwd: cwd || REPO_ROOT,
|
|
encoding: 'utf8',
|
|
}).trim();
|
|
} catch {
|
|
return 'unknown';
|
|
}
|
|
}
|
|
|
|
// --check: re-hash a consumer's vendored tree against its committed
|
|
// MANIFEST.json and exit non-zero on drift. No source needed — one command in
|
|
// a clean clone. Reads only; it never writes to the consumer.
|
|
async function runCheck(args) {
|
|
const pluginDir = resolvePluginDir(args);
|
|
const targetDir = path.join(pluginDir, 'playground', 'vendor', 'playground-design-system');
|
|
const manifestPath = path.join(targetDir, 'MANIFEST.json');
|
|
const manifest = await readJsonIfExists(manifestPath);
|
|
if (!manifest) {
|
|
console.error(`MANIFEST DRIFT: no MANIFEST.json at ${manifestPath}`);
|
|
process.exit(2);
|
|
}
|
|
const drifted = await detectDrift(targetDir, manifest);
|
|
if (drifted.length) {
|
|
console.error(`MANIFEST DRIFT: ${drifted.length} vendored file(s) differ from MANIFEST.json:`);
|
|
for (const f of drifted) console.error(` - ${f}`);
|
|
process.exit(2);
|
|
}
|
|
console.log(`MANIFEST OK (${manifest.file_count} files, source_commit ${manifest.source_commit})`);
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv);
|
|
|
|
if (args.check) {
|
|
await runCheck(args);
|
|
return;
|
|
}
|
|
|
|
const pluginDir = resolvePluginDir(args);
|
|
const sourceDir = args.source ? path.resolve(args.source) : REPO_ROOT;
|
|
|
|
try {
|
|
const stat = await fs.stat(pluginDir);
|
|
if (!stat.isDirectory()) throw new Error('not a directory');
|
|
} catch {
|
|
throw new Error(`Consumer directory not found: ${pluginDir}`);
|
|
}
|
|
|
|
try {
|
|
await fs.access(sourceDir);
|
|
} catch {
|
|
throw new Error(`Source directory missing: ${sourceDir}`);
|
|
}
|
|
|
|
await assertSourceBoundary(sourceDir);
|
|
|
|
const targetDir = path.join(pluginDir, 'playground', 'vendor', 'playground-design-system');
|
|
const manifestPath = path.join(targetDir, 'MANIFEST.json');
|
|
|
|
const prevManifest = await readJsonIfExists(manifestPath);
|
|
const drifted = await detectDrift(targetDir, prevManifest);
|
|
if (drifted.length && !args.force) {
|
|
console.error(`Refusing sync: ${drifted.length} vendored file(s) drifted from previous MANIFEST:`);
|
|
for (const f of drifted) console.error(` - ${f}`);
|
|
console.error('Pass --force to overwrite local changes.');
|
|
process.exit(2);
|
|
}
|
|
if (drifted.length && args.force) {
|
|
console.warn(`--force: overwriting ${drifted.length} drifted file(s).`);
|
|
}
|
|
|
|
await fs.mkdir(path.dirname(targetDir), { recursive: true });
|
|
await fs.rm(targetDir, { recursive: true, force: true });
|
|
await fs.mkdir(targetDir, { recursive: true });
|
|
await copyDelivered(sourceDir, targetDir);
|
|
|
|
const files = await walk(targetDir);
|
|
await injectGeneratedHeader(targetDir, files);
|
|
|
|
const sourceCommit = getCurrentCommit(sourceDir);
|
|
const finalFiles = await walk(targetDir);
|
|
const manifest = await buildManifest(targetDir, finalFiles, sourceCommit, SOURCE_LABEL);
|
|
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
|
|
console.log(`Synced ${SOURCE_LABEL} → ${targetDir}`);
|
|
console.log(` Files: ${manifest.file_count + 1} (incl. MANIFEST.json)`);
|
|
console.log(` Source commit: ${sourceCommit}`);
|
|
console.log(` Sync date: ${manifest.sync_date}`);
|
|
}
|
|
|
|
// Importable: the tests read DELIVERED_FILES from here so the allowlist has
|
|
// exactly one definition.
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
main().catch(err => {
|
|
console.error(`Error: ${err.message}`);
|
|
process.exit(1);
|
|
});
|
|
}
|