@reddoorla/maintenance 0.86.0 → 0.87.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 (167) hide show
  1. package/dist/airtable-ZBUPQJSC.js +9 -0
  2. package/dist/{announce-MXTUQ6C7.js → announce-I2Z4JN3I.js} +49 -28
  3. package/dist/announce-I2Z4JN3I.js.map +1 -0
  4. package/dist/attachments-NLZIJ5NU.js +9 -0
  5. package/dist/{bump-deps-EC74CPLS.js → bump-deps-CGVELRLK.js} +2 -2
  6. package/dist/{chunk-2ZPT7GLV.js → chunk-3JQ2FO3D.js} +3 -3
  7. package/dist/chunk-3JQ2FO3D.js.map +1 -0
  8. package/dist/{chunk-KTH5ZODA.js → chunk-7BZUOZPU.js} +2 -2
  9. package/dist/{chunk-LDWZSLSU.js → chunk-7NYN2P6I.js} +7 -4
  10. package/dist/{chunk-LDWZSLSU.js.map → chunk-7NYN2P6I.js.map} +1 -1
  11. package/dist/{chunk-3VQWSE3F.js → chunk-7UAYMAKR.js} +4 -4
  12. package/dist/{chunk-BYK66EWO.js → chunk-BBUSGEES.js} +52 -46
  13. package/dist/chunk-BBUSGEES.js.map +1 -0
  14. package/dist/{chunk-SLUQ7R6I.js → chunk-C6CLRKPV.js} +5 -5
  15. package/dist/{chunk-P2FE5AI6.js → chunk-CROKUHJB.js} +2 -2
  16. package/dist/{chunk-W7Z3BGCI.js → chunk-DEWJMMG3.js} +2 -2
  17. package/dist/{chunk-ACBRLLFT.js → chunk-F3JWOPTG.js} +7 -3
  18. package/dist/chunk-F3JWOPTG.js.map +1 -0
  19. package/dist/{chunk-TERE5SDF.js → chunk-HEWSRXYV.js} +4 -4
  20. package/dist/chunk-HHMMUR4P.js +335 -0
  21. package/dist/chunk-HHMMUR4P.js.map +1 -0
  22. package/dist/{chunk-X3B723J4.js → chunk-HSI6Q7MI.js} +38 -9
  23. package/dist/chunk-HSI6Q7MI.js.map +1 -0
  24. package/dist/{chunk-Z7AXYKQB.js → chunk-IY6256GK.js} +1 -1
  25. package/dist/{chunk-CQR4LIM7.js → chunk-MT2JINSS.js} +5 -1
  26. package/dist/{chunk-CQR4LIM7.js.map → chunk-MT2JINSS.js.map} +1 -1
  27. package/dist/{chunk-F5KM4LEQ.js → chunk-NCMZMLY7.js} +3 -3
  28. package/dist/{chunk-2NDRHXMR.js → chunk-NDWYJLRF.js} +17 -140
  29. package/dist/chunk-NDWYJLRF.js.map +1 -0
  30. package/dist/{chunk-PPE5KZJK.js → chunk-NXNPDCF3.js} +3 -3
  31. package/dist/{chunk-ITIOFN7A.js → chunk-P6AORG32.js} +29 -10
  32. package/dist/chunk-P6AORG32.js.map +1 -0
  33. package/dist/{chunk-N45M3FDK.js → chunk-SDDPUAWH.js} +4 -4
  34. package/dist/{chunk-ZKXSX4LO.js → chunk-TD2NNXLZ.js} +9 -9
  35. package/dist/{chunk-ILUNZHYV.js → chunk-YFAXOD7Z.js} +10 -6
  36. package/dist/chunk-YFAXOD7Z.js.map +1 -0
  37. package/dist/{chunk-2EVEFOZY.js → chunk-ZBDZTJUO.js} +2 -2
  38. package/dist/chunk-ZGBEIWSC.js +146 -0
  39. package/dist/chunk-ZGBEIWSC.js.map +1 -0
  40. package/dist/cli/bin.js +32 -27
  41. package/dist/cli/bin.js.map +1 -1
  42. package/dist/cli/commands/audit.js +15 -11
  43. package/dist/cli/commands/audit.js.map +1 -1
  44. package/dist/{convert-to-pnpm-6L6P74WR.js → convert-to-pnpm-QFJOEP5V.js} +2 -2
  45. package/dist/{db-B35OGV3G.js → db-UXNIZ2RG.js} +6 -6
  46. package/dist/{digest-BL6UJYKH.js → digest-FW6QMZZJ.js} +17 -16
  47. package/dist/{digest-BL6UJYKH.js.map → digest-FW6QMZZJ.js.map} +1 -1
  48. package/dist/{email-VTTTKKL5.js → email-KJXASVH5.js} +3 -3
  49. package/dist/{ensure-site-DFTB7MN3.js → ensure-site-4DCEBAPM.js} +19 -11
  50. package/dist/ensure-site-4DCEBAPM.js.map +1 -0
  51. package/dist/fleet-state-7TJ2GYT2.js +39 -0
  52. package/dist/{forms-notify-target-75U6OU7L.js → forms-notify-target-2EAWCWBW.js} +7 -4
  53. package/dist/forms-notify-target-2EAWCWBW.js.map +1 -0
  54. package/dist/{github-signals-IJUPGCUX.js → github-signals-E2WPYTBK.js} +9 -9
  55. package/dist/{header-image-AWLLQA4L.js → header-image-6IC53W27.js} +7 -7
  56. package/dist/{header-images-335L2AKB.js → header-images-SQBCE5OD.js} +21 -1
  57. package/dist/header-images-SQBCE5OD.js.map +1 -0
  58. package/dist/{health-endpoint-WJC2UMYS.js → health-endpoint-RFXL2F4A.js} +2 -2
  59. package/dist/health-mirror-L5JBEJVA.js +41 -0
  60. package/dist/health-mirror-L5JBEJVA.js.map +1 -0
  61. package/dist/{import-airtable-HE43FB4H.js → import-airtable-OHVY344G.js} +7 -5
  62. package/dist/index.d.ts +149 -0
  63. package/dist/index.js +147 -50
  64. package/dist/index.js.map +1 -1
  65. package/dist/{init-NJEOEO7I.js → init-XRAQZYID.js} +9 -9
  66. package/dist/{launch-XNAX427U.js → launch-IX4Y5JJC.js} +55 -26
  67. package/dist/launch-IX4Y5JJC.js.map +1 -0
  68. package/dist/{lighthouse-airtable-LBVIYYP5.js → lighthouse-airtable-3GUWHETW.js} +3 -3
  69. package/dist/{onboard-GK4PHPNI.js → onboard-O4IAHJMO.js} +2 -2
  70. package/dist/{orchestrate-77UO4BYJ.js → orchestrate-YLWQ6KK2.js} +10 -9
  71. package/dist/{parity-MWS24A3H.js → parity-56KA2DGR.js} +4 -4
  72. package/dist/{pipeline-S6CVHDGB.js → pipeline-3AANZFCR.js} +2 -2
  73. package/dist/{preflight-6WBNU2GZ.js → preflight-TNORKGCX.js} +11 -10
  74. package/dist/{preflight-6WBNU2GZ.js.map → preflight-TNORKGCX.js.map} +1 -1
  75. package/dist/{prismic-ci-3RHOZUUT.js → prismic-ci-G2AK53VL.js} +5 -5
  76. package/dist/{prismic-models-QBGUT2Q3.js → prismic-models-O6Z3OK3A.js} +13 -8
  77. package/dist/prismic-models-O6Z3OK3A.js.map +1 -0
  78. package/dist/prospect/types.d.ts +267 -0
  79. package/dist/prospect/types.js +1 -0
  80. package/dist/{prospect-audit-IUNCJ2LV.js → prospect-audit-KSXUNQ5J.js} +4 -4
  81. package/dist/recipes/sync-configs.js +2 -2
  82. package/dist/{render-AQAVQDPZ.js → render-IFOGFB5T.js} +3 -3
  83. package/dist/render-IFOGFB5T.js.map +1 -0
  84. package/dist/render-from-row-BWSOIYDS.js +17 -0
  85. package/dist/render-from-row-BWSOIYDS.js.map +1 -0
  86. package/dist/{renovate-dispatch-QJY75ODR.js → renovate-dispatch-6GQWTRKX.js} +10 -10
  87. package/dist/{renovate-dispatch-QJY75ODR.js.map → renovate-dispatch-6GQWTRKX.js.map} +1 -1
  88. package/dist/{report-4EVNDHOZ.js → report-R3E3Y7HR.js} +63 -21
  89. package/dist/report-R3E3Y7HR.js.map +1 -0
  90. package/dist/report-mirror-4MNORYQ4.js +49 -0
  91. package/dist/report-mirror-4MNORYQ4.js.map +1 -0
  92. package/dist/rerender-3FM6DNRH.js +32 -0
  93. package/dist/rerender-3FM6DNRH.js.map +1 -0
  94. package/dist/{self-updating-6DWI2Z56.js → self-updating-UCIWESAX.js} +2 -2
  95. package/dist/{selftest-RC36MTKP.js → selftest-VGHWPN72.js} +22 -20
  96. package/dist/{selftest-RC36MTKP.js.map → selftest-VGHWPN72.js.map} +1 -1
  97. package/dist/site-mirror-NIREX5ME.js +52 -0
  98. package/dist/site-mirror-NIREX5ME.js.map +1 -0
  99. package/dist/{smoke-suite-VYY2LXM2.js → smoke-suite-U3UKD3NS.js} +2 -2
  100. package/dist/{submissions-Q7DRFOCD.js → submissions-IQEJX6FK.js} +2 -2
  101. package/dist/{svelte-codemods-25J2FO22.js → svelte-codemods-GOKXTBHW.js} +2 -2
  102. package/dist/{sync-34HNZZ56.js → sync-CTUVXMCK.js} +4 -4
  103. package/dist/{sync-configs-3SE6PSCO.js → sync-configs-SPTLFJCC.js} +4 -4
  104. package/dist/{upgrade-WBRQ62FX.js → upgrade-W6ZR3FJH.js} +2 -2
  105. package/dist/{webflow-IRKOIT7S.js → webflow-624FEBFD.js} +4 -4
  106. package/dist/{websites-SXWTP72S.js → websites-D6WYFAES.js} +2 -2
  107. package/dist/websites-D6WYFAES.js.map +1 -0
  108. package/dist/{write-audits-to-airtable-MBEILLPU.js → write-audits-to-airtable-CMKHGJNQ.js} +4 -4
  109. package/dist/write-audits-to-airtable-CMKHGJNQ.js.map +1 -0
  110. package/package.json +5 -1
  111. package/dist/airtable-G7WBQQMI.js +0 -9
  112. package/dist/announce-MXTUQ6C7.js.map +0 -1
  113. package/dist/chunk-2NDRHXMR.js.map +0 -1
  114. package/dist/chunk-2ZPT7GLV.js.map +0 -1
  115. package/dist/chunk-ACBRLLFT.js.map +0 -1
  116. package/dist/chunk-BYK66EWO.js.map +0 -1
  117. package/dist/chunk-ILUNZHYV.js.map +0 -1
  118. package/dist/chunk-ITIOFN7A.js.map +0 -1
  119. package/dist/chunk-X3B723J4.js.map +0 -1
  120. package/dist/ensure-site-DFTB7MN3.js.map +0 -1
  121. package/dist/forms-notify-target-75U6OU7L.js.map +0 -1
  122. package/dist/header-images-335L2AKB.js.map +0 -1
  123. package/dist/health-mirror-4B2L7T2Y.js +0 -75
  124. package/dist/health-mirror-4B2L7T2Y.js.map +0 -1
  125. package/dist/launch-XNAX427U.js.map +0 -1
  126. package/dist/prismic-models-QBGUT2Q3.js.map +0 -1
  127. package/dist/report-4EVNDHOZ.js.map +0 -1
  128. /package/dist/{airtable-G7WBQQMI.js.map → airtable-ZBUPQJSC.js.map} +0 -0
  129. /package/dist/{import-airtable-HE43FB4H.js.map → attachments-NLZIJ5NU.js.map} +0 -0
  130. /package/dist/{bump-deps-EC74CPLS.js.map → bump-deps-CGVELRLK.js.map} +0 -0
  131. /package/dist/{chunk-KTH5ZODA.js.map → chunk-7BZUOZPU.js.map} +0 -0
  132. /package/dist/{chunk-3VQWSE3F.js.map → chunk-7UAYMAKR.js.map} +0 -0
  133. /package/dist/{chunk-SLUQ7R6I.js.map → chunk-C6CLRKPV.js.map} +0 -0
  134. /package/dist/{chunk-P2FE5AI6.js.map → chunk-CROKUHJB.js.map} +0 -0
  135. /package/dist/{chunk-W7Z3BGCI.js.map → chunk-DEWJMMG3.js.map} +0 -0
  136. /package/dist/{chunk-TERE5SDF.js.map → chunk-HEWSRXYV.js.map} +0 -0
  137. /package/dist/{chunk-Z7AXYKQB.js.map → chunk-IY6256GK.js.map} +0 -0
  138. /package/dist/{chunk-F5KM4LEQ.js.map → chunk-NCMZMLY7.js.map} +0 -0
  139. /package/dist/{chunk-PPE5KZJK.js.map → chunk-NXNPDCF3.js.map} +0 -0
  140. /package/dist/{chunk-N45M3FDK.js.map → chunk-SDDPUAWH.js.map} +0 -0
  141. /package/dist/{chunk-ZKXSX4LO.js.map → chunk-TD2NNXLZ.js.map} +0 -0
  142. /package/dist/{chunk-2EVEFOZY.js.map → chunk-ZBDZTJUO.js.map} +0 -0
  143. /package/dist/{convert-to-pnpm-6L6P74WR.js.map → convert-to-pnpm-QFJOEP5V.js.map} +0 -0
  144. /package/dist/{db-B35OGV3G.js.map → db-UXNIZ2RG.js.map} +0 -0
  145. /package/dist/{email-VTTTKKL5.js.map → email-KJXASVH5.js.map} +0 -0
  146. /package/dist/{lighthouse-airtable-LBVIYYP5.js.map → fleet-state-7TJ2GYT2.js.map} +0 -0
  147. /package/dist/{github-signals-IJUPGCUX.js.map → github-signals-E2WPYTBK.js.map} +0 -0
  148. /package/dist/{header-image-AWLLQA4L.js.map → header-image-6IC53W27.js.map} +0 -0
  149. /package/dist/{health-endpoint-WJC2UMYS.js.map → health-endpoint-RFXL2F4A.js.map} +0 -0
  150. /package/dist/{orchestrate-77UO4BYJ.js.map → import-airtable-OHVY344G.js.map} +0 -0
  151. /package/dist/{init-NJEOEO7I.js.map → init-XRAQZYID.js.map} +0 -0
  152. /package/dist/{parity-MWS24A3H.js.map → lighthouse-airtable-3GUWHETW.js.map} +0 -0
  153. /package/dist/{onboard-GK4PHPNI.js.map → onboard-O4IAHJMO.js.map} +0 -0
  154. /package/dist/{pipeline-S6CVHDGB.js.map → orchestrate-YLWQ6KK2.js.map} +0 -0
  155. /package/dist/{render-AQAVQDPZ.js.map → parity-56KA2DGR.js.map} +0 -0
  156. /package/dist/{websites-SXWTP72S.js.map → pipeline-3AANZFCR.js.map} +0 -0
  157. /package/dist/{prismic-ci-3RHOZUUT.js.map → prismic-ci-G2AK53VL.js.map} +0 -0
  158. /package/dist/{write-audits-to-airtable-MBEILLPU.js.map → prospect/types.js.map} +0 -0
  159. /package/dist/{prospect-audit-IUNCJ2LV.js.map → prospect-audit-KSXUNQ5J.js.map} +0 -0
  160. /package/dist/{self-updating-6DWI2Z56.js.map → self-updating-UCIWESAX.js.map} +0 -0
  161. /package/dist/{smoke-suite-VYY2LXM2.js.map → smoke-suite-U3UKD3NS.js.map} +0 -0
  162. /package/dist/{submissions-Q7DRFOCD.js.map → submissions-IQEJX6FK.js.map} +0 -0
  163. /package/dist/{svelte-codemods-25J2FO22.js.map → svelte-codemods-GOKXTBHW.js.map} +0 -0
  164. /package/dist/{sync-34HNZZ56.js.map → sync-CTUVXMCK.js.map} +0 -0
  165. /package/dist/{sync-configs-3SE6PSCO.js.map → sync-configs-SPTLFJCC.js.map} +0 -0
  166. /package/dist/{upgrade-WBRQ62FX.js.map → upgrade-W6ZR3FJH.js.map} +0 -0
  167. /package/dist/{webflow-IRKOIT7S.js.map → webflow-624FEBFD.js.map} +0 -0
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/reports/airtable/ensure-site.ts","../src/cli/commands/ensure-site.ts"],"sourcesContent":["import type { FieldSet, Records } from \"airtable\";\nimport type { AirtableBase } from \"./client.js\";\nimport { WEBSITES_TABLE, listWebsites, siteSlug } from \"./websites.js\";\nimport { toAirtableStatus } from \"./site-status.js\";\n\nexport type EnsureSiteInput = {\n /** Canonical slug — matches by siteSlug(Name); the Name on create when no displayName given. */\n slug: string;\n /** Human display name written to Name on create. Name is consumed VERBATIM in\n * client-facing copy (forms auto-reply intro, report subjects), so a bare\n * machine slug there reads as \"Thanks for reaching out to acme-co.\" Must\n * slugify to the same slug or the row wouldn't be found on re-run. */\n displayName?: string;\n url?: string;\n pointOfContact?: string;\n /** owner/repo. Default on create: `reddoorla/<slug>`. */\n gitRepo?: string;\n};\n\nexport type EnsureSiteResult = {\n status: \"created\" | \"exists\";\n siteId: string;\n /** Airtable column names written on the exists path (create path writes all). */\n updatedFields: string[];\n /** Inputs that DIFFERED from an existing non-blank cell and were left\n * untouched (fill-blanks-only) — surfaced so a resumed bootstrap with a\n * corrected value doesn't silently discard it. */\n skippedMismatches: string[];\n};\n\n/** Column-name map (load-bearing magic strings — see websites.ts's mapRow header). */\nconst COLS = {\n url: \"url\",\n pointOfContact: \"point of contact\",\n gitRepo: \"Git repo\",\n} as const;\n\n/**\n * Find-or-create the Websites row for a slug so a new site exists in the fleet\n * inventory (audits, form-ingest slug resolution, reports) from day one.\n *\n * Fill-blanks-only on the exists path: this command runs from a bootstrap skill\n * that may be re-run to resume — it must never clobber operator-edited cells.\n * Frequencies are deliberately NOT set (launch flips the lifecycle); Status is\n * only written on create (\"building\" — Airtable's \"in development\" until the\n * stage-2 vocabulary switch flips).\n */\nexport async function ensureSite(\n base: AirtableBase,\n input: EnsureSiteInput,\n): Promise<EnsureSiteResult> {\n const slug = siteSlug(input.slug);\n if (!slug) throw new Error(`ensure-site: '${input.slug}' does not slugify to a usable slug`);\n if (input.displayName && siteSlug(input.displayName) !== slug)\n throw new Error(\n `ensure-site: display name '${input.displayName}' slugifies to '${siteSlug(input.displayName)}', not '${slug}' — the row would not be found on re-run`,\n );\n\n const existing = (await listWebsites(base)).find((w) => siteSlug(w.name) === slug);\n\n if (!existing) {\n const fields: FieldSet = {\n Name: input.displayName ?? slug,\n Status: toAirtableStatus(\"building\"),\n [COLS.gitRepo]: input.gitRepo ?? `reddoorla/${slug}`,\n };\n if (input.url) fields[COLS.url] = input.url;\n if (input.pointOfContact) fields[COLS.pointOfContact] = input.pointOfContact;\n const created = (await base(WEBSITES_TABLE).create([{ fields }])) as Records<FieldSet>;\n return {\n status: \"created\",\n siteId: created[0]!.id,\n updatedFields: [],\n skippedMismatches: [],\n };\n }\n\n const updates: FieldSet = {};\n const skippedMismatches: string[] = [];\n const blank = (v: unknown) => v === null || v === undefined || v === \"\";\n const consider = (column: string, provided: string | undefined, current: unknown) => {\n if (!provided) return;\n if (blank(current)) updates[column] = provided;\n else if (current !== provided) skippedMismatches.push(column);\n };\n consider(COLS.url, input.url, existing.url || null);\n consider(COLS.pointOfContact, input.pointOfContact, existing.pointOfContact);\n consider(COLS.gitRepo, input.gitRepo, existing.gitRepo);\n\n const updatedFields = Object.keys(updates);\n if (updatedFields.length > 0) {\n await base(WEBSITES_TABLE).update([{ id: existing.id, fields: updates }]);\n }\n return { status: \"exists\", siteId: existing.id, updatedFields, skippedMismatches };\n}\n","import { openBase, readAirtableConfig } from \"../../reports/airtable/client.js\";\nimport { ensureSite } from \"../../reports/airtable/ensure-site.js\";\n\nexport type EnsureSiteCommandOptions = {\n name?: string;\n url?: string;\n contact?: string;\n gitRepo?: string;\n cwd?: string;\n};\n\n/**\n * `ensure-site <slug>` — create/verify the fleet-inventory row for a new site.\n * Day-one step of the /new-site bootstrap skill. Fill-blanks-only; safe to re-run.\n */\nexport async function runEnsureSiteCommand(\n slug: string | undefined,\n opts: EnsureSiteCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (!slug) return { output: \"Provide a <slug> (e.g. `ensure-site roalson`).\", code: 2 };\n try {\n const base = openBase(readAirtableConfig());\n const result = await ensureSite(base, {\n slug,\n ...(opts.name ? { displayName: opts.name } : {}),\n ...(opts.url ? { url: opts.url } : {}),\n ...(opts.contact ? { pointOfContact: opts.contact } : {}),\n ...(opts.gitRepo ? { gitRepo: opts.gitRepo } : {}),\n });\n const filled =\n result.updatedFields.length > 0\n ? ` — filled blank field(s): ${result.updatedFields.join(\", \")}`\n : \"\";\n const skipped =\n result.skippedMismatches.length > 0\n ? ` — differs from existing, left untouched (edit in Airtable): ${result.skippedMismatches.join(\", \")}`\n : \"\";\n const nameNote =\n result.status === \"created\" && !opts.name\n ? ` — Name set to \"${slug}\"; retitle in Airtable before forms/announce go live (or re-create with --name)`\n : \"\";\n return {\n output: `[${slug}] ${result.status} (${result.siteId})${filled}${skipped}${nameNote}`,\n code: 0,\n };\n } catch (err) {\n const e = err as { message?: string; exitCode?: number };\n return { output: e.message ?? String(err), code: e.exitCode ?? 1 };\n }\n}\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/recipes/forms-notify-target.ts","../src/cli/commands/forms-notify-target.ts"],"sourcesContent":["import { openBase, readAirtableConfig } from \"../reports/airtable/client.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport {\n listWebsites,\n siteSlug,\n updateSiteField,\n type Status,\n type WebsiteRow,\n} from \"../reports/airtable/websites.js\";\nimport { canonicalizeStatus, toAirtableStatus } from \"../reports/airtable/site-status.js\";\nimport { describeNotifyTarget, type NotifyTarget } from \"../forms/notify.js\";\n\n/** The Airtable column the pre-launch guard actually lives in. */\nexport const STATUS_COLUMN = \"Status\";\n\n/** The two ends of the verify flip. Deliberately the ONLY transition this\n * performs: a site in any other status is already guarded (or is deliberately\n * something else, like \"hosted-only\"), and silently rewriting that would be the\n * same class of unseen change as the incident. */\nexport const LIVE_STATUS: Status = \"maintained\";\nexport const VERIFY_STATUS: Status = \"launching\";\n\nexport type FormsNotifyTargetDeps = {\n base?: AirtableBase;\n /** Site slug or the Airtable Websites NAME (both accepted). */\n site: string;\n /** `on` routes notifications to the operator; `off` restores. Omit to read. */\n set?: \"on\" | \"off\";\n /** Status to restore with `--set off`. Required, never inferred. */\n restore?: string;\n};\n\nexport type FormsNotifyTargetResult = {\n site: string;\n status: Status | null;\n target: NotifyTarget;\n /** Present only when a flip was attempted. `confirmed` is the whole point:\n * it comes from RE-READING the row, not from the write call returning. */\n flip?: { from: Status | null; to: Status; confirmed: boolean };\n};\n\n/**\n * The exact Airtable cell to write for an operator-supplied `--restore` value:\n * the operator's own string, verbatim.\n *\n * This is the ONE non-revertible surface in the #539 Phase 4 stage-1 rename —\n * every other change is code, and `git revert` undoes code. It cannot undo a\n * rewritten Airtable cell. So the rule here is stricter than everywhere else:\n * substitute only when the substitution is provably lossless, i.e. when\n * canonicalizing and mapping back ROUND-TRIPS to the operator's own string.\n *\n * That condition is, today, never false in a way that changes the answer — which\n * is the point. `toAirtableStatus(canonicalizeStatus(raw))` either equals `raw`\n * (so writing it is writing `raw`) or it does not (so we must write `raw`). The\n * function therefore reduces to \"write raw\", and it is written this way so the\n * reduction is visible rather than assumed. The case that made it matter:\n * `--restore legacy` canonicalizes to `archived`, which maps back to\n * \"deprecated\" — a different, real, operator-visible Airtable option that nobody\n * asked for. `hosting` → `hosted-only` → \"hosting\" round-trips and is safe.\n *\n * Airtable, not this module, is the authority on which option strings the\n * \"Status\" single-select accepts. Writing verbatim delegates to it: a typo is\n * rejected loudly at the API, exactly as it was before the rename.\n */\nexport function restoreCell(raw: string): string {\n const canonical = canonicalizeStatus(raw);\n if (canonical === null) return raw;\n const roundTripped = toAirtableStatus(canonical);\n return roundTripped === raw ? roundTripped : raw;\n}\n\nfunction findSite(rows: WebsiteRow[], site: string): WebsiteRow | undefined {\n const wanted = site.trim().toLowerCase();\n return rows.find(\n (r) => siteSlug(r.name) === siteSlug(site) || r.name.trim().toLowerCase() === wanted,\n );\n}\n\n/**\n * Answer \"who would a form submission on this site email?\" — and optionally\n * flip the pre-launch guard, confirming the flip by reading it back.\n *\n * The guard is a single Airtable `Status` cell. Nothing between \"I intended to\n * flip it\" and \"the client received a test lead\" reported the current state, so\n * on 2026-08-03 a flip that never landed sent a real client a test submission.\n * The fix is not a better intention, it is feedback: this reads the row back\n * after every write and refuses to call the flip confirmed on anything less.\n */\nexport async function formsNotifyTarget(\n deps: FormsNotifyTargetDeps,\n): Promise<FormsNotifyTargetResult> {\n const base = deps.base ?? openBase(readAirtableConfig());\n const rows = await listWebsites(base);\n const row = findSite(rows, deps.site);\n if (!row) {\n // The Websites NAME is not the repo slug (\"Sonder\", not \"gallerysonder\"),\n // and that mismatch has cost time before — so name the near misses rather\n // than making the operator go read Airtable to find the spelling.\n const needle = siteSlug(deps.site);\n const near = rows\n .map((r) => r.name)\n .filter((n) => siteSlug(n).includes(needle) || needle.includes(siteSlug(n)));\n throw Object.assign(\n new Error(\n `No Websites row matches '${deps.site}'. The Websites NAME is not always the repo ` +\n `slug (Sonder, not gallerysonder).` +\n (near.length > 0 ? ` Did you mean: ${near.join(\", \")}?` : \"\"),\n ),\n { exitCode: 2 },\n );\n }\n\n if (!deps.set) {\n return { site: row.name, status: row.status, target: describeNotifyTarget(row) };\n }\n\n // `--restore` is operator free text. It is canonicalized for what this command\n // REPORTS (`flip.to`, and the predicates downstream), but NEVER for what it\n // WRITES — see `restoreCell` below.\n const restoreRaw = deps.restore?.trim();\n const to = deps.set === \"on\" ? VERIFY_STATUS : (canonicalizeStatus(restoreRaw) ?? undefined);\n if (deps.set === \"off\" && !to) {\n throw Object.assign(\n new Error(\n `--set off needs --restore <status>: the status to return to is never inferred. ` +\n `Guessing \"${LIVE_STATUS}\" for a site that was \"hosted-only\" or \"archived\" would start ` +\n `sending real client notifications — the inverse of the failure this command exists ` +\n `to prevent.`,\n ),\n { exitCode: 2 },\n );\n }\n // Only ever flip a LIVE site into verify mode. A site already outside\n // \"maintained\" is guarded already, and rewriting its status would destroy a\n // real value nobody asked us to touch.\n if (deps.set === \"on\" && row.status !== LIVE_STATUS) {\n throw Object.assign(\n new Error(\n `${row.name} is \"${row.status ?? \"blank\"}\", not \"${LIVE_STATUS}\" — notifications ` +\n `already go to the operator only, so there is nothing to flip. Leaving the status ` +\n `untouched.`,\n ),\n { exitCode: 2 },\n );\n }\n\n // `--set on` writes a status this MODULE owns (VERIFY_STATUS), so mapping it to\n // the current Airtable vocabulary is correct. `--set off` writes the operator's\n // own string — see restoreCell.\n const cell = deps.set === \"on\" ? toAirtableStatus(VERIFY_STATUS) : restoreCell(restoreRaw!);\n await updateSiteField(base, row.id, STATUS_COLUMN, cell);\n\n // Read it back. The write returning is NOT evidence the field changed.\n const after = findSite(await listWebsites(base), deps.site);\n if (!after) {\n throw Object.assign(new Error(`${row.name} vanished from Websites during the flip.`), {\n exitCode: 1,\n });\n }\n return {\n site: after.name,\n status: after.status,\n target: describeNotifyTarget(after),\n // `confirmed` compares the RAW cell against the exact string written, not\n // canonical-to-canonical. Canonical equality is too weak to be a read-back\n // guard here: `legacy` and `deprecated` are the same canonical status, so a\n // cell holding either would confirm a flip to the other. Comparing raw is the\n // only comparison that can actually catch \"the cell is not what I sent\". The\n // canonical check rides along so a reader that stopped canonicalizing is\n // caught too.\n flip: {\n from: row.status,\n to: to!,\n confirmed: after.statusRaw === cell && after.status === to,\n },\n };\n}\n","import {\n formsNotifyTarget,\n LIVE_STATUS,\n VERIFY_STATUS,\n type FormsNotifyTargetResult,\n} from \"../../recipes/forms-notify-target.js\";\n\nexport type FormsNotifyTargetCommandOptions = {\n set?: string;\n restore?: string;\n cwd?: string;\n};\n\nconst AUDIENCE_LABEL = {\n client: \"THE CLIENT\",\n operator: \"OPERATOR ONLY\",\n nobody: \"NOBODY\",\n} as const;\n\n/** The line that decides whether it is safe to test-submit. Deliberately the\n * loudest thing on screen: the incident happened because the answer was\n * invisible, not because it was subtle. */\nexport function formatNotifyTarget(r: FormsNotifyTargetResult): string {\n const lines: string[] = [];\n if (r.flip) {\n const arrow = `${r.flip.from ?? \"blank\"} → ${r.flip.to}`;\n lines.push(\n r.flip.confirmed\n ? `${r.site} Status: ${arrow} ✓ confirmed by read-back`\n : `${r.site} Status: ${arrow} ✗ NOT CONFIRMED — the row still reads \"${r.status ?? \"blank\"}\". Nothing was verified; do not test-submit.`,\n );\n } else {\n lines.push(`${r.site} Status: ${r.status ?? \"blank\"}`);\n }\n\n lines.push(`A submission right now would notify: ${AUDIENCE_LABEL[r.target.audience]}`);\n if (r.target.to.length > 0) lines.push(` to: ${r.target.to.join(\", \")}`);\n if (r.target.cc.length > 0) lines.push(` cc: ${r.target.cc.join(\", \")}`);\n lines.push(` ${r.target.reason}`);\n\n if (r.target.audience === \"client\") {\n lines.push(\n \"\",\n `⚠️ A test submission WILL email the client, and email cannot be recalled.`,\n ` Route it to yourself first: reddoor forms-notify-target ${r.site} --set on`,\n );\n }\n if (r.flip?.confirmed && r.flip.to === VERIFY_STATUS) {\n lines.push(\n \"\",\n `Safe to test. When you are done, restore it:`,\n ` reddoor forms-notify-target ${r.site} --set off --restore ${r.flip.from ?? LIVE_STATUS}`,\n );\n }\n return lines.join(\"\\n\");\n}\n\n/**\n * `forms-notify-target <site>` — show who a form submission would email, and\n * optionally flip the pre-launch guard with a read-back confirmation.\n *\n * Read-only by default: answering the question must never be riskier than not\n * asking it.\n */\nexport async function runFormsNotifyTargetCommand(\n site: string | undefined,\n opts: FormsNotifyTargetCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (!site?.trim()) {\n return { output: \"forms-notify-target requires <site> (slug or Airtable name)\", code: 2 };\n }\n const set = opts.set?.trim().toLowerCase();\n if (set !== undefined && set !== \"on\" && set !== \"off\") {\n return { output: `--set must be 'on' or 'off' (got '${opts.set}')`, code: 2 };\n }\n try {\n const result = await formsNotifyTarget({\n site: site.trim(),\n ...(set ? { set: set as \"on\" | \"off\" } : {}),\n ...(opts.restore ? { restore: opts.restore } : {}),\n });\n // An unconfirmed flip must not exit 0: a script (or a person skimming) that\n // reads exit status would otherwise take \"I flipped it\" on faith, which is\n // exactly the assumption that sent a client a test lead.\n return {\n output: formatNotifyTarget(result),\n code: result.flip && !result.flip.confirmed ? 1 : 0,\n };\n } catch (err) {\n const e = err as { message?: string; exitCode?: number };\n return { output: e.message ?? String(err), code: e.exitCode ?? 1 };\n }\n}\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/db/header-images.ts"],"sourcesContent":["/** Header images move to Turso (#539, design D5 completed).\n *\n * Since Phase 1 the schema has reserved `sites.header_image*` as the image's\n * home — the importer deliberately never touches those columns — but nothing\n * wrote them, so the read layer's `headerImage` was null fleet-wide and both\n * cockpit preflight and approve-report stayed pinned to the Airtable reader.\n * Two writers close that:\n *\n * - `storeHeaderImage` — the shared write, used by the header-image CLI's\n * dual-write (Airtable upload + Turso store, until Phase 5 freezes Airtable)\n * and by the backfill.\n * - `backfillHeaderImages` — one-shot: copy every site's CURRENT Airtable\n * attachment into Turso. Idempotent — a site whose BLOB is already\n * populated is skipped, so re-runs never clobber a freshly generated image\n * with a stale Airtable copy. Emits its machine line on EVERY run.\n */\nimport type { Db } from \"./client.js\";\nimport type { RawRecord } from \"./import-airtable.js\";\n\nexport type StoredHeaderImage = {\n bytes: Uint8Array;\n filename: string;\n contentType: string;\n /** The generator's stamp; null for a backfilled copy (a backfill is not a\n * generation — the column keeps meaning \"when the generator made it\"). */\n generatedAt: string | null;\n};\n\nexport async function storeHeaderImage(\n db: Db,\n siteId: string,\n image: StoredHeaderImage,\n): Promise<void> {\n await db\n .updateTable(\"sites\")\n .set({\n header_image: image.bytes,\n header_image_filename: image.filename,\n header_image_type: image.contentType,\n header_image_generated_at: image.generatedAt,\n })\n .where(\"id\", \"=\", siteId)\n .execute();\n}\n\ntype Attachment = { url: string; filename: string; type: string };\n\n/** NEWEST \"Header image\" attachment of a raw Websites record, or null.\n *\n * The tail, not the head: Airtable's uploadAttachment APPENDS, so a field that ever\n * held more than one file served its OLDEST image from `[0]` forever — see the note\n * in `reports/airtable/websites.ts` and #574/#577. Kept in step with that reader so\n * the Turso mirror and the send path can never disagree about which file is current. */\nexport function headerImageAttachment(rec: RawRecord): Attachment | null {\n const atts = rec.fields[\"Header image\"] as Array<Partial<Attachment>> | undefined;\n const a = atts?.at(-1);\n return a && typeof a.url === \"string\" && a.url.length > 0\n ? {\n url: a.url,\n filename: typeof a.filename === \"string\" ? a.filename : \"header-image\",\n type: typeof a.type === \"string\" ? a.type : \"application/octet-stream\",\n }\n : null;\n}\n\nexport type BackfillIo = {\n listWebsiteRecords: () => Promise<RawRecord[]>;\n /** Fetch attachment bytes; null on failure (named in the summary, not fatal). */\n fetchBytes: (url: string) => Promise<Uint8Array | null>;\n};\n\nexport type BackfillResult = {\n stored: number;\n /** Sites whose BLOB was already populated — never overwritten. */\n skipped: number;\n /** Sites with no Airtable attachment at all. */\n absent: number;\n /** Record ids whose attachment fetch failed — named, never silent. */\n failed: string[];\n};\n\nexport async function backfillHeaderImages(db: Db, io: BackfillIo): Promise<BackfillResult> {\n const records = await io.listWebsiteRecords();\n const populated = new Set(\n (await db.selectFrom(\"sites\").select(\"id\").where(\"header_image\", \"is not\", null).execute()).map(\n (r) => r.id,\n ),\n );\n const result: BackfillResult = { stored: 0, skipped: 0, absent: 0, failed: [] };\n for (const rec of records) {\n const att = headerImageAttachment(rec);\n if (!att) {\n result.absent++;\n continue;\n }\n if (populated.has(rec.id)) {\n result.skipped++;\n continue;\n }\n const bytes = await io.fetchBytes(att.url);\n if (bytes === null) {\n result.failed.push(rec.id);\n continue;\n }\n await storeHeaderImage(db, rec.id, {\n bytes,\n filename: att.filename,\n contentType: att.type,\n generatedAt: null,\n });\n result.stored++;\n }\n return result;\n}\n\n/** Human lines + the machine line, emitted on every run (count=0 included). */\nexport function formatBackfillResult(r: BackfillResult): string {\n const lines: string[] = [];\n if (r.failed.length > 0) {\n lines.push(`⚠ ${r.failed.length} attachment fetch(es) failed: ${r.failed.join(\", \")}`);\n }\n lines.push(\n `HEADER_IMAGE_BACKFILL stored=${r.stored} skipped=${r.skipped} absent=${r.absent} failed=${r.failed.length}`,\n );\n return lines.join(\"\\n\");\n}\n"],"mappings":";AA4BA,eAAsB,iBACpB,IACA,QACA,OACe;AACf,QAAM,GACH,YAAY,OAAO,EACnB,IAAI;AAAA,IACH,cAAc,MAAM;AAAA,IACpB,uBAAuB,MAAM;AAAA,IAC7B,mBAAmB,MAAM;AAAA,IACzB,2BAA2B,MAAM;AAAA,EACnC,CAAC,EACA,MAAM,MAAM,KAAK,MAAM,EACvB,QAAQ;AACb;AAUO,SAAS,sBAAsB,KAAmC;AACvE,QAAM,OAAO,IAAI,OAAO,cAAc;AACtC,QAAM,IAAI,MAAM,GAAG,EAAE;AACrB,SAAO,KAAK,OAAO,EAAE,QAAQ,YAAY,EAAE,IAAI,SAAS,IACpD;AAAA,IACE,KAAK,EAAE;AAAA,IACP,UAAU,OAAO,EAAE,aAAa,WAAW,EAAE,WAAW;AAAA,IACxD,MAAM,OAAO,EAAE,SAAS,WAAW,EAAE,OAAO;AAAA,EAC9C,IACA;AACN;AAkBA,eAAsB,qBAAqB,IAAQ,IAAyC;AAC1F,QAAM,UAAU,MAAM,GAAG,mBAAmB;AAC5C,QAAM,YAAY,IAAI;AAAA,KACnB,MAAM,GAAG,WAAW,OAAO,EAAE,OAAO,IAAI,EAAE,MAAM,gBAAgB,UAAU,IAAI,EAAE,QAAQ,GAAG;AAAA,MAC1F,CAAC,MAAM,EAAE;AAAA,IACX;AAAA,EACF;AACA,QAAM,SAAyB,EAAE,QAAQ,GAAG,SAAS,GAAG,QAAQ,GAAG,QAAQ,CAAC,EAAE;AAC9E,aAAW,OAAO,SAAS;AACzB,UAAM,MAAM,sBAAsB,GAAG;AACrC,QAAI,CAAC,KAAK;AACR,aAAO;AACP;AAAA,IACF;AACA,QAAI,UAAU,IAAI,IAAI,EAAE,GAAG;AACzB,aAAO;AACP;AAAA,IACF;AACA,UAAM,QAAQ,MAAM,GAAG,WAAW,IAAI,GAAG;AACzC,QAAI,UAAU,MAAM;AAClB,aAAO,OAAO,KAAK,IAAI,EAAE;AACzB;AAAA,IACF;AACA,UAAM,iBAAiB,IAAI,IAAI,IAAI;AAAA,MACjC;AAAA,MACA,UAAU,IAAI;AAAA,MACd,aAAa,IAAI;AAAA,MACjB,aAAa;AAAA,IACf,CAAC;AACD,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAGO,SAAS,qBAAqB,GAA2B;AAC9D,QAAM,QAAkB,CAAC;AACzB,MAAI,EAAE,OAAO,SAAS,GAAG;AACvB,UAAM,KAAK,UAAK,EAAE,OAAO,MAAM,iCAAiC,EAAE,OAAO,KAAK,IAAI,CAAC,EAAE;AAAA,EACvF;AACA,QAAM;AAAA,IACJ,gCAAgC,EAAE,MAAM,YAAY,EAAE,OAAO,WAAW,EAAE,MAAM,WAAW,EAAE,OAAO,MAAM;AAAA,EAC5G;AACA,SAAO,MAAM,KAAK,IAAI;AACxB;","names":[]}
@@ -1,75 +0,0 @@
1
- import {
2
- healthColumnFor,
3
- scheduleColumnFor
4
- } from "./chunk-ITIOFN7A.js";
5
- import "./chunk-ACBRLLFT.js";
6
- import {
7
- MAINTENANCE_CHECKLIST,
8
- TESTING_CHECKLIST
9
- } from "./chunk-VAD43MOR.js";
10
- import {
11
- openDb,
12
- readDbConfig
13
- } from "./chunk-JHGHLKFS.js";
14
- import "./chunk-3SF7D4DY.js";
15
- import "./chunk-LDWZSLSU.js";
16
- import "./chunk-6VAL7XJT.js";
17
-
18
- // src/db/fleet-state.ts
19
- async function mirrorHealthFields(db, siteId, fields) {
20
- const patch = {};
21
- for (const [field, value] of Object.entries(fields)) {
22
- const m = healthColumnFor(field);
23
- if (!m)
24
- throw new Error(`mirrorHealthFields: importer claims no site_health column for '${field}'`);
25
- patch[m.col] = m.coerce(value);
26
- }
27
- if (Object.keys(patch).length === 0) return true;
28
- const res = await db.updateTable("site_health").set(patch).where("site_id", "=", siteId).executeTakeFirst();
29
- return res.numUpdatedRows > 0n;
30
- }
31
- async function mirrorScheduleFields(db, siteId, fields, computedAt) {
32
- const patch = {};
33
- for (const [field, value] of Object.entries(fields)) {
34
- const m = scheduleColumnFor(field);
35
- if (!m)
36
- throw new Error(
37
- `mirrorScheduleFields: importer claims no site_schedule column for '${field}'`
38
- );
39
- patch[m.col] = m.coerce(value);
40
- }
41
- if (Object.keys(patch).length === 0) return true;
42
- patch.computed_at = computedAt;
43
- const res = await db.updateTable("site_schedule").set(patch).where("site_id", "=", siteId).executeTakeFirst();
44
- return res.numUpdatedRows > 0n;
45
- }
46
- var CHECKLIST_FIELD_BY_KEY = new Map(
47
- [...MAINTENANCE_CHECKLIST, ...TESTING_CHECKLIST].map((i) => [i.key, i.field])
48
- );
49
-
50
- // src/audits/health-mirror.ts
51
- async function makeHealthMirrorBestEffort(open = () => openDb(readDbConfig())) {
52
- let db;
53
- try {
54
- db = await open();
55
- } catch (e) {
56
- console.error(`[health-mirror] mirroring disabled: no libSQL (${String(e)})`);
57
- return null;
58
- }
59
- return (siteId, fields) => mirrorHealthFields(db, siteId, fields);
60
- }
61
- async function makeScheduleMirrorBestEffort(open = () => openDb(readDbConfig())) {
62
- let db;
63
- try {
64
- db = await open();
65
- } catch (e) {
66
- console.error(`[schedule-mirror] mirroring disabled: no libSQL (${String(e)})`);
67
- return null;
68
- }
69
- return (siteId, fields, computedAt) => mirrorScheduleFields(db, siteId, fields, computedAt);
70
- }
71
- export {
72
- makeHealthMirrorBestEffort,
73
- makeScheduleMirrorBestEffort
74
- };
75
- //# sourceMappingURL=health-mirror-4B2L7T2Y.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/db/fleet-state.ts","../src/audits/health-mirror.ts"],"sourcesContent":["/** Phase 2 of the Airtable → Turso migration (#539): the fleet-state READ layer.\n *\n * Returns the exact `WebsiteRow` shape the Airtable module returns, so every\n * repoint is an import-only swap at a composition root — the same trick\n * listSubmissionsForSite used for the hybrid-db cutover. The raw values live\n * in `sites`/`site_health`/`site_schedule` (written by the importer / hourly\n * sync); coercion to `WebsiteRow` happens HERE, reusing the Airtable module's\n * own exported coercers (`toVerdict`, `toFrequency`, `parseNotifyRouting`,\n * `parseSecurityAdvisories`, `trimToNull`) so there is one truth for each.\n *\n * The equivalence instrument (tests/db/fleet-state.test.ts) pins this module\n * to `mapRow` field-by-field: for a fixture record, `mapRow(rec)` must deep-\n * equal the row read back through here after an import. A new WebsiteRow\n * field fails that test until this module carries it.\n *\n * `headerImage` is the ONE deliberate exception (design D5): Airtable stopped\n * being its source — the bytes belong in `sites.header_image*`. Nothing\n * writes those columns yet (verified empty across all 44 prod rows,\n * 2026-08-24), so this reads null fleet-wide today, and approve-report — the\n * only request-path consumer — stays on the Airtable reader until the\n * header-image writer moves in Phase 3. The `url` is \"\" because the bytes\n * live in the row itself, not behind a signed URL.\n */\nimport type { Selectable, Updateable } from \"kysely\";\nimport type { Db } from \"./client.js\";\nimport type { ReportsTable, SiteHealthTable, SiteScheduleTable } from \"./schema.js\";\nimport { SITE_FIELDS, healthColumnFor, scheduleColumnFor } from \"./import-airtable.js\";\nimport {\n toReportType,\n parseAutoEvidence,\n type ReportRow,\n type DeliveryStatus,\n} from \"../reports/airtable/reports.js\";\nimport { MAINTENANCE_CHECKLIST, TESTING_CHECKLIST } from \"../reports/checklist.js\";\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { canonicalizeStatus } from \"../reports/airtable/site-status.js\";\nimport {\n parseNotifyRouting,\n parseSecurityAdvisories,\n toFrequency,\n toPrismicModelsVerdict,\n toVerdict,\n trimToNull,\n} from \"../reports/airtable/websites.js\";\n\ntype JoinedRow = Record<string, unknown>;\n\nconst str = (v: unknown): string | null => (typeof v === \"string\" ? v : null);\nconst num = (v: unknown): number | null => (typeof v === \"number\" ? v : null);\n/** Stored 1/0/null (importer's b01n) → boolean | null. */\nconst bool = (v: unknown): boolean | null => (typeof v === \"number\" ? v !== 0 : null);\n\n/** Stored JSON array (importer-normalized) → the trimmed string[] mapRow yields. */\nfunction parseAwc(raw: unknown): string[] {\n if (typeof raw !== \"string\" || raw.trim() === \"\") return [];\n try {\n const parsed: unknown = JSON.parse(raw);\n if (!Array.isArray(parsed)) return [];\n return parsed\n .filter((x): x is string => typeof x === \"string\")\n .map((s) => s.trim())\n .filter(Boolean);\n } catch {\n return [];\n }\n}\n\nfunction rowFromJoined(r: JoinedRow): WebsiteRow {\n const name = String(r.name ?? \"\");\n const headerFilename = str(r.header_image_filename);\n const headerType = str(r.header_image_type);\n return {\n id: String(r.id),\n name,\n url: str(r.url) ?? \"\",\n // `sites.status` holds the RAW Airtable cell (the importer stores it verbatim\n // so the hourly parity check compares raw-to-raw). Canonicalizing HERE — the\n // twin of mapRow's seam — is what keeps the #558 equivalence instrument green.\n status: canonicalizeStatus(r.status),\n statusRaw: str(r.status),\n pointOfContact: str(r.point_of_contact),\n maintenanceFreq: toFrequency(r.maintenance_freq, `${name} maintenance`),\n testingFreq: toFrequency(r.testing_freq, `${name} testing`),\n maintenanceFreqRaw: str(r.maintenance_freq),\n testingFreqRaw: str(r.testing_freq),\n maintenanceDay: str(r.maintenance_day),\n testingDay: str(r.testing_day),\n ga4PropertyId: str(r.ga4_property_id),\n searchQuery: str(r.search_query),\n searchConsoleProperty: str(r.search_console_property),\n analyticsSoftFailAt: str(r.analytics_soft_fail_at),\n gitRepo: str(r.git_repo),\n reportRecipientsTo: str(r.report_recipients_to),\n reportRecipientsCc: str(r.report_recipients_cc),\n acceptedWatchConditions: parseAwc(r.accepted_watch_conditions),\n headerImage:\n headerFilename !== null && headerType !== null\n ? { url: \"\", filename: headerFilename, type: headerType }\n : null,\n pScore: num(r.p_score),\n rScore: num(r.r_score),\n bpScore: num(r.bp_score),\n seoScore: num(r.seo_score),\n lastLighthouseAuditAt: str(r.lighthouse_at),\n a11yViolations: num(r.a11y_violations),\n depsDrifted: num(r.deps_drifted),\n depsMajorBehind: num(r.deps_major_behind),\n depsOutdated: num(r.deps_outdated),\n depsMajorOutdated: num(r.deps_major_outdated),\n securityVulnsCritical: num(r.vulns_critical),\n securityVulnsHigh: num(r.vulns_high),\n securityVulnsModerate: num(r.vulns_moderate),\n securityVulnsLow: num(r.vulns_low),\n securityAutoFixAttempts: num(r.auto_fix_attempts),\n lastSecurityAuditAt: str(r.security_audit_at),\n securityAdvisories: parseSecurityAdvisories(r.security_advisories),\n certDaysRemaining: num(r.cert_days_remaining),\n domainCheckedAt: str(r.domain_checked_at),\n netlifyId: trimToNull(r.netlify_id),\n deployStatus: str(r.deploy_status),\n lastDeployAt: str(r.last_deploy_at),\n deployLogUrl: str(r.deploy_log_url),\n deployCheckedAt: str(r.deploy_checked_at),\n functionHealth: toVerdict(r.function_health),\n cmsReachable: toVerdict(r.cms_reachable),\n turnstileWidget: toVerdict(r.turnstile_widget),\n functionHealthCheckedAt: str(r.function_health_checked_at),\n crossbrowserOk: bool(r.crossbrowser_ok),\n mobileOk: bool(r.mobile_ok),\n linksOk: bool(r.links_ok),\n brokenLinks: num(r.broken_links),\n browserCheckedAt: str(r.browser_checked_at),\n reachableOk: toVerdict(r.uptime_reachable),\n titleMetaOk: toVerdict(r.titles_meta_ok),\n copyIntro: trimToNull(r.copy_intro),\n copyContact: trimToNull(r.copy_contact),\n copyFooter: trimToNull(r.copy_footer),\n launchedAt: str(r.launched_at),\n newsletterWebhook: trimToNull(r.newsletter_webhook),\n notifyRouting: parseNotifyRouting(r.notify_routing),\n notifyRoutingRaw: trimToNull(r.notify_routing),\n mailchimpApiKey: trimToNull(r.mailchimp_api_key),\n mailchimpAudienceId: trimToNull(r.mailchimp_audience_id),\n requireTurnstile: typeof r.require_turnstile === \"number\" ? r.require_turnstile !== 0 : false,\n renovateFailingCis: num(r.renovate_failing_cis),\n defaultBranchCi: str(r.default_branch_ci),\n lastCommitAt: str(r.last_commit_at),\n githubSignalsAt: str(r.github_signals_at),\n smokeOk: toVerdict(r.smoke_ok),\n lastSmokeAt: str(r.last_smoke_at),\n formE2eOk: toVerdict(r.form_e2e_ok),\n formE2eCheckedAt: str(r.form_e2e_checked_at),\n prismicModels: toPrismicModelsVerdict(r.prismic_models),\n prismicModelsCheckedAt: str(r.prismic_models_checked_at),\n prismicModelsDrift: str(r.prismic_models_drift),\n prismicAckUntil: str(r.prismic_ack_until),\n nextMaintenanceAt: str(r.next_maintenance_at),\n nextTestingAt: str(r.next_testing_at),\n };\n}\n\n/** Every sites column EXCEPT the header-image BLOB. Since the 2026-08-24\n * backfill, header_image holds 0.7–3.5 MB JPEGs — a selectAll would haul\n * megabytes into every site lookup (per LEAD on the ingest path, ×44 on the\n * fleet list) and Turso bills the bytes. WebsiteRow only needs the metadata\n * columns; the bytes have their own dedicated readers when a consumer wants\n * them. Kept in lockstep with SitesTable by the blob-exclusion test. */\nconst SITE_COLUMNS = [\n \"sites.id\",\n \"sites.slug\",\n \"sites.name\",\n \"sites.url\",\n \"sites.status\",\n \"sites.point_of_contact\",\n \"sites.maintenance_freq\",\n \"sites.testing_freq\",\n \"sites.maintenance_day\",\n \"sites.testing_day\",\n \"sites.ga4_property_id\",\n \"sites.search_query\",\n \"sites.search_console_property\",\n \"sites.git_repo\",\n \"sites.netlify_id\",\n \"sites.report_recipients_to\",\n \"sites.report_recipients_cc\",\n \"sites.copy_intro\",\n \"sites.copy_contact\",\n \"sites.copy_footer\",\n \"sites.newsletter_webhook\",\n \"sites.mailchimp_api_key\",\n \"sites.mailchimp_audience_id\",\n \"sites.notify_routing\",\n \"sites.require_turnstile\",\n \"sites.accepted_watch_conditions\",\n \"sites.prismic_ack_until\",\n \"sites.launched_at\",\n \"sites.header_image_filename\",\n \"sites.header_image_type\",\n \"sites.header_image_generated_at\",\n \"sites.legacy\",\n] as const;\n\n/** The three-table join every read composes from. site_health/site_schedule\n * rows are upserted alongside sites by the importer, but LEFT JOIN anyway —\n * a site row must never vanish from a surface because a health row is absent. */\nfunction joined(db: Db) {\n return db\n .selectFrom(\"sites\")\n .leftJoin(\"site_health\", \"site_health.site_id\", \"sites.id\")\n .leftJoin(\"site_schedule\", \"site_schedule.site_id\", \"sites.id\")\n .select(SITE_COLUMNS)\n .selectAll([\"site_health\", \"site_schedule\"]);\n}\n\n/** Write-through for the site-detail editor (Phase 2): after the Airtable\n * write (still the Phase 2 source of truth), mirror the same cell into\n * `sites` so a Turso-reading page renders the edit immediately instead of\n * waiting for the next hourly sync. The column mapping is the IMPORTER's own\n * map — one truth — and the value gets the importer's empty-clears-to-null\n * semantics. Throws on a column the importer doesn't claim (the lockstep\n * test makes that unreachable for the editor's allowlist); the caller treats\n * a mirror failure as non-fatal — the hourly sync converges it.\n *\n * Deliberately NOT applicable to `Require Turnstile` or other non-text\n * columns: every editor field is plain text today, and the lockstep test\n * pins that assumption. */\nexport async function mirrorSiteField(\n db: Db,\n siteId: string,\n airtableColumn: string,\n value: string,\n): Promise<void> {\n const col = SITE_FIELDS[airtableColumn];\n if (!col)\n throw new Error(`mirrorSiteField: importer claims no sites column for '${airtableColumn}'`);\n const stored = value.trim() === \"\" ? null : value;\n await db\n .updateTable(\"sites\")\n .set({ [col]: stored })\n .where(\"id\", \"=\", siteId)\n .execute();\n}\n\n/** Write-through mirror for the nightly health writers (#539 Phase 3). Takes\n * the EXACT FieldSet just written to Airtable (updateAuditFields /\n * updateGitHubSignals return it), so the mirror can never carry a different\n * payload than the Airtable write it shadows. Resolution + coercion come from\n * the importer's healthColumnFor — one truth; an Airtable column no\n * site_health column claims throws (same contract as mirrorSiteField).\n * Partial by design: absent fields stay untouched, matching\n * updateGitHubSignals' deliberate omission of a null lastCommitAt. Returns\n * whether a site_health row matched: a site the hourly sync hasn't imported\n * yet updates 0 rows → false, so the caller can count it honestly\n * (mirror_missed) instead of claiming it mirrored — it still converges on the\n * next sync, like every mirror. An empty FieldSet runs no SQL and returns\n * true: nothing to mirror is not a miss. */\nexport async function mirrorHealthFields(\n db: Db,\n siteId: string,\n fields: Record<string, unknown>,\n): Promise<boolean> {\n // Per-column value types (number vs text) are guaranteed by healthColumnFor's\n // coercion — the numeric/text split lives there, once — so the patch builds\n // untyped and casts at the .set() boundary (the importer's own idiom).\n const patch: Record<string, string | number | null> = {};\n for (const [field, value] of Object.entries(fields)) {\n const m = healthColumnFor(field);\n if (!m)\n throw new Error(`mirrorHealthFields: importer claims no site_health column for '${field}'`);\n patch[m.col] = m.coerce(value);\n }\n if (Object.keys(patch).length === 0) return true;\n const res = await db\n .updateTable(\"site_health\")\n .set(patch as Updateable<SiteHealthTable>)\n .where(\"site_id\", \"=\", siteId)\n .executeTakeFirst();\n // kysely/libSQL reports numUpdatedRows as a BigInt — compare in BigInt.\n return res.numUpdatedRows > 0n;\n}\n\n/** The site_schedule twin of {@link mirrorHealthFields}, for the nightly\n * next-due write-back. `computedAt` stamps when THIS computation ran — the\n * hourly sync overwrites it with its own import stamp, same as every mirrored\n * value. Empty fields → full no-op (no lone computed_at stamp for a write\n * that carried nothing). Same return contract as mirrorHealthFields: false\n * when the UPDATE matched no site_schedule row (site not yet imported), true\n * otherwise — including the empty-fields no-op. */\nexport async function mirrorScheduleFields(\n db: Db,\n siteId: string,\n fields: Record<string, unknown>,\n computedAt: string,\n): Promise<boolean> {\n const patch: Record<string, string | null> = {};\n for (const [field, value] of Object.entries(fields)) {\n const m = scheduleColumnFor(field);\n if (!m)\n throw new Error(\n `mirrorScheduleFields: importer claims no site_schedule column for '${field}'`,\n );\n patch[m.col] = m.coerce(value);\n }\n if (Object.keys(patch).length === 0) return true;\n patch.computed_at = computedAt;\n const res = await db\n .updateTable(\"site_schedule\")\n .set(patch as Updateable<SiteScheduleTable>)\n .where(\"site_id\", \"=\", siteId)\n .executeTakeFirst();\n return res.numUpdatedRows > 0n;\n}\n\n/** Same contract as the Airtable getWebsiteBySlug: slug is siteSlug(Name),\n * precomputed into the UNIQUE sites.slug column at import time. */\nexport async function getSiteBySlug(db: Db, slug: string): Promise<WebsiteRow | null> {\n const r = await joined(db).where(\"sites.slug\", \"=\", slug).executeTakeFirst();\n return r ? rowFromJoined(r as JoinedRow) : null;\n}\n\n/** By Airtable rec id (the PK) — approve-report's lookup shape. */\nexport async function getSiteById(db: Db, id: string): Promise<WebsiteRow | null> {\n const r = await joined(db).where(\"sites.id\", \"=\", id).executeTakeFirst();\n return r ? rowFromJoined(r as JoinedRow) : null;\n}\n\n/** Same contract as the Airtable listWebsites: every site, one row each.\n * Name-ordered for determinism (Airtable returned table order; no consumer\n * is order-sensitive — the cockpit groups and sorts itself). */\nexport async function listSites(db: Db): Promise<WebsiteRow[]> {\n const rows = await joined(db).orderBy(\"sites.name\").execute();\n return rows.map((r) => rowFromJoined(r as JoinedRow));\n}\n\n// ————————————————————————— reports —————————————————————————\n\n/** stable checklist key → Airtable column name. The importer stores stable keys\n * (mapReportRecord); `ReportRow.checklist` exposes Airtable column names — one\n * derived map, built from the checklist definitions themselves. */\nconst CHECKLIST_FIELD_BY_KEY: ReadonlyMap<string, string> = new Map(\n [...MAINTENANCE_CHECKLIST, ...TESTING_CHECKLIST].map((i) => [i.key, i.field]),\n);\n\nfunction checklistFromStored(raw: string | null): Record<string, boolean> {\n let stored: Record<string, unknown> = {};\n if (typeof raw === \"string\" && raw.trim() !== \"\") {\n try {\n const parsed: unknown = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed))\n stored = parsed as Record<string, unknown>;\n } catch {\n // fall through to all-false — a bad blob must not throw the page\n }\n }\n const out: Record<string, boolean> = {};\n for (const [key, field] of CHECKLIST_FIELD_BY_KEY) out[field] = Boolean(stored[key]);\n return out;\n}\n\nfunction reportRowFromDb(r: Selectable<ReportsTable>): ReportRow {\n const p = r.lighthouse_performance;\n const a = r.lighthouse_accessibility;\n const b = r.lighthouse_best_practices;\n const seo = r.lighthouse_seo;\n const lighthouse =\n typeof p === \"number\" &&\n typeof a === \"number\" &&\n typeof b === \"number\" &&\n typeof seo === \"number\"\n ? { performance: p, accessibility: a, bestPractices: b, seo }\n : null;\n return {\n id: r.id,\n reportId: r.report_id ?? \"\",\n siteId: r.site_id ?? \"\",\n reportType: toReportType(r.report_type ?? undefined),\n period: r.period,\n periodStart: r.period_start,\n periodEnd: r.period_end,\n completedOn: r.completed_on,\n lighthouse,\n gaUsersCurrent: r.ga_users_current,\n gaUsersPrevious: r.ga_users_previous,\n searchFoundPage1: bool(r.search_found_page1),\n searchPosition: r.search_position,\n lastTestedDate: r.last_tested_date,\n commentary: r.commentary,\n subjectOverride: r.subject_override,\n draftReady: r.draft_ready !== 0,\n approvedToSend: r.approved_to_send !== 0,\n sentAt: r.sent_at,\n approvedAt: r.approved_at,\n approvedBy: r.approved_by,\n deliveryStatus: (r.delivery_status ?? \"pending\") as DeliveryStatus,\n // The body lives IN the row (rendered_html) — the link points at the\n // dashboard's own preview route instead of an EXPIRING Airtable signed URL.\n // Strictly better for the operator: the old link 404'd once the URL aged out.\n renderedHtmlAttachment:\n r.rendered_html !== null\n ? { url: `/api/reports/${r.id}/preview`, filename: `${r.report_id ?? r.id}.html` }\n : null,\n resendMessageId: r.resend_message_id,\n checklist: checklistFromStored(r.checklist),\n autoEvidence: parseAutoEvidence(r.checklist_auto_evidence),\n sendOverride: r.send_override !== 0,\n overrideReason: r.override_reason,\n overrideBy: r.override_by,\n overrideAt: r.override_at,\n };\n}\n\n/** Same contract as the Airtable listAllReports. Newest-period-first (Airtable\n * returned manual table order; every consumer filters/sorts itself). */\nexport async function listAllReports(db: Db): Promise<ReportRow[]> {\n const rows = await db\n .selectFrom(\"reports\")\n .selectAll()\n .orderBy(\"period_start\", \"desc\")\n .orderBy(\"id\")\n .execute();\n return rows.map(reportRowFromDb);\n}\n\n/** Same contract as the Airtable listReportsForSite — served by idx_reports_site. */\nexport async function listReportsForSite(db: Db, siteId: string): Promise<ReportRow[]> {\n const rows = await db\n .selectFrom(\"reports\")\n .selectAll()\n .where(\"site_id\", \"=\", siteId)\n .orderBy(\"period_start\", \"desc\")\n .orderBy(\"id\")\n .execute();\n return rows.map(reportRowFromDb);\n}\n\n/** Columns the REQUEST-PATH report writers mirror after their Airtable write\n * (same pattern as mirrorSiteField): the approve/override flow and the\n * resend-webhook's delivery status. Send-path columns (sent_at,\n * resend_message_id, rendered_html) are deliberately absent — the send is a\n * CLI batch whose writes reach Turso via the hourly sync until Phase 3. */\nexport type ReportMirrorPatch = Partial<\n Pick<\n ReportsTable,\n | \"approved_to_send\"\n | \"approved_at\"\n | \"approved_by\"\n | \"send_override\"\n | \"override_reason\"\n | \"override_by\"\n | \"override_at\"\n | \"delivery_status\"\n >\n>;\n\n/** Mirror an Airtable report write into Turso so the page re-render after an\n * approve/override/bounce shows the new state immediately instead of after\n * the next hourly sync. Callers treat a failure as non-fatal (the sync\n * converges it); an empty patch is a no-op, never invalid SQL. */\nexport async function mirrorReportPatch(\n db: Db,\n reportId: string,\n patch: ReportMirrorPatch,\n): Promise<void> {\n if (Object.keys(patch).length === 0) return;\n await db.updateTable(\"reports\").set(patch).where(\"id\", \"=\", reportId).execute();\n}\n\n/** By rec id (the PK) — approve-report's read. */\nexport async function getReportById(db: Db, id: string): Promise<ReportRow | null> {\n const r = await db.selectFrom(\"reports\").selectAll().where(\"id\", \"=\", id).executeTakeFirst();\n return r ? reportRowFromDb(r) : null;\n}\n\n/** The preview route's read: the stored rendered body, or null when the report\n * has none (imported while its signed URL was expired, or predates rendering). */\nexport async function getReportHtml(\n db: Db,\n id: string,\n): Promise<{ html: string; reportId: string | null } | null> {\n const r = await db\n .selectFrom(\"reports\")\n .select([\"rendered_html\", \"report_id\"])\n .where(\"id\", \"=\", id)\n .executeTakeFirst();\n if (!r || r.rendered_html === null) return null;\n return { html: r.rendered_html, reportId: r.report_id };\n}\n","import { openDb, readDbConfig, type Db } from \"../db/client.js\";\nimport { mirrorHealthFields, mirrorScheduleFields } from \"../db/fleet-state.js\";\n\n/** One site's just-written Airtable FieldSet, mirrored into site_health.\n * Resolves true when a site_health row matched; false when the UPDATE touched\n * 0 rows (site created in Airtable after the last hourly import) — callers\n * count that as mirror_missed, never as mirrored. */\nexport type HealthMirror = (siteId: string, fields: Record<string, unknown>) => Promise<boolean>;\n\n/** The site_schedule twin, for the nightly next-due write-back. Same\n * matched-row contract as {@link HealthMirror}: false = 0-row UPDATE →\n * mirror_missed. */\nexport type ScheduleMirror = (\n siteId: string,\n fields: Record<string, unknown>,\n computedAt: string,\n) => Promise<boolean>;\n\n/** Build the Turso write-through for the nightly writers (#539 Phase 3\n * dual-write), or null when libSQL creds are absent — mirroring NOT attempted\n * is a different outcome than mirroring failed, and the caller's summary line\n * reports mirror counts only when a mirror existed. Same contract as\n * recordFleetEventsBestEffort: the mirror must never fail the sweep that\n * produced the data (per-site failures are the caller's to count; Airtable\n * stays authoritative and the hourly sync converges whatever the mirror\n * missed). `open` is injectable for tests. */\nexport async function makeHealthMirrorBestEffort(\n open: () => Promise<Db> = () => openDb(readDbConfig()),\n): Promise<HealthMirror | null> {\n let db: Db;\n try {\n db = await open();\n } catch (e) {\n console.error(`[health-mirror] mirroring disabled: no libSQL (${String(e)})`);\n return null;\n }\n return (siteId, fields) => mirrorHealthFields(db, siteId, fields);\n}\n\n/** {@link makeHealthMirrorBestEffort}'s schedule twin — same null-without-creds\n * contract, for `writeNextDueDates`' site_schedule write-through. */\nexport async function makeScheduleMirrorBestEffort(\n open: () => Promise<Db> = () => openDb(readDbConfig()),\n): Promise<ScheduleMirror | null> {\n let db: Db;\n try {\n db = await open();\n } catch (e) {\n console.error(`[schedule-mirror] mirroring disabled: no libSQL (${String(e)})`);\n return null;\n }\n return (siteId, fields, computedAt) => mirrorScheduleFields(db, siteId, fields, computedAt);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAgQA,eAAsB,mBACpB,IACA,QACA,QACkB;AAIlB,QAAM,QAAgD,CAAC;AACvD,aAAW,CAAC,OAAO,KAAK,KAAK,OAAO,QAAQ,MAAM,GAAG;AACnD,UAAM,IAAI,gBAAgB,KAAK;AAC/B,QAAI,CAAC;AACH,YAAM,IAAI,MAAM,kEAAkE,KAAK,GAAG;AAC5F,UAAM,EAAE,GAAG,IAAI,EAAE,OAAO,KAAK;AAAA,EAC/B;AACA,MAAI,OAAO,KAAK,KAAK,EAAE,WAAW,EAAG,QAAO;AAC5C,QAAM,MAAM,MAAM,GACf,YAAY,aAAa,EACzB,IAAI,KAAoC,EACxC,MAAM,WAAW,KAAK,MAAM,EAC5B,iBAAiB;AAEpB,SAAO,IAAI,iBAAiB;AAC9B;AASA,eAAsB,qBACpB,IACA,QACA,QACA,YACkB;AAClB,QAAM,QAAuC,CAAC;AAC9C,aAAW,CAAC,OAAO,KAAK,KAAK,OAAO,QAAQ,MAAM,GAAG;AACnD,UAAM,IAAI,kBAAkB,KAAK;AACjC,QAAI,CAAC;AACH,YAAM,IAAI;AAAA,QACR,sEAAsE,KAAK;AAAA,MAC7E;AACF,UAAM,EAAE,GAAG,IAAI,EAAE,OAAO,KAAK;AAAA,EAC/B;AACA,MAAI,OAAO,KAAK,KAAK,EAAE,WAAW,EAAG,QAAO;AAC5C,QAAM,cAAc;AACpB,QAAM,MAAM,MAAM,GACf,YAAY,eAAe,EAC3B,IAAI,KAAsC,EAC1C,MAAM,WAAW,KAAK,MAAM,EAC5B,iBAAiB;AACpB,SAAO,IAAI,iBAAiB;AAC9B;AA4BA,IAAM,yBAAsD,IAAI;AAAA,EAC9D,CAAC,GAAG,uBAAuB,GAAG,iBAAiB,EAAE,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC;AAC9E;;;AC3TA,eAAsB,2BACpB,OAA0B,MAAM,OAAO,aAAa,CAAC,GACvB;AAC9B,MAAI;AACJ,MAAI;AACF,SAAK,MAAM,KAAK;AAAA,EAClB,SAAS,GAAG;AACV,YAAQ,MAAM,kDAAkD,OAAO,CAAC,CAAC,GAAG;AAC5E,WAAO;AAAA,EACT;AACA,SAAO,CAAC,QAAQ,WAAW,mBAAmB,IAAI,QAAQ,MAAM;AAClE;AAIA,eAAsB,6BACpB,OAA0B,MAAM,OAAO,aAAa,CAAC,GACrB;AAChC,MAAI;AACJ,MAAI;AACF,SAAK,MAAM,KAAK;AAAA,EAClB,SAAS,GAAG;AACV,YAAQ,MAAM,oDAAoD,OAAO,CAAC,CAAC,GAAG;AAC9E,WAAO;AAAA,EACT;AACA,SAAO,CAAC,QAAQ,QAAQ,eAAe,qBAAqB,IAAI,QAAQ,QAAQ,UAAU;AAC5F;","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/cli/commands/launch.ts","../src/recipes/launch.ts"],"sourcesContent":["import { resolve } from \"node:path\";\nimport { launch, type LaunchResult, type LaunchStepResult } from \"../../recipes/launch.js\";\nimport { resolveSites } from \"../fleet/resolve-sites.js\";\n\nexport type LaunchCommandOptions = {\n cwd?: string;\n};\n\nfunction formatStep(name: string, r: LaunchStepResult): string {\n if (r.kind === \"error\") return `${name.padEnd(20)} error: ${r.message}`;\n if (r.kind === \"audit\") {\n const s = r.scores;\n return `${name.padEnd(20)} audited (P=${s.performance} A=${s.accessibility} BP=${s.bestPractices} SEO=${s.seo})`;\n }\n if (r.kind === \"draft\") {\n return `${name.padEnd(20)} drafted ${r.report.reportId}`;\n }\n const rec = r.result;\n if (rec.status === \"noop\") return `${name.padEnd(20)} noop${rec.notes ? ` — ${rec.notes}` : \"\"}`;\n if (rec.status === \"failed\")\n return `${name.padEnd(20)} failed${rec.notes ? ` — ${rec.notes}` : \"\"}`;\n return `${name.padEnd(20)} applied (${rec.commits.length} commit${rec.commits.length === 1 ? \"\" : \"s\"})${rec.notes ? ` — ${rec.notes}` : \"\"}`;\n}\n\nfunction formatResult(r: LaunchResult): string {\n const header = `[${r.site}] launch — ${r.complete ? \"drafted (awaiting approval)\" : \"STOPPED\"}`;\n const body = r.steps.map((s) => formatStep(s.name, s.result)).join(\"\\n\");\n return `${header}\\n${body}`;\n}\n\n/**\n * `launch <site>` — single-site only. Bootstrap (Renovate + protection), first-audit\n * the site, and DRAFT its launch email into the M3 approve queue. Never sends;\n * the operator approves the draft and the next send run delivers the go-live\n * email (flipping Status → maintenance + stamping Launched at).\n */\nexport async function runLaunchCommand(\n site: string,\n opts: LaunchCommandOptions,\n): Promise<{ output: string; code: number }> {\n const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();\n const sites = await resolveSites({ site, cwd });\n const target = sites[0];\n if (!target) {\n return { output: `No site resolved for \"${site}\".`, code: 1 };\n }\n\n const result = await launch(target);\n return { output: formatResult(result), code: result.complete ? 0 : 1 };\n}\n","import type { AuditResult, RecipeResult, Site } from \"../types.js\";\nimport { siteLabel } from \"../util/site.js\";\nimport { selfUpdating } from \"./self-updating/index.js\";\nimport { runAudits } from \"../audits/index.js\";\nimport { hasRealScores, lighthouseScoresFromResult } from \"../audits/lighthouse-airtable.js\";\nimport { writeAuditsToAirtable } from \"../audits/write-audits-to-airtable.js\";\nimport { openBase, readAirtableConfig } from \"../reports/airtable/client.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport { listWebsites, siteSlug } from \"../reports/airtable/websites.js\";\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport {\n createDraft,\n findReportByPeriod,\n updateReportScores,\n} from \"../reports/airtable/reports.js\";\nimport type { ReportRow } from \"../reports/airtable/reports.js\";\nimport { queueDraft } from \"../reports/queue.js\";\nimport { uploadAttachment } from \"../reports/airtable/attachments.js\";\nimport { renderReportHtml } from \"../reports/render.js\";\nimport { resolveCopy } from \"../reports/copy.js\";\nimport type { LighthouseScores } from \"../reports/types.js\";\n\nexport type LaunchStepResult =\n | { kind: \"recipe\"; result: RecipeResult }\n | { kind: \"audit\"; results: AuditResult[]; scores: LighthouseScores }\n | { kind: \"draft\"; report: ReportRow }\n | { kind: \"error\"; message: string };\n\nexport type LaunchResult = {\n site: string;\n steps: Array<{ name: string; result: LaunchStepResult }>;\n /** True if every step ran (bootstrap + audit + draft); false if a step\n * errored or a recipe `failed` short-circuited the chain. */\n complete: boolean;\n};\n\nexport type LaunchDeps = {\n /** Bootstrap step (Renovate + protection; ci.yml comes from the starter). Defaults to the real `selfUpdating`. */\n bootstrap?: (site: Site) => Promise<RecipeResult>;\n /** Audit step. Defaults to the real `runAudits`. */\n audit?: (site: Site) => Promise<AuditResult[]>;\n /** Airtable handle. Defaults to opening the live base from credentials. */\n base?: AirtableBase;\n};\n\n/**\n * Launch a site: bootstrap → first-audit → DRAFT a launch email. The M3\n * approve loop is what actually sends; `launch` never sends — it stops at a\n * dashboard-queued draft (`reportType: \"Launch\"`) carrying the just-audited\n * Lighthouse scores, so `sendOne`'s `report.lighthouse` guard passes.\n *\n * Step-chain (mirrors `init`), stopping on the first error or `failed` recipe:\n * 1. selfUpdating — Renovate + protection (platform auto-merge OFF; ci.yml is the starter's).\n * 2. runAudits + write the scores to the site's Websites row (reuses the\n * `audit --write-airtable` writer); the Lighthouse scores feed the draft.\n * 3. createDraft — reportType \"Launch\", today's period, the audited scores.\n */\nexport async function launch(site: Site, deps: LaunchDeps = {}): Promise<LaunchResult> {\n const label = siteLabel(site);\n const bootstrap = deps.bootstrap ?? selfUpdating;\n const audit = deps.audit ?? runAudits;\n const base = deps.base ?? openBase(readAirtableConfig());\n\n const steps: Array<{ name: string; result: LaunchStepResult }> = [];\n const stop = (): LaunchResult => ({ site: label, steps, complete: false });\n\n // 1. Bootstrap.\n let recipe: RecipeResult;\n try {\n recipe = await bootstrap(site);\n } catch (err) {\n steps.push({ name: \"self-updating\", result: errorOf(err) });\n return stop();\n }\n steps.push({ name: \"self-updating\", result: { kind: \"recipe\", result: recipe } });\n if (recipe.status === \"failed\") return stop();\n\n // 2. Audit + write scores back to Airtable.\n let results: AuditResult[];\n try {\n results = await audit(site);\n } catch (err) {\n steps.push({ name: \"audit\", result: errorOf(err) });\n return stop();\n }\n const lhResult = results.find((r) => r.audit === \"lighthouse\");\n if (!lhResult || !hasRealScores(lhResult)) {\n steps.push({\n name: \"audit\",\n result: { kind: \"error\", message: \"lighthouse audit produced no real scores\" },\n });\n return stop();\n }\n // The launch announcement renders a numeric score per category; a metric that\n // errored this run (now null from lighthouseScoresFromResult) keeps the prior\n // 0 behavior here rather than propagating null into the launch-email path. The\n // Airtable write path (write-audits-to-airtable) keeps the null → shows \"—\".\n const rawScores = lighthouseScoresFromResult(lhResult);\n const scores: LighthouseScores = {\n performance: rawScores.performance ?? 0,\n accessibility: rawScores.accessibility ?? 0,\n bestPractices: rawScores.bestPractices ?? 0,\n seo: rawScores.seo ?? 0,\n };\n\n const websites = await listWebsites(base);\n const target = websites.find((w) => siteSlug(w.name) === siteSlug(label));\n if (!target) {\n steps.push({\n name: \"audit\",\n result: { kind: \"error\", message: `no Websites row matched site \"${label}\"` },\n });\n return stop();\n }\n try {\n await writeAuditsToAirtable({ base, websites, slug: siteSlug(target.name), results });\n } catch (err) {\n steps.push({ name: \"audit\", result: errorOf(err) });\n return stop();\n }\n steps.push({ name: \"audit\", result: { kind: \"audit\", results, scores } });\n\n // 3. Draft the launch email (reuses draft.ts's reportId/period scheme). DRAFTS\n // ONLY — the M3 approve loop sends it and flips Status on send.\n const today = new Date();\n const period = today.toISOString().slice(0, 7);\n const slug = siteSlug(target.name);\n\n let report: ReportRow;\n try {\n // Re-run dedupe: reuse an existing Launch row for this (site, period) instead\n // of stacking a second draft. findReportByPeriod is the same idempotency\n // lookup draft.ts documents (dashboard/digest point lookup).\n const existing = await findReportByPeriod(base, target.id, \"Launch\", period);\n if (existing) {\n // Reuse path: the row was created on a prior run with THAT run's scores. This\n // re-run just produced fresh audit scores AND will re-render the preview from\n // them — so refresh the row's Lighthouse cells (+ Completed on) to match,\n // otherwise the sent email (which reads the row) ships stale scores. The\n // create path already writes fresh scores via createDraft.\n await updateReportScores(base, existing.id, scores, today);\n report = existing;\n } else {\n report = await createDraft(base, draftInputFor(target, scores, today, period));\n }\n } catch (err) {\n steps.push({ name: \"draft\", result: errorOf(err) });\n return stop();\n }\n\n // Mirror draft.ts:135-154 — render → upload \"Rendered HTML\" preview → flip\n // Draft ready. Without setDraftReady the draft never enters the approve queue\n // (every pending-approval gate requires draftReady true), so it can never be\n // approved or sent. The upload is a review convenience; the ready flag is the\n // critical step.\n try {\n const { html } = await renderReportHtml({\n siteName: target.name,\n siteUrl: target.url,\n reportType: \"Launch\",\n completedOn: today,\n lighthouse: scores,\n lastTestedDate: null,\n commentary: null,\n copy: resolveCopy(target),\n headerImageCid: `${slug}-header`,\n });\n // A preview-upload hiccup must NOT fail the launch — log and continue.\n try {\n await uploadAttachment(\n report.id,\n \"Rendered HTML\",\n html,\n `${slug}-${today.toISOString().slice(0, 10)}.html`,\n \"text/html\",\n );\n } catch (uploadErr) {\n console.warn(\n `⚠ Launch preview upload skipped for ${target.name}: ${\n uploadErr instanceof Error ? uploadErr.message : String(uploadErr)\n }`,\n );\n }\n // Critical: NOT wrapped — a failure here must surface as a failed launch. queueDraft\n // supersedes any lower-tier (Maintenance/Testing) drafts queued for this site; a Launch is\n // top tier so it only stands down if another Launch/Announcement is already queued.\n await queueDraft(base, { id: report.id, siteId: target.id, reportType: \"Launch\" });\n } catch (err) {\n steps.push({ name: \"draft\", result: errorOf(err) });\n return stop();\n }\n\n steps.push({ name: \"draft\", result: { kind: \"draft\", report } });\n\n return { site: label, steps, complete: true };\n}\n\n/** Build the Launch `DraftInput`. reportId/period mirror `draftReportForSite`\n * (draft.ts) — do not invent a new id scheme. `today`/`period` are threaded in\n * from `launch()` so a single timestamp drives the render, the draft, the\n * dedupe lookup, and the preview filename. Launch reports have no period window\n * and no prior maintenance test, so periodStart/periodEnd/completedOn all\n * collapse to \"today\" and `lastTestedDate` is null. */\nfunction draftInputFor(\n target: WebsiteRow,\n scores: LighthouseScores,\n today: Date,\n period: string,\n): Parameters<typeof createDraft>[1] {\n const reportType = \"Launch\" as const;\n const reportId = `${target.name} — ${reportType} — ${today.toISOString().slice(0, 10)}`;\n return {\n reportId,\n siteId: target.id,\n reportType,\n period,\n periodStart: today,\n periodEnd: today,\n completedOn: today,\n lighthouse: scores,\n lastTestedDate: null,\n };\n}\n\nfunction errorOf(err: unknown): LaunchStepResult {\n return { kind: \"error\", message: err instanceof Error ? err.message : String(err) };\n}\n"],"mappings":"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","names":[]}