Phase 1 MVP: drop-in step registry + session + Markdown report

Plugin skeleton, drop-in step registry, option-backed session, single-page
checklist UI, downloadable Markdown report. Steps live as one file each
under includes/steps/ — adding/removing one is a single file change.
Step IDs are stable strings so renaming files preserves session data.

Architecture (hc-5ix.3): WPH_Steps singleton globs includes/steps/*.php,
natsort-orders by filename, requires each file (which returns a WPH_Step
instance), then applies a 'wph_steps' filter so installs can drop steps.

Session (hc-5ix.4): option-backed (per decision — plugin is installed
per-engagement, so DB-resident history would be lost on uninstall).
Single in-progress session per site; finished sessions render a report
that the user downloads/copies.

Recovery bootstrap (hc-5ix.1): detects whether wp-site-recovery is
installed + active, surfaces state on the start panel and in every
active session. Manual install for now; private update channel deferred
to hc-5ix.27.

Smoke-tested on testsite: registry discovery (13 steps in correct order),
start → update_step → progress count → finish → 8KB Markdown report →
discard cycle.
This commit is contained in:
2026-06-11 15:49:29 +01:00
parent d0d4d433d8
commit 8de7cad0de
23 changed files with 1160 additions and 0 deletions

28
.beads/issues.jsonl Normal file
View File

@@ -0,0 +1,28 @@
{"_type":"issue","id":"hc-5ix.26","title":"Decision: distribution model — internal-only, no WP.org listing","description":"This is an internal/agency tool, not a public plugin. Distributed as a built ZIP (GitHub release artefact). Technicians install via wp-admin → Plugins → Upload, or via wp-cli (`wp plugin install \u003curl\u003e --activate`).\n\n**Why:** lets us release fast, keeps client-specific text/checks private, no review process.\n\n**How to apply:**\n- Don't add WP.org boilerplate (readme.txt, screenshots, banner.png).\n- Plugin header version = source of truth for what's installed.\n- Phase 3 considers a self-hosted update channel (hc-5ix.NEW).","status":"open","priority":1,"issue_type":"decision","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:40:29Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:40:29Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.26","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:40:28Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.6","title":"Session lifecycle: Start/Resume/Finish actions","description":"Start: creates new session, snapshots site URL + WP/PHP versions + technician. Resume: continues the current in-progress session. Finish: stamps finished_at, locks notes, opens report view. If a session is already in-progress, Start asks to discard or resume.","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:13Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:28Z","started_at":"2026-06-11T14:41:28Z","closed_at":"2026-06-11T14:49:28Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.6","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:12Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.7","title":"Report generation: Markdown export + copy to clipboard + save to private CPT","description":"Render the finished session as Markdown (technician, site URL, started/finished, then per-step status + notes + sub-items). 'Copy' button and 'Save as report post' (private CPT 'wph_report') so reports are retrievable per site. Plain text fallback. No PDF in phase 1.","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:13Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:28Z","started_at":"2026-06-11T14:41:29Z","closed_at":"2026-06-11T14:49:28Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.7","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:13Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.5","title":"Checklist UI: single admin page with section per step, checkbox group + notes textarea","description":"One page, all 12 steps + before-you-start. Each step is a card with: title, sub-items as readable bullets, a status select (not started/done/skipped/blocked/n/a), a notes textarea. Saves on blur or via explicit Save. Shows the 'Watch out for' callouts from steps.md inline.","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:12Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:27Z","started_at":"2026-06-11T14:41:28Z","closed_at":"2026-06-11T14:49:27Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.5","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:12Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.3","title":"Step definitions module: single PHP file defining the 12 steps + 'Before You Start' as structured data (id, title, blurb, sub-items, escalation conditions)","description":"Source of truth for both the MVP UI and the automation layer. Keep it boring: pure data, no presentation. Lives at includes/steps.php returning a typed array. The text comes from steps.md (current copy in repo root).","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:11Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:26Z","started_at":"2026-06-11T14:41:27Z","closed_at":"2026-06-11T14:49:26Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.3","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:10Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.4","title":"Session data model: option-backed in-progress healthcheck record","description":"One in-progress session per site at a time. Stored in a custom option (or a CPT — pick during implementation). Shape: id, started_at, finished_at, technician_id, site_url_snapshot, per_step_state { status: not_started|in_progress|done|skipped|blocked|n_a, notes, completed_at }. Designed so phase-3 automation can attach structured findings later.","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:11Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:27Z","started_at":"2026-06-11T14:41:27Z","closed_at":"2026-06-11T14:49:27Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.4","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:11Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.2","title":"Plugin skeleton: header, activation hook, deactivation hook, admin menu (Tools → Site Healthcheck), capability gate","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:10Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:26Z","started_at":"2026-06-11T14:41:27Z","closed_at":"2026-06-11T14:49:26Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.2","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:09Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.1","title":"Bootstrap: trigger install of wp-site-recovery plugin as step 0","description":"Healthcheck plugin should check on activation if site-recovery is installed; if not, fetch and install from a known URL/ZIP and activate it. Block stepper from starting until recovery is in place.","notes":"Detection in place via WPH_Recovery_Bootstrap (plugin slug check + is_plugin_active). Phase-1 surfaces installed/active/missing state with link to recovery settings. One-click install from a private URL deferred to hc-5ix.27.","status":"open","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T11:26:40Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:29Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.1","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T12:26:39Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.9","title":"Smoke test on testsite: full end-to-end run through the checklist","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":2,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:15Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:29Z","closed_at":"2026-06-11T14:49:29Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.9","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:14Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.8","title":"README + install instructions + screenshots placeholder","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":2,"issue_type":"chore","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:14Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:29Z","closed_at":"2026-06-11T14:49:29Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.8","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:14Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix","title":"Build WordPress Healthcheck plugin (stepper through steps.md)","description":"Plugin that walks a technician through the WordPress healthcheck steps documented in steps.md. Independent of the recovery plugin (which it depends on as step 0).","status":"open","priority":2,"issue_type":"epic","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T11:26:32Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T11:26:32Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.27","title":"Self-hosted update channel: plugin checks a private URL for new releases and offers one-click update from wp-admin","description":"Lightweight wrapper around the WP plugin update transient: site_transient_update_plugins filter that adds an entry for ourselves if a newer release exists at a configured URL. URL hosts a JSON manifest + zip. Lets technicians keep clients up to date without manually re-uploading the zip every check.","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:40:30Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:40:30Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.27","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:40:29Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.25","title":"Step 12 (Wrap-up): pre-fill report with all automated findings, attach diff vs. previous session, optionally push summary to ManageWP/WP Umbrella","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:26Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:26Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.25","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:25Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.24","title":"Step 11 (Small fixes): broken internal link scan (sample first N pages), missing alt-text scan on homepage images, deactivated-but-installed plugin list","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:25Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:25Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.24","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:25Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.22","title":"Step 9 (Database): table sizes report, spam comment count, post revision count, autoload option size, recommendation engine (e.g. 'consider revision limit if \u003e5000')","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:24Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:24Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.22","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:23Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.23","title":"Step 10 (Uptime): pluggable uptime provider integration (ManageWP, UptimeRobot, BetterStack, Pingdom). At minimum expose 'paste downtime summary' field","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:24Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:24Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.23","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:24Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.21","title":"Step 8 (Security): SSL cert expiry check (stream_socket_client to :443 + parse cert), admin user audit (flag unfamiliar accounts vs. snapshot baseline), xmlrpc.php reachability check, custom login URL check","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:23Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:23Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.21","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:23Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.19","title":"Step 6 (Visual \u0026 Functional): homepage screenshot via mShots (or local headless if available); checklist with auto-pulled key pages (front page + posts page + WC shop + login)","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:22Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:22Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.19","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:21Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.20","title":"Step 7 (Performance): PageSpeed Insights API integration (server-side fetch), record mobile + desktop scores, flag \u003e10pt drop vs. previous session, image scan for uncompressed \u003e 500KB on homepage","description":"Requires a Google PageSpeed API key (per-installation setting). Fall back to manually-entered scores if no key. Cache results for 12h.","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:22Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:22Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.20","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:22Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.18","title":"Step 5 (Theme): detect parent/child relationship; if no child, diff parent theme files vs. WP.org canonical to flag direct customisations that would be lost on update","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:21Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:21Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.18","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:20Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.16","title":"Step 3 (Core): wizard around core update with safe-mode (deactivate plugins first, update, smoke-load /, reactivate) and clear rollback path on failure","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:20Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:20Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.16","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:19Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.17","title":"Step 4 (Plugins): enrich each plugin with WP.org 'last updated' date, 'removed from repo' flag, active install count; flag plugins not updated \u003e12 months; flag plugins missing from repo","description":"Calls api.wordpress.org/plugins/info/1.0/\u003cslug\u003e.json for each plugin. Cache per-site for 24h. 'Removed from repo' = 404 or unsupported response.","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:20Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:20Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.17","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:20Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.15","title":"Step 2 (Environment): auto-collect PHP version + EOL flag, disk usage (statvfs), tail of error log (PHP + WP debug.log if enabled), wp-config flags (WP_DEBUG, WP_DEBUG_DISPLAY, DISALLOW_FILE_EDIT), spot-check permissions on wp-config.php / wp-content / uploads","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:19Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:19Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.15","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:18Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.14","title":"Step 1 (Backup): detect installed backup plugin (UpdraftPlus, BackWPup, BlogVault, Jetpack VaultPress), surface last backup time + size + verification status","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:18Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:18Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.14","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:18Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.12","title":"Diff against previous session: highlight deltas vs. last completed session for this site","description":"Pre-fill 'before' values for plugin/theme/WP versions from the previous session's 'after' values. Show a Δ column on the report. Lets clients see trajectory across checks.","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:17Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:17Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.12","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:16Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.13","title":"Polished HTML/PDF report styling + ability to email","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:17Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:17Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.13","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:17Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.11","title":"Stop-and-escalate decision support: when a step is marked blocked/escalate, surface the matching guidance from steps.md and link to a quote/escalation template","description":"Drive from the 'When to Stop and Escalate' table at the bottom of steps.md. Each escalation condition becomes a structured rule that fires when its preconditions hold (e.g. step=backup, status=blocked → 'Stop. Do not proceed. Restore backup before retry.'). Render as a banner on the affected step.","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:16Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:16Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.11","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:16Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.10","title":"Linear stepper UI: replace single-page with one-step-per-page + progress bar + per-step timer","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:15Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:36:15Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.10","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:15Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}

61
README.md Normal file
View File

@@ -0,0 +1,61 @@
# Site Healthcheck
Internal WordPress plugin that walks a technician through a structured healthcheck. Companion to [`wp-site-recovery`](../wp-site-recovery/), which it expects to be installed as step 0.
## Install
```sh
ln -s ~/dev/wp-healthcheck /path/to/wp/wp-content/plugins/site-healthcheck
```
Then activate from *Plugins*. Settings appear under *Tools → Site Healthcheck*.
## Use
1. Open *Tools → Site Healthcheck*.
2. Confirm the recovery plugin status panel shows ✓ active.
3. Click **Start new healthcheck**.
4. Work through each step card. For each: choose a status (done / skipped / blocked / n/a) and add notes.
5. Click **Finish & generate report**.
6. Download the Markdown report or copy it to clipboard.
One in-progress session per site at a time. Reports are not stored in the database (the plugin is meant to be uninstalled at the end of each engagement) — download them.
## Adding, removing, reordering steps
Each step is a single file under `includes/steps/` named `<order>-<slug>.php`.
```php
<?php
// includes/steps/45-staging.php
return new class extends WPH_Step {
public function id(): string { return 'staging'; }
public function title(): string { return 'Step 4.5 — Verify Staging Sync'; }
public function sub_items(): array {
return ['Re-deploy from production', 'Run smoke tests'];
}
};
```
- **Add:** drop a new file.
- **Remove:** delete the file.
- **Reorder:** rename the numeric prefix (steps are loaded in `natsort` order).
- **Conditionally drop on one install:** use the `wph_steps` filter to `unset` the step by id.
Stable string IDs (returned by `id()`) are what's stored in session data, so renaming a file does not break in-progress sessions as long as the id stays the same.
## Phase roadmap
Phase 1 (this) is a checklist + Markdown report. Phase 3 will add per-step automation — backup detection, env auto-collect, plugin update intelligence, PageSpeed Insights, SSL checks, etc. See beads epic `hc-5ix` for the full plan.
## Distribution
Internal/agency tool — not a WP.org plugin (decision `hc-5ix.26`). Distributed as a built ZIP. A self-hosted update channel is `hc-5ix.27` on the phase-3 backlog.
## Tracking
```sh
cd ~/dev/wp-healthcheck
bd list --label phase-1 # MVP work
bd list --label phase-3 # automation backlog
```

275
includes/admin-page.php Normal file
View File

@@ -0,0 +1,275 @@
<?php
if (!defined('ABSPATH')) exit;
add_action('admin_menu', 'wph_register_menu');
add_action('admin_post_wph_start', 'wph_handle_start');
add_action('admin_post_wph_save_step', 'wph_handle_save_step');
add_action('admin_post_wph_finish', 'wph_handle_finish');
add_action('admin_post_wph_discard', 'wph_handle_discard');
add_action('admin_post_wph_download_report', 'wph_handle_download_report');
add_action('admin_enqueue_scripts', 'wph_enqueue_assets');
function wph_register_menu(): void {
add_management_page(
'Site Healthcheck',
'Site Healthcheck',
'manage_options',
'site-healthcheck',
'wph_render_admin_page'
);
}
function wph_enqueue_assets($hook): void {
if ($hook !== 'tools_page_site-healthcheck') return;
wp_register_style('wph-admin', false);
wp_enqueue_style('wph-admin');
wp_add_inline_style('wph-admin', wph_inline_css());
}
function wph_inline_css(): string {
return '
.wph-card { background:#fff; border:1px solid #c3c4c7; border-radius:6px; padding:1rem 1.25rem; margin-bottom:1rem; }
.wph-card h2 { margin-top:0; }
.wph-step-status { display:inline-block; padding:.1rem .55rem; border-radius:10px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.04em; }
.wph-status-not_started { background:#f0f0f1; color:#646970; }
.wph-status-done { background:#def7e3; color:#155724; }
.wph-status-skipped { background:#fff3cd; color:#856404; }
.wph-status-blocked { background:#fbeae8; color:#721c24; }
.wph-status-n_a { background:#e2e3e5; color:#41464b; }
.wph-step { padding:1rem 1.25rem; border:1px solid #dcdcde; border-radius:6px; margin-bottom:.75rem; background:#fff; }
.wph-step header { display:flex; justify-content:space-between; align-items:center; gap:1rem; margin-bottom:.5rem; }
.wph-step header h2 { margin:0; font-size:1.1rem; }
.wph-sub-items { margin:.5rem 0 .75rem 1.25rem; padding:0; }
.wph-sub-items li { margin:.15rem 0; }
.wph-watch-outs { background:#fff8e1; border-left:3px solid #f5b800; padding:.4rem .8rem; margin:.5rem 0; font-size:.92em; }
.wph-watch-outs strong { display:block; margin-bottom:.2rem; }
.wph-escalation { background:#fbeae8; border-left:3px solid #c0392b; padding:.4rem .8rem; margin:.5rem 0; font-weight:500; }
.wph-step textarea { width:100%; min-height:60px; }
.wph-progress { font-weight:600; }
.wph-ok { color:#155724; }
.wph-warn { color:#856404; }
.wph-bad { color:#721c24; }
.wph-bootstrap-panel { padding:.6rem 1rem; background:#f6f7f7; border:1px solid #dcdcde; border-radius:6px; margin-bottom:.75rem; }
.wph-bootstrap-panel h3 { margin:0 0 .35rem; font-size:1rem; }
.wph-actions { display:flex; gap:.5rem; align-items:center; margin-top:.4rem; }
';
}
function wph_render_admin_page(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
$session = WPH_Session::current();
echo '<div class="wrap">';
echo '<h1>Site Healthcheck</h1>';
if (!$session) {
wph_render_start_panel();
echo '</div>';
return;
}
if ($session->is_finished()) {
wph_render_finished_panel($session);
echo '</div>';
return;
}
wph_render_active_session($session);
echo '</div>';
}
function wph_render_start_panel(): void {
?>
<div class="wph-card">
<h2>Start a healthcheck</h2>
<p>This will create a new in-progress session for <code><?php echo esc_html(get_site_url()); ?></code>. One session per site at a time.</p>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<?php wp_nonce_field('wph_start'); ?>
<input type="hidden" name="action" value="wph_start">
<button class="button button-primary">Start new healthcheck</button>
</form>
</div>
<?php
WPH_Recovery_Bootstrap::render_status();
}
function wph_render_active_session(WPH_Session $session): void {
$progress = $session->progress();
$tech = get_userdata($session->technician_id());
?>
<div class="wph-card">
<p>
<strong>Session:</strong> <code><?php echo esc_html($session->id()); ?></code> ·
<strong>Started:</strong> <?php echo esc_html(date('Y-m-d H:i', $session->started_at())); ?> ·
<strong>Technician:</strong> <?php echo esc_html($tech ? $tech->display_name : '#' . $session->technician_id()); ?> ·
<span class="wph-progress"><?php echo (int) $progress['done']; ?> / <?php echo (int) $progress['total']; ?> steps</span>
</p>
<p>WP <code><?php echo esc_html($session->wp_version()); ?></code> · PHP <code><?php echo esc_html($session->php_version()); ?></code> · Site <code><?php echo esc_html($session->site_url()); ?></code></p>
<div class="wph-actions">
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" onsubmit="return confirm('Mark this healthcheck as finished?');" style="display:inline">
<?php wp_nonce_field('wph_finish'); ?>
<input type="hidden" name="action" value="wph_finish">
<button class="button button-primary">Finish &amp; generate report</button>
</form>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" onsubmit="return confirm('Discard this in-progress session? All notes will be lost.');" style="display:inline">
<?php wp_nonce_field('wph_discard'); ?>
<input type="hidden" name="action" value="wph_discard">
<button class="button button-link-delete">Discard</button>
</form>
</div>
</div>
<?php
WPH_Recovery_Bootstrap::render_status();
foreach (WPH_Steps::instance()->all() as $step) {
wph_render_step_card($session, $step);
}
}
function wph_render_step_card(WPH_Session $session, WPH_Step $step): void {
$state = $session->step_state($step->id());
$status = $state['status'];
$notes = $state['notes'];
?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" class="wph-step" id="step-<?php echo esc_attr($step->id()); ?>">
<?php wp_nonce_field('wph_save_step_' . $step->id()); ?>
<input type="hidden" name="action" value="wph_save_step">
<input type="hidden" name="step" value="<?php echo esc_attr($step->id()); ?>">
<header>
<h2><?php echo esc_html($step->title()); ?></h2>
<span class="wph-step-status wph-status-<?php echo esc_attr($status); ?>"><?php echo esc_html(str_replace('_', ' ', $status)); ?></span>
</header>
<?php if ($blurb = $step->blurb()): ?>
<p><?php echo esc_html($blurb); ?></p>
<?php endif; ?>
<?php if ($items = $step->sub_items()): ?>
<ul class="wph-sub-items">
<?php foreach ($items as $item): ?>
<li><?php echo esc_html($item); ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($watch = $step->watch_outs()): ?>
<div class="wph-watch-outs">
<strong>Watch out for:</strong>
<ul style="margin:.2rem 0 0 1rem;">
<?php foreach ($watch as $w): ?>
<li><?php echo esc_html($w); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if ($status === WPH_Session::STATUS_BLOCKED && ($esc = $step->escalation())): ?>
<div class="wph-escalation"><?php echo esc_html($esc); ?></div>
<?php endif; ?>
<p>
<label>
<strong>Status:</strong>
<select name="status">
<?php foreach (WPH_Session::VALID_STATUSES as $s): ?>
<option value="<?php echo esc_attr($s); ?>" <?php selected($status, $s); ?>><?php echo esc_html(str_replace('_', ' ', $s)); ?></option>
<?php endforeach; ?>
</select>
</label>
</p>
<p>
<label><strong>Notes:</strong></label>
<textarea name="notes" placeholder="What did you check, find, fix, or flag?"><?php echo esc_textarea($notes); ?></textarea>
</p>
<p>
<button class="button">Save step</button>
<?php if ($state['updated_at']): ?>
<span class="description">Last saved <?php echo esc_html(human_time_diff($state['updated_at'], time())); ?> ago</span>
<?php endif; ?>
</p>
</form>
<?php
}
function wph_render_finished_panel(WPH_Session $session): void {
$report = wph_build_markdown_report($session);
?>
<div class="wph-card">
<h2>Healthcheck finished</h2>
<p>Started <?php echo esc_html(date('Y-m-d H:i', $session->started_at())); ?> · Finished <?php echo esc_html(date('Y-m-d H:i', (int) $session->finished_at())); ?></p>
<div class="wph-actions">
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<?php wp_nonce_field('wph_download_report'); ?>
<input type="hidden" name="action" value="wph_download_report">
<button class="button button-primary">Download Markdown report</button>
</form>
<button class="button" onclick="navigator.clipboard.writeText(document.getElementById('wph-report-md').textContent);this.textContent='Copied!';setTimeout(()=>this.textContent='Copy to clipboard',2000)">Copy to clipboard</button>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" onsubmit="return confirm('Discard this finished session?');" style="display:inline">
<?php wp_nonce_field('wph_discard'); ?>
<input type="hidden" name="action" value="wph_discard">
<button class="button button-link-delete">Discard &amp; start a new one</button>
</form>
</div>
</div>
<div class="wph-card">
<h2>Report preview</h2>
<pre id="wph-report-md" style="white-space:pre-wrap;background:#f6f7f7;padding:1rem;border-radius:6px;max-height:30em;overflow:auto"><?php echo esc_html($report); ?></pre>
</div>
<?php
}
// --- Handlers ----------------------------------------------------------------
function wph_handle_start(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('wph_start');
WPH_Session::start(get_current_user_id());
wp_safe_redirect(admin_url('tools.php?page=site-healthcheck'));
exit;
}
function wph_handle_save_step(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
$step_id = isset($_POST['step']) ? sanitize_key((string) $_POST['step']) : '';
check_admin_referer('wph_save_step_' . $step_id);
$session = WPH_Session::current();
if (!$session || $session->is_finished()) wp_die('No active session.');
if (!WPH_Steps::instance()->get($step_id)) wp_die('Unknown step.');
$status = isset($_POST['status']) ? sanitize_key((string) $_POST['status']) : WPH_Session::STATUS_NOT_STARTED;
$notes = isset($_POST['notes']) ? wp_unslash((string) $_POST['notes']) : '';
$session->update_step($step_id, $status, $notes);
wp_safe_redirect(admin_url('tools.php?page=site-healthcheck#step-' . rawurlencode($step_id)));
exit;
}
function wph_handle_finish(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('wph_finish');
$session = WPH_Session::current();
if (!$session) wp_die('No active session.');
$session->finish();
wp_safe_redirect(admin_url('tools.php?page=site-healthcheck'));
exit;
}
function wph_handle_discard(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('wph_discard');
WPH_Session::discard();
wp_safe_redirect(admin_url('tools.php?page=site-healthcheck'));
exit;
}
function wph_handle_download_report(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('wph_download_report');
$session = WPH_Session::current();
if (!$session) wp_die('No session.');
$report = wph_build_markdown_report($session);
$host = parse_url(get_site_url(), PHP_URL_HOST) ?: 'site';
$host = preg_replace('/[^a-z0-9.-]/i', '', (string) $host);
$stamp = date('Ymd', $session->started_at() ?: time());
$filename = 'wph-report-' . $host . '-' . $stamp . '.md';
nocache_headers();
header('Content-Type: text/markdown; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
echo $report;
exit;
}

View File

@@ -0,0 +1,105 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* Healthcheck session. Option-backed (single in-progress session per site).
*
* Per beads decision hc-5ix.4: option, not CPT. The plugin is installed per
* engagement, so per-site history living in the DB would die on uninstall.
* Reports are exported as Markdown instead — see report.php.
*/
final class WPH_Session {
public const STATUS_NOT_STARTED = 'not_started';
public const STATUS_DONE = 'done';
public const STATUS_SKIPPED = 'skipped';
public const STATUS_BLOCKED = 'blocked';
public const STATUS_NA = 'n_a';
public const VALID_STATUSES = [
self::STATUS_NOT_STARTED,
self::STATUS_DONE,
self::STATUS_SKIPPED,
self::STATUS_BLOCKED,
self::STATUS_NA,
];
private array $data;
private function __construct(array $data) {
$this->data = $data;
}
public static function current(): ?self {
$raw = get_option(WPH_OPT_SESSION);
if (!is_array($raw) || empty($raw['id'])) return null;
return new self($raw);
}
public static function start(int $technician_id): self {
$data = [
'id' => uniqid('wph_', true),
'started_at' => time(),
'finished_at' => null,
'technician_id'=> $technician_id,
'site_url' => get_site_url(),
'wp_version' => get_bloginfo('version'),
'php_version' => PHP_VERSION,
'steps' => [], // keyed by step id → ['status' => ..., 'notes' => ..., 'updated_at' => ...]
];
update_option(WPH_OPT_SESSION, $data, false);
return new self($data);
}
public static function discard(): void {
delete_option(WPH_OPT_SESSION);
}
public function id(): string { return (string) $this->data['id']; }
public function started_at(): int { return (int) $this->data['started_at']; }
public function finished_at(): ?int { return isset($this->data['finished_at']) ? (int) $this->data['finished_at'] : null; }
public function is_finished(): bool { return $this->finished_at() !== null; }
public function technician_id(): int { return (int) $this->data['technician_id']; }
public function site_url(): string { return (string) ($this->data['site_url'] ?? get_site_url()); }
public function wp_version(): string { return (string) ($this->data['wp_version'] ?? ''); }
public function php_version(): string { return (string) ($this->data['php_version'] ?? ''); }
public function data(): array { return $this->data; }
public function step_state(string $step_id): array {
return $this->data['steps'][$step_id] ?? [
'status' => self::STATUS_NOT_STARTED,
'notes' => '',
'updated_at' => null,
];
}
public function update_step(string $step_id, string $status, string $notes): void {
if (!in_array($status, self::VALID_STATUSES, true)) {
$status = self::STATUS_NOT_STARTED;
}
$this->data['steps'][$step_id] = [
'status' => $status,
'notes' => $notes,
'updated_at' => time(),
];
update_option(WPH_OPT_SESSION, $this->data, false);
}
public function finish(): void {
$this->data['finished_at'] = time();
update_option(WPH_OPT_SESSION, $this->data, false);
}
public function progress(): array {
$steps = WPH_Steps::instance()->all();
$total = count($steps);
$done = 0;
foreach ($steps as $step) {
$st = $this->step_state($step->id());
if (in_array($st['status'], [self::STATUS_DONE, self::STATUS_SKIPPED, self::STATUS_NA], true)) {
$done++;
}
}
return ['done' => $done, 'total' => $total];
}
}

View File

@@ -0,0 +1,45 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* Base class for a healthcheck step.
*
* Each step lives in its own file under includes/steps/ with a numeric prefix
* (e.g. 10-backup.php). The file returns an instance of a subclass of WPH_Step.
* Drop a new file in that directory and it shows up; delete a file and it
* disappears. The numeric prefix controls order so re-ordering is a rename.
*
* Stable string IDs (returned by id()) are stored in the session, so renaming
* the file does NOT lose data as long as id() stays the same.
*/
abstract class WPH_Step {
/** Stable identifier (lowercase slug). NEVER change once shipped. */
abstract public function id(): string;
/** Human-readable title shown in the UI and report. */
abstract public function title(): string;
/** Intro paragraph shown under the title (plain text or simple inline HTML). */
public function blurb(): string { return ''; }
/** Sub-items the technician should tick through (plain strings). */
public function sub_items(): array { return []; }
/** "Watch out for" callouts (plain strings). */
public function watch_outs(): array { return []; }
/**
* If this step has a stop/escalate condition (e.g. backup failed → stop),
* return the guidance string. Rendered as a banner when status=blocked.
*/
public function escalation(): ?string { return null; }
/**
* Phase 3 hook — return structured findings (php version, plugin update
* intel, etc.) for the technician to verify. Phase 1 returns nothing.
*
* @return array<string,mixed>
*/
public function autocheck(array $session_state): array { return []; }
}

View File

@@ -0,0 +1,55 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* Step registry. Singleton.
*
* discover() globs a directory for *.php files in sort order and requires each.
* Each file MUST return a WPH_Step instance. The numeric filename prefix
* (00, 10, 20...) controls the visual order.
*
* After discovery, the 'wph_steps' filter lets installation-specific code
* remove/replace steps (return the array keyed by step id).
*/
final class WPH_Steps {
private static ?WPH_Steps $instance = null;
/** @var array<string,WPH_Step> */
private array $steps = [];
private bool $discovered = false;
public static function instance(): WPH_Steps {
return self::$instance ??= new self();
}
public function discover(string $dir): void {
if ($this->discovered) return;
$files = glob(rtrim($dir, '/') . '/*.php') ?: [];
// Natural sort so 100-* comes after 20-* (not after 10-*).
natsort($files);
$files = array_values($files);
foreach ($files as $file) {
$obj = require $file;
if ($obj instanceof WPH_Step) {
$this->steps[$obj->id()] = $obj;
}
}
/**
* Filter the loaded steps. Return an array keyed by step id.
* To drop a step on a particular install: unset($steps['backup']).
*/
$this->steps = apply_filters('wph_steps', $this->steps);
$this->discovered = true;
}
/** @return array<string,WPH_Step> */
public function all(): array {
return $this->steps;
}
public function get(string $id): ?WPH_Step {
return $this->steps[$id] ?? null;
}
}

View File

@@ -0,0 +1,58 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* Step-0 bootstrap: ensure wp-site-recovery is installed + active before the
* stepper runs (bead hc-5ix.1, references decision hc-5ix.26).
*
* Detection is by plugin folder slug, not by main-file path, because the slug
* is stable across releases. If we ever publish a private update channel for
* the recovery plugin, this is also where we'd offer "fetch latest".
*/
final class WPH_Recovery_Bootstrap {
public const RECOVERY_SLUG = 'site-recovery';
public const RECOVERY_MAIN = 'site-recovery/site-recovery.php';
public static function is_installed(): bool {
if (!function_exists('get_plugins')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
return array_key_exists(self::RECOVERY_MAIN, get_plugins());
}
public static function is_active(): bool {
if (!function_exists('is_plugin_active')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
return is_plugin_active(self::RECOVERY_MAIN);
}
public static function recovery_admin_url(): string {
return admin_url('tools.php?page=site-recovery');
}
/**
* Render a status panel that the admin page calls from the "Before you start"
* section. Phase-1: report state + link to manage. Phase-3 may add a
* one-click installer from a private URL.
*/
public static function render_status(): void {
$installed = self::is_installed();
$active = $installed && self::is_active();
?>
<div class="wph-bootstrap-panel">
<h3>Recovery plugin status</h3>
<?php if ($active): ?>
<p class="wph-ok">✓ <strong>Site Recovery</strong> is installed and active.
<a href="<?php echo esc_url(self::recovery_admin_url()); ?>">Open recovery settings</a> to copy the URL + password into the client record.</p>
<?php elseif ($installed): ?>
<p class="wph-warn">⚠ <strong>Site Recovery</strong> is installed but inactive. <a href="<?php echo esc_url(admin_url('plugins.php')); ?>">Activate it</a> before starting work.</p>
<?php else: ?>
<p class="wph-bad">✗ <strong>Site Recovery</strong> is not installed. Install it before starting the healthcheck — it's the safety net while we work on the site.</p>
<p class="description">Phase-1: install manually via <em>Plugins → Add New → Upload Plugin</em>. A private update channel for one-click install lands in hc-5ix.27.</p>
<?php endif; ?>
</div>
<?php
}
}

91
includes/report.php Normal file
View File

@@ -0,0 +1,91 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* Build a Markdown report for a healthcheck session.
* Per bead hc-5ix.7: pure Markdown, downloadable. No DB-side report storage
* (plugin may be uninstalled at the end of the engagement).
*/
function wph_build_markdown_report(WPH_Session $session): string {
$tech = get_userdata($session->technician_id());
$tech_name = $tech ? $tech->display_name : '#' . $session->technician_id();
$lines = [];
$lines[] = '# Site Healthcheck — ' . $session->site_url();
$lines[] = '';
$lines[] = '- **Started:** ' . date('Y-m-d H:i', $session->started_at());
if ($session->is_finished()) {
$lines[] = '- **Finished:** ' . date('Y-m-d H:i', (int) $session->finished_at());
$minutes = max(1, (int) round(((int) $session->finished_at() - $session->started_at()) / 60));
$lines[] = '- **Duration:** ~' . $minutes . ' minute(s)';
}
$lines[] = '- **Technician:** ' . $tech_name;
$lines[] = '- **Site:** ' . $session->site_url();
$lines[] = '- **WordPress:** ' . $session->wp_version();
$lines[] = '- **PHP:** ' . $session->php_version();
$lines[] = '- **Session ID:** ' . $session->id();
$lines[] = '';
// Summary table
$lines[] = '## Summary';
$lines[] = '';
$lines[] = '| Step | Status |';
$lines[] = '|---|---|';
foreach (WPH_Steps::instance()->all() as $step) {
$state = $session->step_state($step->id());
$lines[] = '| ' . $step->title() . ' | ' . wph_status_label($state['status']) . ' |';
}
$lines[] = '';
// Per-step detail
$lines[] = '## Detail';
$lines[] = '';
foreach (WPH_Steps::instance()->all() as $step) {
$state = $session->step_state($step->id());
$lines[] = '### ' . $step->title();
$lines[] = '';
$lines[] = '_Status: ' . wph_status_label($state['status']) . '_';
if ($state['updated_at']) {
$lines[] = '_Saved: ' . date('Y-m-d H:i', $state['updated_at']) . '_';
}
if ($blurb = $step->blurb()) {
$lines[] = '';
$lines[] = $blurb;
}
if ($items = $step->sub_items()) {
$lines[] = '';
foreach ($items as $item) {
$lines[] = '- [ ] ' . $item;
}
}
if (!empty($state['notes'])) {
$lines[] = '';
$lines[] = '**Notes:**';
$lines[] = '';
foreach (preg_split('/\R/', (string) $state['notes']) as $nl) {
$lines[] = '> ' . $nl;
}
}
if ($state['status'] === WPH_Session::STATUS_BLOCKED && ($esc = $step->escalation())) {
$lines[] = '';
$lines[] = '> ⚠ **Escalation:** ' . $esc;
}
$lines[] = '';
}
$lines[] = '---';
$lines[] = '_Generated by Site Healthcheck plugin v' . WPH_VERSION . '_';
return implode("\n", $lines) . "\n";
}
function wph_status_label(string $status): string {
switch ($status) {
case WPH_Session::STATUS_DONE: return '✅ Done';
case WPH_Session::STATUS_SKIPPED: return '⏭ Skipped';
case WPH_Session::STATUS_BLOCKED: return '🛑 Blocked';
case WPH_Session::STATUS_NA: return '— N/A';
case WPH_Session::STATUS_NOT_STARTED:
default: return '◻ Not started';
}
}

View File

@@ -0,0 +1,19 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'before'; }
public function title(): string { return 'Before You Start'; }
public function blurb(): string {
return 'Confirm prerequisites before touching anything.';
}
public function sub_items(): array {
return [
'Confirm you have admin access to the WordPress dashboard and hosting control panel',
'Check the client record for any known issues, recent changes, or flags from the previous healthcheck',
'Note the current WordPress version, PHP version, and active theme before touching anything',
'Install our recovery plugin (Site Recovery) — done via the button below',
'Make a note of the recovery path in case there is a problem',
];
}
};

View File

@@ -0,0 +1,20 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'backup'; }
public function title(): string { return 'Step 1 — Take a Full Backup'; }
public function blurb(): string {
return 'Before any work takes place, take a complete backup manually. Do not rely on the most recent automated backup.';
}
public function sub_items(): array {
return [
'Back up both the database and all files (wp-content, wp-config.php, .htaccess)',
'Confirm the backup has completed and is accessible/downloadable',
'Note the backup location and timestamp in the client record',
];
}
public function escalation(): ?string {
return 'If the backup fails or cannot be confirmed, stop. Do not proceed until you have a verified backup.';
}
};

View File

@@ -0,0 +1,13 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'uptime'; }
public function title(): string { return 'Step 10 — Uptime and Availability'; }
public function sub_items(): array {
return [
'Check uptime monitoring logs if available — note any downtime incidents since the last healthcheck and flag to client if significant',
'Confirm the site is resolving correctly on both www and non-www',
];
}
};

View File

@@ -0,0 +1,19 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'small_fixes'; }
public function title(): string { return 'Step 11 — Small Fixes'; }
public function blurb(): string {
return 'Address any small issues found during the check that fall within the ~15 minute threshold. Anything longer goes on the quote list.';
}
public function sub_items(): array {
return [
'Broken internal links on key pages',
'Missing alt text on homepage images',
'Obvious content errors noticed in passing (broken shortcodes, missing widgets)',
'Reactivating a deactivated-but-needed plugin',
'Clearing accumulated spam comments',
];
}
};

View File

@@ -0,0 +1,16 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'wrap_up'; }
public function title(): string { return 'Step 12 — Wrap Up and Document'; }
public function sub_items(): array {
return [
'Log everything done in the client record: versions before and after, any issues found, any fixes applied, anything flagged for follow-up',
'Note the date, time taken, and technician',
'If using ManageWP or WP Umbrella, generate the client report and review it before sending — make sure it accurately reflects what was done',
'Send the client report or file it according to your process',
'If anything was flagged that needs a separate quote or client decision, send that communication now rather than leaving it',
];
}
};

View File

@@ -0,0 +1,19 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'environment'; }
public function title(): string { return 'Step 2 — Environment Check'; }
public function blurb(): string {
return 'Before touching updates, review the hosting environment.';
}
public function sub_items(): array {
return [
'PHP version — check against WordPress requirements and plugin compatibility. Flag if below 8.1. Note if EOL.',
'Disk usage — flag if over 80% used',
'Error logs — check the server error log and WordPress debug log if enabled. Note any recurring errors, 500s, or deprecated function warnings',
'wp-config.php — confirm WP_DEBUG is off on production',
'File permissions — spot check wp-config.php (should be 640 or 600), wp-content (755), uploads (755)',
];
}
};

View File

@@ -0,0 +1,21 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'core'; }
public function title(): string { return 'Step 3 — WordPress Core Update'; }
public function sub_items(): array {
return [
'Check current version against latest stable release',
'If an update is available, apply it',
'After update, load the site front-end and wp-admin and confirm both are functioning',
'Check the database upgrade prompt — if WordPress prompts to upgrade the database, run it',
'Note the version you updated from and to',
];
}
public function watch_outs(): array {
return [
'White screen of death post-update, admin redirect loops, missing admin menu items — these usually indicate a theme or plugin conflict with the new core version.',
];
}
};

View File

@@ -0,0 +1,25 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'plugins'; }
public function title(): string { return 'Step 4 — Plugin Updates'; }
public function sub_items(): array {
return [
'Go to Dashboard → Updates and review all pending plugin updates',
'Before updating, note which plugins have updates and what versions they are moving to',
'Update plugins one at a time if the site is complex or has many interdependencies; batch update is acceptable for straightforward sites',
'After each update (or after a batch), check the front-end and any key functional areas (forms, checkout, membership, etc.)',
'Check for any plugins that have been deactivated but not deleted — flag these to the client',
];
}
public function watch_outs(): array {
return [
'WooCommerce updates — always treat these as high-risk, test checkout flow afterwards',
'Page builder updates (Elementor, Divi, Beaver Builder) — can affect layout rendering',
'Security plugin updates — confirm they reactivate and are still reporting clean',
'Plugins that haven\'t been updated by their developer in over 12 months — flag as a risk',
'Plugins showing "Update unavailable" or removed from the WordPress repository — flag immediately, these can indicate abandoned or compromised plugins',
];
}
};

View File

@@ -0,0 +1,20 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'theme'; }
public function title(): string { return 'Step 5 — Theme Updates'; }
public function sub_items(): array {
return [
'Update the active theme if an update is available',
'If a child theme is in use (correct practice), the parent theme can be updated safely — confirm child theme is active',
'If no child theme is in use and the parent theme has been customised directly, do not update without flagging to the client first — the update will overwrite customisations',
'Update inactive themes only if they are legitimate fallback themes (e.g. a default Twenty* theme). Unused themes that serve no purpose should be flagged for removal',
];
}
public function watch_outs(): array {
return [
'Layout changes post-theme update, broken header/footer, missing custom fonts or colours — indicates customisation was done directly in the parent theme.',
];
}
};

View File

@@ -0,0 +1,23 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'visual'; }
public function title(): string { return 'Step 6 — Visual and Functional Check'; }
public function blurb(): string {
return 'Do a manual walkthrough of the site.';
}
public function sub_items(): array {
return [
'Homepage — load and visually inspect. Check for broken images, layout issues, console errors (open browser dev tools)',
'Navigation — click through the main menu. Confirm all links resolve correctly, no 404s on primary pages',
'Key pages — About, Contact, Services or equivalent. Check content renders correctly',
'Contact form — submit a test entry and confirm it delivers (check spam folder if no delivery). Note which form plugin is in use',
'If WooCommerce — check shop page loads, a product page loads, add to basket works. Do not need to complete a full test purchase every time unless flagged',
'If membership/login — confirm login page loads and (if test credentials available) login works',
'Mobile view — check the homepage and one internal page on a mobile viewport in browser dev tools',
'HTTPS — confirm the padlock is showing and there are no mixed content warnings',
'Redirects — confirm www/non-www and HTTP/HTTPS are redirecting correctly to the canonical URL',
];
}
};

View File

@@ -0,0 +1,16 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'performance'; }
public function title(): string { return 'Step 7 — Performance Check'; }
public function sub_items(): array {
return [
'Run a quick PageSpeed Insights check on the homepage',
'Note the scores (mobile and desktop) in the client record',
'Flag if mobile score has dropped significantly since last check (more than 10 points)',
'Check that caching is active — if using a caching plugin, confirm it is enabled and not throwing errors',
'Check image sizes on the homepage — flag if uncompressed images over 500KB are being served',
];
}
};

View File

@@ -0,0 +1,17 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'security'; }
public function title(): string { return 'Step 8 — Security Check'; }
public function sub_items(): array {
return [
'Confirm the SSL certificate is valid and not expiring within 30 days — flag if so',
'Check the WordPress user list — flag any unfamiliar admin accounts',
'Check for any recently modified core files if you have file change monitoring in place',
'Confirm the login URL is not the default /wp-admin if security hardening was previously applied',
'If a security plugin is active, review its dashboard for any flagged issues',
'Check that xmlrpc.php is disabled or restricted if not in use',
];
}
};

View File

@@ -0,0 +1,14 @@
<?php
if (!defined('ABSPATH')) exit;
return new class extends WPH_Step {
public function id(): string { return 'database'; }
public function title(): string { return 'Step 9 — Database'; }
public function sub_items(): array {
return [
'Run a database optimisation (via WP-CLI: wp db optimize, or via a plugin such as WP-Optimize)',
'Check for and remove any spam comments if comment moderation hasn\'t been keeping up',
'Check post revisions — if excessive (thousands), note for client discussion on whether a revision limit should be set',
];
}
};

32
site-healthcheck.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
/**
* Plugin Name: Site Healthcheck
* Description: Walks a technician through a structured WordPress site healthcheck. Steps are drop-in PHP files so adding/removing one is a single file change.
* Version: 0.1.0
* Author: Steve Hanlon
* License: GPL-2.0-or-later
* Requires PHP: 7.4
*
* Internal/agency tool — not distributed via WP.org. See beads decision hc-5ix.26.
*/
if (!defined('ABSPATH')) {
exit;
}
define('WPH_VERSION', '0.1.0');
define('WPH_PLUGIN_FILE', __FILE__);
define('WPH_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('WPH_PLUGIN_URL', plugin_dir_url(__FILE__));
define('WPH_OPT_SESSION', 'wph_session');
require_once WPH_PLUGIN_DIR . 'includes/class-wph-step.php';
require_once WPH_PLUGIN_DIR . 'includes/class-wph-steps.php';
require_once WPH_PLUGIN_DIR . 'includes/class-wph-session.php';
require_once WPH_PLUGIN_DIR . 'includes/recovery-bootstrap.php';
require_once WPH_PLUGIN_DIR . 'includes/admin-page.php';
require_once WPH_PLUGIN_DIR . 'includes/report.php';
add_action('plugins_loaded', function () {
WPH_Steps::instance()->discover(WPH_PLUGIN_DIR . 'includes/steps/');
});

168
steps.md Normal file
View File

@@ -0,0 +1,168 @@
## WordPress Site Healthcheck — Technician Guide
### Before You Start
- Confirm you have admin access to the WordPress dashboard and hosting control panel
- Check the client record for any known issues, recent changes, or flags from the previous healthcheck
- Note the current WordPress version, PHP version, and active theme before touching anything
- Install our recovery plugin. This plugin should also step you through what's needed for the healthcheck as well.
- Make a note of the recovery path in case there is a problem
---
### Step 1 — Take a Full Backup
Before any work takes place, take a complete backup manually. Do not rely on the most recent automated backup.
- Back up both the database and all files (wp-content, wp-config.php, .htaccess)
- Confirm the backup has completed and is accessible/downloadable
- Note the backup location and timestamp in the client record
**If the backup fails or cannot be confirmed, stop. Do not proceed until you have a verified backup.**
---
### Step 2 — Environment Check
Before touching updates, review the hosting environment:
- **PHP version** — check against WordPress requirements and plugin compatibility. Flag if below 8.1. Note if EOL.
- **Disk usage** — flag if over 80% used
- **Error logs** — check the server error log and WordPress debug log if enabled. Note any recurring errors, 500s, or deprecated function warnings
- **wp-config.php** — confirm WP_DEBUG is off on production
- **File permissions** — spot check wp-config.php (should be 640 or 600), wp-content (755), uploads (755)
---
### Step 3 — WordPress Core Update
- Check current version against latest stable release
- If an update is available, apply it
- After update, load the site front-end and wp-admin and confirm both are functioning
- Check the database upgrade prompt — if WordPress prompts to upgrade the database, run it
- Note the version you updated from and to
**Watch out for:** white screen of death post-update, admin redirect loops, missing admin menu items — these usually indicate a theme or plugin conflict with the new core version.
---
### Step 4 — Plugin Updates
- Go to Dashboard → Updates and review all pending plugin updates
- Before updating, note which plugins have updates and what versions they are moving to
- Update plugins one at a time if the site is complex or has many interdependencies; batch update is acceptable for straightforward sites
- After each update (or after a batch), check the front-end and any key functional areas (forms, checkout, membership, etc.)
- Check for any plugins that have been deactivated but not deleted — flag these to the client
**Watch out for:**
- WooCommerce updates — always treat these as high-risk, test checkout flow afterwards
- Page builder updates (Elementor, Divi, Beaver Builder) — can affect layout rendering
- Security plugin updates — confirm they reactivate and are still reporting clean
- Plugins that haven't been updated by their developer in over 12 months — flag as a risk
- Plugins showing "Update unavailable" or removed from the WordPress repository — flag immediately, these can indicate abandoned or compromised plugins
---
### Step 5 — Theme Updates
- Update the active theme if an update is available
- If a child theme is in use (correct practice), the parent theme can be updated safely — confirm child theme is active
- If no child theme is in use and the parent theme has been customised directly, do not update without flagging to the client first — the update will overwrite customisations
- Update inactive themes only if they are legitimate fallback themes (e.g. a default Twenty* theme). Unused themes that serve no purpose should be flagged for removal
**Watch out for:** layout changes post-theme update, broken header/footer, missing custom fonts or colours — indicates customisation was done directly in the parent theme.
---
### Step 6 — Visual and Functional Check
Do a manual walkthrough of the site:
- **Homepage** — load and visually inspect. Check for broken images, layout issues, console errors (open browser dev tools)
- **Navigation** — click through the main menu. Confirm all links resolve correctly, no 404s on primary pages
- **Key pages** — About, Contact, Services or equivalent. Check content renders correctly
- **Contact form** — submit a test entry and confirm it delivers (check spam folder if no delivery). Note which form plugin is in use
- **If WooCommerce** — check shop page loads, a product page loads, add to basket works. Do not need to complete a full test purchase every time unless flagged
- **If membership/login** — confirm login page loads and (if test credentials available) login works
- **Mobile view** — check the homepage and one internal page on a mobile viewport in browser dev tools
- **HTTPS** — confirm the padlock is showing and there are no mixed content warnings
- **Redirects** — confirm www/non-www and HTTP/HTTPS are redirecting correctly to the canonical URL
---
### Step 7 — Performance Check
- Run a quick PageSpeed Insights check on the homepage
- Note the scores (mobile and desktop) in the client record
- Flag if mobile score has dropped significantly since last check (more than 10 points)
- Check that caching is active — if using a caching plugin, confirm it is enabled and not throwing errors
- Check image sizes on the homepage — flag if uncompressed images over 500KB are being served
---
### Step 8 — Security Check
- Confirm the SSL certificate is valid and not expiring within 30 days — flag if so
- Check the WordPress user list — flag any unfamiliar admin accounts
- Check for any recently modified core files if you have file change monitoring in place
- Confirm the login URL is not the default /wp-admin if security hardening was previously applied
- If a security plugin is active, review its dashboard for any flagged issues
- Check that xmlrpc.php is disabled or restricted if not in use
---
### Step 9 — Database
- Run a database optimisation (via WP-CLI: `wp db optimize`, or via a plugin such as WP-Optimize)
- Check for and remove any spam comments if comment moderation hasn't been keeping up
- Check post revisions — if excessive (thousands), note for client discussion on whether a revision limit should be set
---
### Step 10 — Uptime and Availability
- Check uptime monitoring logs if available — note any downtime incidents since the last healthcheck and flag to client if significant
- Confirm the site is resolving correctly on both www and non-www
---
### Step 11 — Small Fixes
Address any small issues found during the check that fall within the ~15 minute threshold:
- Broken internal links on key pages
- Missing alt text on homepage images
- Obvious content errors noticed in passing (broken shortcodes, missing widgets)
- Reactivating a deactivated-but-needed plugin
- Clearing accumulated spam comments
For anything that will take longer, write it up as a separate item to quote for.
---
### Step 12 — Wrap Up and Document
- Log everything done in the client record: versions before and after, any issues found, any fixes applied, anything flagged for follow-up
- Note the date, time taken, and technician
- If using ManageWP or WP Umbrella, generate the client report and review it before sending — make sure it accurately reflects what was done
- Send the client report or file it according to your process
- If anything was flagged that needs a separate quote or client decision, send that communication now rather than leaving it
---
### Quick Reference — When to Stop and Escalate
| Situation | Action |
|---|---|
| Backup cannot be confirmed | Stop. Do not proceed. |
| Site is down before you start | Diagnose and resolve before running updates |
| Core update causes white screen | Restore backup immediately |
| Plugin removed from WP repository | Flag to client before removing |
| Unfamiliar admin user found | Flag to client immediately, do not delete without instruction |
| SSL expiring within 30 days | Escalate for renewal before next healthcheck |
| PHP version EOL | Raise with client as a separate piece of work |
---
*This is intended as a repeatable checklist rather than a rigid script — experienced technicians will move through familiar sites faster, but the sequence and the stop/escalate conditions should be consistent regardless of who runs the check.*