@reddoorla/maintenance 0.55.0 → 0.57.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 (150) hide show
  1. package/README.md +3 -1
  2. package/dist/airtable-2BJX4ATJ.js +9 -0
  3. package/dist/airtable-2BJX4ATJ.js.map +1 -0
  4. package/dist/announce-JEVCDDKL.js +171 -0
  5. package/dist/announce-JEVCDDKL.js.map +1 -0
  6. package/dist/bump-deps-O3SOLQYY.js +57 -0
  7. package/dist/bump-deps-O3SOLQYY.js.map +1 -0
  8. package/dist/chunk-2X322ZES.js +99 -0
  9. package/dist/chunk-2X322ZES.js.map +1 -0
  10. package/dist/chunk-4NAOR44V.js +153 -0
  11. package/dist/chunk-4NAOR44V.js.map +1 -0
  12. package/dist/chunk-4PY4I5OR.js +1167 -0
  13. package/dist/chunk-4PY4I5OR.js.map +1 -0
  14. package/dist/chunk-5L2QXUTU.js +279 -0
  15. package/dist/chunk-5L2QXUTU.js.map +1 -0
  16. package/dist/chunk-5WL3ET24.js +203 -0
  17. package/dist/chunk-5WL3ET24.js.map +1 -0
  18. package/dist/chunk-6LIWDIXU.js +38 -0
  19. package/dist/chunk-6LIWDIXU.js.map +1 -0
  20. package/dist/chunk-6VAL7XJT.js +52 -0
  21. package/dist/chunk-6VAL7XJT.js.map +1 -0
  22. package/dist/chunk-BWLUNY7L.js +585 -0
  23. package/dist/chunk-BWLUNY7L.js.map +1 -0
  24. package/dist/chunk-CASCWQQX.js +161 -0
  25. package/dist/chunk-CASCWQQX.js.map +1 -0
  26. package/dist/chunk-CLVTVDOU.js +274 -0
  27. package/dist/chunk-CLVTVDOU.js.map +1 -0
  28. package/dist/chunk-D3DQFC32.js +79 -0
  29. package/dist/chunk-D3DQFC32.js.map +1 -0
  30. package/dist/chunk-EYODPUHV.js +37 -0
  31. package/dist/chunk-EYODPUHV.js.map +1 -0
  32. package/dist/chunk-FDIFTIXC.js +70 -0
  33. package/dist/chunk-FDIFTIXC.js.map +1 -0
  34. package/dist/chunk-FUD4I2ZW.js +35 -0
  35. package/dist/chunk-FUD4I2ZW.js.map +1 -0
  36. package/dist/chunk-HG5DTFNF.js +62 -0
  37. package/dist/chunk-HG5DTFNF.js.map +1 -0
  38. package/dist/chunk-HN6I2XWI.js +65 -0
  39. package/dist/chunk-HN6I2XWI.js.map +1 -0
  40. package/dist/chunk-HVOOCK6L.js +84 -0
  41. package/dist/chunk-HVOOCK6L.js.map +1 -0
  42. package/dist/chunk-IMIR7SC2.js +51 -0
  43. package/dist/chunk-IMIR7SC2.js.map +1 -0
  44. package/dist/chunk-J3OPWFZE.js +64 -0
  45. package/dist/chunk-J3OPWFZE.js.map +1 -0
  46. package/dist/chunk-J4ECCFRT.js +221 -0
  47. package/dist/chunk-J4ECCFRT.js.map +1 -0
  48. package/dist/chunk-JNXCDUYB.js +211 -0
  49. package/dist/chunk-JNXCDUYB.js.map +1 -0
  50. package/dist/chunk-JQG222CI.js +49 -0
  51. package/dist/chunk-JQG222CI.js.map +1 -0
  52. package/dist/chunk-LKGVSM2O.js +43 -0
  53. package/dist/chunk-LKGVSM2O.js.map +1 -0
  54. package/dist/chunk-LYCKN7ZR.js +41 -0
  55. package/dist/chunk-LYCKN7ZR.js.map +1 -0
  56. package/dist/chunk-MKSZBODM.js +162 -0
  57. package/dist/chunk-MKSZBODM.js.map +1 -0
  58. package/dist/chunk-O65I7G7G.js +246 -0
  59. package/dist/chunk-O65I7G7G.js.map +1 -0
  60. package/dist/chunk-OCS7SZUZ.js +243 -0
  61. package/dist/chunk-OCS7SZUZ.js.map +1 -0
  62. package/dist/chunk-OGGRFBL4.js +14 -0
  63. package/dist/chunk-OGGRFBL4.js.map +1 -0
  64. package/dist/chunk-OQPMZ6BM.js +368 -0
  65. package/dist/chunk-OQPMZ6BM.js.map +1 -0
  66. package/dist/chunk-OTGB6YQO.js +52 -0
  67. package/dist/chunk-OTGB6YQO.js.map +1 -0
  68. package/dist/chunk-PZQ3WED2.js +14 -0
  69. package/dist/chunk-PZQ3WED2.js.map +1 -0
  70. package/dist/chunk-QTHKOIFJ.js +13 -0
  71. package/dist/chunk-QTHKOIFJ.js.map +1 -0
  72. package/dist/chunk-U5SZ4FOP.js +26 -0
  73. package/dist/chunk-U5SZ4FOP.js.map +1 -0
  74. package/dist/chunk-W3G7XFBZ.js +444 -0
  75. package/dist/chunk-W3G7XFBZ.js.map +1 -0
  76. package/dist/chunk-WEKL2HYJ.js +31 -0
  77. package/dist/chunk-WEKL2HYJ.js.map +1 -0
  78. package/dist/chunk-WVRPOXKH.js +74 -0
  79. package/dist/chunk-WVRPOXKH.js.map +1 -0
  80. package/dist/chunk-XKOZUVK2.js +121 -0
  81. package/dist/chunk-XKOZUVK2.js.map +1 -0
  82. package/dist/chunk-XO2RUYVM.js +138 -0
  83. package/dist/chunk-XO2RUYVM.js.map +1 -0
  84. package/dist/chunk-XTK5VIQB.js +97 -0
  85. package/dist/chunk-XTK5VIQB.js.map +1 -0
  86. package/dist/chunk-XXTZBPUY.js +9 -0
  87. package/dist/chunk-XXTZBPUY.js.map +1 -0
  88. package/dist/chunk-ZYBOT5PM.js +153 -0
  89. package/dist/chunk-ZYBOT5PM.js.map +1 -0
  90. package/dist/cli/bin.js +75 -7785
  91. package/dist/cli/bin.js.map +1 -1
  92. package/dist/cli/commands/audit.js +37 -2336
  93. package/dist/cli/commands/audit.js.map +1 -1
  94. package/dist/client-EPVO3FEH.js +10 -0
  95. package/dist/client-EPVO3FEH.js.map +1 -0
  96. package/dist/client-JA6QLEDW.js +48 -0
  97. package/dist/client-JA6QLEDW.js.map +1 -0
  98. package/dist/configs/lighthouse.js +4 -31
  99. package/dist/configs/lighthouse.js.map +1 -1
  100. package/dist/configs/playwright-a11y.js +6 -33
  101. package/dist/configs/playwright-a11y.js.map +1 -1
  102. package/dist/convert-to-pnpm-UR64X3OX.js +51 -0
  103. package/dist/convert-to-pnpm-UR64X3OX.js.map +1 -0
  104. package/dist/db-AXKMRTVA.js +23 -0
  105. package/dist/db-AXKMRTVA.js.map +1 -0
  106. package/dist/digest-YTO73BEO.js +408 -0
  107. package/dist/digest-YTO73BEO.js.map +1 -0
  108. package/dist/forms/index.js +3 -8
  109. package/dist/forms/index.js.map +1 -1
  110. package/dist/github-signals-XLQXXH6Q.js +116 -0
  111. package/dist/github-signals-XLQXXH6Q.js.map +1 -0
  112. package/dist/index.js +102 -4757
  113. package/dist/index.js.map +1 -1
  114. package/dist/init-JITQXFVL.js +85 -0
  115. package/dist/init-JITQXFVL.js.map +1 -0
  116. package/dist/launch-XW6PEKUL.js +207 -0
  117. package/dist/launch-XW6PEKUL.js.map +1 -0
  118. package/dist/lighthouse-airtable-4FKYKFH6.js +12 -0
  119. package/dist/lighthouse-airtable-4FKYKFH6.js.map +1 -0
  120. package/dist/migrate-P2LS4WXI.js +7 -0
  121. package/dist/migrate-P2LS4WXI.js.map +1 -0
  122. package/dist/onboard-TCQOP2W4.js +69 -0
  123. package/dist/onboard-TCQOP2W4.js.map +1 -0
  124. package/dist/orchestrate-LHH3LQNP.js +23 -0
  125. package/dist/orchestrate-LHH3LQNP.js.map +1 -0
  126. package/dist/recipes/sync-configs.js +9 -505
  127. package/dist/recipes/sync-configs.js.map +1 -1
  128. package/dist/renovate-dispatch-YVBTBJJY.js +100 -0
  129. package/dist/renovate-dispatch-YVBTBJJY.js.map +1 -0
  130. package/dist/report-WK3SKWJI.js +240 -0
  131. package/dist/report-WK3SKWJI.js.map +1 -0
  132. package/dist/resend-G2BJMAQV.js +7 -0
  133. package/dist/resend-G2BJMAQV.js.map +1 -0
  134. package/dist/self-updating-BYUBUNO4.js +61 -0
  135. package/dist/self-updating-BYUBUNO4.js.map +1 -0
  136. package/dist/svelte-codemods-BYRJAU7I.js +51 -0
  137. package/dist/svelte-codemods-BYRJAU7I.js.map +1 -0
  138. package/dist/sync-configs-JPRLAW5J.js +109 -0
  139. package/dist/sync-configs-JPRLAW5J.js.map +1 -0
  140. package/dist/upgrade-ERA45EVR.js +64 -0
  141. package/dist/upgrade-ERA45EVR.js.map +1 -0
  142. package/dist/util/git.js +19 -76
  143. package/dist/util/git.js.map +1 -1
  144. package/dist/util/pkg.js +5 -45
  145. package/dist/util/pkg.js.map +1 -1
  146. package/dist/websites-X5DLC2PC.js +43 -0
  147. package/dist/websites-X5DLC2PC.js.map +1 -0
  148. package/dist/write-audits-to-airtable-CE4POBAF.js +13 -0
  149. package/dist/write-audits-to-airtable-CE4POBAF.js.map +1 -0
  150. package/package.json +13 -12
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/airtable/websites.ts"],"sourcesContent":["import type { FieldSet } from \"airtable\";\nimport type { AirtableBase } from \"./client.js\";\nimport type { LighthouseScores } from \"../types.js\";\n\nexport const WEBSITES_TABLE = \"Websites\";\n\nexport type Frequency = \"None\" | \"Monthly\" | \"Quarterly\" | \"Yearly\";\n\nexport type Status =\n | \"in development\"\n | \"launch period\"\n | \"maintenance\"\n | \"hosting\"\n | \"probably not our problem\"\n | \"deprecated\";\n\n/**\n * Per-site notification routing. When present on a `maintenance` site, the form\n * notification is addressed by the value of a submission field (`field`, read from\n * `extraFields`) — e.g. route a contact form's `interest` to a different recipient\n * per option, always CC-ing a shared address. Absent (`null`) → the site keeps the\n * default single-POC behavior. Recipients live HERE (server-side Airtable config),\n * never supplied by the submitting site, so the ingest can't be turned into an open\n * relay.\n */\nexport type NotifyRouting = {\n /** The `extraFields` key whose value selects a route, e.g. \"interest\". */\n field: string;\n /** Field-value → recipient address(es). */\n routes: Record<string, string | string[]>;\n /** Recipient(s) when the value matches no route. */\n default?: string | string[];\n /** Address(es) CC'd on every routed (maintenance) send. */\n cc?: string[];\n};\n\nexport type WebsiteRow = {\n id: string;\n name: string;\n url: string;\n status: Status | null;\n pointOfContact: string | null;\n maintenanceFreq: Frequency;\n testingFreq: Frequency;\n /** Last manually-recorded maintenance day (used as fallback when no Reports row exists). */\n maintenanceDay: string | null;\n testingDay: string | null;\n ga4PropertyId: string | null;\n /** Operator-supplied query for the Google search-presence check (e.g. the business name).\n * Null = no query set → the check is skipped for this site. */\n searchQuery: string | null;\n /** Explicit Search Console property for this site (`sc-domain:...` or `https://.../`).\n * Null = auto-resolve from the SA's visible properties by host. */\n searchConsoleProperty: string | null;\n /** ISO timestamp of the last draft run where THIS site's GA/Search enrichment\n * ERRORED (vs a legitimate \"not configured\" skip). Set by drafting on a soft-fail,\n * cleared (null) on a clean enrichment, so the per-site analytics-failure signal\n * self-heals. Null when the operator-added `Analytics soft-fail at` column is absent. */\n analyticsSoftFailAt: string | null;\n /** GitHub repo identity as `owner/repo`. Null = no git wiring → self-update ops skip\n * (or, for local runs, fall back to the checkout's origin remote). */\n gitRepo: string | null;\n reportRecipientsTo: string | null;\n reportRecipientsCc: string | null;\n /** First attachment in the Header image field (Airtable's signed URL — fetch before expiry). */\n headerImage: { url: string; filename: string; type: string } | null;\n /** Lighthouse \"current state\" snapshot, kept fresh by `audit lighthouse --write-airtable`. */\n pScore: number | null;\n rScore: number | null;\n bpScore: number | null;\n seoScore: number | null;\n /** ISO timestamp set by `audit lighthouse --write-airtable` when scores were last refreshed. */\n lastLighthouseAuditAt: string | null;\n /** Last-known counts from non-lighthouse audits, written by\n * `audit --write-airtable`. `null` = never audited (or this audit\n * type was skipped on the last run). 0 = audited, clean. */\n a11yViolations: number | null;\n /** Declared-range drift vs the Reddoor baseline (what package.json asks for). */\n depsDrifted: number | null;\n depsMajorBehind: number | null;\n /** Real installed-version drift: deps behind the registry's latest, from the\n * committed lockfile (`pnpm outdated`). Null = not determined this run. */\n depsOutdated: number | null;\n securityVulnsCritical: number | null;\n securityVulnsHigh: number | null;\n securityVulnsModerate: number | null;\n securityVulnsLow: number | null;\n /** ISO timestamp the security audit last ran — gates freshness of the Security Updates auto-tick\n * (clean counts only auto-tick when recent). */\n lastSecurityAuditAt: string | null;\n /** The known advisories behind the counts (severity-sorted, capped), so the dashboard can show\n * WHICH packages are vulnerable, not just the totals. null = never audited / unparseable;\n * empty array = audited clean. Written alongside the counts by the security audit. */\n securityAdvisories: SecurityAdvisory[] | null;\n /** Domain/DNS/SSL probe (the `domain` audit). `certDaysRemaining` is days until the TLS cert\n * expires (null = unresolved or no usable cert); `domainCheckedAt` is when it last ran. */\n certDaysRemaining: number | null;\n domainCheckedAt: string | null;\n /** Deployed-URL browser probe (the `browser` audit): cross-engine render OK, mobile render OK,\n * internal-links OK + broken count, and when it last ran (one timestamp gates all three). */\n crossbrowserOk: boolean | null;\n mobileOk: boolean | null;\n linksOk: boolean | null;\n brokenLinks: number | null;\n browserCheckedAt: string | null;\n /** Per-site copy overrides (M6a). Blank → null → the DEFAULT_COPY value. */\n copyIntro: string | null;\n copyContact: string | null;\n copyFooter: string | null;\n /** Go-live timestamp, stamped when a Launch report sends (M6b). Null = not yet launched. */\n launchedAt: string | null;\n /** Optional per-site webhook (e.g. Zapier Catch Hook). When set, the ingest\n * POSTs newsletter-formType submissions here (best-effort). Blank → null. */\n newsletterWebhook: string | null;\n /** Per-site Mailchimp (newsletter). Both must be set for the direct add;\n * blank → skipped. The API key is `key-dc` format; dc is derived from it. */\n mailchimpApiKey: string | null;\n mailchimpAudienceId: string | null;\n /** GitHub-signals sweep (slice 2a), written nightly by `github-signals --fleet`. */\n renovateFailingCis: number | null;\n defaultBranchCi: string | null; // \"passing\" | \"failing\" | \"pending\" | \"none\"\n lastCommitAt: string | null;\n githubSignalsAt: string | null;\n notifyRouting: NotifyRouting | null;\n};\n\nexport function siteSlug(name: string): string {\n return name\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-|-$/g, \"\");\n}\n\n/** Blank-trim-to-null: a non-string or whitespace-only value becomes null,\n * otherwise the trimmed string. */\nfunction trimToNull(raw: unknown): string | null {\n if (typeof raw !== \"string\") return null;\n const trimmed = raw.trim();\n return trimmed.length > 0 ? trimmed : null;\n}\n\n/**\n * Parse the Websites `Notify Routing` JSON into a NotifyRouting, defensively: a\n * non-string, blank, malformed-JSON, or wrong-shape value yields null (the site\n * then keeps default single-POC routing) — never throws. Mirrors the pipeline's\n * \"a bad Airtable string degrades quietly\" rule.\n */\nexport function parseNotifyRouting(raw: unknown): NotifyRouting | null {\n if (typeof raw !== \"string\") return null;\n const trimmed = raw.trim();\n if (!trimmed) return null;\n let parsed: unknown;\n try {\n parsed = JSON.parse(trimmed);\n } catch {\n return null;\n }\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) return null;\n const o = parsed as Record<string, unknown>;\n if (typeof o.field !== \"string\" || !o.field.trim()) return null;\n if (!o.routes || typeof o.routes !== \"object\" || Array.isArray(o.routes)) return null;\n const routing: NotifyRouting = {\n field: o.field,\n routes: o.routes as Record<string, string | string[]>,\n };\n if (o.default !== undefined) routing.default = o.default as string | string[];\n if (Array.isArray(o.cc)) routing.cc = o.cc.filter((x): x is string => typeof x === \"string\");\n return routing;\n}\n\n/**\n * Active sites: actively-maintained or pre-launch. Single source of truth for\n * \"is this a live site\" — the operator cockpit shows these, and the fleet\n * audit/report path runs against these. A `null` status (not-yet-active) is\n * deliberately excluded.\n */\nexport const ACTIVE_STATUSES: ReadonlySet<Status> = new Set<Status>([\n \"maintenance\",\n \"launch period\",\n]);\n\nexport function isDashboardVisible(site: WebsiteRow): boolean {\n return site.status !== null && ACTIVE_STATUSES.has(site.status);\n}\n\nconst FREQUENCIES: readonly Frequency[] = [\"None\", \"Monthly\", \"Quarterly\", \"Yearly\"];\n\n/** Coerce an Airtable single-select value to a known Frequency. An unrecognized value — a\n * renamed / typo'd / whitespace option — falls back to \"None\" (its section is simply omitted)\n * rather than flowing a bogus string downstream, which the announcement would otherwise render\n * as \"We do this undefined.\" into a client email. */\nfunction toFrequency(raw: unknown): Frequency {\n return typeof raw === \"string\" && (FREQUENCIES as readonly string[]).includes(raw)\n ? (raw as Frequency)\n : \"None\";\n}\n\n// NOTE: every `f[\"...\"]` key below is a load-bearing magic string that must match\n// the live Airtable \"Websites\" column name EXACTLY — including the legacy\n// misspelling `\"maintenence freq\"`, the mixed-case `\"GA4 property ID\"`, and the\n// lowercase `\"url\"` / `\"point of contact\"`. A column rename in Airtable silently\n// returns undefined here (→ null), which degrades quietly (GA skipped, recipients\n// empty) with no error. If you rename a column, change it here too.\nexport function mapRow(rec: { id: string; fields: Record<string, unknown> }): WebsiteRow {\n const f = rec.fields;\n const attachments =\n (f[\"Header image\"] as Array<{ url: string; filename: string; type: string }> | undefined) ?? [];\n const header = attachments[0] ?? null;\n return {\n id: rec.id,\n name: String(f[\"Name\"] ?? \"\"),\n url: String(f[\"url\"] ?? \"\"),\n status: (f[\"Status\"] as Status | undefined) ?? null,\n pointOfContact: (f[\"point of contact\"] as string | undefined) ?? null,\n maintenanceFreq: toFrequency(f[\"maintenence freq\"]),\n testingFreq: toFrequency(f[\"testing freq\"]),\n maintenanceDay: (f[\"maintenance day\"] as string | undefined) ?? null,\n testingDay: (f[\"testing day\"] as string | undefined) ?? null,\n ga4PropertyId: (f[\"GA4 property ID\"] as string | undefined) ?? null,\n searchQuery: (f[\"Search query\"] as string | undefined) ?? null,\n searchConsoleProperty: (f[\"Search Console property\"] as string | undefined) ?? null,\n analyticsSoftFailAt: (f[\"Analytics soft-fail at\"] as string | undefined) ?? null,\n gitRepo: (f[\"Git repo\"] as string | undefined) ?? null,\n reportRecipientsTo: (f[\"Report recipients (To)\"] as string | undefined) ?? null,\n reportRecipientsCc: (f[\"Report recipients (CC)\"] as string | undefined) ?? null,\n headerImage: header,\n pScore: (f[\"pScore\"] as number | undefined) ?? null,\n rScore: (f[\"rScore\"] as number | undefined) ?? null,\n bpScore: (f[\"bpScore\"] as number | undefined) ?? null,\n seoScore: (f[\"seoScore\"] as number | undefined) ?? null,\n lastLighthouseAuditAt: (f[\"Last lighthouse audit at\"] as string | undefined) ?? null,\n a11yViolations: (f[\"A11y Violations\"] as number | undefined) ?? null,\n depsDrifted: (f[\"Deps Drifted\"] as number | undefined) ?? null,\n depsMajorBehind: (f[\"Deps Major Behind\"] as number | undefined) ?? null,\n depsOutdated: (f[\"Deps Outdated\"] as number | undefined) ?? null,\n securityVulnsCritical: (f[\"Security Vulns Critical\"] as number | undefined) ?? null,\n securityVulnsHigh: (f[\"Security Vulns High\"] as number | undefined) ?? null,\n securityVulnsModerate: (f[\"Security Vulns Moderate\"] as number | undefined) ?? null,\n securityVulnsLow: (f[\"Security Vulns Low\"] as number | undefined) ?? null,\n lastSecurityAuditAt: (f[\"Last security audit at\"] as string | undefined) ?? null,\n securityAdvisories: parseSecurityAdvisories(f[\"Security advisories\"]),\n certDaysRemaining: (f[\"Cert days remaining\"] as number | undefined) ?? null,\n domainCheckedAt: (f[\"Domain checked at\"] as string | undefined) ?? null,\n crossbrowserOk:\n typeof f[\"Crossbrowser OK\"] === \"boolean\" ? (f[\"Crossbrowser OK\"] as boolean) : null,\n mobileOk: typeof f[\"Mobile OK\"] === \"boolean\" ? (f[\"Mobile OK\"] as boolean) : null,\n linksOk: typeof f[\"Links OK\"] === \"boolean\" ? (f[\"Links OK\"] as boolean) : null,\n brokenLinks: typeof f[\"Broken links\"] === \"number\" ? (f[\"Broken links\"] as number) : null,\n browserCheckedAt: (f[\"Browser checked at\"] as string | undefined) ?? null,\n copyIntro: trimToNull(f[\"Copy — Intro\"]),\n copyContact: trimToNull(f[\"Copy — Contact\"]),\n copyFooter: trimToNull(f[\"Copy — Footer\"]),\n launchedAt: (f[\"Launched at\"] as string | undefined) ?? null,\n newsletterWebhook: trimToNull(f[\"Newsletter Webhook\"]),\n notifyRouting: parseNotifyRouting(f[\"Notify Routing\"]),\n mailchimpApiKey: trimToNull(f[\"Mailchimp API Key\"]),\n mailchimpAudienceId: trimToNull(f[\"Mailchimp Audience ID\"]),\n renovateFailingCis: (f[\"Renovate Failing CIs\"] as number | undefined) ?? null,\n defaultBranchCi: (f[\"Default Branch CI\"] as string | undefined) ?? null,\n lastCommitAt: (f[\"Last Commit At\"] as string | undefined) ?? null,\n githubSignalsAt: (f[\"GitHub Signals At\"] as string | undefined) ?? null,\n };\n}\n\nexport async function listWebsites(base: AirtableBase): Promise<WebsiteRow[]> {\n const out: WebsiteRow[] = [];\n await base(WEBSITES_TABLE)\n .select({ pageSize: 100 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n return out;\n}\n\nexport async function getWebsiteBySlug(\n base: AirtableBase,\n slug: string,\n): Promise<WebsiteRow | null> {\n // Slugs are siteSlug() output: [a-z0-9] segments joined by single hyphens.\n // Reject anything else — it can't match a real row, and it keeps URL-supplied\n // input out of the filter formula below (formula-injection guard).\n if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) return null;\n\n // Narrow the fetch to the slug-matching row server-side instead of paging the\n // whole table per request (MEDIUM-H). The formula replicates siteSlug() on\n // {Name} — lowercase → non-alnum runs to \"-\" → strip leading/trailing \"-\" —\n // verified against the live base. maxRecords caps it (slug collisions keep the\n // prior first-match-wins behavior).\n const formula = `REGEX_REPLACE(REGEX_REPLACE(LOWER({Name}),\"[^a-z0-9]+\",\"-\"),\"^-|-$\",\"\")=${JSON.stringify(\n slug,\n )}`;\n const rows: WebsiteRow[] = [];\n await base(WEBSITES_TABLE)\n .select({ filterByFormula: formula, maxRecords: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push(mapRow({ id: rec.id, fields: rec.fields }));\n fetchNextPage();\n });\n // Confirm the match in JS too: keeps the function correct if the formula and\n // siteSlug() ever drift, and under test fakes that don't evaluate the formula.\n return rows.find((w) => siteSlug(w.name) === slug) ?? null;\n}\n\n// ── audit-field builders ─────────────────────────────────────────────────────\n// One source of truth for the column-name → value mappings of each audit type.\n// The per-audit `updateXxxCounts` writers delegate to these (for their other\n// callers), and `updateAuditFields` merges whichever are present into ONE write —\n// so the field-name magic strings live in exactly one place.\n\nexport type A11yCounts = { violations: number };\nexport type DepsCounts = { drifted: number; majorBehind: number; outdated: number | null };\nexport type SecurityCounts = { critical: number; high: number; moderate: number; low: number };\nexport type Severity = \"low\" | \"moderate\" | \"high\" | \"critical\";\n/** One known vulnerability behind the security counts, as persisted/rendered. */\nexport type SecurityAdvisory = {\n module: string;\n severity: Severity;\n title: string;\n cves: string[];\n url: string | null;\n};\nexport type DomainResult = { certDaysRemaining: number | null; checkedAt: string };\nexport type BrowserAuditFields = {\n desktopOk: boolean;\n mobileOk: boolean;\n linksOk: boolean;\n brokenLinks: number;\n checkedAt: string;\n};\n\nfunction scoreFields(scores: LighthouseScores): FieldSet {\n return {\n pScore: scores.performance,\n rScore: scores.accessibility,\n bpScore: scores.bestPractices,\n seoScore: scores.seo,\n \"Last lighthouse audit at\": new Date().toISOString(),\n };\n}\n\nfunction a11yFields(counts: A11yCounts): FieldSet {\n return { \"A11y Violations\": counts.violations };\n}\n\nfunction depsFields(counts: DepsCounts): FieldSet {\n const fields: FieldSet = {\n \"Deps Drifted\": counts.drifted,\n \"Deps Major Behind\": counts.majorBehind,\n };\n // Only write the outdated count when it was determined — a null (no/stale\n // lockfile this run) must not clobber a previously-good value.\n if (counts.outdated !== null) {\n fields[\"Deps Outdated\"] = counts.outdated;\n }\n return fields;\n}\n\n/** Critical first → low last; the persist cap and the dashboard list both lean on this. */\nexport const SEVERITY_RANK: Record<Severity, number> = {\n critical: 0,\n high: 1,\n moderate: 2,\n low: 3,\n};\n/** Cap persisted advisories so the JSON field can't blow up on a badly-neglected site. */\nconst MAX_PERSISTED_ADVISORIES = 25;\n\n/** Coerce one untrusted value into a SecurityAdvisory, or null if it isn't shaped like one.\n * Shared by the read path (`parseSecurityAdvisories`) and the write path so both validate\n * identically. A missing module or unrecognized severity drops the entry entirely. */\nexport function normalizeSecurityAdvisory(raw: unknown): SecurityAdvisory | null {\n if (!raw || typeof raw !== \"object\") return null;\n const e = raw as Record<string, unknown>;\n const module = typeof e[\"module\"] === \"string\" ? (e[\"module\"] as string) : null;\n const severity = e[\"severity\"];\n if (module === null) return null;\n if (\n severity !== \"low\" &&\n severity !== \"moderate\" &&\n severity !== \"high\" &&\n severity !== \"critical\"\n )\n return null;\n const cves = Array.isArray(e[\"cves\"])\n ? (e[\"cves\"] as unknown[]).filter((c): c is string => typeof c === \"string\")\n : [];\n return {\n module,\n severity,\n title: typeof e[\"title\"] === \"string\" ? (e[\"title\"] as string) : \"\",\n cves,\n url: typeof e[\"url\"] === \"string\" ? (e[\"url\"] as string) : null,\n };\n}\n\n/** Parse the `Security advisories` JSON cell. null when absent/blank/unparseable/not-an-array\n * (treated as \"never audited\"); a valid array (possibly empty = audited clean) otherwise. */\nexport function parseSecurityAdvisories(raw: unknown): SecurityAdvisory[] | null {\n if (typeof raw !== \"string\" || raw.trim() === \"\") return null;\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n return null;\n }\n if (!Array.isArray(parsed)) return null;\n return parsed.map(normalizeSecurityAdvisory).filter((a): a is SecurityAdvisory => a !== null);\n}\n\nfunction securityAdvisoryFields(advisories: SecurityAdvisory[]): FieldSet {\n const capped = [...advisories]\n .sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])\n .slice(0, MAX_PERSISTED_ADVISORIES);\n // Always write — an empty array (\"[]\") clears a stale list when a once-vulnerable site goes clean.\n return { \"Security advisories\": JSON.stringify(capped) };\n}\n\nfunction securityFields(counts: SecurityCounts): FieldSet {\n return {\n \"Security Vulns Critical\": counts.critical,\n \"Security Vulns High\": counts.high,\n \"Security Vulns Moderate\": counts.moderate,\n \"Security Vulns Low\": counts.low,\n // Stamp freshness alongside the counts so the Security Updates auto-tick can require a recent\n // audit (a clean count from months ago must not silently keep ticking the box).\n \"Last security audit at\": new Date().toISOString(),\n };\n}\n\nfunction domainFields(result: DomainResult): FieldSet {\n const fields: FieldSet = { \"Domain checked at\": result.checkedAt };\n // Write the cert days UNCONDITIONALLY: a null (unresolved / no usable cert) must CLEAR any\n // previously-good value. Leaving a stale number in place — next to a freshly-stamped \"Domain\n // checked at\" — false-passes the Domain/DNS/SSL auto-tick (domainEvidence reads the stale\n // non-null value as a current pass) for a site that's actually down. null clears the cell in\n // Airtable; FieldSet's type doesn't model null, hence the cast through a widened record.\n (fields as Record<string, number | null>)[\"Cert days remaining\"] = result.certDaysRemaining;\n return fields;\n}\n\nfunction browserFields(r: BrowserAuditFields): FieldSet {\n return {\n \"Crossbrowser OK\": r.desktopOk,\n \"Mobile OK\": r.mobileOk,\n \"Links OK\": r.linksOk,\n \"Broken links\": r.brokenLinks,\n \"Browser checked at\": r.checkedAt,\n };\n}\n\n/**\n * Write the four Lighthouse scores + a refreshed-at timestamp onto a Websites row.\n * Called by `audit lighthouse --write-airtable` after a successful audit run, so\n * the operator never has to paste numbers manually before drafting a report.\n */\nexport async function updateScores(\n base: AirtableBase,\n recordId: string,\n scores: LighthouseScores,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: scoreFields(scores) }]);\n}\n\n/**\n * Record (or clear) the per-site GA/Search enrichment health on the `Analytics\n * soft-fail at` column. `at` is an ISO timestamp when the site's last draft had a\n * GA/Search soft-failure, or `null` to clear it after a clean enrichment. The\n * caller (drafting) swallows errors: this column is operator-added, so until it\n * exists Airtable throws UNKNOWN_FIELD_NAME — which must not break a draft.\n */\nexport async function updateAnalyticsHealth(\n base: AirtableBase,\n recordId: string,\n at: string | null,\n): Promise<void> {\n const fields: Record<string, string | null> = { \"Analytics soft-fail at\": at };\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: fields as FieldSet }]);\n}\n\n/** Persist a11y violation count. */\nexport async function updateA11yCounts(\n base: AirtableBase,\n recordId: string,\n counts: A11yCounts,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: a11yFields(counts) }]);\n}\n\n/** Persist deps drift counts (declared-range drift + real outdated installs). */\nexport async function updateDepsCounts(\n base: AirtableBase,\n recordId: string,\n counts: DepsCounts,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: depsFields(counts) }]);\n}\n\n/** Persist security vulnerability counts by severity. */\nexport async function updateSecurityCounts(\n base: AirtableBase,\n recordId: string,\n counts: SecurityCounts,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: securityFields(counts) }]);\n}\n\n/**\n * Persist all of a single audit run's results to one Websites row in ONE atomic\n * `update()` — instead of up to four sequential updates on the same id (which left\n * a row half-written on a mid-sequence failure and quadrupled the request volume).\n * Pass only the audit slices that produced real values; each present slice is merged\n * via the SAME field mappings the per-audit writers use. Omit a slice (or pass\n * undefined) to leave those columns untouched. Returns the merged FieldSet so the\n * caller can enumerate what was written.\n */\nexport async function updateAuditFields(\n base: AirtableBase,\n recordId: string,\n audits: {\n scores?: LighthouseScores;\n a11y?: A11yCounts;\n deps?: DepsCounts;\n security?: SecurityCounts;\n securityAdvisories?: SecurityAdvisory[];\n domain?: DomainResult;\n browser?: BrowserAuditFields;\n },\n): Promise<FieldSet> {\n const fields: FieldSet = {};\n if (audits.scores) Object.assign(fields, scoreFields(audits.scores));\n if (audits.a11y) Object.assign(fields, a11yFields(audits.a11y));\n if (audits.deps) Object.assign(fields, depsFields(audits.deps));\n if (audits.security) Object.assign(fields, securityFields(audits.security));\n // Separate slice (not folded into `security`) so the advisory list and the counts can be\n // written independently, but in practice the security audit supplies both together.\n if (audits.securityAdvisories)\n Object.assign(fields, securityAdvisoryFields(audits.securityAdvisories));\n if (audits.domain) Object.assign(fields, domainFields(audits.domain));\n if (audits.browser) Object.assign(fields, browserFields(audits.browser));\n await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);\n return fields;\n}\n\n/** Persist the GitHub-signals sweep onto a Websites row (slice 2a). A null\n * `lastCommitAt` is OMITTED so a not-determined-this-run value never clobbers a\n * previously-good timestamp (mirrors updateDepsCounts' outdated handling). */\nexport async function updateGitHubSignals(\n base: AirtableBase,\n recordId: string,\n signals: {\n renovateFailingCis: number;\n ciState: string;\n lastCommitAt: string | null;\n sweptAt: string;\n },\n): Promise<void> {\n const fields: FieldSet = {\n \"Renovate Failing CIs\": signals.renovateFailingCis,\n \"Default Branch CI\": signals.ciState,\n \"GitHub Signals At\": signals.sweptAt,\n };\n if (signals.lastCommitAt !== null) {\n fields[\"Last Commit At\"] = signals.lastCommitAt;\n }\n await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);\n}\n\n/** Mark a site launched: flip Status → maintenance + stamp Launched at (M6b).\n * The first code that writes Status. Called after a Launch report sends. */\nexport async function updateLaunched(\n base: AirtableBase,\n recordId: string,\n at: string,\n): Promise<void> {\n const fields: FieldSet = { Status: \"maintenance\", \"Launched at\": at };\n await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,79 @@
1
+ import {
2
+ withRecipe
3
+ } from "./chunk-XKOZUVK2.js";
4
+ import {
5
+ defaultSpawn
6
+ } from "./chunk-HVOOCK6L.js";
7
+
8
+ // src/recipes/bump-deps.ts
9
+ import { stat } from "fs/promises";
10
+ import { join } from "path";
11
+ async function exists(path) {
12
+ try {
13
+ await stat(path);
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+ function outdatedFlagsForGroup(group) {
20
+ if (group === "major") return ["--latest"];
21
+ if (group === "minor") return [];
22
+ return ["--depth", "0"];
23
+ }
24
+ function upFlagsForGroup(group) {
25
+ if (group === "major") return ["--latest"];
26
+ return [];
27
+ }
28
+ async function bumpDeps(site, opts = {}) {
29
+ const group = opts.group ?? "minor";
30
+ const spawn = opts.spawn ?? defaultSpawn;
31
+ return withRecipe({
32
+ name: "bump-deps",
33
+ site,
34
+ // pnpm install (in plan) mutates the lockfile, so the clean-tree check
35
+ // MUST happen first — otherwise a desynced-lockfile resync would silently
36
+ // land on top of whatever else was in the tree.
37
+ checkTreeFirst: true,
38
+ plan: async () => {
39
+ const hasPnpmLock = await exists(join(site.path, "pnpm-lock.yaml"));
40
+ if (!hasPnpmLock) {
41
+ const hasNpmLock = await exists(join(site.path, "package-lock.json"));
42
+ const hasYarnLock = await exists(join(site.path, "yarn.lock"));
43
+ if (hasNpmLock || hasYarnLock) {
44
+ const competing = hasNpmLock ? "package-lock.json" : "yarn.lock";
45
+ return {
46
+ kind: "failed",
47
+ notes: `site has ${competing} but no pnpm-lock.yaml \u2014 run convert-to-pnpm first`
48
+ };
49
+ }
50
+ }
51
+ await spawn("pnpm", ["install"], { cwd: site.path, streaming: true });
52
+ const outdated = await spawn(
53
+ "pnpm",
54
+ ["outdated", "--json", ...outdatedFlagsForGroup(group)],
55
+ { cwd: site.path }
56
+ );
57
+ let parsed;
58
+ try {
59
+ parsed = JSON.parse(outdated.stdout || "{}");
60
+ } catch {
61
+ parsed = {};
62
+ }
63
+ if (Object.keys(parsed).length === 0) {
64
+ return { kind: "noop", notes: `pnpm outdated reported nothing for group=${group}` };
65
+ }
66
+ return { kind: "apply", plan: { group } };
67
+ },
68
+ apply: async ({ group: g }, { commit, cwd }) => {
69
+ await spawn("pnpm", ["up", ...upFlagsForGroup(g)], { cwd, streaming: true });
70
+ await commit(`chore(deps): bump dependencies (${g})`);
71
+ return { kind: "ok" };
72
+ }
73
+ });
74
+ }
75
+
76
+ export {
77
+ bumpDeps
78
+ };
79
+ //# sourceMappingURL=chunk-D3DQFC32.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/recipes/bump-deps.ts"],"sourcesContent":["import { stat } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport type { RecipeResult, Site } from \"../types.js\";\nimport { defaultSpawn, type SpawnFn } from \"../audits/util/spawn.js\";\nimport { withRecipe } from \"./_with-recipe.js\";\n\nexport type BumpDepsGroup = \"patch\" | \"minor\" | \"major\";\n\nexport type BumpDepsOptions = {\n group?: BumpDepsGroup;\n spawn?: SpawnFn;\n};\n\nasync function exists(path: string): Promise<boolean> {\n try {\n await stat(path);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction outdatedFlagsForGroup(group: BumpDepsGroup): string[] {\n if (group === \"major\") return [\"--latest\"];\n if (group === \"minor\") return [];\n return [\"--depth\", \"0\"];\n}\n\nfunction upFlagsForGroup(group: BumpDepsGroup): string[] {\n if (group === \"major\") return [\"--latest\"];\n return [];\n}\n\ntype Plan = { group: BumpDepsGroup };\n\nexport async function bumpDeps(site: Site, opts: BumpDepsOptions = {}): Promise<RecipeResult> {\n const group: BumpDepsGroup = opts.group ?? \"minor\";\n const spawn = opts.spawn ?? defaultSpawn;\n\n return withRecipe<Plan>({\n name: \"bump-deps\",\n site,\n // pnpm install (in plan) mutates the lockfile, so the clean-tree check\n // MUST happen first — otherwise a desynced-lockfile resync would silently\n // land on top of whatever else was in the tree.\n checkTreeFirst: true,\n plan: async () => {\n // Pre-flight: the recipe is pnpm-only. A package-lock.json or yarn.lock\n // without pnpm-lock.yaml means the site is still on a different package\n // manager; we refuse to run rather than emit confusing pnpm errors.\n const hasPnpmLock = await exists(join(site.path, \"pnpm-lock.yaml\"));\n if (!hasPnpmLock) {\n const hasNpmLock = await exists(join(site.path, \"package-lock.json\"));\n const hasYarnLock = await exists(join(site.path, \"yarn.lock\"));\n if (hasNpmLock || hasYarnLock) {\n const competing = hasNpmLock ? \"package-lock.json\" : \"yarn.lock\";\n return {\n kind: \"failed\",\n notes: `site has ${competing} but no pnpm-lock.yaml — run convert-to-pnpm first`,\n };\n }\n }\n\n // Ensure the lockfile reflects the current package.json before we ask\n // pnpm what's outdated. Without this, a desynced lockfile can produce\n // stale or empty outdated reports.\n await spawn(\"pnpm\", [\"install\"], { cwd: site.path, streaming: true });\n\n const outdated = await spawn(\n \"pnpm\",\n [\"outdated\", \"--json\", ...outdatedFlagsForGroup(group)],\n { cwd: site.path },\n );\n\n let parsed: Record<string, unknown>;\n try {\n parsed = JSON.parse(outdated.stdout || \"{}\") as Record<string, unknown>;\n } catch {\n parsed = {};\n }\n if (Object.keys(parsed).length === 0) {\n return { kind: \"noop\", notes: `pnpm outdated reported nothing for group=${group}` };\n }\n return { kind: \"apply\", plan: { group } };\n },\n apply: async ({ group: g }, { commit, cwd }) => {\n // Stream pnpm up's output so long-running upgrades don't look frozen.\n await spawn(\"pnpm\", [\"up\", ...upFlagsForGroup(g)], { cwd, streaming: true });\n await commit(`chore(deps): bump dependencies (${g})`);\n return { kind: \"ok\" };\n },\n });\n}\n"],"mappings":";;;;;;;;AAAA,SAAS,YAAY;AACrB,SAAS,YAAY;AAYrB,eAAe,OAAO,MAAgC;AACpD,MAAI;AACF,UAAM,KAAK,IAAI;AACf,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,sBAAsB,OAAgC;AAC7D,MAAI,UAAU,QAAS,QAAO,CAAC,UAAU;AACzC,MAAI,UAAU,QAAS,QAAO,CAAC;AAC/B,SAAO,CAAC,WAAW,GAAG;AACxB;AAEA,SAAS,gBAAgB,OAAgC;AACvD,MAAI,UAAU,QAAS,QAAO,CAAC,UAAU;AACzC,SAAO,CAAC;AACV;AAIA,eAAsB,SAAS,MAAY,OAAwB,CAAC,GAA0B;AAC5F,QAAM,QAAuB,KAAK,SAAS;AAC3C,QAAM,QAAQ,KAAK,SAAS;AAE5B,SAAO,WAAiB;AAAA,IACtB,MAAM;AAAA,IACN;AAAA;AAAA;AAAA;AAAA,IAIA,gBAAgB;AAAA,IAChB,MAAM,YAAY;AAIhB,YAAM,cAAc,MAAM,OAAO,KAAK,KAAK,MAAM,gBAAgB,CAAC;AAClE,UAAI,CAAC,aAAa;AAChB,cAAM,aAAa,MAAM,OAAO,KAAK,KAAK,MAAM,mBAAmB,CAAC;AACpE,cAAM,cAAc,MAAM,OAAO,KAAK,KAAK,MAAM,WAAW,CAAC;AAC7D,YAAI,cAAc,aAAa;AAC7B,gBAAM,YAAY,aAAa,sBAAsB;AACrD,iBAAO;AAAA,YACL,MAAM;AAAA,YACN,OAAO,YAAY,SAAS;AAAA,UAC9B;AAAA,QACF;AAAA,MACF;AAKA,YAAM,MAAM,QAAQ,CAAC,SAAS,GAAG,EAAE,KAAK,KAAK,MAAM,WAAW,KAAK,CAAC;AAEpE,YAAM,WAAW,MAAM;AAAA,QACrB;AAAA,QACA,CAAC,YAAY,UAAU,GAAG,sBAAsB,KAAK,CAAC;AAAA,QACtD,EAAE,KAAK,KAAK,KAAK;AAAA,MACnB;AAEA,UAAI;AACJ,UAAI;AACF,iBAAS,KAAK,MAAM,SAAS,UAAU,IAAI;AAAA,MAC7C,QAAQ;AACN,iBAAS,CAAC;AAAA,MACZ;AACA,UAAI,OAAO,KAAK,MAAM,EAAE,WAAW,GAAG;AACpC,eAAO,EAAE,MAAM,QAAQ,OAAO,4CAA4C,KAAK,GAAG;AAAA,MACpF;AACA,aAAO,EAAE,MAAM,SAAS,MAAM,EAAE,MAAM,EAAE;AAAA,IAC1C;AAAA,IACA,OAAO,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,IAAI,MAAM;AAE9C,YAAM,MAAM,QAAQ,CAAC,MAAM,GAAG,gBAAgB,CAAC,CAAC,GAAG,EAAE,KAAK,WAAW,KAAK,CAAC;AAC3E,YAAM,OAAO,mCAAmC,CAAC,GAAG;AACpD,aAAO,EAAE,MAAM,KAAK;AAAA,IACtB;AAAA,EACF,CAAC;AACH;","names":[]}
@@ -0,0 +1,37 @@
1
+ // src/configs/lighthouse.ts
2
+ var lighthouseConfig = {
3
+ ci: {
4
+ collect: {
5
+ url: ["http://localhost:5173/dev/a11y-fixtures"],
6
+ // `npm run vite:dev` works on both pnpm and npm sites — pnpm respects
7
+ // the `run` form too. Keeps this config portable across the fleet
8
+ // while sites transition to pnpm.
9
+ startServerCommand: "npm run vite:dev",
10
+ startServerReadyPattern: "ready in",
11
+ startServerReadyTimeout: 12e4,
12
+ numberOfRuns: 1,
13
+ settings: {
14
+ preset: "desktop",
15
+ skipAudits: ["uses-http2"]
16
+ }
17
+ },
18
+ assert: {
19
+ assertions: {
20
+ "categories:accessibility": ["error", { minScore: 0.95 }],
21
+ "categories:best-practices": ["error", { minScore: 0.9 }],
22
+ "categories:seo": ["error", { minScore: 0.9 }],
23
+ "categories:performance": ["warn", { minScore: 0.7 }]
24
+ }
25
+ },
26
+ upload: {
27
+ target: "temporary-public-storage"
28
+ }
29
+ }
30
+ };
31
+ var lighthouse_default = lighthouseConfig;
32
+
33
+ export {
34
+ lighthouseConfig,
35
+ lighthouse_default
36
+ };
37
+ //# sourceMappingURL=chunk-EYODPUHV.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/configs/lighthouse.ts"],"sourcesContent":["export const lighthouseConfig = {\n ci: {\n collect: {\n url: [\"http://localhost:5173/dev/a11y-fixtures\"],\n // `npm run vite:dev` works on both pnpm and npm sites — pnpm respects\n // the `run` form too. Keeps this config portable across the fleet\n // while sites transition to pnpm.\n startServerCommand: \"npm run vite:dev\",\n startServerReadyPattern: \"ready in\",\n startServerReadyTimeout: 120_000,\n numberOfRuns: 1,\n settings: {\n preset: \"desktop\",\n skipAudits: [\"uses-http2\"],\n },\n },\n assert: {\n assertions: {\n \"categories:accessibility\": [\"error\", { minScore: 0.95 }],\n \"categories:best-practices\": [\"error\", { minScore: 0.9 }],\n \"categories:seo\": [\"error\", { minScore: 0.9 }],\n \"categories:performance\": [\"warn\", { minScore: 0.7 }],\n },\n },\n upload: {\n target: \"temporary-public-storage\",\n },\n },\n} as const;\n\nexport default lighthouseConfig;\n"],"mappings":";AAAO,IAAM,mBAAmB;AAAA,EAC9B,IAAI;AAAA,IACF,SAAS;AAAA,MACP,KAAK,CAAC,yCAAyC;AAAA;AAAA;AAAA;AAAA,MAI/C,oBAAoB;AAAA,MACpB,yBAAyB;AAAA,MACzB,yBAAyB;AAAA,MACzB,cAAc;AAAA,MACd,UAAU;AAAA,QACR,QAAQ;AAAA,QACR,YAAY,CAAC,YAAY;AAAA,MAC3B;AAAA,IACF;AAAA,IACA,QAAQ;AAAA,MACN,YAAY;AAAA,QACV,4BAA4B,CAAC,SAAS,EAAE,UAAU,KAAK,CAAC;AAAA,QACxD,6BAA6B,CAAC,SAAS,EAAE,UAAU,IAAI,CAAC;AAAA,QACxD,kBAAkB,CAAC,SAAS,EAAE,UAAU,IAAI,CAAC;AAAA,QAC7C,0BAA0B,CAAC,QAAQ,EAAE,UAAU,IAAI,CAAC;AAAA,MACtD;AAAA,IACF;AAAA,IACA,QAAQ;AAAA,MACN,QAAQ;AAAA,IACV;AAAA,EACF;AACF;AAEA,IAAO,qBAAQ;","names":[]}
@@ -0,0 +1,70 @@
1
+ import {
2
+ isHttpUrl
3
+ } from "./chunk-U5SZ4FOP.js";
4
+
5
+ // src/inventory/local.ts
6
+ import { basename } from "path";
7
+ function localPath(path, opts = {}) {
8
+ const site = { path, name: opts.name || basename(path) };
9
+ return async () => [site];
10
+ }
11
+
12
+ // src/inventory/json.ts
13
+ import { readFile } from "fs/promises";
14
+ import { isAbsolute } from "path";
15
+ function validate(raw) {
16
+ if (!Array.isArray(raw)) {
17
+ throw new Error("inventory JSON must be an array of sites");
18
+ }
19
+ return raw.map((entry, i) => {
20
+ if (!entry || typeof entry !== "object") {
21
+ throw new Error(`inventory entry ${i} is not an object`);
22
+ }
23
+ const e = entry;
24
+ if (typeof e.path !== "string" || e.path.length === 0) {
25
+ throw new Error(`inventory entry ${i} is missing required field: path`);
26
+ }
27
+ if (!isAbsolute(e.path)) {
28
+ throw new Error(
29
+ `inventory entry ${i}: path must be absolute (got "${e.path}"). Relative paths are rejected so cwd at invocation can't change which site is targeted.`
30
+ );
31
+ }
32
+ const site = { path: e.path };
33
+ if (typeof e.name === "string") site.name = e.name;
34
+ if (typeof e.repoUrl === "string") site.repoUrl = e.repoUrl;
35
+ if (typeof e.gitRepo === "string") site.gitRepo = e.gitRepo;
36
+ if (typeof e.deployedUrl === "string") {
37
+ if (isHttpUrl(e.deployedUrl)) {
38
+ site.deployedUrl = e.deployedUrl;
39
+ } else {
40
+ console.warn(
41
+ `[inventory] entry ${i}: ignoring deployedUrl that is not http(s): ${JSON.stringify(e.deployedUrl)}`
42
+ );
43
+ }
44
+ }
45
+ if (typeof e.meta === "object" && e.meta !== null) {
46
+ site.meta = e.meta;
47
+ }
48
+ return site;
49
+ });
50
+ }
51
+ function fromJsonFile(path) {
52
+ return async () => {
53
+ const text = await readFile(path, "utf-8");
54
+ let raw;
55
+ try {
56
+ raw = JSON.parse(text);
57
+ } catch (e) {
58
+ throw new Error(`could not parse inventory file ${path}: ${e.message}`, {
59
+ cause: e
60
+ });
61
+ }
62
+ return validate(raw);
63
+ };
64
+ }
65
+
66
+ export {
67
+ localPath,
68
+ fromJsonFile
69
+ };
70
+ //# sourceMappingURL=chunk-FDIFTIXC.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/inventory/local.ts","../src/inventory/json.ts"],"sourcesContent":["import { basename } from \"node:path\";\nimport type { InventoryProvider, Site } from \"../types.js\";\n\nexport type LocalPathOptions = {\n name?: string;\n};\n\nexport function localPath(path: string, opts: LocalPathOptions = {}): InventoryProvider {\n // `||` not `??`: an explicit empty `--name \"\"` should fall back to the path's\n // basename, not become a blank site name.\n const site: Site = { path, name: opts.name || basename(path) };\n return async () => [site];\n}\n","import { readFile } from \"node:fs/promises\";\nimport { isAbsolute } from \"node:path\";\nimport type { InventoryProvider, Site } from \"../types.js\";\nimport { isHttpUrl } from \"../util/url.js\";\n\nfunction validate(raw: unknown): Site[] {\n if (!Array.isArray(raw)) {\n throw new Error(\"inventory JSON must be an array of sites\");\n }\n return raw.map((entry, i) => {\n if (!entry || typeof entry !== \"object\") {\n throw new Error(`inventory entry ${i} is not an object`);\n }\n const e = entry as Record<string, unknown>;\n if (typeof e.path !== \"string\" || e.path.length === 0) {\n throw new Error(`inventory entry ${i} is missing required field: path`);\n }\n if (!isAbsolute(e.path)) {\n throw new Error(\n `inventory entry ${i}: path must be absolute (got \"${e.path}\"). ` +\n `Relative paths are rejected so cwd at invocation can't change which site is targeted.`,\n );\n }\n const site: Site = { path: e.path };\n if (typeof e.name === \"string\") site.name = e.name;\n if (typeof e.repoUrl === \"string\") site.repoUrl = e.repoUrl;\n // Carry gitRepo/deployedUrl like the Airtable provider does, so a JSON\n // inventory can drive checkout (clone-from-gitRepo) and deployed-URL audits.\n if (typeof e.gitRepo === \"string\") site.gitRepo = e.gitRepo;\n // Scheme-allowlist deployedUrl before it can reach Chrome/lhci (same SSRF /\n // local-file gate as the Airtable provider). A non-http(s) value is dropped\n // with a warning rather than trusted into the deployed audit.\n if (typeof e.deployedUrl === \"string\") {\n if (isHttpUrl(e.deployedUrl)) {\n site.deployedUrl = e.deployedUrl;\n } else {\n console.warn(\n `[inventory] entry ${i}: ignoring deployedUrl that is not http(s): ${JSON.stringify(e.deployedUrl)}`,\n );\n }\n }\n if (typeof e.meta === \"object\" && e.meta !== null) {\n site.meta = e.meta as Record<string, unknown>;\n }\n return site;\n });\n}\n\nexport function fromJsonFile(path: string): InventoryProvider {\n return async () => {\n const text = await readFile(path, \"utf-8\");\n let raw: unknown;\n try {\n raw = JSON.parse(text);\n } catch (e) {\n // A bare JSON.parse SyntaxError (\"Unexpected token … at position N\") names\n // neither the file nor that it's the inventory — useless to an operator\n // running a fleet command. Rethrow with the path for an actionable message,\n // preserving the original SyntaxError as `cause`.\n throw new Error(`could not parse inventory file ${path}: ${(e as Error).message}`, {\n cause: e,\n });\n }\n return validate(raw);\n };\n}\n"],"mappings":";;;;;AAAA,SAAS,gBAAgB;AAOlB,SAAS,UAAU,MAAc,OAAyB,CAAC,GAAsB;AAGtF,QAAM,OAAa,EAAE,MAAM,MAAM,KAAK,QAAQ,SAAS,IAAI,EAAE;AAC7D,SAAO,YAAY,CAAC,IAAI;AAC1B;;;ACZA,SAAS,gBAAgB;AACzB,SAAS,kBAAkB;AAI3B,SAAS,SAAS,KAAsB;AACtC,MAAI,CAAC,MAAM,QAAQ,GAAG,GAAG;AACvB,UAAM,IAAI,MAAM,0CAA0C;AAAA,EAC5D;AACA,SAAO,IAAI,IAAI,CAAC,OAAO,MAAM;AAC3B,QAAI,CAAC,SAAS,OAAO,UAAU,UAAU;AACvC,YAAM,IAAI,MAAM,mBAAmB,CAAC,mBAAmB;AAAA,IACzD;AACA,UAAM,IAAI;AACV,QAAI,OAAO,EAAE,SAAS,YAAY,EAAE,KAAK,WAAW,GAAG;AACrD,YAAM,IAAI,MAAM,mBAAmB,CAAC,kCAAkC;AAAA,IACxE;AACA,QAAI,CAAC,WAAW,EAAE,IAAI,GAAG;AACvB,YAAM,IAAI;AAAA,QACR,mBAAmB,CAAC,iCAAiC,EAAE,IAAI;AAAA,MAE7D;AAAA,IACF;AACA,UAAM,OAAa,EAAE,MAAM,EAAE,KAAK;AAClC,QAAI,OAAO,EAAE,SAAS,SAAU,MAAK,OAAO,EAAE;AAC9C,QAAI,OAAO,EAAE,YAAY,SAAU,MAAK,UAAU,EAAE;AAGpD,QAAI,OAAO,EAAE,YAAY,SAAU,MAAK,UAAU,EAAE;AAIpD,QAAI,OAAO,EAAE,gBAAgB,UAAU;AACrC,UAAI,UAAU,EAAE,WAAW,GAAG;AAC5B,aAAK,cAAc,EAAE;AAAA,MACvB,OAAO;AACL,gBAAQ;AAAA,UACN,qBAAqB,CAAC,+CAA+C,KAAK,UAAU,EAAE,WAAW,CAAC;AAAA,QACpG;AAAA,MACF;AAAA,IACF;AACA,QAAI,OAAO,EAAE,SAAS,YAAY,EAAE,SAAS,MAAM;AACjD,WAAK,OAAO,EAAE;AAAA,IAChB;AACA,WAAO;AAAA,EACT,CAAC;AACH;AAEO,SAAS,aAAa,MAAiC;AAC5D,SAAO,YAAY;AACjB,UAAM,OAAO,MAAM,SAAS,MAAM,OAAO;AACzC,QAAI;AACJ,QAAI;AACF,YAAM,KAAK,MAAM,IAAI;AAAA,IACvB,SAAS,GAAG;AAKV,YAAM,IAAI,MAAM,kCAAkC,IAAI,KAAM,EAAY,OAAO,IAAI;AAAA,QACjF,OAAO;AAAA,MACT,CAAC;AAAA,IACH;AACA,WAAO,SAAS,GAAG;AAAA,EACrB;AACF;","names":[]}
@@ -0,0 +1,35 @@
1
+ import {
2
+ planGotchaCodemods
3
+ } from "./chunk-OQPMZ6BM.js";
4
+ import {
5
+ withRecipe
6
+ } from "./chunk-XKOZUVK2.js";
7
+
8
+ // src/recipes/svelte-codemods.ts
9
+ import { writeFile } from "fs/promises";
10
+ import { join } from "path";
11
+ async function svelteCodemods(site) {
12
+ return withRecipe({
13
+ name: "svelte-codemods",
14
+ site,
15
+ plan: async () => {
16
+ const changes = await planGotchaCodemods(site.path);
17
+ if (changes.length === 0) {
18
+ return { kind: "noop", notes: "no codemod targets matched" };
19
+ }
20
+ return { kind: "apply", plan: changes };
21
+ },
22
+ apply: async (changes, { commit, cwd }) => {
23
+ for (const c of changes) {
24
+ await writeFile(join(cwd, c.rel), c.after, "utf-8");
25
+ }
26
+ await commit(`refactor(svelte5): apply codemods (${changes.length} files)`);
27
+ return { kind: "ok" };
28
+ }
29
+ });
30
+ }
31
+
32
+ export {
33
+ svelteCodemods
34
+ };
35
+ //# sourceMappingURL=chunk-FUD4I2ZW.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/recipes/svelte-codemods.ts"],"sourcesContent":["import { writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport type { RecipeResult, Site } from \"../types.js\";\nimport { planGotchaCodemods } from \"./svelte-5/step-gotchas.js\";\nimport { withRecipe } from \"./_with-recipe.js\";\n\ntype Change = { rel: string; after: string };\n\n/**\n * Standalone codemod pass for sites already on Svelte 5.\n *\n * Applies the same gotcha codemods the full `svelte-4-to-5` migration runs,\n * but skips the version checks and migration steps — useful when Svelte 5\n * surfaces new strictness warnings post-upgrade (e.g. `state_referenced_locally`)\n * and the fleet needs a clean re-application.\n *\n * Plans changes in memory first; only creates the branch + writes + commits\n * when there is something to apply. Re-runs against a clean tree are noop.\n */\nexport async function svelteCodemods(site: Site): Promise<RecipeResult> {\n return withRecipe<Change[]>({\n name: \"svelte-codemods\",\n site,\n plan: async () => {\n const changes = await planGotchaCodemods(site.path);\n if (changes.length === 0) {\n return { kind: \"noop\", notes: \"no codemod targets matched\" };\n }\n return { kind: \"apply\", plan: changes };\n },\n apply: async (changes, { commit, cwd }) => {\n for (const c of changes) {\n await writeFile(join(cwd, c.rel), c.after, \"utf-8\");\n }\n await commit(`refactor(svelte5): apply codemods (${changes.length} files)`);\n return { kind: \"ok\" };\n },\n });\n}\n"],"mappings":";;;;;;;;AAAA,SAAS,iBAAiB;AAC1B,SAAS,YAAY;AAkBrB,eAAsB,eAAe,MAAmC;AACtE,SAAO,WAAqB;AAAA,IAC1B,MAAM;AAAA,IACN;AAAA,IACA,MAAM,YAAY;AAChB,YAAM,UAAU,MAAM,mBAAmB,KAAK,IAAI;AAClD,UAAI,QAAQ,WAAW,GAAG;AACxB,eAAO,EAAE,MAAM,QAAQ,OAAO,6BAA6B;AAAA,MAC7D;AACA,aAAO,EAAE,MAAM,SAAS,MAAM,QAAQ;AAAA,IACxC;AAAA,IACA,OAAO,OAAO,SAAS,EAAE,QAAQ,IAAI,MAAM;AACzC,iBAAW,KAAK,SAAS;AACvB,cAAM,UAAU,KAAK,KAAK,EAAE,GAAG,GAAG,EAAE,OAAO,OAAO;AAAA,MACpD;AACA,YAAM,OAAO,sCAAsC,QAAQ,MAAM,SAAS;AAC1E,aAAO,EAAE,MAAM,KAAK;AAAA,IACtB;AAAA,EACF,CAAC;AACH;","names":[]}
@@ -0,0 +1,62 @@
1
+ // src/db/migrations.ts
2
+ var MIGRATIONS = [
3
+ {
4
+ id: "0001_init",
5
+ sql: `
6
+ CREATE TABLE IF NOT EXISTS submissions (
7
+ id TEXT PRIMARY KEY,
8
+ submission_id INTEGER,
9
+ site_id TEXT NOT NULL,
10
+ form_type TEXT NOT NULL,
11
+ name TEXT NOT NULL,
12
+ email TEXT NOT NULL,
13
+ phone TEXT,
14
+ message TEXT,
15
+ extra_fields TEXT,
16
+ source_url TEXT,
17
+ utm TEXT,
18
+ submitted_at TEXT,
19
+ status TEXT NOT NULL DEFAULT 'new',
20
+ notify_status TEXT NOT NULL DEFAULT 'skipped',
21
+ resend_message_id TEXT
22
+ );
23
+ CREATE INDEX IF NOT EXISTS idx_submissions_site_submitted
24
+ ON submissions (site_id, submitted_at DESC);
25
+ CREATE INDEX IF NOT EXISTS idx_submissions_status
26
+ ON submissions (status);
27
+ CREATE TABLE IF NOT EXISTS spam_screenouts (
28
+ site_id TEXT NOT NULL,
29
+ date TEXT NOT NULL,
30
+ honeypot INTEGER NOT NULL DEFAULT 0,
31
+ too_fast INTEGER NOT NULL DEFAULT 0,
32
+ marked_spam INTEGER NOT NULL DEFAULT 0,
33
+ PRIMARY KEY (site_id, date)
34
+ );
35
+ `
36
+ }
37
+ ];
38
+
39
+ // src/db/migrate.ts
40
+ async function runMigrations(client) {
41
+ await client.execute(
42
+ "CREATE TABLE IF NOT EXISTS _migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"
43
+ );
44
+ const existing = await client.execute("SELECT id FROM _migrations");
45
+ const applied = new Set(existing.rows.map((r) => String(r.id)));
46
+ const ran = [];
47
+ for (const m of MIGRATIONS) {
48
+ if (applied.has(m.id)) continue;
49
+ await client.executeMultiple(m.sql);
50
+ await client.execute({
51
+ sql: "INSERT INTO _migrations (id, applied_at) VALUES (?, ?)",
52
+ args: [m.id, (/* @__PURE__ */ new Date()).toISOString()]
53
+ });
54
+ ran.push(m.id);
55
+ }
56
+ return ran;
57
+ }
58
+
59
+ export {
60
+ runMigrations
61
+ };
62
+ //# sourceMappingURL=chunk-HG5DTFNF.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/db/migrations.ts","../src/db/migrate.ts"],"sourcesContent":["/** Ordered, append-only list of standard-SQL migration scripts. Each runs once,\n * tracked by `id` in the `_migrations` table (see migrate.ts). Statements use\n * IF NOT EXISTS so even a partial re-apply is safe. Never edit a shipped script —\n * add a new one. Every statement in a script MUST be independently idempotent:\n * `migrate.ts` runs each script via `executeMultiple`, which is NOT transactional,\n * so a mid-script failure can leave earlier statements applied and the id unrecorded\n * — a re-run then re-executes the whole script. Standard SQLite SQL only (no\n * Turso-specific syntax) so the host stays a connection-string swap. */\nexport type Migration = { id: string; sql: string };\n\nexport const MIGRATIONS: Migration[] = [\n {\n id: \"0001_init\",\n sql: `\n CREATE TABLE IF NOT EXISTS submissions (\n id TEXT PRIMARY KEY,\n submission_id INTEGER,\n site_id TEXT NOT NULL,\n form_type TEXT NOT NULL,\n name TEXT NOT NULL,\n email TEXT NOT NULL,\n phone TEXT,\n message TEXT,\n extra_fields TEXT,\n source_url TEXT,\n utm TEXT,\n submitted_at TEXT,\n status TEXT NOT NULL DEFAULT 'new',\n notify_status TEXT NOT NULL DEFAULT 'skipped',\n resend_message_id TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_submissions_site_submitted\n ON submissions (site_id, submitted_at DESC);\n CREATE INDEX IF NOT EXISTS idx_submissions_status\n ON submissions (status);\n CREATE TABLE IF NOT EXISTS spam_screenouts (\n site_id TEXT NOT NULL,\n date TEXT NOT NULL,\n honeypot INTEGER NOT NULL DEFAULT 0,\n too_fast INTEGER NOT NULL DEFAULT 0,\n marked_spam INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (site_id, date)\n );\n `,\n },\n];\n","import type { Client } from \"@libsql/client\";\nimport { MIGRATIONS } from \"./migrations.js\";\n\n/** Apply every not-yet-applied migration in order against a raw libSQL client,\n * tracking applied ids in `_migrations`. Idempotent: ids already present are\n * skipped, and the DDL uses IF NOT EXISTS. Returns the ids applied this run.\n * Runs on every openDb() — cheap (one indexed SELECT) and the gate every fresh\n * Turso database needs before its first write. */\nexport async function runMigrations(client: Client): Promise<string[]> {\n await client.execute(\n \"CREATE TABLE IF NOT EXISTS _migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)\",\n );\n const existing = await client.execute(\"SELECT id FROM _migrations\");\n const applied = new Set(existing.rows.map((r) => String(r.id)));\n const ran: string[] = [];\n for (const m of MIGRATIONS) {\n if (applied.has(m.id)) continue;\n await client.executeMultiple(m.sql);\n await client.execute({\n sql: \"INSERT INTO _migrations (id, applied_at) VALUES (?, ?)\",\n args: [m.id, new Date().toISOString()],\n });\n ran.push(m.id);\n }\n return ran;\n}\n"],"mappings":";AAUO,IAAM,aAA0B;AAAA,EACrC;AAAA,IACE,IAAI;AAAA,IACJ,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EA+BP;AACF;;;ACrCA,eAAsB,cAAc,QAAmC;AACrE,QAAM,OAAO;AAAA,IACX;AAAA,EACF;AACA,QAAM,WAAW,MAAM,OAAO,QAAQ,4BAA4B;AAClE,QAAM,UAAU,IAAI,IAAI,SAAS,KAAK,IAAI,CAAC,MAAM,OAAO,EAAE,EAAE,CAAC,CAAC;AAC9D,QAAM,MAAgB,CAAC;AACvB,aAAW,KAAK,YAAY;AAC1B,QAAI,QAAQ,IAAI,EAAE,EAAE,EAAG;AACvB,UAAM,OAAO,gBAAgB,EAAE,GAAG;AAClC,UAAM,OAAO,QAAQ;AAAA,MACnB,KAAK;AAAA,MACL,MAAM,CAAC,EAAE,KAAI,oBAAI,KAAK,GAAE,YAAY,CAAC;AAAA,IACvC,CAAC;AACD,QAAI,KAAK,EAAE,EAAE;AAAA,EACf;AACA,SAAO;AACT;","names":[]}
@@ -0,0 +1,65 @@
1
+ import {
2
+ defaultCredentialsPath
3
+ } from "./chunk-6VAL7XJT.js";
4
+
5
+ // src/reports/airtable/client.ts
6
+ import Airtable from "airtable";
7
+
8
+ // src/reports/airtable/throttle.ts
9
+ function createMinIntervalThrottle(opts) {
10
+ const { minIntervalMs, now, delay } = opts;
11
+ return function wrap(fn) {
12
+ let chain = Promise.resolve();
13
+ let last = Number.NEGATIVE_INFINITY;
14
+ return (...args) => {
15
+ chain = chain.then(async () => {
16
+ const wait = minIntervalMs - (now() - last);
17
+ if (wait > 0) await delay(wait);
18
+ last = now();
19
+ fn(...args);
20
+ }).catch(() => {
21
+ });
22
+ };
23
+ };
24
+ }
25
+ function applyThrottle(base, opts) {
26
+ const real = base._base?.runAction;
27
+ if (typeof real !== "function") return base;
28
+ const wrap = createMinIntervalThrottle(opts);
29
+ const throttled = wrap(real.bind(base._base));
30
+ base._base.runAction = throttled;
31
+ base.runAction = throttled;
32
+ return base;
33
+ }
34
+
35
+ // src/reports/airtable/client.ts
36
+ var MIN_REQUEST_INTERVAL_MS = 220;
37
+ function missing(name) {
38
+ return Object.assign(
39
+ new Error(
40
+ `${name} not set. Export it in your shell or put it in ${defaultCredentialsPath()} as ${name}=...`
41
+ ),
42
+ { exitCode: 2 }
43
+ );
44
+ }
45
+ function readAirtableConfig() {
46
+ const apiKey = process.env.AIRTABLE_PAT;
47
+ const baseId = process.env.AIRTABLE_BASE_ID;
48
+ if (!apiKey) throw missing("AIRTABLE_PAT");
49
+ if (!baseId) throw missing("AIRTABLE_BASE_ID");
50
+ return { apiKey, baseId };
51
+ }
52
+ function openBase(cfg) {
53
+ const base = new Airtable({ apiKey: cfg.apiKey }).base(cfg.baseId);
54
+ return applyThrottle(base, {
55
+ minIntervalMs: MIN_REQUEST_INTERVAL_MS,
56
+ now: () => Date.now(),
57
+ delay: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
58
+ });
59
+ }
60
+
61
+ export {
62
+ readAirtableConfig,
63
+ openBase
64
+ };
65
+ //# sourceMappingURL=chunk-HN6I2XWI.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/airtable/client.ts","../src/reports/airtable/throttle.ts"],"sourcesContent":["import Airtable from \"airtable\";\nimport { defaultCredentialsPath } from \"../../util/credentials.js\";\nimport { applyThrottle } from \"./throttle.js\";\n\n/** Min ms between Airtable HTTP starts. 220 ⇒ ≤ ~4.5 req/s, comfortably under\n * Airtable's ~5 req/s-per-base cap with headroom for clock jitter. */\nconst MIN_REQUEST_INTERVAL_MS = 220;\n\nexport type AirtableConfig = {\n apiKey: string;\n baseId: string;\n};\n\nfunction missing(name: string): Error {\n return Object.assign(\n new Error(\n `${name} not set. Export it in your shell or put it in ${defaultCredentialsPath()} as ${name}=...`,\n ),\n { exitCode: 2 },\n );\n}\n\nexport function readAirtableConfig(): AirtableConfig {\n const apiKey = process.env.AIRTABLE_PAT;\n const baseId = process.env.AIRTABLE_BASE_ID;\n if (!apiKey) throw missing(\"AIRTABLE_PAT\");\n if (!baseId) throw missing(\"AIRTABLE_BASE_ID\");\n return { apiKey, baseId };\n}\n\nexport type AirtableBase = ReturnType<typeof openBase>;\n\nexport function openBase(cfg: AirtableConfig) {\n const base = new Airtable({ apiKey: cfg.apiKey }).base(cfg.baseId);\n // Throttle every Airtable HTTP call at its single funnel so paging bursts\n // (cockpit scans, fleet sweeps) stay under the per-base rate limit instead of\n // tripping 429s. See throttle.ts.\n return applyThrottle(base as Parameters<typeof applyThrottle>[0], {\n minIntervalMs: MIN_REQUEST_INTERVAL_MS,\n now: () => Date.now(),\n delay: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),\n }) as typeof base;\n}\n","/**\n * Airtable enforces ~5 requests/second per base. Even fully *sequential* paging\n * (each `eachPage` fetch awaits the previous) bursts past that when responses are\n * fast — a single cockpit load scanning Reports + Submissions can fire a dozen\n * page-GETs in under a second and trip 429s. The SDK retries 429s with backoff,\n * but under sustained over-limit volume that retry budget runs out and the error\n * surfaces.\n *\n * Rather than rate-limit each call site, we throttle at the ONE chokepoint every\n * Airtable HTTP call funnels through: `base._base.runAction` (used by query\n * paging, create, update, and destroy alike). The throttle serializes call\n * *starts* so consecutive requests begin at least `minIntervalMs` apart, keeping\n * the whole process under the per-base limit. The SDK's built-in 429 retry stays\n * as a backstop for cross-process contention we can't see from here.\n */\n\nexport type ThrottleClock = {\n /** Current time in ms (e.g. `Date.now`). */\n now: () => number;\n /** Resolves after at least `ms` have elapsed (e.g. setTimeout-backed). */\n delay: (ms: number) => Promise<void>;\n};\n\nexport type ThrottleOptions = ThrottleClock & {\n /** Minimum ms between successive call starts (220 ⇒ ≤ ~4.5 req/s). */\n minIntervalMs: number;\n};\n\n/**\n * Build a wrapper that spaces the *starts* of calls to any callback-style\n * function by `minIntervalMs`. Calls preserve order. The wrapper does NOT await\n * the wrapped function's completion (Airtable's `runAction` is fire-and-forget\n * with a callback), so spacing is measured start-to-start, which is exactly what\n * the per-second request limit counts.\n */\nexport function createMinIntervalThrottle(opts: ThrottleOptions) {\n const { minIntervalMs, now, delay } = opts;\n // A single promise chain serializes the gate; `last` is the start time of the\n // most recent dispatch. Both are captured per-wrapper so distinct wrapped fns\n // throttle independently (we only ever wrap one base, but this keeps it pure).\n return function wrap<A extends unknown[]>(fn: (...args: A) => unknown): (...args: A) => void {\n let chain: Promise<void> = Promise.resolve();\n let last = Number.NEGATIVE_INFINITY;\n return (...args: A): void => {\n // The trailing `.catch` is load-bearing: if any step rejected, the next\n // `chain.then(...)` would never run its onFulfilled handler and the queue\n // would stall — silently hanging EVERY subsequent Airtable call in the\n // process. Swallowing keeps the chain perpetually fulfilled.\n chain = chain\n .then(async () => {\n const wait = minIntervalMs - (now() - last);\n if (wait > 0) await delay(wait);\n last = now();\n fn(...args);\n })\n .catch(() => {});\n };\n };\n}\n\ntype ThrottleableBase = {\n _base?: { runAction?: (...args: unknown[]) => unknown };\n runAction?: (...args: unknown[]) => unknown;\n};\n\n/**\n * Replace `base._base.runAction` (the real funnel every table operation calls)\n * and `base.runAction` (its bound public copy) with throttled versions. Returns\n * the same base for chaining. No-op when `_base.runAction` is absent so an\n * unexpected SDK shape degrades gracefully rather than throwing at startup.\n */\nexport function applyThrottle<T extends ThrottleableBase>(base: T, opts: ThrottleOptions): T {\n const real = base._base?.runAction;\n if (typeof real !== \"function\") return base;\n const wrap = createMinIntervalThrottle(opts);\n const throttled = wrap(real.bind(base._base));\n base._base!.runAction = throttled;\n base.runAction = throttled;\n return base;\n}\n"],"mappings":";;;;;AAAA,OAAO,cAAc;;;ACmCd,SAAS,0BAA0B,MAAuB;AAC/D,QAAM,EAAE,eAAe,KAAK,MAAM,IAAI;AAItC,SAAO,SAAS,KAA0B,IAAmD;AAC3F,QAAI,QAAuB,QAAQ,QAAQ;AAC3C,QAAI,OAAO,OAAO;AAClB,WAAO,IAAI,SAAkB;AAK3B,cAAQ,MACL,KAAK,YAAY;AAChB,cAAM,OAAO,iBAAiB,IAAI,IAAI;AACtC,YAAI,OAAO,EAAG,OAAM,MAAM,IAAI;AAC9B,eAAO,IAAI;AACX,WAAG,GAAG,IAAI;AAAA,MACZ,CAAC,EACA,MAAM,MAAM;AAAA,MAAC,CAAC;AAAA,IACnB;AAAA,EACF;AACF;AAaO,SAAS,cAA0C,MAAS,MAA0B;AAC3F,QAAM,OAAO,KAAK,OAAO;AACzB,MAAI,OAAO,SAAS,WAAY,QAAO;AACvC,QAAM,OAAO,0BAA0B,IAAI;AAC3C,QAAM,YAAY,KAAK,KAAK,KAAK,KAAK,KAAK,CAAC;AAC5C,OAAK,MAAO,YAAY;AACxB,OAAK,YAAY;AACjB,SAAO;AACT;;;ADzEA,IAAM,0BAA0B;AAOhC,SAAS,QAAQ,MAAqB;AACpC,SAAO,OAAO;AAAA,IACZ,IAAI;AAAA,MACF,GAAG,IAAI,kDAAkD,uBAAuB,CAAC,OAAO,IAAI;AAAA,IAC9F;AAAA,IACA,EAAE,UAAU,EAAE;AAAA,EAChB;AACF;AAEO,SAAS,qBAAqC;AACnD,QAAM,SAAS,QAAQ,IAAI;AAC3B,QAAM,SAAS,QAAQ,IAAI;AAC3B,MAAI,CAAC,OAAQ,OAAM,QAAQ,cAAc;AACzC,MAAI,CAAC,OAAQ,OAAM,QAAQ,kBAAkB;AAC7C,SAAO,EAAE,QAAQ,OAAO;AAC1B;AAIO,SAAS,SAAS,KAAqB;AAC5C,QAAM,OAAO,IAAI,SAAS,EAAE,QAAQ,IAAI,OAAO,CAAC,EAAE,KAAK,IAAI,MAAM;AAIjE,SAAO,cAAc,MAA6C;AAAA,IAChE,eAAe;AAAA,IACf,KAAK,MAAM,KAAK,IAAI;AAAA,IACpB,OAAO,CAAC,OAAO,IAAI,QAAQ,CAAC,YAAY,WAAW,SAAS,EAAE,CAAC;AAAA,EACjE,CAAC;AACH;","names":[]}
@@ -0,0 +1,84 @@
1
+ // src/audits/util/spawn.ts
2
+ import { spawn } from "child_process";
3
+ import { StringDecoder } from "string_decoder";
4
+ var TRUNCATION_MARKER = "\n\u2026[output truncated]";
5
+ function makeSpawn(internals = {}) {
6
+ const spawnImpl = internals.spawnImpl ?? spawn;
7
+ const killImpl = internals.killImpl ?? ((pid, sig) => process.kill(pid, sig));
8
+ const killGraceMs = internals.killGraceMs ?? 5e3;
9
+ const maxOutputBytes = internals.maxOutputBytes ?? 10 * 1024 * 1024;
10
+ return (cmd, args, opts = {}) => new Promise((resolve, reject) => {
11
+ const streaming = opts.streaming === true;
12
+ const child = spawnImpl(cmd, [...args], {
13
+ cwd: opts.cwd,
14
+ env: opts.env ?? process.env,
15
+ stdio: streaming ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "pipe"],
16
+ // Detach ONLY when a timeout can fire: the child then leads its own
17
+ // process group, so the timeout can kill the WHOLE tree (vite, and
18
+ // Chromium under lhci/playwright) via process.kill(-pid), not just the
19
+ // npx/pnpm wrapper. Without it, killing the wrapper orphaned the
20
+ // grandchildren — a zombie vite squatting its port, Chrome left running.
21
+ // We do NOT detach timeout-less streaming calls (pnpm install/up):
22
+ // detaching gains nothing there (no timeout → no group-kill) and would
23
+ // break terminal Ctrl-C, which only reaches the foreground group — i.e.
24
+ // it would re-orphan the very children this guards. We never unref() the
25
+ // child since we still await it.
26
+ detached: opts.timeoutMs !== void 0
27
+ });
28
+ const cap = (acc, chunk) => {
29
+ if (acc.length >= maxOutputBytes) return acc;
30
+ const next = acc + chunk;
31
+ return next.length > maxOutputBytes ? next.slice(0, maxOutputBytes) + TRUNCATION_MARKER : next;
32
+ };
33
+ let stdout = "";
34
+ let stderr = "";
35
+ const outDecoder = new StringDecoder("utf-8");
36
+ const errDecoder = new StringDecoder("utf-8");
37
+ if (!streaming) {
38
+ child.stdout?.on(
39
+ "data",
40
+ (chunk) => stdout = cap(stdout, outDecoder.write(chunk))
41
+ );
42
+ child.stderr?.on(
43
+ "data",
44
+ (chunk) => stderr = cap(stderr, errDecoder.write(chunk))
45
+ );
46
+ }
47
+ const killGroup = (sig) => {
48
+ if (child.pid === void 0) return;
49
+ try {
50
+ killImpl(-child.pid, sig);
51
+ } catch {
52
+ }
53
+ };
54
+ let killTimer;
55
+ const timer = opts.timeoutMs ? setTimeout(() => {
56
+ killGroup("SIGTERM");
57
+ killTimer = setTimeout(() => killGroup("SIGKILL"), killGraceMs);
58
+ killTimer.unref();
59
+ reject(new Error(`spawn timeout after ${opts.timeoutMs}ms: ${cmd}`));
60
+ }, opts.timeoutMs) : void 0;
61
+ const clearTimers = () => {
62
+ if (timer) clearTimeout(timer);
63
+ if (killTimer) clearTimeout(killTimer);
64
+ };
65
+ child.on("error", (err) => {
66
+ clearTimers();
67
+ reject(err);
68
+ });
69
+ child.on("close", (code) => {
70
+ clearTimers();
71
+ if (!streaming) {
72
+ stdout = cap(stdout, outDecoder.end());
73
+ stderr = cap(stderr, errDecoder.end());
74
+ }
75
+ resolve({ code: code ?? -1, stdout, stderr });
76
+ });
77
+ });
78
+ }
79
+ var defaultSpawn = makeSpawn();
80
+
81
+ export {
82
+ defaultSpawn
83
+ };
84
+ //# sourceMappingURL=chunk-HVOOCK6L.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/audits/util/spawn.ts"],"sourcesContent":["import { spawn } from \"node:child_process\";\nimport { StringDecoder } from \"node:string_decoder\";\n\nexport type SpawnResult = { code: number; stdout: string; stderr: string };\n\nexport type SpawnOptions = {\n cwd?: string;\n env?: NodeJS.ProcessEnv;\n timeoutMs?: number;\n /** When true, the child inherits stdout/stderr so the user sees live\n * progress (useful for long-running `pnpm up` / `npm install`). The\n * returned `stdout` and `stderr` will be empty strings in that case. */\n streaming?: boolean;\n};\n\nexport type SpawnFn = (\n cmd: string,\n args: readonly string[],\n opts?: SpawnOptions,\n) => Promise<SpawnResult>;\n\ntype KillFn = (pid: number, signal: NodeJS.Signals | number) => void;\n\n/** Construction-time knobs, separated from per-call {@link SpawnOptions} mainly\n * so tests can inject deterministic `spawnImpl`/`killImpl` and a tiny grace. */\nexport type SpawnInternals = {\n spawnImpl?: typeof spawn;\n killImpl?: KillFn;\n /** Delay after SIGTERM before escalating to SIGKILL on a timeout (default 5s). */\n killGraceMs?: number;\n /** Cap on captured stdout/stderr length so a runaway child can't OOM the CLI. */\n maxOutputBytes?: number;\n};\n\nconst TRUNCATION_MARKER = \"\\n…[output truncated]\";\n\nexport function makeSpawn(internals: SpawnInternals = {}): SpawnFn {\n const spawnImpl = internals.spawnImpl ?? spawn;\n const killImpl: KillFn = internals.killImpl ?? ((pid, sig) => process.kill(pid, sig));\n const killGraceMs = internals.killGraceMs ?? 5000;\n const maxOutputBytes = internals.maxOutputBytes ?? 10 * 1024 * 1024;\n\n return (cmd, args, opts = {}) =>\n new Promise((resolve, reject) => {\n const streaming = opts.streaming === true;\n const child = spawnImpl(cmd, [...args], {\n cwd: opts.cwd,\n env: opts.env ?? process.env,\n stdio: streaming ? [\"ignore\", \"inherit\", \"inherit\"] : [\"ignore\", \"pipe\", \"pipe\"],\n // Detach ONLY when a timeout can fire: the child then leads its own\n // process group, so the timeout can kill the WHOLE tree (vite, and\n // Chromium under lhci/playwright) via process.kill(-pid), not just the\n // npx/pnpm wrapper. Without it, killing the wrapper orphaned the\n // grandchildren — a zombie vite squatting its port, Chrome left running.\n // We do NOT detach timeout-less streaming calls (pnpm install/up):\n // detaching gains nothing there (no timeout → no group-kill) and would\n // break terminal Ctrl-C, which only reaches the foreground group — i.e.\n // it would re-orphan the very children this guards. We never unref() the\n // child since we still await it.\n detached: opts.timeoutMs !== undefined,\n });\n\n // Cap appended output so an unbounded stream can't exhaust memory.\n const cap = (acc: string, chunk: string): string => {\n if (acc.length >= maxOutputBytes) return acc;\n const next = acc + chunk;\n return next.length > maxOutputBytes\n ? next.slice(0, maxOutputBytes) + TRUNCATION_MARKER\n : next;\n };\n\n let stdout = \"\";\n let stderr = \"\";\n // Decode each stream through a StringDecoder so a multibyte UTF-8 char\n // split across two `data` chunks isn't corrupted: the decoder holds the\n // partial trailing bytes until the rest arrives, instead of the old\n // `String(chunk)` which decoded each chunk in isolation (and replaced the\n // split char with U+FFFD). Flushed via `.end()` on close.\n const outDecoder = new StringDecoder(\"utf-8\");\n const errDecoder = new StringDecoder(\"utf-8\");\n if (!streaming) {\n child.stdout?.on(\n \"data\",\n (chunk: Buffer) => (stdout = cap(stdout, outDecoder.write(chunk))),\n );\n child.stderr?.on(\n \"data\",\n (chunk: Buffer) => (stderr = cap(stderr, errDecoder.write(chunk))),\n );\n }\n\n /** Signal the child's whole process group; ignore if it's already gone.\n * POSIX-only: a negative pid signals the group (the project targets\n * macOS/Linux; this is only reached when detached, i.e. on a timeout). */\n const killGroup = (sig: NodeJS.Signals): void => {\n if (child.pid === undefined) return;\n try {\n killImpl(-child.pid, sig);\n } catch {\n // ESRCH: the group already exited between the timeout and the kill.\n }\n };\n\n let killTimer: ReturnType<typeof setTimeout> | undefined;\n const timer = opts.timeoutMs\n ? setTimeout(() => {\n killGroup(\"SIGTERM\");\n // Escalate if SIGTERM is ignored (a wedged Chrome can swallow it).\n killTimer = setTimeout(() => killGroup(\"SIGKILL\"), killGraceMs);\n // Best-effort cleanup AFTER we've already rejected — it must never\n // hold the CLI open past its real work.\n killTimer.unref();\n reject(new Error(`spawn timeout after ${opts.timeoutMs}ms: ${cmd}`));\n }, opts.timeoutMs)\n : undefined;\n\n const clearTimers = (): void => {\n if (timer) clearTimeout(timer);\n if (killTimer) clearTimeout(killTimer);\n };\n\n child.on(\"error\", (err) => {\n clearTimers();\n reject(err);\n });\n child.on(\"close\", (code) => {\n clearTimers();\n if (!streaming) {\n // Flush any bytes the decoder buffered mid-character (e.g. a truncated\n // final UTF-8 sequence). `.end()` returns \"\" when nothing is pending.\n stdout = cap(stdout, outDecoder.end());\n stderr = cap(stderr, errDecoder.end());\n }\n resolve({ code: code ?? -1, stdout, stderr });\n });\n });\n}\n\nexport const defaultSpawn: SpawnFn = makeSpawn();\n"],"mappings":";AAAA,SAAS,aAAa;AACtB,SAAS,qBAAqB;AAiC9B,IAAM,oBAAoB;AAEnB,SAAS,UAAU,YAA4B,CAAC,GAAY;AACjE,QAAM,YAAY,UAAU,aAAa;AACzC,QAAM,WAAmB,UAAU,aAAa,CAAC,KAAK,QAAQ,QAAQ,KAAK,KAAK,GAAG;AACnF,QAAM,cAAc,UAAU,eAAe;AAC7C,QAAM,iBAAiB,UAAU,kBAAkB,KAAK,OAAO;AAE/D,SAAO,CAAC,KAAK,MAAM,OAAO,CAAC,MACzB,IAAI,QAAQ,CAAC,SAAS,WAAW;AAC/B,UAAM,YAAY,KAAK,cAAc;AACrC,UAAM,QAAQ,UAAU,KAAK,CAAC,GAAG,IAAI,GAAG;AAAA,MACtC,KAAK,KAAK;AAAA,MACV,KAAK,KAAK,OAAO,QAAQ;AAAA,MACzB,OAAO,YAAY,CAAC,UAAU,WAAW,SAAS,IAAI,CAAC,UAAU,QAAQ,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAW/E,UAAU,KAAK,cAAc;AAAA,IAC/B,CAAC;AAGD,UAAM,MAAM,CAAC,KAAa,UAA0B;AAClD,UAAI,IAAI,UAAU,eAAgB,QAAO;AACzC,YAAM,OAAO,MAAM;AACnB,aAAO,KAAK,SAAS,iBACjB,KAAK,MAAM,GAAG,cAAc,IAAI,oBAChC;AAAA,IACN;AAEA,QAAI,SAAS;AACb,QAAI,SAAS;AAMb,UAAM,aAAa,IAAI,cAAc,OAAO;AAC5C,UAAM,aAAa,IAAI,cAAc,OAAO;AAC5C,QAAI,CAAC,WAAW;AACd,YAAM,QAAQ;AAAA,QACZ;AAAA,QACA,CAAC,UAAmB,SAAS,IAAI,QAAQ,WAAW,MAAM,KAAK,CAAC;AAAA,MAClE;AACA,YAAM,QAAQ;AAAA,QACZ;AAAA,QACA,CAAC,UAAmB,SAAS,IAAI,QAAQ,WAAW,MAAM,KAAK,CAAC;AAAA,MAClE;AAAA,IACF;AAKA,UAAM,YAAY,CAAC,QAA8B;AAC/C,UAAI,MAAM,QAAQ,OAAW;AAC7B,UAAI;AACF,iBAAS,CAAC,MAAM,KAAK,GAAG;AAAA,MAC1B,QAAQ;AAAA,MAER;AAAA,IACF;AAEA,QAAI;AACJ,UAAM,QAAQ,KAAK,YACf,WAAW,MAAM;AACf,gBAAU,SAAS;AAEnB,kBAAY,WAAW,MAAM,UAAU,SAAS,GAAG,WAAW;AAG9D,gBAAU,MAAM;AAChB,aAAO,IAAI,MAAM,uBAAuB,KAAK,SAAS,OAAO,GAAG,EAAE,CAAC;AAAA,IACrE,GAAG,KAAK,SAAS,IACjB;AAEJ,UAAM,cAAc,MAAY;AAC9B,UAAI,MAAO,cAAa,KAAK;AAC7B,UAAI,UAAW,cAAa,SAAS;AAAA,IACvC;AAEA,UAAM,GAAG,SAAS,CAAC,QAAQ;AACzB,kBAAY;AACZ,aAAO,GAAG;AAAA,IACZ,CAAC;AACD,UAAM,GAAG,SAAS,CAAC,SAAS;AAC1B,kBAAY;AACZ,UAAI,CAAC,WAAW;AAGd,iBAAS,IAAI,QAAQ,WAAW,IAAI,CAAC;AACrC,iBAAS,IAAI,QAAQ,WAAW,IAAI,CAAC;AAAA,MACvC;AACA,cAAQ,EAAE,MAAM,QAAQ,IAAI,QAAQ,OAAO,CAAC;AAAA,IAC9C,CAAC;AAAA,EACH,CAAC;AACL;AAEO,IAAM,eAAwB,UAAU;","names":[]}