@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.
@@ -4,6 +4,12 @@ type AuditCommandOptions = {
4
4
  fleet?: string;
5
5
  workdir?: string;
6
6
  cwd?: string;
7
+ /**
8
+ * After running, push the lighthouse scores to the matching Websites row
9
+ * in Airtable. `true` (no value) = derive slug from cwd/package.json#name;
10
+ * string = explicit slug (e.g. "med-solutions-of-texas").
11
+ */
12
+ writeAirtable?: string | boolean;
7
13
  };
8
14
  declare function runAuditCommand(site: string | undefined, opts: AuditCommandOptions): Promise<{
9
15
  output: string;
@@ -1,3 +1,179 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __esm = (fn, res) => function __init() {
4
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
+ };
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+
11
+ // src/reports/airtable/client.ts
12
+ var client_exports = {};
13
+ __export(client_exports, {
14
+ openBase: () => openBase,
15
+ readAirtableConfig: () => readAirtableConfig
16
+ });
17
+ import Airtable from "airtable";
18
+ function readAirtableConfig() {
19
+ const apiKey = process.env.AIRTABLE_PAT;
20
+ const baseId = process.env.AIRTABLE_BASE_ID;
21
+ if (!apiKey) throw Object.assign(new Error("AIRTABLE_PAT not set"), { exitCode: 2 });
22
+ if (!baseId) throw Object.assign(new Error("AIRTABLE_BASE_ID not set"), { exitCode: 2 });
23
+ return { apiKey, baseId };
24
+ }
25
+ function openBase(cfg) {
26
+ return new Airtable({ apiKey: cfg.apiKey }).base(cfg.baseId);
27
+ }
28
+ var init_client = __esm({
29
+ "src/reports/airtable/client.ts"() {
30
+ "use strict";
31
+ }
32
+ });
33
+
34
+ // src/reports/airtable/websites.ts
35
+ var websites_exports = {};
36
+ __export(websites_exports, {
37
+ WEBSITES_TABLE: () => WEBSITES_TABLE,
38
+ getWebsiteBySlug: () => getWebsiteBySlug,
39
+ listWebsites: () => listWebsites,
40
+ siteSlug: () => siteSlug,
41
+ updateScores: () => updateScores
42
+ });
43
+ function siteSlug(name) {
44
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
45
+ }
46
+ function mapRow(rec) {
47
+ const f = rec.fields;
48
+ const attachments = f["Header image"] ?? [];
49
+ const header = attachments[0] ?? null;
50
+ return {
51
+ id: rec.id,
52
+ name: String(f["Name"] ?? ""),
53
+ url: String(f["url"] ?? ""),
54
+ status: f["Status"] ?? null,
55
+ pointOfContact: f["point of contact"] ?? null,
56
+ maintenanceFreq: f["maintenence freq"] ?? "None",
57
+ testingFreq: f["testing freq"] ?? "None",
58
+ maintenanceDay: f["maintenance day"] ?? null,
59
+ testingDay: f["testing day"] ?? null,
60
+ ga4PropertyId: f["GA4 property ID"] ?? null,
61
+ reportRecipientsTo: f["Report recipients (To)"] ?? null,
62
+ reportRecipientsCc: f["Report recipients (CC)"] ?? null,
63
+ headerImage: header,
64
+ pScore: f["pScore"] ?? null,
65
+ rScore: f["rScore"] ?? null,
66
+ bpScore: f["bpScore"] ?? null,
67
+ seoScore: f["seoScore"] ?? null,
68
+ lastLighthouseAuditAt: f["Last lighthouse audit at"] ?? null
69
+ };
70
+ }
71
+ async function listWebsites(base) {
72
+ const out = [];
73
+ await base(WEBSITES_TABLE).select({ pageSize: 100 }).eachPage((records, fetchNextPage) => {
74
+ for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));
75
+ fetchNextPage();
76
+ });
77
+ return out;
78
+ }
79
+ async function getWebsiteBySlug(base, slug) {
80
+ const all = await listWebsites(base);
81
+ return all.find((w) => siteSlug(w.name) === slug) ?? null;
82
+ }
83
+ async function updateScores(base, recordId, scores) {
84
+ const fields = {
85
+ pScore: scores.performance,
86
+ rScore: scores.accessibility,
87
+ bpScore: scores.bestPractices,
88
+ seoScore: scores.seo,
89
+ "Last lighthouse audit at": (/* @__PURE__ */ new Date()).toISOString()
90
+ };
91
+ await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
92
+ }
93
+ var WEBSITES_TABLE;
94
+ var init_websites = __esm({
95
+ "src/reports/airtable/websites.ts"() {
96
+ "use strict";
97
+ WEBSITES_TABLE = "Websites";
98
+ }
99
+ });
100
+
101
+ // src/inventory/airtable.ts
102
+ var airtable_exports = {};
103
+ __export(airtable_exports, {
104
+ fromAirtableBase: () => fromAirtableBase
105
+ });
106
+ function fromAirtableBase(base, opts = {}) {
107
+ return async () => {
108
+ const workdir = opts.workdir ?? process.env.REDDOOR_FLEET_WORKDIR;
109
+ if (!workdir) {
110
+ throw new Error(
111
+ "fromAirtableBase requires `workdir` option or REDDOOR_FLEET_WORKDIR env (sites need a local path)"
112
+ );
113
+ }
114
+ const websites = await listWebsites(base);
115
+ return websites.filter((w) => w.maintenanceFreq !== "None" || w.testingFreq !== "None").map((w) => {
116
+ const slug = siteSlug(w.name);
117
+ const site = {
118
+ path: `${workdir}/${slug}`,
119
+ name: slug,
120
+ meta: { airtableRowId: w.id, displayName: w.name }
121
+ };
122
+ if (w.url) site.repoUrl = w.url;
123
+ return site;
124
+ });
125
+ };
126
+ }
127
+ var init_airtable = __esm({
128
+ "src/inventory/airtable.ts"() {
129
+ "use strict";
130
+ init_websites();
131
+ }
132
+ });
133
+
134
+ // src/audits/lighthouse-airtable.ts
135
+ var lighthouse_airtable_exports = {};
136
+ __export(lighthouse_airtable_exports, {
137
+ lighthouseScoresFromResult: () => lighthouseScoresFromResult,
138
+ resolveSlugFromCwd: () => resolveSlugFromCwd
139
+ });
140
+ import { readFile as readFile7 } from "fs/promises";
141
+ import { join as join7 } from "path";
142
+ function lighthouseScoresFromResult(result) {
143
+ if (result.audit !== "lighthouse") {
144
+ throw new Error(`Expected a 'lighthouse' AuditResult, got '${result.audit}'`);
145
+ }
146
+ const details = result.details ?? {};
147
+ const summary = details.summary ?? {};
148
+ const toPct = (n) => typeof n === "number" && !Number.isNaN(n) ? Math.round(n * 100) : 0;
149
+ return {
150
+ performance: toPct(summary["performance"]),
151
+ accessibility: toPct(summary["accessibility"]),
152
+ bestPractices: toPct(summary["best-practices"]),
153
+ seo: toPct(summary["seo"])
154
+ };
155
+ }
156
+ async function resolveSlugFromCwd(cwd) {
157
+ try {
158
+ const pkgPath = join7(cwd, "package.json");
159
+ const raw = await readFile7(pkgPath, "utf-8");
160
+ const pkg = JSON.parse(raw);
161
+ if (!pkg.name) throw new Error("package.json has no 'name' field");
162
+ return siteSlug(pkg.name);
163
+ } catch (e) {
164
+ throw new Error(
165
+ `Could not derive site slug from ${cwd}/package.json: ${e.message}. Pass --write-airtable=<slug> explicitly.`,
166
+ { cause: e }
167
+ );
168
+ }
169
+ }
170
+ var init_lighthouse_airtable = __esm({
171
+ "src/audits/lighthouse-airtable.ts"() {
172
+ "use strict";
173
+ init_websites();
174
+ }
175
+ });
176
+
1
177
  // src/cli/commands/audit.ts
2
178
  import { resolve as resolve2 } from "path";
3
179
 
@@ -338,9 +514,9 @@ async function securityAudit(ctx) {
338
514
  }
339
515
 
340
516
  // src/audits/lighthouse.ts
341
- import { readFile as readFile3, writeFile, mkdtemp, rm } from "fs/promises";
517
+ import { readFile as readFile4, writeFile, mkdtemp, rm } from "fs/promises";
342
518
  import { tmpdir } from "os";
343
- import { join as join3 } from "path";
519
+ import { join as join4 } from "path";
344
520
 
345
521
  // src/configs/lighthouse.ts
346
522
  var lighthouseConfig = {
@@ -373,10 +549,37 @@ var lighthouseConfig = {
373
549
  }
374
550
  };
375
551
 
552
+ // src/audits/util/site-config.ts
553
+ import { readFile as readFile3 } from "fs/promises";
554
+ import { join as join3 } from "path";
555
+ async function readSiteConfig(sitePath) {
556
+ let raw;
557
+ try {
558
+ raw = await readFile3(join3(sitePath, "package.json"), "utf-8");
559
+ } catch {
560
+ return {};
561
+ }
562
+ let pkg;
563
+ try {
564
+ pkg = JSON.parse(raw);
565
+ } catch {
566
+ return {};
567
+ }
568
+ if (!pkg || typeof pkg !== "object") return {};
569
+ const cfg = pkg.reddoor;
570
+ if (!cfg || typeof cfg !== "object") return {};
571
+ const out = {};
572
+ const url = cfg.lighthouseUrl;
573
+ if (typeof url === "string" && url.length > 0) {
574
+ out.lighthouseUrl = url;
575
+ }
576
+ return out;
577
+ }
578
+
376
579
  // src/audits/lighthouse.ts
377
580
  async function readJsonMaybe(path) {
378
581
  try {
379
- const raw = await readFile3(path, "utf-8");
582
+ const raw = await readFile4(path, "utf-8");
380
583
  return JSON.parse(raw);
381
584
  } catch {
382
585
  return null;
@@ -412,15 +615,28 @@ async function lighthouseAudit(ctx) {
412
615
  const spawn2 = ctx.spawn ?? defaultSpawn;
413
616
  const site = ctx.site;
414
617
  const label = siteLabel(site);
415
- const configDir = await mkdtemp(join3(tmpdir(), "reddoor-lhci-"));
416
- const configPath = join3(configDir, "lighthouserc.json");
417
- await writeFile(configPath, JSON.stringify(lighthouseConfig), "utf-8");
418
- const resultsDir = join3(site.path, ".lighthouseci");
618
+ const siteCfg = await readSiteConfig(site.path);
619
+ const resolvedConfig = siteCfg.lighthouseUrl ? {
620
+ ...lighthouseConfig,
621
+ ci: {
622
+ ...lighthouseConfig.ci,
623
+ collect: { ...lighthouseConfig.ci.collect, url: [siteCfg.lighthouseUrl] }
624
+ }
625
+ } : lighthouseConfig;
626
+ const configDir = await mkdtemp(join4(tmpdir(), "reddoor-lhci-"));
627
+ const configPath = join4(configDir, "lighthouserc.json");
628
+ await writeFile(configPath, JSON.stringify(resolvedConfig), "utf-8");
629
+ const resultsDir = join4(site.path, ".lighthouseci");
419
630
  await rm(resultsDir, { recursive: true, force: true });
420
631
  let raw;
421
632
  try {
422
633
  raw = await spawn2("npx", ["--yes", "@lhci/cli", "autorun", `--config=${configPath}`], {
423
- cwd: site.path
634
+ cwd: site.path,
635
+ // lhci autorun boots the site's dev server, downloads Chrome on first
636
+ // use, and runs the audit — easily 2–3 min on a cold tree. The shared
637
+ // 30 s default in runAudits is fine for deps/lint/security but starves
638
+ // lhci.
639
+ timeoutMs: 5 * 6e4
424
640
  });
425
641
  } catch (err) {
426
642
  await rm(configDir, { recursive: true, force: true });
@@ -436,7 +652,7 @@ async function lighthouseAudit(ctx) {
436
652
  throw err;
437
653
  }
438
654
  await rm(configDir, { recursive: true, force: true });
439
- const manifest = await readJsonMaybe(join3(resultsDir, "manifest.json"));
655
+ const manifest = await readJsonMaybe(join4(resultsDir, "manifest.json"));
440
656
  if (!manifest || manifest.length === 0) {
441
657
  return {
442
658
  audit: "lighthouse",
@@ -445,7 +661,7 @@ async function lighthouseAudit(ctx) {
445
661
  summary: `lighthouse: no manifest written (exit ${raw.code})${raw.stderr ? ` \u2014 ${raw.stderr.slice(0, 200)}` : ""}`
446
662
  };
447
663
  }
448
- const assertionResults = await readJsonMaybe(join3(resultsDir, "assertion-results.json")) ?? [];
664
+ const assertionResults = await readJsonMaybe(join4(resultsDir, "assertion-results.json")) ?? [];
449
665
  const failed = assertionResults.filter((a) => !a.passed);
450
666
  const assertions = failed.map((a) => ({
451
667
  category: categoryFromAssertion(a),
@@ -471,9 +687,9 @@ async function lighthouseAudit(ctx) {
471
687
  }
472
688
 
473
689
  // src/audits/a11y.ts
474
- import { readFile as readFile4, writeFile as writeFile2, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
690
+ import { readFile as readFile5, writeFile as writeFile2, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
475
691
  import { tmpdir as tmpdir2 } from "os";
476
- import { join as join4 } from "path";
692
+ import { join as join5 } from "path";
477
693
 
478
694
  // src/configs/playwright-a11y.ts
479
695
  import { defineConfig, devices } from "@playwright/test";
@@ -511,7 +727,7 @@ var playwrightA11yConfig = defineConfig({
511
727
  var RESULTS_REL = ".reddoor-a11y/results.json";
512
728
  async function readJsonMaybe2(path) {
513
729
  try {
514
- const raw = await readFile4(path, "utf-8");
730
+ const raw = await readFile5(path, "utf-8");
515
731
  return JSON.parse(raw);
516
732
  } catch {
517
733
  return null;
@@ -567,11 +783,11 @@ async function a11yAudit(ctx) {
567
783
  const spawn2 = ctx.spawn ?? defaultSpawn;
568
784
  const site = ctx.site;
569
785
  const label = siteLabel(site);
570
- const specDir = await mkdtemp2(join4(tmpdir2(), "reddoor-a11y-spec-"));
571
- const specPath = join4(specDir, "a11y.spec.ts");
786
+ const specDir = await mkdtemp2(join5(tmpdir2(), "reddoor-a11y-spec-"));
787
+ const specPath = join5(specDir, "a11y.spec.ts");
572
788
  await writeFile2(specPath, buildSpec(), "utf-8");
573
- const resultsPath = join4(site.path, RESULTS_REL);
574
- await rm2(join4(site.path, ".reddoor-a11y"), { recursive: true, force: true });
789
+ const resultsPath = join5(site.path, RESULTS_REL);
790
+ await rm2(join5(site.path, ".reddoor-a11y"), { recursive: true, force: true });
575
791
  let raw;
576
792
  try {
577
793
  raw = await spawn2("npx", ["--yes", "playwright", "test", "--reporter=line", specPath], {
@@ -647,6 +863,10 @@ async function runAudits(site, which) {
647
863
  )
648
864
  );
649
865
  }
866
+ async function runAuditsAcross(sites, which) {
867
+ const all = await Promise.all(sites.map((s) => runAudits(s, which)));
868
+ return all.flat();
869
+ }
650
870
 
651
871
  // src/cli/fleet/resolve-sites.ts
652
872
  import { pathToFileURL } from "url";
@@ -660,7 +880,7 @@ function localPath(path, opts = {}) {
660
880
  }
661
881
 
662
882
  // src/inventory/json.ts
663
- import { readFile as readFile5 } from "fs/promises";
883
+ import { readFile as readFile6 } from "fs/promises";
664
884
  import { isAbsolute } from "path";
665
885
  function validate(raw) {
666
886
  if (!Array.isArray(raw)) {
@@ -690,7 +910,7 @@ function validate(raw) {
690
910
  }
691
911
  function fromJsonFile(path) {
692
912
  return async () => {
693
- const raw = JSON.parse(await readFile5(path, "utf-8"));
913
+ const raw = JSON.parse(await readFile6(path, "utf-8"));
694
914
  return validate(raw);
695
915
  };
696
916
  }
@@ -702,6 +922,13 @@ async function resolveSites(input) {
702
922
  exitCode: 2
703
923
  });
704
924
  }
925
+ if (input.fleet === "airtable") {
926
+ const { openBase: openBase2, readAirtableConfig: readAirtableConfig2 } = await Promise.resolve().then(() => (init_client(), client_exports));
927
+ const { fromAirtableBase: fromAirtableBase2 } = await Promise.resolve().then(() => (init_airtable(), airtable_exports));
928
+ const base = openBase2(readAirtableConfig2());
929
+ const provider = fromAirtableBase2(base, input.workdir ? { workdir: input.workdir } : {});
930
+ return provider();
931
+ }
705
932
  if (input.fleet) {
706
933
  const fleetPath = resolve(input.cwd, input.fleet);
707
934
  const ext = extname(fleetPath).toLowerCase();
@@ -729,7 +956,7 @@ async function resolveSites(input) {
729
956
 
730
957
  // src/cli/fleet/clone-if-needed.ts
731
958
  import { stat, readdir, mkdir } from "fs/promises";
732
- import { isAbsolute as isAbsolute2, join as join5 } from "path";
959
+ import { isAbsolute as isAbsolute2, join as join6 } from "path";
733
960
  function deriveNameFromRepoUrl(repoUrl) {
734
961
  const slash = repoUrl.split("/").pop() ?? repoUrl;
735
962
  return slash.replace(/\.git$/, "");
@@ -770,7 +997,7 @@ async function cloneIfNeeded(site, opts) {
770
997
  const name = site.name ?? deriveNameFromRepoUrl(site.repoUrl);
771
998
  assertSafeName(name);
772
999
  assertSafeRepoUrl(site.repoUrl);
773
- const target = join5(opts.workdir, name);
1000
+ const target = join6(opts.workdir, name);
774
1001
  await mkdir(opts.workdir, { recursive: true });
775
1002
  if (await isNonEmptyDir(target)) {
776
1003
  return { ...site, name, path: target };
@@ -810,18 +1037,49 @@ async function runAuditCommand(site, opts) {
810
1037
  let sites = await resolveSites({
811
1038
  ...site !== void 0 ? { site } : {},
812
1039
  ...opts.fleet !== void 0 ? { fleet: opts.fleet } : {},
1040
+ ...opts.workdir !== void 0 ? { workdir: opts.workdir } : {},
813
1041
  cwd
814
1042
  });
815
1043
  if (opts.fleet) {
816
1044
  const workdir = opts.workdir ?? `${process.env.HOME ?? ""}/.reddoor-maint/sites`;
817
1045
  sites = await Promise.all(sites.map((s) => cloneIfNeeded(s, { workdir })));
818
1046
  }
819
- const results = [];
820
- for (const s of sites) {
821
- const r = await runAudits(s, which);
822
- results.push(...r);
1047
+ const results = await runAuditsAcross(sites, which);
1048
+ let output = opts.json ? JSON.stringify(results, null, 2) : formatTable(results);
1049
+ if (opts.writeAirtable !== void 0) {
1050
+ const { openBase: openBase2, readAirtableConfig: readAirtableConfig2 } = await Promise.resolve().then(() => (init_client(), client_exports));
1051
+ const { listWebsites: listWebsites2, updateScores: updateScores2, siteSlug: siteSlug2 } = await Promise.resolve().then(() => (init_websites(), websites_exports));
1052
+ const { lighthouseScoresFromResult: lighthouseScoresFromResult2, resolveSlugFromCwd: resolveSlugFromCwd2 } = await Promise.resolve().then(() => (init_lighthouse_airtable(), lighthouse_airtable_exports));
1053
+ const slug = typeof opts.writeAirtable === "string" && opts.writeAirtable.length > 0 ? opts.writeAirtable : await resolveSlugFromCwd2(cwd);
1054
+ const lhResult = results.find((r) => r.audit === "lighthouse");
1055
+ if (!lhResult) {
1056
+ throw Object.assign(
1057
+ new Error(
1058
+ "--write-airtable requires a lighthouse result; did you pass --only without lighthouse?"
1059
+ ),
1060
+ { exitCode: 2 }
1061
+ );
1062
+ }
1063
+ if (lhResult.status === "fail") {
1064
+ throw Object.assign(
1065
+ new Error(
1066
+ `Lighthouse audit failed; refusing to write scores to Airtable. Summary: ${lhResult.summary}`
1067
+ ),
1068
+ { exitCode: 1 }
1069
+ );
1070
+ }
1071
+ const base = openBase2(readAirtableConfig2());
1072
+ const websites = await listWebsites2(base);
1073
+ const target = websites.find((w) => siteSlug2(w.name) === slug);
1074
+ if (!target) {
1075
+ throw Object.assign(new Error(`No Websites row matched slug "${slug}"`), { exitCode: 2 });
1076
+ }
1077
+ const scores = lighthouseScoresFromResult2(lhResult);
1078
+ await updateScores2(base, target.id, scores);
1079
+ output += `
1080
+
1081
+ \u2192 wrote scores to Websites[${target.name}]: P=${scores.performance} A=${scores.accessibility} BP=${scores.bestPractices} SEO=${scores.seo}`;
823
1082
  }
824
- const output = opts.json ? JSON.stringify(results, null, 2) : formatTable(results);
825
1083
  return { output, code: exitCode(results) };
826
1084
  }
827
1085
  export {