@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/index.js CHANGED
@@ -335,9 +335,9 @@ async function securityAudit(ctx) {
335
335
  }
336
336
 
337
337
  // src/audits/lighthouse.ts
338
- import { readFile as readFile3, writeFile, mkdtemp, rm } from "fs/promises";
338
+ import { readFile as readFile4, writeFile, mkdtemp, rm } from "fs/promises";
339
339
  import { tmpdir } from "os";
340
- import { join as join3 } from "path";
340
+ import { join as join4 } from "path";
341
341
 
342
342
  // src/configs/lighthouse.ts
343
343
  var lighthouseConfig = {
@@ -370,10 +370,37 @@ var lighthouseConfig = {
370
370
  }
371
371
  };
372
372
 
373
+ // src/audits/util/site-config.ts
374
+ import { readFile as readFile3 } from "fs/promises";
375
+ import { join as join3 } from "path";
376
+ async function readSiteConfig(sitePath) {
377
+ let raw;
378
+ try {
379
+ raw = await readFile3(join3(sitePath, "package.json"), "utf-8");
380
+ } catch {
381
+ return {};
382
+ }
383
+ let pkg;
384
+ try {
385
+ pkg = JSON.parse(raw);
386
+ } catch {
387
+ return {};
388
+ }
389
+ if (!pkg || typeof pkg !== "object") return {};
390
+ const cfg = pkg.reddoor;
391
+ if (!cfg || typeof cfg !== "object") return {};
392
+ const out = {};
393
+ const url = cfg.lighthouseUrl;
394
+ if (typeof url === "string" && url.length > 0) {
395
+ out.lighthouseUrl = url;
396
+ }
397
+ return out;
398
+ }
399
+
373
400
  // src/audits/lighthouse.ts
374
401
  async function readJsonMaybe(path) {
375
402
  try {
376
- const raw = await readFile3(path, "utf-8");
403
+ const raw = await readFile4(path, "utf-8");
377
404
  return JSON.parse(raw);
378
405
  } catch {
379
406
  return null;
@@ -409,15 +436,28 @@ async function lighthouseAudit(ctx) {
409
436
  const spawn2 = ctx.spawn ?? defaultSpawn;
410
437
  const site = ctx.site;
411
438
  const label = siteLabel(site);
412
- const configDir = await mkdtemp(join3(tmpdir(), "reddoor-lhci-"));
413
- const configPath = join3(configDir, "lighthouserc.json");
414
- await writeFile(configPath, JSON.stringify(lighthouseConfig), "utf-8");
415
- const resultsDir = join3(site.path, ".lighthouseci");
439
+ const siteCfg = await readSiteConfig(site.path);
440
+ const resolvedConfig = siteCfg.lighthouseUrl ? {
441
+ ...lighthouseConfig,
442
+ ci: {
443
+ ...lighthouseConfig.ci,
444
+ collect: { ...lighthouseConfig.ci.collect, url: [siteCfg.lighthouseUrl] }
445
+ }
446
+ } : lighthouseConfig;
447
+ const configDir = await mkdtemp(join4(tmpdir(), "reddoor-lhci-"));
448
+ const configPath = join4(configDir, "lighthouserc.json");
449
+ await writeFile(configPath, JSON.stringify(resolvedConfig), "utf-8");
450
+ const resultsDir = join4(site.path, ".lighthouseci");
416
451
  await rm(resultsDir, { recursive: true, force: true });
417
452
  let raw;
418
453
  try {
419
454
  raw = await spawn2("npx", ["--yes", "@lhci/cli", "autorun", `--config=${configPath}`], {
420
- cwd: site.path
455
+ cwd: site.path,
456
+ // lhci autorun boots the site's dev server, downloads Chrome on first
457
+ // use, and runs the audit — easily 2–3 min on a cold tree. The shared
458
+ // 30 s default in runAudits is fine for deps/lint/security but starves
459
+ // lhci.
460
+ timeoutMs: 5 * 6e4
421
461
  });
422
462
  } catch (err) {
423
463
  await rm(configDir, { recursive: true, force: true });
@@ -433,7 +473,7 @@ async function lighthouseAudit(ctx) {
433
473
  throw err;
434
474
  }
435
475
  await rm(configDir, { recursive: true, force: true });
436
- const manifest = await readJsonMaybe(join3(resultsDir, "manifest.json"));
476
+ const manifest = await readJsonMaybe(join4(resultsDir, "manifest.json"));
437
477
  if (!manifest || manifest.length === 0) {
438
478
  return {
439
479
  audit: "lighthouse",
@@ -442,7 +482,7 @@ async function lighthouseAudit(ctx) {
442
482
  summary: `lighthouse: no manifest written (exit ${raw.code})${raw.stderr ? ` \u2014 ${raw.stderr.slice(0, 200)}` : ""}`
443
483
  };
444
484
  }
445
- const assertionResults = await readJsonMaybe(join3(resultsDir, "assertion-results.json")) ?? [];
485
+ const assertionResults = await readJsonMaybe(join4(resultsDir, "assertion-results.json")) ?? [];
446
486
  const failed = assertionResults.filter((a) => !a.passed);
447
487
  const assertions = failed.map((a) => ({
448
488
  category: categoryFromAssertion(a),
@@ -468,9 +508,9 @@ async function lighthouseAudit(ctx) {
468
508
  }
469
509
 
470
510
  // src/audits/a11y.ts
471
- import { readFile as readFile4, writeFile as writeFile2, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
511
+ import { readFile as readFile5, writeFile as writeFile2, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
472
512
  import { tmpdir as tmpdir2 } from "os";
473
- import { join as join4 } from "path";
513
+ import { join as join5 } from "path";
474
514
 
475
515
  // src/configs/playwright-a11y.ts
476
516
  import { defineConfig, devices } from "@playwright/test";
@@ -508,7 +548,7 @@ var playwrightA11yConfig = defineConfig({
508
548
  var RESULTS_REL = ".reddoor-a11y/results.json";
509
549
  async function readJsonMaybe2(path) {
510
550
  try {
511
- const raw = await readFile4(path, "utf-8");
551
+ const raw = await readFile5(path, "utf-8");
512
552
  return JSON.parse(raw);
513
553
  } catch {
514
554
  return null;
@@ -564,11 +604,11 @@ async function a11yAudit(ctx) {
564
604
  const spawn2 = ctx.spawn ?? defaultSpawn;
565
605
  const site = ctx.site;
566
606
  const label = siteLabel(site);
567
- const specDir = await mkdtemp2(join4(tmpdir2(), "reddoor-a11y-spec-"));
568
- const specPath = join4(specDir, "a11y.spec.ts");
607
+ const specDir = await mkdtemp2(join5(tmpdir2(), "reddoor-a11y-spec-"));
608
+ const specPath = join5(specDir, "a11y.spec.ts");
569
609
  await writeFile2(specPath, buildSpec(), "utf-8");
570
- const resultsPath = join4(site.path, RESULTS_REL);
571
- await rm2(join4(site.path, ".reddoor-a11y"), { recursive: true, force: true });
610
+ const resultsPath = join5(site.path, RESULTS_REL);
611
+ await rm2(join5(site.path, ".reddoor-a11y"), { recursive: true, force: true });
572
612
  let raw;
573
613
  try {
574
614
  raw = await spawn2("npx", ["--yes", "playwright", "test", "--reporter=line", specPath], {
@@ -650,8 +690,8 @@ async function runAuditsAcross(sites, which) {
650
690
  }
651
691
 
652
692
  // src/recipes/sync-configs.ts
653
- import { readFile as readFile5, writeFile as writeFile3 } from "fs/promises";
654
- import { join as join5 } from "path";
693
+ import { readFile as readFile6, writeFile as writeFile3 } from "fs/promises";
694
+ import { join as join6 } from "path";
655
695
 
656
696
  // src/recipes/sync-configs/templates.ts
657
697
  var eslint = {
@@ -904,7 +944,7 @@ async function withRecipe(body) {
904
944
  var GITIGNORE_CONFIG = "gitignore";
905
945
  async function readMaybe(path) {
906
946
  try {
907
- return await readFile5(path, "utf-8");
947
+ return await readFile6(path, "utf-8");
908
948
  } catch {
909
949
  return null;
910
950
  }
@@ -912,13 +952,13 @@ async function readMaybe(path) {
912
952
  async function planTemplateDiffs(cwd, templates) {
913
953
  const diffs = [];
914
954
  for (const t of templates) {
915
- const existing = await readMaybe(join5(cwd, t.path));
955
+ const existing = await readMaybe(join6(cwd, t.path));
916
956
  if (existing !== t.contents) diffs.push(t);
917
957
  }
918
958
  return diffs;
919
959
  }
920
960
  async function planGitignore(cwd) {
921
- const existing = await readMaybe(join5(cwd, ".gitignore"));
961
+ const existing = await readMaybe(join6(cwd, ".gitignore"));
922
962
  const merge = mergeGitignore(existing, CANONICAL_GITIGNORE_ENTRIES);
923
963
  const tracked = await listTrackedFiles(cwd);
924
964
  const toUntrack = findTrackedArtifacts(tracked, CANONICAL_GITIGNORE_ENTRIES);
@@ -926,7 +966,7 @@ async function planGitignore(cwd) {
926
966
  return { kind: "apply", content: merge.content, toUntrack, added: merge.added };
927
967
  }
928
968
  async function applyGitignore(cwd, plan) {
929
- await writeFile3(join5(cwd, ".gitignore"), plan.content, "utf-8");
969
+ await writeFile3(join6(cwd, ".gitignore"), plan.content, "utf-8");
930
970
  if (plan.toUntrack.length > 0) {
931
971
  await removeFromIndex(cwd, plan.toUntrack);
932
972
  }
@@ -949,7 +989,7 @@ async function syncConfigs(site, opts = {}) {
949
989
  },
950
990
  apply: async ({ templateDiffs, gitignorePlan }, { commit: commit2 }) => {
951
991
  for (const t of templateDiffs) {
952
- await writeFile3(join5(site.path, t.path), t.contents, "utf-8");
992
+ await writeFile3(join6(site.path, t.path), t.contents, "utf-8");
953
993
  await commit2(`chore: sync ${t.config} config from @reddoorla/maintenance`);
954
994
  }
955
995
  if (gitignorePlan.kind === "apply") {
@@ -963,7 +1003,7 @@ async function syncConfigs(site, opts = {}) {
963
1003
 
964
1004
  // src/recipes/bump-deps.ts
965
1005
  import { stat } from "fs/promises";
966
- import { join as join6 } from "path";
1006
+ import { join as join7 } from "path";
967
1007
  async function exists(path) {
968
1008
  try {
969
1009
  await stat(path);
@@ -992,10 +1032,10 @@ async function bumpDeps(site, opts = {}) {
992
1032
  // land on top of whatever else was in the tree.
993
1033
  checkTreeFirst: true,
994
1034
  plan: async () => {
995
- const hasPnpmLock = await exists(join6(site.path, "pnpm-lock.yaml"));
1035
+ const hasPnpmLock = await exists(join7(site.path, "pnpm-lock.yaml"));
996
1036
  if (!hasPnpmLock) {
997
- const hasNpmLock = await exists(join6(site.path, "package-lock.json"));
998
- const hasYarnLock = await exists(join6(site.path, "yarn.lock"));
1037
+ const hasNpmLock = await exists(join7(site.path, "package-lock.json"));
1038
+ const hasYarnLock = await exists(join7(site.path, "yarn.lock"));
999
1039
  if (hasNpmLock || hasYarnLock) {
1000
1040
  const competing = hasNpmLock ? "package-lock.json" : "yarn.lock";
1001
1041
  return {
@@ -1030,12 +1070,12 @@ async function bumpDeps(site, opts = {}) {
1030
1070
  }
1031
1071
 
1032
1072
  // src/recipes/svelte-5/index.ts
1033
- import { join as join12 } from "path";
1073
+ import { join as join13 } from "path";
1034
1074
 
1035
1075
  // src/util/pkg.ts
1036
- import { readFile as readFile6, writeFile as writeFile4 } from "fs/promises";
1076
+ import { readFile as readFile7, writeFile as writeFile4 } from "fs/promises";
1037
1077
  async function readPackageJson(path) {
1038
- const raw = await readFile6(path, "utf-8");
1078
+ const raw = await readFile7(path, "utf-8");
1039
1079
  return JSON.parse(raw);
1040
1080
  }
1041
1081
  function detectIndentFromContent(raw) {
@@ -1045,7 +1085,7 @@ function detectIndentFromContent(raw) {
1045
1085
  async function writePackageJson(path, pkg) {
1046
1086
  let indent = " ";
1047
1087
  try {
1048
- const existing = await readFile6(path, "utf-8");
1088
+ const existing = await readFile7(path, "utf-8");
1049
1089
  indent = detectIndentFromContent(existing);
1050
1090
  } catch {
1051
1091
  }
@@ -1079,7 +1119,7 @@ function bumpDep(pkg, name, version, opts = {}) {
1079
1119
  }
1080
1120
 
1081
1121
  // src/recipes/svelte-5/step-bump-versions.ts
1082
- import { join as join7 } from "path";
1122
+ import { join as join8 } from "path";
1083
1123
  var SVELTE_5_VERSIONS = {
1084
1124
  svelte: "^5.55.5",
1085
1125
  "@sveltejs/kit": "^2.59.0",
@@ -1092,7 +1132,7 @@ var SVELTE_5_VERSIONS = {
1092
1132
  "typescript-svelte-plugin": "^0.3.52"
1093
1133
  };
1094
1134
  async function bumpToSvelte5Versions(cwd) {
1095
- const pkgPath = join7(cwd, "package.json");
1135
+ const pkgPath = join8(cwd, "package.json");
1096
1136
  const pkg = await readPackageJson(pkgPath);
1097
1137
  let next = pkg;
1098
1138
  for (const [name, version] of Object.entries(SVELTE_5_VERSIONS)) {
@@ -1104,8 +1144,8 @@ async function bumpToSvelte5Versions(cwd) {
1104
1144
  }
1105
1145
 
1106
1146
  // src/recipes/svelte-5/step-svelte-config.ts
1107
- import { readFile as readFile7, writeFile as writeFile5 } from "fs/promises";
1108
- import { join as join8 } from "path";
1147
+ import { readFile as readFile8, writeFile as writeFile5 } from "fs/promises";
1148
+ import { join as join9 } from "path";
1109
1149
  var VITE_PLUGIN_PKG = "@sveltejs/vite-plugin-svelte";
1110
1150
  var IMPORT_FROM_VITE_PLUGIN = new RegExp(
1111
1151
  String.raw`^import\s+\{\s*([^}]+?)\s*\}\s+from\s+["']` + VITE_PLUGIN_PKG.replace(/[/]/g, "\\/") + String.raw`["'];?[ \t]*\n`,
@@ -1146,10 +1186,10 @@ function dropPreprocessKey(source) {
1146
1186
  return source.slice(0, m.index) + source.slice(tailIdx).replace(new RegExp(`^${indent}\\n`), "");
1147
1187
  }
1148
1188
  async function migrateSvelteConfig(cwd) {
1149
- const path = join8(cwd, "svelte.config.js");
1189
+ const path = join9(cwd, "svelte.config.js");
1150
1190
  let src;
1151
1191
  try {
1152
- src = await readFile7(path, "utf-8");
1192
+ src = await readFile8(path, "utf-8");
1153
1193
  } catch {
1154
1194
  return false;
1155
1195
  }
@@ -1183,9 +1223,9 @@ async function runSvelteMigrate(cwd, spawn2 = defaultSpawn) {
1183
1223
  }
1184
1224
 
1185
1225
  // src/recipes/svelte-5/step-tailwind-upgrade.ts
1186
- import { join as join9 } from "path";
1226
+ import { join as join10 } from "path";
1187
1227
  async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
1188
- const pkg = await readPackageJson(join9(cwd, "package.json"));
1228
+ const pkg = await readPackageJson(join10(cwd, "package.json"));
1189
1229
  const tailwindVersion = pkg.devDependencies?.tailwindcss ?? pkg.dependencies?.tailwindcss;
1190
1230
  if (!tailwindVersion) return { ran: false, reason: "tailwindcss not installed" };
1191
1231
  if (/^\^?4\./.test(tailwindVersion)) return { ran: false, reason: "already on tailwind 4.x" };
@@ -1206,8 +1246,8 @@ async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
1206
1246
  }
1207
1247
 
1208
1248
  // src/recipes/svelte-5/step-gotchas.ts
1209
- import { readFile as readFile8, writeFile as writeFile6 } from "fs/promises";
1210
- import { join as join10 } from "path";
1249
+ import { readFile as readFile9, writeFile as writeFile6 } from "fs/promises";
1250
+ import { join as join11 } from "path";
1211
1251
  import { glob as glob2 } from "tinyglobby";
1212
1252
 
1213
1253
  // src/recipes/svelte-5/codemods/on-event-to-handler.ts
@@ -1539,8 +1579,8 @@ async function planGotchaCodemods(cwd) {
1539
1579
  const changes = [];
1540
1580
  const relPaths = await glob2(SVELTE_GLOBS, { cwd, ignore: IGNORE2, absolute: false });
1541
1581
  for (const rel of relPaths) {
1542
- const path = join10(cwd, rel);
1543
- const before = await readFile8(path, "utf-8");
1582
+ const path = join11(cwd, rel);
1583
+ const before = await readFile9(path, "utf-8");
1544
1584
  const after = CODEMODS.reduce((s, fn) => fn(s), before);
1545
1585
  if (after !== before) changes.push({ rel, after });
1546
1586
  }
@@ -1549,7 +1589,7 @@ async function planGotchaCodemods(cwd) {
1549
1589
  async function applyGotchaCodemods(cwd) {
1550
1590
  const changes = await planGotchaCodemods(cwd);
1551
1591
  for (const c of changes) {
1552
- await writeFile6(join10(cwd, c.rel), c.after, "utf-8");
1592
+ await writeFile6(join11(cwd, c.rel), c.after, "utf-8");
1553
1593
  }
1554
1594
  return { filesChanged: changes.length };
1555
1595
  }
@@ -1573,7 +1613,7 @@ async function verifyMigration(cwd, spawn2 = defaultSpawn) {
1573
1613
 
1574
1614
  // src/recipes/svelte-5/step-summary.ts
1575
1615
  import { writeFile as writeFile7 } from "fs/promises";
1576
- import { join as join11 } from "path";
1616
+ import { join as join12 } from "path";
1577
1617
  async function writeMigrationSummary(input) {
1578
1618
  const lines = [
1579
1619
  `# Svelte 4 \u2192 5 migration summary`,
@@ -1590,7 +1630,7 @@ async function writeMigrationSummary(input) {
1590
1630
  `- Verify Playwright a11y tests still pass.`
1591
1631
  ];
1592
1632
  const content = lines.join("\n") + "\n";
1593
- const path = join11(input.cwd, "MIGRATION_SVELTE_5.md");
1633
+ const path = join12(input.cwd, "MIGRATION_SVELTE_5.md");
1594
1634
  await writeFile7(path, content, "utf-8");
1595
1635
  return path;
1596
1636
  }
@@ -1598,7 +1638,7 @@ async function writeMigrationSummary(input) {
1598
1638
  // src/recipes/svelte-5/index.ts
1599
1639
  async function alreadyOnSvelte5(cwd) {
1600
1640
  try {
1601
- const pkg = await readPackageJson(join12(cwd, "package.json"));
1641
+ const pkg = await readPackageJson(join13(cwd, "package.json"));
1602
1642
  const v = pkg.devDependencies?.svelte ?? pkg.dependencies?.svelte;
1603
1643
  return !!v && /^\^?5\./.test(v);
1604
1644
  } catch {
@@ -1653,7 +1693,7 @@ async function upgradeSvelte4to5(site, opts = {}) {
1653
1693
 
1654
1694
  // src/recipes/svelte-codemods.ts
1655
1695
  import { writeFile as writeFile8 } from "fs/promises";
1656
- import { join as join13 } from "path";
1696
+ import { join as join14 } from "path";
1657
1697
  async function svelteCodemods(site) {
1658
1698
  return withRecipe({
1659
1699
  name: "svelte-codemods",
@@ -1667,7 +1707,7 @@ async function svelteCodemods(site) {
1667
1707
  },
1668
1708
  apply: async (changes, { commit: commit2, cwd }) => {
1669
1709
  for (const c of changes) {
1670
- await writeFile8(join13(cwd, c.rel), c.after, "utf-8");
1710
+ await writeFile8(join14(cwd, c.rel), c.after, "utf-8");
1671
1711
  }
1672
1712
  await commit2(`refactor(svelte5): apply codemods (${changes.length} files)`);
1673
1713
  return { kind: "ok" };
@@ -1677,7 +1717,7 @@ async function svelteCodemods(site) {
1677
1717
 
1678
1718
  // src/recipes/convert-to-pnpm.ts
1679
1719
  import { rm as rm3, stat as stat2 } from "fs/promises";
1680
- import { join as join14 } from "path";
1720
+ import { join as join15 } from "path";
1681
1721
 
1682
1722
  // src/recipes/convert-to-pnpm/script-rewrites.ts
1683
1723
  function rewriteScriptForPnpm(script) {
@@ -1710,9 +1750,9 @@ async function exists2(path) {
1710
1750
  async function convertToPnpm(site, opts = {}) {
1711
1751
  const spawn2 = opts.spawn ?? defaultSpawn;
1712
1752
  const pnpmVersion = opts.pnpmVersion ?? DEFAULT_PNPM_VERSION;
1713
- const pnpmLockPath = join14(site.path, "pnpm-lock.yaml");
1714
- const npmLockPath = join14(site.path, "package-lock.json");
1715
- const yarnLockPath = join14(site.path, "yarn.lock");
1753
+ const pnpmLockPath = join15(site.path, "pnpm-lock.yaml");
1754
+ const npmLockPath = join15(site.path, "package-lock.json");
1755
+ const yarnLockPath = join15(site.path, "yarn.lock");
1716
1756
  return withRecipe({
1717
1757
  name: "convert-to-pnpm",
1718
1758
  site,
@@ -1735,7 +1775,7 @@ async function convertToPnpm(site, opts = {}) {
1735
1775
  if (hasYarnLock) await rm3(yarnLockPath, { force: true });
1736
1776
  const sourceLock = hasNpmLock ? "package-lock.json" : "yarn.lock";
1737
1777
  await commit2(`chore(pnpm): remove ${sourceLock}`);
1738
- const pkgPath = join14(cwd, "package.json");
1778
+ const pkgPath = join15(cwd, "package.json");
1739
1779
  const pkg = await readPackageJson(pkgPath);
1740
1780
  const next = { ...pkg, packageManager: `pnpm@${pnpmVersion}` };
1741
1781
  if (pkg.scripts && typeof pkg.scripts === "object") {
@@ -1748,7 +1788,7 @@ async function convertToPnpm(site, opts = {}) {
1748
1788
  }
1749
1789
  await writePackageJson(pkgPath, next);
1750
1790
  await commit2("chore(pnpm): pin packageManager + rewrite npm scripts");
1751
- await rm3(join14(cwd, "node_modules"), { recursive: true, force: true });
1791
+ await rm3(join15(cwd, "node_modules"), { recursive: true, force: true });
1752
1792
  const installResult = await spawn2("pnpm", ["install"], { cwd, streaming: true });
1753
1793
  if (installResult.code !== 0) {
1754
1794
  return { kind: "failed", notes: `pnpm install failed (exit ${installResult.code})` };
@@ -1761,16 +1801,16 @@ async function convertToPnpm(site, opts = {}) {
1761
1801
 
1762
1802
  // src/recipes/onboard.ts
1763
1803
  import { stat as stat3 } from "fs/promises";
1764
- import { join as join16 } from "path";
1804
+ import { join as join17 } from "path";
1765
1805
 
1766
1806
  // src/util/self-version.ts
1767
1807
  import { readFileSync } from "fs";
1768
1808
  import { fileURLToPath } from "url";
1769
- import { dirname, join as join15 } from "path";
1809
+ import { dirname, join as join16 } from "path";
1770
1810
  function selfPackageVersion(callerImportMetaUrl) {
1771
1811
  try {
1772
- const here = dirname(fileURLToPath(callerImportMetaUrl));
1773
- const raw = readFileSync(join15(here, "..", "..", "package.json"), "utf-8");
1812
+ const here2 = dirname(fileURLToPath(callerImportMetaUrl));
1813
+ const raw = readFileSync(join16(here2, "..", "..", "package.json"), "utf-8");
1774
1814
  const pkg = JSON.parse(raw);
1775
1815
  return pkg.version ?? "0.0.0";
1776
1816
  } catch {
@@ -1820,13 +1860,13 @@ async function onboard(site, opts = {}) {
1820
1860
  name: "onboard",
1821
1861
  site,
1822
1862
  plan: async () => {
1823
- if (!await exists3(join16(site.path, "pnpm-lock.yaml"))) {
1863
+ if (!await exists3(join17(site.path, "pnpm-lock.yaml"))) {
1824
1864
  return {
1825
1865
  kind: "failed",
1826
1866
  notes: "no pnpm-lock.yaml at site root \u2014 run convert-to-pnpm first"
1827
1867
  };
1828
1868
  }
1829
- const pkgPath = join16(site.path, "package.json");
1869
+ const pkgPath = join17(site.path, "package.json");
1830
1870
  const pkg = await readPackageJson(pkgPath);
1831
1871
  const toAdd = [];
1832
1872
  if (!isDeclared(pkg, PACKAGE_NAME)) {
@@ -1846,7 +1886,7 @@ async function onboard(site, opts = {}) {
1846
1886
  return { kind: "apply", plan: { pkg, toAdd } };
1847
1887
  },
1848
1888
  apply: async ({ pkg, toAdd }, { commit: commit2, cwd }) => {
1849
- const pkgPath = join16(cwd, "package.json");
1889
+ const pkgPath = join17(cwd, "package.json");
1850
1890
  let next = pkg;
1851
1891
  for (const dep of toAdd) {
1852
1892
  next = bumpDep(next, dep.name, dep.version);
@@ -1889,7 +1929,7 @@ function localPath(path, opts = {}) {
1889
1929
  }
1890
1930
 
1891
1931
  // src/inventory/json.ts
1892
- import { readFile as readFile9 } from "fs/promises";
1932
+ import { readFile as readFile10 } from "fs/promises";
1893
1933
  import { isAbsolute } from "path";
1894
1934
  function validate(raw) {
1895
1935
  if (!Array.isArray(raw)) {
@@ -1919,21 +1959,111 @@ function validate(raw) {
1919
1959
  }
1920
1960
  function fromJsonFile(path) {
1921
1961
  return async () => {
1922
- const raw = JSON.parse(await readFile9(path, "utf-8"));
1962
+ const raw = JSON.parse(await readFile10(path, "utf-8"));
1923
1963
  return validate(raw);
1924
1964
  };
1925
1965
  }
1926
1966
 
1967
+ // src/reports/airtable/websites.ts
1968
+ var WEBSITES_TABLE = "Websites";
1969
+ function siteSlug(name) {
1970
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1971
+ }
1972
+ function mapRow(rec) {
1973
+ const f = rec.fields;
1974
+ const attachments = f["Header image"] ?? [];
1975
+ const header = attachments[0] ?? null;
1976
+ return {
1977
+ id: rec.id,
1978
+ name: String(f["Name"] ?? ""),
1979
+ url: String(f["url"] ?? ""),
1980
+ status: f["Status"] ?? null,
1981
+ pointOfContact: f["point of contact"] ?? null,
1982
+ maintenanceFreq: f["maintenence freq"] ?? "None",
1983
+ testingFreq: f["testing freq"] ?? "None",
1984
+ maintenanceDay: f["maintenance day"] ?? null,
1985
+ testingDay: f["testing day"] ?? null,
1986
+ ga4PropertyId: f["GA4 property ID"] ?? null,
1987
+ reportRecipientsTo: f["Report recipients (To)"] ?? null,
1988
+ reportRecipientsCc: f["Report recipients (CC)"] ?? null,
1989
+ headerImage: header,
1990
+ pScore: f["pScore"] ?? null,
1991
+ rScore: f["rScore"] ?? null,
1992
+ bpScore: f["bpScore"] ?? null,
1993
+ seoScore: f["seoScore"] ?? null,
1994
+ lastLighthouseAuditAt: f["Last lighthouse audit at"] ?? null
1995
+ };
1996
+ }
1997
+ async function listWebsites(base) {
1998
+ const out = [];
1999
+ await base(WEBSITES_TABLE).select({ pageSize: 100 }).eachPage((records, fetchNextPage) => {
2000
+ for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));
2001
+ fetchNextPage();
2002
+ });
2003
+ return out;
2004
+ }
2005
+
2006
+ // src/inventory/airtable.ts
2007
+ function fromAirtableBase(base, opts = {}) {
2008
+ return async () => {
2009
+ const workdir = opts.workdir ?? process.env.REDDOOR_FLEET_WORKDIR;
2010
+ if (!workdir) {
2011
+ throw new Error(
2012
+ "fromAirtableBase requires `workdir` option or REDDOOR_FLEET_WORKDIR env (sites need a local path)"
2013
+ );
2014
+ }
2015
+ const websites = await listWebsites(base);
2016
+ return websites.filter((w) => w.maintenanceFreq !== "None" || w.testingFreq !== "None").map((w) => {
2017
+ const slug = siteSlug(w.name);
2018
+ const site = {
2019
+ path: `${workdir}/${slug}`,
2020
+ name: slug,
2021
+ meta: { airtableRowId: w.id, displayName: w.name }
2022
+ };
2023
+ if (w.url) site.repoUrl = w.url;
2024
+ return site;
2025
+ });
2026
+ };
2027
+ }
2028
+
1927
2029
  // src/reports/draft.ts
1928
2030
  import { mkdir, writeFile as writeFile9 } from "fs/promises";
1929
- import { dirname as dirname2 } from "path";
2031
+ import { dirname as dirname3 } from "path";
1930
2032
 
1931
2033
  // src/reports/render.ts
1932
2034
  import mjml2html from "mjml";
1933
2035
 
2036
+ // src/reports/maintenance-email/assets/index.ts
2037
+ import { readFile as readFile11 } from "fs/promises";
2038
+ import { dirname as dirname2, join as join18 } from "path";
2039
+ import { fileURLToPath as fileURLToPath2 } from "url";
2040
+ var here = dirname2(fileURLToPath2(import.meta.url));
2041
+ var CHECK_CID = "rd-check-png";
2042
+ var BLURRED_CID = "rd-blurred-tests-jpg";
2043
+ async function loadBundledImages() {
2044
+ const [check, blurred] = await Promise.all([
2045
+ readFile11(join18(here, "check.png")),
2046
+ readFile11(join18(here, "blurredTests.jpg"))
2047
+ ]);
2048
+ return {
2049
+ check: {
2050
+ bytes: new Uint8Array(check),
2051
+ contentType: "image/png",
2052
+ cid: CHECK_CID,
2053
+ filename: "check.png"
2054
+ },
2055
+ blurred: {
2056
+ bytes: new Uint8Array(blurred),
2057
+ contentType: "image/jpeg",
2058
+ cid: BLURRED_CID,
2059
+ filename: "blurredTests.jpg"
2060
+ }
2061
+ };
2062
+ }
2063
+
1934
2064
  // src/reports/maintenance-email/template.ts
1935
- var CHECK_PNG = "https://d3eq0h5l8sxf6t.cloudfront.net/maintenance-email/check.png";
1936
- var BLURRED_TESTS = "https://d3eq0h5l8sxf6t.cloudfront.net/maintenance-email/blurredTests.jpg";
2065
+ var CHECK_PNG = `cid:${CHECK_CID}`;
2066
+ var BLURRED_TESTS = `cid:${BLURRED_CID}`;
1937
2067
  function fmtDate(d) {
1938
2068
  if (!d) return "";
1939
2069
  const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
@@ -2104,43 +2234,6 @@ async function renderReportHtml(data) {
2104
2234
  return { html: out.html, warnings: out.errors ?? [] };
2105
2235
  }
2106
2236
 
2107
- // src/reports/airtable/websites.ts
2108
- var WEBSITES_TABLE = "Websites";
2109
- function siteSlug(name) {
2110
- return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
2111
- }
2112
- function mapRow(rec) {
2113
- const f = rec.fields;
2114
- const attachments = f["Header image"] ?? [];
2115
- const header = attachments[0] ?? null;
2116
- return {
2117
- id: rec.id,
2118
- name: String(f["Name"] ?? ""),
2119
- url: String(f["url"] ?? ""),
2120
- pointOfContact: f["point of contact"] ?? null,
2121
- maintenanceFreq: f["maintenence freq"] ?? "None",
2122
- testingFreq: f["testing freq"] ?? "None",
2123
- maintenanceDay: f["maintenance day"] ?? null,
2124
- testingDay: f["testing day"] ?? null,
2125
- ga4PropertyId: f["GA4 property ID"] ?? null,
2126
- reportRecipientsTo: f["Report recipients (To)"] ?? null,
2127
- reportRecipientsCc: f["Report recipients (CC)"] ?? null,
2128
- headerImage: header,
2129
- pScore: f["pScore"] ?? null,
2130
- rScore: f["rScore"] ?? null,
2131
- bpScore: f["bpScore"] ?? null,
2132
- seoScore: f["seoScore"] ?? null
2133
- };
2134
- }
2135
- async function listWebsites(base) {
2136
- const out = [];
2137
- await base(WEBSITES_TABLE).select({ pageSize: 100 }).eachPage((records, fetchNextPage) => {
2138
- for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));
2139
- fetchNextPage();
2140
- });
2141
- return out;
2142
- }
2143
-
2144
2237
  // src/reports/airtable/reports.ts
2145
2238
  var REPORTS_TABLE = "Reports";
2146
2239
  function mapRow2(rec) {
@@ -2242,6 +2335,40 @@ async function stampSent(base, recordId, sentAt, messageId) {
2242
2335
  ]);
2243
2336
  }
2244
2337
 
2338
+ // src/reports/airtable/attachments.ts
2339
+ async function fetchAttachmentBytes(url) {
2340
+ const res = await fetch(url);
2341
+ if (!res.ok) {
2342
+ throw new Error(
2343
+ `Failed to fetch Airtable attachment ${res.status} ${res.statusText} (url=${url})`
2344
+ );
2345
+ }
2346
+ const contentType = res.headers.get("content-type") ?? "application/octet-stream";
2347
+ const ab = await res.arrayBuffer();
2348
+ return { bytes: new Uint8Array(ab), contentType };
2349
+ }
2350
+ async function uploadAttachment(recordId, fieldName, body, filename, contentType) {
2351
+ const apiKey = process.env.AIRTABLE_PAT;
2352
+ const baseId = process.env.AIRTABLE_BASE_ID;
2353
+ if (!apiKey || !baseId) {
2354
+ throw new Error("AIRTABLE_PAT and AIRTABLE_BASE_ID must be set");
2355
+ }
2356
+ const base64 = typeof body === "string" ? Buffer.from(body, "utf-8").toString("base64") : Buffer.from(body).toString("base64");
2357
+ const payload = { contentType, file: base64, filename };
2358
+ const url = `https://content.airtable.com/v0/${baseId}/${recordId}/${encodeURIComponent(fieldName)}/uploadAttachment`;
2359
+ const res = await fetch(url, {
2360
+ method: "POST",
2361
+ headers: {
2362
+ Authorization: `Bearer ${apiKey}`,
2363
+ "Content-Type": "application/json"
2364
+ },
2365
+ body: JSON.stringify(payload)
2366
+ });
2367
+ if (!res.ok) {
2368
+ throw new Error(`Airtable upload failed: ${res.status} ${res.statusText} ${await res.text()}`);
2369
+ }
2370
+ }
2371
+
2245
2372
  // src/reports/draft.ts
2246
2373
  function scoresFromWebsite(siteRow) {
2247
2374
  const { pScore, rScore, bpScore, seoScore } = siteRow;
@@ -2280,7 +2407,7 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
2280
2407
  });
2281
2408
  if (options.previewOnly) {
2282
2409
  const path = options.previewPath ?? `reports/${slug}/draft.html`;
2283
- await mkdir(dirname2(path), { recursive: true });
2410
+ await mkdir(dirname3(path), { recursive: true });
2284
2411
  await writeFile9(path, html, "utf-8");
2285
2412
  return { reportRow: null, htmlPath: path, html };
2286
2413
  }
@@ -2296,7 +2423,8 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
2296
2423
  lighthouse: scores,
2297
2424
  lastTestedDate
2298
2425
  });
2299
- await uploadHtmlAttachment(created.id, html, slug, periodEnd);
2426
+ const htmlFilename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;
2427
+ await uploadAttachment(created.id, "Rendered HTML", html, htmlFilename, "text/html");
2300
2428
  await setDraftReady(base, created.id, true);
2301
2429
  return { reportRow: created, htmlPath: null, html };
2302
2430
  }
@@ -2306,28 +2434,6 @@ async function derivePeriodStart(base, siteRow, reportType, today) {
2306
2434
  const latest = sameType[sameType.length - 1];
2307
2435
  return latest ? new Date(latest) : daysAgo(today, 30);
2308
2436
  }
2309
- async function uploadHtmlAttachment(recordId, html, slug, periodEnd) {
2310
- const apiKey = process.env.AIRTABLE_PAT;
2311
- const baseId = process.env.AIRTABLE_BASE_ID;
2312
- const filename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;
2313
- const body = {
2314
- contentType: "text/html",
2315
- file: Buffer.from(html, "utf-8").toString("base64"),
2316
- filename
2317
- };
2318
- const url = `https://content.airtable.com/v0/${baseId}/${recordId}/Rendered%20HTML/uploadAttachment`;
2319
- const res = await fetch(url, {
2320
- method: "POST",
2321
- headers: {
2322
- Authorization: `Bearer ${apiKey}`,
2323
- "Content-Type": "application/json"
2324
- },
2325
- body: JSON.stringify(body)
2326
- });
2327
- if (!res.ok) {
2328
- throw new Error(`Airtable upload failed: ${res.status} ${res.statusText} ${await res.text()}`);
2329
- }
2330
- }
2331
2437
 
2332
2438
  // src/reports/airtable/client.ts
2333
2439
  import Airtable from "airtable";
@@ -2342,19 +2448,6 @@ function openBase(cfg) {
2342
2448
  return new Airtable({ apiKey: cfg.apiKey }).base(cfg.baseId);
2343
2449
  }
2344
2450
 
2345
- // src/reports/airtable/attachments.ts
2346
- async function fetchAttachmentBytes(url) {
2347
- const res = await fetch(url);
2348
- if (!res.ok) {
2349
- throw new Error(
2350
- `Failed to fetch Airtable attachment ${res.status} ${res.statusText} (url=${url})`
2351
- );
2352
- }
2353
- const contentType = res.headers.get("content-type") ?? "application/octet-stream";
2354
- const ab = await res.arrayBuffer();
2355
- return { bytes: new Uint8Array(ab), contentType };
2356
- }
2357
-
2358
2451
  // src/reports/send/resend.ts
2359
2452
  import { Resend } from "resend";
2360
2453
  function defaultResendClient() {
@@ -2419,6 +2512,7 @@ async function sendOne(client, base, site, report) {
2419
2512
  throw new Error(`Report ${report.reportId} has no Lighthouse scores`);
2420
2513
  }
2421
2514
  const { bytes, contentType } = await fetchAttachmentBytes(site.headerImage.url);
2515
+ const bundled = await loadBundledImages();
2422
2516
  const slug = siteSlug(site.name);
2423
2517
  const cidName = `${slug}-header`;
2424
2518
  const { html } = await renderReportHtml({
@@ -2471,6 +2565,21 @@ async function sendOne(client, base, site, report) {
2471
2565
  content: Buffer.from(bytes).toString("base64"),
2472
2566
  contentType,
2473
2567
  inlineContentId: cidName
2568
+ },
2569
+ // Bundled images referenced via cid:rd-check-png / cid:rd-blurred-tests-jpg
2570
+ // in the template. Attached inline so the email is self-contained — no
2571
+ // external CDN dependency, no image-blocked broken icons in webmail.
2572
+ {
2573
+ filename: bundled.check.filename,
2574
+ content: Buffer.from(bundled.check.bytes).toString("base64"),
2575
+ contentType: bundled.check.contentType,
2576
+ inlineContentId: bundled.check.cid
2577
+ },
2578
+ {
2579
+ filename: bundled.blurred.filename,
2580
+ content: Buffer.from(bundled.blurred.bytes).toString("base64"),
2581
+ contentType: bundled.blurred.contentType,
2582
+ inlineContentId: bundled.blurred.cid
2474
2583
  }
2475
2584
  ],
2476
2585
  // Stable across retries of the same row — if Airtable stamping fails after a
@@ -2508,6 +2617,12 @@ function isProbablyEmail(s) {
2508
2617
  }
2509
2618
 
2510
2619
  // src/reports/due.ts
2620
+ var ELIGIBLE_STATUSES = /* @__PURE__ */ new Set([
2621
+ "in development",
2622
+ "launch period",
2623
+ "maintenance",
2624
+ "hosting"
2625
+ ]);
2511
2626
  var MONTHS = {
2512
2627
  Monthly: 1,
2513
2628
  Quarterly: 3,
@@ -2537,6 +2652,7 @@ function findDueReports(websites, reports, today) {
2537
2652
  const out = [];
2538
2653
  const todayStart = startOfDay(today);
2539
2654
  for (const site of websites) {
2655
+ if (site.status !== null && !ELIGIBLE_STATUSES.has(site.status)) continue;
2540
2656
  for (const type of ["Maintenance", "Testing"]) {
2541
2657
  const freq = type === "Maintenance" ? site.maintenanceFreq : site.testingFreq;
2542
2658
  if (freq === "None") continue;
@@ -2564,6 +2680,7 @@ export {
2564
2680
  depsAudit,
2565
2681
  draftReportForSite,
2566
2682
  findDueReports,
2683
+ fromAirtableBase,
2567
2684
  fromJsonFile,
2568
2685
  isRecipeName,
2569
2686
  lighthouseAudit,