@reddoorla/maintenance 0.31.0 → 0.33.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 +1367 -394
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.js +31 -1
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/index.d.ts +161 -55
- package/dist/index.js +377 -55
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/bin.js
CHANGED
|
@@ -57,6 +57,79 @@ var init_credentials = __esm({
|
|
|
57
57
|
}
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
+
// src/audits/util/spawn.ts
|
|
61
|
+
import { spawn } from "child_process";
|
|
62
|
+
function makeSpawn(internals = {}) {
|
|
63
|
+
const spawnImpl = internals.spawnImpl ?? spawn;
|
|
64
|
+
const killImpl = internals.killImpl ?? ((pid, sig) => process.kill(pid, sig));
|
|
65
|
+
const killGraceMs = internals.killGraceMs ?? 5e3;
|
|
66
|
+
const maxOutputBytes = internals.maxOutputBytes ?? 10 * 1024 * 1024;
|
|
67
|
+
return (cmd, args, opts = {}) => new Promise((resolve12, reject) => {
|
|
68
|
+
const streaming = opts.streaming === true;
|
|
69
|
+
const child = spawnImpl(cmd, [...args], {
|
|
70
|
+
cwd: opts.cwd,
|
|
71
|
+
env: opts.env ?? process.env,
|
|
72
|
+
stdio: streaming ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "pipe"],
|
|
73
|
+
// Detach ONLY when a timeout can fire: the child then leads its own
|
|
74
|
+
// process group, so the timeout can kill the WHOLE tree (vite, and
|
|
75
|
+
// Chromium under lhci/playwright) via process.kill(-pid), not just the
|
|
76
|
+
// npx/pnpm wrapper. Without it, killing the wrapper orphaned the
|
|
77
|
+
// grandchildren — a zombie vite squatting its port, Chrome left running.
|
|
78
|
+
// We do NOT detach timeout-less streaming calls (pnpm install/up):
|
|
79
|
+
// detaching gains nothing there (no timeout → no group-kill) and would
|
|
80
|
+
// break terminal Ctrl-C, which only reaches the foreground group — i.e.
|
|
81
|
+
// it would re-orphan the very children this guards. We never unref() the
|
|
82
|
+
// child since we still await it.
|
|
83
|
+
detached: opts.timeoutMs !== void 0
|
|
84
|
+
});
|
|
85
|
+
const cap = (acc, chunk) => {
|
|
86
|
+
if (acc.length >= maxOutputBytes) return acc;
|
|
87
|
+
const next = acc + chunk;
|
|
88
|
+
return next.length > maxOutputBytes ? next.slice(0, maxOutputBytes) + TRUNCATION_MARKER : next;
|
|
89
|
+
};
|
|
90
|
+
let stdout = "";
|
|
91
|
+
let stderr = "";
|
|
92
|
+
if (!streaming) {
|
|
93
|
+
child.stdout?.on("data", (chunk) => stdout = cap(stdout, String(chunk)));
|
|
94
|
+
child.stderr?.on("data", (chunk) => stderr = cap(stderr, String(chunk)));
|
|
95
|
+
}
|
|
96
|
+
const killGroup = (sig) => {
|
|
97
|
+
if (child.pid === void 0) return;
|
|
98
|
+
try {
|
|
99
|
+
killImpl(-child.pid, sig);
|
|
100
|
+
} catch {
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
let killTimer;
|
|
104
|
+
const timer = opts.timeoutMs ? setTimeout(() => {
|
|
105
|
+
killGroup("SIGTERM");
|
|
106
|
+
killTimer = setTimeout(() => killGroup("SIGKILL"), killGraceMs);
|
|
107
|
+
killTimer.unref();
|
|
108
|
+
reject(new Error(`spawn timeout after ${opts.timeoutMs}ms: ${cmd}`));
|
|
109
|
+
}, opts.timeoutMs) : void 0;
|
|
110
|
+
const clearTimers = () => {
|
|
111
|
+
if (timer) clearTimeout(timer);
|
|
112
|
+
if (killTimer) clearTimeout(killTimer);
|
|
113
|
+
};
|
|
114
|
+
child.on("error", (err) => {
|
|
115
|
+
clearTimers();
|
|
116
|
+
reject(err);
|
|
117
|
+
});
|
|
118
|
+
child.on("close", (code) => {
|
|
119
|
+
clearTimers();
|
|
120
|
+
resolve12({ code: code ?? -1, stdout, stderr });
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
var TRUNCATION_MARKER, defaultSpawn;
|
|
125
|
+
var init_spawn = __esm({
|
|
126
|
+
"src/audits/util/spawn.ts"() {
|
|
127
|
+
"use strict";
|
|
128
|
+
TRUNCATION_MARKER = "\n\u2026[output truncated]";
|
|
129
|
+
defaultSpawn = makeSpawn();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
60
133
|
// src/reports/airtable/client.ts
|
|
61
134
|
var client_exports = {};
|
|
62
135
|
__export(client_exports, {
|
|
@@ -99,12 +172,19 @@ __export(websites_exports, {
|
|
|
99
172
|
siteSlug: () => siteSlug,
|
|
100
173
|
updateA11yCounts: () => updateA11yCounts,
|
|
101
174
|
updateDepsCounts: () => updateDepsCounts,
|
|
175
|
+
updateGitHubSignals: () => updateGitHubSignals,
|
|
176
|
+
updateLaunched: () => updateLaunched,
|
|
102
177
|
updateScores: () => updateScores,
|
|
103
178
|
updateSecurityCounts: () => updateSecurityCounts
|
|
104
179
|
});
|
|
105
180
|
function siteSlug(name) {
|
|
106
181
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
107
182
|
}
|
|
183
|
+
function trimToNull(raw) {
|
|
184
|
+
if (typeof raw !== "string") return null;
|
|
185
|
+
const trimmed = raw.trim();
|
|
186
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
187
|
+
}
|
|
108
188
|
function mapRow(rec) {
|
|
109
189
|
const f = rec.fields;
|
|
110
190
|
const attachments = f["Header image"] ?? [];
|
|
@@ -144,7 +224,15 @@ function mapRow(rec) {
|
|
|
144
224
|
if (typeof raw !== "string") return null;
|
|
145
225
|
const trimmed = raw.trim();
|
|
146
226
|
return trimmed.length > 0 ? trimmed : null;
|
|
147
|
-
})()
|
|
227
|
+
})(),
|
|
228
|
+
copyIntro: trimToNull(f["Copy \u2014 Intro"]),
|
|
229
|
+
copyContact: trimToNull(f["Copy \u2014 Contact"]),
|
|
230
|
+
copyFooter: trimToNull(f["Copy \u2014 Footer"]),
|
|
231
|
+
launchedAt: f["Launched at"] ?? null,
|
|
232
|
+
renovateFailingCis: f["Renovate Failing CIs"] ?? null,
|
|
233
|
+
defaultBranchCi: f["Default Branch CI"] ?? null,
|
|
234
|
+
lastCommitAt: f["Last Commit At"] ?? null,
|
|
235
|
+
githubSignalsAt: f["GitHub Signals At"] ?? null
|
|
148
236
|
};
|
|
149
237
|
}
|
|
150
238
|
async function listWebsites(base) {
|
|
@@ -202,6 +290,21 @@ async function updateSecurityCounts(base, recordId, counts) {
|
|
|
202
290
|
};
|
|
203
291
|
await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
|
|
204
292
|
}
|
|
293
|
+
async function updateGitHubSignals(base, recordId, signals) {
|
|
294
|
+
const fields = {
|
|
295
|
+
"Renovate Failing CIs": signals.renovateFailingCis,
|
|
296
|
+
"Default Branch CI": signals.ciState,
|
|
297
|
+
"GitHub Signals At": signals.sweptAt
|
|
298
|
+
};
|
|
299
|
+
if (signals.lastCommitAt !== null) {
|
|
300
|
+
fields["Last Commit At"] = signals.lastCommitAt;
|
|
301
|
+
}
|
|
302
|
+
await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
|
|
303
|
+
}
|
|
304
|
+
async function updateLaunched(base, recordId, at) {
|
|
305
|
+
const fields = { Status: "maintenance", "Launched at": at };
|
|
306
|
+
await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
|
|
307
|
+
}
|
|
205
308
|
var WEBSITES_TABLE;
|
|
206
309
|
var init_websites = __esm({
|
|
207
310
|
"src/reports/airtable/websites.ts"() {
|
|
@@ -463,6 +566,186 @@ var init_write_audits_to_airtable = __esm({
|
|
|
463
566
|
}
|
|
464
567
|
});
|
|
465
568
|
|
|
569
|
+
// src/github/gh.ts
|
|
570
|
+
function mapRollupState(state) {
|
|
571
|
+
switch (state) {
|
|
572
|
+
case "SUCCESS":
|
|
573
|
+
return "passing";
|
|
574
|
+
case "FAILURE":
|
|
575
|
+
case "ERROR":
|
|
576
|
+
return "failing";
|
|
577
|
+
case "PENDING":
|
|
578
|
+
case "EXPECTED":
|
|
579
|
+
return "pending";
|
|
580
|
+
default:
|
|
581
|
+
return "none";
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
function makeGitHub(deps) {
|
|
585
|
+
const spawn2 = deps.spawn ?? defaultSpawn;
|
|
586
|
+
const env = { ...process.env, GH_TOKEN: deps.token };
|
|
587
|
+
async function gh(args) {
|
|
588
|
+
const r = await spawn2("gh", args, { env, timeoutMs: 6e4 });
|
|
589
|
+
if (r.code !== 0) throw new Error(`gh ${args[0]} failed (code ${r.code}): ${r.stderr.trim()}`);
|
|
590
|
+
return r.stdout;
|
|
591
|
+
}
|
|
592
|
+
return {
|
|
593
|
+
async openPullRequest(repo, pr) {
|
|
594
|
+
const out = await gh([
|
|
595
|
+
"pr",
|
|
596
|
+
"create",
|
|
597
|
+
"--repo",
|
|
598
|
+
repo,
|
|
599
|
+
"--head",
|
|
600
|
+
pr.head,
|
|
601
|
+
"--base",
|
|
602
|
+
pr.base,
|
|
603
|
+
"--title",
|
|
604
|
+
pr.title,
|
|
605
|
+
"--body",
|
|
606
|
+
pr.body
|
|
607
|
+
]);
|
|
608
|
+
return { url: out.trim() };
|
|
609
|
+
},
|
|
610
|
+
async enableRepoAutoMerge(repo) {
|
|
611
|
+
await gh(["api", "-X", "PATCH", `repos/${repo}`, "-F", "allow_auto_merge=true"]);
|
|
612
|
+
},
|
|
613
|
+
async protectBranch(repo, branch, requiredChecks) {
|
|
614
|
+
const args = [
|
|
615
|
+
"api",
|
|
616
|
+
"-X",
|
|
617
|
+
"PUT",
|
|
618
|
+
`repos/${repo}/branches/${branch}/protection`,
|
|
619
|
+
"-H",
|
|
620
|
+
"Accept: application/vnd.github+json",
|
|
621
|
+
"-F",
|
|
622
|
+
"required_status_checks[strict]=true",
|
|
623
|
+
...requiredChecks.flatMap((c) => ["-f", `required_status_checks[contexts][]=${c}`]),
|
|
624
|
+
"-F",
|
|
625
|
+
"enforce_admins=true",
|
|
626
|
+
"-F",
|
|
627
|
+
"required_pull_request_reviews=null",
|
|
628
|
+
"-F",
|
|
629
|
+
"restrictions=null"
|
|
630
|
+
];
|
|
631
|
+
await gh(args);
|
|
632
|
+
},
|
|
633
|
+
async setRepoSecret(repo, name, value) {
|
|
634
|
+
await gh(["secret", "set", name, "--repo", repo, "--body", value]);
|
|
635
|
+
},
|
|
636
|
+
async repoExists(repo) {
|
|
637
|
+
const r = await spawn2("gh", ["api", `repos/${repo}`], { env, timeoutMs: 6e4 });
|
|
638
|
+
return r.code === 0;
|
|
639
|
+
},
|
|
640
|
+
async defaultBranch(repo) {
|
|
641
|
+
const out = await gh(["api", `repos/${repo}`, "--jq", ".default_branch"]);
|
|
642
|
+
return out.trim();
|
|
643
|
+
},
|
|
644
|
+
// filesOnBranch and branchProtectionContexts call `spawn` directly (not the
|
|
645
|
+
// throwing `gh()` helper) because a 404 is an expected, meaningful answer —
|
|
646
|
+
// "file/protection absent" — not an error. The remaining readers use `gh()`
|
|
647
|
+
// since a non-200 there is a genuine failure (e.g. missing token scope).
|
|
648
|
+
async filesOnBranch(repo, branch, paths) {
|
|
649
|
+
const present = [];
|
|
650
|
+
for (const p of paths) {
|
|
651
|
+
const r = await spawn2("gh", [`api`, `repos/${repo}/contents/${p}?ref=${branch}`], {
|
|
652
|
+
env,
|
|
653
|
+
timeoutMs: 6e4
|
|
654
|
+
});
|
|
655
|
+
if (r.code === 0) present.push(p);
|
|
656
|
+
}
|
|
657
|
+
return present;
|
|
658
|
+
},
|
|
659
|
+
async branchProtectionContexts(repo, branch) {
|
|
660
|
+
const r = await spawn2(
|
|
661
|
+
"gh",
|
|
662
|
+
[
|
|
663
|
+
"api",
|
|
664
|
+
`repos/${repo}/branches/${branch}/protection`,
|
|
665
|
+
"--jq",
|
|
666
|
+
".required_status_checks.contexts[]?"
|
|
667
|
+
],
|
|
668
|
+
{ env, timeoutMs: 6e4 }
|
|
669
|
+
);
|
|
670
|
+
if (r.code !== 0) return [];
|
|
671
|
+
return r.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
672
|
+
},
|
|
673
|
+
async secretExists(repo, name) {
|
|
674
|
+
const out = await gh(["api", `repos/${repo}/actions/secrets`, "--jq", ".secrets[].name"]);
|
|
675
|
+
return out.split("\n").map((l) => l.trim()).includes(name);
|
|
676
|
+
},
|
|
677
|
+
async autoMergeEnabled(repo) {
|
|
678
|
+
const out = await gh(["api", `repos/${repo}`, "--jq", ".allow_auto_merge"]);
|
|
679
|
+
return out.trim() === "true";
|
|
680
|
+
},
|
|
681
|
+
async findOpenSelfUpdatingPR(repo) {
|
|
682
|
+
const out = await gh([
|
|
683
|
+
"api",
|
|
684
|
+
`repos/${repo}/pulls?state=open`,
|
|
685
|
+
"--jq",
|
|
686
|
+
'.[] | select(.head.ref | startswith("maint/self-updating-")) | .html_url'
|
|
687
|
+
]);
|
|
688
|
+
const first = out.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
|
|
689
|
+
return first ?? null;
|
|
690
|
+
},
|
|
691
|
+
async openPullRequests(repo) {
|
|
692
|
+
const [owner, name, ...rest] = repo.split("/");
|
|
693
|
+
if (!owner || !name || rest.length > 0) {
|
|
694
|
+
throw new Error(`openPullRequests: expected "owner/repo", got "${repo}"`);
|
|
695
|
+
}
|
|
696
|
+
const query = "query($owner:String!,$name:String!){repository(owner:$owner,name:$name){pullRequests(states:OPEN,first:100,orderBy:{field:CREATED_AT,direction:DESC}){nodes{number title url headRefName commits(last:1){nodes{commit{statusCheckRollup{state}}}}}}}}";
|
|
697
|
+
const out = await gh([
|
|
698
|
+
"api",
|
|
699
|
+
"graphql",
|
|
700
|
+
"-f",
|
|
701
|
+
`query=${query}`,
|
|
702
|
+
"-F",
|
|
703
|
+
`owner=${owner}`,
|
|
704
|
+
"-F",
|
|
705
|
+
`name=${name}`
|
|
706
|
+
]);
|
|
707
|
+
const parsed = JSON.parse(out);
|
|
708
|
+
const nodes = parsed.data?.repository?.pullRequests?.nodes ?? [];
|
|
709
|
+
return nodes.map((n) => ({
|
|
710
|
+
number: n.number,
|
|
711
|
+
title: n.title,
|
|
712
|
+
url: n.url,
|
|
713
|
+
headRef: n.headRefName,
|
|
714
|
+
ciState: mapRollupState(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state)
|
|
715
|
+
}));
|
|
716
|
+
},
|
|
717
|
+
async defaultBranchStatus(repo) {
|
|
718
|
+
const [owner, name, ...rest] = repo.split("/");
|
|
719
|
+
if (!owner || !name || rest.length > 0) {
|
|
720
|
+
throw new Error(`defaultBranchStatus: expected "owner/repo", got "${repo}"`);
|
|
721
|
+
}
|
|
722
|
+
const query = "query($owner:String!,$name:String!){repository(owner:$owner,name:$name){defaultBranchRef{target{... on Commit{committedDate statusCheckRollup{state}}}}}}";
|
|
723
|
+
const out = await gh([
|
|
724
|
+
"api",
|
|
725
|
+
"graphql",
|
|
726
|
+
"-f",
|
|
727
|
+
`query=${query}`,
|
|
728
|
+
"-F",
|
|
729
|
+
`owner=${owner}`,
|
|
730
|
+
"-F",
|
|
731
|
+
`name=${name}`
|
|
732
|
+
]);
|
|
733
|
+
const parsed = JSON.parse(out);
|
|
734
|
+
const target = parsed.data?.repository?.defaultBranchRef?.target;
|
|
735
|
+
return {
|
|
736
|
+
ciState: mapRollupState(target?.statusCheckRollup?.state),
|
|
737
|
+
lastCommitAt: target?.committedDate ?? null
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
var init_gh = __esm({
|
|
743
|
+
"src/github/gh.ts"() {
|
|
744
|
+
"use strict";
|
|
745
|
+
init_spawn();
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
|
|
466
749
|
// src/reports/airtable/reports.ts
|
|
467
750
|
function mapRow2(rec) {
|
|
468
751
|
const f = rec.fields;
|
|
@@ -473,6 +756,7 @@ function mapRow2(rec) {
|
|
|
473
756
|
reportId: String(f["Report ID"] ?? ""),
|
|
474
757
|
siteId: linkSites[0] ?? "",
|
|
475
758
|
reportType: f["Report type"] ?? "Maintenance",
|
|
759
|
+
period: f["Period"] ?? null,
|
|
476
760
|
periodStart: f["Period start"] ?? null,
|
|
477
761
|
periodEnd: f["Period end"] ?? null,
|
|
478
762
|
completedOn: f["Completed on"] ?? null,
|
|
@@ -487,6 +771,8 @@ function mapRow2(rec) {
|
|
|
487
771
|
draftReady: Boolean(f["Draft ready"]),
|
|
488
772
|
approvedToSend: Boolean(f["Approved to send"]),
|
|
489
773
|
sentAt: f["Sent at"] ?? null,
|
|
774
|
+
approvedAt: f["Approved At"] ?? null,
|
|
775
|
+
approvedBy: f["Approved By"] ?? null,
|
|
490
776
|
deliveryStatus: f["Delivery status"] ?? "pending",
|
|
491
777
|
renderedHtmlAttachment: html,
|
|
492
778
|
resendMessageId: f["Resend message ID"] ?? null
|
|
@@ -526,6 +812,7 @@ async function createDraft(base, input) {
|
|
|
526
812
|
if (input.gaUsersPrevious !== void 0) fields["GA users (prev period)"] = input.gaUsersPrevious;
|
|
527
813
|
if (input.searchFoundPage1 !== void 0) fields["Search found page 1"] = input.searchFoundPage1;
|
|
528
814
|
if (input.searchPosition !== void 0) fields["Search position"] = input.searchPosition;
|
|
815
|
+
if (input.period !== void 0) fields["Period"] = input.period;
|
|
529
816
|
const created = await base(REPORTS_TABLE).create([{ fields }]);
|
|
530
817
|
const rec = created[0];
|
|
531
818
|
if (!rec) throw new Error("Airtable create returned no records");
|
|
@@ -545,18 +832,17 @@ async function listSendableReports(base) {
|
|
|
545
832
|
});
|
|
546
833
|
return out;
|
|
547
834
|
}
|
|
548
|
-
async function
|
|
549
|
-
const safeId = escapeFormulaString(siteId);
|
|
835
|
+
async function listAllReports(base) {
|
|
550
836
|
const out = [];
|
|
551
|
-
await base(REPORTS_TABLE).select({
|
|
552
|
-
filterByFormula: `FIND(",${safeId},", "," & ARRAYJOIN({Site}, ",") & ",") > 0`,
|
|
553
|
-
pageSize: 100
|
|
554
|
-
}).eachPage((records, fetchNextPage) => {
|
|
837
|
+
await base(REPORTS_TABLE).select({ pageSize: 100 }).eachPage((records, fetchNextPage) => {
|
|
555
838
|
for (const rec of records) out.push(mapRow2({ id: rec.id, fields: rec.fields }));
|
|
556
839
|
fetchNextPage();
|
|
557
840
|
});
|
|
558
841
|
return out;
|
|
559
842
|
}
|
|
843
|
+
async function listReportsForSite(base, siteId) {
|
|
844
|
+
return (await listAllReports(base)).filter((r) => r.siteId === siteId);
|
|
845
|
+
}
|
|
560
846
|
async function stampSent(base, recordId, sentAt, messageId) {
|
|
561
847
|
await base(REPORTS_TABLE).update([
|
|
562
848
|
{
|
|
@@ -568,6 +854,17 @@ async function stampSent(base, recordId, sentAt, messageId) {
|
|
|
568
854
|
}
|
|
569
855
|
]);
|
|
570
856
|
}
|
|
857
|
+
async function findReportByPeriod(base, siteId, reportType, period) {
|
|
858
|
+
const safeType = escapeFormulaString(reportType);
|
|
859
|
+
const safePeriod = escapeFormulaString(period);
|
|
860
|
+
const formula = `AND({Report type} = "${safeType}", {Period} = "${safePeriod}")`;
|
|
861
|
+
const rows = [];
|
|
862
|
+
await base(REPORTS_TABLE).select({ filterByFormula: formula, pageSize: 100 }).eachPage((records, fetchNextPage) => {
|
|
863
|
+
for (const rec of records) rows.push(mapRow2({ id: rec.id, fields: rec.fields }));
|
|
864
|
+
fetchNextPage();
|
|
865
|
+
});
|
|
866
|
+
return rows.find((r) => r.siteId === siteId) ?? null;
|
|
867
|
+
}
|
|
571
868
|
var REPORTS_TABLE;
|
|
572
869
|
var init_reports = __esm({
|
|
573
870
|
"src/reports/airtable/reports.ts"() {
|
|
@@ -576,6 +873,67 @@ var init_reports = __esm({
|
|
|
576
873
|
}
|
|
577
874
|
});
|
|
578
875
|
|
|
876
|
+
// src/reports/copy.ts
|
|
877
|
+
function override(v) {
|
|
878
|
+
if (typeof v !== "string") return null;
|
|
879
|
+
const t = v.trim();
|
|
880
|
+
return t.length > 0 ? t : null;
|
|
881
|
+
}
|
|
882
|
+
function splitLines(s) {
|
|
883
|
+
return s.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
884
|
+
}
|
|
885
|
+
function resolveCopy(site) {
|
|
886
|
+
const intro = override(site.copyIntro);
|
|
887
|
+
const contact = override(site.copyContact);
|
|
888
|
+
const footer = override(site.copyFooter);
|
|
889
|
+
const footerLines = footer ? splitLines(footer) : null;
|
|
890
|
+
return {
|
|
891
|
+
...DEFAULT_COPY,
|
|
892
|
+
maintenanceIntro: intro ?? DEFAULT_COPY.maintenanceIntro,
|
|
893
|
+
contact: contact ? splitLines(contact) : DEFAULT_COPY.contact,
|
|
894
|
+
footerOrg: footerLines?.[0] ?? DEFAULT_COPY.footerOrg,
|
|
895
|
+
footerAddress: footerLines ? footerLines.slice(1) : DEFAULT_COPY.footerAddress
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
var DEFAULT_COPY;
|
|
899
|
+
var init_copy = __esm({
|
|
900
|
+
"src/reports/copy.ts"() {
|
|
901
|
+
"use strict";
|
|
902
|
+
DEFAULT_COPY = {
|
|
903
|
+
maintenanceIntro: "Includes checking the hosting, DNS, Content Management System (CMS, if applicable), search indexing and security of the site for major flaws and updating as necessary.",
|
|
904
|
+
maintenanceChecks: [
|
|
905
|
+
"Reviewed Logs",
|
|
906
|
+
"CMS Checked",
|
|
907
|
+
"DNS Checked",
|
|
908
|
+
"Google Indexed",
|
|
909
|
+
"Reviewed Certificate",
|
|
910
|
+
"Security Updates"
|
|
911
|
+
],
|
|
912
|
+
testingIntro: "Testing includes checks similar to those at launch: testing on common browsers and operating systems, at different screen sizes, and checking every function, and updating all packages for performance rather than just those needed for security.",
|
|
913
|
+
testingChecklist: [
|
|
914
|
+
"Desktop Browsers",
|
|
915
|
+
"Mobile Browsers",
|
|
916
|
+
"Package Updates",
|
|
917
|
+
"Bottlenecks",
|
|
918
|
+
"Form Functionality",
|
|
919
|
+
"Animation Functionality"
|
|
920
|
+
],
|
|
921
|
+
notesHeader: "NOTES",
|
|
922
|
+
seoCta: "Contact us if you are interested in more in-depth data or have questions about SEO.",
|
|
923
|
+
contact: ["Just hit reply.", "We're here to help in any way we can."],
|
|
924
|
+
footerOrg: "Reddoor Creative, LLC",
|
|
925
|
+
footerAddress: ["29027 Dapper Dan", "Fair Oaks Ranch, TX 78015"],
|
|
926
|
+
launchHeading: "LAUNCHED",
|
|
927
|
+
launchBody: "Your site is live. We've set it up on the Reddoor stack with hosting, security, and automatic maintenance so it stays fast and healthy. Here's what's in place:",
|
|
928
|
+
launchSetupItems: [
|
|
929
|
+
"Hosting, DNS, and SSL configured",
|
|
930
|
+
"Continuous integration + automatic dependency updates",
|
|
931
|
+
"Analytics and uptime monitoring"
|
|
932
|
+
]
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
|
|
579
937
|
// src/reports/maintenance-email/assets/index.ts
|
|
580
938
|
import { readFile as readFile13 } from "fs/promises";
|
|
581
939
|
import { existsSync as existsSync3 } from "fs";
|
|
@@ -665,16 +1023,9 @@ function analyticsTrendLine(cur, prev) {
|
|
|
665
1023
|
if (pct < 0) return trendText(TREND_NEUTRAL, `\u25BC ${Math.abs(pct)}% vs last period ${range}`);
|
|
666
1024
|
return trendText(TREND_NEUTRAL, `No change vs last period (${fmtUsers(prev)})`);
|
|
667
1025
|
}
|
|
668
|
-
function maintenanceChecksSection(searchPosition) {
|
|
669
|
-
const googleLabel = searchPosition !== void 0 ? `Page 1 Google Result (#${searchPosition})` :
|
|
670
|
-
const rows =
|
|
671
|
-
"Reviewed Logs",
|
|
672
|
-
"CMS Checked",
|
|
673
|
-
"DNS Checked",
|
|
674
|
-
googleLabel,
|
|
675
|
-
"Reviewed Certificate",
|
|
676
|
-
"Security Updates"
|
|
677
|
-
];
|
|
1026
|
+
function maintenanceChecksSection(copy, searchPosition) {
|
|
1027
|
+
const googleLabel = searchPosition !== void 0 ? `Page 1 Google Result (#${searchPosition})` : copy.maintenanceChecks[3];
|
|
1028
|
+
const rows = copy.maintenanceChecks.map((label, i) => i === 3 ? googleLabel : label);
|
|
678
1029
|
return rows.map(
|
|
679
1030
|
(label, i) => `
|
|
680
1031
|
<mj-section background-color="white" padding="0px"${i === rows.length - 1 ? ' padding-bottom="36px"' : ""}>
|
|
@@ -689,15 +1040,8 @@ function maintenanceChecksSection(searchPosition) {
|
|
|
689
1040
|
</mj-section>`
|
|
690
1041
|
).join("");
|
|
691
1042
|
}
|
|
692
|
-
function testingChecklistSection() {
|
|
693
|
-
const rows =
|
|
694
|
-
"Desktop Browsers",
|
|
695
|
-
"Mobile Browsers",
|
|
696
|
-
"Package Updates",
|
|
697
|
-
"Bottlenecks",
|
|
698
|
-
"Form Functionality",
|
|
699
|
-
"Animation Functionality"
|
|
700
|
-
];
|
|
1043
|
+
function testingChecklistSection(copy) {
|
|
1044
|
+
const rows = copy.testingChecklist;
|
|
701
1045
|
return rows.map(
|
|
702
1046
|
(label, i) => `
|
|
703
1047
|
<mj-section background-color="#F4F4F4" padding="0px"${i === rows.length - 1 ? ' padding-bottom="60px"' : ""}>
|
|
@@ -725,20 +1069,20 @@ function maintenanceTestingPlaceholder(lastTested) {
|
|
|
725
1069
|
</mj-column>
|
|
726
1070
|
</mj-section>`;
|
|
727
1071
|
}
|
|
728
|
-
function testingIntroSection() {
|
|
1072
|
+
function testingIntroSection(copy) {
|
|
729
1073
|
return `
|
|
730
1074
|
<mj-section background-color="#F4F4F4">
|
|
731
1075
|
<mj-column>
|
|
732
1076
|
<mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">TESTING</mj-text>
|
|
733
|
-
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px"
|
|
1077
|
+
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml(copy.testingIntro)}</mj-text>
|
|
734
1078
|
</mj-column>
|
|
735
1079
|
</mj-section>`;
|
|
736
1080
|
}
|
|
737
|
-
function commentarySection(text) {
|
|
1081
|
+
function commentarySection(text, copy) {
|
|
738
1082
|
return `
|
|
739
1083
|
<mj-section background-color="white">
|
|
740
1084
|
<mj-column>
|
|
741
|
-
<mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px"
|
|
1085
|
+
<mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">${escapeXml(copy.notesHeader)}</mj-text>
|
|
742
1086
|
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml(text).replace(/\n/g, "<br/>")}</mj-text>
|
|
743
1087
|
</mj-column>
|
|
744
1088
|
</mj-section>`;
|
|
@@ -760,6 +1104,7 @@ function headerStyleBlock(data) {
|
|
|
760
1104
|
return `<mj-style>.rd-header img { height: auto !important; aspect-ratio: ${data.headerWidth} / ${data.headerHeight}; }</mj-style>`;
|
|
761
1105
|
}
|
|
762
1106
|
function buildMjml(data) {
|
|
1107
|
+
const copy = data.copy ?? DEFAULT_COPY;
|
|
763
1108
|
const isTesting = data.reportType === "Testing";
|
|
764
1109
|
const previewText = `Checked up on ${escapeXml(data.siteName)}`;
|
|
765
1110
|
return `<mjml>
|
|
@@ -783,10 +1128,10 @@ function buildMjml(data) {
|
|
|
783
1128
|
<mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">COMPLETED ON</mj-text>
|
|
784
1129
|
<mj-text color="#C00" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
|
|
785
1130
|
<mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">MAINTENANCE CHECKS</mj-text>
|
|
786
|
-
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px"
|
|
1131
|
+
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml(copy.maintenanceIntro)}</mj-text>
|
|
787
1132
|
</mj-column>
|
|
788
1133
|
</mj-section>
|
|
789
|
-
${maintenanceChecksSection(data.searchPosition)}
|
|
1134
|
+
${maintenanceChecksSection(copy, data.searchPosition)}
|
|
790
1135
|
<mj-section background-color="#F4F4F4">
|
|
791
1136
|
<mj-column>
|
|
792
1137
|
<mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">LIGHTHOUSE SCORES*</mj-text>
|
|
@@ -813,22 +1158,23 @@ function buildMjml(data) {
|
|
|
813
1158
|
<mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">ANALYTICS</mj-text>
|
|
814
1159
|
<mj-text color="#C00" font-size="44px" font-weight="400">${data.gaUsersCurrent !== void 0 ? fmtUsers(data.gaUsersCurrent) : "\u2014"} Users</mj-text>
|
|
815
1160
|
${analyticsTrendLine(data.gaUsersCurrent, data.gaUsersPrevious)}
|
|
816
|
-
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" padding-bottom="36px" line-height="20px"
|
|
1161
|
+
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" padding-bottom="36px" line-height="20px">${escapeXml(copy.seoCta)}</mj-text>
|
|
817
1162
|
</mj-column>
|
|
818
1163
|
</mj-section>
|
|
819
|
-
${isTesting ? testingIntroSection() + testingChecklistSection() : maintenanceTestingPlaceholder(data.lastTestedDate)}
|
|
820
|
-
${data.commentary ? commentarySection(data.commentary) : ""}
|
|
1164
|
+
${isTesting ? testingIntroSection(copy) + testingChecklistSection(copy) : maintenanceTestingPlaceholder(data.lastTestedDate)}
|
|
1165
|
+
${data.commentary ? commentarySection(data.commentary, copy) : ""}
|
|
821
1166
|
<mj-section background-color="white">
|
|
822
1167
|
<mj-column padding-top="36px">
|
|
823
1168
|
<mj-text color="#C00" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="36px" line-height="36px">Any questions, concerns or requests?</mj-text>
|
|
824
|
-
|
|
825
|
-
|
|
1169
|
+
${copy.contact.map(
|
|
1170
|
+
(line, i) => i === copy.contact.length - 1 ? `<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" padding-top="0px" line-height="30px" padding-bottom="36px">${escapeXml(line)}</mj-text>` : `<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml(line)}</mj-text>`
|
|
1171
|
+
).join("\n ")}
|
|
826
1172
|
<mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
|
|
827
1173
|
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" line-height="20px" font-style="italic">Copyright ${(/* @__PURE__ */ new Date()).getUTCFullYear()} Reddoor Creative, LLC. All rights reserved.</mj-text>
|
|
828
1174
|
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="700" line-height="16px" padding-top="0" padding-bottom="0px">Our mailing address is:</mj-text>
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1175
|
+
${[copy.footerOrg, ...copy.footerAddress].map(
|
|
1176
|
+
(line) => `<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml(line)}</mj-text>`
|
|
1177
|
+
).join("\n ")}
|
|
832
1178
|
</mj-column>
|
|
833
1179
|
</mj-section>
|
|
834
1180
|
</mj-body>
|
|
@@ -838,6 +1184,7 @@ var CHECK_PNG, BLURRED_TESTS, TREND_UP, TREND_NEUTRAL;
|
|
|
838
1184
|
var init_template = __esm({
|
|
839
1185
|
"src/reports/maintenance-email/template.ts"() {
|
|
840
1186
|
"use strict";
|
|
1187
|
+
init_copy();
|
|
841
1188
|
init_assets();
|
|
842
1189
|
CHECK_PNG = `cid:${CHECK_CID}`;
|
|
843
1190
|
BLURRED_TESTS = `cid:${BLURRED_CID}`;
|
|
@@ -846,10 +1193,73 @@ var init_template = __esm({
|
|
|
846
1193
|
}
|
|
847
1194
|
});
|
|
848
1195
|
|
|
1196
|
+
// src/reports/launch-email/template.ts
|
|
1197
|
+
function buildLaunchMjml(data) {
|
|
1198
|
+
const copy = data.copy ?? DEFAULT_COPY;
|
|
1199
|
+
const previewText = `${escapeXml(data.siteName)} is live`;
|
|
1200
|
+
const setupRows = copy.launchSetupItems.map(
|
|
1201
|
+
(item) => `
|
|
1202
|
+
<mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="4px" padding-bottom="4px">\u2022 ${escapeXml(item)}</mj-text>`
|
|
1203
|
+
).join("");
|
|
1204
|
+
const contactRows = copy.contact.map(
|
|
1205
|
+
(line) => `
|
|
1206
|
+
<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml(line)}</mj-text>`
|
|
1207
|
+
).join("");
|
|
1208
|
+
const footerAddressRows = copy.footerAddress.map(
|
|
1209
|
+
(line) => `
|
|
1210
|
+
<mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml(line)}</mj-text>`
|
|
1211
|
+
).join("");
|
|
1212
|
+
return `<mjml>
|
|
1213
|
+
<mj-head>
|
|
1214
|
+
<mj-attributes>
|
|
1215
|
+
<mj-text font-family="helvetica, sans-serif" padding-left="5px" padding-right="5px" />
|
|
1216
|
+
<mj-section padding-left="11%" padding-right="11%"/>
|
|
1217
|
+
<mj-image padding="0px" />
|
|
1218
|
+
</mj-attributes>
|
|
1219
|
+
<mj-preview>${previewText}</mj-preview>
|
|
1220
|
+
${headerStyleBlock(data)}
|
|
1221
|
+
</mj-head>
|
|
1222
|
+
<mj-body background-color="white">
|
|
1223
|
+
<mj-section background-color="#F4F4F4" padding-top="0px" padding-bottom="0px" padding-left="0px" padding-right="0px">
|
|
1224
|
+
<mj-column>${headerImageTag(data)}</mj-column>
|
|
1225
|
+
</mj-section>
|
|
1226
|
+
<mj-section background-color="white">
|
|
1227
|
+
<mj-column>
|
|
1228
|
+
<mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="75px">${escapeXml(copy.launchHeading)}</mj-text>
|
|
1229
|
+
<mj-text color="${RED}" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
|
|
1230
|
+
<mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="20px">${escapeXml(copy.launchBody)}</mj-text>
|
|
1231
|
+
${setupRows}
|
|
1232
|
+
</mj-column>
|
|
1233
|
+
</mj-section>
|
|
1234
|
+
<mj-section background-color="white">
|
|
1235
|
+
<mj-column padding-top="36px">
|
|
1236
|
+
<mj-text color="${RED}" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="36px" line-height="36px">Any questions, concerns or requests?</mj-text>
|
|
1237
|
+
${contactRows}
|
|
1238
|
+
<mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
|
|
1239
|
+
<mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" line-height="20px" font-style="italic">Copyright ${(/* @__PURE__ */ new Date()).getUTCFullYear()} ${escapeXml(copy.footerOrg)}. All rights reserved.</mj-text>
|
|
1240
|
+
<mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="700" line-height="16px" padding-top="0" padding-bottom="0px">Our mailing address is:</mj-text>
|
|
1241
|
+
<mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml(copy.footerOrg)}</mj-text>
|
|
1242
|
+
${footerAddressRows}
|
|
1243
|
+
</mj-column>
|
|
1244
|
+
</mj-section>
|
|
1245
|
+
</mj-body>
|
|
1246
|
+
</mjml>`;
|
|
1247
|
+
}
|
|
1248
|
+
var RED, GREY;
|
|
1249
|
+
var init_template2 = __esm({
|
|
1250
|
+
"src/reports/launch-email/template.ts"() {
|
|
1251
|
+
"use strict";
|
|
1252
|
+
init_copy();
|
|
1253
|
+
init_template();
|
|
1254
|
+
RED = "#C00";
|
|
1255
|
+
GREY = "#757575";
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
|
|
849
1259
|
// src/reports/render.ts
|
|
850
1260
|
import mjml2html from "mjml";
|
|
851
1261
|
async function renderReportHtml(data) {
|
|
852
|
-
const mjml = buildMjml(data);
|
|
1262
|
+
const mjml = data.reportType === "Launch" ? buildLaunchMjml(data) : buildMjml(data);
|
|
853
1263
|
const out = await mjml2html(mjml, { validationLevel: "strict" });
|
|
854
1264
|
return { html: out.html, warnings: out.errors ?? [] };
|
|
855
1265
|
}
|
|
@@ -857,6 +1267,7 @@ var init_render = __esm({
|
|
|
857
1267
|
"src/reports/render.ts"() {
|
|
858
1268
|
"use strict";
|
|
859
1269
|
init_template();
|
|
1270
|
+
init_template2();
|
|
860
1271
|
}
|
|
861
1272
|
});
|
|
862
1273
|
|
|
@@ -899,44 +1310,6 @@ var init_attachments = __esm({
|
|
|
899
1310
|
}
|
|
900
1311
|
});
|
|
901
1312
|
|
|
902
|
-
// src/reports/maintenance-email/header-image.ts
|
|
903
|
-
import sharp from "sharp";
|
|
904
|
-
function channelToHex(value) {
|
|
905
|
-
return Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0");
|
|
906
|
-
}
|
|
907
|
-
async function prepareHeaderImage(bytes, options = {}) {
|
|
908
|
-
const requestedDisplayWidth = options.displayWidth ?? DEFAULT_DISPLAY_WIDTH;
|
|
909
|
-
const input = Buffer.from(bytes);
|
|
910
|
-
const meta = await sharp(input).metadata();
|
|
911
|
-
const origWidth = meta.width;
|
|
912
|
-
const origHeight = meta.height;
|
|
913
|
-
if (!origWidth || !origHeight) {
|
|
914
|
-
throw new Error("prepareHeaderImage: could not read source image dimensions");
|
|
915
|
-
}
|
|
916
|
-
const displayWidth = Math.min(requestedDisplayWidth, origWidth);
|
|
917
|
-
const displayHeight = Math.round(displayWidth * origHeight / origWidth);
|
|
918
|
-
const targetSourceWidth = Math.min(origWidth, displayWidth * RETINA_SCALE);
|
|
919
|
-
const out = await sharp(input).resize({ width: targetSourceWidth, withoutEnlargement: true }).flatten({ background: "#ffffff" }).jpeg({ quality: JPEG_QUALITY }).toBuffer();
|
|
920
|
-
const { dominant } = await sharp(out).stats();
|
|
921
|
-
const placeholderColor = `#${channelToHex(dominant.r)}${channelToHex(dominant.g)}${channelToHex(dominant.b)}`;
|
|
922
|
-
return {
|
|
923
|
-
bytes: new Uint8Array(out),
|
|
924
|
-
contentType: "image/jpeg",
|
|
925
|
-
displayWidth,
|
|
926
|
-
displayHeight,
|
|
927
|
-
placeholderColor
|
|
928
|
-
};
|
|
929
|
-
}
|
|
930
|
-
var DEFAULT_DISPLAY_WIDTH, RETINA_SCALE, JPEG_QUALITY;
|
|
931
|
-
var init_header_image = __esm({
|
|
932
|
-
"src/reports/maintenance-email/header-image.ts"() {
|
|
933
|
-
"use strict";
|
|
934
|
-
DEFAULT_DISPLAY_WIDTH = 600;
|
|
935
|
-
RETINA_SCALE = 2;
|
|
936
|
-
JPEG_QUALITY = 82;
|
|
937
|
-
}
|
|
938
|
-
});
|
|
939
|
-
|
|
940
1313
|
// src/reports/send/resend.ts
|
|
941
1314
|
import { Resend } from "resend";
|
|
942
1315
|
function defaultResendClient() {
|
|
@@ -963,9 +1336,498 @@ function defaultResendClient() {
|
|
|
963
1336
|
}
|
|
964
1337
|
};
|
|
965
1338
|
}
|
|
966
|
-
var init_resend = __esm({
|
|
967
|
-
"src/reports/send/resend.ts"() {
|
|
1339
|
+
var init_resend = __esm({
|
|
1340
|
+
"src/reports/send/resend.ts"() {
|
|
1341
|
+
"use strict";
|
|
1342
|
+
}
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
// src/alerts/digest-collectors.ts
|
|
1346
|
+
function dashboardUrl(baseUrl, siteName) {
|
|
1347
|
+
return `${baseUrl.replace(/\/$/, "")}/s/${siteSlug(siteName)}`;
|
|
1348
|
+
}
|
|
1349
|
+
function collectVulnAlerts(sites, baseUrl) {
|
|
1350
|
+
const items = [];
|
|
1351
|
+
for (const s of sites) {
|
|
1352
|
+
const critical = s.securityVulnsCritical ?? 0;
|
|
1353
|
+
const high = s.securityVulnsHigh ?? 0;
|
|
1354
|
+
const metric = critical + high;
|
|
1355
|
+
if (metric <= 0) continue;
|
|
1356
|
+
items.push({
|
|
1357
|
+
key: `vuln:${s.id}`,
|
|
1358
|
+
kind: "vuln",
|
|
1359
|
+
siteName: s.name,
|
|
1360
|
+
title: `${metric} critical/high ${metric === 1 ? "vuln" : "vulns"}`,
|
|
1361
|
+
url: dashboardUrl(baseUrl, s.name),
|
|
1362
|
+
severity: critical > 0 ? "critical" : "warning",
|
|
1363
|
+
metric
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
return items;
|
|
1367
|
+
}
|
|
1368
|
+
function collectLighthouseAlerts(sites, baseUrl) {
|
|
1369
|
+
const items = [];
|
|
1370
|
+
for (const s of sites) {
|
|
1371
|
+
for (const cat of LIGHTHOUSE_CATEGORIES2) {
|
|
1372
|
+
const score = s[cat.field];
|
|
1373
|
+
if (score === null || score >= LIGHTHOUSE_FLOOR) continue;
|
|
1374
|
+
items.push({
|
|
1375
|
+
key: `lighthouse:${s.id}:${cat.slug}`,
|
|
1376
|
+
kind: "lighthouse",
|
|
1377
|
+
siteName: s.name,
|
|
1378
|
+
title: `Lighthouse ${cat.label} ${score} (below ${LIGHTHOUSE_FLOOR})`,
|
|
1379
|
+
url: dashboardUrl(baseUrl, s.name),
|
|
1380
|
+
severity: "warning",
|
|
1381
|
+
metric: 100 - score
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
return items;
|
|
1386
|
+
}
|
|
1387
|
+
function collectDeliveryFailures(reports, sitesById, baseUrl) {
|
|
1388
|
+
const items = [];
|
|
1389
|
+
for (const r of reports) {
|
|
1390
|
+
if (r.deliveryStatus !== "bounced" && r.deliveryStatus !== "complained") continue;
|
|
1391
|
+
const site = sitesById.get(r.siteId);
|
|
1392
|
+
if (!site) continue;
|
|
1393
|
+
const complained = r.deliveryStatus === "complained";
|
|
1394
|
+
items.push({
|
|
1395
|
+
key: `delivery:${r.id}`,
|
|
1396
|
+
kind: "delivery",
|
|
1397
|
+
siteName: site.name,
|
|
1398
|
+
title: complained ? "Spam complaint on a sent report" : "A sent report bounced",
|
|
1399
|
+
url: dashboardUrl(baseUrl, site.name),
|
|
1400
|
+
severity: complained ? "critical" : "warning",
|
|
1401
|
+
metric: 1
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
return items;
|
|
1405
|
+
}
|
|
1406
|
+
function renovateFindingsToAttention(result) {
|
|
1407
|
+
const items = [];
|
|
1408
|
+
for (const f of result.findings) {
|
|
1409
|
+
items.push({
|
|
1410
|
+
key: `renovate:${f.repo}#${f.pr.number}`,
|
|
1411
|
+
kind: "renovate",
|
|
1412
|
+
siteName: f.site,
|
|
1413
|
+
title: `Renovate update failing CI: ${f.pr.title}`,
|
|
1414
|
+
url: f.pr.url,
|
|
1415
|
+
severity: "warning",
|
|
1416
|
+
metric: 1
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
if (result.skipped.length > 0) {
|
|
1420
|
+
items.push({
|
|
1421
|
+
key: "renovate:skipped",
|
|
1422
|
+
kind: "renovate",
|
|
1423
|
+
siteName: "Fleet checks",
|
|
1424
|
+
title: `Couldn't check ${result.skipped.length} repo(s) for failing Renovate PRs`,
|
|
1425
|
+
severity: "warning",
|
|
1426
|
+
metric: result.skipped.length
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
return items;
|
|
1430
|
+
}
|
|
1431
|
+
function buildRenovateProbe() {
|
|
1432
|
+
const token = process.env.RENOVATE_TOKEN?.trim() || process.env.GH_TOKEN?.trim();
|
|
1433
|
+
if (!token) return void 0;
|
|
1434
|
+
return makeGitHub({ token }).openPullRequests;
|
|
1435
|
+
}
|
|
1436
|
+
var LIGHTHOUSE_FLOOR, LIGHTHOUSE_CATEGORIES2;
|
|
1437
|
+
var init_digest_collectors = __esm({
|
|
1438
|
+
"src/alerts/digest-collectors.ts"() {
|
|
1439
|
+
"use strict";
|
|
1440
|
+
init_websites();
|
|
1441
|
+
init_gh();
|
|
1442
|
+
LIGHTHOUSE_FLOOR = 75;
|
|
1443
|
+
LIGHTHOUSE_CATEGORIES2 = [
|
|
1444
|
+
{ field: "pScore", slug: "performance", label: "Performance" },
|
|
1445
|
+
{ field: "rScore", slug: "accessibility", label: "Accessibility" },
|
|
1446
|
+
{ field: "bpScore", slug: "best-practices", label: "Best Practices" },
|
|
1447
|
+
{ field: "seoScore", slug: "seo", label: "SEO" }
|
|
1448
|
+
];
|
|
1449
|
+
}
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
// src/alerts/renovate.ts
|
|
1453
|
+
function isRenovatePR(pr) {
|
|
1454
|
+
return RENOVATE_HEAD_PREFIXES.some((p) => pr.headRef.startsWith(p));
|
|
1455
|
+
}
|
|
1456
|
+
function isFailingRenovatePR(pr) {
|
|
1457
|
+
return isRenovatePR(pr) && pr.ciState === "failing";
|
|
1458
|
+
}
|
|
1459
|
+
function siteLabel2(site) {
|
|
1460
|
+
const display = site.meta?.["displayName"];
|
|
1461
|
+
if (typeof display === "string" && display.length > 0) return display;
|
|
1462
|
+
if (typeof site.name === "string" && site.name.length > 0) return site.name;
|
|
1463
|
+
return "unknown";
|
|
1464
|
+
}
|
|
1465
|
+
async function collectRenovateFailures(sites, probe) {
|
|
1466
|
+
const findings = [];
|
|
1467
|
+
const skipped = [];
|
|
1468
|
+
for (const site of sites) {
|
|
1469
|
+
const repo = site.gitRepo;
|
|
1470
|
+
if (!repo) continue;
|
|
1471
|
+
let prs;
|
|
1472
|
+
try {
|
|
1473
|
+
prs = await probe(repo);
|
|
1474
|
+
} catch {
|
|
1475
|
+
skipped.push(repo);
|
|
1476
|
+
continue;
|
|
1477
|
+
}
|
|
1478
|
+
for (const pr of prs) {
|
|
1479
|
+
if (isFailingRenovatePR(pr)) {
|
|
1480
|
+
findings.push({ site: siteLabel2(site), repo, pr });
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
return { findings, skipped };
|
|
1485
|
+
}
|
|
1486
|
+
var RENOVATE_HEAD_PREFIXES;
|
|
1487
|
+
var init_renovate = __esm({
|
|
1488
|
+
"src/alerts/renovate.ts"() {
|
|
1489
|
+
"use strict";
|
|
1490
|
+
RENOVATE_HEAD_PREFIXES = ["renovate/", "renovate-"];
|
|
1491
|
+
}
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
// src/alerts/digest-state.ts
|
|
1495
|
+
function diffAttention(items, prior, today) {
|
|
1496
|
+
const tagged = [];
|
|
1497
|
+
const next = {};
|
|
1498
|
+
for (const it of items) {
|
|
1499
|
+
const was = prior[it.key];
|
|
1500
|
+
let status;
|
|
1501
|
+
let firstFlaggedAt;
|
|
1502
|
+
if (!was) {
|
|
1503
|
+
status = "new";
|
|
1504
|
+
firstFlaggedAt = today;
|
|
1505
|
+
} else if (it.metric > was.metric) {
|
|
1506
|
+
status = "worse";
|
|
1507
|
+
firstFlaggedAt = was.firstFlaggedAt;
|
|
1508
|
+
} else {
|
|
1509
|
+
status = "standing";
|
|
1510
|
+
firstFlaggedAt = was.firstFlaggedAt;
|
|
1511
|
+
}
|
|
1512
|
+
tagged.push({ ...it, status });
|
|
1513
|
+
next[it.key] = { metric: it.metric, firstFlaggedAt };
|
|
1514
|
+
}
|
|
1515
|
+
return { tagged, next };
|
|
1516
|
+
}
|
|
1517
|
+
async function readDigestState(base) {
|
|
1518
|
+
const rows = [];
|
|
1519
|
+
await base(DIGEST_STATE_TABLE).select({ maxRecords: 1, pageSize: 1 }).eachPage((records, fetchNextPage) => {
|
|
1520
|
+
for (const rec of records) rows.push({ id: rec.id, fields: rec.fields });
|
|
1521
|
+
fetchNextPage();
|
|
1522
|
+
});
|
|
1523
|
+
const first = rows[0];
|
|
1524
|
+
if (!first) return {};
|
|
1525
|
+
const raw = first.fields["Snapshot"];
|
|
1526
|
+
if (typeof raw !== "string") return {};
|
|
1527
|
+
try {
|
|
1528
|
+
return JSON.parse(raw);
|
|
1529
|
+
} catch {
|
|
1530
|
+
return {};
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
async function writeDigestState(base, snap, updatedAt = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
1534
|
+
const rows = [];
|
|
1535
|
+
await base(DIGEST_STATE_TABLE).select({ maxRecords: 1, pageSize: 1 }).eachPage((records, fetchNextPage) => {
|
|
1536
|
+
for (const rec of records) rows.push({ id: rec.id });
|
|
1537
|
+
fetchNextPage();
|
|
1538
|
+
});
|
|
1539
|
+
const fields = {
|
|
1540
|
+
Snapshot: JSON.stringify(snap),
|
|
1541
|
+
"Updated At": updatedAt
|
|
1542
|
+
};
|
|
1543
|
+
const existing = rows[0];
|
|
1544
|
+
if (existing) {
|
|
1545
|
+
await base(DIGEST_STATE_TABLE).update([{ id: existing.id, fields }]);
|
|
1546
|
+
} else {
|
|
1547
|
+
await base(DIGEST_STATE_TABLE).create([{ fields }]);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
var DIGEST_STATE_TABLE;
|
|
1551
|
+
var init_digest_state = __esm({
|
|
1552
|
+
"src/alerts/digest-state.ts"() {
|
|
1553
|
+
"use strict";
|
|
1554
|
+
DIGEST_STATE_TABLE = "Digest State";
|
|
1555
|
+
}
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
// src/reports/digest.ts
|
|
1559
|
+
var digest_exports = {};
|
|
1560
|
+
__export(digest_exports, {
|
|
1561
|
+
collectAttention: () => collectAttention,
|
|
1562
|
+
listPendingApproval: () => listPendingApproval,
|
|
1563
|
+
renderDigestHtml: () => renderDigestHtml,
|
|
1564
|
+
runDigest: () => runDigest
|
|
1565
|
+
});
|
|
1566
|
+
function esc(s) {
|
|
1567
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1568
|
+
}
|
|
1569
|
+
function readySection(items) {
|
|
1570
|
+
const heading = `<h2 style="color:${RED2};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Ready for your yes</h2>`;
|
|
1571
|
+
if (items.length === 0) {
|
|
1572
|
+
return `${heading}<p style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;margin:0">Nothing waiting on you.</p>`;
|
|
1573
|
+
}
|
|
1574
|
+
const rows = items.map((it) => {
|
|
1575
|
+
const safeUrl = it.dashboardUrl.startsWith("https://") ? it.dashboardUrl : void 0;
|
|
1576
|
+
const link = safeUrl ? `<a href="${esc(safeUrl)}" style="${ANCHOR_STYLE}">review & approve</a>` : `review & approve`;
|
|
1577
|
+
return `
|
|
1578
|
+
<tr>
|
|
1579
|
+
<td style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">
|
|
1580
|
+
<strong style="color:#222">${esc(it.siteName)}</strong> \u2014 ${esc(it.reportType)} (${esc(it.period)})
|
|
1581
|
+
\u2014 ${link}
|
|
1582
|
+
</td>
|
|
1583
|
+
</tr>`;
|
|
1584
|
+
}).join("");
|
|
1585
|
+
return `${heading}<table role="presentation" style="border-collapse:collapse;margin:0">${rows}</table>`;
|
|
1586
|
+
}
|
|
1587
|
+
function attentionBadge(status) {
|
|
1588
|
+
if (status === "new")
|
|
1589
|
+
return `<strong style="color:${RED2};font-family:helvetica,sans-serif">NEW</strong> `;
|
|
1590
|
+
if (status === "worse")
|
|
1591
|
+
return `<strong style="color:${RED2};font-family:helvetica,sans-serif">WORSE</strong> `;
|
|
1592
|
+
return "";
|
|
1593
|
+
}
|
|
1594
|
+
function attentionSection(items) {
|
|
1595
|
+
const heading = `<h2 style="color:${RED2};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Needs attention</h2>`;
|
|
1596
|
+
if (items.length === 0) {
|
|
1597
|
+
return `${heading}<p style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;margin:0">All clear \u2014 nothing needs attention.</p>`;
|
|
1598
|
+
}
|
|
1599
|
+
const bySite = /* @__PURE__ */ new Map();
|
|
1600
|
+
for (const it of items) {
|
|
1601
|
+
const bucket = bySite.get(it.siteName);
|
|
1602
|
+
if (bucket) bucket.push(it);
|
|
1603
|
+
else bySite.set(it.siteName, [it]);
|
|
1604
|
+
}
|
|
1605
|
+
const groups = [...bySite.entries()].map(([siteName, siteItems]) => {
|
|
1606
|
+
const sorted = [...siteItems].sort(
|
|
1607
|
+
(a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]
|
|
1608
|
+
);
|
|
1609
|
+
const rows = sorted.map((it) => {
|
|
1610
|
+
const safeUrl = it.url?.startsWith("https://") ? it.url : void 0;
|
|
1611
|
+
const titleHtml = safeUrl ? `<a href="${esc(safeUrl)}" style="${ANCHOR_STYLE}">${esc(it.title)}</a>` : esc(it.title);
|
|
1612
|
+
return `
|
|
1613
|
+
<tr>
|
|
1614
|
+
<td style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">${attentionBadge(it.status)}${titleHtml}</td>
|
|
1615
|
+
</tr>`;
|
|
1616
|
+
}).join("");
|
|
1617
|
+
return `
|
|
1618
|
+
<tr>
|
|
1619
|
+
<td style="color:#222;font-family:helvetica,sans-serif;font-size:16px;font-weight:700;padding:8px 0 4px">${esc(siteName)}</td>
|
|
1620
|
+
</tr>
|
|
1621
|
+
${rows}`;
|
|
1622
|
+
}).join("");
|
|
1623
|
+
return `${heading}<table role="presentation" style="border-collapse:collapse;margin:0">${groups}</table>`;
|
|
1624
|
+
}
|
|
1625
|
+
function digestDateKey(d) {
|
|
1626
|
+
return d.toISOString().slice(0, 10);
|
|
1627
|
+
}
|
|
1628
|
+
function isIdempotencyConflict(err) {
|
|
1629
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1630
|
+
if (/idempotency key has been used/i.test(message)) return true;
|
|
1631
|
+
const e = err;
|
|
1632
|
+
if (e.name === "invalid_idempotent_request") return true;
|
|
1633
|
+
if (e.statusCode === 409) return true;
|
|
1634
|
+
return false;
|
|
1635
|
+
}
|
|
1636
|
+
async function listPendingApproval(base) {
|
|
1637
|
+
return (await listAllReports(base)).filter(
|
|
1638
|
+
(r) => r.draftReady && !r.approvedToSend && r.sentAt === null
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
function runCollector(label, fn) {
|
|
1642
|
+
try {
|
|
1643
|
+
return fn();
|
|
1644
|
+
} catch (e) {
|
|
1645
|
+
console.warn(`\u26A0 attention collector "${label}" failed: ${e.message}`);
|
|
1646
|
+
return [];
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
async function runCollectorAsync(label, fn) {
|
|
1650
|
+
try {
|
|
1651
|
+
return await fn();
|
|
1652
|
+
} catch (e) {
|
|
1653
|
+
console.warn(`\u26A0 attention collector "${label}" failed: ${e.message}`);
|
|
1654
|
+
return [];
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
async function collectAttention(deps) {
|
|
1658
|
+
const reports = deps.reports ?? await listAllReports(deps.base);
|
|
1659
|
+
const websites = deps.websites ?? await listWebsites(deps.base);
|
|
1660
|
+
const sitesById = new Map(websites.map((w) => [w.id, w]));
|
|
1661
|
+
const renovate = deps.renovateProbe ? await runCollectorAsync("renovate", async () => {
|
|
1662
|
+
const sites = websites.map((w) => ({
|
|
1663
|
+
path: "",
|
|
1664
|
+
name: w.name,
|
|
1665
|
+
meta: {},
|
|
1666
|
+
...w.gitRepo ? { gitRepo: w.gitRepo } : {}
|
|
1667
|
+
}));
|
|
1668
|
+
const result = await collectRenovateFailures(sites, deps.renovateProbe);
|
|
1669
|
+
return renovateFindingsToAttention(result);
|
|
1670
|
+
}) : [];
|
|
1671
|
+
return [
|
|
1672
|
+
...runCollector("vuln", () => collectVulnAlerts(websites, deps.baseUrl)),
|
|
1673
|
+
...runCollector("delivery", () => collectDeliveryFailures(reports, sitesById, deps.baseUrl)),
|
|
1674
|
+
...runCollector("lighthouse", () => collectLighthouseAlerts(websites, deps.baseUrl)),
|
|
1675
|
+
...renovate
|
|
1676
|
+
];
|
|
1677
|
+
}
|
|
1678
|
+
async function runDigest(options) {
|
|
1679
|
+
const today = /* @__PURE__ */ new Date();
|
|
1680
|
+
try {
|
|
1681
|
+
const base = options.base ?? openBase(readAirtableConfig());
|
|
1682
|
+
const reports = await listAllReports(base);
|
|
1683
|
+
const websites = await listWebsites(base);
|
|
1684
|
+
const sites = new Map(websites.map((w) => [w.id, w]));
|
|
1685
|
+
const pending = reports.filter((r) => r.draftReady && !r.approvedToSend && r.sentAt === null);
|
|
1686
|
+
const readyForYourYes = [];
|
|
1687
|
+
for (const r of pending) {
|
|
1688
|
+
const site = sites.get(r.siteId);
|
|
1689
|
+
if (!site) continue;
|
|
1690
|
+
readyForYourYes.push({
|
|
1691
|
+
siteName: site.name,
|
|
1692
|
+
reportType: r.reportType,
|
|
1693
|
+
period: r.period ?? "\u2014",
|
|
1694
|
+
dashboardUrl: `${options.baseUrl.replace(/\/$/, "")}/s/${siteSlug(site.name)}`
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
const renovateProbe = buildRenovateProbe();
|
|
1698
|
+
const collected = await collectAttention({
|
|
1699
|
+
base,
|
|
1700
|
+
baseUrl: options.baseUrl,
|
|
1701
|
+
websites,
|
|
1702
|
+
reports,
|
|
1703
|
+
...renovateProbe ? { renovateProbe } : {}
|
|
1704
|
+
});
|
|
1705
|
+
const prior = await readDigestState(base);
|
|
1706
|
+
const { tagged, next } = diffAttention(collected, prior, digestDateKey(today));
|
|
1707
|
+
const needsAttention = tagged;
|
|
1708
|
+
if (readyForYourYes.length === 0 && needsAttention.length === 0) {
|
|
1709
|
+
try {
|
|
1710
|
+
await writeDigestState(base, next);
|
|
1711
|
+
} catch (e) {
|
|
1712
|
+
console.warn(`\u26A0 digest state write failed: ${e.message}`);
|
|
1713
|
+
}
|
|
1714
|
+
return { output: "Digest skipped (nothing ready, nothing needs attention).", code: 0 };
|
|
1715
|
+
}
|
|
1716
|
+
const html = renderDigestHtml({ readyForYourYes, needsAttention });
|
|
1717
|
+
const client = options.resend ?? defaultResendClient();
|
|
1718
|
+
const to = [process.env.OPERATOR_EMAIL?.trim() || DIGEST_OPERATOR_FALLBACK];
|
|
1719
|
+
const n = readyForYourYes.length;
|
|
1720
|
+
const reportWord = n === 1 ? "report" : "reports";
|
|
1721
|
+
let result;
|
|
1722
|
+
try {
|
|
1723
|
+
result = await client.send({
|
|
1724
|
+
from: FROM_ADDRESS,
|
|
1725
|
+
to,
|
|
1726
|
+
subject: `Your fleet \u2014 ${digestDateKey(today)}: ${n} ${reportWord} ready for your yes`,
|
|
1727
|
+
html,
|
|
1728
|
+
idempotencyKey: `digest-${digestDateKey(today)}`
|
|
1729
|
+
});
|
|
1730
|
+
} catch (err) {
|
|
1731
|
+
if (isIdempotencyConflict(err)) {
|
|
1732
|
+
return {
|
|
1733
|
+
output: "Digest already sent today (content changed since the first send) \u2014 skipped to avoid a duplicate.",
|
|
1734
|
+
code: 0
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
throw err;
|
|
1738
|
+
}
|
|
1739
|
+
try {
|
|
1740
|
+
await writeDigestState(base, next);
|
|
1741
|
+
} catch (e) {
|
|
1742
|
+
console.warn(`\u26A0 digest state write failed: ${e.message}`);
|
|
1743
|
+
}
|
|
1744
|
+
return { output: `Digest sent to ${to.join(", ")} (${result.messageId})`, code: 0 };
|
|
1745
|
+
} catch (err) {
|
|
1746
|
+
if (typeof err.exitCode === "number") {
|
|
1747
|
+
throw err;
|
|
1748
|
+
}
|
|
1749
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1750
|
+
return { output: `digest failed: ${message}`, code: 1 };
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
function renderDigestHtml(sections) {
|
|
1754
|
+
return `<!doctype html>
|
|
1755
|
+
<html>
|
|
1756
|
+
<head><meta charset="utf-8"></head>
|
|
1757
|
+
<body style="margin:0;padding:0;background:#ffffff">
|
|
1758
|
+
<table width="100%" style="border-collapse:collapse">
|
|
1759
|
+
<tr>
|
|
1760
|
+
<td align="center" style="padding:24px">
|
|
1761
|
+
<table width="600" style="border-collapse:collapse">
|
|
1762
|
+
<tr>
|
|
1763
|
+
<td>
|
|
1764
|
+
<h1 style="color:${RED2};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px">Your fleet today</h1>
|
|
1765
|
+
${readySection(sections.readyForYourYes)}
|
|
1766
|
+
${attentionSection(sections.needsAttention)}
|
|
1767
|
+
</td>
|
|
1768
|
+
</tr>
|
|
1769
|
+
</table>
|
|
1770
|
+
</td>
|
|
1771
|
+
</tr>
|
|
1772
|
+
</table>
|
|
1773
|
+
</body>
|
|
1774
|
+
</html>`;
|
|
1775
|
+
}
|
|
1776
|
+
var GREY2, RED2, ANCHOR_STYLE, SEVERITY_ORDER, FROM_ADDRESS, DIGEST_OPERATOR_FALLBACK;
|
|
1777
|
+
var init_digest = __esm({
|
|
1778
|
+
"src/reports/digest.ts"() {
|
|
1779
|
+
"use strict";
|
|
1780
|
+
init_client();
|
|
1781
|
+
init_reports();
|
|
1782
|
+
init_websites();
|
|
1783
|
+
init_resend();
|
|
1784
|
+
init_digest_collectors();
|
|
1785
|
+
init_renovate();
|
|
1786
|
+
init_digest_state();
|
|
1787
|
+
GREY2 = "#757575";
|
|
1788
|
+
RED2 = "#C00";
|
|
1789
|
+
ANCHOR_STYLE = `color:${RED2};font-family:helvetica,sans-serif`;
|
|
1790
|
+
SEVERITY_ORDER = { critical: 0, warning: 1 };
|
|
1791
|
+
FROM_ADDRESS = "Reddoor Reports <reports@reddoorla.com>";
|
|
1792
|
+
DIGEST_OPERATOR_FALLBACK = "info@reddoorla.com";
|
|
1793
|
+
}
|
|
1794
|
+
});
|
|
1795
|
+
|
|
1796
|
+
// src/reports/maintenance-email/header-image.ts
|
|
1797
|
+
import sharp from "sharp";
|
|
1798
|
+
function channelToHex(value) {
|
|
1799
|
+
return Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0");
|
|
1800
|
+
}
|
|
1801
|
+
async function prepareHeaderImage(bytes, options = {}) {
|
|
1802
|
+
const requestedDisplayWidth = options.displayWidth ?? DEFAULT_DISPLAY_WIDTH;
|
|
1803
|
+
const input = Buffer.from(bytes);
|
|
1804
|
+
const meta = await sharp(input).metadata();
|
|
1805
|
+
const origWidth = meta.width;
|
|
1806
|
+
const origHeight = meta.height;
|
|
1807
|
+
if (!origWidth || !origHeight) {
|
|
1808
|
+
throw new Error("prepareHeaderImage: could not read source image dimensions");
|
|
1809
|
+
}
|
|
1810
|
+
const displayWidth = Math.min(requestedDisplayWidth, origWidth);
|
|
1811
|
+
const displayHeight = Math.round(displayWidth * origHeight / origWidth);
|
|
1812
|
+
const targetSourceWidth = Math.min(origWidth, displayWidth * RETINA_SCALE);
|
|
1813
|
+
const out = await sharp(input).resize({ width: targetSourceWidth, withoutEnlargement: true }).flatten({ background: "#ffffff" }).jpeg({ quality: JPEG_QUALITY }).toBuffer();
|
|
1814
|
+
const { dominant } = await sharp(out).stats();
|
|
1815
|
+
const placeholderColor = `#${channelToHex(dominant.r)}${channelToHex(dominant.g)}${channelToHex(dominant.b)}`;
|
|
1816
|
+
return {
|
|
1817
|
+
bytes: new Uint8Array(out),
|
|
1818
|
+
contentType: "image/jpeg",
|
|
1819
|
+
displayWidth,
|
|
1820
|
+
displayHeight,
|
|
1821
|
+
placeholderColor
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
var DEFAULT_DISPLAY_WIDTH, RETINA_SCALE, JPEG_QUALITY;
|
|
1825
|
+
var init_header_image = __esm({
|
|
1826
|
+
"src/reports/maintenance-email/header-image.ts"() {
|
|
968
1827
|
"use strict";
|
|
1828
|
+
DEFAULT_DISPLAY_WIDTH = 600;
|
|
1829
|
+
RETINA_SCALE = 2;
|
|
1830
|
+
JPEG_QUALITY = 82;
|
|
969
1831
|
}
|
|
970
1832
|
});
|
|
971
1833
|
|
|
@@ -1006,6 +1868,14 @@ async function sendApprovedReports(options = {}) {
|
|
|
1006
1868
|
try {
|
|
1007
1869
|
const messageId = await sendOne(client, base, site, report);
|
|
1008
1870
|
lines.push(`\u2713 sent: ${report.reportId} (${messageId})`);
|
|
1871
|
+
if (report.reportType === "Launch") {
|
|
1872
|
+
try {
|
|
1873
|
+
await updateLaunched(base, site.id, (/* @__PURE__ */ new Date()).toISOString());
|
|
1874
|
+
lines.push(` \u21B3 launched: ${site.name} flipped to maintenance`);
|
|
1875
|
+
} catch (e) {
|
|
1876
|
+
lines.push(` \u26A0 launch flip failed for ${site.name}: ${e.message}`);
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1009
1879
|
} catch (e) {
|
|
1010
1880
|
lines.push(`\u2717 ${report.reportId} \u2014 ${e.message}`);
|
|
1011
1881
|
anyFailed = true;
|
|
@@ -1038,6 +1908,7 @@ async function sendOne(client, base, site, report) {
|
|
|
1038
1908
|
searchPosition: report.searchFoundPage1 && report.searchPosition !== null ? report.searchPosition : void 0,
|
|
1039
1909
|
lastTestedDate: report.lastTestedDate ? new Date(report.lastTestedDate) : null,
|
|
1040
1910
|
commentary: report.commentary,
|
|
1911
|
+
copy: resolveCopy(site),
|
|
1041
1912
|
headerImageCid: cidName,
|
|
1042
1913
|
headerWidth: header.displayWidth,
|
|
1043
1914
|
headerHeight: header.displayHeight,
|
|
@@ -1071,7 +1942,7 @@ async function sendOne(client, base, site, report) {
|
|
|
1071
1942
|
}
|
|
1072
1943
|
}
|
|
1073
1944
|
const payload = {
|
|
1074
|
-
from:
|
|
1945
|
+
from: FROM_ADDRESS2,
|
|
1075
1946
|
to,
|
|
1076
1947
|
replyTo: REPLY_TO,
|
|
1077
1948
|
subject,
|
|
@@ -1132,7 +2003,7 @@ function isProbablyEmail(s) {
|
|
|
1132
2003
|
if (/\s/.test(s)) return false;
|
|
1133
2004
|
return true;
|
|
1134
2005
|
}
|
|
1135
|
-
var
|
|
2006
|
+
var FROM_ADDRESS2, REPLY_TO, MONTHS2;
|
|
1136
2007
|
var init_orchestrate = __esm({
|
|
1137
2008
|
"src/reports/send/orchestrate.ts"() {
|
|
1138
2009
|
"use strict";
|
|
@@ -1141,10 +2012,11 @@ var init_orchestrate = __esm({
|
|
|
1141
2012
|
init_websites();
|
|
1142
2013
|
init_attachments();
|
|
1143
2014
|
init_render();
|
|
2015
|
+
init_copy();
|
|
1144
2016
|
init_assets();
|
|
1145
2017
|
init_header_image();
|
|
1146
2018
|
init_resend();
|
|
1147
|
-
|
|
2019
|
+
FROM_ADDRESS2 = "Reddoor Reports <reports@reddoorla.com>";
|
|
1148
2020
|
REPLY_TO = "info@reddoorla.com";
|
|
1149
2021
|
MONTHS2 = [
|
|
1150
2022
|
"January",
|
|
@@ -1173,72 +2045,8 @@ import { cac } from "cac";
|
|
|
1173
2045
|
import { resolve as resolve2 } from "path";
|
|
1174
2046
|
import { Listr } from "listr2";
|
|
1175
2047
|
|
|
1176
|
-
// src/audits/
|
|
1177
|
-
|
|
1178
|
-
var TRUNCATION_MARKER = "\n\u2026[output truncated]";
|
|
1179
|
-
function makeSpawn(internals = {}) {
|
|
1180
|
-
const spawnImpl = internals.spawnImpl ?? spawn;
|
|
1181
|
-
const killImpl = internals.killImpl ?? ((pid, sig) => process.kill(pid, sig));
|
|
1182
|
-
const killGraceMs = internals.killGraceMs ?? 5e3;
|
|
1183
|
-
const maxOutputBytes = internals.maxOutputBytes ?? 10 * 1024 * 1024;
|
|
1184
|
-
return (cmd, args, opts = {}) => new Promise((resolve11, reject) => {
|
|
1185
|
-
const streaming = opts.streaming === true;
|
|
1186
|
-
const child = spawnImpl(cmd, [...args], {
|
|
1187
|
-
cwd: opts.cwd,
|
|
1188
|
-
env: opts.env ?? process.env,
|
|
1189
|
-
stdio: streaming ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "pipe"],
|
|
1190
|
-
// Detach ONLY when a timeout can fire: the child then leads its own
|
|
1191
|
-
// process group, so the timeout can kill the WHOLE tree (vite, and
|
|
1192
|
-
// Chromium under lhci/playwright) via process.kill(-pid), not just the
|
|
1193
|
-
// npx/pnpm wrapper. Without it, killing the wrapper orphaned the
|
|
1194
|
-
// grandchildren — a zombie vite squatting its port, Chrome left running.
|
|
1195
|
-
// We do NOT detach timeout-less streaming calls (pnpm install/up):
|
|
1196
|
-
// detaching gains nothing there (no timeout → no group-kill) and would
|
|
1197
|
-
// break terminal Ctrl-C, which only reaches the foreground group — i.e.
|
|
1198
|
-
// it would re-orphan the very children this guards. We never unref() the
|
|
1199
|
-
// child since we still await it.
|
|
1200
|
-
detached: opts.timeoutMs !== void 0
|
|
1201
|
-
});
|
|
1202
|
-
const cap = (acc, chunk) => {
|
|
1203
|
-
if (acc.length >= maxOutputBytes) return acc;
|
|
1204
|
-
const next = acc + chunk;
|
|
1205
|
-
return next.length > maxOutputBytes ? next.slice(0, maxOutputBytes) + TRUNCATION_MARKER : next;
|
|
1206
|
-
};
|
|
1207
|
-
let stdout = "";
|
|
1208
|
-
let stderr = "";
|
|
1209
|
-
if (!streaming) {
|
|
1210
|
-
child.stdout?.on("data", (chunk) => stdout = cap(stdout, String(chunk)));
|
|
1211
|
-
child.stderr?.on("data", (chunk) => stderr = cap(stderr, String(chunk)));
|
|
1212
|
-
}
|
|
1213
|
-
const killGroup = (sig) => {
|
|
1214
|
-
if (child.pid === void 0) return;
|
|
1215
|
-
try {
|
|
1216
|
-
killImpl(-child.pid, sig);
|
|
1217
|
-
} catch {
|
|
1218
|
-
}
|
|
1219
|
-
};
|
|
1220
|
-
let killTimer;
|
|
1221
|
-
const timer = opts.timeoutMs ? setTimeout(() => {
|
|
1222
|
-
killGroup("SIGTERM");
|
|
1223
|
-
killTimer = setTimeout(() => killGroup("SIGKILL"), killGraceMs);
|
|
1224
|
-
killTimer.unref();
|
|
1225
|
-
reject(new Error(`spawn timeout after ${opts.timeoutMs}ms: ${cmd}`));
|
|
1226
|
-
}, opts.timeoutMs) : void 0;
|
|
1227
|
-
const clearTimers = () => {
|
|
1228
|
-
if (timer) clearTimeout(timer);
|
|
1229
|
-
if (killTimer) clearTimeout(killTimer);
|
|
1230
|
-
};
|
|
1231
|
-
child.on("error", (err) => {
|
|
1232
|
-
clearTimers();
|
|
1233
|
-
reject(err);
|
|
1234
|
-
});
|
|
1235
|
-
child.on("close", (code) => {
|
|
1236
|
-
clearTimers();
|
|
1237
|
-
resolve11({ code: code ?? -1, stdout, stderr });
|
|
1238
|
-
});
|
|
1239
|
-
});
|
|
1240
|
-
}
|
|
1241
|
-
var defaultSpawn = makeSpawn();
|
|
2048
|
+
// src/audits/index.ts
|
|
2049
|
+
init_spawn();
|
|
1242
2050
|
|
|
1243
2051
|
// src/audits/deps.ts
|
|
1244
2052
|
import { readFile } from "fs/promises";
|
|
@@ -1288,6 +2096,9 @@ var baselineVersions = {
|
|
|
1288
2096
|
"@zerodevx/svelte-img": "^2.1.2"
|
|
1289
2097
|
};
|
|
1290
2098
|
|
|
2099
|
+
// src/audits/deps.ts
|
|
2100
|
+
init_spawn();
|
|
2101
|
+
|
|
1291
2102
|
// src/audits/deps-outdated.ts
|
|
1292
2103
|
import { stat } from "fs/promises";
|
|
1293
2104
|
import { join as join2 } from "path";
|
|
@@ -1467,6 +2278,7 @@ async function lintAudit(ctx) {
|
|
|
1467
2278
|
}
|
|
1468
2279
|
|
|
1469
2280
|
// src/audits/security.ts
|
|
2281
|
+
init_spawn();
|
|
1470
2282
|
function classify(v) {
|
|
1471
2283
|
if (v.critical > 0 || v.high > 0) return "fail";
|
|
1472
2284
|
if (v.moderate > 0 || v.low > 0) return "warn";
|
|
@@ -1641,6 +2453,9 @@ var lighthouseConfig = {
|
|
|
1641
2453
|
}
|
|
1642
2454
|
};
|
|
1643
2455
|
|
|
2456
|
+
// src/audits/lighthouse.ts
|
|
2457
|
+
init_spawn();
|
|
2458
|
+
|
|
1644
2459
|
// src/audits/util/site-config.ts
|
|
1645
2460
|
import { readFile as readFile3 } from "fs/promises";
|
|
1646
2461
|
import { join as join5 } from "path";
|
|
@@ -1671,7 +2486,7 @@ async function readSiteConfig(sitePath) {
|
|
|
1671
2486
|
// src/util/free-port.ts
|
|
1672
2487
|
import { createServer } from "net";
|
|
1673
2488
|
async function findFreePort() {
|
|
1674
|
-
return new Promise((
|
|
2489
|
+
return new Promise((resolve12, reject) => {
|
|
1675
2490
|
const server = createServer();
|
|
1676
2491
|
server.unref();
|
|
1677
2492
|
server.on("error", reject);
|
|
@@ -1679,7 +2494,7 @@ async function findFreePort() {
|
|
|
1679
2494
|
const addr = server.address();
|
|
1680
2495
|
if (typeof addr === "object" && addr) {
|
|
1681
2496
|
const port = addr.port;
|
|
1682
|
-
server.close(() =>
|
|
2497
|
+
server.close(() => resolve12(port));
|
|
1683
2498
|
} else {
|
|
1684
2499
|
server.close();
|
|
1685
2500
|
reject(new Error("findFreePort: could not determine assigned port from socket"));
|
|
@@ -1909,6 +2724,7 @@ var playwrightA11yConfig = defineConfig({
|
|
|
1909
2724
|
});
|
|
1910
2725
|
|
|
1911
2726
|
// src/audits/a11y.ts
|
|
2727
|
+
init_spawn();
|
|
1912
2728
|
var RESULTS_REL = ".reddoor-a11y/results.json";
|
|
1913
2729
|
async function readJsonMaybe2(path) {
|
|
1914
2730
|
try {
|
|
@@ -2225,6 +3041,7 @@ async function resolveSites(input) {
|
|
|
2225
3041
|
}
|
|
2226
3042
|
|
|
2227
3043
|
// src/cli/fleet/clone-if-needed.ts
|
|
3044
|
+
init_spawn();
|
|
2228
3045
|
import { stat as stat2, readdir as readdir2, mkdir } from "fs/promises";
|
|
2229
3046
|
import { isAbsolute as isAbsolute2, join as join8 } from "path";
|
|
2230
3047
|
function deriveNameFromRepoUrl(repoUrl) {
|
|
@@ -3059,6 +3876,7 @@ async function runSyncConfigsCommand(site, opts) {
|
|
|
3059
3876
|
import { resolve as resolve4 } from "path";
|
|
3060
3877
|
|
|
3061
3878
|
// src/recipes/bump-deps.ts
|
|
3879
|
+
init_spawn();
|
|
3062
3880
|
import { stat as stat3 } from "fs/promises";
|
|
3063
3881
|
import { join as join12 } from "path";
|
|
3064
3882
|
async function exists2(path) {
|
|
@@ -3084,247 +3902,97 @@ async function bumpDeps(site, opts = {}) {
|
|
|
3084
3902
|
return withRecipe({
|
|
3085
3903
|
name: "bump-deps",
|
|
3086
3904
|
site,
|
|
3087
|
-
// pnpm install (in plan) mutates the lockfile, so the clean-tree check
|
|
3088
|
-
// MUST happen first — otherwise a desynced-lockfile resync would silently
|
|
3089
|
-
// land on top of whatever else was in the tree.
|
|
3090
|
-
checkTreeFirst: true,
|
|
3091
|
-
plan: async () => {
|
|
3092
|
-
const hasPnpmLock = await exists2(join12(site.path, "pnpm-lock.yaml"));
|
|
3093
|
-
if (!hasPnpmLock) {
|
|
3094
|
-
const hasNpmLock = await exists2(join12(site.path, "package-lock.json"));
|
|
3095
|
-
const hasYarnLock = await exists2(join12(site.path, "yarn.lock"));
|
|
3096
|
-
if (hasNpmLock || hasYarnLock) {
|
|
3097
|
-
const competing = hasNpmLock ? "package-lock.json" : "yarn.lock";
|
|
3098
|
-
return {
|
|
3099
|
-
kind: "failed",
|
|
3100
|
-
notes: `site has ${competing} but no pnpm-lock.yaml \u2014 run convert-to-pnpm first`
|
|
3101
|
-
};
|
|
3102
|
-
}
|
|
3103
|
-
}
|
|
3104
|
-
await spawn2("pnpm", ["install"], { cwd: site.path, streaming: true });
|
|
3105
|
-
const outdated = await spawn2(
|
|
3106
|
-
"pnpm",
|
|
3107
|
-
["outdated", "--json", ...outdatedFlagsForGroup(group)],
|
|
3108
|
-
{ cwd: site.path }
|
|
3109
|
-
);
|
|
3110
|
-
let parsed;
|
|
3111
|
-
try {
|
|
3112
|
-
parsed = JSON.parse(outdated.stdout || "{}");
|
|
3113
|
-
} catch {
|
|
3114
|
-
parsed = {};
|
|
3115
|
-
}
|
|
3116
|
-
if (Object.keys(parsed).length === 0) {
|
|
3117
|
-
return { kind: "noop", notes: `pnpm outdated reported nothing for group=${group}` };
|
|
3118
|
-
}
|
|
3119
|
-
return { kind: "apply", plan: { group } };
|
|
3120
|
-
},
|
|
3121
|
-
apply: async ({ group: g }, { commit: commit2, cwd }) => {
|
|
3122
|
-
await spawn2("pnpm", ["up", ...upFlagsForGroup(g)], { cwd, streaming: true });
|
|
3123
|
-
await commit2(`chore(deps): bump dependencies (${g})`);
|
|
3124
|
-
return { kind: "ok" };
|
|
3125
|
-
}
|
|
3126
|
-
});
|
|
3127
|
-
}
|
|
3128
|
-
|
|
3129
|
-
// src/cli/commands/bump-deps.ts
|
|
3130
|
-
var GROUPS = ["patch", "minor", "major"];
|
|
3131
|
-
function formatResult2(r) {
|
|
3132
|
-
if (r.status === "noop") return `[${r.site}] noop: ${r.notes ?? ""}`;
|
|
3133
|
-
return `[${r.site}] applied: ${r.commits.length} commit(s)
|
|
3134
|
-
${r.notes ?? ""}`;
|
|
3135
|
-
}
|
|
3136
|
-
async function runBumpDepsCommand(site, opts) {
|
|
3137
|
-
const group = opts.group ?? "minor";
|
|
3138
|
-
if (!GROUPS.includes(group)) {
|
|
3139
|
-
throw Object.assign(
|
|
3140
|
-
new Error(`unknown --group: ${group}. expected one of ${GROUPS.join(", ")}`),
|
|
3141
|
-
{ exitCode: 2 }
|
|
3142
|
-
);
|
|
3143
|
-
}
|
|
3144
|
-
const cwd = opts.cwd ? resolve4(opts.cwd) : process.cwd();
|
|
3145
|
-
let sites = await resolveSites({
|
|
3146
|
-
...site !== void 0 ? { site } : {},
|
|
3147
|
-
...opts.fleet !== void 0 ? { fleet: opts.fleet } : {},
|
|
3148
|
-
cwd
|
|
3149
|
-
});
|
|
3150
|
-
if (opts.fleet) {
|
|
3151
|
-
const workdir = opts.workdir ?? `${process.env.HOME ?? ""}/.reddoor-maint/sites`;
|
|
3152
|
-
sites = await Promise.all(sites.map((s) => cloneIfNeeded(s, { workdir })));
|
|
3153
|
-
}
|
|
3154
|
-
const results = [];
|
|
3155
|
-
for (const s of sites) results.push(await bumpDeps(s, { group }));
|
|
3156
|
-
const output = results.map(formatResult2).join("\n");
|
|
3157
|
-
const code = results.some((r) => r.status === "failed") ? 1 : 0;
|
|
3158
|
-
return { output, code };
|
|
3159
|
-
}
|
|
3160
|
-
|
|
3161
|
-
// src/cli/commands/self-updating.ts
|
|
3162
|
-
import { resolve as resolve5 } from "path";
|
|
3163
|
-
|
|
3164
|
-
// src/recipes/self-updating/index.ts
|
|
3165
|
-
import { mkdir as mkdir3, writeFile as writeFile4 } from "fs/promises";
|
|
3166
|
-
import { dirname as dirname2, join as join13 } from "path";
|
|
3167
|
-
|
|
3168
|
-
// src/github/config.ts
|
|
3169
|
-
function readGitHubConfig() {
|
|
3170
|
-
const token = process.env.GITHUB_TOKEN?.trim();
|
|
3171
|
-
if (!token) return null;
|
|
3172
|
-
const renovateToken = process.env.RENOVATE_TOKEN?.trim() || token;
|
|
3173
|
-
return { token, renovateToken };
|
|
3174
|
-
}
|
|
3175
|
-
|
|
3176
|
-
// src/github/gh.ts
|
|
3177
|
-
function mapRollupState(state) {
|
|
3178
|
-
switch (state) {
|
|
3179
|
-
case "SUCCESS":
|
|
3180
|
-
return "passing";
|
|
3181
|
-
case "FAILURE":
|
|
3182
|
-
case "ERROR":
|
|
3183
|
-
return "failing";
|
|
3184
|
-
case "PENDING":
|
|
3185
|
-
case "EXPECTED":
|
|
3186
|
-
return "pending";
|
|
3187
|
-
default:
|
|
3188
|
-
return "none";
|
|
3189
|
-
}
|
|
3190
|
-
}
|
|
3191
|
-
function makeGitHub(deps) {
|
|
3192
|
-
const spawn2 = deps.spawn ?? defaultSpawn;
|
|
3193
|
-
const env = { ...process.env, GH_TOKEN: deps.token };
|
|
3194
|
-
async function gh(args) {
|
|
3195
|
-
const r = await spawn2("gh", args, { env, timeoutMs: 6e4 });
|
|
3196
|
-
if (r.code !== 0) throw new Error(`gh ${args[0]} failed (code ${r.code}): ${r.stderr.trim()}`);
|
|
3197
|
-
return r.stdout;
|
|
3198
|
-
}
|
|
3199
|
-
return {
|
|
3200
|
-
async openPullRequest(repo, pr) {
|
|
3201
|
-
const out = await gh([
|
|
3202
|
-
"pr",
|
|
3203
|
-
"create",
|
|
3204
|
-
"--repo",
|
|
3205
|
-
repo,
|
|
3206
|
-
"--head",
|
|
3207
|
-
pr.head,
|
|
3208
|
-
"--base",
|
|
3209
|
-
pr.base,
|
|
3210
|
-
"--title",
|
|
3211
|
-
pr.title,
|
|
3212
|
-
"--body",
|
|
3213
|
-
pr.body
|
|
3214
|
-
]);
|
|
3215
|
-
return { url: out.trim() };
|
|
3216
|
-
},
|
|
3217
|
-
async enableRepoAutoMerge(repo) {
|
|
3218
|
-
await gh(["api", "-X", "PATCH", `repos/${repo}`, "-F", "allow_auto_merge=true"]);
|
|
3219
|
-
},
|
|
3220
|
-
async protectBranch(repo, branch, requiredChecks) {
|
|
3221
|
-
const args = [
|
|
3222
|
-
"api",
|
|
3223
|
-
"-X",
|
|
3224
|
-
"PUT",
|
|
3225
|
-
`repos/${repo}/branches/${branch}/protection`,
|
|
3226
|
-
"-H",
|
|
3227
|
-
"Accept: application/vnd.github+json",
|
|
3228
|
-
"-F",
|
|
3229
|
-
"required_status_checks[strict]=true",
|
|
3230
|
-
...requiredChecks.flatMap((c) => ["-f", `required_status_checks[contexts][]=${c}`]),
|
|
3231
|
-
"-F",
|
|
3232
|
-
"enforce_admins=true",
|
|
3233
|
-
"-F",
|
|
3234
|
-
"required_pull_request_reviews=null",
|
|
3235
|
-
"-F",
|
|
3236
|
-
"restrictions=null"
|
|
3237
|
-
];
|
|
3238
|
-
await gh(args);
|
|
3239
|
-
},
|
|
3240
|
-
async setRepoSecret(repo, name, value) {
|
|
3241
|
-
await gh(["secret", "set", name, "--repo", repo, "--body", value]);
|
|
3242
|
-
},
|
|
3243
|
-
async repoExists(repo) {
|
|
3244
|
-
const r = await spawn2("gh", ["api", `repos/${repo}`], { env, timeoutMs: 6e4 });
|
|
3245
|
-
return r.code === 0;
|
|
3246
|
-
},
|
|
3247
|
-
async defaultBranch(repo) {
|
|
3248
|
-
const out = await gh(["api", `repos/${repo}`, "--jq", ".default_branch"]);
|
|
3249
|
-
return out.trim();
|
|
3250
|
-
},
|
|
3251
|
-
// filesOnBranch and branchProtectionContexts call `spawn` directly (not the
|
|
3252
|
-
// throwing `gh()` helper) because a 404 is an expected, meaningful answer —
|
|
3253
|
-
// "file/protection absent" — not an error. The remaining readers use `gh()`
|
|
3254
|
-
// since a non-200 there is a genuine failure (e.g. missing token scope).
|
|
3255
|
-
async filesOnBranch(repo, branch, paths) {
|
|
3256
|
-
const present = [];
|
|
3257
|
-
for (const p of paths) {
|
|
3258
|
-
const r = await spawn2("gh", [`api`, `repos/${repo}/contents/${p}?ref=${branch}`], {
|
|
3259
|
-
env,
|
|
3260
|
-
timeoutMs: 6e4
|
|
3261
|
-
});
|
|
3262
|
-
if (r.code === 0) present.push(p);
|
|
3905
|
+
// pnpm install (in plan) mutates the lockfile, so the clean-tree check
|
|
3906
|
+
// MUST happen first — otherwise a desynced-lockfile resync would silently
|
|
3907
|
+
// land on top of whatever else was in the tree.
|
|
3908
|
+
checkTreeFirst: true,
|
|
3909
|
+
plan: async () => {
|
|
3910
|
+
const hasPnpmLock = await exists2(join12(site.path, "pnpm-lock.yaml"));
|
|
3911
|
+
if (!hasPnpmLock) {
|
|
3912
|
+
const hasNpmLock = await exists2(join12(site.path, "package-lock.json"));
|
|
3913
|
+
const hasYarnLock = await exists2(join12(site.path, "yarn.lock"));
|
|
3914
|
+
if (hasNpmLock || hasYarnLock) {
|
|
3915
|
+
const competing = hasNpmLock ? "package-lock.json" : "yarn.lock";
|
|
3916
|
+
return {
|
|
3917
|
+
kind: "failed",
|
|
3918
|
+
notes: `site has ${competing} but no pnpm-lock.yaml \u2014 run convert-to-pnpm first`
|
|
3919
|
+
};
|
|
3920
|
+
}
|
|
3263
3921
|
}
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
[
|
|
3270
|
-
"api",
|
|
3271
|
-
`repos/${repo}/branches/${branch}/protection`,
|
|
3272
|
-
"--jq",
|
|
3273
|
-
".required_status_checks.contexts[]?"
|
|
3274
|
-
],
|
|
3275
|
-
{ env, timeoutMs: 6e4 }
|
|
3922
|
+
await spawn2("pnpm", ["install"], { cwd: site.path, streaming: true });
|
|
3923
|
+
const outdated = await spawn2(
|
|
3924
|
+
"pnpm",
|
|
3925
|
+
["outdated", "--json", ...outdatedFlagsForGroup(group)],
|
|
3926
|
+
{ cwd: site.path }
|
|
3276
3927
|
);
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
return out.split("\n").map((l) => l.trim()).includes(name);
|
|
3283
|
-
},
|
|
3284
|
-
async autoMergeEnabled(repo) {
|
|
3285
|
-
const out = await gh(["api", `repos/${repo}`, "--jq", ".allow_auto_merge"]);
|
|
3286
|
-
return out.trim() === "true";
|
|
3287
|
-
},
|
|
3288
|
-
async findOpenSelfUpdatingPR(repo) {
|
|
3289
|
-
const out = await gh([
|
|
3290
|
-
"api",
|
|
3291
|
-
`repos/${repo}/pulls?state=open`,
|
|
3292
|
-
"--jq",
|
|
3293
|
-
'.[] | select(.head.ref | startswith("maint/self-updating-")) | .html_url'
|
|
3294
|
-
]);
|
|
3295
|
-
const first = out.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
|
|
3296
|
-
return first ?? null;
|
|
3297
|
-
},
|
|
3298
|
-
async openPullRequests(repo) {
|
|
3299
|
-
const [owner, name, ...rest] = repo.split("/");
|
|
3300
|
-
if (!owner || !name || rest.length > 0) {
|
|
3301
|
-
throw new Error(`openPullRequests: expected "owner/repo", got "${repo}"`);
|
|
3928
|
+
let parsed;
|
|
3929
|
+
try {
|
|
3930
|
+
parsed = JSON.parse(outdated.stdout || "{}");
|
|
3931
|
+
} catch {
|
|
3932
|
+
parsed = {};
|
|
3302
3933
|
}
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
`name=${name}`
|
|
3313
|
-
]);
|
|
3314
|
-
const parsed = JSON.parse(out);
|
|
3315
|
-
const nodes = parsed.data?.repository?.pullRequests?.nodes ?? [];
|
|
3316
|
-
return nodes.map((n) => ({
|
|
3317
|
-
number: n.number,
|
|
3318
|
-
title: n.title,
|
|
3319
|
-
url: n.url,
|
|
3320
|
-
headRef: n.headRefName,
|
|
3321
|
-
ciState: mapRollupState(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state)
|
|
3322
|
-
}));
|
|
3934
|
+
if (Object.keys(parsed).length === 0) {
|
|
3935
|
+
return { kind: "noop", notes: `pnpm outdated reported nothing for group=${group}` };
|
|
3936
|
+
}
|
|
3937
|
+
return { kind: "apply", plan: { group } };
|
|
3938
|
+
},
|
|
3939
|
+
apply: async ({ group: g }, { commit: commit2, cwd }) => {
|
|
3940
|
+
await spawn2("pnpm", ["up", ...upFlagsForGroup(g)], { cwd, streaming: true });
|
|
3941
|
+
await commit2(`chore(deps): bump dependencies (${g})`);
|
|
3942
|
+
return { kind: "ok" };
|
|
3323
3943
|
}
|
|
3324
|
-
};
|
|
3944
|
+
});
|
|
3945
|
+
}
|
|
3946
|
+
|
|
3947
|
+
// src/cli/commands/bump-deps.ts
|
|
3948
|
+
var GROUPS = ["patch", "minor", "major"];
|
|
3949
|
+
function formatResult2(r) {
|
|
3950
|
+
if (r.status === "noop") return `[${r.site}] noop: ${r.notes ?? ""}`;
|
|
3951
|
+
return `[${r.site}] applied: ${r.commits.length} commit(s)
|
|
3952
|
+
${r.notes ?? ""}`;
|
|
3953
|
+
}
|
|
3954
|
+
async function runBumpDepsCommand(site, opts) {
|
|
3955
|
+
const group = opts.group ?? "minor";
|
|
3956
|
+
if (!GROUPS.includes(group)) {
|
|
3957
|
+
throw Object.assign(
|
|
3958
|
+
new Error(`unknown --group: ${group}. expected one of ${GROUPS.join(", ")}`),
|
|
3959
|
+
{ exitCode: 2 }
|
|
3960
|
+
);
|
|
3961
|
+
}
|
|
3962
|
+
const cwd = opts.cwd ? resolve4(opts.cwd) : process.cwd();
|
|
3963
|
+
let sites = await resolveSites({
|
|
3964
|
+
...site !== void 0 ? { site } : {},
|
|
3965
|
+
...opts.fleet !== void 0 ? { fleet: opts.fleet } : {},
|
|
3966
|
+
cwd
|
|
3967
|
+
});
|
|
3968
|
+
if (opts.fleet) {
|
|
3969
|
+
const workdir = opts.workdir ?? `${process.env.HOME ?? ""}/.reddoor-maint/sites`;
|
|
3970
|
+
sites = await Promise.all(sites.map((s) => cloneIfNeeded(s, { workdir })));
|
|
3971
|
+
}
|
|
3972
|
+
const results = [];
|
|
3973
|
+
for (const s of sites) results.push(await bumpDeps(s, { group }));
|
|
3974
|
+
const output = results.map(formatResult2).join("\n");
|
|
3975
|
+
const code = results.some((r) => r.status === "failed") ? 1 : 0;
|
|
3976
|
+
return { output, code };
|
|
3977
|
+
}
|
|
3978
|
+
|
|
3979
|
+
// src/cli/commands/self-updating.ts
|
|
3980
|
+
import { resolve as resolve5 } from "path";
|
|
3981
|
+
|
|
3982
|
+
// src/recipes/self-updating/index.ts
|
|
3983
|
+
import { mkdir as mkdir3, writeFile as writeFile4 } from "fs/promises";
|
|
3984
|
+
import { dirname as dirname2, join as join13 } from "path";
|
|
3985
|
+
|
|
3986
|
+
// src/github/config.ts
|
|
3987
|
+
function readGitHubConfig() {
|
|
3988
|
+
const token = process.env.GITHUB_TOKEN?.trim();
|
|
3989
|
+
if (!token) return null;
|
|
3990
|
+
const renovateToken = process.env.RENOVATE_TOKEN?.trim() || token;
|
|
3991
|
+
return { token, renovateToken };
|
|
3325
3992
|
}
|
|
3326
3993
|
|
|
3327
3994
|
// src/recipes/self-updating/index.ts
|
|
3995
|
+
init_gh();
|
|
3328
3996
|
var SELF_UPDATING_CONFIGS = ["ci", "renovate-action", "renovate-config"];
|
|
3329
3997
|
var REQUIRED_CHECK = "ci / ci";
|
|
3330
3998
|
function resultOf(site, status, notes, commits = []) {
|
|
@@ -3493,6 +4161,9 @@ function bumpDep(pkg, name, version2, opts = {}) {
|
|
|
3493
4161
|
return next;
|
|
3494
4162
|
}
|
|
3495
4163
|
|
|
4164
|
+
// src/recipes/svelte-5/index.ts
|
|
4165
|
+
init_spawn();
|
|
4166
|
+
|
|
3496
4167
|
// src/recipes/svelte-5/step-bump-versions.ts
|
|
3497
4168
|
import { join as join14 } from "path";
|
|
3498
4169
|
var SVELTE_5_VERSIONS = {
|
|
@@ -3577,6 +4248,7 @@ async function migrateSvelteConfig(cwd) {
|
|
|
3577
4248
|
}
|
|
3578
4249
|
|
|
3579
4250
|
// src/recipes/svelte-5/step-svelte-migrate.ts
|
|
4251
|
+
init_spawn();
|
|
3580
4252
|
async function runSvelteMigrate(cwd, spawn2 = defaultSpawn) {
|
|
3581
4253
|
try {
|
|
3582
4254
|
const { code, stderr } = await spawn2(
|
|
@@ -3598,6 +4270,7 @@ async function runSvelteMigrate(cwd, spawn2 = defaultSpawn) {
|
|
|
3598
4270
|
}
|
|
3599
4271
|
|
|
3600
4272
|
// src/recipes/svelte-5/step-tailwind-upgrade.ts
|
|
4273
|
+
init_spawn();
|
|
3601
4274
|
import { join as join16 } from "path";
|
|
3602
4275
|
async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
|
|
3603
4276
|
const pkg = await readPackageJson(join16(cwd, "package.json"));
|
|
@@ -3984,6 +4657,7 @@ async function applyGotchaCodemods(cwd) {
|
|
|
3984
4657
|
}
|
|
3985
4658
|
|
|
3986
4659
|
// src/recipes/svelte-5/step-verify.ts
|
|
4660
|
+
init_spawn();
|
|
3987
4661
|
async function verifyMigration(cwd, spawn2 = defaultSpawn) {
|
|
3988
4662
|
let install;
|
|
3989
4663
|
try {
|
|
@@ -4123,6 +4797,7 @@ import { resolve as resolve7 } from "path";
|
|
|
4123
4797
|
// src/recipes/convert-to-pnpm.ts
|
|
4124
4798
|
import { rm as rm3, stat as stat4 } from "fs/promises";
|
|
4125
4799
|
import { join as join20 } from "path";
|
|
4800
|
+
init_spawn();
|
|
4126
4801
|
|
|
4127
4802
|
// src/recipes/convert-to-pnpm/script-rewrites.ts
|
|
4128
4803
|
function rewriteScriptForPnpm(script) {
|
|
@@ -4235,6 +4910,7 @@ import { resolve as resolve8 } from "path";
|
|
|
4235
4910
|
// src/recipes/onboard.ts
|
|
4236
4911
|
import { stat as stat5 } from "fs/promises";
|
|
4237
4912
|
import { join as join22 } from "path";
|
|
4913
|
+
init_spawn();
|
|
4238
4914
|
|
|
4239
4915
|
// src/util/self-version.ts
|
|
4240
4916
|
import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
|
|
@@ -4520,10 +5196,17 @@ function findDueReports(websites, reports, today) {
|
|
|
4520
5196
|
}
|
|
4521
5197
|
return out;
|
|
4522
5198
|
}
|
|
5199
|
+
function reportPeriodKey(dueDate) {
|
|
5200
|
+
if (Number.isNaN(dueDate.getTime())) throw new TypeError("reportPeriodKey: invalid Date");
|
|
5201
|
+
const year = dueDate.getUTCFullYear();
|
|
5202
|
+
const month = String(dueDate.getUTCMonth() + 1).padStart(2, "0");
|
|
5203
|
+
return `${year}-${month}`;
|
|
5204
|
+
}
|
|
4523
5205
|
|
|
4524
5206
|
// src/reports/draft.ts
|
|
4525
5207
|
init_render();
|
|
4526
5208
|
init_websites();
|
|
5209
|
+
init_copy();
|
|
4527
5210
|
init_reports();
|
|
4528
5211
|
init_attachments();
|
|
4529
5212
|
import { mkdir as mkdir4, writeFile as writeFile10 } from "fs/promises";
|
|
@@ -4684,6 +5367,7 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
|
|
|
4684
5367
|
searchPosition: search?.foundOnPage1 ? search.position ?? void 0 : void 0,
|
|
4685
5368
|
lastTestedDate,
|
|
4686
5369
|
commentary: null,
|
|
5370
|
+
copy: resolveCopy(siteRow),
|
|
4687
5371
|
headerImageCid: cidName
|
|
4688
5372
|
});
|
|
4689
5373
|
if (options.previewOnly) {
|
|
@@ -4698,6 +5382,7 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
|
|
|
4698
5382
|
reportId,
|
|
4699
5383
|
siteId: siteRow.id,
|
|
4700
5384
|
reportType,
|
|
5385
|
+
period: options.period ?? periodEnd.toISOString().slice(0, 7),
|
|
4701
5386
|
periodStart,
|
|
4702
5387
|
periodEnd,
|
|
4703
5388
|
completedOn,
|
|
@@ -4760,7 +5445,14 @@ async function derivePeriodStart(base, siteRow, reportType, today) {
|
|
|
4760
5445
|
}
|
|
4761
5446
|
|
|
4762
5447
|
// src/cli/commands/report.ts
|
|
5448
|
+
function dashboardBaseUrl() {
|
|
5449
|
+
return process.env.DASHBOARD_BASE_URL?.trim() || "https://reddoor-maintenance.netlify.app";
|
|
5450
|
+
}
|
|
4763
5451
|
async function runReportCommand(slug, opts) {
|
|
5452
|
+
if (opts.digest) {
|
|
5453
|
+
const { runDigest: runDigest2 } = await Promise.resolve().then(() => (init_digest(), digest_exports));
|
|
5454
|
+
return runDigest2({ baseUrl: dashboardBaseUrl() });
|
|
5455
|
+
}
|
|
4764
5456
|
if (opts.sendReady) {
|
|
4765
5457
|
const { sendApprovedReports: sendApprovedReports2 } = await Promise.resolve().then(() => (init_orchestrate(), orchestrate_exports));
|
|
4766
5458
|
return sendApprovedReports2();
|
|
@@ -4772,7 +5464,7 @@ async function runReportCommand(slug, opts) {
|
|
|
4772
5464
|
return runSingleSiteDraft(slug, { previewOnly: Boolean(opts.preview) });
|
|
4773
5465
|
}
|
|
4774
5466
|
throw Object.assign(
|
|
4775
|
-
new Error("Usage: reddoor-maint report [<slug>] [--due] [--preview] [--send-ready]"),
|
|
5467
|
+
new Error("Usage: reddoor-maint report [<slug>] [--due] [--preview] [--send-ready] [--digest]"),
|
|
4776
5468
|
{
|
|
4777
5469
|
exitCode: 2
|
|
4778
5470
|
}
|
|
@@ -4780,25 +5472,38 @@ async function runReportCommand(slug, opts) {
|
|
|
4780
5472
|
}
|
|
4781
5473
|
async function runDueDraft() {
|
|
4782
5474
|
const base = openBase(readAirtableConfig());
|
|
5475
|
+
return draftDueReports(base, /* @__PURE__ */ new Date());
|
|
5476
|
+
}
|
|
5477
|
+
async function draftDueReports(base, today) {
|
|
4783
5478
|
const websites = await listWebsites(base);
|
|
4784
|
-
const reports =
|
|
4785
|
-
|
|
4786
|
-
const rs = await listReportsForSite(base, w.id);
|
|
4787
|
-
reports.push(...rs);
|
|
4788
|
-
}
|
|
4789
|
-
const due = findDueReports(websites, reports, /* @__PURE__ */ new Date());
|
|
5479
|
+
const reports = await listAllReports(base);
|
|
5480
|
+
const due = findDueReports(websites, reports, today);
|
|
4790
5481
|
if (due.length === 0) return { output: "No reports due.", code: 0 };
|
|
4791
5482
|
const lines = [];
|
|
4792
5483
|
let softFailedSites = 0;
|
|
5484
|
+
let skipped = 0;
|
|
4793
5485
|
for (const item of due) {
|
|
5486
|
+
const period = reportPeriodKey(item.dueDate);
|
|
5487
|
+
const already = reports.some(
|
|
5488
|
+
(r) => r.siteId === item.site.id && r.reportType === item.reportType && r.period === period
|
|
5489
|
+
);
|
|
5490
|
+
if (already) {
|
|
5491
|
+
skipped++;
|
|
5492
|
+
lines.push(`\u2022 skipped (already drafted ${period}): ${item.site.name} ${item.reportType}`);
|
|
5493
|
+
continue;
|
|
5494
|
+
}
|
|
4794
5495
|
try {
|
|
4795
|
-
const result = await draftReportForSite(base, item.site, item.reportType);
|
|
5496
|
+
const result = await draftReportForSite(base, item.site, item.reportType, { period });
|
|
4796
5497
|
lines.push(`\u2713 drafted: ${result.reportRow?.reportId}`);
|
|
5498
|
+
if (result.reportRow) reports.push(result.reportRow);
|
|
4797
5499
|
if (result.softFailures.length > 0) softFailedSites++;
|
|
4798
5500
|
} catch (e) {
|
|
4799
5501
|
lines.push(`\u2717 failed: ${item.site.name} ${item.reportType} \u2014 ${e.message}`);
|
|
4800
5502
|
}
|
|
4801
5503
|
}
|
|
5504
|
+
if (skipped > 0) {
|
|
5505
|
+
lines.push(`\u2022 ${skipped} already drafted this period`);
|
|
5506
|
+
}
|
|
4802
5507
|
if (softFailedSites > 0) {
|
|
4803
5508
|
lines.push(
|
|
4804
5509
|
`\u26A0 ${softFailedSites} site${softFailedSites === 1 ? "" : "s"} had GA/Search enrichment fail \u2014 drafted with blank analytics; check the logs above`
|
|
@@ -4984,6 +5689,256 @@ async function runInitCommand(site, opts) {
|
|
|
4984
5689
|
return { output, code };
|
|
4985
5690
|
}
|
|
4986
5691
|
|
|
5692
|
+
// src/cli/commands/launch.ts
|
|
5693
|
+
import { resolve as resolve11 } from "path";
|
|
5694
|
+
|
|
5695
|
+
// src/recipes/launch.ts
|
|
5696
|
+
init_lighthouse_airtable();
|
|
5697
|
+
init_write_audits_to_airtable();
|
|
5698
|
+
init_client();
|
|
5699
|
+
init_websites();
|
|
5700
|
+
init_reports();
|
|
5701
|
+
init_attachments();
|
|
5702
|
+
init_render();
|
|
5703
|
+
init_copy();
|
|
5704
|
+
async function launch(site, deps = {}) {
|
|
5705
|
+
const label = siteLabel(site);
|
|
5706
|
+
const bootstrap = deps.bootstrap ?? selfUpdating;
|
|
5707
|
+
const audit = deps.audit ?? runAudits;
|
|
5708
|
+
const base = deps.base ?? openBase(readAirtableConfig());
|
|
5709
|
+
const steps = [];
|
|
5710
|
+
const stop = () => ({ site: label, steps, complete: false });
|
|
5711
|
+
let recipe;
|
|
5712
|
+
try {
|
|
5713
|
+
recipe = await bootstrap(site);
|
|
5714
|
+
} catch (err) {
|
|
5715
|
+
steps.push({ name: "self-updating", result: errorOf(err) });
|
|
5716
|
+
return stop();
|
|
5717
|
+
}
|
|
5718
|
+
steps.push({ name: "self-updating", result: { kind: "recipe", result: recipe } });
|
|
5719
|
+
if (recipe.status === "failed") return stop();
|
|
5720
|
+
let results;
|
|
5721
|
+
try {
|
|
5722
|
+
results = await audit(site);
|
|
5723
|
+
} catch (err) {
|
|
5724
|
+
steps.push({ name: "audit", result: errorOf(err) });
|
|
5725
|
+
return stop();
|
|
5726
|
+
}
|
|
5727
|
+
const lhResult = results.find((r) => r.audit === "lighthouse");
|
|
5728
|
+
if (!lhResult || !hasRealScores(lhResult)) {
|
|
5729
|
+
steps.push({
|
|
5730
|
+
name: "audit",
|
|
5731
|
+
result: { kind: "error", message: "lighthouse audit produced no real scores" }
|
|
5732
|
+
});
|
|
5733
|
+
return stop();
|
|
5734
|
+
}
|
|
5735
|
+
const scores = lighthouseScoresFromResult(lhResult);
|
|
5736
|
+
const websites = await listWebsites(base);
|
|
5737
|
+
const target = websites.find((w) => siteSlug(w.name) === siteSlug(label));
|
|
5738
|
+
if (!target) {
|
|
5739
|
+
steps.push({
|
|
5740
|
+
name: "audit",
|
|
5741
|
+
result: { kind: "error", message: `no Websites row matched site "${label}"` }
|
|
5742
|
+
});
|
|
5743
|
+
return stop();
|
|
5744
|
+
}
|
|
5745
|
+
try {
|
|
5746
|
+
await writeAuditsToAirtable({ base, websites, slug: siteSlug(target.name), results });
|
|
5747
|
+
} catch (err) {
|
|
5748
|
+
steps.push({ name: "audit", result: errorOf(err) });
|
|
5749
|
+
return stop();
|
|
5750
|
+
}
|
|
5751
|
+
steps.push({ name: "audit", result: { kind: "audit", results, scores } });
|
|
5752
|
+
const today = /* @__PURE__ */ new Date();
|
|
5753
|
+
const period = today.toISOString().slice(0, 7);
|
|
5754
|
+
const slug = siteSlug(target.name);
|
|
5755
|
+
let report;
|
|
5756
|
+
try {
|
|
5757
|
+
const existing = await findReportByPeriod(base, target.id, "Launch", period);
|
|
5758
|
+
report = existing ?? await createDraft(base, draftInputFor(target, scores, today, period));
|
|
5759
|
+
} catch (err) {
|
|
5760
|
+
steps.push({ name: "draft", result: errorOf(err) });
|
|
5761
|
+
return stop();
|
|
5762
|
+
}
|
|
5763
|
+
try {
|
|
5764
|
+
const { html } = await renderReportHtml({
|
|
5765
|
+
siteName: target.name,
|
|
5766
|
+
siteUrl: target.url,
|
|
5767
|
+
reportType: "Launch",
|
|
5768
|
+
completedOn: today,
|
|
5769
|
+
lighthouse: scores,
|
|
5770
|
+
lastTestedDate: null,
|
|
5771
|
+
commentary: null,
|
|
5772
|
+
copy: resolveCopy(target),
|
|
5773
|
+
headerImageCid: `${slug}-header`
|
|
5774
|
+
});
|
|
5775
|
+
try {
|
|
5776
|
+
await uploadAttachment(
|
|
5777
|
+
report.id,
|
|
5778
|
+
"Rendered HTML",
|
|
5779
|
+
html,
|
|
5780
|
+
`${slug}-${today.toISOString().slice(0, 10)}.html`,
|
|
5781
|
+
"text/html"
|
|
5782
|
+
);
|
|
5783
|
+
} catch (uploadErr) {
|
|
5784
|
+
console.warn(
|
|
5785
|
+
`\u26A0 Launch preview upload skipped for ${target.name}: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`
|
|
5786
|
+
);
|
|
5787
|
+
}
|
|
5788
|
+
await setDraftReady(base, report.id, true);
|
|
5789
|
+
} catch (err) {
|
|
5790
|
+
steps.push({ name: "draft", result: errorOf(err) });
|
|
5791
|
+
return stop();
|
|
5792
|
+
}
|
|
5793
|
+
steps.push({ name: "draft", result: { kind: "draft", report } });
|
|
5794
|
+
return { site: label, steps, complete: true };
|
|
5795
|
+
}
|
|
5796
|
+
function draftInputFor(target, scores, today, period) {
|
|
5797
|
+
const reportType = "Launch";
|
|
5798
|
+
const reportId = `${target.name} \u2014 ${reportType} \u2014 ${today.toISOString().slice(0, 10)}`;
|
|
5799
|
+
return {
|
|
5800
|
+
reportId,
|
|
5801
|
+
siteId: target.id,
|
|
5802
|
+
reportType,
|
|
5803
|
+
period,
|
|
5804
|
+
periodStart: today,
|
|
5805
|
+
periodEnd: today,
|
|
5806
|
+
completedOn: today,
|
|
5807
|
+
lighthouse: scores,
|
|
5808
|
+
lastTestedDate: null
|
|
5809
|
+
};
|
|
5810
|
+
}
|
|
5811
|
+
function errorOf(err) {
|
|
5812
|
+
return { kind: "error", message: err instanceof Error ? err.message : String(err) };
|
|
5813
|
+
}
|
|
5814
|
+
|
|
5815
|
+
// src/cli/commands/launch.ts
|
|
5816
|
+
function formatStep2(name, r) {
|
|
5817
|
+
if (r.kind === "error") return `${name.padEnd(20)} error: ${r.message}`;
|
|
5818
|
+
if (r.kind === "audit") {
|
|
5819
|
+
const s = r.scores;
|
|
5820
|
+
return `${name.padEnd(20)} audited (P=${s.performance} A=${s.accessibility} BP=${s.bestPractices} SEO=${s.seo})`;
|
|
5821
|
+
}
|
|
5822
|
+
if (r.kind === "draft") {
|
|
5823
|
+
return `${name.padEnd(20)} drafted ${r.report.reportId}`;
|
|
5824
|
+
}
|
|
5825
|
+
const rec = r.result;
|
|
5826
|
+
if (rec.status === "noop") return `${name.padEnd(20)} noop${rec.notes ? ` \u2014 ${rec.notes}` : ""}`;
|
|
5827
|
+
if (rec.status === "failed")
|
|
5828
|
+
return `${name.padEnd(20)} failed${rec.notes ? ` \u2014 ${rec.notes}` : ""}`;
|
|
5829
|
+
return `${name.padEnd(20)} applied (${rec.commits.length} commit${rec.commits.length === 1 ? "" : "s"})${rec.notes ? ` \u2014 ${rec.notes}` : ""}`;
|
|
5830
|
+
}
|
|
5831
|
+
function formatResult9(r) {
|
|
5832
|
+
const header = `[${r.site}] launch \u2014 ${r.complete ? "drafted (awaiting approval)" : "STOPPED"}`;
|
|
5833
|
+
const body = r.steps.map((s) => formatStep2(s.name, s.result)).join("\n");
|
|
5834
|
+
return `${header}
|
|
5835
|
+
${body}`;
|
|
5836
|
+
}
|
|
5837
|
+
async function runLaunchCommand(site, opts) {
|
|
5838
|
+
const cwd = opts.cwd ? resolve11(opts.cwd) : process.cwd();
|
|
5839
|
+
const sites = await resolveSites({ site, cwd });
|
|
5840
|
+
const target = sites[0];
|
|
5841
|
+
if (!target) {
|
|
5842
|
+
return { output: `No site resolved for "${site}".`, code: 1 };
|
|
5843
|
+
}
|
|
5844
|
+
const result = await launch(target);
|
|
5845
|
+
return { output: formatResult9(result), code: result.complete ? 0 : 1 };
|
|
5846
|
+
}
|
|
5847
|
+
|
|
5848
|
+
// src/cli/commands/github-signals.ts
|
|
5849
|
+
init_client();
|
|
5850
|
+
init_websites();
|
|
5851
|
+
|
|
5852
|
+
// src/audits/github-signals.ts
|
|
5853
|
+
init_renovate();
|
|
5854
|
+
async function collectGitHubSignals(sites, deps, onSkip = () => {
|
|
5855
|
+
}) {
|
|
5856
|
+
const rows = [];
|
|
5857
|
+
for (const s of sites) {
|
|
5858
|
+
const repo = s.gitRepo;
|
|
5859
|
+
if (!repo) continue;
|
|
5860
|
+
const name = s.name;
|
|
5861
|
+
if (!name) continue;
|
|
5862
|
+
try {
|
|
5863
|
+
const prs = await deps.openPullRequests(repo);
|
|
5864
|
+
const status = await deps.defaultBranchStatus(repo);
|
|
5865
|
+
rows.push({
|
|
5866
|
+
site: name,
|
|
5867
|
+
repo,
|
|
5868
|
+
renovateFailingCis: prs.filter(isFailingRenovatePR).length,
|
|
5869
|
+
ciState: status.ciState,
|
|
5870
|
+
lastCommitAt: status.lastCommitAt
|
|
5871
|
+
});
|
|
5872
|
+
} catch {
|
|
5873
|
+
onSkip({ repo });
|
|
5874
|
+
}
|
|
5875
|
+
}
|
|
5876
|
+
return rows;
|
|
5877
|
+
}
|
|
5878
|
+
|
|
5879
|
+
// src/cli/commands/github-signals.ts
|
|
5880
|
+
init_gh();
|
|
5881
|
+
init_write_audits_to_airtable();
|
|
5882
|
+
async function runGitHubSignalsCommand(opts) {
|
|
5883
|
+
if (!opts.fleet || !opts.writeAirtable) {
|
|
5884
|
+
return { output: "github-signals currently supports only --fleet --write-airtable", code: 2 };
|
|
5885
|
+
}
|
|
5886
|
+
const token = process.env.RENOVATE_TOKEN?.trim() || process.env.GH_TOKEN?.trim();
|
|
5887
|
+
if (!token) {
|
|
5888
|
+
return {
|
|
5889
|
+
output: "github-signals skipped: no RENOVATE_TOKEN/GH_TOKEN (fleet read) configured.",
|
|
5890
|
+
code: 0
|
|
5891
|
+
};
|
|
5892
|
+
}
|
|
5893
|
+
const base = openBase(readAirtableConfig());
|
|
5894
|
+
const websites = await listWebsites(base);
|
|
5895
|
+
const gh = makeGitHub({ token });
|
|
5896
|
+
const sites = websites.map((w) => ({
|
|
5897
|
+
path: "",
|
|
5898
|
+
name: w.name,
|
|
5899
|
+
meta: {},
|
|
5900
|
+
...w.gitRepo ? { gitRepo: w.gitRepo } : {}
|
|
5901
|
+
}));
|
|
5902
|
+
const skipped = [];
|
|
5903
|
+
const rows = await collectGitHubSignals(
|
|
5904
|
+
sites,
|
|
5905
|
+
{
|
|
5906
|
+
openPullRequests: (r) => gh.openPullRequests(r),
|
|
5907
|
+
defaultBranchStatus: (r) => gh.defaultBranchStatus(r)
|
|
5908
|
+
},
|
|
5909
|
+
({ repo }) => skipped.push(repo)
|
|
5910
|
+
);
|
|
5911
|
+
const sweptAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5912
|
+
const result = { written: [], failed: [] };
|
|
5913
|
+
const byRepo = new Map(websites.filter((w) => w.gitRepo).map((w) => [w.gitRepo, w]));
|
|
5914
|
+
for (const row of rows) {
|
|
5915
|
+
const target = byRepo.get(row.repo);
|
|
5916
|
+
if (!target) {
|
|
5917
|
+
result.failed.push({ slug: siteSlug(row.site), error: "no Websites row matched" });
|
|
5918
|
+
continue;
|
|
5919
|
+
}
|
|
5920
|
+
try {
|
|
5921
|
+
await updateGitHubSignals(base, target.id, {
|
|
5922
|
+
renovateFailingCis: row.renovateFailingCis,
|
|
5923
|
+
ciState: row.ciState,
|
|
5924
|
+
lastCommitAt: row.lastCommitAt,
|
|
5925
|
+
sweptAt
|
|
5926
|
+
});
|
|
5927
|
+
result.written.push({
|
|
5928
|
+
siteName: target.name,
|
|
5929
|
+
writes: [{ audit: "github-signals", counts: row }]
|
|
5930
|
+
});
|
|
5931
|
+
} catch (e) {
|
|
5932
|
+
result.failed.push({ slug: siteSlug(row.site), error: e.message });
|
|
5933
|
+
}
|
|
5934
|
+
}
|
|
5935
|
+
for (const repo of skipped) result.failed.push({ slug: repo, error: "probe failed (skipped)" });
|
|
5936
|
+
return {
|
|
5937
|
+
output: formatFleetWriteSummary(result),
|
|
5938
|
+
code: result.failed.length > 0 && result.written.length === 0 ? 1 : 0
|
|
5939
|
+
};
|
|
5940
|
+
}
|
|
5941
|
+
|
|
4987
5942
|
// src/cli/version.ts
|
|
4988
5943
|
import { readFileSync as readFileSync5, existsSync as existsSync4 } from "fs";
|
|
4989
5944
|
import { dirname as dirname8, join as join27 } from "path";
|
|
@@ -5126,15 +6081,33 @@ cli.command(
|
|
|
5126
6081
|
).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
|
|
5127
6082
|
async (site, opts) => runOrExit(() => runInitCommand(site, opts), opts)
|
|
5128
6083
|
);
|
|
6084
|
+
cli.command(
|
|
6085
|
+
"launch <site>",
|
|
6086
|
+
"Bootstrap + first-audit a site, then draft its launch email for approval."
|
|
6087
|
+
).action(
|
|
6088
|
+
async (site, opts) => runOrExit(() => runLaunchCommand(site, opts), opts)
|
|
6089
|
+
);
|
|
5129
6090
|
cli.command("report [site]", "Draft or send maintenance/testing reports.").option("--due", "Scan all Websites and draft overdue reports.").option(
|
|
5130
6091
|
"--preview",
|
|
5131
6092
|
"Single-site dry run; writes reports/<slug>/draft.html, never touches Airtable."
|
|
5132
6093
|
).option(
|
|
5133
6094
|
"--send-ready",
|
|
5134
6095
|
"Send all Reports with Draft ready=true AND Approved to send=true AND Sent at IS NULL."
|
|
6096
|
+
).option(
|
|
6097
|
+
"--digest",
|
|
6098
|
+
"Email the operator one daily digest of reports ready for approval (skips when empty)."
|
|
5135
6099
|
).action(
|
|
5136
6100
|
async (site, opts) => runOrExit(() => runReportCommand(site, opts), opts)
|
|
5137
6101
|
);
|
|
6102
|
+
cli.command(
|
|
6103
|
+
"github-signals",
|
|
6104
|
+
"Sweep the fleet for GitHub signals (Renovate-failing/CI/last-commit) and write Airtable."
|
|
6105
|
+
).option("--fleet", "Run across every site in the Airtable inventory.").option("--write-airtable", "Write each site's signals back to its Websites row.").action(
|
|
6106
|
+
async (opts) => runOrExit(
|
|
6107
|
+
() => runGitHubSignalsCommand({ fleet: opts.fleet, writeAirtable: opts.writeAirtable }),
|
|
6108
|
+
opts
|
|
6109
|
+
)
|
|
6110
|
+
);
|
|
5138
6111
|
cli.help();
|
|
5139
6112
|
cli.version(version);
|
|
5140
6113
|
cli.parse();
|