@reddoorla/maintenance 0.6.7 → 0.7.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
@@ -1,7 +1,563 @@
1
1
  #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/reports/airtable/client.ts
13
+ import Airtable from "airtable";
14
+ function readAirtableConfig() {
15
+ const apiKey = process.env.AIRTABLE_PAT;
16
+ const baseId = process.env.AIRTABLE_BASE_ID;
17
+ if (!apiKey) throw Object.assign(new Error("AIRTABLE_PAT not set"), { exitCode: 2 });
18
+ if (!baseId) throw Object.assign(new Error("AIRTABLE_BASE_ID not set"), { exitCode: 2 });
19
+ return { apiKey, baseId };
20
+ }
21
+ function openBase(cfg) {
22
+ return new Airtable({ apiKey: cfg.apiKey }).base(cfg.baseId);
23
+ }
24
+ var init_client = __esm({
25
+ "src/reports/airtable/client.ts"() {
26
+ "use strict";
27
+ }
28
+ });
29
+
30
+ // src/reports/airtable/websites.ts
31
+ function siteSlug(name) {
32
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
33
+ }
34
+ function mapRow(rec) {
35
+ const f = rec.fields;
36
+ const attachments = f["Header image"] ?? [];
37
+ const header = attachments[0] ?? null;
38
+ return {
39
+ id: rec.id,
40
+ name: String(f["Name"] ?? ""),
41
+ url: String(f["url"] ?? ""),
42
+ pointOfContact: f["point of contact"] ?? null,
43
+ maintenanceFreq: f["maintenence freq"] ?? "None",
44
+ testingFreq: f["testing freq"] ?? "None",
45
+ maintenanceDay: f["maintenance day"] ?? null,
46
+ testingDay: f["testing day"] ?? null,
47
+ ga4PropertyId: f["GA4 property ID"] ?? null,
48
+ reportRecipientsTo: f["Report recipients (To)"] ?? null,
49
+ reportRecipientsCc: f["Report recipients (CC)"] ?? null,
50
+ headerImage: header,
51
+ pScore: f["pScore"] ?? null,
52
+ rScore: f["rScore"] ?? null,
53
+ bpScore: f["bpScore"] ?? null,
54
+ seoScore: f["seoScore"] ?? null
55
+ };
56
+ }
57
+ async function listWebsites(base) {
58
+ const out = [];
59
+ await base(WEBSITES_TABLE).select({ pageSize: 100 }).eachPage((records, fetchNextPage) => {
60
+ for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));
61
+ fetchNextPage();
62
+ });
63
+ return out;
64
+ }
65
+ var WEBSITES_TABLE;
66
+ var init_websites = __esm({
67
+ "src/reports/airtable/websites.ts"() {
68
+ "use strict";
69
+ WEBSITES_TABLE = "Websites";
70
+ }
71
+ });
72
+
73
+ // src/reports/airtable/reports.ts
74
+ function mapRow2(rec) {
75
+ const f = rec.fields;
76
+ const linkSites = f["Site"] ?? [];
77
+ const html = (f["Rendered HTML"] ?? [])[0] ?? null;
78
+ return {
79
+ id: rec.id,
80
+ reportId: String(f["Report ID"] ?? ""),
81
+ siteId: linkSites[0] ?? "",
82
+ reportType: f["Report type"] ?? "Maintenance",
83
+ periodStart: f["Period start"] ?? null,
84
+ periodEnd: f["Period end"] ?? null,
85
+ completedOn: f["Completed on"] ?? null,
86
+ lighthouse: lighthouseFromFields(f),
87
+ gaUsersCurrent: f["GA users (period)"] ?? null,
88
+ gaUsersPrevious: f["GA users (prev period)"] ?? null,
89
+ lastTestedDate: f["Last tested date"] ?? null,
90
+ commentary: f["Commentary"] ?? null,
91
+ subjectOverride: f["Subject override"] ?? null,
92
+ draftReady: Boolean(f["Draft ready"]),
93
+ approvedToSend: Boolean(f["Approved to send"]),
94
+ sentAt: f["Sent at"] ?? null,
95
+ deliveryStatus: f["Delivery status"] ?? "pending",
96
+ renderedHtmlAttachment: html,
97
+ resendMessageId: f["Resend message ID"] ?? null
98
+ };
99
+ }
100
+ function lighthouseFromFields(f) {
101
+ const p = f["Lighthouse \u2014 Performance"];
102
+ const a = f["Lighthouse \u2014 Accessibility"];
103
+ const b = f["Lighthouse \u2014 Best Practices"];
104
+ const s = f["Lighthouse \u2014 SEO"];
105
+ if (typeof p !== "number" || typeof a !== "number" || typeof b !== "number" || typeof s !== "number")
106
+ return null;
107
+ return { performance: p, accessibility: a, bestPractices: b, seo: s };
108
+ }
109
+ function ymd(d) {
110
+ return d.toISOString().slice(0, 10);
111
+ }
112
+ function escapeFormulaString(s) {
113
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
114
+ }
115
+ async function createDraft(base, input) {
116
+ const fields = {
117
+ "Report ID": input.reportId,
118
+ Site: [input.siteId],
119
+ "Report type": input.reportType,
120
+ "Period start": ymd(input.periodStart),
121
+ "Period end": ymd(input.periodEnd),
122
+ "Completed on": ymd(input.completedOn),
123
+ "Lighthouse \u2014 Performance": input.lighthouse.performance,
124
+ "Lighthouse \u2014 Accessibility": input.lighthouse.accessibility,
125
+ "Lighthouse \u2014 Best Practices": input.lighthouse.bestPractices,
126
+ "Lighthouse \u2014 SEO": input.lighthouse.seo,
127
+ "Delivery status": "pending"
128
+ };
129
+ if (input.lastTestedDate) fields["Last tested date"] = ymd(input.lastTestedDate);
130
+ const created = await base(REPORTS_TABLE).create([{ fields }]);
131
+ const rec = created[0];
132
+ if (!rec) throw new Error("Airtable create returned no records");
133
+ return mapRow2({ id: rec.id, fields: rec.fields });
134
+ }
135
+ async function setDraftReady(base, recordId, ready) {
136
+ await base(REPORTS_TABLE).update([{ id: recordId, fields: { "Draft ready": ready } }]);
137
+ }
138
+ async function listSendableReports(base) {
139
+ const out = [];
140
+ await base(REPORTS_TABLE).select({
141
+ filterByFormula: "AND({Draft ready} = TRUE(), {Approved to send} = TRUE(), {Sent at} = BLANK())",
142
+ pageSize: 100
143
+ }).eachPage((records, fetchNextPage) => {
144
+ for (const rec of records) out.push(mapRow2({ id: rec.id, fields: rec.fields }));
145
+ fetchNextPage();
146
+ });
147
+ return out;
148
+ }
149
+ async function listReportsForSite(base, siteId) {
150
+ const safeId = escapeFormulaString(siteId);
151
+ const out = [];
152
+ await base(REPORTS_TABLE).select({
153
+ filterByFormula: `FIND(",${safeId},", "," & ARRAYJOIN({Site}, ",") & ",") > 0`,
154
+ pageSize: 100
155
+ }).eachPage((records, fetchNextPage) => {
156
+ for (const rec of records) out.push(mapRow2({ id: rec.id, fields: rec.fields }));
157
+ fetchNextPage();
158
+ });
159
+ return out;
160
+ }
161
+ async function stampSent(base, recordId, sentAt, messageId) {
162
+ await base(REPORTS_TABLE).update([
163
+ {
164
+ id: recordId,
165
+ fields: {
166
+ "Sent at": sentAt.toISOString(),
167
+ "Resend message ID": messageId
168
+ }
169
+ }
170
+ ]);
171
+ }
172
+ var REPORTS_TABLE;
173
+ var init_reports = __esm({
174
+ "src/reports/airtable/reports.ts"() {
175
+ "use strict";
176
+ REPORTS_TABLE = "Reports";
177
+ }
178
+ });
179
+
180
+ // src/reports/maintenance-email/template.ts
181
+ function fmtDate(d) {
182
+ if (!d) return "";
183
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
184
+ const dd = String(d.getUTCDate()).padStart(2, "0");
185
+ const yyyy = d.getUTCFullYear();
186
+ return `${mm}.${dd}.${yyyy}`;
187
+ }
188
+ function fmtUsers(n) {
189
+ return n.toLocaleString("en-US");
190
+ }
191
+ function maintenanceChecksSection() {
192
+ const rows = [
193
+ "Reviewed Logs",
194
+ "CMS Checked",
195
+ "DNS Checked",
196
+ "Google Indexed",
197
+ "Reviewed Certificate",
198
+ "Security Updates"
199
+ ];
200
+ return rows.map(
201
+ (label, i) => `
202
+ <mj-section background-color="white" padding="0px"${i === rows.length - 1 ? ' padding-bottom="36px"' : ""}>
203
+ <mj-group>
204
+ <mj-column padding-left="0px" width="90%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""}>
205
+ <mj-text height="25px" padding-left="0px" color="#757575" padding-top="20px" padding-bottom="7.5px" font-size="16px">${label}</mj-text>
206
+ </mj-column>
207
+ <mj-column width="10%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""} padding-top="15px">
208
+ <mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
209
+ </mj-column>
210
+ </mj-group>
211
+ </mj-section>`
212
+ ).join("");
213
+ }
214
+ function testingChecklistSection() {
215
+ const rows = [
216
+ "Desktop Browsers",
217
+ "Mobile Browsers",
218
+ "Package Updates",
219
+ "Bottlenecks",
220
+ "Form Functionality",
221
+ "Animation Functionality"
222
+ ];
223
+ return rows.map(
224
+ (label, i) => `
225
+ <mj-section background-color="#F4F4F4" padding="0px"${i === rows.length - 1 ? ' padding-bottom="60px"' : ""}>
226
+ <mj-group>
227
+ <mj-column width="90%" padding-left="0px"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""}>
228
+ <mj-text height="25px" padding-left="0px" color="#757575" padding-top="20px" padding-bottom="7.5px" font-size="16px">${label}</mj-text>
229
+ </mj-column>
230
+ <mj-column width="10%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""} padding-top="15px">
231
+ <mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
232
+ </mj-column>
233
+ </mj-group>
234
+ </mj-section>`
235
+ ).join("");
236
+ }
237
+ function maintenanceTestingPlaceholder(lastTested) {
238
+ return `
239
+ <mj-section background-color="#F4F4F4">
240
+ <mj-column>
241
+ <mj-image href="mailto:info@reddoorla.com" src="${BLURRED_TESTS}" />
242
+ </mj-column>
243
+ </mj-section>
244
+ <mj-section background-color="#F4F4F4" padding-top="0px">
245
+ <mj-column>
246
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">Last Tested: ${fmtDate(lastTested)}</mj-text>
247
+ </mj-column>
248
+ </mj-section>`;
249
+ }
250
+ function testingIntroSection() {
251
+ return `
252
+ <mj-section background-color="#F4F4F4">
253
+ <mj-column>
254
+ <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">TESTING</mj-text>
255
+ <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>
256
+ </mj-column>
257
+ </mj-section>`;
258
+ }
259
+ function commentarySection(text) {
260
+ return `
261
+ <mj-section background-color="white">
262
+ <mj-column>
263
+ <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">NOTES</mj-text>
264
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${text.replace(/\n/g, "<br/>")}</mj-text>
265
+ </mj-column>
266
+ </mj-section>`;
267
+ }
268
+ function buildMjml(data) {
269
+ const isTesting = data.reportType === "Testing";
270
+ const headerSrc = `cid:${data.headerImageCid}`;
271
+ const previewText = `Checked up on ${data.siteName}`;
272
+ return `<mjml>
273
+ <mj-head>
274
+ <mj-attributes>
275
+ <mj-text font-family="helvetica, sans-serif" padding-left="5px" padding-right="5px" />
276
+ <mj-section padding-left="11%" padding-right="11%"/>
277
+ <mj-image padding="0px" />
278
+ </mj-attributes>
279
+ <mj-preview>${previewText}</mj-preview>
280
+ </mj-head>
281
+ <mj-body background-color="white">
282
+ <mj-section background-color="#F4F4F4" padding-top="0px" padding-bottom="0px" padding-left="0px" padding-right="0px">
283
+ <mj-column>
284
+ <mj-image href="${data.siteUrl}" src="${headerSrc}" />
285
+ </mj-column>
286
+ </mj-section>
287
+ <mj-section background-color="white">
288
+ <mj-column>
289
+ <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">COMPLETED ON</mj-text>
290
+ <mj-text color="#C00" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
291
+ <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">MAINTENANCE CHECKS</mj-text>
292
+ <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>
293
+ </mj-column>
294
+ </mj-section>
295
+ ${maintenanceChecksSection()}
296
+ <mj-section background-color="#F4F4F4">
297
+ <mj-column>
298
+ <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">LIGHTHOUSE SCORES*</mj-text>
299
+ <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Performance</mj-text>
300
+ <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.performance}</mj-text>
301
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="0px" padding-bottom="36px">Acceptable 50\u201389 // Ideal 90\u2013100</mj-text>
302
+ <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
303
+ <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Readability</mj-text>
304
+ <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.accessibility}</mj-text>
305
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="0px" padding-bottom="36px">Acceptable 80\u201399 // Ideal 100</mj-text>
306
+ <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
307
+ <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Best Practices</mj-text>
308
+ <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.bestPractices}</mj-text>
309
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="0px" padding-bottom="36px">Acceptable 60\u201379 // Ideal 80\u201392</mj-text>
310
+ <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
311
+ <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Site Structure</mj-text>
312
+ <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.seo}</mj-text>
313
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="0px" padding-bottom="36px">Acceptable 50\u201389 // Ideal 90\u2013100</mj-text>
314
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" padding-bottom="36px" line-height="20px">*A Lighthouse score is a numerical measure provided by Google's Lighthouse tool, which evaluates various aspects of a web page's quality.</mj-text>
315
+ </mj-column>
316
+ </mj-section>
317
+ <mj-section background-color="white">
318
+ <mj-column>
319
+ <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">ANALYTICS</mj-text>
320
+ <mj-text color="#C00" font-size="44px" font-weight="400">${fmtUsers(data.gaUsersCurrent)} Users</mj-text>
321
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">Last Period: ${fmtUsers(data.gaUsersPrevious)}</mj-text>
322
+ <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>
323
+ </mj-column>
324
+ </mj-section>
325
+ ${isTesting ? testingIntroSection() + testingChecklistSection() : maintenanceTestingPlaceholder(data.lastTestedDate)}
326
+ ${data.commentary ? commentarySection(data.commentary) : ""}
327
+ <mj-section background-color="white">
328
+ <mj-column padding-top="36px">
329
+ <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>
330
+ <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">Just hit reply.</mj-text>
331
+ <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>
332
+ <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
333
+ <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()).getFullYear()} Reddoor Creative, LLC. All rights reserved.</mj-text>
334
+ <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>
335
+ <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>
336
+ <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>
337
+ <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>
338
+ </mj-column>
339
+ </mj-section>
340
+ </mj-body>
341
+ </mjml>`;
342
+ }
343
+ var CHECK_PNG, BLURRED_TESTS;
344
+ var init_template = __esm({
345
+ "src/reports/maintenance-email/template.ts"() {
346
+ "use strict";
347
+ CHECK_PNG = "https://d3eq0h5l8sxf6t.cloudfront.net/maintenance-email/check.png";
348
+ BLURRED_TESTS = "https://d3eq0h5l8sxf6t.cloudfront.net/maintenance-email/blurredTests.jpg";
349
+ }
350
+ });
351
+
352
+ // src/reports/render.ts
353
+ import mjml2html from "mjml";
354
+ async function renderReportHtml(data) {
355
+ const mjml = buildMjml(data);
356
+ const out = await mjml2html(mjml, { validationLevel: "strict" });
357
+ return { html: out.html, warnings: out.errors ?? [] };
358
+ }
359
+ var init_render = __esm({
360
+ "src/reports/render.ts"() {
361
+ "use strict";
362
+ init_template();
363
+ }
364
+ });
365
+
366
+ // src/reports/airtable/attachments.ts
367
+ async function fetchAttachmentBytes(url) {
368
+ const res = await fetch(url);
369
+ if (!res.ok) {
370
+ throw new Error(
371
+ `Failed to fetch Airtable attachment ${res.status} ${res.statusText} (url=${url})`
372
+ );
373
+ }
374
+ const contentType = res.headers.get("content-type") ?? "application/octet-stream";
375
+ const ab = await res.arrayBuffer();
376
+ return { bytes: new Uint8Array(ab), contentType };
377
+ }
378
+ var init_attachments = __esm({
379
+ "src/reports/airtable/attachments.ts"() {
380
+ "use strict";
381
+ }
382
+ });
383
+
384
+ // src/reports/send/resend.ts
385
+ import { Resend } from "resend";
386
+ function defaultResendClient() {
387
+ const key = process.env.RESEND_API_KEY;
388
+ if (!key) throw Object.assign(new Error("RESEND_API_KEY not set"), { exitCode: 2 });
389
+ const resend = new Resend(key);
390
+ return {
391
+ async send(input) {
392
+ const payload = {
393
+ from: input.from,
394
+ to: input.to,
395
+ subject: input.subject,
396
+ html: input.html
397
+ };
398
+ if (input.cc) payload.cc = input.cc;
399
+ if (input.replyTo) payload.replyTo = input.replyTo;
400
+ if (input.attachments) payload.attachments = input.attachments;
401
+ const options = {};
402
+ if (input.idempotencyKey) options.idempotencyKey = input.idempotencyKey;
403
+ const { data, error } = await resend.emails.send(payload, options);
404
+ if (error) throw new Error(`Resend error: ${error.message}`);
405
+ if (!data?.id) throw new Error("Resend returned no message id");
406
+ return { messageId: data.id };
407
+ }
408
+ };
409
+ }
410
+ var init_resend = __esm({
411
+ "src/reports/send/resend.ts"() {
412
+ "use strict";
413
+ }
414
+ });
415
+
416
+ // src/reports/send/orchestrate.ts
417
+ var orchestrate_exports = {};
418
+ __export(orchestrate_exports, {
419
+ isProbablyEmail: () => isProbablyEmail,
420
+ parseAddresses: () => parseAddresses,
421
+ sendApprovedReports: () => sendApprovedReports
422
+ });
423
+ async function sendApprovedReports(options = {}) {
424
+ const base = openBase(readAirtableConfig());
425
+ const client = options.resend ?? defaultResendClient();
426
+ const sendable = await listSendableReports(base);
427
+ if (sendable.length === 0) return { output: "No reports ready to send.", code: 0 };
428
+ const websites = await listWebsites(base);
429
+ const sites = new Map(websites.map((w) => [w.id, w]));
430
+ const lines = [];
431
+ let anyFailed = false;
432
+ for (const report of sendable) {
433
+ const site = sites.get(report.siteId);
434
+ if (!site) {
435
+ lines.push(`\u2717 ${report.reportId} \u2014 Site row not found for id=${report.siteId}`);
436
+ anyFailed = true;
437
+ continue;
438
+ }
439
+ try {
440
+ const messageId = await sendOne(client, base, site, report);
441
+ lines.push(`\u2713 sent: ${report.reportId} (${messageId})`);
442
+ } catch (e) {
443
+ lines.push(`\u2717 ${report.reportId} \u2014 ${e.message}`);
444
+ anyFailed = true;
445
+ }
446
+ }
447
+ return { output: lines.join("\n"), code: anyFailed ? 1 : 0 };
448
+ }
449
+ async function sendOne(client, base, site, report) {
450
+ if (!site.headerImage) {
451
+ throw new Error(`Site '${site.name}' has no Header image set on the Websites row`);
452
+ }
453
+ if (!report.lighthouse) {
454
+ throw new Error(`Report ${report.reportId} has no Lighthouse scores`);
455
+ }
456
+ const { bytes, contentType } = await fetchAttachmentBytes(site.headerImage.url);
457
+ const slug = siteSlug(site.name);
458
+ const cidName = `${slug}-header`;
459
+ const { html } = await renderReportHtml({
460
+ siteName: site.name,
461
+ siteUrl: site.url,
462
+ reportType: report.reportType,
463
+ completedOn: report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date(),
464
+ lighthouse: report.lighthouse,
465
+ gaUsersCurrent: report.gaUsersCurrent ?? 0,
466
+ gaUsersPrevious: report.gaUsersPrevious ?? 0,
467
+ lastTestedDate: report.lastTestedDate ? new Date(report.lastTestedDate) : null,
468
+ commentary: report.commentary,
469
+ headerImageCid: cidName
470
+ });
471
+ const subject = report.subjectOverride ?? `${site.name} ${report.reportType} Report`;
472
+ const explicitTo = parseAddresses(site.reportRecipientsTo);
473
+ const fallbackTo = parseAddresses(site.pointOfContact);
474
+ const to = explicitTo ?? fallbackTo ?? [];
475
+ if (to.length === 0) {
476
+ throw new Error(
477
+ `Site '${site.name}' has no recipients (Report recipients (To) AND point of contact are both empty)`
478
+ );
479
+ }
480
+ for (const addr of to) {
481
+ if (!isProbablyEmail(addr)) {
482
+ throw new Error(
483
+ `Site '${site.name}' recipient is malformed: ${addr} \u2014 fix Report recipients (To) or point of contact in Airtable`
484
+ );
485
+ }
486
+ }
487
+ const cc = parseAddresses(site.reportRecipientsCc);
488
+ if (cc) {
489
+ for (const addr of cc) {
490
+ if (!isProbablyEmail(addr)) {
491
+ throw new Error(
492
+ `Site '${site.name}' CC is malformed: ${addr} \u2014 fix Report recipients (CC) in Airtable`
493
+ );
494
+ }
495
+ }
496
+ }
497
+ const payload = {
498
+ from: FROM_ADDRESS,
499
+ to,
500
+ replyTo: REPLY_TO,
501
+ subject,
502
+ html,
503
+ attachments: [
504
+ {
505
+ filename: site.headerImage.filename,
506
+ content: Buffer.from(bytes).toString("base64"),
507
+ contentType,
508
+ inlineContentId: cidName
509
+ }
510
+ ],
511
+ // Stable across retries of the same row — if Airtable stamping fails after a
512
+ // successful Resend, the next --send-ready replays with the same key and
513
+ // Resend returns the original message id rather than sending a duplicate.
514
+ idempotencyKey: `report:${report.id}`
515
+ };
516
+ if (cc) payload.cc = cc;
517
+ const result = await client.send(payload);
518
+ await stampSent(base, report.id, /* @__PURE__ */ new Date(), result.messageId);
519
+ return result.messageId;
520
+ }
521
+ function parseAddresses(field) {
522
+ if (!field) return null;
523
+ const seen = /* @__PURE__ */ new Set();
524
+ const list = [];
525
+ for (const raw of field.split(/[,\n]/)) {
526
+ const trimmed = raw.trim().toLowerCase();
527
+ if (!trimmed) continue;
528
+ if (seen.has(trimmed)) continue;
529
+ seen.add(trimmed);
530
+ list.push(trimmed);
531
+ }
532
+ return list.length > 0 ? list : null;
533
+ }
534
+ function isProbablyEmail(s) {
535
+ const at = s.indexOf("@");
536
+ if (at < 1 || at !== s.lastIndexOf("@")) return false;
537
+ const local = s.slice(0, at);
538
+ const domain = s.slice(at + 1);
539
+ if (!local || !domain) return false;
540
+ if (!domain.includes(".")) return false;
541
+ if (/\s/.test(s)) return false;
542
+ return true;
543
+ }
544
+ var FROM_ADDRESS, REPLY_TO;
545
+ var init_orchestrate = __esm({
546
+ "src/reports/send/orchestrate.ts"() {
547
+ "use strict";
548
+ init_client();
549
+ init_reports();
550
+ init_websites();
551
+ init_attachments();
552
+ init_render();
553
+ init_resend();
554
+ FROM_ADDRESS = "Reddoor Reports <reports@reddoorla.com>";
555
+ REPLY_TO = "info@reddoorla.com";
556
+ }
557
+ });
2
558
 
3
559
  // src/cli/bin.ts
4
- import { dirname as dirname2 } from "path";
560
+ import { dirname as dirname3 } from "path";
5
561
  import { fileURLToPath as fileURLToPath2 } from "url";
6
562
  import { cac } from "cac";
7
563
 
@@ -1028,6 +1584,65 @@ async function commit(cwd, message) {
1028
1584
  return sha.trim();
1029
1585
  }
1030
1586
 
1587
+ // src/recipes/_with-recipe.ts
1588
+ async function withRecipe(body) {
1589
+ const label = siteLabel(body.site);
1590
+ if (body.checkTreeFirst && !await isWorkingTreeClean(body.site.path)) {
1591
+ throw new Error(`refusing to run: working tree is not clean at ${body.site.path}`);
1592
+ }
1593
+ const planned = await body.plan();
1594
+ if (planned.kind === "noop") {
1595
+ return {
1596
+ recipe: body.name,
1597
+ site: label,
1598
+ status: "noop",
1599
+ commits: [],
1600
+ ...planned.notes ? { notes: planned.notes } : {}
1601
+ };
1602
+ }
1603
+ if (planned.kind === "failed") {
1604
+ return {
1605
+ recipe: body.name,
1606
+ site: label,
1607
+ status: "failed",
1608
+ commits: [],
1609
+ notes: planned.notes
1610
+ };
1611
+ }
1612
+ if (!body.checkTreeFirst && !await isWorkingTreeClean(body.site.path)) {
1613
+ throw new Error(`refusing to run: working tree is not clean at ${body.site.path}`);
1614
+ }
1615
+ const branch = branchName(body.name);
1616
+ await createBranch(body.site.path, branch);
1617
+ const shas = [];
1618
+ const result = await body.apply(planned.plan, {
1619
+ cwd: body.site.path,
1620
+ branch,
1621
+ commit: async (msg) => {
1622
+ const sha = await commit(body.site.path, msg);
1623
+ if (sha) shas.push(sha);
1624
+ return sha;
1625
+ }
1626
+ });
1627
+ if (result.kind === "failed") {
1628
+ return {
1629
+ recipe: body.name,
1630
+ site: label,
1631
+ status: "failed",
1632
+ commits: shas,
1633
+ notes: result.notes
1634
+ };
1635
+ }
1636
+ const notes = result.notes ? `${result.notes}; branch: ${branch}` : `branch: ${branch}`;
1637
+ return {
1638
+ recipe: body.name,
1639
+ site: label,
1640
+ status: shas.length > 0 ? "applied" : "noop",
1641
+ commits: shas,
1642
+ notes
1643
+ };
1644
+ }
1645
+
1031
1646
  // src/recipes/sync-configs.ts
1032
1647
  var GITIGNORE_CONFIG = "gitignore";
1033
1648
  var ALL_CONFIG_NAMES = [
@@ -1071,48 +1686,33 @@ async function applyGitignore(cwd, plan) {
1071
1686
  }
1072
1687
  }
1073
1688
  async function syncConfigs(site, opts = {}) {
1074
- const label = siteLabel(site);
1075
1689
  const requested = opts.which ?? ALL_TEMPLATES.map((t) => t.config).concat(GITIGNORE_CONFIG);
1076
1690
  const templateNames = requested.filter((c) => c !== GITIGNORE_CONFIG);
1077
1691
  const templates = templatesByName(templateNames);
1078
1692
  const includeGitignore = requested.includes(GITIGNORE_CONFIG);
1079
- const templateDiffs = await planTemplateDiffs(site.path, templates);
1080
- const gitignorePlan = includeGitignore ? await planGitignore(site.path) : { kind: "noop" };
1081
- if (templateDiffs.length === 0 && gitignorePlan.kind === "noop") {
1082
- return {
1083
- recipe: "sync-configs",
1084
- site: label,
1085
- status: "noop",
1086
- commits: [],
1087
- notes: "all targeted configs already match"
1088
- };
1089
- }
1090
- if (!await isWorkingTreeClean(site.path)) {
1091
- throw new Error(`refusing to run: working tree is not clean at ${site.path}`);
1092
- }
1093
- const branch = branchName("sync-configs");
1094
- await createBranch(site.path, branch);
1095
- const shas = [];
1096
- for (const t of templateDiffs) {
1097
- await writeFile3(join6(site.path, t.path), t.contents, "utf-8");
1098
- const sha = await commit(
1099
- site.path,
1100
- `chore: sync ${t.config} config from @reddoorla/maintenance`
1101
- );
1102
- if (sha) shas.push(sha);
1103
- }
1104
- if (gitignorePlan.kind === "apply") {
1105
- await applyGitignore(site.path, gitignorePlan);
1106
- const sha = await commit(site.path, `chore: sync gitignore from @reddoorla/maintenance`);
1107
- if (sha) shas.push(sha);
1108
- }
1109
- return {
1110
- recipe: "sync-configs",
1111
- site: label,
1112
- status: "applied",
1113
- commits: shas,
1114
- notes: `branch: ${branch}`
1115
- };
1693
+ return withRecipe({
1694
+ name: "sync-configs",
1695
+ site,
1696
+ plan: async () => {
1697
+ const templateDiffs = await planTemplateDiffs(site.path, templates);
1698
+ const gitignorePlan = includeGitignore ? await planGitignore(site.path) : { kind: "noop" };
1699
+ if (templateDiffs.length === 0 && gitignorePlan.kind === "noop") {
1700
+ return { kind: "noop", notes: "all targeted configs already match" };
1701
+ }
1702
+ return { kind: "apply", plan: { templateDiffs, gitignorePlan } };
1703
+ },
1704
+ apply: async ({ templateDiffs, gitignorePlan }, { commit: commit2 }) => {
1705
+ for (const t of templateDiffs) {
1706
+ await writeFile3(join6(site.path, t.path), t.contents, "utf-8");
1707
+ await commit2(`chore: sync ${t.config} config from @reddoorla/maintenance`);
1708
+ }
1709
+ if (gitignorePlan.kind === "apply") {
1710
+ await applyGitignore(site.path, gitignorePlan);
1711
+ await commit2(`chore: sync gitignore from @reddoorla/maintenance`);
1712
+ }
1713
+ return { kind: "ok" };
1714
+ }
1715
+ });
1116
1716
  }
1117
1717
 
1118
1718
  // src/cli/commands/sync-configs.ts
@@ -1214,62 +1814,51 @@ function upFlagsForGroup(group) {
1214
1814
  return [];
1215
1815
  }
1216
1816
  async function bumpDeps(site, opts = {}) {
1217
- const label = siteLabel(site);
1218
1817
  const group = opts.group ?? "minor";
1219
1818
  const spawn2 = opts.spawn ?? defaultSpawn;
1220
- const hasPnpmLock = await exists(join8(site.path, "pnpm-lock.yaml"));
1221
- if (!hasPnpmLock) {
1222
- const hasNpmLock = await exists(join8(site.path, "package-lock.json"));
1223
- const hasYarnLock = await exists(join8(site.path, "yarn.lock"));
1224
- if (hasNpmLock || hasYarnLock) {
1225
- const competing = hasNpmLock ? "package-lock.json" : "yarn.lock";
1226
- return {
1227
- recipe: "bump-deps",
1228
- site: label,
1229
- status: "failed",
1230
- commits: [],
1231
- notes: `site has ${competing} but no pnpm-lock.yaml \u2014 run convert-to-pnpm first`
1232
- };
1819
+ return withRecipe({
1820
+ name: "bump-deps",
1821
+ site,
1822
+ // pnpm install (in plan) mutates the lockfile, so the clean-tree check
1823
+ // MUST happen first — otherwise a desynced-lockfile resync would silently
1824
+ // land on top of whatever else was in the tree.
1825
+ checkTreeFirst: true,
1826
+ plan: async () => {
1827
+ const hasPnpmLock = await exists(join8(site.path, "pnpm-lock.yaml"));
1828
+ if (!hasPnpmLock) {
1829
+ const hasNpmLock = await exists(join8(site.path, "package-lock.json"));
1830
+ const hasYarnLock = await exists(join8(site.path, "yarn.lock"));
1831
+ if (hasNpmLock || hasYarnLock) {
1832
+ const competing = hasNpmLock ? "package-lock.json" : "yarn.lock";
1833
+ return {
1834
+ kind: "failed",
1835
+ notes: `site has ${competing} but no pnpm-lock.yaml \u2014 run convert-to-pnpm first`
1836
+ };
1837
+ }
1838
+ }
1839
+ await spawn2("pnpm", ["install"], { cwd: site.path, streaming: true });
1840
+ const outdated = await spawn2(
1841
+ "pnpm",
1842
+ ["outdated", "--json", ...outdatedFlagsForGroup(group)],
1843
+ { cwd: site.path }
1844
+ );
1845
+ let parsed;
1846
+ try {
1847
+ parsed = JSON.parse(outdated.stdout || "{}");
1848
+ } catch {
1849
+ parsed = {};
1850
+ }
1851
+ if (Object.keys(parsed).length === 0) {
1852
+ return { kind: "noop", notes: `pnpm outdated reported nothing for group=${group}` };
1853
+ }
1854
+ return { kind: "apply", plan: { group } };
1855
+ },
1856
+ apply: async ({ group: g }, { commit: commit2, cwd }) => {
1857
+ await spawn2("pnpm", ["up", ...upFlagsForGroup(g)], { cwd, streaming: true });
1858
+ await commit2(`chore(deps): bump dependencies (${g})`);
1859
+ return { kind: "ok" };
1233
1860
  }
1234
- }
1235
- if (!await isWorkingTreeClean(site.path)) {
1236
- throw new Error(`refusing to run: working tree is not clean at ${site.path}`);
1237
- }
1238
- await spawn2("pnpm", ["install"], { cwd: site.path, streaming: true });
1239
- const outdated = await spawn2("pnpm", ["outdated", "--json", ...outdatedFlagsForGroup(group)], {
1240
- cwd: site.path
1241
1861
  });
1242
- let parsed;
1243
- try {
1244
- parsed = JSON.parse(outdated.stdout || "{}");
1245
- } catch {
1246
- parsed = {};
1247
- }
1248
- const nothingToDo = Object.keys(parsed).length === 0;
1249
- if (nothingToDo) {
1250
- return {
1251
- recipe: "bump-deps",
1252
- site: label,
1253
- status: "noop",
1254
- commits: [],
1255
- notes: `pnpm outdated reported nothing for group=${group}`
1256
- };
1257
- }
1258
- const branch = branchName("bump-deps");
1259
- await createBranch(site.path, branch);
1260
- await spawn2("pnpm", ["up", ...upFlagsForGroup(group)], {
1261
- cwd: site.path,
1262
- streaming: true
1263
- });
1264
- const sha = await commit(site.path, `chore(deps): bump dependencies (${group})`);
1265
- const shas = sha ? [sha] : [];
1266
- return {
1267
- recipe: "bump-deps",
1268
- site: label,
1269
- status: shas.length > 0 ? "applied" : "noop",
1270
- commits: shas,
1271
- notes: `branch: ${branch}`
1272
- };
1273
1862
  }
1274
1863
 
1275
1864
  // src/cli/commands/bump-deps.ts
@@ -1884,72 +2473,49 @@ async function alreadyOnSvelte5(cwd) {
1884
2473
  }
1885
2474
  }
1886
2475
  async function upgradeSvelte4to5(site, opts = {}) {
1887
- const label = siteLabel(site);
1888
2476
  const spawn2 = opts.spawn ?? defaultSpawn;
1889
- if (await alreadyOnSvelte5(site.path)) {
1890
- return {
1891
- recipe: "svelte-4-to-5",
1892
- site: label,
1893
- status: "noop",
1894
- commits: [],
1895
- notes: "site already declares svelte ^5.x"
1896
- };
1897
- }
1898
- if (!await isWorkingTreeClean(site.path)) {
1899
- throw new Error(`refusing to run: working tree is not clean at ${site.path}`);
1900
- }
1901
- const branch = branchName("svelte-4-to-5");
1902
- await createBranch(site.path, branch);
1903
- const shas = [];
1904
- const bumped = await bumpToSvelte5Versions(site.path);
1905
- if (bumped) {
1906
- const sha = await commit(site.path, "chore(svelte5): bump svelte/kit/vite/vite-plugin-svelte");
1907
- if (sha) shas.push(sha);
1908
- }
1909
- const configChanged = await migrateSvelteConfig(site.path);
1910
- if (configChanged) {
1911
- const sha = await commit(
1912
- site.path,
1913
- "refactor(svelte5): migrate svelte.config.js (drop vitePreprocess)"
1914
- );
1915
- if (sha) shas.push(sha);
1916
- }
1917
- const migrate = await runSvelteMigrate(site.path, spawn2);
1918
- if (migrate.ran) {
1919
- const sha = await commit(site.path, "refactor(svelte5): run official svelte-migrate codemod");
1920
- if (sha) shas.push(sha);
1921
- }
1922
- const tw = await upgradeTailwind(site.path, spawn2);
1923
- if (tw.ran) {
1924
- const sha = await commit(site.path, "chore(svelte5): tailwindcss 3 \u2192 4 upgrade");
1925
- if (sha) shas.push(sha);
1926
- }
1927
- const codemods = await applyGotchaCodemods(site.path);
1928
- if (codemods.filesChanged > 0) {
1929
- const sha = await commit(
1930
- site.path,
1931
- `refactor(svelte5): apply gotcha codemods (${codemods.filesChanged} files)`
1932
- );
1933
- if (sha) shas.push(sha);
1934
- }
1935
- await verifyMigration(site.path, spawn2);
1936
- const verifySha = await commit(site.path, "chore(svelte5): pnpm install + check");
1937
- if (verifySha) shas.push(verifySha);
1938
- await writeMigrationSummary({
1939
- cwd: site.path,
1940
- filesChangedByCodemods: codemods.filesChanged,
1941
- svelteMigrateRan: migrate.ran,
1942
- tailwindUpgraded: tw.ran
2477
+ return withRecipe({
2478
+ name: "svelte-4-to-5",
2479
+ site,
2480
+ plan: async () => {
2481
+ if (await alreadyOnSvelte5(site.path)) {
2482
+ return { kind: "noop", notes: "site already declares svelte ^5.x" };
2483
+ }
2484
+ return { kind: "apply", plan: true };
2485
+ },
2486
+ apply: async (_plan, { commit: commit2, cwd }) => {
2487
+ const bumped = await bumpToSvelte5Versions(cwd);
2488
+ if (bumped) {
2489
+ await commit2("chore(svelte5): bump svelte/kit/vite/vite-plugin-svelte");
2490
+ }
2491
+ const configChanged = await migrateSvelteConfig(cwd);
2492
+ if (configChanged) {
2493
+ await commit2("refactor(svelte5): migrate svelte.config.js (drop vitePreprocess)");
2494
+ }
2495
+ const migrate = await runSvelteMigrate(cwd, spawn2);
2496
+ if (migrate.ran) {
2497
+ await commit2("refactor(svelte5): run official svelte-migrate codemod");
2498
+ }
2499
+ const tw = await upgradeTailwind(cwd, spawn2);
2500
+ if (tw.ran) {
2501
+ await commit2("chore(svelte5): tailwindcss 3 \u2192 4 upgrade");
2502
+ }
2503
+ const codemods = await applyGotchaCodemods(cwd);
2504
+ if (codemods.filesChanged > 0) {
2505
+ await commit2(`refactor(svelte5): apply gotcha codemods (${codemods.filesChanged} files)`);
2506
+ }
2507
+ await verifyMigration(cwd, spawn2);
2508
+ await commit2("chore(svelte5): pnpm install + check");
2509
+ await writeMigrationSummary({
2510
+ cwd,
2511
+ filesChangedByCodemods: codemods.filesChanged,
2512
+ svelteMigrateRan: migrate.ran,
2513
+ tailwindUpgraded: tw.ran
2514
+ });
2515
+ await commit2("docs(svelte5): add MIGRATION_SVELTE_5.md summary");
2516
+ return { kind: "ok" };
2517
+ }
1943
2518
  });
1944
- const summarySha = await commit(site.path, "docs(svelte5): add MIGRATION_SVELTE_5.md summary");
1945
- if (summarySha) shas.push(summarySha);
1946
- return {
1947
- recipe: "svelte-4-to-5",
1948
- site: label,
1949
- status: shas.length > 0 ? "applied" : "noop",
1950
- commits: shas,
1951
- notes: `branch: ${branch}`
1952
- };
1953
2519
  }
1954
2520
 
1955
2521
  // src/cli/commands/upgrade.ts
@@ -2025,80 +2591,55 @@ async function exists2(path) {
2025
2591
  }
2026
2592
  }
2027
2593
  async function convertToPnpm(site, opts = {}) {
2028
- const label = siteLabel(site);
2029
2594
  const spawn2 = opts.spawn ?? defaultSpawn;
2030
2595
  const pnpmVersion = opts.pnpmVersion ?? DEFAULT_PNPM_VERSION;
2031
2596
  const pnpmLockPath = join15(site.path, "pnpm-lock.yaml");
2032
2597
  const npmLockPath = join15(site.path, "package-lock.json");
2033
2598
  const yarnLockPath = join15(site.path, "yarn.lock");
2034
- if (await exists2(pnpmLockPath)) {
2035
- return {
2036
- recipe: "convert-to-pnpm",
2037
- site: label,
2038
- status: "noop",
2039
- commits: [],
2040
- notes: "site already has pnpm-lock.yaml"
2041
- };
2042
- }
2043
- const hasNpmLock = await exists2(npmLockPath);
2044
- const hasYarnLock = await exists2(yarnLockPath);
2045
- if (!hasNpmLock && !hasYarnLock) {
2046
- return {
2047
- recipe: "convert-to-pnpm",
2048
- site: label,
2049
- status: "noop",
2050
- commits: [],
2051
- notes: "no convertible lockfile (package-lock.json or yarn.lock) at site root"
2052
- };
2053
- }
2054
- if (!await isWorkingTreeClean(site.path)) {
2055
- throw new Error(`refusing to run: working tree is not clean at ${site.path}`);
2056
- }
2057
- const branch = branchName("convert-to-pnpm");
2058
- await createBranch(site.path, branch);
2059
- const shas = [];
2060
- if (hasNpmLock) await rm3(npmLockPath, { force: true });
2061
- if (hasYarnLock) await rm3(yarnLockPath, { force: true });
2062
- const sourceLock = hasNpmLock ? "package-lock.json" : "yarn.lock";
2063
- const lockSha = await commit(site.path, `chore(pnpm): remove ${sourceLock}`);
2064
- if (lockSha) shas.push(lockSha);
2065
- const pkgPath = join15(site.path, "package.json");
2066
- const pkg = await readPackageJson(pkgPath);
2067
- const next = { ...pkg, packageManager: `pnpm@${pnpmVersion}` };
2068
- if (pkg.scripts && typeof pkg.scripts === "object") {
2069
- const { scripts: rewritten, changedCount } = rewriteScriptsForPnpm(
2070
- pkg.scripts
2071
- );
2072
- if (changedCount > 0) {
2073
- next.scripts = rewritten;
2599
+ return withRecipe({
2600
+ name: "convert-to-pnpm",
2601
+ site,
2602
+ plan: async () => {
2603
+ if (await exists2(pnpmLockPath)) {
2604
+ return { kind: "noop", notes: "site already has pnpm-lock.yaml" };
2605
+ }
2606
+ const hasNpmLock = await exists2(npmLockPath);
2607
+ const hasYarnLock = await exists2(yarnLockPath);
2608
+ if (!hasNpmLock && !hasYarnLock) {
2609
+ return {
2610
+ kind: "noop",
2611
+ notes: "no convertible lockfile (package-lock.json or yarn.lock) at site root"
2612
+ };
2613
+ }
2614
+ return { kind: "apply", plan: { hasNpmLock, hasYarnLock } };
2615
+ },
2616
+ apply: async ({ hasNpmLock, hasYarnLock }, { commit: commit2, cwd }) => {
2617
+ if (hasNpmLock) await rm3(npmLockPath, { force: true });
2618
+ if (hasYarnLock) await rm3(yarnLockPath, { force: true });
2619
+ const sourceLock = hasNpmLock ? "package-lock.json" : "yarn.lock";
2620
+ await commit2(`chore(pnpm): remove ${sourceLock}`);
2621
+ const pkgPath = join15(cwd, "package.json");
2622
+ const pkg = await readPackageJson(pkgPath);
2623
+ const next = { ...pkg, packageManager: `pnpm@${pnpmVersion}` };
2624
+ if (pkg.scripts && typeof pkg.scripts === "object") {
2625
+ const { scripts: rewritten, changedCount } = rewriteScriptsForPnpm(
2626
+ pkg.scripts
2627
+ );
2628
+ if (changedCount > 0) {
2629
+ next.scripts = rewritten;
2630
+ }
2631
+ }
2632
+ await writePackageJson(pkgPath, next);
2633
+ await commit2("chore(pnpm): pin packageManager + rewrite npm scripts");
2634
+ await rm3(join15(cwd, "node_modules"), { recursive: true, force: true });
2635
+ const installResult = await spawn2("pnpm", ["install"], { cwd, streaming: true });
2636
+ if (installResult.code !== 0) {
2637
+ return { kind: "failed", notes: `pnpm install failed (exit ${installResult.code})` };
2638
+ }
2639
+ await commit2("chore(pnpm): add pnpm-lock.yaml");
2640
+ return { kind: "ok" };
2074
2641
  }
2075
- }
2076
- await writePackageJson(pkgPath, next);
2077
- const pkgSha = await commit(site.path, "chore(pnpm): pin packageManager + rewrite npm scripts");
2078
- if (pkgSha) shas.push(pkgSha);
2079
- await rm3(join15(site.path, "node_modules"), { recursive: true, force: true });
2080
- const installResult = await spawn2("pnpm", ["install"], {
2081
- cwd: site.path,
2082
- streaming: true
2083
2642
  });
2084
- if (installResult.code !== 0) {
2085
- return {
2086
- recipe: "convert-to-pnpm",
2087
- site: label,
2088
- status: "failed",
2089
- commits: shas,
2090
- notes: `pnpm install failed (exit ${installResult.code}). branch ${branch} left for inspection.`
2091
- };
2092
- }
2093
- const installSha = await commit(site.path, "chore(pnpm): add pnpm-lock.yaml");
2094
- if (installSha) shas.push(installSha);
2095
- return {
2096
- recipe: "convert-to-pnpm",
2097
- site: label,
2098
- status: "applied",
2099
- commits: shas,
2100
- notes: `branch: ${branch}`
2101
- };
2102
2643
  }
2103
2644
 
2104
2645
  // src/cli/commands/convert-to-pnpm.ts
@@ -2183,74 +2724,59 @@ function isDeclared(pkg, name) {
2183
2724
  return Boolean(pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]);
2184
2725
  }
2185
2726
  async function onboard(site, opts = {}) {
2186
- const label = siteLabel(site);
2187
2727
  const spawn2 = opts.spawn ?? defaultSpawn;
2188
2728
  const audits = opts.audits ?? ["lighthouse", "a11y"];
2189
2729
  const packageVersion = opts.packageVersion ?? selfCaretRange(import.meta.url);
2190
- if (!await exists3(join17(site.path, "pnpm-lock.yaml"))) {
2191
- return {
2192
- recipe: "onboard",
2193
- site: label,
2194
- status: "failed",
2195
- commits: [],
2196
- notes: "no pnpm-lock.yaml at site root \u2014 run convert-to-pnpm first"
2197
- };
2198
- }
2199
- const pkgPath = join17(site.path, "package.json");
2200
- const pkg = await readPackageJson(pkgPath);
2201
- const toAdd = [];
2202
- if (!isDeclared(pkg, PACKAGE_NAME)) {
2203
- toAdd.push({ name: PACKAGE_NAME, version: packageVersion });
2204
- }
2205
- for (const audit of audits) {
2206
- for (const dep of AUDIT_DEPS[audit]) {
2207
- if (!isDeclared(pkg, dep.name)) toAdd.push(dep);
2730
+ return withRecipe({
2731
+ name: "onboard",
2732
+ site,
2733
+ plan: async () => {
2734
+ if (!await exists3(join17(site.path, "pnpm-lock.yaml"))) {
2735
+ return {
2736
+ kind: "failed",
2737
+ notes: "no pnpm-lock.yaml at site root \u2014 run convert-to-pnpm first"
2738
+ };
2739
+ }
2740
+ const pkgPath = join17(site.path, "package.json");
2741
+ const pkg = await readPackageJson(pkgPath);
2742
+ const toAdd = [];
2743
+ if (!isDeclared(pkg, PACKAGE_NAME)) {
2744
+ toAdd.push({ name: PACKAGE_NAME, version: packageVersion });
2745
+ }
2746
+ for (const audit of audits) {
2747
+ for (const dep of AUDIT_DEPS[audit]) {
2748
+ if (!isDeclared(pkg, dep.name)) toAdd.push(dep);
2749
+ }
2750
+ }
2751
+ if (toAdd.length === 0) {
2752
+ return {
2753
+ kind: "noop",
2754
+ notes: `site already has ${PACKAGE_NAME} and audit deps (${audits.join("+")})`
2755
+ };
2756
+ }
2757
+ return { kind: "apply", plan: { pkg, toAdd } };
2758
+ },
2759
+ apply: async ({ pkg, toAdd }, { commit: commit2, cwd }) => {
2760
+ const pkgPath = join17(cwd, "package.json");
2761
+ let next = pkg;
2762
+ for (const dep of toAdd) {
2763
+ next = bumpDep(next, dep.name, dep.version);
2764
+ }
2765
+ await writePackageJson(pkgPath, next);
2766
+ const installResult = await spawn2("pnpm", ["install"], { cwd, streaming: true });
2767
+ if (installResult.code !== 0) {
2768
+ return {
2769
+ kind: "failed",
2770
+ notes: `pnpm install failed (exit ${installResult.code})`
2771
+ };
2772
+ }
2773
+ await commit2(`chore(reddoor): onboard with ${PACKAGE_NAME} ${packageVersion}`);
2774
+ return {
2775
+ kind: "ok",
2776
+ notes: `Added ${toAdd.length} dep(s): ${toAdd.map((d) => d.name).join(", ")}`
2777
+ };
2208
2778
  }
2209
- }
2210
- if (toAdd.length === 0) {
2211
- return {
2212
- recipe: "onboard",
2213
- site: label,
2214
- status: "noop",
2215
- commits: [],
2216
- notes: `site already has ${PACKAGE_NAME} and audit deps (${audits.join("+")})`
2217
- };
2218
- }
2219
- if (!await isWorkingTreeClean(site.path)) {
2220
- throw new Error(`refusing to run: working tree is not clean at ${site.path}`);
2221
- }
2222
- const branch = branchName("onboard");
2223
- await createBranch(site.path, branch);
2224
- let next = pkg;
2225
- for (const dep of toAdd) {
2226
- next = bumpDep(next, dep.name, dep.version);
2227
- }
2228
- await writePackageJson(pkgPath, next);
2229
- const installResult = await spawn2("pnpm", ["install"], {
2230
- cwd: site.path,
2231
- streaming: true
2232
2779
  });
2233
- if (installResult.code !== 0) {
2234
- return {
2235
- recipe: "onboard",
2236
- site: label,
2237
- status: "failed",
2238
- commits: [],
2239
- notes: `pnpm install failed (exit ${installResult.code}). branch ${branch} left for inspection.`
2240
- };
2241
- }
2242
- const sha = await commit(
2243
- site.path,
2244
- `chore(reddoor): onboard with ${PACKAGE_NAME} ${packageVersion}`
2245
- );
2246
- const shas = sha ? [sha] : [];
2247
- return {
2248
- recipe: "onboard",
2249
- site: label,
2250
- status: "applied",
2251
- commits: shas,
2252
- notes: `branch: ${branch}. Added ${toAdd.length} dep(s): ${toAdd.map((d) => d.name).join(", ")}`
2253
- };
2254
2780
  }
2255
2781
 
2256
2782
  // src/cli/commands/onboard.ts
@@ -2302,36 +2828,24 @@ import { resolve as resolve8 } from "path";
2302
2828
  import { writeFile as writeFile8 } from "fs/promises";
2303
2829
  import { join as join18 } from "path";
2304
2830
  async function svelteCodemods(site) {
2305
- const label = siteLabel(site);
2306
- const changes = await planGotchaCodemods(site.path);
2307
- if (changes.length === 0) {
2308
- return {
2309
- recipe: "svelte-codemods",
2310
- site: label,
2311
- status: "noop",
2312
- commits: [],
2313
- notes: "no codemod targets matched"
2314
- };
2315
- }
2316
- if (!await isWorkingTreeClean(site.path)) {
2317
- throw new Error(`refusing to run: working tree is not clean at ${site.path}`);
2318
- }
2319
- const branch = branchName("svelte-codemods");
2320
- await createBranch(site.path, branch);
2321
- for (const c of changes) {
2322
- await writeFile8(join18(site.path, c.rel), c.after, "utf-8");
2323
- }
2324
- const sha = await commit(
2325
- site.path,
2326
- `refactor(svelte5): apply codemods (${changes.length} files)`
2327
- );
2328
- return {
2329
- recipe: "svelte-codemods",
2330
- site: label,
2331
- status: "applied",
2332
- commits: sha ? [sha] : [],
2333
- notes: `branch: ${branch}`
2334
- };
2831
+ return withRecipe({
2832
+ name: "svelte-codemods",
2833
+ site,
2834
+ plan: async () => {
2835
+ const changes = await planGotchaCodemods(site.path);
2836
+ if (changes.length === 0) {
2837
+ return { kind: "noop", notes: "no codemod targets matched" };
2838
+ }
2839
+ return { kind: "apply", plan: changes };
2840
+ },
2841
+ apply: async (changes, { commit: commit2, cwd }) => {
2842
+ for (const c of changes) {
2843
+ await writeFile8(join18(cwd, c.rel), c.after, "utf-8");
2844
+ }
2845
+ await commit2(`refactor(svelte5): apply codemods (${changes.length} files)`);
2846
+ return { kind: "ok" };
2847
+ }
2848
+ });
2335
2849
  }
2336
2850
 
2337
2851
  // src/cli/commands/svelte-codemods.ts
@@ -2359,6 +2873,208 @@ async function runSvelteCodemodsCommand(site, opts) {
2359
2873
  return { output, code };
2360
2874
  }
2361
2875
 
2876
+ // src/cli/commands/report.ts
2877
+ init_client();
2878
+ init_websites();
2879
+ init_reports();
2880
+
2881
+ // src/reports/due.ts
2882
+ var MONTHS = {
2883
+ Monthly: 1,
2884
+ Quarterly: 3,
2885
+ Yearly: 12
2886
+ };
2887
+ function addMonths(d, n) {
2888
+ const out = new Date(d);
2889
+ const day = out.getUTCDate();
2890
+ out.setUTCDate(1);
2891
+ out.setUTCMonth(out.getUTCMonth() + n);
2892
+ const lastDayOfTargetMonth = new Date(
2893
+ Date.UTC(out.getUTCFullYear(), out.getUTCMonth() + 1, 0)
2894
+ ).getUTCDate();
2895
+ out.setUTCDate(Math.min(day, lastDayOfTargetMonth));
2896
+ return out;
2897
+ }
2898
+ function startOfDay(d) {
2899
+ const out = new Date(d);
2900
+ out.setUTCHours(0, 0, 0, 0);
2901
+ return out;
2902
+ }
2903
+ function lastSentForType(reports, siteId, type) {
2904
+ const candidates = reports.filter((r) => r.siteId === siteId && r.reportType === type && r.sentAt !== null).map((r) => r.sentAt).sort();
2905
+ return candidates[candidates.length - 1] ?? null;
2906
+ }
2907
+ function findDueReports(websites, reports, today) {
2908
+ const out = [];
2909
+ const todayStart = startOfDay(today);
2910
+ for (const site of websites) {
2911
+ for (const type of ["Maintenance", "Testing"]) {
2912
+ const freq = type === "Maintenance" ? site.maintenanceFreq : site.testingFreq;
2913
+ if (freq === "None") continue;
2914
+ const lastSent = lastSentForType(reports, site.id, type);
2915
+ const fallback = type === "Maintenance" ? site.maintenanceDay : site.testingDay;
2916
+ const baseIso = lastSent ?? fallback;
2917
+ if (!baseIso) {
2918
+ out.push({ site, reportType: type, dueDate: todayStart, lastSent });
2919
+ continue;
2920
+ }
2921
+ const dueDate = addMonths(new Date(baseIso), MONTHS[freq]);
2922
+ if (todayStart.getTime() >= startOfDay(dueDate).getTime()) {
2923
+ out.push({ site, reportType: type, dueDate, lastSent });
2924
+ }
2925
+ }
2926
+ }
2927
+ return out;
2928
+ }
2929
+
2930
+ // src/reports/draft.ts
2931
+ init_render();
2932
+ init_websites();
2933
+ init_reports();
2934
+ import { mkdir as mkdir2, writeFile as writeFile9 } from "fs/promises";
2935
+ import { dirname as dirname2 } from "path";
2936
+ function scoresFromWebsite(siteRow) {
2937
+ const { pScore, rScore, bpScore, seoScore } = siteRow;
2938
+ if (pScore === null || rScore === null || bpScore === null || seoScore === null) {
2939
+ throw new Error(
2940
+ `Site '${siteRow.name}' is missing one or more Lighthouse scores on the Websites row (pScore, rScore, bpScore, seoScore). Run 'reddoor-maint audit lighthouse' from the site's checkout and paste the four numbers into Airtable, then retry.`
2941
+ );
2942
+ }
2943
+ return { performance: pScore, accessibility: rScore, bestPractices: bpScore, seo: seoScore };
2944
+ }
2945
+ function daysAgo(today, n) {
2946
+ const out = new Date(today);
2947
+ out.setDate(out.getDate() - n);
2948
+ return out;
2949
+ }
2950
+ async function draftReportForSite(base, siteRow, reportType, options = {}) {
2951
+ const scores = scoresFromWebsite(siteRow);
2952
+ const today = /* @__PURE__ */ new Date();
2953
+ const slug = siteSlug(siteRow.name);
2954
+ const periodStart = base !== null ? await derivePeriodStart(base, siteRow, reportType, today) : daysAgo(today, 30);
2955
+ const periodEnd = today;
2956
+ const completedOn = today;
2957
+ const lastTestedDate = reportType === "Maintenance" && siteRow.testingDay ? new Date(siteRow.testingDay) : null;
2958
+ const cidName = `${slug}-header`;
2959
+ const { html } = await renderReportHtml({
2960
+ siteName: siteRow.name,
2961
+ siteUrl: siteRow.url,
2962
+ reportType,
2963
+ completedOn,
2964
+ lighthouse: scores,
2965
+ gaUsersCurrent: 0,
2966
+ gaUsersPrevious: 0,
2967
+ lastTestedDate,
2968
+ commentary: null,
2969
+ headerImageCid: cidName
2970
+ });
2971
+ if (options.previewOnly) {
2972
+ const path = options.previewPath ?? `reports/${slug}/draft.html`;
2973
+ await mkdir2(dirname2(path), { recursive: true });
2974
+ await writeFile9(path, html, "utf-8");
2975
+ return { reportRow: null, htmlPath: path, html };
2976
+ }
2977
+ if (base === null) throw new Error("base required when previewOnly=false");
2978
+ const reportId = `${siteRow.name} \u2014 ${reportType} \u2014 ${periodEnd.toISOString().slice(0, 10)}`;
2979
+ const created = await createDraft(base, {
2980
+ reportId,
2981
+ siteId: siteRow.id,
2982
+ reportType,
2983
+ periodStart,
2984
+ periodEnd,
2985
+ completedOn,
2986
+ lighthouse: scores,
2987
+ lastTestedDate
2988
+ });
2989
+ await uploadHtmlAttachment(created.id, html, slug, periodEnd);
2990
+ await setDraftReady(base, created.id, true);
2991
+ return { reportRow: created, htmlPath: null, html };
2992
+ }
2993
+ async function derivePeriodStart(base, siteRow, reportType, today) {
2994
+ const prior = await listReportsForSite(base, siteRow.id);
2995
+ const sameType = prior.filter((r) => r.reportType === reportType && r.periodEnd).map((r) => r.periodEnd).sort();
2996
+ const latest = sameType[sameType.length - 1];
2997
+ return latest ? new Date(latest) : daysAgo(today, 30);
2998
+ }
2999
+ async function uploadHtmlAttachment(recordId, html, slug, periodEnd) {
3000
+ const apiKey = process.env.AIRTABLE_PAT;
3001
+ const baseId = process.env.AIRTABLE_BASE_ID;
3002
+ const filename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;
3003
+ const body = {
3004
+ contentType: "text/html",
3005
+ file: Buffer.from(html, "utf-8").toString("base64"),
3006
+ filename
3007
+ };
3008
+ const url = `https://content.airtable.com/v0/${baseId}/${recordId}/Rendered%20HTML/uploadAttachment`;
3009
+ const res = await fetch(url, {
3010
+ method: "POST",
3011
+ headers: {
3012
+ Authorization: `Bearer ${apiKey}`,
3013
+ "Content-Type": "application/json"
3014
+ },
3015
+ body: JSON.stringify(body)
3016
+ });
3017
+ if (!res.ok) {
3018
+ throw new Error(`Airtable upload failed: ${res.status} ${res.statusText} ${await res.text()}`);
3019
+ }
3020
+ }
3021
+
3022
+ // src/cli/commands/report.ts
3023
+ async function runReportCommand(slug, opts) {
3024
+ if (opts.sendReady) {
3025
+ const { sendApprovedReports: sendApprovedReports2 } = await Promise.resolve().then(() => (init_orchestrate(), orchestrate_exports));
3026
+ return sendApprovedReports2();
3027
+ }
3028
+ if (opts.due) {
3029
+ return runDueDraft();
3030
+ }
3031
+ if (slug) {
3032
+ return runSingleSiteDraft(slug, { previewOnly: Boolean(opts.preview) });
3033
+ }
3034
+ throw Object.assign(
3035
+ new Error("Usage: reddoor-maint report [<slug>] [--due] [--preview] [--send-ready]"),
3036
+ {
3037
+ exitCode: 2
3038
+ }
3039
+ );
3040
+ }
3041
+ async function runDueDraft() {
3042
+ const base = openBase(readAirtableConfig());
3043
+ const websites = await listWebsites(base);
3044
+ const reports = [];
3045
+ for (const w of websites) {
3046
+ const rs = await listReportsForSite(base, w.id);
3047
+ reports.push(...rs);
3048
+ }
3049
+ const due = findDueReports(websites, reports, /* @__PURE__ */ new Date());
3050
+ if (due.length === 0) return { output: "No reports due.", code: 0 };
3051
+ const lines = [];
3052
+ for (const item of due) {
3053
+ try {
3054
+ const result = await draftReportForSite(base, item.site, item.reportType);
3055
+ lines.push(`\u2713 drafted: ${result.reportRow?.reportId}`);
3056
+ } catch (e) {
3057
+ lines.push(`\u2717 failed: ${item.site.name} ${item.reportType} \u2014 ${e.message}`);
3058
+ }
3059
+ }
3060
+ return { output: lines.join("\n"), code: lines.some((l) => l.startsWith("\u2717")) ? 1 : 0 };
3061
+ }
3062
+ async function runSingleSiteDraft(slug, opts) {
3063
+ const base = openBase(readAirtableConfig());
3064
+ const websites = await listWebsites(base);
3065
+ const site = websites.find((w) => siteSlug(w.name) === slug);
3066
+ if (!site) {
3067
+ throw Object.assign(new Error(`No Websites row matched slug "${slug}"`), { exitCode: 2 });
3068
+ }
3069
+ const result = await draftReportForSite(opts.previewOnly ? null : base, site, "Maintenance", {
3070
+ previewOnly: opts.previewOnly
3071
+ });
3072
+ if (opts.previewOnly) {
3073
+ return { output: `Preview written to ${result.htmlPath}`, code: 0 };
3074
+ }
3075
+ return { output: `Draft created: ${result.reportRow?.reportId}`, code: 0 };
3076
+ }
3077
+
2362
3078
  // src/cli/version.ts
2363
3079
  import { readFileSync as readFileSync2 } from "fs";
2364
3080
  import { join as join19 } from "path";
@@ -2373,7 +3089,7 @@ function resolvePackageVersion(fromDir) {
2373
3089
  }
2374
3090
 
2375
3091
  // src/cli/bin.ts
2376
- var here = dirname2(fileURLToPath2(import.meta.url));
3092
+ var here = dirname3(fileURLToPath2(import.meta.url));
2377
3093
  var version = resolvePackageVersion(here);
2378
3094
  var AUDIT_DESCRIPTIONS = {
2379
3095
  deps: "Diff site package.json against the bundled baseline version map.",
@@ -2441,6 +3157,15 @@ cli.command(
2441
3157
  ).option("--audits <names>", "Comma-separated audit subset: lighthouse,a11y (default: both)").option("--fleet <inventory>", "Inventory file (.json or .mjs/.js)").option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
2442
3158
  async (site, opts) => runOrExit(() => runOnboardCommand(site, opts), opts)
2443
3159
  );
3160
+ cli.command("report [site]", "Draft or send maintenance/testing reports.").option("--due", "Scan all Websites and draft overdue reports.").option(
3161
+ "--preview",
3162
+ "Single-site dry run; writes reports/<slug>/draft.html, never touches Airtable."
3163
+ ).option(
3164
+ "--send-ready",
3165
+ "Send all Reports with Draft ready=true AND Approved to send=true AND Sent at IS NULL."
3166
+ ).action(
3167
+ async (site, opts) => runOrExit(() => runReportCommand(site, opts), opts)
3168
+ );
2444
3169
  cli.help();
2445
3170
  cli.version(version);
2446
3171
  cli.parse();