@reddoorla/maintenance 0.6.8 → 0.8.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,8 +1,742 @@
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
+ };
2
11
 
3
- // src/cli/bin.ts
4
- import { dirname as dirname2 } from "path";
12
+ // src/reports/airtable/client.ts
13
+ var client_exports = {};
14
+ __export(client_exports, {
15
+ openBase: () => openBase,
16
+ readAirtableConfig: () => readAirtableConfig
17
+ });
18
+ import Airtable from "airtable";
19
+ function readAirtableConfig() {
20
+ const apiKey = process.env.AIRTABLE_PAT;
21
+ const baseId = process.env.AIRTABLE_BASE_ID;
22
+ if (!apiKey) throw Object.assign(new Error("AIRTABLE_PAT not set"), { exitCode: 2 });
23
+ if (!baseId) throw Object.assign(new Error("AIRTABLE_BASE_ID not set"), { exitCode: 2 });
24
+ return { apiKey, baseId };
25
+ }
26
+ function openBase(cfg) {
27
+ return new Airtable({ apiKey: cfg.apiKey }).base(cfg.baseId);
28
+ }
29
+ var init_client = __esm({
30
+ "src/reports/airtable/client.ts"() {
31
+ "use strict";
32
+ }
33
+ });
34
+
35
+ // src/reports/airtable/websites.ts
36
+ var websites_exports = {};
37
+ __export(websites_exports, {
38
+ WEBSITES_TABLE: () => WEBSITES_TABLE,
39
+ getWebsiteBySlug: () => getWebsiteBySlug,
40
+ listWebsites: () => listWebsites,
41
+ siteSlug: () => siteSlug,
42
+ updateScores: () => updateScores
43
+ });
44
+ function siteSlug(name) {
45
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
46
+ }
47
+ function mapRow(rec) {
48
+ const f = rec.fields;
49
+ const attachments = f["Header image"] ?? [];
50
+ const header = attachments[0] ?? null;
51
+ return {
52
+ id: rec.id,
53
+ name: String(f["Name"] ?? ""),
54
+ url: String(f["url"] ?? ""),
55
+ status: f["Status"] ?? null,
56
+ pointOfContact: f["point of contact"] ?? null,
57
+ maintenanceFreq: f["maintenence freq"] ?? "None",
58
+ testingFreq: f["testing freq"] ?? "None",
59
+ maintenanceDay: f["maintenance day"] ?? null,
60
+ testingDay: f["testing day"] ?? null,
61
+ ga4PropertyId: f["GA4 property ID"] ?? null,
62
+ reportRecipientsTo: f["Report recipients (To)"] ?? null,
63
+ reportRecipientsCc: f["Report recipients (CC)"] ?? null,
64
+ headerImage: header,
65
+ pScore: f["pScore"] ?? null,
66
+ rScore: f["rScore"] ?? null,
67
+ bpScore: f["bpScore"] ?? null,
68
+ seoScore: f["seoScore"] ?? null,
69
+ lastLighthouseAuditAt: f["Last lighthouse audit at"] ?? null
70
+ };
71
+ }
72
+ async function listWebsites(base) {
73
+ const out = [];
74
+ await base(WEBSITES_TABLE).select({ pageSize: 100 }).eachPage((records, fetchNextPage) => {
75
+ for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));
76
+ fetchNextPage();
77
+ });
78
+ return out;
79
+ }
80
+ async function getWebsiteBySlug(base, slug) {
81
+ const all = await listWebsites(base);
82
+ return all.find((w) => siteSlug(w.name) === slug) ?? null;
83
+ }
84
+ async function updateScores(base, recordId, scores) {
85
+ const fields = {
86
+ pScore: scores.performance,
87
+ rScore: scores.accessibility,
88
+ bpScore: scores.bestPractices,
89
+ seoScore: scores.seo,
90
+ "Last lighthouse audit at": (/* @__PURE__ */ new Date()).toISOString()
91
+ };
92
+ await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
93
+ }
94
+ var WEBSITES_TABLE;
95
+ var init_websites = __esm({
96
+ "src/reports/airtable/websites.ts"() {
97
+ "use strict";
98
+ WEBSITES_TABLE = "Websites";
99
+ }
100
+ });
101
+
102
+ // src/inventory/airtable.ts
103
+ var airtable_exports = {};
104
+ __export(airtable_exports, {
105
+ fromAirtableBase: () => fromAirtableBase
106
+ });
107
+ function fromAirtableBase(base, opts = {}) {
108
+ return async () => {
109
+ const workdir = opts.workdir ?? process.env.REDDOOR_FLEET_WORKDIR;
110
+ if (!workdir) {
111
+ throw new Error(
112
+ "fromAirtableBase requires `workdir` option or REDDOOR_FLEET_WORKDIR env (sites need a local path)"
113
+ );
114
+ }
115
+ const websites = await listWebsites(base);
116
+ return websites.filter((w) => w.maintenanceFreq !== "None" || w.testingFreq !== "None").map((w) => {
117
+ const slug = siteSlug(w.name);
118
+ const site = {
119
+ path: `${workdir}/${slug}`,
120
+ name: slug,
121
+ meta: { airtableRowId: w.id, displayName: w.name }
122
+ };
123
+ if (w.url) site.repoUrl = w.url;
124
+ return site;
125
+ });
126
+ };
127
+ }
128
+ var init_airtable = __esm({
129
+ "src/inventory/airtable.ts"() {
130
+ "use strict";
131
+ init_websites();
132
+ }
133
+ });
134
+
135
+ // src/audits/lighthouse-airtable.ts
136
+ var lighthouse_airtable_exports = {};
137
+ __export(lighthouse_airtable_exports, {
138
+ lighthouseScoresFromResult: () => lighthouseScoresFromResult,
139
+ resolveSlugFromCwd: () => resolveSlugFromCwd
140
+ });
141
+ import { readFile as readFile6 } from "fs/promises";
142
+ import { join as join6 } from "path";
143
+ function lighthouseScoresFromResult(result) {
144
+ if (result.audit !== "lighthouse") {
145
+ throw new Error(`Expected a 'lighthouse' AuditResult, got '${result.audit}'`);
146
+ }
147
+ const details = result.details ?? {};
148
+ const summary = details.summary ?? {};
149
+ const toPct = (n) => typeof n === "number" && !Number.isNaN(n) ? Math.round(n * 100) : 0;
150
+ return {
151
+ performance: toPct(summary["performance"]),
152
+ accessibility: toPct(summary["accessibility"]),
153
+ bestPractices: toPct(summary["best-practices"]),
154
+ seo: toPct(summary["seo"])
155
+ };
156
+ }
157
+ async function resolveSlugFromCwd(cwd) {
158
+ try {
159
+ const pkgPath = join6(cwd, "package.json");
160
+ const raw = await readFile6(pkgPath, "utf-8");
161
+ const pkg = JSON.parse(raw);
162
+ if (!pkg.name) throw new Error("package.json has no 'name' field");
163
+ return siteSlug(pkg.name);
164
+ } catch (e) {
165
+ throw new Error(
166
+ `Could not derive site slug from ${cwd}/package.json: ${e.message}. Pass --write-airtable=<slug> explicitly.`,
167
+ { cause: e }
168
+ );
169
+ }
170
+ }
171
+ var init_lighthouse_airtable = __esm({
172
+ "src/audits/lighthouse-airtable.ts"() {
173
+ "use strict";
174
+ init_websites();
175
+ }
176
+ });
177
+
178
+ // src/reports/airtable/reports.ts
179
+ function mapRow2(rec) {
180
+ const f = rec.fields;
181
+ const linkSites = f["Site"] ?? [];
182
+ const html = (f["Rendered HTML"] ?? [])[0] ?? null;
183
+ return {
184
+ id: rec.id,
185
+ reportId: String(f["Report ID"] ?? ""),
186
+ siteId: linkSites[0] ?? "",
187
+ reportType: f["Report type"] ?? "Maintenance",
188
+ periodStart: f["Period start"] ?? null,
189
+ periodEnd: f["Period end"] ?? null,
190
+ completedOn: f["Completed on"] ?? null,
191
+ lighthouse: lighthouseFromFields(f),
192
+ gaUsersCurrent: f["GA users (period)"] ?? null,
193
+ gaUsersPrevious: f["GA users (prev period)"] ?? null,
194
+ lastTestedDate: f["Last tested date"] ?? null,
195
+ commentary: f["Commentary"] ?? null,
196
+ subjectOverride: f["Subject override"] ?? null,
197
+ draftReady: Boolean(f["Draft ready"]),
198
+ approvedToSend: Boolean(f["Approved to send"]),
199
+ sentAt: f["Sent at"] ?? null,
200
+ deliveryStatus: f["Delivery status"] ?? "pending",
201
+ renderedHtmlAttachment: html,
202
+ resendMessageId: f["Resend message ID"] ?? null
203
+ };
204
+ }
205
+ function lighthouseFromFields(f) {
206
+ const p = f["Lighthouse \u2014 Performance"];
207
+ const a = f["Lighthouse \u2014 Accessibility"];
208
+ const b = f["Lighthouse \u2014 Best Practices"];
209
+ const s = f["Lighthouse \u2014 SEO"];
210
+ if (typeof p !== "number" || typeof a !== "number" || typeof b !== "number" || typeof s !== "number")
211
+ return null;
212
+ return { performance: p, accessibility: a, bestPractices: b, seo: s };
213
+ }
214
+ function ymd(d) {
215
+ return d.toISOString().slice(0, 10);
216
+ }
217
+ function escapeFormulaString(s) {
218
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
219
+ }
220
+ async function createDraft(base, input) {
221
+ const fields = {
222
+ "Report ID": input.reportId,
223
+ Site: [input.siteId],
224
+ "Report type": input.reportType,
225
+ "Period start": ymd(input.periodStart),
226
+ "Period end": ymd(input.periodEnd),
227
+ "Completed on": ymd(input.completedOn),
228
+ "Lighthouse \u2014 Performance": input.lighthouse.performance,
229
+ "Lighthouse \u2014 Accessibility": input.lighthouse.accessibility,
230
+ "Lighthouse \u2014 Best Practices": input.lighthouse.bestPractices,
231
+ "Lighthouse \u2014 SEO": input.lighthouse.seo,
232
+ "Delivery status": "pending"
233
+ };
234
+ if (input.lastTestedDate) fields["Last tested date"] = ymd(input.lastTestedDate);
235
+ const created = await base(REPORTS_TABLE).create([{ fields }]);
236
+ const rec = created[0];
237
+ if (!rec) throw new Error("Airtable create returned no records");
238
+ return mapRow2({ id: rec.id, fields: rec.fields });
239
+ }
240
+ async function setDraftReady(base, recordId, ready) {
241
+ await base(REPORTS_TABLE).update([{ id: recordId, fields: { "Draft ready": ready } }]);
242
+ }
243
+ async function listSendableReports(base) {
244
+ const out = [];
245
+ await base(REPORTS_TABLE).select({
246
+ filterByFormula: "AND({Draft ready} = TRUE(), {Approved to send} = TRUE(), {Sent at} = BLANK())",
247
+ pageSize: 100
248
+ }).eachPage((records, fetchNextPage) => {
249
+ for (const rec of records) out.push(mapRow2({ id: rec.id, fields: rec.fields }));
250
+ fetchNextPage();
251
+ });
252
+ return out;
253
+ }
254
+ async function listReportsForSite(base, siteId) {
255
+ const safeId = escapeFormulaString(siteId);
256
+ const out = [];
257
+ await base(REPORTS_TABLE).select({
258
+ filterByFormula: `FIND(",${safeId},", "," & ARRAYJOIN({Site}, ",") & ",") > 0`,
259
+ pageSize: 100
260
+ }).eachPage((records, fetchNextPage) => {
261
+ for (const rec of records) out.push(mapRow2({ id: rec.id, fields: rec.fields }));
262
+ fetchNextPage();
263
+ });
264
+ return out;
265
+ }
266
+ async function stampSent(base, recordId, sentAt, messageId) {
267
+ await base(REPORTS_TABLE).update([
268
+ {
269
+ id: recordId,
270
+ fields: {
271
+ "Sent at": sentAt.toISOString(),
272
+ "Resend message ID": messageId
273
+ }
274
+ }
275
+ ]);
276
+ }
277
+ var REPORTS_TABLE;
278
+ var init_reports = __esm({
279
+ "src/reports/airtable/reports.ts"() {
280
+ "use strict";
281
+ REPORTS_TABLE = "Reports";
282
+ }
283
+ });
284
+
285
+ // src/reports/maintenance-email/assets/index.ts
286
+ import { readFile as readFile12 } from "fs/promises";
287
+ import { dirname as dirname2, join as join20 } from "path";
5
288
  import { fileURLToPath as fileURLToPath2 } from "url";
289
+ async function loadBundledImages() {
290
+ const [check, blurred] = await Promise.all([
291
+ readFile12(join20(here, "check.png")),
292
+ readFile12(join20(here, "blurredTests.jpg"))
293
+ ]);
294
+ return {
295
+ check: {
296
+ bytes: new Uint8Array(check),
297
+ contentType: "image/png",
298
+ cid: CHECK_CID,
299
+ filename: "check.png"
300
+ },
301
+ blurred: {
302
+ bytes: new Uint8Array(blurred),
303
+ contentType: "image/jpeg",
304
+ cid: BLURRED_CID,
305
+ filename: "blurredTests.jpg"
306
+ }
307
+ };
308
+ }
309
+ var here, CHECK_CID, BLURRED_CID;
310
+ var init_assets = __esm({
311
+ "src/reports/maintenance-email/assets/index.ts"() {
312
+ "use strict";
313
+ here = dirname2(fileURLToPath2(import.meta.url));
314
+ CHECK_CID = "rd-check-png";
315
+ BLURRED_CID = "rd-blurred-tests-jpg";
316
+ }
317
+ });
318
+
319
+ // src/reports/maintenance-email/template.ts
320
+ function fmtDate(d) {
321
+ if (!d) return "";
322
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
323
+ const dd = String(d.getUTCDate()).padStart(2, "0");
324
+ const yyyy = d.getUTCFullYear();
325
+ return `${mm}.${dd}.${yyyy}`;
326
+ }
327
+ function fmtUsers(n) {
328
+ return n.toLocaleString("en-US");
329
+ }
330
+ function maintenanceChecksSection() {
331
+ const rows = [
332
+ "Reviewed Logs",
333
+ "CMS Checked",
334
+ "DNS Checked",
335
+ "Google Indexed",
336
+ "Reviewed Certificate",
337
+ "Security Updates"
338
+ ];
339
+ return rows.map(
340
+ (label, i) => `
341
+ <mj-section background-color="white" padding="0px"${i === rows.length - 1 ? ' padding-bottom="36px"' : ""}>
342
+ <mj-group>
343
+ <mj-column padding-left="0px" width="90%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""}>
344
+ <mj-text height="25px" padding-left="0px" color="#757575" padding-top="20px" padding-bottom="7.5px" font-size="16px">${label}</mj-text>
345
+ </mj-column>
346
+ <mj-column width="10%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""} padding-top="15px">
347
+ <mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
348
+ </mj-column>
349
+ </mj-group>
350
+ </mj-section>`
351
+ ).join("");
352
+ }
353
+ function testingChecklistSection() {
354
+ const rows = [
355
+ "Desktop Browsers",
356
+ "Mobile Browsers",
357
+ "Package Updates",
358
+ "Bottlenecks",
359
+ "Form Functionality",
360
+ "Animation Functionality"
361
+ ];
362
+ return rows.map(
363
+ (label, i) => `
364
+ <mj-section background-color="#F4F4F4" padding="0px"${i === rows.length - 1 ? ' padding-bottom="60px"' : ""}>
365
+ <mj-group>
366
+ <mj-column width="90%" padding-left="0px"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""}>
367
+ <mj-text height="25px" padding-left="0px" color="#757575" padding-top="20px" padding-bottom="7.5px" font-size="16px">${label}</mj-text>
368
+ </mj-column>
369
+ <mj-column width="10%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""} padding-top="15px">
370
+ <mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
371
+ </mj-column>
372
+ </mj-group>
373
+ </mj-section>`
374
+ ).join("");
375
+ }
376
+ function maintenanceTestingPlaceholder(lastTested) {
377
+ return `
378
+ <mj-section background-color="#F4F4F4">
379
+ <mj-column>
380
+ <mj-image href="mailto:info@reddoorla.com" src="${BLURRED_TESTS}" />
381
+ </mj-column>
382
+ </mj-section>
383
+ <mj-section background-color="#F4F4F4" padding-top="0px">
384
+ <mj-column>
385
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">Last Tested: ${fmtDate(lastTested)}</mj-text>
386
+ </mj-column>
387
+ </mj-section>`;
388
+ }
389
+ function testingIntroSection() {
390
+ return `
391
+ <mj-section background-color="#F4F4F4">
392
+ <mj-column>
393
+ <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">TESTING</mj-text>
394
+ <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>
395
+ </mj-column>
396
+ </mj-section>`;
397
+ }
398
+ function commentarySection(text) {
399
+ return `
400
+ <mj-section background-color="white">
401
+ <mj-column>
402
+ <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">NOTES</mj-text>
403
+ <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>
404
+ </mj-column>
405
+ </mj-section>`;
406
+ }
407
+ function buildMjml(data) {
408
+ const isTesting = data.reportType === "Testing";
409
+ const headerSrc = `cid:${data.headerImageCid}`;
410
+ const previewText = `Checked up on ${data.siteName}`;
411
+ return `<mjml>
412
+ <mj-head>
413
+ <mj-attributes>
414
+ <mj-text font-family="helvetica, sans-serif" padding-left="5px" padding-right="5px" />
415
+ <mj-section padding-left="11%" padding-right="11%"/>
416
+ <mj-image padding="0px" />
417
+ </mj-attributes>
418
+ <mj-preview>${previewText}</mj-preview>
419
+ </mj-head>
420
+ <mj-body background-color="white">
421
+ <mj-section background-color="#F4F4F4" padding-top="0px" padding-bottom="0px" padding-left="0px" padding-right="0px">
422
+ <mj-column>
423
+ <mj-image href="${data.siteUrl}" src="${headerSrc}" />
424
+ </mj-column>
425
+ </mj-section>
426
+ <mj-section background-color="white">
427
+ <mj-column>
428
+ <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">COMPLETED ON</mj-text>
429
+ <mj-text color="#C00" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
430
+ <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">MAINTENANCE CHECKS</mj-text>
431
+ <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>
432
+ </mj-column>
433
+ </mj-section>
434
+ ${maintenanceChecksSection()}
435
+ <mj-section background-color="#F4F4F4">
436
+ <mj-column>
437
+ <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">LIGHTHOUSE SCORES*</mj-text>
438
+ <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Performance</mj-text>
439
+ <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.performance}</mj-text>
440
+ <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>
441
+ <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
442
+ <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Readability</mj-text>
443
+ <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.accessibility}</mj-text>
444
+ <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>
445
+ <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
446
+ <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Best Practices</mj-text>
447
+ <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.bestPractices}</mj-text>
448
+ <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>
449
+ <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
450
+ <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Site Structure</mj-text>
451
+ <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.seo}</mj-text>
452
+ <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>
453
+ <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>
454
+ </mj-column>
455
+ </mj-section>
456
+ <mj-section background-color="white">
457
+ <mj-column>
458
+ <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">ANALYTICS</mj-text>
459
+ <mj-text color="#C00" font-size="44px" font-weight="400">${fmtUsers(data.gaUsersCurrent)} Users</mj-text>
460
+ <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>
461
+ <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>
462
+ </mj-column>
463
+ </mj-section>
464
+ ${isTesting ? testingIntroSection() + testingChecklistSection() : maintenanceTestingPlaceholder(data.lastTestedDate)}
465
+ ${data.commentary ? commentarySection(data.commentary) : ""}
466
+ <mj-section background-color="white">
467
+ <mj-column padding-top="36px">
468
+ <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>
469
+ <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">Just hit reply.</mj-text>
470
+ <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>
471
+ <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
472
+ <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>
473
+ <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>
474
+ <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>
475
+ <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>
476
+ <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>
477
+ </mj-column>
478
+ </mj-section>
479
+ </mj-body>
480
+ </mjml>`;
481
+ }
482
+ var CHECK_PNG, BLURRED_TESTS;
483
+ var init_template = __esm({
484
+ "src/reports/maintenance-email/template.ts"() {
485
+ "use strict";
486
+ init_assets();
487
+ CHECK_PNG = `cid:${CHECK_CID}`;
488
+ BLURRED_TESTS = `cid:${BLURRED_CID}`;
489
+ }
490
+ });
491
+
492
+ // src/reports/render.ts
493
+ import mjml2html from "mjml";
494
+ async function renderReportHtml(data) {
495
+ const mjml = buildMjml(data);
496
+ const out = await mjml2html(mjml, { validationLevel: "strict" });
497
+ return { html: out.html, warnings: out.errors ?? [] };
498
+ }
499
+ var init_render = __esm({
500
+ "src/reports/render.ts"() {
501
+ "use strict";
502
+ init_template();
503
+ }
504
+ });
505
+
506
+ // src/reports/airtable/attachments.ts
507
+ async function fetchAttachmentBytes(url) {
508
+ const res = await fetch(url);
509
+ if (!res.ok) {
510
+ throw new Error(
511
+ `Failed to fetch Airtable attachment ${res.status} ${res.statusText} (url=${url})`
512
+ );
513
+ }
514
+ const contentType = res.headers.get("content-type") ?? "application/octet-stream";
515
+ const ab = await res.arrayBuffer();
516
+ return { bytes: new Uint8Array(ab), contentType };
517
+ }
518
+ async function uploadAttachment(recordId, fieldName, body, filename, contentType) {
519
+ const apiKey = process.env.AIRTABLE_PAT;
520
+ const baseId = process.env.AIRTABLE_BASE_ID;
521
+ if (!apiKey || !baseId) {
522
+ throw new Error("AIRTABLE_PAT and AIRTABLE_BASE_ID must be set");
523
+ }
524
+ const base64 = typeof body === "string" ? Buffer.from(body, "utf-8").toString("base64") : Buffer.from(body).toString("base64");
525
+ const payload = { contentType, file: base64, filename };
526
+ const url = `https://content.airtable.com/v0/${baseId}/${recordId}/${encodeURIComponent(fieldName)}/uploadAttachment`;
527
+ const res = await fetch(url, {
528
+ method: "POST",
529
+ headers: {
530
+ Authorization: `Bearer ${apiKey}`,
531
+ "Content-Type": "application/json"
532
+ },
533
+ body: JSON.stringify(payload)
534
+ });
535
+ if (!res.ok) {
536
+ throw new Error(`Airtable upload failed: ${res.status} ${res.statusText} ${await res.text()}`);
537
+ }
538
+ }
539
+ var init_attachments = __esm({
540
+ "src/reports/airtable/attachments.ts"() {
541
+ "use strict";
542
+ }
543
+ });
544
+
545
+ // src/reports/send/resend.ts
546
+ import { Resend } from "resend";
547
+ function defaultResendClient() {
548
+ const key = process.env.RESEND_API_KEY;
549
+ if (!key) throw Object.assign(new Error("RESEND_API_KEY not set"), { exitCode: 2 });
550
+ const resend = new Resend(key);
551
+ return {
552
+ async send(input) {
553
+ const payload = {
554
+ from: input.from,
555
+ to: input.to,
556
+ subject: input.subject,
557
+ html: input.html
558
+ };
559
+ if (input.cc) payload.cc = input.cc;
560
+ if (input.replyTo) payload.replyTo = input.replyTo;
561
+ if (input.attachments) payload.attachments = input.attachments;
562
+ const options = {};
563
+ if (input.idempotencyKey) options.idempotencyKey = input.idempotencyKey;
564
+ const { data, error } = await resend.emails.send(payload, options);
565
+ if (error) throw new Error(`Resend error: ${error.message}`);
566
+ if (!data?.id) throw new Error("Resend returned no message id");
567
+ return { messageId: data.id };
568
+ }
569
+ };
570
+ }
571
+ var init_resend = __esm({
572
+ "src/reports/send/resend.ts"() {
573
+ "use strict";
574
+ }
575
+ });
576
+
577
+ // src/reports/send/orchestrate.ts
578
+ var orchestrate_exports = {};
579
+ __export(orchestrate_exports, {
580
+ isProbablyEmail: () => isProbablyEmail,
581
+ parseAddresses: () => parseAddresses,
582
+ sendApprovedReports: () => sendApprovedReports
583
+ });
584
+ async function sendApprovedReports(options = {}) {
585
+ const base = openBase(readAirtableConfig());
586
+ const client = options.resend ?? defaultResendClient();
587
+ const sendable = await listSendableReports(base);
588
+ if (sendable.length === 0) return { output: "No reports ready to send.", code: 0 };
589
+ const websites = await listWebsites(base);
590
+ const sites = new Map(websites.map((w) => [w.id, w]));
591
+ const lines = [];
592
+ let anyFailed = false;
593
+ for (const report of sendable) {
594
+ const site = sites.get(report.siteId);
595
+ if (!site) {
596
+ lines.push(`\u2717 ${report.reportId} \u2014 Site row not found for id=${report.siteId}`);
597
+ anyFailed = true;
598
+ continue;
599
+ }
600
+ try {
601
+ const messageId = await sendOne(client, base, site, report);
602
+ lines.push(`\u2713 sent: ${report.reportId} (${messageId})`);
603
+ } catch (e) {
604
+ lines.push(`\u2717 ${report.reportId} \u2014 ${e.message}`);
605
+ anyFailed = true;
606
+ }
607
+ }
608
+ return { output: lines.join("\n"), code: anyFailed ? 1 : 0 };
609
+ }
610
+ async function sendOne(client, base, site, report) {
611
+ if (!site.headerImage) {
612
+ throw new Error(`Site '${site.name}' has no Header image set on the Websites row`);
613
+ }
614
+ if (!report.lighthouse) {
615
+ throw new Error(`Report ${report.reportId} has no Lighthouse scores`);
616
+ }
617
+ const { bytes, contentType } = await fetchAttachmentBytes(site.headerImage.url);
618
+ const bundled = await loadBundledImages();
619
+ const slug = siteSlug(site.name);
620
+ const cidName = `${slug}-header`;
621
+ const { html } = await renderReportHtml({
622
+ siteName: site.name,
623
+ siteUrl: site.url,
624
+ reportType: report.reportType,
625
+ completedOn: report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date(),
626
+ lighthouse: report.lighthouse,
627
+ gaUsersCurrent: report.gaUsersCurrent ?? 0,
628
+ gaUsersPrevious: report.gaUsersPrevious ?? 0,
629
+ lastTestedDate: report.lastTestedDate ? new Date(report.lastTestedDate) : null,
630
+ commentary: report.commentary,
631
+ headerImageCid: cidName
632
+ });
633
+ const subject = report.subjectOverride ?? `${site.name} ${report.reportType} Report`;
634
+ const explicitTo = parseAddresses(site.reportRecipientsTo);
635
+ const fallbackTo = parseAddresses(site.pointOfContact);
636
+ const to = explicitTo ?? fallbackTo ?? [];
637
+ if (to.length === 0) {
638
+ throw new Error(
639
+ `Site '${site.name}' has no recipients (Report recipients (To) AND point of contact are both empty)`
640
+ );
641
+ }
642
+ for (const addr of to) {
643
+ if (!isProbablyEmail(addr)) {
644
+ throw new Error(
645
+ `Site '${site.name}' recipient is malformed: ${addr} \u2014 fix Report recipients (To) or point of contact in Airtable`
646
+ );
647
+ }
648
+ }
649
+ const cc = parseAddresses(site.reportRecipientsCc);
650
+ if (cc) {
651
+ for (const addr of cc) {
652
+ if (!isProbablyEmail(addr)) {
653
+ throw new Error(
654
+ `Site '${site.name}' CC is malformed: ${addr} \u2014 fix Report recipients (CC) in Airtable`
655
+ );
656
+ }
657
+ }
658
+ }
659
+ const payload = {
660
+ from: FROM_ADDRESS,
661
+ to,
662
+ replyTo: REPLY_TO,
663
+ subject,
664
+ html,
665
+ attachments: [
666
+ {
667
+ filename: site.headerImage.filename,
668
+ content: Buffer.from(bytes).toString("base64"),
669
+ contentType,
670
+ inlineContentId: cidName
671
+ },
672
+ // Bundled images referenced via cid:rd-check-png / cid:rd-blurred-tests-jpg
673
+ // in the template. Attached inline so the email is self-contained — no
674
+ // external CDN dependency, no image-blocked broken icons in webmail.
675
+ {
676
+ filename: bundled.check.filename,
677
+ content: Buffer.from(bundled.check.bytes).toString("base64"),
678
+ contentType: bundled.check.contentType,
679
+ inlineContentId: bundled.check.cid
680
+ },
681
+ {
682
+ filename: bundled.blurred.filename,
683
+ content: Buffer.from(bundled.blurred.bytes).toString("base64"),
684
+ contentType: bundled.blurred.contentType,
685
+ inlineContentId: bundled.blurred.cid
686
+ }
687
+ ],
688
+ // Stable across retries of the same row — if Airtable stamping fails after a
689
+ // successful Resend, the next --send-ready replays with the same key and
690
+ // Resend returns the original message id rather than sending a duplicate.
691
+ idempotencyKey: `report:${report.id}`
692
+ };
693
+ if (cc) payload.cc = cc;
694
+ const result = await client.send(payload);
695
+ await stampSent(base, report.id, /* @__PURE__ */ new Date(), result.messageId);
696
+ return result.messageId;
697
+ }
698
+ function parseAddresses(field) {
699
+ if (!field) return null;
700
+ const seen = /* @__PURE__ */ new Set();
701
+ const list = [];
702
+ for (const raw of field.split(/[,\n]/)) {
703
+ const trimmed = raw.trim().toLowerCase();
704
+ if (!trimmed) continue;
705
+ if (seen.has(trimmed)) continue;
706
+ seen.add(trimmed);
707
+ list.push(trimmed);
708
+ }
709
+ return list.length > 0 ? list : null;
710
+ }
711
+ function isProbablyEmail(s) {
712
+ const at = s.indexOf("@");
713
+ if (at < 1 || at !== s.lastIndexOf("@")) return false;
714
+ const local = s.slice(0, at);
715
+ const domain = s.slice(at + 1);
716
+ if (!local || !domain) return false;
717
+ if (!domain.includes(".")) return false;
718
+ if (/\s/.test(s)) return false;
719
+ return true;
720
+ }
721
+ var FROM_ADDRESS, REPLY_TO;
722
+ var init_orchestrate = __esm({
723
+ "src/reports/send/orchestrate.ts"() {
724
+ "use strict";
725
+ init_client();
726
+ init_reports();
727
+ init_websites();
728
+ init_attachments();
729
+ init_render();
730
+ init_assets();
731
+ init_resend();
732
+ FROM_ADDRESS = "Reddoor Reports <reports@reddoorla.com>";
733
+ REPLY_TO = "info@reddoorla.com";
734
+ }
735
+ });
736
+
737
+ // src/cli/bin.ts
738
+ import { dirname as dirname4 } from "path";
739
+ import { fileURLToPath as fileURLToPath3 } from "url";
6
740
  import { cac } from "cac";
7
741
 
8
742
  // src/cli/commands/audit.ts
@@ -654,6 +1388,10 @@ async function runAudits(site, which) {
654
1388
  )
655
1389
  );
656
1390
  }
1391
+ async function runAuditsAcross(sites, which) {
1392
+ const all = await Promise.all(sites.map((s) => runAudits(s, which)));
1393
+ return all.flat();
1394
+ }
657
1395
 
658
1396
  // src/cli/fleet/resolve-sites.ts
659
1397
  import { pathToFileURL } from "url";
@@ -709,6 +1447,13 @@ async function resolveSites(input) {
709
1447
  exitCode: 2
710
1448
  });
711
1449
  }
1450
+ if (input.fleet === "airtable") {
1451
+ const { openBase: openBase2, readAirtableConfig: readAirtableConfig2 } = await Promise.resolve().then(() => (init_client(), client_exports));
1452
+ const { fromAirtableBase: fromAirtableBase2 } = await Promise.resolve().then(() => (init_airtable(), airtable_exports));
1453
+ const base = openBase2(readAirtableConfig2());
1454
+ const provider = fromAirtableBase2(base, input.workdir ? { workdir: input.workdir } : {});
1455
+ return provider();
1456
+ }
712
1457
  if (input.fleet) {
713
1458
  const fleetPath = resolve(input.cwd, input.fleet);
714
1459
  const ext = extname(fleetPath).toLowerCase();
@@ -817,28 +1562,59 @@ async function runAuditCommand(site, opts) {
817
1562
  let sites = await resolveSites({
818
1563
  ...site !== void 0 ? { site } : {},
819
1564
  ...opts.fleet !== void 0 ? { fleet: opts.fleet } : {},
1565
+ ...opts.workdir !== void 0 ? { workdir: opts.workdir } : {},
820
1566
  cwd
821
1567
  });
822
1568
  if (opts.fleet) {
823
1569
  const workdir = opts.workdir ?? `${process.env.HOME ?? ""}/.reddoor-maint/sites`;
824
1570
  sites = await Promise.all(sites.map((s) => cloneIfNeeded(s, { workdir })));
825
1571
  }
826
- const results = [];
827
- for (const s of sites) {
828
- const r = await runAudits(s, which);
829
- results.push(...r);
1572
+ const results = await runAuditsAcross(sites, which);
1573
+ let output = opts.json ? JSON.stringify(results, null, 2) : formatTable(results);
1574
+ if (opts.writeAirtable !== void 0) {
1575
+ const { openBase: openBase2, readAirtableConfig: readAirtableConfig2 } = await Promise.resolve().then(() => (init_client(), client_exports));
1576
+ const { listWebsites: listWebsites2, updateScores: updateScores2, siteSlug: siteSlug2 } = await Promise.resolve().then(() => (init_websites(), websites_exports));
1577
+ const { lighthouseScoresFromResult: lighthouseScoresFromResult2, resolveSlugFromCwd: resolveSlugFromCwd2 } = await Promise.resolve().then(() => (init_lighthouse_airtable(), lighthouse_airtable_exports));
1578
+ const slug = typeof opts.writeAirtable === "string" && opts.writeAirtable.length > 0 ? opts.writeAirtable : await resolveSlugFromCwd2(cwd);
1579
+ const lhResult = results.find((r) => r.audit === "lighthouse");
1580
+ if (!lhResult) {
1581
+ throw Object.assign(
1582
+ new Error(
1583
+ "--write-airtable requires a lighthouse result; did you pass --only without lighthouse?"
1584
+ ),
1585
+ { exitCode: 2 }
1586
+ );
1587
+ }
1588
+ if (lhResult.status === "fail") {
1589
+ throw Object.assign(
1590
+ new Error(
1591
+ `Lighthouse audit failed; refusing to write scores to Airtable. Summary: ${lhResult.summary}`
1592
+ ),
1593
+ { exitCode: 1 }
1594
+ );
1595
+ }
1596
+ const base = openBase2(readAirtableConfig2());
1597
+ const websites = await listWebsites2(base);
1598
+ const target = websites.find((w) => siteSlug2(w.name) === slug);
1599
+ if (!target) {
1600
+ throw Object.assign(new Error(`No Websites row matched slug "${slug}"`), { exitCode: 2 });
1601
+ }
1602
+ const scores = lighthouseScoresFromResult2(lhResult);
1603
+ await updateScores2(base, target.id, scores);
1604
+ output += `
1605
+
1606
+ \u2192 wrote scores to Websites[${target.name}]: P=${scores.performance} A=${scores.accessibility} BP=${scores.bestPractices} SEO=${scores.seo}`;
830
1607
  }
831
- const output = opts.json ? JSON.stringify(results, null, 2) : formatTable(results);
832
1608
  return { output, code: exitCode(results) };
833
1609
  }
834
1610
 
835
1611
  // src/cli/commands/sync-configs.ts
836
- import { readFile as readFile7 } from "fs/promises";
837
- import { join as join7, resolve as resolve3 } from "path";
1612
+ import { readFile as readFile8 } from "fs/promises";
1613
+ import { join as join8, resolve as resolve3 } from "path";
838
1614
 
839
1615
  // src/recipes/sync-configs.ts
840
- import { readFile as readFile6, writeFile as writeFile3 } from "fs/promises";
841
- import { join as join6 } from "path";
1616
+ import { readFile as readFile7, writeFile as writeFile3 } from "fs/promises";
1617
+ import { join as join7 } from "path";
842
1618
 
843
1619
  // src/recipes/sync-configs/templates.ts
844
1620
  var eslint = {
@@ -1102,7 +1878,7 @@ function isConfigName(value) {
1102
1878
  }
1103
1879
  async function readMaybe(path) {
1104
1880
  try {
1105
- return await readFile6(path, "utf-8");
1881
+ return await readFile7(path, "utf-8");
1106
1882
  } catch {
1107
1883
  return null;
1108
1884
  }
@@ -1110,13 +1886,13 @@ async function readMaybe(path) {
1110
1886
  async function planTemplateDiffs(cwd, templates) {
1111
1887
  const diffs = [];
1112
1888
  for (const t of templates) {
1113
- const existing = await readMaybe(join6(cwd, t.path));
1889
+ const existing = await readMaybe(join7(cwd, t.path));
1114
1890
  if (existing !== t.contents) diffs.push(t);
1115
1891
  }
1116
1892
  return diffs;
1117
1893
  }
1118
1894
  async function planGitignore(cwd) {
1119
- const existing = await readMaybe(join6(cwd, ".gitignore"));
1895
+ const existing = await readMaybe(join7(cwd, ".gitignore"));
1120
1896
  const merge = mergeGitignore(existing, CANONICAL_GITIGNORE_ENTRIES);
1121
1897
  const tracked = await listTrackedFiles(cwd);
1122
1898
  const toUntrack = findTrackedArtifacts(tracked, CANONICAL_GITIGNORE_ENTRIES);
@@ -1124,7 +1900,7 @@ async function planGitignore(cwd) {
1124
1900
  return { kind: "apply", content: merge.content, toUntrack, added: merge.added };
1125
1901
  }
1126
1902
  async function applyGitignore(cwd, plan) {
1127
- await writeFile3(join6(cwd, ".gitignore"), plan.content, "utf-8");
1903
+ await writeFile3(join7(cwd, ".gitignore"), plan.content, "utf-8");
1128
1904
  if (plan.toUntrack.length > 0) {
1129
1905
  await removeFromIndex(cwd, plan.toUntrack);
1130
1906
  }
@@ -1147,7 +1923,7 @@ async function syncConfigs(site, opts = {}) {
1147
1923
  },
1148
1924
  apply: async ({ templateDiffs, gitignorePlan }, { commit: commit2 }) => {
1149
1925
  for (const t of templateDiffs) {
1150
- await writeFile3(join6(site.path, t.path), t.contents, "utf-8");
1926
+ await writeFile3(join7(site.path, t.path), t.contents, "utf-8");
1151
1927
  await commit2(`chore: sync ${t.config} config from @reddoorla/maintenance`);
1152
1928
  }
1153
1929
  if (gitignorePlan.kind === "apply") {
@@ -1176,7 +1952,7 @@ function parseOnly2(value) {
1176
1952
  async function dryPlanGitignore(cwd) {
1177
1953
  let existing;
1178
1954
  try {
1179
- existing = await readFile7(join7(cwd, ".gitignore"), "utf-8");
1955
+ existing = await readFile8(join8(cwd, ".gitignore"), "utf-8");
1180
1956
  } catch {
1181
1957
  return "would create .gitignore";
1182
1958
  }
@@ -1191,7 +1967,7 @@ async function dryPlan(cwd, which) {
1191
1967
  for (const t of templateTargets) {
1192
1968
  let existing = "";
1193
1969
  try {
1194
- existing = await readFile7(join7(cwd, t.path), "utf-8");
1970
+ existing = await readFile8(join8(cwd, t.path), "utf-8");
1195
1971
  } catch {
1196
1972
  }
1197
1973
  if (existing !== t.contents) lines.push(`would update ${t.path} (config: ${t.config})`);
@@ -1239,7 +2015,7 @@ import { resolve as resolve4 } from "path";
1239
2015
 
1240
2016
  // src/recipes/bump-deps.ts
1241
2017
  import { stat as stat2 } from "fs/promises";
1242
- import { join as join8 } from "path";
2018
+ import { join as join9 } from "path";
1243
2019
  async function exists(path) {
1244
2020
  try {
1245
2021
  await stat2(path);
@@ -1268,10 +2044,10 @@ async function bumpDeps(site, opts = {}) {
1268
2044
  // land on top of whatever else was in the tree.
1269
2045
  checkTreeFirst: true,
1270
2046
  plan: async () => {
1271
- const hasPnpmLock = await exists(join8(site.path, "pnpm-lock.yaml"));
2047
+ const hasPnpmLock = await exists(join9(site.path, "pnpm-lock.yaml"));
1272
2048
  if (!hasPnpmLock) {
1273
- const hasNpmLock = await exists(join8(site.path, "package-lock.json"));
1274
- const hasYarnLock = await exists(join8(site.path, "yarn.lock"));
2049
+ const hasNpmLock = await exists(join9(site.path, "package-lock.json"));
2050
+ const hasYarnLock = await exists(join9(site.path, "yarn.lock"));
1275
2051
  if (hasNpmLock || hasYarnLock) {
1276
2052
  const competing = hasNpmLock ? "package-lock.json" : "yarn.lock";
1277
2053
  return {
@@ -1341,12 +2117,12 @@ async function runBumpDepsCommand(site, opts) {
1341
2117
  import { resolve as resolve5 } from "path";
1342
2118
 
1343
2119
  // src/recipes/svelte-5/index.ts
1344
- import { join as join14 } from "path";
2120
+ import { join as join15 } from "path";
1345
2121
 
1346
2122
  // src/util/pkg.ts
1347
- import { readFile as readFile8, writeFile as writeFile4 } from "fs/promises";
2123
+ import { readFile as readFile9, writeFile as writeFile4 } from "fs/promises";
1348
2124
  async function readPackageJson(path) {
1349
- const raw = await readFile8(path, "utf-8");
2125
+ const raw = await readFile9(path, "utf-8");
1350
2126
  return JSON.parse(raw);
1351
2127
  }
1352
2128
  function detectIndentFromContent(raw) {
@@ -1356,7 +2132,7 @@ function detectIndentFromContent(raw) {
1356
2132
  async function writePackageJson(path, pkg) {
1357
2133
  let indent = " ";
1358
2134
  try {
1359
- const existing = await readFile8(path, "utf-8");
2135
+ const existing = await readFile9(path, "utf-8");
1360
2136
  indent = detectIndentFromContent(existing);
1361
2137
  } catch {
1362
2138
  }
@@ -1390,7 +2166,7 @@ function bumpDep(pkg, name, version2, opts = {}) {
1390
2166
  }
1391
2167
 
1392
2168
  // src/recipes/svelte-5/step-bump-versions.ts
1393
- import { join as join9 } from "path";
2169
+ import { join as join10 } from "path";
1394
2170
  var SVELTE_5_VERSIONS = {
1395
2171
  svelte: "^5.55.5",
1396
2172
  "@sveltejs/kit": "^2.59.0",
@@ -1403,7 +2179,7 @@ var SVELTE_5_VERSIONS = {
1403
2179
  "typescript-svelte-plugin": "^0.3.52"
1404
2180
  };
1405
2181
  async function bumpToSvelte5Versions(cwd) {
1406
- const pkgPath = join9(cwd, "package.json");
2182
+ const pkgPath = join10(cwd, "package.json");
1407
2183
  const pkg = await readPackageJson(pkgPath);
1408
2184
  let next = pkg;
1409
2185
  for (const [name, version2] of Object.entries(SVELTE_5_VERSIONS)) {
@@ -1415,8 +2191,8 @@ async function bumpToSvelte5Versions(cwd) {
1415
2191
  }
1416
2192
 
1417
2193
  // src/recipes/svelte-5/step-svelte-config.ts
1418
- import { readFile as readFile9, writeFile as writeFile5 } from "fs/promises";
1419
- import { join as join10 } from "path";
2194
+ import { readFile as readFile10, writeFile as writeFile5 } from "fs/promises";
2195
+ import { join as join11 } from "path";
1420
2196
  var VITE_PLUGIN_PKG = "@sveltejs/vite-plugin-svelte";
1421
2197
  var IMPORT_FROM_VITE_PLUGIN = new RegExp(
1422
2198
  String.raw`^import\s+\{\s*([^}]+?)\s*\}\s+from\s+["']` + VITE_PLUGIN_PKG.replace(/[/]/g, "\\/") + String.raw`["'];?[ \t]*\n`,
@@ -1457,10 +2233,10 @@ function dropPreprocessKey(source) {
1457
2233
  return source.slice(0, m.index) + source.slice(tailIdx).replace(new RegExp(`^${indent}\\n`), "");
1458
2234
  }
1459
2235
  async function migrateSvelteConfig(cwd) {
1460
- const path = join10(cwd, "svelte.config.js");
2236
+ const path = join11(cwd, "svelte.config.js");
1461
2237
  let src;
1462
2238
  try {
1463
- src = await readFile9(path, "utf-8");
2239
+ src = await readFile10(path, "utf-8");
1464
2240
  } catch {
1465
2241
  return false;
1466
2242
  }
@@ -1494,9 +2270,9 @@ async function runSvelteMigrate(cwd, spawn2 = defaultSpawn) {
1494
2270
  }
1495
2271
 
1496
2272
  // src/recipes/svelte-5/step-tailwind-upgrade.ts
1497
- import { join as join11 } from "path";
2273
+ import { join as join12 } from "path";
1498
2274
  async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
1499
- const pkg = await readPackageJson(join11(cwd, "package.json"));
2275
+ const pkg = await readPackageJson(join12(cwd, "package.json"));
1500
2276
  const tailwindVersion = pkg.devDependencies?.tailwindcss ?? pkg.dependencies?.tailwindcss;
1501
2277
  if (!tailwindVersion) return { ran: false, reason: "tailwindcss not installed" };
1502
2278
  if (/^\^?4\./.test(tailwindVersion)) return { ran: false, reason: "already on tailwind 4.x" };
@@ -1517,8 +2293,8 @@ async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
1517
2293
  }
1518
2294
 
1519
2295
  // src/recipes/svelte-5/step-gotchas.ts
1520
- import { readFile as readFile10, writeFile as writeFile6 } from "fs/promises";
1521
- import { join as join12 } from "path";
2296
+ import { readFile as readFile11, writeFile as writeFile6 } from "fs/promises";
2297
+ import { join as join13 } from "path";
1522
2298
  import { glob as glob2 } from "tinyglobby";
1523
2299
 
1524
2300
  // src/recipes/svelte-5/codemods/on-event-to-handler.ts
@@ -1850,8 +2626,8 @@ async function planGotchaCodemods(cwd) {
1850
2626
  const changes = [];
1851
2627
  const relPaths = await glob2(SVELTE_GLOBS, { cwd, ignore: IGNORE2, absolute: false });
1852
2628
  for (const rel of relPaths) {
1853
- const path = join12(cwd, rel);
1854
- const before = await readFile10(path, "utf-8");
2629
+ const path = join13(cwd, rel);
2630
+ const before = await readFile11(path, "utf-8");
1855
2631
  const after = CODEMODS.reduce((s, fn) => fn(s), before);
1856
2632
  if (after !== before) changes.push({ rel, after });
1857
2633
  }
@@ -1860,7 +2636,7 @@ async function planGotchaCodemods(cwd) {
1860
2636
  async function applyGotchaCodemods(cwd) {
1861
2637
  const changes = await planGotchaCodemods(cwd);
1862
2638
  for (const c of changes) {
1863
- await writeFile6(join12(cwd, c.rel), c.after, "utf-8");
2639
+ await writeFile6(join13(cwd, c.rel), c.after, "utf-8");
1864
2640
  }
1865
2641
  return { filesChanged: changes.length };
1866
2642
  }
@@ -1884,7 +2660,7 @@ async function verifyMigration(cwd, spawn2 = defaultSpawn) {
1884
2660
 
1885
2661
  // src/recipes/svelte-5/step-summary.ts
1886
2662
  import { writeFile as writeFile7 } from "fs/promises";
1887
- import { join as join13 } from "path";
2663
+ import { join as join14 } from "path";
1888
2664
  async function writeMigrationSummary(input) {
1889
2665
  const lines = [
1890
2666
  `# Svelte 4 \u2192 5 migration summary`,
@@ -1901,7 +2677,7 @@ async function writeMigrationSummary(input) {
1901
2677
  `- Verify Playwright a11y tests still pass.`
1902
2678
  ];
1903
2679
  const content = lines.join("\n") + "\n";
1904
- const path = join13(input.cwd, "MIGRATION_SVELTE_5.md");
2680
+ const path = join14(input.cwd, "MIGRATION_SVELTE_5.md");
1905
2681
  await writeFile7(path, content, "utf-8");
1906
2682
  return path;
1907
2683
  }
@@ -1909,7 +2685,7 @@ async function writeMigrationSummary(input) {
1909
2685
  // src/recipes/svelte-5/index.ts
1910
2686
  async function alreadyOnSvelte5(cwd) {
1911
2687
  try {
1912
- const pkg = await readPackageJson(join14(cwd, "package.json"));
2688
+ const pkg = await readPackageJson(join15(cwd, "package.json"));
1913
2689
  const v = pkg.devDependencies?.svelte ?? pkg.dependencies?.svelte;
1914
2690
  return !!v && /^\^?5\./.test(v);
1915
2691
  } catch {
@@ -2004,7 +2780,7 @@ import { resolve as resolve6 } from "path";
2004
2780
 
2005
2781
  // src/recipes/convert-to-pnpm.ts
2006
2782
  import { rm as rm3, stat as stat3 } from "fs/promises";
2007
- import { join as join15 } from "path";
2783
+ import { join as join16 } from "path";
2008
2784
 
2009
2785
  // src/recipes/convert-to-pnpm/script-rewrites.ts
2010
2786
  function rewriteScriptForPnpm(script) {
@@ -2037,9 +2813,9 @@ async function exists2(path) {
2037
2813
  async function convertToPnpm(site, opts = {}) {
2038
2814
  const spawn2 = opts.spawn ?? defaultSpawn;
2039
2815
  const pnpmVersion = opts.pnpmVersion ?? DEFAULT_PNPM_VERSION;
2040
- const pnpmLockPath = join15(site.path, "pnpm-lock.yaml");
2041
- const npmLockPath = join15(site.path, "package-lock.json");
2042
- const yarnLockPath = join15(site.path, "yarn.lock");
2816
+ const pnpmLockPath = join16(site.path, "pnpm-lock.yaml");
2817
+ const npmLockPath = join16(site.path, "package-lock.json");
2818
+ const yarnLockPath = join16(site.path, "yarn.lock");
2043
2819
  return withRecipe({
2044
2820
  name: "convert-to-pnpm",
2045
2821
  site,
@@ -2062,7 +2838,7 @@ async function convertToPnpm(site, opts = {}) {
2062
2838
  if (hasYarnLock) await rm3(yarnLockPath, { force: true });
2063
2839
  const sourceLock = hasNpmLock ? "package-lock.json" : "yarn.lock";
2064
2840
  await commit2(`chore(pnpm): remove ${sourceLock}`);
2065
- const pkgPath = join15(cwd, "package.json");
2841
+ const pkgPath = join16(cwd, "package.json");
2066
2842
  const pkg = await readPackageJson(pkgPath);
2067
2843
  const next = { ...pkg, packageManager: `pnpm@${pnpmVersion}` };
2068
2844
  if (pkg.scripts && typeof pkg.scripts === "object") {
@@ -2075,7 +2851,7 @@ async function convertToPnpm(site, opts = {}) {
2075
2851
  }
2076
2852
  await writePackageJson(pkgPath, next);
2077
2853
  await commit2("chore(pnpm): pin packageManager + rewrite npm scripts");
2078
- await rm3(join15(cwd, "node_modules"), { recursive: true, force: true });
2854
+ await rm3(join16(cwd, "node_modules"), { recursive: true, force: true });
2079
2855
  const installResult = await spawn2("pnpm", ["install"], { cwd, streaming: true });
2080
2856
  if (installResult.code !== 0) {
2081
2857
  return { kind: "failed", notes: `pnpm install failed (exit ${installResult.code})` };
@@ -2116,16 +2892,16 @@ import { resolve as resolve7 } from "path";
2116
2892
 
2117
2893
  // src/recipes/onboard.ts
2118
2894
  import { stat as stat4 } from "fs/promises";
2119
- import { join as join17 } from "path";
2895
+ import { join as join18 } from "path";
2120
2896
 
2121
2897
  // src/util/self-version.ts
2122
2898
  import { readFileSync } from "fs";
2123
2899
  import { fileURLToPath } from "url";
2124
- import { dirname, join as join16 } from "path";
2900
+ import { dirname, join as join17 } from "path";
2125
2901
  function selfPackageVersion(callerImportMetaUrl) {
2126
2902
  try {
2127
- const here2 = dirname(fileURLToPath(callerImportMetaUrl));
2128
- const raw = readFileSync(join16(here2, "..", "..", "package.json"), "utf-8");
2903
+ const here3 = dirname(fileURLToPath(callerImportMetaUrl));
2904
+ const raw = readFileSync(join17(here3, "..", "..", "package.json"), "utf-8");
2129
2905
  const pkg = JSON.parse(raw);
2130
2906
  return pkg.version ?? "0.0.0";
2131
2907
  } catch {
@@ -2175,13 +2951,13 @@ async function onboard(site, opts = {}) {
2175
2951
  name: "onboard",
2176
2952
  site,
2177
2953
  plan: async () => {
2178
- if (!await exists3(join17(site.path, "pnpm-lock.yaml"))) {
2954
+ if (!await exists3(join18(site.path, "pnpm-lock.yaml"))) {
2179
2955
  return {
2180
2956
  kind: "failed",
2181
2957
  notes: "no pnpm-lock.yaml at site root \u2014 run convert-to-pnpm first"
2182
2958
  };
2183
2959
  }
2184
- const pkgPath = join17(site.path, "package.json");
2960
+ const pkgPath = join18(site.path, "package.json");
2185
2961
  const pkg = await readPackageJson(pkgPath);
2186
2962
  const toAdd = [];
2187
2963
  if (!isDeclared(pkg, PACKAGE_NAME)) {
@@ -2201,7 +2977,7 @@ async function onboard(site, opts = {}) {
2201
2977
  return { kind: "apply", plan: { pkg, toAdd } };
2202
2978
  },
2203
2979
  apply: async ({ pkg, toAdd }, { commit: commit2, cwd }) => {
2204
- const pkgPath = join17(cwd, "package.json");
2980
+ const pkgPath = join18(cwd, "package.json");
2205
2981
  let next = pkg;
2206
2982
  for (const dep of toAdd) {
2207
2983
  next = bumpDep(next, dep.name, dep.version);
@@ -2270,7 +3046,7 @@ import { resolve as resolve8 } from "path";
2270
3046
 
2271
3047
  // src/recipes/svelte-codemods.ts
2272
3048
  import { writeFile as writeFile8 } from "fs/promises";
2273
- import { join as join18 } from "path";
3049
+ import { join as join19 } from "path";
2274
3050
  async function svelteCodemods(site) {
2275
3051
  return withRecipe({
2276
3052
  name: "svelte-codemods",
@@ -2284,7 +3060,7 @@ async function svelteCodemods(site) {
2284
3060
  },
2285
3061
  apply: async (changes, { commit: commit2, cwd }) => {
2286
3062
  for (const c of changes) {
2287
- await writeFile8(join18(cwd, c.rel), c.after, "utf-8");
3063
+ await writeFile8(join19(cwd, c.rel), c.after, "utf-8");
2288
3064
  }
2289
3065
  await commit2(`refactor(svelte5): apply codemods (${changes.length} files)`);
2290
3066
  return { kind: "ok" };
@@ -2317,12 +3093,201 @@ async function runSvelteCodemodsCommand(site, opts) {
2317
3093
  return { output, code };
2318
3094
  }
2319
3095
 
3096
+ // src/cli/commands/report.ts
3097
+ init_client();
3098
+ init_websites();
3099
+ init_reports();
3100
+
3101
+ // src/reports/due.ts
3102
+ var ELIGIBLE_STATUSES = /* @__PURE__ */ new Set([
3103
+ "in development",
3104
+ "launch period",
3105
+ "maintenance",
3106
+ "hosting"
3107
+ ]);
3108
+ var MONTHS = {
3109
+ Monthly: 1,
3110
+ Quarterly: 3,
3111
+ Yearly: 12
3112
+ };
3113
+ function addMonths(d, n) {
3114
+ const out = new Date(d);
3115
+ const day = out.getUTCDate();
3116
+ out.setUTCDate(1);
3117
+ out.setUTCMonth(out.getUTCMonth() + n);
3118
+ const lastDayOfTargetMonth = new Date(
3119
+ Date.UTC(out.getUTCFullYear(), out.getUTCMonth() + 1, 0)
3120
+ ).getUTCDate();
3121
+ out.setUTCDate(Math.min(day, lastDayOfTargetMonth));
3122
+ return out;
3123
+ }
3124
+ function startOfDay(d) {
3125
+ const out = new Date(d);
3126
+ out.setUTCHours(0, 0, 0, 0);
3127
+ return out;
3128
+ }
3129
+ function lastSentForType(reports, siteId, type) {
3130
+ const candidates = reports.filter((r) => r.siteId === siteId && r.reportType === type && r.sentAt !== null).map((r) => r.sentAt).sort();
3131
+ return candidates[candidates.length - 1] ?? null;
3132
+ }
3133
+ function findDueReports(websites, reports, today) {
3134
+ const out = [];
3135
+ const todayStart = startOfDay(today);
3136
+ for (const site of websites) {
3137
+ if (site.status !== null && !ELIGIBLE_STATUSES.has(site.status)) continue;
3138
+ for (const type of ["Maintenance", "Testing"]) {
3139
+ const freq = type === "Maintenance" ? site.maintenanceFreq : site.testingFreq;
3140
+ if (freq === "None") continue;
3141
+ const lastSent = lastSentForType(reports, site.id, type);
3142
+ const fallback = type === "Maintenance" ? site.maintenanceDay : site.testingDay;
3143
+ const baseIso = lastSent ?? fallback;
3144
+ if (!baseIso) {
3145
+ out.push({ site, reportType: type, dueDate: todayStart, lastSent });
3146
+ continue;
3147
+ }
3148
+ const dueDate = addMonths(new Date(baseIso), MONTHS[freq]);
3149
+ if (todayStart.getTime() >= startOfDay(dueDate).getTime()) {
3150
+ out.push({ site, reportType: type, dueDate, lastSent });
3151
+ }
3152
+ }
3153
+ }
3154
+ return out;
3155
+ }
3156
+
3157
+ // src/reports/draft.ts
3158
+ init_render();
3159
+ init_websites();
3160
+ init_reports();
3161
+ init_attachments();
3162
+ import { mkdir as mkdir2, writeFile as writeFile9 } from "fs/promises";
3163
+ import { dirname as dirname3 } from "path";
3164
+ function scoresFromWebsite(siteRow) {
3165
+ const { pScore, rScore, bpScore, seoScore } = siteRow;
3166
+ if (pScore === null || rScore === null || bpScore === null || seoScore === null) {
3167
+ throw new Error(
3168
+ `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.`
3169
+ );
3170
+ }
3171
+ return { performance: pScore, accessibility: rScore, bestPractices: bpScore, seo: seoScore };
3172
+ }
3173
+ function daysAgo(today, n) {
3174
+ const out = new Date(today);
3175
+ out.setDate(out.getDate() - n);
3176
+ return out;
3177
+ }
3178
+ async function draftReportForSite(base, siteRow, reportType, options = {}) {
3179
+ const scores = scoresFromWebsite(siteRow);
3180
+ const today = /* @__PURE__ */ new Date();
3181
+ const slug = siteSlug(siteRow.name);
3182
+ const periodStart = base !== null ? await derivePeriodStart(base, siteRow, reportType, today) : daysAgo(today, 30);
3183
+ const periodEnd = today;
3184
+ const completedOn = today;
3185
+ const lastTestedDate = reportType === "Maintenance" && siteRow.testingDay ? new Date(siteRow.testingDay) : null;
3186
+ const cidName = `${slug}-header`;
3187
+ const { html } = await renderReportHtml({
3188
+ siteName: siteRow.name,
3189
+ siteUrl: siteRow.url,
3190
+ reportType,
3191
+ completedOn,
3192
+ lighthouse: scores,
3193
+ gaUsersCurrent: 0,
3194
+ gaUsersPrevious: 0,
3195
+ lastTestedDate,
3196
+ commentary: null,
3197
+ headerImageCid: cidName
3198
+ });
3199
+ if (options.previewOnly) {
3200
+ const path = options.previewPath ?? `reports/${slug}/draft.html`;
3201
+ await mkdir2(dirname3(path), { recursive: true });
3202
+ await writeFile9(path, html, "utf-8");
3203
+ return { reportRow: null, htmlPath: path, html };
3204
+ }
3205
+ if (base === null) throw new Error("base required when previewOnly=false");
3206
+ const reportId = `${siteRow.name} \u2014 ${reportType} \u2014 ${periodEnd.toISOString().slice(0, 10)}`;
3207
+ const created = await createDraft(base, {
3208
+ reportId,
3209
+ siteId: siteRow.id,
3210
+ reportType,
3211
+ periodStart,
3212
+ periodEnd,
3213
+ completedOn,
3214
+ lighthouse: scores,
3215
+ lastTestedDate
3216
+ });
3217
+ const htmlFilename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;
3218
+ await uploadAttachment(created.id, "Rendered HTML", html, htmlFilename, "text/html");
3219
+ await setDraftReady(base, created.id, true);
3220
+ return { reportRow: created, htmlPath: null, html };
3221
+ }
3222
+ async function derivePeriodStart(base, siteRow, reportType, today) {
3223
+ const prior = await listReportsForSite(base, siteRow.id);
3224
+ const sameType = prior.filter((r) => r.reportType === reportType && r.periodEnd).map((r) => r.periodEnd).sort();
3225
+ const latest = sameType[sameType.length - 1];
3226
+ return latest ? new Date(latest) : daysAgo(today, 30);
3227
+ }
3228
+
3229
+ // src/cli/commands/report.ts
3230
+ async function runReportCommand(slug, opts) {
3231
+ if (opts.sendReady) {
3232
+ const { sendApprovedReports: sendApprovedReports2 } = await Promise.resolve().then(() => (init_orchestrate(), orchestrate_exports));
3233
+ return sendApprovedReports2();
3234
+ }
3235
+ if (opts.due) {
3236
+ return runDueDraft();
3237
+ }
3238
+ if (slug) {
3239
+ return runSingleSiteDraft(slug, { previewOnly: Boolean(opts.preview) });
3240
+ }
3241
+ throw Object.assign(
3242
+ new Error("Usage: reddoor-maint report [<slug>] [--due] [--preview] [--send-ready]"),
3243
+ {
3244
+ exitCode: 2
3245
+ }
3246
+ );
3247
+ }
3248
+ async function runDueDraft() {
3249
+ const base = openBase(readAirtableConfig());
3250
+ const websites = await listWebsites(base);
3251
+ const reports = [];
3252
+ for (const w of websites) {
3253
+ const rs = await listReportsForSite(base, w.id);
3254
+ reports.push(...rs);
3255
+ }
3256
+ const due = findDueReports(websites, reports, /* @__PURE__ */ new Date());
3257
+ if (due.length === 0) return { output: "No reports due.", code: 0 };
3258
+ const lines = [];
3259
+ for (const item of due) {
3260
+ try {
3261
+ const result = await draftReportForSite(base, item.site, item.reportType);
3262
+ lines.push(`\u2713 drafted: ${result.reportRow?.reportId}`);
3263
+ } catch (e) {
3264
+ lines.push(`\u2717 failed: ${item.site.name} ${item.reportType} \u2014 ${e.message}`);
3265
+ }
3266
+ }
3267
+ return { output: lines.join("\n"), code: lines.some((l) => l.startsWith("\u2717")) ? 1 : 0 };
3268
+ }
3269
+ async function runSingleSiteDraft(slug, opts) {
3270
+ const base = openBase(readAirtableConfig());
3271
+ const websites = await listWebsites(base);
3272
+ const site = websites.find((w) => siteSlug(w.name) === slug);
3273
+ if (!site) {
3274
+ throw Object.assign(new Error(`No Websites row matched slug "${slug}"`), { exitCode: 2 });
3275
+ }
3276
+ const result = await draftReportForSite(opts.previewOnly ? null : base, site, "Maintenance", {
3277
+ previewOnly: opts.previewOnly
3278
+ });
3279
+ if (opts.previewOnly) {
3280
+ return { output: `Preview written to ${result.htmlPath}`, code: 0 };
3281
+ }
3282
+ return { output: `Draft created: ${result.reportRow?.reportId}`, code: 0 };
3283
+ }
3284
+
2320
3285
  // src/cli/version.ts
2321
3286
  import { readFileSync as readFileSync2 } from "fs";
2322
- import { join as join19 } from "path";
3287
+ import { join as join21 } from "path";
2323
3288
  function resolvePackageVersion(fromDir) {
2324
3289
  try {
2325
- const raw = readFileSync2(join19(fromDir, "..", "..", "package.json"), "utf-8");
3290
+ const raw = readFileSync2(join21(fromDir, "..", "..", "package.json"), "utf-8");
2326
3291
  const pkg = JSON.parse(raw);
2327
3292
  return pkg.version ?? "unknown";
2328
3293
  } catch {
@@ -2331,8 +3296,8 @@ function resolvePackageVersion(fromDir) {
2331
3296
  }
2332
3297
 
2333
3298
  // src/cli/bin.ts
2334
- var here = dirname2(fileURLToPath2(import.meta.url));
2335
- var version = resolvePackageVersion(here);
3299
+ var here2 = dirname4(fileURLToPath3(import.meta.url));
3300
+ var version = resolvePackageVersion(here2);
2336
3301
  var AUDIT_DESCRIPTIONS = {
2337
3302
  deps: "Diff site package.json against the bundled baseline version map.",
2338
3303
  lighthouse: "Run @lhci/cli autorun using the canonical lighthouserc.",
@@ -2372,33 +3337,66 @@ cli.command("list-recipes", "Print the available recipes.").action(() => {
2372
3337
  console.log(`${name.padEnd(16)} ${desc}`);
2373
3338
  }
2374
3339
  });
2375
- cli.command("audit [site]", "Run audits against a site (default: cwd).").option("--only <names>", "Comma-separated audit names (e.g. deps,lighthouse)").option("--json", "Machine-readable JSON output").option("--fleet <inventory>", "Inventory file (.json or .mjs/.js); aggregates across sites").option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
3340
+ cli.command("audit [site]", "Run audits against a site (default: cwd).").option("--only <names>", "Comma-separated audit names (e.g. deps,lighthouse)").option("--json", "Machine-readable JSON output").option(
3341
+ "--fleet <inventory>",
3342
+ 'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
3343
+ ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").option(
3344
+ "--write-airtable [slug]",
3345
+ "After lighthouse runs, write pScore/rScore/bpScore/seoScore + timestamp to the matching Websites row. Slug defaults to cwd's package.json#name."
3346
+ ).action(
2376
3347
  async (site, opts) => runOrExit(() => runAuditCommand(site, opts), opts)
2377
3348
  );
2378
- cli.command("sync-configs [site]", "Sync canonical configs into a site.").option("--only <names>", "Comma-separated config names (e.g. eslint,prettier)").option("--dry", "Print diff without writing").option("--fleet <inventory>", "Inventory file (.json or .mjs/.js)").option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
3349
+ cli.command("sync-configs [site]", "Sync canonical configs into a site.").option("--only <names>", "Comma-separated config names (e.g. eslint,prettier)").option("--dry", "Print diff without writing").option(
3350
+ "--fleet <inventory>",
3351
+ 'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
3352
+ ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
2379
3353
  async (site, opts) => runOrExit(() => runSyncConfigsCommand(site, opts), opts)
2380
3354
  );
2381
- cli.command("bump-deps [site]", "Bump dependencies.").option("--group <group>", "patch | minor | major", { default: "minor" }).option("--fleet <inventory>", "Inventory file (.json or .mjs/.js)").option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
3355
+ cli.command("bump-deps [site]", "Bump dependencies.").option("--group <group>", "patch | minor | major", { default: "minor" }).option(
3356
+ "--fleet <inventory>",
3357
+ 'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
3358
+ ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
2382
3359
  async (site, opts) => runOrExit(() => runBumpDepsCommand(site, opts), opts)
2383
3360
  );
2384
- cli.command("upgrade <upgrade> [site]", "Run a named upgrade recipe (svelte-4-to-5).").example("reddoor-maint upgrade svelte-4-to-5 ./my-site").option("--fleet <inventory>", "Inventory file (.json or .mjs/.js)").option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
3361
+ cli.command("upgrade <upgrade> [site]", "Run a named upgrade recipe (svelte-4-to-5).").example("reddoor-maint upgrade svelte-4-to-5 ./my-site").option(
3362
+ "--fleet <inventory>",
3363
+ 'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
3364
+ ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
2385
3365
  async (upgrade, site, opts) => runOrExit(() => runUpgradeCommand(upgrade, site, opts), opts)
2386
3366
  );
2387
3367
  cli.command(
2388
3368
  "convert-to-pnpm [site]",
2389
3369
  "Convert an npm/yarn site to pnpm (lockfile, packageManager, scripts)."
2390
- ).option("--fleet <inventory>", "Inventory file (.json or .mjs/.js)").option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
3370
+ ).option(
3371
+ "--fleet <inventory>",
3372
+ 'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
3373
+ ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
2391
3374
  async (site, opts) => runOrExit(() => runConvertToPnpmCommand(site, opts), opts)
2392
3375
  );
2393
- cli.command("svelte-codemods [site]", "Apply Svelte 5 gotcha codemods to an already-migrated site.").option("--fleet <inventory>", "Inventory file (.json or .mjs/.js)").option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
3376
+ cli.command("svelte-codemods [site]", "Apply Svelte 5 gotcha codemods to an already-migrated site.").option(
3377
+ "--fleet <inventory>",
3378
+ 'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
3379
+ ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
2394
3380
  async (site, opts) => runOrExit(() => runSvelteCodemodsCommand(site, opts), opts)
2395
3381
  );
2396
3382
  cli.command(
2397
3383
  "onboard [site]",
2398
3384
  "Install @reddoorla/maintenance + audit deps on a site (run after convert-to-pnpm)."
2399
- ).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(
3385
+ ).option("--audits <names>", "Comma-separated audit subset: lighthouse,a11y (default: both)").option(
3386
+ "--fleet <inventory>",
3387
+ 'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
3388
+ ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
2400
3389
  async (site, opts) => runOrExit(() => runOnboardCommand(site, opts), opts)
2401
3390
  );
3391
+ cli.command("report [site]", "Draft or send maintenance/testing reports.").option("--due", "Scan all Websites and draft overdue reports.").option(
3392
+ "--preview",
3393
+ "Single-site dry run; writes reports/<slug>/draft.html, never touches Airtable."
3394
+ ).option(
3395
+ "--send-ready",
3396
+ "Send all Reports with Draft ready=true AND Approved to send=true AND Sent at IS NULL."
3397
+ ).action(
3398
+ async (site, opts) => runOrExit(() => runReportCommand(site, opts), opts)
3399
+ );
2402
3400
  cli.help();
2403
3401
  cli.version(version);
2404
3402
  cli.parse();