@reddoorla/maintenance 0.33.0 → 0.35.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
@@ -19,13 +19,17 @@ function defaultCredentialsPath() {
19
19
  }
20
20
  function parseEnvFile(contents) {
21
21
  const out = {};
22
- for (const rawLine of contents.split(/\r?\n/)) {
23
- const line = rawLine.trim();
24
- if (!line || line.startsWith("#")) continue;
22
+ const lines = contents.split(/\r?\n/);
23
+ for (let i = 0; i < lines.length; i++) {
24
+ const trimmed = lines[i].trim();
25
+ if (!trimmed || trimmed.startsWith("#")) continue;
26
+ const line = trimmed.replace(/^export\s+/, "");
25
27
  const eq = line.indexOf("=");
26
- if (eq <= 0) continue;
27
- const key = line.slice(0, eq).trim();
28
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
28
+ const key = eq > 0 ? line.slice(0, eq).trim() : "";
29
+ if (eq <= 0 || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
30
+ console.warn(`credentials: skipping unparseable line ${i + 1}: ${trimmed}`);
31
+ continue;
32
+ }
29
33
  let value = line.slice(eq + 1).trim();
30
34
  if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
31
35
  value = value.slice(1, -1);
@@ -57,76 +61,19 @@ var init_credentials = __esm({
57
61
  }
58
62
  });
59
63
 
60
- // src/audits/util/spawn.ts
61
- import { spawn } from "child_process";
62
- function makeSpawn(internals = {}) {
63
- const spawnImpl = internals.spawnImpl ?? spawn;
64
- const killImpl = internals.killImpl ?? ((pid, sig) => process.kill(pid, sig));
65
- const killGraceMs = internals.killGraceMs ?? 5e3;
66
- const maxOutputBytes = internals.maxOutputBytes ?? 10 * 1024 * 1024;
67
- return (cmd, args, opts = {}) => new Promise((resolve12, reject) => {
68
- const streaming = opts.streaming === true;
69
- const child = spawnImpl(cmd, [...args], {
70
- cwd: opts.cwd,
71
- env: opts.env ?? process.env,
72
- stdio: streaming ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "pipe"],
73
- // Detach ONLY when a timeout can fire: the child then leads its own
74
- // process group, so the timeout can kill the WHOLE tree (vite, and
75
- // Chromium under lhci/playwright) via process.kill(-pid), not just the
76
- // npx/pnpm wrapper. Without it, killing the wrapper orphaned the
77
- // grandchildren — a zombie vite squatting its port, Chrome left running.
78
- // We do NOT detach timeout-less streaming calls (pnpm install/up):
79
- // detaching gains nothing there (no timeout → no group-kill) and would
80
- // break terminal Ctrl-C, which only reaches the foreground group — i.e.
81
- // it would re-orphan the very children this guards. We never unref() the
82
- // child since we still await it.
83
- detached: opts.timeoutMs !== void 0
84
- });
85
- const cap = (acc, chunk) => {
86
- if (acc.length >= maxOutputBytes) return acc;
87
- const next = acc + chunk;
88
- return next.length > maxOutputBytes ? next.slice(0, maxOutputBytes) + TRUNCATION_MARKER : next;
89
- };
90
- let stdout = "";
91
- let stderr = "";
92
- if (!streaming) {
93
- child.stdout?.on("data", (chunk) => stdout = cap(stdout, String(chunk)));
94
- child.stderr?.on("data", (chunk) => stderr = cap(stderr, String(chunk)));
95
- }
96
- const killGroup = (sig) => {
97
- if (child.pid === void 0) return;
98
- try {
99
- killImpl(-child.pid, sig);
100
- } catch {
101
- }
102
- };
103
- let killTimer;
104
- const timer = opts.timeoutMs ? setTimeout(() => {
105
- killGroup("SIGTERM");
106
- killTimer = setTimeout(() => killGroup("SIGKILL"), killGraceMs);
107
- killTimer.unref();
108
- reject(new Error(`spawn timeout after ${opts.timeoutMs}ms: ${cmd}`));
109
- }, opts.timeoutMs) : void 0;
110
- const clearTimers = () => {
111
- if (timer) clearTimeout(timer);
112
- if (killTimer) clearTimeout(killTimer);
113
- };
114
- child.on("error", (err) => {
115
- clearTimers();
116
- reject(err);
117
- });
118
- child.on("close", (code) => {
119
- clearTimers();
120
- resolve12({ code: code ?? -1, stdout, stderr });
121
- });
122
- });
64
+ // src/util/url.ts
65
+ function isHttpUrl(s) {
66
+ let parsed;
67
+ try {
68
+ parsed = new URL(s);
69
+ } catch {
70
+ return false;
71
+ }
72
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
123
73
  }
124
- var TRUNCATION_MARKER, defaultSpawn;
125
- var init_spawn = __esm({
126
- "src/audits/util/spawn.ts"() {
74
+ var init_url = __esm({
75
+ "src/util/url.ts"() {
127
76
  "use strict";
128
- TRUNCATION_MARKER = "\n\u2026[output truncated]";
129
- defaultSpawn = makeSpawn();
130
77
  }
131
78
  });
132
79
 
@@ -171,6 +118,7 @@ __export(websites_exports, {
171
118
  mapRow: () => mapRow,
172
119
  siteSlug: () => siteSlug,
173
120
  updateA11yCounts: () => updateA11yCounts,
121
+ updateAuditFields: () => updateAuditFields,
174
122
  updateDepsCounts: () => updateDepsCounts,
175
123
  updateGitHubSignals: () => updateGitHubSignals,
176
124
  updateLaunched: () => updateLaunched,
@@ -255,23 +203,19 @@ async function getWebsiteBySlug(base, slug) {
255
203
  });
256
204
  return rows.find((w) => siteSlug(w.name) === slug) ?? null;
257
205
  }
258
- async function updateScores(base, recordId, scores) {
259
- const fields = {
206
+ function scoreFields(scores) {
207
+ return {
260
208
  pScore: scores.performance,
261
209
  rScore: scores.accessibility,
262
210
  bpScore: scores.bestPractices,
263
211
  seoScore: scores.seo,
264
212
  "Last lighthouse audit at": (/* @__PURE__ */ new Date()).toISOString()
265
213
  };
266
- await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
267
214
  }
268
- async function updateA11yCounts(base, recordId, counts) {
269
- const fields = {
270
- "A11y Violations": counts.violations
271
- };
272
- await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
215
+ function a11yFields(counts) {
216
+ return { "A11y Violations": counts.violations };
273
217
  }
274
- async function updateDepsCounts(base, recordId, counts) {
218
+ function depsFields(counts) {
275
219
  const fields = {
276
220
  "Deps Drifted": counts.drifted,
277
221
  "Deps Major Behind": counts.majorBehind
@@ -279,16 +223,36 @@ async function updateDepsCounts(base, recordId, counts) {
279
223
  if (counts.outdated !== null) {
280
224
  fields["Deps Outdated"] = counts.outdated;
281
225
  }
282
- await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
226
+ return fields;
283
227
  }
284
- async function updateSecurityCounts(base, recordId, counts) {
285
- const fields = {
228
+ function securityFields(counts) {
229
+ return {
286
230
  "Security Vulns Critical": counts.critical,
287
231
  "Security Vulns High": counts.high,
288
232
  "Security Vulns Moderate": counts.moderate,
289
233
  "Security Vulns Low": counts.low
290
234
  };
235
+ }
236
+ async function updateScores(base, recordId, scores) {
237
+ await base(WEBSITES_TABLE).update([{ id: recordId, fields: scoreFields(scores) }]);
238
+ }
239
+ async function updateA11yCounts(base, recordId, counts) {
240
+ await base(WEBSITES_TABLE).update([{ id: recordId, fields: a11yFields(counts) }]);
241
+ }
242
+ async function updateDepsCounts(base, recordId, counts) {
243
+ await base(WEBSITES_TABLE).update([{ id: recordId, fields: depsFields(counts) }]);
244
+ }
245
+ async function updateSecurityCounts(base, recordId, counts) {
246
+ await base(WEBSITES_TABLE).update([{ id: recordId, fields: securityFields(counts) }]);
247
+ }
248
+ async function updateAuditFields(base, recordId, audits) {
249
+ const fields = {};
250
+ if (audits.scores) Object.assign(fields, scoreFields(audits.scores));
251
+ if (audits.a11y) Object.assign(fields, a11yFields(audits.a11y));
252
+ if (audits.deps) Object.assign(fields, depsFields(audits.deps));
253
+ if (audits.security) Object.assign(fields, securityFields(audits.security));
291
254
  await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
255
+ return fields;
292
256
  }
293
257
  async function updateGitHubSignals(base, recordId, signals) {
294
258
  const fields = {
@@ -327,16 +291,28 @@ function fromAirtableBase(base, opts = {}) {
327
291
  );
328
292
  }
329
293
  const websites = await listWebsites(base);
330
- return websites.filter((w) => AUDITABLE_STATUSES.has(w.status ?? "") && w.url.length > 0).map((w) => {
294
+ return websites.filter((w) => AUDITABLE_STATUSES.has(w.status ?? "") && w.url.length > 0).flatMap((w) => {
331
295
  const slug = siteSlug(w.name);
296
+ if (slug.length === 0) {
297
+ console.warn(
298
+ `[inventory] skipping "${w.name}" (row ${w.id}): Name has no slug-able characters (empty slug)`
299
+ );
300
+ return [];
301
+ }
332
302
  const site = {
333
303
  path: `${workdir}/${slug}`,
334
304
  name: slug,
335
- deployedUrl: w.url,
336
305
  meta: { airtableRowId: w.id, displayName: w.name }
337
306
  };
307
+ if (isHttpUrl(w.url)) {
308
+ site.deployedUrl = w.url;
309
+ } else {
310
+ console.warn(
311
+ `[inventory] skipping deployed audit for "${w.name}": url is not http(s): ${JSON.stringify(w.url)}`
312
+ );
313
+ }
338
314
  if (w.gitRepo) site.gitRepo = w.gitRepo;
339
- return site;
315
+ return [site];
340
316
  });
341
317
  };
342
318
  }
@@ -345,6 +321,7 @@ var init_airtable = __esm({
345
321
  "src/inventory/airtable.ts"() {
346
322
  "use strict";
347
323
  init_websites();
324
+ init_url();
348
325
  AUDITABLE_STATUSES = /* @__PURE__ */ new Set(["maintenance", "launch period"]);
349
326
  }
350
327
  });
@@ -357,7 +334,7 @@ __export(lighthouse_airtable_exports, {
357
334
  resolveSlugFromCwd: () => resolveSlugFromCwd
358
335
  });
359
336
  import { readFile as readFile7 } from "fs/promises";
360
- import { join as join9 } from "path";
337
+ import { join as join10 } from "path";
361
338
  function hasRealScores(result) {
362
339
  if (result.audit !== "lighthouse") return false;
363
340
  const details = result.details ?? {};
@@ -382,7 +359,7 @@ function lighthouseScoresFromResult(result) {
382
359
  }
383
360
  async function resolveSlugFromCwd(cwd) {
384
361
  try {
385
- const pkgPath = join9(cwd, "package.json");
362
+ const pkgPath = join10(cwd, "package.json");
386
363
  const raw = await readFile7(pkgPath, "utf-8");
387
364
  const pkg = JSON.parse(raw);
388
365
  if (!pkg.name) throw new Error("package.json has no 'name' field");
@@ -488,30 +465,34 @@ async function writeAuditsToAirtable(args) {
488
465
  throw Object.assign(new Error(`No Websites row matched slug "${slug}"`), { exitCode: 2 });
489
466
  }
490
467
  const writes = [];
468
+ const audits = {};
491
469
  const lhHasScores = hasRealScores(lhResult);
492
470
  if (lhHasScores) {
493
471
  const scores = lighthouseScoresFromResult(lhResult);
494
- await updateScores(base, target.id, scores);
472
+ audits.scores = scores;
495
473
  writes.push({ audit: "lighthouse", counts: scores });
496
474
  }
497
475
  const a11y = results.find((r) => r.audit === "a11y");
498
476
  if (a11y && hasA11yCounts(a11y)) {
499
477
  const counts = a11yCountsFromResult(a11y);
500
- await updateA11yCounts(base, target.id, counts);
478
+ audits.a11y = counts;
501
479
  writes.push({ audit: "a11y", counts });
502
480
  }
503
481
  const deps = results.find((r) => r.audit === "deps");
504
482
  if (deps && hasDepsCounts(deps)) {
505
483
  const counts = depsCountsFromResult(deps);
506
- await updateDepsCounts(base, target.id, counts);
484
+ audits.deps = counts;
507
485
  writes.push({ audit: "deps", counts });
508
486
  }
509
487
  const sec = results.find((r) => r.audit === "security");
510
488
  if (sec && hasSecurityCounts(sec)) {
511
489
  const counts = securityCountsFromResult(sec);
512
- await updateSecurityCounts(base, target.id, counts);
490
+ audits.security = counts;
513
491
  writes.push({ audit: "security", counts });
514
492
  }
493
+ if (Object.keys(audits).length > 0) {
494
+ await updateAuditFields(base, target.id, audits);
495
+ }
515
496
  if (!lhHasScores) {
516
497
  const persisted = writes.map((w) => w.audit);
517
498
  throw Object.assign(
@@ -566,187 +547,16 @@ var init_write_audits_to_airtable = __esm({
566
547
  }
567
548
  });
568
549
 
569
- // src/github/gh.ts
570
- function mapRollupState(state) {
571
- switch (state) {
572
- case "SUCCESS":
573
- return "passing";
574
- case "FAILURE":
575
- case "ERROR":
576
- return "failing";
577
- case "PENDING":
578
- case "EXPECTED":
579
- return "pending";
580
- default:
581
- return "none";
582
- }
550
+ // src/reports/airtable/reports.ts
551
+ function toReportType(raw) {
552
+ if (raw && REPORT_TYPES.includes(raw)) return raw;
553
+ if (raw)
554
+ console.warn(`[reports] unknown Report type ${JSON.stringify(raw)} \u2014 treating as Maintenance`);
555
+ return "Maintenance";
583
556
  }
584
- function makeGitHub(deps) {
585
- const spawn2 = deps.spawn ?? defaultSpawn;
586
- const env = { ...process.env, GH_TOKEN: deps.token };
587
- async function gh(args) {
588
- const r = await spawn2("gh", args, { env, timeoutMs: 6e4 });
589
- if (r.code !== 0) throw new Error(`gh ${args[0]} failed (code ${r.code}): ${r.stderr.trim()}`);
590
- return r.stdout;
591
- }
592
- return {
593
- async openPullRequest(repo, pr) {
594
- const out = await gh([
595
- "pr",
596
- "create",
597
- "--repo",
598
- repo,
599
- "--head",
600
- pr.head,
601
- "--base",
602
- pr.base,
603
- "--title",
604
- pr.title,
605
- "--body",
606
- pr.body
607
- ]);
608
- return { url: out.trim() };
609
- },
610
- async enableRepoAutoMerge(repo) {
611
- await gh(["api", "-X", "PATCH", `repos/${repo}`, "-F", "allow_auto_merge=true"]);
612
- },
613
- async protectBranch(repo, branch, requiredChecks) {
614
- const args = [
615
- "api",
616
- "-X",
617
- "PUT",
618
- `repos/${repo}/branches/${branch}/protection`,
619
- "-H",
620
- "Accept: application/vnd.github+json",
621
- "-F",
622
- "required_status_checks[strict]=true",
623
- ...requiredChecks.flatMap((c) => ["-f", `required_status_checks[contexts][]=${c}`]),
624
- "-F",
625
- "enforce_admins=true",
626
- "-F",
627
- "required_pull_request_reviews=null",
628
- "-F",
629
- "restrictions=null"
630
- ];
631
- await gh(args);
632
- },
633
- async setRepoSecret(repo, name, value) {
634
- await gh(["secret", "set", name, "--repo", repo, "--body", value]);
635
- },
636
- async repoExists(repo) {
637
- const r = await spawn2("gh", ["api", `repos/${repo}`], { env, timeoutMs: 6e4 });
638
- return r.code === 0;
639
- },
640
- async defaultBranch(repo) {
641
- const out = await gh(["api", `repos/${repo}`, "--jq", ".default_branch"]);
642
- return out.trim();
643
- },
644
- // filesOnBranch and branchProtectionContexts call `spawn` directly (not the
645
- // throwing `gh()` helper) because a 404 is an expected, meaningful answer —
646
- // "file/protection absent" — not an error. The remaining readers use `gh()`
647
- // since a non-200 there is a genuine failure (e.g. missing token scope).
648
- async filesOnBranch(repo, branch, paths) {
649
- const present = [];
650
- for (const p of paths) {
651
- const r = await spawn2("gh", [`api`, `repos/${repo}/contents/${p}?ref=${branch}`], {
652
- env,
653
- timeoutMs: 6e4
654
- });
655
- if (r.code === 0) present.push(p);
656
- }
657
- return present;
658
- },
659
- async branchProtectionContexts(repo, branch) {
660
- const r = await spawn2(
661
- "gh",
662
- [
663
- "api",
664
- `repos/${repo}/branches/${branch}/protection`,
665
- "--jq",
666
- ".required_status_checks.contexts[]?"
667
- ],
668
- { env, timeoutMs: 6e4 }
669
- );
670
- if (r.code !== 0) return [];
671
- return r.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
672
- },
673
- async secretExists(repo, name) {
674
- const out = await gh(["api", `repos/${repo}/actions/secrets`, "--jq", ".secrets[].name"]);
675
- return out.split("\n").map((l) => l.trim()).includes(name);
676
- },
677
- async autoMergeEnabled(repo) {
678
- const out = await gh(["api", `repos/${repo}`, "--jq", ".allow_auto_merge"]);
679
- return out.trim() === "true";
680
- },
681
- async findOpenSelfUpdatingPR(repo) {
682
- const out = await gh([
683
- "api",
684
- `repos/${repo}/pulls?state=open`,
685
- "--jq",
686
- '.[] | select(.head.ref | startswith("maint/self-updating-")) | .html_url'
687
- ]);
688
- const first = out.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
689
- return first ?? null;
690
- },
691
- async openPullRequests(repo) {
692
- const [owner, name, ...rest] = repo.split("/");
693
- if (!owner || !name || rest.length > 0) {
694
- throw new Error(`openPullRequests: expected "owner/repo", got "${repo}"`);
695
- }
696
- const query = "query($owner:String!,$name:String!){repository(owner:$owner,name:$name){pullRequests(states:OPEN,first:100,orderBy:{field:CREATED_AT,direction:DESC}){nodes{number title url headRefName commits(last:1){nodes{commit{statusCheckRollup{state}}}}}}}}";
697
- const out = await gh([
698
- "api",
699
- "graphql",
700
- "-f",
701
- `query=${query}`,
702
- "-F",
703
- `owner=${owner}`,
704
- "-F",
705
- `name=${name}`
706
- ]);
707
- const parsed = JSON.parse(out);
708
- const nodes = parsed.data?.repository?.pullRequests?.nodes ?? [];
709
- return nodes.map((n) => ({
710
- number: n.number,
711
- title: n.title,
712
- url: n.url,
713
- headRef: n.headRefName,
714
- ciState: mapRollupState(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state)
715
- }));
716
- },
717
- async defaultBranchStatus(repo) {
718
- const [owner, name, ...rest] = repo.split("/");
719
- if (!owner || !name || rest.length > 0) {
720
- throw new Error(`defaultBranchStatus: expected "owner/repo", got "${repo}"`);
721
- }
722
- const query = "query($owner:String!,$name:String!){repository(owner:$owner,name:$name){defaultBranchRef{target{... on Commit{committedDate statusCheckRollup{state}}}}}}";
723
- const out = await gh([
724
- "api",
725
- "graphql",
726
- "-f",
727
- `query=${query}`,
728
- "-F",
729
- `owner=${owner}`,
730
- "-F",
731
- `name=${name}`
732
- ]);
733
- const parsed = JSON.parse(out);
734
- const target = parsed.data?.repository?.defaultBranchRef?.target;
735
- return {
736
- ciState: mapRollupState(target?.statusCheckRollup?.state),
737
- lastCommitAt: target?.committedDate ?? null
738
- };
739
- }
740
- };
557
+ function isPendingApproval(r) {
558
+ return r.draftReady && !r.approvedToSend && r.sentAt === null;
741
559
  }
742
- var init_gh = __esm({
743
- "src/github/gh.ts"() {
744
- "use strict";
745
- init_spawn();
746
- }
747
- });
748
-
749
- // src/reports/airtable/reports.ts
750
560
  function mapRow2(rec) {
751
561
  const f = rec.fields;
752
562
  const linkSites = f["Site"] ?? [];
@@ -755,7 +565,7 @@ function mapRow2(rec) {
755
565
  id: rec.id,
756
566
  reportId: String(f["Report ID"] ?? ""),
757
567
  siteId: linkSites[0] ?? "",
758
- reportType: f["Report type"] ?? "Maintenance",
568
+ reportType: toReportType(f["Report type"]),
759
569
  period: f["Period"] ?? null,
760
570
  periodStart: f["Period start"] ?? null,
761
571
  periodEnd: f["Period end"] ?? null,
@@ -821,6 +631,16 @@ async function createDraft(base, input) {
821
631
  async function setDraftReady(base, recordId, ready) {
822
632
  await base(REPORTS_TABLE).update([{ id: recordId, fields: { "Draft ready": ready } }]);
823
633
  }
634
+ async function updateReportScores(base, recordId, scores, completedOn) {
635
+ const fields = {
636
+ "Lighthouse \u2014 Performance": scores.performance,
637
+ "Lighthouse \u2014 Accessibility": scores.accessibility,
638
+ "Lighthouse \u2014 Best Practices": scores.bestPractices,
639
+ "Lighthouse \u2014 SEO": scores.seo
640
+ };
641
+ if (completedOn) fields["Completed on"] = ymd(completedOn);
642
+ await base(REPORTS_TABLE).update([{ id: recordId, fields }]);
643
+ }
824
644
  async function listSendableReports(base) {
825
645
  const out = [];
826
646
  await base(REPORTS_TABLE).select({
@@ -844,13 +664,12 @@ async function listReportsForSite(base, siteId) {
844
664
  return (await listAllReports(base)).filter((r) => r.siteId === siteId);
845
665
  }
846
666
  async function stampSent(base, recordId, sentAt, messageId) {
667
+ const fields = { "Sent at": sentAt.toISOString() };
668
+ if (messageId !== null) fields["Resend message ID"] = messageId;
847
669
  await base(REPORTS_TABLE).update([
848
670
  {
849
671
  id: recordId,
850
- fields: {
851
- "Sent at": sentAt.toISOString(),
852
- "Resend message ID": messageId
853
- }
672
+ fields
854
673
  }
855
674
  ]);
856
675
  }
@@ -865,11 +684,12 @@ async function findReportByPeriod(base, siteId, reportType, period) {
865
684
  });
866
685
  return rows.find((r) => r.siteId === siteId) ?? null;
867
686
  }
868
- var REPORTS_TABLE;
687
+ var REPORTS_TABLE, REPORT_TYPES;
869
688
  var init_reports = __esm({
870
689
  "src/reports/airtable/reports.ts"() {
871
690
  "use strict";
872
691
  REPORTS_TABLE = "Reports";
692
+ REPORT_TYPES = ["Maintenance", "Testing", "Launch"];
873
693
  }
874
694
  });
875
695
 
@@ -937,18 +757,18 @@ var init_copy = __esm({
937
757
  // src/reports/maintenance-email/assets/index.ts
938
758
  import { readFile as readFile13 } from "fs/promises";
939
759
  import { existsSync as existsSync3 } from "fs";
940
- import { dirname as dirname4, join as join24 } from "path";
760
+ import { dirname as dirname4, join as join25 } from "path";
941
761
  import { fileURLToPath as fileURLToPath2 } from "url";
942
762
  function resolveAssetsDir() {
943
763
  if (cachedAssetsDir) return cachedAssetsDir;
944
764
  let dir = dirname4(fileURLToPath2(import.meta.url));
945
765
  while (true) {
946
- const srcCandidate = join24(dir, "src", "reports", "maintenance-email", "assets", "check.png");
766
+ const srcCandidate = join25(dir, "src", "reports", "maintenance-email", "assets", "check.png");
947
767
  if (existsSync3(srcCandidate)) {
948
768
  cachedAssetsDir = dirname4(srcCandidate);
949
769
  return cachedAssetsDir;
950
770
  }
951
- const distCandidate = join24(dir, "dist", "reports", "maintenance-email", "assets", "check.png");
771
+ const distCandidate = join25(dir, "dist", "reports", "maintenance-email", "assets", "check.png");
952
772
  if (existsSync3(distCandidate)) {
953
773
  cachedAssetsDir = dirname4(distCandidate);
954
774
  return cachedAssetsDir;
@@ -965,8 +785,8 @@ function resolveAssetsDir() {
965
785
  async function loadBundledImages() {
966
786
  const assetsDir = resolveAssetsDir();
967
787
  const [check, blurred] = await Promise.all([
968
- readFile13(join24(assetsDir, "check.png")),
969
- readFile13(join24(assetsDir, "blurredTests.jpg"))
788
+ readFile13(join25(assetsDir, "check.png")),
789
+ readFile13(join25(assetsDir, "blurredTests.jpg"))
970
790
  ]);
971
791
  return {
972
792
  check: {
@@ -993,9 +813,19 @@ var init_assets = __esm({
993
813
  }
994
814
  });
995
815
 
816
+ // src/util/html.ts
817
+ function escapeHtml(s) {
818
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
819
+ }
820
+ var init_html = __esm({
821
+ "src/util/html.ts"() {
822
+ "use strict";
823
+ }
824
+ });
825
+
996
826
  // src/reports/maintenance-email/template.ts
997
827
  function fmtDate(d) {
998
- if (!d) return "";
828
+ if (!d || Number.isNaN(d.getTime())) return "";
999
829
  const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
1000
830
  const dd = String(d.getUTCDate()).padStart(2, "0");
1001
831
  const yyyy = d.getUTCFullYear();
@@ -1004,9 +834,6 @@ function fmtDate(d) {
1004
834
  function fmtUsers(n) {
1005
835
  return n.toLocaleString("en-US");
1006
836
  }
1007
- function escapeXml(s) {
1008
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1009
- }
1010
837
  function trendText(color, text) {
1011
838
  return `<mj-text color="${color}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${text}</mj-text>`;
1012
839
  }
@@ -1024,14 +851,14 @@ function analyticsTrendLine(cur, prev) {
1024
851
  return trendText(TREND_NEUTRAL, `No change vs last period (${fmtUsers(prev)})`);
1025
852
  }
1026
853
  function maintenanceChecksSection(copy, searchPosition) {
1027
- const googleLabel = searchPosition !== void 0 ? `Page 1 Google Result (#${searchPosition})` : copy.maintenanceChecks[3];
854
+ const googleLabel = searchPosition !== void 0 ? `Page 1 Google Result (#${searchPosition})` : copy.maintenanceChecks[3] ?? "";
1028
855
  const rows = copy.maintenanceChecks.map((label, i) => i === 3 ? googleLabel : label);
1029
856
  return rows.map(
1030
857
  (label, i) => `
1031
858
  <mj-section background-color="white" padding="0px"${i === rows.length - 1 ? ' padding-bottom="36px"' : ""}>
1032
859
  <mj-group>
1033
860
  <mj-column padding-left="0px" width="90%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""}>
1034
- <mj-text height="25px" padding-left="0px" color="#757575" padding-top="20px" padding-bottom="7.5px" font-size="16px">${label}</mj-text>
861
+ <mj-text height="25px" padding-left="0px" color="#757575" padding-top="20px" padding-bottom="7.5px" font-size="16px">${escapeXml(label)}</mj-text>
1035
862
  </mj-column>
1036
863
  <mj-column width="10%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""} padding-top="15px">
1037
864
  <mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
@@ -1047,7 +874,7 @@ function testingChecklistSection(copy) {
1047
874
  <mj-section background-color="#F4F4F4" padding="0px"${i === rows.length - 1 ? ' padding-bottom="60px"' : ""}>
1048
875
  <mj-group>
1049
876
  <mj-column width="90%" padding-left="0px"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""}>
1050
- <mj-text height="25px" padding-left="0px" color="#757575" padding-top="20px" padding-bottom="7.5px" font-size="16px">${label}</mj-text>
877
+ <mj-text height="25px" padding-left="0px" color="#757575" padding-top="20px" padding-bottom="7.5px" font-size="16px">${escapeXml(label)}</mj-text>
1051
878
  </mj-column>
1052
879
  <mj-column width="10%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""} padding-top="15px">
1053
880
  <mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
@@ -1083,7 +910,7 @@ function commentarySection(text, copy) {
1083
910
  <mj-section background-color="white">
1084
911
  <mj-column>
1085
912
  <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">${escapeXml(copy.notesHeader)}</mj-text>
1086
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml(text).replace(/\n/g, "<br/>")}</mj-text>
913
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml(text).replace(/\r\n?|\n/g, "<br/>")}</mj-text>
1087
914
  </mj-column>
1088
915
  </mj-section>`;
1089
916
  }
@@ -1093,7 +920,7 @@ function hasHeaderDims(data) {
1093
920
  function headerImageTag(data) {
1094
921
  const src = `cid:${data.headerImageCid}`;
1095
922
  const alt = `${escapeXml(data.siteName)} maintenance report`;
1096
- const href = escapeXml(data.siteUrl);
923
+ const href = isHttpUrl(data.siteUrl) ? escapeXml(data.siteUrl) : "#";
1097
924
  if (hasHeaderDims(data)) {
1098
925
  return `<mj-image href="${href}" src="${src}" alt="${alt}" width="${data.headerWidth}px" css-class="rd-header" container-background-color="${data.headerBgColor}" />`;
1099
926
  }
@@ -1170,7 +997,7 @@ function buildMjml(data) {
1170
997
  (line, i) => i === copy.contact.length - 1 ? `<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" padding-top="0px" line-height="30px" padding-bottom="36px">${escapeXml(line)}</mj-text>` : `<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml(line)}</mj-text>`
1171
998
  ).join("\n ")}
1172
999
  <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
1173
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" line-height="20px" font-style="italic">Copyright ${(/* @__PURE__ */ new Date()).getUTCFullYear()} Reddoor Creative, LLC. All rights reserved.</mj-text>
1000
+ <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()} ${escapeXml(copy.footerOrg)}. All rights reserved.</mj-text>
1174
1001
  <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>
1175
1002
  ${[copy.footerOrg, ...copy.footerAddress].map(
1176
1003
  (line) => `<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml(line)}</mj-text>`
@@ -1180,12 +1007,15 @@ function buildMjml(data) {
1180
1007
  </mj-body>
1181
1008
  </mjml>`;
1182
1009
  }
1183
- var CHECK_PNG, BLURRED_TESTS, TREND_UP, TREND_NEUTRAL;
1010
+ var escapeXml, CHECK_PNG, BLURRED_TESTS, TREND_UP, TREND_NEUTRAL;
1184
1011
  var init_template = __esm({
1185
1012
  "src/reports/maintenance-email/template.ts"() {
1186
1013
  "use strict";
1187
1014
  init_copy();
1188
1015
  init_assets();
1016
+ init_html();
1017
+ init_url();
1018
+ escapeXml = escapeHtml;
1189
1019
  CHECK_PNG = `cid:${CHECK_CID}`;
1190
1020
  BLURRED_TESTS = `cid:${BLURRED_CID}`;
1191
1021
  TREND_UP = "#2E7D32";
@@ -1272,6 +1102,11 @@ var init_render = __esm({
1272
1102
  });
1273
1103
 
1274
1104
  // src/reports/airtable/attachments.ts
1105
+ function looksLikeHtml(bytes) {
1106
+ const start = bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191 ? 3 : 0;
1107
+ const head = Buffer.from(bytes.slice(start, start + 64)).toString("ascii").replace(/^[\s]+/, "").toLowerCase();
1108
+ return head.startsWith("<!doctype html") || head.startsWith("<html") || head.startsWith("<head");
1109
+ }
1275
1110
  async function fetchAttachmentBytes(url) {
1276
1111
  const res = await fetch(url);
1277
1112
  if (!res.ok) {
@@ -1281,7 +1116,14 @@ async function fetchAttachmentBytes(url) {
1281
1116
  }
1282
1117
  const contentType = res.headers.get("content-type") ?? "application/octet-stream";
1283
1118
  const ab = await res.arrayBuffer();
1284
- return { bytes: new Uint8Array(ab), contentType };
1119
+ const bytes = new Uint8Array(ab);
1120
+ const isImageType = contentType.toLowerCase().startsWith("image/");
1121
+ if (!isImageType && looksLikeHtml(bytes)) {
1122
+ throw new Error(
1123
+ `Airtable attachment did not return image data (content-type="${contentType}", body looks like an HTML page \u2014 the signed URL may have expired) (url=${url})`
1124
+ );
1125
+ }
1126
+ return { bytes, contentType };
1285
1127
  }
1286
1128
  async function uploadAttachment(recordId, fieldName, body, filename, contentType) {
1287
1129
  const apiKey = process.env.AIRTABLE_PAT;
@@ -1342,10 +1184,31 @@ var init_resend = __esm({
1342
1184
  }
1343
1185
  });
1344
1186
 
1187
+ // src/reports/send/idempotency.ts
1188
+ function isIdempotencyConflict(err) {
1189
+ const message = err instanceof Error ? err.message : String(err);
1190
+ if (/idempotency key has been used/i.test(message)) return true;
1191
+ const e = err;
1192
+ if (e.name === "invalid_idempotent_request") return true;
1193
+ if (e.statusCode === 409) return true;
1194
+ return false;
1195
+ }
1196
+ var init_idempotency = __esm({
1197
+ "src/reports/send/idempotency.ts"() {
1198
+ "use strict";
1199
+ }
1200
+ });
1201
+
1345
1202
  // src/alerts/digest-collectors.ts
1346
1203
  function dashboardUrl(baseUrl, siteName) {
1347
1204
  return `${baseUrl.replace(/\/$/, "")}/s/${siteSlug(siteName)}`;
1348
1205
  }
1206
+ function gitHubSignalsStale(swept, now) {
1207
+ if (swept === null) return true;
1208
+ const ageMs = now.getTime() - Date.parse(swept);
1209
+ if (!Number.isFinite(ageMs)) return true;
1210
+ return ageMs > GITHUB_SIGNALS_STALE_DAYS * MS_PER_DAY2;
1211
+ }
1349
1212
  function collectVulnAlerts(sites, baseUrl) {
1350
1213
  const items = [];
1351
1214
  for (const s of sites) {
@@ -1403,42 +1266,48 @@ function collectDeliveryFailures(reports, sitesById, baseUrl) {
1403
1266
  }
1404
1267
  return items;
1405
1268
  }
1406
- function renovateFindingsToAttention(result) {
1269
+ function collectRenovateAlerts(sites, baseUrl, now = /* @__PURE__ */ new Date()) {
1407
1270
  const items = [];
1408
- for (const f of result.findings) {
1271
+ for (const s of sites) {
1272
+ if (gitHubSignalsStale(s.githubSignalsAt, now)) continue;
1273
+ const n = s.renovateFailingCis ?? 0;
1274
+ if (n <= 0) continue;
1409
1275
  items.push({
1410
- key: `renovate:${f.repo}#${f.pr.number}`,
1276
+ key: `renovate:${s.id}`,
1411
1277
  kind: "renovate",
1412
- siteName: f.site,
1413
- title: `Renovate update failing CI: ${f.pr.title}`,
1414
- url: f.pr.url,
1278
+ siteName: s.name,
1279
+ title: `${n} Renovate ${n === 1 ? "PR" : "PRs"} failing CI`,
1280
+ url: dashboardUrl(baseUrl, s.name),
1415
1281
  severity: "warning",
1416
- metric: 1
1282
+ metric: n
1417
1283
  });
1418
1284
  }
1419
- if (result.skipped.length > 0) {
1285
+ return items;
1286
+ }
1287
+ function collectCiAlerts(sites, baseUrl, now = /* @__PURE__ */ new Date()) {
1288
+ const items = [];
1289
+ for (const s of sites) {
1290
+ if (gitHubSignalsStale(s.githubSignalsAt, now)) continue;
1291
+ if (s.defaultBranchCi !== "failing") continue;
1420
1292
  items.push({
1421
- key: "renovate:skipped",
1422
- kind: "renovate",
1423
- siteName: "Fleet checks",
1424
- title: `Couldn't check ${result.skipped.length} repo(s) for failing Renovate PRs`,
1293
+ key: `ci:${s.id}`,
1294
+ kind: "ci",
1295
+ siteName: s.name,
1296
+ title: "Default-branch CI failing",
1297
+ url: dashboardUrl(baseUrl, s.name),
1425
1298
  severity: "warning",
1426
- metric: result.skipped.length
1299
+ metric: 1
1427
1300
  });
1428
1301
  }
1429
1302
  return items;
1430
1303
  }
1431
- function buildRenovateProbe() {
1432
- const token = process.env.RENOVATE_TOKEN?.trim() || process.env.GH_TOKEN?.trim();
1433
- if (!token) return void 0;
1434
- return makeGitHub({ token }).openPullRequests;
1435
- }
1436
- var LIGHTHOUSE_FLOOR, LIGHTHOUSE_CATEGORIES2;
1304
+ var GITHUB_SIGNALS_STALE_DAYS, MS_PER_DAY2, LIGHTHOUSE_FLOOR, LIGHTHOUSE_CATEGORIES2;
1437
1305
  var init_digest_collectors = __esm({
1438
1306
  "src/alerts/digest-collectors.ts"() {
1439
1307
  "use strict";
1440
1308
  init_websites();
1441
- init_gh();
1309
+ GITHUB_SIGNALS_STALE_DAYS = 3;
1310
+ MS_PER_DAY2 = 24 * 60 * 60 * 1e3;
1442
1311
  LIGHTHOUSE_FLOOR = 75;
1443
1312
  LIGHTHOUSE_CATEGORIES2 = [
1444
1313
  { field: "pScore", slug: "performance", label: "Performance" },
@@ -1449,48 +1318,6 @@ var init_digest_collectors = __esm({
1449
1318
  }
1450
1319
  });
1451
1320
 
1452
- // src/alerts/renovate.ts
1453
- function isRenovatePR(pr) {
1454
- return RENOVATE_HEAD_PREFIXES.some((p) => pr.headRef.startsWith(p));
1455
- }
1456
- function isFailingRenovatePR(pr) {
1457
- return isRenovatePR(pr) && pr.ciState === "failing";
1458
- }
1459
- function siteLabel2(site) {
1460
- const display = site.meta?.["displayName"];
1461
- if (typeof display === "string" && display.length > 0) return display;
1462
- if (typeof site.name === "string" && site.name.length > 0) return site.name;
1463
- return "unknown";
1464
- }
1465
- async function collectRenovateFailures(sites, probe) {
1466
- const findings = [];
1467
- const skipped = [];
1468
- for (const site of sites) {
1469
- const repo = site.gitRepo;
1470
- if (!repo) continue;
1471
- let prs;
1472
- try {
1473
- prs = await probe(repo);
1474
- } catch {
1475
- skipped.push(repo);
1476
- continue;
1477
- }
1478
- for (const pr of prs) {
1479
- if (isFailingRenovatePR(pr)) {
1480
- findings.push({ site: siteLabel2(site), repo, pr });
1481
- }
1482
- }
1483
- }
1484
- return { findings, skipped };
1485
- }
1486
- var RENOVATE_HEAD_PREFIXES;
1487
- var init_renovate = __esm({
1488
- "src/alerts/renovate.ts"() {
1489
- "use strict";
1490
- RENOVATE_HEAD_PREFIXES = ["renovate/", "renovate-"];
1491
- }
1492
- });
1493
-
1494
1321
  // src/alerts/digest-state.ts
1495
1322
  function diffAttention(items, prior, today) {
1496
1323
  const tagged = [];
@@ -1563,9 +1390,6 @@ __export(digest_exports, {
1563
1390
  renderDigestHtml: () => renderDigestHtml,
1564
1391
  runDigest: () => runDigest
1565
1392
  });
1566
- function esc(s) {
1567
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1568
- }
1569
1393
  function readySection(items) {
1570
1394
  const heading = `<h2 style="color:${RED2};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Ready for your yes</h2>`;
1571
1395
  if (items.length === 0) {
@@ -1573,11 +1397,11 @@ function readySection(items) {
1573
1397
  }
1574
1398
  const rows = items.map((it) => {
1575
1399
  const safeUrl = it.dashboardUrl.startsWith("https://") ? it.dashboardUrl : void 0;
1576
- const link = safeUrl ? `<a href="${esc(safeUrl)}" style="${ANCHOR_STYLE}">review &amp; approve</a>` : `review &amp; approve`;
1400
+ const link = safeUrl ? `<a href="${escapeHtml(safeUrl)}" style="${ANCHOR_STYLE}">review &amp; approve</a>` : `review &amp; approve`;
1577
1401
  return `
1578
1402
  <tr>
1579
1403
  <td style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">
1580
- <strong style="color:#222">${esc(it.siteName)}</strong> \u2014 ${esc(it.reportType)} (${esc(it.period)})
1404
+ <strong style="color:#222">${escapeHtml(it.siteName)}</strong> \u2014 ${escapeHtml(it.reportType)} (${escapeHtml(it.period)})
1581
1405
  \u2014 ${link}
1582
1406
  </td>
1583
1407
  </tr>`;
@@ -1608,7 +1432,7 @@ function attentionSection(items) {
1608
1432
  );
1609
1433
  const rows = sorted.map((it) => {
1610
1434
  const safeUrl = it.url?.startsWith("https://") ? it.url : void 0;
1611
- const titleHtml = safeUrl ? `<a href="${esc(safeUrl)}" style="${ANCHOR_STYLE}">${esc(it.title)}</a>` : esc(it.title);
1435
+ const titleHtml = safeUrl ? `<a href="${escapeHtml(safeUrl)}" style="${ANCHOR_STYLE}">${escapeHtml(it.title)}</a>` : escapeHtml(it.title);
1612
1436
  return `
1613
1437
  <tr>
1614
1438
  <td style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">${attentionBadge(it.status)}${titleHtml}</td>
@@ -1616,7 +1440,7 @@ function attentionSection(items) {
1616
1440
  }).join("");
1617
1441
  return `
1618
1442
  <tr>
1619
- <td style="color:#222;font-family:helvetica,sans-serif;font-size:16px;font-weight:700;padding:8px 0 4px">${esc(siteName)}</td>
1443
+ <td style="color:#222;font-family:helvetica,sans-serif;font-size:16px;font-weight:700;padding:8px 0 4px">${escapeHtml(siteName)}</td>
1620
1444
  </tr>
1621
1445
  ${rows}`;
1622
1446
  }).join("");
@@ -1625,18 +1449,8 @@ function attentionSection(items) {
1625
1449
  function digestDateKey(d) {
1626
1450
  return d.toISOString().slice(0, 10);
1627
1451
  }
1628
- function isIdempotencyConflict(err) {
1629
- const message = err instanceof Error ? err.message : String(err);
1630
- if (/idempotency key has been used/i.test(message)) return true;
1631
- const e = err;
1632
- if (e.name === "invalid_idempotent_request") return true;
1633
- if (e.statusCode === 409) return true;
1634
- return false;
1635
- }
1636
1452
  async function listPendingApproval(base) {
1637
- return (await listAllReports(base)).filter(
1638
- (r) => r.draftReady && !r.approvedToSend && r.sentAt === null
1639
- );
1453
+ return (await listAllReports(base)).filter(isPendingApproval);
1640
1454
  }
1641
1455
  function runCollector(label, fn) {
1642
1456
  try {
@@ -1646,33 +1460,17 @@ function runCollector(label, fn) {
1646
1460
  return [];
1647
1461
  }
1648
1462
  }
1649
- async function runCollectorAsync(label, fn) {
1650
- try {
1651
- return await fn();
1652
- } catch (e) {
1653
- console.warn(`\u26A0 attention collector "${label}" failed: ${e.message}`);
1654
- return [];
1655
- }
1656
- }
1657
1463
  async function collectAttention(deps) {
1658
1464
  const reports = deps.reports ?? await listAllReports(deps.base);
1659
1465
  const websites = deps.websites ?? await listWebsites(deps.base);
1466
+ const now = deps.now ?? /* @__PURE__ */ new Date();
1660
1467
  const sitesById = new Map(websites.map((w) => [w.id, w]));
1661
- const renovate = deps.renovateProbe ? await runCollectorAsync("renovate", async () => {
1662
- const sites = websites.map((w) => ({
1663
- path: "",
1664
- name: w.name,
1665
- meta: {},
1666
- ...w.gitRepo ? { gitRepo: w.gitRepo } : {}
1667
- }));
1668
- const result = await collectRenovateFailures(sites, deps.renovateProbe);
1669
- return renovateFindingsToAttention(result);
1670
- }) : [];
1671
1468
  return [
1672
1469
  ...runCollector("vuln", () => collectVulnAlerts(websites, deps.baseUrl)),
1673
1470
  ...runCollector("delivery", () => collectDeliveryFailures(reports, sitesById, deps.baseUrl)),
1674
1471
  ...runCollector("lighthouse", () => collectLighthouseAlerts(websites, deps.baseUrl)),
1675
- ...renovate
1472
+ ...runCollector("renovate", () => collectRenovateAlerts(websites, deps.baseUrl, now)),
1473
+ ...runCollector("ci", () => collectCiAlerts(websites, deps.baseUrl, now))
1676
1474
  ];
1677
1475
  }
1678
1476
  async function runDigest(options) {
@@ -1682,25 +1480,26 @@ async function runDigest(options) {
1682
1480
  const reports = await listAllReports(base);
1683
1481
  const websites = await listWebsites(base);
1684
1482
  const sites = new Map(websites.map((w) => [w.id, w]));
1685
- const pending = reports.filter((r) => r.draftReady && !r.approvedToSend && r.sentAt === null);
1483
+ const pending = reports.filter(isPendingApproval);
1686
1484
  const readyForYourYes = [];
1485
+ const baseUrl = options.baseUrl.replace(/\/$/, "");
1687
1486
  for (const r of pending) {
1688
1487
  const site = sites.get(r.siteId);
1689
1488
  if (!site) continue;
1489
+ const slug = siteSlug(site.name);
1690
1490
  readyForYourYes.push({
1691
1491
  siteName: site.name,
1692
1492
  reportType: r.reportType,
1693
1493
  period: r.period ?? "\u2014",
1694
- dashboardUrl: `${options.baseUrl.replace(/\/$/, "")}/s/${siteSlug(site.name)}`
1494
+ dashboardUrl: slug ? `${baseUrl}/s/${slug}` : baseUrl
1695
1495
  });
1696
1496
  }
1697
- const renovateProbe = buildRenovateProbe();
1698
1497
  const collected = await collectAttention({
1699
1498
  base,
1700
1499
  baseUrl: options.baseUrl,
1701
1500
  websites,
1702
1501
  reports,
1703
- ...renovateProbe ? { renovateProbe } : {}
1502
+ now: today
1704
1503
  });
1705
1504
  const prior = await readDigestState(base);
1706
1505
  const { tagged, next } = diffAttention(collected, prior, digestDateKey(today));
@@ -1781,9 +1580,10 @@ var init_digest = __esm({
1781
1580
  init_reports();
1782
1581
  init_websites();
1783
1582
  init_resend();
1583
+ init_idempotency();
1784
1584
  init_digest_collectors();
1785
- init_renovate();
1786
1585
  init_digest_state();
1586
+ init_html();
1787
1587
  GREY2 = "#757575";
1788
1588
  RED2 = "#C00";
1789
1589
  ANCHOR_STYLE = `color:${RED2};font-family:helvetica,sans-serif`;
@@ -1892,30 +1692,6 @@ async function sendOne(client, base, site, report) {
1892
1692
  `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`
1893
1693
  );
1894
1694
  }
1895
- const original = await fetchAttachmentBytes(site.headerImage.url);
1896
- const header = await prepareHeaderImage(original.bytes);
1897
- const bundled = await loadBundledImages();
1898
- const slug = siteSlug(site.name);
1899
- const cidName = `${slug}-header`;
1900
- const { html } = await renderReportHtml({
1901
- siteName: site.name,
1902
- siteUrl: site.url,
1903
- reportType: report.reportType,
1904
- completedOn: report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date(),
1905
- lighthouse: report.lighthouse,
1906
- gaUsersCurrent: report.gaUsersCurrent ?? void 0,
1907
- gaUsersPrevious: report.gaUsersPrevious ?? void 0,
1908
- searchPosition: report.searchFoundPage1 && report.searchPosition !== null ? report.searchPosition : void 0,
1909
- lastTestedDate: report.lastTestedDate ? new Date(report.lastTestedDate) : null,
1910
- commentary: report.commentary,
1911
- copy: resolveCopy(site),
1912
- headerImageCid: cidName,
1913
- headerWidth: header.displayWidth,
1914
- headerHeight: header.displayHeight,
1915
- headerBgColor: header.placeholderColor
1916
- });
1917
- const reportDate = report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date();
1918
- const subject = report.subjectOverride ?? `${site.name} \u2014 ${monthYear(reportDate)} ${report.reportType} Report`;
1919
1695
  const explicitTo = parseAddresses(site.reportRecipientsTo);
1920
1696
  const fallbackTo = parseAddresses(site.pointOfContact);
1921
1697
  const to = explicitTo ?? fallbackTo ?? [];
@@ -1941,6 +1717,30 @@ async function sendOne(client, base, site, report) {
1941
1717
  }
1942
1718
  }
1943
1719
  }
1720
+ const original = await fetchAttachmentBytes(site.headerImage.url);
1721
+ const header = await prepareHeaderImage(original.bytes);
1722
+ const bundled = await loadBundledImages();
1723
+ const slug = siteSlug(site.name);
1724
+ const cidName = `${slug}-header`;
1725
+ const { html } = await renderReportHtml({
1726
+ siteName: site.name,
1727
+ siteUrl: site.url,
1728
+ reportType: report.reportType,
1729
+ completedOn: report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date(),
1730
+ lighthouse: report.lighthouse,
1731
+ gaUsersCurrent: report.gaUsersCurrent ?? void 0,
1732
+ gaUsersPrevious: report.gaUsersPrevious ?? void 0,
1733
+ searchPosition: report.searchFoundPage1 && report.searchPosition !== null ? report.searchPosition : void 0,
1734
+ lastTestedDate: report.lastTestedDate ? new Date(report.lastTestedDate) : null,
1735
+ commentary: report.commentary,
1736
+ copy: resolveCopy(site),
1737
+ headerImageCid: cidName,
1738
+ headerWidth: header.displayWidth,
1739
+ headerHeight: header.displayHeight,
1740
+ headerBgColor: header.placeholderColor
1741
+ });
1742
+ const reportDate = report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date();
1743
+ const subject = report.subjectOverride ?? `${site.name} \u2014 ${monthYear(reportDate)} ${report.reportType} Report`;
1944
1744
  const payload = {
1945
1745
  from: FROM_ADDRESS2,
1946
1746
  to,
@@ -1976,7 +1776,17 @@ async function sendOne(client, base, site, report) {
1976
1776
  idempotencyKey: `report:${report.id}`
1977
1777
  };
1978
1778
  if (cc) payload.cc = cc;
1979
- const result = await client.send(payload);
1779
+ let result;
1780
+ try {
1781
+ result = await client.send(payload);
1782
+ } catch (err) {
1783
+ if (isIdempotencyConflict(err)) {
1784
+ await stampSent(base, report.id, /* @__PURE__ */ new Date(), null);
1785
+ console.log(`\u21BB already sent (idempotency conflict), stamped: ${report.reportId}`);
1786
+ return "idempotent-conflict";
1787
+ }
1788
+ throw err;
1789
+ }
1980
1790
  await stampSent(base, report.id, /* @__PURE__ */ new Date(), result.messageId);
1981
1791
  return result.messageId;
1982
1792
  }
@@ -2016,6 +1826,7 @@ var init_orchestrate = __esm({
2016
1826
  init_assets();
2017
1827
  init_header_image();
2018
1828
  init_resend();
1829
+ init_idempotency();
2019
1830
  FROM_ADDRESS2 = "Reddoor Reports <reports@reddoorla.com>";
2020
1831
  REPLY_TO = "info@reddoorla.com";
2021
1832
  MONTHS2 = [
@@ -2045,8 +1856,85 @@ import { cac } from "cac";
2045
1856
  import { resolve as resolve2 } from "path";
2046
1857
  import { Listr } from "listr2";
2047
1858
 
2048
- // src/audits/index.ts
2049
- init_spawn();
1859
+ // src/audits/util/spawn.ts
1860
+ import { spawn } from "child_process";
1861
+ import { StringDecoder } from "string_decoder";
1862
+ var TRUNCATION_MARKER = "\n\u2026[output truncated]";
1863
+ function makeSpawn(internals = {}) {
1864
+ const spawnImpl = internals.spawnImpl ?? spawn;
1865
+ const killImpl = internals.killImpl ?? ((pid, sig) => process.kill(pid, sig));
1866
+ const killGraceMs = internals.killGraceMs ?? 5e3;
1867
+ const maxOutputBytes = internals.maxOutputBytes ?? 10 * 1024 * 1024;
1868
+ return (cmd, args, opts = {}) => new Promise((resolve12, reject) => {
1869
+ const streaming = opts.streaming === true;
1870
+ const child = spawnImpl(cmd, [...args], {
1871
+ cwd: opts.cwd,
1872
+ env: opts.env ?? process.env,
1873
+ stdio: streaming ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "pipe"],
1874
+ // Detach ONLY when a timeout can fire: the child then leads its own
1875
+ // process group, so the timeout can kill the WHOLE tree (vite, and
1876
+ // Chromium under lhci/playwright) via process.kill(-pid), not just the
1877
+ // npx/pnpm wrapper. Without it, killing the wrapper orphaned the
1878
+ // grandchildren — a zombie vite squatting its port, Chrome left running.
1879
+ // We do NOT detach timeout-less streaming calls (pnpm install/up):
1880
+ // detaching gains nothing there (no timeout → no group-kill) and would
1881
+ // break terminal Ctrl-C, which only reaches the foreground group — i.e.
1882
+ // it would re-orphan the very children this guards. We never unref() the
1883
+ // child since we still await it.
1884
+ detached: opts.timeoutMs !== void 0
1885
+ });
1886
+ const cap = (acc, chunk) => {
1887
+ if (acc.length >= maxOutputBytes) return acc;
1888
+ const next = acc + chunk;
1889
+ return next.length > maxOutputBytes ? next.slice(0, maxOutputBytes) + TRUNCATION_MARKER : next;
1890
+ };
1891
+ let stdout = "";
1892
+ let stderr = "";
1893
+ const outDecoder = new StringDecoder("utf-8");
1894
+ const errDecoder = new StringDecoder("utf-8");
1895
+ if (!streaming) {
1896
+ child.stdout?.on(
1897
+ "data",
1898
+ (chunk) => stdout = cap(stdout, outDecoder.write(chunk))
1899
+ );
1900
+ child.stderr?.on(
1901
+ "data",
1902
+ (chunk) => stderr = cap(stderr, errDecoder.write(chunk))
1903
+ );
1904
+ }
1905
+ const killGroup = (sig) => {
1906
+ if (child.pid === void 0) return;
1907
+ try {
1908
+ killImpl(-child.pid, sig);
1909
+ } catch {
1910
+ }
1911
+ };
1912
+ let killTimer;
1913
+ const timer = opts.timeoutMs ? setTimeout(() => {
1914
+ killGroup("SIGTERM");
1915
+ killTimer = setTimeout(() => killGroup("SIGKILL"), killGraceMs);
1916
+ killTimer.unref();
1917
+ reject(new Error(`spawn timeout after ${opts.timeoutMs}ms: ${cmd}`));
1918
+ }, opts.timeoutMs) : void 0;
1919
+ const clearTimers = () => {
1920
+ if (timer) clearTimeout(timer);
1921
+ if (killTimer) clearTimeout(killTimer);
1922
+ };
1923
+ child.on("error", (err) => {
1924
+ clearTimers();
1925
+ reject(err);
1926
+ });
1927
+ child.on("close", (code) => {
1928
+ clearTimers();
1929
+ if (!streaming) {
1930
+ stdout = cap(stdout, outDecoder.end());
1931
+ stderr = cap(stderr, errDecoder.end());
1932
+ }
1933
+ resolve12({ code: code ?? -1, stdout, stderr });
1934
+ });
1935
+ });
1936
+ }
1937
+ var defaultSpawn = makeSpawn();
2050
1938
 
2051
1939
  // src/audits/deps.ts
2052
1940
  import { readFile } from "fs/promises";
@@ -2054,7 +1942,7 @@ import { join as join3 } from "path";
2054
1942
 
2055
1943
  // src/util/site.ts
2056
1944
  function siteLabel(site) {
2057
- return site.name ?? site.path;
1945
+ return site.name || site.path;
2058
1946
  }
2059
1947
 
2060
1948
  // src/configs/baseline-versions.ts
@@ -2096,9 +1984,6 @@ var baselineVersions = {
2096
1984
  "@zerodevx/svelte-img": "^2.1.2"
2097
1985
  };
2098
1986
 
2099
- // src/audits/deps.ts
2100
- init_spawn();
2101
-
2102
1987
  // src/audits/deps-outdated.ts
2103
1988
  import { stat } from "fs/promises";
2104
1989
  import { join as join2 } from "path";
@@ -2278,7 +2163,6 @@ async function lintAudit(ctx) {
2278
2163
  }
2279
2164
 
2280
2165
  // src/audits/security.ts
2281
- init_spawn();
2282
2166
  function classify(v) {
2283
2167
  if (v.critical > 0 || v.high > 0) return "fail";
2284
2168
  if (v.moderate > 0 || v.low > 0) return "warn";
@@ -2453,9 +2337,6 @@ var lighthouseConfig = {
2453
2337
  }
2454
2338
  };
2455
2339
 
2456
- // src/audits/lighthouse.ts
2457
- init_spawn();
2458
-
2459
2340
  // src/audits/util/site-config.ts
2460
2341
  import { readFile as readFile3 } from "fs/promises";
2461
2342
  import { join as join5 } from "path";
@@ -2557,7 +2438,8 @@ function categoryFromAssertion(a) {
2557
2438
  return colonIdx >= 0 ? a.name.slice(colonIdx + 1) : a.name;
2558
2439
  }
2559
2440
  function messageForAssertion(a) {
2560
- return `${a.name} ${a.operator} ${a.expected} (actual: ${a.actual.toFixed(2)})`;
2441
+ const actual = typeof a.actual === "number" ? a.actual.toFixed(2) : "n/a";
2442
+ return `${a.name} ${a.operator} ${a.expected} (actual: ${actual})`;
2561
2443
  }
2562
2444
  async function parseLhciResults(resultsDir, label, raw) {
2563
2445
  const manifest = await readLhrEntries(resultsDir);
@@ -2724,7 +2606,6 @@ var playwrightA11yConfig = defineConfig({
2724
2606
  });
2725
2607
 
2726
2608
  // src/audits/a11y.ts
2727
- init_spawn();
2728
2609
  var RESULTS_REL = ".reddoor-a11y/results.json";
2729
2610
  async function readJsonMaybe2(path) {
2730
2611
  try {
@@ -2932,7 +2813,7 @@ function timedSpawn(timeoutMs) {
2932
2813
  async function runOneAudit(site, name) {
2933
2814
  if (!(name in REGISTRY)) throw new Error(`unknown audit: ${name}`);
2934
2815
  const spawn2 = timedSpawn(DEFAULT_AUDIT_TIMEOUT_MS);
2935
- const label = site.name ?? site.path;
2816
+ const label = site.name || site.path;
2936
2817
  try {
2937
2818
  return await REGISTRY[name]({ site, spawn: spawn2 });
2938
2819
  } catch (err) {
@@ -2959,11 +2840,12 @@ import { resolve, extname } from "path";
2959
2840
  // src/inventory/local.ts
2960
2841
  import { basename } from "path";
2961
2842
  function localPath(path, opts = {}) {
2962
- const site = { path, name: opts.name ?? basename(path) };
2843
+ const site = { path, name: opts.name || basename(path) };
2963
2844
  return async () => [site];
2964
2845
  }
2965
2846
 
2966
2847
  // src/inventory/json.ts
2848
+ init_url();
2967
2849
  import { readFile as readFile6 } from "fs/promises";
2968
2850
  import { isAbsolute } from "path";
2969
2851
  function validate(raw) {
@@ -2987,7 +2869,15 @@ function validate(raw) {
2987
2869
  if (typeof e.name === "string") site.name = e.name;
2988
2870
  if (typeof e.repoUrl === "string") site.repoUrl = e.repoUrl;
2989
2871
  if (typeof e.gitRepo === "string") site.gitRepo = e.gitRepo;
2990
- if (typeof e.deployedUrl === "string") site.deployedUrl = e.deployedUrl;
2872
+ if (typeof e.deployedUrl === "string") {
2873
+ if (isHttpUrl(e.deployedUrl)) {
2874
+ site.deployedUrl = e.deployedUrl;
2875
+ } else {
2876
+ console.warn(
2877
+ `[inventory] entry ${i}: ignoring deployedUrl that is not http(s): ${JSON.stringify(e.deployedUrl)}`
2878
+ );
2879
+ }
2880
+ }
2991
2881
  if (typeof e.meta === "object" && e.meta !== null) {
2992
2882
  site.meta = e.meta;
2993
2883
  }
@@ -2996,7 +2886,15 @@ function validate(raw) {
2996
2886
  }
2997
2887
  function fromJsonFile(path) {
2998
2888
  return async () => {
2999
- const raw = JSON.parse(await readFile6(path, "utf-8"));
2889
+ const text = await readFile6(path, "utf-8");
2890
+ let raw;
2891
+ try {
2892
+ raw = JSON.parse(text);
2893
+ } catch (e) {
2894
+ throw new Error(`could not parse inventory file ${path}: ${e.message}`, {
2895
+ cause: e
2896
+ });
2897
+ }
3000
2898
  return validate(raw);
3001
2899
  };
3002
2900
  }
@@ -3041,14 +2939,95 @@ async function resolveSites(input) {
3041
2939
  }
3042
2940
 
3043
2941
  // src/cli/fleet/clone-if-needed.ts
3044
- init_spawn();
3045
2942
  import { stat as stat2, readdir as readdir2, mkdir } from "fs/promises";
3046
2943
  import { isAbsolute as isAbsolute2, join as join8 } from "path";
2944
+
2945
+ // src/util/git.ts
2946
+ import { execFile } from "child_process";
2947
+ import { promisify } from "util";
2948
+ var exec = promisify(execFile);
2949
+ async function git(cwd, args) {
2950
+ return exec("git", args, { cwd, env: process.env });
2951
+ }
2952
+ function branchName(recipe, when = /* @__PURE__ */ new Date()) {
2953
+ const compact = when.toISOString().replace(/[-:.]/g, "");
2954
+ return `maint/${recipe}-${compact}`;
2955
+ }
2956
+ async function currentBranch(cwd) {
2957
+ const { stdout } = await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
2958
+ return stdout.trim();
2959
+ }
2960
+ async function isWorkingTreeClean(cwd) {
2961
+ const { stdout } = await git(cwd, ["status", "--porcelain"]);
2962
+ return stdout.trim().length === 0;
2963
+ }
2964
+ async function createBranch(cwd, name) {
2965
+ await git(cwd, ["checkout", "-b", name]);
2966
+ }
2967
+ async function checkoutBranch(cwd, name) {
2968
+ await git(cwd, ["checkout", name]);
2969
+ }
2970
+ async function forceCheckoutBranch(cwd, name) {
2971
+ await git(cwd, ["checkout", "-f", name]);
2972
+ }
2973
+ async function deleteBranch(cwd, name) {
2974
+ await git(cwd, ["branch", "-D", name]);
2975
+ }
2976
+ async function stageAll(cwd) {
2977
+ await git(cwd, ["add", "-A"]);
2978
+ }
2979
+ async function listTrackedFiles(cwd) {
2980
+ const { stdout } = await git(cwd, ["ls-files"]);
2981
+ return stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
2982
+ }
2983
+ async function removeFromIndex(cwd, paths) {
2984
+ if (paths.length === 0) return;
2985
+ await git(cwd, ["rm", "-r", "--cached", "--", ...paths]);
2986
+ }
2987
+ async function commit(cwd, message) {
2988
+ await stageAll(cwd);
2989
+ const { stdout: status } = await git(cwd, ["status", "--porcelain"]);
2990
+ if (status.trim().length === 0) return null;
2991
+ await git(cwd, ["commit", "-m", message]);
2992
+ const { stdout: sha } = await git(cwd, ["rev-parse", "HEAD"]);
2993
+ return sha.trim();
2994
+ }
2995
+ var OWNER_REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
2996
+ function isOwnerRepo(repo) {
2997
+ if (repo.includes("..")) return false;
2998
+ return OWNER_REPO_RE.test(repo);
2999
+ }
3000
+ function sameOwnerRepo(a, b) {
3001
+ const na = isOwnerRepo(a.trim()) ? a.trim() : parseOwnerRepo(a);
3002
+ const nb = isOwnerRepo(b.trim()) ? b.trim() : parseOwnerRepo(b);
3003
+ if (na === null || nb === null) return false;
3004
+ return na.toLowerCase() === nb.toLowerCase();
3005
+ }
3006
+ function parseOwnerRepo(remoteUrl) {
3007
+ const trimmed = remoteUrl.trim().replace(/\.git$/, "").replace(/\/$/, "");
3008
+ const scp = trimmed.match(/^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:(.+)$/);
3009
+ const path = scp ? scp[1] : trimmed.replace(/^https?:\/\/[^/]+\//, "");
3010
+ const segments = path.split("/").filter(Boolean);
3011
+ if (segments.length < 2) return null;
3012
+ return `${segments[segments.length - 2]}/${segments[segments.length - 1]}`;
3013
+ }
3014
+ async function getRemoteUrl(cwd) {
3015
+ const { stdout } = await git(cwd, ["remote", "get-url", "origin"]);
3016
+ return stdout.trim();
3017
+ }
3018
+ async function push(cwd, branch) {
3019
+ await git(cwd, ["push", "-u", "origin", branch]);
3020
+ }
3021
+
3022
+ // src/cli/fleet/clone-if-needed.ts
3047
3023
  function deriveNameFromRepoUrl(repoUrl) {
3048
3024
  const slash = repoUrl.split("/").pop() ?? repoUrl;
3049
3025
  return slash.replace(/\.git$/, "");
3050
3026
  }
3051
3027
  function assertSafeName(name) {
3028
+ if (name.length === 0) {
3029
+ throw new Error("unsafe site name (empty)");
3030
+ }
3052
3031
  if (isAbsolute2(name)) {
3053
3032
  throw new Error(`unsafe site name (absolute path not allowed): ${name}`);
3054
3033
  }
@@ -3076,6 +3055,24 @@ async function isNonEmptyDir(path) {
3076
3055
  return false;
3077
3056
  }
3078
3057
  }
3058
+ function expectedRepoRef(site) {
3059
+ return site.gitRepo ?? site.repoUrl ?? void 0;
3060
+ }
3061
+ async function assertCheckoutMatches(site, path, spawn2) {
3062
+ const expected = expectedRepoRef(site);
3063
+ if (!expected) return;
3064
+ const r = await spawn2("git", ["-C", path, "remote", "get-url", "origin"], {
3065
+ timeoutMs: 3e4
3066
+ });
3067
+ if (r.code !== 0) return;
3068
+ const origin = r.stdout.trim();
3069
+ if (origin.length === 0) return;
3070
+ if (!sameOwnerRepo(origin, expected)) {
3071
+ throw new Error(
3072
+ `checkout at ${path} is the wrong repo: origin is ${JSON.stringify(origin)} but site expects ${JSON.stringify(expected)} (slug collision?) \u2014 refusing to reuse it`
3073
+ );
3074
+ }
3075
+ }
3079
3076
  var GIT_REPO_RE = /^[\w.-]+\/[\w.-]+$/;
3080
3077
  function resolveCloneUrl(site) {
3081
3078
  if (site.repoUrl) return site.repoUrl;
@@ -3086,22 +3083,26 @@ function resolveCloneUrl(site) {
3086
3083
  return `https://github.com/${site.gitRepo}.git`;
3087
3084
  }
3088
3085
  async function cloneIfNeeded(site, opts) {
3089
- if (await isNonEmptyDir(site.path)) return site;
3086
+ const spawn2 = opts.spawn ?? defaultSpawn;
3087
+ if (await isNonEmptyDir(site.path)) {
3088
+ await assertCheckoutMatches(site, site.path, spawn2);
3089
+ return site;
3090
+ }
3090
3091
  const repoUrl = resolveCloneUrl(site);
3091
3092
  if (!repoUrl) {
3092
3093
  throw new Error(
3093
3094
  `site path does not exist (${site.path}) and no repoUrl or gitRepo is set \u2014 cannot clone`
3094
3095
  );
3095
3096
  }
3096
- const name = site.name ?? deriveNameFromRepoUrl(repoUrl);
3097
+ const name = site.name || deriveNameFromRepoUrl(repoUrl);
3097
3098
  assertSafeName(name);
3098
3099
  assertSafeRepoUrl(repoUrl);
3099
3100
  const target = join8(opts.workdir, name);
3100
3101
  await mkdir(opts.workdir, { recursive: true });
3101
3102
  if (await isNonEmptyDir(target)) {
3103
+ await assertCheckoutMatches(site, target, spawn2);
3102
3104
  return { ...site, name, path: target };
3103
3105
  }
3104
- const spawn2 = opts.spawn ?? defaultSpawn;
3105
3106
  const result = await spawn2("git", ["clone", "--", repoUrl, target], {
3106
3107
  cwd: opts.workdir,
3107
3108
  timeoutMs: 5 * 6e4
@@ -3112,6 +3113,52 @@ async function cloneIfNeeded(site, opts) {
3112
3113
  return { ...site, name, path: target };
3113
3114
  }
3114
3115
 
3116
+ // src/cli/fleet/prepare-sites.ts
3117
+ async function prepareFleetSites(sites, opts) {
3118
+ const clone = opts.clone ?? cloneIfNeeded;
3119
+ const needsCheckout = opts.needsCheckout ?? (() => true);
3120
+ const settled = await Promise.all(
3121
+ sites.map(async (site) => {
3122
+ if (!needsCheckout(site)) return { ok: true, site };
3123
+ try {
3124
+ return { ok: true, site: await clone(site, { workdir: opts.workdir }) };
3125
+ } catch (e) {
3126
+ return { ok: false, site: site.name || site.path, reason: e.message };
3127
+ }
3128
+ })
3129
+ );
3130
+ const prepared = [];
3131
+ const skipped = [];
3132
+ for (const r of settled) {
3133
+ if (r.ok) prepared.push(r.site);
3134
+ else skipped.push({ site: r.site, reason: r.reason });
3135
+ }
3136
+ return { prepared, skipped };
3137
+ }
3138
+ function formatSkippedNotice(skipped) {
3139
+ if (skipped.length === 0) return null;
3140
+ const detail = skipped.map((s) => `${s.site} (${s.reason})`).join("; ");
3141
+ return `\u26A0 ${skipped.length} site(s) skipped (could not prepare): ${detail}`;
3142
+ }
3143
+ function appendSkipNotice(output, skipped) {
3144
+ const notice = formatSkippedNotice(skipped);
3145
+ return notice ? `${output}
3146
+
3147
+ ${notice}` : output;
3148
+ }
3149
+
3150
+ // src/cli/commands/audit.ts
3151
+ init_url();
3152
+
3153
+ // src/util/fleet-workdir.ts
3154
+ import { tmpdir as tmpdir2 } from "os";
3155
+ import { join as join9 } from "path";
3156
+ function fleetWorkdir() {
3157
+ const home = process.env.HOME?.trim();
3158
+ const base = home ? home : tmpdir2();
3159
+ return join9(base, ".reddoor-maint", "sites");
3160
+ }
3161
+
3115
3162
  // src/cli/commands/audit.ts
3116
3163
  function parseOnly(value) {
3117
3164
  if (!value) return void 0;
@@ -3177,7 +3224,7 @@ function buildAuditTasks(sites, which, results, renderer, concurrency) {
3177
3224
  }
3178
3225
  return new Listr(
3179
3226
  sites.map((site) => {
3180
- const label = site.name ?? site.path;
3227
+ const label = site.name || site.path;
3181
3228
  return {
3182
3229
  title: label,
3183
3230
  task: async (_ctx, task) => {
@@ -3245,10 +3292,10 @@ function applyDeployedUrl(sites, url) {
3245
3292
  { exitCode: 2 }
3246
3293
  );
3247
3294
  }
3248
- try {
3249
- new URL(url);
3250
- } catch {
3251
- throw Object.assign(new Error(`--url is not a valid URL: ${url}`), { exitCode: 2 });
3295
+ if (!isHttpUrl(url)) {
3296
+ throw Object.assign(new Error(`--url must be an http(s) URL (got: ${JSON.stringify(url)})`), {
3297
+ exitCode: 2
3298
+ });
3252
3299
  }
3253
3300
  return [{ ...sites[0], deployedUrl: url }];
3254
3301
  }
@@ -3278,18 +3325,25 @@ async function runAuditCommand(site, opts) {
3278
3325
  cwd
3279
3326
  });
3280
3327
  sites = applyDeployedUrl(sites, opts.url);
3328
+ let skippedPrep = [];
3281
3329
  if (opts.fleet) {
3282
- const workdir = opts.workdir ?? `${process.env.HOME ?? ""}/.reddoor-maint/sites`;
3283
- sites = await Promise.all(
3284
- sites.map(
3285
- (s) => auditNeedsCheckout(s, which) ? cloneIfNeeded(s, { workdir }) : Promise.resolve(s)
3286
- )
3287
- );
3330
+ const workdir = opts.workdir ?? fleetWorkdir();
3331
+ const prep = await prepareFleetSites(sites, {
3332
+ workdir,
3333
+ needsCheckout: (s) => auditNeedsCheckout(s, which)
3334
+ });
3335
+ sites = prep.prepared;
3336
+ skippedPrep = prep.skipped;
3288
3337
  }
3289
3338
  const results = [];
3290
3339
  const renderer = rendererFor(opts.json);
3291
3340
  await buildAuditTasks(sites, which, results, renderer, parseConcurrency(opts.concurrency)).run();
3292
3341
  let output = opts.json ? JSON.stringify(results, null, 2) : formatTable(results);
3342
+ const skipNotice = formatSkippedNotice(skippedPrep);
3343
+ if (skipNotice && !opts.json) output += `
3344
+
3345
+ ${skipNotice}`;
3346
+ let writeBackFailed = false;
3293
3347
  if (opts.writeAirtable !== void 0) {
3294
3348
  const { openBase: openBase2, readAirtableConfig: readAirtableConfig2 } = await Promise.resolve().then(() => (init_client(), client_exports));
3295
3349
  const { listWebsites: listWebsites2 } = await Promise.resolve().then(() => (init_websites(), websites_exports));
@@ -3298,7 +3352,8 @@ async function runAuditCommand(site, opts) {
3298
3352
  const base = openBase2(readAirtableConfig2());
3299
3353
  const websites = await listWebsites2(base);
3300
3354
  const fleetWrite = await writeFleetAuditsToAirtable2({ base, websites, results });
3301
- output += `
3355
+ if (fleetWrite.failed.length > 0) writeBackFailed = true;
3356
+ if (!opts.json) output += `
3302
3357
 
3303
3358
  ${formatFleetWriteSummary2(fleetWrite)}`;
3304
3359
  } else {
@@ -3322,7 +3377,7 @@ ${formatFleetWriteSummary2(fleetWrite)}`;
3322
3377
  ],
3323
3378
  { renderer }
3324
3379
  ).run();
3325
- if (writeSummary) output += `
3380
+ if (writeSummary && !opts.json) output += `
3326
3381
 
3327
3382
  ${formatWriteSummary(writeSummary)}`;
3328
3383
  }
@@ -3331,16 +3386,20 @@ ${formatWriteSummary(writeSummary)}`;
3331
3386
  if (notice && !opts.json) output += `
3332
3387
 
3333
3388
  ${notice}`;
3334
- return { output, code: auditExitCode(results, opts.failOnViolations === true) };
3389
+ const code = Math.max(
3390
+ auditExitCode(results, opts.failOnViolations === true),
3391
+ writeBackFailed ? 1 : 0
3392
+ );
3393
+ return { output, code };
3335
3394
  }
3336
3395
 
3337
3396
  // src/cli/commands/sync-configs.ts
3338
3397
  import { readFile as readFile9 } from "fs/promises";
3339
- import { join as join11, resolve as resolve3 } from "path";
3398
+ import { join as join12, resolve as resolve3 } from "path";
3340
3399
 
3341
3400
  // src/recipes/sync-configs.ts
3342
3401
  import { readFile as readFile8, writeFile as writeFile3, mkdir as mkdir2 } from "fs/promises";
3343
- import { join as join10, dirname } from "path";
3402
+ import { join as join11, dirname } from "path";
3344
3403
 
3345
3404
  // src/recipes/sync-configs/templates.ts
3346
3405
  var eslint = {
@@ -3590,59 +3649,6 @@ function findTrackedArtifacts(tracked, canonical) {
3590
3649
  return matched;
3591
3650
  }
3592
3651
 
3593
- // src/util/git.ts
3594
- import { execFile } from "child_process";
3595
- import { promisify } from "util";
3596
- var exec = promisify(execFile);
3597
- async function git(cwd, args) {
3598
- return exec("git", args, { cwd, env: process.env });
3599
- }
3600
- function branchName(recipe, when = /* @__PURE__ */ new Date()) {
3601
- const compact = when.toISOString().replace(/[-:.]/g, "");
3602
- return `maint/${recipe}-${compact}`;
3603
- }
3604
- async function isWorkingTreeClean(cwd) {
3605
- const { stdout } = await git(cwd, ["status", "--porcelain"]);
3606
- return stdout.trim().length === 0;
3607
- }
3608
- async function createBranch(cwd, name) {
3609
- await git(cwd, ["checkout", "-b", name]);
3610
- }
3611
- async function stageAll(cwd) {
3612
- await git(cwd, ["add", "-A"]);
3613
- }
3614
- async function listTrackedFiles(cwd) {
3615
- const { stdout } = await git(cwd, ["ls-files"]);
3616
- return stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
3617
- }
3618
- async function removeFromIndex(cwd, paths) {
3619
- if (paths.length === 0) return;
3620
- await git(cwd, ["rm", "-r", "--cached", "--", ...paths]);
3621
- }
3622
- async function commit(cwd, message) {
3623
- await stageAll(cwd);
3624
- const { stdout: status } = await git(cwd, ["status", "--porcelain"]);
3625
- if (status.trim().length === 0) return null;
3626
- await git(cwd, ["commit", "-m", message]);
3627
- const { stdout: sha } = await git(cwd, ["rev-parse", "HEAD"]);
3628
- return sha.trim();
3629
- }
3630
- function parseOwnerRepo(remoteUrl) {
3631
- const trimmed = remoteUrl.trim().replace(/\.git$/, "").replace(/\/$/, "");
3632
- const scp = trimmed.match(/^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:(.+)$/);
3633
- const path = scp ? scp[1] : trimmed.replace(/^https?:\/\/[^/]+\//, "");
3634
- const segments = path.split("/").filter(Boolean);
3635
- if (segments.length < 2) return null;
3636
- return `${segments[segments.length - 2]}/${segments[segments.length - 1]}`;
3637
- }
3638
- async function getRemoteUrl(cwd) {
3639
- const { stdout } = await git(cwd, ["remote", "get-url", "origin"]);
3640
- return stdout.trim();
3641
- }
3642
- async function push(cwd, branch) {
3643
- await git(cwd, ["push", "-u", "origin", branch]);
3644
- }
3645
-
3646
3652
  // src/recipes/_with-recipe.ts
3647
3653
  async function withRecipe(body) {
3648
3654
  const label = siteLabel(body.site);
@@ -3671,19 +3677,60 @@ async function withRecipe(body) {
3671
3677
  if (!body.checkTreeFirst && !await isWorkingTreeClean(body.site.path)) {
3672
3678
  throw new Error(`refusing to run: working tree is not clean at ${body.site.path}`);
3673
3679
  }
3680
+ let original = null;
3681
+ try {
3682
+ original = await currentBranch(body.site.path);
3683
+ } catch {
3684
+ original = null;
3685
+ }
3674
3686
  const branch = branchName(body.name);
3675
3687
  await createBranch(body.site.path, branch);
3676
- const shas = [];
3677
- const result = await body.apply(planned.plan, {
3678
- cwd: body.site.path,
3679
- branch,
3680
- commit: async (msg) => {
3681
- const sha = await commit(body.site.path, msg);
3682
- if (sha) shas.push(sha);
3683
- return sha;
3688
+ const restoreOriginal = async () => {
3689
+ if (original === null || original === branch) return;
3690
+ try {
3691
+ await checkoutBranch(body.site.path, original);
3692
+ } catch (err) {
3693
+ console.warn(
3694
+ `warning: could not restore branch ${original} after ${body.name}: ${err instanceof Error ? err.message : String(err)}`
3695
+ );
3684
3696
  }
3685
- });
3697
+ };
3698
+ const restoreAfterFailure = async () => {
3699
+ if (original === null || original === branch) return;
3700
+ try {
3701
+ await forceCheckoutBranch(body.site.path, original);
3702
+ } catch (err) {
3703
+ console.warn(
3704
+ `warning: could not force-restore branch ${original} after failed ${body.name}: ${err instanceof Error ? err.message : String(err)}`
3705
+ );
3706
+ return;
3707
+ }
3708
+ try {
3709
+ await deleteBranch(body.site.path, branch);
3710
+ } catch (err) {
3711
+ console.warn(
3712
+ `warning: could not delete recipe branch ${branch} after failed ${body.name}: ${err instanceof Error ? err.message : String(err)}`
3713
+ );
3714
+ }
3715
+ };
3716
+ const shas = [];
3717
+ let result;
3718
+ try {
3719
+ result = await body.apply(planned.plan, {
3720
+ cwd: body.site.path,
3721
+ branch,
3722
+ commit: async (msg) => {
3723
+ const sha = await commit(body.site.path, msg);
3724
+ if (sha) shas.push(sha);
3725
+ return sha;
3726
+ }
3727
+ });
3728
+ } catch (err) {
3729
+ await restoreAfterFailure();
3730
+ throw err;
3731
+ }
3686
3732
  if (result.kind === "failed") {
3733
+ await restoreAfterFailure();
3687
3734
  return {
3688
3735
  recipe: body.name,
3689
3736
  site: label,
@@ -3692,6 +3739,9 @@ async function withRecipe(body) {
3692
3739
  notes: result.notes
3693
3740
  };
3694
3741
  }
3742
+ if (shas.length === 0) {
3743
+ await restoreOriginal();
3744
+ }
3695
3745
  const notes = result.notes ? `${result.notes}; branch: ${branch}` : `branch: ${branch}`;
3696
3746
  return {
3697
3747
  recipe: body.name,
@@ -3739,7 +3789,7 @@ async function readMaybe(path) {
3739
3789
  async function planTemplateDiffs(cwd, templates) {
3740
3790
  const diffs = [];
3741
3791
  for (const t of templates) {
3742
- const existing = await readMaybe(join10(cwd, t.path));
3792
+ const existing = await readMaybe(join11(cwd, t.path));
3743
3793
  if (existing === t.contents) continue;
3744
3794
  if (t.config === SVELTE_CONFIG && existing !== null && isSvelteConfigCompliant(existing)) {
3745
3795
  continue;
@@ -3752,7 +3802,7 @@ async function planTemplateDiffs(cwd, templates) {
3752
3802
  return diffs;
3753
3803
  }
3754
3804
  async function planGitignore(cwd) {
3755
- const existing = await readMaybe(join10(cwd, ".gitignore"));
3805
+ const existing = await readMaybe(join11(cwd, ".gitignore"));
3756
3806
  const merge = mergeGitignore(existing, CANONICAL_GITIGNORE_ENTRIES);
3757
3807
  const tracked = await listTrackedFiles(cwd);
3758
3808
  const toUntrack = findTrackedArtifacts(tracked, CANONICAL_GITIGNORE_ENTRIES);
@@ -3760,7 +3810,7 @@ async function planGitignore(cwd) {
3760
3810
  return { kind: "apply", content: merge.content, toUntrack, added: merge.added };
3761
3811
  }
3762
3812
  async function applyGitignore(cwd, plan) {
3763
- await writeFile3(join10(cwd, ".gitignore"), plan.content, "utf-8");
3813
+ await writeFile3(join11(cwd, ".gitignore"), plan.content, "utf-8");
3764
3814
  if (plan.toUntrack.length > 0) {
3765
3815
  await removeFromIndex(cwd, plan.toUntrack);
3766
3816
  }
@@ -3783,7 +3833,7 @@ async function syncConfigs(site, opts = {}) {
3783
3833
  },
3784
3834
  apply: async ({ templateDiffs, gitignorePlan }, { commit: commit2 }) => {
3785
3835
  for (const t of templateDiffs) {
3786
- const dest = join10(site.path, t.path);
3836
+ const dest = join11(site.path, t.path);
3787
3837
  await mkdir2(dirname(dest), { recursive: true });
3788
3838
  await writeFile3(dest, t.contents, "utf-8");
3789
3839
  await commit2(`chore: sync ${t.config} config from @reddoorla/maintenance`);
@@ -3814,7 +3864,7 @@ function parseOnly2(value) {
3814
3864
  async function dryPlanGitignore(cwd) {
3815
3865
  let existing;
3816
3866
  try {
3817
- existing = await readFile9(join11(cwd, ".gitignore"), "utf-8");
3867
+ existing = await readFile9(join12(cwd, ".gitignore"), "utf-8");
3818
3868
  } catch {
3819
3869
  return "would create .gitignore";
3820
3870
  }
@@ -3829,7 +3879,7 @@ async function dryPlan(cwd, which) {
3829
3879
  for (const t of templateTargets) {
3830
3880
  let existing = "";
3831
3881
  try {
3832
- existing = await readFile9(join11(cwd, t.path), "utf-8");
3882
+ existing = await readFile9(join12(cwd, t.path), "utf-8");
3833
3883
  } catch {
3834
3884
  }
3835
3885
  if (existing !== t.contents) lines.push(`would update ${t.path} (config: ${t.config})`);
@@ -3853,32 +3903,34 @@ async function runSyncConfigsCommand(site, opts) {
3853
3903
  ...opts.fleet !== void 0 ? { fleet: opts.fleet } : {},
3854
3904
  cwd
3855
3905
  });
3906
+ let skipped = [];
3856
3907
  if (opts.fleet) {
3857
- const workdir = opts.workdir ?? `${process.env.HOME ?? ""}/.reddoor-maint/sites`;
3858
- sites = await Promise.all(sites.map((s) => cloneIfNeeded(s, { workdir })));
3908
+ const workdir = opts.workdir ?? fleetWorkdir();
3909
+ const prep = await prepareFleetSites(sites, { workdir });
3910
+ sites = prep.prepared;
3911
+ skipped = prep.skipped;
3859
3912
  }
3860
3913
  if (opts.dry) {
3861
3914
  const blocks = [];
3862
3915
  for (const s of sites) {
3863
- blocks.push(`[${s.name ?? s.path}]
3916
+ blocks.push(`[${s.name || s.path}]
3864
3917
  ` + await dryPlan(s.path, which));
3865
3918
  }
3866
- return { output: blocks.join("\n\n"), code: 0 };
3919
+ return { output: appendSkipNotice(blocks.join("\n\n"), skipped), code: 0 };
3867
3920
  }
3868
3921
  const results = [];
3869
3922
  for (const s of sites) results.push(await syncConfigs(s, which ? { which } : {}));
3870
3923
  const output = results.map(formatResult).join("\n");
3871
3924
  const code = results.some((r) => r.status === "failed") ? 1 : 0;
3872
- return { output, code };
3925
+ return { output: appendSkipNotice(output, skipped), code };
3873
3926
  }
3874
3927
 
3875
3928
  // src/cli/commands/bump-deps.ts
3876
3929
  import { resolve as resolve4 } from "path";
3877
3930
 
3878
3931
  // src/recipes/bump-deps.ts
3879
- init_spawn();
3880
3932
  import { stat as stat3 } from "fs/promises";
3881
- import { join as join12 } from "path";
3933
+ import { join as join13 } from "path";
3882
3934
  async function exists2(path) {
3883
3935
  try {
3884
3936
  await stat3(path);
@@ -3907,10 +3959,10 @@ async function bumpDeps(site, opts = {}) {
3907
3959
  // land on top of whatever else was in the tree.
3908
3960
  checkTreeFirst: true,
3909
3961
  plan: async () => {
3910
- const hasPnpmLock = await exists2(join12(site.path, "pnpm-lock.yaml"));
3962
+ const hasPnpmLock = await exists2(join13(site.path, "pnpm-lock.yaml"));
3911
3963
  if (!hasPnpmLock) {
3912
- const hasNpmLock = await exists2(join12(site.path, "package-lock.json"));
3913
- const hasYarnLock = await exists2(join12(site.path, "yarn.lock"));
3964
+ const hasNpmLock = await exists2(join13(site.path, "package-lock.json"));
3965
+ const hasYarnLock = await exists2(join13(site.path, "yarn.lock"));
3914
3966
  if (hasNpmLock || hasYarnLock) {
3915
3967
  const competing = hasNpmLock ? "package-lock.json" : "yarn.lock";
3916
3968
  return {
@@ -3965,15 +4017,18 @@ async function runBumpDepsCommand(site, opts) {
3965
4017
  ...opts.fleet !== void 0 ? { fleet: opts.fleet } : {},
3966
4018
  cwd
3967
4019
  });
4020
+ let skipped = [];
3968
4021
  if (opts.fleet) {
3969
- const workdir = opts.workdir ?? `${process.env.HOME ?? ""}/.reddoor-maint/sites`;
3970
- sites = await Promise.all(sites.map((s) => cloneIfNeeded(s, { workdir })));
4022
+ const workdir = opts.workdir ?? fleetWorkdir();
4023
+ const prep = await prepareFleetSites(sites, { workdir });
4024
+ sites = prep.prepared;
4025
+ skipped = prep.skipped;
3971
4026
  }
3972
4027
  const results = [];
3973
4028
  for (const s of sites) results.push(await bumpDeps(s, { group }));
3974
4029
  const output = results.map(formatResult2).join("\n");
3975
4030
  const code = results.some((r) => r.status === "failed") ? 1 : 0;
3976
- return { output, code };
4031
+ return { output: appendSkipNotice(output, skipped), code };
3977
4032
  }
3978
4033
 
3979
4034
  // src/cli/commands/self-updating.ts
@@ -3981,7 +4036,7 @@ import { resolve as resolve5 } from "path";
3981
4036
 
3982
4037
  // src/recipes/self-updating/index.ts
3983
4038
  import { mkdir as mkdir3, writeFile as writeFile4 } from "fs/promises";
3984
- import { dirname as dirname2, join as join13 } from "path";
4039
+ import { dirname as dirname2, join as join14 } from "path";
3985
4040
 
3986
4041
  // src/github/config.ts
3987
4042
  function readGitHubConfig() {
@@ -3991,25 +4046,230 @@ function readGitHubConfig() {
3991
4046
  return { token, renovateToken };
3992
4047
  }
3993
4048
 
4049
+ // src/github/gh.ts
4050
+ function assertUrlSegment(kind, value) {
4051
+ const structural = /[\s?#%\\]|\.\./;
4052
+ if (value.length === 0 || value.startsWith("/") || structural.test(value)) {
4053
+ throw new Error(
4054
+ `unsafe ${kind} for gh api path (illegal characters or traversal): ${JSON.stringify(value)}`
4055
+ );
4056
+ }
4057
+ }
4058
+ function mapRollupState(state) {
4059
+ switch (state) {
4060
+ case "SUCCESS":
4061
+ return "passing";
4062
+ case "FAILURE":
4063
+ case "ERROR":
4064
+ return "failing";
4065
+ case "PENDING":
4066
+ case "EXPECTED":
4067
+ return "pending";
4068
+ default:
4069
+ return "none";
4070
+ }
4071
+ }
4072
+ function makeGitHub(deps) {
4073
+ const spawn2 = deps.spawn ?? defaultSpawn;
4074
+ const env = { ...process.env, GH_TOKEN: deps.token };
4075
+ async function gh(args) {
4076
+ const r = await spawn2("gh", args, { env, timeoutMs: 6e4 });
4077
+ if (r.code !== 0) throw new Error(`gh ${args[0]} failed (code ${r.code}): ${r.stderr.trim()}`);
4078
+ return r.stdout;
4079
+ }
4080
+ return {
4081
+ async openPullRequest(repo, pr) {
4082
+ const out = await gh([
4083
+ "pr",
4084
+ "create",
4085
+ "--repo",
4086
+ repo,
4087
+ "--head",
4088
+ pr.head,
4089
+ "--base",
4090
+ pr.base,
4091
+ "--title",
4092
+ pr.title,
4093
+ "--body",
4094
+ pr.body
4095
+ ]);
4096
+ return { url: out.trim() };
4097
+ },
4098
+ async enableRepoAutoMerge(repo) {
4099
+ await gh(["api", "-X", "PATCH", `repos/${repo}`, "-F", "allow_auto_merge=true"]);
4100
+ },
4101
+ async protectBranch(repo, branch, requiredChecks) {
4102
+ assertUrlSegment("branch", branch);
4103
+ const args = [
4104
+ "api",
4105
+ "-X",
4106
+ "PUT",
4107
+ `repos/${repo}/branches/${branch}/protection`,
4108
+ "-H",
4109
+ "Accept: application/vnd.github+json",
4110
+ "-F",
4111
+ "required_status_checks[strict]=true",
4112
+ ...requiredChecks.flatMap((c) => ["-f", `required_status_checks[contexts][]=${c}`]),
4113
+ "-F",
4114
+ "enforce_admins=true",
4115
+ "-F",
4116
+ "required_pull_request_reviews=null",
4117
+ "-F",
4118
+ "restrictions=null"
4119
+ ];
4120
+ await gh(args);
4121
+ },
4122
+ async setRepoSecret(repo, name, value) {
4123
+ await gh(["secret", "set", name, "--repo", repo, "--body", value]);
4124
+ },
4125
+ async repoExists(repo) {
4126
+ const r = await spawn2("gh", ["api", `repos/${repo}`], { env, timeoutMs: 6e4 });
4127
+ return r.code === 0;
4128
+ },
4129
+ async defaultBranch(repo) {
4130
+ const out = await gh(["api", `repos/${repo}`, "--jq", ".default_branch"]);
4131
+ return out.trim();
4132
+ },
4133
+ // filesOnBranch and branchProtectionContexts call `spawn` directly (not the
4134
+ // throwing `gh()` helper) because a 404 is an expected, meaningful answer —
4135
+ // "file/protection absent" — not an error. The remaining readers use `gh()`
4136
+ // since a non-200 there is a genuine failure (e.g. missing token scope).
4137
+ async filesOnBranch(repo, branch, paths) {
4138
+ assertUrlSegment("branch", branch);
4139
+ const present = [];
4140
+ for (const p of paths) {
4141
+ assertUrlSegment("path", p);
4142
+ const r = await spawn2("gh", [`api`, `repos/${repo}/contents/${p}?ref=${branch}`], {
4143
+ env,
4144
+ timeoutMs: 6e4
4145
+ });
4146
+ if (r.code === 0) present.push(p);
4147
+ }
4148
+ return present;
4149
+ },
4150
+ async branchProtectionContexts(repo, branch) {
4151
+ assertUrlSegment("branch", branch);
4152
+ const r = await spawn2(
4153
+ "gh",
4154
+ [
4155
+ "api",
4156
+ `repos/${repo}/branches/${branch}/protection`,
4157
+ "--jq",
4158
+ ".required_status_checks.contexts[]?"
4159
+ ],
4160
+ { env, timeoutMs: 6e4 }
4161
+ );
4162
+ if (r.code !== 0) return [];
4163
+ return r.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
4164
+ },
4165
+ async secretExists(repo, name) {
4166
+ const out = await gh(["api", `repos/${repo}/actions/secrets`, "--jq", ".secrets[].name"]);
4167
+ return out.split("\n").map((l) => l.trim()).includes(name);
4168
+ },
4169
+ async autoMergeEnabled(repo) {
4170
+ const out = await gh(["api", `repos/${repo}`, "--jq", ".allow_auto_merge"]);
4171
+ return out.trim() === "true";
4172
+ },
4173
+ async findOpenSelfUpdatingPR(repo) {
4174
+ const out = await gh([
4175
+ "api",
4176
+ `repos/${repo}/pulls?state=open`,
4177
+ "--jq",
4178
+ '.[] | select(.head.ref | startswith("maint/self-updating-")) | .html_url'
4179
+ ]);
4180
+ const first = out.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
4181
+ return first ?? null;
4182
+ },
4183
+ async openPullRequests(repo) {
4184
+ const [owner, name, ...rest] = repo.split("/");
4185
+ if (!owner || !name || rest.length > 0) {
4186
+ throw new Error(`openPullRequests: expected "owner/repo", got "${repo}"`);
4187
+ }
4188
+ 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}}}}}}}}";
4189
+ const out = await gh([
4190
+ "api",
4191
+ "graphql",
4192
+ "-f",
4193
+ `query=${query}`,
4194
+ "-F",
4195
+ `owner=${owner}`,
4196
+ "-F",
4197
+ `name=${name}`
4198
+ ]);
4199
+ const parsed = JSON.parse(out);
4200
+ const nodes = parsed.data?.repository?.pullRequests?.nodes ?? [];
4201
+ return nodes.map((n) => ({
4202
+ number: n.number,
4203
+ title: n.title,
4204
+ url: n.url,
4205
+ headRef: n.headRefName,
4206
+ ciState: mapRollupState(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state)
4207
+ }));
4208
+ },
4209
+ async defaultBranchStatus(repo) {
4210
+ const [owner, name, ...rest] = repo.split("/");
4211
+ if (!owner || !name || rest.length > 0) {
4212
+ throw new Error(`defaultBranchStatus: expected "owner/repo", got "${repo}"`);
4213
+ }
4214
+ const query = "query($owner:String!,$name:String!){repository(owner:$owner,name:$name){defaultBranchRef{target{... on Commit{committedDate statusCheckRollup{state}}}}}}";
4215
+ const out = await gh([
4216
+ "api",
4217
+ "graphql",
4218
+ "-f",
4219
+ `query=${query}`,
4220
+ "-F",
4221
+ `owner=${owner}`,
4222
+ "-F",
4223
+ `name=${name}`
4224
+ ]);
4225
+ const parsed = JSON.parse(out);
4226
+ const target = parsed.data?.repository?.defaultBranchRef?.target;
4227
+ return {
4228
+ ciState: mapRollupState(target?.statusCheckRollup?.state),
4229
+ lastCommitAt: target?.committedDate ?? null
4230
+ };
4231
+ }
4232
+ };
4233
+ }
4234
+
3994
4235
  // src/recipes/self-updating/index.ts
3995
- init_gh();
3996
4236
  var SELF_UPDATING_CONFIGS = ["ci", "renovate-action", "renovate-config"];
3997
4237
  var REQUIRED_CHECK = "ci / ci";
3998
4238
  function resultOf(site, status, notes, commits = []) {
3999
4239
  return { recipe: "self-updating", site: siteLabel(site), status, commits, notes };
4000
4240
  }
4001
4241
  async function resolveRepo(site) {
4002
- if (site.gitRepo) return site.gitRepo;
4242
+ if (site.gitRepo) {
4243
+ if (!isOwnerRepo(site.gitRepo)) {
4244
+ throw new Error(
4245
+ `refusing to act on malformed repo identity: expected "owner/repo", got ${JSON.stringify(site.gitRepo)}`
4246
+ );
4247
+ }
4248
+ return site.gitRepo;
4249
+ }
4250
+ let fromOrigin;
4003
4251
  try {
4004
- return parseOwnerRepo(await getRemoteUrl(site.path));
4252
+ fromOrigin = parseOwnerRepo(await getRemoteUrl(site.path));
4005
4253
  } catch {
4006
4254
  return null;
4007
4255
  }
4256
+ if (fromOrigin === null) return null;
4257
+ if (!isOwnerRepo(fromOrigin)) {
4258
+ throw new Error(
4259
+ `refusing to act on malformed repo identity from origin: ${JSON.stringify(fromOrigin)}`
4260
+ );
4261
+ }
4262
+ return fromOrigin;
4008
4263
  }
4009
4264
  async function selfUpdating(site, deps = {}) {
4010
4265
  const templates = templatesByName([...SELF_UPDATING_CONFIGS]);
4011
4266
  const paths = templates.map((t) => t.path);
4012
- const repo = await resolveRepo(site);
4267
+ let repo;
4268
+ try {
4269
+ repo = await resolveRepo(site);
4270
+ } catch (err) {
4271
+ return resultOf(site, "failed", err instanceof Error ? err.message : String(err));
4272
+ }
4013
4273
  if (!repo) {
4014
4274
  return resultOf(
4015
4275
  site,
@@ -4025,6 +4285,8 @@ async function selfUpdating(site, deps = {}) {
4025
4285
  const base = await github.defaultBranch(repo).catch(() => "main");
4026
4286
  const actions = [];
4027
4287
  const commits = [];
4288
+ let original = null;
4289
+ let maintBranch = null;
4028
4290
  try {
4029
4291
  const present = await github.filesOnBranch(repo, base, paths);
4030
4292
  if (present.length < paths.length) {
@@ -4035,10 +4297,15 @@ async function selfUpdating(site, deps = {}) {
4035
4297
  if (!await isWorkingTreeClean(site.path)) {
4036
4298
  return resultOf(site, "failed", "working tree not clean \u2014 commit or stash first");
4037
4299
  }
4038
- const branch = branchName("self-updating");
4039
- await createBranch(site.path, branch);
4300
+ try {
4301
+ original = await currentBranch(site.path);
4302
+ } catch {
4303
+ original = null;
4304
+ }
4305
+ maintBranch = branchName("self-updating");
4306
+ await createBranch(site.path, maintBranch);
4040
4307
  for (const t of templates) {
4041
- const dest = join13(site.path, t.path);
4308
+ const dest = join14(site.path, t.path);
4042
4309
  await mkdir3(dirname2(dest), { recursive: true });
4043
4310
  await writeFile4(dest, t.contents, "utf-8");
4044
4311
  }
@@ -4047,9 +4314,9 @@ async function selfUpdating(site, deps = {}) {
4047
4314
  "ci: enable self-updating (CI + Renovate auto-merge)"
4048
4315
  );
4049
4316
  if (sha) commits.push(sha);
4050
- await (deps.pushBranch ?? push)(site.path, branch);
4317
+ await (deps.pushBranch ?? push)(site.path, maintBranch);
4051
4318
  const pr = await github.openPullRequest(repo, {
4052
- head: branch,
4319
+ head: maintBranch,
4053
4320
  base,
4054
4321
  title: "Enable self-updating (CI + Renovate)",
4055
4322
  body: "Adds the unified CI gate, nightly Renovate, and auto-merge for patch/minor updates."
@@ -4061,8 +4328,10 @@ async function selfUpdating(site, deps = {}) {
4061
4328
  await github.enableRepoAutoMerge(repo);
4062
4329
  actions.push("enabled auto-merge");
4063
4330
  }
4064
- if (!(await github.branchProtectionContexts(repo, base)).includes(REQUIRED_CHECK)) {
4065
- await github.protectBranch(repo, base, [REQUIRED_CHECK]);
4331
+ const existingContexts = await github.branchProtectionContexts(repo, base);
4332
+ if (!existingContexts.includes(REQUIRED_CHECK)) {
4333
+ const contexts = [.../* @__PURE__ */ new Set([...existingContexts, REQUIRED_CHECK])];
4334
+ await github.protectBranch(repo, base, contexts);
4066
4335
  actions.push(`required "${REQUIRED_CHECK}" check on ${base}`);
4067
4336
  }
4068
4337
  if (!await github.secretExists(repo, "RENOVATE_TOKEN")) {
@@ -4073,6 +4342,16 @@ async function selfUpdating(site, deps = {}) {
4073
4342
  const done = actions.length ? ` (completed: ${actions.join("; ")})` : "";
4074
4343
  const message = err instanceof Error ? err.message : String(err);
4075
4344
  return resultOf(site, "failed", `${message}${done}`, commits);
4345
+ } finally {
4346
+ if (maintBranch !== null && original !== null && original !== maintBranch) {
4347
+ try {
4348
+ await checkoutBranch(site.path, original);
4349
+ } catch (err) {
4350
+ console.warn(
4351
+ `warning: could not restore branch ${original} after self-updating: ${err instanceof Error ? err.message : String(err)}`
4352
+ );
4353
+ }
4354
+ }
4076
4355
  }
4077
4356
  return actions.length ? resultOf(site, "applied", actions.join("; "), commits) : resultOf(site, "noop", "already self-updating", commits);
4078
4357
  }
@@ -4091,20 +4370,26 @@ async function runSelfUpdatingCommand(site, opts) {
4091
4370
  ...opts.fleet !== void 0 ? { fleet: opts.fleet } : {},
4092
4371
  cwd
4093
4372
  });
4373
+ let skipped = [];
4094
4374
  if (opts.fleet) {
4095
- const workdir = opts.workdir ?? `${process.env.HOME ?? ""}/.reddoor-maint/sites`;
4096
- sites = await Promise.all(sites.map((s) => cloneIfNeeded(s, { workdir })));
4375
+ const workdir = opts.workdir ?? fleetWorkdir();
4376
+ const prep = await prepareFleetSites(sites, { workdir });
4377
+ sites = prep.prepared;
4378
+ skipped = prep.skipped;
4097
4379
  }
4098
4380
  if (opts.dry) {
4099
4381
  return {
4100
- output: sites.map((s) => `[${s.name ?? s.path}] would enable self-updating`).join("\n"),
4382
+ output: appendSkipNotice(
4383
+ sites.map((s) => `[${s.name || s.path}] would enable self-updating`).join("\n"),
4384
+ skipped
4385
+ ),
4101
4386
  code: 0
4102
4387
  };
4103
4388
  }
4104
4389
  const results = [];
4105
4390
  for (const s of sites) results.push(await selfUpdating(s));
4106
4391
  return {
4107
- output: results.map(formatResult3).join("\n"),
4392
+ output: appendSkipNotice(results.map(formatResult3).join("\n"), skipped),
4108
4393
  code: results.some((r) => r.status === "failed") ? 1 : 0
4109
4394
  };
4110
4395
  }
@@ -4113,7 +4398,7 @@ async function runSelfUpdatingCommand(site, opts) {
4113
4398
  import { resolve as resolve6 } from "path";
4114
4399
 
4115
4400
  // src/recipes/svelte-5/index.ts
4116
- import { join as join19 } from "path";
4401
+ import { join as join20 } from "path";
4117
4402
 
4118
4403
  // src/util/pkg.ts
4119
4404
  import { readFile as readFile10, writeFile as writeFile5 } from "fs/promises";
@@ -4161,11 +4446,8 @@ function bumpDep(pkg, name, version2, opts = {}) {
4161
4446
  return next;
4162
4447
  }
4163
4448
 
4164
- // src/recipes/svelte-5/index.ts
4165
- init_spawn();
4166
-
4167
4449
  // src/recipes/svelte-5/step-bump-versions.ts
4168
- import { join as join14 } from "path";
4450
+ import { join as join15 } from "path";
4169
4451
  var SVELTE_5_VERSIONS = {
4170
4452
  svelte: "^5.55.5",
4171
4453
  "@sveltejs/kit": "^2.59.0",
@@ -4178,7 +4460,7 @@ var SVELTE_5_VERSIONS = {
4178
4460
  "typescript-svelte-plugin": "^0.3.52"
4179
4461
  };
4180
4462
  async function bumpToSvelte5Versions(cwd) {
4181
- const pkgPath = join14(cwd, "package.json");
4463
+ const pkgPath = join15(cwd, "package.json");
4182
4464
  const pkg = await readPackageJson(pkgPath);
4183
4465
  let next = pkg;
4184
4466
  for (const [name, version2] of Object.entries(SVELTE_5_VERSIONS)) {
@@ -4191,7 +4473,7 @@ async function bumpToSvelte5Versions(cwd) {
4191
4473
 
4192
4474
  // src/recipes/svelte-5/step-svelte-config.ts
4193
4475
  import { readFile as readFile11, writeFile as writeFile6 } from "fs/promises";
4194
- import { join as join15 } from "path";
4476
+ import { join as join16 } from "path";
4195
4477
  var VITE_PLUGIN_PKG = "@sveltejs/vite-plugin-svelte";
4196
4478
  var IMPORT_FROM_VITE_PLUGIN = new RegExp(
4197
4479
  String.raw`^import\s+\{\s*([^}]+?)\s*\}\s+from\s+["']` + VITE_PLUGIN_PKG.replace(/[/]/g, "\\/") + String.raw`["'];?[ \t]*\n`,
@@ -4232,7 +4514,7 @@ function dropPreprocessKey(source) {
4232
4514
  return source.slice(0, m.index) + source.slice(tailIdx).replace(new RegExp(`^${indent}\\n`), "");
4233
4515
  }
4234
4516
  async function migrateSvelteConfig(cwd) {
4235
- const path = join15(cwd, "svelte.config.js");
4517
+ const path = join16(cwd, "svelte.config.js");
4236
4518
  let src;
4237
4519
  try {
4238
4520
  src = await readFile11(path, "utf-8");
@@ -4248,7 +4530,6 @@ async function migrateSvelteConfig(cwd) {
4248
4530
  }
4249
4531
 
4250
4532
  // src/recipes/svelte-5/step-svelte-migrate.ts
4251
- init_spawn();
4252
4533
  async function runSvelteMigrate(cwd, spawn2 = defaultSpawn) {
4253
4534
  try {
4254
4535
  const { code, stderr } = await spawn2(
@@ -4270,10 +4551,9 @@ async function runSvelteMigrate(cwd, spawn2 = defaultSpawn) {
4270
4551
  }
4271
4552
 
4272
4553
  // src/recipes/svelte-5/step-tailwind-upgrade.ts
4273
- init_spawn();
4274
- import { join as join16 } from "path";
4554
+ import { join as join17 } from "path";
4275
4555
  async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
4276
- const pkg = await readPackageJson(join16(cwd, "package.json"));
4556
+ const pkg = await readPackageJson(join17(cwd, "package.json"));
4277
4557
  const tailwindVersion = pkg.devDependencies?.tailwindcss ?? pkg.dependencies?.tailwindcss;
4278
4558
  if (!tailwindVersion) return { ran: false, reason: "tailwindcss not installed" };
4279
4559
  if (/^\^?4\./.test(tailwindVersion)) return { ran: false, reason: "already on tailwind 4.x" };
@@ -4295,7 +4575,7 @@ async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
4295
4575
 
4296
4576
  // src/recipes/svelte-5/step-gotchas.ts
4297
4577
  import { readFile as readFile12, writeFile as writeFile7 } from "fs/promises";
4298
- import { join as join17 } from "path";
4578
+ import { join as join18 } from "path";
4299
4579
  import { glob as glob2 } from "tinyglobby";
4300
4580
 
4301
4581
  // src/recipes/svelte-5/codemods/on-event-to-handler.ts
@@ -4641,7 +4921,7 @@ async function planGotchaCodemods(cwd) {
4641
4921
  const changes = [];
4642
4922
  const relPaths = await glob2(SVELTE_GLOBS, { cwd, ignore: IGNORE2, absolute: false });
4643
4923
  for (const rel of relPaths) {
4644
- const path = join17(cwd, rel);
4924
+ const path = join18(cwd, rel);
4645
4925
  const before = await readFile12(path, "utf-8");
4646
4926
  const after = CODEMODS.reduce((s, fn) => fn(s), before);
4647
4927
  if (after !== before) changes.push({ rel, after });
@@ -4651,13 +4931,12 @@ async function planGotchaCodemods(cwd) {
4651
4931
  async function applyGotchaCodemods(cwd) {
4652
4932
  const changes = await planGotchaCodemods(cwd);
4653
4933
  for (const c of changes) {
4654
- await writeFile7(join17(cwd, c.rel), c.after, "utf-8");
4934
+ await writeFile7(join18(cwd, c.rel), c.after, "utf-8");
4655
4935
  }
4656
4936
  return { filesChanged: changes.length };
4657
4937
  }
4658
4938
 
4659
4939
  // src/recipes/svelte-5/step-verify.ts
4660
- init_spawn();
4661
4940
  async function verifyMigration(cwd, spawn2 = defaultSpawn) {
4662
4941
  let install;
4663
4942
  try {
@@ -4676,7 +4955,7 @@ async function verifyMigration(cwd, spawn2 = defaultSpawn) {
4676
4955
 
4677
4956
  // src/recipes/svelte-5/step-summary.ts
4678
4957
  import { writeFile as writeFile8 } from "fs/promises";
4679
- import { join as join18 } from "path";
4958
+ import { join as join19 } from "path";
4680
4959
  async function writeMigrationSummary(input) {
4681
4960
  const lines = [
4682
4961
  `# Svelte 4 \u2192 5 migration summary`,
@@ -4693,7 +4972,7 @@ async function writeMigrationSummary(input) {
4693
4972
  `- Verify Playwright a11y tests still pass.`
4694
4973
  ];
4695
4974
  const content = lines.join("\n") + "\n";
4696
- const path = join18(input.cwd, "MIGRATION_SVELTE_5.md");
4975
+ const path = join19(input.cwd, "MIGRATION_SVELTE_5.md");
4697
4976
  await writeFile8(path, content, "utf-8");
4698
4977
  return path;
4699
4978
  }
@@ -4701,7 +4980,7 @@ async function writeMigrationSummary(input) {
4701
4980
  // src/recipes/svelte-5/index.ts
4702
4981
  async function alreadyOnSvelte5(cwd) {
4703
4982
  try {
4704
- const pkg = await readPackageJson(join19(cwd, "package.json"));
4983
+ const pkg = await readPackageJson(join20(cwd, "package.json"));
4705
4984
  const v = pkg.devDependencies?.svelte ?? pkg.dependencies?.svelte;
4706
4985
  return !!v && /^\^?5\./.test(v);
4707
4986
  } catch {
@@ -4776,9 +5055,12 @@ async function runUpgradeCommand(upgradeName, site, opts = {}) {
4776
5055
  ...opts.fleet !== void 0 ? { fleet: opts.fleet } : {},
4777
5056
  cwd
4778
5057
  });
5058
+ let skipped = [];
4779
5059
  if (opts.fleet) {
4780
- const workdir = opts.workdir ?? `${process.env.HOME ?? ""}/.reddoor-maint/sites`;
4781
- sites = await Promise.all(sites.map((s) => cloneIfNeeded(s, { workdir })));
5060
+ const workdir = opts.workdir ?? fleetWorkdir();
5061
+ const prep = await prepareFleetSites(sites, { workdir });
5062
+ sites = prep.prepared;
5063
+ skipped = prep.skipped;
4782
5064
  }
4783
5065
  const results = [];
4784
5066
  for (const s of sites) {
@@ -4788,7 +5070,7 @@ async function runUpgradeCommand(upgradeName, site, opts = {}) {
4788
5070
  }
4789
5071
  const output = results.map(formatResult4).join("\n");
4790
5072
  const code = results.some((r) => r.status === "failed") ? 1 : 0;
4791
- return { output, code };
5073
+ return { output: appendSkipNotice(output, skipped), code };
4792
5074
  }
4793
5075
 
4794
5076
  // src/cli/commands/convert-to-pnpm.ts
@@ -4796,8 +5078,7 @@ import { resolve as resolve7 } from "path";
4796
5078
 
4797
5079
  // src/recipes/convert-to-pnpm.ts
4798
5080
  import { rm as rm3, stat as stat4 } from "fs/promises";
4799
- import { join as join20 } from "path";
4800
- init_spawn();
5081
+ import { join as join21 } from "path";
4801
5082
 
4802
5083
  // src/recipes/convert-to-pnpm/script-rewrites.ts
4803
5084
  function rewriteScriptForPnpm(script) {
@@ -4830,9 +5111,9 @@ async function exists3(path) {
4830
5111
  async function convertToPnpm(site, opts = {}) {
4831
5112
  const spawn2 = opts.spawn ?? defaultSpawn;
4832
5113
  const pnpmVersion = opts.pnpmVersion ?? DEFAULT_PNPM_VERSION;
4833
- const pnpmLockPath = join20(site.path, "pnpm-lock.yaml");
4834
- const npmLockPath = join20(site.path, "package-lock.json");
4835
- const yarnLockPath = join20(site.path, "yarn.lock");
5114
+ const pnpmLockPath = join21(site.path, "pnpm-lock.yaml");
5115
+ const npmLockPath = join21(site.path, "package-lock.json");
5116
+ const yarnLockPath = join21(site.path, "yarn.lock");
4836
5117
  return withRecipe({
4837
5118
  name: "convert-to-pnpm",
4838
5119
  site,
@@ -4855,7 +5136,7 @@ async function convertToPnpm(site, opts = {}) {
4855
5136
  if (hasYarnLock) await rm3(yarnLockPath, { force: true });
4856
5137
  const sourceLock = hasNpmLock ? "package-lock.json" : "yarn.lock";
4857
5138
  await commit2(`chore(pnpm): remove ${sourceLock}`);
4858
- const pkgPath = join20(cwd, "package.json");
5139
+ const pkgPath = join21(cwd, "package.json");
4859
5140
  const pkg = await readPackageJson(pkgPath);
4860
5141
  const next = { ...pkg, packageManager: `pnpm@${pnpmVersion}` };
4861
5142
  if (pkg.scripts && typeof pkg.scripts === "object") {
@@ -4868,7 +5149,7 @@ async function convertToPnpm(site, opts = {}) {
4868
5149
  }
4869
5150
  await writePackageJson(pkgPath, next);
4870
5151
  await commit2("chore(pnpm): pin packageManager + rewrite npm scripts");
4871
- await rm3(join20(cwd, "node_modules"), { recursive: true, force: true });
5152
+ await rm3(join21(cwd, "node_modules"), { recursive: true, force: true });
4872
5153
  const installResult = await spawn2("pnpm", ["install"], { cwd, streaming: true });
4873
5154
  if (installResult.code !== 0) {
4874
5155
  return { kind: "failed", notes: `pnpm install failed (exit ${installResult.code})` };
@@ -4893,15 +5174,18 @@ async function runConvertToPnpmCommand(site, opts) {
4893
5174
  ...opts.fleet !== void 0 ? { fleet: opts.fleet } : {},
4894
5175
  cwd
4895
5176
  });
5177
+ let skipped = [];
4896
5178
  if (opts.fleet) {
4897
- const workdir = opts.workdir ?? `${process.env.HOME ?? ""}/.reddoor-maint/sites`;
4898
- sites = await Promise.all(sites.map((s) => cloneIfNeeded(s, { workdir })));
5179
+ const workdir = opts.workdir ?? fleetWorkdir();
5180
+ const prep = await prepareFleetSites(sites, { workdir });
5181
+ sites = prep.prepared;
5182
+ skipped = prep.skipped;
4899
5183
  }
4900
5184
  const results = [];
4901
5185
  for (const s of sites) results.push(await convertToPnpm(s));
4902
5186
  const output = results.map(formatResult5).join("\n");
4903
5187
  const code = results.some((r) => r.status === "failed") ? 1 : 0;
4904
- return { output, code };
5188
+ return { output: appendSkipNotice(output, skipped), code };
4905
5189
  }
4906
5190
 
4907
5191
  // src/cli/commands/onboard.ts
@@ -4909,18 +5193,17 @@ import { resolve as resolve8 } from "path";
4909
5193
 
4910
5194
  // src/recipes/onboard.ts
4911
5195
  import { stat as stat5 } from "fs/promises";
4912
- import { join as join22 } from "path";
4913
- init_spawn();
5196
+ import { join as join23 } from "path";
4914
5197
 
4915
5198
  // src/util/self-version.ts
4916
5199
  import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
4917
5200
  import { fileURLToPath } from "url";
4918
- import { dirname as dirname3, join as join21 } from "path";
5201
+ import { dirname as dirname3, join as join22 } from "path";
4919
5202
  function selfPackageVersion(callerImportMetaUrl) {
4920
5203
  try {
4921
5204
  let dir = dirname3(fileURLToPath(callerImportMetaUrl));
4922
5205
  while (true) {
4923
- const candidate = join21(dir, "package.json");
5206
+ const candidate = join22(dir, "package.json");
4924
5207
  if (existsSync2(candidate)) {
4925
5208
  const raw = readFileSync2(candidate, "utf-8");
4926
5209
  const pkg = JSON.parse(raw);
@@ -4991,13 +5274,13 @@ async function onboard(site, opts = {}) {
4991
5274
  name: "onboard",
4992
5275
  site,
4993
5276
  plan: async () => {
4994
- if (!await exists4(join22(site.path, "pnpm-lock.yaml"))) {
5277
+ if (!await exists4(join23(site.path, "pnpm-lock.yaml"))) {
4995
5278
  return {
4996
5279
  kind: "failed",
4997
5280
  notes: "no pnpm-lock.yaml at site root \u2014 run convert-to-pnpm first"
4998
5281
  };
4999
5282
  }
5000
- const pkgPath = join22(site.path, "package.json");
5283
+ const pkgPath = join23(site.path, "package.json");
5001
5284
  const pkg = await readPackageJson(pkgPath);
5002
5285
  const toAdd = [];
5003
5286
  if (!isDeclared(pkg, PACKAGE_NAME)) {
@@ -5020,7 +5303,7 @@ async function onboard(site, opts = {}) {
5020
5303
  return { kind: "apply", plan: { pkg, toAdd } };
5021
5304
  },
5022
5305
  apply: async ({ pkg, toAdd }, { commit: commit2, cwd }) => {
5023
- const pkgPath = join22(cwd, "package.json");
5306
+ const pkgPath = join23(cwd, "package.json");
5024
5307
  let next = pkg;
5025
5308
  for (const dep of toAdd) {
5026
5309
  next = bumpDep(next, dep.name, dep.version);
@@ -5071,9 +5354,12 @@ async function runOnboardCommand(site, opts) {
5071
5354
  ...opts.fleet !== void 0 ? { fleet: opts.fleet } : {},
5072
5355
  cwd
5073
5356
  });
5357
+ let skipped = [];
5074
5358
  if (opts.fleet) {
5075
- const workdir = opts.workdir ?? `${process.env.HOME ?? ""}/.reddoor-maint/sites`;
5076
- sites = await Promise.all(sites.map((s) => cloneIfNeeded(s, { workdir })));
5359
+ const workdir = opts.workdir ?? fleetWorkdir();
5360
+ const prep = await prepareFleetSites(sites, { workdir });
5361
+ sites = prep.prepared;
5362
+ skipped = prep.skipped;
5077
5363
  }
5078
5364
  const results = [];
5079
5365
  for (const s of sites) {
@@ -5081,7 +5367,7 @@ async function runOnboardCommand(site, opts) {
5081
5367
  }
5082
5368
  const output = results.map(formatResult6).join("\n");
5083
5369
  const code = results.some((r) => r.status === "failed") ? 1 : 0;
5084
- return { output, code };
5370
+ return { output: appendSkipNotice(output, skipped), code };
5085
5371
  }
5086
5372
 
5087
5373
  // src/cli/commands/svelte-codemods.ts
@@ -5089,7 +5375,7 @@ import { resolve as resolve9 } from "path";
5089
5375
 
5090
5376
  // src/recipes/svelte-codemods.ts
5091
5377
  import { writeFile as writeFile9 } from "fs/promises";
5092
- import { join as join23 } from "path";
5378
+ import { join as join24 } from "path";
5093
5379
  async function svelteCodemods(site) {
5094
5380
  return withRecipe({
5095
5381
  name: "svelte-codemods",
@@ -5103,7 +5389,7 @@ async function svelteCodemods(site) {
5103
5389
  },
5104
5390
  apply: async (changes, { commit: commit2, cwd }) => {
5105
5391
  for (const c of changes) {
5106
- await writeFile9(join23(cwd, c.rel), c.after, "utf-8");
5392
+ await writeFile9(join24(cwd, c.rel), c.after, "utf-8");
5107
5393
  }
5108
5394
  await commit2(`refactor(svelte5): apply codemods (${changes.length} files)`);
5109
5395
  return { kind: "ok" };
@@ -5125,15 +5411,18 @@ async function runSvelteCodemodsCommand(site, opts) {
5125
5411
  ...opts.fleet !== void 0 ? { fleet: opts.fleet } : {},
5126
5412
  cwd
5127
5413
  });
5414
+ let skipped = [];
5128
5415
  if (opts.fleet) {
5129
- const workdir = opts.workdir ?? `${process.env.HOME ?? ""}/.reddoor-maint/sites`;
5130
- sites = await Promise.all(sites.map((s) => cloneIfNeeded(s, { workdir })));
5416
+ const workdir = opts.workdir ?? fleetWorkdir();
5417
+ const prep = await prepareFleetSites(sites, { workdir });
5418
+ sites = prep.prepared;
5419
+ skipped = prep.skipped;
5131
5420
  }
5132
5421
  const results = [];
5133
5422
  for (const s of sites) results.push(await svelteCodemods(s));
5134
5423
  const output = results.map(formatResult7).join("\n");
5135
5424
  const code = results.some((r) => r.status === "failed") ? 1 : 0;
5136
- return { output, code };
5425
+ return { output: appendSkipNotice(output, skipped), code };
5137
5426
  }
5138
5427
 
5139
5428
  // src/cli/commands/report.ts
@@ -5179,8 +5468,15 @@ function findDueReports(websites, reports, today) {
5179
5468
  for (const site of websites) {
5180
5469
  if (site.status !== null && !ELIGIBLE_STATUSES.has(site.status)) continue;
5181
5470
  for (const type of ["Maintenance", "Testing"]) {
5182
- const freq = type === "Maintenance" ? site.maintenanceFreq : site.testingFreq;
5183
- if (freq === "None") continue;
5471
+ const rawFreq = type === "Maintenance" ? site.maintenanceFreq : site.testingFreq;
5472
+ const freq = typeof rawFreq === "string" ? rawFreq.trim() : rawFreq;
5473
+ if (freq === "None" || freq === "") continue;
5474
+ if (!(freq in MONTHS)) {
5475
+ console.warn(
5476
+ `\u26A0 ${site.name}: unrecognized ${type === "Maintenance" ? "maintenance" : "testing"} frequency '${rawFreq}' \u2014 not scheduling; fix the Airtable value`
5477
+ );
5478
+ continue;
5479
+ }
5184
5480
  const lastSent = lastSentForType(reports, site.id, type);
5185
5481
  const fallback = type === "Maintenance" ? site.maintenanceDay : site.testingDay;
5186
5482
  const baseIso = lastSent ?? fallback;
@@ -5214,11 +5510,11 @@ import { dirname as dirname6 } from "path";
5214
5510
 
5215
5511
  // src/reports/ga/config.ts
5216
5512
  init_credentials();
5217
- import { dirname as dirname5, join as join25 } from "path";
5513
+ import { dirname as dirname5, join as join26 } from "path";
5218
5514
  function readGaConfig() {
5219
5515
  const subject = process.env.GA_SUBJECT?.trim();
5220
5516
  if (!subject) return null;
5221
- const keyPath = process.env.GA_SA_KEY_PATH?.trim() || join25(dirname5(defaultCredentialsPath()), "ga-service-account.json");
5517
+ const keyPath = process.env.GA_SA_KEY_PATH?.trim() || join26(dirname5(defaultCredentialsPath()), "ga-service-account.json");
5222
5518
  return { subject, keyPath };
5223
5519
  }
5224
5520
 
@@ -5377,6 +5673,10 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
5377
5673
  return { reportRow: null, htmlPath: path, html, softFailures };
5378
5674
  }
5379
5675
  if (base === null) throw new Error("base required when previewOnly=false");
5676
+ if (options.completeRowId) {
5677
+ await finishDraftRow(base, options.completeRowId, slug, periodEnd, html);
5678
+ return { reportRow: options.existingRow ?? null, htmlPath: null, html, softFailures };
5679
+ }
5380
5680
  const reportId = `${siteRow.name} \u2014 ${reportType} \u2014 ${periodEnd.toISOString().slice(0, 10)}`;
5381
5681
  const created = await createDraft(base, {
5382
5682
  reportId,
@@ -5392,11 +5692,14 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
5392
5692
  ...search ? { searchFoundPage1: search.foundOnPage1 } : {},
5393
5693
  ...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {}
5394
5694
  });
5395
- const htmlFilename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;
5396
- await uploadAttachment(created.id, "Rendered HTML", html, htmlFilename, "text/html");
5397
- await setDraftReady(base, created.id, true);
5695
+ await finishDraftRow(base, created.id, slug, periodEnd, html);
5398
5696
  return { reportRow: created, htmlPath: null, html, softFailures };
5399
5697
  }
5698
+ async function finishDraftRow(base, rowId, slug, periodEnd, html) {
5699
+ const htmlFilename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;
5700
+ await uploadAttachment(rowId, "Rendered HTML", html, htmlFilename, "text/html");
5701
+ await setDraftReady(base, rowId, true);
5702
+ }
5400
5703
  var NO_ENRICHMENT = { value: null, softFailed: false };
5401
5704
  async function fetchGaUsers(siteRow, periodStart, periodEnd) {
5402
5705
  const cfg = readGaConfig();
@@ -5484,12 +5787,39 @@ async function draftDueReports(base, today) {
5484
5787
  let skipped = 0;
5485
5788
  for (const item of due) {
5486
5789
  const period = reportPeriodKey(item.dueDate);
5487
- const already = reports.some(
5790
+ const existing = reports.find(
5488
5791
  (r) => r.siteId === item.site.id && r.reportType === item.reportType && r.period === period
5489
5792
  );
5490
- if (already) {
5793
+ if (existing) {
5794
+ if (existing.draftReady) {
5795
+ skipped++;
5796
+ lines.push(`\u2022 skipped (already drafted ${period}): ${item.site.name} ${item.reportType}`);
5797
+ continue;
5798
+ }
5799
+ try {
5800
+ const result = await draftReportForSite(base, item.site, item.reportType, {
5801
+ period,
5802
+ completeRowId: existing.id,
5803
+ existingRow: existing
5804
+ });
5805
+ existing.draftReady = true;
5806
+ lines.push(
5807
+ `\u2713 completed half-made draft: ${result.reportRow?.reportId ?? existing.reportId}`
5808
+ );
5809
+ if (result.softFailures.length > 0) softFailedSites++;
5810
+ } catch (e) {
5811
+ lines.push(`\u2717 failed: ${item.site.name} ${item.reportType} \u2014 ${e.message}`);
5812
+ }
5813
+ continue;
5814
+ }
5815
+ const pendingEarlier = reports.find(
5816
+ (r) => r.siteId === item.site.id && r.reportType === item.reportType && r.sentAt === null && r.period !== null && r.period < period
5817
+ );
5818
+ if (pendingEarlier) {
5491
5819
  skipped++;
5492
- lines.push(`\u2022 skipped (already drafted ${period}): ${item.site.name} ${item.reportType}`);
5820
+ lines.push(
5821
+ `\u2022 skipped: ${item.site.name} ${item.reportType} already has an unsent ${pendingEarlier.period} draft pending approval`
5822
+ );
5493
5823
  continue;
5494
5824
  }
5495
5825
  try {
@@ -5502,7 +5832,7 @@ async function draftDueReports(base, today) {
5502
5832
  }
5503
5833
  }
5504
5834
  if (skipped > 0) {
5505
- lines.push(`\u2022 ${skipped} already drafted this period`);
5835
+ lines.push(`\u2022 ${skipped} already drafted or pending this period`);
5506
5836
  }
5507
5837
  if (softFailedSites > 0) {
5508
5838
  lines.push(
@@ -5532,7 +5862,7 @@ import { resolve as resolve10 } from "path";
5532
5862
 
5533
5863
  // src/recipes/a11y-fixtures-page/index.ts
5534
5864
  import { access, mkdir as mkdir5, writeFile as writeFile11 } from "fs/promises";
5535
- import { dirname as dirname7, join as join26 } from "path";
5865
+ import { dirname as dirname7, join as join27 } from "path";
5536
5866
 
5537
5867
  // src/recipes/a11y-fixtures-page/template.ts
5538
5868
  var A11Y_FIXTURES_PAGE_RELATIVE = "src/routes/dev/a11y-fixtures/+page.svelte";
@@ -5583,7 +5913,7 @@ async function fileExists(path) {
5583
5913
  }
5584
5914
  }
5585
5915
  async function a11yFixturesPage(site) {
5586
- const target = join26(site.path, A11Y_FIXTURES_PAGE_RELATIVE);
5916
+ const target = join27(site.path, A11Y_FIXTURES_PAGE_RELATIVE);
5587
5917
  return withRecipe({
5588
5918
  name: "a11y-fixtures-page",
5589
5919
  site,
@@ -5678,15 +6008,18 @@ async function runInitCommand(site, opts) {
5678
6008
  ...opts.fleet !== void 0 ? { fleet: opts.fleet } : {},
5679
6009
  cwd
5680
6010
  });
6011
+ let skipped = [];
5681
6012
  if (opts.fleet) {
5682
- const workdir = opts.workdir ?? `${process.env.HOME ?? ""}/.reddoor-maint/sites`;
5683
- sites = await Promise.all(sites.map((s) => cloneIfNeeded(s, { workdir })));
6013
+ const workdir = opts.workdir ?? fleetWorkdir();
6014
+ const prep = await prepareFleetSites(sites, { workdir });
6015
+ sites = prep.prepared;
6016
+ skipped = prep.skipped;
5684
6017
  }
5685
6018
  const results = [];
5686
6019
  for (const s of sites) results.push(await init(s));
5687
6020
  const output = results.map(formatResult8).join("\n\n");
5688
6021
  const code = results.some((r) => exitCodeFor(r) !== 0) ? 1 : 0;
5689
- return { output, code };
6022
+ return { output: appendSkipNotice(output, skipped), code };
5690
6023
  }
5691
6024
 
5692
6025
  // src/cli/commands/launch.ts
@@ -5755,7 +6088,12 @@ async function launch(site, deps = {}) {
5755
6088
  let report;
5756
6089
  try {
5757
6090
  const existing = await findReportByPeriod(base, target.id, "Launch", period);
5758
- report = existing ?? await createDraft(base, draftInputFor(target, scores, today, period));
6091
+ if (existing) {
6092
+ await updateReportScores(base, existing.id, scores, today);
6093
+ report = existing;
6094
+ } else {
6095
+ report = await createDraft(base, draftInputFor(target, scores, today, period));
6096
+ }
5759
6097
  } catch (err) {
5760
6098
  steps.push({ name: "draft", result: errorOf(err) });
5761
6099
  return stop();
@@ -5849,8 +6187,16 @@ async function runLaunchCommand(site, opts) {
5849
6187
  init_client();
5850
6188
  init_websites();
5851
6189
 
6190
+ // src/alerts/renovate.ts
6191
+ var RENOVATE_HEAD_PREFIXES = ["renovate/", "renovate-"];
6192
+ function isRenovatePR(pr) {
6193
+ return RENOVATE_HEAD_PREFIXES.some((p) => pr.headRef.startsWith(p));
6194
+ }
6195
+ function isFailingRenovatePR(pr) {
6196
+ return isRenovatePR(pr) && pr.ciState === "failing";
6197
+ }
6198
+
5852
6199
  // src/audits/github-signals.ts
5853
- init_renovate();
5854
6200
  async function collectGitHubSignals(sites, deps, onSkip = () => {
5855
6201
  }) {
5856
6202
  const rows = [];
@@ -5877,8 +6223,10 @@ async function collectGitHubSignals(sites, deps, onSkip = () => {
5877
6223
  }
5878
6224
 
5879
6225
  // src/cli/commands/github-signals.ts
5880
- init_gh();
5881
6226
  init_write_audits_to_airtable();
6227
+ function githubSignalsExitCode(written, failed) {
6228
+ return failed > written ? 1 : 0;
6229
+ }
5882
6230
  async function runGitHubSignalsCommand(opts) {
5883
6231
  if (!opts.fleet || !opts.writeAirtable) {
5884
6232
  return { output: "github-signals currently supports only --fleet --write-airtable", code: 2 };
@@ -5935,18 +6283,18 @@ async function runGitHubSignalsCommand(opts) {
5935
6283
  for (const repo of skipped) result.failed.push({ slug: repo, error: "probe failed (skipped)" });
5936
6284
  return {
5937
6285
  output: formatFleetWriteSummary(result),
5938
- code: result.failed.length > 0 && result.written.length === 0 ? 1 : 0
6286
+ code: githubSignalsExitCode(result.written.length, result.failed.length)
5939
6287
  };
5940
6288
  }
5941
6289
 
5942
6290
  // src/cli/version.ts
5943
6291
  import { readFileSync as readFileSync5, existsSync as existsSync4 } from "fs";
5944
- import { dirname as dirname8, join as join27 } from "path";
6292
+ import { dirname as dirname8, join as join28 } from "path";
5945
6293
  function resolvePackageVersion(fromDir) {
5946
6294
  try {
5947
6295
  let dir = fromDir;
5948
6296
  while (true) {
5949
- const candidate = join27(dir, "package.json");
6297
+ const candidate = join28(dir, "package.json");
5950
6298
  if (existsSync4(candidate)) {
5951
6299
  const raw = readFileSync5(candidate, "utf-8");
5952
6300
  const pkg = JSON.parse(raw);
@@ -5989,7 +6337,8 @@ async function runOrExit(fn, opts) {
5989
6337
  try {
5990
6338
  const { output, code } = await fn();
5991
6339
  console.log(output);
5992
- process.exit(code);
6340
+ process.exitCode = code;
6341
+ return;
5993
6342
  } catch (err) {
5994
6343
  const e = err;
5995
6344
  console.error(opts.verbose ? e.stack ?? e.message : e.message ?? String(err));
@@ -6110,5 +6459,15 @@ cli.command(
6110
6459
  );
6111
6460
  cli.help();
6112
6461
  cli.version(version);
6462
+ cli.on("command:*", () => {
6463
+ const unknown = cli.args[0] ?? process.argv[2] ?? "";
6464
+ console.error(
6465
+ `error: unknown command '${unknown}'. Run 'reddoor-maint --help' to see available commands.`
6466
+ );
6467
+ process.exit(1);
6468
+ });
6113
6469
  cli.parse();
6470
+ export {
6471
+ runOrExit
6472
+ };
6114
6473
  //# sourceMappingURL=bin.js.map