@reddoorla/maintenance 0.29.0 → 0.31.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 +664 -279
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +29 -2
- package/dist/cli/commands/audit.js +468 -189
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/configs/svelte.d.ts +39 -10
- package/dist/configs/svelte.js +75 -2
- package/dist/configs/svelte.js.map +1 -1
- package/dist/index.d.ts +23 -32
- package/dist/index.js +433 -237
- package/dist/index.js.map +1 -1
- package/dist/recipes/sync-configs.d.ts +1 -1
- package/dist/recipes/sync-configs.js +36 -1
- package/dist/recipes/sync-configs.js.map +1 -1
- package/dist/{types-D2TnxZOW.d.ts → types-DeKpgkG-.d.ts} +3 -0
- package/package.json +4 -2
package/dist/cli/bin.js
CHANGED
|
@@ -134,6 +134,7 @@ function mapRow(rec) {
|
|
|
134
134
|
a11yViolations: f["A11y Violations"] ?? null,
|
|
135
135
|
depsDrifted: f["Deps Drifted"] ?? null,
|
|
136
136
|
depsMajorBehind: f["Deps Major Behind"] ?? null,
|
|
137
|
+
depsOutdated: f["Deps Outdated"] ?? null,
|
|
137
138
|
securityVulnsCritical: f["Security Vulns Critical"] ?? null,
|
|
138
139
|
securityVulnsHigh: f["Security Vulns High"] ?? null,
|
|
139
140
|
securityVulnsModerate: f["Security Vulns Moderate"] ?? null,
|
|
@@ -155,8 +156,16 @@ async function listWebsites(base) {
|
|
|
155
156
|
return out;
|
|
156
157
|
}
|
|
157
158
|
async function getWebsiteBySlug(base, slug) {
|
|
158
|
-
|
|
159
|
-
|
|
159
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) return null;
|
|
160
|
+
const formula = `REGEX_REPLACE(REGEX_REPLACE(LOWER({Name}),"[^a-z0-9]+","-"),"^-|-$","")=${JSON.stringify(
|
|
161
|
+
slug
|
|
162
|
+
)}`;
|
|
163
|
+
const rows = [];
|
|
164
|
+
await base(WEBSITES_TABLE).select({ filterByFormula: formula, maxRecords: 1 }).eachPage((records, fetchNextPage) => {
|
|
165
|
+
for (const rec of records) rows.push(mapRow({ id: rec.id, fields: rec.fields }));
|
|
166
|
+
fetchNextPage();
|
|
167
|
+
});
|
|
168
|
+
return rows.find((w) => siteSlug(w.name) === slug) ?? null;
|
|
160
169
|
}
|
|
161
170
|
async function updateScores(base, recordId, scores) {
|
|
162
171
|
const fields = {
|
|
@@ -179,6 +188,9 @@ async function updateDepsCounts(base, recordId, counts) {
|
|
|
179
188
|
"Deps Drifted": counts.drifted,
|
|
180
189
|
"Deps Major Behind": counts.majorBehind
|
|
181
190
|
};
|
|
191
|
+
if (counts.outdated !== null) {
|
|
192
|
+
fields["Deps Outdated"] = counts.outdated;
|
|
193
|
+
}
|
|
182
194
|
await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
|
|
183
195
|
}
|
|
184
196
|
async function updateSecurityCounts(base, recordId, counts) {
|
|
@@ -212,23 +224,25 @@ function fromAirtableBase(base, opts = {}) {
|
|
|
212
224
|
);
|
|
213
225
|
}
|
|
214
226
|
const websites = await listWebsites(base);
|
|
215
|
-
return websites.filter((w) => w.
|
|
227
|
+
return websites.filter((w) => AUDITABLE_STATUSES.has(w.status ?? "") && w.url.length > 0).map((w) => {
|
|
216
228
|
const slug = siteSlug(w.name);
|
|
217
229
|
const site = {
|
|
218
230
|
path: `${workdir}/${slug}`,
|
|
219
231
|
name: slug,
|
|
232
|
+
deployedUrl: w.url,
|
|
220
233
|
meta: { airtableRowId: w.id, displayName: w.name }
|
|
221
234
|
};
|
|
222
|
-
if (w.url) site.repoUrl = w.url;
|
|
223
235
|
if (w.gitRepo) site.gitRepo = w.gitRepo;
|
|
224
236
|
return site;
|
|
225
237
|
});
|
|
226
238
|
};
|
|
227
239
|
}
|
|
240
|
+
var AUDITABLE_STATUSES;
|
|
228
241
|
var init_airtable = __esm({
|
|
229
242
|
"src/inventory/airtable.ts"() {
|
|
230
243
|
"use strict";
|
|
231
244
|
init_websites();
|
|
245
|
+
AUDITABLE_STATUSES = /* @__PURE__ */ new Set(["maintenance", "launch period"]);
|
|
232
246
|
}
|
|
233
247
|
});
|
|
234
248
|
|
|
@@ -240,7 +254,7 @@ __export(lighthouse_airtable_exports, {
|
|
|
240
254
|
resolveSlugFromCwd: () => resolveSlugFromCwd
|
|
241
255
|
});
|
|
242
256
|
import { readFile as readFile7 } from "fs/promises";
|
|
243
|
-
import { join as
|
|
257
|
+
import { join as join9 } from "path";
|
|
244
258
|
function hasRealScores(result) {
|
|
245
259
|
if (result.audit !== "lighthouse") return false;
|
|
246
260
|
const details = result.details ?? {};
|
|
@@ -265,7 +279,7 @@ function lighthouseScoresFromResult(result) {
|
|
|
265
279
|
}
|
|
266
280
|
async function resolveSlugFromCwd(cwd) {
|
|
267
281
|
try {
|
|
268
|
-
const pkgPath =
|
|
282
|
+
const pkgPath = join9(cwd, "package.json");
|
|
269
283
|
const raw = await readFile7(pkgPath, "utf-8");
|
|
270
284
|
const pkg = JSON.parse(raw);
|
|
271
285
|
if (!pkg.name) throw new Error("package.json has no 'name' field");
|
|
@@ -308,16 +322,19 @@ var init_a11y_airtable = __esm({
|
|
|
308
322
|
// src/audits/deps-airtable.ts
|
|
309
323
|
function hasDepsCounts(result) {
|
|
310
324
|
if (result.audit !== "deps") return false;
|
|
311
|
-
|
|
325
|
+
const d = result.details;
|
|
326
|
+
return Array.isArray(d?.entries);
|
|
312
327
|
}
|
|
313
328
|
function depsCountsFromResult(result) {
|
|
314
329
|
if (result.audit !== "deps") {
|
|
315
330
|
throw new Error(`Expected a 'deps' AuditResult, got '${result.audit}'`);
|
|
316
331
|
}
|
|
317
|
-
const
|
|
332
|
+
const details = result.details ?? {};
|
|
333
|
+
const entries = details.entries ?? [];
|
|
318
334
|
const drifted = entries.filter((e) => e.drift !== "same").length;
|
|
319
335
|
const majorBehind = entries.filter((e) => e.drift === "major").length;
|
|
320
|
-
|
|
336
|
+
const outdated = details.outdated?.outdated ?? null;
|
|
337
|
+
return { drifted, majorBehind, outdated };
|
|
321
338
|
}
|
|
322
339
|
var init_deps_airtable = __esm({
|
|
323
340
|
"src/audits/deps-airtable.ts"() {
|
|
@@ -348,7 +365,9 @@ var init_security_airtable = __esm({
|
|
|
348
365
|
// src/audits/write-audits-to-airtable.ts
|
|
349
366
|
var write_audits_to_airtable_exports = {};
|
|
350
367
|
__export(write_audits_to_airtable_exports, {
|
|
351
|
-
|
|
368
|
+
formatFleetWriteSummary: () => formatFleetWriteSummary,
|
|
369
|
+
writeAuditsToAirtable: () => writeAuditsToAirtable,
|
|
370
|
+
writeFleetAuditsToAirtable: () => writeFleetAuditsToAirtable
|
|
352
371
|
});
|
|
353
372
|
async function writeAuditsToAirtable(args) {
|
|
354
373
|
const { base, websites, slug, results } = args;
|
|
@@ -361,22 +380,17 @@ async function writeAuditsToAirtable(args) {
|
|
|
361
380
|
{ exitCode: 2 }
|
|
362
381
|
);
|
|
363
382
|
}
|
|
364
|
-
if (!hasRealScores(lhResult)) {
|
|
365
|
-
throw Object.assign(
|
|
366
|
-
new Error(
|
|
367
|
-
`Lighthouse audit produced no scores; refusing to write to Airtable. Summary: ${lhResult.summary}`
|
|
368
|
-
),
|
|
369
|
-
{ exitCode: 1 }
|
|
370
|
-
);
|
|
371
|
-
}
|
|
372
383
|
const target = websites.find((w) => siteSlug(w.name) === slug);
|
|
373
384
|
if (!target) {
|
|
374
385
|
throw Object.assign(new Error(`No Websites row matched slug "${slug}"`), { exitCode: 2 });
|
|
375
386
|
}
|
|
376
387
|
const writes = [];
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
388
|
+
const lhHasScores = hasRealScores(lhResult);
|
|
389
|
+
if (lhHasScores) {
|
|
390
|
+
const scores = lighthouseScoresFromResult(lhResult);
|
|
391
|
+
await updateScores(base, target.id, scores);
|
|
392
|
+
writes.push({ audit: "lighthouse", counts: scores });
|
|
393
|
+
}
|
|
380
394
|
const a11y = results.find((r) => r.audit === "a11y");
|
|
381
395
|
if (a11y && hasA11yCounts(a11y)) {
|
|
382
396
|
const counts = a11yCountsFromResult(a11y);
|
|
@@ -395,8 +409,49 @@ async function writeAuditsToAirtable(args) {
|
|
|
395
409
|
await updateSecurityCounts(base, target.id, counts);
|
|
396
410
|
writes.push({ audit: "security", counts });
|
|
397
411
|
}
|
|
412
|
+
if (!lhHasScores) {
|
|
413
|
+
const persisted = writes.map((w) => w.audit);
|
|
414
|
+
throw Object.assign(
|
|
415
|
+
new Error(
|
|
416
|
+
`Lighthouse audit produced no scores; ${persisted.length ? `wrote ${persisted.join("/")} but refused Lighthouse` : "wrote nothing"}. Summary: ${lhResult.summary}`
|
|
417
|
+
),
|
|
418
|
+
{ exitCode: 1 }
|
|
419
|
+
);
|
|
420
|
+
}
|
|
398
421
|
return { siteName: target.name, writes };
|
|
399
422
|
}
|
|
423
|
+
function formatFleetWriteSummary(result) {
|
|
424
|
+
const wrote = result.written.length;
|
|
425
|
+
const failed = result.failed.length;
|
|
426
|
+
const total = wrote + failed;
|
|
427
|
+
let out = `\u2192 wrote ${wrote} site(s) to Airtable`;
|
|
428
|
+
if (failed > 0) {
|
|
429
|
+
out += `
|
|
430
|
+
\u26A0 ${failed} site(s) not written: ${result.failed.map((f) => `${f.slug} (${f.error})`).join("; ")}`;
|
|
431
|
+
}
|
|
432
|
+
out += `
|
|
433
|
+
FLEET_WRITE_SUMMARY wrote=${wrote} failed=${failed} total=${total}`;
|
|
434
|
+
return out;
|
|
435
|
+
}
|
|
436
|
+
async function writeFleetAuditsToAirtable(args) {
|
|
437
|
+
const { base, websites, results } = args;
|
|
438
|
+
const bySlug = /* @__PURE__ */ new Map();
|
|
439
|
+
for (const r of results) {
|
|
440
|
+
const arr = bySlug.get(r.site) ?? [];
|
|
441
|
+
arr.push(r);
|
|
442
|
+
bySlug.set(r.site, arr);
|
|
443
|
+
}
|
|
444
|
+
const written = [];
|
|
445
|
+
const failed = [];
|
|
446
|
+
for (const [slug, siteResults] of bySlug) {
|
|
447
|
+
try {
|
|
448
|
+
written.push(await writeAuditsToAirtable({ base, websites, slug, results: siteResults }));
|
|
449
|
+
} catch (e) {
|
|
450
|
+
failed.push({ slug, error: e.message });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return { written, failed };
|
|
454
|
+
}
|
|
400
455
|
var init_write_audits_to_airtable = __esm({
|
|
401
456
|
"src/audits/write-audits-to-airtable.ts"() {
|
|
402
457
|
"use strict";
|
|
@@ -524,18 +579,18 @@ var init_reports = __esm({
|
|
|
524
579
|
// src/reports/maintenance-email/assets/index.ts
|
|
525
580
|
import { readFile as readFile13 } from "fs/promises";
|
|
526
581
|
import { existsSync as existsSync3 } from "fs";
|
|
527
|
-
import { dirname as dirname4, join as
|
|
582
|
+
import { dirname as dirname4, join as join24 } from "path";
|
|
528
583
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
529
584
|
function resolveAssetsDir() {
|
|
530
585
|
if (cachedAssetsDir) return cachedAssetsDir;
|
|
531
586
|
let dir = dirname4(fileURLToPath2(import.meta.url));
|
|
532
587
|
while (true) {
|
|
533
|
-
const srcCandidate =
|
|
588
|
+
const srcCandidate = join24(dir, "src", "reports", "maintenance-email", "assets", "check.png");
|
|
534
589
|
if (existsSync3(srcCandidate)) {
|
|
535
590
|
cachedAssetsDir = dirname4(srcCandidate);
|
|
536
591
|
return cachedAssetsDir;
|
|
537
592
|
}
|
|
538
|
-
const distCandidate =
|
|
593
|
+
const distCandidate = join24(dir, "dist", "reports", "maintenance-email", "assets", "check.png");
|
|
539
594
|
if (existsSync3(distCandidate)) {
|
|
540
595
|
cachedAssetsDir = dirname4(distCandidate);
|
|
541
596
|
return cachedAssetsDir;
|
|
@@ -552,8 +607,8 @@ function resolveAssetsDir() {
|
|
|
552
607
|
async function loadBundledImages() {
|
|
553
608
|
const assetsDir = resolveAssetsDir();
|
|
554
609
|
const [check, blurred] = await Promise.all([
|
|
555
|
-
readFile13(
|
|
556
|
-
readFile13(
|
|
610
|
+
readFile13(join24(assetsDir, "check.png")),
|
|
611
|
+
readFile13(join24(assetsDir, "blurredTests.jpg"))
|
|
557
612
|
]);
|
|
558
613
|
return {
|
|
559
614
|
check: {
|
|
@@ -769,7 +824,7 @@ function buildMjml(data) {
|
|
|
769
824
|
<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">Just hit reply.</mj-text>
|
|
770
825
|
<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" padding-top="0px" line-height="30px" padding-bottom="36px">We're here to help in any way we can.</mj-text>
|
|
771
826
|
<mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
|
|
772
|
-
<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()).
|
|
827
|
+
<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>
|
|
773
828
|
<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>
|
|
774
829
|
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">Reddoor Creative, LLC</mj-text>
|
|
775
830
|
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">29027 Dapper Dan</mj-text>
|
|
@@ -924,6 +979,14 @@ __export(orchestrate_exports, {
|
|
|
924
979
|
function monthYear(d) {
|
|
925
980
|
return `${MONTHS2[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
|
|
926
981
|
}
|
|
982
|
+
function toInlineAttachment(a) {
|
|
983
|
+
return {
|
|
984
|
+
filename: a.filename,
|
|
985
|
+
content: Buffer.from(a.bytes).toString("base64"),
|
|
986
|
+
contentType: a.contentType,
|
|
987
|
+
inlineContentId: a.cid
|
|
988
|
+
};
|
|
989
|
+
}
|
|
927
990
|
async function sendApprovedReports(options = {}) {
|
|
928
991
|
const base = openBase(readAirtableConfig());
|
|
929
992
|
const client = options.resend ?? defaultResendClient();
|
|
@@ -955,7 +1018,9 @@ async function sendOne(client, base, site, report) {
|
|
|
955
1018
|
throw new Error(`Site '${site.name}' has no Header image set on the Websites row`);
|
|
956
1019
|
}
|
|
957
1020
|
if (!report.lighthouse) {
|
|
958
|
-
throw new Error(
|
|
1021
|
+
throw new Error(
|
|
1022
|
+
`Report ${report.reportId} has no Lighthouse scores \u2014 all four cells (Lighthouse \u2014 Performance / Accessibility / Best Practices / SEO) must be numeric on the Reports row; one non-numeric or blank cell nulls all four`
|
|
1023
|
+
);
|
|
959
1024
|
}
|
|
960
1025
|
const original = await fetchAttachmentBytes(site.headerImage.url);
|
|
961
1026
|
const header = await prepareHeaderImage(original.bytes);
|
|
@@ -991,7 +1056,7 @@ async function sendOne(client, base, site, report) {
|
|
|
991
1056
|
for (const addr of to) {
|
|
992
1057
|
if (!isProbablyEmail(addr)) {
|
|
993
1058
|
throw new Error(
|
|
994
|
-
`Site '${site.name}' recipient is malformed: ${addr} \u2014 fix Report recipients (To) or point of contact in Airtable`
|
|
1059
|
+
`Site '${site.name}' recipient is malformed: ${addr} \u2014 use a bare address only (no \`Name <addr>\` display-name syntax); fix Report recipients (To) or point of contact in Airtable`
|
|
995
1060
|
);
|
|
996
1061
|
}
|
|
997
1062
|
}
|
|
@@ -1012,27 +1077,27 @@ async function sendOne(client, base, site, report) {
|
|
|
1012
1077
|
subject,
|
|
1013
1078
|
html,
|
|
1014
1079
|
attachments: [
|
|
1015
|
-
{
|
|
1080
|
+
toInlineAttachment({
|
|
1081
|
+
bytes: header.bytes,
|
|
1016
1082
|
filename: `${cidName}.jpg`,
|
|
1017
|
-
content: Buffer.from(header.bytes).toString("base64"),
|
|
1018
1083
|
contentType: header.contentType,
|
|
1019
|
-
|
|
1020
|
-
},
|
|
1084
|
+
cid: cidName
|
|
1085
|
+
}),
|
|
1021
1086
|
// Bundled images referenced via cid:rd-check-png / cid:rd-blurred-tests-jpg
|
|
1022
1087
|
// in the template. Attached inline so the email is self-contained — no
|
|
1023
1088
|
// external CDN dependency, no image-blocked broken icons in webmail.
|
|
1024
|
-
{
|
|
1089
|
+
toInlineAttachment({
|
|
1090
|
+
bytes: bundled.check.bytes,
|
|
1025
1091
|
filename: bundled.check.filename,
|
|
1026
|
-
content: Buffer.from(bundled.check.bytes).toString("base64"),
|
|
1027
1092
|
contentType: bundled.check.contentType,
|
|
1028
|
-
|
|
1029
|
-
},
|
|
1030
|
-
{
|
|
1093
|
+
cid: bundled.check.cid
|
|
1094
|
+
}),
|
|
1095
|
+
toInlineAttachment({
|
|
1096
|
+
bytes: bundled.blurred.bytes,
|
|
1031
1097
|
filename: bundled.blurred.filename,
|
|
1032
|
-
content: Buffer.from(bundled.blurred.bytes).toString("base64"),
|
|
1033
1098
|
contentType: bundled.blurred.contentType,
|
|
1034
|
-
|
|
1035
|
-
}
|
|
1099
|
+
cid: bundled.blurred.cid
|
|
1100
|
+
})
|
|
1036
1101
|
],
|
|
1037
1102
|
// Stable across retries of the same row — if Airtable stamping fails after a
|
|
1038
1103
|
// successful Resend, the next --send-ready replays with the same key and
|
|
@@ -1110,36 +1175,74 @@ import { Listr } from "listr2";
|
|
|
1110
1175
|
|
|
1111
1176
|
// src/audits/util/spawn.ts
|
|
1112
1177
|
import { spawn } from "child_process";
|
|
1113
|
-
var
|
|
1114
|
-
|
|
1115
|
-
const
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
})
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
+
});
|
|
1137
1239
|
});
|
|
1138
|
-
}
|
|
1240
|
+
}
|
|
1241
|
+
var defaultSpawn = makeSpawn();
|
|
1139
1242
|
|
|
1140
1243
|
// src/audits/deps.ts
|
|
1141
1244
|
import { readFile } from "fs/promises";
|
|
1142
|
-
import { join as
|
|
1245
|
+
import { join as join3 } from "path";
|
|
1143
1246
|
|
|
1144
1247
|
// src/util/site.ts
|
|
1145
1248
|
function siteLabel(site) {
|
|
@@ -1185,10 +1288,54 @@ var baselineVersions = {
|
|
|
1185
1288
|
"@zerodevx/svelte-img": "^2.1.2"
|
|
1186
1289
|
};
|
|
1187
1290
|
|
|
1291
|
+
// src/audits/deps-outdated.ts
|
|
1292
|
+
import { stat } from "fs/promises";
|
|
1293
|
+
import { join as join2 } from "path";
|
|
1294
|
+
async function exists(path) {
|
|
1295
|
+
try {
|
|
1296
|
+
await stat(path);
|
|
1297
|
+
return true;
|
|
1298
|
+
} catch {
|
|
1299
|
+
return false;
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
function majorOf(version2) {
|
|
1303
|
+
const head = version2.replace(/^[\^~]/, "").split(".")[0] ?? "0";
|
|
1304
|
+
const n = Number.parseInt(head, 10);
|
|
1305
|
+
return Number.isNaN(n) ? 0 : n;
|
|
1306
|
+
}
|
|
1307
|
+
async function scanOutdated(sitePath, spawn2) {
|
|
1308
|
+
if (!await exists(join2(sitePath, "pnpm-lock.yaml"))) return null;
|
|
1309
|
+
try {
|
|
1310
|
+
if (!await exists(join2(sitePath, "node_modules"))) {
|
|
1311
|
+
const install = await spawn2("pnpm", ["install", "--frozen-lockfile"], {
|
|
1312
|
+
cwd: sitePath,
|
|
1313
|
+
timeoutMs: 18e4
|
|
1314
|
+
});
|
|
1315
|
+
if (install.code !== 0) return null;
|
|
1316
|
+
}
|
|
1317
|
+
const res = await spawn2("pnpm", ["outdated", "--json"], {
|
|
1318
|
+
cwd: sitePath,
|
|
1319
|
+
timeoutMs: 6e4
|
|
1320
|
+
});
|
|
1321
|
+
const parsed = JSON.parse(res.stdout || "{}");
|
|
1322
|
+
const entries = Object.values(parsed);
|
|
1323
|
+
return {
|
|
1324
|
+
outdated: entries.length,
|
|
1325
|
+
major: entries.filter((e) => e.current && e.latest && majorOf(e.latest) > majorOf(e.current)).length
|
|
1326
|
+
};
|
|
1327
|
+
} catch {
|
|
1328
|
+
return null;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1188
1332
|
// src/audits/deps.ts
|
|
1189
1333
|
function stripCaret(range) {
|
|
1190
1334
|
return range.replace(/^[\^~]/, "");
|
|
1191
1335
|
}
|
|
1336
|
+
function isComparableRange(spec) {
|
|
1337
|
+
return /^[\^~]?\d/.test(spec.trim());
|
|
1338
|
+
}
|
|
1192
1339
|
function parseSemver(v) {
|
|
1193
1340
|
const cleaned = stripCaret(v).split("-")[0] ?? "0.0.0";
|
|
1194
1341
|
const parts = cleaned.split(".").map((n) => Number.parseInt(n, 10));
|
|
@@ -1206,7 +1353,7 @@ function compareSemver(actual, baseline) {
|
|
|
1206
1353
|
return "same";
|
|
1207
1354
|
}
|
|
1208
1355
|
async function depsAudit(ctx) {
|
|
1209
|
-
const pkgPath =
|
|
1356
|
+
const pkgPath = join3(ctx.site.path, "package.json");
|
|
1210
1357
|
let pkgRaw;
|
|
1211
1358
|
try {
|
|
1212
1359
|
pkgRaw = await readFile(pkgPath, "utf-8");
|
|
@@ -1219,40 +1366,54 @@ async function depsAudit(ctx) {
|
|
|
1219
1366
|
details: { error: String(err) }
|
|
1220
1367
|
};
|
|
1221
1368
|
}
|
|
1222
|
-
|
|
1369
|
+
let pkg;
|
|
1370
|
+
try {
|
|
1371
|
+
pkg = JSON.parse(pkgRaw);
|
|
1372
|
+
} catch (err) {
|
|
1373
|
+
return {
|
|
1374
|
+
audit: "deps",
|
|
1375
|
+
site: siteLabel(ctx.site),
|
|
1376
|
+
status: "fail",
|
|
1377
|
+
summary: `package.json is not valid JSON: ${err.message}`,
|
|
1378
|
+
details: { error: String(err) }
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1223
1381
|
const installed = {
|
|
1224
1382
|
...pkg.dependencies ?? {},
|
|
1225
1383
|
...pkg.devDependencies ?? {}
|
|
1226
1384
|
};
|
|
1227
|
-
const
|
|
1385
|
+
const entries = [];
|
|
1228
1386
|
for (const [name, baseline] of Object.entries(baselineVersions)) {
|
|
1229
1387
|
const actual = installed[name];
|
|
1230
1388
|
if (!actual) continue;
|
|
1231
|
-
|
|
1389
|
+
if (!isComparableRange(actual)) continue;
|
|
1390
|
+
entries.push({
|
|
1232
1391
|
pkg: name,
|
|
1233
1392
|
baseline,
|
|
1234
1393
|
actual,
|
|
1235
1394
|
drift: compareSemver(actual, baseline)
|
|
1236
1395
|
});
|
|
1237
1396
|
}
|
|
1238
|
-
const anyMajor =
|
|
1239
|
-
const anyMinor =
|
|
1240
|
-
const anyNewer =
|
|
1397
|
+
const anyMajor = entries.some((d) => d.drift === "major");
|
|
1398
|
+
const anyMinor = entries.some((d) => d.drift === "minor");
|
|
1399
|
+
const anyNewer = entries.some((d) => d.drift === "newer");
|
|
1241
1400
|
const status = anyMajor ? "fail" : anyMinor || anyNewer ? "warn" : "pass";
|
|
1242
|
-
const
|
|
1401
|
+
const driftSummary = status === "pass" ? `all ${entries.length} tracked deps in line with baseline` : status === "warn" ? `${entries.filter((d) => d.drift !== "same").length} of ${entries.length} tracked deps drifted` : `${entries.filter((d) => d.drift === "major").length} deps lagging by a major version`;
|
|
1402
|
+
const outdated = await scanOutdated(ctx.site.path, ctx.spawn ?? defaultSpawn);
|
|
1403
|
+
const summary = outdated ? `${driftSummary}; ${outdated.outdated} outdated install(s) (${outdated.major} major)` : driftSummary;
|
|
1243
1404
|
return {
|
|
1244
1405
|
audit: "deps",
|
|
1245
1406
|
site: siteLabel(ctx.site),
|
|
1246
1407
|
status,
|
|
1247
1408
|
summary,
|
|
1248
|
-
details
|
|
1409
|
+
details: { entries, outdated }
|
|
1249
1410
|
};
|
|
1250
1411
|
}
|
|
1251
1412
|
|
|
1252
1413
|
// src/audits/lint.ts
|
|
1253
1414
|
import { existsSync } from "fs";
|
|
1254
1415
|
import { readFile as readFile2 } from "fs/promises";
|
|
1255
|
-
import { join as
|
|
1416
|
+
import { join as join4 } from "path";
|
|
1256
1417
|
import { ESLint } from "eslint";
|
|
1257
1418
|
import { check as prettierCheck, resolveConfig as prettierResolveConfig } from "prettier";
|
|
1258
1419
|
import { glob } from "tinyglobby";
|
|
@@ -1263,7 +1424,7 @@ async function listFiles(cwd) {
|
|
|
1263
1424
|
}
|
|
1264
1425
|
async function lintAudit(ctx) {
|
|
1265
1426
|
const { site } = ctx;
|
|
1266
|
-
const configPath =
|
|
1427
|
+
const configPath = join4(site.path, "eslint.config.js");
|
|
1267
1428
|
if (!existsSync(configPath)) {
|
|
1268
1429
|
return {
|
|
1269
1430
|
audit: "lint",
|
|
@@ -1283,7 +1444,7 @@ async function lintAudit(ctx) {
|
|
|
1283
1444
|
const eslintWarnings = eslintResults.reduce((n, r) => n + r.warningCount, 0);
|
|
1284
1445
|
const prettierUnformatted = [];
|
|
1285
1446
|
for (const rel of relFiles) {
|
|
1286
|
-
const absForResolve =
|
|
1447
|
+
const absForResolve = join4(site.path, rel);
|
|
1287
1448
|
const source = await readFile2(absForResolve, "utf-8");
|
|
1288
1449
|
const options = await prettierResolveConfig(absForResolve) ?? {};
|
|
1289
1450
|
const ok = await prettierCheck(source, { ...options, filepath: absForResolve });
|
|
@@ -1447,7 +1608,7 @@ async function securityAudit(ctx) {
|
|
|
1447
1608
|
// src/audits/lighthouse.ts
|
|
1448
1609
|
import { readFile as readFile4, writeFile, mkdtemp, rm, readdir } from "fs/promises";
|
|
1449
1610
|
import { tmpdir } from "os";
|
|
1450
|
-
import { join as
|
|
1611
|
+
import { join as join6 } from "path";
|
|
1451
1612
|
|
|
1452
1613
|
// src/configs/lighthouse.ts
|
|
1453
1614
|
var lighthouseConfig = {
|
|
@@ -1482,11 +1643,11 @@ var lighthouseConfig = {
|
|
|
1482
1643
|
|
|
1483
1644
|
// src/audits/util/site-config.ts
|
|
1484
1645
|
import { readFile as readFile3 } from "fs/promises";
|
|
1485
|
-
import { join as
|
|
1646
|
+
import { join as join5 } from "path";
|
|
1486
1647
|
async function readSiteConfig(sitePath) {
|
|
1487
1648
|
let raw;
|
|
1488
1649
|
try {
|
|
1489
|
-
raw = await readFile3(
|
|
1650
|
+
raw = await readFile3(join5(sitePath, "package.json"), "utf-8");
|
|
1490
1651
|
} catch {
|
|
1491
1652
|
return {};
|
|
1492
1653
|
}
|
|
@@ -1547,7 +1708,7 @@ async function readLhrEntries(resultsDir) {
|
|
|
1547
1708
|
const entries = [];
|
|
1548
1709
|
for (const f of files) {
|
|
1549
1710
|
if (!f.startsWith("lhr-") || !f.endsWith(".json")) continue;
|
|
1550
|
-
const lhr = await readJsonMaybe(
|
|
1711
|
+
const lhr = await readJsonMaybe(join6(resultsDir, f));
|
|
1551
1712
|
if (!lhr || !lhr.categories) continue;
|
|
1552
1713
|
const summary = {};
|
|
1553
1714
|
for (const [k, v] of Object.entries(lhr.categories)) {
|
|
@@ -1583,10 +1744,35 @@ function categoryFromAssertion(a) {
|
|
|
1583
1744
|
function messageForAssertion(a) {
|
|
1584
1745
|
return `${a.name} ${a.operator} ${a.expected} (actual: ${a.actual.toFixed(2)})`;
|
|
1585
1746
|
}
|
|
1586
|
-
async function
|
|
1587
|
-
const
|
|
1588
|
-
|
|
1589
|
-
|
|
1747
|
+
async function parseLhciResults(resultsDir, label, raw) {
|
|
1748
|
+
const manifest = await readLhrEntries(resultsDir);
|
|
1749
|
+
if (manifest.length === 0) {
|
|
1750
|
+
return {
|
|
1751
|
+
audit: "lighthouse",
|
|
1752
|
+
site: label,
|
|
1753
|
+
status: "fail",
|
|
1754
|
+
summary: `lighthouse: no lhr-*.json written (exit ${raw.code})${raw.stderr ? ` \u2014 ${raw.stderr.slice(0, 200)}` : ""}`
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
const assertionResults = await readJsonMaybe(join6(resultsDir, "assertion-results.json")) ?? [];
|
|
1758
|
+
const failed = assertionResults.filter((a) => !a.passed);
|
|
1759
|
+
const assertions = failed.map((a) => ({
|
|
1760
|
+
category: categoryFromAssertion(a),
|
|
1761
|
+
level: a.level,
|
|
1762
|
+
message: messageForAssertion(a)
|
|
1763
|
+
}));
|
|
1764
|
+
const anyError = assertions.some((a) => a.level === "error");
|
|
1765
|
+
const anyWarn = assertions.some((a) => a.level === "warn");
|
|
1766
|
+
const status = anyError ? "fail" : anyWarn ? "warn" : "pass";
|
|
1767
|
+
const normalized = {
|
|
1768
|
+
summary: averageSummaries(manifest),
|
|
1769
|
+
assertionsFailed: failed.length,
|
|
1770
|
+
assertions
|
|
1771
|
+
};
|
|
1772
|
+
const summary = status === "pass" ? "lighthouse: all categories passing" : `lighthouse: ${failed.length} assertion(s) failed`;
|
|
1773
|
+
return { audit: "lighthouse", site: label, status, summary, details: normalized };
|
|
1774
|
+
}
|
|
1775
|
+
async function checkoutLighthouse(spawn2, site, label) {
|
|
1590
1776
|
const siteCfg = await readSiteConfig(site.path);
|
|
1591
1777
|
const port = await findFreePort();
|
|
1592
1778
|
const baseUrl = siteCfg.lighthouseUrl ?? lighthouseConfig.ci.collect.url[0];
|
|
@@ -1601,19 +1787,15 @@ async function lighthouseAudit(ctx) {
|
|
|
1601
1787
|
}
|
|
1602
1788
|
}
|
|
1603
1789
|
};
|
|
1604
|
-
const configDir = await mkdtemp(
|
|
1605
|
-
const configPath =
|
|
1790
|
+
const configDir = await mkdtemp(join6(tmpdir(), "reddoor-lhci-"));
|
|
1791
|
+
const configPath = join6(configDir, "lighthouserc.json");
|
|
1606
1792
|
await writeFile(configPath, JSON.stringify(resolvedConfig), "utf-8");
|
|
1607
|
-
const resultsDir =
|
|
1793
|
+
const resultsDir = join6(site.path, ".lighthouseci");
|
|
1608
1794
|
await rm(resultsDir, { recursive: true, force: true });
|
|
1609
1795
|
let raw;
|
|
1610
1796
|
try {
|
|
1611
1797
|
raw = await spawn2("npx", ["--yes", "@lhci/cli", "autorun", `--config=${configPath}`], {
|
|
1612
1798
|
cwd: site.path,
|
|
1613
|
-
// lhci autorun boots the site's dev server, downloads Chrome on first
|
|
1614
|
-
// use, and runs the audit — easily 2–3 min on a cold tree. The shared
|
|
1615
|
-
// 30 s default in runAudits is fine for deps/lint/security but starves
|
|
1616
|
-
// lhci.
|
|
1617
1799
|
timeoutMs: 5 * 6e4
|
|
1618
1800
|
});
|
|
1619
1801
|
} catch (err) {
|
|
@@ -1630,43 +1812,68 @@ async function lighthouseAudit(ctx) {
|
|
|
1630
1812
|
throw err;
|
|
1631
1813
|
}
|
|
1632
1814
|
await rm(configDir, { recursive: true, force: true });
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
const status = anyError ? "fail" : anyWarn ? "warn" : "pass";
|
|
1652
|
-
const normalized = {
|
|
1653
|
-
summary: averageSummaries(manifest),
|
|
1654
|
-
assertionsFailed: failed.length,
|
|
1655
|
-
assertions
|
|
1656
|
-
};
|
|
1657
|
-
const summary = status === "pass" ? "lighthouse: all categories passing" : `lighthouse: ${failed.length} assertion(s) failed`;
|
|
1658
|
-
return {
|
|
1659
|
-
audit: "lighthouse",
|
|
1660
|
-
site: label,
|
|
1661
|
-
status,
|
|
1662
|
-
summary,
|
|
1663
|
-
details: normalized
|
|
1815
|
+
return parseLhciResults(resultsDir, label, raw);
|
|
1816
|
+
}
|
|
1817
|
+
async function deployedLighthouse(spawn2, deployedUrl, label) {
|
|
1818
|
+
const workDir = await mkdtemp(join6(tmpdir(), "reddoor-lh-deployed-"));
|
|
1819
|
+
const resolvedConfig = {
|
|
1820
|
+
ci: {
|
|
1821
|
+
// Deliberately NOT spread from lighthouseConfig.ci.collect: deployed mode
|
|
1822
|
+
// must omit startServerCommand and the dev-server settings entirely.
|
|
1823
|
+
collect: {
|
|
1824
|
+
url: [deployedUrl],
|
|
1825
|
+
// 3 runs to damp Lighthouse's run-to-run variance; parseLhciResults
|
|
1826
|
+
// averages the lhr files. (Median is a tracked future refinement.)
|
|
1827
|
+
numberOfRuns: 3,
|
|
1828
|
+
settings: { preset: "desktop", skipAudits: ["uses-http2"] }
|
|
1829
|
+
},
|
|
1830
|
+
assert: lighthouseConfig.ci.assert,
|
|
1831
|
+
upload: { target: "filesystem", outputDir: join6(workDir, "lhci-report") }
|
|
1832
|
+
}
|
|
1664
1833
|
};
|
|
1834
|
+
const configPath = join6(workDir, "lighthouserc.json");
|
|
1835
|
+
await writeFile(configPath, JSON.stringify(resolvedConfig), "utf-8");
|
|
1836
|
+
const resultsDir = join6(workDir, ".lighthouseci");
|
|
1837
|
+
let raw;
|
|
1838
|
+
try {
|
|
1839
|
+
raw = await spawn2("npx", ["--yes", "@lhci/cli", "autorun", `--config=${configPath}`], {
|
|
1840
|
+
cwd: workDir,
|
|
1841
|
+
// 3 serial cold runs of a slow deployed site (lhci's own maxWaitForLoad
|
|
1842
|
+
// ~45-60s each) + first-use Chrome download can plausibly exceed 3 min →
|
|
1843
|
+
// SIGTERM → no lhr-*.json → spurious "no scores". Match the 5-min budget
|
|
1844
|
+
// the checkout path already gives (erp-industrials nightly flake,
|
|
1845
|
+
// morning-brief 2026-06-10 MEDIUM-F).
|
|
1846
|
+
timeoutMs: 5 * 6e4
|
|
1847
|
+
});
|
|
1848
|
+
} catch (err) {
|
|
1849
|
+
await rm(workDir, { recursive: true, force: true });
|
|
1850
|
+
const e = err;
|
|
1851
|
+
if (e.code === "ENOENT" || /ENOENT/.test(String(err))) {
|
|
1852
|
+
return {
|
|
1853
|
+
audit: "lighthouse",
|
|
1854
|
+
site: label,
|
|
1855
|
+
status: "skip",
|
|
1856
|
+
summary: "npx/@lhci/cli not available"
|
|
1857
|
+
};
|
|
1858
|
+
}
|
|
1859
|
+
throw err;
|
|
1860
|
+
}
|
|
1861
|
+
try {
|
|
1862
|
+
return await parseLhciResults(resultsDir, label, raw);
|
|
1863
|
+
} finally {
|
|
1864
|
+
await rm(workDir, { recursive: true, force: true });
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
async function lighthouseAudit(ctx) {
|
|
1868
|
+
const spawn2 = ctx.spawn ?? defaultSpawn;
|
|
1869
|
+
const site = ctx.site;
|
|
1870
|
+
const label = siteLabel(site);
|
|
1871
|
+
return site.deployedUrl ? deployedLighthouse(spawn2, site.deployedUrl, label) : checkoutLighthouse(spawn2, site, label);
|
|
1665
1872
|
}
|
|
1666
1873
|
|
|
1667
1874
|
// src/audits/a11y.ts
|
|
1668
1875
|
import { readFile as readFile5, writeFile as writeFile2, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
|
|
1669
|
-
import { join as
|
|
1876
|
+
import { join as join7 } from "path";
|
|
1670
1877
|
|
|
1671
1878
|
// src/configs/playwright-a11y.ts
|
|
1672
1879
|
import { defineConfig, devices } from "@playwright/test";
|
|
@@ -1832,63 +2039,65 @@ async function a11yAudit(ctx) {
|
|
|
1832
2039
|
const spawn2 = ctx.spawn ?? defaultSpawn;
|
|
1833
2040
|
const site = ctx.site;
|
|
1834
2041
|
const label = siteLabel(site);
|
|
1835
|
-
const specDir = await mkdtemp2(
|
|
1836
|
-
const specPath = join6(specDir, "a11y.spec.ts");
|
|
1837
|
-
await writeFile2(specPath, buildSpec(), "utf-8");
|
|
1838
|
-
const port = await findFreePort();
|
|
1839
|
-
const configPath = join6(specDir, "playwright.config.ts");
|
|
1840
|
-
await writeFile2(configPath, buildPlaywrightConfig(port, site.path), "utf-8");
|
|
1841
|
-
const resultsPath = join6(site.path, RESULTS_REL);
|
|
1842
|
-
await rm2(join6(site.path, ".reddoor-a11y"), { recursive: true, force: true });
|
|
1843
|
-
let raw;
|
|
2042
|
+
const specDir = await mkdtemp2(join7(site.path, ".reddoor-a11y-spec-"));
|
|
1844
2043
|
try {
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
2044
|
+
const specPath = join7(specDir, "a11y.spec.ts");
|
|
2045
|
+
await writeFile2(specPath, buildSpec(), "utf-8");
|
|
2046
|
+
const port = await findFreePort();
|
|
2047
|
+
const configPath = join7(specDir, "playwright.config.ts");
|
|
2048
|
+
await writeFile2(configPath, buildPlaywrightConfig(port, site.path), "utf-8");
|
|
2049
|
+
const resultsPath = join7(site.path, RESULTS_REL);
|
|
2050
|
+
await rm2(join7(site.path, ".reddoor-a11y"), { recursive: true, force: true });
|
|
2051
|
+
let raw;
|
|
2052
|
+
try {
|
|
2053
|
+
raw = await spawn2(
|
|
2054
|
+
"npx",
|
|
2055
|
+
["--yes", "playwright", "test", `--config=${configPath}`, "--reporter=line", specPath],
|
|
2056
|
+
{
|
|
2057
|
+
cwd: site.path,
|
|
2058
|
+
env: { ...process.env, REDDOOR_A11Y_OUTPUT: resultsPath },
|
|
2059
|
+
// playwright on a cold tree downloads Chrome, boots the site's dev
|
|
2060
|
+
// server, and runs axe over every configured route. The shared 30 s
|
|
2061
|
+
// default in runAudits is fine for deps/lint/security but starves
|
|
2062
|
+
// playwright (mirrors the lighthouse fix shipped earlier).
|
|
2063
|
+
timeoutMs: 5 * 6e4
|
|
2064
|
+
}
|
|
2065
|
+
);
|
|
2066
|
+
} catch (err) {
|
|
2067
|
+
const e = err;
|
|
2068
|
+
if (e.code === "ENOENT" || /ENOENT/.test(String(err))) {
|
|
2069
|
+
return {
|
|
2070
|
+
audit: "a11y",
|
|
2071
|
+
site: label,
|
|
2072
|
+
status: "skip",
|
|
2073
|
+
summary: "npx/playwright not available"
|
|
2074
|
+
};
|
|
1856
2075
|
}
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
if (e.code === "ENOENT" || /ENOENT/.test(String(err))) {
|
|
2076
|
+
throw err;
|
|
2077
|
+
}
|
|
2078
|
+
const artifact = await readJsonMaybe2(resultsPath);
|
|
2079
|
+
if (!artifact) {
|
|
1862
2080
|
return {
|
|
1863
2081
|
audit: "a11y",
|
|
1864
2082
|
site: label,
|
|
1865
|
-
status: "
|
|
1866
|
-
summary:
|
|
2083
|
+
status: "fail",
|
|
2084
|
+
summary: `a11y: no results written (exit ${raw.code})${raw.stderr ? ` \u2014 ${raw.stderr.slice(0, 200)}` : ""}`
|
|
1867
2085
|
};
|
|
1868
2086
|
}
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
if (!artifact) {
|
|
2087
|
+
const hasSerious = (artifact.byImpact.serious ?? 0) > 0 || (artifact.byImpact.critical ?? 0) > 0;
|
|
2088
|
+
const hasAny = artifact.totalViolations > 0;
|
|
2089
|
+
const status = hasSerious ? "fail" : hasAny ? "warn" : "pass";
|
|
2090
|
+
const summary = status === "pass" ? `a11y: 0 violations across ${a11yRoutes.length} routes (+${smokeRoutes.length} hydration smoke)` : `a11y: ${artifact.totalViolations} violations`;
|
|
1874
2091
|
return {
|
|
1875
2092
|
audit: "a11y",
|
|
1876
2093
|
site: label,
|
|
1877
|
-
status
|
|
1878
|
-
summary
|
|
2094
|
+
status,
|
|
2095
|
+
summary,
|
|
2096
|
+
details: artifact
|
|
1879
2097
|
};
|
|
2098
|
+
} finally {
|
|
2099
|
+
await rm2(specDir, { recursive: true, force: true });
|
|
1880
2100
|
}
|
|
1881
|
-
const hasSerious = (artifact.byImpact.serious ?? 0) > 0 || (artifact.byImpact.critical ?? 0) > 0;
|
|
1882
|
-
const hasAny = artifact.totalViolations > 0;
|
|
1883
|
-
const status = hasSerious ? "fail" : hasAny ? "warn" : "pass";
|
|
1884
|
-
const summary = status === "pass" ? `a11y: 0 violations across ${a11yRoutes.length} routes (+${smokeRoutes.length} hydration smoke)` : `a11y: ${artifact.totalViolations} violations`;
|
|
1885
|
-
return {
|
|
1886
|
-
audit: "a11y",
|
|
1887
|
-
site: label,
|
|
1888
|
-
status,
|
|
1889
|
-
summary,
|
|
1890
|
-
details: artifact
|
|
1891
|
-
};
|
|
1892
2101
|
}
|
|
1893
2102
|
|
|
1894
2103
|
// src/audits/index.ts
|
|
@@ -1961,6 +2170,8 @@ function validate(raw) {
|
|
|
1961
2170
|
const site = { path: e.path };
|
|
1962
2171
|
if (typeof e.name === "string") site.name = e.name;
|
|
1963
2172
|
if (typeof e.repoUrl === "string") site.repoUrl = e.repoUrl;
|
|
2173
|
+
if (typeof e.gitRepo === "string") site.gitRepo = e.gitRepo;
|
|
2174
|
+
if (typeof e.deployedUrl === "string") site.deployedUrl = e.deployedUrl;
|
|
1964
2175
|
if (typeof e.meta === "object" && e.meta !== null) {
|
|
1965
2176
|
site.meta = e.meta;
|
|
1966
2177
|
}
|
|
@@ -2014,8 +2225,8 @@ async function resolveSites(input) {
|
|
|
2014
2225
|
}
|
|
2015
2226
|
|
|
2016
2227
|
// src/cli/fleet/clone-if-needed.ts
|
|
2017
|
-
import { stat, readdir as readdir2, mkdir } from "fs/promises";
|
|
2018
|
-
import { isAbsolute as isAbsolute2, join as
|
|
2228
|
+
import { stat as stat2, readdir as readdir2, mkdir } from "fs/promises";
|
|
2229
|
+
import { isAbsolute as isAbsolute2, join as join8 } from "path";
|
|
2019
2230
|
function deriveNameFromRepoUrl(repoUrl) {
|
|
2020
2231
|
const slash = repoUrl.split("/").pop() ?? repoUrl;
|
|
2021
2232
|
return slash.replace(/\.git$/, "");
|
|
@@ -2040,7 +2251,7 @@ function assertSafeRepoUrl(repoUrl) {
|
|
|
2040
2251
|
}
|
|
2041
2252
|
async function isNonEmptyDir(path) {
|
|
2042
2253
|
try {
|
|
2043
|
-
const s = await
|
|
2254
|
+
const s = await stat2(path);
|
|
2044
2255
|
if (!s.isDirectory()) return false;
|
|
2045
2256
|
const entries = await readdir2(path);
|
|
2046
2257
|
return entries.length > 0;
|
|
@@ -2048,21 +2259,33 @@ async function isNonEmptyDir(path) {
|
|
|
2048
2259
|
return false;
|
|
2049
2260
|
}
|
|
2050
2261
|
}
|
|
2262
|
+
var GIT_REPO_RE = /^[\w.-]+\/[\w.-]+$/;
|
|
2263
|
+
function resolveCloneUrl(site) {
|
|
2264
|
+
if (site.repoUrl) return site.repoUrl;
|
|
2265
|
+
if (!site.gitRepo) return void 0;
|
|
2266
|
+
if (!GIT_REPO_RE.test(site.gitRepo)) {
|
|
2267
|
+
throw new Error(`unsafe gitRepo: expected "owner/repo" (got: ${JSON.stringify(site.gitRepo)})`);
|
|
2268
|
+
}
|
|
2269
|
+
return `https://github.com/${site.gitRepo}.git`;
|
|
2270
|
+
}
|
|
2051
2271
|
async function cloneIfNeeded(site, opts) {
|
|
2052
2272
|
if (await isNonEmptyDir(site.path)) return site;
|
|
2053
|
-
|
|
2054
|
-
|
|
2273
|
+
const repoUrl = resolveCloneUrl(site);
|
|
2274
|
+
if (!repoUrl) {
|
|
2275
|
+
throw new Error(
|
|
2276
|
+
`site path does not exist (${site.path}) and no repoUrl or gitRepo is set \u2014 cannot clone`
|
|
2277
|
+
);
|
|
2055
2278
|
}
|
|
2056
|
-
const name = site.name ?? deriveNameFromRepoUrl(
|
|
2279
|
+
const name = site.name ?? deriveNameFromRepoUrl(repoUrl);
|
|
2057
2280
|
assertSafeName(name);
|
|
2058
|
-
assertSafeRepoUrl(
|
|
2059
|
-
const target =
|
|
2281
|
+
assertSafeRepoUrl(repoUrl);
|
|
2282
|
+
const target = join8(opts.workdir, name);
|
|
2060
2283
|
await mkdir(opts.workdir, { recursive: true });
|
|
2061
2284
|
if (await isNonEmptyDir(target)) {
|
|
2062
2285
|
return { ...site, name, path: target };
|
|
2063
2286
|
}
|
|
2064
2287
|
const spawn2 = opts.spawn ?? defaultSpawn;
|
|
2065
|
-
const result = await spawn2("git", ["clone", "--",
|
|
2288
|
+
const result = await spawn2("git", ["clone", "--", repoUrl, target], {
|
|
2066
2289
|
cwd: opts.workdir,
|
|
2067
2290
|
timeoutMs: 5 * 6e4
|
|
2068
2291
|
});
|
|
@@ -2106,7 +2329,17 @@ function formatDuration(ms) {
|
|
|
2106
2329
|
const s = totalSeconds % 60;
|
|
2107
2330
|
return `${m}m${s.toString().padStart(2, "0")}s`;
|
|
2108
2331
|
}
|
|
2109
|
-
function
|
|
2332
|
+
function parseConcurrency(value) {
|
|
2333
|
+
if (value === void 0) return true;
|
|
2334
|
+
const n = Number(value);
|
|
2335
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
2336
|
+
throw Object.assign(new Error(`--concurrency must be a positive integer, got "${value}"`), {
|
|
2337
|
+
exitCode: 2
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
return n;
|
|
2341
|
+
}
|
|
2342
|
+
function buildAuditTasks(sites, which, results, renderer, concurrency) {
|
|
2110
2343
|
const singleSite = sites.length === 1;
|
|
2111
2344
|
if (singleSite) {
|
|
2112
2345
|
const site = sites[0];
|
|
@@ -2152,7 +2385,7 @@ function buildAuditTasks(sites, which, results, renderer) {
|
|
|
2152
2385
|
}
|
|
2153
2386
|
};
|
|
2154
2387
|
}),
|
|
2155
|
-
{ concurrent:
|
|
2388
|
+
{ concurrent: concurrency, exitOnError: false, renderer }
|
|
2156
2389
|
);
|
|
2157
2390
|
}
|
|
2158
2391
|
function formatWriteSummary(summary) {
|
|
@@ -2177,13 +2410,46 @@ ${lines.join("\n")}`;
|
|
|
2177
2410
|
function rendererFor(json) {
|
|
2178
2411
|
return json ? "silent" : "default";
|
|
2179
2412
|
}
|
|
2413
|
+
function deployedUrlNotice(which, url, cwd) {
|
|
2414
|
+
if (url === void 0) return null;
|
|
2415
|
+
const others = which.filter((n) => n !== "lighthouse");
|
|
2416
|
+
if (others.length === 0) return null;
|
|
2417
|
+
return `note: --url only affects lighthouse; ${others.join(", ")} ran against the local checkout at ${cwd}`;
|
|
2418
|
+
}
|
|
2419
|
+
function auditNeedsCheckout(site, which) {
|
|
2420
|
+
const deployedCapable = site.deployedUrl !== void 0 && which.every((n) => n === "lighthouse");
|
|
2421
|
+
return !deployedCapable;
|
|
2422
|
+
}
|
|
2423
|
+
function applyDeployedUrl(sites, url) {
|
|
2424
|
+
if (url === void 0) return sites;
|
|
2425
|
+
if (sites.length !== 1) {
|
|
2426
|
+
throw Object.assign(
|
|
2427
|
+
new Error(`--url expects exactly one site, but ${sites.length} resolved.`),
|
|
2428
|
+
{ exitCode: 2 }
|
|
2429
|
+
);
|
|
2430
|
+
}
|
|
2431
|
+
try {
|
|
2432
|
+
new URL(url);
|
|
2433
|
+
} catch {
|
|
2434
|
+
throw Object.assign(new Error(`--url is not a valid URL: ${url}`), { exitCode: 2 });
|
|
2435
|
+
}
|
|
2436
|
+
return [{ ...sites[0], deployedUrl: url }];
|
|
2437
|
+
}
|
|
2180
2438
|
async function runAuditCommand(site, opts) {
|
|
2181
2439
|
const which = parseOnly(opts.only) ?? ALL_AUDIT_NAMES;
|
|
2182
2440
|
const cwd = opts.cwd ? resolve2(opts.cwd) : process.cwd();
|
|
2183
|
-
if (opts.writeAirtable
|
|
2441
|
+
if (typeof opts.writeAirtable === "string" && opts.fleet !== void 0) {
|
|
2184
2442
|
throw Object.assign(
|
|
2185
2443
|
new Error(
|
|
2186
|
-
"--write-airtable is
|
|
2444
|
+
"--write-airtable=<slug> is single-site; with --fleet each site's slug comes from the inventory. Use --write-airtable (no slug) + --fleet."
|
|
2445
|
+
),
|
|
2446
|
+
{ exitCode: 2 }
|
|
2447
|
+
);
|
|
2448
|
+
}
|
|
2449
|
+
if (opts.url !== void 0 && opts.fleet !== void 0) {
|
|
2450
|
+
throw Object.assign(
|
|
2451
|
+
new Error(
|
|
2452
|
+
"--url is single-site only and cannot be combined with --fleet. Audit a single site instead."
|
|
2187
2453
|
),
|
|
2188
2454
|
{ exitCode: 2 }
|
|
2189
2455
|
);
|
|
@@ -2194,51 +2460,70 @@ async function runAuditCommand(site, opts) {
|
|
|
2194
2460
|
...opts.workdir !== void 0 ? { workdir: opts.workdir } : {},
|
|
2195
2461
|
cwd
|
|
2196
2462
|
});
|
|
2463
|
+
sites = applyDeployedUrl(sites, opts.url);
|
|
2197
2464
|
if (opts.fleet) {
|
|
2198
2465
|
const workdir = opts.workdir ?? `${process.env.HOME ?? ""}/.reddoor-maint/sites`;
|
|
2199
|
-
sites = await Promise.all(
|
|
2466
|
+
sites = await Promise.all(
|
|
2467
|
+
sites.map(
|
|
2468
|
+
(s) => auditNeedsCheckout(s, which) ? cloneIfNeeded(s, { workdir }) : Promise.resolve(s)
|
|
2469
|
+
)
|
|
2470
|
+
);
|
|
2200
2471
|
}
|
|
2201
2472
|
const results = [];
|
|
2202
2473
|
const renderer = rendererFor(opts.json);
|
|
2203
|
-
await buildAuditTasks(sites, which, results, renderer).run();
|
|
2474
|
+
await buildAuditTasks(sites, which, results, renderer, parseConcurrency(opts.concurrency)).run();
|
|
2204
2475
|
let output = opts.json ? JSON.stringify(results, null, 2) : formatTable(results);
|
|
2205
2476
|
if (opts.writeAirtable !== void 0) {
|
|
2206
2477
|
const { openBase: openBase2, readAirtableConfig: readAirtableConfig2 } = await Promise.resolve().then(() => (init_client(), client_exports));
|
|
2207
2478
|
const { listWebsites: listWebsites2 } = await Promise.resolve().then(() => (init_websites(), websites_exports));
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2479
|
+
if (opts.fleet !== void 0) {
|
|
2480
|
+
const { writeFleetAuditsToAirtable: writeFleetAuditsToAirtable2, formatFleetWriteSummary: formatFleetWriteSummary2 } = await Promise.resolve().then(() => (init_write_audits_to_airtable(), write_audits_to_airtable_exports));
|
|
2481
|
+
const base = openBase2(readAirtableConfig2());
|
|
2482
|
+
const websites = await listWebsites2(base);
|
|
2483
|
+
const fleetWrite = await writeFleetAuditsToAirtable2({ base, websites, results });
|
|
2484
|
+
output += `
|
|
2485
|
+
|
|
2486
|
+
${formatFleetWriteSummary2(fleetWrite)}`;
|
|
2487
|
+
} else {
|
|
2488
|
+
const { resolveSlugFromCwd: resolveSlugFromCwd2 } = await Promise.resolve().then(() => (init_lighthouse_airtable(), lighthouse_airtable_exports));
|
|
2489
|
+
const { writeAuditsToAirtable: writeAuditsToAirtable2 } = await Promise.resolve().then(() => (init_write_audits_to_airtable(), write_audits_to_airtable_exports));
|
|
2490
|
+
const slug = typeof opts.writeAirtable === "string" && opts.writeAirtable.length > 0 ? opts.writeAirtable : await resolveSlugFromCwd2(cwd);
|
|
2491
|
+
let writeSummary = null;
|
|
2492
|
+
await new Listr(
|
|
2493
|
+
[
|
|
2494
|
+
{
|
|
2495
|
+
title: `Write to Airtable[${slug}]`,
|
|
2496
|
+
task: async (_ctx, task) => {
|
|
2497
|
+
const base = openBase2(readAirtableConfig2());
|
|
2498
|
+
task.output = "loading Websites\u2026";
|
|
2499
|
+
const websites = await listWebsites2(base);
|
|
2500
|
+
task.output = "writing scores\u2026";
|
|
2501
|
+
writeSummary = await writeAuditsToAirtable2({ base, websites, slug, results });
|
|
2502
|
+
task.title = `Wrote to Websites[${writeSummary.siteName}] (${writeSummary.writes.length} audit type${writeSummary.writes.length === 1 ? "" : "s"})`;
|
|
2503
|
+
}
|
|
2223
2504
|
}
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
if (writeSummary) output += `
|
|
2505
|
+
],
|
|
2506
|
+
{ renderer }
|
|
2507
|
+
).run();
|
|
2508
|
+
if (writeSummary) output += `
|
|
2229
2509
|
|
|
2230
2510
|
${formatWriteSummary(writeSummary)}`;
|
|
2511
|
+
}
|
|
2231
2512
|
}
|
|
2513
|
+
const notice = deployedUrlNotice(which, opts.url, cwd);
|
|
2514
|
+
if (notice && !opts.json) output += `
|
|
2515
|
+
|
|
2516
|
+
${notice}`;
|
|
2232
2517
|
return { output, code: auditExitCode(results, opts.failOnViolations === true) };
|
|
2233
2518
|
}
|
|
2234
2519
|
|
|
2235
2520
|
// src/cli/commands/sync-configs.ts
|
|
2236
2521
|
import { readFile as readFile9 } from "fs/promises";
|
|
2237
|
-
import { join as
|
|
2522
|
+
import { join as join11, resolve as resolve3 } from "path";
|
|
2238
2523
|
|
|
2239
2524
|
// src/recipes/sync-configs.ts
|
|
2240
2525
|
import { readFile as readFile8, writeFile as writeFile3, mkdir as mkdir2 } from "fs/promises";
|
|
2241
|
-
import { join as
|
|
2526
|
+
import { join as join10, dirname } from "path";
|
|
2242
2527
|
|
|
2243
2528
|
// src/recipes/sync-configs/templates.ts
|
|
2244
2529
|
var eslint = {
|
|
@@ -2355,6 +2640,29 @@ var netlify = {
|
|
|
2355
2640
|
[build.environment]
|
|
2356
2641
|
NODE_VERSION = "22"
|
|
2357
2642
|
COREPACK_INTEGRITY_KEYS = "0"
|
|
2643
|
+
|
|
2644
|
+
# Baseline security headers for all responses. CSP is emitted per-response by
|
|
2645
|
+
# SvelteKit (see \`kit.csp\` in svelte.config.js) so it is intentionally omitted
|
|
2646
|
+
# here to avoid conflicting duplicates.
|
|
2647
|
+
[[headers]]
|
|
2648
|
+
for = "/*"
|
|
2649
|
+
[headers.values]
|
|
2650
|
+
Strict-Transport-Security = "max-age=63072000; includeSubDomains; preload"
|
|
2651
|
+
X-Content-Type-Options = "nosniff"
|
|
2652
|
+
X-Frame-Options = "SAMEORIGIN"
|
|
2653
|
+
Referrer-Policy = "strict-origin-when-cross-origin"
|
|
2654
|
+
Permissions-Policy = "camera=(), microphone=(), geolocation=(), interest-cohort=()"
|
|
2655
|
+
Cross-Origin-Opener-Policy = "same-origin"
|
|
2656
|
+
|
|
2657
|
+
[[headers]]
|
|
2658
|
+
for = "/favicon.png"
|
|
2659
|
+
[headers.values]
|
|
2660
|
+
Cache-Control = "public, max-age=31536000, immutable"
|
|
2661
|
+
|
|
2662
|
+
[[headers]]
|
|
2663
|
+
for = "/_app/immutable/*"
|
|
2664
|
+
[headers.values]
|
|
2665
|
+
Cache-Control = "public, max-age=31536000, immutable"
|
|
2358
2666
|
`
|
|
2359
2667
|
};
|
|
2360
2668
|
var ALL_TEMPLATES = [
|
|
@@ -2393,7 +2701,11 @@ var CANONICAL_GITIGNORE_ENTRIES = [
|
|
|
2393
2701
|
"*.log",
|
|
2394
2702
|
".vercel/",
|
|
2395
2703
|
".netlify/",
|
|
2396
|
-
".reddoor-a11y/"
|
|
2704
|
+
".reddoor-a11y/",
|
|
2705
|
+
// The a11y audit's transient spec dir, written inside the checkout and
|
|
2706
|
+
// normally cleaned, but a timeout-SIGKILL of the parent orphans it. Ignored
|
|
2707
|
+
// fleet-wide so it never dirties a self-updating repo's tree (2026-06-10 M-D).
|
|
2708
|
+
".reddoor-a11y-spec-*/"
|
|
2397
2709
|
];
|
|
2398
2710
|
function stripLeadingSlash(s) {
|
|
2399
2711
|
return s.startsWith("/") ? s.slice(1) : s;
|
|
@@ -2576,9 +2888,14 @@ async function withRecipe(body) {
|
|
|
2576
2888
|
// src/recipes/sync-configs.ts
|
|
2577
2889
|
var GITIGNORE_CONFIG = "gitignore";
|
|
2578
2890
|
var SVELTE_CONFIG = "svelte";
|
|
2891
|
+
var NETLIFY_CONFIG = "netlify";
|
|
2579
2892
|
function isSvelteConfigCompliant(contents) {
|
|
2580
2893
|
return contents.includes("createSvelteConfig") && contents.includes("@sveltejs/adapter-netlify");
|
|
2581
2894
|
}
|
|
2895
|
+
var SECURITY_HEADER_RE = /Strict-Transport-Security|Content-Security-Policy|X-Frame-Options|X-Content-Type-Options|Referrer-Policy|Permissions-Policy|Cross-Origin-Opener-Policy/i;
|
|
2896
|
+
function isNetlifyConfigCompliant(contents) {
|
|
2897
|
+
return contents.includes("[[headers]]") && SECURITY_HEADER_RE.test(contents);
|
|
2898
|
+
}
|
|
2582
2899
|
var ALL_CONFIG_NAMES = [
|
|
2583
2900
|
"lighthouse",
|
|
2584
2901
|
"eslint",
|
|
@@ -2605,17 +2922,20 @@ async function readMaybe(path) {
|
|
|
2605
2922
|
async function planTemplateDiffs(cwd, templates) {
|
|
2606
2923
|
const diffs = [];
|
|
2607
2924
|
for (const t of templates) {
|
|
2608
|
-
const existing = await readMaybe(
|
|
2925
|
+
const existing = await readMaybe(join10(cwd, t.path));
|
|
2609
2926
|
if (existing === t.contents) continue;
|
|
2610
2927
|
if (t.config === SVELTE_CONFIG && existing !== null && isSvelteConfigCompliant(existing)) {
|
|
2611
2928
|
continue;
|
|
2612
2929
|
}
|
|
2930
|
+
if (t.config === NETLIFY_CONFIG && existing !== null && isNetlifyConfigCompliant(existing)) {
|
|
2931
|
+
continue;
|
|
2932
|
+
}
|
|
2613
2933
|
diffs.push(t);
|
|
2614
2934
|
}
|
|
2615
2935
|
return diffs;
|
|
2616
2936
|
}
|
|
2617
2937
|
async function planGitignore(cwd) {
|
|
2618
|
-
const existing = await readMaybe(
|
|
2938
|
+
const existing = await readMaybe(join10(cwd, ".gitignore"));
|
|
2619
2939
|
const merge = mergeGitignore(existing, CANONICAL_GITIGNORE_ENTRIES);
|
|
2620
2940
|
const tracked = await listTrackedFiles(cwd);
|
|
2621
2941
|
const toUntrack = findTrackedArtifacts(tracked, CANONICAL_GITIGNORE_ENTRIES);
|
|
@@ -2623,7 +2943,7 @@ async function planGitignore(cwd) {
|
|
|
2623
2943
|
return { kind: "apply", content: merge.content, toUntrack, added: merge.added };
|
|
2624
2944
|
}
|
|
2625
2945
|
async function applyGitignore(cwd, plan) {
|
|
2626
|
-
await writeFile3(
|
|
2946
|
+
await writeFile3(join10(cwd, ".gitignore"), plan.content, "utf-8");
|
|
2627
2947
|
if (plan.toUntrack.length > 0) {
|
|
2628
2948
|
await removeFromIndex(cwd, plan.toUntrack);
|
|
2629
2949
|
}
|
|
@@ -2646,7 +2966,7 @@ async function syncConfigs(site, opts = {}) {
|
|
|
2646
2966
|
},
|
|
2647
2967
|
apply: async ({ templateDiffs, gitignorePlan }, { commit: commit2 }) => {
|
|
2648
2968
|
for (const t of templateDiffs) {
|
|
2649
|
-
const dest =
|
|
2969
|
+
const dest = join10(site.path, t.path);
|
|
2650
2970
|
await mkdir2(dirname(dest), { recursive: true });
|
|
2651
2971
|
await writeFile3(dest, t.contents, "utf-8");
|
|
2652
2972
|
await commit2(`chore: sync ${t.config} config from @reddoorla/maintenance`);
|
|
@@ -2677,7 +2997,7 @@ function parseOnly2(value) {
|
|
|
2677
2997
|
async function dryPlanGitignore(cwd) {
|
|
2678
2998
|
let existing;
|
|
2679
2999
|
try {
|
|
2680
|
-
existing = await readFile9(
|
|
3000
|
+
existing = await readFile9(join11(cwd, ".gitignore"), "utf-8");
|
|
2681
3001
|
} catch {
|
|
2682
3002
|
return "would create .gitignore";
|
|
2683
3003
|
}
|
|
@@ -2692,7 +3012,7 @@ async function dryPlan(cwd, which) {
|
|
|
2692
3012
|
for (const t of templateTargets) {
|
|
2693
3013
|
let existing = "";
|
|
2694
3014
|
try {
|
|
2695
|
-
existing = await readFile9(
|
|
3015
|
+
existing = await readFile9(join11(cwd, t.path), "utf-8");
|
|
2696
3016
|
} catch {
|
|
2697
3017
|
}
|
|
2698
3018
|
if (existing !== t.contents) lines.push(`would update ${t.path} (config: ${t.config})`);
|
|
@@ -2739,11 +3059,11 @@ async function runSyncConfigsCommand(site, opts) {
|
|
|
2739
3059
|
import { resolve as resolve4 } from "path";
|
|
2740
3060
|
|
|
2741
3061
|
// src/recipes/bump-deps.ts
|
|
2742
|
-
import { stat as
|
|
2743
|
-
import { join as
|
|
2744
|
-
async function
|
|
3062
|
+
import { stat as stat3 } from "fs/promises";
|
|
3063
|
+
import { join as join12 } from "path";
|
|
3064
|
+
async function exists2(path) {
|
|
2745
3065
|
try {
|
|
2746
|
-
await
|
|
3066
|
+
await stat3(path);
|
|
2747
3067
|
return true;
|
|
2748
3068
|
} catch {
|
|
2749
3069
|
return false;
|
|
@@ -2769,10 +3089,10 @@ async function bumpDeps(site, opts = {}) {
|
|
|
2769
3089
|
// land on top of whatever else was in the tree.
|
|
2770
3090
|
checkTreeFirst: true,
|
|
2771
3091
|
plan: async () => {
|
|
2772
|
-
const hasPnpmLock = await
|
|
3092
|
+
const hasPnpmLock = await exists2(join12(site.path, "pnpm-lock.yaml"));
|
|
2773
3093
|
if (!hasPnpmLock) {
|
|
2774
|
-
const hasNpmLock = await
|
|
2775
|
-
const hasYarnLock = await
|
|
3094
|
+
const hasNpmLock = await exists2(join12(site.path, "package-lock.json"));
|
|
3095
|
+
const hasYarnLock = await exists2(join12(site.path, "yarn.lock"));
|
|
2776
3096
|
if (hasNpmLock || hasYarnLock) {
|
|
2777
3097
|
const competing = hasNpmLock ? "package-lock.json" : "yarn.lock";
|
|
2778
3098
|
return {
|
|
@@ -2843,7 +3163,7 @@ import { resolve as resolve5 } from "path";
|
|
|
2843
3163
|
|
|
2844
3164
|
// src/recipes/self-updating/index.ts
|
|
2845
3165
|
import { mkdir as mkdir3, writeFile as writeFile4 } from "fs/promises";
|
|
2846
|
-
import { dirname as dirname2, join as
|
|
3166
|
+
import { dirname as dirname2, join as join13 } from "path";
|
|
2847
3167
|
|
|
2848
3168
|
// src/github/config.ts
|
|
2849
3169
|
function readGitHubConfig() {
|
|
@@ -2854,6 +3174,20 @@ function readGitHubConfig() {
|
|
|
2854
3174
|
}
|
|
2855
3175
|
|
|
2856
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
|
+
}
|
|
2857
3191
|
function makeGitHub(deps) {
|
|
2858
3192
|
const spawn2 = deps.spawn ?? defaultSpawn;
|
|
2859
3193
|
const env = { ...process.env, GH_TOKEN: deps.token };
|
|
@@ -2960,6 +3294,32 @@ function makeGitHub(deps) {
|
|
|
2960
3294
|
]);
|
|
2961
3295
|
const first = out.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
|
|
2962
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}"`);
|
|
3302
|
+
}
|
|
3303
|
+
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}}}}}}}}";
|
|
3304
|
+
const out = await gh([
|
|
3305
|
+
"api",
|
|
3306
|
+
"graphql",
|
|
3307
|
+
"-f",
|
|
3308
|
+
`query=${query}`,
|
|
3309
|
+
"-F",
|
|
3310
|
+
`owner=${owner}`,
|
|
3311
|
+
"-F",
|
|
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
|
+
}));
|
|
2963
3323
|
}
|
|
2964
3324
|
};
|
|
2965
3325
|
}
|
|
@@ -3010,7 +3370,7 @@ async function selfUpdating(site, deps = {}) {
|
|
|
3010
3370
|
const branch = branchName("self-updating");
|
|
3011
3371
|
await createBranch(site.path, branch);
|
|
3012
3372
|
for (const t of templates) {
|
|
3013
|
-
const dest =
|
|
3373
|
+
const dest = join13(site.path, t.path);
|
|
3014
3374
|
await mkdir3(dirname2(dest), { recursive: true });
|
|
3015
3375
|
await writeFile4(dest, t.contents, "utf-8");
|
|
3016
3376
|
}
|
|
@@ -3085,7 +3445,7 @@ async function runSelfUpdatingCommand(site, opts) {
|
|
|
3085
3445
|
import { resolve as resolve6 } from "path";
|
|
3086
3446
|
|
|
3087
3447
|
// src/recipes/svelte-5/index.ts
|
|
3088
|
-
import { join as
|
|
3448
|
+
import { join as join19 } from "path";
|
|
3089
3449
|
|
|
3090
3450
|
// src/util/pkg.ts
|
|
3091
3451
|
import { readFile as readFile10, writeFile as writeFile5 } from "fs/promises";
|
|
@@ -3134,7 +3494,7 @@ function bumpDep(pkg, name, version2, opts = {}) {
|
|
|
3134
3494
|
}
|
|
3135
3495
|
|
|
3136
3496
|
// src/recipes/svelte-5/step-bump-versions.ts
|
|
3137
|
-
import { join as
|
|
3497
|
+
import { join as join14 } from "path";
|
|
3138
3498
|
var SVELTE_5_VERSIONS = {
|
|
3139
3499
|
svelte: "^5.55.5",
|
|
3140
3500
|
"@sveltejs/kit": "^2.59.0",
|
|
@@ -3147,7 +3507,7 @@ var SVELTE_5_VERSIONS = {
|
|
|
3147
3507
|
"typescript-svelte-plugin": "^0.3.52"
|
|
3148
3508
|
};
|
|
3149
3509
|
async function bumpToSvelte5Versions(cwd) {
|
|
3150
|
-
const pkgPath =
|
|
3510
|
+
const pkgPath = join14(cwd, "package.json");
|
|
3151
3511
|
const pkg = await readPackageJson(pkgPath);
|
|
3152
3512
|
let next = pkg;
|
|
3153
3513
|
for (const [name, version2] of Object.entries(SVELTE_5_VERSIONS)) {
|
|
@@ -3160,7 +3520,7 @@ async function bumpToSvelte5Versions(cwd) {
|
|
|
3160
3520
|
|
|
3161
3521
|
// src/recipes/svelte-5/step-svelte-config.ts
|
|
3162
3522
|
import { readFile as readFile11, writeFile as writeFile6 } from "fs/promises";
|
|
3163
|
-
import { join as
|
|
3523
|
+
import { join as join15 } from "path";
|
|
3164
3524
|
var VITE_PLUGIN_PKG = "@sveltejs/vite-plugin-svelte";
|
|
3165
3525
|
var IMPORT_FROM_VITE_PLUGIN = new RegExp(
|
|
3166
3526
|
String.raw`^import\s+\{\s*([^}]+?)\s*\}\s+from\s+["']` + VITE_PLUGIN_PKG.replace(/[/]/g, "\\/") + String.raw`["'];?[ \t]*\n`,
|
|
@@ -3201,7 +3561,7 @@ function dropPreprocessKey(source) {
|
|
|
3201
3561
|
return source.slice(0, m.index) + source.slice(tailIdx).replace(new RegExp(`^${indent}\\n`), "");
|
|
3202
3562
|
}
|
|
3203
3563
|
async function migrateSvelteConfig(cwd) {
|
|
3204
|
-
const path =
|
|
3564
|
+
const path = join15(cwd, "svelte.config.js");
|
|
3205
3565
|
let src;
|
|
3206
3566
|
try {
|
|
3207
3567
|
src = await readFile11(path, "utf-8");
|
|
@@ -3238,9 +3598,9 @@ async function runSvelteMigrate(cwd, spawn2 = defaultSpawn) {
|
|
|
3238
3598
|
}
|
|
3239
3599
|
|
|
3240
3600
|
// src/recipes/svelte-5/step-tailwind-upgrade.ts
|
|
3241
|
-
import { join as
|
|
3601
|
+
import { join as join16 } from "path";
|
|
3242
3602
|
async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
|
|
3243
|
-
const pkg = await readPackageJson(
|
|
3603
|
+
const pkg = await readPackageJson(join16(cwd, "package.json"));
|
|
3244
3604
|
const tailwindVersion = pkg.devDependencies?.tailwindcss ?? pkg.dependencies?.tailwindcss;
|
|
3245
3605
|
if (!tailwindVersion) return { ran: false, reason: "tailwindcss not installed" };
|
|
3246
3606
|
if (/^\^?4\./.test(tailwindVersion)) return { ran: false, reason: "already on tailwind 4.x" };
|
|
@@ -3262,7 +3622,7 @@ async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
|
|
|
3262
3622
|
|
|
3263
3623
|
// src/recipes/svelte-5/step-gotchas.ts
|
|
3264
3624
|
import { readFile as readFile12, writeFile as writeFile7 } from "fs/promises";
|
|
3265
|
-
import { join as
|
|
3625
|
+
import { join as join17 } from "path";
|
|
3266
3626
|
import { glob as glob2 } from "tinyglobby";
|
|
3267
3627
|
|
|
3268
3628
|
// src/recipes/svelte-5/codemods/on-event-to-handler.ts
|
|
@@ -3608,7 +3968,7 @@ async function planGotchaCodemods(cwd) {
|
|
|
3608
3968
|
const changes = [];
|
|
3609
3969
|
const relPaths = await glob2(SVELTE_GLOBS, { cwd, ignore: IGNORE2, absolute: false });
|
|
3610
3970
|
for (const rel of relPaths) {
|
|
3611
|
-
const path =
|
|
3971
|
+
const path = join17(cwd, rel);
|
|
3612
3972
|
const before = await readFile12(path, "utf-8");
|
|
3613
3973
|
const after = CODEMODS.reduce((s, fn) => fn(s), before);
|
|
3614
3974
|
if (after !== before) changes.push({ rel, after });
|
|
@@ -3618,7 +3978,7 @@ async function planGotchaCodemods(cwd) {
|
|
|
3618
3978
|
async function applyGotchaCodemods(cwd) {
|
|
3619
3979
|
const changes = await planGotchaCodemods(cwd);
|
|
3620
3980
|
for (const c of changes) {
|
|
3621
|
-
await writeFile7(
|
|
3981
|
+
await writeFile7(join17(cwd, c.rel), c.after, "utf-8");
|
|
3622
3982
|
}
|
|
3623
3983
|
return { filesChanged: changes.length };
|
|
3624
3984
|
}
|
|
@@ -3642,7 +4002,7 @@ async function verifyMigration(cwd, spawn2 = defaultSpawn) {
|
|
|
3642
4002
|
|
|
3643
4003
|
// src/recipes/svelte-5/step-summary.ts
|
|
3644
4004
|
import { writeFile as writeFile8 } from "fs/promises";
|
|
3645
|
-
import { join as
|
|
4005
|
+
import { join as join18 } from "path";
|
|
3646
4006
|
async function writeMigrationSummary(input) {
|
|
3647
4007
|
const lines = [
|
|
3648
4008
|
`# Svelte 4 \u2192 5 migration summary`,
|
|
@@ -3659,7 +4019,7 @@ async function writeMigrationSummary(input) {
|
|
|
3659
4019
|
`- Verify Playwright a11y tests still pass.`
|
|
3660
4020
|
];
|
|
3661
4021
|
const content = lines.join("\n") + "\n";
|
|
3662
|
-
const path =
|
|
4022
|
+
const path = join18(input.cwd, "MIGRATION_SVELTE_5.md");
|
|
3663
4023
|
await writeFile8(path, content, "utf-8");
|
|
3664
4024
|
return path;
|
|
3665
4025
|
}
|
|
@@ -3667,7 +4027,7 @@ async function writeMigrationSummary(input) {
|
|
|
3667
4027
|
// src/recipes/svelte-5/index.ts
|
|
3668
4028
|
async function alreadyOnSvelte5(cwd) {
|
|
3669
4029
|
try {
|
|
3670
|
-
const pkg = await readPackageJson(
|
|
4030
|
+
const pkg = await readPackageJson(join19(cwd, "package.json"));
|
|
3671
4031
|
const v = pkg.devDependencies?.svelte ?? pkg.dependencies?.svelte;
|
|
3672
4032
|
return !!v && /^\^?5\./.test(v);
|
|
3673
4033
|
} catch {
|
|
@@ -3761,8 +4121,8 @@ async function runUpgradeCommand(upgradeName, site, opts = {}) {
|
|
|
3761
4121
|
import { resolve as resolve7 } from "path";
|
|
3762
4122
|
|
|
3763
4123
|
// src/recipes/convert-to-pnpm.ts
|
|
3764
|
-
import { rm as rm3, stat as
|
|
3765
|
-
import { join as
|
|
4124
|
+
import { rm as rm3, stat as stat4 } from "fs/promises";
|
|
4125
|
+
import { join as join20 } from "path";
|
|
3766
4126
|
|
|
3767
4127
|
// src/recipes/convert-to-pnpm/script-rewrites.ts
|
|
3768
4128
|
function rewriteScriptForPnpm(script) {
|
|
@@ -3784,9 +4144,9 @@ function rewriteScriptsForPnpm(scripts) {
|
|
|
3784
4144
|
|
|
3785
4145
|
// src/recipes/convert-to-pnpm.ts
|
|
3786
4146
|
var DEFAULT_PNPM_VERSION = "10.33.1";
|
|
3787
|
-
async function
|
|
4147
|
+
async function exists3(path) {
|
|
3788
4148
|
try {
|
|
3789
|
-
await
|
|
4149
|
+
await stat4(path);
|
|
3790
4150
|
return true;
|
|
3791
4151
|
} catch {
|
|
3792
4152
|
return false;
|
|
@@ -3795,18 +4155,18 @@ async function exists2(path) {
|
|
|
3795
4155
|
async function convertToPnpm(site, opts = {}) {
|
|
3796
4156
|
const spawn2 = opts.spawn ?? defaultSpawn;
|
|
3797
4157
|
const pnpmVersion = opts.pnpmVersion ?? DEFAULT_PNPM_VERSION;
|
|
3798
|
-
const pnpmLockPath =
|
|
3799
|
-
const npmLockPath =
|
|
3800
|
-
const yarnLockPath =
|
|
4158
|
+
const pnpmLockPath = join20(site.path, "pnpm-lock.yaml");
|
|
4159
|
+
const npmLockPath = join20(site.path, "package-lock.json");
|
|
4160
|
+
const yarnLockPath = join20(site.path, "yarn.lock");
|
|
3801
4161
|
return withRecipe({
|
|
3802
4162
|
name: "convert-to-pnpm",
|
|
3803
4163
|
site,
|
|
3804
4164
|
plan: async () => {
|
|
3805
|
-
if (await
|
|
4165
|
+
if (await exists3(pnpmLockPath)) {
|
|
3806
4166
|
return { kind: "noop", notes: "site already has pnpm-lock.yaml" };
|
|
3807
4167
|
}
|
|
3808
|
-
const hasNpmLock = await
|
|
3809
|
-
const hasYarnLock = await
|
|
4168
|
+
const hasNpmLock = await exists3(npmLockPath);
|
|
4169
|
+
const hasYarnLock = await exists3(yarnLockPath);
|
|
3810
4170
|
if (!hasNpmLock && !hasYarnLock) {
|
|
3811
4171
|
return {
|
|
3812
4172
|
kind: "noop",
|
|
@@ -3820,7 +4180,7 @@ async function convertToPnpm(site, opts = {}) {
|
|
|
3820
4180
|
if (hasYarnLock) await rm3(yarnLockPath, { force: true });
|
|
3821
4181
|
const sourceLock = hasNpmLock ? "package-lock.json" : "yarn.lock";
|
|
3822
4182
|
await commit2(`chore(pnpm): remove ${sourceLock}`);
|
|
3823
|
-
const pkgPath =
|
|
4183
|
+
const pkgPath = join20(cwd, "package.json");
|
|
3824
4184
|
const pkg = await readPackageJson(pkgPath);
|
|
3825
4185
|
const next = { ...pkg, packageManager: `pnpm@${pnpmVersion}` };
|
|
3826
4186
|
if (pkg.scripts && typeof pkg.scripts === "object") {
|
|
@@ -3833,7 +4193,7 @@ async function convertToPnpm(site, opts = {}) {
|
|
|
3833
4193
|
}
|
|
3834
4194
|
await writePackageJson(pkgPath, next);
|
|
3835
4195
|
await commit2("chore(pnpm): pin packageManager + rewrite npm scripts");
|
|
3836
|
-
await rm3(
|
|
4196
|
+
await rm3(join20(cwd, "node_modules"), { recursive: true, force: true });
|
|
3837
4197
|
const installResult = await spawn2("pnpm", ["install"], { cwd, streaming: true });
|
|
3838
4198
|
if (installResult.code !== 0) {
|
|
3839
4199
|
return { kind: "failed", notes: `pnpm install failed (exit ${installResult.code})` };
|
|
@@ -3873,18 +4233,18 @@ async function runConvertToPnpmCommand(site, opts) {
|
|
|
3873
4233
|
import { resolve as resolve8 } from "path";
|
|
3874
4234
|
|
|
3875
4235
|
// src/recipes/onboard.ts
|
|
3876
|
-
import { stat as
|
|
3877
|
-
import { join as
|
|
4236
|
+
import { stat as stat5 } from "fs/promises";
|
|
4237
|
+
import { join as join22 } from "path";
|
|
3878
4238
|
|
|
3879
4239
|
// src/util/self-version.ts
|
|
3880
4240
|
import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
|
|
3881
4241
|
import { fileURLToPath } from "url";
|
|
3882
|
-
import { dirname as dirname3, join as
|
|
4242
|
+
import { dirname as dirname3, join as join21 } from "path";
|
|
3883
4243
|
function selfPackageVersion(callerImportMetaUrl) {
|
|
3884
4244
|
try {
|
|
3885
4245
|
let dir = dirname3(fileURLToPath(callerImportMetaUrl));
|
|
3886
4246
|
while (true) {
|
|
3887
|
-
const candidate =
|
|
4247
|
+
const candidate = join21(dir, "package.json");
|
|
3888
4248
|
if (existsSync2(candidate)) {
|
|
3889
4249
|
const raw = readFileSync2(candidate, "utf-8");
|
|
3890
4250
|
const pkg = JSON.parse(raw);
|
|
@@ -3936,9 +4296,9 @@ var AUDIT_DEPS = Object.fromEntries(
|
|
|
3936
4296
|
})
|
|
3937
4297
|
])
|
|
3938
4298
|
);
|
|
3939
|
-
async function
|
|
4299
|
+
async function exists4(path) {
|
|
3940
4300
|
try {
|
|
3941
|
-
await
|
|
4301
|
+
await stat5(path);
|
|
3942
4302
|
return true;
|
|
3943
4303
|
} catch {
|
|
3944
4304
|
return false;
|
|
@@ -3955,13 +4315,13 @@ async function onboard(site, opts = {}) {
|
|
|
3955
4315
|
name: "onboard",
|
|
3956
4316
|
site,
|
|
3957
4317
|
plan: async () => {
|
|
3958
|
-
if (!await
|
|
4318
|
+
if (!await exists4(join22(site.path, "pnpm-lock.yaml"))) {
|
|
3959
4319
|
return {
|
|
3960
4320
|
kind: "failed",
|
|
3961
4321
|
notes: "no pnpm-lock.yaml at site root \u2014 run convert-to-pnpm first"
|
|
3962
4322
|
};
|
|
3963
4323
|
}
|
|
3964
|
-
const pkgPath =
|
|
4324
|
+
const pkgPath = join22(site.path, "package.json");
|
|
3965
4325
|
const pkg = await readPackageJson(pkgPath);
|
|
3966
4326
|
const toAdd = [];
|
|
3967
4327
|
if (!isDeclared(pkg, PACKAGE_NAME)) {
|
|
@@ -3984,7 +4344,7 @@ async function onboard(site, opts = {}) {
|
|
|
3984
4344
|
return { kind: "apply", plan: { pkg, toAdd } };
|
|
3985
4345
|
},
|
|
3986
4346
|
apply: async ({ pkg, toAdd }, { commit: commit2, cwd }) => {
|
|
3987
|
-
const pkgPath =
|
|
4347
|
+
const pkgPath = join22(cwd, "package.json");
|
|
3988
4348
|
let next = pkg;
|
|
3989
4349
|
for (const dep of toAdd) {
|
|
3990
4350
|
next = bumpDep(next, dep.name, dep.version);
|
|
@@ -4053,7 +4413,7 @@ import { resolve as resolve9 } from "path";
|
|
|
4053
4413
|
|
|
4054
4414
|
// src/recipes/svelte-codemods.ts
|
|
4055
4415
|
import { writeFile as writeFile9 } from "fs/promises";
|
|
4056
|
-
import { join as
|
|
4416
|
+
import { join as join23 } from "path";
|
|
4057
4417
|
async function svelteCodemods(site) {
|
|
4058
4418
|
return withRecipe({
|
|
4059
4419
|
name: "svelte-codemods",
|
|
@@ -4067,7 +4427,7 @@ async function svelteCodemods(site) {
|
|
|
4067
4427
|
},
|
|
4068
4428
|
apply: async (changes, { commit: commit2, cwd }) => {
|
|
4069
4429
|
for (const c of changes) {
|
|
4070
|
-
await writeFile9(
|
|
4430
|
+
await writeFile9(join23(cwd, c.rel), c.after, "utf-8");
|
|
4071
4431
|
}
|
|
4072
4432
|
await commit2(`refactor(svelte5): apply codemods (${changes.length} files)`);
|
|
4073
4433
|
return { kind: "ok" };
|
|
@@ -4171,11 +4531,11 @@ import { dirname as dirname6 } from "path";
|
|
|
4171
4531
|
|
|
4172
4532
|
// src/reports/ga/config.ts
|
|
4173
4533
|
init_credentials();
|
|
4174
|
-
import { dirname as dirname5, join as
|
|
4534
|
+
import { dirname as dirname5, join as join25 } from "path";
|
|
4175
4535
|
function readGaConfig() {
|
|
4176
4536
|
const subject = process.env.GA_SUBJECT?.trim();
|
|
4177
4537
|
if (!subject) return null;
|
|
4178
|
-
const keyPath = process.env.GA_SA_KEY_PATH?.trim() ||
|
|
4538
|
+
const keyPath = process.env.GA_SA_KEY_PATH?.trim() || join25(dirname5(defaultCredentialsPath()), "ga-service-account.json");
|
|
4179
4539
|
return { subject, keyPath };
|
|
4180
4540
|
}
|
|
4181
4541
|
|
|
@@ -4275,7 +4635,7 @@ async function fetchSearchPresence(q, periodStart, periodEnd) {
|
|
|
4275
4635
|
});
|
|
4276
4636
|
const pos = res.data.rows?.[0]?.position;
|
|
4277
4637
|
if (typeof pos === "number") {
|
|
4278
|
-
return { foundOnPage1: pos <= PAGE_1_MAX_POSITION, position: Math.round(pos) };
|
|
4638
|
+
return { foundOnPage1: pos <= PAGE_1_MAX_POSITION, position: Math.max(1, Math.round(pos)) };
|
|
4279
4639
|
}
|
|
4280
4640
|
}
|
|
4281
4641
|
return { foundOnPage1: false, position: null };
|
|
@@ -4304,8 +4664,14 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
|
|
|
4304
4664
|
const periodEnd = today;
|
|
4305
4665
|
const completedOn = today;
|
|
4306
4666
|
const lastTestedDate = reportType === "Maintenance" && siteRow.testingDay ? new Date(siteRow.testingDay) : null;
|
|
4307
|
-
const
|
|
4308
|
-
const
|
|
4667
|
+
const gaResult = base !== null ? await fetchGaUsers(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
|
|
4668
|
+
const searchResult = base !== null ? await fetchSearch(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
|
|
4669
|
+
const gaUsers = gaResult.value;
|
|
4670
|
+
const search = searchResult.value;
|
|
4671
|
+
const softFailures = [
|
|
4672
|
+
...gaResult.softFailed ? ["ga"] : [],
|
|
4673
|
+
...searchResult.softFailed ? ["search"] : []
|
|
4674
|
+
];
|
|
4309
4675
|
const cidName = `${slug}-header`;
|
|
4310
4676
|
const { html } = await renderReportHtml({
|
|
4311
4677
|
siteName: siteRow.name,
|
|
@@ -4324,7 +4690,7 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
|
|
|
4324
4690
|
const path = options.previewPath ?? `reports/${slug}/draft.html`;
|
|
4325
4691
|
await mkdir4(dirname6(path), { recursive: true });
|
|
4326
4692
|
await writeFile10(path, html, "utf-8");
|
|
4327
|
-
return { reportRow: null, htmlPath: path, html };
|
|
4693
|
+
return { reportRow: null, htmlPath: path, html, softFailures };
|
|
4328
4694
|
}
|
|
4329
4695
|
if (base === null) throw new Error("base required when previewOnly=false");
|
|
4330
4696
|
const reportId = `${siteRow.name} \u2014 ${reportType} \u2014 ${periodEnd.toISOString().slice(0, 10)}`;
|
|
@@ -4344,27 +4710,29 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
|
|
|
4344
4710
|
const htmlFilename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;
|
|
4345
4711
|
await uploadAttachment(created.id, "Rendered HTML", html, htmlFilename, "text/html");
|
|
4346
4712
|
await setDraftReady(base, created.id, true);
|
|
4347
|
-
return { reportRow: created, htmlPath: null, html };
|
|
4713
|
+
return { reportRow: created, htmlPath: null, html, softFailures };
|
|
4348
4714
|
}
|
|
4715
|
+
var NO_ENRICHMENT = { value: null, softFailed: false };
|
|
4349
4716
|
async function fetchGaUsers(siteRow, periodStart, periodEnd) {
|
|
4350
4717
|
const cfg = readGaConfig();
|
|
4351
|
-
if (!cfg || !siteRow.ga4PropertyId) return
|
|
4718
|
+
if (!cfg || !siteRow.ga4PropertyId) return NO_ENRICHMENT;
|
|
4352
4719
|
try {
|
|
4353
|
-
|
|
4720
|
+
const value = await fetchPeriodUsers(
|
|
4354
4721
|
{ propertyId: siteRow.ga4PropertyId, subject: cfg.subject, keyPath: cfg.keyPath },
|
|
4355
4722
|
periodStart,
|
|
4356
4723
|
periodEnd
|
|
4357
4724
|
);
|
|
4725
|
+
return { value, softFailed: false };
|
|
4358
4726
|
} catch (e) {
|
|
4359
4727
|
console.warn(`\u26A0 GA skipped for ${siteRow.name}: ${e.message}`);
|
|
4360
|
-
return null;
|
|
4728
|
+
return { value: null, softFailed: true };
|
|
4361
4729
|
}
|
|
4362
4730
|
}
|
|
4363
4731
|
async function fetchSearch(siteRow, periodStart, periodEnd) {
|
|
4364
4732
|
const cfg = readGaConfig();
|
|
4365
|
-
if (!cfg || !siteRow.searchQuery) return
|
|
4733
|
+
if (!cfg || !siteRow.searchQuery) return NO_ENRICHMENT;
|
|
4366
4734
|
try {
|
|
4367
|
-
|
|
4735
|
+
const value = await fetchSearchPresence(
|
|
4368
4736
|
{
|
|
4369
4737
|
keyPath: cfg.keyPath,
|
|
4370
4738
|
subject: cfg.subject,
|
|
@@ -4375,16 +4743,20 @@ async function fetchSearch(siteRow, periodStart, periodEnd) {
|
|
|
4375
4743
|
periodStart,
|
|
4376
4744
|
periodEnd
|
|
4377
4745
|
);
|
|
4746
|
+
return { value, softFailed: false };
|
|
4378
4747
|
} catch (e) {
|
|
4379
4748
|
console.warn(`\u26A0 Search presence skipped for ${siteRow.name}: ${e.message}`);
|
|
4380
|
-
return null;
|
|
4749
|
+
return { value: null, softFailed: true };
|
|
4381
4750
|
}
|
|
4382
4751
|
}
|
|
4383
4752
|
async function derivePeriodStart(base, siteRow, reportType, today) {
|
|
4384
4753
|
const prior = await listReportsForSite(base, siteRow.id);
|
|
4385
4754
|
const sameType = prior.filter((r) => r.reportType === reportType && r.periodEnd).map((r) => r.periodEnd).sort();
|
|
4386
4755
|
const latest = sameType[sameType.length - 1];
|
|
4387
|
-
|
|
4756
|
+
if (!latest) return daysAgo(today, 30);
|
|
4757
|
+
const start = new Date(latest);
|
|
4758
|
+
start.setUTCDate(start.getUTCDate() + 1);
|
|
4759
|
+
return start;
|
|
4388
4760
|
}
|
|
4389
4761
|
|
|
4390
4762
|
// src/cli/commands/report.ts
|
|
@@ -4417,14 +4789,21 @@ async function runDueDraft() {
|
|
|
4417
4789
|
const due = findDueReports(websites, reports, /* @__PURE__ */ new Date());
|
|
4418
4790
|
if (due.length === 0) return { output: "No reports due.", code: 0 };
|
|
4419
4791
|
const lines = [];
|
|
4792
|
+
let softFailedSites = 0;
|
|
4420
4793
|
for (const item of due) {
|
|
4421
4794
|
try {
|
|
4422
4795
|
const result = await draftReportForSite(base, item.site, item.reportType);
|
|
4423
4796
|
lines.push(`\u2713 drafted: ${result.reportRow?.reportId}`);
|
|
4797
|
+
if (result.softFailures.length > 0) softFailedSites++;
|
|
4424
4798
|
} catch (e) {
|
|
4425
4799
|
lines.push(`\u2717 failed: ${item.site.name} ${item.reportType} \u2014 ${e.message}`);
|
|
4426
4800
|
}
|
|
4427
4801
|
}
|
|
4802
|
+
if (softFailedSites > 0) {
|
|
4803
|
+
lines.push(
|
|
4804
|
+
`\u26A0 ${softFailedSites} site${softFailedSites === 1 ? "" : "s"} had GA/Search enrichment fail \u2014 drafted with blank analytics; check the logs above`
|
|
4805
|
+
);
|
|
4806
|
+
}
|
|
4428
4807
|
return { output: lines.join("\n"), code: lines.some((l) => l.startsWith("\u2717")) ? 1 : 0 };
|
|
4429
4808
|
}
|
|
4430
4809
|
async function runSingleSiteDraft(slug, opts) {
|
|
@@ -4448,7 +4827,7 @@ import { resolve as resolve10 } from "path";
|
|
|
4448
4827
|
|
|
4449
4828
|
// src/recipes/a11y-fixtures-page/index.ts
|
|
4450
4829
|
import { access, mkdir as mkdir5, writeFile as writeFile11 } from "fs/promises";
|
|
4451
|
-
import { dirname as dirname7, join as
|
|
4830
|
+
import { dirname as dirname7, join as join26 } from "path";
|
|
4452
4831
|
|
|
4453
4832
|
// src/recipes/a11y-fixtures-page/template.ts
|
|
4454
4833
|
var A11Y_FIXTURES_PAGE_RELATIVE = "src/routes/dev/a11y-fixtures/+page.svelte";
|
|
@@ -4499,7 +4878,7 @@ async function fileExists(path) {
|
|
|
4499
4878
|
}
|
|
4500
4879
|
}
|
|
4501
4880
|
async function a11yFixturesPage(site) {
|
|
4502
|
-
const target =
|
|
4881
|
+
const target = join26(site.path, A11Y_FIXTURES_PAGE_RELATIVE);
|
|
4503
4882
|
return withRecipe({
|
|
4504
4883
|
name: "a11y-fixtures-page",
|
|
4505
4884
|
site,
|
|
@@ -4607,12 +4986,12 @@ async function runInitCommand(site, opts) {
|
|
|
4607
4986
|
|
|
4608
4987
|
// src/cli/version.ts
|
|
4609
4988
|
import { readFileSync as readFileSync5, existsSync as existsSync4 } from "fs";
|
|
4610
|
-
import { dirname as dirname8, join as
|
|
4989
|
+
import { dirname as dirname8, join as join27 } from "path";
|
|
4611
4990
|
function resolvePackageVersion(fromDir) {
|
|
4612
4991
|
try {
|
|
4613
4992
|
let dir = fromDir;
|
|
4614
4993
|
while (true) {
|
|
4615
|
-
const candidate =
|
|
4994
|
+
const candidate = join27(dir, "package.json");
|
|
4616
4995
|
if (existsSync4(candidate)) {
|
|
4617
4996
|
const raw = readFileSync5(candidate, "utf-8");
|
|
4618
4997
|
const pkg = JSON.parse(raw);
|
|
@@ -4681,7 +5060,13 @@ cli.command("audit [site]", "Run audits against a site (default: cwd).").option(
|
|
|
4681
5060
|
).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").option(
|
|
4682
5061
|
"--write-airtable [slug]",
|
|
4683
5062
|
"After lighthouse runs, write pScore/rScore/bpScore/seoScore + timestamp to the matching Websites row. Slug defaults to cwd's package.json#name."
|
|
4684
|
-
).option("--fail-on-violations", "Exit non-zero if any a11y violations are found (for CI gates)").
|
|
5063
|
+
).option("--fail-on-violations", "Exit non-zero if any a11y violations are found (for CI gates)").option(
|
|
5064
|
+
"--url <url>",
|
|
5065
|
+
"Audit this deployed URL with lighthouse (no dev server); single-site. Pair with --only lighthouse \u2014 other audits still use the local checkout."
|
|
5066
|
+
).option(
|
|
5067
|
+
"--concurrency <n>",
|
|
5068
|
+
"Max sites to audit in parallel in --fleet mode (default: all at once). Use 1 for sequential (CI)."
|
|
5069
|
+
).action(
|
|
4685
5070
|
async (site, opts) => runOrExit(() => runAuditCommand(site, opts), opts)
|
|
4686
5071
|
);
|
|
4687
5072
|
cli.command("sync-configs [site]", "Sync canonical configs into a site.").option("--only <names>", "Comma-separated config names (e.g. eslint,prettier)").option("--dry", "Print diff without writing").option(
|