@reddoorla/maintenance 0.67.0 → 0.69.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.
Files changed (99) hide show
  1. package/README.md +16 -0
  2. package/dist/airtable-WGQQ3KAD.js +9 -0
  3. package/dist/{announce-74ZIAV26.js → announce-72R3LRNT.js} +9 -11
  4. package/dist/{announce-74ZIAV26.js.map → announce-72R3LRNT.js.map} +1 -1
  5. package/dist/{bump-deps-HYBYNPMY.js → bump-deps-QNM3H4GI.js} +2 -2
  6. package/dist/{chunk-YKOOAWVL.js → chunk-2DCA7FHZ.js} +21 -15
  7. package/dist/chunk-2DCA7FHZ.js.map +1 -0
  8. package/dist/{chunk-LCKKIJ22.js → chunk-42KKVLYN.js} +3 -3
  9. package/dist/{chunk-VKAYZVMI.js → chunk-45AUGDFR.js} +2 -1
  10. package/dist/chunk-45AUGDFR.js.map +1 -0
  11. package/dist/{chunk-WCN54744.js → chunk-6G7BC5SN.js} +4 -4
  12. package/dist/{chunk-SYTPVYIK.js → chunk-6JFOAPW4.js} +241 -4
  13. package/dist/chunk-6JFOAPW4.js.map +1 -0
  14. package/dist/{chunk-Y4O44LZB.js → chunk-6MT5W6UF.js} +2 -2
  15. package/dist/{chunk-OAMJINM5.js → chunk-A5KYRGQA.js} +4 -4
  16. package/dist/{chunk-JDGIO54P.js → chunk-AESJBEM7.js} +43 -3
  17. package/dist/chunk-AESJBEM7.js.map +1 -0
  18. package/dist/{chunk-CL5DYUWQ.js → chunk-C3PDS623.js} +3 -1
  19. package/dist/chunk-C3PDS623.js.map +1 -0
  20. package/dist/chunk-E2GKKUEX.js +322 -0
  21. package/dist/chunk-E2GKKUEX.js.map +1 -0
  22. package/dist/{chunk-KN5OC5YH.js → chunk-GKSXZIMX.js} +3 -3
  23. package/dist/{chunk-XVR7UM6Z.js → chunk-JNXCDUYB.js} +4 -4
  24. package/dist/{chunk-ULL4AD3Y.js → chunk-O6G4HHMF.js} +7 -9
  25. package/dist/{chunk-ULL4AD3Y.js.map → chunk-O6G4HHMF.js.map} +1 -1
  26. package/dist/{chunk-6LIWDIXU.js → chunk-RINXPU3H.js} +2 -2
  27. package/dist/{chunk-T34EUM5B.js → chunk-XWPZ46IN.js} +2 -2
  28. package/dist/{chunk-7MJJEEII.js → chunk-YQMYQAJX.js} +2 -2
  29. package/dist/cli/bin.js +32 -14
  30. package/dist/cli/bin.js.map +1 -1
  31. package/dist/cli/commands/audit.js +8 -8
  32. package/dist/client/index.d.ts +84 -0
  33. package/dist/client/index.js +47 -0
  34. package/dist/client/index.js.map +1 -0
  35. package/dist/{convert-to-pnpm-5MPZWFHM.js → convert-to-pnpm-XH7NYRVK.js} +2 -2
  36. package/dist/{digest-MECMZPFE.js → digest-Q2TAQHXF.js} +17 -8
  37. package/dist/digest-Q2TAQHXF.js.map +1 -0
  38. package/dist/ensure-site-2PF522VL.js +87 -0
  39. package/dist/ensure-site-2PF522VL.js.map +1 -0
  40. package/dist/{github-signals-VWBMQNLH.js → github-signals-CLQC2IPY.js} +7 -7
  41. package/dist/index.d.ts +7 -1
  42. package/dist/index.js +103 -37
  43. package/dist/index.js.map +1 -1
  44. package/dist/{init-X3BCJAUG.js → init-UA6D7GKW.js} +7 -7
  45. package/dist/{launch-NQCL5K5H.js → launch-AENAS2O2.js} +15 -17
  46. package/dist/{launch-NQCL5K5H.js.map → launch-AENAS2O2.js.map} +1 -1
  47. package/dist/{lighthouse-airtable-QUNOSDT5.js → lighthouse-airtable-UBSXKHAQ.js} +3 -3
  48. package/dist/{onboard-7JOBZY35.js → onboard-X6KJ5Z2V.js} +2 -2
  49. package/dist/{orchestrate-JQX72RJE.js → orchestrate-GADNFGDH.js} +5 -7
  50. package/dist/preflight-D5RQGX4V.js +64 -0
  51. package/dist/preflight-D5RQGX4V.js.map +1 -0
  52. package/dist/recipes/sync-configs.js +2 -2
  53. package/dist/{renovate-dispatch-PWXFZNHN.js → renovate-dispatch-LG4RJGDY.js} +2 -2
  54. package/dist/{report-QUKNKUUE.js → report-3FJ224H5.js} +8 -9
  55. package/dist/{report-QUKNKUUE.js.map → report-3FJ224H5.js.map} +1 -1
  56. package/dist/{self-updating-OKJKU2FE.js → self-updating-JTTFOJ7Q.js} +2 -2
  57. package/dist/{selftest-6JVYR24F.js → selftest-55OJIWEB.js} +10 -12
  58. package/dist/{selftest-6JVYR24F.js.map → selftest-55OJIWEB.js.map} +1 -1
  59. package/dist/{svelte-codemods-GG6EEES5.js → svelte-codemods-KJLNH6FQ.js} +2 -2
  60. package/dist/{sync-configs-UFHZZPL7.js → sync-configs-TIBO6DUJ.js} +4 -4
  61. package/dist/{upgrade-S25N3473.js → upgrade-TPTE26FG.js} +2 -2
  62. package/dist/{websites-U3UZS5VG.js → websites-6YTT5ZTO.js} +2 -2
  63. package/dist/{write-audits-to-airtable-GAJUCSTY.js → write-audits-to-airtable-IWOO4FFH.js} +4 -4
  64. package/package.json +5 -1
  65. package/dist/airtable-33SY2UBZ.js +0 -9
  66. package/dist/chunk-CL5DYUWQ.js.map +0 -1
  67. package/dist/chunk-JDGIO54P.js.map +0 -1
  68. package/dist/chunk-OCS7SZUZ.js +0 -243
  69. package/dist/chunk-OCS7SZUZ.js.map +0 -1
  70. package/dist/chunk-OGGRFBL4.js +0 -14
  71. package/dist/chunk-OGGRFBL4.js.map +0 -1
  72. package/dist/chunk-SYTPVYIK.js.map +0 -1
  73. package/dist/chunk-VKAYZVMI.js.map +0 -1
  74. package/dist/chunk-YKOOAWVL.js.map +0 -1
  75. package/dist/digest-MECMZPFE.js.map +0 -1
  76. /package/dist/{airtable-33SY2UBZ.js.map → airtable-WGQQ3KAD.js.map} +0 -0
  77. /package/dist/{bump-deps-HYBYNPMY.js.map → bump-deps-QNM3H4GI.js.map} +0 -0
  78. /package/dist/{chunk-LCKKIJ22.js.map → chunk-42KKVLYN.js.map} +0 -0
  79. /package/dist/{chunk-WCN54744.js.map → chunk-6G7BC5SN.js.map} +0 -0
  80. /package/dist/{chunk-Y4O44LZB.js.map → chunk-6MT5W6UF.js.map} +0 -0
  81. /package/dist/{chunk-OAMJINM5.js.map → chunk-A5KYRGQA.js.map} +0 -0
  82. /package/dist/{chunk-KN5OC5YH.js.map → chunk-GKSXZIMX.js.map} +0 -0
  83. /package/dist/{chunk-XVR7UM6Z.js.map → chunk-JNXCDUYB.js.map} +0 -0
  84. /package/dist/{chunk-6LIWDIXU.js.map → chunk-RINXPU3H.js.map} +0 -0
  85. /package/dist/{chunk-T34EUM5B.js.map → chunk-XWPZ46IN.js.map} +0 -0
  86. /package/dist/{chunk-7MJJEEII.js.map → chunk-YQMYQAJX.js.map} +0 -0
  87. /package/dist/{convert-to-pnpm-5MPZWFHM.js.map → convert-to-pnpm-XH7NYRVK.js.map} +0 -0
  88. /package/dist/{github-signals-VWBMQNLH.js.map → github-signals-CLQC2IPY.js.map} +0 -0
  89. /package/dist/{init-X3BCJAUG.js.map → init-UA6D7GKW.js.map} +0 -0
  90. /package/dist/{lighthouse-airtable-QUNOSDT5.js.map → lighthouse-airtable-UBSXKHAQ.js.map} +0 -0
  91. /package/dist/{onboard-7JOBZY35.js.map → onboard-X6KJ5Z2V.js.map} +0 -0
  92. /package/dist/{orchestrate-JQX72RJE.js.map → orchestrate-GADNFGDH.js.map} +0 -0
  93. /package/dist/{renovate-dispatch-PWXFZNHN.js.map → renovate-dispatch-LG4RJGDY.js.map} +0 -0
  94. /package/dist/{self-updating-OKJKU2FE.js.map → self-updating-JTTFOJ7Q.js.map} +0 -0
  95. /package/dist/{svelte-codemods-GG6EEES5.js.map → svelte-codemods-KJLNH6FQ.js.map} +0 -0
  96. /package/dist/{sync-configs-UFHZZPL7.js.map → sync-configs-TIBO6DUJ.js.map} +0 -0
  97. /package/dist/{upgrade-S25N3473.js.map → upgrade-TPTE26FG.js.map} +0 -0
  98. /package/dist/{websites-U3UZS5VG.js.map → websites-6YTT5ZTO.js.map} +0 -0
  99. /package/dist/{write-audits-to-airtable-GAJUCSTY.js.map → write-audits-to-airtable-IWOO4FFH.js.map} +0 -0
@@ -1,23 +1,21 @@
1
1
  import {
2
2
  queueDraft
3
- } from "./chunk-6LIWDIXU.js";
3
+ } from "./chunk-RINXPU3H.js";
4
4
  import {
5
+ checklistFor,
6
+ createDraft,
7
+ listReportsForSite,
5
8
  renderReportHtml,
6
9
  resolveCopy,
7
10
  uploadAttachment
8
- } from "./chunk-SYTPVYIK.js";
9
- import {
10
- checklistFor,
11
- createDraft,
12
- listReportsForSite
13
- } from "./chunk-OCS7SZUZ.js";
11
+ } from "./chunk-6JFOAPW4.js";
14
12
  import {
15
13
  isNetlifyAppUrl
16
14
  } from "./chunk-U5SZ4FOP.js";
17
15
  import {
18
16
  siteSlug,
19
17
  updateAnalyticsHealth
20
- } from "./chunk-CL5DYUWQ.js";
18
+ } from "./chunk-C3PDS623.js";
21
19
  import {
22
20
  defaultCredentialsPath
23
21
  } from "./chunk-6VAL7XJT.js";
@@ -441,4 +439,4 @@ export {
441
439
  fetchGaUsers,
442
440
  fetchSearch
443
441
  };
444
- //# sourceMappingURL=chunk-ULL4AD3Y.js.map
442
+ //# sourceMappingURL=chunk-O6G4HHMF.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/reports/ga/config.ts","../src/reports/draft.ts","../src/reports/auto-tick.ts","../src/reports/ga/client.ts","../src/reports/search/client.ts"],"sourcesContent":["import { dirname, join } from \"node:path\";\nimport { defaultCredentialsPath } from \"../../util/credentials.js\";\n\nexport type GaConfig = {\n /** Workspace user the service account impersonates (domain-wide delegation). */\n subject: string;\n /** Absolute path to the service-account JSON key file. */\n keyPath: string;\n};\n\n/**\n * Read GA configuration from the environment (credentials.env is already loaded into\n * process.env by the CLI entrypoint). Returns null when `GA_SUBJECT` is unset — the\n * signal that GA enrichment is simply not configured, so drafting skips it silently.\n *\n * `GA_SA_KEY_PATH` is optional; it defaults to `ga-service-account.json` alongside the\n * credentials file (e.g. ~/.config/reddoor-maint/), keeping the key out of the repo.\n */\nexport function readGaConfig(): GaConfig | null {\n const subject = process.env.GA_SUBJECT?.trim();\n if (!subject) return null;\n const keyPath =\n process.env.GA_SA_KEY_PATH?.trim() ||\n join(dirname(defaultCredentialsPath()), \"ga-service-account.json\");\n return { subject, keyPath };\n}\n","import { mkdir, writeFile } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport type { ReportType, LighthouseScores } from \"./types.js\";\nimport { renderReportHtml } from \"./render.js\";\nimport { siteSlug, updateAnalyticsHealth } from \"./airtable/websites.js\";\nimport { resolveCopy } from \"./copy.js\";\nimport type { WebsiteRow } from \"./airtable/websites.js\";\nimport type { ReportRow } from \"./airtable/reports.js\";\nimport { createDraft, listReportsForSite } from \"./airtable/reports.js\";\nimport { queueDraft } from \"./queue.js\";\nimport { autoTickChecklist } from \"./auto-tick.js\";\nimport { uploadAttachment } from \"./airtable/attachments.js\";\nimport type { AirtableBase } from \"./airtable/client.js\";\nimport { readGaConfig } from \"./ga/config.js\";\nimport { fetchPeriodUsers } from \"./ga/client.js\";\nimport { fetchSearchPresence } from \"./search/client.js\";\nimport type { SearchPresence } from \"./search/client.js\";\n\nexport type DraftOptions = {\n /** Where to write the local preview HTML when `previewOnly`. Defaults to `reports/<slug>/draft.html`. */\n previewPath?: string;\n /** If true: render locally only, never touch Airtable. */\n previewOnly?: boolean;\n /** UTC \"YYYY-MM\" recurrence key; falls back to periodEnd's month when omitted. */\n period?: string;\n /** Airtable record id of an EXISTING (not-ready) row to COMPLETE in place rather\n * than creating a new one. When set, we skip createDraft and only re-render →\n * upload the HTML attachment → flip Draft ready on this row. Used by the --due\n * re-draft path to finish a draft whose createDraft succeeded but whose\n * setDraftReady never ran (a crash mid-sequence wedged the period). */\n completeRowId?: string;\n /** The mapped ReportRow being completed, returned as `reportRow` from the\n * complete path so callers keep the same shape they get on the create path. */\n existingRow?: ReportRow;\n};\n\n/** An enrichment fetch that *errored* (not one that was legitimately skipped\n * because it isn't configured / the site lacks the inputs). Surfaced so a\n * fleet-wide GA/Search outage is visible in a `--due` batch summary instead of\n * hiding behind one easily-missed console.warn per site. */\nexport type SoftFailure = \"ga\" | \"search\";\n\nexport type DraftResult = {\n /** null when previewOnly. */\n reportRow: ReportRow | null;\n /** Path to the local preview file (only set when previewOnly). */\n htmlPath: string | null;\n /** Always present — the rendered HTML string. */\n html: string;\n /** Enrichment fetches that errored for this site (empty on success or skip). */\n softFailures: SoftFailure[];\n /** Whether the draft was placed in the approve queue. False when a higher-or-equal-tier\n * report is already queued for the site (single-queue rule); null on the previewOnly path. */\n queued: boolean | null;\n /** Ids of lower-tier queued reports this draft superseded (un-queued). */\n supersededIds: string[];\n};\n\nfunction scoresFromWebsite(siteRow: WebsiteRow): LighthouseScores {\n const { pScore, rScore, bpScore, seoScore } = siteRow;\n if (pScore === null || rScore === null || bpScore === null || seoScore === null) {\n throw new Error(\n `Site '${siteRow.name}' is missing one or more Lighthouse scores on the Websites row (pScore, rScore, bpScore, seoScore). ` +\n `Run 'reddoor-maint audit lighthouse' from the site's checkout and paste the four numbers into Airtable, then retry.`,\n );\n }\n return { performance: pScore, accessibility: rScore, bestPractices: bpScore, seo: seoScore };\n}\n\nfunction daysAgo(today: Date, n: number): Date {\n // UTC accessors to stay TZ-consistent with `due.ts` (and avoid landing\n // Airtable's `Period start` on a different calendar day than the operator\n // expects on late-night runs near a month boundary). See morning brief\n // 2026-05-29 (M1) for context.\n const out = new Date(today);\n out.setUTCDate(out.getUTCDate() - n);\n return out;\n}\n\n/**\n * Render and create an Airtable draft for one site.\n *\n * No idempotency guard here — the recurrence guard lives in draftDueReports\n * (cli/commands/report.ts), keyed on reportPeriodKey(dueDate). The manual\n * single-site path intentionally always drafts (an operator asking for a draft\n * gets one). findReportByPeriod (airtable/reports.ts) is the real-Airtable\n * point lookup available to dashboard/digest callers that need the same\n * idempotency guarantee outside the CLI batch loop.\n */\nexport async function draftReportForSite(\n base: AirtableBase | null,\n siteRow: WebsiteRow,\n reportType: ReportType,\n options: DraftOptions = {},\n): Promise<DraftResult> {\n const scores = scoresFromWebsite(siteRow);\n\n const today = new Date();\n const slug = siteSlug(siteRow.name);\n\n const periodStart =\n base !== null ? await derivePeriodStart(base, siteRow, reportType, today) : daysAgo(today, 30);\n\n const periodEnd = today;\n const completedOn = today;\n // \"Last Tested\" on the Maintenance email is the REAL timestamp of the most recent automated\n // Lighthouse audit — stamped live on the Websites row (`Last lighthouse audit at`) by\n // `audit lighthouse --write-airtable` every time the scores refresh. It is deliberately NOT\n // the `testing day` field: that's the recurrence anchor consumed by due.ts and is hand-set, so\n // it goes stale. Reading the audit timestamp keeps the date current with no manual upkeep.\n const lastTestedDate =\n reportType === \"Maintenance\" && siteRow.lastLighthouseAuditAt\n ? new Date(siteRow.lastLighthouseAuditAt)\n : null;\n\n // GA enrichment (real path only). Soft-fail: any GA problem leaves the numbers null so\n // the draft still proceeds (operator fills them manually) — GA is an enhancement, not a\n // gate. Rendered with the fetched numbers so the review HTML matches the Airtable fields.\n // An *error* (vs a legitimate not-configured skip) is recorded in softFailures so the\n // caller can surface a fleet-wide outage in the batch summary.\n const gaResult =\n base !== null ? await fetchGaUsers(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;\n const searchResult =\n base !== null ? await fetchSearch(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;\n const gaUsers = gaResult.value;\n const search = searchResult.value;\n const softFailures: SoftFailure[] = [\n ...(gaResult.softFailed ? ([\"ga\"] as const) : []),\n ...(searchResult.softFailed ? ([\"search\"] as const) : []),\n ];\n\n const cidName = `${slug}-header`;\n const { html } = await renderReportHtml({\n siteName: siteRow.name,\n siteUrl: siteRow.url,\n reportType,\n completedOn,\n lighthouse: scores,\n gaUsersCurrent: gaUsers?.current,\n gaUsersPrevious: gaUsers?.previous,\n searchPosition: search?.foundOnPage1 ? (search.position ?? undefined) : undefined,\n lastTestedDate,\n commentary: null,\n copy: resolveCopy(siteRow),\n headerImageCid: cidName,\n });\n\n if (options.previewOnly) {\n const path = options.previewPath ?? `reports/${slug}/draft.html`;\n await mkdir(dirname(path), { recursive: true });\n await writeFile(path, html, \"utf-8\");\n return { reportRow: null, htmlPath: path, html, softFailures, queued: null, supersededIds: [] };\n }\n\n if (base === null) throw new Error(\"base required when previewOnly=false\");\n\n // Record this site's GA/Search enrichment health for the per-site analytics-failure\n // signal (cockpit/digest). Only when analytics is configured for THIS site — set the\n // timestamp on a soft-fail, clear it (null) on a clean enrichment so the signal\n // self-heals. Best-effort: the `Analytics soft-fail at` column is operator-added, so\n // until it exists the write throws UNKNOWN_FIELD_NAME — which must NOT break drafting.\n if (readGaConfig() !== null && Boolean(siteRow.ga4PropertyId || siteRow.searchQuery)) {\n try {\n await updateAnalyticsHealth(\n base,\n siteRow.id,\n softFailures.length > 0 ? today.toISOString() : null,\n );\n } catch (e) {\n console.warn(`⚠ analytics-health write skipped for ${siteRow.name}: ${(e as Error).message}`);\n }\n }\n\n // \"Finish an existing row\" path (the --due re-draft wedge fix). When the caller\n // hands us a row that was created but never made Draft-ready — a crash between\n // createDraft and setDraftReady leaves exactly this — we DON'T createDraft again\n // (that would duplicate the period). We re-attach the rendered HTML and queue the\n // EXISTING row, completing the half-made draft in place. The row's other fields\n // (scores, period, dates) were already written at create time; the only pieces a\n // crash drops are the attachment + the ready flag.\n if (options.completeRowId) {\n await uploadDraftHtml(options.completeRowId, slug, periodEnd, html);\n const outcome = await queueDraft(base, {\n id: options.completeRowId,\n siteId: siteRow.id,\n reportType,\n });\n return {\n reportRow: options.existingRow ?? null,\n htmlPath: null,\n html,\n softFailures,\n queued: outcome.queued,\n supersededIds: outcome.supersededIds,\n };\n }\n\n // Auto-tick the checklist boxes the system can prove (Phase 1: Google Indexed via the\n // inline search signal). Fail-safe lives in autoTickChecklist: only `pass` entries are ticked;\n // the evidence snapshot drives the dashboard's green/amber badges. The operator's approve gate\n // and per-box override are unchanged.\n const evidence = autoTickChecklist(siteRow, reportType, completedOn, { search: searchResult });\n const checklistTicks = [...evidence.entries()]\n .filter(([, e]) => e.result === \"pass\")\n .map(([field]) => field);\n const autoEvidence = Object.fromEntries(evidence);\n\n const reportId = `${siteRow.name} — ${reportType} — ${periodEnd.toISOString().slice(0, 10)}`;\n const created = await createDraft(base, {\n reportId,\n siteId: siteRow.id,\n reportType,\n period: options.period ?? periodEnd.toISOString().slice(0, 7),\n periodStart,\n periodEnd,\n completedOn,\n lighthouse: scores,\n lastTestedDate,\n ...(gaUsers ? { gaUsersCurrent: gaUsers.current, gaUsersPrevious: gaUsers.previous } : {}),\n ...(search ? { searchFoundPage1: search.foundOnPage1 } : {}),\n ...(search?.foundOnPage1 && search.position !== null\n ? { searchPosition: search.position }\n : {}),\n checklistTicks,\n autoEvidence,\n });\n\n await uploadDraftHtml(created.id, slug, periodEnd, html);\n const outcome = await queueDraft(base, {\n id: created.id,\n siteId: siteRow.id,\n reportType,\n });\n\n return {\n reportRow: created,\n htmlPath: null,\n html,\n softFailures,\n queued: outcome.queued,\n supersededIds: outcome.supersededIds,\n };\n}\n\n/** Attach the rendered HTML to a Reports row. Queueing (Draft ready + the single-queue\n * reconciliation) is handled separately by queueDraft so both the create path and the\n * \"complete a half-made row\" path share the identical, re-runnable upload step. */\nasync function uploadDraftHtml(\n rowId: string,\n slug: string,\n periodEnd: Date,\n html: string,\n): Promise<void> {\n const htmlFilename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;\n await uploadAttachment(rowId, \"Rendered HTML\", html, htmlFilename, \"text/html\");\n}\n\n/** Result of an enrichment fetch: the value (null if unavailable) plus whether\n * it errored (`softFailed`) as opposed to being legitimately not-configured. */\ntype Enrichment<T> = { value: T | null; softFailed: boolean };\n/** A not-configured / skipped enrichment — null value, not a soft-failure. */\nconst NO_ENRICHMENT: Enrichment<never> = { value: null, softFailed: false };\n\n/**\n * Fetch GA \"Users\" for the period, soft-failing to null. Returns a null value (no enrichment)\n * when GA isn't configured (`GA_SUBJECT` unset) or the site has no GA4 property ID — those are\n * legitimate skips, `softFailed: false`. When the GA API errors it logs a one-line warning and\n * returns `softFailed: true`. Never throws, so a GA problem can never block a draft; the\n * operator can always enter the numbers by hand.\n */\nexport async function fetchGaUsers(\n siteRow: WebsiteRow,\n periodStart: Date,\n periodEnd: Date,\n): Promise<Enrichment<{ current: number; previous: number }>> {\n const cfg = readGaConfig();\n if (!cfg || !siteRow.ga4PropertyId) return NO_ENRICHMENT;\n try {\n const value = await fetchPeriodUsers(\n { propertyId: siteRow.ga4PropertyId, subject: cfg.subject, keyPath: cfg.keyPath },\n periodStart,\n periodEnd,\n );\n return { value, softFailed: false };\n } catch (e) {\n console.warn(`⚠ GA skipped for ${siteRow.name}: ${(e as Error).message}`);\n return { value: null, softFailed: true };\n }\n}\n\n/**\n * Fetch the site's Google search presence for the period, soft-failing to null. Returns a null\n * value when GA/SA isn't configured (`readGaConfig()` null — search shares the SA credentials)\n * or the site has no `searchQuery` (legitimate skips, `softFailed: false`). When the Search\n * Console API errors it logs a one-line warning and returns `softFailed: true`. Never throws,\n * so a search problem can never block a draft.\n */\nexport async function fetchSearch(\n siteRow: WebsiteRow,\n periodStart: Date,\n periodEnd: Date,\n): Promise<Enrichment<SearchPresence>> {\n const cfg = readGaConfig();\n if (!cfg || !siteRow.searchQuery) return NO_ENRICHMENT;\n try {\n const value = await fetchSearchPresence(\n {\n keyPath: cfg.keyPath,\n subject: cfg.subject,\n property: siteRow.searchConsoleProperty ?? undefined,\n host: siteRow.url,\n query: siteRow.searchQuery,\n },\n periodStart,\n periodEnd,\n );\n return { value, softFailed: false };\n } catch (e) {\n console.warn(`⚠ Search presence skipped for ${siteRow.name}: ${(e as Error).message}`);\n return { value: null, softFailed: true };\n }\n}\n\nasync function derivePeriodStart(\n base: AirtableBase,\n siteRow: WebsiteRow,\n reportType: ReportType,\n today: Date,\n): Promise<Date> {\n const prior = await listReportsForSite(base, siteRow.id);\n const sameType = prior\n .filter((r) => r.reportType === reportType && r.periodEnd)\n .map((r) => r.periodEnd!)\n .sort();\n const latest = sameType[sameType.length - 1];\n if (!latest) return daysAgo(today, 30);\n // Half-open periods. The prior report's GA/Search windows are inclusive of its\n // periodEnd, so starting this report on the *same* day double-counts that\n // boundary day across two consecutive reports (and inflates the headline Users\n // count). Start the next day instead. UTC to stay TZ-consistent with daysAgo.\n const start = new Date(latest);\n start.setUTCDate(start.getUTCDate() + 1);\n return start;\n}\n","import type { ReportType } from \"./types.js\";\nimport type { WebsiteRow } from \"./airtable/websites.js\";\nimport type { SearchPresence } from \"./search/client.js\";\nimport { checklistFor } from \"./checklist.js\";\nimport { isNetlifyAppUrl } from \"../util/url.js\";\n\n/** A persisted audit signal is only trusted within this window — a stale \"pass\" degrades to\n * \"unknown\" (no tick). Matches the github-signals staleness convention (~3 days). */\nconst STALE_DAYS = 3;\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\n\n/** True when `checkedAt` is a valid timestamp within STALE_DAYS of `now`. */\nfunction isFresh(checkedAt: string | null, now: Date): boolean {\n if (!checkedAt) return false;\n const t = new Date(checkedAt).getTime();\n if (Number.isNaN(t)) return false;\n return now.getTime() - t <= STALE_DAYS * MS_PER_DAY;\n}\n\n/** Cert-expiry buffer: a cert must have MORE than this many days left to pass (renew window).\n * Exactly 14 days does NOT pass — matches the spec's strict \">14 days\" and the domain audit's\n * own `> 14` pass status, so the two thresholds can't drift. */\nconst CERT_MIN_DAYS = 14;\n\n/** A single auto-check outcome. `pass` + fresh ⇒ the caller ticks the box. */\nexport type EvidenceResult = \"pass\" | \"fail\" | \"unknown\";\nexport type EvidenceRecord = { result: EvidenceResult; checkedAt: string | null; note: string };\n\n/**\n * Inline signals fetched during the draft. Phase 1 carries Search Console only; Phase 2/3 read\n * the remaining signals (security, domain, browser) off the Websites row passed as `site`.\n */\nexport type AutoTickSignals = {\n search: { value: SearchPresence | null; softFailed: boolean };\n};\n\n/**\n * Decide, per checklist item for this report type, an evidence record — ONLY for items that\n * currently have a signal. Items with no signal are omitted, so the caller leaves them manual.\n * PURE. The fail-safe invariant lives here: a box is only tickable when `result === \"pass\"`.\n * Google Indexed is an inline signal (fetched at draft), so its evidence is inherently fresh\n * (`checkedAt = now`).\n */\nexport function autoTickChecklist(\n site: WebsiteRow,\n reportType: ReportType,\n now: Date,\n signals: AutoTickSignals,\n): Map<string, EvidenceRecord> {\n const out = new Map<string, EvidenceRecord>();\n const fields = new Set(checklistFor(reportType).map((i) => i.field));\n\n // Google Indexed — Search Console (inline, always fresh).\n if (fields.has(\"Maint: Google Indexed\")) {\n const g = googleEvidence(now, signals.search);\n if (g) out.set(\"Maint: Google Indexed\", g);\n }\n\n // Security Updates — the nightly `security` audit's persisted vuln counts.\n if (fields.has(\"Maint: Security Updates\")) {\n const s = securityEvidence(site, now);\n if (s) out.set(\"Maint: Security Updates\", s);\n }\n\n // Domain, DNS & SSL — the nightly `domain` audit's persisted cert/resolve signal.\n if (fields.has(\"Maint: Domain, DNS & SSL\")) {\n const d = domainEvidence(site, now);\n if (d) out.set(\"Maint: Domain, DNS & SSL\", d);\n }\n\n // Desktop / Mobile / Links — the nightly `browser` audit's persisted verdicts (one timestamp\n // gates all three). Each is a stored boolean computed by the audit (multi-engine/route);\n // here we add freshness + omit-when-absent.\n if (fields.has(\"Test: Desktop Browsers\")) {\n const e = browserEvidence(\n site.crossbrowserOk,\n site,\n now,\n \"Desktop renders cleanly\",\n \"render errors\",\n );\n if (e) out.set(\"Test: Desktop Browsers\", e);\n }\n if (fields.has(\"Test: Mobile Browsers\")) {\n const e = browserEvidence(\n site.mobileOk,\n site,\n now,\n \"Mobile renders cleanly\",\n \"overflow/errors\",\n );\n if (e) out.set(\"Test: Mobile Browsers\", e);\n }\n if (fields.has(\"Test: Links & Navigation\")) {\n const broken = site.brokenLinks;\n const failNote = broken && broken > 0 ? `${broken} broken link(s)` : \"broken links / nav\";\n const e = browserEvidence(site.linksOk, site, now, \"All internal links resolve\", failNote);\n if (e) out.set(\"Test: Links & Navigation\", e);\n }\n\n return out;\n}\n\n/** One browser-audit verdict (Desktop/Mobile/Links) → evidence, gated on `Browser checked at`\n * freshness. null verdict (never run) → omit (manual); stale → unknown; true → pass; false →\n * fail (with the failure note). The verdict itself was computed by the audit (summarizeBrowser). */\nfunction browserEvidence(\n ok: boolean | null,\n site: WebsiteRow,\n now: Date,\n passNote: string,\n failNote: string,\n): EvidenceRecord | null {\n if (ok === null || !site.browserCheckedAt) return null;\n const at = site.browserCheckedAt;\n if (!isFresh(at, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"Browser check is stale (>3d)\" };\n }\n return ok\n ? { result: \"pass\", checkedAt: at, note: passNote }\n : { result: \"fail\", checkedAt: at, note: failNote };\n}\n\n/**\n * Security Updates evidence from the persisted `security` audit. Omits (→ manual) when the audit\n * never ran (counts null / no timestamp); stale → unknown; 0 critical AND 0 high → pass; any\n * critical/high → fail (with the count). Honest scope: \"no known critical/high advisories in the\n * declared dependencies as of the last audit\" — moderate/low are advisory, not gating, and this\n * does not prove the fix is deployed.\n */\nfunction securityEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n const crit = site.securityVulnsCritical;\n const high = site.securityVulnsHigh;\n if (crit === null || high === null || !site.lastSecurityAuditAt) return null;\n const at = site.lastSecurityAuditAt;\n if (!isFresh(at, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"Security audit is stale (>3d)\" };\n }\n if (crit === 0 && high === 0) {\n return { result: \"pass\", checkedAt: at, note: \"No known critical/high vulnerabilities\" };\n }\n return { result: \"fail\", checkedAt: at, note: `${crit} critical / ${high} high vuln(s)` };\n}\n\n/** Search Console → Google Indexed evidence. `null` (not configured) emits nothing so the box\n * stays manual; a soft-fail is `unknown`; page-1 is `pass` (with the position in the note). */\nfunction googleEvidence(now: Date, search: AutoTickSignals[\"search\"]): EvidenceRecord | null {\n const at = now.toISOString();\n if (search.softFailed) {\n return { result: \"unknown\", checkedAt: at, note: \"Search Console unavailable this run\" };\n }\n if (search.value === null) return null;\n if (search.value.foundOnPage1) {\n const pos = search.value.position;\n return {\n result: \"pass\",\n checkedAt: at,\n note: `Page 1 on Google${pos !== null ? ` (#${pos})` : \"\"}`,\n };\n }\n const pos = search.value.position;\n return {\n result: \"fail\",\n checkedAt: at,\n note: `Not on page 1${pos !== null ? ` (avg #${pos})` : \"\"}`,\n };\n}\n\n/**\n * Domain/DNS/SSL evidence from the persisted `domain` audit. Omits (→ manual) when there's no\n * custom domain to verify (`*.netlify.app`) or the audit never ran. A `pass` requires a fresh\n * check that resolved with a cert comfortably before expiry; stale → unknown; resolved-but-no-cert\n * or near/past expiry → fail. The honest scope is resolve + valid cert — NOT registrar expiry,\n * www↔apex redirect, or MX.\n */\nfunction domainEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n // No custom domain to verify — the no-domain watch signal covers `*.netlify.app` elsewhere.\n if (!site.url || isNetlifyAppUrl(site.url)) return null;\n if (!site.domainCheckedAt) return null; // never probed → leave manual\n const at = site.domainCheckedAt;\n if (!isFresh(site.domainCheckedAt, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"Domain check is stale (>3d)\" };\n }\n const days = site.certDaysRemaining;\n if (days === null) {\n return { result: \"fail\", checkedAt: at, note: \"Did not resolve, or no valid TLS cert\" };\n }\n if (days <= CERT_MIN_DAYS) {\n return { result: \"fail\", checkedAt: at, note: `TLS cert expires in ${days}d` };\n }\n return { result: \"pass\", checkedAt: at, note: `Custom domain, valid cert (${days}d left)` };\n}\n","import { readFileSync } from \"node:fs\";\nimport { JWT } from \"google-auth-library\";\nimport { BetaAnalyticsDataClient } from \"@google-analytics/data\";\n\nconst ANALYTICS_READONLY = \"https://www.googleapis.com/auth/analytics.readonly\";\nconst MS_PER_DAY = 86_400_000;\n\nexport type GaQuery = {\n /** GA4 numeric property ID (e.g. \"471880366\"). */\n propertyId: string;\n /** Workspace user to impersonate via domain-wide delegation. */\n subject: string;\n /** Path to the service-account JSON key. */\n keyPath: string;\n};\n\n/** UTC YYYY-MM-DD — matches the rest of the reports pipeline's date handling. */\nfunction ymd(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Fetch GA4 `activeUsers` (\"Users\") for a report period and the equal-length window\n * immediately before it, via a domain-wide-delegation service account that impersonates\n * `subject`. Throws on any auth/API error — the caller (draftReportForSite) soft-fails.\n *\n * Previous window: same length as the current period, ending the day before `periodStart`.\n */\nexport async function fetchPeriodUsers(\n query: GaQuery,\n periodStart: Date,\n periodEnd: Date,\n): Promise<{ current: number; previous: number }> {\n const key = JSON.parse(readFileSync(query.keyPath, \"utf8\")) as {\n client_email: string;\n private_key: string;\n };\n const authClient = new JWT({\n email: key.client_email,\n key: key.private_key,\n scopes: [ANALYTICS_READONLY],\n subject: query.subject,\n });\n const client = new BetaAnalyticsDataClient({ authClient });\n\n const lengthDays = Math.round((periodEnd.getTime() - periodStart.getTime()) / MS_PER_DAY);\n const prevEnd = new Date(periodStart.getTime() - MS_PER_DAY);\n const prevStart = new Date(prevEnd.getTime() - lengthDays * MS_PER_DAY);\n\n const property = `properties/${query.propertyId}`;\n const run = async (start: Date, end: Date): Promise<number> => {\n const [resp] = await client.runReport({\n property,\n dateRanges: [{ startDate: ymd(start), endDate: ymd(end) }],\n metrics: [{ name: \"activeUsers\" }],\n });\n const raw = resp.rows?.[0]?.metricValues?.[0]?.value ?? \"0\";\n const n = Number.parseInt(raw, 10);\n return Number.isFinite(n) ? n : 0;\n };\n\n const current = await run(periodStart, periodEnd);\n const previous = await run(prevStart, prevEnd);\n return { current, previous };\n}\n","import { readFileSync } from \"node:fs\";\nimport { JWT } from \"google-auth-library\";\n\nconst WEBMASTERS_READONLY = \"https://www.googleapis.com/auth/webmasters.readonly\";\nconst SC_BASE = \"https://searchconsole.googleapis.com/webmasters/v3\";\n/** Average-position threshold for \"on page 1\" (10 organic results per page). */\nconst PAGE_1_MAX_POSITION = 10;\n/** Rows to pull for the brand `contains` match. searchAnalytics has no orderBy and returns\n * rows clicks-desc, so the exact-query row survives into the response only if it isn't paged\n * out beyond this cap. Sized with wide headroom — a brand has a handful of phrasing variants,\n * far below 100 — so the exact query (and every variant) is in practice always present. */\nexport const BRAND_QUERY_ROW_LIMIT = 100;\n\nexport type SearchPresenceQuery = {\n /** Path to the service-account JSON key (same one GA uses). */\n keyPath: string;\n /** Workspace user to impersonate via domain-wide delegation. */\n subject: string;\n /** Explicit Search Console property (`sc-domain:...` or `https://.../`). Overrides auto-resolution. */\n property?: string | undefined;\n /** Site host, used to auto-resolve the property from `sites.list` when `property` is absent. */\n host: string;\n /** Operator-supplied brand hint (e.g. the business name). Matched as a case-insensitive\n * SUBSTRING (`contains`) of the actual user query — NOT a hard exact string — so it tolerates\n * phrasing (\"red door creative\" vs \"…creative la\"). An exact-query match is preferred when\n * present; otherwise the most-searched matching query wins. */\n query: string;\n};\n\nexport type SearchPresence = {\n /** True when the average position for the query is on page 1 (<= 10). */\n foundOnPage1: boolean;\n /** Rounded average position, or null when not found / no data. */\n position: number | null;\n};\n\ntype SiteEntry = { siteUrl: string };\n\n/** Reduce any property string or URL to a bare host: no `sc-domain:`, scheme, `www.`, path, lowercased. */\nexport function bareHost(s: string): string {\n return s\n .trim()\n .replace(/^sc-domain:/i, \"\")\n .replace(/^https?:\\/\\//i, \"\")\n .split(\"/\")[0]!\n .replace(/^www\\./i, \"\")\n .toLowerCase();\n}\n\n/**\n * All Search Console properties matching `host`, ordered for query fallback: Domain\n * (`sc-domain:`) forms first (broadest coverage), then URL-prefix forms. A site can be verified\n * as both; a freshly-created Domain property has no backfilled history, so its data can be empty\n * even while a long-lived URL-prefix property has data — hence we return every match and let the\n * caller try them in order until one returns data. Empty list = nothing matches.\n */\nexport function resolvePropertyCandidates(entries: SiteEntry[], host: string): string[] {\n const target = bareHost(host);\n const matches = entries.filter((e) => bareHost(e.siteUrl) === target).map((e) => e.siteUrl);\n const domains = matches.filter((s) => s.toLowerCase().startsWith(\"sc-domain:\"));\n const prefixes = matches.filter((s) => !s.toLowerCase().startsWith(\"sc-domain:\"));\n return [...domains, ...prefixes];\n}\n\n/** UTC YYYY-MM-DD — matches the rest of the reports pipeline. */\nfunction ymd(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * From the queries matching the brand substring, pick the one the brand is actually FOUND\n * by most — the highest-impression row — and return its average position; tie-break on the\n * better (lower) position. Returns undefined when no row carries a numeric position. This is\n * what makes the reported \"brand search\" position robust to phrasing: with a `contains`\n * filter several variants can match, and we report the position of the variant real users\n * search most, not whichever the operator happened to type. Rows missing `impressions` count\n * as 0 (keeps a single-row, impression-less response — e.g. a test fixture — working). PURE.\n */\nexport function pickBrandQuery(\n rows: Array<{ position?: number; impressions?: number }>,\n): number | undefined {\n let best: { position: number; impressions: number } | undefined;\n for (const r of rows) {\n if (typeof r.position !== \"number\") continue;\n const impressions = r.impressions ?? 0;\n if (\n best === undefined ||\n impressions > best.impressions ||\n (impressions === best.impressions && r.position < best.position)\n ) {\n best = { position: r.position, impressions };\n }\n }\n return best?.position;\n}\n\n/**\n * The brand's reported position from the `contains`-matched rows, preferring an EXACT match.\n * When a returned row's query equals the operator's configured string (case-insensitive), its\n * position is used verbatim — a precisely-configured brand query is honored, with no chance a\n * longer higher-impression variant (\"…reviews\", \"…near me\") hijacks the number. Otherwise we\n * fall back to the most-searched matching query (`pickBrandQuery`). Operates only on the rows\n * actually returned: a string `contains` itself, so the exact query is among the `contains`\n * results whenever it has data AND isn't paged out beyond `BRAND_QUERY_ROW_LIMIT` (sized so a\n * brand's handful of phrasing variants always fit; if it ever were paged out, the most-searched\n * fallback still returns a valid brand position). Returns undefined when no row carries a\n * numeric position. PURE.\n */\nexport function selectBrandPosition(\n rows: Array<{ keys?: string[]; position?: number; impressions?: number }>,\n exactQuery: string,\n): number | undefined {\n const want = exactQuery.toLowerCase();\n const exact = rows.find(\n (r) => typeof r.position === \"number\" && (r.keys?.[0] ?? \"\").toLowerCase() === want,\n );\n if (exact) return exact.position;\n return pickBrandQuery(rows);\n}\n\n/**\n * Query Google Search Console for the brand's average position on the site over the report\n * period, via a domain-wide-delegation service account impersonating `subject`. `query` is a\n * case-insensitive SUBSTRING (`contains`) hint; among the matching user queries we report the\n * position of the exact-match query when present, else the most-searched one (see\n * `selectBrandPosition`) — so it doesn't depend on the operator typing the exact search string,\n * yet honors one verbatim when they do. Uses `property` verbatim when given (operator's\n * choice is final — no fallback); otherwise auto-discovers all matching properties via\n * `sites.list` and tries them in order (Domain first) until one returns data. Throws on any\n * auth/API error — the caller (draftReportForSite) soft-fails.\n */\nexport async function fetchSearchPresence(\n q: SearchPresenceQuery,\n periodStart: Date,\n periodEnd: Date,\n): Promise<SearchPresence> {\n const key = JSON.parse(readFileSync(q.keyPath, \"utf8\")) as {\n client_email: string;\n private_key: string;\n };\n const jwt = new JWT({\n email: key.client_email,\n key: key.private_key,\n scopes: [WEBMASTERS_READONLY],\n subject: q.subject,\n });\n\n const explicit = q.property?.trim();\n let candidates: string[];\n if (explicit) {\n candidates = [explicit];\n } else {\n const list = await jwt.request<{ siteEntry?: SiteEntry[] }>({\n url: `${SC_BASE}/sites`,\n method: \"GET\",\n });\n candidates = resolvePropertyCandidates(list.data.siteEntry ?? [], q.host);\n if (candidates.length === 0) return { foundOnPage1: false, position: null };\n }\n\n for (const property of candidates) {\n const res = await jwt.request<{\n rows?: Array<{ keys?: string[]; position?: number; impressions?: number }>;\n }>({\n url: `${SC_BASE}/sites/${encodeURIComponent(property)}/searchAnalytics/query`,\n method: \"POST\",\n data: {\n startDate: ymd(periodStart),\n endDate: ymd(periodEnd),\n dimensions: [\"query\"],\n dimensionFilterGroups: [\n {\n filters: [\n { dimension: \"query\", operator: \"contains\", expression: q.query.toLowerCase() },\n ],\n },\n ],\n rowLimit: BRAND_QUERY_ROW_LIMIT,\n },\n });\n const pos = selectBrandPosition(res.data.rows ?? [], q.query.toLowerCase());\n if (typeof pos === \"number\") {\n // Search Console can average below 1; floor to 1 so the template never\n // renders a nonsensical \"#0\" (positions are 1-indexed).\n return { foundOnPage1: pos <= PAGE_1_MAX_POSITION, position: Math.max(1, Math.round(pos)) };\n }\n }\n return { foundOnPage1: false, position: null };\n}\n"],"mappings":"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","names":["dirname","pos","MS_PER_DAY","readFileSync","JWT","ymd","dirname","outcome"]}
1
+ {"version":3,"sources":["../src/reports/ga/config.ts","../src/reports/draft.ts","../src/reports/auto-tick.ts","../src/reports/ga/client.ts","../src/reports/search/client.ts"],"sourcesContent":["import { dirname, join } from \"node:path\";\nimport { defaultCredentialsPath } from \"../../util/credentials.js\";\n\nexport type GaConfig = {\n /** Workspace user the service account impersonates (domain-wide delegation). */\n subject: string;\n /** Absolute path to the service-account JSON key file. */\n keyPath: string;\n};\n\n/**\n * Read GA configuration from the environment (credentials.env is already loaded into\n * process.env by the CLI entrypoint). Returns null when `GA_SUBJECT` is unset — the\n * signal that GA enrichment is simply not configured, so drafting skips it silently.\n *\n * `GA_SA_KEY_PATH` is optional; it defaults to `ga-service-account.json` alongside the\n * credentials file (e.g. ~/.config/reddoor-maint/), keeping the key out of the repo.\n */\nexport function readGaConfig(): GaConfig | null {\n const subject = process.env.GA_SUBJECT?.trim();\n if (!subject) return null;\n const keyPath =\n process.env.GA_SA_KEY_PATH?.trim() ||\n join(dirname(defaultCredentialsPath()), \"ga-service-account.json\");\n return { subject, keyPath };\n}\n","import { mkdir, writeFile } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport type { ReportType, LighthouseScores } from \"./types.js\";\nimport { renderReportHtml } from \"./render.js\";\nimport { siteSlug, updateAnalyticsHealth } from \"./airtable/websites.js\";\nimport { resolveCopy } from \"./copy.js\";\nimport type { WebsiteRow } from \"./airtable/websites.js\";\nimport type { ReportRow } from \"./airtable/reports.js\";\nimport { createDraft, listReportsForSite } from \"./airtable/reports.js\";\nimport { queueDraft } from \"./queue.js\";\nimport { autoTickChecklist } from \"./auto-tick.js\";\nimport { uploadAttachment } from \"./airtable/attachments.js\";\nimport type { AirtableBase } from \"./airtable/client.js\";\nimport { readGaConfig } from \"./ga/config.js\";\nimport { fetchPeriodUsers } from \"./ga/client.js\";\nimport { fetchSearchPresence } from \"./search/client.js\";\nimport type { SearchPresence } from \"./search/client.js\";\n\nexport type DraftOptions = {\n /** Where to write the local preview HTML when `previewOnly`. Defaults to `reports/<slug>/draft.html`. */\n previewPath?: string;\n /** If true: render locally only, never touch Airtable. */\n previewOnly?: boolean;\n /** UTC \"YYYY-MM\" recurrence key; falls back to periodEnd's month when omitted. */\n period?: string;\n /** Airtable record id of an EXISTING (not-ready) row to COMPLETE in place rather\n * than creating a new one. When set, we skip createDraft and only re-render →\n * upload the HTML attachment → flip Draft ready on this row. Used by the --due\n * re-draft path to finish a draft whose createDraft succeeded but whose\n * setDraftReady never ran (a crash mid-sequence wedged the period). */\n completeRowId?: string;\n /** The mapped ReportRow being completed, returned as `reportRow` from the\n * complete path so callers keep the same shape they get on the create path. */\n existingRow?: ReportRow;\n};\n\n/** An enrichment fetch that *errored* (not one that was legitimately skipped\n * because it isn't configured / the site lacks the inputs). Surfaced so a\n * fleet-wide GA/Search outage is visible in a `--due` batch summary instead of\n * hiding behind one easily-missed console.warn per site. */\nexport type SoftFailure = \"ga\" | \"search\";\n\nexport type DraftResult = {\n /** null when previewOnly. */\n reportRow: ReportRow | null;\n /** Path to the local preview file (only set when previewOnly). */\n htmlPath: string | null;\n /** Always present — the rendered HTML string. */\n html: string;\n /** Enrichment fetches that errored for this site (empty on success or skip). */\n softFailures: SoftFailure[];\n /** Whether the draft was placed in the approve queue. False when a higher-or-equal-tier\n * report is already queued for the site (single-queue rule); null on the previewOnly path. */\n queued: boolean | null;\n /** Ids of lower-tier queued reports this draft superseded (un-queued). */\n supersededIds: string[];\n};\n\nfunction scoresFromWebsite(siteRow: WebsiteRow): LighthouseScores {\n const { pScore, rScore, bpScore, seoScore } = siteRow;\n if (pScore === null || rScore === null || bpScore === null || seoScore === null) {\n throw new Error(\n `Site '${siteRow.name}' is missing one or more Lighthouse scores on the Websites row (pScore, rScore, bpScore, seoScore). ` +\n `Run 'reddoor-maint audit lighthouse' from the site's checkout and paste the four numbers into Airtable, then retry.`,\n );\n }\n return { performance: pScore, accessibility: rScore, bestPractices: bpScore, seo: seoScore };\n}\n\nfunction daysAgo(today: Date, n: number): Date {\n // UTC accessors to stay TZ-consistent with `due.ts` (and avoid landing\n // Airtable's `Period start` on a different calendar day than the operator\n // expects on late-night runs near a month boundary). See morning brief\n // 2026-05-29 (M1) for context.\n const out = new Date(today);\n out.setUTCDate(out.getUTCDate() - n);\n return out;\n}\n\n/**\n * Render and create an Airtable draft for one site.\n *\n * No idempotency guard here — the recurrence guard lives in draftDueReports\n * (cli/commands/report.ts), keyed on reportPeriodKey(dueDate). The manual\n * single-site path intentionally always drafts (an operator asking for a draft\n * gets one). findReportByPeriod (airtable/reports.ts) is the real-Airtable\n * point lookup available to dashboard/digest callers that need the same\n * idempotency guarantee outside the CLI batch loop.\n */\nexport async function draftReportForSite(\n base: AirtableBase | null,\n siteRow: WebsiteRow,\n reportType: ReportType,\n options: DraftOptions = {},\n): Promise<DraftResult> {\n const scores = scoresFromWebsite(siteRow);\n\n const today = new Date();\n const slug = siteSlug(siteRow.name);\n\n const periodStart =\n base !== null ? await derivePeriodStart(base, siteRow, reportType, today) : daysAgo(today, 30);\n\n const periodEnd = today;\n const completedOn = today;\n // \"Last Tested\" on the Maintenance email is the REAL timestamp of the most recent automated\n // Lighthouse audit — stamped live on the Websites row (`Last lighthouse audit at`) by\n // `audit lighthouse --write-airtable` every time the scores refresh. It is deliberately NOT\n // the `testing day` field: that's the recurrence anchor consumed by due.ts and is hand-set, so\n // it goes stale. Reading the audit timestamp keeps the date current with no manual upkeep.\n const lastTestedDate =\n reportType === \"Maintenance\" && siteRow.lastLighthouseAuditAt\n ? new Date(siteRow.lastLighthouseAuditAt)\n : null;\n\n // GA enrichment (real path only). Soft-fail: any GA problem leaves the numbers null so\n // the draft still proceeds (operator fills them manually) — GA is an enhancement, not a\n // gate. Rendered with the fetched numbers so the review HTML matches the Airtable fields.\n // An *error* (vs a legitimate not-configured skip) is recorded in softFailures so the\n // caller can surface a fleet-wide outage in the batch summary.\n const gaResult =\n base !== null ? await fetchGaUsers(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;\n const searchResult =\n base !== null ? await fetchSearch(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;\n const gaUsers = gaResult.value;\n const search = searchResult.value;\n const softFailures: SoftFailure[] = [\n ...(gaResult.softFailed ? ([\"ga\"] as const) : []),\n ...(searchResult.softFailed ? ([\"search\"] as const) : []),\n ];\n\n const cidName = `${slug}-header`;\n const { html } = await renderReportHtml({\n siteName: siteRow.name,\n siteUrl: siteRow.url,\n reportType,\n completedOn,\n lighthouse: scores,\n gaUsersCurrent: gaUsers?.current,\n gaUsersPrevious: gaUsers?.previous,\n searchPosition: search?.foundOnPage1 ? (search.position ?? undefined) : undefined,\n lastTestedDate,\n commentary: null,\n copy: resolveCopy(siteRow),\n headerImageCid: cidName,\n });\n\n if (options.previewOnly) {\n const path = options.previewPath ?? `reports/${slug}/draft.html`;\n await mkdir(dirname(path), { recursive: true });\n await writeFile(path, html, \"utf-8\");\n return { reportRow: null, htmlPath: path, html, softFailures, queued: null, supersededIds: [] };\n }\n\n if (base === null) throw new Error(\"base required when previewOnly=false\");\n\n // Record this site's GA/Search enrichment health for the per-site analytics-failure\n // signal (cockpit/digest). Only when analytics is configured for THIS site — set the\n // timestamp on a soft-fail, clear it (null) on a clean enrichment so the signal\n // self-heals. Best-effort: the `Analytics soft-fail at` column is operator-added, so\n // until it exists the write throws UNKNOWN_FIELD_NAME — which must NOT break drafting.\n if (readGaConfig() !== null && Boolean(siteRow.ga4PropertyId || siteRow.searchQuery)) {\n try {\n await updateAnalyticsHealth(\n base,\n siteRow.id,\n softFailures.length > 0 ? today.toISOString() : null,\n );\n } catch (e) {\n console.warn(`⚠ analytics-health write skipped for ${siteRow.name}: ${(e as Error).message}`);\n }\n }\n\n // \"Finish an existing row\" path (the --due re-draft wedge fix). When the caller\n // hands us a row that was created but never made Draft-ready — a crash between\n // createDraft and setDraftReady leaves exactly this — we DON'T createDraft again\n // (that would duplicate the period). We re-attach the rendered HTML and queue the\n // EXISTING row, completing the half-made draft in place. The row's other fields\n // (scores, period, dates) were already written at create time; the only pieces a\n // crash drops are the attachment + the ready flag.\n if (options.completeRowId) {\n await uploadDraftHtml(options.completeRowId, slug, periodEnd, html);\n const outcome = await queueDraft(base, {\n id: options.completeRowId,\n siteId: siteRow.id,\n reportType,\n });\n return {\n reportRow: options.existingRow ?? null,\n htmlPath: null,\n html,\n softFailures,\n queued: outcome.queued,\n supersededIds: outcome.supersededIds,\n };\n }\n\n // Auto-tick the checklist boxes the system can prove (Phase 1: Google Indexed via the\n // inline search signal). Fail-safe lives in autoTickChecklist: only `pass` entries are ticked;\n // the evidence snapshot drives the dashboard's green/amber badges. The operator's approve gate\n // and per-box override are unchanged.\n const evidence = autoTickChecklist(siteRow, reportType, completedOn, { search: searchResult });\n const checklistTicks = [...evidence.entries()]\n .filter(([, e]) => e.result === \"pass\")\n .map(([field]) => field);\n const autoEvidence = Object.fromEntries(evidence);\n\n const reportId = `${siteRow.name} — ${reportType} — ${periodEnd.toISOString().slice(0, 10)}`;\n const created = await createDraft(base, {\n reportId,\n siteId: siteRow.id,\n reportType,\n period: options.period ?? periodEnd.toISOString().slice(0, 7),\n periodStart,\n periodEnd,\n completedOn,\n lighthouse: scores,\n lastTestedDate,\n ...(gaUsers ? { gaUsersCurrent: gaUsers.current, gaUsersPrevious: gaUsers.previous } : {}),\n ...(search ? { searchFoundPage1: search.foundOnPage1 } : {}),\n ...(search?.foundOnPage1 && search.position !== null\n ? { searchPosition: search.position }\n : {}),\n checklistTicks,\n autoEvidence,\n });\n\n await uploadDraftHtml(created.id, slug, periodEnd, html);\n const outcome = await queueDraft(base, {\n id: created.id,\n siteId: siteRow.id,\n reportType,\n });\n\n return {\n reportRow: created,\n htmlPath: null,\n html,\n softFailures,\n queued: outcome.queued,\n supersededIds: outcome.supersededIds,\n };\n}\n\n/** Attach the rendered HTML to a Reports row. Queueing (Draft ready + the single-queue\n * reconciliation) is handled separately by queueDraft so both the create path and the\n * \"complete a half-made row\" path share the identical, re-runnable upload step. */\nasync function uploadDraftHtml(\n rowId: string,\n slug: string,\n periodEnd: Date,\n html: string,\n): Promise<void> {\n const htmlFilename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;\n await uploadAttachment(rowId, \"Rendered HTML\", html, htmlFilename, \"text/html\");\n}\n\n/** Result of an enrichment fetch: the value (null if unavailable) plus whether\n * it errored (`softFailed`) as opposed to being legitimately not-configured. */\ntype Enrichment<T> = { value: T | null; softFailed: boolean };\n/** A not-configured / skipped enrichment — null value, not a soft-failure. */\nconst NO_ENRICHMENT: Enrichment<never> = { value: null, softFailed: false };\n\n/**\n * Fetch GA \"Users\" for the period, soft-failing to null. Returns a null value (no enrichment)\n * when GA isn't configured (`GA_SUBJECT` unset) or the site has no GA4 property ID — those are\n * legitimate skips, `softFailed: false`. When the GA API errors it logs a one-line warning and\n * returns `softFailed: true`. Never throws, so a GA problem can never block a draft; the\n * operator can always enter the numbers by hand.\n */\nexport async function fetchGaUsers(\n siteRow: WebsiteRow,\n periodStart: Date,\n periodEnd: Date,\n): Promise<Enrichment<{ current: number; previous: number }>> {\n const cfg = readGaConfig();\n if (!cfg || !siteRow.ga4PropertyId) return NO_ENRICHMENT;\n try {\n const value = await fetchPeriodUsers(\n { propertyId: siteRow.ga4PropertyId, subject: cfg.subject, keyPath: cfg.keyPath },\n periodStart,\n periodEnd,\n );\n return { value, softFailed: false };\n } catch (e) {\n console.warn(`⚠ GA skipped for ${siteRow.name}: ${(e as Error).message}`);\n return { value: null, softFailed: true };\n }\n}\n\n/**\n * Fetch the site's Google search presence for the period, soft-failing to null. Returns a null\n * value when GA/SA isn't configured (`readGaConfig()` null — search shares the SA credentials)\n * or the site has no `searchQuery` (legitimate skips, `softFailed: false`). When the Search\n * Console API errors it logs a one-line warning and returns `softFailed: true`. Never throws,\n * so a search problem can never block a draft.\n */\nexport async function fetchSearch(\n siteRow: WebsiteRow,\n periodStart: Date,\n periodEnd: Date,\n): Promise<Enrichment<SearchPresence>> {\n const cfg = readGaConfig();\n if (!cfg || !siteRow.searchQuery) return NO_ENRICHMENT;\n try {\n const value = await fetchSearchPresence(\n {\n keyPath: cfg.keyPath,\n subject: cfg.subject,\n property: siteRow.searchConsoleProperty ?? undefined,\n host: siteRow.url,\n query: siteRow.searchQuery,\n },\n periodStart,\n periodEnd,\n );\n return { value, softFailed: false };\n } catch (e) {\n console.warn(`⚠ Search presence skipped for ${siteRow.name}: ${(e as Error).message}`);\n return { value: null, softFailed: true };\n }\n}\n\nasync function derivePeriodStart(\n base: AirtableBase,\n siteRow: WebsiteRow,\n reportType: ReportType,\n today: Date,\n): Promise<Date> {\n const prior = await listReportsForSite(base, siteRow.id);\n const sameType = prior\n .filter((r) => r.reportType === reportType && r.periodEnd)\n .map((r) => r.periodEnd!)\n .sort();\n const latest = sameType[sameType.length - 1];\n if (!latest) return daysAgo(today, 30);\n // Half-open periods. The prior report's GA/Search windows are inclusive of its\n // periodEnd, so starting this report on the *same* day double-counts that\n // boundary day across two consecutive reports (and inflates the headline Users\n // count). Start the next day instead. UTC to stay TZ-consistent with daysAgo.\n const start = new Date(latest);\n start.setUTCDate(start.getUTCDate() + 1);\n return start;\n}\n","import type { ReportType } from \"./types.js\";\nimport type { WebsiteRow } from \"./airtable/websites.js\";\nimport type { SearchPresence } from \"./search/client.js\";\nimport { checklistFor } from \"./checklist.js\";\nimport { isNetlifyAppUrl } from \"../util/url.js\";\n\n/** A persisted audit signal is only trusted within this window — a stale \"pass\" degrades to\n * \"unknown\" (no tick). Matches the github-signals staleness convention (~3 days). */\nconst STALE_DAYS = 3;\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\n\n/** True when `checkedAt` is a valid timestamp within STALE_DAYS of `now`. */\nfunction isFresh(checkedAt: string | null, now: Date): boolean {\n if (!checkedAt) return false;\n const t = new Date(checkedAt).getTime();\n if (Number.isNaN(t)) return false;\n return now.getTime() - t <= STALE_DAYS * MS_PER_DAY;\n}\n\n/** Cert-expiry buffer: a cert must have MORE than this many days left to pass (renew window).\n * Exactly 14 days does NOT pass — matches the spec's strict \">14 days\" and the domain audit's\n * own `> 14` pass status, so the two thresholds can't drift. */\nconst CERT_MIN_DAYS = 14;\n\n/** A single auto-check outcome. `pass` + fresh ⇒ the caller ticks the box. */\nexport type EvidenceResult = \"pass\" | \"fail\" | \"unknown\";\nexport type EvidenceRecord = { result: EvidenceResult; checkedAt: string | null; note: string };\n\n/**\n * Inline signals fetched during the draft. Phase 1 carries Search Console only; Phase 2/3 read\n * the remaining signals (security, domain, browser) off the Websites row passed as `site`.\n */\nexport type AutoTickSignals = {\n search: { value: SearchPresence | null; softFailed: boolean };\n};\n\n/**\n * Decide, per checklist item for this report type, an evidence record — ONLY for items that\n * currently have a signal. Items with no signal are omitted, so the caller leaves them manual.\n * PURE. The fail-safe invariant lives here: a box is only tickable when `result === \"pass\"`.\n * Google Indexed is an inline signal (fetched at draft), so its evidence is inherently fresh\n * (`checkedAt = now`).\n */\nexport function autoTickChecklist(\n site: WebsiteRow,\n reportType: ReportType,\n now: Date,\n signals: AutoTickSignals,\n): Map<string, EvidenceRecord> {\n const out = new Map<string, EvidenceRecord>();\n const fields = new Set(checklistFor(reportType).map((i) => i.field));\n\n // Google Indexed — Search Console (inline, always fresh).\n if (fields.has(\"Maint: Google Indexed\")) {\n const g = googleEvidence(now, signals.search);\n if (g) out.set(\"Maint: Google Indexed\", g);\n }\n\n // Security Updates — the nightly `security` audit's persisted vuln counts.\n if (fields.has(\"Maint: Security Updates\")) {\n const s = securityEvidence(site, now);\n if (s) out.set(\"Maint: Security Updates\", s);\n }\n\n // Domain, DNS & SSL — the nightly `domain` audit's persisted cert/resolve signal.\n if (fields.has(\"Maint: Domain, DNS & SSL\")) {\n const d = domainEvidence(site, now);\n if (d) out.set(\"Maint: Domain, DNS & SSL\", d);\n }\n\n // Desktop / Mobile / Links — the nightly `browser` audit's persisted verdicts (one timestamp\n // gates all three). Each is a stored boolean computed by the audit (multi-engine/route);\n // here we add freshness + omit-when-absent.\n if (fields.has(\"Test: Desktop Browsers\")) {\n const e = browserEvidence(\n site.crossbrowserOk,\n site,\n now,\n \"Desktop renders cleanly\",\n \"render errors\",\n );\n if (e) out.set(\"Test: Desktop Browsers\", e);\n }\n if (fields.has(\"Test: Mobile Browsers\")) {\n const e = browserEvidence(\n site.mobileOk,\n site,\n now,\n \"Mobile renders cleanly\",\n \"overflow/errors\",\n );\n if (e) out.set(\"Test: Mobile Browsers\", e);\n }\n if (fields.has(\"Test: Links & Navigation\")) {\n const broken = site.brokenLinks;\n const failNote = broken && broken > 0 ? `${broken} broken link(s)` : \"broken links / nav\";\n const e = browserEvidence(site.linksOk, site, now, \"All internal links resolve\", failNote);\n if (e) out.set(\"Test: Links & Navigation\", e);\n }\n\n return out;\n}\n\n/** One browser-audit verdict (Desktop/Mobile/Links) → evidence, gated on `Browser checked at`\n * freshness. null verdict (never run) → omit (manual); stale → unknown; true → pass; false →\n * fail (with the failure note). The verdict itself was computed by the audit (summarizeBrowser). */\nfunction browserEvidence(\n ok: boolean | null,\n site: WebsiteRow,\n now: Date,\n passNote: string,\n failNote: string,\n): EvidenceRecord | null {\n if (ok === null || !site.browserCheckedAt) return null;\n const at = site.browserCheckedAt;\n if (!isFresh(at, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"Browser check is stale (>3d)\" };\n }\n return ok\n ? { result: \"pass\", checkedAt: at, note: passNote }\n : { result: \"fail\", checkedAt: at, note: failNote };\n}\n\n/**\n * Security Updates evidence from the persisted `security` audit. Omits (→ manual) when the audit\n * never ran (counts null / no timestamp); stale → unknown; 0 critical AND 0 high → pass; any\n * critical/high → fail (with the count). Honest scope: \"no known critical/high advisories in the\n * declared dependencies as of the last audit\" — moderate/low are advisory, not gating, and this\n * does not prove the fix is deployed.\n */\nfunction securityEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n const crit = site.securityVulnsCritical;\n const high = site.securityVulnsHigh;\n if (crit === null || high === null || !site.lastSecurityAuditAt) return null;\n const at = site.lastSecurityAuditAt;\n if (!isFresh(at, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"Security audit is stale (>3d)\" };\n }\n if (crit === 0 && high === 0) {\n return { result: \"pass\", checkedAt: at, note: \"No known critical/high vulnerabilities\" };\n }\n return { result: \"fail\", checkedAt: at, note: `${crit} critical / ${high} high vuln(s)` };\n}\n\n/** Search Console → Google Indexed evidence. `null` (not configured) emits nothing so the box\n * stays manual; a soft-fail is `unknown`; page-1 is `pass` (with the position in the note). */\nfunction googleEvidence(now: Date, search: AutoTickSignals[\"search\"]): EvidenceRecord | null {\n const at = now.toISOString();\n if (search.softFailed) {\n return { result: \"unknown\", checkedAt: at, note: \"Search Console unavailable this run\" };\n }\n if (search.value === null) return null;\n if (search.value.foundOnPage1) {\n const pos = search.value.position;\n return {\n result: \"pass\",\n checkedAt: at,\n note: `Page 1 on Google${pos !== null ? ` (#${pos})` : \"\"}`,\n };\n }\n const pos = search.value.position;\n return {\n result: \"fail\",\n checkedAt: at,\n note: `Not on page 1${pos !== null ? ` (avg #${pos})` : \"\"}`,\n };\n}\n\n/**\n * Domain/DNS/SSL evidence from the persisted `domain` audit. Omits (→ manual) when there's no\n * custom domain to verify (`*.netlify.app`) or the audit never ran. A `pass` requires a fresh\n * check that resolved with a cert comfortably before expiry; stale → unknown; resolved-but-no-cert\n * or near/past expiry → fail. The honest scope is resolve + valid cert — NOT registrar expiry,\n * www↔apex redirect, or MX.\n */\nfunction domainEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n // No custom domain to verify — the no-domain watch signal covers `*.netlify.app` elsewhere.\n if (!site.url || isNetlifyAppUrl(site.url)) return null;\n if (!site.domainCheckedAt) return null; // never probed → leave manual\n const at = site.domainCheckedAt;\n if (!isFresh(site.domainCheckedAt, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"Domain check is stale (>3d)\" };\n }\n const days = site.certDaysRemaining;\n if (days === null) {\n return { result: \"fail\", checkedAt: at, note: \"Did not resolve, or no valid TLS cert\" };\n }\n if (days <= CERT_MIN_DAYS) {\n return { result: \"fail\", checkedAt: at, note: `TLS cert expires in ${days}d` };\n }\n return { result: \"pass\", checkedAt: at, note: `Custom domain, valid cert (${days}d left)` };\n}\n","import { readFileSync } from \"node:fs\";\nimport { JWT } from \"google-auth-library\";\nimport { BetaAnalyticsDataClient } from \"@google-analytics/data\";\n\nconst ANALYTICS_READONLY = \"https://www.googleapis.com/auth/analytics.readonly\";\nconst MS_PER_DAY = 86_400_000;\n\nexport type GaQuery = {\n /** GA4 numeric property ID (e.g. \"471880366\"). */\n propertyId: string;\n /** Workspace user to impersonate via domain-wide delegation. */\n subject: string;\n /** Path to the service-account JSON key. */\n keyPath: string;\n};\n\n/** UTC YYYY-MM-DD — matches the rest of the reports pipeline's date handling. */\nfunction ymd(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Fetch GA4 `activeUsers` (\"Users\") for a report period and the equal-length window\n * immediately before it, via a domain-wide-delegation service account that impersonates\n * `subject`. Throws on any auth/API error — the caller (draftReportForSite) soft-fails.\n *\n * Previous window: same length as the current period, ending the day before `periodStart`.\n */\nexport async function fetchPeriodUsers(\n query: GaQuery,\n periodStart: Date,\n periodEnd: Date,\n): Promise<{ current: number; previous: number }> {\n const key = JSON.parse(readFileSync(query.keyPath, \"utf8\")) as {\n client_email: string;\n private_key: string;\n };\n const authClient = new JWT({\n email: key.client_email,\n key: key.private_key,\n scopes: [ANALYTICS_READONLY],\n subject: query.subject,\n });\n const client = new BetaAnalyticsDataClient({ authClient });\n\n const lengthDays = Math.round((periodEnd.getTime() - periodStart.getTime()) / MS_PER_DAY);\n const prevEnd = new Date(periodStart.getTime() - MS_PER_DAY);\n const prevStart = new Date(prevEnd.getTime() - lengthDays * MS_PER_DAY);\n\n const property = `properties/${query.propertyId}`;\n const run = async (start: Date, end: Date): Promise<number> => {\n const [resp] = await client.runReport({\n property,\n dateRanges: [{ startDate: ymd(start), endDate: ymd(end) }],\n metrics: [{ name: \"activeUsers\" }],\n });\n const raw = resp.rows?.[0]?.metricValues?.[0]?.value ?? \"0\";\n const n = Number.parseInt(raw, 10);\n return Number.isFinite(n) ? n : 0;\n };\n\n const current = await run(periodStart, periodEnd);\n const previous = await run(prevStart, prevEnd);\n return { current, previous };\n}\n","import { readFileSync } from \"node:fs\";\nimport { JWT } from \"google-auth-library\";\n\nconst WEBMASTERS_READONLY = \"https://www.googleapis.com/auth/webmasters.readonly\";\nconst SC_BASE = \"https://searchconsole.googleapis.com/webmasters/v3\";\n/** Average-position threshold for \"on page 1\" (10 organic results per page). */\nconst PAGE_1_MAX_POSITION = 10;\n/** Rows to pull for the brand `contains` match. searchAnalytics has no orderBy and returns\n * rows clicks-desc, so the exact-query row survives into the response only if it isn't paged\n * out beyond this cap. Sized with wide headroom — a brand has a handful of phrasing variants,\n * far below 100 — so the exact query (and every variant) is in practice always present. */\nexport const BRAND_QUERY_ROW_LIMIT = 100;\n\nexport type SearchPresenceQuery = {\n /** Path to the service-account JSON key (same one GA uses). */\n keyPath: string;\n /** Workspace user to impersonate via domain-wide delegation. */\n subject: string;\n /** Explicit Search Console property (`sc-domain:...` or `https://.../`). Overrides auto-resolution. */\n property?: string | undefined;\n /** Site host, used to auto-resolve the property from `sites.list` when `property` is absent. */\n host: string;\n /** Operator-supplied brand hint (e.g. the business name). Matched as a case-insensitive\n * SUBSTRING (`contains`) of the actual user query — NOT a hard exact string — so it tolerates\n * phrasing (\"red door creative\" vs \"…creative la\"). An exact-query match is preferred when\n * present; otherwise the most-searched matching query wins. */\n query: string;\n};\n\nexport type SearchPresence = {\n /** True when the average position for the query is on page 1 (<= 10). */\n foundOnPage1: boolean;\n /** Rounded average position, or null when not found / no data. */\n position: number | null;\n};\n\ntype SiteEntry = { siteUrl: string };\n\n/** Reduce any property string or URL to a bare host: no `sc-domain:`, scheme, `www.`, path, lowercased. */\nexport function bareHost(s: string): string {\n return s\n .trim()\n .replace(/^sc-domain:/i, \"\")\n .replace(/^https?:\\/\\//i, \"\")\n .split(\"/\")[0]!\n .replace(/^www\\./i, \"\")\n .toLowerCase();\n}\n\n/**\n * All Search Console properties matching `host`, ordered for query fallback: Domain\n * (`sc-domain:`) forms first (broadest coverage), then URL-prefix forms. A site can be verified\n * as both; a freshly-created Domain property has no backfilled history, so its data can be empty\n * even while a long-lived URL-prefix property has data — hence we return every match and let the\n * caller try them in order until one returns data. Empty list = nothing matches.\n */\nexport function resolvePropertyCandidates(entries: SiteEntry[], host: string): string[] {\n const target = bareHost(host);\n const matches = entries.filter((e) => bareHost(e.siteUrl) === target).map((e) => e.siteUrl);\n const domains = matches.filter((s) => s.toLowerCase().startsWith(\"sc-domain:\"));\n const prefixes = matches.filter((s) => !s.toLowerCase().startsWith(\"sc-domain:\"));\n return [...domains, ...prefixes];\n}\n\n/** UTC YYYY-MM-DD — matches the rest of the reports pipeline. */\nfunction ymd(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * From the queries matching the brand substring, pick the one the brand is actually FOUND\n * by most — the highest-impression row — and return its average position; tie-break on the\n * better (lower) position. Returns undefined when no row carries a numeric position. This is\n * what makes the reported \"brand search\" position robust to phrasing: with a `contains`\n * filter several variants can match, and we report the position of the variant real users\n * search most, not whichever the operator happened to type. Rows missing `impressions` count\n * as 0 (keeps a single-row, impression-less response — e.g. a test fixture — working). PURE.\n */\nexport function pickBrandQuery(\n rows: Array<{ position?: number; impressions?: number }>,\n): number | undefined {\n let best: { position: number; impressions: number } | undefined;\n for (const r of rows) {\n if (typeof r.position !== \"number\") continue;\n const impressions = r.impressions ?? 0;\n if (\n best === undefined ||\n impressions > best.impressions ||\n (impressions === best.impressions && r.position < best.position)\n ) {\n best = { position: r.position, impressions };\n }\n }\n return best?.position;\n}\n\n/**\n * The brand's reported position from the `contains`-matched rows, preferring an EXACT match.\n * When a returned row's query equals the operator's configured string (case-insensitive), its\n * position is used verbatim — a precisely-configured brand query is honored, with no chance a\n * longer higher-impression variant (\"…reviews\", \"…near me\") hijacks the number. Otherwise we\n * fall back to the most-searched matching query (`pickBrandQuery`). Operates only on the rows\n * actually returned: a string `contains` itself, so the exact query is among the `contains`\n * results whenever it has data AND isn't paged out beyond `BRAND_QUERY_ROW_LIMIT` (sized so a\n * brand's handful of phrasing variants always fit; if it ever were paged out, the most-searched\n * fallback still returns a valid brand position). Returns undefined when no row carries a\n * numeric position. PURE.\n */\nexport function selectBrandPosition(\n rows: Array<{ keys?: string[]; position?: number; impressions?: number }>,\n exactQuery: string,\n): number | undefined {\n const want = exactQuery.toLowerCase();\n const exact = rows.find(\n (r) => typeof r.position === \"number\" && (r.keys?.[0] ?? \"\").toLowerCase() === want,\n );\n if (exact) return exact.position;\n return pickBrandQuery(rows);\n}\n\n/**\n * Query Google Search Console for the brand's average position on the site over the report\n * period, via a domain-wide-delegation service account impersonating `subject`. `query` is a\n * case-insensitive SUBSTRING (`contains`) hint; among the matching user queries we report the\n * position of the exact-match query when present, else the most-searched one (see\n * `selectBrandPosition`) — so it doesn't depend on the operator typing the exact search string,\n * yet honors one verbatim when they do. Uses `property` verbatim when given (operator's\n * choice is final — no fallback); otherwise auto-discovers all matching properties via\n * `sites.list` and tries them in order (Domain first) until one returns data. Throws on any\n * auth/API error — the caller (draftReportForSite) soft-fails.\n */\nexport async function fetchSearchPresence(\n q: SearchPresenceQuery,\n periodStart: Date,\n periodEnd: Date,\n): Promise<SearchPresence> {\n const key = JSON.parse(readFileSync(q.keyPath, \"utf8\")) as {\n client_email: string;\n private_key: string;\n };\n const jwt = new JWT({\n email: key.client_email,\n key: key.private_key,\n scopes: [WEBMASTERS_READONLY],\n subject: q.subject,\n });\n\n const explicit = q.property?.trim();\n let candidates: string[];\n if (explicit) {\n candidates = [explicit];\n } else {\n const list = await jwt.request<{ siteEntry?: SiteEntry[] }>({\n url: `${SC_BASE}/sites`,\n method: \"GET\",\n });\n candidates = resolvePropertyCandidates(list.data.siteEntry ?? [], q.host);\n if (candidates.length === 0) return { foundOnPage1: false, position: null };\n }\n\n for (const property of candidates) {\n const res = await jwt.request<{\n rows?: Array<{ keys?: string[]; position?: number; impressions?: number }>;\n }>({\n url: `${SC_BASE}/sites/${encodeURIComponent(property)}/searchAnalytics/query`,\n method: \"POST\",\n data: {\n startDate: ymd(periodStart),\n endDate: ymd(periodEnd),\n dimensions: [\"query\"],\n dimensionFilterGroups: [\n {\n filters: [\n { dimension: \"query\", operator: \"contains\", expression: q.query.toLowerCase() },\n ],\n },\n ],\n rowLimit: BRAND_QUERY_ROW_LIMIT,\n },\n });\n const pos = selectBrandPosition(res.data.rows ?? [], q.query.toLowerCase());\n if (typeof pos === \"number\") {\n // Search Console can average below 1; floor to 1 so the template never\n // renders a nonsensical \"#0\" (positions are 1-indexed).\n return { foundOnPage1: pos <= PAGE_1_MAX_POSITION, position: Math.max(1, Math.round(pos)) };\n }\n }\n return { foundOnPage1: false, position: null };\n}\n"],"mappings":"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","names":["dirname","pos","MS_PER_DAY","readFileSync","JWT","ymd","dirname","outcome"]}
@@ -2,7 +2,7 @@ import {
2
2
  isPendingApproval,
3
3
  listReportsForSite,
4
4
  setDraftReady
5
- } from "./chunk-OCS7SZUZ.js";
5
+ } from "./chunk-6JFOAPW4.js";
6
6
 
7
7
  // src/reports/queue.ts
8
8
  var REPORT_TIER = {
@@ -35,4 +35,4 @@ export {
35
35
  reportTier,
36
36
  queueDraft
37
37
  };
38
- //# sourceMappingURL=chunk-6LIWDIXU.js.map
38
+ //# sourceMappingURL=chunk-RINXPU3H.js.map
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  siteSlug
3
- } from "./chunk-CL5DYUWQ.js";
3
+ } from "./chunk-C3PDS623.js";
4
4
 
5
5
  // src/audits/lighthouse-airtable.ts
6
6
  import { readFile } from "fs/promises";
@@ -48,4 +48,4 @@ export {
48
48
  lighthouseScoresFromResult,
49
49
  resolveSlugFromCwd
50
50
  };
51
- //# sourceMappingURL=chunk-T34EUM5B.js.map
51
+ //# sourceMappingURL=chunk-XWPZ46IN.js.map
@@ -30,7 +30,7 @@ async function resolveSites(input) {
30
30
  }
31
31
  if (input.fleet === "airtable") {
32
32
  const { openBase, readAirtableConfig } = await import("./client-EPVO3FEH.js");
33
- const { fromAirtableBase } = await import("./airtable-33SY2UBZ.js");
33
+ const { fromAirtableBase } = await import("./airtable-WGQQ3KAD.js");
34
34
  const base = openBase(readAirtableConfig());
35
35
  const provider = fromAirtableBase(base, input.workdir ? { workdir: input.workdir } : {});
36
36
  return provider();
@@ -61,4 +61,4 @@ async function resolveSites(input) {
61
61
  export {
62
62
  resolveSites
63
63
  };
64
- //# sourceMappingURL=chunk-7MJJEEII.js.map
64
+ //# sourceMappingURL=chunk-YQMYQAJX.js.map
package/dist/cli/bin.js CHANGED
@@ -105,7 +105,7 @@ cli.command("sync-configs [site]", "Sync canonical configs into a site.").option
105
105
  'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
106
106
  ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
107
107
  async (site, opts) => runOrExit(
108
- async () => (await import("../sync-configs-UFHZZPL7.js")).runSyncConfigsCommand(site, opts),
108
+ async () => (await import("../sync-configs-TIBO6DUJ.js")).runSyncConfigsCommand(site, opts),
109
109
  opts
110
110
  )
111
111
  );
@@ -114,7 +114,7 @@ cli.command("bump-deps [site]", "Bump dependencies.").option("--group <group>",
114
114
  'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
115
115
  ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
116
116
  async (site, opts) => runOrExit(
117
- async () => (await import("../bump-deps-HYBYNPMY.js")).runBumpDepsCommand(site, opts),
117
+ async () => (await import("../bump-deps-QNM3H4GI.js")).runBumpDepsCommand(site, opts),
118
118
  opts
119
119
  )
120
120
  );
@@ -123,7 +123,7 @@ cli.command(
123
123
  "Bootstrap a repo to keep itself updated (CI + Renovate + auto-merge)."
124
124
  ).option("--dry", "List what would be enabled without writing or opening PRs").option("--fleet <inventory>", 'Inventory file (.json or .mjs/.js), or "airtable"').option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
125
125
  async (site, opts) => runOrExit(
126
- async () => (await import("../self-updating-OKJKU2FE.js")).runSelfUpdatingCommand(site, opts),
126
+ async () => (await import("../self-updating-JTTFOJ7Q.js")).runSelfUpdatingCommand(site, opts),
127
127
  opts
128
128
  )
129
129
  );
@@ -132,7 +132,7 @@ cli.command("upgrade <upgrade> [site]", "Run a named upgrade recipe (svelte-4-to
132
132
  'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
133
133
  ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
134
134
  async (upgrade, site, opts) => runOrExit(
135
- async () => (await import("../upgrade-S25N3473.js")).runUpgradeCommand(upgrade, site, opts),
135
+ async () => (await import("../upgrade-TPTE26FG.js")).runUpgradeCommand(upgrade, site, opts),
136
136
  opts
137
137
  )
138
138
  );
@@ -144,7 +144,7 @@ cli.command(
144
144
  'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
145
145
  ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
146
146
  async (site, opts) => runOrExit(
147
- async () => (await import("../convert-to-pnpm-5MPZWFHM.js")).runConvertToPnpmCommand(site, opts),
147
+ async () => (await import("../convert-to-pnpm-XH7NYRVK.js")).runConvertToPnpmCommand(site, opts),
148
148
  opts
149
149
  )
150
150
  );
@@ -153,7 +153,7 @@ cli.command("svelte-codemods [site]", "Apply Svelte 5 gotcha codemods to an alre
153
153
  'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
154
154
  ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
155
155
  async (site, opts) => runOrExit(
156
- async () => (await import("../svelte-codemods-GG6EEES5.js")).runSvelteCodemodsCommand(site, opts),
156
+ async () => (await import("../svelte-codemods-KJLNH6FQ.js")).runSvelteCodemodsCommand(site, opts),
157
157
  opts
158
158
  )
159
159
  );
@@ -165,7 +165,7 @@ cli.command(
165
165
  'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
166
166
  ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
167
167
  async (site, opts) => runOrExit(
168
- async () => (await import("../onboard-7JOBZY35.js")).runOnboardCommand(site, opts),
168
+ async () => (await import("../onboard-X6KJ5Z2V.js")).runOnboardCommand(site, opts),
169
169
  opts
170
170
  )
171
171
  );
@@ -176,14 +176,14 @@ cli.command(
176
176
  "--fleet <inventory>",
177
177
  'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
178
178
  ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
179
- async (site, opts) => runOrExit(async () => (await import("../init-X3BCJAUG.js")).runInitCommand(site, opts), opts)
179
+ async (site, opts) => runOrExit(async () => (await import("../init-UA6D7GKW.js")).runInitCommand(site, opts), opts)
180
180
  );
181
181
  cli.command(
182
182
  "launch <site>",
183
183
  "Bootstrap + first-audit a site, then draft its launch email for approval."
184
184
  ).action(
185
185
  async (site, opts) => runOrExit(
186
- async () => (await import("../launch-NQCL5K5H.js")).runLaunchCommand(site, opts),
186
+ async () => (await import("../launch-AENAS2O2.js")).runLaunchCommand(site, opts),
187
187
  opts
188
188
  )
189
189
  );
@@ -192,7 +192,7 @@ cli.command(
192
192
  "Draft the monthly-report announcement email for maintenance sites (all, or one) for approval."
193
193
  ).action(
194
194
  async (site, opts) => runOrExit(
195
- async () => (await import("../announce-74ZIAV26.js")).runAnnounceCommand(site, opts),
195
+ async () => (await import("../announce-72R3LRNT.js")).runAnnounceCommand(site, opts),
196
196
  opts
197
197
  )
198
198
  );
@@ -201,7 +201,25 @@ cli.command(
201
201
  "Operator self-tests. kind=email: preview a report email for a site (or --all) to yourself."
202
202
  ).option("--type <type>", "Report type: announcement (default) | maintenance | testing | launch").option("--to <addr>", "Recipient(s), comma-separated. Default: OPERATOR_EMAIL.").option("--all", "Send a preview for every maintenance site (to --to/operator).").option("--dry-run", "Render only; write reports/<slug>/selftest-<type>.html; do not send.").action(
203
203
  async (kind, site, opts) => runOrExit(
204
- async () => (await import("../selftest-6JVYR24F.js")).runSelftestCommand(kind, site, opts),
204
+ async () => (await import("../selftest-55OJIWEB.js")).runSelftestCommand(kind, site, opts),
205
+ opts
206
+ )
207
+ );
208
+ cli.command(
209
+ "preflight [site]",
210
+ "Read-only pre-send checks: recipients, header image, pending drafts, schedule hygiene."
211
+ ).option("--type <type>", "Report type: announcement (default) | maintenance | testing").option(
212
+ "--all",
213
+ "Fleet mode: announcement\u2192maintenance sites; maintenance/testing\u2192all schedulable sites."
214
+ ).action(
215
+ async (site, opts) => runOrExit(
216
+ async () => (await import("../preflight-D5RQGX4V.js")).runPreflightCommand(site, opts),
217
+ opts
218
+ )
219
+ );
220
+ cli.command("ensure-site <slug>", "Create/verify the Airtable Websites row for a new site.").option("--name <display name>", "Human Name for the row (client-facing copy uses it verbatim).").option("--url <url>", "Deployed URL (e.g. the Netlify site URL).").option("--contact <email>", "point of contact \u2014 the client address reports resolve to.").option("--git-repo <owner/repo>", "GitHub identity. Default on create: reddoorla/<slug>.").action(
221
+ async (slug, opts) => runOrExit(
222
+ async () => (await import("../ensure-site-2PF522VL.js")).runEnsureSiteCommand(slug, opts),
205
223
  opts
206
224
  )
207
225
  );
@@ -216,7 +234,7 @@ cli.command("report [site]", "Draft or send maintenance/testing reports.").optio
216
234
  "Email the operator one daily digest of reports ready for approval (skips when empty)."
217
235
  ).action(
218
236
  async (site, opts) => runOrExit(
219
- async () => (await import("../report-QUKNKUUE.js")).runReportCommand(site, opts),
237
+ async () => (await import("../report-3FJ224H5.js")).runReportCommand(site, opts),
220
238
  opts
221
239
  )
222
240
  );
@@ -225,7 +243,7 @@ cli.command(
225
243
  "Sweep the fleet for GitHub signals (Renovate-failing/CI/last-commit) and write Airtable."
226
244
  ).option("--fleet", "Run across every site in the Airtable inventory.").option("--write-airtable", "Write each site's signals back to its Websites row.").action(
227
245
  async (opts) => runOrExit(
228
- async () => (await import("../github-signals-VWBMQNLH.js")).runGitHubSignalsCommand({
246
+ async () => (await import("../github-signals-CLQC2IPY.js")).runGitHubSignalsCommand({
229
247
  fleet: opts.fleet,
230
248
  writeAirtable: opts.writeAirtable
231
249
  }),
@@ -243,7 +261,7 @@ cli.command(
243
261
  "Trigger Renovate on fleet sites the security sweep flagged with critical/high vulns."
244
262
  ).option("--fleet", "Run across every active, repo-backed site in the Airtable inventory.").action(
245
263
  async (opts) => runOrExit(
246
- async () => (await import("../renovate-dispatch-PWXFZNHN.js")).runRenovateDispatchCommand({
264
+ async () => (await import("../renovate-dispatch-LG4RJGDY.js")).runRenovateDispatchCommand({
247
265
  fleet: opts.fleet
248
266
  }),
249
267
  opts
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/cli/bin.ts","../../src/cli/version.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { dirname } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { cac } from \"cac\";\nimport type { AuditName, RecipeName } from \"../types.js\";\nimport { loadCredentialsIntoEnv } from \"../util/credentials.js\";\nimport { resolvePackageVersion } from \"./version.js\";\n\n// Command modules are loaded LAZILY (dynamic `import()` inside each `.action()`),\n// never eagerly at the top. An eager `import { runReportCommand } from\n// \"./commands/report.js\"` would pull EVERY command's transitive dependency chain\n// into the CLI's startup graph — report/announce/launch drag in mjml + resend +\n// @google-analytics/data, `db` drags in the libSQL/kysely stack, etc. Those heavy\n// packages are `devDependencies` (this repo's CLI/functions/audits use them); a\n// consuming fleet site installs @reddoorla/maintenance only for `./forms` +\n// `./configs/*` and runs just `reddoor-maint audit --only a11y` in CI. Lazy\n// loading keeps that path (and `--help`/`--version`) free of the report/db\n// chains, so those packages never land in a consumer's node_modules. The\n// smoke-dist gate asserts bin.js's STATIC import closure stays free of them.\n\n// Load credentials from ~/.config/reddoor-maint/credentials.env before any\n// command runs, so AIRTABLE_PAT/AIRTABLE_BASE_ID/RESEND_API_KEY/etc. are\n// available from any cwd. Shell-exported env vars still win. Silent on\n// missing file — commands that need the credentials will fail with their\n// own clear error.\nloadCredentialsIntoEnv();\n\nconst here = dirname(fileURLToPath(import.meta.url));\nconst version = resolvePackageVersion(here);\n\nconst AUDIT_DESCRIPTIONS: Record<AuditName, string> = {\n deps: \"Diff site package.json against the bundled baseline version map.\",\n lighthouse: \"Run @lhci/cli autorun using the canonical lighthouserc.\",\n a11y: \"Playwright + axe against the canonical a11y routes.\",\n security:\n \"GitHub Dependabot alerts (prod+dev) for repo-backed sites; falls back to pnpm/npm audit (prod-deps) with no repo/token.\",\n lint: \"ESLint + Prettier using the canonical configs.\",\n domain: \"DNS resolve + TLS cert expiry against the deployed URL (checkout-free).\",\n browser:\n \"Playwright across desktop engines + mobile devices + link-check against the deployed URL (checkout-free).\",\n \"netlify-deploy\":\n \"Latest production deploy health via the Netlify API by site id (checkout-free; needs NETLIFY_PAT).\",\n};\n\nconst RECIPE_DESCRIPTIONS: Record<RecipeName, string> = {\n \"sync-configs\": \"Overwrite a site's canonical configs to match @reddoorla/maintenance.\",\n \"bump-deps\": \"Bump dependencies and commit the lockfile change.\",\n \"svelte-4-to-5\": \"Run the 7-commit Svelte 4 → 5 upgrade recipe.\",\n \"svelte-codemods\":\n \"Apply Svelte 5 gotcha codemods to an already-migrated site (state_referenced_locally, etc.).\",\n \"convert-to-pnpm\": \"Convert an npm/yarn site to pnpm (lockfile, packageManager, scripts).\",\n onboard: \"Install @reddoorla/maintenance + audit deps on a site (preferred first step).\",\n \"a11y-fixtures-page\":\n \"Write src/routes/dev/a11y-fixtures/+page.svelte (stub for lhci + axe targets).\",\n \"self-updating\":\n \"Bootstrap CI + Renovate + auto-merge per repo (writes workflows, opens PR, sets RENOVATE_TOKEN).\",\n init: \"Run the full onboarding chain (convert-to-pnpm → onboard → sync-configs → svelte-codemods → a11y-fixtures-page → audit).\",\n};\n\n/** Run a command thunk and surface its result, falling back to a clean error\n * message on throw. Wraps the ~10-line try/catch every `.action()` used to\n * duplicate. `verbose` flips between full stack and message-only.\n *\n * On success it sets `process.exitCode` and RETURNS rather than calling\n * `process.exit()` right after `console.log()`. `process.exit()` does not wait\n * for stdout to flush when stdout is a pipe, so a large `--json` payload piped\n * to another process would get truncated mid-write. Setting `exitCode` and\n * returning lets Node drain stdout and exit naturally with the right code. A\n * non-zero `code` still yields a non-zero process exit.\n *\n * The error path keeps `process.exit()` — error messages are small (one line),\n * always go to stderr, and exiting immediately is the desired fail-fast. */\nexport async function runOrExit(\n fn: () => Promise<{ output: string; code: number }>,\n opts: { verbose?: boolean },\n): Promise<void> {\n try {\n const { output, code } = await fn();\n console.log(output);\n process.exitCode = code;\n return;\n } catch (err) {\n const e = err as { exitCode?: number; message?: string; stack?: string };\n console.error(opts.verbose ? (e.stack ?? e.message) : (e.message ?? String(err)));\n process.exit(e.exitCode ?? 1);\n }\n}\n\nconst cli = cac(\"reddoor-maint\");\n\ncli.option(\"--cwd <path>\", \"Override working directory (default: process.cwd())\");\ncli.option(\"--verbose\", \"Verbose output (full stack on errors)\");\n\ncli.command(\"list-audits\", \"Print the available audits.\").action(() => {\n for (const [name, desc] of Object.entries(AUDIT_DESCRIPTIONS)) {\n console.log(`${name.padEnd(12)} ${desc}`);\n }\n});\n\ncli.command(\"list-recipes\", \"Print the available recipes.\").action(() => {\n for (const [name, desc] of Object.entries(RECIPE_DESCRIPTIONS)) {\n console.log(`${name.padEnd(16)} ${desc}`);\n }\n});\n\ncli\n .command(\"audit [site]\", \"Run audits against a site (default: cwd).\")\n .option(\"--only <names>\", \"Comma-separated audit names (e.g. deps,lighthouse)\")\n .option(\"--json\", \"Machine-readable JSON output\")\n .option(\n \"--fleet <inventory>\",\n 'Inventory file (.json or .mjs/.js), or \"airtable\" to read from Websites table',\n )\n .option(\"--workdir <path>\", \"Clone target for fleet mode (default ~/.reddoor-maint/sites)\")\n .option(\n \"--write-airtable [slug]\",\n \"After lighthouse runs, write pScore/rScore/bpScore/seoScore + timestamp to the matching Websites row. Slug defaults to cwd's package.json#name.\",\n )\n .option(\"--fail-on-violations\", \"Exit non-zero if any a11y violations are found (for CI gates)\")\n .option(\n \"--url <url>\",\n \"Audit this deployed URL with lighthouse (no dev server); single-site. Pair with --only lighthouse — other audits still use the local checkout.\",\n )\n .option(\n \"--concurrency <n>\",\n \"Max sites to audit in parallel in --fleet mode (default: all at once). Use 1 for sequential (CI).\",\n )\n .action(\n async (\n site,\n opts: {\n only?: string;\n json?: boolean;\n fleet?: string;\n workdir?: string;\n cwd?: string;\n verbose?: boolean;\n writeAirtable?: string | boolean;\n failOnViolations?: boolean;\n url?: string;\n concurrency?: string;\n },\n ) =>\n runOrExit(\n async () => (await import(\"./commands/audit.js\")).runAuditCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\"sync-configs [site]\", \"Sync canonical configs into a site.\")\n .option(\"--only <names>\", \"Comma-separated config names (e.g. eslint,prettier)\")\n .option(\"--dry\", \"Print diff without writing\")\n .option(\n \"--fleet <inventory>\",\n 'Inventory file (.json or .mjs/.js), or \"airtable\" to read from Websites table',\n )\n .option(\"--workdir <path>\", \"Clone target for fleet mode (default ~/.reddoor-maint/sites)\")\n .action(\n async (\n site,\n opts: {\n only?: string;\n dry?: boolean;\n fleet?: string;\n workdir?: string;\n cwd?: string;\n verbose?: boolean;\n },\n ) =>\n runOrExit(\n async () => (await import(\"./commands/sync-configs.js\")).runSyncConfigsCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\"bump-deps [site]\", \"Bump dependencies.\")\n .option(\"--group <group>\", \"patch | minor | major\", { default: \"minor\" })\n .option(\n \"--fleet <inventory>\",\n 'Inventory file (.json or .mjs/.js), or \"airtable\" to read from Websites table',\n )\n .option(\"--workdir <path>\", \"Clone target for fleet mode (default ~/.reddoor-maint/sites)\")\n .action(\n async (\n site,\n opts: {\n group?: string;\n fleet?: string;\n workdir?: string;\n cwd?: string;\n verbose?: boolean;\n },\n ) =>\n runOrExit(\n async () => (await import(\"./commands/bump-deps.js\")).runBumpDepsCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\n \"self-updating [site]\",\n \"Bootstrap a repo to keep itself updated (CI + Renovate + auto-merge).\",\n )\n .option(\"--dry\", \"List what would be enabled without writing or opening PRs\")\n .option(\"--fleet <inventory>\", 'Inventory file (.json or .mjs/.js), or \"airtable\"')\n .option(\"--workdir <path>\", \"Clone target for fleet mode (default ~/.reddoor-maint/sites)\")\n .action(\n async (\n site,\n opts: {\n dry?: boolean;\n fleet?: string;\n workdir?: string;\n cwd?: string;\n verbose?: boolean;\n },\n ) =>\n runOrExit(\n async () =>\n (await import(\"./commands/self-updating.js\")).runSelfUpdatingCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\"upgrade <upgrade> [site]\", \"Run a named upgrade recipe (svelte-4-to-5).\")\n .example(\"reddoor-maint upgrade svelte-4-to-5 ./my-site\")\n .option(\n \"--fleet <inventory>\",\n 'Inventory file (.json or .mjs/.js), or \"airtable\" to read from Websites table',\n )\n .option(\"--workdir <path>\", \"Clone target for fleet mode (default ~/.reddoor-maint/sites)\")\n .action(\n async (\n upgrade: string,\n site: string | undefined,\n opts: { fleet?: string; workdir?: string; cwd?: string; verbose?: boolean },\n ) =>\n runOrExit(\n async () => (await import(\"./commands/upgrade.js\")).runUpgradeCommand(upgrade, site, opts),\n opts,\n ),\n );\n\ncli\n .command(\n \"convert-to-pnpm [site]\",\n \"Convert an npm/yarn site to pnpm (lockfile, packageManager, scripts).\",\n )\n .option(\n \"--fleet <inventory>\",\n 'Inventory file (.json or .mjs/.js), or \"airtable\" to read from Websites table',\n )\n .option(\"--workdir <path>\", \"Clone target for fleet mode (default ~/.reddoor-maint/sites)\")\n .action(\n async (site, opts: { fleet?: string; workdir?: string; cwd?: string; verbose?: boolean }) =>\n runOrExit(\n async () =>\n (await import(\"./commands/convert-to-pnpm.js\")).runConvertToPnpmCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\"svelte-codemods [site]\", \"Apply Svelte 5 gotcha codemods to an already-migrated site.\")\n .option(\n \"--fleet <inventory>\",\n 'Inventory file (.json or .mjs/.js), or \"airtable\" to read from Websites table',\n )\n .option(\"--workdir <path>\", \"Clone target for fleet mode (default ~/.reddoor-maint/sites)\")\n .action(\n async (site, opts: { fleet?: string; workdir?: string; cwd?: string; verbose?: boolean }) =>\n runOrExit(\n async () =>\n (await import(\"./commands/svelte-codemods.js\")).runSvelteCodemodsCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\n \"onboard [site]\",\n \"Install @reddoorla/maintenance + audit deps on a site (run after convert-to-pnpm).\",\n )\n .option(\"--audits <names>\", \"Comma-separated audit subset: lighthouse,a11y (default: both)\")\n .option(\n \"--fleet <inventory>\",\n 'Inventory file (.json or .mjs/.js), or \"airtable\" to read from Websites table',\n )\n .option(\"--workdir <path>\", \"Clone target for fleet mode (default ~/.reddoor-maint/sites)\")\n .action(\n async (\n site,\n opts: {\n audits?: string;\n fleet?: string;\n workdir?: string;\n cwd?: string;\n verbose?: boolean;\n },\n ) =>\n runOrExit(\n async () => (await import(\"./commands/onboard.js\")).runOnboardCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\n \"init [site]\",\n \"One-shot guided onboarding: convert-to-pnpm → onboard → sync-configs → svelte-codemods → a11y-fixtures-page → audit.\",\n )\n .option(\n \"--fleet <inventory>\",\n 'Inventory file (.json or .mjs/.js), or \"airtable\" to read from Websites table',\n )\n .option(\"--workdir <path>\", \"Clone target for fleet mode (default ~/.reddoor-maint/sites)\")\n .action(\n async (site, opts: { fleet?: string; workdir?: string; cwd?: string; verbose?: boolean }) =>\n runOrExit(async () => (await import(\"./commands/init.js\")).runInitCommand(site, opts), opts),\n );\n\ncli\n .command(\n \"launch <site>\",\n \"Bootstrap + first-audit a site, then draft its launch email for approval.\",\n )\n .action(async (site: string, opts: { cwd?: string; verbose?: boolean }) =>\n runOrExit(\n async () => (await import(\"./commands/launch.js\")).runLaunchCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\n \"announce [site]\",\n \"Draft the monthly-report announcement email for maintenance sites (all, or one) for approval.\",\n )\n .action(async (site: string | undefined, opts: { cwd?: string; verbose?: boolean }) =>\n runOrExit(\n async () => (await import(\"./commands/announce.js\")).runAnnounceCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\n \"selftest <kind> [site]\",\n \"Operator self-tests. kind=email: preview a report email for a site (or --all) to yourself.\",\n )\n .option(\"--type <type>\", \"Report type: announcement (default) | maintenance | testing | launch\")\n .option(\"--to <addr>\", \"Recipient(s), comma-separated. Default: OPERATOR_EMAIL.\")\n .option(\"--all\", \"Send a preview for every maintenance site (to --to/operator).\")\n .option(\"--dry-run\", \"Render only; write reports/<slug>/selftest-<type>.html; do not send.\")\n .action(\n async (\n kind: string,\n site: string | undefined,\n opts: {\n type?: string;\n to?: string;\n all?: boolean;\n dryRun?: boolean;\n cwd?: string;\n verbose?: boolean;\n },\n ) =>\n runOrExit(\n async () => (await import(\"./commands/selftest.js\")).runSelftestCommand(kind, site, opts),\n opts,\n ),\n );\n\ncli\n .command(\"report [site]\", \"Draft or send maintenance/testing reports.\")\n .option(\"--due\", \"Scan all Websites and draft overdue reports.\")\n .option(\"--type <type>\", \"Single-site draft report type: Maintenance (default) or Testing.\")\n .option(\n \"--preview\",\n \"Single-site dry run; writes reports/<slug>/draft.html, never touches Airtable.\",\n )\n .option(\n \"--send-ready\",\n \"Send all Reports with Draft ready=true AND Approved to send=true AND Sent at IS NULL.\",\n )\n .option(\n \"--digest\",\n \"Email the operator one daily digest of reports ready for approval (skips when empty).\",\n )\n .action(\n async (\n site,\n opts: {\n due?: boolean;\n type?: string;\n preview?: boolean;\n sendReady?: boolean;\n digest?: boolean;\n cwd?: string;\n verbose?: boolean;\n },\n ) =>\n runOrExit(\n async () => (await import(\"./commands/report.js\")).runReportCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\n \"github-signals\",\n \"Sweep the fleet for GitHub signals (Renovate-failing/CI/last-commit) and write Airtable.\",\n )\n .option(\"--fleet\", \"Run across every site in the Airtable inventory.\")\n .option(\"--write-airtable\", \"Write each site's signals back to its Websites row.\")\n .action(\n async (opts: { fleet?: boolean; writeAirtable?: boolean; cwd?: string; verbose?: boolean }) =>\n runOrExit(\n async () =>\n (await import(\"./commands/github-signals.js\")).runGitHubSignalsCommand({\n fleet: opts.fleet,\n writeAirtable: opts.writeAirtable,\n }),\n opts,\n ),\n );\n\ncli\n .command(\n \"db <action>\",\n \"Migrate / backfill / reconcile the libSQL store (migrate | backfill | reconcile).\",\n )\n .action(async (action: string, opts: { cwd?: string; verbose?: boolean }) =>\n runOrExit(async () => (await import(\"./commands/db.js\")).runDbCommand(action, opts), opts),\n );\n\ncli\n .command(\n \"renovate-dispatch\",\n \"Trigger Renovate on fleet sites the security sweep flagged with critical/high vulns.\",\n )\n .option(\"--fleet\", \"Run across every active, repo-backed site in the Airtable inventory.\")\n .action(async (opts: { fleet?: boolean; cwd?: string; verbose?: boolean }) =>\n runOrExit(\n async () =>\n (await import(\"./commands/renovate-dispatch.js\")).runRenovateDispatchCommand({\n fleet: opts.fleet,\n }),\n opts,\n ),\n );\n\ncli.help();\ncli.version(version);\n\n// A typo'd / unrecognized subcommand (e.g. `reddoor-maint auditt`) otherwise\n// falls through cac with no matched command and exits 0 — a cron/CI typo would\n// \"succeed\" silently. cac emits `command:*` at the end of parse() exactly when\n// a positional arg was given but matched no command (a bare `reddoor-maint`,\n// `--help`, and `--version` do NOT trigger it: they have no leading positional\n// or are handled before this fires). Turn that into a clear stderr error +\n// non-zero exit. process.argv[2] is the first positional, i.e. the bad command.\ncli.on(\"command:*\", () => {\n const unknown = cli.args[0] ?? process.argv[2] ?? \"\";\n console.error(\n `error: unknown command '${unknown}'. Run 'reddoor-maint --help' to see available commands.`,\n );\n process.exit(1);\n});\n\ncli.parse();\n","import { readFileSync, existsSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\n\n/**\n * Read the @reddoorla/maintenance package version, given the directory of a\n * file that lives inside the package. Walks UP looking for the first\n * `package.json` whose `name` matches. Defensive against bundling-layout\n * changes — the older \"two levels up\" assumption held for `dist/cli/bin.js`\n * but would silently mis-read (or return \"unknown\") if tsup ever moved bin\n * to a different depth. Same walk-up pattern as the self-version helper.\n *\n * Returns \"unknown\" when no matching package.json is reachable (Yarn PnP\n * setups stash manifests inside .zip caches; the readFileSync there fails\n * before any name check).\n */\nexport function resolvePackageVersion(fromDir: string): string {\n try {\n let dir = fromDir;\n while (true) {\n const candidate = join(dir, \"package.json\");\n if (existsSync(candidate)) {\n const raw = readFileSync(candidate, \"utf-8\");\n const pkg = JSON.parse(raw) as { name?: string; version?: string };\n if (pkg.name === \"@reddoorla/maintenance\") {\n return pkg.version ?? \"unknown\";\n }\n }\n const parent = dirname(dir);\n if (parent === dir) return \"unknown\";\n dir = parent;\n }\n } catch {\n return \"unknown\";\n }\n}\n"],"mappings":"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","names":["dirname","dirname"]}
1
+ {"version":3,"sources":["../../src/cli/bin.ts","../../src/cli/version.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { dirname } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { cac } from \"cac\";\nimport type { AuditName, RecipeName } from \"../types.js\";\nimport { loadCredentialsIntoEnv } from \"../util/credentials.js\";\nimport { resolvePackageVersion } from \"./version.js\";\n\n// Command modules are loaded LAZILY (dynamic `import()` inside each `.action()`),\n// never eagerly at the top. An eager `import { runReportCommand } from\n// \"./commands/report.js\"` would pull EVERY command's transitive dependency chain\n// into the CLI's startup graph — report/announce/launch drag in mjml + resend +\n// @google-analytics/data, `db` drags in the libSQL/kysely stack, etc. Those heavy\n// packages are `devDependencies` (this repo's CLI/functions/audits use them); a\n// consuming fleet site installs @reddoorla/maintenance only for `./forms` +\n// `./configs/*` and runs just `reddoor-maint audit --only a11y` in CI. Lazy\n// loading keeps that path (and `--help`/`--version`) free of the report/db\n// chains, so those packages never land in a consumer's node_modules. The\n// smoke-dist gate asserts bin.js's STATIC import closure stays free of them.\n\n// Load credentials from ~/.config/reddoor-maint/credentials.env before any\n// command runs, so AIRTABLE_PAT/AIRTABLE_BASE_ID/RESEND_API_KEY/etc. are\n// available from any cwd. Shell-exported env vars still win. Silent on\n// missing file — commands that need the credentials will fail with their\n// own clear error.\nloadCredentialsIntoEnv();\n\nconst here = dirname(fileURLToPath(import.meta.url));\nconst version = resolvePackageVersion(here);\n\nconst AUDIT_DESCRIPTIONS: Record<AuditName, string> = {\n deps: \"Diff site package.json against the bundled baseline version map.\",\n lighthouse: \"Run @lhci/cli autorun using the canonical lighthouserc.\",\n a11y: \"Playwright + axe against the canonical a11y routes.\",\n security:\n \"GitHub Dependabot alerts (prod+dev) for repo-backed sites; falls back to pnpm/npm audit (prod-deps) with no repo/token.\",\n lint: \"ESLint + Prettier using the canonical configs.\",\n domain: \"DNS resolve + TLS cert expiry against the deployed URL (checkout-free).\",\n browser:\n \"Playwright across desktop engines + mobile devices + link-check against the deployed URL (checkout-free).\",\n \"netlify-deploy\":\n \"Latest production deploy health via the Netlify API by site id (checkout-free; needs NETLIFY_PAT).\",\n};\n\nconst RECIPE_DESCRIPTIONS: Record<RecipeName, string> = {\n \"sync-configs\": \"Overwrite a site's canonical configs to match @reddoorla/maintenance.\",\n \"bump-deps\": \"Bump dependencies and commit the lockfile change.\",\n \"svelte-4-to-5\": \"Run the 7-commit Svelte 4 → 5 upgrade recipe.\",\n \"svelte-codemods\":\n \"Apply Svelte 5 gotcha codemods to an already-migrated site (state_referenced_locally, etc.).\",\n \"convert-to-pnpm\": \"Convert an npm/yarn site to pnpm (lockfile, packageManager, scripts).\",\n onboard: \"Install @reddoorla/maintenance + audit deps on a site (preferred first step).\",\n \"a11y-fixtures-page\":\n \"Write src/routes/dev/a11y-fixtures/+page.svelte (stub for lhci + axe targets).\",\n \"self-updating\":\n \"Bootstrap CI + Renovate + auto-merge per repo (writes workflows, opens PR, sets RENOVATE_TOKEN).\",\n init: \"Run the full onboarding chain (convert-to-pnpm → onboard → sync-configs → svelte-codemods → a11y-fixtures-page → audit).\",\n};\n\n/** Run a command thunk and surface its result, falling back to a clean error\n * message on throw. Wraps the ~10-line try/catch every `.action()` used to\n * duplicate. `verbose` flips between full stack and message-only.\n *\n * On success it sets `process.exitCode` and RETURNS rather than calling\n * `process.exit()` right after `console.log()`. `process.exit()` does not wait\n * for stdout to flush when stdout is a pipe, so a large `--json` payload piped\n * to another process would get truncated mid-write. Setting `exitCode` and\n * returning lets Node drain stdout and exit naturally with the right code. A\n * non-zero `code` still yields a non-zero process exit.\n *\n * The error path keeps `process.exit()` — error messages are small (one line),\n * always go to stderr, and exiting immediately is the desired fail-fast. */\nexport async function runOrExit(\n fn: () => Promise<{ output: string; code: number }>,\n opts: { verbose?: boolean },\n): Promise<void> {\n try {\n const { output, code } = await fn();\n console.log(output);\n process.exitCode = code;\n return;\n } catch (err) {\n const e = err as { exitCode?: number; message?: string; stack?: string };\n console.error(opts.verbose ? (e.stack ?? e.message) : (e.message ?? String(err)));\n process.exit(e.exitCode ?? 1);\n }\n}\n\nconst cli = cac(\"reddoor-maint\");\n\ncli.option(\"--cwd <path>\", \"Override working directory (default: process.cwd())\");\ncli.option(\"--verbose\", \"Verbose output (full stack on errors)\");\n\ncli.command(\"list-audits\", \"Print the available audits.\").action(() => {\n for (const [name, desc] of Object.entries(AUDIT_DESCRIPTIONS)) {\n console.log(`${name.padEnd(12)} ${desc}`);\n }\n});\n\ncli.command(\"list-recipes\", \"Print the available recipes.\").action(() => {\n for (const [name, desc] of Object.entries(RECIPE_DESCRIPTIONS)) {\n console.log(`${name.padEnd(16)} ${desc}`);\n }\n});\n\ncli\n .command(\"audit [site]\", \"Run audits against a site (default: cwd).\")\n .option(\"--only <names>\", \"Comma-separated audit names (e.g. deps,lighthouse)\")\n .option(\"--json\", \"Machine-readable JSON output\")\n .option(\n \"--fleet <inventory>\",\n 'Inventory file (.json or .mjs/.js), or \"airtable\" to read from Websites table',\n )\n .option(\"--workdir <path>\", \"Clone target for fleet mode (default ~/.reddoor-maint/sites)\")\n .option(\n \"--write-airtable [slug]\",\n \"After lighthouse runs, write pScore/rScore/bpScore/seoScore + timestamp to the matching Websites row. Slug defaults to cwd's package.json#name.\",\n )\n .option(\"--fail-on-violations\", \"Exit non-zero if any a11y violations are found (for CI gates)\")\n .option(\n \"--url <url>\",\n \"Audit this deployed URL with lighthouse (no dev server); single-site. Pair with --only lighthouse — other audits still use the local checkout.\",\n )\n .option(\n \"--concurrency <n>\",\n \"Max sites to audit in parallel in --fleet mode (default: all at once). Use 1 for sequential (CI).\",\n )\n .action(\n async (\n site,\n opts: {\n only?: string;\n json?: boolean;\n fleet?: string;\n workdir?: string;\n cwd?: string;\n verbose?: boolean;\n writeAirtable?: string | boolean;\n failOnViolations?: boolean;\n url?: string;\n concurrency?: string;\n },\n ) =>\n runOrExit(\n async () => (await import(\"./commands/audit.js\")).runAuditCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\"sync-configs [site]\", \"Sync canonical configs into a site.\")\n .option(\"--only <names>\", \"Comma-separated config names (e.g. eslint,prettier)\")\n .option(\"--dry\", \"Print diff without writing\")\n .option(\n \"--fleet <inventory>\",\n 'Inventory file (.json or .mjs/.js), or \"airtable\" to read from Websites table',\n )\n .option(\"--workdir <path>\", \"Clone target for fleet mode (default ~/.reddoor-maint/sites)\")\n .action(\n async (\n site,\n opts: {\n only?: string;\n dry?: boolean;\n fleet?: string;\n workdir?: string;\n cwd?: string;\n verbose?: boolean;\n },\n ) =>\n runOrExit(\n async () => (await import(\"./commands/sync-configs.js\")).runSyncConfigsCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\"bump-deps [site]\", \"Bump dependencies.\")\n .option(\"--group <group>\", \"patch | minor | major\", { default: \"minor\" })\n .option(\n \"--fleet <inventory>\",\n 'Inventory file (.json or .mjs/.js), or \"airtable\" to read from Websites table',\n )\n .option(\"--workdir <path>\", \"Clone target for fleet mode (default ~/.reddoor-maint/sites)\")\n .action(\n async (\n site,\n opts: {\n group?: string;\n fleet?: string;\n workdir?: string;\n cwd?: string;\n verbose?: boolean;\n },\n ) =>\n runOrExit(\n async () => (await import(\"./commands/bump-deps.js\")).runBumpDepsCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\n \"self-updating [site]\",\n \"Bootstrap a repo to keep itself updated (CI + Renovate + auto-merge).\",\n )\n .option(\"--dry\", \"List what would be enabled without writing or opening PRs\")\n .option(\"--fleet <inventory>\", 'Inventory file (.json or .mjs/.js), or \"airtable\"')\n .option(\"--workdir <path>\", \"Clone target for fleet mode (default ~/.reddoor-maint/sites)\")\n .action(\n async (\n site,\n opts: {\n dry?: boolean;\n fleet?: string;\n workdir?: string;\n cwd?: string;\n verbose?: boolean;\n },\n ) =>\n runOrExit(\n async () =>\n (await import(\"./commands/self-updating.js\")).runSelfUpdatingCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\"upgrade <upgrade> [site]\", \"Run a named upgrade recipe (svelte-4-to-5).\")\n .example(\"reddoor-maint upgrade svelte-4-to-5 ./my-site\")\n .option(\n \"--fleet <inventory>\",\n 'Inventory file (.json or .mjs/.js), or \"airtable\" to read from Websites table',\n )\n .option(\"--workdir <path>\", \"Clone target for fleet mode (default ~/.reddoor-maint/sites)\")\n .action(\n async (\n upgrade: string,\n site: string | undefined,\n opts: { fleet?: string; workdir?: string; cwd?: string; verbose?: boolean },\n ) =>\n runOrExit(\n async () => (await import(\"./commands/upgrade.js\")).runUpgradeCommand(upgrade, site, opts),\n opts,\n ),\n );\n\ncli\n .command(\n \"convert-to-pnpm [site]\",\n \"Convert an npm/yarn site to pnpm (lockfile, packageManager, scripts).\",\n )\n .option(\n \"--fleet <inventory>\",\n 'Inventory file (.json or .mjs/.js), or \"airtable\" to read from Websites table',\n )\n .option(\"--workdir <path>\", \"Clone target for fleet mode (default ~/.reddoor-maint/sites)\")\n .action(\n async (site, opts: { fleet?: string; workdir?: string; cwd?: string; verbose?: boolean }) =>\n runOrExit(\n async () =>\n (await import(\"./commands/convert-to-pnpm.js\")).runConvertToPnpmCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\"svelte-codemods [site]\", \"Apply Svelte 5 gotcha codemods to an already-migrated site.\")\n .option(\n \"--fleet <inventory>\",\n 'Inventory file (.json or .mjs/.js), or \"airtable\" to read from Websites table',\n )\n .option(\"--workdir <path>\", \"Clone target for fleet mode (default ~/.reddoor-maint/sites)\")\n .action(\n async (site, opts: { fleet?: string; workdir?: string; cwd?: string; verbose?: boolean }) =>\n runOrExit(\n async () =>\n (await import(\"./commands/svelte-codemods.js\")).runSvelteCodemodsCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\n \"onboard [site]\",\n \"Install @reddoorla/maintenance + audit deps on a site (run after convert-to-pnpm).\",\n )\n .option(\"--audits <names>\", \"Comma-separated audit subset: lighthouse,a11y (default: both)\")\n .option(\n \"--fleet <inventory>\",\n 'Inventory file (.json or .mjs/.js), or \"airtable\" to read from Websites table',\n )\n .option(\"--workdir <path>\", \"Clone target for fleet mode (default ~/.reddoor-maint/sites)\")\n .action(\n async (\n site,\n opts: {\n audits?: string;\n fleet?: string;\n workdir?: string;\n cwd?: string;\n verbose?: boolean;\n },\n ) =>\n runOrExit(\n async () => (await import(\"./commands/onboard.js\")).runOnboardCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\n \"init [site]\",\n \"One-shot guided onboarding: convert-to-pnpm → onboard → sync-configs → svelte-codemods → a11y-fixtures-page → audit.\",\n )\n .option(\n \"--fleet <inventory>\",\n 'Inventory file (.json or .mjs/.js), or \"airtable\" to read from Websites table',\n )\n .option(\"--workdir <path>\", \"Clone target for fleet mode (default ~/.reddoor-maint/sites)\")\n .action(\n async (site, opts: { fleet?: string; workdir?: string; cwd?: string; verbose?: boolean }) =>\n runOrExit(async () => (await import(\"./commands/init.js\")).runInitCommand(site, opts), opts),\n );\n\ncli\n .command(\n \"launch <site>\",\n \"Bootstrap + first-audit a site, then draft its launch email for approval.\",\n )\n .action(async (site: string, opts: { cwd?: string; verbose?: boolean }) =>\n runOrExit(\n async () => (await import(\"./commands/launch.js\")).runLaunchCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\n \"announce [site]\",\n \"Draft the monthly-report announcement email for maintenance sites (all, or one) for approval.\",\n )\n .action(async (site: string | undefined, opts: { cwd?: string; verbose?: boolean }) =>\n runOrExit(\n async () => (await import(\"./commands/announce.js\")).runAnnounceCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\n \"selftest <kind> [site]\",\n \"Operator self-tests. kind=email: preview a report email for a site (or --all) to yourself.\",\n )\n .option(\"--type <type>\", \"Report type: announcement (default) | maintenance | testing | launch\")\n .option(\"--to <addr>\", \"Recipient(s), comma-separated. Default: OPERATOR_EMAIL.\")\n .option(\"--all\", \"Send a preview for every maintenance site (to --to/operator).\")\n .option(\"--dry-run\", \"Render only; write reports/<slug>/selftest-<type>.html; do not send.\")\n .action(\n async (\n kind: string,\n site: string | undefined,\n opts: {\n type?: string;\n to?: string;\n all?: boolean;\n dryRun?: boolean;\n cwd?: string;\n verbose?: boolean;\n },\n ) =>\n runOrExit(\n async () => (await import(\"./commands/selftest.js\")).runSelftestCommand(kind, site, opts),\n opts,\n ),\n );\n\ncli\n .command(\n \"preflight [site]\",\n \"Read-only pre-send checks: recipients, header image, pending drafts, schedule hygiene.\",\n )\n .option(\"--type <type>\", \"Report type: announcement (default) | maintenance | testing\")\n .option(\n \"--all\",\n \"Fleet mode: announcement→maintenance sites; maintenance/testing→all schedulable sites.\",\n )\n .action(\n async (\n site: string | undefined,\n opts: { type?: string; all?: boolean; cwd?: string; verbose?: boolean },\n ) =>\n runOrExit(\n async () => (await import(\"./commands/preflight.js\")).runPreflightCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\"ensure-site <slug>\", \"Create/verify the Airtable Websites row for a new site.\")\n .option(\"--name <display name>\", \"Human Name for the row (client-facing copy uses it verbatim).\")\n .option(\"--url <url>\", \"Deployed URL (e.g. the Netlify site URL).\")\n .option(\"--contact <email>\", \"point of contact — the client address reports resolve to.\")\n .option(\"--git-repo <owner/repo>\", \"GitHub identity. Default on create: reddoorla/<slug>.\")\n .action(\n async (\n slug: string,\n opts: {\n name?: string;\n url?: string;\n contact?: string;\n gitRepo?: string;\n cwd?: string;\n verbose?: boolean;\n },\n ) =>\n runOrExit(\n async () => (await import(\"./commands/ensure-site.js\")).runEnsureSiteCommand(slug, opts),\n opts,\n ),\n );\n\ncli\n .command(\"report [site]\", \"Draft or send maintenance/testing reports.\")\n .option(\"--due\", \"Scan all Websites and draft overdue reports.\")\n .option(\"--type <type>\", \"Single-site draft report type: Maintenance (default) or Testing.\")\n .option(\n \"--preview\",\n \"Single-site dry run; writes reports/<slug>/draft.html, never touches Airtable.\",\n )\n .option(\n \"--send-ready\",\n \"Send all Reports with Draft ready=true AND Approved to send=true AND Sent at IS NULL.\",\n )\n .option(\n \"--digest\",\n \"Email the operator one daily digest of reports ready for approval (skips when empty).\",\n )\n .action(\n async (\n site,\n opts: {\n due?: boolean;\n type?: string;\n preview?: boolean;\n sendReady?: boolean;\n digest?: boolean;\n cwd?: string;\n verbose?: boolean;\n },\n ) =>\n runOrExit(\n async () => (await import(\"./commands/report.js\")).runReportCommand(site, opts),\n opts,\n ),\n );\n\ncli\n .command(\n \"github-signals\",\n \"Sweep the fleet for GitHub signals (Renovate-failing/CI/last-commit) and write Airtable.\",\n )\n .option(\"--fleet\", \"Run across every site in the Airtable inventory.\")\n .option(\"--write-airtable\", \"Write each site's signals back to its Websites row.\")\n .action(\n async (opts: { fleet?: boolean; writeAirtable?: boolean; cwd?: string; verbose?: boolean }) =>\n runOrExit(\n async () =>\n (await import(\"./commands/github-signals.js\")).runGitHubSignalsCommand({\n fleet: opts.fleet,\n writeAirtable: opts.writeAirtable,\n }),\n opts,\n ),\n );\n\ncli\n .command(\n \"db <action>\",\n \"Migrate / backfill / reconcile the libSQL store (migrate | backfill | reconcile).\",\n )\n .action(async (action: string, opts: { cwd?: string; verbose?: boolean }) =>\n runOrExit(async () => (await import(\"./commands/db.js\")).runDbCommand(action, opts), opts),\n );\n\ncli\n .command(\n \"renovate-dispatch\",\n \"Trigger Renovate on fleet sites the security sweep flagged with critical/high vulns.\",\n )\n .option(\"--fleet\", \"Run across every active, repo-backed site in the Airtable inventory.\")\n .action(async (opts: { fleet?: boolean; cwd?: string; verbose?: boolean }) =>\n runOrExit(\n async () =>\n (await import(\"./commands/renovate-dispatch.js\")).runRenovateDispatchCommand({\n fleet: opts.fleet,\n }),\n opts,\n ),\n );\n\ncli.help();\ncli.version(version);\n\n// A typo'd / unrecognized subcommand (e.g. `reddoor-maint auditt`) otherwise\n// falls through cac with no matched command and exits 0 — a cron/CI typo would\n// \"succeed\" silently. cac emits `command:*` at the end of parse() exactly when\n// a positional arg was given but matched no command (a bare `reddoor-maint`,\n// `--help`, and `--version` do NOT trigger it: they have no leading positional\n// or are handled before this fires). Turn that into a clear stderr error +\n// non-zero exit. process.argv[2] is the first positional, i.e. the bad command.\ncli.on(\"command:*\", () => {\n const unknown = cli.args[0] ?? process.argv[2] ?? \"\";\n console.error(\n `error: unknown command '${unknown}'. Run 'reddoor-maint --help' to see available commands.`,\n );\n process.exit(1);\n});\n\ncli.parse();\n","import { readFileSync, existsSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\n\n/**\n * Read the @reddoorla/maintenance package version, given the directory of a\n * file that lives inside the package. Walks UP looking for the first\n * `package.json` whose `name` matches. Defensive against bundling-layout\n * changes — the older \"two levels up\" assumption held for `dist/cli/bin.js`\n * but would silently mis-read (or return \"unknown\") if tsup ever moved bin\n * to a different depth. Same walk-up pattern as the self-version helper.\n *\n * Returns \"unknown\" when no matching package.json is reachable (Yarn PnP\n * setups stash manifests inside .zip caches; the readFileSync there fails\n * before any name check).\n */\nexport function resolvePackageVersion(fromDir: string): string {\n try {\n let dir = fromDir;\n while (true) {\n const candidate = join(dir, \"package.json\");\n if (existsSync(candidate)) {\n const raw = readFileSync(candidate, \"utf-8\");\n const pkg = JSON.parse(raw) as { name?: string; version?: string };\n if (pkg.name === \"@reddoorla/maintenance\") {\n return pkg.version ?? \"unknown\";\n }\n }\n const parent = dirname(dir);\n if (parent === dir) return \"unknown\";\n dir = parent;\n }\n } catch {\n return \"unknown\";\n }\n}\n"],"mappings":"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","names":["dirname","dirname"]}