diff --git a/scripts/sync-design-system.mjs b/scripts/sync-design-system.mjs new file mode 100644 index 0000000..53496bf --- /dev/null +++ b/scripts/sync-design-system.mjs @@ -0,0 +1,182 @@ +#!/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 }; + for (const a of argv.slice(2)) { + if (a === '--force') args.force = true; + 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 [--force]'); + } + return args; +} + +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) { + 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: 'shared/playground-design-system/', + source_commit: sourceCommit, + sync_date: new Date().toISOString(), + file_count: files.length, + files: fileHashes, + }; +} + +function getCurrentCommit() { + try { + return execSync('git rev-parse HEAD', { + cwd: MARKETPLACE_ROOT, + encoding: 'utf8', + }).trim(); + } catch { + return 'unknown'; + } +} + +async function main() { + const args = parseArgs(process.argv); + const pluginDir = path.join(MARKETPLACE_ROOT, 'plugins', args.plugin); + + 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(SOURCE_DIR); + } catch { + throw new Error(`Source directory missing: ${SOURCE_DIR}`); + } + + 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(SOURCE_DIR, targetDir, { recursive: true, force: true }); + + const files = await walk(targetDir); + await injectGeneratedHeader(targetDir, files); + + const sourceCommit = getCurrentCommit(); + const finalFiles = await walk(targetDir); + const manifest = await buildManifest(targetDir, finalFiles, sourceCommit); + await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); + + console.log(`Synced shared/playground-design-system/ → plugins/${args.plugin}/playground/vendor/playground-design-system/`); + 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); +});