@reddoorla/maintenance 0.54.3 → 0.55.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
@@ -174,6 +174,7 @@ __export(websites_exports, {
174
174
  parseSecurityAdvisories: () => parseSecurityAdvisories,
175
175
  siteSlug: () => siteSlug,
176
176
  updateA11yCounts: () => updateA11yCounts,
177
+ updateAnalyticsHealth: () => updateAnalyticsHealth,
177
178
  updateAuditFields: () => updateAuditFields,
178
179
  updateDepsCounts: () => updateDepsCounts,
179
180
  updateGitHubSignals: () => updateGitHubSignals,
@@ -234,6 +235,7 @@ function mapRow(rec) {
234
235
  ga4PropertyId: f["GA4 property ID"] ?? null,
235
236
  searchQuery: f["Search query"] ?? null,
236
237
  searchConsoleProperty: f["Search Console property"] ?? null,
238
+ analyticsSoftFailAt: f["Analytics soft-fail at"] ?? null,
237
239
  gitRepo: f["Git repo"] ?? null,
238
240
  reportRecipientsTo: f["Report recipients (To)"] ?? null,
239
241
  reportRecipientsCc: f["Report recipients (CC)"] ?? null,
@@ -376,6 +378,10 @@ function browserFields(r) {
376
378
  async function updateScores(base, recordId, scores) {
377
379
  await base(WEBSITES_TABLE).update([{ id: recordId, fields: scoreFields(scores) }]);
378
380
  }
381
+ async function updateAnalyticsHealth(base, recordId, at) {
382
+ const fields = { "Analytics soft-fail at": at };
383
+ await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
384
+ }
379
385
  async function updateA11yCounts(base, recordId, counts) {
380
386
  await base(WEBSITES_TABLE).update([{ id: recordId, fields: a11yFields(counts) }]);
381
387
  }
@@ -1629,6 +1635,10 @@ var init_attachments = __esm({
1629
1635
  });
1630
1636
 
1631
1637
  // src/reports/send/resend.ts
1638
+ var resend_exports = {};
1639
+ __export(resend_exports, {
1640
+ defaultResendClient: () => defaultResendClient
1641
+ });
1632
1642
  import { Resend } from "resend";
1633
1643
  function defaultResendClient() {
1634
1644
  const key = process.env.RESEND_API_KEY;
@@ -1777,7 +1787,26 @@ function collectCiAlerts(sites, baseUrl, now = /* @__PURE__ */ new Date()) {
1777
1787
  }
1778
1788
  return items;
1779
1789
  }
1780
- var GITHUB_SIGNALS_STALE_DAYS, MS_PER_DAY4, LIGHTHOUSE_FLOOR, LIGHTHOUSE_CATEGORIES2;
1790
+ function collectAnalyticsFailures(sites, baseUrl, now = /* @__PURE__ */ new Date()) {
1791
+ const items = [];
1792
+ for (const s of sites) {
1793
+ const at = s.analyticsSoftFailAt;
1794
+ if (at === null) continue;
1795
+ const ageMs = now.getTime() - Date.parse(at);
1796
+ if (Number.isFinite(ageMs) && ageMs > ANALYTICS_SOFT_FAIL_STALE_DAYS * MS_PER_DAY4) continue;
1797
+ items.push({
1798
+ key: `analytics:${s.id}`,
1799
+ kind: "analytics",
1800
+ siteName: s.name,
1801
+ title: "GA/Search enrichment failing (analytics blank)",
1802
+ url: dashboardUrl(baseUrl, s.name),
1803
+ severity: "warning",
1804
+ metric: 1
1805
+ });
1806
+ }
1807
+ return items;
1808
+ }
1809
+ var GITHUB_SIGNALS_STALE_DAYS, MS_PER_DAY4, LIGHTHOUSE_FLOOR, LIGHTHOUSE_CATEGORIES2, ANALYTICS_SOFT_FAIL_STALE_DAYS;
1781
1810
  var init_digest_collectors = __esm({
1782
1811
  "src/alerts/digest-collectors.ts"() {
1783
1812
  "use strict";
@@ -1791,6 +1820,7 @@ var init_digest_collectors = __esm({
1791
1820
  { field: "bpScore", slug: "best-practices", label: "Best Practices" },
1792
1821
  { field: "seoScore", slug: "seo", label: "SEO" }
1793
1822
  ];
1823
+ ANALYTICS_SOFT_FAIL_STALE_DAYS = 45;
1794
1824
  }
1795
1825
  });
1796
1826
 
@@ -1946,7 +1976,8 @@ async function collectAttention(deps) {
1946
1976
  ...runCollector("delivery", () => collectDeliveryFailures(reports, sitesById, deps.baseUrl)),
1947
1977
  ...runCollector("lighthouse", () => collectLighthouseAlerts(websites, deps.baseUrl)),
1948
1978
  ...runCollector("renovate", () => collectRenovateAlerts(websites, deps.baseUrl, now)),
1949
- ...runCollector("ci", () => collectCiAlerts(websites, deps.baseUrl, now))
1979
+ ...runCollector("ci", () => collectCiAlerts(websites, deps.baseUrl, now)),
1980
+ ...runCollector("analytics", () => collectAnalyticsFailures(websites, deps.baseUrl, now))
1950
1981
  ];
1951
1982
  }
1952
1983
  async function runDigest(options) {
@@ -3858,6 +3889,20 @@ function fromJsonFile(path) {
3858
3889
  }
3859
3890
 
3860
3891
  // src/cli/fleet/resolve-sites.ts
3892
+ init_url();
3893
+ function sanitizeDynamicSites(sites) {
3894
+ return sites.map((s) => {
3895
+ if (s.deployedUrl !== void 0 && !isHttpUrl(s.deployedUrl)) {
3896
+ console.warn(
3897
+ `[inventory] dynamic inventory: ignoring deployedUrl that is not http(s) for ${s.name ?? s.path}: ${JSON.stringify(s.deployedUrl)}`
3898
+ );
3899
+ const copy = { ...s };
3900
+ delete copy.deployedUrl;
3901
+ return copy;
3902
+ }
3903
+ return s;
3904
+ });
3905
+ }
3861
3906
  async function resolveSites(input) {
3862
3907
  if (input.site && input.fleet) {
3863
3908
  throw Object.assign(new Error("cannot combine a positional [site] with --fleet"), {
@@ -3874,24 +3919,22 @@ async function resolveSites(input) {
3874
3919
  if (input.fleet) {
3875
3920
  const fleetPath = resolve(input.cwd, input.fleet);
3876
3921
  const ext = extname(fleetPath).toLowerCase();
3877
- let provider;
3878
3922
  if (ext === ".json") {
3879
- provider = fromJsonFile(fleetPath);
3880
- } else if (ext === ".js" || ext === ".mjs" || ext === ".cjs") {
3923
+ return fromJsonFile(fleetPath)();
3924
+ }
3925
+ if (ext === ".js" || ext === ".mjs" || ext === ".cjs") {
3881
3926
  const mod = await import(pathToFileURL(fleetPath).href);
3882
3927
  if (!mod.default || typeof mod.default !== "function") {
3883
3928
  throw Object.assign(new Error(`--fleet ${input.fleet}: default export is not a function`), {
3884
3929
  exitCode: 2
3885
3930
  });
3886
3931
  }
3887
- provider = mod.default;
3888
- } else {
3889
- throw Object.assign(
3890
- new Error(`--fleet ${input.fleet}: unsupported extension ${ext || "(none)"}`),
3891
- { exitCode: 2 }
3892
- );
3932
+ return sanitizeDynamicSites(await mod.default());
3893
3933
  }
3894
- return provider();
3934
+ throw Object.assign(
3935
+ new Error(`--fleet ${input.fleet}: unsupported extension ${ext || "(none)"}`),
3936
+ { exitCode: 2 }
3937
+ );
3895
3938
  }
3896
3939
  return localPath(resolve(input.cwd, input.site ?? input.cwd))();
3897
3940
  }
@@ -6805,6 +6848,17 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
6805
6848
  return { reportRow: null, htmlPath: path, html, softFailures, queued: null, supersededIds: [] };
6806
6849
  }
6807
6850
  if (base === null) throw new Error("base required when previewOnly=false");
6851
+ if (readGaConfig() !== null && Boolean(siteRow.ga4PropertyId || siteRow.searchQuery)) {
6852
+ try {
6853
+ await updateAnalyticsHealth(
6854
+ base,
6855
+ siteRow.id,
6856
+ softFailures.length > 0 ? today.toISOString() : null
6857
+ );
6858
+ } catch (e) {
6859
+ console.warn(`\u26A0 analytics-health write skipped for ${siteRow.name}: ${e.message}`);
6860
+ }
6861
+ }
6808
6862
  if (options.completeRowId) {
6809
6863
  await uploadDraftHtml(options.completeRowId, slug, periodEnd, html);
6810
6864
  const outcome2 = await queueDraft(base, {
@@ -6907,6 +6961,25 @@ async function derivePeriodStart(base, siteRow, reportType, today) {
6907
6961
  return start;
6908
6962
  }
6909
6963
 
6964
+ // src/alerts/analytics-health.ts
6965
+ init_html();
6966
+ var MIN_FAILED_SITES = 2;
6967
+ function assessAnalyticsAlert(h) {
6968
+ const { softFailedSites, configuredSites } = h;
6969
+ const fire = configuredSites >= 2 && softFailedSites >= MIN_FAILED_SITES && softFailedSites * 2 >= configuredSites;
6970
+ const reason = fire ? `${softFailedSites} of ${configuredSites} analytics-configured sites had GA/Search enrichment fail this run \u2014 the shared GA_SUBJECT likely lost access (an offboarded user, revoked property access, or a botched role-account cutover). Reports were drafted with BLANK analytics.` : "";
6971
+ return { fire, reason };
6972
+ }
6973
+ function composeAnalyticsAlertEmail(h, dashboardUrl2) {
6974
+ const subject = `\u26A0 Fleet analytics enrichment failing \u2014 ${h.softFailedSites}/${h.configuredSites} sites`;
6975
+ const { reason } = assessAnalyticsAlert(h);
6976
+ const html = `<p><strong>${escapeHtml(reason)}</strong></p>
6977
+ <p>This usually means the Google Workspace user the service account impersonates can no longer read the GA4 / Search Console properties. Reports still send \u2014 but with blank analytics \u2014 until the subject is restored.</p>
6978
+ <p>Next step: follow the GA/Search subject runbook (<code>docs/runbooks/ga-search-role-account-cutover.md</code>) to restore or move the subject, then re-run <code>reddoor-maint report --due</code> and confirm the warning clears.</p>
6979
+ <p><a href="${escapeHtml(dashboardUrl2)}">Open the fleet dashboard \u2192</a></p>`;
6980
+ return { subject, html };
6981
+ }
6982
+
6910
6983
  // src/cli/commands/report.ts
6911
6984
  function draftLine(reportId, queued, supersededIds, verb = "drafted") {
6912
6985
  const id = reportId ?? "(unknown)";
@@ -6957,15 +7030,44 @@ async function runReportCommand(slug, opts) {
6957
7030
  }
6958
7031
  async function runDueDraft() {
6959
7032
  const base = openBase(readAirtableConfig());
6960
- return draftDueReports(base, /* @__PURE__ */ new Date());
7033
+ const result = await draftDueReports(base, /* @__PURE__ */ new Date());
7034
+ await alertOnFleetAnalyticsFailure(result.health);
7035
+ return { output: result.output, code: result.code };
7036
+ }
7037
+ async function alertOnFleetAnalyticsFailure(health) {
7038
+ if (!assessAnalyticsAlert(health).fire) return;
7039
+ try {
7040
+ const to = process.env.OPERATOR_EMAIL?.trim() || "info@reddoorla.com";
7041
+ const { subject, html } = composeAnalyticsAlertEmail(health, dashboardBaseUrl());
7042
+ const { defaultResendClient: defaultResendClient2 } = await Promise.resolve().then(() => (init_resend(), resend_exports));
7043
+ await defaultResendClient2().send({
7044
+ from: "Reddoor Reports <reports@reddoorla.com>",
7045
+ to: [to],
7046
+ subject,
7047
+ html,
7048
+ idempotencyKey: `analytics-alert-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`
7049
+ });
7050
+ console.warn(`\u26A0 ${subject} \u2014 operator alerted (${to})`);
7051
+ } catch (e) {
7052
+ console.warn(`\u26A0 analytics-failure alert send failed: ${e.message}`);
7053
+ }
6961
7054
  }
6962
7055
  async function draftDueReports(base, today) {
6963
7056
  const websites = await listWebsites(base);
6964
7057
  const reports = await listAllReports(base);
6965
7058
  const due = findDueReports(websites, reports, today);
6966
- if (due.length === 0) return { output: "No reports due.", code: 0 };
7059
+ const gaConfigured = readGaConfig() !== null;
7060
+ const isAnalyticsConfigured = (s) => gaConfigured && Boolean(s.ga4PropertyId || s.searchQuery);
7061
+ if (due.length === 0) {
7062
+ return {
7063
+ output: "No reports due.",
7064
+ code: 0,
7065
+ health: { softFailedSites: 0, configuredSites: 0 }
7066
+ };
7067
+ }
6967
7068
  const lines = [];
6968
7069
  let softFailedSites = 0;
7070
+ let gaConfiguredSites = 0;
6969
7071
  let skipped = 0;
6970
7072
  for (const item of due) {
6971
7073
  const period = reportPeriodKey(item.dueDate);
@@ -7003,6 +7105,7 @@ async function draftDueReports(base, today) {
7003
7105
  "completed half-made draft"
7004
7106
  )
7005
7107
  );
7108
+ if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;
7006
7109
  if (result.softFailures.length > 0) softFailedSites++;
7007
7110
  } catch (e) {
7008
7111
  lines.push(`\u2717 failed: ${item.site.name} ${item.reportType} \u2014 ${e.message}`);
@@ -7023,6 +7126,7 @@ async function draftDueReports(base, today) {
7023
7126
  const result = await draftReportForSite(base, item.site, item.reportType, { period });
7024
7127
  lines.push(draftLine(result.reportRow?.reportId, result.queued, result.supersededIds));
7025
7128
  if (result.reportRow) reports.push(result.reportRow);
7129
+ if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;
7026
7130
  if (result.softFailures.length > 0) softFailedSites++;
7027
7131
  } catch (e) {
7028
7132
  lines.push(`\u2717 failed: ${item.site.name} ${item.reportType} \u2014 ${e.message}`);
@@ -7036,7 +7140,11 @@ async function draftDueReports(base, today) {
7036
7140
  `\u26A0 ${softFailedSites} site${softFailedSites === 1 ? "" : "s"} had GA/Search enrichment fail \u2014 drafted with blank analytics; check the logs above`
7037
7141
  );
7038
7142
  }
7039
- return { output: lines.join("\n"), code: lines.some((l) => l.startsWith("\u2717")) ? 1 : 0 };
7143
+ return {
7144
+ output: lines.join("\n"),
7145
+ code: lines.some((l) => l.startsWith("\u2717")) ? 1 : 0,
7146
+ health: { softFailedSites, configuredSites: gaConfiguredSites }
7147
+ };
7040
7148
  }
7041
7149
  async function runSingleSiteDraft(slug, opts) {
7042
7150
  const base = openBase(readAirtableConfig());