@reddoorla/maintenance 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/bin.js CHANGED
@@ -57,6 +57,79 @@ var init_credentials = __esm({
57
57
  }
58
58
  });
59
59
 
60
+ // src/audits/util/spawn.ts
61
+ import { spawn } from "child_process";
62
+ function makeSpawn(internals = {}) {
63
+ const spawnImpl = internals.spawnImpl ?? spawn;
64
+ const killImpl = internals.killImpl ?? ((pid, sig) => process.kill(pid, sig));
65
+ const killGraceMs = internals.killGraceMs ?? 5e3;
66
+ const maxOutputBytes = internals.maxOutputBytes ?? 10 * 1024 * 1024;
67
+ return (cmd, args, opts = {}) => new Promise((resolve12, reject) => {
68
+ const streaming = opts.streaming === true;
69
+ const child = spawnImpl(cmd, [...args], {
70
+ cwd: opts.cwd,
71
+ env: opts.env ?? process.env,
72
+ stdio: streaming ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "pipe"],
73
+ // Detach ONLY when a timeout can fire: the child then leads its own
74
+ // process group, so the timeout can kill the WHOLE tree (vite, and
75
+ // Chromium under lhci/playwright) via process.kill(-pid), not just the
76
+ // npx/pnpm wrapper. Without it, killing the wrapper orphaned the
77
+ // grandchildren — a zombie vite squatting its port, Chrome left running.
78
+ // We do NOT detach timeout-less streaming calls (pnpm install/up):
79
+ // detaching gains nothing there (no timeout → no group-kill) and would
80
+ // break terminal Ctrl-C, which only reaches the foreground group — i.e.
81
+ // it would re-orphan the very children this guards. We never unref() the
82
+ // child since we still await it.
83
+ detached: opts.timeoutMs !== void 0
84
+ });
85
+ const cap = (acc, chunk) => {
86
+ if (acc.length >= maxOutputBytes) return acc;
87
+ const next = acc + chunk;
88
+ return next.length > maxOutputBytes ? next.slice(0, maxOutputBytes) + TRUNCATION_MARKER : next;
89
+ };
90
+ let stdout = "";
91
+ let stderr = "";
92
+ if (!streaming) {
93
+ child.stdout?.on("data", (chunk) => stdout = cap(stdout, String(chunk)));
94
+ child.stderr?.on("data", (chunk) => stderr = cap(stderr, String(chunk)));
95
+ }
96
+ const killGroup = (sig) => {
97
+ if (child.pid === void 0) return;
98
+ try {
99
+ killImpl(-child.pid, sig);
100
+ } catch {
101
+ }
102
+ };
103
+ let killTimer;
104
+ const timer = opts.timeoutMs ? setTimeout(() => {
105
+ killGroup("SIGTERM");
106
+ killTimer = setTimeout(() => killGroup("SIGKILL"), killGraceMs);
107
+ killTimer.unref();
108
+ reject(new Error(`spawn timeout after ${opts.timeoutMs}ms: ${cmd}`));
109
+ }, opts.timeoutMs) : void 0;
110
+ const clearTimers = () => {
111
+ if (timer) clearTimeout(timer);
112
+ if (killTimer) clearTimeout(killTimer);
113
+ };
114
+ child.on("error", (err) => {
115
+ clearTimers();
116
+ reject(err);
117
+ });
118
+ child.on("close", (code) => {
119
+ clearTimers();
120
+ resolve12({ code: code ?? -1, stdout, stderr });
121
+ });
122
+ });
123
+ }
124
+ var TRUNCATION_MARKER, defaultSpawn;
125
+ var init_spawn = __esm({
126
+ "src/audits/util/spawn.ts"() {
127
+ "use strict";
128
+ TRUNCATION_MARKER = "\n\u2026[output truncated]";
129
+ defaultSpawn = makeSpawn();
130
+ }
131
+ });
132
+
60
133
  // src/reports/airtable/client.ts
61
134
  var client_exports = {};
62
135
  __export(client_exports, {
@@ -99,12 +172,19 @@ __export(websites_exports, {
99
172
  siteSlug: () => siteSlug,
100
173
  updateA11yCounts: () => updateA11yCounts,
101
174
  updateDepsCounts: () => updateDepsCounts,
175
+ updateGitHubSignals: () => updateGitHubSignals,
176
+ updateLaunched: () => updateLaunched,
102
177
  updateScores: () => updateScores,
103
178
  updateSecurityCounts: () => updateSecurityCounts
104
179
  });
105
180
  function siteSlug(name) {
106
181
  return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
107
182
  }
183
+ function trimToNull(raw) {
184
+ if (typeof raw !== "string") return null;
185
+ const trimmed = raw.trim();
186
+ return trimmed.length > 0 ? trimmed : null;
187
+ }
108
188
  function mapRow(rec) {
109
189
  const f = rec.fields;
110
190
  const attachments = f["Header image"] ?? [];
@@ -144,7 +224,15 @@ function mapRow(rec) {
144
224
  if (typeof raw !== "string") return null;
145
225
  const trimmed = raw.trim();
146
226
  return trimmed.length > 0 ? trimmed : null;
147
- })()
227
+ })(),
228
+ copyIntro: trimToNull(f["Copy \u2014 Intro"]),
229
+ copyContact: trimToNull(f["Copy \u2014 Contact"]),
230
+ copyFooter: trimToNull(f["Copy \u2014 Footer"]),
231
+ launchedAt: f["Launched at"] ?? null,
232
+ renovateFailingCis: f["Renovate Failing CIs"] ?? null,
233
+ defaultBranchCi: f["Default Branch CI"] ?? null,
234
+ lastCommitAt: f["Last Commit At"] ?? null,
235
+ githubSignalsAt: f["GitHub Signals At"] ?? null
148
236
  };
149
237
  }
150
238
  async function listWebsites(base) {
@@ -202,6 +290,21 @@ async function updateSecurityCounts(base, recordId, counts) {
202
290
  };
203
291
  await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
204
292
  }
293
+ async function updateGitHubSignals(base, recordId, signals) {
294
+ const fields = {
295
+ "Renovate Failing CIs": signals.renovateFailingCis,
296
+ "Default Branch CI": signals.ciState,
297
+ "GitHub Signals At": signals.sweptAt
298
+ };
299
+ if (signals.lastCommitAt !== null) {
300
+ fields["Last Commit At"] = signals.lastCommitAt;
301
+ }
302
+ await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
303
+ }
304
+ async function updateLaunched(base, recordId, at) {
305
+ const fields = { Status: "maintenance", "Launched at": at };
306
+ await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
307
+ }
205
308
  var WEBSITES_TABLE;
206
309
  var init_websites = __esm({
207
310
  "src/reports/airtable/websites.ts"() {
@@ -463,6 +566,186 @@ var init_write_audits_to_airtable = __esm({
463
566
  }
464
567
  });
465
568
 
569
+ // src/github/gh.ts
570
+ function mapRollupState(state) {
571
+ switch (state) {
572
+ case "SUCCESS":
573
+ return "passing";
574
+ case "FAILURE":
575
+ case "ERROR":
576
+ return "failing";
577
+ case "PENDING":
578
+ case "EXPECTED":
579
+ return "pending";
580
+ default:
581
+ return "none";
582
+ }
583
+ }
584
+ function makeGitHub(deps) {
585
+ const spawn2 = deps.spawn ?? defaultSpawn;
586
+ const env = { ...process.env, GH_TOKEN: deps.token };
587
+ async function gh(args) {
588
+ const r = await spawn2("gh", args, { env, timeoutMs: 6e4 });
589
+ if (r.code !== 0) throw new Error(`gh ${args[0]} failed (code ${r.code}): ${r.stderr.trim()}`);
590
+ return r.stdout;
591
+ }
592
+ return {
593
+ async openPullRequest(repo, pr) {
594
+ const out = await gh([
595
+ "pr",
596
+ "create",
597
+ "--repo",
598
+ repo,
599
+ "--head",
600
+ pr.head,
601
+ "--base",
602
+ pr.base,
603
+ "--title",
604
+ pr.title,
605
+ "--body",
606
+ pr.body
607
+ ]);
608
+ return { url: out.trim() };
609
+ },
610
+ async enableRepoAutoMerge(repo) {
611
+ await gh(["api", "-X", "PATCH", `repos/${repo}`, "-F", "allow_auto_merge=true"]);
612
+ },
613
+ async protectBranch(repo, branch, requiredChecks) {
614
+ const args = [
615
+ "api",
616
+ "-X",
617
+ "PUT",
618
+ `repos/${repo}/branches/${branch}/protection`,
619
+ "-H",
620
+ "Accept: application/vnd.github+json",
621
+ "-F",
622
+ "required_status_checks[strict]=true",
623
+ ...requiredChecks.flatMap((c) => ["-f", `required_status_checks[contexts][]=${c}`]),
624
+ "-F",
625
+ "enforce_admins=true",
626
+ "-F",
627
+ "required_pull_request_reviews=null",
628
+ "-F",
629
+ "restrictions=null"
630
+ ];
631
+ await gh(args);
632
+ },
633
+ async setRepoSecret(repo, name, value) {
634
+ await gh(["secret", "set", name, "--repo", repo, "--body", value]);
635
+ },
636
+ async repoExists(repo) {
637
+ const r = await spawn2("gh", ["api", `repos/${repo}`], { env, timeoutMs: 6e4 });
638
+ return r.code === 0;
639
+ },
640
+ async defaultBranch(repo) {
641
+ const out = await gh(["api", `repos/${repo}`, "--jq", ".default_branch"]);
642
+ return out.trim();
643
+ },
644
+ // filesOnBranch and branchProtectionContexts call `spawn` directly (not the
645
+ // throwing `gh()` helper) because a 404 is an expected, meaningful answer —
646
+ // "file/protection absent" — not an error. The remaining readers use `gh()`
647
+ // since a non-200 there is a genuine failure (e.g. missing token scope).
648
+ async filesOnBranch(repo, branch, paths) {
649
+ const present = [];
650
+ for (const p of paths) {
651
+ const r = await spawn2("gh", [`api`, `repos/${repo}/contents/${p}?ref=${branch}`], {
652
+ env,
653
+ timeoutMs: 6e4
654
+ });
655
+ if (r.code === 0) present.push(p);
656
+ }
657
+ return present;
658
+ },
659
+ async branchProtectionContexts(repo, branch) {
660
+ const r = await spawn2(
661
+ "gh",
662
+ [
663
+ "api",
664
+ `repos/${repo}/branches/${branch}/protection`,
665
+ "--jq",
666
+ ".required_status_checks.contexts[]?"
667
+ ],
668
+ { env, timeoutMs: 6e4 }
669
+ );
670
+ if (r.code !== 0) return [];
671
+ return r.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
672
+ },
673
+ async secretExists(repo, name) {
674
+ const out = await gh(["api", `repos/${repo}/actions/secrets`, "--jq", ".secrets[].name"]);
675
+ return out.split("\n").map((l) => l.trim()).includes(name);
676
+ },
677
+ async autoMergeEnabled(repo) {
678
+ const out = await gh(["api", `repos/${repo}`, "--jq", ".allow_auto_merge"]);
679
+ return out.trim() === "true";
680
+ },
681
+ async findOpenSelfUpdatingPR(repo) {
682
+ const out = await gh([
683
+ "api",
684
+ `repos/${repo}/pulls?state=open`,
685
+ "--jq",
686
+ '.[] | select(.head.ref | startswith("maint/self-updating-")) | .html_url'
687
+ ]);
688
+ const first = out.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
689
+ return first ?? null;
690
+ },
691
+ async openPullRequests(repo) {
692
+ const [owner, name, ...rest] = repo.split("/");
693
+ if (!owner || !name || rest.length > 0) {
694
+ throw new Error(`openPullRequests: expected "owner/repo", got "${repo}"`);
695
+ }
696
+ const query = "query($owner:String!,$name:String!){repository(owner:$owner,name:$name){pullRequests(states:OPEN,first:100,orderBy:{field:CREATED_AT,direction:DESC}){nodes{number title url headRefName commits(last:1){nodes{commit{statusCheckRollup{state}}}}}}}}";
697
+ const out = await gh([
698
+ "api",
699
+ "graphql",
700
+ "-f",
701
+ `query=${query}`,
702
+ "-F",
703
+ `owner=${owner}`,
704
+ "-F",
705
+ `name=${name}`
706
+ ]);
707
+ const parsed = JSON.parse(out);
708
+ const nodes = parsed.data?.repository?.pullRequests?.nodes ?? [];
709
+ return nodes.map((n) => ({
710
+ number: n.number,
711
+ title: n.title,
712
+ url: n.url,
713
+ headRef: n.headRefName,
714
+ ciState: mapRollupState(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state)
715
+ }));
716
+ },
717
+ async defaultBranchStatus(repo) {
718
+ const [owner, name, ...rest] = repo.split("/");
719
+ if (!owner || !name || rest.length > 0) {
720
+ throw new Error(`defaultBranchStatus: expected "owner/repo", got "${repo}"`);
721
+ }
722
+ const query = "query($owner:String!,$name:String!){repository(owner:$owner,name:$name){defaultBranchRef{target{... on Commit{committedDate statusCheckRollup{state}}}}}}";
723
+ const out = await gh([
724
+ "api",
725
+ "graphql",
726
+ "-f",
727
+ `query=${query}`,
728
+ "-F",
729
+ `owner=${owner}`,
730
+ "-F",
731
+ `name=${name}`
732
+ ]);
733
+ const parsed = JSON.parse(out);
734
+ const target = parsed.data?.repository?.defaultBranchRef?.target;
735
+ return {
736
+ ciState: mapRollupState(target?.statusCheckRollup?.state),
737
+ lastCommitAt: target?.committedDate ?? null
738
+ };
739
+ }
740
+ };
741
+ }
742
+ var init_gh = __esm({
743
+ "src/github/gh.ts"() {
744
+ "use strict";
745
+ init_spawn();
746
+ }
747
+ });
748
+
466
749
  // src/reports/airtable/reports.ts
467
750
  function mapRow2(rec) {
468
751
  const f = rec.fields;
@@ -507,6 +790,9 @@ function lighthouseFromFields(f) {
507
790
  function ymd(d) {
508
791
  return d.toISOString().slice(0, 10);
509
792
  }
793
+ function escapeFormulaString(s) {
794
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
795
+ }
510
796
  async function createDraft(base, input) {
511
797
  const fields = {
512
798
  "Report ID": input.reportId,
@@ -568,6 +854,17 @@ async function stampSent(base, recordId, sentAt, messageId) {
568
854
  }
569
855
  ]);
570
856
  }
857
+ async function findReportByPeriod(base, siteId, reportType, period) {
858
+ const safeType = escapeFormulaString(reportType);
859
+ const safePeriod = escapeFormulaString(period);
860
+ const formula = `AND({Report type} = "${safeType}", {Period} = "${safePeriod}")`;
861
+ const rows = [];
862
+ await base(REPORTS_TABLE).select({ filterByFormula: formula, pageSize: 100 }).eachPage((records, fetchNextPage) => {
863
+ for (const rec of records) rows.push(mapRow2({ id: rec.id, fields: rec.fields }));
864
+ fetchNextPage();
865
+ });
866
+ return rows.find((r) => r.siteId === siteId) ?? null;
867
+ }
571
868
  var REPORTS_TABLE;
572
869
  var init_reports = __esm({
573
870
  "src/reports/airtable/reports.ts"() {
@@ -576,6 +873,67 @@ var init_reports = __esm({
576
873
  }
577
874
  });
578
875
 
876
+ // src/reports/copy.ts
877
+ function override(v) {
878
+ if (typeof v !== "string") return null;
879
+ const t = v.trim();
880
+ return t.length > 0 ? t : null;
881
+ }
882
+ function splitLines(s) {
883
+ return s.split(/\r?\n/).filter((l) => l.trim().length > 0);
884
+ }
885
+ function resolveCopy(site) {
886
+ const intro = override(site.copyIntro);
887
+ const contact = override(site.copyContact);
888
+ const footer = override(site.copyFooter);
889
+ const footerLines = footer ? splitLines(footer) : null;
890
+ return {
891
+ ...DEFAULT_COPY,
892
+ maintenanceIntro: intro ?? DEFAULT_COPY.maintenanceIntro,
893
+ contact: contact ? splitLines(contact) : DEFAULT_COPY.contact,
894
+ footerOrg: footerLines?.[0] ?? DEFAULT_COPY.footerOrg,
895
+ footerAddress: footerLines ? footerLines.slice(1) : DEFAULT_COPY.footerAddress
896
+ };
897
+ }
898
+ var DEFAULT_COPY;
899
+ var init_copy = __esm({
900
+ "src/reports/copy.ts"() {
901
+ "use strict";
902
+ DEFAULT_COPY = {
903
+ maintenanceIntro: "Includes checking the hosting, DNS, Content Management System (CMS, if applicable), search indexing and security of the site for major flaws and updating as necessary.",
904
+ maintenanceChecks: [
905
+ "Reviewed Logs",
906
+ "CMS Checked",
907
+ "DNS Checked",
908
+ "Google Indexed",
909
+ "Reviewed Certificate",
910
+ "Security Updates"
911
+ ],
912
+ testingIntro: "Testing includes checks similar to those at launch: testing on common browsers and operating systems, at different screen sizes, and checking every function, and updating all packages for performance rather than just those needed for security.",
913
+ testingChecklist: [
914
+ "Desktop Browsers",
915
+ "Mobile Browsers",
916
+ "Package Updates",
917
+ "Bottlenecks",
918
+ "Form Functionality",
919
+ "Animation Functionality"
920
+ ],
921
+ notesHeader: "NOTES",
922
+ seoCta: "Contact us if you are interested in more in-depth data or have questions about SEO.",
923
+ contact: ["Just hit reply.", "We're here to help in any way we can."],
924
+ footerOrg: "Reddoor Creative, LLC",
925
+ footerAddress: ["29027 Dapper Dan", "Fair Oaks Ranch, TX 78015"],
926
+ launchHeading: "LAUNCHED",
927
+ launchBody: "Your site is live. We've set it up on the Reddoor stack with hosting, security, and automatic maintenance so it stays fast and healthy. Here's what's in place:",
928
+ launchSetupItems: [
929
+ "Hosting, DNS, and SSL configured",
930
+ "Continuous integration + automatic dependency updates",
931
+ "Analytics and uptime monitoring"
932
+ ]
933
+ };
934
+ }
935
+ });
936
+
579
937
  // src/reports/maintenance-email/assets/index.ts
580
938
  import { readFile as readFile13 } from "fs/promises";
581
939
  import { existsSync as existsSync3 } from "fs";
@@ -665,16 +1023,9 @@ function analyticsTrendLine(cur, prev) {
665
1023
  if (pct < 0) return trendText(TREND_NEUTRAL, `\u25BC ${Math.abs(pct)}% vs last period ${range}`);
666
1024
  return trendText(TREND_NEUTRAL, `No change vs last period (${fmtUsers(prev)})`);
667
1025
  }
668
- function maintenanceChecksSection(searchPosition) {
669
- const googleLabel = searchPosition !== void 0 ? `Page 1 Google Result (#${searchPosition})` : "Google Indexed";
670
- const rows = [
671
- "Reviewed Logs",
672
- "CMS Checked",
673
- "DNS Checked",
674
- googleLabel,
675
- "Reviewed Certificate",
676
- "Security Updates"
677
- ];
1026
+ function maintenanceChecksSection(copy, searchPosition) {
1027
+ const googleLabel = searchPosition !== void 0 ? `Page 1 Google Result (#${searchPosition})` : copy.maintenanceChecks[3];
1028
+ const rows = copy.maintenanceChecks.map((label, i) => i === 3 ? googleLabel : label);
678
1029
  return rows.map(
679
1030
  (label, i) => `
680
1031
  <mj-section background-color="white" padding="0px"${i === rows.length - 1 ? ' padding-bottom="36px"' : ""}>
@@ -689,15 +1040,8 @@ function maintenanceChecksSection(searchPosition) {
689
1040
  </mj-section>`
690
1041
  ).join("");
691
1042
  }
692
- function testingChecklistSection() {
693
- const rows = [
694
- "Desktop Browsers",
695
- "Mobile Browsers",
696
- "Package Updates",
697
- "Bottlenecks",
698
- "Form Functionality",
699
- "Animation Functionality"
700
- ];
1043
+ function testingChecklistSection(copy) {
1044
+ const rows = copy.testingChecklist;
701
1045
  return rows.map(
702
1046
  (label, i) => `
703
1047
  <mj-section background-color="#F4F4F4" padding="0px"${i === rows.length - 1 ? ' padding-bottom="60px"' : ""}>
@@ -725,20 +1069,20 @@ function maintenanceTestingPlaceholder(lastTested) {
725
1069
  </mj-column>
726
1070
  </mj-section>`;
727
1071
  }
728
- function testingIntroSection() {
1072
+ function testingIntroSection(copy) {
729
1073
  return `
730
1074
  <mj-section background-color="#F4F4F4">
731
1075
  <mj-column>
732
1076
  <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">TESTING</mj-text>
733
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">Testing includes checks similar to those at launch: testing on common browsers and operating systems, at different screen sizes, and checking every function, and updating all packages for performance rather than just those needed for security.</mj-text>
1077
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml(copy.testingIntro)}</mj-text>
734
1078
  </mj-column>
735
1079
  </mj-section>`;
736
1080
  }
737
- function commentarySection(text) {
1081
+ function commentarySection(text, copy) {
738
1082
  return `
739
1083
  <mj-section background-color="white">
740
1084
  <mj-column>
741
- <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">NOTES</mj-text>
1085
+ <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">${escapeXml(copy.notesHeader)}</mj-text>
742
1086
  <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml(text).replace(/\n/g, "<br/>")}</mj-text>
743
1087
  </mj-column>
744
1088
  </mj-section>`;
@@ -760,6 +1104,7 @@ function headerStyleBlock(data) {
760
1104
  return `<mj-style>.rd-header img { height: auto !important; aspect-ratio: ${data.headerWidth} / ${data.headerHeight}; }</mj-style>`;
761
1105
  }
762
1106
  function buildMjml(data) {
1107
+ const copy = data.copy ?? DEFAULT_COPY;
763
1108
  const isTesting = data.reportType === "Testing";
764
1109
  const previewText = `Checked up on ${escapeXml(data.siteName)}`;
765
1110
  return `<mjml>
@@ -783,10 +1128,10 @@ function buildMjml(data) {
783
1128
  <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">COMPLETED ON</mj-text>
784
1129
  <mj-text color="#C00" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
785
1130
  <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">MAINTENANCE CHECKS</mj-text>
786
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">Includes checking the hosting, DNS, Content Management System (CMS, if applicable), search indexing and security of the site for major flaws and updating as necessary.</mj-text>
1131
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml(copy.maintenanceIntro)}</mj-text>
787
1132
  </mj-column>
788
1133
  </mj-section>
789
- ${maintenanceChecksSection(data.searchPosition)}
1134
+ ${maintenanceChecksSection(copy, data.searchPosition)}
790
1135
  <mj-section background-color="#F4F4F4">
791
1136
  <mj-column>
792
1137
  <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">LIGHTHOUSE SCORES*</mj-text>
@@ -813,22 +1158,23 @@ function buildMjml(data) {
813
1158
  <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">ANALYTICS</mj-text>
814
1159
  <mj-text color="#C00" font-size="44px" font-weight="400">${data.gaUsersCurrent !== void 0 ? fmtUsers(data.gaUsersCurrent) : "\u2014"} Users</mj-text>
815
1160
  ${analyticsTrendLine(data.gaUsersCurrent, data.gaUsersPrevious)}
816
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" padding-bottom="36px" line-height="20px">Contact us if you are interested in more in-depth data or have questions about SEO.</mj-text>
1161
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" padding-bottom="36px" line-height="20px">${escapeXml(copy.seoCta)}</mj-text>
817
1162
  </mj-column>
818
1163
  </mj-section>
819
- ${isTesting ? testingIntroSection() + testingChecklistSection() : maintenanceTestingPlaceholder(data.lastTestedDate)}
820
- ${data.commentary ? commentarySection(data.commentary) : ""}
1164
+ ${isTesting ? testingIntroSection(copy) + testingChecklistSection(copy) : maintenanceTestingPlaceholder(data.lastTestedDate)}
1165
+ ${data.commentary ? commentarySection(data.commentary, copy) : ""}
821
1166
  <mj-section background-color="white">
822
1167
  <mj-column padding-top="36px">
823
1168
  <mj-text color="#C00" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="36px" line-height="36px">Any questions, concerns or requests?</mj-text>
824
- <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">Just hit reply.</mj-text>
825
- <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" padding-top="0px" line-height="30px" padding-bottom="36px">We're here to help in any way we can.</mj-text>
1169
+ ${copy.contact.map(
1170
+ (line, i) => i === copy.contact.length - 1 ? `<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" padding-top="0px" line-height="30px" padding-bottom="36px">${escapeXml(line)}</mj-text>` : `<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml(line)}</mj-text>`
1171
+ ).join("\n ")}
826
1172
  <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
827
1173
  <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" line-height="20px" font-style="italic">Copyright ${(/* @__PURE__ */ new Date()).getUTCFullYear()} Reddoor Creative, LLC. All rights reserved.</mj-text>
828
1174
  <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="700" line-height="16px" padding-top="0" padding-bottom="0px">Our mailing address is:</mj-text>
829
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">Reddoor Creative, LLC</mj-text>
830
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">29027 Dapper Dan</mj-text>
831
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">Fair Oaks Ranch, TX 78015</mj-text>
1175
+ ${[copy.footerOrg, ...copy.footerAddress].map(
1176
+ (line) => `<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml(line)}</mj-text>`
1177
+ ).join("\n ")}
832
1178
  </mj-column>
833
1179
  </mj-section>
834
1180
  </mj-body>
@@ -838,6 +1184,7 @@ var CHECK_PNG, BLURRED_TESTS, TREND_UP, TREND_NEUTRAL;
838
1184
  var init_template = __esm({
839
1185
  "src/reports/maintenance-email/template.ts"() {
840
1186
  "use strict";
1187
+ init_copy();
841
1188
  init_assets();
842
1189
  CHECK_PNG = `cid:${CHECK_CID}`;
843
1190
  BLURRED_TESTS = `cid:${BLURRED_CID}`;
@@ -846,10 +1193,73 @@ var init_template = __esm({
846
1193
  }
847
1194
  });
848
1195
 
1196
+ // src/reports/launch-email/template.ts
1197
+ function buildLaunchMjml(data) {
1198
+ const copy = data.copy ?? DEFAULT_COPY;
1199
+ const previewText = `${escapeXml(data.siteName)} is live`;
1200
+ const setupRows = copy.launchSetupItems.map(
1201
+ (item) => `
1202
+ <mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="4px" padding-bottom="4px">\u2022 ${escapeXml(item)}</mj-text>`
1203
+ ).join("");
1204
+ const contactRows = copy.contact.map(
1205
+ (line) => `
1206
+ <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml(line)}</mj-text>`
1207
+ ).join("");
1208
+ const footerAddressRows = copy.footerAddress.map(
1209
+ (line) => `
1210
+ <mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml(line)}</mj-text>`
1211
+ ).join("");
1212
+ return `<mjml>
1213
+ <mj-head>
1214
+ <mj-attributes>
1215
+ <mj-text font-family="helvetica, sans-serif" padding-left="5px" padding-right="5px" />
1216
+ <mj-section padding-left="11%" padding-right="11%"/>
1217
+ <mj-image padding="0px" />
1218
+ </mj-attributes>
1219
+ <mj-preview>${previewText}</mj-preview>
1220
+ ${headerStyleBlock(data)}
1221
+ </mj-head>
1222
+ <mj-body background-color="white">
1223
+ <mj-section background-color="#F4F4F4" padding-top="0px" padding-bottom="0px" padding-left="0px" padding-right="0px">
1224
+ <mj-column>${headerImageTag(data)}</mj-column>
1225
+ </mj-section>
1226
+ <mj-section background-color="white">
1227
+ <mj-column>
1228
+ <mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="75px">${escapeXml(copy.launchHeading)}</mj-text>
1229
+ <mj-text color="${RED}" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
1230
+ <mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="20px">${escapeXml(copy.launchBody)}</mj-text>
1231
+ ${setupRows}
1232
+ </mj-column>
1233
+ </mj-section>
1234
+ <mj-section background-color="white">
1235
+ <mj-column padding-top="36px">
1236
+ <mj-text color="${RED}" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="36px" line-height="36px">Any questions, concerns or requests?</mj-text>
1237
+ ${contactRows}
1238
+ <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
1239
+ <mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" line-height="20px" font-style="italic">Copyright ${(/* @__PURE__ */ new Date()).getUTCFullYear()} ${escapeXml(copy.footerOrg)}. All rights reserved.</mj-text>
1240
+ <mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="700" line-height="16px" padding-top="0" padding-bottom="0px">Our mailing address is:</mj-text>
1241
+ <mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml(copy.footerOrg)}</mj-text>
1242
+ ${footerAddressRows}
1243
+ </mj-column>
1244
+ </mj-section>
1245
+ </mj-body>
1246
+ </mjml>`;
1247
+ }
1248
+ var RED, GREY;
1249
+ var init_template2 = __esm({
1250
+ "src/reports/launch-email/template.ts"() {
1251
+ "use strict";
1252
+ init_copy();
1253
+ init_template();
1254
+ RED = "#C00";
1255
+ GREY = "#757575";
1256
+ }
1257
+ });
1258
+
849
1259
  // src/reports/render.ts
850
1260
  import mjml2html from "mjml";
851
1261
  async function renderReportHtml(data) {
852
- const mjml = buildMjml(data);
1262
+ const mjml = data.reportType === "Launch" ? buildLaunchMjml(data) : buildMjml(data);
853
1263
  const out = await mjml2html(mjml, { validationLevel: "strict" });
854
1264
  return { html: out.html, warnings: out.errors ?? [] };
855
1265
  }
@@ -857,6 +1267,7 @@ var init_render = __esm({
857
1267
  "src/reports/render.ts"() {
858
1268
  "use strict";
859
1269
  init_template();
1270
+ init_template2();
860
1271
  }
861
1272
  });
862
1273
 
@@ -931,9 +1342,223 @@ var init_resend = __esm({
931
1342
  }
932
1343
  });
933
1344
 
1345
+ // src/alerts/digest-collectors.ts
1346
+ function dashboardUrl(baseUrl, siteName) {
1347
+ return `${baseUrl.replace(/\/$/, "")}/s/${siteSlug(siteName)}`;
1348
+ }
1349
+ function collectVulnAlerts(sites, baseUrl) {
1350
+ const items = [];
1351
+ for (const s of sites) {
1352
+ const critical = s.securityVulnsCritical ?? 0;
1353
+ const high = s.securityVulnsHigh ?? 0;
1354
+ const metric = critical + high;
1355
+ if (metric <= 0) continue;
1356
+ items.push({
1357
+ key: `vuln:${s.id}`,
1358
+ kind: "vuln",
1359
+ siteName: s.name,
1360
+ title: `${metric} critical/high ${metric === 1 ? "vuln" : "vulns"}`,
1361
+ url: dashboardUrl(baseUrl, s.name),
1362
+ severity: critical > 0 ? "critical" : "warning",
1363
+ metric
1364
+ });
1365
+ }
1366
+ return items;
1367
+ }
1368
+ function collectLighthouseAlerts(sites, baseUrl) {
1369
+ const items = [];
1370
+ for (const s of sites) {
1371
+ for (const cat of LIGHTHOUSE_CATEGORIES2) {
1372
+ const score = s[cat.field];
1373
+ if (score === null || score >= LIGHTHOUSE_FLOOR) continue;
1374
+ items.push({
1375
+ key: `lighthouse:${s.id}:${cat.slug}`,
1376
+ kind: "lighthouse",
1377
+ siteName: s.name,
1378
+ title: `Lighthouse ${cat.label} ${score} (below ${LIGHTHOUSE_FLOOR})`,
1379
+ url: dashboardUrl(baseUrl, s.name),
1380
+ severity: "warning",
1381
+ metric: 100 - score
1382
+ });
1383
+ }
1384
+ }
1385
+ return items;
1386
+ }
1387
+ function collectDeliveryFailures(reports, sitesById, baseUrl) {
1388
+ const items = [];
1389
+ for (const r of reports) {
1390
+ if (r.deliveryStatus !== "bounced" && r.deliveryStatus !== "complained") continue;
1391
+ const site = sitesById.get(r.siteId);
1392
+ if (!site) continue;
1393
+ const complained = r.deliveryStatus === "complained";
1394
+ items.push({
1395
+ key: `delivery:${r.id}`,
1396
+ kind: "delivery",
1397
+ siteName: site.name,
1398
+ title: complained ? "Spam complaint on a sent report" : "A sent report bounced",
1399
+ url: dashboardUrl(baseUrl, site.name),
1400
+ severity: complained ? "critical" : "warning",
1401
+ metric: 1
1402
+ });
1403
+ }
1404
+ return items;
1405
+ }
1406
+ function renovateFindingsToAttention(result) {
1407
+ const items = [];
1408
+ for (const f of result.findings) {
1409
+ items.push({
1410
+ key: `renovate:${f.repo}#${f.pr.number}`,
1411
+ kind: "renovate",
1412
+ siteName: f.site,
1413
+ title: `Renovate update failing CI: ${f.pr.title}`,
1414
+ url: f.pr.url,
1415
+ severity: "warning",
1416
+ metric: 1
1417
+ });
1418
+ }
1419
+ if (result.skipped.length > 0) {
1420
+ items.push({
1421
+ key: "renovate:skipped",
1422
+ kind: "renovate",
1423
+ siteName: "Fleet checks",
1424
+ title: `Couldn't check ${result.skipped.length} repo(s) for failing Renovate PRs`,
1425
+ severity: "warning",
1426
+ metric: result.skipped.length
1427
+ });
1428
+ }
1429
+ return items;
1430
+ }
1431
+ function buildRenovateProbe() {
1432
+ const token = process.env.RENOVATE_TOKEN?.trim() || process.env.GH_TOKEN?.trim();
1433
+ if (!token) return void 0;
1434
+ return makeGitHub({ token }).openPullRequests;
1435
+ }
1436
+ var LIGHTHOUSE_FLOOR, LIGHTHOUSE_CATEGORIES2;
1437
+ var init_digest_collectors = __esm({
1438
+ "src/alerts/digest-collectors.ts"() {
1439
+ "use strict";
1440
+ init_websites();
1441
+ init_gh();
1442
+ LIGHTHOUSE_FLOOR = 75;
1443
+ LIGHTHOUSE_CATEGORIES2 = [
1444
+ { field: "pScore", slug: "performance", label: "Performance" },
1445
+ { field: "rScore", slug: "accessibility", label: "Accessibility" },
1446
+ { field: "bpScore", slug: "best-practices", label: "Best Practices" },
1447
+ { field: "seoScore", slug: "seo", label: "SEO" }
1448
+ ];
1449
+ }
1450
+ });
1451
+
1452
+ // src/alerts/renovate.ts
1453
+ function isRenovatePR(pr) {
1454
+ return RENOVATE_HEAD_PREFIXES.some((p) => pr.headRef.startsWith(p));
1455
+ }
1456
+ function isFailingRenovatePR(pr) {
1457
+ return isRenovatePR(pr) && pr.ciState === "failing";
1458
+ }
1459
+ function siteLabel2(site) {
1460
+ const display = site.meta?.["displayName"];
1461
+ if (typeof display === "string" && display.length > 0) return display;
1462
+ if (typeof site.name === "string" && site.name.length > 0) return site.name;
1463
+ return "unknown";
1464
+ }
1465
+ async function collectRenovateFailures(sites, probe) {
1466
+ const findings = [];
1467
+ const skipped = [];
1468
+ for (const site of sites) {
1469
+ const repo = site.gitRepo;
1470
+ if (!repo) continue;
1471
+ let prs;
1472
+ try {
1473
+ prs = await probe(repo);
1474
+ } catch {
1475
+ skipped.push(repo);
1476
+ continue;
1477
+ }
1478
+ for (const pr of prs) {
1479
+ if (isFailingRenovatePR(pr)) {
1480
+ findings.push({ site: siteLabel2(site), repo, pr });
1481
+ }
1482
+ }
1483
+ }
1484
+ return { findings, skipped };
1485
+ }
1486
+ var RENOVATE_HEAD_PREFIXES;
1487
+ var init_renovate = __esm({
1488
+ "src/alerts/renovate.ts"() {
1489
+ "use strict";
1490
+ RENOVATE_HEAD_PREFIXES = ["renovate/", "renovate-"];
1491
+ }
1492
+ });
1493
+
1494
+ // src/alerts/digest-state.ts
1495
+ function diffAttention(items, prior, today) {
1496
+ const tagged = [];
1497
+ const next = {};
1498
+ for (const it of items) {
1499
+ const was = prior[it.key];
1500
+ let status;
1501
+ let firstFlaggedAt;
1502
+ if (!was) {
1503
+ status = "new";
1504
+ firstFlaggedAt = today;
1505
+ } else if (it.metric > was.metric) {
1506
+ status = "worse";
1507
+ firstFlaggedAt = was.firstFlaggedAt;
1508
+ } else {
1509
+ status = "standing";
1510
+ firstFlaggedAt = was.firstFlaggedAt;
1511
+ }
1512
+ tagged.push({ ...it, status });
1513
+ next[it.key] = { metric: it.metric, firstFlaggedAt };
1514
+ }
1515
+ return { tagged, next };
1516
+ }
1517
+ async function readDigestState(base) {
1518
+ const rows = [];
1519
+ await base(DIGEST_STATE_TABLE).select({ maxRecords: 1, pageSize: 1 }).eachPage((records, fetchNextPage) => {
1520
+ for (const rec of records) rows.push({ id: rec.id, fields: rec.fields });
1521
+ fetchNextPage();
1522
+ });
1523
+ const first = rows[0];
1524
+ if (!first) return {};
1525
+ const raw = first.fields["Snapshot"];
1526
+ if (typeof raw !== "string") return {};
1527
+ try {
1528
+ return JSON.parse(raw);
1529
+ } catch {
1530
+ return {};
1531
+ }
1532
+ }
1533
+ async function writeDigestState(base, snap, updatedAt = (/* @__PURE__ */ new Date()).toISOString()) {
1534
+ const rows = [];
1535
+ await base(DIGEST_STATE_TABLE).select({ maxRecords: 1, pageSize: 1 }).eachPage((records, fetchNextPage) => {
1536
+ for (const rec of records) rows.push({ id: rec.id });
1537
+ fetchNextPage();
1538
+ });
1539
+ const fields = {
1540
+ Snapshot: JSON.stringify(snap),
1541
+ "Updated At": updatedAt
1542
+ };
1543
+ const existing = rows[0];
1544
+ if (existing) {
1545
+ await base(DIGEST_STATE_TABLE).update([{ id: existing.id, fields }]);
1546
+ } else {
1547
+ await base(DIGEST_STATE_TABLE).create([{ fields }]);
1548
+ }
1549
+ }
1550
+ var DIGEST_STATE_TABLE;
1551
+ var init_digest_state = __esm({
1552
+ "src/alerts/digest-state.ts"() {
1553
+ "use strict";
1554
+ DIGEST_STATE_TABLE = "Digest State";
1555
+ }
1556
+ });
1557
+
934
1558
  // src/reports/digest.ts
935
1559
  var digest_exports = {};
936
1560
  __export(digest_exports, {
1561
+ collectAttention: () => collectAttention,
937
1562
  listPendingApproval: () => listPendingApproval,
938
1563
  renderDigestHtml: () => renderDigestHtml,
939
1564
  runDigest: () => runDigest
@@ -942,16 +1567,16 @@ function esc(s) {
942
1567
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
943
1568
  }
944
1569
  function readySection(items) {
945
- const heading = `<h2 style="color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Ready for your yes</h2>`;
1570
+ const heading = `<h2 style="color:${RED2};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Ready for your yes</h2>`;
946
1571
  if (items.length === 0) {
947
- return `${heading}<p style="color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0">Nothing waiting on you.</p>`;
1572
+ return `${heading}<p style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;margin:0">Nothing waiting on you.</p>`;
948
1573
  }
949
1574
  const rows = items.map((it) => {
950
1575
  const safeUrl = it.dashboardUrl.startsWith("https://") ? it.dashboardUrl : void 0;
951
1576
  const link = safeUrl ? `<a href="${esc(safeUrl)}" style="${ANCHOR_STYLE}">review &amp; approve</a>` : `review &amp; approve`;
952
1577
  return `
953
1578
  <tr>
954
- <td style="color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">
1579
+ <td style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">
955
1580
  <strong style="color:#222">${esc(it.siteName)}</strong> \u2014 ${esc(it.reportType)} (${esc(it.period)})
956
1581
  \u2014 ${link}
957
1582
  </td>
@@ -959,36 +1584,105 @@ function readySection(items) {
959
1584
  }).join("");
960
1585
  return `${heading}<table role="presentation" style="border-collapse:collapse;margin:0">${rows}</table>`;
961
1586
  }
1587
+ function attentionBadge(status) {
1588
+ if (status === "new")
1589
+ return `<strong style="color:${RED2};font-family:helvetica,sans-serif">NEW</strong> `;
1590
+ if (status === "worse")
1591
+ return `<strong style="color:${RED2};font-family:helvetica,sans-serif">WORSE</strong> `;
1592
+ return "";
1593
+ }
962
1594
  function attentionSection(items) {
963
- const heading = `<h2 style="color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Needs attention</h2>`;
1595
+ const heading = `<h2 style="color:${RED2};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Needs attention</h2>`;
964
1596
  if (items.length === 0) {
965
- return `${heading}<p style="color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0">All clear \u2014 nothing needs attention.</p>`;
1597
+ return `${heading}<p style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;margin:0">All clear \u2014 nothing needs attention.</p>`;
966
1598
  }
967
- const rows = items.map((it) => {
968
- const safeUrl = it.url?.startsWith("https://") ? it.url : void 0;
969
- const label = safeUrl ? `<a href="${esc(safeUrl)}" style="${ANCHOR_STYLE}">${esc(it.title)}</a>` : esc(it.title);
1599
+ const bySite = /* @__PURE__ */ new Map();
1600
+ for (const it of items) {
1601
+ const bucket = bySite.get(it.siteName);
1602
+ if (bucket) bucket.push(it);
1603
+ else bySite.set(it.siteName, [it]);
1604
+ }
1605
+ const groups = [...bySite.entries()].map(([siteName, siteItems]) => {
1606
+ const sorted = [...siteItems].sort(
1607
+ (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]
1608
+ );
1609
+ const rows = sorted.map((it) => {
1610
+ const safeUrl = it.url?.startsWith("https://") ? it.url : void 0;
1611
+ const titleHtml = safeUrl ? `<a href="${esc(safeUrl)}" style="${ANCHOR_STYLE}">${esc(it.title)}</a>` : esc(it.title);
1612
+ return `
1613
+ <tr>
1614
+ <td style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">${attentionBadge(it.status)}${titleHtml}</td>
1615
+ </tr>`;
1616
+ }).join("");
970
1617
  return `
971
1618
  <tr>
972
- <td style="color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">${label}</td>
973
- </tr>`;
1619
+ <td style="color:#222;font-family:helvetica,sans-serif;font-size:16px;font-weight:700;padding:8px 0 4px">${esc(siteName)}</td>
1620
+ </tr>
1621
+ ${rows}`;
974
1622
  }).join("");
975
- return `${heading}<table role="presentation" style="border-collapse:collapse;margin:0">${rows}</table>`;
1623
+ return `${heading}<table role="presentation" style="border-collapse:collapse;margin:0">${groups}</table>`;
976
1624
  }
977
1625
  function digestDateKey(d) {
978
1626
  return d.toISOString().slice(0, 10);
979
1627
  }
1628
+ function isIdempotencyConflict(err) {
1629
+ const message = err instanceof Error ? err.message : String(err);
1630
+ if (/idempotency key has been used/i.test(message)) return true;
1631
+ const e = err;
1632
+ if (e.name === "invalid_idempotent_request") return true;
1633
+ if (e.statusCode === 409) return true;
1634
+ return false;
1635
+ }
980
1636
  async function listPendingApproval(base) {
981
1637
  return (await listAllReports(base)).filter(
982
1638
  (r) => r.draftReady && !r.approvedToSend && r.sentAt === null
983
1639
  );
984
1640
  }
1641
+ function runCollector(label, fn) {
1642
+ try {
1643
+ return fn();
1644
+ } catch (e) {
1645
+ console.warn(`\u26A0 attention collector "${label}" failed: ${e.message}`);
1646
+ return [];
1647
+ }
1648
+ }
1649
+ async function runCollectorAsync(label, fn) {
1650
+ try {
1651
+ return await fn();
1652
+ } catch (e) {
1653
+ console.warn(`\u26A0 attention collector "${label}" failed: ${e.message}`);
1654
+ return [];
1655
+ }
1656
+ }
1657
+ async function collectAttention(deps) {
1658
+ const reports = deps.reports ?? await listAllReports(deps.base);
1659
+ const websites = deps.websites ?? await listWebsites(deps.base);
1660
+ const sitesById = new Map(websites.map((w) => [w.id, w]));
1661
+ const renovate = deps.renovateProbe ? await runCollectorAsync("renovate", async () => {
1662
+ const sites = websites.map((w) => ({
1663
+ path: "",
1664
+ name: w.name,
1665
+ meta: {},
1666
+ ...w.gitRepo ? { gitRepo: w.gitRepo } : {}
1667
+ }));
1668
+ const result = await collectRenovateFailures(sites, deps.renovateProbe);
1669
+ return renovateFindingsToAttention(result);
1670
+ }) : [];
1671
+ return [
1672
+ ...runCollector("vuln", () => collectVulnAlerts(websites, deps.baseUrl)),
1673
+ ...runCollector("delivery", () => collectDeliveryFailures(reports, sitesById, deps.baseUrl)),
1674
+ ...runCollector("lighthouse", () => collectLighthouseAlerts(websites, deps.baseUrl)),
1675
+ ...renovate
1676
+ ];
1677
+ }
985
1678
  async function runDigest(options) {
986
1679
  const today = /* @__PURE__ */ new Date();
987
1680
  try {
988
1681
  const base = options.base ?? openBase(readAirtableConfig());
1682
+ const reports = await listAllReports(base);
989
1683
  const websites = await listWebsites(base);
990
1684
  const sites = new Map(websites.map((w) => [w.id, w]));
991
- const pending = await listPendingApproval(base);
1685
+ const pending = reports.filter((r) => r.draftReady && !r.approvedToSend && r.sentAt === null);
992
1686
  const readyForYourYes = [];
993
1687
  for (const r of pending) {
994
1688
  const site = sites.get(r.siteId);
@@ -1000,8 +1694,23 @@ async function runDigest(options) {
1000
1694
  dashboardUrl: `${options.baseUrl.replace(/\/$/, "")}/s/${siteSlug(site.name)}`
1001
1695
  });
1002
1696
  }
1003
- const needsAttention = [];
1697
+ const renovateProbe = buildRenovateProbe();
1698
+ const collected = await collectAttention({
1699
+ base,
1700
+ baseUrl: options.baseUrl,
1701
+ websites,
1702
+ reports,
1703
+ ...renovateProbe ? { renovateProbe } : {}
1704
+ });
1705
+ const prior = await readDigestState(base);
1706
+ const { tagged, next } = diffAttention(collected, prior, digestDateKey(today));
1707
+ const needsAttention = tagged;
1004
1708
  if (readyForYourYes.length === 0 && needsAttention.length === 0) {
1709
+ try {
1710
+ await writeDigestState(base, next);
1711
+ } catch (e) {
1712
+ console.warn(`\u26A0 digest state write failed: ${e.message}`);
1713
+ }
1005
1714
  return { output: "Digest skipped (nothing ready, nothing needs attention).", code: 0 };
1006
1715
  }
1007
1716
  const html = renderDigestHtml({ readyForYourYes, needsAttention });
@@ -1009,13 +1718,29 @@ async function runDigest(options) {
1009
1718
  const to = [process.env.OPERATOR_EMAIL?.trim() || DIGEST_OPERATOR_FALLBACK];
1010
1719
  const n = readyForYourYes.length;
1011
1720
  const reportWord = n === 1 ? "report" : "reports";
1012
- const result = await client.send({
1013
- from: FROM_ADDRESS,
1014
- to,
1015
- subject: `Your fleet \u2014 ${digestDateKey(today)}: ${n} ${reportWord} ready for your yes`,
1016
- html,
1017
- idempotencyKey: `digest-${digestDateKey(today)}`
1018
- });
1721
+ let result;
1722
+ try {
1723
+ result = await client.send({
1724
+ from: FROM_ADDRESS,
1725
+ to,
1726
+ subject: `Your fleet \u2014 ${digestDateKey(today)}: ${n} ${reportWord} ready for your yes`,
1727
+ html,
1728
+ idempotencyKey: `digest-${digestDateKey(today)}`
1729
+ });
1730
+ } catch (err) {
1731
+ if (isIdempotencyConflict(err)) {
1732
+ return {
1733
+ output: "Digest already sent today (content changed since the first send) \u2014 skipped to avoid a duplicate.",
1734
+ code: 0
1735
+ };
1736
+ }
1737
+ throw err;
1738
+ }
1739
+ try {
1740
+ await writeDigestState(base, next);
1741
+ } catch (e) {
1742
+ console.warn(`\u26A0 digest state write failed: ${e.message}`);
1743
+ }
1019
1744
  return { output: `Digest sent to ${to.join(", ")} (${result.messageId})`, code: 0 };
1020
1745
  } catch (err) {
1021
1746
  if (typeof err.exitCode === "number") {
@@ -1036,7 +1761,7 @@ function renderDigestHtml(sections) {
1036
1761
  <table width="600" style="border-collapse:collapse">
1037
1762
  <tr>
1038
1763
  <td>
1039
- <h1 style="color:${RED};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px">Your fleet today</h1>
1764
+ <h1 style="color:${RED2};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px">Your fleet today</h1>
1040
1765
  ${readySection(sections.readyForYourYes)}
1041
1766
  ${attentionSection(sections.needsAttention)}
1042
1767
  </td>
@@ -1048,7 +1773,7 @@ function renderDigestHtml(sections) {
1048
1773
  </body>
1049
1774
  </html>`;
1050
1775
  }
1051
- var GREY, RED, ANCHOR_STYLE, FROM_ADDRESS, DIGEST_OPERATOR_FALLBACK;
1776
+ var GREY2, RED2, ANCHOR_STYLE, SEVERITY_ORDER, FROM_ADDRESS, DIGEST_OPERATOR_FALLBACK;
1052
1777
  var init_digest = __esm({
1053
1778
  "src/reports/digest.ts"() {
1054
1779
  "use strict";
@@ -1056,9 +1781,13 @@ var init_digest = __esm({
1056
1781
  init_reports();
1057
1782
  init_websites();
1058
1783
  init_resend();
1059
- GREY = "#757575";
1060
- RED = "#C00";
1061
- ANCHOR_STYLE = `color:${RED};font-family:helvetica,sans-serif`;
1784
+ init_digest_collectors();
1785
+ init_renovate();
1786
+ init_digest_state();
1787
+ GREY2 = "#757575";
1788
+ RED2 = "#C00";
1789
+ ANCHOR_STYLE = `color:${RED2};font-family:helvetica,sans-serif`;
1790
+ SEVERITY_ORDER = { critical: 0, warning: 1 };
1062
1791
  FROM_ADDRESS = "Reddoor Reports <reports@reddoorla.com>";
1063
1792
  DIGEST_OPERATOR_FALLBACK = "info@reddoorla.com";
1064
1793
  }
@@ -1139,6 +1868,14 @@ async function sendApprovedReports(options = {}) {
1139
1868
  try {
1140
1869
  const messageId = await sendOne(client, base, site, report);
1141
1870
  lines.push(`\u2713 sent: ${report.reportId} (${messageId})`);
1871
+ if (report.reportType === "Launch") {
1872
+ try {
1873
+ await updateLaunched(base, site.id, (/* @__PURE__ */ new Date()).toISOString());
1874
+ lines.push(` \u21B3 launched: ${site.name} flipped to maintenance`);
1875
+ } catch (e) {
1876
+ lines.push(` \u26A0 launch flip failed for ${site.name}: ${e.message}`);
1877
+ }
1878
+ }
1142
1879
  } catch (e) {
1143
1880
  lines.push(`\u2717 ${report.reportId} \u2014 ${e.message}`);
1144
1881
  anyFailed = true;
@@ -1171,6 +1908,7 @@ async function sendOne(client, base, site, report) {
1171
1908
  searchPosition: report.searchFoundPage1 && report.searchPosition !== null ? report.searchPosition : void 0,
1172
1909
  lastTestedDate: report.lastTestedDate ? new Date(report.lastTestedDate) : null,
1173
1910
  commentary: report.commentary,
1911
+ copy: resolveCopy(site),
1174
1912
  headerImageCid: cidName,
1175
1913
  headerWidth: header.displayWidth,
1176
1914
  headerHeight: header.displayHeight,
@@ -1274,6 +2012,7 @@ var init_orchestrate = __esm({
1274
2012
  init_websites();
1275
2013
  init_attachments();
1276
2014
  init_render();
2015
+ init_copy();
1277
2016
  init_assets();
1278
2017
  init_header_image();
1279
2018
  init_resend();
@@ -1306,72 +2045,8 @@ import { cac } from "cac";
1306
2045
  import { resolve as resolve2 } from "path";
1307
2046
  import { Listr } from "listr2";
1308
2047
 
1309
- // src/audits/util/spawn.ts
1310
- import { spawn } from "child_process";
1311
- var TRUNCATION_MARKER = "\n\u2026[output truncated]";
1312
- function makeSpawn(internals = {}) {
1313
- const spawnImpl = internals.spawnImpl ?? spawn;
1314
- const killImpl = internals.killImpl ?? ((pid, sig) => process.kill(pid, sig));
1315
- const killGraceMs = internals.killGraceMs ?? 5e3;
1316
- const maxOutputBytes = internals.maxOutputBytes ?? 10 * 1024 * 1024;
1317
- return (cmd, args, opts = {}) => new Promise((resolve11, reject) => {
1318
- const streaming = opts.streaming === true;
1319
- const child = spawnImpl(cmd, [...args], {
1320
- cwd: opts.cwd,
1321
- env: opts.env ?? process.env,
1322
- stdio: streaming ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "pipe"],
1323
- // Detach ONLY when a timeout can fire: the child then leads its own
1324
- // process group, so the timeout can kill the WHOLE tree (vite, and
1325
- // Chromium under lhci/playwright) via process.kill(-pid), not just the
1326
- // npx/pnpm wrapper. Without it, killing the wrapper orphaned the
1327
- // grandchildren — a zombie vite squatting its port, Chrome left running.
1328
- // We do NOT detach timeout-less streaming calls (pnpm install/up):
1329
- // detaching gains nothing there (no timeout → no group-kill) and would
1330
- // break terminal Ctrl-C, which only reaches the foreground group — i.e.
1331
- // it would re-orphan the very children this guards. We never unref() the
1332
- // child since we still await it.
1333
- detached: opts.timeoutMs !== void 0
1334
- });
1335
- const cap = (acc, chunk) => {
1336
- if (acc.length >= maxOutputBytes) return acc;
1337
- const next = acc + chunk;
1338
- return next.length > maxOutputBytes ? next.slice(0, maxOutputBytes) + TRUNCATION_MARKER : next;
1339
- };
1340
- let stdout = "";
1341
- let stderr = "";
1342
- if (!streaming) {
1343
- child.stdout?.on("data", (chunk) => stdout = cap(stdout, String(chunk)));
1344
- child.stderr?.on("data", (chunk) => stderr = cap(stderr, String(chunk)));
1345
- }
1346
- const killGroup = (sig) => {
1347
- if (child.pid === void 0) return;
1348
- try {
1349
- killImpl(-child.pid, sig);
1350
- } catch {
1351
- }
1352
- };
1353
- let killTimer;
1354
- const timer = opts.timeoutMs ? setTimeout(() => {
1355
- killGroup("SIGTERM");
1356
- killTimer = setTimeout(() => killGroup("SIGKILL"), killGraceMs);
1357
- killTimer.unref();
1358
- reject(new Error(`spawn timeout after ${opts.timeoutMs}ms: ${cmd}`));
1359
- }, opts.timeoutMs) : void 0;
1360
- const clearTimers = () => {
1361
- if (timer) clearTimeout(timer);
1362
- if (killTimer) clearTimeout(killTimer);
1363
- };
1364
- child.on("error", (err) => {
1365
- clearTimers();
1366
- reject(err);
1367
- });
1368
- child.on("close", (code) => {
1369
- clearTimers();
1370
- resolve11({ code: code ?? -1, stdout, stderr });
1371
- });
1372
- });
1373
- }
1374
- var defaultSpawn = makeSpawn();
2048
+ // src/audits/index.ts
2049
+ init_spawn();
1375
2050
 
1376
2051
  // src/audits/deps.ts
1377
2052
  import { readFile } from "fs/promises";
@@ -1421,6 +2096,9 @@ var baselineVersions = {
1421
2096
  "@zerodevx/svelte-img": "^2.1.2"
1422
2097
  };
1423
2098
 
2099
+ // src/audits/deps.ts
2100
+ init_spawn();
2101
+
1424
2102
  // src/audits/deps-outdated.ts
1425
2103
  import { stat } from "fs/promises";
1426
2104
  import { join as join2 } from "path";
@@ -1600,6 +2278,7 @@ async function lintAudit(ctx) {
1600
2278
  }
1601
2279
 
1602
2280
  // src/audits/security.ts
2281
+ init_spawn();
1603
2282
  function classify(v) {
1604
2283
  if (v.critical > 0 || v.high > 0) return "fail";
1605
2284
  if (v.moderate > 0 || v.low > 0) return "warn";
@@ -1774,6 +2453,9 @@ var lighthouseConfig = {
1774
2453
  }
1775
2454
  };
1776
2455
 
2456
+ // src/audits/lighthouse.ts
2457
+ init_spawn();
2458
+
1777
2459
  // src/audits/util/site-config.ts
1778
2460
  import { readFile as readFile3 } from "fs/promises";
1779
2461
  import { join as join5 } from "path";
@@ -1804,7 +2486,7 @@ async function readSiteConfig(sitePath) {
1804
2486
  // src/util/free-port.ts
1805
2487
  import { createServer } from "net";
1806
2488
  async function findFreePort() {
1807
- return new Promise((resolve11, reject) => {
2489
+ return new Promise((resolve12, reject) => {
1808
2490
  const server = createServer();
1809
2491
  server.unref();
1810
2492
  server.on("error", reject);
@@ -1812,7 +2494,7 @@ async function findFreePort() {
1812
2494
  const addr = server.address();
1813
2495
  if (typeof addr === "object" && addr) {
1814
2496
  const port = addr.port;
1815
- server.close(() => resolve11(port));
2497
+ server.close(() => resolve12(port));
1816
2498
  } else {
1817
2499
  server.close();
1818
2500
  reject(new Error("findFreePort: could not determine assigned port from socket"));
@@ -2042,6 +2724,7 @@ var playwrightA11yConfig = defineConfig({
2042
2724
  });
2043
2725
 
2044
2726
  // src/audits/a11y.ts
2727
+ init_spawn();
2045
2728
  var RESULTS_REL = ".reddoor-a11y/results.json";
2046
2729
  async function readJsonMaybe2(path) {
2047
2730
  try {
@@ -2358,6 +3041,7 @@ async function resolveSites(input) {
2358
3041
  }
2359
3042
 
2360
3043
  // src/cli/fleet/clone-if-needed.ts
3044
+ init_spawn();
2361
3045
  import { stat as stat2, readdir as readdir2, mkdir } from "fs/promises";
2362
3046
  import { isAbsolute as isAbsolute2, join as join8 } from "path";
2363
3047
  function deriveNameFromRepoUrl(repoUrl) {
@@ -3192,6 +3876,7 @@ async function runSyncConfigsCommand(site, opts) {
3192
3876
  import { resolve as resolve4 } from "path";
3193
3877
 
3194
3878
  // src/recipes/bump-deps.ts
3879
+ init_spawn();
3195
3880
  import { stat as stat3 } from "fs/promises";
3196
3881
  import { join as join12 } from "path";
3197
3882
  async function exists2(path) {
@@ -3306,158 +3991,8 @@ function readGitHubConfig() {
3306
3991
  return { token, renovateToken };
3307
3992
  }
3308
3993
 
3309
- // src/github/gh.ts
3310
- function mapRollupState(state) {
3311
- switch (state) {
3312
- case "SUCCESS":
3313
- return "passing";
3314
- case "FAILURE":
3315
- case "ERROR":
3316
- return "failing";
3317
- case "PENDING":
3318
- case "EXPECTED":
3319
- return "pending";
3320
- default:
3321
- return "none";
3322
- }
3323
- }
3324
- function makeGitHub(deps) {
3325
- const spawn2 = deps.spawn ?? defaultSpawn;
3326
- const env = { ...process.env, GH_TOKEN: deps.token };
3327
- async function gh(args) {
3328
- const r = await spawn2("gh", args, { env, timeoutMs: 6e4 });
3329
- if (r.code !== 0) throw new Error(`gh ${args[0]} failed (code ${r.code}): ${r.stderr.trim()}`);
3330
- return r.stdout;
3331
- }
3332
- return {
3333
- async openPullRequest(repo, pr) {
3334
- const out = await gh([
3335
- "pr",
3336
- "create",
3337
- "--repo",
3338
- repo,
3339
- "--head",
3340
- pr.head,
3341
- "--base",
3342
- pr.base,
3343
- "--title",
3344
- pr.title,
3345
- "--body",
3346
- pr.body
3347
- ]);
3348
- return { url: out.trim() };
3349
- },
3350
- async enableRepoAutoMerge(repo) {
3351
- await gh(["api", "-X", "PATCH", `repos/${repo}`, "-F", "allow_auto_merge=true"]);
3352
- },
3353
- async protectBranch(repo, branch, requiredChecks) {
3354
- const args = [
3355
- "api",
3356
- "-X",
3357
- "PUT",
3358
- `repos/${repo}/branches/${branch}/protection`,
3359
- "-H",
3360
- "Accept: application/vnd.github+json",
3361
- "-F",
3362
- "required_status_checks[strict]=true",
3363
- ...requiredChecks.flatMap((c) => ["-f", `required_status_checks[contexts][]=${c}`]),
3364
- "-F",
3365
- "enforce_admins=true",
3366
- "-F",
3367
- "required_pull_request_reviews=null",
3368
- "-F",
3369
- "restrictions=null"
3370
- ];
3371
- await gh(args);
3372
- },
3373
- async setRepoSecret(repo, name, value) {
3374
- await gh(["secret", "set", name, "--repo", repo, "--body", value]);
3375
- },
3376
- async repoExists(repo) {
3377
- const r = await spawn2("gh", ["api", `repos/${repo}`], { env, timeoutMs: 6e4 });
3378
- return r.code === 0;
3379
- },
3380
- async defaultBranch(repo) {
3381
- const out = await gh(["api", `repos/${repo}`, "--jq", ".default_branch"]);
3382
- return out.trim();
3383
- },
3384
- // filesOnBranch and branchProtectionContexts call `spawn` directly (not the
3385
- // throwing `gh()` helper) because a 404 is an expected, meaningful answer —
3386
- // "file/protection absent" — not an error. The remaining readers use `gh()`
3387
- // since a non-200 there is a genuine failure (e.g. missing token scope).
3388
- async filesOnBranch(repo, branch, paths) {
3389
- const present = [];
3390
- for (const p of paths) {
3391
- const r = await spawn2("gh", [`api`, `repos/${repo}/contents/${p}?ref=${branch}`], {
3392
- env,
3393
- timeoutMs: 6e4
3394
- });
3395
- if (r.code === 0) present.push(p);
3396
- }
3397
- return present;
3398
- },
3399
- async branchProtectionContexts(repo, branch) {
3400
- const r = await spawn2(
3401
- "gh",
3402
- [
3403
- "api",
3404
- `repos/${repo}/branches/${branch}/protection`,
3405
- "--jq",
3406
- ".required_status_checks.contexts[]?"
3407
- ],
3408
- { env, timeoutMs: 6e4 }
3409
- );
3410
- if (r.code !== 0) return [];
3411
- return r.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
3412
- },
3413
- async secretExists(repo, name) {
3414
- const out = await gh(["api", `repos/${repo}/actions/secrets`, "--jq", ".secrets[].name"]);
3415
- return out.split("\n").map((l) => l.trim()).includes(name);
3416
- },
3417
- async autoMergeEnabled(repo) {
3418
- const out = await gh(["api", `repos/${repo}`, "--jq", ".allow_auto_merge"]);
3419
- return out.trim() === "true";
3420
- },
3421
- async findOpenSelfUpdatingPR(repo) {
3422
- const out = await gh([
3423
- "api",
3424
- `repos/${repo}/pulls?state=open`,
3425
- "--jq",
3426
- '.[] | select(.head.ref | startswith("maint/self-updating-")) | .html_url'
3427
- ]);
3428
- const first = out.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
3429
- return first ?? null;
3430
- },
3431
- async openPullRequests(repo) {
3432
- const [owner, name, ...rest] = repo.split("/");
3433
- if (!owner || !name || rest.length > 0) {
3434
- throw new Error(`openPullRequests: expected "owner/repo", got "${repo}"`);
3435
- }
3436
- const query = "query($owner:String!,$name:String!){repository(owner:$owner,name:$name){pullRequests(states:OPEN,first:100,orderBy:{field:CREATED_AT,direction:DESC}){nodes{number title url headRefName commits(last:1){nodes{commit{statusCheckRollup{state}}}}}}}}";
3437
- const out = await gh([
3438
- "api",
3439
- "graphql",
3440
- "-f",
3441
- `query=${query}`,
3442
- "-F",
3443
- `owner=${owner}`,
3444
- "-F",
3445
- `name=${name}`
3446
- ]);
3447
- const parsed = JSON.parse(out);
3448
- const nodes = parsed.data?.repository?.pullRequests?.nodes ?? [];
3449
- return nodes.map((n) => ({
3450
- number: n.number,
3451
- title: n.title,
3452
- url: n.url,
3453
- headRef: n.headRefName,
3454
- ciState: mapRollupState(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state)
3455
- }));
3456
- }
3457
- };
3458
- }
3459
-
3460
3994
  // src/recipes/self-updating/index.ts
3995
+ init_gh();
3461
3996
  var SELF_UPDATING_CONFIGS = ["ci", "renovate-action", "renovate-config"];
3462
3997
  var REQUIRED_CHECK = "ci / ci";
3463
3998
  function resultOf(site, status, notes, commits = []) {
@@ -3626,6 +4161,9 @@ function bumpDep(pkg, name, version2, opts = {}) {
3626
4161
  return next;
3627
4162
  }
3628
4163
 
4164
+ // src/recipes/svelte-5/index.ts
4165
+ init_spawn();
4166
+
3629
4167
  // src/recipes/svelte-5/step-bump-versions.ts
3630
4168
  import { join as join14 } from "path";
3631
4169
  var SVELTE_5_VERSIONS = {
@@ -3710,6 +4248,7 @@ async function migrateSvelteConfig(cwd) {
3710
4248
  }
3711
4249
 
3712
4250
  // src/recipes/svelte-5/step-svelte-migrate.ts
4251
+ init_spawn();
3713
4252
  async function runSvelteMigrate(cwd, spawn2 = defaultSpawn) {
3714
4253
  try {
3715
4254
  const { code, stderr } = await spawn2(
@@ -3731,6 +4270,7 @@ async function runSvelteMigrate(cwd, spawn2 = defaultSpawn) {
3731
4270
  }
3732
4271
 
3733
4272
  // src/recipes/svelte-5/step-tailwind-upgrade.ts
4273
+ init_spawn();
3734
4274
  import { join as join16 } from "path";
3735
4275
  async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
3736
4276
  const pkg = await readPackageJson(join16(cwd, "package.json"));
@@ -4117,6 +4657,7 @@ async function applyGotchaCodemods(cwd) {
4117
4657
  }
4118
4658
 
4119
4659
  // src/recipes/svelte-5/step-verify.ts
4660
+ init_spawn();
4120
4661
  async function verifyMigration(cwd, spawn2 = defaultSpawn) {
4121
4662
  let install;
4122
4663
  try {
@@ -4256,6 +4797,7 @@ import { resolve as resolve7 } from "path";
4256
4797
  // src/recipes/convert-to-pnpm.ts
4257
4798
  import { rm as rm3, stat as stat4 } from "fs/promises";
4258
4799
  import { join as join20 } from "path";
4800
+ init_spawn();
4259
4801
 
4260
4802
  // src/recipes/convert-to-pnpm/script-rewrites.ts
4261
4803
  function rewriteScriptForPnpm(script) {
@@ -4368,6 +4910,7 @@ import { resolve as resolve8 } from "path";
4368
4910
  // src/recipes/onboard.ts
4369
4911
  import { stat as stat5 } from "fs/promises";
4370
4912
  import { join as join22 } from "path";
4913
+ init_spawn();
4371
4914
 
4372
4915
  // src/util/self-version.ts
4373
4916
  import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
@@ -4663,6 +5206,7 @@ function reportPeriodKey(dueDate) {
4663
5206
  // src/reports/draft.ts
4664
5207
  init_render();
4665
5208
  init_websites();
5209
+ init_copy();
4666
5210
  init_reports();
4667
5211
  init_attachments();
4668
5212
  import { mkdir as mkdir4, writeFile as writeFile10 } from "fs/promises";
@@ -4823,6 +5367,7 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
4823
5367
  searchPosition: search?.foundOnPage1 ? search.position ?? void 0 : void 0,
4824
5368
  lastTestedDate,
4825
5369
  commentary: null,
5370
+ copy: resolveCopy(siteRow),
4826
5371
  headerImageCid: cidName
4827
5372
  });
4828
5373
  if (options.previewOnly) {
@@ -5144,6 +5689,256 @@ async function runInitCommand(site, opts) {
5144
5689
  return { output, code };
5145
5690
  }
5146
5691
 
5692
+ // src/cli/commands/launch.ts
5693
+ import { resolve as resolve11 } from "path";
5694
+
5695
+ // src/recipes/launch.ts
5696
+ init_lighthouse_airtable();
5697
+ init_write_audits_to_airtable();
5698
+ init_client();
5699
+ init_websites();
5700
+ init_reports();
5701
+ init_attachments();
5702
+ init_render();
5703
+ init_copy();
5704
+ async function launch(site, deps = {}) {
5705
+ const label = siteLabel(site);
5706
+ const bootstrap = deps.bootstrap ?? selfUpdating;
5707
+ const audit = deps.audit ?? runAudits;
5708
+ const base = deps.base ?? openBase(readAirtableConfig());
5709
+ const steps = [];
5710
+ const stop = () => ({ site: label, steps, complete: false });
5711
+ let recipe;
5712
+ try {
5713
+ recipe = await bootstrap(site);
5714
+ } catch (err) {
5715
+ steps.push({ name: "self-updating", result: errorOf(err) });
5716
+ return stop();
5717
+ }
5718
+ steps.push({ name: "self-updating", result: { kind: "recipe", result: recipe } });
5719
+ if (recipe.status === "failed") return stop();
5720
+ let results;
5721
+ try {
5722
+ results = await audit(site);
5723
+ } catch (err) {
5724
+ steps.push({ name: "audit", result: errorOf(err) });
5725
+ return stop();
5726
+ }
5727
+ const lhResult = results.find((r) => r.audit === "lighthouse");
5728
+ if (!lhResult || !hasRealScores(lhResult)) {
5729
+ steps.push({
5730
+ name: "audit",
5731
+ result: { kind: "error", message: "lighthouse audit produced no real scores" }
5732
+ });
5733
+ return stop();
5734
+ }
5735
+ const scores = lighthouseScoresFromResult(lhResult);
5736
+ const websites = await listWebsites(base);
5737
+ const target = websites.find((w) => siteSlug(w.name) === siteSlug(label));
5738
+ if (!target) {
5739
+ steps.push({
5740
+ name: "audit",
5741
+ result: { kind: "error", message: `no Websites row matched site "${label}"` }
5742
+ });
5743
+ return stop();
5744
+ }
5745
+ try {
5746
+ await writeAuditsToAirtable({ base, websites, slug: siteSlug(target.name), results });
5747
+ } catch (err) {
5748
+ steps.push({ name: "audit", result: errorOf(err) });
5749
+ return stop();
5750
+ }
5751
+ steps.push({ name: "audit", result: { kind: "audit", results, scores } });
5752
+ const today = /* @__PURE__ */ new Date();
5753
+ const period = today.toISOString().slice(0, 7);
5754
+ const slug = siteSlug(target.name);
5755
+ let report;
5756
+ try {
5757
+ const existing = await findReportByPeriod(base, target.id, "Launch", period);
5758
+ report = existing ?? await createDraft(base, draftInputFor(target, scores, today, period));
5759
+ } catch (err) {
5760
+ steps.push({ name: "draft", result: errorOf(err) });
5761
+ return stop();
5762
+ }
5763
+ try {
5764
+ const { html } = await renderReportHtml({
5765
+ siteName: target.name,
5766
+ siteUrl: target.url,
5767
+ reportType: "Launch",
5768
+ completedOn: today,
5769
+ lighthouse: scores,
5770
+ lastTestedDate: null,
5771
+ commentary: null,
5772
+ copy: resolveCopy(target),
5773
+ headerImageCid: `${slug}-header`
5774
+ });
5775
+ try {
5776
+ await uploadAttachment(
5777
+ report.id,
5778
+ "Rendered HTML",
5779
+ html,
5780
+ `${slug}-${today.toISOString().slice(0, 10)}.html`,
5781
+ "text/html"
5782
+ );
5783
+ } catch (uploadErr) {
5784
+ console.warn(
5785
+ `\u26A0 Launch preview upload skipped for ${target.name}: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`
5786
+ );
5787
+ }
5788
+ await setDraftReady(base, report.id, true);
5789
+ } catch (err) {
5790
+ steps.push({ name: "draft", result: errorOf(err) });
5791
+ return stop();
5792
+ }
5793
+ steps.push({ name: "draft", result: { kind: "draft", report } });
5794
+ return { site: label, steps, complete: true };
5795
+ }
5796
+ function draftInputFor(target, scores, today, period) {
5797
+ const reportType = "Launch";
5798
+ const reportId = `${target.name} \u2014 ${reportType} \u2014 ${today.toISOString().slice(0, 10)}`;
5799
+ return {
5800
+ reportId,
5801
+ siteId: target.id,
5802
+ reportType,
5803
+ period,
5804
+ periodStart: today,
5805
+ periodEnd: today,
5806
+ completedOn: today,
5807
+ lighthouse: scores,
5808
+ lastTestedDate: null
5809
+ };
5810
+ }
5811
+ function errorOf(err) {
5812
+ return { kind: "error", message: err instanceof Error ? err.message : String(err) };
5813
+ }
5814
+
5815
+ // src/cli/commands/launch.ts
5816
+ function formatStep2(name, r) {
5817
+ if (r.kind === "error") return `${name.padEnd(20)} error: ${r.message}`;
5818
+ if (r.kind === "audit") {
5819
+ const s = r.scores;
5820
+ return `${name.padEnd(20)} audited (P=${s.performance} A=${s.accessibility} BP=${s.bestPractices} SEO=${s.seo})`;
5821
+ }
5822
+ if (r.kind === "draft") {
5823
+ return `${name.padEnd(20)} drafted ${r.report.reportId}`;
5824
+ }
5825
+ const rec = r.result;
5826
+ if (rec.status === "noop") return `${name.padEnd(20)} noop${rec.notes ? ` \u2014 ${rec.notes}` : ""}`;
5827
+ if (rec.status === "failed")
5828
+ return `${name.padEnd(20)} failed${rec.notes ? ` \u2014 ${rec.notes}` : ""}`;
5829
+ return `${name.padEnd(20)} applied (${rec.commits.length} commit${rec.commits.length === 1 ? "" : "s"})${rec.notes ? ` \u2014 ${rec.notes}` : ""}`;
5830
+ }
5831
+ function formatResult9(r) {
5832
+ const header = `[${r.site}] launch \u2014 ${r.complete ? "drafted (awaiting approval)" : "STOPPED"}`;
5833
+ const body = r.steps.map((s) => formatStep2(s.name, s.result)).join("\n");
5834
+ return `${header}
5835
+ ${body}`;
5836
+ }
5837
+ async function runLaunchCommand(site, opts) {
5838
+ const cwd = opts.cwd ? resolve11(opts.cwd) : process.cwd();
5839
+ const sites = await resolveSites({ site, cwd });
5840
+ const target = sites[0];
5841
+ if (!target) {
5842
+ return { output: `No site resolved for "${site}".`, code: 1 };
5843
+ }
5844
+ const result = await launch(target);
5845
+ return { output: formatResult9(result), code: result.complete ? 0 : 1 };
5846
+ }
5847
+
5848
+ // src/cli/commands/github-signals.ts
5849
+ init_client();
5850
+ init_websites();
5851
+
5852
+ // src/audits/github-signals.ts
5853
+ init_renovate();
5854
+ async function collectGitHubSignals(sites, deps, onSkip = () => {
5855
+ }) {
5856
+ const rows = [];
5857
+ for (const s of sites) {
5858
+ const repo = s.gitRepo;
5859
+ if (!repo) continue;
5860
+ const name = s.name;
5861
+ if (!name) continue;
5862
+ try {
5863
+ const prs = await deps.openPullRequests(repo);
5864
+ const status = await deps.defaultBranchStatus(repo);
5865
+ rows.push({
5866
+ site: name,
5867
+ repo,
5868
+ renovateFailingCis: prs.filter(isFailingRenovatePR).length,
5869
+ ciState: status.ciState,
5870
+ lastCommitAt: status.lastCommitAt
5871
+ });
5872
+ } catch {
5873
+ onSkip({ repo });
5874
+ }
5875
+ }
5876
+ return rows;
5877
+ }
5878
+
5879
+ // src/cli/commands/github-signals.ts
5880
+ init_gh();
5881
+ init_write_audits_to_airtable();
5882
+ async function runGitHubSignalsCommand(opts) {
5883
+ if (!opts.fleet || !opts.writeAirtable) {
5884
+ return { output: "github-signals currently supports only --fleet --write-airtable", code: 2 };
5885
+ }
5886
+ const token = process.env.RENOVATE_TOKEN?.trim() || process.env.GH_TOKEN?.trim();
5887
+ if (!token) {
5888
+ return {
5889
+ output: "github-signals skipped: no RENOVATE_TOKEN/GH_TOKEN (fleet read) configured.",
5890
+ code: 0
5891
+ };
5892
+ }
5893
+ const base = openBase(readAirtableConfig());
5894
+ const websites = await listWebsites(base);
5895
+ const gh = makeGitHub({ token });
5896
+ const sites = websites.map((w) => ({
5897
+ path: "",
5898
+ name: w.name,
5899
+ meta: {},
5900
+ ...w.gitRepo ? { gitRepo: w.gitRepo } : {}
5901
+ }));
5902
+ const skipped = [];
5903
+ const rows = await collectGitHubSignals(
5904
+ sites,
5905
+ {
5906
+ openPullRequests: (r) => gh.openPullRequests(r),
5907
+ defaultBranchStatus: (r) => gh.defaultBranchStatus(r)
5908
+ },
5909
+ ({ repo }) => skipped.push(repo)
5910
+ );
5911
+ const sweptAt = (/* @__PURE__ */ new Date()).toISOString();
5912
+ const result = { written: [], failed: [] };
5913
+ const byRepo = new Map(websites.filter((w) => w.gitRepo).map((w) => [w.gitRepo, w]));
5914
+ for (const row of rows) {
5915
+ const target = byRepo.get(row.repo);
5916
+ if (!target) {
5917
+ result.failed.push({ slug: siteSlug(row.site), error: "no Websites row matched" });
5918
+ continue;
5919
+ }
5920
+ try {
5921
+ await updateGitHubSignals(base, target.id, {
5922
+ renovateFailingCis: row.renovateFailingCis,
5923
+ ciState: row.ciState,
5924
+ lastCommitAt: row.lastCommitAt,
5925
+ sweptAt
5926
+ });
5927
+ result.written.push({
5928
+ siteName: target.name,
5929
+ writes: [{ audit: "github-signals", counts: row }]
5930
+ });
5931
+ } catch (e) {
5932
+ result.failed.push({ slug: siteSlug(row.site), error: e.message });
5933
+ }
5934
+ }
5935
+ for (const repo of skipped) result.failed.push({ slug: repo, error: "probe failed (skipped)" });
5936
+ return {
5937
+ output: formatFleetWriteSummary(result),
5938
+ code: result.failed.length > 0 && result.written.length === 0 ? 1 : 0
5939
+ };
5940
+ }
5941
+
5147
5942
  // src/cli/version.ts
5148
5943
  import { readFileSync as readFileSync5, existsSync as existsSync4 } from "fs";
5149
5944
  import { dirname as dirname8, join as join27 } from "path";
@@ -5286,6 +6081,12 @@ cli.command(
5286
6081
  ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
5287
6082
  async (site, opts) => runOrExit(() => runInitCommand(site, opts), opts)
5288
6083
  );
6084
+ cli.command(
6085
+ "launch <site>",
6086
+ "Bootstrap + first-audit a site, then draft its launch email for approval."
6087
+ ).action(
6088
+ async (site, opts) => runOrExit(() => runLaunchCommand(site, opts), opts)
6089
+ );
5289
6090
  cli.command("report [site]", "Draft or send maintenance/testing reports.").option("--due", "Scan all Websites and draft overdue reports.").option(
5290
6091
  "--preview",
5291
6092
  "Single-site dry run; writes reports/<slug>/draft.html, never touches Airtable."
@@ -5298,6 +6099,15 @@ cli.command("report [site]", "Draft or send maintenance/testing reports.").optio
5298
6099
  ).action(
5299
6100
  async (site, opts) => runOrExit(() => runReportCommand(site, opts), opts)
5300
6101
  );
6102
+ cli.command(
6103
+ "github-signals",
6104
+ "Sweep the fleet for GitHub signals (Renovate-failing/CI/last-commit) and write Airtable."
6105
+ ).option("--fleet", "Run across every site in the Airtable inventory.").option("--write-airtable", "Write each site's signals back to its Websites row.").action(
6106
+ async (opts) => runOrExit(
6107
+ () => runGitHubSignalsCommand({ fleet: opts.fleet, writeAirtable: opts.writeAirtable }),
6108
+ opts
6109
+ )
6110
+ );
5301
6111
  cli.help();
5302
6112
  cli.version(version);
5303
6113
  cli.parse();