@reddoorla/maintenance 0.40.0 → 0.42.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
@@ -118,6 +118,7 @@ __export(websites_exports, {
118
118
  isDashboardVisible: () => isDashboardVisible,
119
119
  listWebsites: () => listWebsites,
120
120
  mapRow: () => mapRow,
121
+ parseNotifyRouting: () => parseNotifyRouting,
121
122
  siteSlug: () => siteSlug,
122
123
  updateA11yCounts: () => updateA11yCounts,
123
124
  updateAuditFields: () => updateAuditFields,
@@ -135,6 +136,28 @@ function trimToNull(raw) {
135
136
  const trimmed = raw.trim();
136
137
  return trimmed.length > 0 ? trimmed : null;
137
138
  }
139
+ function parseNotifyRouting(raw) {
140
+ if (typeof raw !== "string") return null;
141
+ const trimmed = raw.trim();
142
+ if (!trimmed) return null;
143
+ let parsed;
144
+ try {
145
+ parsed = JSON.parse(trimmed);
146
+ } catch {
147
+ return null;
148
+ }
149
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
150
+ const o = parsed;
151
+ if (typeof o.field !== "string" || !o.field.trim()) return null;
152
+ if (!o.routes || typeof o.routes !== "object" || Array.isArray(o.routes)) return null;
153
+ const routing = {
154
+ field: o.field,
155
+ routes: o.routes
156
+ };
157
+ if (o.default !== void 0) routing.default = o.default;
158
+ if (Array.isArray(o.cc)) routing.cc = o.cc.filter((x) => typeof x === "string");
159
+ return routing;
160
+ }
138
161
  function isDashboardVisible(site) {
139
162
  return site.status !== null && ACTIVE_STATUSES.has(site.status);
140
163
  }
@@ -177,6 +200,7 @@ function mapRow(rec) {
177
200
  copyFooter: trimToNull(f["Copy \u2014 Footer"]),
178
201
  launchedAt: f["Launched at"] ?? null,
179
202
  newsletterWebhook: trimToNull(f["Newsletter Webhook"]),
203
+ notifyRouting: parseNotifyRouting(f["Notify Routing"]),
180
204
  mailchimpApiKey: trimToNull(f["Mailchimp API Key"]),
181
205
  mailchimpAudienceId: trimToNull(f["Mailchimp Audience ID"]),
182
206
  renovateFailingCis: f["Renovate Failing CIs"] ?? null,
@@ -627,6 +651,7 @@ async function createDraft(base, input) {
627
651
  if (input.searchFoundPage1 !== void 0) fields["Search found page 1"] = input.searchFoundPage1;
628
652
  if (input.searchPosition !== void 0) fields["Search position"] = input.searchPosition;
629
653
  if (input.period !== void 0) fields["Period"] = input.period;
654
+ if (input.subjectOverride !== void 0) fields["Subject override"] = input.subjectOverride;
630
655
  const created = await base(REPORTS_TABLE).create([{ fields }]);
631
656
  const rec = created[0];
632
657
  if (!rec) throw new Error("Airtable create returned no records");
@@ -693,7 +718,7 @@ var init_reports = __esm({
693
718
  "src/reports/airtable/reports.ts"() {
694
719
  "use strict";
695
720
  REPORTS_TABLE = "Reports";
696
- REPORT_TYPES = ["Maintenance", "Testing", "Launch"];
721
+ REPORT_TYPES = ["Maintenance", "Testing", "Launch", "Announcement"];
697
722
  }
698
723
  });
699
724
 
@@ -753,7 +778,15 @@ var init_copy = __esm({
753
778
  "Hosting, DNS, and SSL configured",
754
779
  "Continuous integration + automatic dependency updates",
755
780
  "Analytics and uptime monitoring"
756
- ]
781
+ ],
782
+ announceHeading: "YOUR MONTHLY REPORT",
783
+ announceBody: "We've set up ongoing monitoring and maintenance for your site. Each month we quietly check that everything's healthy and up to date \u2014 and now you'll get a short report so you can see it at a glance.",
784
+ announceMonitorItems: ["Performance", "Accessibility", "Security", "Uptime"],
785
+ announcePreviewLabel: "A snapshot of your latest scores:",
786
+ announceImprovementResend: "Your contact forms now deliver straight to your inbox through reliable infrastructure, so no inquiry slips through the cracks.",
787
+ announceImprovementSvelte5: "We've modernized your site to the latest framework \u2014 it's faster, more secure, and built to last.",
788
+ announceCadence: "You'll receive this every month. There's nothing you need to do.",
789
+ announceOpenDoor: "And if you'd ever like to expand the scope, add features, or freshen anything up, just reply \u2014 we'd love to help."
757
790
  };
758
791
  }
759
792
  });
@@ -1090,10 +1123,115 @@ var init_template2 = __esm({
1090
1123
  }
1091
1124
  });
1092
1125
 
1126
+ // src/reports/announcement-email/template.ts
1127
+ function buildAnnouncementMjml(data) {
1128
+ const copy = data.copy ?? DEFAULT_COPY;
1129
+ const previewText = "Your monthly report from Reddoor";
1130
+ const improvementItems = [];
1131
+ if (data.improvements?.resendForms) improvementItems.push(copy.announceImprovementResend);
1132
+ if (data.improvements?.svelte5) improvementItems.push(copy.announceImprovementSvelte5);
1133
+ const improvementsSection = improvementItems.length > 0 ? `
1134
+ <mj-section background-color="white">
1135
+ <mj-column>
1136
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">RECENT IMPROVEMENTS</mj-text>
1137
+ ${improvementItems.map(
1138
+ (item) => `
1139
+ <mj-text color="${GREY2}" 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>`
1140
+ ).join("")}
1141
+ </mj-column>
1142
+ </mj-section>` : "";
1143
+ const monitorRows = copy.announceMonitorItems.map(
1144
+ (item) => `
1145
+ <mj-text color="${GREY2}" 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>`
1146
+ ).join("");
1147
+ const scoreRows = SCORE_PREVIEW.map(
1148
+ ({ label, key }) => `
1149
+ <mj-text color="${RED2}" font-size="20px" font-weight="300" padding-top="25px">${label}</mj-text>
1150
+ <mj-text color="${RED2}" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse[key]}</mj-text>`
1151
+ ).join("");
1152
+ const contactRows = copy.contact.map(
1153
+ (line) => `
1154
+ <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml(line)}</mj-text>`
1155
+ ).join("");
1156
+ const footerAddressRows = copy.footerAddress.map(
1157
+ (line) => `
1158
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml(line)}</mj-text>`
1159
+ ).join("");
1160
+ return `<mjml>
1161
+ <mj-head>
1162
+ <mj-attributes>
1163
+ <mj-text font-family="helvetica, sans-serif" padding-left="5px" padding-right="5px" />
1164
+ <mj-section padding-left="11%" padding-right="11%"/>
1165
+ <mj-image padding="0px" />
1166
+ </mj-attributes>
1167
+ <mj-preview>${escapeXml(previewText)}</mj-preview>
1168
+ ${headerStyleBlock(data)}
1169
+ </mj-head>
1170
+ <mj-body background-color="white">
1171
+ <mj-section background-color="#F4F4F4" padding-top="0px" padding-bottom="0px" padding-left="0px" padding-right="0px">
1172
+ <mj-column>${headerImageTag(data)}</mj-column>
1173
+ </mj-section>
1174
+ <mj-section background-color="white">
1175
+ <mj-column>
1176
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="75px">${escapeXml(copy.announceHeading)}</mj-text>
1177
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="20px">Prepared for ${escapeXml(data.siteName)}</mj-text>
1178
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="8px">${escapeXml(copy.announceBody)}</mj-text>
1179
+ </mj-column>
1180
+ </mj-section>
1181
+ ${improvementsSection}
1182
+ <mj-section background-color="white">
1183
+ <mj-column>
1184
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">WHAT WE MONITOR</mj-text>
1185
+ ${monitorRows}
1186
+ </mj-column>
1187
+ </mj-section>
1188
+ <mj-section background-color="#F4F4F4">
1189
+ <mj-column>
1190
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="55px">${escapeXml(copy.announcePreviewLabel)}</mj-text>
1191
+ ${scoreRows}
1192
+ </mj-column>
1193
+ </mj-section>
1194
+ <mj-section background-color="white">
1195
+ <mj-column>
1196
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="36px">${escapeXml(copy.announceCadence)}</mj-text>
1197
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="8px">${escapeXml(copy.announceOpenDoor)}</mj-text>
1198
+ </mj-column>
1199
+ </mj-section>
1200
+ <mj-section background-color="white">
1201
+ <mj-column padding-top="36px">
1202
+ <mj-text color="${RED2}" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="36px" line-height="36px">Any questions, concerns or requests?</mj-text>
1203
+ ${contactRows}
1204
+ <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
1205
+ <mj-text color="${GREY2}" 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>
1206
+ <mj-text color="${GREY2}" 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>
1207
+ <mj-text color="${GREY2}" 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>
1208
+ ${footerAddressRows}
1209
+ </mj-column>
1210
+ </mj-section>
1211
+ </mj-body>
1212
+ </mjml>`;
1213
+ }
1214
+ var RED2, GREY2, SCORE_PREVIEW;
1215
+ var init_template3 = __esm({
1216
+ "src/reports/announcement-email/template.ts"() {
1217
+ "use strict";
1218
+ init_copy();
1219
+ init_template();
1220
+ RED2 = "#C00";
1221
+ GREY2 = "#757575";
1222
+ SCORE_PREVIEW = [
1223
+ { label: "Performance", key: "performance" },
1224
+ { label: "Readability", key: "accessibility" },
1225
+ { label: "Best Practices", key: "bestPractices" },
1226
+ { label: "Site Structure", key: "seo" }
1227
+ ];
1228
+ }
1229
+ });
1230
+
1093
1231
  // src/reports/render.ts
1094
1232
  import mjml2html from "mjml";
1095
1233
  async function renderReportHtml(data) {
1096
- const mjml = data.reportType === "Launch" ? buildLaunchMjml(data) : buildMjml(data);
1234
+ const mjml = data.reportType === "Launch" ? buildLaunchMjml(data) : data.reportType === "Announcement" ? buildAnnouncementMjml(data) : buildMjml(data);
1097
1235
  const out = await mjml2html(mjml, { validationLevel: "strict" });
1098
1236
  return { html: out.html, warnings: out.errors ?? [] };
1099
1237
  }
@@ -1102,6 +1240,7 @@ var init_render = __esm({
1102
1240
  "use strict";
1103
1241
  init_template();
1104
1242
  init_template2();
1243
+ init_template3();
1105
1244
  }
1106
1245
  });
1107
1246
 
@@ -1395,16 +1534,16 @@ __export(digest_exports, {
1395
1534
  runDigest: () => runDigest
1396
1535
  });
1397
1536
  function readySection(items) {
1398
- 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>`;
1537
+ const heading = `<h2 style="color:${RED3};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Ready for your yes</h2>`;
1399
1538
  if (items.length === 0) {
1400
- return `${heading}<p style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;margin:0">Nothing waiting on you.</p>`;
1539
+ return `${heading}<p style="color:${GREY3};font-family:helvetica,sans-serif;font-size:16px;margin:0">Nothing waiting on you.</p>`;
1401
1540
  }
1402
1541
  const rows = items.map((it) => {
1403
1542
  const safeUrl = it.dashboardUrl.startsWith("https://") ? it.dashboardUrl : void 0;
1404
1543
  const link = safeUrl ? `<a href="${escapeHtml(safeUrl)}" style="${ANCHOR_STYLE}">review &amp; approve</a>` : `review &amp; approve`;
1405
1544
  return `
1406
1545
  <tr>
1407
- <td style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">
1546
+ <td style="color:${GREY3};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">
1408
1547
  <strong style="color:#222">${escapeHtml(it.siteName)}</strong> \u2014 ${escapeHtml(it.reportType)} (${escapeHtml(it.period)})
1409
1548
  \u2014 ${link}
1410
1549
  </td>
@@ -1414,15 +1553,15 @@ function readySection(items) {
1414
1553
  }
1415
1554
  function attentionBadge(status) {
1416
1555
  if (status === "new")
1417
- return `<strong style="color:${RED2};font-family:helvetica,sans-serif">NEW</strong> `;
1556
+ return `<strong style="color:${RED3};font-family:helvetica,sans-serif">NEW</strong> `;
1418
1557
  if (status === "worse")
1419
- return `<strong style="color:${RED2};font-family:helvetica,sans-serif">WORSE</strong> `;
1558
+ return `<strong style="color:${RED3};font-family:helvetica,sans-serif">WORSE</strong> `;
1420
1559
  return "";
1421
1560
  }
1422
1561
  function attentionSection(items) {
1423
- const heading = `<h2 style="color:${RED2};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Needs attention</h2>`;
1562
+ const heading = `<h2 style="color:${RED3};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Needs attention</h2>`;
1424
1563
  if (items.length === 0) {
1425
- return `${heading}<p style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;margin:0">All clear \u2014 nothing needs attention.</p>`;
1564
+ return `${heading}<p style="color:${GREY3};font-family:helvetica,sans-serif;font-size:16px;margin:0">All clear \u2014 nothing needs attention.</p>`;
1426
1565
  }
1427
1566
  const bySite = /* @__PURE__ */ new Map();
1428
1567
  for (const it of items) {
@@ -1439,7 +1578,7 @@ function attentionSection(items) {
1439
1578
  const titleHtml = safeUrl ? `<a href="${escapeHtml(safeUrl)}" style="${ANCHOR_STYLE}">${escapeHtml(it.title)}</a>` : escapeHtml(it.title);
1440
1579
  return `
1441
1580
  <tr>
1442
- <td style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">${attentionBadge(it.status)}${titleHtml}</td>
1581
+ <td style="color:${GREY3};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">${attentionBadge(it.status)}${titleHtml}</td>
1443
1582
  </tr>`;
1444
1583
  }).join("");
1445
1584
  return `
@@ -1564,7 +1703,7 @@ function renderDigestHtml(sections) {
1564
1703
  <table width="600" style="border-collapse:collapse">
1565
1704
  <tr>
1566
1705
  <td>
1567
- <h1 style="color:${RED2};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px">Your fleet today</h1>
1706
+ <h1 style="color:${RED3};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px">Your fleet today</h1>
1568
1707
  ${readySection(sections.readyForYourYes)}
1569
1708
  ${attentionSection(sections.needsAttention)}
1570
1709
  </td>
@@ -1576,7 +1715,7 @@ function renderDigestHtml(sections) {
1576
1715
  </body>
1577
1716
  </html>`;
1578
1717
  }
1579
- var GREY2, RED2, ANCHOR_STYLE, SEVERITY_ORDER, FROM_ADDRESS, DIGEST_OPERATOR_FALLBACK;
1718
+ var GREY3, RED3, ANCHOR_STYLE, SEVERITY_ORDER, FROM_ADDRESS, DIGEST_OPERATOR_FALLBACK;
1580
1719
  var init_digest = __esm({
1581
1720
  "src/reports/digest.ts"() {
1582
1721
  "use strict";
@@ -1588,9 +1727,9 @@ var init_digest = __esm({
1588
1727
  init_digest_collectors();
1589
1728
  init_digest_state();
1590
1729
  init_html();
1591
- GREY2 = "#757575";
1592
- RED2 = "#C00";
1593
- ANCHOR_STYLE = `color:${RED2};font-family:helvetica,sans-serif`;
1730
+ GREY3 = "#757575";
1731
+ RED3 = "#C00";
1732
+ ANCHOR_STYLE = `color:${RED3};font-family:helvetica,sans-serif`;
1594
1733
  SEVERITY_ORDER = { critical: 0, warning: 1 };
1595
1734
  FROM_ADDRESS = "Reddoor Reports <reports@reddoorla.com>";
1596
1735
  DIGEST_OPERATOR_FALLBACK = "info@reddoorla.com";
@@ -6187,6 +6326,127 @@ async function runLaunchCommand(site, opts) {
6187
6326
  return { output: formatResult9(result), code: result.complete ? 0 : 1 };
6188
6327
  }
6189
6328
 
6329
+ // src/recipes/announce.ts
6330
+ init_client();
6331
+ init_websites();
6332
+ init_reports();
6333
+ init_attachments();
6334
+ init_render();
6335
+ init_copy();
6336
+ async function announce(deps) {
6337
+ const base = deps?.base ?? openBase(readAirtableConfig());
6338
+ const now = deps?.now ?? /* @__PURE__ */ new Date();
6339
+ const websites = await listWebsites(base);
6340
+ let targets = websites.filter((w) => w.status === "maintenance");
6341
+ if (deps?.site) {
6342
+ const wanted = siteSlug(deps.site);
6343
+ targets = targets.filter((w) => siteSlug(w.name) === wanted);
6344
+ }
6345
+ const period = now.toISOString().slice(0, 7);
6346
+ const results = [];
6347
+ for (const w of targets) {
6348
+ try {
6349
+ const scores = scoresFromRow(w);
6350
+ if (scores === null) {
6351
+ results.push({ site: w.name, status: "skipped-no-scores" });
6352
+ continue;
6353
+ }
6354
+ let report;
6355
+ let statusKind;
6356
+ const existing = await findReportByPeriod(base, w.id, "Announcement", period);
6357
+ if (existing) {
6358
+ await updateReportScores(base, existing.id, scores, now);
6359
+ report = existing;
6360
+ statusKind = "reused";
6361
+ } else {
6362
+ report = await createDraft(base, draftInputFor2(w, scores, now, period));
6363
+ statusKind = "drafted";
6364
+ }
6365
+ const slug = siteSlug(w.name);
6366
+ const { html } = await renderReportHtml({
6367
+ siteName: w.name,
6368
+ siteUrl: w.url,
6369
+ reportType: "Announcement",
6370
+ completedOn: now,
6371
+ lighthouse: scores,
6372
+ lastTestedDate: null,
6373
+ commentary: null,
6374
+ copy: resolveCopy(w),
6375
+ headerImageCid: `${slug}-header`,
6376
+ // Default-on fleet-wide for v1: both recent-improvement callouts render for every
6377
+ // site. Operator review (the draft never auto-sends) is the relevance backstop;
6378
+ // per-site conditioning of these flags is a future lever, not a v1 requirement.
6379
+ improvements: { resendForms: true, svelte5: true }
6380
+ });
6381
+ try {
6382
+ await uploadAttachment(
6383
+ report.id,
6384
+ "Rendered HTML",
6385
+ html,
6386
+ `${slug}-${now.toISOString().slice(0, 10)}.html`,
6387
+ "text/html"
6388
+ );
6389
+ } catch (uploadErr) {
6390
+ console.warn(
6391
+ `\u26A0 Announcement preview upload skipped for ${w.name}: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`
6392
+ );
6393
+ }
6394
+ await setDraftReady(base, report.id, true);
6395
+ const recipientMissing = !(w.reportRecipientsTo && w.reportRecipientsTo.trim());
6396
+ results.push({ site: w.name, status: statusKind, reportId: report.id, recipientMissing });
6397
+ } catch (err) {
6398
+ results.push({
6399
+ site: w.name,
6400
+ status: "error",
6401
+ message: err instanceof Error ? err.message : String(err)
6402
+ });
6403
+ }
6404
+ }
6405
+ return { results };
6406
+ }
6407
+ function scoresFromRow(w) {
6408
+ if (w.pScore === null || w.rScore === null || w.bpScore === null || w.seoScore === null) {
6409
+ return null;
6410
+ }
6411
+ return {
6412
+ performance: w.pScore,
6413
+ accessibility: w.rScore,
6414
+ bestPractices: w.bpScore,
6415
+ seo: w.seoScore
6416
+ };
6417
+ }
6418
+ function draftInputFor2(w, scores, now, period) {
6419
+ return {
6420
+ reportId: `${w.name} \u2014 Announcement \u2014 ${now.toISOString().slice(0, 10)}`,
6421
+ siteId: w.id,
6422
+ reportType: "Announcement",
6423
+ period,
6424
+ periodStart: now,
6425
+ periodEnd: now,
6426
+ completedOn: now,
6427
+ lighthouse: scores,
6428
+ lastTestedDate: null,
6429
+ subjectOverride: `Your new monthly report for ${w.name}`
6430
+ };
6431
+ }
6432
+
6433
+ // src/cli/commands/announce.ts
6434
+ function formatSiteResult(r) {
6435
+ if (r.status === "skipped-no-scores") return `[${r.site}] skipped-no-scores`;
6436
+ if (r.status === "error") return `[${r.site}] error: ${r.message}`;
6437
+ const note = r.recipientMissing ? " \u26A0 recipient missing" : "";
6438
+ return `[${r.site}] ${r.status}${note}`;
6439
+ }
6440
+ function formatAnnounceResult(result) {
6441
+ if (result.results.length === 0) return "No maintenance sites to announce.";
6442
+ return result.results.map(formatSiteResult).join("\n");
6443
+ }
6444
+ async function runAnnounceCommand(site, _opts) {
6445
+ const result = await announce(site ? { site } : {});
6446
+ const hadError = result.results.some((r) => r.status === "error");
6447
+ return { output: formatAnnounceResult(result), code: hadError ? 1 : 0 };
6448
+ }
6449
+
6190
6450
  // src/cli/commands/github-signals.ts
6191
6451
  init_client();
6192
6452
  init_websites();
@@ -6440,6 +6700,12 @@ cli.command(
6440
6700
  ).action(
6441
6701
  async (site, opts) => runOrExit(() => runLaunchCommand(site, opts), opts)
6442
6702
  );
6703
+ cli.command(
6704
+ "announce [site]",
6705
+ "Draft the monthly-report announcement email for maintenance sites (all, or one) for approval."
6706
+ ).action(
6707
+ async (site, opts) => runOrExit(() => runAnnounceCommand(site, opts), opts)
6708
+ );
6443
6709
  cli.command("report [site]", "Draft or send maintenance/testing reports.").option("--due", "Scan all Websites and draft overdue reports.").option(
6444
6710
  "--preview",
6445
6711
  "Single-site dry run; writes reports/<slug>/draft.html, never touches Airtable."