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