@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 +34 -16
- package/dist/cli/bin.js +103 -18
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.js +103 -18
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/index.js +101 -16
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
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`)
|
|
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
|
|
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
|
-
### `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
### `
|
|
124
|
+
### `sync-configs`
|
|
119
125
|
|
|
120
|
-
|
|
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
|
-
### `
|
|
128
|
+
### `svelte-codemods`
|
|
123
129
|
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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: {
|
|
1256
|
+
collect: {
|
|
1257
|
+
...lighthouseConfig.ci.collect,
|
|
1258
|
+
url: [withFreePort(baseUrl, port)],
|
|
1259
|
+
startServerCommand: `npm run vite:dev -- --port ${port} --strictPort`
|
|
1260
|
+
}
|
|
1214
1261
|
}
|
|
1215
|
-
}
|
|
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
|
|
1246
|
-
if (
|
|
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
|
|
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(
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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
|
|
1666
|
+
const entries = await readdir2(path);
|
|
1582
1667
|
return entries.length > 0;
|
|
1583
1668
|
} catch {
|
|
1584
1669
|
return false;
|