@reddoorla/maintenance 0.49.0 → 0.50.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
@@ -71,6 +71,16 @@ function isHttpUrl(s) {
71
71
  }
72
72
  return parsed.protocol === "http:" || parsed.protocol === "https:";
73
73
  }
74
+ function isNetlifyAppUrl(s) {
75
+ let parsed;
76
+ try {
77
+ parsed = new URL(s);
78
+ } catch {
79
+ return false;
80
+ }
81
+ const host = parsed.hostname.toLowerCase();
82
+ return host === "netlify.app" || host.endsWith(".netlify.app");
83
+ }
74
84
  var init_url = __esm({
75
85
  "src/util/url.ts"() {
76
86
  "use strict";
@@ -198,6 +208,14 @@ function mapRow(rec) {
198
208
  securityVulnsHigh: f["Security Vulns High"] ?? null,
199
209
  securityVulnsModerate: f["Security Vulns Moderate"] ?? null,
200
210
  securityVulnsLow: f["Security Vulns Low"] ?? null,
211
+ lastSecurityAuditAt: f["Last security audit at"] ?? null,
212
+ certDaysRemaining: f["Cert days remaining"] ?? null,
213
+ domainCheckedAt: f["Domain checked at"] ?? null,
214
+ crossbrowserOk: typeof f["Crossbrowser OK"] === "boolean" ? f["Crossbrowser OK"] : null,
215
+ mobileOk: typeof f["Mobile OK"] === "boolean" ? f["Mobile OK"] : null,
216
+ linksOk: typeof f["Links OK"] === "boolean" ? f["Links OK"] : null,
217
+ brokenLinks: typeof f["Broken links"] === "number" ? f["Broken links"] : null,
218
+ browserCheckedAt: f["Browser checked at"] ?? null,
201
219
  copyIntro: trimToNull(f["Copy \u2014 Intro"]),
202
220
  copyContact: trimToNull(f["Copy \u2014 Contact"]),
203
221
  copyFooter: trimToNull(f["Copy \u2014 Footer"]),
@@ -259,7 +277,24 @@ function securityFields(counts) {
259
277
  "Security Vulns Critical": counts.critical,
260
278
  "Security Vulns High": counts.high,
261
279
  "Security Vulns Moderate": counts.moderate,
262
- "Security Vulns Low": counts.low
280
+ "Security Vulns Low": counts.low,
281
+ // Stamp freshness alongside the counts so the Security Updates auto-tick can require a recent
282
+ // audit (a clean count from months ago must not silently keep ticking the box).
283
+ "Last security audit at": (/* @__PURE__ */ new Date()).toISOString()
284
+ };
285
+ }
286
+ function domainFields(result) {
287
+ const fields = { "Domain checked at": result.checkedAt };
288
+ if (result.certDaysRemaining !== null) fields["Cert days remaining"] = result.certDaysRemaining;
289
+ return fields;
290
+ }
291
+ function browserFields(r) {
292
+ return {
293
+ "Crossbrowser OK": r.desktopOk,
294
+ "Mobile OK": r.mobileOk,
295
+ "Links OK": r.linksOk,
296
+ "Broken links": r.brokenLinks,
297
+ "Browser checked at": r.checkedAt
263
298
  };
264
299
  }
265
300
  async function updateScores(base, recordId, scores) {
@@ -280,6 +315,8 @@ async function updateAuditFields(base, recordId, audits) {
280
315
  if (audits.a11y) Object.assign(fields, a11yFields(audits.a11y));
281
316
  if (audits.deps) Object.assign(fields, depsFields(audits.deps));
282
317
  if (audits.security) Object.assign(fields, securityFields(audits.security));
318
+ if (audits.domain) Object.assign(fields, domainFields(audits.domain));
319
+ if (audits.browser) Object.assign(fields, browserFields(audits.browser));
283
320
  await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
284
321
  return fields;
285
322
  }
@@ -474,6 +511,53 @@ var init_security_airtable = __esm({
474
511
  }
475
512
  });
476
513
 
514
+ // src/audits/domain-airtable.ts
515
+ function hasDomainResult(result) {
516
+ if (result.audit !== "domain") return false;
517
+ const d = result.details;
518
+ return !!d && typeof d.checkedAt === "string";
519
+ }
520
+ function domainResultFromAudit(result) {
521
+ if (result.audit !== "domain") {
522
+ throw new Error(`Expected a 'domain' AuditResult, got '${result.audit}'`);
523
+ }
524
+ const d = result.details;
525
+ return {
526
+ certDaysRemaining: typeof d?.certDaysRemaining === "number" ? d.certDaysRemaining : null,
527
+ checkedAt: typeof d?.checkedAt === "string" ? d.checkedAt : (/* @__PURE__ */ new Date()).toISOString()
528
+ };
529
+ }
530
+ var init_domain_airtable = __esm({
531
+ "src/audits/domain-airtable.ts"() {
532
+ "use strict";
533
+ }
534
+ });
535
+
536
+ // src/audits/browser-airtable.ts
537
+ function hasBrowserResult(result) {
538
+ if (result.audit !== "browser") return false;
539
+ const d = result.details;
540
+ return !!d && typeof d.checkedAt === "string";
541
+ }
542
+ function browserFieldsFromAudit(result) {
543
+ if (result.audit !== "browser") {
544
+ throw new Error(`Expected a 'browser' AuditResult, got '${result.audit}'`);
545
+ }
546
+ const d = result.details;
547
+ return {
548
+ desktopOk: d?.desktopOk === true,
549
+ mobileOk: d?.mobileOk === true,
550
+ linksOk: d?.linksOk === true,
551
+ brokenLinks: typeof d?.brokenLinks === "number" ? d.brokenLinks : 0,
552
+ checkedAt: typeof d?.checkedAt === "string" ? d.checkedAt : (/* @__PURE__ */ new Date()).toISOString()
553
+ };
554
+ }
555
+ var init_browser_airtable = __esm({
556
+ "src/audits/browser-airtable.ts"() {
557
+ "use strict";
558
+ }
559
+ });
560
+
477
561
  // src/audits/write-audits-to-airtable.ts
478
562
  var write_audits_to_airtable_exports = {};
479
563
  __export(write_audits_to_airtable_exports, {
@@ -484,22 +568,14 @@ __export(write_audits_to_airtable_exports, {
484
568
  async function writeAuditsToAirtable(args) {
485
569
  const { base, websites, slug, results } = args;
486
570
  const lhResult = results.find((r) => r.audit === "lighthouse");
487
- if (!lhResult) {
488
- throw Object.assign(
489
- new Error(
490
- "--write-airtable requires a lighthouse result; did you pass --only without lighthouse?"
491
- ),
492
- { exitCode: 2 }
493
- );
494
- }
495
571
  const target = websites.find((w) => siteSlug(w.name) === slug);
496
572
  if (!target) {
497
573
  throw Object.assign(new Error(`No Websites row matched slug "${slug}"`), { exitCode: 2 });
498
574
  }
499
575
  const writes = [];
500
576
  const audits = {};
501
- const lhHasScores = hasRealScores(lhResult);
502
- if (lhHasScores) {
577
+ const lhHasScores = lhResult ? hasRealScores(lhResult) : false;
578
+ if (lhResult && lhHasScores) {
503
579
  const scores = lighthouseScoresFromResult(lhResult);
504
580
  audits.scores = scores;
505
581
  writes.push({ audit: "lighthouse", counts: scores });
@@ -522,10 +598,22 @@ async function writeAuditsToAirtable(args) {
522
598
  audits.security = counts;
523
599
  writes.push({ audit: "security", counts });
524
600
  }
601
+ const dom = results.find((r) => r.audit === "domain");
602
+ if (dom && hasDomainResult(dom)) {
603
+ const result = domainResultFromAudit(dom);
604
+ audits.domain = result;
605
+ writes.push({ audit: "domain", counts: result });
606
+ }
607
+ const browser = results.find((r) => r.audit === "browser");
608
+ if (browser && hasBrowserResult(browser)) {
609
+ const fields = browserFieldsFromAudit(browser);
610
+ audits.browser = fields;
611
+ writes.push({ audit: "browser", counts: fields });
612
+ }
525
613
  if (Object.keys(audits).length > 0) {
526
614
  await updateAuditFields(base, target.id, audits);
527
615
  }
528
- if (!lhHasScores) {
616
+ if (lhResult && !lhHasScores) {
529
617
  const persisted = writes.map((w) => w.audit);
530
618
  throw Object.assign(
531
619
  new Error(
@@ -576,6 +664,8 @@ var init_write_audits_to_airtable = __esm({
576
664
  init_a11y_airtable();
577
665
  init_deps_airtable();
578
666
  init_security_airtable();
667
+ init_domain_airtable();
668
+ init_browser_airtable();
579
669
  }
580
670
  });
581
671
 
@@ -661,9 +751,32 @@ function mapRow2(rec) {
661
751
  deliveryStatus: f["Delivery status"] ?? "pending",
662
752
  renderedHtmlAttachment: html,
663
753
  resendMessageId: f["Resend message ID"] ?? null,
664
- checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])]))
754
+ checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])])),
755
+ autoEvidence: parseAutoEvidence(f["Checklist auto-evidence"])
665
756
  };
666
757
  }
758
+ function parseAutoEvidence(raw) {
759
+ if (typeof raw !== "string" || !raw.trim()) return null;
760
+ let parsed;
761
+ try {
762
+ parsed = JSON.parse(raw);
763
+ } catch {
764
+ return null;
765
+ }
766
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
767
+ const out = {};
768
+ for (const [field, v] of Object.entries(parsed)) {
769
+ if (!v || typeof v !== "object") continue;
770
+ const o = v;
771
+ if (o.result !== "pass" && o.result !== "fail" && o.result !== "unknown") continue;
772
+ out[field] = {
773
+ result: o.result,
774
+ checkedAt: typeof o.checkedAt === "string" ? o.checkedAt : null,
775
+ note: typeof o.note === "string" ? o.note : ""
776
+ };
777
+ }
778
+ return Object.keys(out).length > 0 ? out : null;
779
+ }
667
780
  function lighthouseFromFields(f) {
668
781
  const p = f["Lighthouse \u2014 Performance"];
669
782
  const a = f["Lighthouse \u2014 Accessibility"];
@@ -700,6 +813,10 @@ async function createDraft(base, input) {
700
813
  if (input.searchPosition !== void 0) fields["Search position"] = input.searchPosition;
701
814
  if (input.period !== void 0) fields["Period"] = input.period;
702
815
  if (input.subjectOverride !== void 0) fields["Subject override"] = input.subjectOverride;
816
+ for (const field of input.checklistTicks ?? []) fields[field] = true;
817
+ if (input.autoEvidence && Object.keys(input.autoEvidence).length > 0) {
818
+ fields["Checklist auto-evidence"] = JSON.stringify(input.autoEvidence);
819
+ }
703
820
  const created = await base(REPORTS_TABLE).create([{ fields }]);
704
821
  const rec = created[0];
705
822
  if (!rec) throw new Error("Airtable create returned no records");
@@ -1470,7 +1587,7 @@ function gitHubSignalsStale(swept, now) {
1470
1587
  if (swept === null) return true;
1471
1588
  const ageMs = now.getTime() - Date.parse(swept);
1472
1589
  if (!Number.isFinite(ageMs)) return true;
1473
- return ageMs > GITHUB_SIGNALS_STALE_DAYS * MS_PER_DAY2;
1590
+ return ageMs > GITHUB_SIGNALS_STALE_DAYS * MS_PER_DAY4;
1474
1591
  }
1475
1592
  function collectVulnAlerts(sites, baseUrl) {
1476
1593
  const items = [];
@@ -1564,13 +1681,13 @@ function collectCiAlerts(sites, baseUrl, now = /* @__PURE__ */ new Date()) {
1564
1681
  }
1565
1682
  return items;
1566
1683
  }
1567
- var GITHUB_SIGNALS_STALE_DAYS, MS_PER_DAY2, LIGHTHOUSE_FLOOR, LIGHTHOUSE_CATEGORIES2;
1684
+ var GITHUB_SIGNALS_STALE_DAYS, MS_PER_DAY4, LIGHTHOUSE_FLOOR, LIGHTHOUSE_CATEGORIES2;
1568
1685
  var init_digest_collectors = __esm({
1569
1686
  "src/alerts/digest-collectors.ts"() {
1570
1687
  "use strict";
1571
1688
  init_websites();
1572
1689
  GITHUB_SIGNALS_STALE_DAYS = 3;
1573
- MS_PER_DAY2 = 24 * 60 * 60 * 1e3;
1690
+ MS_PER_DAY4 = 24 * 60 * 60 * 1e3;
1574
1691
  LIGHTHOUSE_FLOOR = 75;
1575
1692
  LIGHTHOUSE_CATEGORIES2 = [
1576
1693
  { field: "pScore", slug: "performance", label: "Performance" },
@@ -2015,35 +2132,33 @@ async function sendOne(client, base, site, report) {
2015
2132
  });
2016
2133
  const reportDate = report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date();
2017
2134
  const subject = report.subjectOverride ?? `${site.name} \u2014 ${monthYear(reportDate)} ${report.reportType} Report`;
2135
+ const attachments = [
2136
+ toInlineAttachment({
2137
+ bytes: header.bytes,
2138
+ filename: `${cidName}.jpg`,
2139
+ contentType: header.contentType,
2140
+ cid: cidName
2141
+ })
2142
+ ];
2143
+ for (const img of [bundled.check, bundled.blurred]) {
2144
+ if (html.includes(`cid:${img.cid}`)) {
2145
+ attachments.push(
2146
+ toInlineAttachment({
2147
+ bytes: img.bytes,
2148
+ filename: img.filename,
2149
+ contentType: img.contentType,
2150
+ cid: img.cid
2151
+ })
2152
+ );
2153
+ }
2154
+ }
2018
2155
  const payload = {
2019
2156
  from: FROM_ADDRESS2,
2020
2157
  to,
2021
2158
  replyTo: REPLY_TO,
2022
2159
  subject,
2023
2160
  html,
2024
- attachments: [
2025
- toInlineAttachment({
2026
- bytes: header.bytes,
2027
- filename: `${cidName}.jpg`,
2028
- contentType: header.contentType,
2029
- cid: cidName
2030
- }),
2031
- // Bundled images referenced via cid:rd-check-png / cid:rd-blurred-tests-jpg
2032
- // in the template. Attached inline so the email is self-contained — no
2033
- // external CDN dependency, no image-blocked broken icons in webmail.
2034
- toInlineAttachment({
2035
- bytes: bundled.check.bytes,
2036
- filename: bundled.check.filename,
2037
- contentType: bundled.check.contentType,
2038
- cid: bundled.check.cid
2039
- }),
2040
- toInlineAttachment({
2041
- bytes: bundled.blurred.bytes,
2042
- filename: bundled.blurred.filename,
2043
- contentType: bundled.blurred.contentType,
2044
- cid: bundled.blurred.cid
2045
- })
2046
- ],
2161
+ attachments,
2047
2162
  // Stable across retries of the same row — if Airtable stamping fails after a
2048
2163
  // successful Resend, the next --send-ready replays with the same key and
2049
2164
  // Resend returns the original message id rather than sending a duplicate.
@@ -3073,13 +3188,348 @@ async function a11yAudit(ctx) {
3073
3188
  }
3074
3189
  }
3075
3190
 
3191
+ // src/audits/domain.ts
3192
+ import { promises as dnsPromises } from "dns";
3193
+ import tls from "tls";
3194
+ var MS_PER_DAY = 24 * 60 * 60 * 1e3;
3195
+ async function checkDomain(url, deps) {
3196
+ let host;
3197
+ try {
3198
+ host = new URL(url).hostname;
3199
+ } catch {
3200
+ return { resolved: false, certDaysRemaining: null };
3201
+ }
3202
+ try {
3203
+ await deps.lookup(host);
3204
+ } catch {
3205
+ return { resolved: false, certDaysRemaining: null };
3206
+ }
3207
+ let validTo;
3208
+ try {
3209
+ validTo = await deps.certValidTo(host);
3210
+ } catch {
3211
+ validTo = null;
3212
+ }
3213
+ if (!validTo || Number.isNaN(validTo.getTime()))
3214
+ return { resolved: true, certDaysRemaining: null };
3215
+ return {
3216
+ resolved: true,
3217
+ certDaysRemaining: Math.floor((validTo.getTime() - deps.now.getTime()) / MS_PER_DAY)
3218
+ };
3219
+ }
3220
+ function defaultDomainDeps(now) {
3221
+ return {
3222
+ lookup: async (host) => {
3223
+ await dnsPromises.lookup(host);
3224
+ },
3225
+ certValidTo: (host) => new Promise((resolvePromise) => {
3226
+ const socket = tls.connect(
3227
+ { host, port: 443, servername: host, timeout: 1e4, rejectUnauthorized: true },
3228
+ () => {
3229
+ const cert = socket.authorized ? socket.getPeerCertificate() : null;
3230
+ socket.end();
3231
+ const validTo = cert && cert.valid_to ? new Date(cert.valid_to) : null;
3232
+ resolvePromise(validTo);
3233
+ }
3234
+ );
3235
+ socket.on("error", () => resolvePromise(null));
3236
+ socket.on("timeout", () => {
3237
+ socket.destroy();
3238
+ resolvePromise(null);
3239
+ });
3240
+ }),
3241
+ now
3242
+ };
3243
+ }
3244
+ async function domainAudit(ctx) {
3245
+ const { site } = ctx;
3246
+ const label = siteLabel(site);
3247
+ if (!site.deployedUrl) {
3248
+ return { audit: "domain", site: label, status: "skip", summary: "no deployed URL" };
3249
+ }
3250
+ const now = ctx.now ?? /* @__PURE__ */ new Date();
3251
+ const deps = ctx.domainDeps ?? defaultDomainDeps(now);
3252
+ const check = await checkDomain(site.deployedUrl, deps);
3253
+ const checkedAt = now.toISOString();
3254
+ const status = check.resolved && check.certDaysRemaining !== null && check.certDaysRemaining > 14 ? "pass" : "warn";
3255
+ const summary = !check.resolved ? "did not resolve" : check.certDaysRemaining === null ? "resolved, no usable TLS cert" : `resolved, cert ${check.certDaysRemaining}d remaining`;
3256
+ return {
3257
+ audit: "domain",
3258
+ site: label,
3259
+ status,
3260
+ summary,
3261
+ details: { resolved: check.resolved, certDaysRemaining: check.certDaysRemaining, checkedAt }
3262
+ };
3263
+ }
3264
+
3265
+ // src/audits/route-discovery.ts
3266
+ var DEFAULT_CAP = 15;
3267
+ function parseSitemapUrls(xml) {
3268
+ const out = [];
3269
+ const re = /<loc>\s*([^<\s]+)\s*<\/loc>/gi;
3270
+ let m;
3271
+ while ((m = re.exec(xml)) !== null) {
3272
+ const url = m[1];
3273
+ if (url) out.push(url.trim());
3274
+ }
3275
+ return out;
3276
+ }
3277
+ function parseHtmlLinks(html, baseUrl) {
3278
+ const out = /* @__PURE__ */ new Set();
3279
+ const re = /<a\b[^>]*\bhref\s*=\s*["']([^"']+)["']/gi;
3280
+ let m;
3281
+ while ((m = re.exec(html)) !== null) {
3282
+ const href = m[1];
3283
+ if (!href || href.startsWith("#") || /^(mailto:|tel:|javascript:)/i.test(href)) continue;
3284
+ try {
3285
+ const u = new URL(href, baseUrl);
3286
+ if (u.origin !== new URL(baseUrl).origin) continue;
3287
+ out.add(u.pathname);
3288
+ } catch {
3289
+ }
3290
+ }
3291
+ return [...out];
3292
+ }
3293
+ function family(pathname) {
3294
+ return pathname.split("/").filter(Boolean)[0] ?? "";
3295
+ }
3296
+ function sampleRoutePaths(urlsOrPaths, cap = DEFAULT_CAP) {
3297
+ const seen = /* @__PURE__ */ new Set(["/"]);
3298
+ const buckets = /* @__PURE__ */ new Map();
3299
+ for (const raw of urlsOrPaths) {
3300
+ let pathname;
3301
+ try {
3302
+ pathname = raw.startsWith("/") ? new URL(raw, "https://x.invalid").pathname : new URL(raw).pathname;
3303
+ } catch {
3304
+ continue;
3305
+ }
3306
+ if (pathname === "/") continue;
3307
+ if (seen.has(pathname)) continue;
3308
+ seen.add(pathname);
3309
+ const fam = family(pathname);
3310
+ const arr = buckets.get(fam) ?? [];
3311
+ arr.push(pathname);
3312
+ buckets.set(fam, arr);
3313
+ }
3314
+ const result = ["/"];
3315
+ const families = [...buckets.values()];
3316
+ let guard = 0;
3317
+ while (result.length < cap && families.some((f) => f.length > 0) && guard++ < 1e4) {
3318
+ for (const fam of families) {
3319
+ if (result.length >= cap) break;
3320
+ const next = fam.shift();
3321
+ if (next) result.push(next);
3322
+ }
3323
+ }
3324
+ return result;
3325
+ }
3326
+ function familyCountsOf(paths) {
3327
+ const counts = {};
3328
+ for (const p of paths) {
3329
+ const key = p === "/" ? "/" : `/${family(p)}`;
3330
+ counts[key] = (counts[key] ?? 0) + 1;
3331
+ }
3332
+ return counts;
3333
+ }
3334
+ async function discoverRoutes(deployedUrl, deps, cap = DEFAULT_CAP) {
3335
+ const origin = new URL(deployedUrl).origin;
3336
+ const abs = (paths) => paths.map((p) => new URL(p, origin).href);
3337
+ const sitemapXml = await deps.fetchText(new URL("/sitemap.xml", origin).href);
3338
+ if (sitemapXml) {
3339
+ const urls = parseSitemapUrls(sitemapXml);
3340
+ if (urls.length > 0) {
3341
+ const paths = sampleRoutePaths(urls, cap);
3342
+ return { routes: abs(paths), source: "sitemap", familyCounts: familyCountsOf(paths) };
3343
+ }
3344
+ }
3345
+ const homeHtml = await deps.fetchText(origin);
3346
+ if (homeHtml) {
3347
+ const links = parseHtmlLinks(homeHtml, origin);
3348
+ if (links.length > 0) {
3349
+ const paths = sampleRoutePaths(links, cap);
3350
+ return { routes: abs(paths), source: "homepage-links", familyCounts: familyCountsOf(paths) };
3351
+ }
3352
+ }
3353
+ return { routes: [new URL("/", origin).href], source: "root-only", familyCounts: { "/": 1 } };
3354
+ }
3355
+
3356
+ // src/audits/browser.ts
3357
+ function isBroken(status) {
3358
+ return status === null || status >= 400;
3359
+ }
3360
+ function summarizeBrowser(routes, links, familyCounts) {
3361
+ const desktopChecks = routes.flatMap((r) => r.desktop);
3362
+ const mobileChecks = routes.flatMap((r) => r.mobile);
3363
+ const desktopOk = routes.length > 0 && routes.every((r) => r.desktop.length > 0 && r.desktop.every((d) => d.ok));
3364
+ const mobileOk = routes.length > 0 && routes.every((r) => r.mobile.length > 0 && r.mobile.every((m) => m.ok));
3365
+ const brokenLinks = links.filter((l) => isBroken(l.status)).length;
3366
+ const linksOk = links.length > 0 && brokenLinks === 0;
3367
+ const engines = [...new Set(desktopChecks.map((d) => d.engine))];
3368
+ const devices2 = [...new Set(mobileChecks.map((m) => m.device))];
3369
+ const families = Object.entries(familyCounts).map(([f, n]) => f === "/" ? "/" : `${f} \xD7${n}`).join(", ");
3370
+ const note = `${routes.length} routes (${families}); desktop ${engines.join("/") || "\u2014"}; mobile ${devices2.join("/") || "\u2014"}; ${links.length} links, ${brokenLinks} broken`;
3371
+ return { desktopOk, mobileOk, linksOk, brokenLinks, routesChecked: routes.length, note };
3372
+ }
3373
+ async function browserAudit(ctx) {
3374
+ const { site } = ctx;
3375
+ const label = siteLabel(site);
3376
+ if (!site.deployedUrl) {
3377
+ return { audit: "browser", site: label, status: "skip", summary: "no deployed URL" };
3378
+ }
3379
+ const now = ctx.now ?? /* @__PURE__ */ new Date();
3380
+ const discoverDeps = ctx.discoverDeps ?? defaultDiscoverDeps();
3381
+ const runner = ctx.browserRunner ?? await defaultBrowserRunner();
3382
+ try {
3383
+ const discovered = await discoverRoutes(site.deployedUrl, discoverDeps);
3384
+ const routeResults = await runner.probe(discovered.routes);
3385
+ const internalLinks = [...new Set(routeResults.flatMap((r) => r.links))];
3386
+ const linkResults = await runner.checkLinks(internalLinks);
3387
+ const summary = summarizeBrowser(
3388
+ routeResults,
3389
+ linkResults,
3390
+ discovered.familyCounts ?? familyCountsOf(discovered.routes)
3391
+ );
3392
+ const status = summary.desktopOk && summary.mobileOk && summary.linksOk ? "pass" : "warn";
3393
+ return {
3394
+ audit: "browser",
3395
+ site: label,
3396
+ status,
3397
+ summary: summary.note,
3398
+ details: { ...summary, checkedAt: now.toISOString() }
3399
+ };
3400
+ } finally {
3401
+ await runner.close?.();
3402
+ }
3403
+ }
3404
+ function defaultDiscoverDeps() {
3405
+ return {
3406
+ fetchText: async (url) => {
3407
+ try {
3408
+ const res = await fetch(url, { redirect: "follow" });
3409
+ if (!res.ok) return null;
3410
+ return await res.text();
3411
+ } catch {
3412
+ return null;
3413
+ }
3414
+ }
3415
+ };
3416
+ }
3417
+ var DESKTOP_VIEWPORT = { width: 1366, height: 900 };
3418
+ var PAGE_TIMEOUT_MS = 3e4;
3419
+ async function defaultBrowserRunner() {
3420
+ const { chromium, firefox, webkit, devices: devices2 } = await import("@playwright/test");
3421
+ const desktopEngines = [
3422
+ { engine: "chromium", type: chromium },
3423
+ { engine: "firefox", type: firefox },
3424
+ { engine: "webkit", type: webkit }
3425
+ ];
3426
+ const mobileTargets = [
3427
+ { device: "Pixel 7", descriptor: devices2["Pixel 7"] },
3428
+ { device: "iPhone 14", descriptor: devices2["iPhone 14"] }
3429
+ ];
3430
+ return {
3431
+ async probe(urls) {
3432
+ const results = [];
3433
+ const browsers = await Promise.all(desktopEngines.map((e) => e.type.launch()));
3434
+ const mobileBrowsers = await Promise.all(mobileTargets.map(() => chromium.launch()));
3435
+ try {
3436
+ for (const url of urls) {
3437
+ const desktop = [];
3438
+ const linkSet = /* @__PURE__ */ new Set();
3439
+ for (let i = 0; i < desktopEngines.length; i++) {
3440
+ const engine = desktopEngines[i].engine;
3441
+ const browser = browsers[i];
3442
+ const ctx = await browser.newContext({ viewport: DESKTOP_VIEWPORT });
3443
+ const page = await ctx.newPage();
3444
+ const errors = [];
3445
+ page.on("pageerror", (e) => errors.push(String(e)));
3446
+ let ok = false;
3447
+ try {
3448
+ const resp = await page.goto(url, {
3449
+ waitUntil: "domcontentloaded",
3450
+ timeout: PAGE_TIMEOUT_MS
3451
+ });
3452
+ const hasMain = await page.locator("main, [role=main]").first().isVisible().catch(() => false);
3453
+ ok = !!resp && resp.ok() && errors.length === 0 && hasMain;
3454
+ if (engine === "chromium") {
3455
+ const hrefs = await page.evaluate("Array.from(document.querySelectorAll('a[href]')).map((a) => a.href)").catch(() => []);
3456
+ const origin = new URL(url).origin;
3457
+ for (const h of hrefs) {
3458
+ try {
3459
+ if (new URL(h).origin === origin) linkSet.add(new URL(h).href);
3460
+ } catch {
3461
+ }
3462
+ }
3463
+ }
3464
+ } catch {
3465
+ ok = false;
3466
+ } finally {
3467
+ await ctx.close().catch(() => {
3468
+ });
3469
+ }
3470
+ desktop.push({ engine, ok });
3471
+ }
3472
+ const mobile = [];
3473
+ for (let i = 0; i < mobileTargets.length; i++) {
3474
+ const { device, descriptor } = mobileTargets[i];
3475
+ const browser = mobileBrowsers[i];
3476
+ const ctx = await browser.newContext({ ...descriptor });
3477
+ const page = await ctx.newPage();
3478
+ const errors = [];
3479
+ page.on("pageerror", (e) => errors.push(String(e)));
3480
+ let ok = false;
3481
+ try {
3482
+ const resp = await page.goto(url, {
3483
+ waitUntil: "domcontentloaded",
3484
+ timeout: PAGE_TIMEOUT_MS
3485
+ });
3486
+ const overflow = await page.evaluate("document.documentElement.scrollWidth > window.innerWidth + 2").catch(() => true);
3487
+ ok = !!resp && resp.ok() && errors.length === 0 && !overflow;
3488
+ } catch {
3489
+ ok = false;
3490
+ } finally {
3491
+ await ctx.close().catch(() => {
3492
+ });
3493
+ }
3494
+ mobile.push({ device, ok });
3495
+ }
3496
+ results.push({ url, desktop, mobile, links: [...linkSet] });
3497
+ }
3498
+ } finally {
3499
+ await Promise.all([...browsers, ...mobileBrowsers].map((b) => b.close().catch(() => {
3500
+ })));
3501
+ }
3502
+ return results;
3503
+ },
3504
+ async checkLinks(urls) {
3505
+ const out = [];
3506
+ for (const url of urls) {
3507
+ let status;
3508
+ try {
3509
+ let res = await fetch(url, { method: "HEAD", redirect: "follow" });
3510
+ if (res.status === 405 || res.status === 501) {
3511
+ res = await fetch(url, { method: "GET", redirect: "follow" });
3512
+ }
3513
+ status = res.status;
3514
+ } catch {
3515
+ status = null;
3516
+ }
3517
+ out.push({ url, status });
3518
+ }
3519
+ return out;
3520
+ }
3521
+ };
3522
+ }
3523
+
3076
3524
  // src/audits/index.ts
3077
3525
  var REGISTRY = {
3078
3526
  deps: depsAudit,
3079
3527
  lint: lintAudit,
3080
3528
  security: securityAudit,
3081
3529
  lighthouse: lighthouseAudit,
3082
- a11y: a11yAudit
3530
+ a11y: a11yAudit,
3531
+ domain: domainAudit,
3532
+ browser: browserAudit
3083
3533
  };
3084
3534
  var ALL_AUDIT_NAMES = Object.keys(REGISTRY);
3085
3535
  var DEFAULT_AUDIT_TIMEOUT_MS = 3e4;
@@ -3556,8 +4006,13 @@ function deployedUrlNotice(which, url, cwd) {
3556
4006
  if (others.length === 0) return null;
3557
4007
  return `note: --url only affects lighthouse; ${others.join(", ")} ran against the local checkout at ${cwd}`;
3558
4008
  }
4009
+ var CHECKOUT_FREE_AUDITS = /* @__PURE__ */ new Set([
4010
+ "lighthouse",
4011
+ "domain",
4012
+ "browser"
4013
+ ]);
3559
4014
  function auditNeedsCheckout(site, which) {
3560
- const deployedCapable = site.deployedUrl !== void 0 && which.every((n) => n === "lighthouse");
4015
+ const deployedCapable = site.deployedUrl !== void 0 && which.every((n) => CHECKOUT_FREE_AUDITS.has(n));
3561
4016
  return !deployedCapable;
3562
4017
  }
3563
4018
  function applyDeployedUrl(sites, url) {
@@ -5811,6 +6266,120 @@ async function queueDraft(base, report) {
5811
6266
  return { queued: true, supersededIds };
5812
6267
  }
5813
6268
 
6269
+ // src/reports/auto-tick.ts
6270
+ init_checklist();
6271
+ init_url();
6272
+ var STALE_DAYS = 3;
6273
+ var MS_PER_DAY2 = 24 * 60 * 60 * 1e3;
6274
+ function isFresh(checkedAt, now) {
6275
+ if (!checkedAt) return false;
6276
+ const t = new Date(checkedAt).getTime();
6277
+ if (Number.isNaN(t)) return false;
6278
+ return now.getTime() - t <= STALE_DAYS * MS_PER_DAY2;
6279
+ }
6280
+ var CERT_MIN_DAYS = 14;
6281
+ function autoTickChecklist(site, reportType, now, signals) {
6282
+ const out = /* @__PURE__ */ new Map();
6283
+ const fields = new Set(checklistFor(reportType).map((i) => i.field));
6284
+ if (fields.has("Maint: Google Indexed")) {
6285
+ const g = googleEvidence(now, signals.search);
6286
+ if (g) out.set("Maint: Google Indexed", g);
6287
+ }
6288
+ if (fields.has("Maint: Security Updates")) {
6289
+ const s = securityEvidence(site, now);
6290
+ if (s) out.set("Maint: Security Updates", s);
6291
+ }
6292
+ if (fields.has("Maint: Domain, DNS & SSL")) {
6293
+ const d = domainEvidence(site, now);
6294
+ if (d) out.set("Maint: Domain, DNS & SSL", d);
6295
+ }
6296
+ if (fields.has("Test: Desktop Browsers")) {
6297
+ const e = browserEvidence(
6298
+ site.crossbrowserOk,
6299
+ site,
6300
+ now,
6301
+ "Desktop renders cleanly",
6302
+ "render errors"
6303
+ );
6304
+ if (e) out.set("Test: Desktop Browsers", e);
6305
+ }
6306
+ if (fields.has("Test: Mobile Browsers")) {
6307
+ const e = browserEvidence(
6308
+ site.mobileOk,
6309
+ site,
6310
+ now,
6311
+ "Mobile renders cleanly",
6312
+ "overflow/errors"
6313
+ );
6314
+ if (e) out.set("Test: Mobile Browsers", e);
6315
+ }
6316
+ if (fields.has("Test: Links & Navigation")) {
6317
+ const broken = site.brokenLinks;
6318
+ const failNote = broken && broken > 0 ? `${broken} broken link(s)` : "broken links / nav";
6319
+ const e = browserEvidence(site.linksOk, site, now, "All internal links resolve", failNote);
6320
+ if (e) out.set("Test: Links & Navigation", e);
6321
+ }
6322
+ return out;
6323
+ }
6324
+ function browserEvidence(ok, site, now, passNote, failNote) {
6325
+ if (ok === null || !site.browserCheckedAt) return null;
6326
+ const at = site.browserCheckedAt;
6327
+ if (!isFresh(at, now)) {
6328
+ return { result: "unknown", checkedAt: at, note: "Browser check is stale (>3d)" };
6329
+ }
6330
+ return ok ? { result: "pass", checkedAt: at, note: passNote } : { result: "fail", checkedAt: at, note: failNote };
6331
+ }
6332
+ function securityEvidence(site, now) {
6333
+ const crit = site.securityVulnsCritical;
6334
+ const high = site.securityVulnsHigh;
6335
+ if (crit === null || high === null || !site.lastSecurityAuditAt) return null;
6336
+ const at = site.lastSecurityAuditAt;
6337
+ if (!isFresh(at, now)) {
6338
+ return { result: "unknown", checkedAt: at, note: "Security audit is stale (>3d)" };
6339
+ }
6340
+ if (crit === 0 && high === 0) {
6341
+ return { result: "pass", checkedAt: at, note: "No known critical/high vulnerabilities" };
6342
+ }
6343
+ return { result: "fail", checkedAt: at, note: `${crit} critical / ${high} high vuln(s)` };
6344
+ }
6345
+ function googleEvidence(now, search) {
6346
+ const at = now.toISOString();
6347
+ if (search.softFailed) {
6348
+ return { result: "unknown", checkedAt: at, note: "Search Console unavailable this run" };
6349
+ }
6350
+ if (search.value === null) return null;
6351
+ if (search.value.foundOnPage1) {
6352
+ const pos2 = search.value.position;
6353
+ return {
6354
+ result: "pass",
6355
+ checkedAt: at,
6356
+ note: `Page 1 on Google${pos2 !== null ? ` (#${pos2})` : ""}`
6357
+ };
6358
+ }
6359
+ const pos = search.value.position;
6360
+ return {
6361
+ result: "fail",
6362
+ checkedAt: at,
6363
+ note: `Not on page 1${pos !== null ? ` (avg #${pos})` : ""}`
6364
+ };
6365
+ }
6366
+ function domainEvidence(site, now) {
6367
+ if (!site.url || isNetlifyAppUrl(site.url)) return null;
6368
+ if (!site.domainCheckedAt) return null;
6369
+ const at = site.domainCheckedAt;
6370
+ if (!isFresh(site.domainCheckedAt, now)) {
6371
+ return { result: "unknown", checkedAt: at, note: "Domain check is stale (>3d)" };
6372
+ }
6373
+ const days = site.certDaysRemaining;
6374
+ if (days === null) {
6375
+ return { result: "fail", checkedAt: at, note: "Did not resolve, or no valid TLS cert" };
6376
+ }
6377
+ if (days <= CERT_MIN_DAYS) {
6378
+ return { result: "fail", checkedAt: at, note: `TLS cert expires in ${days}d` };
6379
+ }
6380
+ return { result: "pass", checkedAt: at, note: `Custom domain, valid cert (${days}d left)` };
6381
+ }
6382
+
5814
6383
  // src/reports/draft.ts
5815
6384
  init_attachments();
5816
6385
 
@@ -5829,7 +6398,7 @@ import { readFileSync as readFileSync3 } from "fs";
5829
6398
  import { JWT } from "google-auth-library";
5830
6399
  import { BetaAnalyticsDataClient } from "@google-analytics/data";
5831
6400
  var ANALYTICS_READONLY = "https://www.googleapis.com/auth/analytics.readonly";
5832
- var MS_PER_DAY = 864e5;
6401
+ var MS_PER_DAY3 = 864e5;
5833
6402
  function ymd2(d) {
5834
6403
  return d.toISOString().slice(0, 10);
5835
6404
  }
@@ -5842,9 +6411,9 @@ async function fetchPeriodUsers(query, periodStart, periodEnd) {
5842
6411
  subject: query.subject
5843
6412
  });
5844
6413
  const client = new BetaAnalyticsDataClient({ authClient });
5845
- const lengthDays = Math.round((periodEnd.getTime() - periodStart.getTime()) / MS_PER_DAY);
5846
- const prevEnd = new Date(periodStart.getTime() - MS_PER_DAY);
5847
- const prevStart = new Date(prevEnd.getTime() - lengthDays * MS_PER_DAY);
6414
+ const lengthDays = Math.round((periodEnd.getTime() - periodStart.getTime()) / MS_PER_DAY3);
6415
+ const prevEnd = new Date(periodStart.getTime() - MS_PER_DAY3);
6416
+ const prevStart = new Date(prevEnd.getTime() - lengthDays * MS_PER_DAY3);
5848
6417
  const property = `properties/${query.propertyId}`;
5849
6418
  const run = async (start, end) => {
5850
6419
  const [resp] = await client.runReport({
@@ -5968,7 +6537,7 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
5968
6537
  const periodStart = base !== null ? await derivePeriodStart(base, siteRow, reportType, today) : daysAgo(today, 30);
5969
6538
  const periodEnd = today;
5970
6539
  const completedOn = today;
5971
- const lastTestedDate = reportType === "Maintenance" && siteRow.testingDay ? new Date(siteRow.testingDay) : null;
6540
+ const lastTestedDate = reportType === "Maintenance" && siteRow.lastLighthouseAuditAt ? new Date(siteRow.lastLighthouseAuditAt) : null;
5972
6541
  const gaResult = base !== null ? await fetchGaUsers(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
5973
6542
  const searchResult = base !== null ? await fetchSearch(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
5974
6543
  const gaUsers = gaResult.value;
@@ -6015,6 +6584,9 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
6015
6584
  supersededIds: outcome2.supersededIds
6016
6585
  };
6017
6586
  }
6587
+ const evidence = autoTickChecklist(siteRow, reportType, completedOn, { search: searchResult });
6588
+ const checklistTicks = [...evidence.entries()].filter(([, e]) => e.result === "pass").map(([field]) => field);
6589
+ const autoEvidence = Object.fromEntries(evidence);
6018
6590
  const reportId = `${siteRow.name} \u2014 ${reportType} \u2014 ${periodEnd.toISOString().slice(0, 10)}`;
6019
6591
  const created = await createDraft(base, {
6020
6592
  reportId,
@@ -6028,7 +6600,9 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
6028
6600
  lastTestedDate,
6029
6601
  ...gaUsers ? { gaUsersCurrent: gaUsers.current, gaUsersPrevious: gaUsers.previous } : {},
6030
6602
  ...search ? { searchFoundPage1: search.foundOnPage1 } : {},
6031
- ...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {}
6603
+ ...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {},
6604
+ checklistTicks,
6605
+ autoEvidence
6032
6606
  });
6033
6607
  await uploadDraftHtml(created.id, slug, periodEnd, html);
6034
6608
  const outcome = await queueDraft(base, {
@@ -6859,7 +7433,9 @@ var AUDIT_DESCRIPTIONS = {
6859
7433
  lighthouse: "Run @lhci/cli autorun using the canonical lighthouserc.",
6860
7434
  a11y: "Playwright + axe against the canonical a11y routes.",
6861
7435
  security: "pnpm audit (falls back to npm audit), prod-deps by default.",
6862
- lint: "ESLint + Prettier using the canonical configs."
7436
+ lint: "ESLint + Prettier using the canonical configs.",
7437
+ domain: "DNS resolve + TLS cert expiry against the deployed URL (checkout-free).",
7438
+ browser: "Playwright across desktop engines + mobile devices + link-check against the deployed URL (checkout-free)."
6863
7439
  };
6864
7440
  var RECIPE_DESCRIPTIONS = {
6865
7441
  "sync-configs": "Overwrite a site's canonical configs to match @reddoorla/maintenance.",