Files
GDevelop-BYOK/scripts/patch.js
T

236 lines
7.7 KiB
JavaScript

#!/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();