@reddoorla/maintenance 0.65.0 → 0.65.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -10
- package/dist/airtable-JUPGAI4R.js +9 -0
- package/dist/{announce-GFLKAPTT.js → announce-WXWOHR2I.js} +24 -9
- package/dist/announce-WXWOHR2I.js.map +1 -0
- package/dist/{bump-deps-3QQHYDKJ.js → bump-deps-2MAMI7DB.js} +6 -4
- package/dist/bump-deps-2MAMI7DB.js.map +1 -0
- package/dist/{chunk-MQM5YP4J.js → chunk-ARHDSMFK.js} +2 -2
- package/dist/{chunk-FUD4I2ZW.js → chunk-BTEBRTW4.js} +2 -2
- package/dist/{chunk-7RGQ4KLE.js → chunk-CCXVX5JN.js} +5 -1
- package/dist/chunk-CCXVX5JN.js.map +1 -0
- package/dist/{chunk-4A5PYCGB.js → chunk-IMMB64VH.js} +2 -2
- package/dist/{chunk-UATHDNWP.js → chunk-IRRZG66U.js} +13 -6
- package/dist/chunk-IRRZG66U.js.map +1 -0
- package/dist/{chunk-OQPMZ6BM.js → chunk-J7SZQUCW.js} +10 -2
- package/dist/{chunk-OQPMZ6BM.js.map → chunk-J7SZQUCW.js.map} +1 -1
- package/dist/{chunk-M5LX2VSR.js → chunk-JWA4FO2J.js} +3 -3
- package/dist/{chunk-FX3QBSUF.js → chunk-LCKKIJ22.js} +3 -3
- package/dist/{chunk-LM42CATB.js → chunk-MEP6PR32.js} +5 -4
- package/dist/chunk-MEP6PR32.js.map +1 -0
- package/dist/{chunk-CSTLFSGM.js → chunk-N5MZ6WRX.js} +2 -2
- package/dist/{chunk-OYN6H4CX.js → chunk-PMIKXAPF.js} +4 -4
- package/dist/{chunk-U3MF6RCS.js → chunk-SYTPVYIK.js} +13 -14
- package/dist/chunk-SYTPVYIK.js.map +1 -0
- package/dist/chunk-VPJ7N6P7.js +27 -0
- package/dist/chunk-VPJ7N6P7.js.map +1 -0
- package/dist/{chunk-FH7WQHD7.js → chunk-VUKSRWRD.js} +2 -2
- package/dist/{chunk-TC7Z5LG7.js → chunk-WCN54744.js} +30 -16
- package/dist/{chunk-TC7Z5LG7.js.map → chunk-WCN54744.js.map} +1 -1
- package/dist/{chunk-QSL3EJGI.js → chunk-WXNSLK4Q.js} +3 -3
- package/dist/{chunk-4GACMVNE.js → chunk-YEGHUODU.js} +17 -1
- package/dist/chunk-YEGHUODU.js.map +1 -0
- package/dist/{chunk-O65I7G7G.js → chunk-ZO7WNY57.js} +2 -2
- package/dist/cli/bin.js +14 -14
- package/dist/cli/commands/audit.js +7 -7
- package/dist/{convert-to-pnpm-ZO673UFB.js → convert-to-pnpm-3OEHYF76.js} +6 -4
- package/dist/convert-to-pnpm-3OEHYF76.js.map +1 -0
- package/dist/{digest-R3ROSGLP.js → digest-BTHXYYQZ.js} +3 -3
- package/dist/{github-signals-N5JGWHW2.js → github-signals-H7VU4EOH.js} +5 -5
- package/dist/index.d.ts +29 -7
- package/dist/index.js +16 -16
- package/dist/index.js.map +1 -1
- package/dist/{init-FOHNISF2.js → init-VA3H4OHM.js} +26 -9
- package/dist/init-VA3H4OHM.js.map +1 -0
- package/dist/{launch-TM3IHSSU.js → launch-OMHMW5OK.js} +9 -9
- package/dist/{lighthouse-airtable-5FHELRCA.js → lighthouse-airtable-4KWC3P72.js} +3 -3
- package/dist/{onboard-K2EL4MSG.js → onboard-2PLVVJWN.js} +10 -6
- package/dist/onboard-2PLVVJWN.js.map +1 -0
- package/dist/{orchestrate-OX3Z4DUI.js → orchestrate-6PKHJXLF.js} +4 -4
- package/dist/{renovate-dispatch-H25CQ36P.js → renovate-dispatch-K6VO5KWM.js} +3 -3
- package/dist/{report-UEYKO6WH.js → report-X3XPBRBO.js} +6 -6
- package/dist/reports/maintenance-email/assets/blurredTests.jpg +0 -0
- package/dist/{self-updating-YXOGQWH5.js → self-updating-VXVJSV5T.js} +8 -6
- package/dist/self-updating-VXVJSV5T.js.map +1 -0
- package/dist/{selftest-VE7CSGWE.js → selftest-FV4MO7TX.js} +6 -6
- package/dist/{svelte-codemods-745QM25J.js → svelte-codemods-5RXRRTMK.js} +8 -6
- package/dist/svelte-codemods-5RXRRTMK.js.map +1 -0
- package/dist/{sync-configs-NYC3Z625.js → sync-configs-TPJMRNRU.js} +10 -4
- package/dist/sync-configs-TPJMRNRU.js.map +1 -0
- package/dist/{upgrade-UBPPJ6HN.js → upgrade-7TN2XPFK.js} +8 -10
- package/dist/upgrade-7TN2XPFK.js.map +1 -0
- package/dist/{websites-CSVLSEVL.js → websites-EIPNY4DY.js} +2 -2
- package/dist/{write-audits-to-airtable-JCV7FYAW.js → write-audits-to-airtable-WVWFESZW.js} +4 -4
- package/package.json +1 -1
- package/dist/airtable-GGZJUGQB.js +0 -9
- package/dist/announce-GFLKAPTT.js.map +0 -1
- package/dist/bump-deps-3QQHYDKJ.js.map +0 -1
- package/dist/chunk-4GACMVNE.js.map +0 -1
- package/dist/chunk-7RGQ4KLE.js.map +0 -1
- package/dist/chunk-LM42CATB.js.map +0 -1
- package/dist/chunk-U3MF6RCS.js.map +0 -1
- package/dist/chunk-UATHDNWP.js.map +0 -1
- package/dist/convert-to-pnpm-ZO673UFB.js.map +0 -1
- package/dist/init-FOHNISF2.js.map +0 -1
- package/dist/onboard-K2EL4MSG.js.map +0 -1
- package/dist/self-updating-YXOGQWH5.js.map +0 -1
- package/dist/svelte-codemods-745QM25J.js.map +0 -1
- package/dist/sync-configs-NYC3Z625.js.map +0 -1
- package/dist/upgrade-UBPPJ6HN.js.map +0 -1
- /package/dist/{airtable-GGZJUGQB.js.map → airtable-JUPGAI4R.js.map} +0 -0
- /package/dist/{chunk-MQM5YP4J.js.map → chunk-ARHDSMFK.js.map} +0 -0
- /package/dist/{chunk-FUD4I2ZW.js.map → chunk-BTEBRTW4.js.map} +0 -0
- /package/dist/{chunk-4A5PYCGB.js.map → chunk-IMMB64VH.js.map} +0 -0
- /package/dist/{chunk-M5LX2VSR.js.map → chunk-JWA4FO2J.js.map} +0 -0
- /package/dist/{chunk-FX3QBSUF.js.map → chunk-LCKKIJ22.js.map} +0 -0
- /package/dist/{chunk-CSTLFSGM.js.map → chunk-N5MZ6WRX.js.map} +0 -0
- /package/dist/{chunk-OYN6H4CX.js.map → chunk-PMIKXAPF.js.map} +0 -0
- /package/dist/{chunk-FH7WQHD7.js.map → chunk-VUKSRWRD.js.map} +0 -0
- /package/dist/{chunk-QSL3EJGI.js.map → chunk-WXNSLK4Q.js.map} +0 -0
- /package/dist/{chunk-O65I7G7G.js.map → chunk-ZO7WNY57.js.map} +0 -0
- /package/dist/{digest-R3ROSGLP.js.map → digest-BTHXYYQZ.js.map} +0 -0
- /package/dist/{github-signals-N5JGWHW2.js.map → github-signals-H7VU4EOH.js.map} +0 -0
- /package/dist/{launch-TM3IHSSU.js.map → launch-OMHMW5OK.js.map} +0 -0
- /package/dist/{lighthouse-airtable-5FHELRCA.js.map → lighthouse-airtable-4KWC3P72.js.map} +0 -0
- /package/dist/{orchestrate-OX3Z4DUI.js.map → orchestrate-6PKHJXLF.js.map} +0 -0
- /package/dist/{renovate-dispatch-H25CQ36P.js.map → renovate-dispatch-K6VO5KWM.js.map} +0 -0
- /package/dist/{report-UEYKO6WH.js.map → report-X3XPBRBO.js.map} +0 -0
- /package/dist/{selftest-VE7CSGWE.js.map → selftest-FV4MO7TX.js.map} +0 -0
- /package/dist/{websites-CSVLSEVL.js.map → websites-EIPNY4DY.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-JCV7FYAW.js.map → write-audits-to-airtable-WVWFESZW.js.map} +0 -0
package/README.md
CHANGED
|
@@ -352,14 +352,20 @@ This repo's Netlify site hosts the whole dashboard + forms surface: the Resend d
|
|
|
352
352
|
|
|
353
353
|
**2. Set env vars** in Site settings → Environment variables:
|
|
354
354
|
|
|
355
|
-
| Variable | Value
|
|
356
|
-
| ----------------------- |
|
|
357
|
-
| `AIRTABLE_PAT` | Same PAT used by the CLI (read+write on the Websites + Reports tables)
|
|
358
|
-
| `AIRTABLE_BASE_ID` | `appHG8nLOzULzXOER`
|
|
359
|
-
| `RESEND_WEBHOOK_SECRET` | Generated by Resend in step 4 — paste back here after creating the endpoint
|
|
360
|
-
| `TURSO_DATABASE_URL` | `libsql://<db>-<org>.turso.io` — submissions + spam counters live here; the dashboard + forms functions 500 without it
|
|
361
|
-
| `TURSO_AUTH_TOKEN` | Auth token for that Turso url (required for a remote `libsql://` url)
|
|
362
|
-
| `FORMS_INGEST_TOKEN` | Shared secret gating `POST /api/forms/:slug` — the SAME value is set on each fleet site
|
|
355
|
+
| Variable | Value |
|
|
356
|
+
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
357
|
+
| `AIRTABLE_PAT` | Same PAT used by the CLI (read+write on the Websites + Reports tables) |
|
|
358
|
+
| `AIRTABLE_BASE_ID` | `appHG8nLOzULzXOER` |
|
|
359
|
+
| `RESEND_WEBHOOK_SECRET` | Generated by Resend in step 4 — paste back here after creating the endpoint |
|
|
360
|
+
| `TURSO_DATABASE_URL` | `libsql://<db>-<org>.turso.io` — submissions + spam counters live here; the dashboard + forms functions 500 without it |
|
|
361
|
+
| `TURSO_AUTH_TOKEN` | Auth token for that Turso url (required for a remote `libsql://` url) |
|
|
362
|
+
| `FORMS_INGEST_TOKEN` | Shared secret gating `POST /api/forms/:slug` — the SAME value is set on each fleet site |
|
|
363
|
+
| `DASHBOARD_PASSWORD` | Basic-auth password gating the cockpit, per-site dashboards, and every action endpoint — the dashboard is unauthable without it |
|
|
364
|
+
| `DASHBOARD_BASE_URL` | Public base URL of this deploy (e.g. `https://<your-site>.netlify.app`) — used to build absolute links such as report approve URLs |
|
|
365
|
+
| `RENOVATE_TOKEN` | GitHub PAT with `actions:write` — powers the dashboard's "Trigger Renovate" button (the button 502s without it) |
|
|
366
|
+
| `GH_TOKEN` | GitHub token for the dashboard's request-path REST calls (fleet-refresh dispatch + GitHub-sourced signals) |
|
|
367
|
+
|
|
368
|
+
> The first six vars cover the webhook + forms ingest; `DASHBOARD_PASSWORD` / `DASHBOARD_BASE_URL` / `RENOVATE_TOKEN` / `GH_TOKEN` are additionally required by the operator **dashboard** this same deploy serves (cockpit at `/`, per-site at `/s/:slug`).
|
|
363
369
|
|
|
364
370
|
**3. Trigger a deploy** (Deploys → Trigger deploy → Deploy site). When it goes green, curl the health endpoint:
|
|
365
371
|
|
|
@@ -376,12 +382,13 @@ Expected:
|
|
|
376
382
|
"env": {
|
|
377
383
|
"RESEND_WEBHOOK_SECRET": true,
|
|
378
384
|
"AIRTABLE_PAT": true,
|
|
379
|
-
"AIRTABLE_BASE_ID": true
|
|
385
|
+
"AIRTABLE_BASE_ID": true,
|
|
386
|
+
"TURSO_DATABASE_URL": true
|
|
380
387
|
}
|
|
381
388
|
}
|
|
382
389
|
```
|
|
383
390
|
|
|
384
|
-
Any `false` means the env var didn't land — re-check step 2 and re-deploy. The health endpoint reports presence-only and never returns secret values, so the output is safe to share in a support ticket.
|
|
391
|
+
Any `false` means the env var didn't land — re-check step 2 and re-deploy. (`TURSO_DATABASE_URL` is surfaced here because its absence 500s the dashboard + forms surface — the most common fresh-deploy failure; `DASHBOARD_PASSWORD` and the other dashboard vars aren't in this presence list, so verify those by loading the cockpit.) The health endpoint reports presence-only and never returns secret values, so the output is safe to share in a support ticket.
|
|
385
392
|
|
|
386
393
|
**4. Register the webhook in Resend:** [resend.com/webhooks](https://resend.com/webhooks) → Add Endpoint → paste the function URL from step 3 → select events `email.delivered`, `email.bounced`, `email.complained` → save. Copy the generated **Signing Secret** (`whsec_…`) into Netlify's `RESEND_WEBHOOK_SECRET` env var (step 2) and trigger another deploy so the secret takes effect.
|
|
387
394
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
scoresFromRow
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-PMIKXAPF.js";
|
|
4
4
|
import {
|
|
5
5
|
fetchGaUsers,
|
|
6
|
-
fetchSearch
|
|
7
|
-
|
|
6
|
+
fetchSearch,
|
|
7
|
+
readGaConfig
|
|
8
|
+
} from "./chunk-JWA4FO2J.js";
|
|
8
9
|
import {
|
|
9
10
|
queueDraft
|
|
10
11
|
} from "./chunk-6LIWDIXU.js";
|
|
@@ -16,7 +17,7 @@ import {
|
|
|
16
17
|
renderReportHtml,
|
|
17
18
|
resolveCopy,
|
|
18
19
|
uploadAttachment
|
|
19
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-SYTPVYIK.js";
|
|
20
21
|
import {
|
|
21
22
|
createDraft,
|
|
22
23
|
findReportByPeriod,
|
|
@@ -25,8 +26,9 @@ import {
|
|
|
25
26
|
import "./chunk-U5SZ4FOP.js";
|
|
26
27
|
import {
|
|
27
28
|
listWebsites,
|
|
28
|
-
siteSlug
|
|
29
|
-
|
|
29
|
+
siteSlug,
|
|
30
|
+
updateAnalyticsHealth
|
|
31
|
+
} from "./chunk-CCXVX5JN.js";
|
|
30
32
|
import {
|
|
31
33
|
openBase,
|
|
32
34
|
readAirtableConfig
|
|
@@ -55,13 +57,26 @@ async function announce(deps) {
|
|
|
55
57
|
}
|
|
56
58
|
const periodEnd = now;
|
|
57
59
|
const periodStart = new Date(now.getTime() - GA_WINDOW_DAYS * 24 * 60 * 60 * 1e3);
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
+
const gaResult = await fetchGaUsers(w, periodStart, periodEnd);
|
|
61
|
+
const searchResult = await fetchSearch(w, periodStart, periodEnd);
|
|
62
|
+
const gaUsers = gaResult.value;
|
|
63
|
+
const search = searchResult.value;
|
|
60
64
|
const enrichment = {
|
|
61
65
|
...gaUsers ? { gaUsersCurrent: gaUsers.current, gaUsersPrevious: gaUsers.previous } : {},
|
|
62
66
|
...search ? { searchFoundPage1: search.foundOnPage1 } : {},
|
|
63
67
|
...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {}
|
|
64
68
|
};
|
|
69
|
+
if (readGaConfig() !== null && Boolean(w.ga4PropertyId || w.searchQuery)) {
|
|
70
|
+
try {
|
|
71
|
+
await updateAnalyticsHealth(
|
|
72
|
+
base,
|
|
73
|
+
w.id,
|
|
74
|
+
gaResult.softFailed || searchResult.softFailed ? now.toISOString() : null
|
|
75
|
+
);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
console.warn(`\u26A0 analytics-health write skipped for ${w.name}: ${e.message}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
65
80
|
let report;
|
|
66
81
|
let statusKind;
|
|
67
82
|
const existing = await findReportByPeriod(base, w.id, "Announcement", period);
|
|
@@ -170,4 +185,4 @@ export {
|
|
|
170
185
|
formatAnnounceResult,
|
|
171
186
|
runAnnounceCommand
|
|
172
187
|
};
|
|
173
|
-
//# sourceMappingURL=announce-
|
|
188
|
+
//# sourceMappingURL=announce-WXWOHR2I.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/recipes/announce.ts","../src/cli/commands/announce.ts"],"sourcesContent":["import { openBase, readAirtableConfig } from \"../reports/airtable/client.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport { listWebsites, siteSlug, updateAnalyticsHealth } from \"../reports/airtable/websites.js\";\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { readGaConfig } from \"../reports/ga/config.js\";\nimport {\n createDraft,\n findReportByPeriod,\n updateReportScores,\n type ReportEnrichment,\n} 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 { fetchGaUsers, fetchSearch } from \"../reports/draft.js\";\nimport { announcementSiteExtras } from \"../reports/announcement-email/template.js\";\nimport type { LighthouseScores } from \"../reports/types.js\";\nimport { defaultReportSubject } from \"../reports/subject.js\";\nimport { scoresFromRow } from \"../reports/report-data.js\";\n\nexport type AnnounceSiteResult =\n | {\n site: string;\n status: \"drafted\" | \"reused\";\n reportId: string;\n recipientMissing: boolean;\n /** False when a higher-or-equal-tier report was already queued (single-queue rule). */\n queued: boolean;\n }\n | { site: string; status: \"skipped-no-scores\" }\n | { site: string; status: \"error\"; message: string };\n\nexport type AnnounceResult = { results: AnnounceSiteResult[] };\n\n/** The traffic/search lookback window (days) the announcement reports on. The trend compares it\n * against the equal-length prior window, and the email labels it \"vs the previous N days\". */\nconst GA_WINDOW_DAYS = 30;\n\nexport type AnnounceDeps = {\n /** Airtable handle. Defaults to opening the live base from credentials. */\n base?: AirtableBase;\n /** When set, restrict to the single site whose slug matches. Default: all maintenance sites. */\n site?: string;\n /** Single timestamp driving the period key, render, draft, and preview filename. */\n now?: Date;\n};\n\n/**\n * Draft the monthly-report ANNOUNCEMENT email for every `maintenance` site (or one,\n * via `deps.site`). Airtable-driven and fleet-wide: unlike `launch`, it runs no audits\n * and takes no `Site`/inventory object — it reads the Lighthouse scores already stored\n * on each Websites row. DRAFTS ONLY; the M3 approve loop sends.\n *\n * One bad site must never abort the run: each site is wrapped in its own try/catch that\n * records an `error` result and continues.\n */\nexport async function announce(deps?: AnnounceDeps): Promise<AnnounceResult> {\n const base = deps?.base ?? openBase(readAirtableConfig());\n const now = deps?.now ?? new Date();\n\n const websites = await listWebsites(base);\n let targets = websites.filter((w) => w.status === \"maintenance\");\n if (deps?.site) {\n const wanted = siteSlug(deps.site);\n targets = targets.filter((w) => siteSlug(w.name) === wanted);\n }\n\n const period = now.toISOString().slice(0, 7);\n const results: AnnounceSiteResult[] = [];\n\n for (const w of targets) {\n try {\n const scores = scoresFromRow(w);\n if (scores === null) {\n results.push({ site: w.name, status: \"skipped-no-scores\" });\n continue;\n }\n\n // Traffic + search snapshot over a ~30-day window ending now (fetchPeriodUsers derives\n // the equal-length previous window for the trend). Reuses the report pipeline's\n // soft-failing enrichment: GA/search unconfigured or an API error leaves the numbers\n // null and the email simply omits the traffic section — it never blocks the draft.\n const periodEnd = now;\n const periodStart = new Date(now.getTime() - GA_WINDOW_DAYS * 24 * 60 * 60 * 1000);\n const gaResult = await fetchGaUsers(w, periodStart, periodEnd);\n const searchResult = await fetchSearch(w, periodStart, periodEnd);\n const gaUsers = gaResult.value;\n const search = searchResult.value;\n const enrichment: ReportEnrichment = {\n ...(gaUsers ? { gaUsersCurrent: gaUsers.current, gaUsersPrevious: gaUsers.previous } : {}),\n ...(search ? { searchFoundPage1: search.foundOnPage1 } : {}),\n ...(search?.foundOnPage1 && search.position !== null\n ? { searchPosition: search.position }\n : {}),\n };\n\n // Record this site's GA/Search enrichment health for the per-site analytics-failure\n // signal (cockpit/digest) — exactly as the `--due` draft path does. Without this, an\n // announcement-time GA outage hides the traffic block with NO operator signal (it reads\n // identically to \"site has no GA configured\"). Set the timestamp on a soft-fail, clear\n // it on a clean enrichment so the signal self-heals. Best-effort: the column is\n // operator-added, so until it exists the write throws — which must not break the draft.\n if (readGaConfig() !== null && Boolean(w.ga4PropertyId || w.searchQuery)) {\n try {\n await updateAnalyticsHealth(\n base,\n w.id,\n gaResult.softFailed || searchResult.softFailed ? now.toISOString() : null,\n );\n } catch (e) {\n console.warn(`⚠ analytics-health write skipped for ${w.name}: ${(e as Error).message}`);\n }\n }\n\n // Dedupe: reuse an existing Announcement row for this (site, period) rather than\n // stacking a second draft. The reuse path refreshes the stored scores + traffic/search\n // (and Completed on) so the eventually-sent email — which reads the row — isn't stale.\n // The create path writes them via createDraft.\n let report;\n let statusKind: \"drafted\" | \"reused\";\n const existing = await findReportByPeriod(base, w.id, \"Announcement\", period);\n if (existing) {\n await updateReportScores(base, existing.id, scores, now, enrichment);\n report = existing;\n statusKind = \"reused\";\n } else {\n report = await createDraft(base, draftInputFor(w, scores, now, period, enrichment));\n statusKind = \"drafted\";\n }\n\n const slug = siteSlug(w.name);\n const { html } = await renderReportHtml({\n siteName: w.name,\n siteUrl: w.url,\n reportType: \"Announcement\",\n completedOn: now,\n lighthouse: scores,\n gaUsersCurrent: gaUsers?.current,\n gaUsersPrevious: gaUsers?.previous,\n gaPeriodDays: GA_WINDOW_DAYS,\n searchPosition: search?.foundOnPage1 ? (search.position ?? undefined) : undefined,\n lastTestedDate: null,\n commentary: null,\n copy: resolveCopy(w),\n headerImageCid: `${slug}-header`,\n // cadence (the client's go-forward pace, \"None\" omitted) + default-on improvement\n // callouts. Shared with the send re-render via announcementSiteExtras so the sent\n // email matches this reviewed preview.\n ...announcementSiteExtras(w),\n });\n\n // A preview-upload hiccup must NOT fail the site — log and continue.\n try {\n await uploadAttachment(\n report.id,\n \"Rendered HTML\",\n html,\n `${slug}-${now.toISOString().slice(0, 10)}.html`,\n \"text/html\",\n );\n } catch (uploadErr) {\n console.warn(\n `⚠ Announcement preview upload skipped for ${w.name}: ${\n uploadErr instanceof Error ? uploadErr.message : String(uploadErr)\n }`,\n );\n }\n\n // Critical: NOT wrapped — without queueing, the draft never enters the approve queue,\n // so a failure here must surface as an error result for the site. queueDraft also\n // supersedes any lower-tier (Maintenance/Testing) drafts queued for this site, and\n // stands down if an equal-or-higher report is already queued (single-queue rule).\n const queue = await queueDraft(base, {\n id: report.id,\n siteId: w.id,\n reportType: \"Announcement\",\n });\n\n const recipientMissing = !(w.reportRecipientsTo && w.reportRecipientsTo.trim());\n results.push({\n site: w.name,\n status: statusKind,\n reportId: report.id,\n recipientMissing,\n queued: queue.queued,\n });\n } catch (err) {\n results.push({\n site: w.name,\n status: \"error\",\n message: err instanceof Error ? err.message : String(err),\n });\n }\n }\n\n return { results };\n}\n\n/** Build the Announcement `DraftInput`. Announcements have no period window and no prior\n * maintenance test, so periodStart/periodEnd/completedOn all collapse to `now` and\n * `lastTestedDate` is null. The subject override gives the email a purpose-built line. */\nfunction draftInputFor(\n w: WebsiteRow,\n scores: LighthouseScores,\n now: Date,\n period: string,\n enrichment: ReportEnrichment,\n): Parameters<typeof createDraft>[1] {\n return {\n reportId: `${w.name} — Announcement — ${now.toISOString().slice(0, 10)}`,\n siteId: w.id,\n reportType: \"Announcement\",\n period,\n periodStart: now,\n periodEnd: now,\n completedOn: now,\n lighthouse: scores,\n lastTestedDate: null,\n subjectOverride: defaultReportSubject({\n name: w.name,\n url: w.url,\n type: \"Announcement\",\n date: now,\n }),\n ...enrichment,\n };\n}\n","import { announce, type AnnounceResult, type AnnounceSiteResult } from \"../../recipes/announce.js\";\n\nexport type AnnounceCommandOptions = {\n cwd?: string;\n};\n\nfunction formatSiteResult(r: AnnounceSiteResult): string {\n if (r.status === \"skipped-no-scores\") return `[${r.site}] skipped-no-scores`;\n if (r.status === \"error\") return `[${r.site}] error: ${r.message}`;\n const note = r.recipientMissing ? \" ⚠ recipient missing\" : \"\";\n return `[${r.site}] ${r.status}${note}`;\n}\n\nexport function formatAnnounceResult(result: AnnounceResult): string {\n if (result.results.length === 0) return \"No maintenance sites to announce.\";\n return result.results.map(formatSiteResult).join(\"\\n\");\n}\n\n/**\n * `announce [site]` — Airtable-driven and fleet-wide. Draft the monthly-report\n * announcement email for every `maintenance` site (or one, when `site` is given) into\n * the M3 approve queue. Never sends; the operator approves each draft and the next send\n * run delivers it. Reads the Lighthouse scores already stored on each Websites row —\n * no audits are run.\n */\nexport async function runAnnounceCommand(\n site: string | undefined,\n _opts: AnnounceCommandOptions,\n): Promise<{ output: string; code: number }> {\n const result = await announce(site ? { site } : {});\n const hadError = result.results.some((r) => r.status === \"error\");\n return { output: formatAnnounceResult(result), code: hadError ? 1 : 0 };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import {
|
|
2
|
+
runRecipeOverSites
|
|
3
|
+
} from "./chunk-VPJ7N6P7.js";
|
|
1
4
|
import {
|
|
2
5
|
appendSkipNotice,
|
|
3
6
|
fleetWorkdir,
|
|
@@ -5,7 +8,7 @@ import {
|
|
|
5
8
|
} from "./chunk-4NAOR44V.js";
|
|
6
9
|
import {
|
|
7
10
|
resolveSites
|
|
8
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-VUKSRWRD.js";
|
|
9
12
|
import {
|
|
10
13
|
bumpDeps
|
|
11
14
|
} from "./chunk-D3DQFC32.js";
|
|
@@ -45,8 +48,7 @@ async function runBumpDepsCommand(site, opts) {
|
|
|
45
48
|
sites = prep.prepared;
|
|
46
49
|
skipped = prep.skipped;
|
|
47
50
|
}
|
|
48
|
-
const results =
|
|
49
|
-
for (const s of sites) results.push(await bumpDeps(s, { group }));
|
|
51
|
+
const results = await runRecipeOverSites("bump-deps", sites, (s) => bumpDeps(s, { group }));
|
|
50
52
|
const output = results.map(formatResult).join("\n");
|
|
51
53
|
const code = results.some((r) => r.status === "failed") ? 1 : 0;
|
|
52
54
|
return { output: appendSkipNotice(output, skipped), code };
|
|
@@ -54,4 +56,4 @@ async function runBumpDepsCommand(site, opts) {
|
|
|
54
56
|
export {
|
|
55
57
|
runBumpDepsCommand
|
|
56
58
|
};
|
|
57
|
-
//# sourceMappingURL=bump-deps-
|
|
59
|
+
//# sourceMappingURL=bump-deps-2MAMI7DB.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cli/commands/bump-deps.ts"],"sourcesContent":["import { resolve } from \"node:path\";\nimport { bumpDeps, type BumpDepsGroup } from \"../../recipes/bump-deps.js\";\nimport type { RecipeResult } from \"../../types.js\";\nimport { resolveSites } from \"../fleet/resolve-sites.js\";\nimport { prepareFleetSites, appendSkipNotice, type SkippedSite } from \"../fleet/prepare-sites.js\";\nimport { runRecipeOverSites } from \"../fleet/run-recipe-over-sites.js\";\nimport { fleetWorkdir } from \"../../util/fleet-workdir.js\";\n\nconst GROUPS: BumpDepsGroup[] = [\"patch\", \"minor\", \"major\"];\n\nexport type BumpDepsCommandOptions = {\n group?: string;\n fleet?: string;\n workdir?: string;\n cwd?: string;\n};\n\nfunction formatResult(r: RecipeResult): string {\n if (r.status === \"noop\") return `[${r.site}] noop: ${r.notes ?? \"\"}`;\n return `[${r.site}] applied: ${r.commits.length} commit(s)\\n${r.notes ?? \"\"}`;\n}\n\nexport async function runBumpDepsCommand(\n site: string | undefined,\n opts: BumpDepsCommandOptions,\n): Promise<{ output: string; code: number }> {\n const group = (opts.group ?? \"minor\") as BumpDepsGroup;\n if (!GROUPS.includes(group)) {\n throw Object.assign(\n new Error(`unknown --group: ${group}. expected one of ${GROUPS.join(\", \")}`),\n { exitCode: 2 },\n );\n }\n\n const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();\n\n let sites = await resolveSites({\n ...(site !== undefined ? { site } : {}),\n ...(opts.fleet !== undefined ? { fleet: opts.fleet } : {}),\n cwd,\n });\n\n let skipped: SkippedSite[] = [];\n if (opts.fleet) {\n const workdir = opts.workdir ?? fleetWorkdir();\n const prep = await prepareFleetSites(sites, { workdir });\n sites = prep.prepared;\n skipped = prep.skipped;\n }\n\n const results = await runRecipeOverSites(\"bump-deps\", sites, (s) => bumpDeps(s, { group }));\n\n const output = results.map(formatResult).join(\"\\n\");\n const code = results.some((r) => r.status === \"failed\") ? 1 : 0;\n return { output: appendSkipNotice(output, skipped), code };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAAA,SAAS,eAAe;AAQxB,IAAM,SAA0B,CAAC,SAAS,SAAS,OAAO;AAS1D,SAAS,aAAa,GAAyB;AAC7C,MAAI,EAAE,WAAW,OAAQ,QAAO,IAAI,EAAE,IAAI,WAAW,EAAE,SAAS,EAAE;AAClE,SAAO,IAAI,EAAE,IAAI,cAAc,EAAE,QAAQ,MAAM;AAAA,EAAe,EAAE,SAAS,EAAE;AAC7E;AAEA,eAAsB,mBACpB,MACA,MAC2C;AAC3C,QAAM,QAAS,KAAK,SAAS;AAC7B,MAAI,CAAC,OAAO,SAAS,KAAK,GAAG;AAC3B,UAAM,OAAO;AAAA,MACX,IAAI,MAAM,oBAAoB,KAAK,qBAAqB,OAAO,KAAK,IAAI,CAAC,EAAE;AAAA,MAC3E,EAAE,UAAU,EAAE;AAAA,IAChB;AAAA,EACF;AAEA,QAAM,MAAM,KAAK,MAAM,QAAQ,KAAK,GAAG,IAAI,QAAQ,IAAI;AAEvD,MAAI,QAAQ,MAAM,aAAa;AAAA,IAC7B,GAAI,SAAS,SAAY,EAAE,KAAK,IAAI,CAAC;AAAA,IACrC,GAAI,KAAK,UAAU,SAAY,EAAE,OAAO,KAAK,MAAM,IAAI,CAAC;AAAA,IACxD;AAAA,EACF,CAAC;AAED,MAAI,UAAyB,CAAC;AAC9B,MAAI,KAAK,OAAO;AACd,UAAM,UAAU,KAAK,WAAW,aAAa;AAC7C,UAAM,OAAO,MAAM,kBAAkB,OAAO,EAAE,QAAQ,CAAC;AACvD,YAAQ,KAAK;AACb,cAAU,KAAK;AAAA,EACjB;AAEA,QAAM,UAAU,MAAM,mBAAmB,aAAa,OAAO,CAAC,MAAM,SAAS,GAAG,EAAE,MAAM,CAAC,CAAC;AAE1F,QAAM,SAAS,QAAQ,IAAI,YAAY,EAAE,KAAK,IAAI;AAClD,QAAM,OAAO,QAAQ,KAAK,CAAC,MAAM,EAAE,WAAW,QAAQ,IAAI,IAAI;AAC9D,SAAO,EAAE,QAAQ,iBAAiB,QAAQ,OAAO,GAAG,KAAK;AAC3D;","names":[]}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
ACTIVE_STATUSES,
|
|
6
6
|
listWebsites,
|
|
7
7
|
siteSlug
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-CCXVX5JN.js";
|
|
9
9
|
|
|
10
10
|
// src/inventory/airtable.ts
|
|
11
11
|
function fromAirtableBase(base, opts = {}) {
|
|
@@ -47,4 +47,4 @@ function fromAirtableBase(base, opts = {}) {
|
|
|
47
47
|
export {
|
|
48
48
|
fromAirtableBase
|
|
49
49
|
};
|
|
50
|
-
//# sourceMappingURL=chunk-
|
|
50
|
+
//# sourceMappingURL=chunk-ARHDSMFK.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
planGotchaCodemods
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-J7SZQUCW.js";
|
|
4
4
|
import {
|
|
5
5
|
withRecipe
|
|
6
6
|
} from "./chunk-XKOZUVK2.js";
|
|
@@ -32,4 +32,4 @@ async function svelteCodemods(site) {
|
|
|
32
32
|
export {
|
|
33
33
|
svelteCodemods
|
|
34
34
|
};
|
|
35
|
-
//# sourceMappingURL=chunk-
|
|
35
|
+
//# sourceMappingURL=chunk-BTEBRTW4.js.map
|
|
@@ -73,6 +73,7 @@ function mapRow(rec) {
|
|
|
73
73
|
depsDrifted: f["Deps Drifted"] ?? null,
|
|
74
74
|
depsMajorBehind: f["Deps Major Behind"] ?? null,
|
|
75
75
|
depsOutdated: f["Deps Outdated"] ?? null,
|
|
76
|
+
depsMajorOutdated: f["Deps Major Outdated"] ?? null,
|
|
76
77
|
securityVulnsCritical: f["Security Vulns Critical"] ?? null,
|
|
77
78
|
securityVulnsHigh: f["Security Vulns High"] ?? null,
|
|
78
79
|
securityVulnsModerate: f["Security Vulns Moderate"] ?? null,
|
|
@@ -146,6 +147,9 @@ function depsFields(counts) {
|
|
|
146
147
|
if (counts.outdated !== null) {
|
|
147
148
|
fields["Deps Outdated"] = counts.outdated;
|
|
148
149
|
}
|
|
150
|
+
if (counts.majorOutdated !== null) {
|
|
151
|
+
fields["Deps Major Outdated"] = counts.majorOutdated;
|
|
152
|
+
}
|
|
149
153
|
return fields;
|
|
150
154
|
}
|
|
151
155
|
var SEVERITY_RANK = {
|
|
@@ -300,4 +304,4 @@ export {
|
|
|
300
304
|
updateGitHubSignals,
|
|
301
305
|
updateLaunched
|
|
302
306
|
};
|
|
303
|
-
//# sourceMappingURL=chunk-
|
|
307
|
+
//# sourceMappingURL=chunk-CCXVX5JN.js.map
|
|
@@ -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, LighthouseScoreWriteback } 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 acceptedWatchConditions: string[];\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 /** Of {@link depsOutdated}, how many are a *major* version behind the\n * registry's latest — the \"majors available on npm\" signal, distinct from the\n * baseline-drift {@link depsMajorBehind}. Null = not determined this run. */\n depsMajorOutdated: number | null;\n securityVulnsCritical: number | null;\n securityVulnsHigh: number | null;\n securityVulnsModerate: number | null;\n securityVulnsLow: number | null;\n /** Count of consecutive nightly Renovate auto-fix dispatches for the CURRENT\n * critical/high vuln episode that have NOT yet cleared it. Owned by\n * `renovate-dispatch`: +1 per real dispatch, reset to 0 when vulns clear.\n * Null = field absent / never dispatched → reads as 0. At/above\n * AUTO_FIX_EXHAUSTED_CYCLES the vuln renders as \"auto-fix failed\". */\n securityAutoFixAttempts: 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 /** Netlify site id — the IDENTITY the `netlify-deploy` audit needs to query the\n * Netlify API. Read-only input (operator-set in Airtable); null = not on Netlify\n * (or not wired) → that audit skips. Never derived from the URL. */\n netlifyId: string | null;\n /** Latest PRODUCTION deploy health (the `netlify-deploy` audit). `deployStatus` is\n * Netlify's deploy state lower-cased (`ready`/`error`/`building`/…), null = none / not\n * checked; `lastDeployAt` is when it deployed; `deployLogUrl` links to the deploy/log. */\n deployStatus: string | null;\n lastDeployAt: string | null;\n deployLogUrl: 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 acceptedWatchConditions: Array.isArray(f[\"Accepted Watch Conditions\"])\n ? (f[\"Accepted Watch Conditions\"] as string[])\n : [],\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 depsMajorOutdated: (f[\"Deps Major 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 securityAutoFixAttempts: (f[\"Security Auto-Fix Attempts\"] 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 netlifyId: trimToNull(f[\"Netlify ID\"]),\n deployStatus: (f[\"Deploy status\"] as string | undefined) ?? null,\n lastDeployAt: (f[\"Last deploy at\"] as string | undefined) ?? null,\n deployLogUrl: (f[\"Deploy log URL\"] 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 = {\n drifted: number;\n majorBehind: number;\n outdated: number | null;\n majorOutdated: number | null;\n};\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 /** GitHub Dependabot dependency scope (\"runtime\" | \"development\"), when known. Lets the\n * dashboard flag build-time-only (\"development\") vulns. Omitted for advisories from the\n * lockfile `pnpm audit` fallback, which carries no per-package graph scope. */\n scope?: \"runtime\" | \"development\";\n};\nexport type DomainResult = { certDaysRemaining: number | null; checkedAt: string };\nexport type NetlifyDeployResult = {\n /** Netlify deploy state lower-cased (`ready`/`error`/`building`/…), null = none. */\n state: string | null;\n /** When the latest production deploy was published/created (ISO), null = unknown. */\n deployedAt: string | null;\n /** Link to the deploy / its build log, null = unknown. */\n logUrl: string | null;\n /** When the audit last ran. */\n checkedAt: string;\n};\nexport type BrowserAuditFields = {\n desktopOk: boolean;\n mobileOk: boolean;\n linksOk: boolean;\n brokenLinks: number;\n checkedAt: string;\n};\n\nfunction scoreFields(scores: LighthouseScoreWriteback): FieldSet {\n // A null score CLEARS the cell (→ dashboard \"—\"), distinguishing a metric that\n // errored this run (e.g. NO_LCP → null performance) from a real low score. This\n // intentionally overwrites a prior value: a run that couldn't measure the metric\n // shouldn't keep showing a stale number. Writing null to clear mirrors\n // updateAnalyticsHealth; the `as FieldSet` cast is needed because airtable's\n // FieldSet type omits null.\n const fields: Record<string, number | string | null> = {\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 return fields as FieldSet;\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 // Same null-guard for the registry-major breakdown (same source signal).\n if (counts.majorOutdated !== null) {\n fields[\"Deps Major Outdated\"] = counts.majorOutdated;\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 const scope =\n e[\"scope\"] === \"runtime\" || e[\"scope\"] === \"development\"\n ? (e[\"scope\"] as \"runtime\" | \"development\")\n : undefined;\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 ...(scope ? { scope } : {}),\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 netlifyDeployFields(result: NetlifyDeployResult): FieldSet {\n // Write all three deploy fields UNCONDITIONALLY (null clears the cell): a null state /\n // deployedAt / logUrl this run means \"couldn't read it\" and must not leave a STALE value\n // sitting next to a fresh \"Deploy checked at\" — the dashboard would otherwise render a green\n // \"ready\" badge for a site whose latest deploy actually errored. FieldSet's type doesn't model\n // null, hence the cast through a widened record (same approach as domainFields).\n const fields: Record<string, string | null> = {\n \"Deploy status\": result.state,\n \"Last deploy at\": result.deployedAt,\n \"Deploy log URL\": result.logUrl,\n \"Deploy checked at\": result.checkedAt,\n };\n return fields as FieldSet;\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/** Persist a site's auto-fix attempt counter. Its own one-field writer so the\n * nightly Renovate dispatch can update it without touching the audit's counts. */\nexport async function updateAutoFixAttempts(\n base: AirtableBase,\n recordId: string,\n attempts: number,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([\n { id: recordId, fields: { \"Security Auto-Fix Attempts\": attempts } },\n ]);\n}\n\n/** Generic single-field writer for the dashboard site-details editor. The caller\n * (setSiteDetail) restricts `column` to the EDITABLE_SITE_FIELDS allowlist, so this\n * never writes an arbitrary column from request input. */\nexport async function updateSiteField(\n base: AirtableBase,\n recordId: string,\n column: string,\n value: string,\n): Promise<void> {\n await base(WEBSITES_TABLE).update([{ id: recordId, fields: { [column]: value } }]);\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?: LighthouseScoreWriteback;\n a11y?: A11yCounts;\n deps?: DepsCounts;\n security?: SecurityCounts;\n securityAdvisories?: SecurityAdvisory[];\n domain?: DomainResult;\n browser?: BrowserAuditFields;\n netlifyDeploy?: NetlifyDeployResult;\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 if (audits.netlifyDeploy) Object.assign(fields, netlifyDeployFields(audits.netlifyDeploy));\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":[]}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
siteSlug
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-CCXVX5JN.js";
|
|
4
4
|
|
|
5
5
|
// src/alerts/digest-collectors.ts
|
|
6
6
|
function dashboardUrl(baseUrl, siteName) {
|
|
@@ -208,4 +208,4 @@ export {
|
|
|
208
208
|
readDigestState,
|
|
209
209
|
writeDigestState
|
|
210
210
|
};
|
|
211
|
-
//# sourceMappingURL=chunk-
|
|
211
|
+
//# sourceMappingURL=chunk-IMMB64VH.js.map
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from "./chunk-GASBX52O.js";
|
|
7
7
|
import {
|
|
8
8
|
makeGitHub
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-YEGHUODU.js";
|
|
10
10
|
import {
|
|
11
11
|
siteLabel
|
|
12
12
|
} from "./chunk-XXTZBPUY.js";
|
|
@@ -31,6 +31,10 @@ var REQUIRED_CHECK = "ci / ci";
|
|
|
31
31
|
function resultOf(site, status, notes, commits = []) {
|
|
32
32
|
return { recipe: "self-updating", site: siteLabel(site), status, commits, notes };
|
|
33
33
|
}
|
|
34
|
+
function sameConfigContents(current, canonical) {
|
|
35
|
+
const norm = (s) => s.replace(/\r\n/g, "\n").replace(/\s+$/, "");
|
|
36
|
+
return norm(current) === norm(canonical);
|
|
37
|
+
}
|
|
34
38
|
async function resolveRepo(site) {
|
|
35
39
|
if (site.gitRepo) {
|
|
36
40
|
if (!isOwnerRepo(site.gitRepo)) {
|
|
@@ -56,7 +60,6 @@ async function resolveRepo(site) {
|
|
|
56
60
|
}
|
|
57
61
|
async function selfUpdating(site, deps = {}) {
|
|
58
62
|
const templates = templatesByName([...SELF_UPDATING_CONFIGS]);
|
|
59
|
-
const paths = templates.map((t) => t.path);
|
|
60
63
|
let repo;
|
|
61
64
|
try {
|
|
62
65
|
repo = await resolveRepo(site);
|
|
@@ -81,11 +84,15 @@ async function selfUpdating(site, deps = {}) {
|
|
|
81
84
|
let original = null;
|
|
82
85
|
let maintBranch = null;
|
|
83
86
|
try {
|
|
84
|
-
const
|
|
85
|
-
|
|
87
|
+
const drifted = [];
|
|
88
|
+
for (const t of templates) {
|
|
89
|
+
const current = await github.fileContentsOnBranch(repo, base, t.path);
|
|
90
|
+
if (current === null || !sameConfigContents(current, t.contents)) drifted.push(t.path);
|
|
91
|
+
}
|
|
92
|
+
if (drifted.length > 0) {
|
|
86
93
|
const existingPR = await github.findOpenSelfUpdatingPR(repo);
|
|
87
94
|
if (existingPR) {
|
|
88
|
-
actions.push(`
|
|
95
|
+
actions.push(`self-updating PR already open: ${existingPR}`);
|
|
89
96
|
} else {
|
|
90
97
|
if (!await isWorkingTreeClean(site.path)) {
|
|
91
98
|
return resultOf(site, "failed", "working tree not clean \u2014 commit or stash first");
|
|
@@ -152,4 +159,4 @@ async function selfUpdating(site, deps = {}) {
|
|
|
152
159
|
export {
|
|
153
160
|
selfUpdating
|
|
154
161
|
};
|
|
155
|
-
//# sourceMappingURL=chunk-
|
|
162
|
+
//# sourceMappingURL=chunk-IRRZG66U.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/recipes/self-updating/index.ts"],"sourcesContent":["import { mkdir, writeFile } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport type { RecipeResult, Site } from \"../../types.js\";\nimport { templatesByName } from \"../sync-configs/templates.js\";\nimport {\n getRemoteUrl,\n parseOwnerRepo,\n isOwnerRepo,\n push as gitPush,\n branchName,\n checkoutBranch,\n createBranch,\n currentBranch,\n commit as gitCommit,\n isWorkingTreeClean,\n} from \"../../util/git.js\";\nimport { siteLabel } from \"../../util/site.js\";\nimport { readGitHubConfig } from \"../../github/config.js\";\nimport { makeGitHub, type GitHub } from \"../../github/gh.js\";\n\nconst SELF_UPDATING_CONFIGS = [\"ci\", \"renovate-action\", \"renovate-config\"] as const;\n\n// Reusable-workflow jobs report their check as \"<caller-job> / <reusable-job>\".\n// The thin `ci` caller (job `ci`) calls reddoorla/.github's reusable workflow (job `ci`),\n// so the required context is \"ci / ci\", NOT \"ci\". (Provisional — verified empirically on the\n// starter during M7.1 rollout; correct here if the live checks API reports a different string.)\nconst REQUIRED_CHECK = \"ci / ci\";\n\nexport type SelfUpdatingDeps = {\n github?: GitHub;\n pushBranch?: (cwd: string, branch: string) => Promise<void>;\n renovateToken?: string;\n};\n\nfunction resultOf(\n site: Site,\n status: RecipeResult[\"status\"],\n notes: string,\n commits: string[] = [],\n): RecipeResult {\n return { recipe: \"self-updating\", site: siteLabel(site), status, commits, notes };\n}\n\n/** True when a file's current content matches the canonical template for drift\n * purposes. Normalizes line endings + trailing whitespace so a stray trailing\n * newline (or CRLF) can't read as drift and open a needless PR every nightly run;\n * any real content change still differs. */\nfunction sameConfigContents(current: string, canonical: string): boolean {\n const norm = (s: string) => s.replace(/\\r\\n/g, \"\\n\").replace(/\\s+$/, \"\");\n return norm(current) === norm(canonical);\n}\n\n/**\n * Resolve the `owner/repo` this recipe will mutate. An explicit `site.gitRepo`\n * (from Airtable) wins; otherwise derive it from the checkout's `origin`.\n *\n * Returns `null` when there is no repo to act on (no `gitRepo`, no origin) — a\n * benign \"nothing wired\" state. THROWS when a repo value IS present but doesn't\n * match the strict `owner/repo` shape: this recipe writes the broad GitHub\n * token as a repo secret (plus branch protection / auto-merge) at this\n * identity, so an attacker/typo-controlled value must be rejected here, before\n * the first `gh` call, rather than passed through to `gh`.\n */\nasync function resolveRepo(site: Site): Promise<string | null> {\n if (site.gitRepo) {\n if (!isOwnerRepo(site.gitRepo)) {\n throw new Error(\n `refusing to act on malformed repo identity: expected \"owner/repo\", got ${JSON.stringify(site.gitRepo)}`,\n );\n }\n return site.gitRepo;\n }\n let fromOrigin: string | null;\n try {\n fromOrigin = parseOwnerRepo(await getRemoteUrl(site.path));\n } catch {\n return null;\n }\n if (fromOrigin === null) return null;\n if (!isOwnerRepo(fromOrigin)) {\n throw new Error(\n `refusing to act on malformed repo identity from origin: ${JSON.stringify(fromOrigin)}`,\n );\n }\n return fromOrigin;\n}\n\nexport async function selfUpdating(site: Site, deps: SelfUpdatingDeps = {}): Promise<RecipeResult> {\n const templates = templatesByName([...SELF_UPDATING_CONFIGS]);\n\n let repo: string | null;\n try {\n repo = await resolveRepo(site);\n } catch (err) {\n // A malformed repo identity must abort before any `gh` write — surface it\n // as a recipe failure rather than letting the token reach an unintended repo.\n return resultOf(site, \"failed\", err instanceof Error ? err.message : String(err));\n }\n if (!repo) {\n return resultOf(\n site,\n \"failed\",\n \"no Git repo (set Airtable 'Git repo' or add an origin remote)\",\n );\n }\n\n const cfg = readGitHubConfig();\n const renovateToken = deps.renovateToken ?? cfg?.renovateToken;\n if (!deps.github && !cfg) return resultOf(site, \"failed\", \"GITHUB_TOKEN not set\");\n if (!renovateToken) return resultOf(site, \"failed\", \"no RENOVATE_TOKEN available\");\n const github = deps.github ?? makeGitHub({ token: cfg!.token });\n\n const base = await github.defaultBranch(repo).catch(() => \"main\");\n const actions: string[] = [];\n const commits: string[] = [];\n // Hoisted so the `finally` can restore the operator's branch even when a\n // failure (push/PR error) aborts AFTER we created + checked out the maint\n // branch. `maintBranch` stays null until createBranch succeeds, so the\n // restore is a no-op on every path that never left the operator's branch.\n let original: string | null = null;\n let maintBranch: string | null = null;\n\n try {\n // A. CI/Renovate config on the default branch — compare CONTENT, not just\n // existence, so a present-but-STALE config (an old pinned reusable-workflow SHA,\n // a drifted Renovate schedule window) is corrected rather than left silently\n // forever. The prior existence-only gate reported \"already self-updating\" for any\n // repo that merely HAD the three files, however out of date — the very drift this\n // recipe exists to repair (e.g. the Renovate schedule-window regression).\n const drifted: string[] = [];\n for (const t of templates) {\n const current = await github.fileContentsOnBranch(repo, base, t.path);\n if (current === null || !sameConfigContents(current, t.contents)) drifted.push(t.path);\n }\n if (drifted.length > 0) {\n const existingPR = await github.findOpenSelfUpdatingPR(repo);\n if (existingPR) {\n actions.push(`self-updating PR already open: ${existingPR}`);\n } else {\n if (!(await isWorkingTreeClean(site.path))) {\n return resultOf(site, \"failed\", \"working tree not clean — commit or stash first\");\n }\n // Capture the operator's branch BEFORE creating the maint branch so the\n // `finally` can return them to it (#2). Best-effort capture: if it fails\n // we just skip the restore rather than guess.\n try {\n original = await currentBranch(site.path);\n } catch {\n original = null;\n }\n maintBranch = branchName(\"self-updating\");\n await createBranch(site.path, maintBranch);\n for (const t of templates) {\n const dest = join(site.path, t.path);\n await mkdir(dirname(dest), { recursive: true });\n await writeFile(dest, t.contents, \"utf-8\");\n }\n const sha = await gitCommit(\n site.path,\n \"ci: enable self-updating (CI + Renovate auto-merge)\",\n );\n if (sha) commits.push(sha);\n await (deps.pushBranch ?? gitPush)(site.path, maintBranch);\n const pr = await github.openPullRequest(repo, {\n head: maintBranch,\n base,\n title: \"Enable self-updating (CI + Renovate)\",\n body: \"Adds the unified CI gate, nightly Renovate, and auto-merge for patch/minor updates.\",\n });\n actions.push(`opened PR ${pr.url}`);\n // (Branch restore happens in the `finally` below — on success AND on a\n // failure that aborts after createBranch.)\n }\n }\n\n // B. Repo settings — check-then-ensure, each independent (self-healing).\n if (!(await github.autoMergeEnabled(repo))) {\n await github.enableRepoAutoMerge(repo);\n actions.push(\"enabled auto-merge\");\n }\n const existingContexts = await github.branchProtectionContexts(repo, base);\n if (!existingContexts.includes(REQUIRED_CHECK)) {\n // protectBranch issues a full PUT that REPLACES required-status-check contexts.\n // Send the UNION of the branch's existing required contexts + our REQUIRED_CHECK\n // so we ADD the CI gate without silently dropping any other checks the repo\n // already requires. (When the branch has no protection yet, existingContexts is\n // [] and this is just [REQUIRED_CHECK] — the original behavior.) Dedupe defends\n // against REQUIRED_CHECK already appearing among the existing contexts.\n const contexts = [...new Set([...existingContexts, REQUIRED_CHECK])];\n await github.protectBranch(repo, base, contexts);\n actions.push(`required \"${REQUIRED_CHECK}\" check on ${base}`);\n }\n if (!(await github.secretExists(repo, \"RENOVATE_TOKEN\"))) {\n await github.setRepoSecret(repo, \"RENOVATE_TOKEN\", renovateToken);\n actions.push(\"set RENOVATE_TOKEN secret\");\n }\n } catch (err) {\n const done = actions.length ? ` (completed: ${actions.join(\"; \")})` : \"\";\n const message = err instanceof Error ? err.message : String(err);\n return resultOf(site, \"failed\", `${message}${done}`, commits);\n } finally {\n // Restore the operator's branch whenever we created + checked out a maint\n // branch — on SUCCESS and, critically, on FAILURE. Without this, a push/PR\n // error after createBranch strands the checkout on the maint branch with an\n // unpushed commit, and the retry then fails at createBranch (\"branch already\n // exists\"). Best-effort: a restore failure must not mask the real outcome.\n if (maintBranch !== null && original !== null && original !== maintBranch) {\n try {\n await checkoutBranch(site.path, original);\n } catch (err) {\n console.warn(\n `warning: could not restore branch ${original} after self-updating: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n }\n }\n }\n\n return actions.length\n ? resultOf(site, \"applied\", actions.join(\"; \"), commits)\n : resultOf(site, \"noop\", \"already self-updating\", commits);\n}\n"],"mappings":"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","names":[]}
|
|
@@ -236,7 +236,15 @@ function dollarPropsClass(source) {
|
|
|
236
236
|
let updated = source.replace(PROPS_DESTRUCTURE, (full, body, typeAnno, typeBody) => {
|
|
237
237
|
if (/\bclass\s*:/.test(body)) return full;
|
|
238
238
|
const cleanBody = body.trim().replace(/,\s*$/, "").trim();
|
|
239
|
-
const
|
|
239
|
+
const restMatch = cleanBody.match(/,?\s*(\.\.\.[A-Za-z_$][\w$]*)\s*$/);
|
|
240
|
+
let newBody;
|
|
241
|
+
if (restMatch) {
|
|
242
|
+
const beforeRest = cleanBody.slice(0, restMatch.index ?? 0).replace(/,\s*$/, "").trim();
|
|
243
|
+
const rest = restMatch[1];
|
|
244
|
+
newBody = beforeRest ? `${beforeRest}, class: ${IDENT} = "", ${rest}` : `class: ${IDENT} = "", ${rest}`;
|
|
245
|
+
} else {
|
|
246
|
+
newBody = cleanBody ? `${cleanBody}, class: ${IDENT} = ""` : `class: ${IDENT} = ""`;
|
|
247
|
+
}
|
|
240
248
|
if (typeAnno) {
|
|
241
249
|
const cleanType = (typeBody ?? "").trim().replace(/;\s*$/, "").trim();
|
|
242
250
|
const newType = cleanType ? `${cleanType}; class?: string` : `class?: string`;
|
|
@@ -365,4 +373,4 @@ export {
|
|
|
365
373
|
planGotchaCodemods,
|
|
366
374
|
applyGotchaCodemods
|
|
367
375
|
};
|
|
368
|
-
//# sourceMappingURL=chunk-
|
|
376
|
+
//# sourceMappingURL=chunk-J7SZQUCW.js.map
|