From 653db4785eafb691e386378224d87970de47af34 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Wed, 17 Jun 2026 12:34:01 +0200 Subject: [PATCH] feat(migration): sync-design-system --source + --check for DS re-home [skip-docs] --- scripts/sync-design-system.mjs | 71 +++++++++++++++++++++++------ scripts/sync-design-system.test.mjs | 60 ++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 14 deletions(-) create mode 100644 scripts/sync-design-system.test.mjs diff --git a/scripts/sync-design-system.mjs b/scripts/sync-design-system.mjs index 53496bf..dcc4225 100644 --- a/scripts/sync-design-system.mjs +++ b/scripts/sync-design-system.mjs @@ -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'; function parseArgs(argv) { - const args = { plugin: null, force: false }; - for (const a of argv.slice(2)) { + 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) { @@ -40,11 +49,17 @@ function parseArgs(argv) { } } if (!args.plugin) { - throw new Error('Missing plugin name. Usage: node scripts/sync-design-system.mjs [--force]'); + 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'); @@ -99,7 +114,7 @@ async function injectGeneratedHeader(targetDir, files) { } } -async function buildManifest(targetDir, files, sourceCommit) { +async function buildManifest(targetDir, files, sourceCommit, sourceLabel) { const fileHashes = {}; for (const rel of files.sort()) { fileHashes[rel] = await sha256(path.join(targetDir, rel)); @@ -107,7 +122,7 @@ async function buildManifest(targetDir, files, sourceCommit) { return { generated_by: 'scripts/sync-design-system.mjs', do_not_edit: true, - source: 'shared/playground-design-system/', + source: sourceLabel, source_commit: sourceCommit, sync_date: new Date().toISOString(), file_count: files.length, @@ -115,10 +130,10 @@ async function buildManifest(targetDir, files, sourceCommit) { }; } -function getCurrentCommit() { +function getCurrentCommit(cwd) { try { return execSync('git rev-parse HEAD', { - cwd: MARKETPLACE_ROOT, + cwd: cwd || MARKETPLACE_ROOT, encoding: 'utf8', }).trim(); } 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() { 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 { const stat = await fs.stat(pluginDir); @@ -138,9 +181,9 @@ async function main() { } try { - await fs.access(SOURCE_DIR); + await fs.access(sourceDir); } 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'); @@ -160,17 +203,17 @@ async function main() { 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 }); + await fs.cp(sourceDir, targetDir, { recursive: true, force: true }); const files = await walk(targetDir); await injectGeneratedHeader(targetDir, files); - const sourceCommit = getCurrentCommit(); + const sourceCommit = getCurrentCommit(args.source ? sourceDir : MARKETPLACE_ROOT); 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'); - 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(` Source commit: ${sourceCommit}`); console.log(` Sync date: ${manifest.sync_date}`); diff --git a/scripts/sync-design-system.test.mjs b/scripts/sync-design-system.test.mjs new file mode 100644 index 0000000..443aafa --- /dev/null +++ b/scripts/sync-design-system.test.mjs @@ -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 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/); +});