@reddoorla/maintenance 0.31.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;
@@ -473,6 +756,7 @@ function mapRow2(rec) {
473
756
  reportId: String(f["Report ID"] ?? ""),
474
757
  siteId: linkSites[0] ?? "",
475
758
  reportType: f["Report type"] ?? "Maintenance",
759
+ period: f["Period"] ?? null,
476
760
  periodStart: f["Period start"] ?? null,
477
761
  periodEnd: f["Period end"] ?? null,
478
762
  completedOn: f["Completed on"] ?? null,
@@ -487,6 +771,8 @@ function mapRow2(rec) {
487
771
  draftReady: Boolean(f["Draft ready"]),
488
772
  approvedToSend: Boolean(f["Approved to send"]),
489
773
  sentAt: f["Sent at"] ?? null,
774
+ approvedAt: f["Approved At"] ?? null,
775
+ approvedBy: f["Approved By"] ?? null,
490
776
  deliveryStatus: f["Delivery status"] ?? "pending",
491
777
  renderedHtmlAttachment: html,
492
778
  resendMessageId: f["Resend message ID"] ?? null
@@ -526,6 +812,7 @@ async function createDraft(base, input) {
526
812
  if (input.gaUsersPrevious !== void 0) fields["GA users (prev period)"] = input.gaUsersPrevious;
527
813
  if (input.searchFoundPage1 !== void 0) fields["Search found page 1"] = input.searchFoundPage1;
528
814
  if (input.searchPosition !== void 0) fields["Search position"] = input.searchPosition;
815
+ if (input.period !== void 0) fields["Period"] = input.period;
529
816
  const created = await base(REPORTS_TABLE).create([{ fields }]);
530
817
  const rec = created[0];
531
818
  if (!rec) throw new Error("Airtable create returned no records");
@@ -545,18 +832,17 @@ async function listSendableReports(base) {
545
832
  });
546
833
  return out;
547
834
  }
548
- async function listReportsForSite(base, siteId) {
549
- const safeId = escapeFormulaString(siteId);
835
+ async function listAllReports(base) {
550
836
  const out = [];
551
- await base(REPORTS_TABLE).select({
552
- filterByFormula: `FIND(",${safeId},", "," & ARRAYJOIN({Site}, ",") & ",") > 0`,
553
- pageSize: 100
554
- }).eachPage((records, fetchNextPage) => {
837
+ await base(REPORTS_TABLE).select({ pageSize: 100 }).eachPage((records, fetchNextPage) => {
555
838
  for (const rec of records) out.push(mapRow2({ id: rec.id, fields: rec.fields }));
556
839
  fetchNextPage();
557
840
  });
558
841
  return out;
559
842
  }
843
+ async function listReportsForSite(base, siteId) {
844
+ return (await listAllReports(base)).filter((r) => r.siteId === siteId);
845
+ }
560
846
  async function stampSent(base, recordId, sentAt, messageId) {
561
847
  await base(REPORTS_TABLE).update([
562
848
  {
@@ -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
 
@@ -899,44 +1310,6 @@ var init_attachments = __esm({
899
1310
  }
900
1311
  });
901
1312
 
902
- // src/reports/maintenance-email/header-image.ts
903
- import sharp from "sharp";
904
- function channelToHex(value) {
905
- return Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0");
906
- }
907
- async function prepareHeaderImage(bytes, options = {}) {
908
- const requestedDisplayWidth = options.displayWidth ?? DEFAULT_DISPLAY_WIDTH;
909
- const input = Buffer.from(bytes);
910
- const meta = await sharp(input).metadata();
911
- const origWidth = meta.width;
912
- const origHeight = meta.height;
913
- if (!origWidth || !origHeight) {
914
- throw new Error("prepareHeaderImage: could not read source image dimensions");
915
- }
916
- const displayWidth = Math.min(requestedDisplayWidth, origWidth);
917
- const displayHeight = Math.round(displayWidth * origHeight / origWidth);
918
- const targetSourceWidth = Math.min(origWidth, displayWidth * RETINA_SCALE);
919
- const out = await sharp(input).resize({ width: targetSourceWidth, withoutEnlargement: true }).flatten({ background: "#ffffff" }).jpeg({ quality: JPEG_QUALITY }).toBuffer();
920
- const { dominant } = await sharp(out).stats();
921
- const placeholderColor = `#${channelToHex(dominant.r)}${channelToHex(dominant.g)}${channelToHex(dominant.b)}`;
922
- return {
923
- bytes: new Uint8Array(out),
924
- contentType: "image/jpeg",
925
- displayWidth,
926
- displayHeight,
927
- placeholderColor
928
- };
929
- }
930
- var DEFAULT_DISPLAY_WIDTH, RETINA_SCALE, JPEG_QUALITY;
931
- var init_header_image = __esm({
932
- "src/reports/maintenance-email/header-image.ts"() {
933
- "use strict";
934
- DEFAULT_DISPLAY_WIDTH = 600;
935
- RETINA_SCALE = 2;
936
- JPEG_QUALITY = 82;
937
- }
938
- });
939
-
940
1313
  // src/reports/send/resend.ts
941
1314
  import { Resend } from "resend";
942
1315
  function defaultResendClient() {
@@ -963,9 +1336,498 @@ function defaultResendClient() {
963
1336
  }
964
1337
  };
965
1338
  }
966
- var init_resend = __esm({
967
- "src/reports/send/resend.ts"() {
1339
+ var init_resend = __esm({
1340
+ "src/reports/send/resend.ts"() {
1341
+ "use strict";
1342
+ }
1343
+ });
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
+
1558
+ // src/reports/digest.ts
1559
+ var digest_exports = {};
1560
+ __export(digest_exports, {
1561
+ collectAttention: () => collectAttention,
1562
+ listPendingApproval: () => listPendingApproval,
1563
+ renderDigestHtml: () => renderDigestHtml,
1564
+ runDigest: () => runDigest
1565
+ });
1566
+ function esc(s) {
1567
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1568
+ }
1569
+ function readySection(items) {
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>`;
1571
+ if (items.length === 0) {
1572
+ return `${heading}<p style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;margin:0">Nothing waiting on you.</p>`;
1573
+ }
1574
+ const rows = items.map((it) => {
1575
+ const safeUrl = it.dashboardUrl.startsWith("https://") ? it.dashboardUrl : void 0;
1576
+ const link = safeUrl ? `<a href="${esc(safeUrl)}" style="${ANCHOR_STYLE}">review &amp; approve</a>` : `review &amp; approve`;
1577
+ return `
1578
+ <tr>
1579
+ <td style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">
1580
+ <strong style="color:#222">${esc(it.siteName)}</strong> \u2014 ${esc(it.reportType)} (${esc(it.period)})
1581
+ \u2014 ${link}
1582
+ </td>
1583
+ </tr>`;
1584
+ }).join("");
1585
+ return `${heading}<table role="presentation" style="border-collapse:collapse;margin:0">${rows}</table>`;
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
+ }
1594
+ function attentionSection(items) {
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>`;
1596
+ if (items.length === 0) {
1597
+ return `${heading}<p style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;margin:0">All clear \u2014 nothing needs attention.</p>`;
1598
+ }
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("");
1617
+ return `
1618
+ <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}`;
1622
+ }).join("");
1623
+ return `${heading}<table role="presentation" style="border-collapse:collapse;margin:0">${groups}</table>`;
1624
+ }
1625
+ function digestDateKey(d) {
1626
+ return d.toISOString().slice(0, 10);
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
+ }
1636
+ async function listPendingApproval(base) {
1637
+ return (await listAllReports(base)).filter(
1638
+ (r) => r.draftReady && !r.approvedToSend && r.sentAt === null
1639
+ );
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
+ }
1678
+ async function runDigest(options) {
1679
+ const today = /* @__PURE__ */ new Date();
1680
+ try {
1681
+ const base = options.base ?? openBase(readAirtableConfig());
1682
+ const reports = await listAllReports(base);
1683
+ const websites = await listWebsites(base);
1684
+ const sites = new Map(websites.map((w) => [w.id, w]));
1685
+ const pending = reports.filter((r) => r.draftReady && !r.approvedToSend && r.sentAt === null);
1686
+ const readyForYourYes = [];
1687
+ for (const r of pending) {
1688
+ const site = sites.get(r.siteId);
1689
+ if (!site) continue;
1690
+ readyForYourYes.push({
1691
+ siteName: site.name,
1692
+ reportType: r.reportType,
1693
+ period: r.period ?? "\u2014",
1694
+ dashboardUrl: `${options.baseUrl.replace(/\/$/, "")}/s/${siteSlug(site.name)}`
1695
+ });
1696
+ }
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;
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
+ }
1714
+ return { output: "Digest skipped (nothing ready, nothing needs attention).", code: 0 };
1715
+ }
1716
+ const html = renderDigestHtml({ readyForYourYes, needsAttention });
1717
+ const client = options.resend ?? defaultResendClient();
1718
+ const to = [process.env.OPERATOR_EMAIL?.trim() || DIGEST_OPERATOR_FALLBACK];
1719
+ const n = readyForYourYes.length;
1720
+ const reportWord = n === 1 ? "report" : "reports";
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
+ }
1744
+ return { output: `Digest sent to ${to.join(", ")} (${result.messageId})`, code: 0 };
1745
+ } catch (err) {
1746
+ if (typeof err.exitCode === "number") {
1747
+ throw err;
1748
+ }
1749
+ const message = err instanceof Error ? err.message : String(err);
1750
+ return { output: `digest failed: ${message}`, code: 1 };
1751
+ }
1752
+ }
1753
+ function renderDigestHtml(sections) {
1754
+ return `<!doctype html>
1755
+ <html>
1756
+ <head><meta charset="utf-8"></head>
1757
+ <body style="margin:0;padding:0;background:#ffffff">
1758
+ <table width="100%" style="border-collapse:collapse">
1759
+ <tr>
1760
+ <td align="center" style="padding:24px">
1761
+ <table width="600" style="border-collapse:collapse">
1762
+ <tr>
1763
+ <td>
1764
+ <h1 style="color:${RED2};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px">Your fleet today</h1>
1765
+ ${readySection(sections.readyForYourYes)}
1766
+ ${attentionSection(sections.needsAttention)}
1767
+ </td>
1768
+ </tr>
1769
+ </table>
1770
+ </td>
1771
+ </tr>
1772
+ </table>
1773
+ </body>
1774
+ </html>`;
1775
+ }
1776
+ var GREY2, RED2, ANCHOR_STYLE, SEVERITY_ORDER, FROM_ADDRESS, DIGEST_OPERATOR_FALLBACK;
1777
+ var init_digest = __esm({
1778
+ "src/reports/digest.ts"() {
1779
+ "use strict";
1780
+ init_client();
1781
+ init_reports();
1782
+ init_websites();
1783
+ init_resend();
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 };
1791
+ FROM_ADDRESS = "Reddoor Reports <reports@reddoorla.com>";
1792
+ DIGEST_OPERATOR_FALLBACK = "info@reddoorla.com";
1793
+ }
1794
+ });
1795
+
1796
+ // src/reports/maintenance-email/header-image.ts
1797
+ import sharp from "sharp";
1798
+ function channelToHex(value) {
1799
+ return Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0");
1800
+ }
1801
+ async function prepareHeaderImage(bytes, options = {}) {
1802
+ const requestedDisplayWidth = options.displayWidth ?? DEFAULT_DISPLAY_WIDTH;
1803
+ const input = Buffer.from(bytes);
1804
+ const meta = await sharp(input).metadata();
1805
+ const origWidth = meta.width;
1806
+ const origHeight = meta.height;
1807
+ if (!origWidth || !origHeight) {
1808
+ throw new Error("prepareHeaderImage: could not read source image dimensions");
1809
+ }
1810
+ const displayWidth = Math.min(requestedDisplayWidth, origWidth);
1811
+ const displayHeight = Math.round(displayWidth * origHeight / origWidth);
1812
+ const targetSourceWidth = Math.min(origWidth, displayWidth * RETINA_SCALE);
1813
+ const out = await sharp(input).resize({ width: targetSourceWidth, withoutEnlargement: true }).flatten({ background: "#ffffff" }).jpeg({ quality: JPEG_QUALITY }).toBuffer();
1814
+ const { dominant } = await sharp(out).stats();
1815
+ const placeholderColor = `#${channelToHex(dominant.r)}${channelToHex(dominant.g)}${channelToHex(dominant.b)}`;
1816
+ return {
1817
+ bytes: new Uint8Array(out),
1818
+ contentType: "image/jpeg",
1819
+ displayWidth,
1820
+ displayHeight,
1821
+ placeholderColor
1822
+ };
1823
+ }
1824
+ var DEFAULT_DISPLAY_WIDTH, RETINA_SCALE, JPEG_QUALITY;
1825
+ var init_header_image = __esm({
1826
+ "src/reports/maintenance-email/header-image.ts"() {
968
1827
  "use strict";
1828
+ DEFAULT_DISPLAY_WIDTH = 600;
1829
+ RETINA_SCALE = 2;
1830
+ JPEG_QUALITY = 82;
969
1831
  }
970
1832
  });
971
1833
 
@@ -1006,6 +1868,14 @@ async function sendApprovedReports(options = {}) {
1006
1868
  try {
1007
1869
  const messageId = await sendOne(client, base, site, report);
1008
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
+ }
1009
1879
  } catch (e) {
1010
1880
  lines.push(`\u2717 ${report.reportId} \u2014 ${e.message}`);
1011
1881
  anyFailed = true;
@@ -1038,6 +1908,7 @@ async function sendOne(client, base, site, report) {
1038
1908
  searchPosition: report.searchFoundPage1 && report.searchPosition !== null ? report.searchPosition : void 0,
1039
1909
  lastTestedDate: report.lastTestedDate ? new Date(report.lastTestedDate) : null,
1040
1910
  commentary: report.commentary,
1911
+ copy: resolveCopy(site),
1041
1912
  headerImageCid: cidName,
1042
1913
  headerWidth: header.displayWidth,
1043
1914
  headerHeight: header.displayHeight,
@@ -1071,7 +1942,7 @@ async function sendOne(client, base, site, report) {
1071
1942
  }
1072
1943
  }
1073
1944
  const payload = {
1074
- from: FROM_ADDRESS,
1945
+ from: FROM_ADDRESS2,
1075
1946
  to,
1076
1947
  replyTo: REPLY_TO,
1077
1948
  subject,
@@ -1132,7 +2003,7 @@ function isProbablyEmail(s) {
1132
2003
  if (/\s/.test(s)) return false;
1133
2004
  return true;
1134
2005
  }
1135
- var FROM_ADDRESS, REPLY_TO, MONTHS2;
2006
+ var FROM_ADDRESS2, REPLY_TO, MONTHS2;
1136
2007
  var init_orchestrate = __esm({
1137
2008
  "src/reports/send/orchestrate.ts"() {
1138
2009
  "use strict";
@@ -1141,10 +2012,11 @@ var init_orchestrate = __esm({
1141
2012
  init_websites();
1142
2013
  init_attachments();
1143
2014
  init_render();
2015
+ init_copy();
1144
2016
  init_assets();
1145
2017
  init_header_image();
1146
2018
  init_resend();
1147
- FROM_ADDRESS = "Reddoor Reports <reports@reddoorla.com>";
2019
+ FROM_ADDRESS2 = "Reddoor Reports <reports@reddoorla.com>";
1148
2020
  REPLY_TO = "info@reddoorla.com";
1149
2021
  MONTHS2 = [
1150
2022
  "January",
@@ -1173,72 +2045,8 @@ import { cac } from "cac";
1173
2045
  import { resolve as resolve2 } from "path";
1174
2046
  import { Listr } from "listr2";
1175
2047
 
1176
- // src/audits/util/spawn.ts
1177
- import { spawn } from "child_process";
1178
- var TRUNCATION_MARKER = "\n\u2026[output truncated]";
1179
- function makeSpawn(internals = {}) {
1180
- const spawnImpl = internals.spawnImpl ?? spawn;
1181
- const killImpl = internals.killImpl ?? ((pid, sig) => process.kill(pid, sig));
1182
- const killGraceMs = internals.killGraceMs ?? 5e3;
1183
- const maxOutputBytes = internals.maxOutputBytes ?? 10 * 1024 * 1024;
1184
- return (cmd, args, opts = {}) => new Promise((resolve11, reject) => {
1185
- const streaming = opts.streaming === true;
1186
- const child = spawnImpl(cmd, [...args], {
1187
- cwd: opts.cwd,
1188
- env: opts.env ?? process.env,
1189
- stdio: streaming ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "pipe"],
1190
- // Detach ONLY when a timeout can fire: the child then leads its own
1191
- // process group, so the timeout can kill the WHOLE tree (vite, and
1192
- // Chromium under lhci/playwright) via process.kill(-pid), not just the
1193
- // npx/pnpm wrapper. Without it, killing the wrapper orphaned the
1194
- // grandchildren — a zombie vite squatting its port, Chrome left running.
1195
- // We do NOT detach timeout-less streaming calls (pnpm install/up):
1196
- // detaching gains nothing there (no timeout → no group-kill) and would
1197
- // break terminal Ctrl-C, which only reaches the foreground group — i.e.
1198
- // it would re-orphan the very children this guards. We never unref() the
1199
- // child since we still await it.
1200
- detached: opts.timeoutMs !== void 0
1201
- });
1202
- const cap = (acc, chunk) => {
1203
- if (acc.length >= maxOutputBytes) return acc;
1204
- const next = acc + chunk;
1205
- return next.length > maxOutputBytes ? next.slice(0, maxOutputBytes) + TRUNCATION_MARKER : next;
1206
- };
1207
- let stdout = "";
1208
- let stderr = "";
1209
- if (!streaming) {
1210
- child.stdout?.on("data", (chunk) => stdout = cap(stdout, String(chunk)));
1211
- child.stderr?.on("data", (chunk) => stderr = cap(stderr, String(chunk)));
1212
- }
1213
- const killGroup = (sig) => {
1214
- if (child.pid === void 0) return;
1215
- try {
1216
- killImpl(-child.pid, sig);
1217
- } catch {
1218
- }
1219
- };
1220
- let killTimer;
1221
- const timer = opts.timeoutMs ? setTimeout(() => {
1222
- killGroup("SIGTERM");
1223
- killTimer = setTimeout(() => killGroup("SIGKILL"), killGraceMs);
1224
- killTimer.unref();
1225
- reject(new Error(`spawn timeout after ${opts.timeoutMs}ms: ${cmd}`));
1226
- }, opts.timeoutMs) : void 0;
1227
- const clearTimers = () => {
1228
- if (timer) clearTimeout(timer);
1229
- if (killTimer) clearTimeout(killTimer);
1230
- };
1231
- child.on("error", (err) => {
1232
- clearTimers();
1233
- reject(err);
1234
- });
1235
- child.on("close", (code) => {
1236
- clearTimers();
1237
- resolve11({ code: code ?? -1, stdout, stderr });
1238
- });
1239
- });
1240
- }
1241
- var defaultSpawn = makeSpawn();
2048
+ // src/audits/index.ts
2049
+ init_spawn();
1242
2050
 
1243
2051
  // src/audits/deps.ts
1244
2052
  import { readFile } from "fs/promises";
@@ -1288,6 +2096,9 @@ var baselineVersions = {
1288
2096
  "@zerodevx/svelte-img": "^2.1.2"
1289
2097
  };
1290
2098
 
2099
+ // src/audits/deps.ts
2100
+ init_spawn();
2101
+
1291
2102
  // src/audits/deps-outdated.ts
1292
2103
  import { stat } from "fs/promises";
1293
2104
  import { join as join2 } from "path";
@@ -1467,6 +2278,7 @@ async function lintAudit(ctx) {
1467
2278
  }
1468
2279
 
1469
2280
  // src/audits/security.ts
2281
+ init_spawn();
1470
2282
  function classify(v) {
1471
2283
  if (v.critical > 0 || v.high > 0) return "fail";
1472
2284
  if (v.moderate > 0 || v.low > 0) return "warn";
@@ -1641,6 +2453,9 @@ var lighthouseConfig = {
1641
2453
  }
1642
2454
  };
1643
2455
 
2456
+ // src/audits/lighthouse.ts
2457
+ init_spawn();
2458
+
1644
2459
  // src/audits/util/site-config.ts
1645
2460
  import { readFile as readFile3 } from "fs/promises";
1646
2461
  import { join as join5 } from "path";
@@ -1671,7 +2486,7 @@ async function readSiteConfig(sitePath) {
1671
2486
  // src/util/free-port.ts
1672
2487
  import { createServer } from "net";
1673
2488
  async function findFreePort() {
1674
- return new Promise((resolve11, reject) => {
2489
+ return new Promise((resolve12, reject) => {
1675
2490
  const server = createServer();
1676
2491
  server.unref();
1677
2492
  server.on("error", reject);
@@ -1679,7 +2494,7 @@ async function findFreePort() {
1679
2494
  const addr = server.address();
1680
2495
  if (typeof addr === "object" && addr) {
1681
2496
  const port = addr.port;
1682
- server.close(() => resolve11(port));
2497
+ server.close(() => resolve12(port));
1683
2498
  } else {
1684
2499
  server.close();
1685
2500
  reject(new Error("findFreePort: could not determine assigned port from socket"));
@@ -1909,6 +2724,7 @@ var playwrightA11yConfig = defineConfig({
1909
2724
  });
1910
2725
 
1911
2726
  // src/audits/a11y.ts
2727
+ init_spawn();
1912
2728
  var RESULTS_REL = ".reddoor-a11y/results.json";
1913
2729
  async function readJsonMaybe2(path) {
1914
2730
  try {
@@ -2225,6 +3041,7 @@ async function resolveSites(input) {
2225
3041
  }
2226
3042
 
2227
3043
  // src/cli/fleet/clone-if-needed.ts
3044
+ init_spawn();
2228
3045
  import { stat as stat2, readdir as readdir2, mkdir } from "fs/promises";
2229
3046
  import { isAbsolute as isAbsolute2, join as join8 } from "path";
2230
3047
  function deriveNameFromRepoUrl(repoUrl) {
@@ -3059,6 +3876,7 @@ async function runSyncConfigsCommand(site, opts) {
3059
3876
  import { resolve as resolve4 } from "path";
3060
3877
 
3061
3878
  // src/recipes/bump-deps.ts
3879
+ init_spawn();
3062
3880
  import { stat as stat3 } from "fs/promises";
3063
3881
  import { join as join12 } from "path";
3064
3882
  async function exists2(path) {
@@ -3084,247 +3902,97 @@ async function bumpDeps(site, opts = {}) {
3084
3902
  return withRecipe({
3085
3903
  name: "bump-deps",
3086
3904
  site,
3087
- // pnpm install (in plan) mutates the lockfile, so the clean-tree check
3088
- // MUST happen first — otherwise a desynced-lockfile resync would silently
3089
- // land on top of whatever else was in the tree.
3090
- checkTreeFirst: true,
3091
- plan: async () => {
3092
- const hasPnpmLock = await exists2(join12(site.path, "pnpm-lock.yaml"));
3093
- if (!hasPnpmLock) {
3094
- const hasNpmLock = await exists2(join12(site.path, "package-lock.json"));
3095
- const hasYarnLock = await exists2(join12(site.path, "yarn.lock"));
3096
- if (hasNpmLock || hasYarnLock) {
3097
- const competing = hasNpmLock ? "package-lock.json" : "yarn.lock";
3098
- return {
3099
- kind: "failed",
3100
- notes: `site has ${competing} but no pnpm-lock.yaml \u2014 run convert-to-pnpm first`
3101
- };
3102
- }
3103
- }
3104
- await spawn2("pnpm", ["install"], { cwd: site.path, streaming: true });
3105
- const outdated = await spawn2(
3106
- "pnpm",
3107
- ["outdated", "--json", ...outdatedFlagsForGroup(group)],
3108
- { cwd: site.path }
3109
- );
3110
- let parsed;
3111
- try {
3112
- parsed = JSON.parse(outdated.stdout || "{}");
3113
- } catch {
3114
- parsed = {};
3115
- }
3116
- if (Object.keys(parsed).length === 0) {
3117
- return { kind: "noop", notes: `pnpm outdated reported nothing for group=${group}` };
3118
- }
3119
- return { kind: "apply", plan: { group } };
3120
- },
3121
- apply: async ({ group: g }, { commit: commit2, cwd }) => {
3122
- await spawn2("pnpm", ["up", ...upFlagsForGroup(g)], { cwd, streaming: true });
3123
- await commit2(`chore(deps): bump dependencies (${g})`);
3124
- return { kind: "ok" };
3125
- }
3126
- });
3127
- }
3128
-
3129
- // src/cli/commands/bump-deps.ts
3130
- var GROUPS = ["patch", "minor", "major"];
3131
- function formatResult2(r) {
3132
- if (r.status === "noop") return `[${r.site}] noop: ${r.notes ?? ""}`;
3133
- return `[${r.site}] applied: ${r.commits.length} commit(s)
3134
- ${r.notes ?? ""}`;
3135
- }
3136
- async function runBumpDepsCommand(site, opts) {
3137
- const group = opts.group ?? "minor";
3138
- if (!GROUPS.includes(group)) {
3139
- throw Object.assign(
3140
- new Error(`unknown --group: ${group}. expected one of ${GROUPS.join(", ")}`),
3141
- { exitCode: 2 }
3142
- );
3143
- }
3144
- const cwd = opts.cwd ? resolve4(opts.cwd) : process.cwd();
3145
- let sites = await resolveSites({
3146
- ...site !== void 0 ? { site } : {},
3147
- ...opts.fleet !== void 0 ? { fleet: opts.fleet } : {},
3148
- cwd
3149
- });
3150
- if (opts.fleet) {
3151
- const workdir = opts.workdir ?? `${process.env.HOME ?? ""}/.reddoor-maint/sites`;
3152
- sites = await Promise.all(sites.map((s) => cloneIfNeeded(s, { workdir })));
3153
- }
3154
- const results = [];
3155
- for (const s of sites) results.push(await bumpDeps(s, { group }));
3156
- const output = results.map(formatResult2).join("\n");
3157
- const code = results.some((r) => r.status === "failed") ? 1 : 0;
3158
- return { output, code };
3159
- }
3160
-
3161
- // src/cli/commands/self-updating.ts
3162
- import { resolve as resolve5 } from "path";
3163
-
3164
- // src/recipes/self-updating/index.ts
3165
- import { mkdir as mkdir3, writeFile as writeFile4 } from "fs/promises";
3166
- import { dirname as dirname2, join as join13 } from "path";
3167
-
3168
- // src/github/config.ts
3169
- function readGitHubConfig() {
3170
- const token = process.env.GITHUB_TOKEN?.trim();
3171
- if (!token) return null;
3172
- const renovateToken = process.env.RENOVATE_TOKEN?.trim() || token;
3173
- return { token, renovateToken };
3174
- }
3175
-
3176
- // src/github/gh.ts
3177
- function mapRollupState(state) {
3178
- switch (state) {
3179
- case "SUCCESS":
3180
- return "passing";
3181
- case "FAILURE":
3182
- case "ERROR":
3183
- return "failing";
3184
- case "PENDING":
3185
- case "EXPECTED":
3186
- return "pending";
3187
- default:
3188
- return "none";
3189
- }
3190
- }
3191
- function makeGitHub(deps) {
3192
- const spawn2 = deps.spawn ?? defaultSpawn;
3193
- const env = { ...process.env, GH_TOKEN: deps.token };
3194
- async function gh(args) {
3195
- const r = await spawn2("gh", args, { env, timeoutMs: 6e4 });
3196
- if (r.code !== 0) throw new Error(`gh ${args[0]} failed (code ${r.code}): ${r.stderr.trim()}`);
3197
- return r.stdout;
3198
- }
3199
- return {
3200
- async openPullRequest(repo, pr) {
3201
- const out = await gh([
3202
- "pr",
3203
- "create",
3204
- "--repo",
3205
- repo,
3206
- "--head",
3207
- pr.head,
3208
- "--base",
3209
- pr.base,
3210
- "--title",
3211
- pr.title,
3212
- "--body",
3213
- pr.body
3214
- ]);
3215
- return { url: out.trim() };
3216
- },
3217
- async enableRepoAutoMerge(repo) {
3218
- await gh(["api", "-X", "PATCH", `repos/${repo}`, "-F", "allow_auto_merge=true"]);
3219
- },
3220
- async protectBranch(repo, branch, requiredChecks) {
3221
- const args = [
3222
- "api",
3223
- "-X",
3224
- "PUT",
3225
- `repos/${repo}/branches/${branch}/protection`,
3226
- "-H",
3227
- "Accept: application/vnd.github+json",
3228
- "-F",
3229
- "required_status_checks[strict]=true",
3230
- ...requiredChecks.flatMap((c) => ["-f", `required_status_checks[contexts][]=${c}`]),
3231
- "-F",
3232
- "enforce_admins=true",
3233
- "-F",
3234
- "required_pull_request_reviews=null",
3235
- "-F",
3236
- "restrictions=null"
3237
- ];
3238
- await gh(args);
3239
- },
3240
- async setRepoSecret(repo, name, value) {
3241
- await gh(["secret", "set", name, "--repo", repo, "--body", value]);
3242
- },
3243
- async repoExists(repo) {
3244
- const r = await spawn2("gh", ["api", `repos/${repo}`], { env, timeoutMs: 6e4 });
3245
- return r.code === 0;
3246
- },
3247
- async defaultBranch(repo) {
3248
- const out = await gh(["api", `repos/${repo}`, "--jq", ".default_branch"]);
3249
- return out.trim();
3250
- },
3251
- // filesOnBranch and branchProtectionContexts call `spawn` directly (not the
3252
- // throwing `gh()` helper) because a 404 is an expected, meaningful answer —
3253
- // "file/protection absent" — not an error. The remaining readers use `gh()`
3254
- // since a non-200 there is a genuine failure (e.g. missing token scope).
3255
- async filesOnBranch(repo, branch, paths) {
3256
- const present = [];
3257
- for (const p of paths) {
3258
- const r = await spawn2("gh", [`api`, `repos/${repo}/contents/${p}?ref=${branch}`], {
3259
- env,
3260
- timeoutMs: 6e4
3261
- });
3262
- if (r.code === 0) present.push(p);
3905
+ // pnpm install (in plan) mutates the lockfile, so the clean-tree check
3906
+ // MUST happen first — otherwise a desynced-lockfile resync would silently
3907
+ // land on top of whatever else was in the tree.
3908
+ checkTreeFirst: true,
3909
+ plan: async () => {
3910
+ const hasPnpmLock = await exists2(join12(site.path, "pnpm-lock.yaml"));
3911
+ if (!hasPnpmLock) {
3912
+ const hasNpmLock = await exists2(join12(site.path, "package-lock.json"));
3913
+ const hasYarnLock = await exists2(join12(site.path, "yarn.lock"));
3914
+ if (hasNpmLock || hasYarnLock) {
3915
+ const competing = hasNpmLock ? "package-lock.json" : "yarn.lock";
3916
+ return {
3917
+ kind: "failed",
3918
+ notes: `site has ${competing} but no pnpm-lock.yaml \u2014 run convert-to-pnpm first`
3919
+ };
3920
+ }
3263
3921
  }
3264
- return present;
3265
- },
3266
- async branchProtectionContexts(repo, branch) {
3267
- const r = await spawn2(
3268
- "gh",
3269
- [
3270
- "api",
3271
- `repos/${repo}/branches/${branch}/protection`,
3272
- "--jq",
3273
- ".required_status_checks.contexts[]?"
3274
- ],
3275
- { env, timeoutMs: 6e4 }
3922
+ await spawn2("pnpm", ["install"], { cwd: site.path, streaming: true });
3923
+ const outdated = await spawn2(
3924
+ "pnpm",
3925
+ ["outdated", "--json", ...outdatedFlagsForGroup(group)],
3926
+ { cwd: site.path }
3276
3927
  );
3277
- if (r.code !== 0) return [];
3278
- return r.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
3279
- },
3280
- async secretExists(repo, name) {
3281
- const out = await gh(["api", `repos/${repo}/actions/secrets`, "--jq", ".secrets[].name"]);
3282
- return out.split("\n").map((l) => l.trim()).includes(name);
3283
- },
3284
- async autoMergeEnabled(repo) {
3285
- const out = await gh(["api", `repos/${repo}`, "--jq", ".allow_auto_merge"]);
3286
- return out.trim() === "true";
3287
- },
3288
- async findOpenSelfUpdatingPR(repo) {
3289
- const out = await gh([
3290
- "api",
3291
- `repos/${repo}/pulls?state=open`,
3292
- "--jq",
3293
- '.[] | select(.head.ref | startswith("maint/self-updating-")) | .html_url'
3294
- ]);
3295
- const first = out.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
3296
- return first ?? null;
3297
- },
3298
- async openPullRequests(repo) {
3299
- const [owner, name, ...rest] = repo.split("/");
3300
- if (!owner || !name || rest.length > 0) {
3301
- throw new Error(`openPullRequests: expected "owner/repo", got "${repo}"`);
3928
+ let parsed;
3929
+ try {
3930
+ parsed = JSON.parse(outdated.stdout || "{}");
3931
+ } catch {
3932
+ parsed = {};
3302
3933
  }
3303
- 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}}}}}}}}";
3304
- const out = await gh([
3305
- "api",
3306
- "graphql",
3307
- "-f",
3308
- `query=${query}`,
3309
- "-F",
3310
- `owner=${owner}`,
3311
- "-F",
3312
- `name=${name}`
3313
- ]);
3314
- const parsed = JSON.parse(out);
3315
- const nodes = parsed.data?.repository?.pullRequests?.nodes ?? [];
3316
- return nodes.map((n) => ({
3317
- number: n.number,
3318
- title: n.title,
3319
- url: n.url,
3320
- headRef: n.headRefName,
3321
- ciState: mapRollupState(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state)
3322
- }));
3934
+ if (Object.keys(parsed).length === 0) {
3935
+ return { kind: "noop", notes: `pnpm outdated reported nothing for group=${group}` };
3936
+ }
3937
+ return { kind: "apply", plan: { group } };
3938
+ },
3939
+ apply: async ({ group: g }, { commit: commit2, cwd }) => {
3940
+ await spawn2("pnpm", ["up", ...upFlagsForGroup(g)], { cwd, streaming: true });
3941
+ await commit2(`chore(deps): bump dependencies (${g})`);
3942
+ return { kind: "ok" };
3323
3943
  }
3324
- };
3944
+ });
3945
+ }
3946
+
3947
+ // src/cli/commands/bump-deps.ts
3948
+ var GROUPS = ["patch", "minor", "major"];
3949
+ function formatResult2(r) {
3950
+ if (r.status === "noop") return `[${r.site}] noop: ${r.notes ?? ""}`;
3951
+ return `[${r.site}] applied: ${r.commits.length} commit(s)
3952
+ ${r.notes ?? ""}`;
3953
+ }
3954
+ async function runBumpDepsCommand(site, opts) {
3955
+ const group = opts.group ?? "minor";
3956
+ if (!GROUPS.includes(group)) {
3957
+ throw Object.assign(
3958
+ new Error(`unknown --group: ${group}. expected one of ${GROUPS.join(", ")}`),
3959
+ { exitCode: 2 }
3960
+ );
3961
+ }
3962
+ const cwd = opts.cwd ? resolve4(opts.cwd) : process.cwd();
3963
+ let sites = await resolveSites({
3964
+ ...site !== void 0 ? { site } : {},
3965
+ ...opts.fleet !== void 0 ? { fleet: opts.fleet } : {},
3966
+ cwd
3967
+ });
3968
+ if (opts.fleet) {
3969
+ const workdir = opts.workdir ?? `${process.env.HOME ?? ""}/.reddoor-maint/sites`;
3970
+ sites = await Promise.all(sites.map((s) => cloneIfNeeded(s, { workdir })));
3971
+ }
3972
+ const results = [];
3973
+ for (const s of sites) results.push(await bumpDeps(s, { group }));
3974
+ const output = results.map(formatResult2).join("\n");
3975
+ const code = results.some((r) => r.status === "failed") ? 1 : 0;
3976
+ return { output, code };
3977
+ }
3978
+
3979
+ // src/cli/commands/self-updating.ts
3980
+ import { resolve as resolve5 } from "path";
3981
+
3982
+ // src/recipes/self-updating/index.ts
3983
+ import { mkdir as mkdir3, writeFile as writeFile4 } from "fs/promises";
3984
+ import { dirname as dirname2, join as join13 } from "path";
3985
+
3986
+ // src/github/config.ts
3987
+ function readGitHubConfig() {
3988
+ const token = process.env.GITHUB_TOKEN?.trim();
3989
+ if (!token) return null;
3990
+ const renovateToken = process.env.RENOVATE_TOKEN?.trim() || token;
3991
+ return { token, renovateToken };
3325
3992
  }
3326
3993
 
3327
3994
  // src/recipes/self-updating/index.ts
3995
+ init_gh();
3328
3996
  var SELF_UPDATING_CONFIGS = ["ci", "renovate-action", "renovate-config"];
3329
3997
  var REQUIRED_CHECK = "ci / ci";
3330
3998
  function resultOf(site, status, notes, commits = []) {
@@ -3493,6 +4161,9 @@ function bumpDep(pkg, name, version2, opts = {}) {
3493
4161
  return next;
3494
4162
  }
3495
4163
 
4164
+ // src/recipes/svelte-5/index.ts
4165
+ init_spawn();
4166
+
3496
4167
  // src/recipes/svelte-5/step-bump-versions.ts
3497
4168
  import { join as join14 } from "path";
3498
4169
  var SVELTE_5_VERSIONS = {
@@ -3577,6 +4248,7 @@ async function migrateSvelteConfig(cwd) {
3577
4248
  }
3578
4249
 
3579
4250
  // src/recipes/svelte-5/step-svelte-migrate.ts
4251
+ init_spawn();
3580
4252
  async function runSvelteMigrate(cwd, spawn2 = defaultSpawn) {
3581
4253
  try {
3582
4254
  const { code, stderr } = await spawn2(
@@ -3598,6 +4270,7 @@ async function runSvelteMigrate(cwd, spawn2 = defaultSpawn) {
3598
4270
  }
3599
4271
 
3600
4272
  // src/recipes/svelte-5/step-tailwind-upgrade.ts
4273
+ init_spawn();
3601
4274
  import { join as join16 } from "path";
3602
4275
  async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
3603
4276
  const pkg = await readPackageJson(join16(cwd, "package.json"));
@@ -3984,6 +4657,7 @@ async function applyGotchaCodemods(cwd) {
3984
4657
  }
3985
4658
 
3986
4659
  // src/recipes/svelte-5/step-verify.ts
4660
+ init_spawn();
3987
4661
  async function verifyMigration(cwd, spawn2 = defaultSpawn) {
3988
4662
  let install;
3989
4663
  try {
@@ -4123,6 +4797,7 @@ import { resolve as resolve7 } from "path";
4123
4797
  // src/recipes/convert-to-pnpm.ts
4124
4798
  import { rm as rm3, stat as stat4 } from "fs/promises";
4125
4799
  import { join as join20 } from "path";
4800
+ init_spawn();
4126
4801
 
4127
4802
  // src/recipes/convert-to-pnpm/script-rewrites.ts
4128
4803
  function rewriteScriptForPnpm(script) {
@@ -4235,6 +4910,7 @@ import { resolve as resolve8 } from "path";
4235
4910
  // src/recipes/onboard.ts
4236
4911
  import { stat as stat5 } from "fs/promises";
4237
4912
  import { join as join22 } from "path";
4913
+ init_spawn();
4238
4914
 
4239
4915
  // src/util/self-version.ts
4240
4916
  import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
@@ -4520,10 +5196,17 @@ function findDueReports(websites, reports, today) {
4520
5196
  }
4521
5197
  return out;
4522
5198
  }
5199
+ function reportPeriodKey(dueDate) {
5200
+ if (Number.isNaN(dueDate.getTime())) throw new TypeError("reportPeriodKey: invalid Date");
5201
+ const year = dueDate.getUTCFullYear();
5202
+ const month = String(dueDate.getUTCMonth() + 1).padStart(2, "0");
5203
+ return `${year}-${month}`;
5204
+ }
4523
5205
 
4524
5206
  // src/reports/draft.ts
4525
5207
  init_render();
4526
5208
  init_websites();
5209
+ init_copy();
4527
5210
  init_reports();
4528
5211
  init_attachments();
4529
5212
  import { mkdir as mkdir4, writeFile as writeFile10 } from "fs/promises";
@@ -4684,6 +5367,7 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
4684
5367
  searchPosition: search?.foundOnPage1 ? search.position ?? void 0 : void 0,
4685
5368
  lastTestedDate,
4686
5369
  commentary: null,
5370
+ copy: resolveCopy(siteRow),
4687
5371
  headerImageCid: cidName
4688
5372
  });
4689
5373
  if (options.previewOnly) {
@@ -4698,6 +5382,7 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
4698
5382
  reportId,
4699
5383
  siteId: siteRow.id,
4700
5384
  reportType,
5385
+ period: options.period ?? periodEnd.toISOString().slice(0, 7),
4701
5386
  periodStart,
4702
5387
  periodEnd,
4703
5388
  completedOn,
@@ -4760,7 +5445,14 @@ async function derivePeriodStart(base, siteRow, reportType, today) {
4760
5445
  }
4761
5446
 
4762
5447
  // src/cli/commands/report.ts
5448
+ function dashboardBaseUrl() {
5449
+ return process.env.DASHBOARD_BASE_URL?.trim() || "https://reddoor-maintenance.netlify.app";
5450
+ }
4763
5451
  async function runReportCommand(slug, opts) {
5452
+ if (opts.digest) {
5453
+ const { runDigest: runDigest2 } = await Promise.resolve().then(() => (init_digest(), digest_exports));
5454
+ return runDigest2({ baseUrl: dashboardBaseUrl() });
5455
+ }
4764
5456
  if (opts.sendReady) {
4765
5457
  const { sendApprovedReports: sendApprovedReports2 } = await Promise.resolve().then(() => (init_orchestrate(), orchestrate_exports));
4766
5458
  return sendApprovedReports2();
@@ -4772,7 +5464,7 @@ async function runReportCommand(slug, opts) {
4772
5464
  return runSingleSiteDraft(slug, { previewOnly: Boolean(opts.preview) });
4773
5465
  }
4774
5466
  throw Object.assign(
4775
- new Error("Usage: reddoor-maint report [<slug>] [--due] [--preview] [--send-ready]"),
5467
+ new Error("Usage: reddoor-maint report [<slug>] [--due] [--preview] [--send-ready] [--digest]"),
4776
5468
  {
4777
5469
  exitCode: 2
4778
5470
  }
@@ -4780,25 +5472,38 @@ async function runReportCommand(slug, opts) {
4780
5472
  }
4781
5473
  async function runDueDraft() {
4782
5474
  const base = openBase(readAirtableConfig());
5475
+ return draftDueReports(base, /* @__PURE__ */ new Date());
5476
+ }
5477
+ async function draftDueReports(base, today) {
4783
5478
  const websites = await listWebsites(base);
4784
- const reports = [];
4785
- for (const w of websites) {
4786
- const rs = await listReportsForSite(base, w.id);
4787
- reports.push(...rs);
4788
- }
4789
- const due = findDueReports(websites, reports, /* @__PURE__ */ new Date());
5479
+ const reports = await listAllReports(base);
5480
+ const due = findDueReports(websites, reports, today);
4790
5481
  if (due.length === 0) return { output: "No reports due.", code: 0 };
4791
5482
  const lines = [];
4792
5483
  let softFailedSites = 0;
5484
+ let skipped = 0;
4793
5485
  for (const item of due) {
5486
+ const period = reportPeriodKey(item.dueDate);
5487
+ const already = reports.some(
5488
+ (r) => r.siteId === item.site.id && r.reportType === item.reportType && r.period === period
5489
+ );
5490
+ if (already) {
5491
+ skipped++;
5492
+ lines.push(`\u2022 skipped (already drafted ${period}): ${item.site.name} ${item.reportType}`);
5493
+ continue;
5494
+ }
4794
5495
  try {
4795
- const result = await draftReportForSite(base, item.site, item.reportType);
5496
+ const result = await draftReportForSite(base, item.site, item.reportType, { period });
4796
5497
  lines.push(`\u2713 drafted: ${result.reportRow?.reportId}`);
5498
+ if (result.reportRow) reports.push(result.reportRow);
4797
5499
  if (result.softFailures.length > 0) softFailedSites++;
4798
5500
  } catch (e) {
4799
5501
  lines.push(`\u2717 failed: ${item.site.name} ${item.reportType} \u2014 ${e.message}`);
4800
5502
  }
4801
5503
  }
5504
+ if (skipped > 0) {
5505
+ lines.push(`\u2022 ${skipped} already drafted this period`);
5506
+ }
4802
5507
  if (softFailedSites > 0) {
4803
5508
  lines.push(
4804
5509
  `\u26A0 ${softFailedSites} site${softFailedSites === 1 ? "" : "s"} had GA/Search enrichment fail \u2014 drafted with blank analytics; check the logs above`
@@ -4984,6 +5689,256 @@ async function runInitCommand(site, opts) {
4984
5689
  return { output, code };
4985
5690
  }
4986
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
+
4987
5942
  // src/cli/version.ts
4988
5943
  import { readFileSync as readFileSync5, existsSync as existsSync4 } from "fs";
4989
5944
  import { dirname as dirname8, join as join27 } from "path";
@@ -5126,15 +6081,33 @@ cli.command(
5126
6081
  ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
5127
6082
  async (site, opts) => runOrExit(() => runInitCommand(site, opts), opts)
5128
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
+ );
5129
6090
  cli.command("report [site]", "Draft or send maintenance/testing reports.").option("--due", "Scan all Websites and draft overdue reports.").option(
5130
6091
  "--preview",
5131
6092
  "Single-site dry run; writes reports/<slug>/draft.html, never touches Airtable."
5132
6093
  ).option(
5133
6094
  "--send-ready",
5134
6095
  "Send all Reports with Draft ready=true AND Approved to send=true AND Sent at IS NULL."
6096
+ ).option(
6097
+ "--digest",
6098
+ "Email the operator one daily digest of reports ready for approval (skips when empty)."
5135
6099
  ).action(
5136
6100
  async (site, opts) => runOrExit(() => runReportCommand(site, opts), opts)
5137
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
+ );
5138
6111
  cli.help();
5139
6112
  cli.version(version);
5140
6113
  cli.parse();