@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 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
- const all = await listWebsites(base);
159
- return all.find((w) => siteSlug(w.name) === slug) ?? null;
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.maintenanceFreq !== "None" || w.testingFreq !== "None").map((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 join8 } from "path";
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 = join8(cwd, "package.json");
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
- return Array.isArray(result.details);
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 entries = result.details ?? [];
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
- return { drifted, majorBehind };
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
- writeAuditsToAirtable: () => writeAuditsToAirtable
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 scores = lighthouseScoresFromResult(lhResult);
378
- await updateScores(base, target.id, scores);
379
- writes.push({ audit: "lighthouse", counts: scores });
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 join23 } from "path";
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 = join23(dir, "src", "reports", "maintenance-email", "assets", "check.png");
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 = join23(dir, "dist", "reports", "maintenance-email", "assets", "check.png");
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(join23(assetsDir, "check.png")),
556
- readFile13(join23(assetsDir, "blurredTests.jpg"))
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()).getFullYear()} Reddoor Creative, LLC. All rights reserved.</mj-text>
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(`Report ${report.reportId} has no Lighthouse scores`);
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
- inlineContentId: cidName
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
- inlineContentId: bundled.check.cid
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
- inlineContentId: bundled.blurred.cid
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 defaultSpawn = (cmd, args, opts = {}) => new Promise((resolve11, reject) => {
1114
- const streaming = opts.streaming === true;
1115
- const child = spawn(cmd, [...args], {
1116
- cwd: opts.cwd,
1117
- env: opts.env ?? process.env,
1118
- stdio: streaming ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "pipe"]
1119
- });
1120
- let stdout = "";
1121
- let stderr = "";
1122
- if (!streaming) {
1123
- child.stdout?.on("data", (chunk) => stdout += String(chunk));
1124
- child.stderr?.on("data", (chunk) => stderr += String(chunk));
1125
- }
1126
- const timer = opts.timeoutMs ? setTimeout(() => {
1127
- child.kill("SIGTERM");
1128
- reject(new Error(`spawn timeout after ${opts.timeoutMs}ms: ${cmd}`));
1129
- }, opts.timeoutMs) : void 0;
1130
- child.on("error", (err) => {
1131
- if (timer) clearTimeout(timer);
1132
- reject(err);
1133
- });
1134
- child.on("close", (code) => {
1135
- if (timer) clearTimeout(timer);
1136
- resolve11({ code: code ?? -1, stdout, stderr });
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 join2 } from "path";
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 = join2(ctx.site.path, "package.json");
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
- const pkg = JSON.parse(pkgRaw);
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 details = [];
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
- details.push({
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 = details.some((d) => d.drift === "major");
1239
- const anyMinor = details.some((d) => d.drift === "minor");
1240
- const anyNewer = details.some((d) => d.drift === "newer");
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 summary = status === "pass" ? `all ${details.length} tracked deps in line with baseline` : status === "warn" ? `${details.filter((d) => d.drift !== "same").length} of ${details.length} tracked deps drifted` : `${details.filter((d) => d.drift === "major").length} deps lagging by a major version`;
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 join3 } from "path";
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 = join3(site.path, "eslint.config.js");
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 = join3(site.path, rel);
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 join5 } from "path";
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 join4 } from "path";
1646
+ import { join as join5 } from "path";
1486
1647
  async function readSiteConfig(sitePath) {
1487
1648
  let raw;
1488
1649
  try {
1489
- raw = await readFile3(join4(sitePath, "package.json"), "utf-8");
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(join5(resultsDir, f));
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 lighthouseAudit(ctx) {
1587
- const spawn2 = ctx.spawn ?? defaultSpawn;
1588
- const site = ctx.site;
1589
- const label = siteLabel(site);
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(join5(tmpdir(), "reddoor-lhci-"));
1605
- const configPath = join5(configDir, "lighthouserc.json");
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 = join5(site.path, ".lighthouseci");
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
- const manifest = await readLhrEntries(resultsDir);
1634
- if (manifest.length === 0) {
1635
- return {
1636
- audit: "lighthouse",
1637
- site: label,
1638
- status: "fail",
1639
- summary: `lighthouse: no lhr-*.json written (exit ${raw.code})${raw.stderr ? ` \u2014 ${raw.stderr.slice(0, 200)}` : ""}`
1640
- };
1641
- }
1642
- const assertionResults = await readJsonMaybe(join5(resultsDir, "assertion-results.json")) ?? [];
1643
- const failed = assertionResults.filter((a) => !a.passed);
1644
- const assertions = failed.map((a) => ({
1645
- category: categoryFromAssertion(a),
1646
- level: a.level,
1647
- message: messageForAssertion(a)
1648
- }));
1649
- const anyError = assertions.some((a) => a.level === "error");
1650
- const anyWarn = assertions.some((a) => a.level === "warn");
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 join6 } from "path";
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(join6(site.path, ".reddoor-a11y-spec-"));
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
- raw = await spawn2(
1846
- "npx",
1847
- ["--yes", "playwright", "test", `--config=${configPath}`, "--reporter=line", specPath],
1848
- {
1849
- cwd: site.path,
1850
- env: { ...process.env, REDDOOR_A11Y_OUTPUT: resultsPath },
1851
- // playwright on a cold tree downloads Chrome, boots the site's dev
1852
- // server, and runs axe over every configured route. The shared 30 s
1853
- // default in runAudits is fine for deps/lint/security but starves
1854
- // playwright (mirrors the lighthouse fix shipped earlier).
1855
- timeoutMs: 5 * 6e4
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
- } catch (err) {
1859
- await rm2(specDir, { recursive: true, force: true });
1860
- const e = err;
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: "skip",
1866
- summary: "npx/playwright not available"
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
- throw err;
1870
- }
1871
- await rm2(specDir, { recursive: true, force: true });
1872
- const artifact = await readJsonMaybe2(resultsPath);
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: "fail",
1878
- summary: `a11y: no results written (exit ${raw.code})${raw.stderr ? ` \u2014 ${raw.stderr.slice(0, 200)}` : ""}`
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 join7 } from "path";
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 stat(path);
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
- if (!site.repoUrl) {
2054
- throw new Error(`site path does not exist (${site.path}) and no repoUrl is set \u2014 cannot clone`);
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(site.repoUrl);
2279
+ const name = site.name ?? deriveNameFromRepoUrl(repoUrl);
2057
2280
  assertSafeName(name);
2058
- assertSafeRepoUrl(site.repoUrl);
2059
- const target = join7(opts.workdir, name);
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", "--", site.repoUrl, target], {
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 buildAuditTasks(sites, which, results, renderer) {
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: true, exitOnError: false, renderer }
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 !== void 0 && opts.fleet !== void 0) {
2441
+ if (typeof opts.writeAirtable === "string" && opts.fleet !== void 0) {
2184
2442
  throw Object.assign(
2185
2443
  new Error(
2186
- "--write-airtable is not supported with --fleet. Each site has its own Airtable row; run per-site instead: `cd <site>/ && reddoor-maint audit --write-airtable`."
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(sites.map((s) => cloneIfNeeded(s, { workdir })));
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
- const { resolveSlugFromCwd: resolveSlugFromCwd2 } = await Promise.resolve().then(() => (init_lighthouse_airtable(), lighthouse_airtable_exports));
2209
- const { writeAuditsToAirtable: writeAuditsToAirtable2 } = await Promise.resolve().then(() => (init_write_audits_to_airtable(), write_audits_to_airtable_exports));
2210
- const slug = typeof opts.writeAirtable === "string" && opts.writeAirtable.length > 0 ? opts.writeAirtable : await resolveSlugFromCwd2(cwd);
2211
- let writeSummary = null;
2212
- await new Listr(
2213
- [
2214
- {
2215
- title: `Write to Airtable[${slug}]`,
2216
- task: async (_ctx, task) => {
2217
- const base = openBase2(readAirtableConfig2());
2218
- task.output = "loading Websites\u2026";
2219
- const websites = await listWebsites2(base);
2220
- task.output = "writing scores\u2026";
2221
- writeSummary = await writeAuditsToAirtable2({ base, websites, slug, results });
2222
- task.title = `Wrote to Websites[${writeSummary.siteName}] (${writeSummary.writes.length} audit type${writeSummary.writes.length === 1 ? "" : "s"})`;
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
- { renderer }
2227
- ).run();
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 join10, resolve as resolve3 } from "path";
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 join9, dirname } from "path";
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(join9(cwd, t.path));
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(join9(cwd, ".gitignore"));
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(join9(cwd, ".gitignore"), plan.content, "utf-8");
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 = join9(site.path, t.path);
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(join10(cwd, ".gitignore"), "utf-8");
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(join10(cwd, t.path), "utf-8");
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 stat2 } from "fs/promises";
2743
- import { join as join11 } from "path";
2744
- async function exists(path) {
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 stat2(path);
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 exists(join11(site.path, "pnpm-lock.yaml"));
3092
+ const hasPnpmLock = await exists2(join12(site.path, "pnpm-lock.yaml"));
2773
3093
  if (!hasPnpmLock) {
2774
- const hasNpmLock = await exists(join11(site.path, "package-lock.json"));
2775
- const hasYarnLock = await exists(join11(site.path, "yarn.lock"));
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 join12 } from "path";
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 = join12(site.path, t.path);
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 join18 } from "path";
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 join13 } from "path";
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 = join13(cwd, "package.json");
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 join14 } from "path";
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 = join14(cwd, "svelte.config.js");
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 join15 } from "path";
3601
+ import { join as join16 } from "path";
3242
3602
  async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
3243
- const pkg = await readPackageJson(join15(cwd, "package.json"));
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 join16 } from "path";
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 = join16(cwd, rel);
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(join16(cwd, c.rel), c.after, "utf-8");
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 join17 } from "path";
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 = join17(input.cwd, "MIGRATION_SVELTE_5.md");
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(join18(cwd, "package.json"));
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 stat3 } from "fs/promises";
3765
- import { join as join19 } from "path";
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 exists2(path) {
4147
+ async function exists3(path) {
3788
4148
  try {
3789
- await stat3(path);
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 = join19(site.path, "pnpm-lock.yaml");
3799
- const npmLockPath = join19(site.path, "package-lock.json");
3800
- const yarnLockPath = join19(site.path, "yarn.lock");
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 exists2(pnpmLockPath)) {
4165
+ if (await exists3(pnpmLockPath)) {
3806
4166
  return { kind: "noop", notes: "site already has pnpm-lock.yaml" };
3807
4167
  }
3808
- const hasNpmLock = await exists2(npmLockPath);
3809
- const hasYarnLock = await exists2(yarnLockPath);
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 = join19(cwd, "package.json");
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(join19(cwd, "node_modules"), { recursive: true, force: true });
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 stat4 } from "fs/promises";
3877
- import { join as join21 } from "path";
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 join20 } from "path";
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 = join20(dir, "package.json");
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 exists3(path) {
4299
+ async function exists4(path) {
3940
4300
  try {
3941
- await stat4(path);
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 exists3(join21(site.path, "pnpm-lock.yaml"))) {
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 = join21(site.path, "package.json");
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 = join21(cwd, "package.json");
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 join22 } from "path";
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(join22(cwd, c.rel), c.after, "utf-8");
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 join24 } from "path";
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() || join24(dirname5(defaultCredentialsPath()), "ga-service-account.json");
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 gaUsers = base !== null ? await fetchGaUsers(siteRow, periodStart, periodEnd) : null;
4308
- const search = base !== null ? await fetchSearch(siteRow, periodStart, periodEnd) : null;
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 null;
4718
+ if (!cfg || !siteRow.ga4PropertyId) return NO_ENRICHMENT;
4352
4719
  try {
4353
- return await fetchPeriodUsers(
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 null;
4733
+ if (!cfg || !siteRow.searchQuery) return NO_ENRICHMENT;
4366
4734
  try {
4367
- return await fetchSearchPresence(
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
- return latest ? new Date(latest) : daysAgo(today, 30);
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 join25 } from "path";
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 = join25(site.path, A11Y_FIXTURES_PAGE_RELATIVE);
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 join26 } from "path";
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 = join26(dir, "package.json");
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)").action(
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(