@reddoorla/maintenance 0.80.0 → 0.82.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/airtable-ZDHLKK5K.js +9 -0
- package/dist/{announce-KZDU65IF.js → announce-7QHQGJPV.js} +4 -4
- package/dist/{blux-2P4FGCUL.js → blux-EATHOHKK.js} +2 -2
- package/dist/{bump-deps-XB2LJTVN.js → bump-deps-ZREAMK7M.js} +2 -2
- package/dist/{chunk-JR67FZVC.js → chunk-2BHSGLWR.js} +3 -3
- package/dist/{chunk-AKRUYPLU.js → chunk-4PW6ISX7.js} +3 -3
- package/dist/{chunk-R5KAXGSP.js → chunk-5E4L7WKE.js} +3 -3
- package/dist/{chunk-42A4H6SF.js → chunk-FFI7P74K.js} +2 -2
- package/dist/{chunk-3PU2QWOJ.js → chunk-LMCO7CYQ.js} +2 -2
- package/dist/{chunk-4BHNP6Y2.js → chunk-ODC4PXZL.js} +2 -2
- package/dist/{chunk-SK44IFZN.js → chunk-OIDDJLS7.js} +2 -2
- package/dist/{spam-classifier-VG4ICIWS.js → chunk-SNSTJLQW.js} +4 -3
- package/dist/{chunk-JMAFXEQE.js → chunk-T4FQ6TZT.js} +6 -3
- package/dist/chunk-T4FQ6TZT.js.map +1 -0
- package/dist/{chunk-6M2IXJ3E.js → chunk-UH5YPYS4.js} +2 -2
- package/dist/{chunk-KKIKJWM2.js → chunk-VXXF4Y25.js} +7 -5
- package/dist/chunk-VXXF4Y25.js.map +1 -0
- package/dist/{chunk-GXVX4HUH.js → chunk-YRM6EED7.js} +11 -2
- package/dist/chunk-YRM6EED7.js.map +1 -0
- package/dist/{chunk-TEOENEJH.js → chunk-ZXRRKKMR.js} +2 -2
- package/dist/cli/bin.js +22 -22
- package/dist/cli/commands/audit.js +6 -6
- package/dist/{convert-to-pnpm-FZHSLUQB.js → convert-to-pnpm-UT2R6EOW.js} +2 -2
- package/dist/{digest-N232AYVG.js → digest-CLFUWW62.js} +5 -5
- package/dist/{ensure-site-3SOAWTZF.js → ensure-site-LLPOXLWP.js} +2 -2
- package/dist/{forms-notify-target-ZDQUEEEY.js → forms-notify-target-PN6P3WPW.js} +7 -2
- package/dist/forms-notify-target-PN6P3WPW.js.map +1 -0
- package/dist/{freeze-Y5OJFOST.js → freeze-Z7JZTQJS.js} +47 -7
- package/dist/freeze-Z7JZTQJS.js.map +1 -0
- package/dist/{github-signals-2BFMZLPV.js → github-signals-HTH7474O.js} +4 -4
- package/dist/{header-image-H4N54DHN.js → header-image-WMHZFU7B.js} +2 -2
- package/dist/{health-endpoint-LFVE26QS.js → health-endpoint-4E53ZMLE.js} +2 -2
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -9
- package/dist/{init-RYKQFSZI.js → init-3ZJ7Z5NG.js} +4 -4
- package/dist/{launch-EGKYS3BD.js → launch-FRCDOB56.js} +6 -6
- package/dist/{lighthouse-airtable-ZXNMDEPE.js → lighthouse-airtable-I46QCEIS.js} +3 -3
- package/dist/{onboard-RDGHVCNG.js → onboard-45NPDMMW.js} +2 -2
- package/dist/{orchestrate-NY6L4Q7Q.js → orchestrate-34JYNIXT.js} +3 -3
- package/dist/{preflight-SRDQS7Y6.js → preflight-WVD32COX.js} +4 -4
- package/dist/{renovate-dispatch-7D7FMMJP.js → renovate-dispatch-LOOCNZTW.js} +6 -3
- package/dist/renovate-dispatch-LOOCNZTW.js.map +1 -0
- package/dist/{report-BGKMN5GU.js → report-CLF2753O.js} +5 -5
- package/dist/{self-updating-WJEHPASK.js → self-updating-5TJ2NB26.js} +2 -2
- package/dist/{selftest-ZJDB4JQB.js → selftest-BTCZLCTL.js} +5 -5
- package/dist/{smoke-suite-SUEJFBYP.js → smoke-suite-GHKXMKIR.js} +2 -2
- package/dist/spam-classifier-XLBFCO7G.js +15 -0
- package/dist/{submissions-SURN7BV5.js → submissions-KUXLKCSQ.js} +4 -4
- package/dist/{svelte-codemods-IWO7X3F3.js → svelte-codemods-YTCTLK7G.js} +2 -2
- package/dist/{sync-configs-7XGHFC6P.js → sync-configs-KSSQZU5L.js} +2 -2
- package/dist/{upgrade-AM4S7JF3.js → upgrade-UDAPY3SN.js} +2 -2
- package/dist/{websites-U6642XSE.js → websites-JXG3WWAU.js} +4 -2
- package/dist/{write-audits-to-airtable-22T6D733.js → write-audits-to-airtable-7D3LV4KP.js} +4 -4
- package/dist/write-audits-to-airtable-7D3LV4KP.js.map +1 -0
- package/package.json +3 -3
- package/dist/airtable-6L3JD7FY.js +0 -9
- package/dist/chunk-GXVX4HUH.js.map +0 -1
- package/dist/chunk-JMAFXEQE.js.map +0 -1
- package/dist/chunk-KKIKJWM2.js.map +0 -1
- package/dist/forms-notify-target-ZDQUEEEY.js.map +0 -1
- package/dist/freeze-Y5OJFOST.js.map +0 -1
- package/dist/renovate-dispatch-7D7FMMJP.js.map +0 -1
- /package/dist/{airtable-6L3JD7FY.js.map → airtable-ZDHLKK5K.js.map} +0 -0
- /package/dist/{announce-KZDU65IF.js.map → announce-7QHQGJPV.js.map} +0 -0
- /package/dist/{blux-2P4FGCUL.js.map → blux-EATHOHKK.js.map} +0 -0
- /package/dist/{bump-deps-XB2LJTVN.js.map → bump-deps-ZREAMK7M.js.map} +0 -0
- /package/dist/{chunk-JR67FZVC.js.map → chunk-2BHSGLWR.js.map} +0 -0
- /package/dist/{chunk-AKRUYPLU.js.map → chunk-4PW6ISX7.js.map} +0 -0
- /package/dist/{chunk-R5KAXGSP.js.map → chunk-5E4L7WKE.js.map} +0 -0
- /package/dist/{chunk-42A4H6SF.js.map → chunk-FFI7P74K.js.map} +0 -0
- /package/dist/{chunk-3PU2QWOJ.js.map → chunk-LMCO7CYQ.js.map} +0 -0
- /package/dist/{chunk-4BHNP6Y2.js.map → chunk-ODC4PXZL.js.map} +0 -0
- /package/dist/{chunk-SK44IFZN.js.map → chunk-OIDDJLS7.js.map} +0 -0
- /package/dist/{spam-classifier-VG4ICIWS.js.map → chunk-SNSTJLQW.js.map} +0 -0
- /package/dist/{chunk-6M2IXJ3E.js.map → chunk-UH5YPYS4.js.map} +0 -0
- /package/dist/{chunk-TEOENEJH.js.map → chunk-ZXRRKKMR.js.map} +0 -0
- /package/dist/{convert-to-pnpm-FZHSLUQB.js.map → convert-to-pnpm-UT2R6EOW.js.map} +0 -0
- /package/dist/{digest-N232AYVG.js.map → digest-CLFUWW62.js.map} +0 -0
- /package/dist/{ensure-site-3SOAWTZF.js.map → ensure-site-LLPOXLWP.js.map} +0 -0
- /package/dist/{github-signals-2BFMZLPV.js.map → github-signals-HTH7474O.js.map} +0 -0
- /package/dist/{header-image-H4N54DHN.js.map → header-image-WMHZFU7B.js.map} +0 -0
- /package/dist/{health-endpoint-LFVE26QS.js.map → health-endpoint-4E53ZMLE.js.map} +0 -0
- /package/dist/{init-RYKQFSZI.js.map → init-3ZJ7Z5NG.js.map} +0 -0
- /package/dist/{launch-EGKYS3BD.js.map → launch-FRCDOB56.js.map} +0 -0
- /package/dist/{lighthouse-airtable-ZXNMDEPE.js.map → lighthouse-airtable-I46QCEIS.js.map} +0 -0
- /package/dist/{onboard-RDGHVCNG.js.map → onboard-45NPDMMW.js.map} +0 -0
- /package/dist/{orchestrate-NY6L4Q7Q.js.map → orchestrate-34JYNIXT.js.map} +0 -0
- /package/dist/{preflight-SRDQS7Y6.js.map → preflight-WVD32COX.js.map} +0 -0
- /package/dist/{report-BGKMN5GU.js.map → report-CLF2753O.js.map} +0 -0
- /package/dist/{self-updating-WJEHPASK.js.map → self-updating-5TJ2NB26.js.map} +0 -0
- /package/dist/{selftest-ZJDB4JQB.js.map → selftest-BTCZLCTL.js.map} +0 -0
- /package/dist/{smoke-suite-SUEJFBYP.js.map → smoke-suite-GHKXMKIR.js.map} +0 -0
- /package/dist/{websites-U6642XSE.js.map → spam-classifier-XLBFCO7G.js.map} +0 -0
- /package/dist/{submissions-SURN7BV5.js.map → submissions-KUXLKCSQ.js.map} +0 -0
- /package/dist/{svelte-codemods-IWO7X3F3.js.map → svelte-codemods-YTCTLK7G.js.map} +0 -0
- /package/dist/{sync-configs-7XGHFC6P.js.map → sync-configs-KSSQZU5L.js.map} +0 -0
- /package/dist/{upgrade-AM4S7JF3.js.map → upgrade-UDAPY3SN.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-22T6D733.js.map → websites-JXG3WWAU.js.map} +0 -0
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/alerts/digest-collectors.ts","../src/alerts/digest-state.ts"],"sourcesContent":["// src/alerts/digest-collectors.ts\nimport type { AttentionItem } from \"./attention.js\";\nimport { siteSlug, type WebsiteRow } from \"../reports/airtable/websites.js\";\nimport type { ReportRow } from \"../reports/airtable/reports.js\";\nimport { approveBlockers } from \"../reports/preflight.js\";\n\n/** Build the same `/s/<slug>` dashboard link the M3 ready-section uses, trailing-slash-safe.\n * An empty Name slugs to \"\" and `/s/` is a dead link — fall back to the fleet homepage,\n * exactly like the ready-section does. */\nfunction dashboardUrl(baseUrl: string, siteName: string): string {\n const root = baseUrl.replace(/\\/$/, \"\");\n const slug = siteSlug(siteName);\n return slug ? `${root}/s/${slug}` : root;\n}\n\n/**\n * A GitHub-signals sweep older than this (or never run) is no longer trustworthy:\n * a repo whose nightly probe THREW stops being re-swept, so its persisted\n * `Default Branch CI` / `Renovate Failing CIs` freeze at their last value forever\n * — a phantom 🔴 that can never clear. 3 days ≈ 3× the daily sweep interval, so a\n * single missed/flaky run doesn't drop a real signal. The CI/Renovate collectors\n * (only) skip a site whose `githubSignalsAt` is staler than this. Vuln/Lighthouse/\n * delivery signals come from other sweeps and are unaffected.\n */\nconst GITHUB_SIGNALS_STALE_DAYS = 3;\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\n\n/** True when a site's GitHub-signals sweep is too old (or never ran) to trust the\n * persisted CI/Renovate fields. A null `githubSignalsAt` (never swept) is stale —\n * there's no sweep to vouch for the values. A future timestamp (now - swept < 0)\n * is fresh. `now` is injected so the gate is testable. */\nfunction gitHubSignalsStale(swept: string | null, now: Date): boolean {\n if (swept === null) return true;\n const ageMs = now.getTime() - Date.parse(swept);\n if (!Number.isFinite(ageMs)) return true; // unparseable timestamp → don't trust it\n return ageMs > GITHUB_SIGNALS_STALE_DAYS * MS_PER_DAY;\n}\n\n/** Renovate auto-fix dispatches for one vuln episode before it's \"exhausted\" (manual fix needed). */\nconst AUTO_FIX_EXHAUSTED_CYCLES = 3;\n\n/**\n * One attention item per site carrying current critical+high vulns (medium/low omitted\n * per the locked threshold). PURE: takes already-fetched Websites rows. `metric` is the\n * critical+high count (so a rising count diffs as WORSE); `severity` is `critical` when\n * any critical exists, else `warning`. Null counts (never audited) read as 0 → skipped.\n * Once `securityAutoFixAttempts` reaches AUTO_FIX_EXHAUSTED_CYCLES the item is flagged\n * `autoFixExhausted` (forced-critical, escalated title) — Renovate tried and couldn't fix it.\n *\n * Emission is UNCONDITIONAL — every surface applies its own policy on top:\n * - digest email: includes a vuln only when `autoFixExhausted` (the operator hears\n * about a vuln only after Renovate tried and failed; before that the fleet is\n * still self-patching) — runDigest filters\n * - cockpit: non-exhausted vuln → amber Watch; exhausted → hard break (assignTier),\n * and only an exhausted vuln pierces the pre-launch mute (piercesPreLaunchMute)\n */\nexport function collectVulnAlerts(sites: WebsiteRow[], baseUrl: string): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const critical = s.securityVulnsCritical ?? 0;\n const high = s.securityVulnsHigh ?? 0;\n const metric = critical + high;\n if (metric <= 0) continue;\n const attempts = s.securityAutoFixAttempts ?? 0;\n const exhausted = attempts >= AUTO_FIX_EXHAUSTED_CYCLES;\n const noun = metric === 1 ? \"vuln\" : \"vulns\";\n items.push({\n key: `vuln:${s.id}`,\n kind: \"vuln\",\n siteName: s.name,\n title: exhausted\n ? `${metric} critical/high ${noun} — auto-fix failed (${attempts}×)`\n : `${metric} critical/high ${noun}`,\n url: dashboardUrl(baseUrl, s.name),\n severity: exhausted || critical > 0 ? \"critical\" : \"warning\",\n metric,\n ...(exhausted ? { autoFixExhausted: true } : {}),\n });\n }\n return items;\n}\n\n/** Absolute floor below which a Lighthouse category is \"Needs attention\" (Tucker's call). */\nconst LIGHTHOUSE_FLOOR = 75;\n\n/** The four Lighthouse categories, each mapped to its WebsiteRow score field, URL slug,\n * and the human label rendered in the digest title. Order is the operator's reading order. */\nconst LIGHTHOUSE_CATEGORIES: ReadonlyArray<{\n field: \"pScore\" | \"rScore\" | \"bpScore\" | \"seoScore\";\n slug: string;\n label: string;\n}> = [\n { field: \"pScore\", slug: \"performance\", label: \"Performance\" },\n { field: \"rScore\", slug: \"accessibility\", label: \"Accessibility\" },\n { field: \"bpScore\", slug: \"best-practices\", label: \"Best Practices\" },\n { field: \"seoScore\", slug: \"seo\", label: \"SEO\" },\n];\n\n/**\n * One attention item per Lighthouse category below the absolute floor (75) for each site.\n * PURE: takes already-fetched Websites rows. Categories are Performance/Accessibility/\n * Best-Practices/SEO. A null score (never audited) or a score >= 75 is skipped. The\n * `metric` is the DEFICIT (`100 - score`): a lower score → higher metric, so a category\n * that drops further diffs as WORSE and one that first crosses below 75 diffs as NEW —\n * which is how `diffAttention`'s \"WORSE on increase\" rule reads an inverted score. `key`\n * is `lighthouse:<siteId>:<categorySlug>`, so the four categories stay distinct per site.\n */\nexport function collectLighthouseAlerts(sites: WebsiteRow[], baseUrl: string): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n for (const cat of LIGHTHOUSE_CATEGORIES) {\n const score = s[cat.field];\n if (score === null || score >= LIGHTHOUSE_FLOOR) continue;\n items.push({\n key: `lighthouse:${s.id}:${cat.slug}`,\n kind: \"lighthouse\",\n siteName: s.name,\n title: `Lighthouse ${cat.label} ${score} (below ${LIGHTHOUSE_FLOOR})`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 100 - score,\n });\n }\n }\n return items;\n}\n\n/**\n * One attention item per report whose `deliveryStatus` is a failure (`bounced` or\n * `complained` — `delivered`/`pending` are ignored). PURE: takes already-fetched\n * Reports rows + a record-id→site map. A complaint ranks above a bounce (locked\n * threshold), so `severity` is `critical` for complained / `warning` for bounced.\n * `metric` is 1 (a binary event). Orphans (siteId not in the map) are skipped, as\n * the M3 ready-section does, so the digest never renders a broken link. The diff\n * key is the report RECORD id, so two failures on one site stay distinct.\n */\n/**\n * One attention item per unsent draft whose send is ALREADY known to fail\n * (approveBlockers: recipients / header image / report scores). An APPROVED one\n * is critical — the next 09:23 UTC run will go red on it; a pending one is a\n * warning — approving it just schedules that failure. Keyed `preflight:<reportId>`.\n * PURE; same predicate the approve gate and the dashboard chip use.\n */\nexport function collectPreflightBlocked(\n reports: ReportRow[],\n sitesById: Map<string, WebsiteRow>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const r of reports) {\n if (!r.draftReady || r.sentAt !== null) continue;\n // The approved state rides the KEY so a pending→approved escalation with\n // unchanged blockers re-news as a fresh critical instead of diffing\n // \"standing\" against its old warning self.\n const state = r.approvedToSend ? \"approved\" : \"pending\";\n const site = sitesById.get(r.siteId);\n if (!site) {\n // A dangling/empty Site link is itself a send blocker (sendApprovedReports\n // fails with \"Site row not found\") — surface it; the empty-slug\n // dashboardUrl fallback yields the fleet root, not a broken /s/ link.\n items.push({\n key: `preflight:${r.id}:${state}`,\n kind: \"preflight\",\n siteName: \"(unlinked site)\",\n title: r.approvedToSend\n ? `Approved ${r.reportType} will fail at send — site-not-found`\n : `${r.reportType} draft can't be approved — site-not-found`,\n url: dashboardUrl(baseUrl, \"\"),\n severity: r.approvedToSend ? \"critical\" : \"warning\",\n metric: 1,\n });\n continue;\n }\n const fails = approveBlockers(site, r).filter((f) => f.level === \"fail\");\n if (fails.length === 0) continue;\n const first = fails[0]!;\n const more = fails.length > 1 ? ` (+${fails.length - 1} more)` : \"\";\n items.push({\n key: `preflight:${r.id}:${state}`,\n kind: \"preflight\",\n siteName: site.name,\n title: r.approvedToSend\n ? `Approved ${r.reportType} will fail at send — ${first.check}${more}`\n : `${r.reportType} draft can't be approved — ${first.check}${more}`,\n url: dashboardUrl(baseUrl, site.name),\n severity: r.approvedToSend ? \"critical\" : \"warning\",\n metric: fails.length,\n });\n }\n return items;\n}\n\nexport function collectDeliveryFailures(\n reports: ReportRow[],\n sitesById: Map<string, WebsiteRow>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const r of reports) {\n if (r.deliveryStatus !== \"bounced\" && r.deliveryStatus !== \"complained\") continue;\n const site = sitesById.get(r.siteId);\n if (!site) continue; // orphan → skip rather than render a broken link\n const complained = r.deliveryStatus === \"complained\";\n items.push({\n key: `delivery:${r.id}`,\n kind: \"delivery\",\n siteName: site.name,\n title: complained ? \"Spam complaint on a sent report\" : \"A sent report bounced\",\n url: dashboardUrl(baseUrl, site.name),\n severity: complained ? \"critical\" : \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/**\n * One attention item per site carrying failing Renovate PRs, read from the\n * slice-2a-persisted `renovateFailingCis` field (the nightly github-signals sweep\n * populates it). PURE. Keyed `renovate:<siteId>` so the digest and the cockpit\n * share one diff key. `metric` is the count (a rising count diffs WORSE); severity\n * `warning`. Null/0 → skipped. A site whose `githubSignalsAt` is >3 days stale (or\n * null) is ALSO skipped — a repo that stopped being swept must not show a phantom\n * count forever (`now` injected, defaults to wall-clock).\n */\nexport function collectRenovateAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (gitHubSignalsStale(s.githubSignalsAt, now)) continue;\n const n = s.renovateFailingCis ?? 0;\n if (n <= 0) continue;\n items.push({\n key: `renovate:${s.id}`,\n kind: \"renovate\",\n siteName: s.name,\n title: `${n} Renovate ${n === 1 ? \"PR\" : \"PRs\"} failing CI`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: n,\n });\n }\n return items;\n}\n\n/**\n * One attention item per site whose persisted default-branch CI rollup is\n * `failing` (slice 2a). PURE. `metric` 1 (binary); severity `warning`. Any other\n * state (passing/pending/none) or null is skipped. A site whose `githubSignalsAt`\n * is >3 days stale (or null) is ALSO skipped — a repo that stopped being swept must\n * not show a phantom 🔴 forever (`now` injected, defaults to wall-clock).\n */\nexport function collectCiAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (gitHubSignalsStale(s.githubSignalsAt, now)) continue;\n if (s.defaultBranchCi !== \"failing\") continue;\n items.push({\n key: `ci:${s.id}`,\n kind: \"ci\",\n siteName: s.name,\n title: \"Default-branch CI failing\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/**\n * A soft-fail older than this is no longer a trustworthy CURRENT signal. Drafting\n * self-clears `analyticsSoftFailAt` on the next clean enrichment, so a non-null\n * value normally means \"errored on the most recent draft and hasn't recovered\" —\n * but a site that stopped being drafted (freq→None, deprecated) would otherwise\n * show a phantom forever. 45 days covers a monthly cadence + margin; older drops.\n */\nconst ANALYTICS_SOFT_FAIL_STALE_DAYS = 45;\n\n/**\n * One attention item per site whose last draft's GA/Search enrichment ERRORED, read\n * from the `analyticsSoftFailAt` timestamp (drafting sets it on a soft-fail, clears\n * it on a clean enrichment). PURE. Keyed `analytics:<siteId>` so the digest and the\n * cockpit share one diff key; `metric` 1 (binary), severity `warning`. A null\n * timestamp (clean, or the operator-added `Analytics soft-fail at` column absent) is\n * skipped, as is one staler than {@link ANALYTICS_SOFT_FAIL_STALE_DAYS}. On a\n * FLEET-WIDE subject outage many sites surface this at once — that breadth IS the\n * signal here; the report cron additionally emails a single concise fleet-wide alert\n * (see `assessAnalyticsAlert`). `now` injected, defaults to wall-clock.\n */\n/** A function-health sweep older than this can't confirm the CURRENT widget state.\n * The sweep is nightly, so 3 days (mirrors GITHUB_SIGNALS_STALE_DAYS) tolerates a\n * weekend of runner flakes without letting a months-old verdict drive an alarm. */\nconst TURNSTILE_WIDGET_STALE_DAYS = 3;\n\n/**\n * One CRITICAL attention item per site with `Require Turnstile` ON whose deployed\n * `/health` reports the Turnstile widget NOT configured (`turnstileWidget === \"fail\"`,\n * fresh per {@link TURNSTILE_WIDGET_STALE_DAYS}). That combination silently buckets\n * 100% of the site's real leads (token-less submissions escalate to spam_auto with\n * notify skipped) and the form-e2e probe cannot see it — testMode bypasses the gate —\n * so the operator's inbox just goes quiet. PURE. Keyed `turnstile:<siteId>`. Because\n * this is an AttentionItem it rides assignTier's items short-circuit, which sits ABOVE\n * the accepted-watch mute loop — an accept key can never silence it. A null verdict or\n * a stale sweep is NOT alarmed here; assignTier raises those as an acceptable\n * \"can't verify\" watch instead. `now` injected, defaults to wall-clock.\n */\nexport function collectTurnstileGuardrailAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (!s.requireTurnstile || s.turnstileWidget !== \"fail\") continue;\n const at = s.functionHealthCheckedAt;\n if (at !== null) {\n const ageMs = now.getTime() - Date.parse(at);\n // Parseable and beyond the window → stale, downgraded to assignTier's watch.\n // Unparseable (NaN) keeps the item — never silently drop a real failure.\n if (Number.isFinite(ageMs) && ageMs > TURNSTILE_WIDGET_STALE_DAYS * MS_PER_DAY) continue;\n }\n items.push({\n key: `turnstile:${s.id}`,\n kind: \"turnstile\",\n siteName: s.name,\n title:\n \"Require Turnstile is ON but /health reports no widget — real leads are being auto-bucketed\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"critical\",\n metric: 1,\n });\n }\n return items;\n}\n\n/** Bounced lead notifications inside the window before a site alarms. One bounce can\n * be a transient greylist/full-mailbox blip; a repeat says the point-of-contact\n * address itself is bad (the Espada mode: apm@ bounced 4 of 8 with nothing alarming). */\nexport const NOTIFY_BOUNCE_THRESHOLD = 2;\n\n/** Window (days) the bounce count covers. Two weeks: long enough that a low-volume\n * site's second bounce still lands in the same window as its first, short enough\n * that a long-fixed address doesn't alarm forever (bounced rows never self-clear —\n * the alarm ages out as bounced submissions leave the window). */\nexport const NOTIFY_BOUNCE_WINDOW_DAYS = 14;\n\n/**\n * One CRITICAL attention item per site with >= {@link NOTIFY_BOUNCE_THRESHOLD} bounced\n * lead notifications in the last {@link NOTIFY_BOUNCE_WINDOW_DAYS} days — leads are\n * being captured but silently never reach the client, which is worse than no form at\n * all (notifyStatus \"sent\" only means Resend accepted the email; the resend-webhook\n * flips it to \"bounced\" when Resend later reports the failure, 2026-07-16). PURE:\n * takes the pre-fetched per-site counts (`countNotifyBouncedBySite`, keyed by the\n * Websites record id). Keyed `notify-bounce:<siteId>` so the digest and the cockpit\n * share one diff key; `metric` is the bounce count (a rising count diffs WORSE).\n * A site absent from the map (or below threshold) is skipped, as is a count for a\n * site id not in `sites` (orphan → never render a broken link).\n */\nexport function collectNotifyBounceAlerts(\n sites: WebsiteRow[],\n bouncedBySite: ReadonlyMap<string, number>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const n = bouncedBySite.get(s.id) ?? 0;\n if (n < NOTIFY_BOUNCE_THRESHOLD) continue;\n items.push({\n key: `notify-bounce:${s.id}`,\n kind: \"notify-bounce\",\n siteName: s.name,\n title: `${n} lead notifications bounced (${NOTIFY_BOUNCE_WINDOW_DAYS}d) — check the point-of-contact address`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"critical\",\n metric: n,\n });\n }\n return items;\n}\n\nexport function collectAnalyticsFailures(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const at = s.analyticsSoftFailAt;\n if (at === null) continue;\n const ageMs = now.getTime() - Date.parse(at);\n // Unparseable timestamp (NaN) → keep the item (don't silently drop a real failure\n // on a parse glitch); only a parseable, beyond-window age is skipped as stale.\n if (Number.isFinite(ageMs) && ageMs > ANALYTICS_SOFT_FAIL_STALE_DAYS * MS_PER_DAY) continue;\n items.push({\n key: `analytics:${s.id}`,\n kind: \"analytics\",\n siteName: s.name,\n title: \"GA/Search enrichment failing (analytics blank)\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n","// src/alerts/digest-state.ts\nimport type { FieldSet } from \"airtable\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport type { AttentionItem } from \"./attention.js\";\n\n/**\n * The persisted prior-run snapshot: stable item `key` → its last metric + the\n * date it was FIRST flagged. Lives as JSON in the single \"Digest State\" Airtable\n * row (the IO that loads/stores it — readDigestState/writeDigestState — is added\n * in component 2). `next` from diffAttention is what gets written back.\n * `exhausted` is carried only for vuln keys whose Renovate auto-fix has been\n * exhausted (absent = not exhausted; older snapshots without the field read the\n * same way).\n */\nexport type DigestSnapshot = Record<\n string,\n { metric: number; firstFlaggedAt: string; exhausted?: boolean }\n>;\n\n/**\n * PURE diff — the testable core of the hybrid \"snapshot now, mark what's new\".\n * For each current item vs the prior snapshot:\n * - key absent from prior → NEW (firstFlaggedAt = today)\n * - present and metric > prior.metric → WORSE (keep the original firstFlaggedAt)\n * - present and autoFixExhausted flipped on → WORSE (the count didn't move, but\n * \"Renovate gave up\" is an escalation — without this the digest's first mention\n * of a vuln, which is gated on exhaustion, would arrive unbadged)\n * - otherwise (equal or dropped) → STANDING (keep the original firstFlaggedAt)\n * `next` contains EXACTLY the current items' keys: resolved keys drop out, so a\n * fixed-then-recurring problem re-news correctly. Neither input is mutated.\n */\nexport function diffAttention(\n items: AttentionItem[],\n prior: DigestSnapshot,\n today: string,\n): { tagged: AttentionItem[]; next: DigestSnapshot } {\n const tagged: AttentionItem[] = [];\n const next: DigestSnapshot = {};\n for (const it of items) {\n const was = prior[it.key];\n const exhaustedFlip = it.autoFixExhausted === true && was?.exhausted !== true;\n let status: AttentionItem[\"status\"];\n let firstFlaggedAt: string;\n if (!was) {\n status = \"new\";\n firstFlaggedAt = today;\n } else if (it.metric > was.metric || exhaustedFlip) {\n status = \"worse\";\n firstFlaggedAt = was.firstFlaggedAt;\n } else {\n status = \"standing\";\n firstFlaggedAt = was.firstFlaggedAt;\n }\n tagged.push({ ...it, status });\n next[it.key] = {\n metric: it.metric,\n firstFlaggedAt,\n ...(it.autoFixExhausted === true ? { exhausted: true } : {}),\n };\n }\n return { tagged, next };\n}\n\n/** The single-row Airtable table that persists the prior digest snapshot. */\nexport const DIGEST_STATE_TABLE = \"Digest State\";\n\n/**\n * Read the persisted prior snapshot from the \"Digest State\" singleton.\n *\n * Reads the FIRST row of an unfiltered select (the table holds exactly one row;\n * the test fake does not evaluate filterByFormula, so we never rely on one). A\n * read miss (no row) OR a parse error (malformed Snapshot JSON) collapses to `{}`\n * — every key then reads as NEW once, which is safe degradation (never crashes\n * the digest).\n */\nexport async function readDigestState(base: AirtableBase): Promise<DigestSnapshot> {\n const rows: { id: string; fields: Record<string, unknown> }[] = [];\n await base(DIGEST_STATE_TABLE)\n .select({ maxRecords: 1, pageSize: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push({ id: rec.id, fields: rec.fields });\n fetchNextPage();\n });\n const first = rows[0];\n if (!first) return {};\n const raw = first.fields[\"Snapshot\"];\n if (typeof raw !== \"string\") return {};\n try {\n return JSON.parse(raw) as DigestSnapshot;\n } catch {\n return {};\n }\n}\n\n/**\n * Persist the next snapshot to the \"Digest State\" singleton: get-or-create the\n * one row. If a row exists, UPDATE it (keyed by its record id); otherwise CREATE\n * one. `Snapshot` = JSON.stringify(snap); `Updated At` = the injected ISO\n * timestamp (or now). A caller that catches+logs a write failure keeps the\n * already-sent digest unaffected (next run re-news at worst).\n */\nexport async function writeDigestState(\n base: AirtableBase,\n snap: DigestSnapshot,\n updatedAt: string = new Date().toISOString(),\n): Promise<void> {\n const rows: { id: string }[] = [];\n await base(DIGEST_STATE_TABLE)\n .select({ maxRecords: 1, pageSize: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push({ id: rec.id });\n fetchNextPage();\n });\n const fields: FieldSet = {\n Snapshot: JSON.stringify(snap),\n \"Updated At\": updatedAt,\n };\n const existing = rows[0];\n if (existing) {\n await base(DIGEST_STATE_TABLE).update([{ id: existing.id, fields }]);\n } else {\n await base(DIGEST_STATE_TABLE).create([{ fields }]);\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/forms/notify.ts","../src/recipes/forms-notify-target.ts","../src/cli/commands/forms-notify-target.ts"],"sourcesContent":["import type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport type { SubmissionRow, NotifyStatus } from \"../reports/submission-row.js\";\nimport type { ResendSendInput } from \"../reports/send/resend.js\";\nimport { escapeHtml } from \"../util/html.js\";\n\nconst FORMS_FROM = \"forms@reddoorla.com\";\nconst FALLBACK_REPLY_TO = \"info@reddoorla.com\";\n\n// Single-operator fleet fallback when OPERATOR_EMAIL is unset. Deliberately the\n// monitored personal inbox (not the digest's info@ alias) — a missed pre-launch\n// LEAD is higher-stakes than a missed digest, so it should land somewhere watched.\nconst OPERATOR_FALLBACK = \"tucker@reddoorla.com\";\n\nfunction operatorEmail(): string {\n return process.env.OPERATOR_EMAIL?.trim() || OPERATOR_FALLBACK;\n}\n\n/** Strip characters that would break an RFC 5322 display name. */\nfunction displayName(raw: string): string {\n return raw.replace(/[\"\\r\\n]/g, \"\").trim() || \"Reddoor\";\n}\n\nfunction pocAddress(site: WebsiteRow): string | null {\n return site.pointOfContact ?? site.reportRecipientsTo ?? null;\n}\n\n/** Coerce a string|string[] recipient config into a clean, de-duped address list. */\nfunction normalizeRecipients(v: string | string[] | undefined): string[] {\n const arr = Array.isArray(v) ? v : v ? [v] : [];\n const seen = new Set<string>();\n const out: string[] = [];\n for (const raw of arr) {\n if (typeof raw !== \"string\") continue;\n const t = raw.trim();\n if (t && !seen.has(t)) {\n seen.add(t);\n out.push(t);\n }\n }\n return out;\n}\n\nexport type Recipients = { to: string[]; cc: string[] };\n\n/**\n * Where a submission notification goes.\n * - Pre-launch (status !== \"maintenance\"): the operator only — no routing, no CC.\n * Preserves the verify guard (flip a site to \"launch period\" to route tests to\n * yourself).\n * - Maintenance + a `Notify Routing` config: address by the routing field's value\n * (`extraFields[field]`) → its matched route, else the config `default`; CC from\n * the config. If nothing resolves, fall through to the single POC.\n * - Maintenance, no routing: the single site POC (pointOfContact ?? reportRecipientsTo).\n * Returns null only when nothing resolves — the lead is still persisted; notify skips.\n */\nexport function resolveRecipients(site: WebsiteRow, submission: SubmissionRow): Recipients | null {\n if (site.status !== \"maintenance\") {\n return { to: [operatorEmail()], cc: [] };\n }\n const routing = site.notifyRouting;\n if (routing) {\n const value = parseExtraFields(submission.extraFields)[routing.field];\n const match = typeof value === \"string\" ? routing.routes[value] : undefined;\n const to = normalizeRecipients(match ?? routing.default);\n if (to.length > 0) return { to, cc: normalizeRecipients(routing.cc) };\n // routing matched nothing → fall through to the POC below\n }\n const poc = pocAddress(site);\n return poc ? { to: [poc], cc: [] } : null;\n}\n\n/** Who a submission to this site would notify, answered WITHOUT sending one. */\nexport type NotifyTarget = {\n /** `client` means a real point of contact receives it. `operator` means the\n * pre-launch guard is holding and only you do. `nobody` means nothing\n * resolves — the lead is still stored, but no notification goes out. */\n audience: \"client\" | \"operator\" | \"nobody\";\n /** Every address a submission could reach, across ALL routing branches. */\n to: string[];\n cc: string[];\n /** Plain-language reason, naming the field that decided it. */\n reason: string;\n};\n\n/** A submission that exists only to ask resolveRecipients a question. */\nfunction probeSubmission(extraFields: string | null): SubmissionRow {\n return {\n id: \"probe\",\n submissionId: null,\n siteId: \"probe\",\n formType: \"contact\" as SubmissionRow[\"formType\"],\n name: \"probe\",\n email: \"probe@example.com\",\n phone: null,\n message: null,\n extraFields,\n sourceUrl: null,\n utm: null,\n submittedAt: null,\n status: \"new\" as SubmissionRow[\"status\"],\n notifyStatus: \"pending\" as NotifyStatus,\n resendMessageId: null,\n };\n}\n\n/**\n * Resolve who a form submission would notify, for a site, right now.\n *\n * This exists because the pre-launch guard had no feedback. Its whole state is\n * one Airtable `Status` cell nobody is looking at while testing, the site\n * reports nothing about it, and the only way to learn the answer was to submit\n * a form and read the `resend_message_id` afterwards. On 2026-08-03 an intended\n * flip never landed and a real client received a test lead — an email cannot be\n * recalled. A guard whose state you cannot see is a guard you will eventually\n * assume is on.\n *\n * Every address here comes back out of `resolveRecipients` itself rather than\n * from a second reading of the same rules — for a routed site each branch is\n * probed with a synthetic submission and the results unioned. A copy of the\n * routing logic that drifted would be worse than no answer at all, because it\n * would be a confident wrong one.\n */\nexport function describeNotifyTarget(site: WebsiteRow): NotifyTarget {\n if (site.status !== \"maintenance\") {\n const guarded = resolveRecipients(site, probeSubmission(null));\n return {\n audience: \"operator\",\n to: guarded?.to ?? [],\n cc: [],\n reason: `Status is ${site.status === null ? \"blank\" : `\"${site.status}\"`}, not \"maintenance\" — the pre-launch guard routes every notification to the operator.`,\n };\n }\n\n const routing = site.notifyRouting;\n if (!routing) {\n const direct = resolveRecipients(site, probeSubmission(null));\n if (!direct || direct.to.length === 0) {\n return {\n audience: \"nobody\",\n to: [],\n cc: [],\n reason: `Status is \"maintenance\" but the site has no Point of Contact and no Notify Routing — the lead is stored and no notification is sent.`,\n };\n }\n return {\n audience: \"client\",\n to: direct.to,\n cc: direct.cc,\n reason: `Status is \"maintenance\" — notifications go to the site's Point of Contact.`,\n };\n }\n\n // Probe every declared route value, plus one value that matches none so the\n // `default` (and the POC fall-through behind it) is covered too.\n const to = new Set<string>();\n const cc = new Set<string>();\n const values = [...Object.keys(routing.routes), \"\u0000no-such-route\"];\n for (const value of values) {\n const r = resolveRecipients(site, probeSubmission(JSON.stringify({ [routing.field]: value })));\n r?.to.forEach((a) => to.add(a));\n r?.cc.forEach((a) => cc.add(a));\n }\n if (to.size === 0) {\n return {\n audience: \"nobody\",\n to: [],\n cc: [],\n reason: `Status is \"maintenance\" with Notify Routing on \"${routing.field}\", but no route, default or Point of Contact resolves to an address.`,\n };\n }\n return {\n audience: \"client\",\n to: [...to],\n cc: [...cc],\n reason: `Status is \"maintenance\" with Notify Routing on \"${routing.field}\" — the recipient depends on that field's value; every address it could reach is listed.`,\n };\n}\n\n/** Humanize an extraFields key for display: \"appointment_date\" → \"Appointment date\". */\nfunction humanizeKey(k: string): string {\n const spaced = k.replace(/[_-]+/g, \" \").trim();\n return spaced ? spaced.charAt(0).toUpperCase() + spaced.slice(1) : k;\n}\n\nfunction formatValue(v: unknown): string {\n if (v === null || v === undefined) return \"—\";\n if (typeof v === \"string\") return v;\n if (typeof v === \"number\" || typeof v === \"boolean\") return String(v);\n return JSON.stringify(v);\n}\n\n/** Parse the stored `extraFields` JSON into a plain object — bad JSON or a\n * non-object yields {} (never throws). Shared by the email renderer and routing. */\nfunction parseExtraFields(raw: string | null): Record<string, unknown> {\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n } catch {\n /* malformed JSON → no fields */\n }\n return {};\n}\n\nfunction extraFieldRows(raw: string | null): Array<[string, string]> {\n return Object.entries(parseExtraFields(raw))\n .filter(([, v]) => !(typeof v === \"string\" && v.trim() === \"\"))\n .map(([k, v]) => [humanizeKey(k), formatValue(v)] as [string, string]);\n}\n\nfunction fieldsTable(submission: SubmissionRow): string {\n const rows: Array<[string, string]> = [\n [\"Form\", submission.formType],\n [\"Name\", submission.name || \"—\"],\n [\"Email\", submission.email || \"—\"],\n ];\n if (submission.phone) rows.push([\"Phone\", submission.phone]);\n // Site-specific context (the artwork an inquiry is about, the event an rsvp is\n // for, etc.) lives in extraFields — surface it so the recipient sees what the\n // submitter was looking at, not just their name and email.\n rows.push(...extraFieldRows(submission.extraFields));\n if (submission.sourceUrl) rows.push([\"Page\", submission.sourceUrl]);\n if (submission.utm) rows.push([\"UTM\", submission.utm]);\n const body = rows\n .map(([k, v]) => `<tr><td><strong>${escapeHtml(k)}</strong></td><td>${escapeHtml(v)}</td></tr>`)\n .join(\"\");\n const message = submission.message\n ? `<p style=\"white-space:pre-wrap\">${escapeHtml(submission.message)}</p>`\n : \"\";\n return `<table>${body}</table>${message}`;\n}\n\n/** POC notification — the primary email; null when the site has no contact address. */\nexport function buildPocNotification(\n site: WebsiteRow,\n submission: SubmissionRow,\n): ResendSendInput | null {\n if (submission.status === \"spam_auto\" || submission.status === \"spam\") return null;\n const recipients = resolveRecipients(site, submission);\n if (!recipients || recipients.to.length === 0) return null;\n const input: ResendSendInput = {\n from: `${displayName(site.name)} Forms <${FORMS_FROM}>`,\n to: recipients.to,\n subject: `New ${submission.formType} from ${site.name}`,\n html: `<h2>New ${escapeHtml(submission.formType)} submission — ${escapeHtml(\n site.name,\n )}</h2>${fieldsTable(submission)}`,\n };\n if (recipients.cc.length > 0) input.cc = recipients.cc;\n // Reply straight to the lead.\n if (submission.email) input.replyTo = submission.email;\n return input;\n}\n\n/** Autoresponder to the submitter — null when there's no submitter email. */\nexport function buildAutoresponder(\n site: WebsiteRow,\n submission: SubmissionRow,\n): ResendSendInput | null {\n if (submission.status === \"spam_auto\" || submission.status === \"spam\") return null;\n if (!submission.email) return null;\n const intro = site.copyIntro ?? `Thanks for reaching out to ${site.name}.`;\n const contact = site.copyContact ?? \"We've received your message and will be in touch soon.\";\n const footer = site.copyFooter ?? site.name;\n return {\n from: `${displayName(site.name)} <${FORMS_FROM}>`,\n to: [submission.email],\n replyTo: resolveRecipients(site, submission)?.to[0] ?? FALLBACK_REPLY_TO,\n subject: \"We got your message\",\n html: `<p>${escapeHtml(intro)}</p><p>${escapeHtml(contact)}</p><p>${escapeHtml(footer)}</p>`,\n };\n}\n\nexport type NotifyDeps = {\n send: (input: ResendSendInput) => Promise<{ messageId: string }>;\n};\n\nexport type NotifyOutcome = { status: NotifyStatus; messageId: string | null };\n\n/**\n * Send the POC notification (primary — drives notifyStatus) then the submitter\n * autoresponder (best-effort — logged, never changes the outcome). The submission\n * is already persisted before this runs, so a Resend outage degrades to\n * notifyStatus=\"failed\", never a lost lead.\n */\nexport async function notifySubmission(\n deps: NotifyDeps,\n site: WebsiteRow,\n submission: SubmissionRow,\n): Promise<NotifyOutcome> {\n const poc = buildPocNotification(site, submission);\n let outcome: NotifyOutcome;\n if (!poc) {\n outcome = { status: \"skipped\", messageId: null };\n } else {\n try {\n const { messageId } = await deps.send(poc);\n outcome = { status: \"sent\", messageId };\n } catch (err) {\n console.error(`[submissions] POC notification failed: ${String(err)}`);\n outcome = { status: \"failed\", messageId: null };\n }\n }\n const auto = buildAutoresponder(site, submission);\n if (auto) {\n try {\n await deps.send(auto);\n } catch (err) {\n console.error(`[submissions] autoresponder failed: ${String(err)}`);\n }\n }\n return outcome;\n}\n\n/**\n * Build the ingest `notify` dependency from a Resend send fn — or `null` when the\n * Resend client couldn't even be constructed (e.g. `RESEND_API_KEY` unset). A null\n * send marks the notification `failed` WITHOUT attempting it, so a Resend\n * misconfiguration degrades to a captured-but-unemailed lead rather than aborting\n * ingest and losing it. Mirrors the in-flight failure isolation in notifySubmission.\n */\nexport function makeNotify(\n send: NotifyDeps[\"send\"] | null,\n): (site: WebsiteRow, submission: SubmissionRow) => Promise<NotifyOutcome> {\n return (site, submission) =>\n send\n ? notifySubmission({ send }, site, submission)\n : Promise.resolve({ status: \"failed\", messageId: null });\n}\n","import { openBase, readAirtableConfig } from \"../reports/airtable/client.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport {\n listWebsites,\n siteSlug,\n updateSiteField,\n type Status,\n type WebsiteRow,\n} from \"../reports/airtable/websites.js\";\nimport { describeNotifyTarget, type NotifyTarget } from \"../forms/notify.js\";\n\n/** The Airtable column the pre-launch guard actually lives in. */\nexport const STATUS_COLUMN = \"Status\";\n\n/** The two ends of the verify flip. Deliberately the ONLY transition this\n * performs: a site in any other status is already guarded (or is deliberately\n * something else, like \"hosting\"), and silently rewriting that would be the\n * same class of unseen change as the incident. */\nexport const LIVE_STATUS: Status = \"maintenance\";\nexport const VERIFY_STATUS: Status = \"launch period\";\n\nexport type FormsNotifyTargetDeps = {\n base?: AirtableBase;\n /** Site slug or the Airtable Websites NAME (both accepted). */\n site: string;\n /** `on` routes notifications to the operator; `off` restores. Omit to read. */\n set?: \"on\" | \"off\";\n /** Status to restore with `--set off`. Required, never inferred. */\n restore?: string;\n};\n\nexport type FormsNotifyTargetResult = {\n site: string;\n status: Status | null;\n target: NotifyTarget;\n /** Present only when a flip was attempted. `confirmed` is the whole point:\n * it comes from RE-READING the row, not from the write call returning. */\n flip?: { from: Status | null; to: Status; confirmed: boolean };\n};\n\nfunction findSite(rows: WebsiteRow[], site: string): WebsiteRow | undefined {\n const wanted = site.trim().toLowerCase();\n return rows.find(\n (r) => siteSlug(r.name) === siteSlug(site) || r.name.trim().toLowerCase() === wanted,\n );\n}\n\n/**\n * Answer \"who would a form submission on this site email?\" — and optionally\n * flip the pre-launch guard, confirming the flip by reading it back.\n *\n * The guard is a single Airtable `Status` cell. Nothing between \"I intended to\n * flip it\" and \"the client received a test lead\" reported the current state, so\n * on 2026-08-03 a flip that never landed sent a real client a test submission.\n * The fix is not a better intention, it is feedback: this reads the row back\n * after every write and refuses to call the flip confirmed on anything less.\n */\nexport async function formsNotifyTarget(\n deps: FormsNotifyTargetDeps,\n): Promise<FormsNotifyTargetResult> {\n const base = deps.base ?? openBase(readAirtableConfig());\n const rows = await listWebsites(base);\n const row = findSite(rows, deps.site);\n if (!row) {\n // The Websites NAME is not the repo slug (\"Sonder\", not \"gallerysonder\"),\n // and that mismatch has cost time before — so name the near misses rather\n // than making the operator go read Airtable to find the spelling.\n const needle = siteSlug(deps.site);\n const near = rows\n .map((r) => r.name)\n .filter((n) => siteSlug(n).includes(needle) || needle.includes(siteSlug(n)));\n throw Object.assign(\n new Error(\n `No Websites row matches '${deps.site}'. The Websites NAME is not always the repo ` +\n `slug (Sonder, not gallerysonder).` +\n (near.length > 0 ? ` Did you mean: ${near.join(\", \")}?` : \"\"),\n ),\n { exitCode: 2 },\n );\n }\n\n if (!deps.set) {\n return { site: row.name, status: row.status, target: describeNotifyTarget(row) };\n }\n\n const to = deps.set === \"on\" ? VERIFY_STATUS : (deps.restore?.trim() as Status | undefined);\n if (deps.set === \"off\" && !to) {\n throw Object.assign(\n new Error(\n `--set off needs --restore <status>: the status to return to is never inferred. ` +\n `Guessing \"${LIVE_STATUS}\" for a site that was \"hosting\" or \"legacy\" would start ` +\n `sending real client notifications — the inverse of the failure this command exists ` +\n `to prevent.`,\n ),\n { exitCode: 2 },\n );\n }\n // Only ever flip a LIVE site into verify mode. A site already outside\n // \"maintenance\" is guarded already, and rewriting its status would destroy a\n // real value nobody asked us to touch.\n if (deps.set === \"on\" && row.status !== LIVE_STATUS) {\n throw Object.assign(\n new Error(\n `${row.name} is \"${row.status ?? \"blank\"}\", not \"${LIVE_STATUS}\" — notifications ` +\n `already go to the operator only, so there is nothing to flip. Leaving the status ` +\n `untouched.`,\n ),\n { exitCode: 2 },\n );\n }\n\n await updateSiteField(base, row.id, STATUS_COLUMN, to!);\n\n // Read it back. The write returning is NOT evidence the field changed.\n const after = findSite(await listWebsites(base), deps.site);\n if (!after) {\n throw Object.assign(new Error(`${row.name} vanished from Websites during the flip.`), {\n exitCode: 1,\n });\n }\n return {\n site: after.name,\n status: after.status,\n target: describeNotifyTarget(after),\n flip: { from: row.status, to: to!, confirmed: after.status === to },\n };\n}\n","import {\n formsNotifyTarget,\n LIVE_STATUS,\n VERIFY_STATUS,\n type FormsNotifyTargetResult,\n} from \"../../recipes/forms-notify-target.js\";\n\nexport type FormsNotifyTargetCommandOptions = {\n set?: string;\n restore?: string;\n cwd?: string;\n};\n\nconst AUDIENCE_LABEL = {\n client: \"THE CLIENT\",\n operator: \"OPERATOR ONLY\",\n nobody: \"NOBODY\",\n} as const;\n\n/** The line that decides whether it is safe to test-submit. Deliberately the\n * loudest thing on screen: the incident happened because the answer was\n * invisible, not because it was subtle. */\nexport function formatNotifyTarget(r: FormsNotifyTargetResult): string {\n const lines: string[] = [];\n if (r.flip) {\n const arrow = `${r.flip.from ?? \"blank\"} → ${r.flip.to}`;\n lines.push(\n r.flip.confirmed\n ? `${r.site} Status: ${arrow} ✓ confirmed by read-back`\n : `${r.site} Status: ${arrow} ✗ NOT CONFIRMED — the row still reads \"${r.status ?? \"blank\"}\". Nothing was verified; do not test-submit.`,\n );\n } else {\n lines.push(`${r.site} Status: ${r.status ?? \"blank\"}`);\n }\n\n lines.push(`A submission right now would notify: ${AUDIENCE_LABEL[r.target.audience]}`);\n if (r.target.to.length > 0) lines.push(` to: ${r.target.to.join(\", \")}`);\n if (r.target.cc.length > 0) lines.push(` cc: ${r.target.cc.join(\", \")}`);\n lines.push(` ${r.target.reason}`);\n\n if (r.target.audience === \"client\") {\n lines.push(\n \"\",\n `⚠️ A test submission WILL email the client, and email cannot be recalled.`,\n ` Route it to yourself first: reddoor forms-notify-target ${r.site} --set on`,\n );\n }\n if (r.flip?.confirmed && r.flip.to === VERIFY_STATUS) {\n lines.push(\n \"\",\n `Safe to test. When you are done, restore it:`,\n ` reddoor forms-notify-target ${r.site} --set off --restore ${r.flip.from ?? LIVE_STATUS}`,\n );\n }\n return lines.join(\"\\n\");\n}\n\n/**\n * `forms-notify-target <site>` — show who a form submission would email, and\n * optionally flip the pre-launch guard with a read-back confirmation.\n *\n * Read-only by default: answering the question must never be riskier than not\n * asking it.\n */\nexport async function runFormsNotifyTargetCommand(\n site: string | undefined,\n opts: FormsNotifyTargetCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (!site?.trim()) {\n return { output: \"forms-notify-target requires <site> (slug or Airtable name)\", code: 2 };\n }\n const set = opts.set?.trim().toLowerCase();\n if (set !== undefined && set !== \"on\" && set !== \"off\") {\n return { output: `--set must be 'on' or 'off' (got '${opts.set}')`, code: 2 };\n }\n try {\n const result = await formsNotifyTarget({\n site: site.trim(),\n ...(set ? { set: set as \"on\" | \"off\" } : {}),\n ...(opts.restore ? { restore: opts.restore } : {}),\n });\n // An unconfirmed flip must not exit 0: a script (or a person skimming) that\n // reads exit status would otherwise take \"I flipped it\" on faith, which is\n // exactly the assumption that sent a client a test lead.\n return {\n output: formatNotifyTarget(result),\n code: result.flip && !result.flip.confirmed ? 1 : 0,\n };\n } catch (err) {\n const e = err as { message?: string; exitCode?: number };\n return { output: e.message ?? String(err), code: e.exitCode ?? 1 };\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/blux/freeze/index.ts","../src/blux/freeze/settle.ts","../src/blux/freeze/map-placeholder.ts","../src/blux/freeze/slider-pin.ts","../src/blux/freeze/resolve-anchors.ts","../src/blux/freeze/bake-images.ts","../src/blux/freeze/types.ts","../src/blux/freeze/section.ts","../src/blux/freeze/tokenize-text.ts","../src/blux/freeze/finalize.ts","../src/blux/freeze/extra-slots.ts"],"sourcesContent":["import { mkdir, writeFile, readFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { settleExport } from \"./settle.js\";\nimport { mapPlaceholder } from \"./map-placeholder.js\";\nimport { pinSliders } from \"./slider-pin.js\";\nimport { resolveAnchors } from \"./resolve-anchors.js\";\nimport { bakeImages } from \"./bake-images.js\";\nimport { tokenizeText } from \"./tokenize-text.js\";\nimport { finalize } from \"./finalize.js\";\nimport { extractMapConfig } from \"../grid/extract-map.js\";\nimport { validateExtraSlots } from \"./extra-slots.js\";\nimport type { FrozenResult } from \"./types.js\";\n\nexport type { Slot, FrozenManifest, FrozenResult, ExtraSlotsFile } from \"./types.js\";\nexport { frozenPageCustomType, FROZEN_PAGE_TYPE } from \"./frozen-page-type.js\";\nexport { validateExtraSlots } from \"./extra-slots.js\";\nexport { EXTRA_SLOT_PREFIX } from \"./types.js\";\n\nexport interface FreezeOptions {\n /** Path to the export's index.html. */\n indexHtmlPath: string;\n /** Site slug — names the emitted template/style/manifest files. */\n site: string;\n /** Prismic uid for the page doc (default \"home\"). */\n uid?: string;\n /** A site's own extra-slots declaration (parsed `ExtraSlotsFile`), for\n * editable content the template carries no token for. See types.ts. */\n extraSlots?: unknown;\n}\n\n/**\n * Freeze one Blux page into a byte-faithful template + editable slot manifest.\n * Order matters: bake images (their urls become slots) BEFORE tokenizing text,\n * so image tokens live in style attributes and never collide with text tokens.\n */\nexport async function freezeSite(opts: FreezeOptions): Promise<FrozenResult> {\n const { html: settled, anchorTargets } = await settleExport(opts.indexHtmlPath);\n // Settled-DOM repairs run before image/text tokenizing: swap the dead\n // Google-Map DOM for a placeholder (using the raw export for the KML mid),\n // pin each hero slider to slide 1, and bake nav hashlink targets (settle's\n // click audit measured them against the export's own runtime).\n const exportHtml = await readFile(opts.indexHtmlPath, \"utf-8\");\n const mapped = mapPlaceholder(settled, exportHtml);\n const pinned = pinSliders(mapped);\n const anchored = resolveAnchors(pinned, anchorTargets);\n const baked = bakeImages(anchored);\n const tokenized = tokenizeText(baked.html);\n const fin = finalize(tokenized.html);\n\n // The full map widget config (layers/toggles/styles) lives in the export's\n // initMap script — carried alongside the template so the render can hydrate\n // the real map. Only meaningful when its mount survived into the template.\n const rawMap = extractMapConfig(exportHtml);\n const mapConfig =\n rawMap && fin.templateHtml.includes(`id=\"${rawMap.mountId}\"`) ? rawMap : undefined;\n\n // Derived slots first, then the site's own. Validated against the derived\n // keys so a declaration can never shadow real page content.\n const derived = [...baked.slots, ...tokenized.slots];\n const extra = validateExtraSlots(opts.extraSlots, derived);\n\n return {\n manifest: {\n site: opts.site,\n uid: opts.uid ?? \"home\",\n title: fin.title,\n metaTitle: fin.metaTitle,\n metaImageUrl: fin.metaImageUrl,\n fontLinks: fin.fontLinks,\n slots: [...derived, ...extra],\n },\n templateHtml: fin.templateHtml,\n styleCss: fin.styleCss,\n mapConfig,\n };\n}\n\n/**\n * Write the freeze artifacts under `outDir`. Returns their paths.\n *\n * `frozen/` is site-repo-ready: uid-keyed `<uid>.html` / `<uid>.style.css` /\n * `<uid>.fonts.json` (+ `<uid>.map.json` when the export has a map widget),\n * copied verbatim into the site repo's `src/lib/blux-frozen/frozen/` — exactly\n * the names the starter's artifact globs load. The slots manifest stays\n * site-keyed at the out-dir root, where `blux migrate-frozen` finds it.\n */\nexport async function emitFrozen(\n outDir: string,\n result: FrozenResult,\n): Promise<{\n template: string;\n style: string;\n fonts: string;\n manifest: string;\n map?: string;\n}> {\n const { site, uid } = result.manifest;\n const frozenDir = join(outDir, \"frozen\");\n await mkdir(frozenDir, { recursive: true });\n const template = join(frozenDir, `${uid}.html`);\n const style = join(frozenDir, `${uid}.style.css`);\n const fonts = join(frozenDir, `${uid}.fonts.json`);\n const manifest = join(outDir, `${site}.slots.json`);\n await writeFile(template, result.templateHtml, \"utf-8\");\n await writeFile(style, result.styleCss, \"utf-8\");\n await writeFile(fonts, JSON.stringify(result.manifest.fontLinks, null, 2), \"utf-8\");\n await writeFile(manifest, JSON.stringify(result.manifest, null, 2), \"utf-8\");\n if (!result.mapConfig) return { template, style, fonts, manifest };\n const map = join(frozenDir, `${uid}.map.json`);\n await writeFile(map, JSON.stringify(result.mapConfig, null, 2), \"utf-8\");\n return { template, style, fonts, manifest, map };\n}\n","/// <reference lib=\"dom\" />\n// The evaluate() callbacks below run in the browser page context, so they use\n// DOM globals (document/window). The CLI's base tsconfig is Node-only (lib\n// ES2022); this directive adds DOM types for this file's type-checking.\nimport { createServer, type Server } from \"node:http\";\nimport { readFile } from \"node:fs/promises\";\nimport { dirname, extname, join, normalize, sep } from \"node:path\";\nimport { chromium, type Page } from \"@playwright/test\";\n\n// The Blux export's index.html renders its final layout only after its runtime\n// JS applies height/position adjustments. Freezing the RAW html renders the\n// wrong height (the-pointe: 16873px); freezing the JS-SETTLED DOM renders the\n// live height (15333px). So the freeze hydrates the page once in headless\n// chromium, scrolls top→bottom to trigger the layout pass, and snapshots the\n// settled DOM. (Proven in the design POC scratchpad/freeze-final.mjs.)\n\nexport interface SettleOptions {\n /** Layout viewport — Blux desktop layout is authored at 1440. */\n width?: number;\n height?: number;\n /** Scroll step in px (smaller = more reliable lazy triggers, slower). */\n step?: number;\n}\n\nexport interface SettledExport {\n /** The serialized settled DOM (full document HTML). */\n html: string;\n /**\n * Nav-anchor answer key, measured from the export's OWN runtime: hash index\n * → the id its JS actually scrolls to. Blux core maps `/#N` to\n * `page-block-N`, but site-specific custom scripts override that (the-pointe\n * embeds one sending any \"Contact Us\" link to `footer0`) — behavior no\n * static rule can recover, so it is recorded by clicking each hashlink with\n * `scrollPageToTarget` instrumented. Empty when the runtime is absent.\n */\n anchorTargets: Record<string, string>;\n}\n\n// Blux runtime globals reached from inside evaluate() callbacks. Types are\n// erased before serialization, so declaring this at module level is safe.\ntype BluxWindow = Window & {\n DANDOM?: { prototype: { scrollPageToTarget?: (t: string) => unknown } };\n __rdAnchorCalls?: string[];\n alreadyScrolling?: boolean;\n};\n\nconst MIME: Record<string, string> = {\n \".html\": \"text/html; charset=utf-8\",\n \".css\": \"text/css\",\n \".js\": \"text/javascript\",\n \".json\": \"application/json\",\n \".png\": \"image/png\",\n \".jpg\": \"image/jpeg\",\n \".jpeg\": \"image/jpeg\",\n \".gif\": \"image/gif\",\n \".svg\": \"image/svg+xml\",\n \".webp\": \"image/webp\",\n \".ico\": \"image/x-icon\",\n \".woff\": \"font/woff\",\n \".woff2\": \"font/woff2\",\n};\n\n/**\n * One-shot local static server over the export dir, for the anchor audit ONLY.\n * The audit must run on an http origin: there `/#N` is a same-document hash\n * change (exactly the live site's semantics), while on `file://` it resolves\n * to the filesystem ROOT — a cross-document navigation that Blux's handler\n * chain triggers even past preventDefault + timer stubs, tearing the page down\n * mid-audit. Serving beats intercepting.\n */\nasync function serveExport(dir: string): Promise<{ url: string; server: Server }> {\n const root = normalize(dir);\n const server = createServer((req, res) => {\n void (async () => {\n try {\n const path = decodeURIComponent((req.url ?? \"/\").split(/[?#]/)[0]!);\n const rel = path === \"/\" ? \"index.html\" : path.slice(1);\n // Separator-anchored: a bare prefix test would also pass sibling\n // dirs that merely share the prefix (/exports/site-secret vs\n // /exports/site).\n const file = normalize(join(root, rel));\n if (!file.startsWith(root + sep)) throw new Error(\"traversal\");\n const body = await readFile(file);\n res.writeHead(200, {\n \"content-type\": MIME[extname(file)] ?? \"application/octet-stream\",\n });\n res.end(body);\n } catch {\n res.writeHead(404);\n res.end();\n }\n })();\n });\n await new Promise<void>((resolve) => server.listen(0, \"127.0.0.1\", resolve));\n const address = server.address();\n if (address === null || typeof address === \"string\")\n throw new Error(\"export server failed to bind\");\n return { url: `http://127.0.0.1:${address.port}/`, server };\n}\n\n/**\n * Click every distinct `/#N` hashlink on the SERVED page and record the first\n * target the site's runtime passes to `scrollPageToTarget` — first, because on\n * the live site later calls in a click's handler chain are gated by\n * `alreadyScrolling`, so the first call is the one users see. The scroll\n * itself is suppressed (the wrapper records and returns), so clicks leave no\n * scroll state behind; the settle snapshot is taken from a separate file://\n * page that the audit never touches.\n */\nasync function auditAnchors(page: Page): Promise<Record<string, string>> {\n const ns = await page.evaluate(() => {\n const found = new Set<string>();\n for (const a of document.querySelectorAll('a[href^=\"/#\"]')) {\n const m = /^\\/#(\\d+)$/.exec(a.getAttribute(\"href\") ?? \"\");\n if (m?.[1]) found.add(m[1]);\n }\n return [...found];\n });\n if (ns.length === 0) return {};\n\n const instrumented = await page.evaluate(() => {\n const w = window as unknown as BluxWindow;\n const proto = w.DANDOM?.prototype;\n if (!proto?.scrollPageToTarget) return false;\n w.__rdAnchorCalls = [];\n proto.scrollPageToTarget = function (t: string) {\n w.__rdAnchorCalls?.push(t);\n return this; // record only — never actually scroll\n };\n return true;\n });\n if (!instrumented) return {};\n\n const targets: Record<string, string> = {};\n for (const n of ns) {\n // Audit EVERY link sharing this index, not just the first: custom scripts\n // intercept by link semantics (the-pointe keys on \"Contact Us\" text), so\n // two /#N links can legitimately scroll to different targets. The bake is\n // per-index, so disagreement can't be represented — surface it loudly and\n // bake the first link's (document-order) behavior.\n const count = await page.evaluate(\n (nn) => document.querySelectorAll(`a[href=\"/#${nn}\"]`).length,\n n,\n );\n const seen: string[] = [];\n for (let i = 0; i < count; i++) {\n await page.evaluate(\n ({ nn, idx }) => {\n const w = window as unknown as BluxWindow;\n w.__rdAnchorCalls = [];\n w.alreadyScrolling = false;\n history.replaceState(null, \"\", \"/\");\n document.querySelectorAll<HTMLElement>(`a[href=\"/#${nn}\"]`)[idx]?.click();\n },\n { nn: n, idx: i },\n );\n // checkScrollHash defers its core scroll 100ms past the hashchange (and\n // re-navigates at +50ms); 400ms covers the whole handler chain.\n await page.waitForTimeout(400);\n const first = await page.evaluate(\n () => (window as unknown as BluxWindow).__rdAnchorCalls?.[0] ?? null,\n );\n if (first) seen.push(first);\n }\n const distinct = [...new Set(seen)];\n if (distinct.length > 1) {\n console.warn(\n `[freeze] anchor /#${n}: links disagree on target (${distinct.join(\", \")}) — ` +\n `baking \"${seen[0]}\" for all; verify that page's nav fidelity by hand`,\n );\n }\n if (seen[0]) targets[n] = seen[0];\n }\n return targets;\n}\n\n/**\n * Render a local `index.html` in headless chromium, let its JS settle the\n * layout, and return the serialized settled DOM plus the measured nav-anchor\n * answer key.\n */\nexport async function settleExport(\n indexHtmlPath: string,\n opts: SettleOptions = {},\n): Promise<SettledExport> {\n const width = opts.width ?? 1440;\n const height = opts.height ?? 1000;\n const step = opts.step ?? 500;\n\n const browser = await chromium.launch();\n try {\n const page = await browser.newPage({ viewport: { width, height } });\n await page.goto(`file://${indexHtmlPath}`, {\n waitUntil: \"networkidle\",\n timeout: 60000,\n });\n // Trigger the JS layout pass across the whole page.\n const total = await page.evaluate(() => document.body.scrollHeight);\n for (let y = 0; y < total + 1000; y += step) {\n await page.evaluate((yy) => window.scrollTo(0, yy), y);\n await page.waitForTimeout(120);\n }\n await page.evaluate(() => window.scrollTo(0, 0));\n await page.waitForTimeout(1200);\n const html = await page.content();\n\n // Anchor audit on a separate, HTTP-served copy (see serveExport). Media\n // is aborted for speed — the audit needs handlers, not layout.\n const { url, server } = await serveExport(dirname(indexHtmlPath));\n try {\n const auditPage = await browser.newPage({\n viewport: { width, height },\n });\n await auditPage.route(\n \"**/*\",\n (route) =>\n void ([\"image\", \"media\", \"font\"].includes(route.request().resourceType())\n ? route.abort()\n : route.continue()),\n );\n await auditPage.goto(url, { waitUntil: \"load\", timeout: 60000 });\n await auditPage.waitForTimeout(800);\n const anchorTargets = await auditAnchors(auditPage);\n await auditPage.close();\n return { html, anchorTargets };\n } finally {\n await new Promise<void>((resolve) => server.close(() => resolve()));\n }\n } finally {\n await browser.close();\n }\n}\n","import { parse } from \"node-html-parser\";\n\n// The frozen export's Google-Map band is a CUSTOM Google My Maps: a KmlLayer\n// keyed by a My Maps id (`…/kml?…&mid=<ID>`) rendered at runtime. With the Blux\n// JS stripped, the settled `gm-style` DOM Google left behind is dead (broken\n// tiles + CSP console noise). Replace it with a clean, height-preserving\n// placeholder that carries the `mid` so a future (production) hydrator can bring\n// the real map back once a domain-scoped Maps key exists.\n\n// The My Maps id, pulled from the export's KML url (deterministic, not from the\n// settled DOM which no longer holds it).\nconst MID_RE = /[?&]mid=([\\w-]+)/;\n\n/**\n * Convert each rendered Google-Map container into a `.blux-frozen-map`\n * placeholder. A container is an element whose `id` names a map (`/map/i`) AND\n * that actually rendered (has a `.gm-style` descendant); its dead children are\n * removed and the KML `mid` (if found in `exportHtml`) is attached. Its existing\n * id + inline height are kept so the band's box is unchanged. `exportHtml` is the\n * raw Blux export string.\n */\nexport function mapPlaceholder(html: string, exportHtml: string): string {\n const root = parse(html);\n const mid = exportHtml.match(MID_RE)?.[1];\n\n for (const el of root.querySelectorAll(\"[id]\")) {\n const id = el.getAttribute(\"id\") ?? \"\";\n if (!/map/i.test(id)) continue;\n if (!el.querySelector(\".gm-style\")) continue; // only actually-rendered maps\n el.set_content(\"\"); // drop the dead gm-style DOM\n el.classList.add(\"blux-frozen-map\");\n if (mid) el.setAttribute(\"data-kml-mid\", mid);\n }\n\n return root.toString();\n}\n","import { parse, HTMLElement } from \"node-html-parser\";\n\n// Blux single-column hero sliders (`.caslider` with `data-columns` 1 or absent)\n// animate through their slides at runtime. The settle snapshot freezes whichever\n// slide happened to be active, which is not reproducible across re-freezes. Pin\n// such a slider to its first slide: force slide 1 visible + in position, hide the\n// rest with `display:none`. Appended declarations win (inline-style last-wins),\n// so the first slide's own styles are preserved and height is unchanged (slide 1\n// keeps the band's natural height).\n//\n// A MULTI-column slider (`data-columns` >= 2) shows several slides at once — the\n// settled snapshot already renders those columns faithfully — so pinning would\n// hide the simultaneously-visible columns and regress the band. Leave those\n// untouched; the pin is only for the single-column hero case.\n\n/**\n * Pin each single-column `.caslider` to its first slide, deterministically.\n * Multi-column sliders (`data-columns` >= 2) are left as settled.\n */\nexport function pinSliders(html: string): string {\n const root = parse(html);\n\n for (const slider of root.querySelectorAll(\".caslider\")) {\n const columns = parseInt(slider.getAttribute(\"data-columns\") ?? \"1\", 10) || 1;\n if (columns > 1) continue; // multi-column carousel: keep the settled columns\n const slides = slider.childNodes.filter((n): n is HTMLElement => n instanceof HTMLElement);\n slides.forEach((slide, i) => {\n const style = slide.getAttribute(\"style\") ?? \"\";\n const pin = i === 0 ? \"display:block;transform:translateX(0%);opacity:1\" : \"display:none\";\n slide.setAttribute(\"style\", style ? `${style};${pin}` : pin);\n });\n }\n\n return root.toString();\n}\n","// Blux nav anchors are JS-driven: core runtime maps `href=\"/#N\"` to the band\n// id `page-block-N` — but site-specific custom scripts embedded in the export\n// can send a link anywhere (the-pointe's sends \"Contact Us\" to `footer0`), so\n// the authoritative mapping is the answer key MEASURED from the export's own\n// runtime by settle's click audit. With the runtime stripped those hrefs are\n// dead; bake the resolved target into the frozen markup. Unmeasured indexes\n// fall back to Blux core's `page-block-N` (when that band is missing, Blux\n// core no-ops — exactly what a dead in-page anchor does, so the fallback is\n// faithful either way). Digit-only fragments only — real named anchors\n// (`/#about`) are untouched. The starter's render-time enhance layer keeps its\n// own `/#N` rewrite as a repair path for pre-bake artifacts; baked hrefs no\n// longer match it.\n\nconst HASHLINK_RE = /href=\"\\/#(\\d+)\"/g;\n\nexport function resolveAnchors(html: string, targets: Record<string, string> = {}): string {\n return html.replace(\n HASHLINK_RE,\n (_full, n: string) => `href=\"#${targets[n] ?? `page-block-${n}`}\"`,\n );\n}\n","import { parse, HTMLElement } from \"node-html-parser\";\nimport { slotKey, tokenFor, type Slot } from \"./types.js\";\nimport { sectionIndexOf, sectionKeyOf } from \"./section.js\";\nimport { CDN_HOSTS } from \"../assets.js\";\n\nconst isCdnUrl = (v: string): boolean => CDN_HOSTS.some((h) => v.includes(h));\n\n// Blux media elements carry the image as data-attributes, not a live src: the\n// runtime reads `data-base` (CDN folder) + `data-size` (width) + `data-media`\n// (filename) and paints a background on a lazy `.load-media-element` span\n// (loading/unloading per viewport). We assemble the url deterministically —\n// `{data-base}w:{data-size}/{data-media}` (verified 200) — and bake it as an\n// inline background-image token so no runtime is needed. `data-bgmedia` is a\n// full-bleed layer whose filename wins over `data-media`. A second pass catches\n// any other CDN url still living in src/href/poster (e.g. a <video>), so the\n// frozen template leaks no CDN host at all.\n\n// Runtime-only url hints the (removed) Blux JS used; not rendered — drop them so\n// they don't leak a CDN host.\nfunction stripUrlHintAttrs(root: HTMLElement): void {\n for (const el of root.querySelectorAll(\"*\")) {\n for (const name of Object.keys(el.attributes)) {\n if (name === \"data-og-src\" || name.startsWith(\"data-swaps\")) {\n el.removeAttribute(name);\n }\n }\n }\n}\n\n/**\n * Tokenize every media url in the document as a `⟦i:KEY⟧` image slot. Pass 1:\n * data-attribute backgrounds → inline `background-image:url(token)`. Pass 2: any\n * remaining CDN url in `src`/`href`/`poster` → bare `token`. Keys are\n * `s{section}.i{n}` in document order.\n */\nexport function bakeImages(html: string): { html: string; slots: Slot[] } {\n const root = parse(html);\n const sectionIndex = sectionIndexOf(root);\n const counters = new Map<string, number>();\n const slots: Slot[] = [];\n\n const nextKey = (el: HTMLElement): string => {\n const section = sectionKeyOf(el, sectionIndex);\n const n = counters.get(section) ?? 0;\n counters.set(section, n + 1);\n return slotKey(section, \"image\", n);\n };\n\n // Pass 1: data-* backgrounds. The filename is ALWAYS `data-media`;\n // `data-bgmedia` is a boolean flag (\"1\") marking a full-bleed layer — it is\n // NOT a filename. When an element is unsized we default full-bleed layers to a\n // wide variant and inline images to the raw file.\n for (const el of root.querySelectorAll(\"[data-media]\")) {\n const base = el.getAttribute(\"data-base\");\n if (!base) continue;\n const media = el.getAttribute(\"data-media\");\n if (!media) continue;\n const isBg = el.getAttribute(\"data-bgmedia\") != null;\n const size = el.getAttribute(\"data-size\");\n const sizeSeg = size ? `w:${size}/` : isBg ? \"w:1600/\" : \"\";\n const url = `${base}${sizeSeg}${media}`;\n\n const key = nextKey(el);\n slots.push({ key, kind: \"image\", url, section: sectionKeyOf(el, sectionIndex) });\n const decl = `background-image:url(${tokenFor(\"image\", key)})`;\n const style = el.getAttribute(\"style\");\n el.setAttribute(\"style\", style ? `${style};${decl}` : decl);\n el.removeAttribute(\"data-base\");\n }\n\n // Pass 2: any remaining CDN url in src/href/poster (video, source, links…).\n for (const el of root.querySelectorAll(\"[src],[href],[poster]\")) {\n for (const attr of [\"src\", \"href\", \"poster\"] as const) {\n const v = el.getAttribute(attr);\n if (!v || !isCdnUrl(v)) continue;\n const key = nextKey(el);\n slots.push({ key, kind: \"image\", url: v, section: sectionKeyOf(el, sectionIndex) });\n el.setAttribute(attr, tokenFor(\"image\", key));\n }\n }\n\n stripUrlHintAttrs(root);\n return { html: root.toString(), slots };\n}\n","// Frozen-page freeze: shared types + token contract. A frozen site keeps the\n// Blux export's own markup as the layout (byte-faithful) and exposes only the\n// editable leaves — text runs and image urls — as Prismic slots. The template\n// carries a placeholder token per slot; the render substitutes current Prismic\n// values back in. See docs/superpowers/specs/2026-07-24-blux-frozen-page-design.md.\n\nimport type { MapConfig } from \"../grid/extract-map.js\";\n\nexport type SlotKind = \"text\" | \"image\";\n\nexport interface Slot {\n /** Stable, document-order key `s{section}.{t|i}{n}` (see `slotKey`). */\n key: string;\n kind: SlotKind;\n /** Present when kind === \"text\": the original text-node value. */\n text?: string;\n /** Present when kind === \"image\": the assembled (cloudfront) image url. */\n url?: string;\n /** Grouping label for editor sanity — the owning section key. */\n section: string;\n}\n\n/**\n * Slots a SITE declares, on top of the ones the freeze derives from the export.\n *\n * Every derived slot exists because the template carries a token for it. Some\n * editable content has no token and never can: the render composes it itself,\n * from markup the export never had. Two real cases on the-pointe — a `<video>`\n * poster (the export ships none, so there is no attribute to tokenize) and a\n * data panel the export baked as a flattened PNG (rebuilt as real text, so the\n * text exists only in the render).\n *\n * Without this, that content can only live in committed files and a deploy is\n * required to change a leasing number. Declaring it here puts it in Prismic\n * alongside everything else, and — because `blux migrate-frozen` PUTs the whole\n * document — keeps it there across re-migrations, which a row added by hand in\n * the Prismic UI would not survive.\n *\n * Kept generic on purpose: the tool serves several frozen sites, so it knows\n * only that extra slots exist, never what any one site puts in them.\n */\nexport interface ExtraSlotsFile {\n /** Free-form note for whoever opens the file; ignored by the freeze. */\n comment?: string;\n slots: Slot[];\n}\n\n/** Prefix reserved for site-declared slots, so a synthetic key can never\n * collide with a derived `s{n}.t{n}` / `h.i{n}` one. */\nexport const EXTRA_SLOT_PREFIX = \"x.\";\n\nexport interface FrozenManifest {\n site: string;\n uid: string;\n title: string;\n metaTitle?: string | undefined;\n metaImageUrl?: string | undefined;\n /** External stylesheet hrefs from the export <head> (e.g. Google Fonts) that\n * the render must re-inject — font metrics are load-bearing for layout. */\n fontLinks: string[];\n slots: Slot[];\n}\n\n/** The emitted freeze artifact: the manifest plus the repo files' bodies. */\nexport interface FrozenResult {\n manifest: FrozenManifest;\n /** Tokenized `<body>` innerHTML — the repo template. */\n templateHtml: string;\n /** The extracted `<style>` block text — the repo style file. */\n styleCss: string;\n /** Present when the export carries a Blux map widget whose mount survives\n * in the template — emitted as `frozen/<uid>.map.json`, hydrated by the\n * starter's frozen-map layer. */\n mapConfig?: MapConfig | undefined;\n}\n\n/** The placeholder a slot's value is substituted for at render time.\n * Text: replaces the whole text node. Image: sits inside `url(...)`. */\nexport function tokenFor(kind: SlotKind, key: string): string {\n return `⟦${kind === \"text\" ? \"t\" : \"i\"}:${key}⟧`;\n}\n\n/** Build a slot key. `section` is the nearest-ancestor <section> index (or\n * \"h\" for chrome above the first section); `n` is a per-kind counter. */\nexport function slotKey(section: string, kind: SlotKind, n: number): string {\n return `${section}.${kind === \"text\" ? \"t\" : \"i\"}${n}`;\n}\n\n/** Matches any slot token; group 1 = \"t\"|\"i\", group 2 = key. Global — callers\n * that reuse it across `.test()`/`.replace()` should construct a fresh copy or\n * reset `lastIndex`. Use `TOKEN_RE()` to get a fresh instance. */\nexport const TOKEN_RE = (): RegExp => /⟦([ti]):([^⟧]+)⟧/g;\n","import { HTMLElement } from \"node-html-parser\";\n\n/** Map each <section> element to its document-order index. */\nexport function sectionIndexOf(root: HTMLElement): Map<HTMLElement, number> {\n const index = new Map<HTMLElement, number>();\n root.querySelectorAll(\"section\").forEach((s, i) => index.set(s, i));\n return index;\n}\n\n/** Nearest-ancestor <section> key (`s{index}`), or `h` for chrome (nav/footer)\n * that lives above/outside any <section>. */\nexport function sectionKeyOf(el: HTMLElement, sectionIndex: Map<HTMLElement, number>): string {\n let a: HTMLElement | null | undefined = el;\n while (a) {\n if (a.tagName === \"SECTION\" && sectionIndex.has(a)) {\n return `s${sectionIndex.get(a)}`;\n }\n a = a.parentNode as HTMLElement | null | undefined;\n }\n return \"h\";\n}\n","import { parse, HTMLElement, NodeType } from \"node-html-parser\";\nimport { slotKey, tokenFor, type Slot } from \"./types.js\";\nimport { sectionIndexOf, sectionKeyOf } from \"./section.js\";\n\n// Elements whose text is not editable page copy.\nconst SKIP = new Set([\"SCRIPT\", \"STYLE\", \"NOSCRIPT\", \"HEAD\", \"TITLE\", \"SVG\"]);\n\n/**\n * Replace every text node that carries CONTENT with a `⟦t:KEY⟧` token and\n * collect the originals as text slots. Keys are `s{section}.t{n}` in document\n * order so the template + manifest stay stable across re-freezes (golden\n * canary). The raw source text is preserved verbatim (entities and surrounding\n * whitespace) so a frozen render is byte-faithful before any Prismic edit.\n *\n * \"Carries content\" is decided on the DECODED text, not the raw source. Page\n * builders emit whitespace-only leaves as layout — a list item or table cell\n * holding ` ` purely to occupy a line — and those must stay literal in the\n * template rather than becoming CMS fields, for two reasons:\n *\n * 1. They are not editable copy. Nobody wants a Prismic field whose only\n * correct value is \"one blank line\".\n * 2. Prismic Rich Text CANNOT store a whitespace-only value. It round-trips to\n * \"\", the row collapses to its padding, and the page silently loses a line\n * of vertical rhythm — a defect that only shows up after the migration, on\n * the live site, in a place nobody thought to re-measure.\n *\n * `rawText.trim()` cannot see this: for a ` ` leaf it trims to the literal\n * string \" \", which is not empty, so the leaf looked like content. Testing\n * the decoded `.text` catches every spelling — ` `, ` `, ` `,\n * ` `, ` ` — because JS `String.trim()` strips the whole Unicode\n * whitespace class. A real character such as `&` still decodes to content\n * and is tokenized as before.\n */\nexport function tokenizeText(html: string): { html: string; slots: Slot[] } {\n const root = parse(html);\n const sectionIndex = sectionIndexOf(root);\n\n const counters = new Map<string, number>();\n const slots: Slot[] = [];\n\n const walk = (el: HTMLElement): void => {\n for (const child of el.childNodes) {\n if (child.nodeType === NodeType.TEXT_NODE) {\n const raw = child.rawText;\n // Decoded, so entity-encoded whitespace is treated as the whitespace it\n // is. A skipped leaf does not advance the section counter — same as a\n // plain-whitespace leaf, which this check has always skipped.\n if (raw && child.text.trim() !== \"\") {\n const section = sectionKeyOf(el, sectionIndex);\n const n = counters.get(section) ?? 0;\n counters.set(section, n + 1);\n const key = slotKey(section, \"text\", n);\n slots.push({ key, kind: \"text\", text: raw, section });\n child.rawText = tokenFor(\"text\", key);\n }\n } else if (child.nodeType === NodeType.ELEMENT_NODE) {\n const elChild = child as HTMLElement;\n if (!SKIP.has(elChild.tagName)) walk(elChild);\n }\n }\n };\n walk(root);\n\n return { html: root.toString(), slots };\n}\n","import { parse } from \"node-html-parser\";\n\n// Reveal effects hide content until the Blux runtime adds `block-effects-applied`\n// on scroll. With the runtime stripped, force the end-state so everything is\n// visible without JS. (`.block-effects{opacity:0}` is the initial rule.)\nexport const REVEAL_FORCE_CSS =\n \".block-effects{opacity:1!important;transform:none!important;animation:none!important;visibility:visible!important}\";\n\nexport interface Finalized {\n /** `<body>` inner HTML wrapped so the body's own class/style survive. */\n templateHtml: string;\n /** All page `<style>` blocks + the reveal-force override. */\n styleCss: string;\n title: string;\n metaTitle?: string | undefined;\n metaImageUrl?: string | undefined;\n /** External stylesheet hrefs (Google Fonts) to re-inject at render. */\n fontLinks: string[];\n}\n\n/**\n * Turn a tokenized settled document into the two repo artifacts: strip all\n * `<script>`, extract every `<style>` block (+ reveal-force) into a stylesheet,\n * and wrap the body's inner HTML in a `.frozen-root` div that carries the\n * body's own class/style (which `body.innerHTML` would otherwise drop).\n */\nexport function finalize(html: string): Finalized {\n const root = parse(html);\n const title = root.querySelector(\"title\")?.text?.trim() ?? \"\";\n const metaOf = (prop: string): string | undefined =>\n root.querySelector(`meta[property=\"${prop}\"]`)?.getAttribute(\"content\") ?? undefined;\n const metaTitle = metaOf(\"og:title\") ?? (title || undefined);\n const metaImageUrl = metaOf(\"og:image\");\n\n const fontLinks = root\n .querySelectorAll('link[rel=\"stylesheet\"]')\n .map((l) => l.getAttribute(\"href\"))\n .filter((h): h is string => !!h);\n\n const styles: string[] = [];\n for (const s of root.querySelectorAll(\"style\")) {\n styles.push(s.innerHTML);\n s.remove();\n }\n for (const sc of root.querySelectorAll(\"script\")) sc.remove();\n // Remove the Blux runtime's lazy media spans: they carry raw cloudfront urls\n // for whichever images were on-screen at snapshot (the rest unloaded), and the\n // deterministic baked token on the parent media div is what renders instead.\n for (const holder of root.querySelectorAll(\".load-media-holder, .load-media-element\")) {\n holder.remove();\n }\n\n const body = root.querySelector(\"body\");\n const inner = body ? body.innerHTML : root.toString();\n const bodyClass = body?.getAttribute(\"class\") ?? \"\";\n const bodyStyle = body?.getAttribute(\"style\") ?? \"\";\n const cls = `frozen-root ${bodyClass}`.trim();\n const styleAttr = bodyStyle ? ` style=\"${bodyStyle}\"` : \"\";\n const templateHtml = `<div class=\"${cls}\"${styleAttr}>${inner}</div>`;\n\n const styleCss = [...styles, REVEAL_FORCE_CSS].join(\"\\n\");\n return { templateHtml, styleCss, title, metaTitle, metaImageUrl, fontLinks };\n}\n","// Site-declared slots, merged into the freeze manifest alongside the derived\n// ones. See `ExtraSlotsFile` in types.ts for why they exist.\n//\n// This module is deliberately incurious about what a site declares: it checks\n// the shape, the reserved prefix and key uniqueness, and nothing else. The\n// freeze tool serves several frozen sites and must not learn any one site's\n// content model.\n\nimport { EXTRA_SLOT_PREFIX, type Slot } from \"./types.js\";\n\n/** Thrown with the offending path so a bad declaration names itself. */\nfunction fail(path: string, why: string): never {\n throw new Error(`extra slots: ${path} ${why}`);\n}\n\nfunction asString(v: unknown, path: string): string {\n if (typeof v !== \"string\" || v.trim() === \"\") {\n fail(path, `must be a non-empty string (got ${JSON.stringify(v)})`);\n }\n return v as string;\n}\n\n/**\n * Validate a site's extra-slots declaration against the slots the freeze\n * derived, returning the extras to append.\n *\n * A declaration that is wrong must stop the freeze rather than ship: these\n * slots become live CMS fields, and a key that collides with a derived one\n * would let a site-declared value silently overwrite real page content when\n * the render builds its value map.\n */\nexport function validateExtraSlots(file: unknown, derived: Slot[]): Slot[] {\n if (file === undefined || file === null) return [];\n if (typeof file !== \"object\" || Array.isArray(file)) {\n fail(\"root\", \"must be an object with a `slots` array\");\n }\n // Read as `unknown`, not as `ExtraSlotsFile` — the whole point here is that\n // the input has not been checked yet, and typing it as the target would make\n // every access below assert what this function is supposed to prove.\n const slots: unknown = (file as { slots?: unknown }).slots;\n if (!Array.isArray(slots)) fail(\"slots\", \"must be an array\");\n\n const derivedKeys = new Set(derived.map((s) => s.key));\n const seen = new Set<string>();\n const out: Slot[] = [];\n\n slots.forEach((raw, i) => {\n const at = `slots[${i}]`;\n if (typeof raw !== \"object\" || raw === null) fail(at, \"must be an object\");\n const s = raw as Record<string, unknown>;\n\n const key = asString(s.key, `${at}.key`);\n if (!key.startsWith(EXTRA_SLOT_PREFIX)) {\n fail(\n `${at}.key`,\n `must start with \"${EXTRA_SLOT_PREFIX}\" so it cannot collide with a derived slot (got \"${key}\")`,\n );\n }\n if (derivedKeys.has(key)) fail(`${at}.key`, `collides with a derived slot (\"${key}\")`);\n if (seen.has(key)) fail(`${at}.key`, `is declared twice (\"${key}\")`);\n seen.add(key);\n\n const kind = s.kind;\n if (kind !== \"text\" && kind !== \"image\") {\n fail(`${at}.kind`, `must be \"text\" or \"image\" (got ${JSON.stringify(kind)})`);\n }\n\n const section = typeof s.section === \"string\" && s.section.trim() ? s.section : \"x\";\n if (kind === \"text\") {\n out.push({ key, kind, text: asString(s.text, `${at}.text`), section });\n } else {\n out.push({ key, kind, url: asString(s.url, `${at}.url`), section });\n }\n });\n\n return out;\n}\n"],"mappings":"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","names":["readFile","join","parse","parse","parse","parse","parse","parse","readFile","join"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/github/renovate-dispatch.ts","../src/cli/commands/renovate-dispatch.ts"],"sourcesContent":["import { isDashboardVisible, type WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { isRenovatePR } from \"../alerts/renovate.js\";\nimport type { PullRequestSummary } from \"./gh.js\";\n\n/**\n * On-demand Renovate trigger for fleet sites.\n *\n * The nightly security sweep (`fleet-security.yml`) writes each active site's\n * vulnerability counts to Airtable. This module turns that into action: for the\n * sites the sweep just flagged with *actionable* (critical or high) vulns, it\n * dispatches that repo's `renovate.yml` `workflow_dispatch` so Renovate runs off\n * its twice-daily schedule. Renovate's OSV vulnerability alerts bypass the\n * schedule, so the remediation PR opens immediately — and because GitHub's\n * platform auto-merge is disabled fleet-wide, Renovate merges it from inside that\n * same run (or a later scheduled one) per the shared preset, which is what keeps\n * the preset's \"majors are never auto-merged\" rule authoritative. Moderate/low\n * vulns are left to the normal schedule — not urgent enough to dispatch + churn\n * CI nightly.\n */\n\n/** The per-repo workflow file the fleet uses for its self-hosted Renovate run. */\nexport const RENOVATE_WORKFLOW_FILE = \"renovate.yml\";\n\n/**\n * Does the repo have a HEALTHY open Renovate PR — one that is NOT conflicting?\n *\n * A healthy PR means remediation is genuinely in flight (moving toward merge), so\n * re-dispatching would just churn CI — skip it. A CONFLICTING Renovate PR is STUCK\n * (its branch fell behind the base after another PR merged the same lockfile), so\n * it does NOT count as healthy: re-dispatching triggers Renovate to rebase it.\n * `UNKNOWN` mergeability (GitHub still computing it) is treated as healthy — don't\n * churn on uncertainty. Reuses {@link isRenovatePR} for the branch-prefix check.\n */\nexport function hasHealthyRenovatePr(prs: PullRequestSummary[]): boolean {\n return prs.some((pr) => isRenovatePR(pr) && pr.mergeable !== \"CONFLICTING\");\n}\n\n/** A site selected for an on-demand Renovate run, with the reason (vuln counts). */\nexport type RenovateDispatchTarget = {\n repo: string; // owner/repo, from the Websites \"Git repo\" field\n siteName: string;\n critical: number;\n high: number;\n};\n\n/**\n * Pick the active, repo-backed sites whose latest security sweep found a\n * critical or high vulnerability. A `null` vuln count is treated as zero (a site\n * that was never swept, or had its counts cleared, is not dispatched). Inactive\n * sites and sites without a `Git repo` are excluded — there's nothing to kick.\n *\n * Counts are only guaranteed fresh for sites the sweep actually audited (active +\n * URL present); a site whose write-back failed/was skipped this run is selected on\n * its last-known counts. That's safe — a stale-high false dispatch is an idempotent\n * Renovate no-op, and the open-PR skip in `dispatchRenovateAcross` avoids re-firing\n * when remediation is already in flight.\n */\nexport function selectRenovateTargets(sites: WebsiteRow[]): RenovateDispatchTarget[] {\n const targets: RenovateDispatchTarget[] = [];\n for (const s of sites) {\n if (!isDashboardVisible(s)) continue; // active (maintenance / launch period) only\n const repo = s.gitRepo?.trim();\n if (!repo) continue; // repo-backed only — the dispatch target is its renovate.yml\n const critical = s.securityVulnsCritical ?? 0;\n const high = s.securityVulnsHigh ?? 0;\n if (critical + high <= 0) continue; // actionable (critical/high) vulns only\n targets.push({ repo, siteName: s.name, critical, high });\n }\n return targets;\n}\n\n/** Injected GitHub operations — real ones come from `makeGitHub`, fakes from tests. */\nexport type RenovateDispatchDeps = {\n /** True iff the repo has a HEALTHY (non-conflicting) open Renovate PR —\n * remediation in flight, so don't re-dispatch. A conflicting/stuck PR returns\n * false so the dispatch goes through and Renovate rebases it. */\n hasHealthyOpenRenovatePr: (repo: string) => Promise<boolean>;\n defaultBranch: (repo: string) => Promise<string>;\n dispatch: (repo: string, workflow: string, ref: string) => Promise<void>;\n};\n\nexport type RenovateDispatchResult = {\n dispatched: string[];\n /** Repos skipped because a Renovate PR is already open — no point re-firing. */\n skipped: string[];\n failed: { repo: string; error: string }[];\n};\n\n/**\n * Dispatch `renovate.yml` for each target on its default branch.\n *\n * Skips a repo with a HEALTHY (non-conflicting) open Renovate PR — remediation is\n * in flight, so re-dispatching would just churn CI (keeps a persistent vuln from\n * re-firing every nightly run while its fix PR waits, e.g. a human-gated major\n * bump). A repo whose open Renovate PR is CONFLICTING/stuck is NOT skipped:\n * dispatching triggers Renovate to rebase it. Best-effort and isolated: a repo\n * that lacks `renovate.yml`, or whose\n * dispatch/PR-check is refused (the token's `actions:write` scope), is recorded in\n * `failed` and the rest still run. Never throws — the caller (a follow-up step on\n * the security sweep) must not be able to fail the sweep over a dispatch hiccup.\n */\nexport async function dispatchRenovateAcross(\n targets: RenovateDispatchTarget[],\n deps: RenovateDispatchDeps,\n): Promise<RenovateDispatchResult> {\n const dispatched: string[] = [];\n const skipped: string[] = [];\n const failed: { repo: string; error: string }[] = [];\n for (const t of targets) {\n try {\n if (await deps.hasHealthyOpenRenovatePr(t.repo)) {\n skipped.push(t.repo);\n continue;\n }\n const ref = await deps.defaultBranch(t.repo);\n await deps.dispatch(t.repo, RENOVATE_WORKFLOW_FILE, ref);\n dispatched.push(t.repo);\n } catch (e) {\n failed.push({ repo: t.repo, error: e instanceof Error ? e.message : String(e) });\n }\n }\n return { dispatched, skipped, failed };\n}\n\n/** Machine-readable counts line the workflow greps to annotate partial failures. */\nexport function formatRenovateDispatchSummary(result: RenovateDispatchResult): string {\n return `RENOVATE_DISPATCH_SUMMARY dispatched=${result.dispatched.length} skipped=${result.skipped.length} failed=${result.failed.length}`;\n}\n\n/**\n * Plan the per-site auto-fix-attempt counter writes from a dispatch result. PURE.\n * For each site:\n * - vulns now 0 (or never counted) → reset to 0 (episode resolved)\n * - else dispatched this run → attempts + 1 (a fresh failed-so-far attempt)\n * - else (skipped / failed / untouched) → unchanged\n * The RESET applies fleet-wide — regardless of Status or Git repo — so a site\n * archived or un-repo'd after its episode can't carry a stale counter into its\n * next vuln (Alamo sat at 7 from a long-closed episode). A null vuln count\n * (never-audited) reads as zero, consistent with the existing null-as-zero\n * semantics, so a lingering counter on such a row also clears. The INCREMENT\n * keeps its active + repo-backed filters (only those sites are dispatched).\n * Returns only the rows whose value CHANGES (a steady fleet writes nothing —\n * a 0→0 site emits no write). A skipped repo (healthy Renovate PR in flight) is\n * NOT a failed attempt — a fix is genuinely moving toward merge — so its counter\n * holds.\n */\nexport function computeAutoFixAttemptUpdates(\n sites: WebsiteRow[],\n result: RenovateDispatchResult,\n): { id: string; attempts: number }[] {\n const dispatched = new Set(result.dispatched);\n const updates: { id: string; attempts: number }[] = [];\n for (const s of sites) {\n const current = s.securityAutoFixAttempts ?? 0;\n const vulns = (s.securityVulnsCritical ?? 0) + (s.securityVulnsHigh ?? 0);\n if (vulns === 0) {\n // Episode resolved (or never confirmed): reset REGARDLESS of status/repo —\n // a site archived or un-repo'd after its episode must not carry a stale\n // counter into its next vuln (Alamo sat at 7 from a long-closed episode).\n if (current !== 0) updates.push({ id: s.id, attempts: 0 });\n continue;\n }\n if (!isDashboardVisible(s)) continue; // increments stay active-only\n const repo = s.gitRepo?.trim();\n if (!repo) continue; // …and repo-backed-only\n if (dispatched.has(repo)) updates.push({ id: s.id, attempts: current + 1 });\n }\n return updates;\n}\n\n/**\n * Apply the planned counter updates with an injected writer, BEST-EFFORT: a writer\n * that throws (e.g. the Airtable field not yet created, or a transient error) is\n * counted in `failed` and never propagates — the security sweep must not fail over\n * counter bookkeeping. Returns the applied/failed tallies for the summary line.\n */\nexport async function applyAutoFixAttemptUpdates(\n updates: { id: string; attempts: number }[],\n write: (id: string, attempts: number) => Promise<void>,\n): Promise<{ written: number; failed: number }> {\n let written = 0;\n let failed = 0;\n for (const u of updates) {\n try {\n await write(u.id, u.attempts);\n written++;\n } catch {\n failed++;\n }\n }\n return { written, failed };\n}\n\n/** Machine-readable counts line the workflow can grep, mirroring RENOVATE_DISPATCH_SUMMARY. */\nexport function formatAutoFixAttemptsSummary(tally: { written: number; failed: number }): string {\n return `AUTO_FIX_ATTEMPTS_SUMMARY written=${tally.written} failed=${tally.failed}`;\n}\n","import { openBase, readAirtableConfig, type AirtableBase } from \"../../reports/airtable/client.js\";\nimport { listWebsites, updateAutoFixAttempts } from \"../../reports/airtable/websites.js\";\nimport { makeGitHub } from \"../../github/gh.js\";\nimport {\n selectRenovateTargets,\n dispatchRenovateAcross,\n formatRenovateDispatchSummary,\n hasHealthyRenovatePr,\n computeAutoFixAttemptUpdates,\n applyAutoFixAttemptUpdates,\n formatAutoFixAttemptsSummary,\n type RenovateDispatchResult,\n} from \"../../github/renovate-dispatch.js\";\n\n/**\n * `renovate-dispatch --fleet`: read the Websites table, pick the active,\n * repo-backed sites the latest security sweep flagged with critical/high vulns,\n * and fire each one's `renovate.yml` `workflow_dispatch` so Renovate's OSV\n * vulnerability alerts open the remediation PR now (instead of waiting for the\n * weekly schedule). Designed to run as a best-effort follow-up step on\n * `fleet-security.yml` AFTER the sweep has written fresh counts to Airtable.\n *\n * Always exits 0: a missing token is a clean skip; a partial dispatch failure\n * (a repo without `renovate.yml`, or a token lacking `actions:write`) is reported\n * in the summary for the workflow to annotate, never failing the security sweep.\n */\nexport async function runRenovateDispatchCommand(opts: {\n fleet?: boolean | undefined;\n /** Inject a pre-opened Airtable base (tests). Defaults to env config. */\n base?: AirtableBase;\n}): Promise<{ output: string; code: number }> {\n if (!opts.fleet) {\n return { output: \"renovate-dispatch currently supports only --fleet\", code: 2 };\n }\n const token = process.env.RENOVATE_TOKEN?.trim() || process.env.GH_TOKEN?.trim();\n if (!token) {\n return {\n output: \"renovate-dispatch skipped: no RENOVATE_TOKEN/GH_TOKEN (fleet dispatch) configured.\",\n code: 0,\n };\n }\n\n const base = opts.base ?? openBase(readAirtableConfig());\n const websites = await listWebsites(base);\n const targets = selectRenovateTargets(websites);\n\n const lines: string[] = [];\n let result: RenovateDispatchResult = { dispatched: [], skipped: [], failed: [] };\n if (targets.length === 0) {\n lines.push(\n \"No active repo-backed sites with critical/high vulnerabilities — nothing to dispatch.\",\n );\n } else {\n const gh = makeGitHub({ token });\n result = await dispatchRenovateAcross(targets, {\n hasHealthyOpenRenovatePr: async (repo) =>\n hasHealthyRenovatePr(await gh.openPullRequests(repo)),\n defaultBranch: (repo) => gh.defaultBranch(repo),\n dispatch: (repo, workflow, ref) => gh.dispatchWorkflow(repo, workflow, ref),\n });\n\n const failedRepos = new Set(result.failed.map((f) => f.repo));\n const skippedRepos = new Set(result.skipped);\n for (const t of targets) {\n const status = failedRepos.has(t.repo)\n ? \"FAILED \"\n : skippedRepos.has(t.repo)\n ? \"skipped (open Renovate PR) \"\n : \"dispatched \";\n lines.push(`${status} ${t.repo} (critical=${t.critical} high=${t.high}) — ${t.siteName}`);\n }\n for (const f of result.failed) lines.push(` ↳ ${f.repo}: ${f.error}`);\n }\n\n // Counter bookkeeping runs on EVERY fleet run — the reset-on-clean branch was\n // previously unreachable behind the zero-targets early return, so a fully-clean\n // fleet never cleared stale counters (Alamo stuck at 7, 2026-07). Best-effort:\n // a failed write is tallied, never thrown. Uses the full `websites` list so\n // 0-vuln sites reset.\n const attemptUpdates = computeAutoFixAttemptUpdates(websites, result);\n const attemptTally = await applyAutoFixAttemptUpdates(attemptUpdates, (id, attempts) =>\n updateAutoFixAttempts(base, id, attempts),\n );\n\n lines.push(formatRenovateDispatchSummary(result));\n lines.push(formatAutoFixAttemptsSummary(attemptTally));\n\n return { output: lines.join(\"\\n\"), code: 0 };\n}\n"],"mappings":"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","names":[]}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|