#!/usr/bin/env node /** * patch.js — Applies BYOK + premium-unlock patches to a GDevelop source tree. * * Reads a declarative manifest of patch entries (JSON), each using a * "finder / replacer / verify" sandwich. Supports --strict, --dry-run, * --verbose, --manifest, --repo, and --release flags. * * Usage: * node scripts/patch.js --release v5.6.269 * node scripts/patch.js --repo test/fixtures --strict * node scripts/patch.js --repo test/fixtures --dry-run --verbose * node scripts/patch.js --manifest custom/manifest.json --repo my/repo * * Constraints: Node.js built-in modules only (no npm dependencies). */ const fs = require('fs'); const path = require('path'); const { loadManifest, STEPS } = require('../patch/manifest'); // ── CLI flag parsing ───────────────────────────────────────────────────── const argv = process.argv; function flagValue(flag) { const i = argv.indexOf(flag); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null; } function hasFlag(flag) { return argv.includes(flag); } const RELEASE_TAG = flagValue('--release') || 'unknown'; const ROOT = flagValue('--repo') || process.cwd(); const MANIFEST_PATH = flagValue('--manifest') || path.join(process.cwd(), 'patch', 'manifest.json'); const STRICT = hasFlag('--strict'); const DRY_RUN = hasFlag('--dry-run'); const VERBOSE = hasFlag('--verbose'); // ── Helpers ────────────────────────────────────────────────────────────── /** * Print a message if --verbose is active. */ function verbose(msg) { if (VERBOSE) console.error(`[verbose] ${msg}`); } /** * Truncate a string for display (single-line, max 80 chars). */ function preview(s) { const oneLine = s.replace(/\n/g, '\\n'); return oneLine.length > 70 ? oneLine.slice(0, 67) + '...' : oneLine; } /** * Format a structured error to stderr and return it. */ function structuredError(patchId, file, step) { const msg = `PATCH_FAIL:${patchId}:${file}:${step}`; console.error(msg); return msg; } // ── Core engine ────────────────────────────────────────────────────────── /** * Apply a single manifest entry against the repo. * * @param {object} entry Manifest entry with id, file, finder, replacer, verify, required, description. * @param {string} root Absolute repo root path. * @returns {object} { pass: boolean, step: string|null, msg: string } */ function applyEntry(entry, root) { const filePath = path.join(root, entry.file); // ── Step 0: file must exist ── if (!fs.existsSync(filePath)) { return { pass: false, step: 'finder', msg: `file not found: ${entry.file}` }; } const original = fs.readFileSync(filePath, 'utf8'); // ── Step 1: finder regex must match exactly once ── let finderRegex; try { finderRegex = new RegExp(entry.finder, 'gm'); } catch (e) { return { pass: false, step: STEPS.finder, msg: `finder regex compile error: ${e.message}` }; } const finderMatches = [...original.matchAll(new RegExp(finderRegex.source, finderRegex.flags))]; verbose(`${entry.id}: finder matches = ${finderMatches.length}`); if (VERBOSE && finderMatches.length > 0) { for (const m of finderMatches) { verbose(`${entry.id}: finder match at index ${m.index}: "${preview(m[0])}"`); } } if (DRY_RUN) { console.log(`[DRY] ${entry.id}: finder matches ${finderMatches.length} time(s) in ${entry.file}`); if (finderMatches.length !== 1) { return { pass: false, step: STEPS.finder, msg: `finder matches ${finderMatches.length} time(s) (expected 1)` }; } return { pass: true, step: null, msg: `dry-run ok` }; } if (finderMatches.length !== 1) { return { pass: false, step: STEPS.finder, msg: `finder matches ${finderMatches.length} time(s) in ${entry.file} (expected exactly 1)` }; } verbose(`${entry.id}: applying replacer`); // ── Step 2: apply replacer ── const patched = original.replace(new RegExp(finderRegex.source, finderRegex.flags), entry.replacer); if (patched === original) { return { pass: false, step: STEPS.replacer, msg: `replacer did not change file content` }; } // ── Step 3: verify regex must match exactly once in the result ── let verifyRegex; try { verifyRegex = new RegExp(entry.verify, 'gm'); } catch (e) { return { pass: false, step: STEPS.verify, msg: `verify regex compile error: ${e.message}` }; } const verifyMatches = [...patched.matchAll(new RegExp(verifyRegex.source, verifyRegex.flags))]; verbose(`${entry.id}: verify matches = ${verifyMatches.length}`); if (VERBOSE && verifyMatches.length > 0) { for (const m of verifyMatches) { verbose(`${entry.id}: verify match at index ${m.index}: "${preview(m[0])}"`); } } if (verifyMatches.length !== 1) { return { pass: false, step: STEPS.verify, msg: `verify matches ${verifyMatches.length} time(s) (expected exactly 1)` }; } // ── Write back ── try { fs.writeFileSync(filePath, patched, 'utf8'); } catch (e) { return { pass: false, step: STEPS.verify, msg: `write error: ${e.message}` }; } return { pass: true, step: null, msg: 'ok' }; } // ── Main ───────────────────────────────────────────────────────────────── function main() { const repoRoot = path.resolve(ROOT); if (VERBOSE) { console.error(`[verbose] repo root : ${repoRoot}`); console.error(`[verbose] manifest : ${MANIFEST_PATH}`); console.error(`[verbose] strict mode : ${STRICT}`); console.error(`[verbose] dry-run : ${DRY_RUN}`); console.error(`[verbose] release : ${RELEASE_TAG}`); } // 1. Load and validate manifest const { entries, errors } = loadManifest(MANIFEST_PATH, repoRoot); if (errors.length > 0) { console.error('[ERROR] Manifest validation failed:'); for (const err of errors) console.error(` - ${err}`); process.exit(2); } const requiredCount = entries.filter(e => e.required).length; const optionalCount = entries.filter(e => !e.required).length; console.log(`[manifest] ${entries.length} entries (${requiredCount} required, ${optionalCount} optional)`); if (entries.length === 0) { console.log('[manifest] No entries — nothing to apply.'); if (DRY_RUN) { console.log('[DRY] Dry-run complete. No changes made.'); } return; } // 2. Apply each entry let failures = 0; let requiredFailures = 0; for (const entry of entries) { const result = applyEntry(entry, repoRoot); if (result.pass) { if (DRY_RUN) { // already printed in applyEntry } else { console.log(`[PASS] ${entry.id}: ${entry.description}`); } } else { failures++; const errLine = structuredError(entry.id, entry.file, result.step); if (VERBOSE) { console.error(` detail: ${result.msg}`); } else { console.error(` (${result.msg})`); } if (entry.required) { requiredFailures++; } } } // 3. Summary if (DRY_RUN) { console.log(`\n[DONE] Dry-run complete. ${entries.length} entries previewed.`); } else { console.log(`\n[DONE] Applied: ${entries.length - failures} succeeded, ${failures} failed (${requiredFailures} required).`); } // 4. Exit code if (STRICT && requiredFailures > 0) { console.error(`\n[FAIL] ${requiredFailures} required patch(es) failed in strict mode.`); process.exit(1); } } main();