@reddoorla/maintenance 0.7.0 → 0.9.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
@@ -10,6 +10,11 @@ var __export = (target, all) => {
10
10
  };
11
11
 
12
12
  // src/reports/airtable/client.ts
13
+ var client_exports = {};
14
+ __export(client_exports, {
15
+ openBase: () => openBase,
16
+ readAirtableConfig: () => readAirtableConfig
17
+ });
13
18
  import Airtable from "airtable";
14
19
  function readAirtableConfig() {
15
20
  const apiKey = process.env.AIRTABLE_PAT;
@@ -28,6 +33,14 @@ var init_client = __esm({
28
33
  });
29
34
 
30
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
+ });
31
44
  function siteSlug(name) {
32
45
  return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
33
46
  }
@@ -39,6 +52,7 @@ function mapRow(rec) {
39
52
  id: rec.id,
40
53
  name: String(f["Name"] ?? ""),
41
54
  url: String(f["url"] ?? ""),
55
+ status: f["Status"] ?? null,
42
56
  pointOfContact: f["point of contact"] ?? null,
43
57
  maintenanceFreq: f["maintenence freq"] ?? "None",
44
58
  testingFreq: f["testing freq"] ?? "None",
@@ -51,7 +65,8 @@ function mapRow(rec) {
51
65
  pScore: f["pScore"] ?? null,
52
66
  rScore: f["rScore"] ?? null,
53
67
  bpScore: f["bpScore"] ?? null,
54
- seoScore: f["seoScore"] ?? null
68
+ seoScore: f["seoScore"] ?? null,
69
+ lastLighthouseAuditAt: f["Last lighthouse audit at"] ?? null
55
70
  };
56
71
  }
57
72
  async function listWebsites(base) {
@@ -62,6 +77,20 @@ async function listWebsites(base) {
62
77
  });
63
78
  return out;
64
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
+ }
65
94
  var WEBSITES_TABLE;
66
95
  var init_websites = __esm({
67
96
  "src/reports/airtable/websites.ts"() {
@@ -70,6 +99,82 @@ var init_websites = __esm({
70
99
  }
71
100
  });
72
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 readFile7 } from "fs/promises";
142
+ import { join as join7 } 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 = join7(cwd, "package.json");
160
+ const raw = await readFile7(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
+
73
178
  // src/reports/airtable/reports.ts
74
179
  function mapRow2(rec) {
75
180
  const f = rec.fields;
@@ -177,6 +282,40 @@ var init_reports = __esm({
177
282
  }
178
283
  });
179
284
 
285
+ // src/reports/maintenance-email/assets/index.ts
286
+ import { readFile as readFile13 } from "fs/promises";
287
+ import { dirname as dirname2, join as join21 } from "path";
288
+ import { fileURLToPath as fileURLToPath2 } from "url";
289
+ async function loadBundledImages() {
290
+ const [check, blurred] = await Promise.all([
291
+ readFile13(join21(here, "check.png")),
292
+ readFile13(join21(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
+
180
319
  // src/reports/maintenance-email/template.ts
181
320
  function fmtDate(d) {
182
321
  if (!d) return "";
@@ -344,8 +483,9 @@ var CHECK_PNG, BLURRED_TESTS;
344
483
  var init_template = __esm({
345
484
  "src/reports/maintenance-email/template.ts"() {
346
485
  "use strict";
347
- CHECK_PNG = "https://d3eq0h5l8sxf6t.cloudfront.net/maintenance-email/check.png";
348
- BLURRED_TESTS = "https://d3eq0h5l8sxf6t.cloudfront.net/maintenance-email/blurredTests.jpg";
486
+ init_assets();
487
+ CHECK_PNG = `cid:${CHECK_CID}`;
488
+ BLURRED_TESTS = `cid:${BLURRED_CID}`;
349
489
  }
350
490
  });
351
491
 
@@ -375,6 +515,27 @@ async function fetchAttachmentBytes(url) {
375
515
  const ab = await res.arrayBuffer();
376
516
  return { bytes: new Uint8Array(ab), contentType };
377
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
+ }
378
539
  var init_attachments = __esm({
379
540
  "src/reports/airtable/attachments.ts"() {
380
541
  "use strict";
@@ -454,6 +615,7 @@ async function sendOne(client, base, site, report) {
454
615
  throw new Error(`Report ${report.reportId} has no Lighthouse scores`);
455
616
  }
456
617
  const { bytes, contentType } = await fetchAttachmentBytes(site.headerImage.url);
618
+ const bundled = await loadBundledImages();
457
619
  const slug = siteSlug(site.name);
458
620
  const cidName = `${slug}-header`;
459
621
  const { html } = await renderReportHtml({
@@ -506,6 +668,21 @@ async function sendOne(client, base, site, report) {
506
668
  content: Buffer.from(bytes).toString("base64"),
507
669
  contentType,
508
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
509
686
  }
510
687
  ],
511
688
  // Stable across retries of the same row — if Airtable stamping fails after a
@@ -550,6 +727,7 @@ var init_orchestrate = __esm({
550
727
  init_websites();
551
728
  init_attachments();
552
729
  init_render();
730
+ init_assets();
553
731
  init_resend();
554
732
  FROM_ADDRESS = "Reddoor Reports <reports@reddoorla.com>";
555
733
  REPLY_TO = "info@reddoorla.com";
@@ -557,8 +735,8 @@ var init_orchestrate = __esm({
557
735
  });
558
736
 
559
737
  // src/cli/bin.ts
560
- import { dirname as dirname3 } from "path";
561
- import { fileURLToPath as fileURLToPath2 } from "url";
738
+ import { dirname as dirname4 } from "path";
739
+ import { fileURLToPath as fileURLToPath3 } from "url";
562
740
  import { cac } from "cac";
563
741
 
564
742
  // src/cli/commands/audit.ts
@@ -901,9 +1079,9 @@ async function securityAudit(ctx) {
901
1079
  }
902
1080
 
903
1081
  // src/audits/lighthouse.ts
904
- import { readFile as readFile3, writeFile, mkdtemp, rm } from "fs/promises";
1082
+ import { readFile as readFile4, writeFile, mkdtemp, rm } from "fs/promises";
905
1083
  import { tmpdir } from "os";
906
- import { join as join3 } from "path";
1084
+ import { join as join4 } from "path";
907
1085
 
908
1086
  // src/configs/lighthouse.ts
909
1087
  var lighthouseConfig = {
@@ -936,10 +1114,37 @@ var lighthouseConfig = {
936
1114
  }
937
1115
  };
938
1116
 
1117
+ // src/audits/util/site-config.ts
1118
+ import { readFile as readFile3 } from "fs/promises";
1119
+ import { join as join3 } from "path";
1120
+ async function readSiteConfig(sitePath) {
1121
+ let raw;
1122
+ try {
1123
+ raw = await readFile3(join3(sitePath, "package.json"), "utf-8");
1124
+ } catch {
1125
+ return {};
1126
+ }
1127
+ let pkg;
1128
+ try {
1129
+ pkg = JSON.parse(raw);
1130
+ } catch {
1131
+ return {};
1132
+ }
1133
+ if (!pkg || typeof pkg !== "object") return {};
1134
+ const cfg = pkg.reddoor;
1135
+ if (!cfg || typeof cfg !== "object") return {};
1136
+ const out = {};
1137
+ const url = cfg.lighthouseUrl;
1138
+ if (typeof url === "string" && url.length > 0) {
1139
+ out.lighthouseUrl = url;
1140
+ }
1141
+ return out;
1142
+ }
1143
+
939
1144
  // src/audits/lighthouse.ts
940
1145
  async function readJsonMaybe(path) {
941
1146
  try {
942
- const raw = await readFile3(path, "utf-8");
1147
+ const raw = await readFile4(path, "utf-8");
943
1148
  return JSON.parse(raw);
944
1149
  } catch {
945
1150
  return null;
@@ -975,15 +1180,28 @@ async function lighthouseAudit(ctx) {
975
1180
  const spawn2 = ctx.spawn ?? defaultSpawn;
976
1181
  const site = ctx.site;
977
1182
  const label = siteLabel(site);
978
- const configDir = await mkdtemp(join3(tmpdir(), "reddoor-lhci-"));
979
- const configPath = join3(configDir, "lighthouserc.json");
980
- await writeFile(configPath, JSON.stringify(lighthouseConfig), "utf-8");
981
- const resultsDir = join3(site.path, ".lighthouseci");
1183
+ const siteCfg = await readSiteConfig(site.path);
1184
+ const resolvedConfig = siteCfg.lighthouseUrl ? {
1185
+ ...lighthouseConfig,
1186
+ ci: {
1187
+ ...lighthouseConfig.ci,
1188
+ collect: { ...lighthouseConfig.ci.collect, url: [siteCfg.lighthouseUrl] }
1189
+ }
1190
+ } : lighthouseConfig;
1191
+ const configDir = await mkdtemp(join4(tmpdir(), "reddoor-lhci-"));
1192
+ const configPath = join4(configDir, "lighthouserc.json");
1193
+ await writeFile(configPath, JSON.stringify(resolvedConfig), "utf-8");
1194
+ const resultsDir = join4(site.path, ".lighthouseci");
982
1195
  await rm(resultsDir, { recursive: true, force: true });
983
1196
  let raw;
984
1197
  try {
985
1198
  raw = await spawn2("npx", ["--yes", "@lhci/cli", "autorun", `--config=${configPath}`], {
986
- cwd: site.path
1199
+ cwd: site.path,
1200
+ // lhci autorun boots the site's dev server, downloads Chrome on first
1201
+ // use, and runs the audit — easily 2–3 min on a cold tree. The shared
1202
+ // 30 s default in runAudits is fine for deps/lint/security but starves
1203
+ // lhci.
1204
+ timeoutMs: 5 * 6e4
987
1205
  });
988
1206
  } catch (err) {
989
1207
  await rm(configDir, { recursive: true, force: true });
@@ -999,7 +1217,7 @@ async function lighthouseAudit(ctx) {
999
1217
  throw err;
1000
1218
  }
1001
1219
  await rm(configDir, { recursive: true, force: true });
1002
- const manifest = await readJsonMaybe(join3(resultsDir, "manifest.json"));
1220
+ const manifest = await readJsonMaybe(join4(resultsDir, "manifest.json"));
1003
1221
  if (!manifest || manifest.length === 0) {
1004
1222
  return {
1005
1223
  audit: "lighthouse",
@@ -1008,7 +1226,7 @@ async function lighthouseAudit(ctx) {
1008
1226
  summary: `lighthouse: no manifest written (exit ${raw.code})${raw.stderr ? ` \u2014 ${raw.stderr.slice(0, 200)}` : ""}`
1009
1227
  };
1010
1228
  }
1011
- const assertionResults = await readJsonMaybe(join3(resultsDir, "assertion-results.json")) ?? [];
1229
+ const assertionResults = await readJsonMaybe(join4(resultsDir, "assertion-results.json")) ?? [];
1012
1230
  const failed = assertionResults.filter((a) => !a.passed);
1013
1231
  const assertions = failed.map((a) => ({
1014
1232
  category: categoryFromAssertion(a),
@@ -1034,9 +1252,9 @@ async function lighthouseAudit(ctx) {
1034
1252
  }
1035
1253
 
1036
1254
  // src/audits/a11y.ts
1037
- import { readFile as readFile4, writeFile as writeFile2, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
1255
+ import { readFile as readFile5, writeFile as writeFile2, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
1038
1256
  import { tmpdir as tmpdir2 } from "os";
1039
- import { join as join4 } from "path";
1257
+ import { join as join5 } from "path";
1040
1258
 
1041
1259
  // src/configs/playwright-a11y.ts
1042
1260
  import { defineConfig, devices } from "@playwright/test";
@@ -1074,7 +1292,7 @@ var playwrightA11yConfig = defineConfig({
1074
1292
  var RESULTS_REL = ".reddoor-a11y/results.json";
1075
1293
  async function readJsonMaybe2(path) {
1076
1294
  try {
1077
- const raw = await readFile4(path, "utf-8");
1295
+ const raw = await readFile5(path, "utf-8");
1078
1296
  return JSON.parse(raw);
1079
1297
  } catch {
1080
1298
  return null;
@@ -1130,11 +1348,11 @@ async function a11yAudit(ctx) {
1130
1348
  const spawn2 = ctx.spawn ?? defaultSpawn;
1131
1349
  const site = ctx.site;
1132
1350
  const label = siteLabel(site);
1133
- const specDir = await mkdtemp2(join4(tmpdir2(), "reddoor-a11y-spec-"));
1134
- const specPath = join4(specDir, "a11y.spec.ts");
1351
+ const specDir = await mkdtemp2(join5(tmpdir2(), "reddoor-a11y-spec-"));
1352
+ const specPath = join5(specDir, "a11y.spec.ts");
1135
1353
  await writeFile2(specPath, buildSpec(), "utf-8");
1136
- const resultsPath = join4(site.path, RESULTS_REL);
1137
- await rm2(join4(site.path, ".reddoor-a11y"), { recursive: true, force: true });
1354
+ const resultsPath = join5(site.path, RESULTS_REL);
1355
+ await rm2(join5(site.path, ".reddoor-a11y"), { recursive: true, force: true });
1138
1356
  let raw;
1139
1357
  try {
1140
1358
  raw = await spawn2("npx", ["--yes", "playwright", "test", "--reporter=line", specPath], {
@@ -1210,6 +1428,10 @@ async function runAudits(site, which) {
1210
1428
  )
1211
1429
  );
1212
1430
  }
1431
+ async function runAuditsAcross(sites, which) {
1432
+ const all = await Promise.all(sites.map((s) => runAudits(s, which)));
1433
+ return all.flat();
1434
+ }
1213
1435
 
1214
1436
  // src/cli/fleet/resolve-sites.ts
1215
1437
  import { pathToFileURL } from "url";
@@ -1223,7 +1445,7 @@ function localPath(path, opts = {}) {
1223
1445
  }
1224
1446
 
1225
1447
  // src/inventory/json.ts
1226
- import { readFile as readFile5 } from "fs/promises";
1448
+ import { readFile as readFile6 } from "fs/promises";
1227
1449
  import { isAbsolute } from "path";
1228
1450
  function validate(raw) {
1229
1451
  if (!Array.isArray(raw)) {
@@ -1253,7 +1475,7 @@ function validate(raw) {
1253
1475
  }
1254
1476
  function fromJsonFile(path) {
1255
1477
  return async () => {
1256
- const raw = JSON.parse(await readFile5(path, "utf-8"));
1478
+ const raw = JSON.parse(await readFile6(path, "utf-8"));
1257
1479
  return validate(raw);
1258
1480
  };
1259
1481
  }
@@ -1265,6 +1487,13 @@ async function resolveSites(input) {
1265
1487
  exitCode: 2
1266
1488
  });
1267
1489
  }
1490
+ if (input.fleet === "airtable") {
1491
+ const { openBase: openBase2, readAirtableConfig: readAirtableConfig2 } = await Promise.resolve().then(() => (init_client(), client_exports));
1492
+ const { fromAirtableBase: fromAirtableBase2 } = await Promise.resolve().then(() => (init_airtable(), airtable_exports));
1493
+ const base = openBase2(readAirtableConfig2());
1494
+ const provider = fromAirtableBase2(base, input.workdir ? { workdir: input.workdir } : {});
1495
+ return provider();
1496
+ }
1268
1497
  if (input.fleet) {
1269
1498
  const fleetPath = resolve(input.cwd, input.fleet);
1270
1499
  const ext = extname(fleetPath).toLowerCase();
@@ -1292,7 +1521,7 @@ async function resolveSites(input) {
1292
1521
 
1293
1522
  // src/cli/fleet/clone-if-needed.ts
1294
1523
  import { stat, readdir, mkdir } from "fs/promises";
1295
- import { isAbsolute as isAbsolute2, join as join5 } from "path";
1524
+ import { isAbsolute as isAbsolute2, join as join6 } from "path";
1296
1525
  function deriveNameFromRepoUrl(repoUrl) {
1297
1526
  const slash = repoUrl.split("/").pop() ?? repoUrl;
1298
1527
  return slash.replace(/\.git$/, "");
@@ -1333,7 +1562,7 @@ async function cloneIfNeeded(site, opts) {
1333
1562
  const name = site.name ?? deriveNameFromRepoUrl(site.repoUrl);
1334
1563
  assertSafeName(name);
1335
1564
  assertSafeRepoUrl(site.repoUrl);
1336
- const target = join5(opts.workdir, name);
1565
+ const target = join6(opts.workdir, name);
1337
1566
  await mkdir(opts.workdir, { recursive: true });
1338
1567
  if (await isNonEmptyDir(target)) {
1339
1568
  return { ...site, name, path: target };
@@ -1373,28 +1602,59 @@ async function runAuditCommand(site, opts) {
1373
1602
  let sites = await resolveSites({
1374
1603
  ...site !== void 0 ? { site } : {},
1375
1604
  ...opts.fleet !== void 0 ? { fleet: opts.fleet } : {},
1605
+ ...opts.workdir !== void 0 ? { workdir: opts.workdir } : {},
1376
1606
  cwd
1377
1607
  });
1378
1608
  if (opts.fleet) {
1379
1609
  const workdir = opts.workdir ?? `${process.env.HOME ?? ""}/.reddoor-maint/sites`;
1380
1610
  sites = await Promise.all(sites.map((s) => cloneIfNeeded(s, { workdir })));
1381
1611
  }
1382
- const results = [];
1383
- for (const s of sites) {
1384
- const r = await runAudits(s, which);
1385
- results.push(...r);
1612
+ const results = await runAuditsAcross(sites, which);
1613
+ let output = opts.json ? JSON.stringify(results, null, 2) : formatTable(results);
1614
+ if (opts.writeAirtable !== void 0) {
1615
+ const { openBase: openBase2, readAirtableConfig: readAirtableConfig2 } = await Promise.resolve().then(() => (init_client(), client_exports));
1616
+ const { listWebsites: listWebsites2, updateScores: updateScores2, siteSlug: siteSlug2 } = await Promise.resolve().then(() => (init_websites(), websites_exports));
1617
+ const { lighthouseScoresFromResult: lighthouseScoresFromResult2, resolveSlugFromCwd: resolveSlugFromCwd2 } = await Promise.resolve().then(() => (init_lighthouse_airtable(), lighthouse_airtable_exports));
1618
+ const slug = typeof opts.writeAirtable === "string" && opts.writeAirtable.length > 0 ? opts.writeAirtable : await resolveSlugFromCwd2(cwd);
1619
+ const lhResult = results.find((r) => r.audit === "lighthouse");
1620
+ if (!lhResult) {
1621
+ throw Object.assign(
1622
+ new Error(
1623
+ "--write-airtable requires a lighthouse result; did you pass --only without lighthouse?"
1624
+ ),
1625
+ { exitCode: 2 }
1626
+ );
1627
+ }
1628
+ if (lhResult.status === "fail") {
1629
+ throw Object.assign(
1630
+ new Error(
1631
+ `Lighthouse audit failed; refusing to write scores to Airtable. Summary: ${lhResult.summary}`
1632
+ ),
1633
+ { exitCode: 1 }
1634
+ );
1635
+ }
1636
+ const base = openBase2(readAirtableConfig2());
1637
+ const websites = await listWebsites2(base);
1638
+ const target = websites.find((w) => siteSlug2(w.name) === slug);
1639
+ if (!target) {
1640
+ throw Object.assign(new Error(`No Websites row matched slug "${slug}"`), { exitCode: 2 });
1641
+ }
1642
+ const scores = lighthouseScoresFromResult2(lhResult);
1643
+ await updateScores2(base, target.id, scores);
1644
+ output += `
1645
+
1646
+ \u2192 wrote scores to Websites[${target.name}]: P=${scores.performance} A=${scores.accessibility} BP=${scores.bestPractices} SEO=${scores.seo}`;
1386
1647
  }
1387
- const output = opts.json ? JSON.stringify(results, null, 2) : formatTable(results);
1388
1648
  return { output, code: exitCode(results) };
1389
1649
  }
1390
1650
 
1391
1651
  // src/cli/commands/sync-configs.ts
1392
- import { readFile as readFile7 } from "fs/promises";
1393
- import { join as join7, resolve as resolve3 } from "path";
1652
+ import { readFile as readFile9 } from "fs/promises";
1653
+ import { join as join9, resolve as resolve3 } from "path";
1394
1654
 
1395
1655
  // src/recipes/sync-configs.ts
1396
- import { readFile as readFile6, writeFile as writeFile3 } from "fs/promises";
1397
- import { join as join6 } from "path";
1656
+ import { readFile as readFile8, writeFile as writeFile3 } from "fs/promises";
1657
+ import { join as join8 } from "path";
1398
1658
 
1399
1659
  // src/recipes/sync-configs/templates.ts
1400
1660
  var eslint = {
@@ -1658,7 +1918,7 @@ function isConfigName(value) {
1658
1918
  }
1659
1919
  async function readMaybe(path) {
1660
1920
  try {
1661
- return await readFile6(path, "utf-8");
1921
+ return await readFile8(path, "utf-8");
1662
1922
  } catch {
1663
1923
  return null;
1664
1924
  }
@@ -1666,13 +1926,13 @@ async function readMaybe(path) {
1666
1926
  async function planTemplateDiffs(cwd, templates) {
1667
1927
  const diffs = [];
1668
1928
  for (const t of templates) {
1669
- const existing = await readMaybe(join6(cwd, t.path));
1929
+ const existing = await readMaybe(join8(cwd, t.path));
1670
1930
  if (existing !== t.contents) diffs.push(t);
1671
1931
  }
1672
1932
  return diffs;
1673
1933
  }
1674
1934
  async function planGitignore(cwd) {
1675
- const existing = await readMaybe(join6(cwd, ".gitignore"));
1935
+ const existing = await readMaybe(join8(cwd, ".gitignore"));
1676
1936
  const merge = mergeGitignore(existing, CANONICAL_GITIGNORE_ENTRIES);
1677
1937
  const tracked = await listTrackedFiles(cwd);
1678
1938
  const toUntrack = findTrackedArtifacts(tracked, CANONICAL_GITIGNORE_ENTRIES);
@@ -1680,7 +1940,7 @@ async function planGitignore(cwd) {
1680
1940
  return { kind: "apply", content: merge.content, toUntrack, added: merge.added };
1681
1941
  }
1682
1942
  async function applyGitignore(cwd, plan) {
1683
- await writeFile3(join6(cwd, ".gitignore"), plan.content, "utf-8");
1943
+ await writeFile3(join8(cwd, ".gitignore"), plan.content, "utf-8");
1684
1944
  if (plan.toUntrack.length > 0) {
1685
1945
  await removeFromIndex(cwd, plan.toUntrack);
1686
1946
  }
@@ -1703,7 +1963,7 @@ async function syncConfigs(site, opts = {}) {
1703
1963
  },
1704
1964
  apply: async ({ templateDiffs, gitignorePlan }, { commit: commit2 }) => {
1705
1965
  for (const t of templateDiffs) {
1706
- await writeFile3(join6(site.path, t.path), t.contents, "utf-8");
1966
+ await writeFile3(join8(site.path, t.path), t.contents, "utf-8");
1707
1967
  await commit2(`chore: sync ${t.config} config from @reddoorla/maintenance`);
1708
1968
  }
1709
1969
  if (gitignorePlan.kind === "apply") {
@@ -1732,7 +1992,7 @@ function parseOnly2(value) {
1732
1992
  async function dryPlanGitignore(cwd) {
1733
1993
  let existing;
1734
1994
  try {
1735
- existing = await readFile7(join7(cwd, ".gitignore"), "utf-8");
1995
+ existing = await readFile9(join9(cwd, ".gitignore"), "utf-8");
1736
1996
  } catch {
1737
1997
  return "would create .gitignore";
1738
1998
  }
@@ -1747,7 +2007,7 @@ async function dryPlan(cwd, which) {
1747
2007
  for (const t of templateTargets) {
1748
2008
  let existing = "";
1749
2009
  try {
1750
- existing = await readFile7(join7(cwd, t.path), "utf-8");
2010
+ existing = await readFile9(join9(cwd, t.path), "utf-8");
1751
2011
  } catch {
1752
2012
  }
1753
2013
  if (existing !== t.contents) lines.push(`would update ${t.path} (config: ${t.config})`);
@@ -1795,7 +2055,7 @@ import { resolve as resolve4 } from "path";
1795
2055
 
1796
2056
  // src/recipes/bump-deps.ts
1797
2057
  import { stat as stat2 } from "fs/promises";
1798
- import { join as join8 } from "path";
2058
+ import { join as join10 } from "path";
1799
2059
  async function exists(path) {
1800
2060
  try {
1801
2061
  await stat2(path);
@@ -1824,10 +2084,10 @@ async function bumpDeps(site, opts = {}) {
1824
2084
  // land on top of whatever else was in the tree.
1825
2085
  checkTreeFirst: true,
1826
2086
  plan: async () => {
1827
- const hasPnpmLock = await exists(join8(site.path, "pnpm-lock.yaml"));
2087
+ const hasPnpmLock = await exists(join10(site.path, "pnpm-lock.yaml"));
1828
2088
  if (!hasPnpmLock) {
1829
- const hasNpmLock = await exists(join8(site.path, "package-lock.json"));
1830
- const hasYarnLock = await exists(join8(site.path, "yarn.lock"));
2089
+ const hasNpmLock = await exists(join10(site.path, "package-lock.json"));
2090
+ const hasYarnLock = await exists(join10(site.path, "yarn.lock"));
1831
2091
  if (hasNpmLock || hasYarnLock) {
1832
2092
  const competing = hasNpmLock ? "package-lock.json" : "yarn.lock";
1833
2093
  return {
@@ -1897,12 +2157,12 @@ async function runBumpDepsCommand(site, opts) {
1897
2157
  import { resolve as resolve5 } from "path";
1898
2158
 
1899
2159
  // src/recipes/svelte-5/index.ts
1900
- import { join as join14 } from "path";
2160
+ import { join as join16 } from "path";
1901
2161
 
1902
2162
  // src/util/pkg.ts
1903
- import { readFile as readFile8, writeFile as writeFile4 } from "fs/promises";
2163
+ import { readFile as readFile10, writeFile as writeFile4 } from "fs/promises";
1904
2164
  async function readPackageJson(path) {
1905
- const raw = await readFile8(path, "utf-8");
2165
+ const raw = await readFile10(path, "utf-8");
1906
2166
  return JSON.parse(raw);
1907
2167
  }
1908
2168
  function detectIndentFromContent(raw) {
@@ -1912,7 +2172,7 @@ function detectIndentFromContent(raw) {
1912
2172
  async function writePackageJson(path, pkg) {
1913
2173
  let indent = " ";
1914
2174
  try {
1915
- const existing = await readFile8(path, "utf-8");
2175
+ const existing = await readFile10(path, "utf-8");
1916
2176
  indent = detectIndentFromContent(existing);
1917
2177
  } catch {
1918
2178
  }
@@ -1946,7 +2206,7 @@ function bumpDep(pkg, name, version2, opts = {}) {
1946
2206
  }
1947
2207
 
1948
2208
  // src/recipes/svelte-5/step-bump-versions.ts
1949
- import { join as join9 } from "path";
2209
+ import { join as join11 } from "path";
1950
2210
  var SVELTE_5_VERSIONS = {
1951
2211
  svelte: "^5.55.5",
1952
2212
  "@sveltejs/kit": "^2.59.0",
@@ -1959,7 +2219,7 @@ var SVELTE_5_VERSIONS = {
1959
2219
  "typescript-svelte-plugin": "^0.3.52"
1960
2220
  };
1961
2221
  async function bumpToSvelte5Versions(cwd) {
1962
- const pkgPath = join9(cwd, "package.json");
2222
+ const pkgPath = join11(cwd, "package.json");
1963
2223
  const pkg = await readPackageJson(pkgPath);
1964
2224
  let next = pkg;
1965
2225
  for (const [name, version2] of Object.entries(SVELTE_5_VERSIONS)) {
@@ -1971,8 +2231,8 @@ async function bumpToSvelte5Versions(cwd) {
1971
2231
  }
1972
2232
 
1973
2233
  // src/recipes/svelte-5/step-svelte-config.ts
1974
- import { readFile as readFile9, writeFile as writeFile5 } from "fs/promises";
1975
- import { join as join10 } from "path";
2234
+ import { readFile as readFile11, writeFile as writeFile5 } from "fs/promises";
2235
+ import { join as join12 } from "path";
1976
2236
  var VITE_PLUGIN_PKG = "@sveltejs/vite-plugin-svelte";
1977
2237
  var IMPORT_FROM_VITE_PLUGIN = new RegExp(
1978
2238
  String.raw`^import\s+\{\s*([^}]+?)\s*\}\s+from\s+["']` + VITE_PLUGIN_PKG.replace(/[/]/g, "\\/") + String.raw`["'];?[ \t]*\n`,
@@ -2013,10 +2273,10 @@ function dropPreprocessKey(source) {
2013
2273
  return source.slice(0, m.index) + source.slice(tailIdx).replace(new RegExp(`^${indent}\\n`), "");
2014
2274
  }
2015
2275
  async function migrateSvelteConfig(cwd) {
2016
- const path = join10(cwd, "svelte.config.js");
2276
+ const path = join12(cwd, "svelte.config.js");
2017
2277
  let src;
2018
2278
  try {
2019
- src = await readFile9(path, "utf-8");
2279
+ src = await readFile11(path, "utf-8");
2020
2280
  } catch {
2021
2281
  return false;
2022
2282
  }
@@ -2050,9 +2310,9 @@ async function runSvelteMigrate(cwd, spawn2 = defaultSpawn) {
2050
2310
  }
2051
2311
 
2052
2312
  // src/recipes/svelte-5/step-tailwind-upgrade.ts
2053
- import { join as join11 } from "path";
2313
+ import { join as join13 } from "path";
2054
2314
  async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
2055
- const pkg = await readPackageJson(join11(cwd, "package.json"));
2315
+ const pkg = await readPackageJson(join13(cwd, "package.json"));
2056
2316
  const tailwindVersion = pkg.devDependencies?.tailwindcss ?? pkg.dependencies?.tailwindcss;
2057
2317
  if (!tailwindVersion) return { ran: false, reason: "tailwindcss not installed" };
2058
2318
  if (/^\^?4\./.test(tailwindVersion)) return { ran: false, reason: "already on tailwind 4.x" };
@@ -2073,8 +2333,8 @@ async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
2073
2333
  }
2074
2334
 
2075
2335
  // src/recipes/svelte-5/step-gotchas.ts
2076
- import { readFile as readFile10, writeFile as writeFile6 } from "fs/promises";
2077
- import { join as join12 } from "path";
2336
+ import { readFile as readFile12, writeFile as writeFile6 } from "fs/promises";
2337
+ import { join as join14 } from "path";
2078
2338
  import { glob as glob2 } from "tinyglobby";
2079
2339
 
2080
2340
  // src/recipes/svelte-5/codemods/on-event-to-handler.ts
@@ -2406,8 +2666,8 @@ async function planGotchaCodemods(cwd) {
2406
2666
  const changes = [];
2407
2667
  const relPaths = await glob2(SVELTE_GLOBS, { cwd, ignore: IGNORE2, absolute: false });
2408
2668
  for (const rel of relPaths) {
2409
- const path = join12(cwd, rel);
2410
- const before = await readFile10(path, "utf-8");
2669
+ const path = join14(cwd, rel);
2670
+ const before = await readFile12(path, "utf-8");
2411
2671
  const after = CODEMODS.reduce((s, fn) => fn(s), before);
2412
2672
  if (after !== before) changes.push({ rel, after });
2413
2673
  }
@@ -2416,7 +2676,7 @@ async function planGotchaCodemods(cwd) {
2416
2676
  async function applyGotchaCodemods(cwd) {
2417
2677
  const changes = await planGotchaCodemods(cwd);
2418
2678
  for (const c of changes) {
2419
- await writeFile6(join12(cwd, c.rel), c.after, "utf-8");
2679
+ await writeFile6(join14(cwd, c.rel), c.after, "utf-8");
2420
2680
  }
2421
2681
  return { filesChanged: changes.length };
2422
2682
  }
@@ -2440,7 +2700,7 @@ async function verifyMigration(cwd, spawn2 = defaultSpawn) {
2440
2700
 
2441
2701
  // src/recipes/svelte-5/step-summary.ts
2442
2702
  import { writeFile as writeFile7 } from "fs/promises";
2443
- import { join as join13 } from "path";
2703
+ import { join as join15 } from "path";
2444
2704
  async function writeMigrationSummary(input) {
2445
2705
  const lines = [
2446
2706
  `# Svelte 4 \u2192 5 migration summary`,
@@ -2457,7 +2717,7 @@ async function writeMigrationSummary(input) {
2457
2717
  `- Verify Playwright a11y tests still pass.`
2458
2718
  ];
2459
2719
  const content = lines.join("\n") + "\n";
2460
- const path = join13(input.cwd, "MIGRATION_SVELTE_5.md");
2720
+ const path = join15(input.cwd, "MIGRATION_SVELTE_5.md");
2461
2721
  await writeFile7(path, content, "utf-8");
2462
2722
  return path;
2463
2723
  }
@@ -2465,7 +2725,7 @@ async function writeMigrationSummary(input) {
2465
2725
  // src/recipes/svelte-5/index.ts
2466
2726
  async function alreadyOnSvelte5(cwd) {
2467
2727
  try {
2468
- const pkg = await readPackageJson(join14(cwd, "package.json"));
2728
+ const pkg = await readPackageJson(join16(cwd, "package.json"));
2469
2729
  const v = pkg.devDependencies?.svelte ?? pkg.dependencies?.svelte;
2470
2730
  return !!v && /^\^?5\./.test(v);
2471
2731
  } catch {
@@ -2560,7 +2820,7 @@ import { resolve as resolve6 } from "path";
2560
2820
 
2561
2821
  // src/recipes/convert-to-pnpm.ts
2562
2822
  import { rm as rm3, stat as stat3 } from "fs/promises";
2563
- import { join as join15 } from "path";
2823
+ import { join as join17 } from "path";
2564
2824
 
2565
2825
  // src/recipes/convert-to-pnpm/script-rewrites.ts
2566
2826
  function rewriteScriptForPnpm(script) {
@@ -2593,9 +2853,9 @@ async function exists2(path) {
2593
2853
  async function convertToPnpm(site, opts = {}) {
2594
2854
  const spawn2 = opts.spawn ?? defaultSpawn;
2595
2855
  const pnpmVersion = opts.pnpmVersion ?? DEFAULT_PNPM_VERSION;
2596
- const pnpmLockPath = join15(site.path, "pnpm-lock.yaml");
2597
- const npmLockPath = join15(site.path, "package-lock.json");
2598
- const yarnLockPath = join15(site.path, "yarn.lock");
2856
+ const pnpmLockPath = join17(site.path, "pnpm-lock.yaml");
2857
+ const npmLockPath = join17(site.path, "package-lock.json");
2858
+ const yarnLockPath = join17(site.path, "yarn.lock");
2599
2859
  return withRecipe({
2600
2860
  name: "convert-to-pnpm",
2601
2861
  site,
@@ -2618,7 +2878,7 @@ async function convertToPnpm(site, opts = {}) {
2618
2878
  if (hasYarnLock) await rm3(yarnLockPath, { force: true });
2619
2879
  const sourceLock = hasNpmLock ? "package-lock.json" : "yarn.lock";
2620
2880
  await commit2(`chore(pnpm): remove ${sourceLock}`);
2621
- const pkgPath = join15(cwd, "package.json");
2881
+ const pkgPath = join17(cwd, "package.json");
2622
2882
  const pkg = await readPackageJson(pkgPath);
2623
2883
  const next = { ...pkg, packageManager: `pnpm@${pnpmVersion}` };
2624
2884
  if (pkg.scripts && typeof pkg.scripts === "object") {
@@ -2631,7 +2891,7 @@ async function convertToPnpm(site, opts = {}) {
2631
2891
  }
2632
2892
  await writePackageJson(pkgPath, next);
2633
2893
  await commit2("chore(pnpm): pin packageManager + rewrite npm scripts");
2634
- await rm3(join15(cwd, "node_modules"), { recursive: true, force: true });
2894
+ await rm3(join17(cwd, "node_modules"), { recursive: true, force: true });
2635
2895
  const installResult = await spawn2("pnpm", ["install"], { cwd, streaming: true });
2636
2896
  if (installResult.code !== 0) {
2637
2897
  return { kind: "failed", notes: `pnpm install failed (exit ${installResult.code})` };
@@ -2672,16 +2932,16 @@ import { resolve as resolve7 } from "path";
2672
2932
 
2673
2933
  // src/recipes/onboard.ts
2674
2934
  import { stat as stat4 } from "fs/promises";
2675
- import { join as join17 } from "path";
2935
+ import { join as join19 } from "path";
2676
2936
 
2677
2937
  // src/util/self-version.ts
2678
2938
  import { readFileSync } from "fs";
2679
2939
  import { fileURLToPath } from "url";
2680
- import { dirname, join as join16 } from "path";
2940
+ import { dirname, join as join18 } from "path";
2681
2941
  function selfPackageVersion(callerImportMetaUrl) {
2682
2942
  try {
2683
- const here2 = dirname(fileURLToPath(callerImportMetaUrl));
2684
- const raw = readFileSync(join16(here2, "..", "..", "package.json"), "utf-8");
2943
+ const here3 = dirname(fileURLToPath(callerImportMetaUrl));
2944
+ const raw = readFileSync(join18(here3, "..", "..", "package.json"), "utf-8");
2685
2945
  const pkg = JSON.parse(raw);
2686
2946
  return pkg.version ?? "0.0.0";
2687
2947
  } catch {
@@ -2731,13 +2991,13 @@ async function onboard(site, opts = {}) {
2731
2991
  name: "onboard",
2732
2992
  site,
2733
2993
  plan: async () => {
2734
- if (!await exists3(join17(site.path, "pnpm-lock.yaml"))) {
2994
+ if (!await exists3(join19(site.path, "pnpm-lock.yaml"))) {
2735
2995
  return {
2736
2996
  kind: "failed",
2737
2997
  notes: "no pnpm-lock.yaml at site root \u2014 run convert-to-pnpm first"
2738
2998
  };
2739
2999
  }
2740
- const pkgPath = join17(site.path, "package.json");
3000
+ const pkgPath = join19(site.path, "package.json");
2741
3001
  const pkg = await readPackageJson(pkgPath);
2742
3002
  const toAdd = [];
2743
3003
  if (!isDeclared(pkg, PACKAGE_NAME)) {
@@ -2757,7 +3017,7 @@ async function onboard(site, opts = {}) {
2757
3017
  return { kind: "apply", plan: { pkg, toAdd } };
2758
3018
  },
2759
3019
  apply: async ({ pkg, toAdd }, { commit: commit2, cwd }) => {
2760
- const pkgPath = join17(cwd, "package.json");
3020
+ const pkgPath = join19(cwd, "package.json");
2761
3021
  let next = pkg;
2762
3022
  for (const dep of toAdd) {
2763
3023
  next = bumpDep(next, dep.name, dep.version);
@@ -2826,7 +3086,7 @@ import { resolve as resolve8 } from "path";
2826
3086
 
2827
3087
  // src/recipes/svelte-codemods.ts
2828
3088
  import { writeFile as writeFile8 } from "fs/promises";
2829
- import { join as join18 } from "path";
3089
+ import { join as join20 } from "path";
2830
3090
  async function svelteCodemods(site) {
2831
3091
  return withRecipe({
2832
3092
  name: "svelte-codemods",
@@ -2840,7 +3100,7 @@ async function svelteCodemods(site) {
2840
3100
  },
2841
3101
  apply: async (changes, { commit: commit2, cwd }) => {
2842
3102
  for (const c of changes) {
2843
- await writeFile8(join18(cwd, c.rel), c.after, "utf-8");
3103
+ await writeFile8(join20(cwd, c.rel), c.after, "utf-8");
2844
3104
  }
2845
3105
  await commit2(`refactor(svelte5): apply codemods (${changes.length} files)`);
2846
3106
  return { kind: "ok" };
@@ -2879,6 +3139,12 @@ init_websites();
2879
3139
  init_reports();
2880
3140
 
2881
3141
  // src/reports/due.ts
3142
+ var ELIGIBLE_STATUSES = /* @__PURE__ */ new Set([
3143
+ "in development",
3144
+ "launch period",
3145
+ "maintenance",
3146
+ "hosting"
3147
+ ]);
2882
3148
  var MONTHS = {
2883
3149
  Monthly: 1,
2884
3150
  Quarterly: 3,
@@ -2908,6 +3174,7 @@ function findDueReports(websites, reports, today) {
2908
3174
  const out = [];
2909
3175
  const todayStart = startOfDay(today);
2910
3176
  for (const site of websites) {
3177
+ if (site.status !== null && !ELIGIBLE_STATUSES.has(site.status)) continue;
2911
3178
  for (const type of ["Maintenance", "Testing"]) {
2912
3179
  const freq = type === "Maintenance" ? site.maintenanceFreq : site.testingFreq;
2913
3180
  if (freq === "None") continue;
@@ -2931,8 +3198,9 @@ function findDueReports(websites, reports, today) {
2931
3198
  init_render();
2932
3199
  init_websites();
2933
3200
  init_reports();
3201
+ init_attachments();
2934
3202
  import { mkdir as mkdir2, writeFile as writeFile9 } from "fs/promises";
2935
- import { dirname as dirname2 } from "path";
3203
+ import { dirname as dirname3 } from "path";
2936
3204
  function scoresFromWebsite(siteRow) {
2937
3205
  const { pScore, rScore, bpScore, seoScore } = siteRow;
2938
3206
  if (pScore === null || rScore === null || bpScore === null || seoScore === null) {
@@ -2970,7 +3238,7 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
2970
3238
  });
2971
3239
  if (options.previewOnly) {
2972
3240
  const path = options.previewPath ?? `reports/${slug}/draft.html`;
2973
- await mkdir2(dirname2(path), { recursive: true });
3241
+ await mkdir2(dirname3(path), { recursive: true });
2974
3242
  await writeFile9(path, html, "utf-8");
2975
3243
  return { reportRow: null, htmlPath: path, html };
2976
3244
  }
@@ -2986,7 +3254,8 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
2986
3254
  lighthouse: scores,
2987
3255
  lastTestedDate
2988
3256
  });
2989
- await uploadHtmlAttachment(created.id, html, slug, periodEnd);
3257
+ const htmlFilename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;
3258
+ await uploadAttachment(created.id, "Rendered HTML", html, htmlFilename, "text/html");
2990
3259
  await setDraftReady(base, created.id, true);
2991
3260
  return { reportRow: created, htmlPath: null, html };
2992
3261
  }
@@ -2996,28 +3265,6 @@ async function derivePeriodStart(base, siteRow, reportType, today) {
2996
3265
  const latest = sameType[sameType.length - 1];
2997
3266
  return latest ? new Date(latest) : daysAgo(today, 30);
2998
3267
  }
2999
- async function uploadHtmlAttachment(recordId, html, slug, periodEnd) {
3000
- const apiKey = process.env.AIRTABLE_PAT;
3001
- const baseId = process.env.AIRTABLE_BASE_ID;
3002
- const filename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;
3003
- const body = {
3004
- contentType: "text/html",
3005
- file: Buffer.from(html, "utf-8").toString("base64"),
3006
- filename
3007
- };
3008
- const url = `https://content.airtable.com/v0/${baseId}/${recordId}/Rendered%20HTML/uploadAttachment`;
3009
- const res = await fetch(url, {
3010
- method: "POST",
3011
- headers: {
3012
- Authorization: `Bearer ${apiKey}`,
3013
- "Content-Type": "application/json"
3014
- },
3015
- body: JSON.stringify(body)
3016
- });
3017
- if (!res.ok) {
3018
- throw new Error(`Airtable upload failed: ${res.status} ${res.statusText} ${await res.text()}`);
3019
- }
3020
- }
3021
3268
 
3022
3269
  // src/cli/commands/report.ts
3023
3270
  async function runReportCommand(slug, opts) {
@@ -3077,10 +3324,10 @@ async function runSingleSiteDraft(slug, opts) {
3077
3324
 
3078
3325
  // src/cli/version.ts
3079
3326
  import { readFileSync as readFileSync2 } from "fs";
3080
- import { join as join19 } from "path";
3327
+ import { join as join22 } from "path";
3081
3328
  function resolvePackageVersion(fromDir) {
3082
3329
  try {
3083
- const raw = readFileSync2(join19(fromDir, "..", "..", "package.json"), "utf-8");
3330
+ const raw = readFileSync2(join22(fromDir, "..", "..", "package.json"), "utf-8");
3084
3331
  const pkg = JSON.parse(raw);
3085
3332
  return pkg.version ?? "unknown";
3086
3333
  } catch {
@@ -3089,8 +3336,8 @@ function resolvePackageVersion(fromDir) {
3089
3336
  }
3090
3337
 
3091
3338
  // src/cli/bin.ts
3092
- var here = dirname3(fileURLToPath2(import.meta.url));
3093
- var version = resolvePackageVersion(here);
3339
+ var here2 = dirname4(fileURLToPath3(import.meta.url));
3340
+ var version = resolvePackageVersion(here2);
3094
3341
  var AUDIT_DESCRIPTIONS = {
3095
3342
  deps: "Diff site package.json against the bundled baseline version map.",
3096
3343
  lighthouse: "Run @lhci/cli autorun using the canonical lighthouserc.",
@@ -3130,31 +3377,55 @@ cli.command("list-recipes", "Print the available recipes.").action(() => {
3130
3377
  console.log(`${name.padEnd(16)} ${desc}`);
3131
3378
  }
3132
3379
  });
3133
- 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(
3380
+ 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(
3381
+ "--fleet <inventory>",
3382
+ 'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
3383
+ ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").option(
3384
+ "--write-airtable [slug]",
3385
+ "After lighthouse runs, write pScore/rScore/bpScore/seoScore + timestamp to the matching Websites row. Slug defaults to cwd's package.json#name."
3386
+ ).action(
3134
3387
  async (site, opts) => runOrExit(() => runAuditCommand(site, opts), opts)
3135
3388
  );
3136
- 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(
3389
+ 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(
3390
+ "--fleet <inventory>",
3391
+ 'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
3392
+ ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
3137
3393
  async (site, opts) => runOrExit(() => runSyncConfigsCommand(site, opts), opts)
3138
3394
  );
3139
- 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(
3395
+ cli.command("bump-deps [site]", "Bump dependencies.").option("--group <group>", "patch | minor | major", { default: "minor" }).option(
3396
+ "--fleet <inventory>",
3397
+ 'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
3398
+ ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
3140
3399
  async (site, opts) => runOrExit(() => runBumpDepsCommand(site, opts), opts)
3141
3400
  );
3142
- 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(
3401
+ 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(
3402
+ "--fleet <inventory>",
3403
+ 'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
3404
+ ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
3143
3405
  async (upgrade, site, opts) => runOrExit(() => runUpgradeCommand(upgrade, site, opts), opts)
3144
3406
  );
3145
3407
  cli.command(
3146
3408
  "convert-to-pnpm [site]",
3147
3409
  "Convert an npm/yarn site to pnpm (lockfile, packageManager, scripts)."
3148
- ).option("--fleet <inventory>", "Inventory file (.json or .mjs/.js)").option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
3410
+ ).option(
3411
+ "--fleet <inventory>",
3412
+ 'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
3413
+ ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
3149
3414
  async (site, opts) => runOrExit(() => runConvertToPnpmCommand(site, opts), opts)
3150
3415
  );
3151
- 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(
3416
+ cli.command("svelte-codemods [site]", "Apply Svelte 5 gotcha codemods to an already-migrated site.").option(
3417
+ "--fleet <inventory>",
3418
+ 'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
3419
+ ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
3152
3420
  async (site, opts) => runOrExit(() => runSvelteCodemodsCommand(site, opts), opts)
3153
3421
  );
3154
3422
  cli.command(
3155
3423
  "onboard [site]",
3156
3424
  "Install @reddoorla/maintenance + audit deps on a site (run after convert-to-pnpm)."
3157
- ).option("--audits <names>", "Comma-separated audit subset: lighthouse,a11y (default: both)").option("--fleet <inventory>", "Inventory file (.json or .mjs/.js)").option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
3425
+ ).option("--audits <names>", "Comma-separated audit subset: lighthouse,a11y (default: both)").option(
3426
+ "--fleet <inventory>",
3427
+ 'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
3428
+ ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
3158
3429
  async (site, opts) => runOrExit(() => runOnboardCommand(site, opts), opts)
3159
3430
  );
3160
3431
  cli.command("report [site]", "Draft or send maintenance/testing reports.").option("--due", "Scan all Websites and draft overdue reports.").option(