@reddoorla/maintenance 0.32.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 +1089 -279
- 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 +145 -55
- package/dist/index.js +323 -43
- 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;
|
|
@@ -507,6 +790,9 @@ function lighthouseFromFields(f) {
|
|
|
507
790
|
function ymd(d) {
|
|
508
791
|
return d.toISOString().slice(0, 10);
|
|
509
792
|
}
|
|
793
|
+
function escapeFormulaString(s) {
|
|
794
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
795
|
+
}
|
|
510
796
|
async function createDraft(base, input) {
|
|
511
797
|
const fields = {
|
|
512
798
|
"Report ID": input.reportId,
|
|
@@ -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
|
|
|
@@ -931,9 +1342,223 @@ var init_resend = __esm({
|
|
|
931
1342
|
}
|
|
932
1343
|
});
|
|
933
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
|
+
|
|
934
1558
|
// src/reports/digest.ts
|
|
935
1559
|
var digest_exports = {};
|
|
936
1560
|
__export(digest_exports, {
|
|
1561
|
+
collectAttention: () => collectAttention,
|
|
937
1562
|
listPendingApproval: () => listPendingApproval,
|
|
938
1563
|
renderDigestHtml: () => renderDigestHtml,
|
|
939
1564
|
runDigest: () => runDigest
|
|
@@ -942,16 +1567,16 @@ function esc(s) {
|
|
|
942
1567
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
943
1568
|
}
|
|
944
1569
|
function readySection(items) {
|
|
945
|
-
const heading = `<h2 style="color:${
|
|
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>`;
|
|
946
1571
|
if (items.length === 0) {
|
|
947
|
-
return `${heading}<p style="color:${
|
|
1572
|
+
return `${heading}<p style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;margin:0">Nothing waiting on you.</p>`;
|
|
948
1573
|
}
|
|
949
1574
|
const rows = items.map((it) => {
|
|
950
1575
|
const safeUrl = it.dashboardUrl.startsWith("https://") ? it.dashboardUrl : void 0;
|
|
951
1576
|
const link = safeUrl ? `<a href="${esc(safeUrl)}" style="${ANCHOR_STYLE}">review & approve</a>` : `review & approve`;
|
|
952
1577
|
return `
|
|
953
1578
|
<tr>
|
|
954
|
-
<td style="color:${
|
|
1579
|
+
<td style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">
|
|
955
1580
|
<strong style="color:#222">${esc(it.siteName)}</strong> \u2014 ${esc(it.reportType)} (${esc(it.period)})
|
|
956
1581
|
\u2014 ${link}
|
|
957
1582
|
</td>
|
|
@@ -959,36 +1584,105 @@ function readySection(items) {
|
|
|
959
1584
|
}).join("");
|
|
960
1585
|
return `${heading}<table role="presentation" style="border-collapse:collapse;margin:0">${rows}</table>`;
|
|
961
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
|
+
}
|
|
962
1594
|
function attentionSection(items) {
|
|
963
|
-
const heading = `<h2 style="color:${
|
|
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>`;
|
|
964
1596
|
if (items.length === 0) {
|
|
965
|
-
return `${heading}<p style="color:${
|
|
1597
|
+
return `${heading}<p style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;margin:0">All clear \u2014 nothing needs attention.</p>`;
|
|
966
1598
|
}
|
|
967
|
-
const
|
|
968
|
-
|
|
969
|
-
const
|
|
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("");
|
|
970
1617
|
return `
|
|
971
1618
|
<tr>
|
|
972
|
-
<td style="color
|
|
973
|
-
</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}`;
|
|
974
1622
|
}).join("");
|
|
975
|
-
return `${heading}<table role="presentation" style="border-collapse:collapse;margin:0">${
|
|
1623
|
+
return `${heading}<table role="presentation" style="border-collapse:collapse;margin:0">${groups}</table>`;
|
|
976
1624
|
}
|
|
977
1625
|
function digestDateKey(d) {
|
|
978
1626
|
return d.toISOString().slice(0, 10);
|
|
979
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
|
+
}
|
|
980
1636
|
async function listPendingApproval(base) {
|
|
981
1637
|
return (await listAllReports(base)).filter(
|
|
982
1638
|
(r) => r.draftReady && !r.approvedToSend && r.sentAt === null
|
|
983
1639
|
);
|
|
984
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
|
+
}
|
|
985
1678
|
async function runDigest(options) {
|
|
986
1679
|
const today = /* @__PURE__ */ new Date();
|
|
987
1680
|
try {
|
|
988
1681
|
const base = options.base ?? openBase(readAirtableConfig());
|
|
1682
|
+
const reports = await listAllReports(base);
|
|
989
1683
|
const websites = await listWebsites(base);
|
|
990
1684
|
const sites = new Map(websites.map((w) => [w.id, w]));
|
|
991
|
-
const pending =
|
|
1685
|
+
const pending = reports.filter((r) => r.draftReady && !r.approvedToSend && r.sentAt === null);
|
|
992
1686
|
const readyForYourYes = [];
|
|
993
1687
|
for (const r of pending) {
|
|
994
1688
|
const site = sites.get(r.siteId);
|
|
@@ -1000,8 +1694,23 @@ async function runDigest(options) {
|
|
|
1000
1694
|
dashboardUrl: `${options.baseUrl.replace(/\/$/, "")}/s/${siteSlug(site.name)}`
|
|
1001
1695
|
});
|
|
1002
1696
|
}
|
|
1003
|
-
const
|
|
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;
|
|
1004
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
|
+
}
|
|
1005
1714
|
return { output: "Digest skipped (nothing ready, nothing needs attention).", code: 0 };
|
|
1006
1715
|
}
|
|
1007
1716
|
const html = renderDigestHtml({ readyForYourYes, needsAttention });
|
|
@@ -1009,13 +1718,29 @@ async function runDigest(options) {
|
|
|
1009
1718
|
const to = [process.env.OPERATOR_EMAIL?.trim() || DIGEST_OPERATOR_FALLBACK];
|
|
1010
1719
|
const n = readyForYourYes.length;
|
|
1011
1720
|
const reportWord = n === 1 ? "report" : "reports";
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
+
}
|
|
1019
1744
|
return { output: `Digest sent to ${to.join(", ")} (${result.messageId})`, code: 0 };
|
|
1020
1745
|
} catch (err) {
|
|
1021
1746
|
if (typeof err.exitCode === "number") {
|
|
@@ -1036,7 +1761,7 @@ function renderDigestHtml(sections) {
|
|
|
1036
1761
|
<table width="600" style="border-collapse:collapse">
|
|
1037
1762
|
<tr>
|
|
1038
1763
|
<td>
|
|
1039
|
-
<h1 style="color:${
|
|
1764
|
+
<h1 style="color:${RED2};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px">Your fleet today</h1>
|
|
1040
1765
|
${readySection(sections.readyForYourYes)}
|
|
1041
1766
|
${attentionSection(sections.needsAttention)}
|
|
1042
1767
|
</td>
|
|
@@ -1048,7 +1773,7 @@ function renderDigestHtml(sections) {
|
|
|
1048
1773
|
</body>
|
|
1049
1774
|
</html>`;
|
|
1050
1775
|
}
|
|
1051
|
-
var
|
|
1776
|
+
var GREY2, RED2, ANCHOR_STYLE, SEVERITY_ORDER, FROM_ADDRESS, DIGEST_OPERATOR_FALLBACK;
|
|
1052
1777
|
var init_digest = __esm({
|
|
1053
1778
|
"src/reports/digest.ts"() {
|
|
1054
1779
|
"use strict";
|
|
@@ -1056,9 +1781,13 @@ var init_digest = __esm({
|
|
|
1056
1781
|
init_reports();
|
|
1057
1782
|
init_websites();
|
|
1058
1783
|
init_resend();
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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 };
|
|
1062
1791
|
FROM_ADDRESS = "Reddoor Reports <reports@reddoorla.com>";
|
|
1063
1792
|
DIGEST_OPERATOR_FALLBACK = "info@reddoorla.com";
|
|
1064
1793
|
}
|
|
@@ -1139,6 +1868,14 @@ async function sendApprovedReports(options = {}) {
|
|
|
1139
1868
|
try {
|
|
1140
1869
|
const messageId = await sendOne(client, base, site, report);
|
|
1141
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
|
+
}
|
|
1142
1879
|
} catch (e) {
|
|
1143
1880
|
lines.push(`\u2717 ${report.reportId} \u2014 ${e.message}`);
|
|
1144
1881
|
anyFailed = true;
|
|
@@ -1171,6 +1908,7 @@ async function sendOne(client, base, site, report) {
|
|
|
1171
1908
|
searchPosition: report.searchFoundPage1 && report.searchPosition !== null ? report.searchPosition : void 0,
|
|
1172
1909
|
lastTestedDate: report.lastTestedDate ? new Date(report.lastTestedDate) : null,
|
|
1173
1910
|
commentary: report.commentary,
|
|
1911
|
+
copy: resolveCopy(site),
|
|
1174
1912
|
headerImageCid: cidName,
|
|
1175
1913
|
headerWidth: header.displayWidth,
|
|
1176
1914
|
headerHeight: header.displayHeight,
|
|
@@ -1274,6 +2012,7 @@ var init_orchestrate = __esm({
|
|
|
1274
2012
|
init_websites();
|
|
1275
2013
|
init_attachments();
|
|
1276
2014
|
init_render();
|
|
2015
|
+
init_copy();
|
|
1277
2016
|
init_assets();
|
|
1278
2017
|
init_header_image();
|
|
1279
2018
|
init_resend();
|
|
@@ -1306,72 +2045,8 @@ import { cac } from "cac";
|
|
|
1306
2045
|
import { resolve as resolve2 } from "path";
|
|
1307
2046
|
import { Listr } from "listr2";
|
|
1308
2047
|
|
|
1309
|
-
// src/audits/
|
|
1310
|
-
|
|
1311
|
-
var TRUNCATION_MARKER = "\n\u2026[output truncated]";
|
|
1312
|
-
function makeSpawn(internals = {}) {
|
|
1313
|
-
const spawnImpl = internals.spawnImpl ?? spawn;
|
|
1314
|
-
const killImpl = internals.killImpl ?? ((pid, sig) => process.kill(pid, sig));
|
|
1315
|
-
const killGraceMs = internals.killGraceMs ?? 5e3;
|
|
1316
|
-
const maxOutputBytes = internals.maxOutputBytes ?? 10 * 1024 * 1024;
|
|
1317
|
-
return (cmd, args, opts = {}) => new Promise((resolve11, reject) => {
|
|
1318
|
-
const streaming = opts.streaming === true;
|
|
1319
|
-
const child = spawnImpl(cmd, [...args], {
|
|
1320
|
-
cwd: opts.cwd,
|
|
1321
|
-
env: opts.env ?? process.env,
|
|
1322
|
-
stdio: streaming ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "pipe"],
|
|
1323
|
-
// Detach ONLY when a timeout can fire: the child then leads its own
|
|
1324
|
-
// process group, so the timeout can kill the WHOLE tree (vite, and
|
|
1325
|
-
// Chromium under lhci/playwright) via process.kill(-pid), not just the
|
|
1326
|
-
// npx/pnpm wrapper. Without it, killing the wrapper orphaned the
|
|
1327
|
-
// grandchildren — a zombie vite squatting its port, Chrome left running.
|
|
1328
|
-
// We do NOT detach timeout-less streaming calls (pnpm install/up):
|
|
1329
|
-
// detaching gains nothing there (no timeout → no group-kill) and would
|
|
1330
|
-
// break terminal Ctrl-C, which only reaches the foreground group — i.e.
|
|
1331
|
-
// it would re-orphan the very children this guards. We never unref() the
|
|
1332
|
-
// child since we still await it.
|
|
1333
|
-
detached: opts.timeoutMs !== void 0
|
|
1334
|
-
});
|
|
1335
|
-
const cap = (acc, chunk) => {
|
|
1336
|
-
if (acc.length >= maxOutputBytes) return acc;
|
|
1337
|
-
const next = acc + chunk;
|
|
1338
|
-
return next.length > maxOutputBytes ? next.slice(0, maxOutputBytes) + TRUNCATION_MARKER : next;
|
|
1339
|
-
};
|
|
1340
|
-
let stdout = "";
|
|
1341
|
-
let stderr = "";
|
|
1342
|
-
if (!streaming) {
|
|
1343
|
-
child.stdout?.on("data", (chunk) => stdout = cap(stdout, String(chunk)));
|
|
1344
|
-
child.stderr?.on("data", (chunk) => stderr = cap(stderr, String(chunk)));
|
|
1345
|
-
}
|
|
1346
|
-
const killGroup = (sig) => {
|
|
1347
|
-
if (child.pid === void 0) return;
|
|
1348
|
-
try {
|
|
1349
|
-
killImpl(-child.pid, sig);
|
|
1350
|
-
} catch {
|
|
1351
|
-
}
|
|
1352
|
-
};
|
|
1353
|
-
let killTimer;
|
|
1354
|
-
const timer = opts.timeoutMs ? setTimeout(() => {
|
|
1355
|
-
killGroup("SIGTERM");
|
|
1356
|
-
killTimer = setTimeout(() => killGroup("SIGKILL"), killGraceMs);
|
|
1357
|
-
killTimer.unref();
|
|
1358
|
-
reject(new Error(`spawn timeout after ${opts.timeoutMs}ms: ${cmd}`));
|
|
1359
|
-
}, opts.timeoutMs) : void 0;
|
|
1360
|
-
const clearTimers = () => {
|
|
1361
|
-
if (timer) clearTimeout(timer);
|
|
1362
|
-
if (killTimer) clearTimeout(killTimer);
|
|
1363
|
-
};
|
|
1364
|
-
child.on("error", (err) => {
|
|
1365
|
-
clearTimers();
|
|
1366
|
-
reject(err);
|
|
1367
|
-
});
|
|
1368
|
-
child.on("close", (code) => {
|
|
1369
|
-
clearTimers();
|
|
1370
|
-
resolve11({ code: code ?? -1, stdout, stderr });
|
|
1371
|
-
});
|
|
1372
|
-
});
|
|
1373
|
-
}
|
|
1374
|
-
var defaultSpawn = makeSpawn();
|
|
2048
|
+
// src/audits/index.ts
|
|
2049
|
+
init_spawn();
|
|
1375
2050
|
|
|
1376
2051
|
// src/audits/deps.ts
|
|
1377
2052
|
import { readFile } from "fs/promises";
|
|
@@ -1421,6 +2096,9 @@ var baselineVersions = {
|
|
|
1421
2096
|
"@zerodevx/svelte-img": "^2.1.2"
|
|
1422
2097
|
};
|
|
1423
2098
|
|
|
2099
|
+
// src/audits/deps.ts
|
|
2100
|
+
init_spawn();
|
|
2101
|
+
|
|
1424
2102
|
// src/audits/deps-outdated.ts
|
|
1425
2103
|
import { stat } from "fs/promises";
|
|
1426
2104
|
import { join as join2 } from "path";
|
|
@@ -1600,6 +2278,7 @@ async function lintAudit(ctx) {
|
|
|
1600
2278
|
}
|
|
1601
2279
|
|
|
1602
2280
|
// src/audits/security.ts
|
|
2281
|
+
init_spawn();
|
|
1603
2282
|
function classify(v) {
|
|
1604
2283
|
if (v.critical > 0 || v.high > 0) return "fail";
|
|
1605
2284
|
if (v.moderate > 0 || v.low > 0) return "warn";
|
|
@@ -1774,6 +2453,9 @@ var lighthouseConfig = {
|
|
|
1774
2453
|
}
|
|
1775
2454
|
};
|
|
1776
2455
|
|
|
2456
|
+
// src/audits/lighthouse.ts
|
|
2457
|
+
init_spawn();
|
|
2458
|
+
|
|
1777
2459
|
// src/audits/util/site-config.ts
|
|
1778
2460
|
import { readFile as readFile3 } from "fs/promises";
|
|
1779
2461
|
import { join as join5 } from "path";
|
|
@@ -1804,7 +2486,7 @@ async function readSiteConfig(sitePath) {
|
|
|
1804
2486
|
// src/util/free-port.ts
|
|
1805
2487
|
import { createServer } from "net";
|
|
1806
2488
|
async function findFreePort() {
|
|
1807
|
-
return new Promise((
|
|
2489
|
+
return new Promise((resolve12, reject) => {
|
|
1808
2490
|
const server = createServer();
|
|
1809
2491
|
server.unref();
|
|
1810
2492
|
server.on("error", reject);
|
|
@@ -1812,7 +2494,7 @@ async function findFreePort() {
|
|
|
1812
2494
|
const addr = server.address();
|
|
1813
2495
|
if (typeof addr === "object" && addr) {
|
|
1814
2496
|
const port = addr.port;
|
|
1815
|
-
server.close(() =>
|
|
2497
|
+
server.close(() => resolve12(port));
|
|
1816
2498
|
} else {
|
|
1817
2499
|
server.close();
|
|
1818
2500
|
reject(new Error("findFreePort: could not determine assigned port from socket"));
|
|
@@ -2042,6 +2724,7 @@ var playwrightA11yConfig = defineConfig({
|
|
|
2042
2724
|
});
|
|
2043
2725
|
|
|
2044
2726
|
// src/audits/a11y.ts
|
|
2727
|
+
init_spawn();
|
|
2045
2728
|
var RESULTS_REL = ".reddoor-a11y/results.json";
|
|
2046
2729
|
async function readJsonMaybe2(path) {
|
|
2047
2730
|
try {
|
|
@@ -2358,6 +3041,7 @@ async function resolveSites(input) {
|
|
|
2358
3041
|
}
|
|
2359
3042
|
|
|
2360
3043
|
// src/cli/fleet/clone-if-needed.ts
|
|
3044
|
+
init_spawn();
|
|
2361
3045
|
import { stat as stat2, readdir as readdir2, mkdir } from "fs/promises";
|
|
2362
3046
|
import { isAbsolute as isAbsolute2, join as join8 } from "path";
|
|
2363
3047
|
function deriveNameFromRepoUrl(repoUrl) {
|
|
@@ -3192,6 +3876,7 @@ async function runSyncConfigsCommand(site, opts) {
|
|
|
3192
3876
|
import { resolve as resolve4 } from "path";
|
|
3193
3877
|
|
|
3194
3878
|
// src/recipes/bump-deps.ts
|
|
3879
|
+
init_spawn();
|
|
3195
3880
|
import { stat as stat3 } from "fs/promises";
|
|
3196
3881
|
import { join as join12 } from "path";
|
|
3197
3882
|
async function exists2(path) {
|
|
@@ -3306,158 +3991,8 @@ function readGitHubConfig() {
|
|
|
3306
3991
|
return { token, renovateToken };
|
|
3307
3992
|
}
|
|
3308
3993
|
|
|
3309
|
-
// src/github/gh.ts
|
|
3310
|
-
function mapRollupState(state) {
|
|
3311
|
-
switch (state) {
|
|
3312
|
-
case "SUCCESS":
|
|
3313
|
-
return "passing";
|
|
3314
|
-
case "FAILURE":
|
|
3315
|
-
case "ERROR":
|
|
3316
|
-
return "failing";
|
|
3317
|
-
case "PENDING":
|
|
3318
|
-
case "EXPECTED":
|
|
3319
|
-
return "pending";
|
|
3320
|
-
default:
|
|
3321
|
-
return "none";
|
|
3322
|
-
}
|
|
3323
|
-
}
|
|
3324
|
-
function makeGitHub(deps) {
|
|
3325
|
-
const spawn2 = deps.spawn ?? defaultSpawn;
|
|
3326
|
-
const env = { ...process.env, GH_TOKEN: deps.token };
|
|
3327
|
-
async function gh(args) {
|
|
3328
|
-
const r = await spawn2("gh", args, { env, timeoutMs: 6e4 });
|
|
3329
|
-
if (r.code !== 0) throw new Error(`gh ${args[0]} failed (code ${r.code}): ${r.stderr.trim()}`);
|
|
3330
|
-
return r.stdout;
|
|
3331
|
-
}
|
|
3332
|
-
return {
|
|
3333
|
-
async openPullRequest(repo, pr) {
|
|
3334
|
-
const out = await gh([
|
|
3335
|
-
"pr",
|
|
3336
|
-
"create",
|
|
3337
|
-
"--repo",
|
|
3338
|
-
repo,
|
|
3339
|
-
"--head",
|
|
3340
|
-
pr.head,
|
|
3341
|
-
"--base",
|
|
3342
|
-
pr.base,
|
|
3343
|
-
"--title",
|
|
3344
|
-
pr.title,
|
|
3345
|
-
"--body",
|
|
3346
|
-
pr.body
|
|
3347
|
-
]);
|
|
3348
|
-
return { url: out.trim() };
|
|
3349
|
-
},
|
|
3350
|
-
async enableRepoAutoMerge(repo) {
|
|
3351
|
-
await gh(["api", "-X", "PATCH", `repos/${repo}`, "-F", "allow_auto_merge=true"]);
|
|
3352
|
-
},
|
|
3353
|
-
async protectBranch(repo, branch, requiredChecks) {
|
|
3354
|
-
const args = [
|
|
3355
|
-
"api",
|
|
3356
|
-
"-X",
|
|
3357
|
-
"PUT",
|
|
3358
|
-
`repos/${repo}/branches/${branch}/protection`,
|
|
3359
|
-
"-H",
|
|
3360
|
-
"Accept: application/vnd.github+json",
|
|
3361
|
-
"-F",
|
|
3362
|
-
"required_status_checks[strict]=true",
|
|
3363
|
-
...requiredChecks.flatMap((c) => ["-f", `required_status_checks[contexts][]=${c}`]),
|
|
3364
|
-
"-F",
|
|
3365
|
-
"enforce_admins=true",
|
|
3366
|
-
"-F",
|
|
3367
|
-
"required_pull_request_reviews=null",
|
|
3368
|
-
"-F",
|
|
3369
|
-
"restrictions=null"
|
|
3370
|
-
];
|
|
3371
|
-
await gh(args);
|
|
3372
|
-
},
|
|
3373
|
-
async setRepoSecret(repo, name, value) {
|
|
3374
|
-
await gh(["secret", "set", name, "--repo", repo, "--body", value]);
|
|
3375
|
-
},
|
|
3376
|
-
async repoExists(repo) {
|
|
3377
|
-
const r = await spawn2("gh", ["api", `repos/${repo}`], { env, timeoutMs: 6e4 });
|
|
3378
|
-
return r.code === 0;
|
|
3379
|
-
},
|
|
3380
|
-
async defaultBranch(repo) {
|
|
3381
|
-
const out = await gh(["api", `repos/${repo}`, "--jq", ".default_branch"]);
|
|
3382
|
-
return out.trim();
|
|
3383
|
-
},
|
|
3384
|
-
// filesOnBranch and branchProtectionContexts call `spawn` directly (not the
|
|
3385
|
-
// throwing `gh()` helper) because a 404 is an expected, meaningful answer —
|
|
3386
|
-
// "file/protection absent" — not an error. The remaining readers use `gh()`
|
|
3387
|
-
// since a non-200 there is a genuine failure (e.g. missing token scope).
|
|
3388
|
-
async filesOnBranch(repo, branch, paths) {
|
|
3389
|
-
const present = [];
|
|
3390
|
-
for (const p of paths) {
|
|
3391
|
-
const r = await spawn2("gh", [`api`, `repos/${repo}/contents/${p}?ref=${branch}`], {
|
|
3392
|
-
env,
|
|
3393
|
-
timeoutMs: 6e4
|
|
3394
|
-
});
|
|
3395
|
-
if (r.code === 0) present.push(p);
|
|
3396
|
-
}
|
|
3397
|
-
return present;
|
|
3398
|
-
},
|
|
3399
|
-
async branchProtectionContexts(repo, branch) {
|
|
3400
|
-
const r = await spawn2(
|
|
3401
|
-
"gh",
|
|
3402
|
-
[
|
|
3403
|
-
"api",
|
|
3404
|
-
`repos/${repo}/branches/${branch}/protection`,
|
|
3405
|
-
"--jq",
|
|
3406
|
-
".required_status_checks.contexts[]?"
|
|
3407
|
-
],
|
|
3408
|
-
{ env, timeoutMs: 6e4 }
|
|
3409
|
-
);
|
|
3410
|
-
if (r.code !== 0) return [];
|
|
3411
|
-
return r.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
3412
|
-
},
|
|
3413
|
-
async secretExists(repo, name) {
|
|
3414
|
-
const out = await gh(["api", `repos/${repo}/actions/secrets`, "--jq", ".secrets[].name"]);
|
|
3415
|
-
return out.split("\n").map((l) => l.trim()).includes(name);
|
|
3416
|
-
},
|
|
3417
|
-
async autoMergeEnabled(repo) {
|
|
3418
|
-
const out = await gh(["api", `repos/${repo}`, "--jq", ".allow_auto_merge"]);
|
|
3419
|
-
return out.trim() === "true";
|
|
3420
|
-
},
|
|
3421
|
-
async findOpenSelfUpdatingPR(repo) {
|
|
3422
|
-
const out = await gh([
|
|
3423
|
-
"api",
|
|
3424
|
-
`repos/${repo}/pulls?state=open`,
|
|
3425
|
-
"--jq",
|
|
3426
|
-
'.[] | select(.head.ref | startswith("maint/self-updating-")) | .html_url'
|
|
3427
|
-
]);
|
|
3428
|
-
const first = out.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
|
|
3429
|
-
return first ?? null;
|
|
3430
|
-
},
|
|
3431
|
-
async openPullRequests(repo) {
|
|
3432
|
-
const [owner, name, ...rest] = repo.split("/");
|
|
3433
|
-
if (!owner || !name || rest.length > 0) {
|
|
3434
|
-
throw new Error(`openPullRequests: expected "owner/repo", got "${repo}"`);
|
|
3435
|
-
}
|
|
3436
|
-
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}}}}}}}}";
|
|
3437
|
-
const out = await gh([
|
|
3438
|
-
"api",
|
|
3439
|
-
"graphql",
|
|
3440
|
-
"-f",
|
|
3441
|
-
`query=${query}`,
|
|
3442
|
-
"-F",
|
|
3443
|
-
`owner=${owner}`,
|
|
3444
|
-
"-F",
|
|
3445
|
-
`name=${name}`
|
|
3446
|
-
]);
|
|
3447
|
-
const parsed = JSON.parse(out);
|
|
3448
|
-
const nodes = parsed.data?.repository?.pullRequests?.nodes ?? [];
|
|
3449
|
-
return nodes.map((n) => ({
|
|
3450
|
-
number: n.number,
|
|
3451
|
-
title: n.title,
|
|
3452
|
-
url: n.url,
|
|
3453
|
-
headRef: n.headRefName,
|
|
3454
|
-
ciState: mapRollupState(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state)
|
|
3455
|
-
}));
|
|
3456
|
-
}
|
|
3457
|
-
};
|
|
3458
|
-
}
|
|
3459
|
-
|
|
3460
3994
|
// src/recipes/self-updating/index.ts
|
|
3995
|
+
init_gh();
|
|
3461
3996
|
var SELF_UPDATING_CONFIGS = ["ci", "renovate-action", "renovate-config"];
|
|
3462
3997
|
var REQUIRED_CHECK = "ci / ci";
|
|
3463
3998
|
function resultOf(site, status, notes, commits = []) {
|
|
@@ -3626,6 +4161,9 @@ function bumpDep(pkg, name, version2, opts = {}) {
|
|
|
3626
4161
|
return next;
|
|
3627
4162
|
}
|
|
3628
4163
|
|
|
4164
|
+
// src/recipes/svelte-5/index.ts
|
|
4165
|
+
init_spawn();
|
|
4166
|
+
|
|
3629
4167
|
// src/recipes/svelte-5/step-bump-versions.ts
|
|
3630
4168
|
import { join as join14 } from "path";
|
|
3631
4169
|
var SVELTE_5_VERSIONS = {
|
|
@@ -3710,6 +4248,7 @@ async function migrateSvelteConfig(cwd) {
|
|
|
3710
4248
|
}
|
|
3711
4249
|
|
|
3712
4250
|
// src/recipes/svelte-5/step-svelte-migrate.ts
|
|
4251
|
+
init_spawn();
|
|
3713
4252
|
async function runSvelteMigrate(cwd, spawn2 = defaultSpawn) {
|
|
3714
4253
|
try {
|
|
3715
4254
|
const { code, stderr } = await spawn2(
|
|
@@ -3731,6 +4270,7 @@ async function runSvelteMigrate(cwd, spawn2 = defaultSpawn) {
|
|
|
3731
4270
|
}
|
|
3732
4271
|
|
|
3733
4272
|
// src/recipes/svelte-5/step-tailwind-upgrade.ts
|
|
4273
|
+
init_spawn();
|
|
3734
4274
|
import { join as join16 } from "path";
|
|
3735
4275
|
async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
|
|
3736
4276
|
const pkg = await readPackageJson(join16(cwd, "package.json"));
|
|
@@ -4117,6 +4657,7 @@ async function applyGotchaCodemods(cwd) {
|
|
|
4117
4657
|
}
|
|
4118
4658
|
|
|
4119
4659
|
// src/recipes/svelte-5/step-verify.ts
|
|
4660
|
+
init_spawn();
|
|
4120
4661
|
async function verifyMigration(cwd, spawn2 = defaultSpawn) {
|
|
4121
4662
|
let install;
|
|
4122
4663
|
try {
|
|
@@ -4256,6 +4797,7 @@ import { resolve as resolve7 } from "path";
|
|
|
4256
4797
|
// src/recipes/convert-to-pnpm.ts
|
|
4257
4798
|
import { rm as rm3, stat as stat4 } from "fs/promises";
|
|
4258
4799
|
import { join as join20 } from "path";
|
|
4800
|
+
init_spawn();
|
|
4259
4801
|
|
|
4260
4802
|
// src/recipes/convert-to-pnpm/script-rewrites.ts
|
|
4261
4803
|
function rewriteScriptForPnpm(script) {
|
|
@@ -4368,6 +4910,7 @@ import { resolve as resolve8 } from "path";
|
|
|
4368
4910
|
// src/recipes/onboard.ts
|
|
4369
4911
|
import { stat as stat5 } from "fs/promises";
|
|
4370
4912
|
import { join as join22 } from "path";
|
|
4913
|
+
init_spawn();
|
|
4371
4914
|
|
|
4372
4915
|
// src/util/self-version.ts
|
|
4373
4916
|
import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
|
|
@@ -4663,6 +5206,7 @@ function reportPeriodKey(dueDate) {
|
|
|
4663
5206
|
// src/reports/draft.ts
|
|
4664
5207
|
init_render();
|
|
4665
5208
|
init_websites();
|
|
5209
|
+
init_copy();
|
|
4666
5210
|
init_reports();
|
|
4667
5211
|
init_attachments();
|
|
4668
5212
|
import { mkdir as mkdir4, writeFile as writeFile10 } from "fs/promises";
|
|
@@ -4823,6 +5367,7 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
|
|
|
4823
5367
|
searchPosition: search?.foundOnPage1 ? search.position ?? void 0 : void 0,
|
|
4824
5368
|
lastTestedDate,
|
|
4825
5369
|
commentary: null,
|
|
5370
|
+
copy: resolveCopy(siteRow),
|
|
4826
5371
|
headerImageCid: cidName
|
|
4827
5372
|
});
|
|
4828
5373
|
if (options.previewOnly) {
|
|
@@ -5144,6 +5689,256 @@ async function runInitCommand(site, opts) {
|
|
|
5144
5689
|
return { output, code };
|
|
5145
5690
|
}
|
|
5146
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
|
+
|
|
5147
5942
|
// src/cli/version.ts
|
|
5148
5943
|
import { readFileSync as readFileSync5, existsSync as existsSync4 } from "fs";
|
|
5149
5944
|
import { dirname as dirname8, join as join27 } from "path";
|
|
@@ -5286,6 +6081,12 @@ cli.command(
|
|
|
5286
6081
|
).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
|
|
5287
6082
|
async (site, opts) => runOrExit(() => runInitCommand(site, opts), opts)
|
|
5288
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
|
+
);
|
|
5289
6090
|
cli.command("report [site]", "Draft or send maintenance/testing reports.").option("--due", "Scan all Websites and draft overdue reports.").option(
|
|
5290
6091
|
"--preview",
|
|
5291
6092
|
"Single-site dry run; writes reports/<slug>/draft.html, never touches Airtable."
|
|
@@ -5298,6 +6099,15 @@ cli.command("report [site]", "Draft or send maintenance/testing reports.").optio
|
|
|
5298
6099
|
).action(
|
|
5299
6100
|
async (site, opts) => runOrExit(() => runReportCommand(site, opts), opts)
|
|
5300
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
|
+
);
|
|
5301
6111
|
cli.help();
|
|
5302
6112
|
cli.version(version);
|
|
5303
6113
|
cli.parse();
|