feat(migration): sync-design-system --source + --check for DS re-home [skip-docs]

This commit is contained in:
Kjell Tore Guttormsen 2026-06-17 12:34:01 +02:00
commit 0f7093583c
2 changed files with 117 additions and 14 deletions

View file

@ -28,9 +28,18 @@ const SOURCE_DIR = path.join(MARKETPLACE_ROOT, 'shared', 'playground-design-syst
const GENERATED_HEADER = '/* Code generated by sync-design-system.mjs; DO NOT EDIT. */\n'; const GENERATED_HEADER = '/* Code generated by sync-design-system.mjs; DO NOT EDIT. */\n';
function parseArgs(argv) { function parseArgs(argv) {
const args = { plugin: null, force: false }; const args = { plugin: null, force: false, source: null, target: null, check: false };
for (const a of argv.slice(2)) { const rest = argv.slice(2);
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (a === '--force') args.force = true; 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('--')) { else if (a.startsWith('--')) {
throw new Error(`Unknown flag: ${a}`); throw new Error(`Unknown flag: ${a}`);
} else if (!args.plugin) { } else if (!args.plugin) {
@ -40,11 +49,17 @@ function parseArgs(argv) {
} }
} }
if (!args.plugin) { if (!args.plugin) {
throw new Error('Missing plugin name. Usage: node scripts/sync-design-system.mjs <plugin-name> [--force]'); throw new Error('Missing plugin name. Usage: node scripts/sync-design-system.mjs <plugin-name> [--source <dir>] [--target <dir>] [--check] [--force]');
} }
return args; 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) { async function sha256(filePath) {
const buf = await fs.readFile(filePath); const buf = await fs.readFile(filePath);
return createHash('sha256').update(buf).digest('hex'); return createHash('sha256').update(buf).digest('hex');
@ -99,7 +114,7 @@ async function injectGeneratedHeader(targetDir, files) {
} }
} }
async function buildManifest(targetDir, files, sourceCommit) { async function buildManifest(targetDir, files, sourceCommit, sourceLabel) {
const fileHashes = {}; const fileHashes = {};
for (const rel of files.sort()) { for (const rel of files.sort()) {
fileHashes[rel] = await sha256(path.join(targetDir, rel)); fileHashes[rel] = await sha256(path.join(targetDir, rel));
@ -107,7 +122,7 @@ async function buildManifest(targetDir, files, sourceCommit) {
return { return {
generated_by: 'scripts/sync-design-system.mjs', generated_by: 'scripts/sync-design-system.mjs',
do_not_edit: true, do_not_edit: true,
source: 'shared/playground-design-system/', source: sourceLabel,
source_commit: sourceCommit, source_commit: sourceCommit,
sync_date: new Date().toISOString(), sync_date: new Date().toISOString(),
file_count: files.length, file_count: files.length,
@ -115,10 +130,10 @@ async function buildManifest(targetDir, files, sourceCommit) {
}; };
} }
function getCurrentCommit() { function getCurrentCommit(cwd) {
try { try {
return execSync('git rev-parse HEAD', { return execSync('git rev-parse HEAD', {
cwd: MARKETPLACE_ROOT, cwd: cwd || MARKETPLACE_ROOT,
encoding: 'utf8', encoding: 'utf8',
}).trim(); }).trim();
} catch { } catch {
@ -126,9 +141,37 @@ function getCurrentCommit() {
} }
} }
// --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() { async function main() {
const args = parseArgs(process.argv); const args = parseArgs(process.argv);
const pluginDir = path.join(MARKETPLACE_ROOT, 'plugins', args.plugin);
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 { try {
const stat = await fs.stat(pluginDir); const stat = await fs.stat(pluginDir);
@ -138,9 +181,9 @@ async function main() {
} }
try { try {
await fs.access(SOURCE_DIR); await fs.access(sourceDir);
} catch { } catch {
throw new Error(`Source directory missing: ${SOURCE_DIR}`); throw new Error(`Source directory missing: ${sourceDir}`);
} }
const targetDir = path.join(pluginDir, 'playground', 'vendor', 'playground-design-system'); const targetDir = path.join(pluginDir, 'playground', 'vendor', 'playground-design-system');
@ -160,17 +203,17 @@ async function main() {
await fs.mkdir(path.dirname(targetDir), { recursive: true }); await fs.mkdir(path.dirname(targetDir), { recursive: true });
await fs.rm(targetDir, { recursive: true, force: true }); await fs.rm(targetDir, { recursive: true, force: true });
await fs.cp(SOURCE_DIR, targetDir, { recursive: true, force: true }); await fs.cp(sourceDir, targetDir, { recursive: true, force: true });
const files = await walk(targetDir); const files = await walk(targetDir);
await injectGeneratedHeader(targetDir, files); await injectGeneratedHeader(targetDir, files);
const sourceCommit = getCurrentCommit(); const sourceCommit = getCurrentCommit(args.source ? sourceDir : MARKETPLACE_ROOT);
const finalFiles = await walk(targetDir); const finalFiles = await walk(targetDir);
const manifest = await buildManifest(targetDir, finalFiles, sourceCommit); const manifest = await buildManifest(targetDir, finalFiles, sourceCommit, sourceLabel);
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); 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(`Synced ${sourceLabel}${path.relative(MARKETPLACE_ROOT, targetDir) || targetDir}`);
console.log(` Files: ${manifest.file_count + 1} (incl. MANIFEST.json)`); console.log(` Files: ${manifest.file_count + 1} (incl. MANIFEST.json)`);
console.log(` Source commit: ${sourceCommit}`); console.log(` Source commit: ${sourceCommit}`);
console.log(` Sync date: ${manifest.sync_date}`); console.log(` Sync date: ${manifest.sync_date}`);

View file

@ -0,0 +1,60 @@
// Step 2 test — --source parity (SC5), --check pass/fail, in-repo default fallback.
// Pattern: plugins/voyage/tests/scripts/*.test.mjs (node:test, zero deps).
// All vendoring is done into throwaway --target dirs so the repo's own vendored trees are never touched.
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, cpSync, readFileSync, appendFileSync, mkdirSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(here, '..');
const SCRIPT = path.join(REPO_ROOT, 'scripts', 'sync-design-system.mjs');
const IN_REPO_DS = path.join(REPO_ROOT, 'shared', 'playground-design-system');
const VENDOR_REL = path.join('playground', 'vendor', 'playground-design-system');
const tmp = mkdtempSync(path.join(os.tmpdir(), 'ds-sync-'));
after(() => rmSync(tmp, { recursive: true, force: true }));
const run = (args) => spawnSync(process.execPath, [SCRIPT, ...args], { encoding: 'utf8' });
const manifestOf = (pluginRoot) =>
JSON.parse(readFileSync(path.join(pluginRoot, VENDOR_REL, 'MANIFEST.json'), 'utf8'));
const pluginA = path.join(tmp, 'pluginA');
const pluginB = path.join(tmp, 'pluginB');
const dsCopy = path.join(tmp, 'ds-copy');
test('default (no --source) vendors from the in-repo design system', () => {
mkdirSync(pluginA, { recursive: true });
const r = run(['dummy', '--target', pluginA]);
assert.equal(r.status, 0, r.stderr);
const m = manifestOf(pluginA);
assert.equal(m.source, 'shared/playground-design-system/');
assert.ok(m.file_count > 0, 'expected vendored files');
});
test('--source <dir> vendors byte-identically to the in-repo default (SC5 parity)', () => {
cpSync(IN_REPO_DS, dsCopy, { recursive: true });
mkdirSync(pluginB, { recursive: true });
const r = run(['dummy', '--source', dsCopy, '--target', pluginB]);
assert.equal(r.status, 0, r.stderr);
const a = manifestOf(pluginA);
const b = manifestOf(pluginB);
assert.deepEqual(b.files, a.files, 'vendored hashes must match the default source');
assert.equal(b.source, dsCopy, '--source label recorded in MANIFEST');
});
test('--check passes (exit 0, MANIFEST OK) on an unmodified vendored tree', () => {
const r = run(['dummy', '--check', '--target', pluginA]);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /MANIFEST OK/);
});
test('--check fails (exit 2) after a tampered byte', () => {
appendFileSync(path.join(pluginA, VENDOR_REL, 'tokens.css'), '\n/* tampered */\n');
const r = run(['dummy', '--check', '--target', pluginA]);
assert.equal(r.status, 2, `expected exit 2, got ${r.status}: ${r.stderr}`);
assert.match(r.stderr, /MANIFEST DRIFT/);
});