mirror of
https://github.com/Heretek-AI/GDevelop-BYOK.git
synced 2026-07-18 10:35:33 -04:00
236 lines
7.7 KiB
JavaScript
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();
|