#!/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 --target [--source ] [--force] * node scripts/sync-design-system.mjs --target --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 --target [--source ] [--check] [--force]'); } if (!args.target) { throw new Error('Missing --target. The marketplace no longer holds plugins/, 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); }); }