@reddoorla/maintenance 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/bin.js CHANGED
@@ -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 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
+
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 readFile12 } from "fs/promises";
287
+ import { dirname as dirname2, join as join20 } from "path";
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
+
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
@@ -1210,6 +1388,10 @@ async function runAudits(site, which) {
1210
1388
  )
1211
1389
  );
1212
1390
  }
1391
+ async function runAuditsAcross(sites, which) {
1392
+ const all = await Promise.all(sites.map((s) => runAudits(s, which)));
1393
+ return all.flat();
1394
+ }
1213
1395
 
1214
1396
  // src/cli/fleet/resolve-sites.ts
1215
1397
  import { pathToFileURL } from "url";
@@ -1265,6 +1447,13 @@ async function resolveSites(input) {
1265
1447
  exitCode: 2
1266
1448
  });
1267
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
+ }
1268
1457
  if (input.fleet) {
1269
1458
  const fleetPath = resolve(input.cwd, input.fleet);
1270
1459
  const ext = extname(fleetPath).toLowerCase();
@@ -1373,28 +1562,59 @@ async function runAuditCommand(site, opts) {
1373
1562
  let sites = await resolveSites({
1374
1563
  ...site !== void 0 ? { site } : {},
1375
1564
  ...opts.fleet !== void 0 ? { fleet: opts.fleet } : {},
1565
+ ...opts.workdir !== void 0 ? { workdir: opts.workdir } : {},
1376
1566
  cwd
1377
1567
  });
1378
1568
  if (opts.fleet) {
1379
1569
  const workdir = opts.workdir ?? `${process.env.HOME ?? ""}/.reddoor-maint/sites`;
1380
1570
  sites = await Promise.all(sites.map((s) => cloneIfNeeded(s, { workdir })));
1381
1571
  }
1382
- const results = [];
1383
- for (const s of sites) {
1384
- const r = await runAudits(s, which);
1385
- results.push(...r);
1572
+ const results = await runAuditsAcross(sites, which);
1573
+ let output = opts.json ? JSON.stringify(results, null, 2) : formatTable(results);
1574
+ if (opts.writeAirtable !== void 0) {
1575
+ const { openBase: openBase2, readAirtableConfig: readAirtableConfig2 } = await Promise.resolve().then(() => (init_client(), client_exports));
1576
+ const { listWebsites: listWebsites2, updateScores: updateScores2, siteSlug: siteSlug2 } = await Promise.resolve().then(() => (init_websites(), websites_exports));
1577
+ const { lighthouseScoresFromResult: lighthouseScoresFromResult2, resolveSlugFromCwd: resolveSlugFromCwd2 } = await Promise.resolve().then(() => (init_lighthouse_airtable(), lighthouse_airtable_exports));
1578
+ const slug = typeof opts.writeAirtable === "string" && opts.writeAirtable.length > 0 ? opts.writeAirtable : await resolveSlugFromCwd2(cwd);
1579
+ const lhResult = results.find((r) => r.audit === "lighthouse");
1580
+ if (!lhResult) {
1581
+ throw Object.assign(
1582
+ new Error(
1583
+ "--write-airtable requires a lighthouse result; did you pass --only without lighthouse?"
1584
+ ),
1585
+ { exitCode: 2 }
1586
+ );
1587
+ }
1588
+ if (lhResult.status === "fail") {
1589
+ throw Object.assign(
1590
+ new Error(
1591
+ `Lighthouse audit failed; refusing to write scores to Airtable. Summary: ${lhResult.summary}`
1592
+ ),
1593
+ { exitCode: 1 }
1594
+ );
1595
+ }
1596
+ const base = openBase2(readAirtableConfig2());
1597
+ const websites = await listWebsites2(base);
1598
+ const target = websites.find((w) => siteSlug2(w.name) === slug);
1599
+ if (!target) {
1600
+ throw Object.assign(new Error(`No Websites row matched slug "${slug}"`), { exitCode: 2 });
1601
+ }
1602
+ const scores = lighthouseScoresFromResult2(lhResult);
1603
+ await updateScores2(base, target.id, scores);
1604
+ output += `
1605
+
1606
+ \u2192 wrote scores to Websites[${target.name}]: P=${scores.performance} A=${scores.accessibility} BP=${scores.bestPractices} SEO=${scores.seo}`;
1386
1607
  }
1387
- const output = opts.json ? JSON.stringify(results, null, 2) : formatTable(results);
1388
1608
  return { output, code: exitCode(results) };
1389
1609
  }
1390
1610
 
1391
1611
  // src/cli/commands/sync-configs.ts
1392
- import { readFile as readFile7 } from "fs/promises";
1393
- import { join as join7, resolve as resolve3 } from "path";
1612
+ import { readFile as readFile8 } from "fs/promises";
1613
+ import { join as join8, resolve as resolve3 } from "path";
1394
1614
 
1395
1615
  // src/recipes/sync-configs.ts
1396
- import { readFile as readFile6, writeFile as writeFile3 } from "fs/promises";
1397
- import { join as join6 } from "path";
1616
+ import { readFile as readFile7, writeFile as writeFile3 } from "fs/promises";
1617
+ import { join as join7 } from "path";
1398
1618
 
1399
1619
  // src/recipes/sync-configs/templates.ts
1400
1620
  var eslint = {
@@ -1658,7 +1878,7 @@ function isConfigName(value) {
1658
1878
  }
1659
1879
  async function readMaybe(path) {
1660
1880
  try {
1661
- return await readFile6(path, "utf-8");
1881
+ return await readFile7(path, "utf-8");
1662
1882
  } catch {
1663
1883
  return null;
1664
1884
  }
@@ -1666,13 +1886,13 @@ async function readMaybe(path) {
1666
1886
  async function planTemplateDiffs(cwd, templates) {
1667
1887
  const diffs = [];
1668
1888
  for (const t of templates) {
1669
- const existing = await readMaybe(join6(cwd, t.path));
1889
+ const existing = await readMaybe(join7(cwd, t.path));
1670
1890
  if (existing !== t.contents) diffs.push(t);
1671
1891
  }
1672
1892
  return diffs;
1673
1893
  }
1674
1894
  async function planGitignore(cwd) {
1675
- const existing = await readMaybe(join6(cwd, ".gitignore"));
1895
+ const existing = await readMaybe(join7(cwd, ".gitignore"));
1676
1896
  const merge = mergeGitignore(existing, CANONICAL_GITIGNORE_ENTRIES);
1677
1897
  const tracked = await listTrackedFiles(cwd);
1678
1898
  const toUntrack = findTrackedArtifacts(tracked, CANONICAL_GITIGNORE_ENTRIES);
@@ -1680,7 +1900,7 @@ async function planGitignore(cwd) {
1680
1900
  return { kind: "apply", content: merge.content, toUntrack, added: merge.added };
1681
1901
  }
1682
1902
  async function applyGitignore(cwd, plan) {
1683
- await writeFile3(join6(cwd, ".gitignore"), plan.content, "utf-8");
1903
+ await writeFile3(join7(cwd, ".gitignore"), plan.content, "utf-8");
1684
1904
  if (plan.toUntrack.length > 0) {
1685
1905
  await removeFromIndex(cwd, plan.toUntrack);
1686
1906
  }
@@ -1703,7 +1923,7 @@ async function syncConfigs(site, opts = {}) {
1703
1923
  },
1704
1924
  apply: async ({ templateDiffs, gitignorePlan }, { commit: commit2 }) => {
1705
1925
  for (const t of templateDiffs) {
1706
- await writeFile3(join6(site.path, t.path), t.contents, "utf-8");
1926
+ await writeFile3(join7(site.path, t.path), t.contents, "utf-8");
1707
1927
  await commit2(`chore: sync ${t.config} config from @reddoorla/maintenance`);
1708
1928
  }
1709
1929
  if (gitignorePlan.kind === "apply") {
@@ -1732,7 +1952,7 @@ function parseOnly2(value) {
1732
1952
  async function dryPlanGitignore(cwd) {
1733
1953
  let existing;
1734
1954
  try {
1735
- existing = await readFile7(join7(cwd, ".gitignore"), "utf-8");
1955
+ existing = await readFile8(join8(cwd, ".gitignore"), "utf-8");
1736
1956
  } catch {
1737
1957
  return "would create .gitignore";
1738
1958
  }
@@ -1747,7 +1967,7 @@ async function dryPlan(cwd, which) {
1747
1967
  for (const t of templateTargets) {
1748
1968
  let existing = "";
1749
1969
  try {
1750
- existing = await readFile7(join7(cwd, t.path), "utf-8");
1970
+ existing = await readFile8(join8(cwd, t.path), "utf-8");
1751
1971
  } catch {
1752
1972
  }
1753
1973
  if (existing !== t.contents) lines.push(`would update ${t.path} (config: ${t.config})`);
@@ -1795,7 +2015,7 @@ import { resolve as resolve4 } from "path";
1795
2015
 
1796
2016
  // src/recipes/bump-deps.ts
1797
2017
  import { stat as stat2 } from "fs/promises";
1798
- import { join as join8 } from "path";
2018
+ import { join as join9 } from "path";
1799
2019
  async function exists(path) {
1800
2020
  try {
1801
2021
  await stat2(path);
@@ -1824,10 +2044,10 @@ async function bumpDeps(site, opts = {}) {
1824
2044
  // land on top of whatever else was in the tree.
1825
2045
  checkTreeFirst: true,
1826
2046
  plan: async () => {
1827
- const hasPnpmLock = await exists(join8(site.path, "pnpm-lock.yaml"));
2047
+ const hasPnpmLock = await exists(join9(site.path, "pnpm-lock.yaml"));
1828
2048
  if (!hasPnpmLock) {
1829
- const hasNpmLock = await exists(join8(site.path, "package-lock.json"));
1830
- const hasYarnLock = await exists(join8(site.path, "yarn.lock"));
2049
+ const hasNpmLock = await exists(join9(site.path, "package-lock.json"));
2050
+ const hasYarnLock = await exists(join9(site.path, "yarn.lock"));
1831
2051
  if (hasNpmLock || hasYarnLock) {
1832
2052
  const competing = hasNpmLock ? "package-lock.json" : "yarn.lock";
1833
2053
  return {
@@ -1897,12 +2117,12 @@ async function runBumpDepsCommand(site, opts) {
1897
2117
  import { resolve as resolve5 } from "path";
1898
2118
 
1899
2119
  // src/recipes/svelte-5/index.ts
1900
- import { join as join14 } from "path";
2120
+ import { join as join15 } from "path";
1901
2121
 
1902
2122
  // src/util/pkg.ts
1903
- import { readFile as readFile8, writeFile as writeFile4 } from "fs/promises";
2123
+ import { readFile as readFile9, writeFile as writeFile4 } from "fs/promises";
1904
2124
  async function readPackageJson(path) {
1905
- const raw = await readFile8(path, "utf-8");
2125
+ const raw = await readFile9(path, "utf-8");
1906
2126
  return JSON.parse(raw);
1907
2127
  }
1908
2128
  function detectIndentFromContent(raw) {
@@ -1912,7 +2132,7 @@ function detectIndentFromContent(raw) {
1912
2132
  async function writePackageJson(path, pkg) {
1913
2133
  let indent = " ";
1914
2134
  try {
1915
- const existing = await readFile8(path, "utf-8");
2135
+ const existing = await readFile9(path, "utf-8");
1916
2136
  indent = detectIndentFromContent(existing);
1917
2137
  } catch {
1918
2138
  }
@@ -1946,7 +2166,7 @@ function bumpDep(pkg, name, version2, opts = {}) {
1946
2166
  }
1947
2167
 
1948
2168
  // src/recipes/svelte-5/step-bump-versions.ts
1949
- import { join as join9 } from "path";
2169
+ import { join as join10 } from "path";
1950
2170
  var SVELTE_5_VERSIONS = {
1951
2171
  svelte: "^5.55.5",
1952
2172
  "@sveltejs/kit": "^2.59.0",
@@ -1959,7 +2179,7 @@ var SVELTE_5_VERSIONS = {
1959
2179
  "typescript-svelte-plugin": "^0.3.52"
1960
2180
  };
1961
2181
  async function bumpToSvelte5Versions(cwd) {
1962
- const pkgPath = join9(cwd, "package.json");
2182
+ const pkgPath = join10(cwd, "package.json");
1963
2183
  const pkg = await readPackageJson(pkgPath);
1964
2184
  let next = pkg;
1965
2185
  for (const [name, version2] of Object.entries(SVELTE_5_VERSIONS)) {
@@ -1971,8 +2191,8 @@ async function bumpToSvelte5Versions(cwd) {
1971
2191
  }
1972
2192
 
1973
2193
  // 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";
2194
+ import { readFile as readFile10, writeFile as writeFile5 } from "fs/promises";
2195
+ import { join as join11 } from "path";
1976
2196
  var VITE_PLUGIN_PKG = "@sveltejs/vite-plugin-svelte";
1977
2197
  var IMPORT_FROM_VITE_PLUGIN = new RegExp(
1978
2198
  String.raw`^import\s+\{\s*([^}]+?)\s*\}\s+from\s+["']` + VITE_PLUGIN_PKG.replace(/[/]/g, "\\/") + String.raw`["'];?[ \t]*\n`,
@@ -2013,10 +2233,10 @@ function dropPreprocessKey(source) {
2013
2233
  return source.slice(0, m.index) + source.slice(tailIdx).replace(new RegExp(`^${indent}\\n`), "");
2014
2234
  }
2015
2235
  async function migrateSvelteConfig(cwd) {
2016
- const path = join10(cwd, "svelte.config.js");
2236
+ const path = join11(cwd, "svelte.config.js");
2017
2237
  let src;
2018
2238
  try {
2019
- src = await readFile9(path, "utf-8");
2239
+ src = await readFile10(path, "utf-8");
2020
2240
  } catch {
2021
2241
  return false;
2022
2242
  }
@@ -2050,9 +2270,9 @@ async function runSvelteMigrate(cwd, spawn2 = defaultSpawn) {
2050
2270
  }
2051
2271
 
2052
2272
  // src/recipes/svelte-5/step-tailwind-upgrade.ts
2053
- import { join as join11 } from "path";
2273
+ import { join as join12 } from "path";
2054
2274
  async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
2055
- const pkg = await readPackageJson(join11(cwd, "package.json"));
2275
+ const pkg = await readPackageJson(join12(cwd, "package.json"));
2056
2276
  const tailwindVersion = pkg.devDependencies?.tailwindcss ?? pkg.dependencies?.tailwindcss;
2057
2277
  if (!tailwindVersion) return { ran: false, reason: "tailwindcss not installed" };
2058
2278
  if (/^\^?4\./.test(tailwindVersion)) return { ran: false, reason: "already on tailwind 4.x" };
@@ -2073,8 +2293,8 @@ async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
2073
2293
  }
2074
2294
 
2075
2295
  // 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";
2296
+ import { readFile as readFile11, writeFile as writeFile6 } from "fs/promises";
2297
+ import { join as join13 } from "path";
2078
2298
  import { glob as glob2 } from "tinyglobby";
2079
2299
 
2080
2300
  // src/recipes/svelte-5/codemods/on-event-to-handler.ts
@@ -2406,8 +2626,8 @@ async function planGotchaCodemods(cwd) {
2406
2626
  const changes = [];
2407
2627
  const relPaths = await glob2(SVELTE_GLOBS, { cwd, ignore: IGNORE2, absolute: false });
2408
2628
  for (const rel of relPaths) {
2409
- const path = join12(cwd, rel);
2410
- const before = await readFile10(path, "utf-8");
2629
+ const path = join13(cwd, rel);
2630
+ const before = await readFile11(path, "utf-8");
2411
2631
  const after = CODEMODS.reduce((s, fn) => fn(s), before);
2412
2632
  if (after !== before) changes.push({ rel, after });
2413
2633
  }
@@ -2416,7 +2636,7 @@ async function planGotchaCodemods(cwd) {
2416
2636
  async function applyGotchaCodemods(cwd) {
2417
2637
  const changes = await planGotchaCodemods(cwd);
2418
2638
  for (const c of changes) {
2419
- await writeFile6(join12(cwd, c.rel), c.after, "utf-8");
2639
+ await writeFile6(join13(cwd, c.rel), c.after, "utf-8");
2420
2640
  }
2421
2641
  return { filesChanged: changes.length };
2422
2642
  }
@@ -2440,7 +2660,7 @@ async function verifyMigration(cwd, spawn2 = defaultSpawn) {
2440
2660
 
2441
2661
  // src/recipes/svelte-5/step-summary.ts
2442
2662
  import { writeFile as writeFile7 } from "fs/promises";
2443
- import { join as join13 } from "path";
2663
+ import { join as join14 } from "path";
2444
2664
  async function writeMigrationSummary(input) {
2445
2665
  const lines = [
2446
2666
  `# Svelte 4 \u2192 5 migration summary`,
@@ -2457,7 +2677,7 @@ async function writeMigrationSummary(input) {
2457
2677
  `- Verify Playwright a11y tests still pass.`
2458
2678
  ];
2459
2679
  const content = lines.join("\n") + "\n";
2460
- const path = join13(input.cwd, "MIGRATION_SVELTE_5.md");
2680
+ const path = join14(input.cwd, "MIGRATION_SVELTE_5.md");
2461
2681
  await writeFile7(path, content, "utf-8");
2462
2682
  return path;
2463
2683
  }
@@ -2465,7 +2685,7 @@ async function writeMigrationSummary(input) {
2465
2685
  // src/recipes/svelte-5/index.ts
2466
2686
  async function alreadyOnSvelte5(cwd) {
2467
2687
  try {
2468
- const pkg = await readPackageJson(join14(cwd, "package.json"));
2688
+ const pkg = await readPackageJson(join15(cwd, "package.json"));
2469
2689
  const v = pkg.devDependencies?.svelte ?? pkg.dependencies?.svelte;
2470
2690
  return !!v && /^\^?5\./.test(v);
2471
2691
  } catch {
@@ -2560,7 +2780,7 @@ import { resolve as resolve6 } from "path";
2560
2780
 
2561
2781
  // src/recipes/convert-to-pnpm.ts
2562
2782
  import { rm as rm3, stat as stat3 } from "fs/promises";
2563
- import { join as join15 } from "path";
2783
+ import { join as join16 } from "path";
2564
2784
 
2565
2785
  // src/recipes/convert-to-pnpm/script-rewrites.ts
2566
2786
  function rewriteScriptForPnpm(script) {
@@ -2593,9 +2813,9 @@ async function exists2(path) {
2593
2813
  async function convertToPnpm(site, opts = {}) {
2594
2814
  const spawn2 = opts.spawn ?? defaultSpawn;
2595
2815
  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");
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");
2599
2819
  return withRecipe({
2600
2820
  name: "convert-to-pnpm",
2601
2821
  site,
@@ -2618,7 +2838,7 @@ async function convertToPnpm(site, opts = {}) {
2618
2838
  if (hasYarnLock) await rm3(yarnLockPath, { force: true });
2619
2839
  const sourceLock = hasNpmLock ? "package-lock.json" : "yarn.lock";
2620
2840
  await commit2(`chore(pnpm): remove ${sourceLock}`);
2621
- const pkgPath = join15(cwd, "package.json");
2841
+ const pkgPath = join16(cwd, "package.json");
2622
2842
  const pkg = await readPackageJson(pkgPath);
2623
2843
  const next = { ...pkg, packageManager: `pnpm@${pnpmVersion}` };
2624
2844
  if (pkg.scripts && typeof pkg.scripts === "object") {
@@ -2631,7 +2851,7 @@ async function convertToPnpm(site, opts = {}) {
2631
2851
  }
2632
2852
  await writePackageJson(pkgPath, next);
2633
2853
  await commit2("chore(pnpm): pin packageManager + rewrite npm scripts");
2634
- await rm3(join15(cwd, "node_modules"), { recursive: true, force: true });
2854
+ await rm3(join16(cwd, "node_modules"), { recursive: true, force: true });
2635
2855
  const installResult = await spawn2("pnpm", ["install"], { cwd, streaming: true });
2636
2856
  if (installResult.code !== 0) {
2637
2857
  return { kind: "failed", notes: `pnpm install failed (exit ${installResult.code})` };
@@ -2672,16 +2892,16 @@ import { resolve as resolve7 } from "path";
2672
2892
 
2673
2893
  // src/recipes/onboard.ts
2674
2894
  import { stat as stat4 } from "fs/promises";
2675
- import { join as join17 } from "path";
2895
+ import { join as join18 } from "path";
2676
2896
 
2677
2897
  // src/util/self-version.ts
2678
2898
  import { readFileSync } from "fs";
2679
2899
  import { fileURLToPath } from "url";
2680
- import { dirname, join as join16 } from "path";
2900
+ import { dirname, join as join17 } from "path";
2681
2901
  function selfPackageVersion(callerImportMetaUrl) {
2682
2902
  try {
2683
- const here2 = dirname(fileURLToPath(callerImportMetaUrl));
2684
- const raw = readFileSync(join16(here2, "..", "..", "package.json"), "utf-8");
2903
+ const here3 = dirname(fileURLToPath(callerImportMetaUrl));
2904
+ const raw = readFileSync(join17(here3, "..", "..", "package.json"), "utf-8");
2685
2905
  const pkg = JSON.parse(raw);
2686
2906
  return pkg.version ?? "0.0.0";
2687
2907
  } catch {
@@ -2731,13 +2951,13 @@ async function onboard(site, opts = {}) {
2731
2951
  name: "onboard",
2732
2952
  site,
2733
2953
  plan: async () => {
2734
- if (!await exists3(join17(site.path, "pnpm-lock.yaml"))) {
2954
+ if (!await exists3(join18(site.path, "pnpm-lock.yaml"))) {
2735
2955
  return {
2736
2956
  kind: "failed",
2737
2957
  notes: "no pnpm-lock.yaml at site root \u2014 run convert-to-pnpm first"
2738
2958
  };
2739
2959
  }
2740
- const pkgPath = join17(site.path, "package.json");
2960
+ const pkgPath = join18(site.path, "package.json");
2741
2961
  const pkg = await readPackageJson(pkgPath);
2742
2962
  const toAdd = [];
2743
2963
  if (!isDeclared(pkg, PACKAGE_NAME)) {
@@ -2757,7 +2977,7 @@ async function onboard(site, opts = {}) {
2757
2977
  return { kind: "apply", plan: { pkg, toAdd } };
2758
2978
  },
2759
2979
  apply: async ({ pkg, toAdd }, { commit: commit2, cwd }) => {
2760
- const pkgPath = join17(cwd, "package.json");
2980
+ const pkgPath = join18(cwd, "package.json");
2761
2981
  let next = pkg;
2762
2982
  for (const dep of toAdd) {
2763
2983
  next = bumpDep(next, dep.name, dep.version);
@@ -2826,7 +3046,7 @@ import { resolve as resolve8 } from "path";
2826
3046
 
2827
3047
  // src/recipes/svelte-codemods.ts
2828
3048
  import { writeFile as writeFile8 } from "fs/promises";
2829
- import { join as join18 } from "path";
3049
+ import { join as join19 } from "path";
2830
3050
  async function svelteCodemods(site) {
2831
3051
  return withRecipe({
2832
3052
  name: "svelte-codemods",
@@ -2840,7 +3060,7 @@ async function svelteCodemods(site) {
2840
3060
  },
2841
3061
  apply: async (changes, { commit: commit2, cwd }) => {
2842
3062
  for (const c of changes) {
2843
- await writeFile8(join18(cwd, c.rel), c.after, "utf-8");
3063
+ await writeFile8(join19(cwd, c.rel), c.after, "utf-8");
2844
3064
  }
2845
3065
  await commit2(`refactor(svelte5): apply codemods (${changes.length} files)`);
2846
3066
  return { kind: "ok" };
@@ -2879,6 +3099,12 @@ init_websites();
2879
3099
  init_reports();
2880
3100
 
2881
3101
  // src/reports/due.ts
3102
+ var ELIGIBLE_STATUSES = /* @__PURE__ */ new Set([
3103
+ "in development",
3104
+ "launch period",
3105
+ "maintenance",
3106
+ "hosting"
3107
+ ]);
2882
3108
  var MONTHS = {
2883
3109
  Monthly: 1,
2884
3110
  Quarterly: 3,
@@ -2908,6 +3134,7 @@ function findDueReports(websites, reports, today) {
2908
3134
  const out = [];
2909
3135
  const todayStart = startOfDay(today);
2910
3136
  for (const site of websites) {
3137
+ if (site.status !== null && !ELIGIBLE_STATUSES.has(site.status)) continue;
2911
3138
  for (const type of ["Maintenance", "Testing"]) {
2912
3139
  const freq = type === "Maintenance" ? site.maintenanceFreq : site.testingFreq;
2913
3140
  if (freq === "None") continue;
@@ -2931,8 +3158,9 @@ function findDueReports(websites, reports, today) {
2931
3158
  init_render();
2932
3159
  init_websites();
2933
3160
  init_reports();
3161
+ init_attachments();
2934
3162
  import { mkdir as mkdir2, writeFile as writeFile9 } from "fs/promises";
2935
- import { dirname as dirname2 } from "path";
3163
+ import { dirname as dirname3 } from "path";
2936
3164
  function scoresFromWebsite(siteRow) {
2937
3165
  const { pScore, rScore, bpScore, seoScore } = siteRow;
2938
3166
  if (pScore === null || rScore === null || bpScore === null || seoScore === null) {
@@ -2970,7 +3198,7 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
2970
3198
  });
2971
3199
  if (options.previewOnly) {
2972
3200
  const path = options.previewPath ?? `reports/${slug}/draft.html`;
2973
- await mkdir2(dirname2(path), { recursive: true });
3201
+ await mkdir2(dirname3(path), { recursive: true });
2974
3202
  await writeFile9(path, html, "utf-8");
2975
3203
  return { reportRow: null, htmlPath: path, html };
2976
3204
  }
@@ -2986,7 +3214,8 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
2986
3214
  lighthouse: scores,
2987
3215
  lastTestedDate
2988
3216
  });
2989
- await uploadHtmlAttachment(created.id, html, slug, periodEnd);
3217
+ const htmlFilename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;
3218
+ await uploadAttachment(created.id, "Rendered HTML", html, htmlFilename, "text/html");
2990
3219
  await setDraftReady(base, created.id, true);
2991
3220
  return { reportRow: created, htmlPath: null, html };
2992
3221
  }
@@ -2996,28 +3225,6 @@ async function derivePeriodStart(base, siteRow, reportType, today) {
2996
3225
  const latest = sameType[sameType.length - 1];
2997
3226
  return latest ? new Date(latest) : daysAgo(today, 30);
2998
3227
  }
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
3228
 
3022
3229
  // src/cli/commands/report.ts
3023
3230
  async function runReportCommand(slug, opts) {
@@ -3077,10 +3284,10 @@ async function runSingleSiteDraft(slug, opts) {
3077
3284
 
3078
3285
  // src/cli/version.ts
3079
3286
  import { readFileSync as readFileSync2 } from "fs";
3080
- import { join as join19 } from "path";
3287
+ import { join as join21 } from "path";
3081
3288
  function resolvePackageVersion(fromDir) {
3082
3289
  try {
3083
- const raw = readFileSync2(join19(fromDir, "..", "..", "package.json"), "utf-8");
3290
+ const raw = readFileSync2(join21(fromDir, "..", "..", "package.json"), "utf-8");
3084
3291
  const pkg = JSON.parse(raw);
3085
3292
  return pkg.version ?? "unknown";
3086
3293
  } catch {
@@ -3089,8 +3296,8 @@ function resolvePackageVersion(fromDir) {
3089
3296
  }
3090
3297
 
3091
3298
  // src/cli/bin.ts
3092
- var here = dirname3(fileURLToPath2(import.meta.url));
3093
- var version = resolvePackageVersion(here);
3299
+ var here2 = dirname4(fileURLToPath3(import.meta.url));
3300
+ var version = resolvePackageVersion(here2);
3094
3301
  var AUDIT_DESCRIPTIONS = {
3095
3302
  deps: "Diff site package.json against the bundled baseline version map.",
3096
3303
  lighthouse: "Run @lhci/cli autorun using the canonical lighthouserc.",
@@ -3130,31 +3337,55 @@ cli.command("list-recipes", "Print the available recipes.").action(() => {
3130
3337
  console.log(`${name.padEnd(16)} ${desc}`);
3131
3338
  }
3132
3339
  });
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(
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(
3134
3347
  async (site, opts) => runOrExit(() => runAuditCommand(site, opts), opts)
3135
3348
  );
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(
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(
3137
3353
  async (site, opts) => runOrExit(() => runSyncConfigsCommand(site, opts), opts)
3138
3354
  );
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(
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(
3140
3359
  async (site, opts) => runOrExit(() => runBumpDepsCommand(site, opts), opts)
3141
3360
  );
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(
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(
3143
3365
  async (upgrade, site, opts) => runOrExit(() => runUpgradeCommand(upgrade, site, opts), opts)
3144
3366
  );
3145
3367
  cli.command(
3146
3368
  "convert-to-pnpm [site]",
3147
3369
  "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(
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(
3149
3374
  async (site, opts) => runOrExit(() => runConvertToPnpmCommand(site, opts), opts)
3150
3375
  );
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(
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(
3152
3380
  async (site, opts) => runOrExit(() => runSvelteCodemodsCommand(site, opts), opts)
3153
3381
  );
3154
3382
  cli.command(
3155
3383
  "onboard [site]",
3156
3384
  "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(
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(
3158
3389
  async (site, opts) => runOrExit(() => runOnboardCommand(site, opts), opts)
3159
3390
  );
3160
3391
  cli.command("report [site]", "Draft or send maintenance/testing reports.").option("--due", "Scan all Websites and draft overdue reports.").option(