Files
Sven 800af630a1
Deploy Indiekit Server / deploy (push) Successful in 2m18s
Add unified patch runner (scripts/run-patches.mjs)
Replaces the 60-command &&-chain in postinstall and serve with a single
node scripts/run-patches.mjs call.

- Discovers all patch-*.mjs dynamically via readdir
- Runs sequentially (8 files shared across multiple patches)
- Classifies output: already / applied / not-found / failed
- Prints summary: ✓ N already applied | M newly applied | K not found | J failed
- --check flag: exits 1 if any patch was newly applied (CI idempotence)
- Surfaced hidden issue: patch-actor-aliases-successor broken (ERR_PACKAGE_PATH_NOT_EXPORTED)

ISC-36 (npm run postinstall on server) deferred — verify after next deploy.
2026-05-03 12:03:21 +02:00

180 lines
5.8 KiB
JavaScript

#!/usr/bin/env node
// scripts/run-patches.mjs
//
// Unified patch runner: discovers all sibling `patch-*.mjs` files, executes
// each one sequentially as a Node subprocess, classifies the result based on
// captured output, and prints a summary.
//
// Sequential by design — multiple patches may target the same file, and
// concurrent writes would corrupt them.
//
// Flags:
// --check After the summary, fail (exit 1) if any patch was *newly*
// applied. Used in CI to assert idempotence: a clean checkout
// of an already-patched node_modules tree must produce zero
// "applied" results.
//
// Exit codes:
// 0 — all patches succeeded; with --check, also requires zero new applies.
// 1 — at least one patch failed, OR --check was set and applies > 0.
import { readdir } from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { dirname, join, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const projectRoot = join(__dirname, '..');
const args = new Set(process.argv.slice(2));
const checkMode = args.has('--check');
/**
* Discover all `patch-*.mjs` files alongside this script.
* Returns absolute paths sorted alphabetically for deterministic ordering.
*/
async function discoverPatches(scriptsDir) {
const entries = await readdir(scriptsDir, { withFileTypes: true });
const patches = entries
.filter((e) => e.isFile() && e.name.startsWith('patch-') && e.name.endsWith('.mjs'))
.map((e) => join(scriptsDir, e.name))
.sort();
return patches;
}
/**
* Run a single patch as a Node subprocess. Captures stdout+stderr together
* (merged in encounter order) and the exit code.
*
* Resolves with `{ output, exitCode }`. Rejects only on unrecoverable spawn
* failures (e.g. the Node binary itself cannot be invoked) — those bubble up
* to the caller and abort the run.
*/
function runPatch(patchPath) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [patchPath], {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env,
});
const chunks = [];
child.stdout.on('data', (buf) => chunks.push(buf));
child.stderr.on('data', (buf) => chunks.push(buf));
child.once('error', (err) => reject(err));
child.once('close', (code) => {
const output = Buffer.concat(chunks).toString('utf8');
// `code` is null when the process was killed by a signal; treat that as
// a non-zero exit so it lands in the `failed` bucket.
const exitCode = typeof code === 'number' ? code : 1;
resolve({ output, exitCode });
});
});
}
/**
* Classify a patch result into one of:
* 'already' | 'applied' | 'notFound' | 'failed'
*
* Order matters:
* 1. Non-zero exit → 'failed' regardless of output content.
* 2. "already" anywhere in output (case-insensitive) → 'already'.
* This wins over "applied" because patches commonly emit phrases like
* "already applied" — we must not double-count those as new applies.
* 3. "patched" / "applied to" / leading "Applied" → 'applied'.
* 4. "no " / "not found" (case-insensitive) → 'notFound'.
* 5. Empty output with exit 0 → 'notFound' (silent no-op).
* 6. Anything else with exit 0 → 'applied' (conservative default: a
* successful run that produced output is treated as having done work).
*/
function classify(output, exitCode) {
if (exitCode !== 0) return 'failed';
const lower = output.toLowerCase();
const trimmed = output.trim();
if (lower.includes('already')) return 'already';
// "Applied" at the start of any line, or the substrings "patched" /
// "applied to" anywhere in the output.
const hasAppliedMarker =
lower.includes('patched') ||
lower.includes('applied to') ||
/(^|\n)\s*Applied\b/.test(output);
if (hasAppliedMarker) return 'applied';
if (lower.includes('no ') || lower.includes('not found') || lower.includes(': error')) return 'notFound';
if (trimmed.length === 0) return 'notFound';
// Successful exit, non-empty output, but no recognized marker: treat as
// applied so it surfaces in --check rather than being silently swallowed.
return 'applied';
}
async function main() {
const patches = await discoverPatches(__dirname);
if (patches.length === 0) {
console.log('\n✓ 0 already applied | 0 newly applied | 0 not found | 0 failed');
return;
}
const buckets = {
already: [],
applied: [],
notFound: [],
failed: [],
};
// Sequential: shared target files mean parallel runs would corrupt them.
for (const patchPath of patches) {
const name = basename(patchPath, '.mjs');
const { output, exitCode } = await runPatch(patchPath);
const verdict = classify(output, exitCode);
buckets[verdict].push({ name, output, exitCode });
if (verdict === 'applied') {
console.log(`\n=== ${name} ===`);
process.stdout.write(output.endsWith('\n') ? output : `${output}\n`);
} else if (verdict === 'failed') {
console.error(`\n=== ${name} (FAILED, exit ${exitCode}) ===`);
process.stderr.write(output.endsWith('\n') ? output : `${output}\n`);
}
}
const summary =
`\n${buckets.already.length} already applied` +
` | ${buckets.applied.length} newly applied` +
` | ${buckets.notFound.length} not found` +
` | ${buckets.failed.length} failed`;
console.log(summary);
let exitCode = 0;
if (checkMode && buckets.applied.length > 0) {
for (const { name } of buckets.applied) {
console.log(`[CHECK FAIL] ${name}`);
}
exitCode = 1;
}
if (buckets.failed.length > 0) {
exitCode = 1;
}
process.exit(exitCode);
}
main().catch((err) => {
console.error('run-patches: fatal error');
console.error(err && err.stack ? err.stack : err);
process.exit(1);
});