@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/index.d.ts CHANGED
@@ -92,6 +92,35 @@ declare function localPath(path: string, opts?: LocalPathOptions): InventoryProv
92
92
 
93
93
  declare function fromJsonFile(path: string): InventoryProvider;
94
94
 
95
+ type AirtableConfig = {
96
+ apiKey: string;
97
+ baseId: string;
98
+ };
99
+ type AirtableBase = ReturnType<typeof openBase>;
100
+ declare function openBase(cfg: AirtableConfig): airtable_lib_airtable_base_js.AirtableBase;
101
+
102
+ type AirtableInventoryOptions = {
103
+ /**
104
+ * Local workdir to compute each site's path as `{workdir}/{slug}`.
105
+ * Defaults to REDDOOR_FLEET_WORKDIR env var if not provided.
106
+ * Airtable doesn't store local checkout paths, so this is required.
107
+ */
108
+ workdir?: string;
109
+ };
110
+ /**
111
+ * Read sites from the Airtable Websites table as an InventoryProvider.
112
+ * Each row becomes one Site; `path` is computed as `{workdir}/{slug}`.
113
+ * Sites where BOTH maintenance freq AND testing freq are "None" are excluded
114
+ * (they're inactive — no scheduled audits or reports).
115
+ *
116
+ * Note: `repoUrl` is set to the production URL (Websites.url). For sites
117
+ * cloned via `--workdir` semantics this is wrong — the convention should be
118
+ * tightened (e.g. add a `repo` field to Websites) when fleet-clone-from-
119
+ * airtable becomes a real flow. For local audits where the site is already
120
+ * checked out at `path`, the `repoUrl` is unused.
121
+ */
122
+ declare function fromAirtableBase(base: AirtableBase, opts?: AirtableInventoryOptions): InventoryProvider;
123
+
95
124
  type ReportType = "Maintenance" | "Testing";
96
125
  type LighthouseScores = {
97
126
  performance: number;
@@ -124,18 +153,13 @@ type ReportData = {
124
153
  headerImageCid: string;
125
154
  };
126
155
 
127
- type AirtableConfig = {
128
- apiKey: string;
129
- baseId: string;
130
- };
131
- type AirtableBase = ReturnType<typeof openBase>;
132
- declare function openBase(cfg: AirtableConfig): airtable_lib_airtable_base_js.AirtableBase;
133
-
134
156
  type Frequency = "None" | "Monthly" | "Quarterly" | "Yearly";
157
+ type Status = "in development" | "launch period" | "maintenance" | "hosting" | "probably not our problem" | "deprecated";
135
158
  type WebsiteRow = {
136
159
  id: string;
137
160
  name: string;
138
161
  url: string;
162
+ status: Status | null;
139
163
  pointOfContact: string | null;
140
164
  maintenanceFreq: Frequency;
141
165
  testingFreq: Frequency;
@@ -151,11 +175,13 @@ type WebsiteRow = {
151
175
  filename: string;
152
176
  type: string;
153
177
  } | null;
154
- /** Lighthouse "current state" snapshot, manually kept fresh by the operator. */
178
+ /** Lighthouse "current state" snapshot, kept fresh by `audit lighthouse --write-airtable`. */
155
179
  pScore: number | null;
156
180
  rScore: number | null;
157
181
  bpScore: number | null;
158
182
  seoScore: number | null;
183
+ /** ISO timestamp set by `audit lighthouse --write-airtable` when scores were last refreshed. */
184
+ lastLighthouseAuditAt: string | null;
159
185
  };
160
186
 
161
187
  type DeliveryStatus = "pending" | "delivered" | "bounced" | "complained";
@@ -268,4 +294,4 @@ type DueItem = {
268
294
  */
269
295
  declare function findDueReports(websites: WebsiteRow[], reports: ReportRow[], today: Date): DueItem[];
270
296
 
271
- export { ALL_AUDIT_NAMES, ALL_RECIPE_NAMES, AuditName, AuditResult, type BumpDepsOptions, type ConvertToPnpmOptions, type DraftOptions, type DraftResult, type DueItem, type HeaderImage, InventoryProvider, type LighthouseScores, type LocalPathOptions, type OnboardAudit, type OnboardOptions, type OrchestrateOptions, RecipeName, RecipeResult, type RenderResult, type ReportData, type ReportType, Site, type UpgradeSvelte4to5Options, a11yAudit, bumpDeps, convertToPnpm, depsAudit, draftReportForSite, findDueReports, fromJsonFile, isRecipeName, lighthouseAudit, lintAudit, localPath, onboard, renderReportHtml, runAudits, runAuditsAcross, securityAudit, sendApprovedReports, svelteCodemods, upgradeSvelte4to5 };
297
+ export { ALL_AUDIT_NAMES, ALL_RECIPE_NAMES, type AirtableInventoryOptions, AuditName, AuditResult, type BumpDepsOptions, type ConvertToPnpmOptions, type DraftOptions, type DraftResult, type DueItem, type HeaderImage, InventoryProvider, type LighthouseScores, type LocalPathOptions, type OnboardAudit, type OnboardOptions, type OrchestrateOptions, RecipeName, RecipeResult, type RenderResult, type ReportData, type ReportType, Site, type UpgradeSvelte4to5Options, a11yAudit, bumpDeps, convertToPnpm, depsAudit, draftReportForSite, findDueReports, fromAirtableBase, fromJsonFile, isRecipeName, lighthouseAudit, lintAudit, localPath, onboard, renderReportHtml, runAudits, runAuditsAcross, securityAudit, sendApprovedReports, svelteCodemods, upgradeSvelte4to5 };
package/dist/index.js CHANGED
@@ -1769,8 +1769,8 @@ import { fileURLToPath } from "url";
1769
1769
  import { dirname, join as join15 } from "path";
1770
1770
  function selfPackageVersion(callerImportMetaUrl) {
1771
1771
  try {
1772
- const here = dirname(fileURLToPath(callerImportMetaUrl));
1773
- const raw = readFileSync(join15(here, "..", "..", "package.json"), "utf-8");
1772
+ const here2 = dirname(fileURLToPath(callerImportMetaUrl));
1773
+ const raw = readFileSync(join15(here2, "..", "..", "package.json"), "utf-8");
1774
1774
  const pkg = JSON.parse(raw);
1775
1775
  return pkg.version ?? "0.0.0";
1776
1776
  } catch {
@@ -1924,16 +1924,106 @@ function fromJsonFile(path) {
1924
1924
  };
1925
1925
  }
1926
1926
 
1927
+ // src/reports/airtable/websites.ts
1928
+ var WEBSITES_TABLE = "Websites";
1929
+ function siteSlug(name) {
1930
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1931
+ }
1932
+ function mapRow(rec) {
1933
+ const f = rec.fields;
1934
+ const attachments = f["Header image"] ?? [];
1935
+ const header = attachments[0] ?? null;
1936
+ return {
1937
+ id: rec.id,
1938
+ name: String(f["Name"] ?? ""),
1939
+ url: String(f["url"] ?? ""),
1940
+ status: f["Status"] ?? null,
1941
+ pointOfContact: f["point of contact"] ?? null,
1942
+ maintenanceFreq: f["maintenence freq"] ?? "None",
1943
+ testingFreq: f["testing freq"] ?? "None",
1944
+ maintenanceDay: f["maintenance day"] ?? null,
1945
+ testingDay: f["testing day"] ?? null,
1946
+ ga4PropertyId: f["GA4 property ID"] ?? null,
1947
+ reportRecipientsTo: f["Report recipients (To)"] ?? null,
1948
+ reportRecipientsCc: f["Report recipients (CC)"] ?? null,
1949
+ headerImage: header,
1950
+ pScore: f["pScore"] ?? null,
1951
+ rScore: f["rScore"] ?? null,
1952
+ bpScore: f["bpScore"] ?? null,
1953
+ seoScore: f["seoScore"] ?? null,
1954
+ lastLighthouseAuditAt: f["Last lighthouse audit at"] ?? null
1955
+ };
1956
+ }
1957
+ async function listWebsites(base) {
1958
+ const out = [];
1959
+ await base(WEBSITES_TABLE).select({ pageSize: 100 }).eachPage((records, fetchNextPage) => {
1960
+ for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));
1961
+ fetchNextPage();
1962
+ });
1963
+ return out;
1964
+ }
1965
+
1966
+ // src/inventory/airtable.ts
1967
+ function fromAirtableBase(base, opts = {}) {
1968
+ return async () => {
1969
+ const workdir = opts.workdir ?? process.env.REDDOOR_FLEET_WORKDIR;
1970
+ if (!workdir) {
1971
+ throw new Error(
1972
+ "fromAirtableBase requires `workdir` option or REDDOOR_FLEET_WORKDIR env (sites need a local path)"
1973
+ );
1974
+ }
1975
+ const websites = await listWebsites(base);
1976
+ return websites.filter((w) => w.maintenanceFreq !== "None" || w.testingFreq !== "None").map((w) => {
1977
+ const slug = siteSlug(w.name);
1978
+ const site = {
1979
+ path: `${workdir}/${slug}`,
1980
+ name: slug,
1981
+ meta: { airtableRowId: w.id, displayName: w.name }
1982
+ };
1983
+ if (w.url) site.repoUrl = w.url;
1984
+ return site;
1985
+ });
1986
+ };
1987
+ }
1988
+
1927
1989
  // src/reports/draft.ts
1928
1990
  import { mkdir, writeFile as writeFile9 } from "fs/promises";
1929
- import { dirname as dirname2 } from "path";
1991
+ import { dirname as dirname3 } from "path";
1930
1992
 
1931
1993
  // src/reports/render.ts
1932
1994
  import mjml2html from "mjml";
1933
1995
 
1996
+ // src/reports/maintenance-email/assets/index.ts
1997
+ import { readFile as readFile10 } from "fs/promises";
1998
+ import { dirname as dirname2, join as join17 } from "path";
1999
+ import { fileURLToPath as fileURLToPath2 } from "url";
2000
+ var here = dirname2(fileURLToPath2(import.meta.url));
2001
+ var CHECK_CID = "rd-check-png";
2002
+ var BLURRED_CID = "rd-blurred-tests-jpg";
2003
+ async function loadBundledImages() {
2004
+ const [check, blurred] = await Promise.all([
2005
+ readFile10(join17(here, "check.png")),
2006
+ readFile10(join17(here, "blurredTests.jpg"))
2007
+ ]);
2008
+ return {
2009
+ check: {
2010
+ bytes: new Uint8Array(check),
2011
+ contentType: "image/png",
2012
+ cid: CHECK_CID,
2013
+ filename: "check.png"
2014
+ },
2015
+ blurred: {
2016
+ bytes: new Uint8Array(blurred),
2017
+ contentType: "image/jpeg",
2018
+ cid: BLURRED_CID,
2019
+ filename: "blurredTests.jpg"
2020
+ }
2021
+ };
2022
+ }
2023
+
1934
2024
  // 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";
2025
+ var CHECK_PNG = `cid:${CHECK_CID}`;
2026
+ var BLURRED_TESTS = `cid:${BLURRED_CID}`;
1937
2027
  function fmtDate(d) {
1938
2028
  if (!d) return "";
1939
2029
  const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
@@ -2104,43 +2194,6 @@ async function renderReportHtml(data) {
2104
2194
  return { html: out.html, warnings: out.errors ?? [] };
2105
2195
  }
2106
2196
 
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
2197
  // src/reports/airtable/reports.ts
2145
2198
  var REPORTS_TABLE = "Reports";
2146
2199
  function mapRow2(rec) {
@@ -2242,6 +2295,40 @@ async function stampSent(base, recordId, sentAt, messageId) {
2242
2295
  ]);
2243
2296
  }
2244
2297
 
2298
+ // src/reports/airtable/attachments.ts
2299
+ async function fetchAttachmentBytes(url) {
2300
+ const res = await fetch(url);
2301
+ if (!res.ok) {
2302
+ throw new Error(
2303
+ `Failed to fetch Airtable attachment ${res.status} ${res.statusText} (url=${url})`
2304
+ );
2305
+ }
2306
+ const contentType = res.headers.get("content-type") ?? "application/octet-stream";
2307
+ const ab = await res.arrayBuffer();
2308
+ return { bytes: new Uint8Array(ab), contentType };
2309
+ }
2310
+ async function uploadAttachment(recordId, fieldName, body, filename, contentType) {
2311
+ const apiKey = process.env.AIRTABLE_PAT;
2312
+ const baseId = process.env.AIRTABLE_BASE_ID;
2313
+ if (!apiKey || !baseId) {
2314
+ throw new Error("AIRTABLE_PAT and AIRTABLE_BASE_ID must be set");
2315
+ }
2316
+ const base64 = typeof body === "string" ? Buffer.from(body, "utf-8").toString("base64") : Buffer.from(body).toString("base64");
2317
+ const payload = { contentType, file: base64, filename };
2318
+ const url = `https://content.airtable.com/v0/${baseId}/${recordId}/${encodeURIComponent(fieldName)}/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(payload)
2326
+ });
2327
+ if (!res.ok) {
2328
+ throw new Error(`Airtable upload failed: ${res.status} ${res.statusText} ${await res.text()}`);
2329
+ }
2330
+ }
2331
+
2245
2332
  // src/reports/draft.ts
2246
2333
  function scoresFromWebsite(siteRow) {
2247
2334
  const { pScore, rScore, bpScore, seoScore } = siteRow;
@@ -2280,7 +2367,7 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
2280
2367
  });
2281
2368
  if (options.previewOnly) {
2282
2369
  const path = options.previewPath ?? `reports/${slug}/draft.html`;
2283
- await mkdir(dirname2(path), { recursive: true });
2370
+ await mkdir(dirname3(path), { recursive: true });
2284
2371
  await writeFile9(path, html, "utf-8");
2285
2372
  return { reportRow: null, htmlPath: path, html };
2286
2373
  }
@@ -2296,7 +2383,8 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
2296
2383
  lighthouse: scores,
2297
2384
  lastTestedDate
2298
2385
  });
2299
- await uploadHtmlAttachment(created.id, html, slug, periodEnd);
2386
+ const htmlFilename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;
2387
+ await uploadAttachment(created.id, "Rendered HTML", html, htmlFilename, "text/html");
2300
2388
  await setDraftReady(base, created.id, true);
2301
2389
  return { reportRow: created, htmlPath: null, html };
2302
2390
  }
@@ -2306,28 +2394,6 @@ async function derivePeriodStart(base, siteRow, reportType, today) {
2306
2394
  const latest = sameType[sameType.length - 1];
2307
2395
  return latest ? new Date(latest) : daysAgo(today, 30);
2308
2396
  }
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
2397
 
2332
2398
  // src/reports/airtable/client.ts
2333
2399
  import Airtable from "airtable";
@@ -2342,19 +2408,6 @@ function openBase(cfg) {
2342
2408
  return new Airtable({ apiKey: cfg.apiKey }).base(cfg.baseId);
2343
2409
  }
2344
2410
 
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
2411
  // src/reports/send/resend.ts
2359
2412
  import { Resend } from "resend";
2360
2413
  function defaultResendClient() {
@@ -2419,6 +2472,7 @@ async function sendOne(client, base, site, report) {
2419
2472
  throw new Error(`Report ${report.reportId} has no Lighthouse scores`);
2420
2473
  }
2421
2474
  const { bytes, contentType } = await fetchAttachmentBytes(site.headerImage.url);
2475
+ const bundled = await loadBundledImages();
2422
2476
  const slug = siteSlug(site.name);
2423
2477
  const cidName = `${slug}-header`;
2424
2478
  const { html } = await renderReportHtml({
@@ -2471,6 +2525,21 @@ async function sendOne(client, base, site, report) {
2471
2525
  content: Buffer.from(bytes).toString("base64"),
2472
2526
  contentType,
2473
2527
  inlineContentId: cidName
2528
+ },
2529
+ // Bundled images referenced via cid:rd-check-png / cid:rd-blurred-tests-jpg
2530
+ // in the template. Attached inline so the email is self-contained — no
2531
+ // external CDN dependency, no image-blocked broken icons in webmail.
2532
+ {
2533
+ filename: bundled.check.filename,
2534
+ content: Buffer.from(bundled.check.bytes).toString("base64"),
2535
+ contentType: bundled.check.contentType,
2536
+ inlineContentId: bundled.check.cid
2537
+ },
2538
+ {
2539
+ filename: bundled.blurred.filename,
2540
+ content: Buffer.from(bundled.blurred.bytes).toString("base64"),
2541
+ contentType: bundled.blurred.contentType,
2542
+ inlineContentId: bundled.blurred.cid
2474
2543
  }
2475
2544
  ],
2476
2545
  // Stable across retries of the same row — if Airtable stamping fails after a
@@ -2508,6 +2577,12 @@ function isProbablyEmail(s) {
2508
2577
  }
2509
2578
 
2510
2579
  // src/reports/due.ts
2580
+ var ELIGIBLE_STATUSES = /* @__PURE__ */ new Set([
2581
+ "in development",
2582
+ "launch period",
2583
+ "maintenance",
2584
+ "hosting"
2585
+ ]);
2511
2586
  var MONTHS = {
2512
2587
  Monthly: 1,
2513
2588
  Quarterly: 3,
@@ -2537,6 +2612,7 @@ function findDueReports(websites, reports, today) {
2537
2612
  const out = [];
2538
2613
  const todayStart = startOfDay(today);
2539
2614
  for (const site of websites) {
2615
+ if (site.status !== null && !ELIGIBLE_STATUSES.has(site.status)) continue;
2540
2616
  for (const type of ["Maintenance", "Testing"]) {
2541
2617
  const freq = type === "Maintenance" ? site.maintenanceFreq : site.testingFreq;
2542
2618
  if (freq === "None") continue;
@@ -2564,6 +2640,7 @@ export {
2564
2640
  depsAudit,
2565
2641
  draftReportForSite,
2566
2642
  findDueReports,
2643
+ fromAirtableBase,
2567
2644
  fromJsonFile,
2568
2645
  isRecipeName,
2569
2646
  lighthouseAudit,