@reddoorla/maintenance 0.9.0 → 0.10.1

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
@@ -15,19 +15,26 @@ pnpm reddoor-maint --help
15
15
 
16
16
  A reddoor site goes through this sequence the first time you adopt the package. Each recipe is idempotent — running it again on an already-onboarded site is a `noop`.
17
17
 
18
+ ```bash
19
+ pnpm reddoor-maint init
20
+ ```
21
+
22
+ `init` runs the whole chain in order against the current directory (or `init [site]` for an explicit path). It's a thin orchestrator — every underlying recipe still creates its own branch and stops on a dirty tree, so the operator ends up with a stack of `maint/<recipe>-<ts>` branches to PR.
23
+
18
24
  ```text
19
- convert-to-pnpm → onboard → sync-configs → svelte-codemods → audit
25
+ convert-to-pnpm → onboard → sync-configs → svelte-codemods → a11y-fixtures-page → audit
20
26
  ```
21
27
 
22
- | Step | Recipe | What it does |
23
- | ---- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
24
- | 1 | `convert-to-pnpm` | Removes `package-lock.json` / `yarn.lock`, pins `packageManager: pnpm@…`, rewrites `npm` references in scripts, runs `pnpm install` to materialise `pnpm-lock.yaml`. |
25
- | 2 | `onboard` | Installs `@reddoorla/maintenance` + the audit deps (`@lhci/cli`, `@playwright/test`, `@axe-core/playwright`) on the site. Pins the maintenance dep to a caret range against this package's own version at runtime. |
26
- | 3 | `sync-configs` | Writes the canonical config templates into the site (eslint, prettier, lighthouserc, playwright config, svelte config) and merges canonical entries into `.gitignore`. |
27
- | 4 | `svelte-codemods` | Optional cleanup pass applying the Svelte 5 gotcha codemods (`export let` → `$props()`, `on:event` → `onevent`, `$:` → `$derived`/`$effect`, etc.) for sites that surface new strictness warnings after the original upgrade. |
28
- | 5 | `audit` | Runs `deps`, `lighthouse`, `a11y`, `security`, `lint` see [Audits](#audits). |
28
+ | Step | Recipe | What it does |
29
+ | ---- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
30
+ | 1 | `convert-to-pnpm` | Removes `package-lock.json` / `yarn.lock`, pins `packageManager: pnpm@…`, rewrites `npm` references in scripts, runs `pnpm install` to materialise `pnpm-lock.yaml`. |
31
+ | 2 | `onboard` | Installs `@reddoorla/maintenance` + the audit deps (`@lhci/cli`, `@playwright/test`, `@axe-core/playwright`) on the site. Pins the maintenance dep to a caret range against this package's own version at runtime. |
32
+ | 3 | `sync-configs` | Writes the canonical config templates into the site (eslint, prettier, lighthouserc, playwright config, svelte config) and merges canonical entries into `.gitignore`. |
33
+ | 4 | `svelte-codemods` | Optional cleanup pass applying the Svelte 5 gotcha codemods (`export let` → `$props()`, `on:event` → `onevent`, `$:` → `$derived`/`$effect`, etc.) for sites that surface new strictness warnings after the original upgrade. |
34
+ | 5 | `a11y-fixtures-page` | Writes a starter `src/routes/dev/a11y-fixtures/+page.svelte` if the route doesn't already exist. The hardcoded URL in `lighthouse` and `playwright-a11y` configs targets this route. Operator edits are never clobbered. |
35
+ | 6 | `audit` | Runs `deps`, `lighthouse`, `a11y`, `security`, `lint` — see [Audits](#audits). |
29
36
 
30
- Each recipe refuses to run on a dirty working tree, creates a fresh `maint/<recipe>-<UTC-ms-timestamp>` branch, and emits one or more atomic commits.
37
+ Each recipe refuses to run on a dirty working tree, creates a fresh `maint/<recipe>-<UTC-ms-timestamp>` branch, and emits one or more atomic commits. Running individual steps standalone (e.g. `reddoor-maint sync-configs`) still works — `init` is for when you want the whole chain in one command.
31
38
 
32
39
  ---
33
40
 
@@ -37,6 +44,7 @@ Each recipe refuses to run on a dirty working tree, creates a fresh `maint/<reci
37
44
  reddoor-maint list-audits # audit descriptions
38
45
  reddoor-maint list-recipes # recipe descriptions
39
46
 
47
+ reddoor-maint init [site] # full onboarding chain (preferred entrypoint)
40
48
  reddoor-maint audit [site] # run audits
41
49
  reddoor-maint sync-configs [site]
42
50
  reddoor-maint bump-deps [site]
@@ -72,11 +80,13 @@ Each recipe is `(site, opts?) => Promise<RecipeResult>` and is exported from the
72
80
 
73
81
  ```ts
74
82
  import {
83
+ init,
75
84
  syncConfigs,
76
85
  bumpDeps,
77
86
  onboard,
78
87
  convertToPnpm,
79
88
  svelteCodemods,
89
+ a11yFixturesPage,
80
90
  upgradeSvelte4to5,
81
91
  } from "@reddoorla/maintenance";
82
92
  ```
@@ -113,6 +123,14 @@ Standalone codemod pass for sites already on Svelte 5. Applies the same gotcha c
113
123
 
114
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.
115
125
 
126
+ ### `a11y-fixtures-page`
127
+
128
+ 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
+
130
+ ### `init`
131
+
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`.
133
+
116
134
  ---
117
135
 
118
136
  ## Audits
@@ -296,7 +314,60 @@ RESEND_WEBHOOK_SECRET=whsec_XXXX # only for the deployed webhook
296
314
 
297
315
  Renders + sends every Reports row with `Draft ready=true && Approved to send=true && Sent at IS NULL`. Stamps `Sent at` + `Delivery status=pending` on each.
298
316
 
299
- 5. **Delivery status updates automatically** via the Resend webhook (Netlify Function at `netlify/functions/resend-webhook.mts`) — `Delivery status` flips to `delivered` / `bounced` / `complained` as events arrive.
317
+ 5. **Delivery status updates automatically** via the Resend webhook (Netlify Function at `netlify/functions/resend-webhook.mts`) — `Delivery status` flips to `delivered` / `bounced` / `complained` as events arrive. Deploy procedure: see [Webhook deployment](#webhook-deployment-netlify--resend) below.
318
+
319
+ ### Webhook deployment (Netlify + Resend)
320
+
321
+ The Resend delivery webhook lives at `netlify/functions/resend-webhook.mts` in this repo. Deploying it is a one-time operation per environment: connect the repo to a Netlify site, set three env vars, register the deployed URL with Resend.
322
+
323
+ **1. Create the Netlify site:**
324
+
325
+ - New site → Import from Git → pick `tucksravin/reddoor-maintenance`.
326
+ - Branch to deploy: `main`. Build settings are read from [`netlify.toml`](netlify.toml) (no build command needed — functions-only).
327
+ - Site name: pick something stable (e.g. `reddoor-webhooks`) since it ends up in the public webhook URL.
328
+
329
+ **2. Set env vars** in Site settings → Environment variables:
330
+
331
+ | Variable | Value |
332
+ | ----------------------- | --------------------------------------------------------------------------- |
333
+ | `AIRTABLE_PAT` | Same PAT used by the CLI (read+write on the Websites + Reports tables) |
334
+ | `AIRTABLE_BASE_ID` | `appHG8nLOzULzXOER` |
335
+ | `RESEND_WEBHOOK_SECRET` | Generated by Resend in step 4 — paste back here after creating the endpoint |
336
+
337
+ **3. Trigger a deploy** (Deploys → Trigger deploy → Deploy site). When it goes green, curl the health endpoint:
338
+
339
+ ```bash
340
+ curl https://<your-site>.netlify.app/.netlify/functions/resend-webhook
341
+ ```
342
+
343
+ Expected:
344
+
345
+ ```json
346
+ {
347
+ "status": "ok",
348
+ "service": "reddoor-resend-webhook",
349
+ "env": {
350
+ "RESEND_WEBHOOK_SECRET": true,
351
+ "AIRTABLE_PAT": true,
352
+ "AIRTABLE_BASE_ID": true
353
+ }
354
+ }
355
+ ```
356
+
357
+ Any `false` means the env var didn't land — re-check step 2 and re-deploy. The health endpoint reports presence-only and never returns secret values, so the output is safe to share in a support ticket.
358
+
359
+ **4. Register the webhook in Resend:** [resend.com/webhooks](https://resend.com/webhooks) → Add Endpoint → paste the function URL from step 3 → select events `email.delivered`, `email.bounced`, `email.complained` → save. Copy the generated **Signing Secret** (`whsec_…`) into Netlify's `RESEND_WEBHOOK_SECRET` env var (step 2) and trigger another deploy so the secret takes effect.
360
+
361
+ **5. End-to-end smoke test (using ERP Industrials):**
362
+
363
+ ```bash
364
+ # from your CLI:
365
+ reddoor-maint report erp-industrials # drafts a Maintenance report → Airtable
366
+ # approve the draft in Airtable, then:
367
+ reddoor-maint report --send-ready
368
+ ```
369
+
370
+ Within a few minutes, watch the ERP Reports row's `Delivery status` flip from `pending` → `delivered` as Resend pings the webhook. Function logs in Netlify show each event with its messageId + new status. If the webhook returns 5xx, svix retries; check the Netlify function log for the error envelope.
300
371
 
301
372
  ### Frequency math
302
373
 
package/dist/cli/bin.js CHANGED
@@ -735,7 +735,7 @@ var init_orchestrate = __esm({
735
735
  });
736
736
 
737
737
  // src/cli/bin.ts
738
- import { dirname as dirname4 } from "path";
738
+ import { dirname as dirname5 } from "path";
739
739
  import { fileURLToPath as fileURLToPath3 } from "url";
740
740
  import { cac } from "cac";
741
741
 
@@ -744,7 +744,7 @@ import { resolve as resolve2 } from "path";
744
744
 
745
745
  // src/audits/util/spawn.ts
746
746
  import { spawn } from "child_process";
747
- var defaultSpawn = (cmd, args, opts = {}) => new Promise((resolve9, reject) => {
747
+ var defaultSpawn = (cmd, args, opts = {}) => new Promise((resolve10, reject) => {
748
748
  const streaming = opts.streaming === true;
749
749
  const child = spawn(cmd, [...args], {
750
750
  cwd: opts.cwd,
@@ -767,7 +767,7 @@ var defaultSpawn = (cmd, args, opts = {}) => new Promise((resolve9, reject) => {
767
767
  });
768
768
  child.on("close", (code) => {
769
769
  if (timer) clearTimeout(timer);
770
- resolve9({ code: code ?? -1, stdout, stderr });
770
+ resolve10({ code: code ?? -1, stdout, stderr });
771
771
  });
772
772
  });
773
773
 
@@ -3322,12 +3322,174 @@ async function runSingleSiteDraft(slug, opts) {
3322
3322
  return { output: `Draft created: ${result.reportRow?.reportId}`, code: 0 };
3323
3323
  }
3324
3324
 
3325
+ // src/cli/commands/init.ts
3326
+ import { resolve as resolve9 } from "path";
3327
+
3328
+ // src/recipes/a11y-fixtures-page/index.ts
3329
+ import { access, mkdir as mkdir3, writeFile as writeFile10 } from "fs/promises";
3330
+ import { dirname as dirname4, join as join22 } from "path";
3331
+
3332
+ // src/recipes/a11y-fixtures-page/template.ts
3333
+ var A11Y_FIXTURES_PAGE_RELATIVE = "src/routes/dev/a11y-fixtures/+page.svelte";
3334
+ var A11Y_FIXTURES_PAGE_TEMPLATE = `<svelte:head>
3335
+ <title>a11y fixtures \u2014 Reddoor</title>
3336
+ <meta
3337
+ name="description"
3338
+ content="Reddoor accessibility fixtures \u2014 semantic landmarks, heading hierarchy, and a stable target for @lhci/cli and Playwright + axe-core coverage. Not linked from the public site."
3339
+ />
3340
+ </svelte:head>
3341
+
3342
+ <main>
3343
+ <header>
3344
+ <h1>Accessibility fixtures</h1>
3345
+ <p>
3346
+ This page exists so <code>@lhci/cli</code> and Playwright + axe-core have a
3347
+ stable target with predictable a11y characteristics. It is not linked from
3348
+ the public site.
3349
+ </p>
3350
+ </header>
3351
+
3352
+ <section aria-labelledby="landmarks-heading">
3353
+ <h2 id="landmarks-heading">Landmarks</h2>
3354
+ <p>
3355
+ A single <code>main</code> wraps the page; sections each declare
3356
+ <code>aria-labelledby</code> matched to their heading id so screen readers
3357
+ and axe both see a clean outline.
3358
+ </p>
3359
+ </section>
3360
+
3361
+ <section aria-labelledby="links-heading">
3362
+ <h2 id="links-heading">Links</h2>
3363
+ <p>
3364
+ <a href="/">Back to home</a> \u2014 relative link with descriptive visible text,
3365
+ so no <code>aria-label</code> override is needed.
3366
+ </p>
3367
+ </section>
3368
+ </main>
3369
+ `;
3370
+
3371
+ // src/recipes/a11y-fixtures-page/index.ts
3372
+ async function fileExists(path) {
3373
+ try {
3374
+ await access(path);
3375
+ return true;
3376
+ } catch {
3377
+ return false;
3378
+ }
3379
+ }
3380
+ async function a11yFixturesPage(site) {
3381
+ const target = join22(site.path, A11Y_FIXTURES_PAGE_RELATIVE);
3382
+ return withRecipe({
3383
+ name: "a11y-fixtures-page",
3384
+ site,
3385
+ plan: async () => {
3386
+ if (await fileExists(target)) {
3387
+ return { kind: "noop", notes: `${A11Y_FIXTURES_PAGE_RELATIVE} already exists` };
3388
+ }
3389
+ return { kind: "apply", plan: { target } };
3390
+ },
3391
+ apply: async (planned, { commit: commit2 }) => {
3392
+ await mkdir3(dirname4(planned.target), { recursive: true });
3393
+ await writeFile10(planned.target, A11Y_FIXTURES_PAGE_TEMPLATE, "utf-8");
3394
+ await commit2("feat: add /dev/a11y-fixtures starter route");
3395
+ return { kind: "ok" };
3396
+ }
3397
+ });
3398
+ }
3399
+
3400
+ // src/recipes/init.ts
3401
+ function recipeStep(name, fn) {
3402
+ return {
3403
+ name,
3404
+ run: async (site) => ({ kind: "recipe", result: await fn(site) })
3405
+ };
3406
+ }
3407
+ var DEFAULT_INIT_STEPS = [
3408
+ recipeStep("convert-to-pnpm", convertToPnpm),
3409
+ recipeStep("onboard", onboard),
3410
+ recipeStep("sync-configs", syncConfigs),
3411
+ recipeStep("svelte-codemods", svelteCodemods),
3412
+ recipeStep("a11y-fixtures-page", a11yFixturesPage),
3413
+ {
3414
+ name: "audit",
3415
+ run: async (site) => ({ kind: "audit", results: await runAudits(site) })
3416
+ }
3417
+ ];
3418
+ async function init(site, opts = {}) {
3419
+ const steps = opts.steps ?? DEFAULT_INIT_STEPS;
3420
+ const out = [];
3421
+ for (const step of steps) {
3422
+ let result;
3423
+ try {
3424
+ result = await step.run(site);
3425
+ } catch (err) {
3426
+ const message = err instanceof Error ? err.message : String(err);
3427
+ out.push({ name: step.name, result: { kind: "error", message } });
3428
+ return { site: siteLabel(site), steps: out, complete: false };
3429
+ }
3430
+ out.push({ name: step.name, result });
3431
+ if (result.kind === "recipe" && result.result.status === "failed") {
3432
+ return { site: siteLabel(site), steps: out, complete: false };
3433
+ }
3434
+ }
3435
+ return { site: siteLabel(site), steps: out, complete: true };
3436
+ }
3437
+
3438
+ // src/cli/commands/init.ts
3439
+ function formatStep(name, r) {
3440
+ if (r.kind === "error") return `${name.padEnd(20)} error: ${r.message}`;
3441
+ if (r.kind === "audit") {
3442
+ const lines = r.results.map(
3443
+ (a) => ` ${a.audit.padEnd(12)} ${a.status.padEnd(5)} ${a.summary}`
3444
+ );
3445
+ return `${name.padEnd(20)} ${r.results.length} audit(s):
3446
+ ${lines.join("\n")}`;
3447
+ }
3448
+ const rec = r.result;
3449
+ if (rec.status === "noop") return `${name.padEnd(20)} noop${rec.notes ? ` \u2014 ${rec.notes}` : ""}`;
3450
+ if (rec.status === "failed")
3451
+ return `${name.padEnd(20)} failed${rec.notes ? ` \u2014 ${rec.notes}` : ""}`;
3452
+ return `${name.padEnd(20)} applied (${rec.commits.length} commit${rec.commits.length === 1 ? "" : "s"})${rec.notes ? ` \u2014 ${rec.notes}` : ""}`;
3453
+ }
3454
+ function formatResult7(r) {
3455
+ const header = `[${r.site}] init \u2014 ${r.complete ? "complete" : "STOPPED"}`;
3456
+ const body = r.steps.map((s) => formatStep(s.name, s.result)).join("\n");
3457
+ return `${header}
3458
+ ${body}`;
3459
+ }
3460
+ function exitCodeFor(r) {
3461
+ if (!r.complete) return 1;
3462
+ for (const step of r.steps) {
3463
+ if (step.result.kind === "audit" && step.result.results.some((a) => a.status === "fail")) {
3464
+ return 1;
3465
+ }
3466
+ }
3467
+ return 0;
3468
+ }
3469
+ async function runInitCommand(site, opts) {
3470
+ const cwd = opts.cwd ? resolve9(opts.cwd) : process.cwd();
3471
+ let sites = await resolveSites({
3472
+ ...site !== void 0 ? { site } : {},
3473
+ ...opts.fleet !== void 0 ? { fleet: opts.fleet } : {},
3474
+ cwd
3475
+ });
3476
+ if (opts.fleet) {
3477
+ const workdir = opts.workdir ?? `${process.env.HOME ?? ""}/.reddoor-maint/sites`;
3478
+ sites = await Promise.all(sites.map((s) => cloneIfNeeded(s, { workdir })));
3479
+ }
3480
+ const results = [];
3481
+ for (const s of sites) results.push(await init(s));
3482
+ const output = results.map(formatResult7).join("\n\n");
3483
+ const code = results.some((r) => exitCodeFor(r) !== 0) ? 1 : 0;
3484
+ return { output, code };
3485
+ }
3486
+
3325
3487
  // src/cli/version.ts
3326
3488
  import { readFileSync as readFileSync2 } from "fs";
3327
- import { join as join22 } from "path";
3489
+ import { join as join23 } from "path";
3328
3490
  function resolvePackageVersion(fromDir) {
3329
3491
  try {
3330
- const raw = readFileSync2(join22(fromDir, "..", "..", "package.json"), "utf-8");
3492
+ const raw = readFileSync2(join23(fromDir, "..", "..", "package.json"), "utf-8");
3331
3493
  const pkg = JSON.parse(raw);
3332
3494
  return pkg.version ?? "unknown";
3333
3495
  } catch {
@@ -3336,7 +3498,7 @@ function resolvePackageVersion(fromDir) {
3336
3498
  }
3337
3499
 
3338
3500
  // src/cli/bin.ts
3339
- var here2 = dirname4(fileURLToPath3(import.meta.url));
3501
+ var here2 = dirname5(fileURLToPath3(import.meta.url));
3340
3502
  var version = resolvePackageVersion(here2);
3341
3503
  var AUDIT_DESCRIPTIONS = {
3342
3504
  deps: "Diff site package.json against the bundled baseline version map.",
@@ -3351,7 +3513,9 @@ var RECIPE_DESCRIPTIONS = {
3351
3513
  "svelte-4-to-5": "Run the 7-commit Svelte 4 \u2192 5 upgrade recipe.",
3352
3514
  "svelte-codemods": "Apply Svelte 5 gotcha codemods to an already-migrated site (state_referenced_locally, etc.).",
3353
3515
  "convert-to-pnpm": "Convert an npm/yarn site to pnpm (lockfile, packageManager, scripts).",
3354
- onboard: "Install @reddoorla/maintenance + audit deps on a site (preferred first step)."
3516
+ onboard: "Install @reddoorla/maintenance + audit deps on a site (preferred first step).",
3517
+ "a11y-fixtures-page": "Write src/routes/dev/a11y-fixtures/+page.svelte (stub for lhci + axe targets).",
3518
+ init: "Run the full onboarding chain (convert-to-pnpm \u2192 onboard \u2192 sync-configs \u2192 svelte-codemods \u2192 a11y-fixtures-page \u2192 audit)."
3355
3519
  };
3356
3520
  async function runOrExit(fn, opts) {
3357
3521
  try {
@@ -3428,6 +3592,15 @@ cli.command(
3428
3592
  ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
3429
3593
  async (site, opts) => runOrExit(() => runOnboardCommand(site, opts), opts)
3430
3594
  );
3595
+ cli.command(
3596
+ "init [site]",
3597
+ "One-shot guided onboarding: convert-to-pnpm \u2192 onboard \u2192 sync-configs \u2192 svelte-codemods \u2192 a11y-fixtures-page \u2192 audit."
3598
+ ).option(
3599
+ "--fleet <inventory>",
3600
+ 'Inventory file (.json or .mjs/.js), or "airtable" to read from Websites table'
3601
+ ).option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
3602
+ async (site, opts) => runOrExit(() => runInitCommand(site, opts), opts)
3603
+ );
3431
3604
  cli.command("report [site]", "Draft or send maintenance/testing reports.").option("--due", "Scan all Websites and draft overdue reports.").option(
3432
3605
  "--preview",
3433
3606
  "Single-site dry run; writes reports/<slug>/draft.html, never touches Airtable."