@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/README.md +305 -7
- package/dist/cli/bin.js +1066 -68
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +6 -0
- package/dist/cli/commands/audit.js +223 -5
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/index.d.ts +204 -1
- package/dist/index.js +715 -2
- package/dist/index.js.map +1 -1
- package/dist/reports/maintenance-email/assets/blurredTests.jpg +0 -0
- package/dist/reports/maintenance-email/assets/check.png +0 -0
- package/package.json +7 -1
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/
|
|
4
|
-
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
|
837
|
-
import { join as
|
|
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
|
|
841
|
-
import { join as
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
2047
|
+
const hasPnpmLock = await exists(join9(site.path, "pnpm-lock.yaml"));
|
|
1272
2048
|
if (!hasPnpmLock) {
|
|
1273
|
-
const hasNpmLock = await exists(
|
|
1274
|
-
const hasYarnLock = await exists(
|
|
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
|
|
2120
|
+
import { join as join15 } from "path";
|
|
1345
2121
|
|
|
1346
2122
|
// src/util/pkg.ts
|
|
1347
|
-
import { readFile as
|
|
2123
|
+
import { readFile as readFile9, writeFile as writeFile4 } from "fs/promises";
|
|
1348
2124
|
async function readPackageJson(path) {
|
|
1349
|
-
const raw = await
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
1419
|
-
import { join as
|
|
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 =
|
|
2236
|
+
const path = join11(cwd, "svelte.config.js");
|
|
1461
2237
|
let src;
|
|
1462
2238
|
try {
|
|
1463
|
-
src = await
|
|
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
|
|
2273
|
+
import { join as join12 } from "path";
|
|
1498
2274
|
async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
|
|
1499
|
-
const pkg = await readPackageJson(
|
|
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
|
|
1521
|
-
import { join as
|
|
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 =
|
|
1854
|
-
const before = await
|
|
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(
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
|
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 =
|
|
2041
|
-
const npmLockPath =
|
|
2042
|
-
const yarnLockPath =
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
2900
|
+
import { dirname, join as join17 } from "path";
|
|
2125
2901
|
function selfPackageVersion(callerImportMetaUrl) {
|
|
2126
2902
|
try {
|
|
2127
|
-
const
|
|
2128
|
-
const raw = readFileSync(
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
3287
|
+
import { join as join21 } from "path";
|
|
2323
3288
|
function resolvePackageVersion(fromDir) {
|
|
2324
3289
|
try {
|
|
2325
|
-
const raw = readFileSync2(
|
|
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
|
|
2335
|
-
var version = resolvePackageVersion(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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();
|