From 800af630a1859db9f2158ff999eaad2c44d459c7 Mon Sep 17 00:00:00 2001 From: Sven Date: Sun, 3 May 2026 12:03:21 +0200 Subject: [PATCH] Add unified patch runner (scripts/run-patches.mjs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package.json | 4 +- scripts/run-patches.mjs | 179 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 scripts/run-patches.mjs diff --git a/package.json b/package.json index add9ff3c..e4aa2330 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "main": "index.js", "scripts": { "preinstall": "node scripts/setup-gitea-url-rewrite.mjs", - "postinstall": "xattr -w com.apple.fileprovider.ignore#P 1 node_modules 2>/dev/null || true && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-endpoint-posts-locales.mjs && node scripts/patch-endpoint-conversations-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-endpoint-github-contributions-log.mjs && node scripts/patch-store-github-error-message.mjs && node scripts/patch-store-github-update-fallback.mjs && node scripts/patch-store-github-gitea-methods.mjs && node scripts/patch-store-github-put-fallback.mjs && node scripts/patch-store-github-content-type.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-endpoint-posts-content-null-guard.mjs && node scripts/patch-endpoint-posts-content-field-object.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-syndicate-force-checked-default.mjs && node scripts/patch-syndicate-normalize-syndication-array.mjs && node scripts/patch-endpoint-posts-fetch-diagnostic.mjs && node scripts/patch-micropub-fetch-internal-url.mjs && node scripts/patch-micropub-session-token.mjs && node scripts/patch-indiekit-endpoint-urls-protocol.mjs && node scripts/patch-webmention-sender-retry.mjs && node scripts/patch-webmention-sender-livefetch.mjs && node scripts/patch-webmention-sender-reset-stale.mjs && node scripts/patch-webmention-sender-empty-details.mjs && node scripts/patch-bluesky-syndicator-internal-url.mjs && node scripts/patch-bluesky-syndicator-media-type-guard.mjs && node scripts/patch-bluesky-og-own-post-title.mjs && node scripts/patch-bluesky-syndicator-delete.mjs && node scripts/patch-micropub-delete-propagation.mjs && node scripts/patch-micropub-category-from-posts.mjs && node scripts/patch-tag-input-autocomplete.mjs && node scripts/patch-microsub-compose-draft-guard.mjs && node scripts/patch-microsub-no-bookmark-autofollow.mjs && node scripts/patch-microsub-reader-ap-dispatch.mjs && node scripts/patch-microsub-batch-concurrency.mjs && node scripts/patch-session-maxage.mjs && node scripts/patch-ap-dm-bsky-source-content.mjs && node scripts/patch-ap-dm-send-serialize-id.mjs", - "serve": "export NODE_ENV=${NODE_ENV:-production} INDIEKIT_DEBUG=${INDIEKIT_DEBUG:-0} && node scripts/preflight-production-security.mjs && node scripts/preflight-mongo-connection.mjs && node scripts/preflight-activitypub-rsa-key.mjs && node scripts/preflight-activitypub-profile-urls.mjs && node scripts/preflight-startup-gate.mjs && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-endpoint-posts-locales.mjs && node scripts/patch-endpoint-conversations-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-endpoint-github-contributions-log.mjs && node scripts/patch-store-github-error-message.mjs && node scripts/patch-store-github-update-fallback.mjs && node scripts/patch-store-github-gitea-methods.mjs && node scripts/patch-store-github-put-fallback.mjs && node scripts/patch-store-github-content-type.mjs && node scripts/patch-microsub-reader-ap-dispatch.mjs && node scripts/patch-microsub-compose-draft-guard.mjs && node scripts/patch-microsub-no-bookmark-autofollow.mjs && node scripts/patch-microsub-batch-concurrency.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-endpoint-posts-content-null-guard.mjs && node scripts/patch-endpoint-posts-content-field-object.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-syndicate-force-checked-default.mjs && node scripts/patch-syndicate-normalize-syndication-array.mjs && node scripts/patch-endpoint-posts-fetch-diagnostic.mjs && node scripts/patch-micropub-fetch-internal-url.mjs && node scripts/patch-micropub-session-token.mjs && node scripts/patch-indiekit-endpoint-urls-protocol.mjs && node scripts/patch-webmention-sender-retry.mjs && node scripts/patch-webmention-sender-livefetch.mjs && node scripts/patch-webmention-sender-reset-stale.mjs && node scripts/patch-webmention-sender-empty-details.mjs && node scripts/patch-bluesky-syndicator-internal-url.mjs && node scripts/patch-bluesky-syndicator-media-type-guard.mjs && node scripts/patch-bluesky-og-own-post-title.mjs && node scripts/patch-bluesky-syndicator-delete.mjs && node scripts/patch-micropub-delete-propagation.mjs && node scripts/patch-micropub-category-from-posts.mjs && node scripts/patch-tag-input-autocomplete.mjs && node scripts/patch-session-maxage.mjs && node scripts/patch-ap-dm-bsky-source-content.mjs && node scripts/patch-ap-dm-send-serialize-id.mjs && node --max-old-space-size=1024 --max-semi-space-size=32 --require ./metrics-shim.cjs node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs", + "postinstall": "xattr -w com.apple.fileprovider.ignore#P 1 node_modules 2>/dev/null || true && node scripts/run-patches.mjs", + "serve": "export NODE_ENV=${NODE_ENV:-production} INDIEKIT_DEBUG=${INDIEKIT_DEBUG:-0} && node scripts/preflight-production-security.mjs && node scripts/preflight-mongo-connection.mjs && node scripts/preflight-activitypub-rsa-key.mjs && node scripts/preflight-activitypub-profile-urls.mjs && node scripts/preflight-startup-gate.mjs && node scripts/run-patches.mjs && node --max-old-space-size=1024 --max-semi-space-size=32 --require ./metrics-shim.cjs node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/scripts/run-patches.mjs b/scripts/run-patches.mjs new file mode 100644 index 00000000..9b35cb6f --- /dev/null +++ b/scripts/run-patches.mjs @@ -0,0 +1,179 @@ +#!/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); +});