@reddoorla/maintenance 0.10.3 → 0.10.5

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
@@ -284,7 +284,7 @@ var init_reports = __esm({
284
284
 
285
285
  // src/reports/maintenance-email/assets/index.ts
286
286
  import { readFile as readFile13 } from "fs/promises";
287
- import { existsSync as existsSync2 } from "fs";
287
+ import { existsSync as existsSync3 } from "fs";
288
288
  import { dirname as dirname2, join as join21 } from "path";
289
289
  import { fileURLToPath as fileURLToPath2 } from "url";
290
290
  function resolveAssetsDir() {
@@ -292,12 +292,12 @@ function resolveAssetsDir() {
292
292
  let dir = dirname2(fileURLToPath2(import.meta.url));
293
293
  while (true) {
294
294
  const srcCandidate = join21(dir, "src", "reports", "maintenance-email", "assets", "check.png");
295
- if (existsSync2(srcCandidate)) {
295
+ if (existsSync3(srcCandidate)) {
296
296
  cachedAssetsDir = dirname2(srcCandidate);
297
297
  return cachedAssetsDir;
298
298
  }
299
299
  const distCandidate = join21(dir, "dist", "reports", "maintenance-email", "assets", "check.png");
300
- if (existsSync2(distCandidate)) {
300
+ if (existsSync3(distCandidate)) {
301
301
  cachedAssetsDir = dirname2(distCandidate);
302
302
  return cachedAssetsDir;
303
303
  }
@@ -760,7 +760,7 @@ var init_orchestrate = __esm({
760
760
  });
761
761
 
762
762
  // src/cli/bin.ts
763
- import { dirname as dirname5 } from "path";
763
+ import { dirname as dirname6 } from "path";
764
764
  import { fileURLToPath as fileURLToPath3 } from "url";
765
765
  import { cac } from "cac";
766
766
 
@@ -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 {
@@ -1206,13 +1232,19 @@ async function lighthouseAudit(ctx) {
1206
1232
  const site = ctx.site;
1207
1233
  const label = siteLabel(site);
1208
1234
  const siteCfg = await readSiteConfig(site.path);
1209
- const resolvedConfig = siteCfg.lighthouseUrl ? {
1235
+ const port = await findFreePort();
1236
+ const baseUrl = siteCfg.lighthouseUrl ?? lighthouseConfig.ci.collect.url[0];
1237
+ const resolvedConfig = {
1210
1238
  ...lighthouseConfig,
1211
1239
  ci: {
1212
1240
  ...lighthouseConfig.ci,
1213
- collect: { ...lighthouseConfig.ci.collect, url: [siteCfg.lighthouseUrl] }
1241
+ collect: {
1242
+ ...lighthouseConfig.ci.collect,
1243
+ url: [withFreePort(baseUrl, port)],
1244
+ startServerCommand: `npm run vite:dev -- --port ${port} --strictPort`
1245
+ }
1214
1246
  }
1215
- } : lighthouseConfig;
1247
+ };
1216
1248
  const configDir = await mkdtemp(join4(tmpdir(), "reddoor-lhci-"));
1217
1249
  const configPath = join4(configDir, "lighthouserc.json");
1218
1250
  await writeFile(configPath, JSON.stringify(resolvedConfig), "utf-8");
@@ -1323,6 +1355,32 @@ async function readJsonMaybe2(path) {
1323
1355
  return null;
1324
1356
  }
1325
1357
  }
1358
+ function buildPlaywrightConfig(port) {
1359
+ return `import { defineConfig } from "@playwright/test";
1360
+
1361
+ export default defineConfig({
1362
+ testDir: ".",
1363
+ testMatch: /.*\\.spec\\.ts$/,
1364
+ fullyParallel: true,
1365
+ forbidOnly: !!process.env.CI,
1366
+ retries: process.env.CI ? 2 : 0,
1367
+ reporter: process.env.CI ? "github" : "list",
1368
+ use: {
1369
+ baseURL: "http://localhost:${port}",
1370
+ trace: "on-first-retry",
1371
+ },
1372
+ webServer: {
1373
+ // --strictPort: refuse to bump to a different port if ours is taken,
1374
+ // so the audit fails loudly instead of probing a zombie.
1375
+ // reuseExistingServer:false: never reuse \u2014 we control the lifecycle.
1376
+ command: "npm run vite:dev -- --port ${port} --strictPort",
1377
+ url: "http://localhost:${port}/dev/a11y-fixtures",
1378
+ reuseExistingServer: false,
1379
+ timeout: 120_000,
1380
+ },
1381
+ });
1382
+ `;
1383
+ }
1326
1384
  function buildSpec() {
1327
1385
  return `import { test, expect } from "@playwright/test";
1328
1386
  import AxeBuilder from "@axe-core/playwright";
@@ -1376,19 +1434,26 @@ async function a11yAudit(ctx) {
1376
1434
  const specDir = await mkdtemp2(join5(tmpdir2(), "reddoor-a11y-spec-"));
1377
1435
  const specPath = join5(specDir, "a11y.spec.ts");
1378
1436
  await writeFile2(specPath, buildSpec(), "utf-8");
1437
+ const port = await findFreePort();
1438
+ const configPath = join5(specDir, "playwright.config.ts");
1439
+ await writeFile2(configPath, buildPlaywrightConfig(port), "utf-8");
1379
1440
  const resultsPath = join5(site.path, RESULTS_REL);
1380
1441
  await rm2(join5(site.path, ".reddoor-a11y"), { recursive: true, force: true });
1381
1442
  let raw;
1382
1443
  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
- });
1444
+ raw = await spawn2(
1445
+ "npx",
1446
+ ["--yes", "playwright", "test", `--config=${configPath}`, "--reporter=line", specPath],
1447
+ {
1448
+ cwd: site.path,
1449
+ env: { ...process.env, REDDOOR_A11Y_OUTPUT: resultsPath },
1450
+ // playwright on a cold tree downloads Chrome, boots the site's dev
1451
+ // server, and runs axe over every configured route. The shared 30 s
1452
+ // default in runAudits is fine for deps/lint/security but starves
1453
+ // playwright (mirrors the lighthouse fix shipped earlier).
1454
+ timeoutMs: 5 * 6e4
1455
+ }
1456
+ );
1392
1457
  } catch (err) {
1393
1458
  await rm2(specDir, { recursive: true, force: true });
1394
1459
  const e = err;
@@ -2633,6 +2698,20 @@ function findMatchingClose(source, openIdx) {
2633
2698
  i = closeStr + 1;
2634
2699
  continue;
2635
2700
  }
2701
+ if (ch === "/") {
2702
+ const next = source[i + 1];
2703
+ if (next === "/") {
2704
+ const eol = source.indexOf("\n", i + 2);
2705
+ i = eol === -1 ? source.length : eol;
2706
+ continue;
2707
+ }
2708
+ if (next === "*") {
2709
+ const end = source.indexOf("*/", i + 2);
2710
+ if (end === -1) return -1;
2711
+ i = end + 2;
2712
+ continue;
2713
+ }
2714
+ }
2636
2715
  if (ch === "{") depth++;
2637
2716
  else if (ch === "}") {
2638
2717
  depth--;
@@ -2965,15 +3044,25 @@ import { stat as stat4 } from "fs/promises";
2965
3044
  import { join as join19 } from "path";
2966
3045
 
2967
3046
  // src/util/self-version.ts
2968
- import { readFileSync } from "fs";
3047
+ import { readFileSync, existsSync as existsSync2 } from "fs";
2969
3048
  import { fileURLToPath } from "url";
2970
3049
  import { dirname, join as join18 } from "path";
2971
3050
  function selfPackageVersion(callerImportMetaUrl) {
2972
3051
  try {
2973
- const here2 = dirname(fileURLToPath(callerImportMetaUrl));
2974
- const raw = readFileSync(join18(here2, "..", "..", "package.json"), "utf-8");
2975
- const pkg = JSON.parse(raw);
2976
- return pkg.version ?? "0.0.0";
3052
+ let dir = dirname(fileURLToPath(callerImportMetaUrl));
3053
+ while (true) {
3054
+ const candidate = join18(dir, "package.json");
3055
+ if (existsSync2(candidate)) {
3056
+ const raw = readFileSync(candidate, "utf-8");
3057
+ const pkg = JSON.parse(raw);
3058
+ if (pkg.name === "@reddoorla/maintenance") {
3059
+ return pkg.version ?? "0.0.0";
3060
+ }
3061
+ }
3062
+ const parent = dirname(dir);
3063
+ if (parent === dir) return "0.0.0";
3064
+ dir = parent;
3065
+ }
2977
3066
  } catch {
2978
3067
  return "0.0.0";
2979
3068
  }
@@ -3515,20 +3604,31 @@ async function runInitCommand(site, opts) {
3515
3604
  }
3516
3605
 
3517
3606
  // src/cli/version.ts
3518
- import { readFileSync as readFileSync2 } from "fs";
3519
- import { join as join23 } from "path";
3607
+ import { readFileSync as readFileSync2, existsSync as existsSync4 } from "fs";
3608
+ import { dirname as dirname5, join as join23 } from "path";
3520
3609
  function resolvePackageVersion(fromDir) {
3521
3610
  try {
3522
- const raw = readFileSync2(join23(fromDir, "..", "..", "package.json"), "utf-8");
3523
- const pkg = JSON.parse(raw);
3524
- return pkg.version ?? "unknown";
3611
+ let dir = fromDir;
3612
+ while (true) {
3613
+ const candidate = join23(dir, "package.json");
3614
+ if (existsSync4(candidate)) {
3615
+ const raw = readFileSync2(candidate, "utf-8");
3616
+ const pkg = JSON.parse(raw);
3617
+ if (pkg.name === "@reddoorla/maintenance") {
3618
+ return pkg.version ?? "unknown";
3619
+ }
3620
+ }
3621
+ const parent = dirname5(dir);
3622
+ if (parent === dir) return "unknown";
3623
+ dir = parent;
3624
+ }
3525
3625
  } catch {
3526
3626
  return "unknown";
3527
3627
  }
3528
3628
  }
3529
3629
 
3530
3630
  // src/cli/bin.ts
3531
- var here = dirname5(fileURLToPath3(import.meta.url));
3631
+ var here = dirname6(fileURLToPath3(import.meta.url));
3532
3632
  var version = resolvePackageVersion(here);
3533
3633
  var AUDIT_DESCRIPTIONS = {
3534
3634
  deps: "Diff site package.json against the bundled baseline version map.",