ktg-plugin-marketplace/scripts/sync-design-system.mjs

225 lines
7.7 KiB
JavaScript

#!/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 <plugin-name> [--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 <plugin-name> [--source <dir>] [--target <dir>] [--check] [--force]');
}
return args;
}
// The plugin (vendor target) root: an explicit --target wins, so the script still
// works once plugins/<name> 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);
});