@reddoorla/maintenance 0.29.0 → 0.30.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,
@@ -179,6 +180,9 @@ async function updateDepsCounts(base, recordId, counts) {
179
180
  "Deps Drifted": counts.drifted,
180
181
  "Deps Major Behind": counts.majorBehind
181
182
  };
183
+ if (counts.outdated !== null) {
184
+ fields["Deps Outdated"] = counts.outdated;
185
+ }
182
186
  await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
183
187
  }
184
188
  async function updateSecurityCounts(base, recordId, counts) {
@@ -212,23 +216,25 @@ function fromAirtableBase(base, opts = {}) {
212
216
  );
213
217
  }
214
218
  const websites = await listWebsites(base);
215
- return websites.filter((w) => w.maintenanceFreq !== "None" || w.testingFreq !== "None").map((w) => {
219
+ return websites.filter((w) => AUDITABLE_STATUSES.has(w.status ?? "") && w.url.length > 0).map((w) => {
216
220
  const slug = siteSlug(w.name);
217
221
  const site = {
218
222
  path: `${workdir}/${slug}`,
219
223
  name: slug,
224
+ deployedUrl: w.url,
220
225
  meta: { airtableRowId: w.id, displayName: w.name }
221
226
  };
222
- if (w.url) site.repoUrl = w.url;
223
227
  if (w.gitRepo) site.gitRepo = w.gitRepo;
224
228
  return site;
225
229
  });
226
230
  };
227
231
  }
232
+ var AUDITABLE_STATUSES;
228
233
  var init_airtable = __esm({
229
234
  "src/inventory/airtable.ts"() {
230
235
  "use strict";
231
236
  init_websites();
237
+ AUDITABLE_STATUSES = /* @__PURE__ */ new Set(["maintenance", "launch period"]);
232
238
  }
233
239
  });
234
240
 
@@ -240,7 +246,7 @@ __export(lighthouse_airtable_exports, {
240
246
  resolveSlugFromCwd: () => resolveSlugFromCwd
241
247
  });
242
248
  import { readFile as readFile7 } from "fs/promises";
243
- import { join as join8 } from "path";
249
+ import { join as join9 } from "path";
244
250
  function hasRealScores(result) {
245
251
  if (result.audit !== "lighthouse") return false;
246
252
  const details = result.details ?? {};
@@ -265,7 +271,7 @@ function lighthouseScoresFromResult(result) {
265
271
  }
266
272
  async function resolveSlugFromCwd(cwd) {
267
273
  try {
268
- const pkgPath = join8(cwd, "package.json");
274
+ const pkgPath = join9(cwd, "package.json");
269
275
  const raw = await readFile7(pkgPath, "utf-8");
270
276
  const pkg = JSON.parse(raw);
271
277
  if (!pkg.name) throw new Error("package.json has no 'name' field");
@@ -308,16 +314,19 @@ var init_a11y_airtable = __esm({
308
314
  // src/audits/deps-airtable.ts
309
315
  function hasDepsCounts(result) {
310
316
  if (result.audit !== "deps") return false;
311
- return Array.isArray(result.details);
317
+ const d = result.details;
318
+ return Array.isArray(d?.entries);
312
319
  }
313
320
  function depsCountsFromResult(result) {
314
321
  if (result.audit !== "deps") {
315
322
  throw new Error(`Expected a 'deps' AuditResult, got '${result.audit}'`);
316
323
  }
317
- const entries = result.details ?? [];
324
+ const details = result.details ?? {};
325
+ const entries = details.entries ?? [];
318
326
  const drifted = entries.filter((e) => e.drift !== "same").length;
319
327
  const majorBehind = entries.filter((e) => e.drift === "major").length;
320
- return { drifted, majorBehind };
328
+ const outdated = details.outdated?.outdated ?? null;
329
+ return { drifted, majorBehind, outdated };
321
330
  }
322
331
  var init_deps_airtable = __esm({
323
332
  "src/audits/deps-airtable.ts"() {
@@ -348,7 +357,9 @@ var init_security_airtable = __esm({
348
357
  // src/audits/write-audits-to-airtable.ts
349
358
  var write_audits_to_airtable_exports = {};
350
359
  __export(write_audits_to_airtable_exports, {
351
- writeAuditsToAirtable: () => writeAuditsToAirtable
360
+ formatFleetWriteSummary: () => formatFleetWriteSummary,
361
+ writeAuditsToAirtable: () => writeAuditsToAirtable,
362
+ writeFleetAuditsToAirtable: () => writeFleetAuditsToAirtable
352
363
  });
353
364
  async function writeAuditsToAirtable(args) {
354
365
  const { base, websites, slug, results } = args;
@@ -397,6 +408,38 @@ async function writeAuditsToAirtable(args) {
397
408
  }
398
409
  return { siteName: target.name, writes };
399
410
  }
411
+ function formatFleetWriteSummary(result) {
412
+ const wrote = result.written.length;
413
+ const failed = result.failed.length;
414
+ const total = wrote + failed;
415
+ let out = `\u2192 wrote ${wrote} site(s) to Airtable`;
416
+ if (failed > 0) {
417
+ out += `
418
+ \u26A0 ${failed} site(s) not written: ${result.failed.map((f) => `${f.slug} (${f.error})`).join("; ")}`;
419
+ }
420
+ out += `
421
+ FLEET_WRITE_SUMMARY wrote=${wrote} failed=${failed} total=${total}`;
422
+ return out;
423
+ }
424
+ async function writeFleetAuditsToAirtable(args) {
425
+ const { base, websites, results } = args;
426
+ const bySlug = /* @__PURE__ */ new Map();
427
+ for (const r of results) {
428
+ const arr = bySlug.get(r.site) ?? [];
429
+ arr.push(r);
430
+ bySlug.set(r.site, arr);
431
+ }
432
+ const written = [];
433
+ const failed = [];
434
+ for (const [slug, siteResults] of bySlug) {
435
+ try {
436
+ written.push(await writeAuditsToAirtable({ base, websites, slug, results: siteResults }));
437
+ } catch (e) {
438
+ failed.push({ slug, error: e.message });
439
+ }
440
+ }
441
+ return { written, failed };
442
+ }
400
443
  var init_write_audits_to_airtable = __esm({
401
444
  "src/audits/write-audits-to-airtable.ts"() {
402
445
  "use strict";
@@ -524,18 +567,18 @@ var init_reports = __esm({
524
567
  // src/reports/maintenance-email/assets/index.ts
525
568
  import { readFile as readFile13 } from "fs/promises";
526
569
  import { existsSync as existsSync3 } from "fs";
527
- import { dirname as dirname4, join as join23 } from "path";
570
+ import { dirname as dirname4, join as join24 } from "path";
528
571
  import { fileURLToPath as fileURLToPath2 } from "url";
529
572
  function resolveAssetsDir() {
530
573
  if (cachedAssetsDir) return cachedAssetsDir;
531
574
  let dir = dirname4(fileURLToPath2(import.meta.url));
532
575
  while (true) {
533
- const srcCandidate = join23(dir, "src", "reports", "maintenance-email", "assets", "check.png");
576
+ const srcCandidate = join24(dir, "src", "reports", "maintenance-email", "assets", "check.png");
534
577
  if (existsSync3(srcCandidate)) {
535
578
  cachedAssetsDir = dirname4(srcCandidate);
536
579
  return cachedAssetsDir;
537
580
  }
538
- const distCandidate = join23(dir, "dist", "reports", "maintenance-email", "assets", "check.png");
581
+ const distCandidate = join24(dir, "dist", "reports", "maintenance-email", "assets", "check.png");
539
582
  if (existsSync3(distCandidate)) {
540
583
  cachedAssetsDir = dirname4(distCandidate);
541
584
  return cachedAssetsDir;
@@ -552,8 +595,8 @@ function resolveAssetsDir() {
552
595
  async function loadBundledImages() {
553
596
  const assetsDir = resolveAssetsDir();
554
597
  const [check, blurred] = await Promise.all([
555
- readFile13(join23(assetsDir, "check.png")),
556
- readFile13(join23(assetsDir, "blurredTests.jpg"))
598
+ readFile13(join24(assetsDir, "check.png")),
599
+ readFile13(join24(assetsDir, "blurredTests.jpg"))
557
600
  ]);
558
601
  return {
559
602
  check: {
@@ -769,7 +812,7 @@ function buildMjml(data) {
769
812
  <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">Just hit reply.</mj-text>
770
813
  <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
814
  <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>
815
+ <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
816
  <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
817
  <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
818
  <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 +967,14 @@ __export(orchestrate_exports, {
924
967
  function monthYear(d) {
925
968
  return `${MONTHS2[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
926
969
  }
970
+ function toInlineAttachment(a) {
971
+ return {
972
+ filename: a.filename,
973
+ content: Buffer.from(a.bytes).toString("base64"),
974
+ contentType: a.contentType,
975
+ inlineContentId: a.cid
976
+ };
977
+ }
927
978
  async function sendApprovedReports(options = {}) {
928
979
  const base = openBase(readAirtableConfig());
929
980
  const client = options.resend ?? defaultResendClient();
@@ -955,7 +1006,9 @@ async function sendOne(client, base, site, report) {
955
1006
  throw new Error(`Site '${site.name}' has no Header image set on the Websites row`);
956
1007
  }
957
1008
  if (!report.lighthouse) {
958
- throw new Error(`Report ${report.reportId} has no Lighthouse scores`);
1009
+ throw new Error(
1010
+ `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`
1011
+ );
959
1012
  }
960
1013
  const original = await fetchAttachmentBytes(site.headerImage.url);
961
1014
  const header = await prepareHeaderImage(original.bytes);
@@ -991,7 +1044,7 @@ async function sendOne(client, base, site, report) {
991
1044
  for (const addr of to) {
992
1045
  if (!isProbablyEmail(addr)) {
993
1046
  throw new Error(
994
- `Site '${site.name}' recipient is malformed: ${addr} \u2014 fix Report recipients (To) or point of contact in Airtable`
1047
+ `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
1048
  );
996
1049
  }
997
1050
  }
@@ -1012,27 +1065,27 @@ async function sendOne(client, base, site, report) {
1012
1065
  subject,
1013
1066
  html,
1014
1067
  attachments: [
1015
- {
1068
+ toInlineAttachment({
1069
+ bytes: header.bytes,
1016
1070
  filename: `${cidName}.jpg`,
1017
- content: Buffer.from(header.bytes).toString("base64"),
1018
1071
  contentType: header.contentType,
1019
- inlineContentId: cidName
1020
- },
1072
+ cid: cidName
1073
+ }),
1021
1074
  // Bundled images referenced via cid:rd-check-png / cid:rd-blurred-tests-jpg
1022
1075
  // in the template. Attached inline so the email is self-contained — no
1023
1076
  // external CDN dependency, no image-blocked broken icons in webmail.
1024
- {
1077
+ toInlineAttachment({
1078
+ bytes: bundled.check.bytes,
1025
1079
  filename: bundled.check.filename,
1026
- content: Buffer.from(bundled.check.bytes).toString("base64"),
1027
1080
  contentType: bundled.check.contentType,
1028
- inlineContentId: bundled.check.cid
1029
- },
1030
- {
1081
+ cid: bundled.check.cid
1082
+ }),
1083
+ toInlineAttachment({
1084
+ bytes: bundled.blurred.bytes,
1031
1085
  filename: bundled.blurred.filename,
1032
- content: Buffer.from(bundled.blurred.bytes).toString("base64"),
1033
1086
  contentType: bundled.blurred.contentType,
1034
- inlineContentId: bundled.blurred.cid
1035
- }
1087
+ cid: bundled.blurred.cid
1088
+ })
1036
1089
  ],
1037
1090
  // Stable across retries of the same row — if Airtable stamping fails after a
1038
1091
  // successful Resend, the next --send-ready replays with the same key and
@@ -1110,36 +1163,74 @@ import { Listr } from "listr2";
1110
1163
 
1111
1164
  // src/audits/util/spawn.ts
1112
1165
  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 });
1166
+ var TRUNCATION_MARKER = "\n\u2026[output truncated]";
1167
+ function makeSpawn(internals = {}) {
1168
+ const spawnImpl = internals.spawnImpl ?? spawn;
1169
+ const killImpl = internals.killImpl ?? ((pid, sig) => process.kill(pid, sig));
1170
+ const killGraceMs = internals.killGraceMs ?? 5e3;
1171
+ const maxOutputBytes = internals.maxOutputBytes ?? 10 * 1024 * 1024;
1172
+ return (cmd, args, opts = {}) => new Promise((resolve11, reject) => {
1173
+ const streaming = opts.streaming === true;
1174
+ const child = spawnImpl(cmd, [...args], {
1175
+ cwd: opts.cwd,
1176
+ env: opts.env ?? process.env,
1177
+ stdio: streaming ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "pipe"],
1178
+ // Detach ONLY when a timeout can fire: the child then leads its own
1179
+ // process group, so the timeout can kill the WHOLE tree (vite, and
1180
+ // Chromium under lhci/playwright) via process.kill(-pid), not just the
1181
+ // npx/pnpm wrapper. Without it, killing the wrapper orphaned the
1182
+ // grandchildren — a zombie vite squatting its port, Chrome left running.
1183
+ // We do NOT detach timeout-less streaming calls (pnpm install/up):
1184
+ // detaching gains nothing there (no timeout → no group-kill) and would
1185
+ // break terminal Ctrl-C, which only reaches the foreground group — i.e.
1186
+ // it would re-orphan the very children this guards. We never unref() the
1187
+ // child since we still await it.
1188
+ detached: opts.timeoutMs !== void 0
1189
+ });
1190
+ const cap = (acc, chunk) => {
1191
+ if (acc.length >= maxOutputBytes) return acc;
1192
+ const next = acc + chunk;
1193
+ return next.length > maxOutputBytes ? next.slice(0, maxOutputBytes) + TRUNCATION_MARKER : next;
1194
+ };
1195
+ let stdout = "";
1196
+ let stderr = "";
1197
+ if (!streaming) {
1198
+ child.stdout?.on("data", (chunk) => stdout = cap(stdout, String(chunk)));
1199
+ child.stderr?.on("data", (chunk) => stderr = cap(stderr, String(chunk)));
1200
+ }
1201
+ const killGroup = (sig) => {
1202
+ if (child.pid === void 0) return;
1203
+ try {
1204
+ killImpl(-child.pid, sig);
1205
+ } catch {
1206
+ }
1207
+ };
1208
+ let killTimer;
1209
+ const timer = opts.timeoutMs ? setTimeout(() => {
1210
+ killGroup("SIGTERM");
1211
+ killTimer = setTimeout(() => killGroup("SIGKILL"), killGraceMs);
1212
+ killTimer.unref();
1213
+ reject(new Error(`spawn timeout after ${opts.timeoutMs}ms: ${cmd}`));
1214
+ }, opts.timeoutMs) : void 0;
1215
+ const clearTimers = () => {
1216
+ if (timer) clearTimeout(timer);
1217
+ if (killTimer) clearTimeout(killTimer);
1218
+ };
1219
+ child.on("error", (err) => {
1220
+ clearTimers();
1221
+ reject(err);
1222
+ });
1223
+ child.on("close", (code) => {
1224
+ clearTimers();
1225
+ resolve11({ code: code ?? -1, stdout, stderr });
1226
+ });
1137
1227
  });
1138
- });
1228
+ }
1229
+ var defaultSpawn = makeSpawn();
1139
1230
 
1140
1231
  // src/audits/deps.ts
1141
1232
  import { readFile } from "fs/promises";
1142
- import { join as join2 } from "path";
1233
+ import { join as join3 } from "path";
1143
1234
 
1144
1235
  // src/util/site.ts
1145
1236
  function siteLabel(site) {
@@ -1185,6 +1276,47 @@ var baselineVersions = {
1185
1276
  "@zerodevx/svelte-img": "^2.1.2"
1186
1277
  };
1187
1278
 
1279
+ // src/audits/deps-outdated.ts
1280
+ import { stat } from "fs/promises";
1281
+ import { join as join2 } from "path";
1282
+ async function exists(path) {
1283
+ try {
1284
+ await stat(path);
1285
+ return true;
1286
+ } catch {
1287
+ return false;
1288
+ }
1289
+ }
1290
+ function majorOf(version2) {
1291
+ const head = version2.replace(/^[\^~]/, "").split(".")[0] ?? "0";
1292
+ const n = Number.parseInt(head, 10);
1293
+ return Number.isNaN(n) ? 0 : n;
1294
+ }
1295
+ async function scanOutdated(sitePath, spawn2) {
1296
+ if (!await exists(join2(sitePath, "pnpm-lock.yaml"))) return null;
1297
+ try {
1298
+ if (!await exists(join2(sitePath, "node_modules"))) {
1299
+ const install = await spawn2("pnpm", ["install", "--frozen-lockfile"], {
1300
+ cwd: sitePath,
1301
+ timeoutMs: 18e4
1302
+ });
1303
+ if (install.code !== 0) return null;
1304
+ }
1305
+ const res = await spawn2("pnpm", ["outdated", "--json"], {
1306
+ cwd: sitePath,
1307
+ timeoutMs: 6e4
1308
+ });
1309
+ const parsed = JSON.parse(res.stdout || "{}");
1310
+ const entries = Object.values(parsed);
1311
+ return {
1312
+ outdated: entries.length,
1313
+ major: entries.filter((e) => e.current && e.latest && majorOf(e.latest) > majorOf(e.current)).length
1314
+ };
1315
+ } catch {
1316
+ return null;
1317
+ }
1318
+ }
1319
+
1188
1320
  // src/audits/deps.ts
1189
1321
  function stripCaret(range) {
1190
1322
  return range.replace(/^[\^~]/, "");
@@ -1206,7 +1338,7 @@ function compareSemver(actual, baseline) {
1206
1338
  return "same";
1207
1339
  }
1208
1340
  async function depsAudit(ctx) {
1209
- const pkgPath = join2(ctx.site.path, "package.json");
1341
+ const pkgPath = join3(ctx.site.path, "package.json");
1210
1342
  let pkgRaw;
1211
1343
  try {
1212
1344
  pkgRaw = await readFile(pkgPath, "utf-8");
@@ -1224,35 +1356,37 @@ async function depsAudit(ctx) {
1224
1356
  ...pkg.dependencies ?? {},
1225
1357
  ...pkg.devDependencies ?? {}
1226
1358
  };
1227
- const details = [];
1359
+ const entries = [];
1228
1360
  for (const [name, baseline] of Object.entries(baselineVersions)) {
1229
1361
  const actual = installed[name];
1230
1362
  if (!actual) continue;
1231
- details.push({
1363
+ entries.push({
1232
1364
  pkg: name,
1233
1365
  baseline,
1234
1366
  actual,
1235
1367
  drift: compareSemver(actual, baseline)
1236
1368
  });
1237
1369
  }
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");
1370
+ const anyMajor = entries.some((d) => d.drift === "major");
1371
+ const anyMinor = entries.some((d) => d.drift === "minor");
1372
+ const anyNewer = entries.some((d) => d.drift === "newer");
1241
1373
  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`;
1374
+ 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`;
1375
+ const outdated = await scanOutdated(ctx.site.path, ctx.spawn ?? defaultSpawn);
1376
+ const summary = outdated ? `${driftSummary}; ${outdated.outdated} outdated install(s) (${outdated.major} major)` : driftSummary;
1243
1377
  return {
1244
1378
  audit: "deps",
1245
1379
  site: siteLabel(ctx.site),
1246
1380
  status,
1247
1381
  summary,
1248
- details
1382
+ details: { entries, outdated }
1249
1383
  };
1250
1384
  }
1251
1385
 
1252
1386
  // src/audits/lint.ts
1253
1387
  import { existsSync } from "fs";
1254
1388
  import { readFile as readFile2 } from "fs/promises";
1255
- import { join as join3 } from "path";
1389
+ import { join as join4 } from "path";
1256
1390
  import { ESLint } from "eslint";
1257
1391
  import { check as prettierCheck, resolveConfig as prettierResolveConfig } from "prettier";
1258
1392
  import { glob } from "tinyglobby";
@@ -1263,7 +1397,7 @@ async function listFiles(cwd) {
1263
1397
  }
1264
1398
  async function lintAudit(ctx) {
1265
1399
  const { site } = ctx;
1266
- const configPath = join3(site.path, "eslint.config.js");
1400
+ const configPath = join4(site.path, "eslint.config.js");
1267
1401
  if (!existsSync(configPath)) {
1268
1402
  return {
1269
1403
  audit: "lint",
@@ -1283,7 +1417,7 @@ async function lintAudit(ctx) {
1283
1417
  const eslintWarnings = eslintResults.reduce((n, r) => n + r.warningCount, 0);
1284
1418
  const prettierUnformatted = [];
1285
1419
  for (const rel of relFiles) {
1286
- const absForResolve = join3(site.path, rel);
1420
+ const absForResolve = join4(site.path, rel);
1287
1421
  const source = await readFile2(absForResolve, "utf-8");
1288
1422
  const options = await prettierResolveConfig(absForResolve) ?? {};
1289
1423
  const ok = await prettierCheck(source, { ...options, filepath: absForResolve });
@@ -1447,7 +1581,7 @@ async function securityAudit(ctx) {
1447
1581
  // src/audits/lighthouse.ts
1448
1582
  import { readFile as readFile4, writeFile, mkdtemp, rm, readdir } from "fs/promises";
1449
1583
  import { tmpdir } from "os";
1450
- import { join as join5 } from "path";
1584
+ import { join as join6 } from "path";
1451
1585
 
1452
1586
  // src/configs/lighthouse.ts
1453
1587
  var lighthouseConfig = {
@@ -1482,11 +1616,11 @@ var lighthouseConfig = {
1482
1616
 
1483
1617
  // src/audits/util/site-config.ts
1484
1618
  import { readFile as readFile3 } from "fs/promises";
1485
- import { join as join4 } from "path";
1619
+ import { join as join5 } from "path";
1486
1620
  async function readSiteConfig(sitePath) {
1487
1621
  let raw;
1488
1622
  try {
1489
- raw = await readFile3(join4(sitePath, "package.json"), "utf-8");
1623
+ raw = await readFile3(join5(sitePath, "package.json"), "utf-8");
1490
1624
  } catch {
1491
1625
  return {};
1492
1626
  }
@@ -1547,7 +1681,7 @@ async function readLhrEntries(resultsDir) {
1547
1681
  const entries = [];
1548
1682
  for (const f of files) {
1549
1683
  if (!f.startsWith("lhr-") || !f.endsWith(".json")) continue;
1550
- const lhr = await readJsonMaybe(join5(resultsDir, f));
1684
+ const lhr = await readJsonMaybe(join6(resultsDir, f));
1551
1685
  if (!lhr || !lhr.categories) continue;
1552
1686
  const summary = {};
1553
1687
  for (const [k, v] of Object.entries(lhr.categories)) {
@@ -1583,10 +1717,35 @@ function categoryFromAssertion(a) {
1583
1717
  function messageForAssertion(a) {
1584
1718
  return `${a.name} ${a.operator} ${a.expected} (actual: ${a.actual.toFixed(2)})`;
1585
1719
  }
1586
- async function lighthouseAudit(ctx) {
1587
- const spawn2 = ctx.spawn ?? defaultSpawn;
1588
- const site = ctx.site;
1589
- const label = siteLabel(site);
1720
+ async function parseLhciResults(resultsDir, label, raw) {
1721
+ const manifest = await readLhrEntries(resultsDir);
1722
+ if (manifest.length === 0) {
1723
+ return {
1724
+ audit: "lighthouse",
1725
+ site: label,
1726
+ status: "fail",
1727
+ summary: `lighthouse: no lhr-*.json written (exit ${raw.code})${raw.stderr ? ` \u2014 ${raw.stderr.slice(0, 200)}` : ""}`
1728
+ };
1729
+ }
1730
+ const assertionResults = await readJsonMaybe(join6(resultsDir, "assertion-results.json")) ?? [];
1731
+ const failed = assertionResults.filter((a) => !a.passed);
1732
+ const assertions = failed.map((a) => ({
1733
+ category: categoryFromAssertion(a),
1734
+ level: a.level,
1735
+ message: messageForAssertion(a)
1736
+ }));
1737
+ const anyError = assertions.some((a) => a.level === "error");
1738
+ const anyWarn = assertions.some((a) => a.level === "warn");
1739
+ const status = anyError ? "fail" : anyWarn ? "warn" : "pass";
1740
+ const normalized = {
1741
+ summary: averageSummaries(manifest),
1742
+ assertionsFailed: failed.length,
1743
+ assertions
1744
+ };
1745
+ const summary = status === "pass" ? "lighthouse: all categories passing" : `lighthouse: ${failed.length} assertion(s) failed`;
1746
+ return { audit: "lighthouse", site: label, status, summary, details: normalized };
1747
+ }
1748
+ async function checkoutLighthouse(spawn2, site, label) {
1590
1749
  const siteCfg = await readSiteConfig(site.path);
1591
1750
  const port = await findFreePort();
1592
1751
  const baseUrl = siteCfg.lighthouseUrl ?? lighthouseConfig.ci.collect.url[0];
@@ -1601,19 +1760,15 @@ async function lighthouseAudit(ctx) {
1601
1760
  }
1602
1761
  }
1603
1762
  };
1604
- const configDir = await mkdtemp(join5(tmpdir(), "reddoor-lhci-"));
1605
- const configPath = join5(configDir, "lighthouserc.json");
1763
+ const configDir = await mkdtemp(join6(tmpdir(), "reddoor-lhci-"));
1764
+ const configPath = join6(configDir, "lighthouserc.json");
1606
1765
  await writeFile(configPath, JSON.stringify(resolvedConfig), "utf-8");
1607
- const resultsDir = join5(site.path, ".lighthouseci");
1766
+ const resultsDir = join6(site.path, ".lighthouseci");
1608
1767
  await rm(resultsDir, { recursive: true, force: true });
1609
1768
  let raw;
1610
1769
  try {
1611
1770
  raw = await spawn2("npx", ["--yes", "@lhci/cli", "autorun", `--config=${configPath}`], {
1612
1771
  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
1772
  timeoutMs: 5 * 6e4
1618
1773
  });
1619
1774
  } catch (err) {
@@ -1630,43 +1785,64 @@ async function lighthouseAudit(ctx) {
1630
1785
  throw err;
1631
1786
  }
1632
1787
  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
1788
+ return parseLhciResults(resultsDir, label, raw);
1789
+ }
1790
+ async function deployedLighthouse(spawn2, deployedUrl, label) {
1791
+ const workDir = await mkdtemp(join6(tmpdir(), "reddoor-lh-deployed-"));
1792
+ const resolvedConfig = {
1793
+ ci: {
1794
+ // Deliberately NOT spread from lighthouseConfig.ci.collect: deployed mode
1795
+ // must omit startServerCommand and the dev-server settings entirely.
1796
+ collect: {
1797
+ url: [deployedUrl],
1798
+ // 3 runs to damp Lighthouse's run-to-run variance; parseLhciResults
1799
+ // averages the lhr files. (Median is a tracked future refinement.)
1800
+ numberOfRuns: 3,
1801
+ settings: { preset: "desktop", skipAudits: ["uses-http2"] }
1802
+ },
1803
+ assert: lighthouseConfig.ci.assert,
1804
+ upload: { target: "filesystem", outputDir: join6(workDir, "lhci-report") }
1805
+ }
1664
1806
  };
1807
+ const configPath = join6(workDir, "lighthouserc.json");
1808
+ await writeFile(configPath, JSON.stringify(resolvedConfig), "utf-8");
1809
+ const resultsDir = join6(workDir, ".lighthouseci");
1810
+ let raw;
1811
+ try {
1812
+ raw = await spawn2("npx", ["--yes", "@lhci/cli", "autorun", `--config=${configPath}`], {
1813
+ cwd: workDir,
1814
+ // No dev-server boot: ~30s/run × 3 + first-use Chrome download headroom.
1815
+ timeoutMs: 3 * 6e4
1816
+ });
1817
+ } catch (err) {
1818
+ await rm(workDir, { recursive: true, force: true });
1819
+ const e = err;
1820
+ if (e.code === "ENOENT" || /ENOENT/.test(String(err))) {
1821
+ return {
1822
+ audit: "lighthouse",
1823
+ site: label,
1824
+ status: "skip",
1825
+ summary: "npx/@lhci/cli not available"
1826
+ };
1827
+ }
1828
+ throw err;
1829
+ }
1830
+ try {
1831
+ return await parseLhciResults(resultsDir, label, raw);
1832
+ } finally {
1833
+ await rm(workDir, { recursive: true, force: true });
1834
+ }
1835
+ }
1836
+ async function lighthouseAudit(ctx) {
1837
+ const spawn2 = ctx.spawn ?? defaultSpawn;
1838
+ const site = ctx.site;
1839
+ const label = siteLabel(site);
1840
+ return site.deployedUrl ? deployedLighthouse(spawn2, site.deployedUrl, label) : checkoutLighthouse(spawn2, site, label);
1665
1841
  }
1666
1842
 
1667
1843
  // src/audits/a11y.ts
1668
1844
  import { readFile as readFile5, writeFile as writeFile2, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
1669
- import { join as join6 } from "path";
1845
+ import { join as join7 } from "path";
1670
1846
 
1671
1847
  // src/configs/playwright-a11y.ts
1672
1848
  import { defineConfig, devices } from "@playwright/test";
@@ -1832,14 +2008,14 @@ async function a11yAudit(ctx) {
1832
2008
  const spawn2 = ctx.spawn ?? defaultSpawn;
1833
2009
  const site = ctx.site;
1834
2010
  const label = siteLabel(site);
1835
- const specDir = await mkdtemp2(join6(site.path, ".reddoor-a11y-spec-"));
1836
- const specPath = join6(specDir, "a11y.spec.ts");
2011
+ const specDir = await mkdtemp2(join7(site.path, ".reddoor-a11y-spec-"));
2012
+ const specPath = join7(specDir, "a11y.spec.ts");
1837
2013
  await writeFile2(specPath, buildSpec(), "utf-8");
1838
2014
  const port = await findFreePort();
1839
- const configPath = join6(specDir, "playwright.config.ts");
2015
+ const configPath = join7(specDir, "playwright.config.ts");
1840
2016
  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 });
2017
+ const resultsPath = join7(site.path, RESULTS_REL);
2018
+ await rm2(join7(site.path, ".reddoor-a11y"), { recursive: true, force: true });
1843
2019
  let raw;
1844
2020
  try {
1845
2021
  raw = await spawn2(
@@ -1961,6 +2137,8 @@ function validate(raw) {
1961
2137
  const site = { path: e.path };
1962
2138
  if (typeof e.name === "string") site.name = e.name;
1963
2139
  if (typeof e.repoUrl === "string") site.repoUrl = e.repoUrl;
2140
+ if (typeof e.gitRepo === "string") site.gitRepo = e.gitRepo;
2141
+ if (typeof e.deployedUrl === "string") site.deployedUrl = e.deployedUrl;
1964
2142
  if (typeof e.meta === "object" && e.meta !== null) {
1965
2143
  site.meta = e.meta;
1966
2144
  }
@@ -2014,8 +2192,8 @@ async function resolveSites(input) {
2014
2192
  }
2015
2193
 
2016
2194
  // 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";
2195
+ import { stat as stat2, readdir as readdir2, mkdir } from "fs/promises";
2196
+ import { isAbsolute as isAbsolute2, join as join8 } from "path";
2019
2197
  function deriveNameFromRepoUrl(repoUrl) {
2020
2198
  const slash = repoUrl.split("/").pop() ?? repoUrl;
2021
2199
  return slash.replace(/\.git$/, "");
@@ -2040,7 +2218,7 @@ function assertSafeRepoUrl(repoUrl) {
2040
2218
  }
2041
2219
  async function isNonEmptyDir(path) {
2042
2220
  try {
2043
- const s = await stat(path);
2221
+ const s = await stat2(path);
2044
2222
  if (!s.isDirectory()) return false;
2045
2223
  const entries = await readdir2(path);
2046
2224
  return entries.length > 0;
@@ -2048,21 +2226,33 @@ async function isNonEmptyDir(path) {
2048
2226
  return false;
2049
2227
  }
2050
2228
  }
2229
+ var GIT_REPO_RE = /^[\w.-]+\/[\w.-]+$/;
2230
+ function resolveCloneUrl(site) {
2231
+ if (site.repoUrl) return site.repoUrl;
2232
+ if (!site.gitRepo) return void 0;
2233
+ if (!GIT_REPO_RE.test(site.gitRepo)) {
2234
+ throw new Error(`unsafe gitRepo: expected "owner/repo" (got: ${JSON.stringify(site.gitRepo)})`);
2235
+ }
2236
+ return `https://github.com/${site.gitRepo}.git`;
2237
+ }
2051
2238
  async function cloneIfNeeded(site, opts) {
2052
2239
  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`);
2240
+ const repoUrl = resolveCloneUrl(site);
2241
+ if (!repoUrl) {
2242
+ throw new Error(
2243
+ `site path does not exist (${site.path}) and no repoUrl or gitRepo is set \u2014 cannot clone`
2244
+ );
2055
2245
  }
2056
- const name = site.name ?? deriveNameFromRepoUrl(site.repoUrl);
2246
+ const name = site.name ?? deriveNameFromRepoUrl(repoUrl);
2057
2247
  assertSafeName(name);
2058
- assertSafeRepoUrl(site.repoUrl);
2059
- const target = join7(opts.workdir, name);
2248
+ assertSafeRepoUrl(repoUrl);
2249
+ const target = join8(opts.workdir, name);
2060
2250
  await mkdir(opts.workdir, { recursive: true });
2061
2251
  if (await isNonEmptyDir(target)) {
2062
2252
  return { ...site, name, path: target };
2063
2253
  }
2064
2254
  const spawn2 = opts.spawn ?? defaultSpawn;
2065
- const result = await spawn2("git", ["clone", "--", site.repoUrl, target], {
2255
+ const result = await spawn2("git", ["clone", "--", repoUrl, target], {
2066
2256
  cwd: opts.workdir,
2067
2257
  timeoutMs: 5 * 6e4
2068
2258
  });
@@ -2106,7 +2296,17 @@ function formatDuration(ms) {
2106
2296
  const s = totalSeconds % 60;
2107
2297
  return `${m}m${s.toString().padStart(2, "0")}s`;
2108
2298
  }
2109
- function buildAuditTasks(sites, which, results, renderer) {
2299
+ function parseConcurrency(value) {
2300
+ if (value === void 0) return true;
2301
+ const n = Number(value);
2302
+ if (!Number.isInteger(n) || n < 1) {
2303
+ throw Object.assign(new Error(`--concurrency must be a positive integer, got "${value}"`), {
2304
+ exitCode: 2
2305
+ });
2306
+ }
2307
+ return n;
2308
+ }
2309
+ function buildAuditTasks(sites, which, results, renderer, concurrency) {
2110
2310
  const singleSite = sites.length === 1;
2111
2311
  if (singleSite) {
2112
2312
  const site = sites[0];
@@ -2152,7 +2352,7 @@ function buildAuditTasks(sites, which, results, renderer) {
2152
2352
  }
2153
2353
  };
2154
2354
  }),
2155
- { concurrent: true, exitOnError: false, renderer }
2355
+ { concurrent: concurrency, exitOnError: false, renderer }
2156
2356
  );
2157
2357
  }
2158
2358
  function formatWriteSummary(summary) {
@@ -2177,13 +2377,46 @@ ${lines.join("\n")}`;
2177
2377
  function rendererFor(json) {
2178
2378
  return json ? "silent" : "default";
2179
2379
  }
2380
+ function deployedUrlNotice(which, url, cwd) {
2381
+ if (url === void 0) return null;
2382
+ const others = which.filter((n) => n !== "lighthouse");
2383
+ if (others.length === 0) return null;
2384
+ return `note: --url only affects lighthouse; ${others.join(", ")} ran against the local checkout at ${cwd}`;
2385
+ }
2386
+ function auditNeedsCheckout(site, which) {
2387
+ const deployedCapable = site.deployedUrl !== void 0 && which.every((n) => n === "lighthouse");
2388
+ return !deployedCapable;
2389
+ }
2390
+ function applyDeployedUrl(sites, url) {
2391
+ if (url === void 0) return sites;
2392
+ if (sites.length !== 1) {
2393
+ throw Object.assign(
2394
+ new Error(`--url expects exactly one site, but ${sites.length} resolved.`),
2395
+ { exitCode: 2 }
2396
+ );
2397
+ }
2398
+ try {
2399
+ new URL(url);
2400
+ } catch {
2401
+ throw Object.assign(new Error(`--url is not a valid URL: ${url}`), { exitCode: 2 });
2402
+ }
2403
+ return [{ ...sites[0], deployedUrl: url }];
2404
+ }
2180
2405
  async function runAuditCommand(site, opts) {
2181
2406
  const which = parseOnly(opts.only) ?? ALL_AUDIT_NAMES;
2182
2407
  const cwd = opts.cwd ? resolve2(opts.cwd) : process.cwd();
2183
- if (opts.writeAirtable !== void 0 && opts.fleet !== void 0) {
2408
+ if (typeof opts.writeAirtable === "string" && opts.fleet !== void 0) {
2409
+ throw Object.assign(
2410
+ new Error(
2411
+ "--write-airtable=<slug> is single-site; with --fleet each site's slug comes from the inventory. Use --write-airtable (no slug) + --fleet."
2412
+ ),
2413
+ { exitCode: 2 }
2414
+ );
2415
+ }
2416
+ if (opts.url !== void 0 && opts.fleet !== void 0) {
2184
2417
  throw Object.assign(
2185
2418
  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`."
2419
+ "--url is single-site only and cannot be combined with --fleet. Audit a single site instead."
2187
2420
  ),
2188
2421
  { exitCode: 2 }
2189
2422
  );
@@ -2194,51 +2427,70 @@ async function runAuditCommand(site, opts) {
2194
2427
  ...opts.workdir !== void 0 ? { workdir: opts.workdir } : {},
2195
2428
  cwd
2196
2429
  });
2430
+ sites = applyDeployedUrl(sites, opts.url);
2197
2431
  if (opts.fleet) {
2198
2432
  const workdir = opts.workdir ?? `${process.env.HOME ?? ""}/.reddoor-maint/sites`;
2199
- sites = await Promise.all(sites.map((s) => cloneIfNeeded(s, { workdir })));
2433
+ sites = await Promise.all(
2434
+ sites.map(
2435
+ (s) => auditNeedsCheckout(s, which) ? cloneIfNeeded(s, { workdir }) : Promise.resolve(s)
2436
+ )
2437
+ );
2200
2438
  }
2201
2439
  const results = [];
2202
2440
  const renderer = rendererFor(opts.json);
2203
- await buildAuditTasks(sites, which, results, renderer).run();
2441
+ await buildAuditTasks(sites, which, results, renderer, parseConcurrency(opts.concurrency)).run();
2204
2442
  let output = opts.json ? JSON.stringify(results, null, 2) : formatTable(results);
2205
2443
  if (opts.writeAirtable !== void 0) {
2206
2444
  const { openBase: openBase2, readAirtableConfig: readAirtableConfig2 } = await Promise.resolve().then(() => (init_client(), client_exports));
2207
2445
  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"})`;
2446
+ if (opts.fleet !== void 0) {
2447
+ const { writeFleetAuditsToAirtable: writeFleetAuditsToAirtable2, formatFleetWriteSummary: formatFleetWriteSummary2 } = await Promise.resolve().then(() => (init_write_audits_to_airtable(), write_audits_to_airtable_exports));
2448
+ const base = openBase2(readAirtableConfig2());
2449
+ const websites = await listWebsites2(base);
2450
+ const fleetWrite = await writeFleetAuditsToAirtable2({ base, websites, results });
2451
+ output += `
2452
+
2453
+ ${formatFleetWriteSummary2(fleetWrite)}`;
2454
+ } else {
2455
+ const { resolveSlugFromCwd: resolveSlugFromCwd2 } = await Promise.resolve().then(() => (init_lighthouse_airtable(), lighthouse_airtable_exports));
2456
+ const { writeAuditsToAirtable: writeAuditsToAirtable2 } = await Promise.resolve().then(() => (init_write_audits_to_airtable(), write_audits_to_airtable_exports));
2457
+ const slug = typeof opts.writeAirtable === "string" && opts.writeAirtable.length > 0 ? opts.writeAirtable : await resolveSlugFromCwd2(cwd);
2458
+ let writeSummary = null;
2459
+ await new Listr(
2460
+ [
2461
+ {
2462
+ title: `Write to Airtable[${slug}]`,
2463
+ task: async (_ctx, task) => {
2464
+ const base = openBase2(readAirtableConfig2());
2465
+ task.output = "loading Websites\u2026";
2466
+ const websites = await listWebsites2(base);
2467
+ task.output = "writing scores\u2026";
2468
+ writeSummary = await writeAuditsToAirtable2({ base, websites, slug, results });
2469
+ task.title = `Wrote to Websites[${writeSummary.siteName}] (${writeSummary.writes.length} audit type${writeSummary.writes.length === 1 ? "" : "s"})`;
2470
+ }
2223
2471
  }
2224
- }
2225
- ],
2226
- { renderer }
2227
- ).run();
2228
- if (writeSummary) output += `
2472
+ ],
2473
+ { renderer }
2474
+ ).run();
2475
+ if (writeSummary) output += `
2229
2476
 
2230
2477
  ${formatWriteSummary(writeSummary)}`;
2478
+ }
2231
2479
  }
2480
+ const notice = deployedUrlNotice(which, opts.url, cwd);
2481
+ if (notice && !opts.json) output += `
2482
+
2483
+ ${notice}`;
2232
2484
  return { output, code: auditExitCode(results, opts.failOnViolations === true) };
2233
2485
  }
2234
2486
 
2235
2487
  // src/cli/commands/sync-configs.ts
2236
2488
  import { readFile as readFile9 } from "fs/promises";
2237
- import { join as join10, resolve as resolve3 } from "path";
2489
+ import { join as join11, resolve as resolve3 } from "path";
2238
2490
 
2239
2491
  // src/recipes/sync-configs.ts
2240
2492
  import { readFile as readFile8, writeFile as writeFile3, mkdir as mkdir2 } from "fs/promises";
2241
- import { join as join9, dirname } from "path";
2493
+ import { join as join10, dirname } from "path";
2242
2494
 
2243
2495
  // src/recipes/sync-configs/templates.ts
2244
2496
  var eslint = {
@@ -2355,6 +2607,29 @@ var netlify = {
2355
2607
  [build.environment]
2356
2608
  NODE_VERSION = "22"
2357
2609
  COREPACK_INTEGRITY_KEYS = "0"
2610
+
2611
+ # Baseline security headers for all responses. CSP is emitted per-response by
2612
+ # SvelteKit (see \`kit.csp\` in svelte.config.js) so it is intentionally omitted
2613
+ # here to avoid conflicting duplicates.
2614
+ [[headers]]
2615
+ for = "/*"
2616
+ [headers.values]
2617
+ Strict-Transport-Security = "max-age=63072000; includeSubDomains; preload"
2618
+ X-Content-Type-Options = "nosniff"
2619
+ X-Frame-Options = "SAMEORIGIN"
2620
+ Referrer-Policy = "strict-origin-when-cross-origin"
2621
+ Permissions-Policy = "camera=(), microphone=(), geolocation=(), interest-cohort=()"
2622
+ Cross-Origin-Opener-Policy = "same-origin"
2623
+
2624
+ [[headers]]
2625
+ for = "/favicon.png"
2626
+ [headers.values]
2627
+ Cache-Control = "public, max-age=31536000, immutable"
2628
+
2629
+ [[headers]]
2630
+ for = "/_app/immutable/*"
2631
+ [headers.values]
2632
+ Cache-Control = "public, max-age=31536000, immutable"
2358
2633
  `
2359
2634
  };
2360
2635
  var ALL_TEMPLATES = [
@@ -2576,9 +2851,14 @@ async function withRecipe(body) {
2576
2851
  // src/recipes/sync-configs.ts
2577
2852
  var GITIGNORE_CONFIG = "gitignore";
2578
2853
  var SVELTE_CONFIG = "svelte";
2854
+ var NETLIFY_CONFIG = "netlify";
2579
2855
  function isSvelteConfigCompliant(contents) {
2580
2856
  return contents.includes("createSvelteConfig") && contents.includes("@sveltejs/adapter-netlify");
2581
2857
  }
2858
+ 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;
2859
+ function isNetlifyConfigCompliant(contents) {
2860
+ return contents.includes("[[headers]]") && SECURITY_HEADER_RE.test(contents);
2861
+ }
2582
2862
  var ALL_CONFIG_NAMES = [
2583
2863
  "lighthouse",
2584
2864
  "eslint",
@@ -2605,17 +2885,20 @@ async function readMaybe(path) {
2605
2885
  async function planTemplateDiffs(cwd, templates) {
2606
2886
  const diffs = [];
2607
2887
  for (const t of templates) {
2608
- const existing = await readMaybe(join9(cwd, t.path));
2888
+ const existing = await readMaybe(join10(cwd, t.path));
2609
2889
  if (existing === t.contents) continue;
2610
2890
  if (t.config === SVELTE_CONFIG && existing !== null && isSvelteConfigCompliant(existing)) {
2611
2891
  continue;
2612
2892
  }
2893
+ if (t.config === NETLIFY_CONFIG && existing !== null && isNetlifyConfigCompliant(existing)) {
2894
+ continue;
2895
+ }
2613
2896
  diffs.push(t);
2614
2897
  }
2615
2898
  return diffs;
2616
2899
  }
2617
2900
  async function planGitignore(cwd) {
2618
- const existing = await readMaybe(join9(cwd, ".gitignore"));
2901
+ const existing = await readMaybe(join10(cwd, ".gitignore"));
2619
2902
  const merge = mergeGitignore(existing, CANONICAL_GITIGNORE_ENTRIES);
2620
2903
  const tracked = await listTrackedFiles(cwd);
2621
2904
  const toUntrack = findTrackedArtifacts(tracked, CANONICAL_GITIGNORE_ENTRIES);
@@ -2623,7 +2906,7 @@ async function planGitignore(cwd) {
2623
2906
  return { kind: "apply", content: merge.content, toUntrack, added: merge.added };
2624
2907
  }
2625
2908
  async function applyGitignore(cwd, plan) {
2626
- await writeFile3(join9(cwd, ".gitignore"), plan.content, "utf-8");
2909
+ await writeFile3(join10(cwd, ".gitignore"), plan.content, "utf-8");
2627
2910
  if (plan.toUntrack.length > 0) {
2628
2911
  await removeFromIndex(cwd, plan.toUntrack);
2629
2912
  }
@@ -2646,7 +2929,7 @@ async function syncConfigs(site, opts = {}) {
2646
2929
  },
2647
2930
  apply: async ({ templateDiffs, gitignorePlan }, { commit: commit2 }) => {
2648
2931
  for (const t of templateDiffs) {
2649
- const dest = join9(site.path, t.path);
2932
+ const dest = join10(site.path, t.path);
2650
2933
  await mkdir2(dirname(dest), { recursive: true });
2651
2934
  await writeFile3(dest, t.contents, "utf-8");
2652
2935
  await commit2(`chore: sync ${t.config} config from @reddoorla/maintenance`);
@@ -2677,7 +2960,7 @@ function parseOnly2(value) {
2677
2960
  async function dryPlanGitignore(cwd) {
2678
2961
  let existing;
2679
2962
  try {
2680
- existing = await readFile9(join10(cwd, ".gitignore"), "utf-8");
2963
+ existing = await readFile9(join11(cwd, ".gitignore"), "utf-8");
2681
2964
  } catch {
2682
2965
  return "would create .gitignore";
2683
2966
  }
@@ -2692,7 +2975,7 @@ async function dryPlan(cwd, which) {
2692
2975
  for (const t of templateTargets) {
2693
2976
  let existing = "";
2694
2977
  try {
2695
- existing = await readFile9(join10(cwd, t.path), "utf-8");
2978
+ existing = await readFile9(join11(cwd, t.path), "utf-8");
2696
2979
  } catch {
2697
2980
  }
2698
2981
  if (existing !== t.contents) lines.push(`would update ${t.path} (config: ${t.config})`);
@@ -2739,11 +3022,11 @@ async function runSyncConfigsCommand(site, opts) {
2739
3022
  import { resolve as resolve4 } from "path";
2740
3023
 
2741
3024
  // 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) {
3025
+ import { stat as stat3 } from "fs/promises";
3026
+ import { join as join12 } from "path";
3027
+ async function exists2(path) {
2745
3028
  try {
2746
- await stat2(path);
3029
+ await stat3(path);
2747
3030
  return true;
2748
3031
  } catch {
2749
3032
  return false;
@@ -2769,10 +3052,10 @@ async function bumpDeps(site, opts = {}) {
2769
3052
  // land on top of whatever else was in the tree.
2770
3053
  checkTreeFirst: true,
2771
3054
  plan: async () => {
2772
- const hasPnpmLock = await exists(join11(site.path, "pnpm-lock.yaml"));
3055
+ const hasPnpmLock = await exists2(join12(site.path, "pnpm-lock.yaml"));
2773
3056
  if (!hasPnpmLock) {
2774
- const hasNpmLock = await exists(join11(site.path, "package-lock.json"));
2775
- const hasYarnLock = await exists(join11(site.path, "yarn.lock"));
3057
+ const hasNpmLock = await exists2(join12(site.path, "package-lock.json"));
3058
+ const hasYarnLock = await exists2(join12(site.path, "yarn.lock"));
2776
3059
  if (hasNpmLock || hasYarnLock) {
2777
3060
  const competing = hasNpmLock ? "package-lock.json" : "yarn.lock";
2778
3061
  return {
@@ -2843,7 +3126,7 @@ import { resolve as resolve5 } from "path";
2843
3126
 
2844
3127
  // src/recipes/self-updating/index.ts
2845
3128
  import { mkdir as mkdir3, writeFile as writeFile4 } from "fs/promises";
2846
- import { dirname as dirname2, join as join12 } from "path";
3129
+ import { dirname as dirname2, join as join13 } from "path";
2847
3130
 
2848
3131
  // src/github/config.ts
2849
3132
  function readGitHubConfig() {
@@ -3010,7 +3293,7 @@ async function selfUpdating(site, deps = {}) {
3010
3293
  const branch = branchName("self-updating");
3011
3294
  await createBranch(site.path, branch);
3012
3295
  for (const t of templates) {
3013
- const dest = join12(site.path, t.path);
3296
+ const dest = join13(site.path, t.path);
3014
3297
  await mkdir3(dirname2(dest), { recursive: true });
3015
3298
  await writeFile4(dest, t.contents, "utf-8");
3016
3299
  }
@@ -3085,7 +3368,7 @@ async function runSelfUpdatingCommand(site, opts) {
3085
3368
  import { resolve as resolve6 } from "path";
3086
3369
 
3087
3370
  // src/recipes/svelte-5/index.ts
3088
- import { join as join18 } from "path";
3371
+ import { join as join19 } from "path";
3089
3372
 
3090
3373
  // src/util/pkg.ts
3091
3374
  import { readFile as readFile10, writeFile as writeFile5 } from "fs/promises";
@@ -3134,7 +3417,7 @@ function bumpDep(pkg, name, version2, opts = {}) {
3134
3417
  }
3135
3418
 
3136
3419
  // src/recipes/svelte-5/step-bump-versions.ts
3137
- import { join as join13 } from "path";
3420
+ import { join as join14 } from "path";
3138
3421
  var SVELTE_5_VERSIONS = {
3139
3422
  svelte: "^5.55.5",
3140
3423
  "@sveltejs/kit": "^2.59.0",
@@ -3147,7 +3430,7 @@ var SVELTE_5_VERSIONS = {
3147
3430
  "typescript-svelte-plugin": "^0.3.52"
3148
3431
  };
3149
3432
  async function bumpToSvelte5Versions(cwd) {
3150
- const pkgPath = join13(cwd, "package.json");
3433
+ const pkgPath = join14(cwd, "package.json");
3151
3434
  const pkg = await readPackageJson(pkgPath);
3152
3435
  let next = pkg;
3153
3436
  for (const [name, version2] of Object.entries(SVELTE_5_VERSIONS)) {
@@ -3160,7 +3443,7 @@ async function bumpToSvelte5Versions(cwd) {
3160
3443
 
3161
3444
  // src/recipes/svelte-5/step-svelte-config.ts
3162
3445
  import { readFile as readFile11, writeFile as writeFile6 } from "fs/promises";
3163
- import { join as join14 } from "path";
3446
+ import { join as join15 } from "path";
3164
3447
  var VITE_PLUGIN_PKG = "@sveltejs/vite-plugin-svelte";
3165
3448
  var IMPORT_FROM_VITE_PLUGIN = new RegExp(
3166
3449
  String.raw`^import\s+\{\s*([^}]+?)\s*\}\s+from\s+["']` + VITE_PLUGIN_PKG.replace(/[/]/g, "\\/") + String.raw`["'];?[ \t]*\n`,
@@ -3201,7 +3484,7 @@ function dropPreprocessKey(source) {
3201
3484
  return source.slice(0, m.index) + source.slice(tailIdx).replace(new RegExp(`^${indent}\\n`), "");
3202
3485
  }
3203
3486
  async function migrateSvelteConfig(cwd) {
3204
- const path = join14(cwd, "svelte.config.js");
3487
+ const path = join15(cwd, "svelte.config.js");
3205
3488
  let src;
3206
3489
  try {
3207
3490
  src = await readFile11(path, "utf-8");
@@ -3238,9 +3521,9 @@ async function runSvelteMigrate(cwd, spawn2 = defaultSpawn) {
3238
3521
  }
3239
3522
 
3240
3523
  // src/recipes/svelte-5/step-tailwind-upgrade.ts
3241
- import { join as join15 } from "path";
3524
+ import { join as join16 } from "path";
3242
3525
  async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
3243
- const pkg = await readPackageJson(join15(cwd, "package.json"));
3526
+ const pkg = await readPackageJson(join16(cwd, "package.json"));
3244
3527
  const tailwindVersion = pkg.devDependencies?.tailwindcss ?? pkg.dependencies?.tailwindcss;
3245
3528
  if (!tailwindVersion) return { ran: false, reason: "tailwindcss not installed" };
3246
3529
  if (/^\^?4\./.test(tailwindVersion)) return { ran: false, reason: "already on tailwind 4.x" };
@@ -3262,7 +3545,7 @@ async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
3262
3545
 
3263
3546
  // src/recipes/svelte-5/step-gotchas.ts
3264
3547
  import { readFile as readFile12, writeFile as writeFile7 } from "fs/promises";
3265
- import { join as join16 } from "path";
3548
+ import { join as join17 } from "path";
3266
3549
  import { glob as glob2 } from "tinyglobby";
3267
3550
 
3268
3551
  // src/recipes/svelte-5/codemods/on-event-to-handler.ts
@@ -3608,7 +3891,7 @@ async function planGotchaCodemods(cwd) {
3608
3891
  const changes = [];
3609
3892
  const relPaths = await glob2(SVELTE_GLOBS, { cwd, ignore: IGNORE2, absolute: false });
3610
3893
  for (const rel of relPaths) {
3611
- const path = join16(cwd, rel);
3894
+ const path = join17(cwd, rel);
3612
3895
  const before = await readFile12(path, "utf-8");
3613
3896
  const after = CODEMODS.reduce((s, fn) => fn(s), before);
3614
3897
  if (after !== before) changes.push({ rel, after });
@@ -3618,7 +3901,7 @@ async function planGotchaCodemods(cwd) {
3618
3901
  async function applyGotchaCodemods(cwd) {
3619
3902
  const changes = await planGotchaCodemods(cwd);
3620
3903
  for (const c of changes) {
3621
- await writeFile7(join16(cwd, c.rel), c.after, "utf-8");
3904
+ await writeFile7(join17(cwd, c.rel), c.after, "utf-8");
3622
3905
  }
3623
3906
  return { filesChanged: changes.length };
3624
3907
  }
@@ -3642,7 +3925,7 @@ async function verifyMigration(cwd, spawn2 = defaultSpawn) {
3642
3925
 
3643
3926
  // src/recipes/svelte-5/step-summary.ts
3644
3927
  import { writeFile as writeFile8 } from "fs/promises";
3645
- import { join as join17 } from "path";
3928
+ import { join as join18 } from "path";
3646
3929
  async function writeMigrationSummary(input) {
3647
3930
  const lines = [
3648
3931
  `# Svelte 4 \u2192 5 migration summary`,
@@ -3659,7 +3942,7 @@ async function writeMigrationSummary(input) {
3659
3942
  `- Verify Playwright a11y tests still pass.`
3660
3943
  ];
3661
3944
  const content = lines.join("\n") + "\n";
3662
- const path = join17(input.cwd, "MIGRATION_SVELTE_5.md");
3945
+ const path = join18(input.cwd, "MIGRATION_SVELTE_5.md");
3663
3946
  await writeFile8(path, content, "utf-8");
3664
3947
  return path;
3665
3948
  }
@@ -3667,7 +3950,7 @@ async function writeMigrationSummary(input) {
3667
3950
  // src/recipes/svelte-5/index.ts
3668
3951
  async function alreadyOnSvelte5(cwd) {
3669
3952
  try {
3670
- const pkg = await readPackageJson(join18(cwd, "package.json"));
3953
+ const pkg = await readPackageJson(join19(cwd, "package.json"));
3671
3954
  const v = pkg.devDependencies?.svelte ?? pkg.dependencies?.svelte;
3672
3955
  return !!v && /^\^?5\./.test(v);
3673
3956
  } catch {
@@ -3761,8 +4044,8 @@ async function runUpgradeCommand(upgradeName, site, opts = {}) {
3761
4044
  import { resolve as resolve7 } from "path";
3762
4045
 
3763
4046
  // src/recipes/convert-to-pnpm.ts
3764
- import { rm as rm3, stat as stat3 } from "fs/promises";
3765
- import { join as join19 } from "path";
4047
+ import { rm as rm3, stat as stat4 } from "fs/promises";
4048
+ import { join as join20 } from "path";
3766
4049
 
3767
4050
  // src/recipes/convert-to-pnpm/script-rewrites.ts
3768
4051
  function rewriteScriptForPnpm(script) {
@@ -3784,9 +4067,9 @@ function rewriteScriptsForPnpm(scripts) {
3784
4067
 
3785
4068
  // src/recipes/convert-to-pnpm.ts
3786
4069
  var DEFAULT_PNPM_VERSION = "10.33.1";
3787
- async function exists2(path) {
4070
+ async function exists3(path) {
3788
4071
  try {
3789
- await stat3(path);
4072
+ await stat4(path);
3790
4073
  return true;
3791
4074
  } catch {
3792
4075
  return false;
@@ -3795,18 +4078,18 @@ async function exists2(path) {
3795
4078
  async function convertToPnpm(site, opts = {}) {
3796
4079
  const spawn2 = opts.spawn ?? defaultSpawn;
3797
4080
  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");
4081
+ const pnpmLockPath = join20(site.path, "pnpm-lock.yaml");
4082
+ const npmLockPath = join20(site.path, "package-lock.json");
4083
+ const yarnLockPath = join20(site.path, "yarn.lock");
3801
4084
  return withRecipe({
3802
4085
  name: "convert-to-pnpm",
3803
4086
  site,
3804
4087
  plan: async () => {
3805
- if (await exists2(pnpmLockPath)) {
4088
+ if (await exists3(pnpmLockPath)) {
3806
4089
  return { kind: "noop", notes: "site already has pnpm-lock.yaml" };
3807
4090
  }
3808
- const hasNpmLock = await exists2(npmLockPath);
3809
- const hasYarnLock = await exists2(yarnLockPath);
4091
+ const hasNpmLock = await exists3(npmLockPath);
4092
+ const hasYarnLock = await exists3(yarnLockPath);
3810
4093
  if (!hasNpmLock && !hasYarnLock) {
3811
4094
  return {
3812
4095
  kind: "noop",
@@ -3820,7 +4103,7 @@ async function convertToPnpm(site, opts = {}) {
3820
4103
  if (hasYarnLock) await rm3(yarnLockPath, { force: true });
3821
4104
  const sourceLock = hasNpmLock ? "package-lock.json" : "yarn.lock";
3822
4105
  await commit2(`chore(pnpm): remove ${sourceLock}`);
3823
- const pkgPath = join19(cwd, "package.json");
4106
+ const pkgPath = join20(cwd, "package.json");
3824
4107
  const pkg = await readPackageJson(pkgPath);
3825
4108
  const next = { ...pkg, packageManager: `pnpm@${pnpmVersion}` };
3826
4109
  if (pkg.scripts && typeof pkg.scripts === "object") {
@@ -3833,7 +4116,7 @@ async function convertToPnpm(site, opts = {}) {
3833
4116
  }
3834
4117
  await writePackageJson(pkgPath, next);
3835
4118
  await commit2("chore(pnpm): pin packageManager + rewrite npm scripts");
3836
- await rm3(join19(cwd, "node_modules"), { recursive: true, force: true });
4119
+ await rm3(join20(cwd, "node_modules"), { recursive: true, force: true });
3837
4120
  const installResult = await spawn2("pnpm", ["install"], { cwd, streaming: true });
3838
4121
  if (installResult.code !== 0) {
3839
4122
  return { kind: "failed", notes: `pnpm install failed (exit ${installResult.code})` };
@@ -3873,18 +4156,18 @@ async function runConvertToPnpmCommand(site, opts) {
3873
4156
  import { resolve as resolve8 } from "path";
3874
4157
 
3875
4158
  // src/recipes/onboard.ts
3876
- import { stat as stat4 } from "fs/promises";
3877
- import { join as join21 } from "path";
4159
+ import { stat as stat5 } from "fs/promises";
4160
+ import { join as join22 } from "path";
3878
4161
 
3879
4162
  // src/util/self-version.ts
3880
4163
  import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
3881
4164
  import { fileURLToPath } from "url";
3882
- import { dirname as dirname3, join as join20 } from "path";
4165
+ import { dirname as dirname3, join as join21 } from "path";
3883
4166
  function selfPackageVersion(callerImportMetaUrl) {
3884
4167
  try {
3885
4168
  let dir = dirname3(fileURLToPath(callerImportMetaUrl));
3886
4169
  while (true) {
3887
- const candidate = join20(dir, "package.json");
4170
+ const candidate = join21(dir, "package.json");
3888
4171
  if (existsSync2(candidate)) {
3889
4172
  const raw = readFileSync2(candidate, "utf-8");
3890
4173
  const pkg = JSON.parse(raw);
@@ -3936,9 +4219,9 @@ var AUDIT_DEPS = Object.fromEntries(
3936
4219
  })
3937
4220
  ])
3938
4221
  );
3939
- async function exists3(path) {
4222
+ async function exists4(path) {
3940
4223
  try {
3941
- await stat4(path);
4224
+ await stat5(path);
3942
4225
  return true;
3943
4226
  } catch {
3944
4227
  return false;
@@ -3955,13 +4238,13 @@ async function onboard(site, opts = {}) {
3955
4238
  name: "onboard",
3956
4239
  site,
3957
4240
  plan: async () => {
3958
- if (!await exists3(join21(site.path, "pnpm-lock.yaml"))) {
4241
+ if (!await exists4(join22(site.path, "pnpm-lock.yaml"))) {
3959
4242
  return {
3960
4243
  kind: "failed",
3961
4244
  notes: "no pnpm-lock.yaml at site root \u2014 run convert-to-pnpm first"
3962
4245
  };
3963
4246
  }
3964
- const pkgPath = join21(site.path, "package.json");
4247
+ const pkgPath = join22(site.path, "package.json");
3965
4248
  const pkg = await readPackageJson(pkgPath);
3966
4249
  const toAdd = [];
3967
4250
  if (!isDeclared(pkg, PACKAGE_NAME)) {
@@ -3984,7 +4267,7 @@ async function onboard(site, opts = {}) {
3984
4267
  return { kind: "apply", plan: { pkg, toAdd } };
3985
4268
  },
3986
4269
  apply: async ({ pkg, toAdd }, { commit: commit2, cwd }) => {
3987
- const pkgPath = join21(cwd, "package.json");
4270
+ const pkgPath = join22(cwd, "package.json");
3988
4271
  let next = pkg;
3989
4272
  for (const dep of toAdd) {
3990
4273
  next = bumpDep(next, dep.name, dep.version);
@@ -4053,7 +4336,7 @@ import { resolve as resolve9 } from "path";
4053
4336
 
4054
4337
  // src/recipes/svelte-codemods.ts
4055
4338
  import { writeFile as writeFile9 } from "fs/promises";
4056
- import { join as join22 } from "path";
4339
+ import { join as join23 } from "path";
4057
4340
  async function svelteCodemods(site) {
4058
4341
  return withRecipe({
4059
4342
  name: "svelte-codemods",
@@ -4067,7 +4350,7 @@ async function svelteCodemods(site) {
4067
4350
  },
4068
4351
  apply: async (changes, { commit: commit2, cwd }) => {
4069
4352
  for (const c of changes) {
4070
- await writeFile9(join22(cwd, c.rel), c.after, "utf-8");
4353
+ await writeFile9(join23(cwd, c.rel), c.after, "utf-8");
4071
4354
  }
4072
4355
  await commit2(`refactor(svelte5): apply codemods (${changes.length} files)`);
4073
4356
  return { kind: "ok" };
@@ -4171,11 +4454,11 @@ import { dirname as dirname6 } from "path";
4171
4454
 
4172
4455
  // src/reports/ga/config.ts
4173
4456
  init_credentials();
4174
- import { dirname as dirname5, join as join24 } from "path";
4457
+ import { dirname as dirname5, join as join25 } from "path";
4175
4458
  function readGaConfig() {
4176
4459
  const subject = process.env.GA_SUBJECT?.trim();
4177
4460
  if (!subject) return null;
4178
- const keyPath = process.env.GA_SA_KEY_PATH?.trim() || join24(dirname5(defaultCredentialsPath()), "ga-service-account.json");
4461
+ const keyPath = process.env.GA_SA_KEY_PATH?.trim() || join25(dirname5(defaultCredentialsPath()), "ga-service-account.json");
4179
4462
  return { subject, keyPath };
4180
4463
  }
4181
4464
 
@@ -4275,7 +4558,7 @@ async function fetchSearchPresence(q, periodStart, periodEnd) {
4275
4558
  });
4276
4559
  const pos = res.data.rows?.[0]?.position;
4277
4560
  if (typeof pos === "number") {
4278
- return { foundOnPage1: pos <= PAGE_1_MAX_POSITION, position: Math.round(pos) };
4561
+ return { foundOnPage1: pos <= PAGE_1_MAX_POSITION, position: Math.max(1, Math.round(pos)) };
4279
4562
  }
4280
4563
  }
4281
4564
  return { foundOnPage1: false, position: null };
@@ -4304,8 +4587,14 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
4304
4587
  const periodEnd = today;
4305
4588
  const completedOn = today;
4306
4589
  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;
4590
+ const gaResult = base !== null ? await fetchGaUsers(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
4591
+ const searchResult = base !== null ? await fetchSearch(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
4592
+ const gaUsers = gaResult.value;
4593
+ const search = searchResult.value;
4594
+ const softFailures = [
4595
+ ...gaResult.softFailed ? ["ga"] : [],
4596
+ ...searchResult.softFailed ? ["search"] : []
4597
+ ];
4309
4598
  const cidName = `${slug}-header`;
4310
4599
  const { html } = await renderReportHtml({
4311
4600
  siteName: siteRow.name,
@@ -4324,7 +4613,7 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
4324
4613
  const path = options.previewPath ?? `reports/${slug}/draft.html`;
4325
4614
  await mkdir4(dirname6(path), { recursive: true });
4326
4615
  await writeFile10(path, html, "utf-8");
4327
- return { reportRow: null, htmlPath: path, html };
4616
+ return { reportRow: null, htmlPath: path, html, softFailures };
4328
4617
  }
4329
4618
  if (base === null) throw new Error("base required when previewOnly=false");
4330
4619
  const reportId = `${siteRow.name} \u2014 ${reportType} \u2014 ${periodEnd.toISOString().slice(0, 10)}`;
@@ -4344,27 +4633,29 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
4344
4633
  const htmlFilename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;
4345
4634
  await uploadAttachment(created.id, "Rendered HTML", html, htmlFilename, "text/html");
4346
4635
  await setDraftReady(base, created.id, true);
4347
- return { reportRow: created, htmlPath: null, html };
4636
+ return { reportRow: created, htmlPath: null, html, softFailures };
4348
4637
  }
4638
+ var NO_ENRICHMENT = { value: null, softFailed: false };
4349
4639
  async function fetchGaUsers(siteRow, periodStart, periodEnd) {
4350
4640
  const cfg = readGaConfig();
4351
- if (!cfg || !siteRow.ga4PropertyId) return null;
4641
+ if (!cfg || !siteRow.ga4PropertyId) return NO_ENRICHMENT;
4352
4642
  try {
4353
- return await fetchPeriodUsers(
4643
+ const value = await fetchPeriodUsers(
4354
4644
  { propertyId: siteRow.ga4PropertyId, subject: cfg.subject, keyPath: cfg.keyPath },
4355
4645
  periodStart,
4356
4646
  periodEnd
4357
4647
  );
4648
+ return { value, softFailed: false };
4358
4649
  } catch (e) {
4359
4650
  console.warn(`\u26A0 GA skipped for ${siteRow.name}: ${e.message}`);
4360
- return null;
4651
+ return { value: null, softFailed: true };
4361
4652
  }
4362
4653
  }
4363
4654
  async function fetchSearch(siteRow, periodStart, periodEnd) {
4364
4655
  const cfg = readGaConfig();
4365
- if (!cfg || !siteRow.searchQuery) return null;
4656
+ if (!cfg || !siteRow.searchQuery) return NO_ENRICHMENT;
4366
4657
  try {
4367
- return await fetchSearchPresence(
4658
+ const value = await fetchSearchPresence(
4368
4659
  {
4369
4660
  keyPath: cfg.keyPath,
4370
4661
  subject: cfg.subject,
@@ -4375,16 +4666,20 @@ async function fetchSearch(siteRow, periodStart, periodEnd) {
4375
4666
  periodStart,
4376
4667
  periodEnd
4377
4668
  );
4669
+ return { value, softFailed: false };
4378
4670
  } catch (e) {
4379
4671
  console.warn(`\u26A0 Search presence skipped for ${siteRow.name}: ${e.message}`);
4380
- return null;
4672
+ return { value: null, softFailed: true };
4381
4673
  }
4382
4674
  }
4383
4675
  async function derivePeriodStart(base, siteRow, reportType, today) {
4384
4676
  const prior = await listReportsForSite(base, siteRow.id);
4385
4677
  const sameType = prior.filter((r) => r.reportType === reportType && r.periodEnd).map((r) => r.periodEnd).sort();
4386
4678
  const latest = sameType[sameType.length - 1];
4387
- return latest ? new Date(latest) : daysAgo(today, 30);
4679
+ if (!latest) return daysAgo(today, 30);
4680
+ const start = new Date(latest);
4681
+ start.setUTCDate(start.getUTCDate() + 1);
4682
+ return start;
4388
4683
  }
4389
4684
 
4390
4685
  // src/cli/commands/report.ts
@@ -4417,14 +4712,21 @@ async function runDueDraft() {
4417
4712
  const due = findDueReports(websites, reports, /* @__PURE__ */ new Date());
4418
4713
  if (due.length === 0) return { output: "No reports due.", code: 0 };
4419
4714
  const lines = [];
4715
+ let softFailedSites = 0;
4420
4716
  for (const item of due) {
4421
4717
  try {
4422
4718
  const result = await draftReportForSite(base, item.site, item.reportType);
4423
4719
  lines.push(`\u2713 drafted: ${result.reportRow?.reportId}`);
4720
+ if (result.softFailures.length > 0) softFailedSites++;
4424
4721
  } catch (e) {
4425
4722
  lines.push(`\u2717 failed: ${item.site.name} ${item.reportType} \u2014 ${e.message}`);
4426
4723
  }
4427
4724
  }
4725
+ if (softFailedSites > 0) {
4726
+ lines.push(
4727
+ `\u26A0 ${softFailedSites} site${softFailedSites === 1 ? "" : "s"} had GA/Search enrichment fail \u2014 drafted with blank analytics; check the logs above`
4728
+ );
4729
+ }
4428
4730
  return { output: lines.join("\n"), code: lines.some((l) => l.startsWith("\u2717")) ? 1 : 0 };
4429
4731
  }
4430
4732
  async function runSingleSiteDraft(slug, opts) {
@@ -4448,7 +4750,7 @@ import { resolve as resolve10 } from "path";
4448
4750
 
4449
4751
  // src/recipes/a11y-fixtures-page/index.ts
4450
4752
  import { access, mkdir as mkdir5, writeFile as writeFile11 } from "fs/promises";
4451
- import { dirname as dirname7, join as join25 } from "path";
4753
+ import { dirname as dirname7, join as join26 } from "path";
4452
4754
 
4453
4755
  // src/recipes/a11y-fixtures-page/template.ts
4454
4756
  var A11Y_FIXTURES_PAGE_RELATIVE = "src/routes/dev/a11y-fixtures/+page.svelte";
@@ -4499,7 +4801,7 @@ async function fileExists(path) {
4499
4801
  }
4500
4802
  }
4501
4803
  async function a11yFixturesPage(site) {
4502
- const target = join25(site.path, A11Y_FIXTURES_PAGE_RELATIVE);
4804
+ const target = join26(site.path, A11Y_FIXTURES_PAGE_RELATIVE);
4503
4805
  return withRecipe({
4504
4806
  name: "a11y-fixtures-page",
4505
4807
  site,
@@ -4607,12 +4909,12 @@ async function runInitCommand(site, opts) {
4607
4909
 
4608
4910
  // src/cli/version.ts
4609
4911
  import { readFileSync as readFileSync5, existsSync as existsSync4 } from "fs";
4610
- import { dirname as dirname8, join as join26 } from "path";
4912
+ import { dirname as dirname8, join as join27 } from "path";
4611
4913
  function resolvePackageVersion(fromDir) {
4612
4914
  try {
4613
4915
  let dir = fromDir;
4614
4916
  while (true) {
4615
- const candidate = join26(dir, "package.json");
4917
+ const candidate = join27(dir, "package.json");
4616
4918
  if (existsSync4(candidate)) {
4617
4919
  const raw = readFileSync5(candidate, "utf-8");
4618
4920
  const pkg = JSON.parse(raw);
@@ -4681,7 +4983,13 @@ cli.command("audit [site]", "Run audits against a site (default: cwd).").option(
4681
4983
  ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").option(
4682
4984
  "--write-airtable [slug]",
4683
4985
  "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(
4986
+ ).option("--fail-on-violations", "Exit non-zero if any a11y violations are found (for CI gates)").option(
4987
+ "--url <url>",
4988
+ "Audit this deployed URL with lighthouse (no dev server); single-site. Pair with --only lighthouse \u2014 other audits still use the local checkout."
4989
+ ).option(
4990
+ "--concurrency <n>",
4991
+ "Max sites to audit in parallel in --fleet mode (default: all at once). Use 1 for sequential (CI)."
4992
+ ).action(
4685
4993
  async (site, opts) => runOrExit(() => runAuditCommand(site, opts), opts)
4686
4994
  );
4687
4995
  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(