@reddoorla/maintenance 0.10.4 → 0.10.6

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/README.md CHANGED
@@ -2,7 +2,14 @@
2
2
 
3
3
  Canonical maintenance configs, audits, and recipes for the reddoor SvelteKit + Prismic fleet.
4
4
 
5
- A single CLI (`reddoor-maint`) that runs **audits** to inspect a site, **recipes** to mutate one (branch-isolated, idempotent, never on a dirty tree), and ships the **canonical configs** every reddoor site shares (eslint, prettier, lighthouse, playwright-a11y, svelte). Designed to run against either a single local site or a fleet declared in an inventory file.
5
+ A single CLI (`reddoor-maint`) covering the full operator workflow:
6
+
7
+ - **Audits** inspect a site (lighthouse, a11y, security, lint, deps).
8
+ - **Recipes** mutate one (branch-isolated, idempotent, never on a dirty tree) — start with [`init`](#init) for fresh sites.
9
+ - **Reports** ([per-site monthly/quarterly emails](#reports-per-site-maintenancetesting-emails)) draft + send client-facing maintenance + testing summaries, backed by Airtable as the source of truth and Resend for delivery.
10
+ - **Canonical configs** (eslint, prettier, lighthouserc, playwright-a11y, svelte) ship as importable presets every reddoor site shares.
11
+
12
+ Designed to run against either a single local site or a fleet declared in an inventory file.
6
13
 
7
14
  ```bash
8
15
  pnpm add -D @reddoorla/maintenance
@@ -46,6 +53,7 @@ reddoor-maint list-recipes # recipe descriptions
46
53
 
47
54
  reddoor-maint init [site] # full onboarding chain (preferred entrypoint)
48
55
  reddoor-maint audit [site] # run audits
56
+ reddoor-maint report [site] # draft / send per-site maintenance + testing emails (see Reports)
49
57
  reddoor-maint sync-configs [site]
50
58
  reddoor-maint bump-deps [site]
51
59
  reddoor-maint convert-to-pnpm [site]
@@ -76,7 +84,7 @@ reddoor-maint upgrade svelte-4-to-5 [site]
76
84
 
77
85
  ## Recipes
78
86
 
79
- Each recipe is `(site, opts?) => Promise<RecipeResult>` and is exported from the package entry as a library function too:
87
+ Each recipe is `(site, opts?) => Promise<RecipeResult>` and is also exported from the package entry as a library function:
80
88
 
81
89
  ```ts
82
90
  import {
@@ -99,13 +107,11 @@ Shared contract — every recipe:
99
107
  - **Is idempotent** — re-running on the already-applied state returns `{ status: "noop", commits: [] }` without creating a branch.
100
108
  - **Returns a `RecipeResult`** — `{ recipe, site, status: "applied" | "noop" | "failed", commits: string[], notes?: string }`.
101
109
 
102
- ### `sync-configs`
103
-
104
- Writes the canonical config templates into the site (eslint, prettier, lighthouserc, playwright config, svelte config) and merges the canonical entries into `.gitignore`. Each config is its own commit. `--dry` reports the planned diff (including gitignore drift) without writing. `--only <name>[,<name>]` restricts to a subset.
110
+ ### `init`
105
111
 
106
- ### `bump-deps`
112
+ One-shot guided onboarding — the recommended entrypoint for a fresh site. Runs `convert-to-pnpm → onboard → sync-configs → svelte-codemods → a11y-fixtures-page → audit` in sequence. Each underlying recipe still creates its own branch — `init` is a thin orchestrator, not a branch-collapser. Stops the chain on the first `failed` recipe or uncaught error; `noop` results continue the chain. Exit code is 1 if any step failed _or_ the final audit pass reports a `fail`.
107
113
 
108
- Pre-flights that the site is on pnpm (refuses with a clear remediation if `package-lock.json` or `yarn.lock` is present without `pnpm-lock.yaml`), runs `pnpm install` to ensure the lockfile is current, then `pnpm outdated --json` to decide whether anything needs upgrading. If yes: creates a branch and runs `pnpm up` scoped to the requested `--group` (patch / minor / major), then commits the result. `noop` if nothing's out of date.
114
+ The remaining sections describe each step `init` invokes (in chain order), plus the standalone recipes that aren't part of onboarding.
109
115
 
110
116
  ### `convert-to-pnpm`
111
117
 
@@ -115,21 +121,25 @@ Removes the npm or yarn lockfile, pins `packageManager: pnpm@X.Y.Z` in `package.
115
121
 
116
122
  Adds `@reddoorla/maintenance` + the audit deps (`@lhci/cli`, `@playwright/test`, `@axe-core/playwright`) to the site's `devDependencies` if they're missing. Audit dep versions come from `src/configs/baseline-versions.ts` so they can't drift from the rest of the package. The maintenance dep is pinned to a caret range against this package's own version at runtime — no manual syncing required at each minor bump. Refuses with `{ status: "failed", notes: "run convert-to-pnpm first" }` if the site has no `pnpm-lock.yaml`.
117
123
 
118
- ### `svelte-codemods`
124
+ ### `sync-configs`
119
125
 
120
- Standalone codemod pass for sites already on Svelte 5. Applies the same gotcha codemods the full `svelte-4-to-5` recipe runs (`export let` `$props()`, `on:event` `onevent`, `$:` → `$derived`/`$effect`, `$$props.class` rewrite, `$$restProps` destructured `...rest`, `$state` + `$effect` → `$derived`). Useful when post-upgrade Svelte 5 surfaces new strictness warnings and the fleet needs a clean re-application.
126
+ Writes the canonical config templates into the site (eslint, prettier, lighthouserc, playwright config, svelte config) and merges the canonical entries into `.gitignore`. Each config is its own commit. `--dry` reports the planned diff (including gitignore drift) without writing. `--only <name>[,<name>]` restricts to a subset.
121
127
 
122
- ### `upgrade svelte-4-to-5`
128
+ ### `svelte-codemods`
123
129
 
124
- The full 7-step Svelte 4 5 migration: bump framework versions, migrate `svelte.config.js`, run the official `svelte-migrate` codemod, run `@tailwindcss/upgrade`, apply gotcha codemods over `src/**/*.svelte`, verify with `pnpm install` + `pnpm run check`, and write a `MIGRATION_SVELTE_5.md` summary. Each step is its own commit; the file leaves a record of what ran and what may need manual review.
130
+ Standalone codemod pass for sites already on Svelte 5. Applies the same gotcha codemods the full `svelte-4-to-5` recipe runs (`export let` `$props()`, `on:event` `onevent`, `$:` `$derived`/`$effect`, `$$props.class` rewrite, `$$restProps` destructured `...rest`, `$state` + `$effect` → `$derived`). Useful when post-upgrade Svelte 5 surfaces new strictness warnings and the fleet needs a clean re-application.
125
131
 
126
132
  ### `a11y-fixtures-page`
127
133
 
128
134
  Writes a starter `src/routes/dev/a11y-fixtures/+page.svelte` if the route doesn't already exist. The `lighthouse` and `playwright-a11y` configs both target this URL — newly-onboarded sites need the route to exist for either audit to pass. The template is intentionally generic (semantic landmarks + headings + a relative link); operator edits to an existing page are never clobbered.
129
135
 
130
- ### `init`
136
+ ### `bump-deps` _(standalone — not part of `init`)_
137
+
138
+ Pre-flights that the site is on pnpm (refuses with a clear remediation if `package-lock.json` or `yarn.lock` is present without `pnpm-lock.yaml`), runs `pnpm install` to ensure the lockfile is current, then `pnpm outdated --json` to decide whether anything needs upgrading. If yes: creates a branch and runs `pnpm up` scoped to the requested `--group` (patch / minor / major), then commits the result. `noop` if nothing's out of date.
131
139
 
132
- One-shot guided onboarding: runs `convert-to-pnpm → onboard → sync-configs → svelte-codemods → a11y-fixtures-page → audit` in sequence against a site. Each underlying recipe still creates its own branch `init` is a thin orchestrator, not a branch-collapser. Stops the chain on the first `failed` recipe or uncaught error; `noop` results continue the chain. Exit code is 1 if any step failed _or_ the final audit pass reports a `fail`.
140
+ ### `upgrade svelte-4-to-5` _(standalone — not part of `init`)_
141
+
142
+ The full 7-step Svelte 4 → 5 migration: bump framework versions, migrate `svelte.config.js`, run the official `svelte-migrate` codemod, run `@tailwindcss/upgrade`, apply gotcha codemods over `src/**/*.svelte`, verify with `pnpm install` + `pnpm run check`, and write a `MIGRATION_SVELTE_5.md` summary. Each step is its own commit; the file leaves a record of what ran and what may need manual review.
133
143
 
134
144
  ---
135
145
 
@@ -190,22 +200,30 @@ Pass `--fleet <path>` to run a command against multiple sites declared in an inv
190
200
 
191
201
  ## Library usage
192
202
 
193
- The package's main entry exports every recipe and audit so you can wire them into custom tooling (CI jobs, scheduled scripts, alternative CLIs):
203
+ The package's main entry exports every recipe, audit, and report function so you can wire them into custom tooling (CI jobs, scheduled scripts, alternative CLIs):
194
204
 
195
205
  ```ts
196
206
  import {
197
207
  // recipes
198
- syncConfigs,
199
- bumpDeps,
208
+ init,
200
209
  convertToPnpm,
201
210
  onboard,
211
+ syncConfigs,
202
212
  svelteCodemods,
213
+ a11yFixturesPage,
214
+ bumpDeps,
203
215
  upgradeSvelte4to5,
204
216
 
205
217
  // audits
206
218
  runAudits,
207
219
  ALL_AUDIT_NAMES,
208
220
 
221
+ // reports (per-site maintenance / testing emails)
222
+ draftReportForSite,
223
+ sendApprovedReports,
224
+ renderReportHtml,
225
+ findDueReports,
226
+
209
227
  // recipe registry
210
228
  ALL_RECIPE_NAMES,
211
229
  isRecipeName,
package/dist/cli/bin.js CHANGED
@@ -1104,7 +1104,7 @@ async function securityAudit(ctx) {
1104
1104
  }
1105
1105
 
1106
1106
  // src/audits/lighthouse.ts
1107
- import { readFile as readFile4, writeFile, mkdtemp, rm } from "fs/promises";
1107
+ import { readFile as readFile4, writeFile, mkdtemp, rm, readdir } from "fs/promises";
1108
1108
  import { tmpdir } from "os";
1109
1109
  import { join as join4 } from "path";
1110
1110
 
@@ -1166,6 +1166,32 @@ async function readSiteConfig(sitePath) {
1166
1166
  return out;
1167
1167
  }
1168
1168
 
1169
+ // src/util/free-port.ts
1170
+ import { createServer } from "net";
1171
+ async function findFreePort() {
1172
+ return new Promise((resolve10, reject) => {
1173
+ const server = createServer();
1174
+ server.unref();
1175
+ server.on("error", reject);
1176
+ server.listen(0, "127.0.0.1", () => {
1177
+ const addr = server.address();
1178
+ if (typeof addr === "object" && addr) {
1179
+ const port = addr.port;
1180
+ server.close(() => resolve10(port));
1181
+ } else {
1182
+ server.close();
1183
+ reject(new Error("findFreePort: could not determine assigned port from socket"));
1184
+ }
1185
+ });
1186
+ });
1187
+ }
1188
+ function withFreePort(url, port) {
1189
+ const u = new URL(url);
1190
+ u.hostname = "localhost";
1191
+ u.port = String(port);
1192
+ return u.toString();
1193
+ }
1194
+
1169
1195
  // src/audits/lighthouse.ts
1170
1196
  async function readJsonMaybe(path) {
1171
1197
  try {
@@ -1175,6 +1201,21 @@ async function readJsonMaybe(path) {
1175
1201
  return null;
1176
1202
  }
1177
1203
  }
1204
+ async function readLhrEntries(resultsDir) {
1205
+ const files = await readdir(resultsDir).catch(() => []);
1206
+ const entries = [];
1207
+ for (const f of files) {
1208
+ if (!f.startsWith("lhr-") || !f.endsWith(".json")) continue;
1209
+ const lhr = await readJsonMaybe(join4(resultsDir, f));
1210
+ if (!lhr || !lhr.categories) continue;
1211
+ const summary = {};
1212
+ for (const [k, v] of Object.entries(lhr.categories)) {
1213
+ if (typeof v?.score === "number") summary[k] = v.score;
1214
+ }
1215
+ entries.push({ url: lhr.requestedUrl, summary });
1216
+ }
1217
+ return entries;
1218
+ }
1178
1219
  function averageSummaries(entries) {
1179
1220
  if (entries.length === 0) return {};
1180
1221
  const sums = {};
@@ -1206,13 +1247,19 @@ async function lighthouseAudit(ctx) {
1206
1247
  const site = ctx.site;
1207
1248
  const label = siteLabel(site);
1208
1249
  const siteCfg = await readSiteConfig(site.path);
1209
- const resolvedConfig = siteCfg.lighthouseUrl ? {
1250
+ const port = await findFreePort();
1251
+ const baseUrl = siteCfg.lighthouseUrl ?? lighthouseConfig.ci.collect.url[0];
1252
+ const resolvedConfig = {
1210
1253
  ...lighthouseConfig,
1211
1254
  ci: {
1212
1255
  ...lighthouseConfig.ci,
1213
- collect: { ...lighthouseConfig.ci.collect, url: [siteCfg.lighthouseUrl] }
1256
+ collect: {
1257
+ ...lighthouseConfig.ci.collect,
1258
+ url: [withFreePort(baseUrl, port)],
1259
+ startServerCommand: `npm run vite:dev -- --port ${port} --strictPort`
1260
+ }
1214
1261
  }
1215
- } : lighthouseConfig;
1262
+ };
1216
1263
  const configDir = await mkdtemp(join4(tmpdir(), "reddoor-lhci-"));
1217
1264
  const configPath = join4(configDir, "lighthouserc.json");
1218
1265
  await writeFile(configPath, JSON.stringify(resolvedConfig), "utf-8");
@@ -1242,13 +1289,13 @@ async function lighthouseAudit(ctx) {
1242
1289
  throw err;
1243
1290
  }
1244
1291
  await rm(configDir, { recursive: true, force: true });
1245
- const manifest = await readJsonMaybe(join4(resultsDir, "manifest.json"));
1246
- if (!manifest || manifest.length === 0) {
1292
+ const manifest = await readLhrEntries(resultsDir);
1293
+ if (manifest.length === 0) {
1247
1294
  return {
1248
1295
  audit: "lighthouse",
1249
1296
  site: label,
1250
1297
  status: "fail",
1251
- summary: `lighthouse: no manifest written (exit ${raw.code})${raw.stderr ? ` \u2014 ${raw.stderr.slice(0, 200)}` : ""}`
1298
+ summary: `lighthouse: no lhr-*.json written (exit ${raw.code})${raw.stderr ? ` \u2014 ${raw.stderr.slice(0, 200)}` : ""}`
1252
1299
  };
1253
1300
  }
1254
1301
  const assertionResults = await readJsonMaybe(join4(resultsDir, "assertion-results.json")) ?? [];
@@ -1323,6 +1370,37 @@ async function readJsonMaybe2(path) {
1323
1370
  return null;
1324
1371
  }
1325
1372
  }
1373
+ function buildPlaywrightConfig(port, sitePath) {
1374
+ return `import { defineConfig } from "@playwright/test";
1375
+
1376
+ export default defineConfig({
1377
+ testDir: ".",
1378
+ testMatch: /.*\\.spec\\.ts$/,
1379
+ fullyParallel: true,
1380
+ forbidOnly: !!process.env.CI,
1381
+ retries: process.env.CI ? 2 : 0,
1382
+ reporter: process.env.CI ? "github" : "list",
1383
+ use: {
1384
+ baseURL: "http://localhost:${port}",
1385
+ trace: "on-first-retry",
1386
+ },
1387
+ webServer: {
1388
+ // --strictPort: refuse to bump to a different port if ours is taken,
1389
+ // so the audit fails loudly instead of probing a zombie.
1390
+ // reuseExistingServer:false: never reuse \u2014 we control the lifecycle.
1391
+ // cwd: playwright's default webServer.cwd is the config file's
1392
+ // directory. Our config lives in /tmp so without this override,
1393
+ // "npm run vite:dev" tries to read /tmp/.../package.json and
1394
+ // ENOENTs before vite ever starts. Caltex 2026-05-28 (0.10.5).
1395
+ command: "npm run vite:dev -- --port ${port} --strictPort",
1396
+ url: "http://localhost:${port}/dev/a11y-fixtures",
1397
+ cwd: ${JSON.stringify(sitePath)},
1398
+ reuseExistingServer: false,
1399
+ timeout: 120_000,
1400
+ },
1401
+ });
1402
+ `;
1403
+ }
1326
1404
  function buildSpec() {
1327
1405
  return `import { test, expect } from "@playwright/test";
1328
1406
  import AxeBuilder from "@axe-core/playwright";
@@ -1376,19 +1454,26 @@ async function a11yAudit(ctx) {
1376
1454
  const specDir = await mkdtemp2(join5(tmpdir2(), "reddoor-a11y-spec-"));
1377
1455
  const specPath = join5(specDir, "a11y.spec.ts");
1378
1456
  await writeFile2(specPath, buildSpec(), "utf-8");
1457
+ const port = await findFreePort();
1458
+ const configPath = join5(specDir, "playwright.config.ts");
1459
+ await writeFile2(configPath, buildPlaywrightConfig(port, site.path), "utf-8");
1379
1460
  const resultsPath = join5(site.path, RESULTS_REL);
1380
1461
  await rm2(join5(site.path, ".reddoor-a11y"), { recursive: true, force: true });
1381
1462
  let raw;
1382
1463
  try {
1383
- raw = await spawn2("npx", ["--yes", "playwright", "test", "--reporter=line", specPath], {
1384
- cwd: site.path,
1385
- env: { ...process.env, REDDOOR_A11Y_OUTPUT: resultsPath },
1386
- // playwright on a cold tree downloads Chrome, boots the site's dev
1387
- // server, and runs axe over every configured route. The shared 30 s
1388
- // default in runAudits is fine for deps/lint/security but starves
1389
- // playwright (mirrors the lighthouse fix shipped earlier).
1390
- timeoutMs: 5 * 6e4
1391
- });
1464
+ raw = await spawn2(
1465
+ "npx",
1466
+ ["--yes", "playwright", "test", `--config=${configPath}`, "--reporter=line", specPath],
1467
+ {
1468
+ cwd: site.path,
1469
+ env: { ...process.env, REDDOOR_A11Y_OUTPUT: resultsPath },
1470
+ // playwright on a cold tree downloads Chrome, boots the site's dev
1471
+ // server, and runs axe over every configured route. The shared 30 s
1472
+ // default in runAudits is fine for deps/lint/security but starves
1473
+ // playwright (mirrors the lighthouse fix shipped earlier).
1474
+ timeoutMs: 5 * 6e4
1475
+ }
1476
+ );
1392
1477
  } catch (err) {
1393
1478
  await rm2(specDir, { recursive: true, force: true });
1394
1479
  const e = err;
@@ -1550,7 +1635,7 @@ async function resolveSites(input) {
1550
1635
  }
1551
1636
 
1552
1637
  // src/cli/fleet/clone-if-needed.ts
1553
- import { stat, readdir, mkdir } from "fs/promises";
1638
+ import { stat, readdir as readdir2, mkdir } from "fs/promises";
1554
1639
  import { isAbsolute as isAbsolute2, join as join6 } from "path";
1555
1640
  function deriveNameFromRepoUrl(repoUrl) {
1556
1641
  const slash = repoUrl.split("/").pop() ?? repoUrl;
@@ -1578,7 +1663,7 @@ async function isNonEmptyDir(path) {
1578
1663
  try {
1579
1664
  const s = await stat(path);
1580
1665
  if (!s.isDirectory()) return false;
1581
- const entries = await readdir(path);
1666
+ const entries = await readdir2(path);
1582
1667
  return entries.length > 0;
1583
1668
  } catch {
1584
1669
  return false;