@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 +123 -15
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.js +28 -10
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +18 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -5
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
|
-
|
|
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
|
-
|
|
3880
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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());
|