800af630a1
Deploy Indiekit Server / deploy (push) Successful in 2m18s
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.
180 lines
5.8 KiB
JavaScript
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);
|
|
});
|