feat: consolidated patch-ap-mastodon-misc (compose, og-image, interactions, inbox)

Absorbs patch-ap-compose-default-checked, patch-ap-og-image,
patch-ap-repost-announce-fix, patch-ap-interactions-send-guard,
patch-ap-interactions-cleanup-preserve, patch-ap-interactions-accounts-uid,
patch-inbox-ignore-view-activity, and patch-inbox-skip-view-activity-parse
into a single script using the shared applyPatch helper pattern.

Added inbox-ignore-view-activity-handler-v2 variant to handle the evolved
upstream inbox-listeners.js that uses enqueueActivity instead of try/catch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sven
2026-04-12 13:59:35 +02:00
parent fd0375d5c5
commit 421f119d14
+571
View File
@@ -0,0 +1,571 @@
/**
* Consolidated patch: AP compose, OG image, repost announce, interactions
* send guard, interactions cleanup preserve, interactions accounts uid,
* inbox ignore View activity, inbox skip View activity parse.
*
* Absorbs:
* - patch-ap-compose-default-checked
* - patch-ap-og-image
* - patch-ap-repost-announce-fix
* - patch-ap-interactions-send-guard
* - patch-ap-interactions-cleanup-preserve
* - patch-ap-interactions-accounts-uid
* - patch-inbox-ignore-view-activity
* - patch-inbox-skip-view-activity-parse
*/
import { access, readFile, writeFile } from "node:fs/promises";
const AP_BASE = "@rmdes/indiekit-endpoint-activitypub";
const AP_ROOTS = [
`node_modules/${AP_BASE}`,
`node_modules/@indiekit/indiekit/node_modules/${AP_BASE}`,
];
function apPath(rel) {
return AP_ROOTS.map(r => `${r}/${rel}`);
}
async function fileExists(p) {
try { await access(p); return true; } catch { return false; }
}
async function applyPatch(filePath, marker, oldSnippet, newSnippet) {
if (!(await fileExists(filePath))) return "file_not_found";
const src = await readFile(filePath, "utf8");
if (src.includes(marker)) return "already_applied";
if (!src.includes(oldSnippet)) return "snippet_not_found";
await writeFile(filePath, src.replace(oldSnippet, newSnippet), "utf8");
return "applied";
}
// applyRegexPatch: like applyPatch but uses a regex instead of a literal string match.
// oldRegex must be a RegExp (tested + replaced against src).
async function applyRegexPatch(filePath, marker, oldRegex, newSnippet) {
if (!(await fileExists(filePath))) return "file_not_found";
const src = await readFile(filePath, "utf8");
if (src.includes(marker)) return "already_applied";
if (!oldRegex.test(src)) return "snippet_not_found";
await writeFile(filePath, src.replace(oldRegex, newSnippet), "utf8");
return "applied";
}
const SCRIPT = "patch-ap-mastodon-misc";
// ── patch-ap-og-image: regex-based, two replacements per file ─────────────────
// This patch is handled separately below the main loop because it uses regexes
// and applies two independent replacements to the same file.
const OG_MARKER = "// og-image-v2";
const OG_CANDIDATES = apPath("lib/jf2-to-as2.js");
const CN_BLOCK_RE =
/ const og(?:Slug|Match) = postUrl && postUrl\.match\([^\n]+\n if \(og(?:Slug|Match)\) \{[\s\S]*?\n \}/;
const AS2_BLOCK_RE =
/ const og(?:SlugF|MatchF) = postUrl && postUrl\.match\([^\n]+\n if \(og(?:SlugF|MatchF)\) \{[\s\S]*?\n \}/;
const NEW_CN = ` const _ogPhoto = properties.photo && asArray(properties.photo)[0]; // og-image-v2
const _ogPhotoUrl = _ogPhoto && (typeof _ogPhoto === "string" ? _ogPhoto : _ogPhoto.url); // og-image-v2
const ogSlug = postUrl && postUrl.match(/\\/([\\\w-]+)\\/?$/)?.[1]; // og-image-v2
const _ogUrl = _ogPhotoUrl
? resolveMediaUrl(_ogPhotoUrl, publicationUrl) // og-image-v2
: ogSlug ? \`\${publicationUrl.replace(/\\/$/, "")}/og/\${ogSlug}.png\` : null; // og-image-v2
if (_ogUrl) { // og-image-v2
object.image = {
type: "Image",
url: _ogUrl, // og-image-v2
mediaType: _ogPhotoUrl ? guessMediaType(_ogUrl) : "image/png", // og-image-v2
};
}`;
const NEW_AS2 = ` const _ogPhotoF = properties.photo && asArray(properties.photo)[0]; // og-image-v2
const _ogPhotoUrlF = _ogPhotoF && (typeof _ogPhotoF === "string" ? _ogPhotoF : _ogPhotoF.url); // og-image-v2
const ogSlugF = postUrl && postUrl.match(/\\/([\\\w-]+)\\/?$/)?.[1]; // og-image-v2
const _ogUrlF = _ogPhotoUrlF
? resolveMediaUrl(_ogPhotoUrlF, publicationUrl) // og-image-v2
: ogSlugF ? \`\${publicationUrl.replace(/\\/$/, "")}/og/\${ogSlugF}.png\` : null; // og-image-v2
if (_ogUrlF) { // og-image-v2
noteOptions.image = new Image({
url: new URL(_ogUrlF), // og-image-v2
mediaType: _ogPhotoUrlF ? guessMediaType(_ogUrlF) : "image/png", // og-image-v2
});
}`;
// ── Standard patches (string-replace) ─────────────────────────────────────────
const PATCHES = [
// ── patch-ap-compose-default-checked ──────────────────────────────────────
{
name: "ap-compose-default-checked",
files: [
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/controllers/compose.js",
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/controllers/compose.js",
],
marker: "// [patch] ap-compose-default-checked",
oldSnippet: ` // Default-check only AP (Fedify) and Bluesky targets
// "@rick@rmendes.net" = AP Fedify, "@rmendes.net" = Bluesky
for (const target of syndicationTargets) {
const name = target.name || "";
target.defaultChecked = name === "@rick@rmendes.net" || name === "@rmendes.net";
}`,
newSnippet: ` // Pre-check syndication targets based on their configured checked state // [patch] ap-compose-default-checked
for (const target of syndicationTargets) { // [patch] ap-compose-default-checked
target.defaultChecked = target.checked === true; // [patch] ap-compose-default-checked
} // [patch] ap-compose-default-checked`,
},
// ── patch-ap-repost-announce-fix ──────────────────────────────────────────
{
name: "ap-repost-announce-fix",
files: apPath("lib/jf2-to-as2.js"),
marker: "// [patch] ap-repost-announce-fix",
oldSnippet: ` // Reposts are always public — upstream @rmdes addressing
if (postType === "repost") {
const repostOf = properties["repost-of"];
if (!repostOf) return null;
const repostContent = properties.content?.html || properties.content || "";
if (!repostContent) {
// Pure repost — send as a native Announce (boost) so remote servers
// can display it as a boost of the original post.
return new Announce({
actor: actorUri,
object: new URL(repostOf),
to: new URL("https://www.w3.org/ns/activitystreams#Public"),
});
}
// Has commentary — fall through to Create(Note) so the text is federated.
// The note content block below handles the "repost" post-type.
}`,
newSnippet: ` // Reposts are always public — upstream @rmdes addressing
if (postType === "repost") {
const repostOf = Array.isArray(properties["repost-of"])
? properties["repost-of"][0]
: properties["repost-of"];
if (!repostOf) return null;
const repostContent = properties.content?.html || properties.content || "";
if (!repostContent) {
// Only send Announce if repost-of is an ActivityPub URL.
// Non-AP URLs (web articles) cannot be federated as a boost — fall
// through to Create(Note) which renders as "🔁 <link>" on the fediverse.
if (await isApUrl(repostOf)) { // [patch] ap-repost-announce-fix
const actorPath = new URL(actorUrl).pathname;
const mp = actorPath.replace(/\\/users\\/[^/]+$/, "");
const postRelPath = (properties.url || "")
.replace(publicationUrl.replace(/\\/$/, ""), "")
.replace(/^\\//, "")
.replace(/\\/$/, "");
const announceId = \`\${publicationUrl.replace(/\\/$/, "")}\${mp}/activities/boost/\${postRelPath}\`;
return new Announce({
id: new URL(announceId),
actor: actorUri,
object: new URL(repostOf),
to: new URL("https://www.w3.org/ns/activitystreams#Public"),
cc: new URL(\`\${actorUrl.replace(/\\/$/, "")}/followers\`),
});
}
}
// Has commentary or non-AP repost-of URL — fall through to Create(Note) so the text is federated.
// The note content block below handles the "repost" post-type.
}`,
},
// ── patch-ap-interactions-send-guard: likePost ────────────────────────────
{
name: "ap-interactions-send-guard-like",
files: [
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/interactions.js",
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/interactions.js",
],
marker: "// [patch] ap-interactions-send-guard",
oldSnippet: ` if (recipient) {
await ctx.sendActivity({ identifier: handle }, recipient, like, {
orderingKey: targetUrl,
});
}`,
newSnippet: ` if (recipient) {
try { // [patch] ap-interactions-send-guard
await ctx.sendActivity({ identifier: handle }, recipient, like, {
orderingKey: targetUrl,
});
} catch { /* delivery failed — interaction still recorded locally */ }
}`,
},
// ── patch-ap-interactions-send-guard: boostPost ───────────────────────────
{
name: "ap-interactions-send-guard-boost",
files: [
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/interactions.js",
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/interactions.js",
],
marker: "// [patch] ap-interactions-send-guard",
oldSnippet: ` // Send to followers
await ctx.sendActivity({ identifier: handle }, "followers", announce, {
preferSharedInbox: true,
syncCollection: true,
orderingKey: targetUrl,
});`,
newSnippet: ` // Send to followers
try { // [patch] ap-interactions-send-guard
await ctx.sendActivity({ identifier: handle }, "followers", announce, {
preferSharedInbox: true,
syncCollection: true,
orderingKey: targetUrl,
});
} catch { /* delivery failed — interaction still recorded locally */ }`,
},
// ── patch-ap-interactions-cleanup-preserve ────────────────────────────────
{
name: "ap-interactions-cleanup-preserve",
files: apPath("lib/timeline-cleanup.js"),
marker: "// [patch] ap-interactions-cleanup-preserve",
oldSnippet: ` const removedUids = toDelete.map((item) => item.uid).filter(Boolean);
// Delete old timeline items by UID
const deleteResult = await collections.ap_timeline.deleteMany({
_id: { $in: toDelete.map((item) => item._id) },
});
// Clean up stale interactions for removed items
let interactionsRemoved = 0;
if (removedUids.length > 0 && collections.ap_interactions) {
const interactionResult = await collections.ap_interactions.deleteMany({
objectUrl: { $in: removedUids },
});
interactionsRemoved = interactionResult.deletedCount || 0;
}`,
newSnippet: ` const removedUids = toDelete.map((item) => item.uid).filter(Boolean);
// Preserve items the user has interacted with (liked, bookmarked, boosted). // [patch] ap-interactions-cleanup-preserve
// Deleting them would silently remove entries from the Favourites/Bookmarks pages.
let interactedUids = new Set();
if (removedUids.length > 0 && collections.ap_interactions) {
const interacted = await collections.ap_interactions.distinct("objectUrl");
interactedUids = new Set(interacted);
}
const itemsToDelete = toDelete.filter((item) => !interactedUids.has(item.uid));
const uidsToDelete = itemsToDelete.map((item) => item.uid).filter(Boolean);
if (!itemsToDelete.length) {
return { removed: 0, interactionsRemoved: 0 };
}
// Delete old timeline items by UID
const deleteResult = await collections.ap_timeline.deleteMany({
_id: { $in: itemsToDelete.map((item) => item._id) },
});
// Clean up stale interactions for removed items
let interactionsRemoved = 0;
if (uidsToDelete.length > 0 && collections.ap_interactions) {
const interactionResult = await collections.ap_interactions.deleteMany({
objectUrl: { $in: uidsToDelete },
});
interactionsRemoved = interactionResult.deletedCount || 0;
}`,
},
// ── patch-ap-interactions-accounts-uid ───────────────────────────────────
{
name: "ap-interactions-accounts-uid",
files: [
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/accounts.js",
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/accounts.js",
],
marker: "// [patch] ap-interactions-accounts-uid",
oldSnippet: ` const lookupUrls = items.flatMap((i) => [i.uid, i.url].filter(Boolean));
if (lookupUrls.length > 0) {
const interactions = await collections.ap_interactions
.find({ objectUrl: { $in: lookupUrls } })
.toArray();
for (const ix of interactions) {
if (ix.type === "like") favouritedIds.add(ix.objectUrl);
else if (ix.type === "boost") rebloggedIds.add(ix.objectUrl);
else if (ix.type === "bookmark") bookmarkedIds.add(ix.objectUrl);
}
}`,
newSnippet: ` const urlToUid = new Map(); // [patch] ap-interactions-accounts-uid
for (const i of items) {
if (i.uid) {
urlToUid.set(i.uid, i.uid);
if (i.url && i.url !== i.uid) urlToUid.set(i.url, i.uid);
}
}
const lookupUrls = [...urlToUid.keys()];
if (lookupUrls.length > 0) {
const interactions = await collections.ap_interactions
.find({ objectUrl: { $in: lookupUrls } })
.toArray();
for (const ix of interactions) {
const uid = urlToUid.get(ix.objectUrl) || ix.objectUrl;
if (ix.type === "like") favouritedIds.add(uid);
else if (ix.type === "boost") rebloggedIds.add(uid);
else if (ix.type === "bookmark") bookmarkedIds.add(uid);
}
}`,
},
// ── patch-inbox-ignore-view-activity: import ──────────────────────────────
{
name: "inbox-ignore-view-activity-import",
files: apPath("lib/inbox-listeners.js"),
marker: "// View imported",
oldSnippet: ` Undo,
Update,
} from "@fedify/fedify/vocab";`,
newSnippet: ` Undo,
Update,
View, // View imported
} from "@fedify/fedify/vocab";`,
},
// ── patch-inbox-ignore-view-activity: handler ────────────────────────────
// Two variants: one for files with the console.warn block (original upstream),
// one for files with the simpler enqueueActivity-only structure (evolved upstream).
{
name: "inbox-ignore-view-activity-handler",
files: apPath("lib/inbox-listeners.js"),
marker: "// PeerTube View handler",
oldSnippet: ` console.info(\`[ActivityPub] Flag received from \${reporterName} — \${reportedIds.length} objects reported\`);
} catch (error) {
console.warn("[ActivityPub] Flag handler error:", error.message);
}
});
}`,
newSnippet: ` console.info(\`[ActivityPub] Flag received from \${reporterName} — \${reportedIds.length} objects reported\`);
} catch (error) {
console.warn("[ActivityPub] Flag handler error:", error.message);
}
})
// ── View (PeerTube watch) ─────────────────────────────────────────────
// PeerTube broadcasts View (WatchAction) activities to all followers
// whenever someone watches a video. Fedify has no built-in handler for
// this type, producing noisy "Unsupported activity type" log errors.
// Silently accept and discard. // PeerTube View handler
.on(View, async () => {});
}`,
},
{
name: "inbox-ignore-view-activity-handler-v2",
files: apPath("lib/inbox-listeners.js"),
marker: "// PeerTube View handler",
oldSnippet: ` await enqueueActivity(collections, {
activityType: "Flag",
actorUrl,
rawJson: await flag.toJsonLd(),
});
});
}`,
newSnippet: ` await enqueueActivity(collections, {
activityType: "Flag",
actorUrl,
rawJson: await flag.toJsonLd(),
});
})
// ── View (PeerTube watch) ─────────────────────────────────────────────
// PeerTube broadcasts View (WatchAction) activities to all followers
// whenever someone watches a video. Fedify has no built-in handler for
// this type, producing noisy "Unsupported activity type" log errors.
// Silently accept and discard. // PeerTube View handler
.on(View, async () => {});
}`,
},
// ── patch-inbox-skip-view-activity-parse: fromExpressRequest fix ──────────
{
name: "from-express-request-activity-json-fix",
files: apPath("lib/federation-bridge.js"),
marker: "// PeerTube activity+json body fix",
oldSnippet: ` if (ct.includes("application/json")) {
body = JSON.stringify(req.body);
} else if (ct.includes("application/x-www-form-urlencoded")) {`,
newSnippet: ` // PeerTube activity+json body fix
if (ct.includes("application/json") || ct.includes("activity+json") || ct.includes("ld+json")) {
// Use original raw bytes when available (set by createFedifyMiddleware buffer guard).
// JSON.stringify() changes byte layout, breaking Fedify's HTTP Signature Digest check.
body = req._rawBody || JSON.stringify(req.body); // raw body digest fix
} else if (ct.includes("application/x-www-form-urlencoded")) {`,
},
// ── patch-inbox-skip-view-activity-parse: upgrade v1 raw-body fix ─────────
{
name: "from-express-request-raw-body-fix",
files: apPath("lib/federation-bridge.js"),
marker: "req._rawBody || JSON.stringify",
oldSnippet: ` // PeerTube activity+json body fix
if (ct.includes("application/json") || ct.includes("activity+json") || ct.includes("ld+json")) {
body = JSON.stringify(req.body);
} else if (ct.includes("application/x-www-form-urlencoded")) {`,
newSnippet: ` // PeerTube activity+json body fix
if (ct.includes("application/json") || ct.includes("activity+json") || ct.includes("ld+json")) {
// Use original raw bytes when available (set by createFedifyMiddleware buffer guard).
// JSON.stringify() changes byte layout, breaking Fedify's HTTP Signature Digest check.
body = req._rawBody || JSON.stringify(req.body); // raw body digest fix
} else if (ct.includes("application/x-www-form-urlencoded")) {`,
},
// ── patch-inbox-skip-view-activity-parse: middleware buffer _rawBody fix ──
{
name: "inbox-buffer-raw-body-fix",
files: apPath("lib/federation-bridge.js"),
marker: "req._rawBody = _raw",
oldSnippet: ` const _chunks = [];
for await (const _chunk of req) {
_chunks.push(Buffer.isBuffer(_chunk) ? _chunk : Buffer.from(_chunk));
}
try {
req.body = JSON.parse(Buffer.concat(_chunks).toString("utf8"));`,
newSnippet: ` const _chunks = [];
for await (const _chunk of req) {
_chunks.push(Buffer.isBuffer(_chunk) ? _chunk : Buffer.from(_chunk));
}
const _raw = Buffer.concat(_chunks); // raw body digest fix
req._rawBody = _raw; // Preserve original bytes for Fedify HTTP Signature Digest verification
try {
req.body = JSON.parse(_raw.toString("utf8"));`,
},
// ── patch-inbox-skip-view-activity-parse: v2 guard replacing v1 ───────────
{
name: "inbox-skip-view-activity-parse-v2",
files: apPath("lib/federation-bridge.js"),
marker: "// PeerTube View parse skip v2",
oldSnippet: ` // Short-circuit PeerTube View (WatchAction) activities before Fedify
// attempts JSON-LD parsing. Fedify's vocab parser throws on PeerTube's
// Schema.org extensions (e.g. InteractionCounter), causing a
// "Failed to parse activity" error. Return 200 to prevent retries.
// PeerTube View parse skip
if (req.method === "POST" && req.body?.type === "View") {
return res.status(200).end();
}
const request = fromExpressRequest(req);`,
newSnippet: ` // Short-circuit PeerTube View (WatchAction) activities before Fedify
// attempts JSON-LD parsing. Fedify's vocab parser throws on PeerTube's
// Schema.org extensions (e.g. InteractionCounter), causing a
// "Failed to parse activity" error. Return 200 to prevent retries.
// PeerTube View parse skip v2
const _apct = req.headers["content-type"] || "";
if (
req.method === "POST" &&
!req.body &&
req.readable &&
(_apct.includes("activity+json") || _apct.includes("ld+json"))
) {
// Express doesn't parse application/activity+json, so buffer it ourselves.
const _chunks = [];
for await (const _chunk of req) {
_chunks.push(Buffer.isBuffer(_chunk) ? _chunk : Buffer.from(_chunk));
}
const _raw = Buffer.concat(_chunks); // raw body digest fix
req._rawBody = _raw; // Preserve original bytes for Fedify HTTP Signature Digest verification
try {
req.body = JSON.parse(_raw.toString("utf8"));
} catch {
req.body = {};
}
}
if (req.method === "POST" && req.body?.type === "View") {
return res.status(200).end();
}
const request = fromExpressRequest(req);`,
},
// ── patch-inbox-skip-view-activity-parse: v2 guard on fresh file ──────────
{
name: "inbox-skip-view-activity-parse-v2-fresh",
files: apPath("lib/federation-bridge.js"),
marker: "// PeerTube View parse skip v2",
oldSnippet: ` return async (req, res, next) => {
try {
const request = fromExpressRequest(req);`,
newSnippet: ` return async (req, res, next) => {
try {
// Short-circuit PeerTube View (WatchAction) activities before Fedify
// attempts JSON-LD parsing. Fedify's vocab parser throws on PeerTube's
// Schema.org extensions (e.g. InteractionCounter), causing a
// "Failed to parse activity" error. Return 200 to prevent retries.
// PeerTube View parse skip v2
const _apct = req.headers["content-type"] || "";
if (
req.method === "POST" &&
!req.body &&
req.readable &&
(_apct.includes("activity+json") || _apct.includes("ld+json"))
) {
// Express doesn't parse application/activity+json, so buffer it ourselves.
const _chunks = [];
for await (const _chunk of req) {
_chunks.push(Buffer.isBuffer(_chunk) ? _chunk : Buffer.from(_chunk));
}
const _raw = Buffer.concat(_chunks); // raw body digest fix
req._rawBody = _raw; // Preserve original bytes for Fedify HTTP Signature Digest verification
try {
req.body = JSON.parse(_raw.toString("utf8"));
} catch {
req.body = {};
}
}
if (req.method === "POST" && req.body?.type === "View") {
return res.status(200).end();
}
const request = fromExpressRequest(req);`,
},
];
// ── Run standard patches ───────────────────────────────────────────────────────
let total = 0;
for (const p of PATCHES) {
let done = false;
for (const f of p.files) {
const r = await applyPatch(f, p.marker, p.oldSnippet, p.newSnippet);
if (r === "applied") {
console.log(`[postinstall] ${SCRIPT}: applied ${p.name} to ${f}`);
total++; done = true; break;
} else if (r === "already_applied") {
console.log(`[postinstall] ${SCRIPT}: ${p.name} already applied in ${f}`);
done = true; break;
} else if (r === "snippet_not_found") {
console.warn(`[postinstall] ${SCRIPT}: ${p.name} — snippet not found in ${f}, skipping`);
}
}
if (!done) console.log(`[postinstall] ${SCRIPT}: ${p.name} — no target file found`);
}
// ── Run og-image patch (regex-based, two replacements per file) ───────────────
{
let ogDone = false;
for (const f of OG_CANDIDATES) {
if (!(await fileExists(f))) continue;
const src = await readFile(f, "utf8");
if (src.includes(OG_MARKER)) {
console.log(`[postinstall] ${SCRIPT}: ap-og-image already applied in ${f}`);
ogDone = true; break;
}
let updated = src;
let changed = false;
if (CN_BLOCK_RE.test(updated)) {
updated = updated.replace(CN_BLOCK_RE, NEW_CN);
changed = true;
} else {
console.warn(`[postinstall] ${SCRIPT}: ap-og-image — jf2ToActivityStreams OG block not found in ${f}`);
}
if (AS2_BLOCK_RE.test(updated)) {
updated = updated.replace(AS2_BLOCK_RE, NEW_AS2);
changed = true;
} else {
console.warn(`[postinstall] ${SCRIPT}: ap-og-image — jf2ToAS2Activity OG block not found in ${f}`);
}
if (changed && updated !== src) {
await writeFile(f, updated, "utf8");
console.log(`[postinstall] ${SCRIPT}: applied ap-og-image to ${f}`);
total++; ogDone = true; break;
}
}
if (!ogDone) console.log(`[postinstall] ${SCRIPT}: ap-og-image — no target file found or no changes`);
}
console.log(`[postinstall] ${SCRIPT}: done (${total} patch(es) applied)`);