#!/usr/bin/env node /** * sync-design-system.mjs * * Vendors shared/playground-design-system/ into a plugin's * playground/vendor/playground-design-system/ tree. * * Usage: * node scripts/sync-design-system.mjs [--force] * * Each plugin 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. * * No npm dependencies. Node 16.7+ for fs.cp(). */ 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 } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const MARKETPLACE_ROOT = path.resolve(__dirname, '..'); const SOURCE_DIR = path.join(MARKETPLACE_ROOT, 'shared', 'playground-design-system'); const GENERATED_HEADER = '/* Code generated by sync-design-system.mjs; DO NOT EDIT. */\n'; 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 plugin name. Usage: node scripts/sync-design-system.mjs [--source ] [--target ] [--check] [--force]'); } return args; } // The plugin (vendor target) root: an explicit --target wins, so the script still // works once plugins/ no longer exists in-repo (post-migration / extracted repo). function resolvePluginDir(args) { return args.target ? path.resolve(args.target) : path.join(MARKETPLACE_ROOT, 'plugins', args.plugin); } 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; } } 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 || MARKETPLACE_ROOT, encoding: 'utf8', }).trim(); } catch { return 'unknown'; } } // --check: re-hash a plugin's vendored tree against its committed MANIFEST.json and // exit non-zero on drift. No source needed — makes SC5 a single command in a clean clone (D3). 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) : SOURCE_DIR; const sourceLabel = args.source ? sourceDir : 'shared/playground-design-system/'; try { const stat = await fs.stat(pluginDir); if (!stat.isDirectory()) throw new Error('not a directory'); } catch { throw new Error(`Plugin directory not found: ${pluginDir}`); } try { await fs.access(sourceDir); } catch { throw new Error(`Source directory missing: ${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.cp(sourceDir, targetDir, { recursive: true, force: true }); const files = await walk(targetDir); await injectGeneratedHeader(targetDir, files); const sourceCommit = getCurrentCommit(args.source ? sourceDir : MARKETPLACE_ROOT); const finalFiles = await walk(targetDir); const manifest = await buildManifest(targetDir, finalFiles, sourceCommit, sourceLabel); await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); console.log(`Synced ${sourceLabel} → ${path.relative(MARKETPLACE_ROOT, targetDir) || targetDir}`); console.log(` Files: ${manifest.file_count + 1} (incl. MANIFEST.json)`); console.log(` Source commit: ${sourceCommit}`); console.log(` Sync date: ${manifest.sync_date}`); } main().catch(err => { console.error(`Error: ${err.message}`); process.exit(1); });