spinuptui 0.19.0

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.
Files changed (88) hide show
  1. package/CHANGELOG.md +943 -0
  2. package/LICENSE +21 -0
  3. package/README.md +856 -0
  4. package/bin/spinuptui.js +13 -0
  5. package/package.json +55 -0
  6. package/src/api/client.ts +354 -0
  7. package/src/api/types.ts +206 -0
  8. package/src/config.ts +309 -0
  9. package/src/dev/devMode.ts +7 -0
  10. package/src/dev/fixtures.ts +238 -0
  11. package/src/dev/mockClient.ts +214 -0
  12. package/src/index.tsx +68 -0
  13. package/src/lib/appUpdate.ts +206 -0
  14. package/src/lib/awsSigV4.ts +133 -0
  15. package/src/lib/cloneDomains.ts +112 -0
  16. package/src/lib/cloneLog.ts +85 -0
  17. package/src/lib/dbBackup.ts +198 -0
  18. package/src/lib/dbSync.ts +253 -0
  19. package/src/lib/discover.ts +165 -0
  20. package/src/lib/dns.ts +144 -0
  21. package/src/lib/dnsCache.ts +94 -0
  22. package/src/lib/dnsQuery.ts +180 -0
  23. package/src/lib/dnsRecords.ts +411 -0
  24. package/src/lib/format.ts +106 -0
  25. package/src/lib/gitDeployKey.ts +0 -0
  26. package/src/lib/gitStatus.ts +37 -0
  27. package/src/lib/jobs.ts +32 -0
  28. package/src/lib/keychain.ts +49 -0
  29. package/src/lib/local.ts +116 -0
  30. package/src/lib/mediaFallback.ts +288 -0
  31. package/src/lib/open.ts +103 -0
  32. package/src/lib/phpEol.ts +151 -0
  33. package/src/lib/probe.ts +183 -0
  34. package/src/lib/providers.ts +284 -0
  35. package/src/lib/providersCache.ts +105 -0
  36. package/src/lib/releaseNotes.ts +67 -0
  37. package/src/lib/serverClone.ts +782 -0
  38. package/src/lib/serverCreate.ts +161 -0
  39. package/src/lib/siteDoctor.ts +260 -0
  40. package/src/lib/siteFingerprint.ts +108 -0
  41. package/src/lib/spinupweb.ts +33 -0
  42. package/src/lib/ssh.ts +669 -0
  43. package/src/lib/stack.ts +78 -0
  44. package/src/lib/stackCache.ts +113 -0
  45. package/src/lib/theme.ts +54 -0
  46. package/src/lib/ubuntuEol.ts +124 -0
  47. package/src/lib/uptimeKuma.ts +551 -0
  48. package/src/lib/vanitySite.ts +297 -0
  49. package/src/lib/wpInventory.ts +153 -0
  50. package/src/ui/App.tsx +265 -0
  51. package/src/ui/Details.tsx +403 -0
  52. package/src/ui/Explain.tsx +158 -0
  53. package/src/ui/Header.tsx +117 -0
  54. package/src/ui/Help.tsx +296 -0
  55. package/src/ui/List.tsx +95 -0
  56. package/src/ui/Onboarding.tsx +93 -0
  57. package/src/ui/Splash.tsx +51 -0
  58. package/src/ui/StatusBar.tsx +60 -0
  59. package/src/ui/components.tsx +392 -0
  60. package/src/ui/store.tsx +4253 -0
  61. package/src/ui/views/Browser.tsx +365 -0
  62. package/src/ui/views/CloneWizard.tsx +932 -0
  63. package/src/ui/views/Dashboard.tsx +149 -0
  64. package/src/ui/views/DbBackup.tsx +228 -0
  65. package/src/ui/views/DbSync.tsx +307 -0
  66. package/src/ui/views/Discover.tsx +271 -0
  67. package/src/ui/views/DnsInventory.tsx +580 -0
  68. package/src/ui/views/DnsRecords.tsx +562 -0
  69. package/src/ui/views/Events.tsx +98 -0
  70. package/src/ui/views/Forgotten.tsx +165 -0
  71. package/src/ui/views/GrantKey.tsx +504 -0
  72. package/src/ui/views/Health.tsx +322 -0
  73. package/src/ui/views/HttpsToggle.tsx +195 -0
  74. package/src/ui/views/KumaSite.tsx +571 -0
  75. package/src/ui/views/LocalLink.tsx +220 -0
  76. package/src/ui/views/MediaFallback.tsx +180 -0
  77. package/src/ui/views/NewServer.tsx +750 -0
  78. package/src/ui/views/PhpUpgrade.tsx +301 -0
  79. package/src/ui/views/ProviderConnect.tsx +527 -0
  80. package/src/ui/views/PurgeCache.tsx +186 -0
  81. package/src/ui/views/ReleaseNotes.tsx +84 -0
  82. package/src/ui/views/Search.tsx +448 -0
  83. package/src/ui/views/ServerActions.tsx +328 -0
  84. package/src/ui/views/Stacks.tsx +371 -0
  85. package/src/ui/views/SudoConnect.tsx +358 -0
  86. package/src/ui/views/VanityNewSite.tsx +407 -0
  87. package/src/ui/views/WpInventory.tsx +218 -0
  88. package/src/version.ts +8 -0
package/README.md ADDED
@@ -0,0 +1,856 @@
1
+ <p align="center">
2
+ <img src=".github/assets/banner.png" alt="SpinupWP TUI" width="100%">
3
+ </p>
4
+
5
+ <h1 align="center">SpinupTUI</h1>
6
+
7
+ <p align="center">
8
+ A fast, keyboard-driven terminal control center for your
9
+ <a href="https://spinupwp.com">SpinupWP</a> account — browse and monitor your
10
+ servers and sites, and run local-dev workflows against them.<br>
11
+ Built with <a href="https://opentui.com">OpenTUI</a> and <a href="https://bun.sh">Bun</a>.
12
+ </p>
13
+
14
+ Once you're in, the dashboard looks like this:
15
+
16
+ ```
17
+ ◆ SpinupTUI v0.9.0 1 Dashboard 2 Servers 3 Stacks 4 Search 5 Events 20 servers · 171 sites
18
+
19
+ ┌──────────────┐ ┌───────────────┐ ┌───────────────────┐ ┌──────────────────────┐
20
+ │ Servers │ │ Sites │ │ Fleet Disk │ │ WP Updates │
21
+ │ 20 │ │ 171 │ │ 22% │ │ 359 │
22
+ │ 20 connected │ │ 139 WordPress │ │ 616.3 GB / 2.8 TB │ │ 217 plugin · 67 core │
23
+ └──────────────┘ └───────────────┘ └───────────────────┘ └──────────────────────┘
24
+ ┌─ Disk usage by server ───────────────┐ ┌─ Needs attention (27) ──────────────┐
25
+ │ web1.example.com ██████░░░ 60% │ │ • db1.example.com — … │
26
+ │ web2.example.com ██████░░░ 57% │ │ • web3.example.org — OS … │
27
+ └──────────────────────────────────────┘ └─────────────────────────────────────┘
28
+ ```
29
+
30
+ ## Features
31
+
32
+ - **Fleet dashboard** — at-a-glance health of every server: connection status,
33
+ disk usage bars, pending reboots/SpinupWP platform upgrades, WordPress update
34
+ counts, and a recent activity feed. Servers running an **end-of-life Ubuntu
35
+ release** are flagged too — a `⚠ os` badge in the Servers list, a red "Ubuntu …
36
+ EOL" line in a server's detail panel, and an entry in the dashboard's "Needs
37
+ attention" list pointing at the clone wizard (`C`).
38
+ - **Server & site browser** — a three-pane navigator. Pick a server, see its
39
+ sites, drill into full details (PHP version, HTTPS, page cache, backups, Git
40
+ deployment, WP updates, and more). Each site row carries an at-a-glance **status
41
+ column** — `👤🔑` granted SSH keys, `H` HTTPS, `C` page cache, `B` backups (lit
42
+ when on, faint `·` when off), plus `◆` linked and `↑N` pending updates — aligned
43
+ into columns across every row, with a compact key in the Sites panel title.
44
+ - **Stack detection & fleet composition** — the Stacks tab classifies every site
45
+ as Standard WP, Bedrock, or Non-WP, with a fleet-wide PHP version breakdown
46
+ (EOL versions flagged). Press `d` to SSH-probe a site's actual stack — naming
47
+ WHMCS, Laravel, Static HTML, and WordPress versions the API can't tell you —
48
+ or `D` to probe a whole stack at once. (See "Stack detection" below.)
49
+ - **Global search** — fuzzy search across every server and site at once by name,
50
+ domain, or IP. Tab onto a result to act on it (open, SpinupWP, PHP upgrade,
51
+ health) right from the results, without leaving the search.
52
+ - **Events feed** — recent provisioning and operation activity, with per-event
53
+ detail and output.
54
+ - **Live server health** — press `h` on any server for a real-time view over
55
+ SSH: CPU (aggregate + per-core + sparkline), load, memory/swap, disk mounts,
56
+ and top processes. Polls every few seconds. (See "Server health" below.)
57
+ - **Installed plugins & themes** — press `p` on a site for its real `wp plugin
58
+ list` / `wp theme list` over SSH: every plugin and theme with its status,
59
+ current version, and available update — the detail the API only exposes as bare
60
+ counts. Read-only, combined scrollable list with per-section update badges.
61
+ Detects the WordPress directory itself, so it works on `/public/` and Bedrock
62
+ installs alike (even sites SpinupWP misclassifies as non-WordPress). (See
63
+ "Installed plugins & themes" below.)
64
+ - **Open in browser** — press `o` on any site to open it in your default browser.
65
+ - **Link local working copies** — press `L` on a site to link its local checkout
66
+ (a path plus the local URL where you serve it), then open it with `t` (a
67
+ terminal there) or `v` (its local URL). The Stacks tab can **auto-discover**
68
+ copies (`S`) by git remote / Bedrock `WP_HOME` / folder name, and **report**
69
+ managed sites that still need one (`f`). Linked sites show a `◆` marker, and a
70
+ linked checkout shows its local git drift (`⇡N unpushed` / `● uncommitted`).
71
+ (See "Local working copies" below.)
72
+ - **SSH into a site** — press `s` on a site to open a new terminal already running
73
+ `ssh` into it (`{site_user}@{server_ip}`).
74
+ - **DNS migration lens** — press `n` on a site (or `N` on a server) to see the DNS
75
+ records that move a site to another server: each site's hostnames with their type,
76
+ TTL (in seconds), and a `◀ here` flag when they point at this server. Connect a
77
+ provider (AWS Route 53 / Cloudflare) and press `⏎` to **edit a record's TTL** — the
78
+ prep step for a low-risk cutover — or `p` to **repoint the record**: pick another
79
+ of your SpinupWP servers from a list (or enter an IP) and move the traffic. It only
80
+ ever touches a site's own hosting records, never your MX/TXT/other records. (See
81
+ "DNS hosts, access & editing" below.)
82
+ - **Database backup & sync** — on a linked WordPress site (Search tab), press `d`
83
+ to download a gzipped production database backup into the copy's `sql/` folder
84
+ (**read-only on production**), or — opt-in — press `p` to pull production into
85
+ your **local** database (backs up local first, rewrites URLs, runs your existing
86
+ `sync.d` hook). Works with Standard WP and Bedrock, no per-site config. (See
87
+ "Database backup & sync" below.)
88
+ - **Production media fallback** — after a DB pull, your local site shows broken
89
+ images because the (often huge) media library isn't synced. Press `m` on a
90
+ linked WordPress site to drop a small mu-plugin that serves any **missing-locally**
91
+ upload from production — so images resolve without copying a single file. Works
92
+ on any local stack (it's pure PHP, not web-server config). (See "Production media
93
+ fallback" below.)
94
+ - **Upgrade a site's PHP version** — press `u` on a site to pick a new PHP
95
+ version and apply it (`PUT /sites/{id}/php`), then watch the upgrade event run
96
+ to completion. (See "Upgrading PHP" below.)
97
+ - **Enable / disable HTTPS** — press `H` on a site to toggle its certificate.
98
+ Enabling issues a free Let's Encrypt cert (the domain must already resolve to
99
+ the server); disabling removes it — the confirm screen warns plainly that
100
+ https:// visitors will see errors until it's re-enabled. Same background
101
+ event-tracking as the PHP upgrade.
102
+ - **Purge cache** — press `P` on a site to clear its page cache and WordPress
103
+ object cache together (SpinupWP has no enable/disable for either on an
104
+ existing site, only purge). Low-risk and fully reversible — both rebuild on
105
+ the next page load.
106
+ - **Server actions** — press `a` on a server to reboot it or restart a service
107
+ (Nginx / PHP-FPM / MySQL / Redis). Servers needing a reboot show a `↻rbt`
108
+ badge, and the overlay reads the box over SSH to show *why* (the pending
109
+ kernel/security packages). (See "Server actions" below.)
110
+ - **Create & connect a server** — press `c` on the Servers tab to provision a new
111
+ server (DigitalOcean / Vultr / Linode / Hetzner), priced from the provider catalog
112
+ and pre-filled to match a selected server. Then press `V` on any server that
113
+ doesn't yet have a **site at its own hostname** to connect it end to end: Spinup
114
+ writes the DNS A record, creates a
115
+ placeholder "vanity" site, enables HTTPS, hands you off to add your SSH key, and
116
+ publishes a status page — turning a bare server into one you can actually work
117
+ with. Long builds run in the background and survive a restart. (See "Creating &
118
+ connecting servers" below.)
119
+ - **Clone a whole server to a new one** — press `C` on a server to clone one or
120
+ more of its sites onto a fresh (or existing) destination, **without touching DNS
121
+ until you say so**. A guided wizard plans + sizes the sites, provisions or picks
122
+ the destination, pulls each site server-to-server (Standard WP **and** Bedrock),
123
+ verifies the clone against the source, and finally repoints DNS on your word.
124
+ Runs in the background with a header badge. (See "Cloning a server" below.)
125
+ - **Privileged writes over SSH (sudo + SSH keys)** — the SpinupWP API can't manage
126
+ SSH keys or sudo users, so Spinup does it directly. Press `S` on a server to
127
+ **connect sudo** for the session (optionally **remembered in your macOS Keychain**
128
+ so it auto-unlocks next time), then `K` on a site to **grant or revoke** your
129
+ personal key and/or Spinup's dedicated machine key — one site or every site on the
130
+ server. (See "Privileged writes over SSH" below.)
131
+ - **Uptime Kuma monitoring** — press `m` on a site to wire it into your own
132
+ [Uptime Kuma](https://github.com/louislam/uptime-kuma) instance (one-time
133
+ connect, 2FA supported, verified by a real login). Vanity pages double as
134
+ health endpoints (`?healthz` returns 503 under CPU pressure / low disk;
135
+ `?format=json&key=…` serves full metrics) and get a load push monitor fed by a
136
+ once-a-minute cron — Kuma graphs server load, and a silent cron acts as a
137
+ dead-man's switch. Monitor status flows back in: a Monitor row in Details, a
138
+ "▼ N monitors down" header badge, uptime/response/load sparklines in the
139
+ Health view, and reboots automatically open a Kuma maintenance window so
140
+ planned downtime never pages you. Regular sites get a homepage monitor
141
+ (up/down + cert-expiry alerts) — client site files are never touched.
142
+ Showed a push URL or health key on a screencast? `r` in the `m` overlay
143
+ **rotates both secrets in place** — the old ones die immediately, the Kuma
144
+ monitor (and its history) survives. Vanity sites are marked `⌂` in the
145
+ Servers tab so they never hide among domain-named servers' sites.
146
+ (See `docs/uptime-kuma.md` for recipes.)
147
+ - **Site monitoring: fingerprints, a doctor, and a Redis sentinel** — press `M`
148
+ on any site (Servers / Stacks / Search) for the monitoring overlay. `f`
149
+ calibrates a **front-page check**: Spinup reads your healthy front page,
150
+ derives a template-identity fingerprint from what WordPress itself stamps
151
+ into the markup (not a headline someone might edit), proves it discriminates,
152
+ and registers a Kuma keyword monitor at your chosen window (5m–1h) — catching
153
+ "the cache is serving the wrong page while HTTP stays 200". `d` runs the
154
+ **site doctor**: a read-only cache differential (cached vs. bypassed render,
155
+ proven by the server's own `fastcgi-cache` headers) plus a wp-admin door
156
+ probe, diagnosing stale caches and **partial outages** — cached pages fine
157
+ while everything uncached throws 500 (a dead Redis does exactly this) — and
158
+ ending in a copyable runbook. `n` wires **alerts**: Spinup detects the
159
+ notification providers configured in Kuma by name and toggles them per site.
160
+ And servers get a **Redis sentinel**: the heartbeat cron pings Redis every
161
+ minute and reports up/down to its own `{server} redis` monitor.
162
+ (See "Site monitoring" below.)
163
+ - **Completion toasts** — background writes that take a while (a PHP upgrade, a
164
+ server reboot/restart, resolving your fleet's DNS) raise a non-focus-stealing
165
+ toast when they finish, so you're not left guessing after you've moved on.
166
+ - **Release notes** — after Spinup updates to a new version, a one-time overlay
167
+ shows what changed (sourced straight from that version's GitHub release —
168
+ no separate feed to maintain). Press `n` in `?` Help to see the current
169
+ version's notes again any time.
170
+
171
+ > The tool is **read-only by default** and works great with a Read Only API
172
+ > token. The write actions — creating a server, connecting it with a vanity site,
173
+ > cloning a server, upgrading a site's PHP version, and rebooting / restarting
174
+ > services — need a **Read/Write** token. The SSH actions (sudo connect, grant key,
175
+ > clone pull) use **your own SSH access**, not the API token; everything else keeps
176
+ > working without any of it.
177
+
178
+ ## Requirements
179
+
180
+ - [Bun](https://bun.sh) ≥ 1.3 (OpenTUI uses Bun's native FFI). Install with:
181
+ ```sh
182
+ curl -fsSL https://bun.sh/install | bash
183
+ ```
184
+ - A SpinupWP API token — create one at
185
+ [spinupwp.app/account/api](https://spinupwp.app/account/api/). **Read Only**
186
+ scope is enough to browse; use **Read/Write** if you want to upgrade a site's
187
+ PHP version.
188
+
189
+ ## Install & run
190
+
191
+ The easy way — install the published package globally:
192
+
193
+ ```sh
194
+ bun install -g spinuptui
195
+ spinuptui login # save your API token to the config file (once)
196
+ spinuptui # launch from any directory
197
+ ```
198
+
199
+ On first launch, if no token is configured you'll be guided through a short
200
+ onboarding flow that validates your token and saves it locally.
201
+
202
+ ### From source
203
+
204
+ ```sh
205
+ git clone <this-repo> spinuptui
206
+ cd spinuptui
207
+ bun install
208
+ bun run start
209
+ ```
210
+
211
+ To run a source checkout from anywhere, install the `spinuptui` command globally
212
+ with a symlink to the checkout (updates as you pull):
213
+
214
+ ```sh
215
+ bun run link-global # = bun link; creates `spinuptui` on your PATH
216
+ ```
217
+
218
+ `spinuptui login` is what makes it work outside the project: the project `.env`
219
+ is only read from the project directory, so the global command relies on the
220
+ token saved in the config file. (Run `bun run unlink-global` to remove the
221
+ command.)
222
+
223
+ For a standalone binary that doesn't need Bun on `PATH` at runtime:
224
+
225
+ ```sh
226
+ bun run build:binary # produces ./spinuptui — move it onto your PATH
227
+ ```
228
+
229
+ ### Updating
230
+
231
+ The app tells you when a newer release exists — a gold `✦ vX.Y.Z` appears next
232
+ to the version in the header (and in the `?` About panel).
233
+
234
+ - **Package install:** `bun update -g spinuptui` (the About panel shows the
235
+ same command).
236
+ - **Source checkout:** press **`u`** in the About panel to update in place
237
+ (`git pull --ff-only`; refuses if you have uncommitted changes, and never
238
+ merges/rebases). It can't hot-reload the already-running process, so it tells
239
+ you plainly when to restart — press `q`, then relaunch `spinuptui`. **If the
240
+ update changed dependencies, it tells you to run `bun install` too** before
241
+ restarting. `git pull` by hand works the same way — the global `spinuptui`
242
+ symlink picks up the new code immediately. A standalone binary needs a fresh
243
+ `bun run build:binary` either way.
244
+
245
+ #### CLI subcommands
246
+
247
+ ```
248
+ spinuptui Launch the dashboard
249
+ spinuptui login Set or update your saved API token
250
+ spinuptui where Show the config path and which source the token came from
251
+ spinuptui --version Print the version
252
+ spinuptui --help Show help
253
+ ```
254
+
255
+ ## Configuration
256
+
257
+ The token is resolved in this order (first match wins):
258
+
259
+ 1. **`SPINUPWP_ACCESS_TOKEN`** environment variable. Bun automatically loads a
260
+ `.env` file from the working directory, so a project-local `.env` works:
261
+ ```sh
262
+ # .env
263
+ SPINUPWP_ACCESS_TOKEN=your-token-here
264
+ ```
265
+ 2. **`~/.config/spinupwp-tui/config.json`** — written by the onboarding wizard.
266
+ Respects `XDG_CONFIG_HOME`.
267
+
268
+ To reconfigure, delete the config file (the path is shown on the onboarding
269
+ screen) and relaunch, or set the environment variable.
270
+
271
+ ### Optional settings
272
+
273
+ These can be set in `config.json` or via an environment variable:
274
+
275
+ - **`accountSlug`** / `SPINUPWP_ACCOUNT_SLUG` — your SpinupWP account/team slug
276
+ (the first path segment in a SpinupWP URL, e.g. `wenmark-digital-solutions` in
277
+ `https://spinupwp.app/wenmark-digital-solutions/servers/35633`). The API
278
+ doesn't expose it, so set it to enable the `w` deep links into the web app.
279
+ Without it, `w` opens the SpinupWP dashboard root.
280
+ - **`sshUser`** / `SPINUPWP_SSH_USER` — override the SSH user for the health view
281
+ and stack probes (see "Server health" below).
282
+ - **`localSync`** / `SPINUPWP_LOCAL_SYNC` — opt-in for the **Pull production →
283
+ local** DB sync (`p`); off by default because it overwrites your local database.
284
+ **Prefer `"localSync": true` in `config.json`** — it's read from a fixed path
285
+ (`~/.config/spinupwp-tui/config.json`) so it applies wherever you launch `spinuptui`
286
+ from. The environment variable works too, but note a `.env` is only loaded when
287
+ you launch from the directory that contains it (Bun reads `.env` from the current
288
+ working directory, not from where the installed command lives) — so a repo or
289
+ project `.env` won't take effect for the globally-installed `spinuptui` run from
290
+ elsewhere. For a persistent, location-independent setting, use `config.json`.
291
+
292
+ - **`uptimeKuma`** / `SPINUP_KUMA_URL` + `SPINUP_KUMA_USERNAME` +
293
+ `SPINUP_KUMA_PASSWORD` — an Uptime Kuma connection for the monitoring
294
+ features. Easiest path: press `m` on any site and connect in-app (creds are
295
+ verified by logging in, then stored in `config.json`, chmod 600, alongside a
296
+ login token so 2FA is only asked once). The env trio exists for
297
+ externally-managed setups and is read-only in the UI.
298
+
299
+ ## Keybindings
300
+
301
+ | Key | Action |
302
+ | --- | --- |
303
+ | `1`…`5` | Switch tabs: Dashboard · Servers · Stacks · Search · Events |
304
+ | `↑`/`↓` or `j`/`k` | Move selection |
305
+ | `Enter` / `→` | Drill in (server → its sites) |
306
+ | `←` / `Esc` | Go back / collapse |
307
+ | `Tab` | Switch focus between columns |
308
+ | `g` / `G` | Jump to top / bottom |
309
+ | `o` | Open the selected site in your browser |
310
+ | `s` | Open a terminal and SSH into the selected site |
311
+ | `d` | Download a production DB backup into the linked copy (Search; WordPress + linked) |
312
+ | `p` | Pull the production DB into the linked copy — overwrites local; opt-in via `localSync` (Search) |
313
+ | `m` | Production media fallback: serve missing-locally images from production (Search; WordPress + linked) |
314
+ | `L` | Link / edit a site's local working copy |
315
+ | `t` / `v` | Open the linked copy in a terminal / its local URL in your browser |
316
+ | `n` | DNS migration view for a site — its records + TTLs (`⏎` edits a TTL; `p` repoints the record; `a` shows the whole server) |
317
+ | `N` | DNS migration view for the whole server |
318
+ | `h` | Live server health (CPU/mem/disk over SSH) |
319
+ | `p` | List a site's installed plugins & themes over SSH — version + updates (Servers tab, sites pane) |
320
+ | `d` | Detect a site's stack via SSH (Servers / Stacks tabs) |
321
+ | `D` | Detect every site in the selected stack (Stacks tab) |
322
+ | `S` | Auto-discover & batch-link local copies (Stacks tab) |
323
+ | `f` | Report sites with no usable local copy (Stacks tab) |
324
+ | `u` | Upgrade a site's PHP version (Servers / Stacks / Search; needs a Read/Write token) |
325
+ | `H` | Enable / disable HTTPS on a site (Servers / Stacks / Search; needs a Read/Write token) |
326
+ | `P` | Purge a site's page cache + object cache (Servers / Stacks / Search; needs a Read/Write token) |
327
+ | `m` | Site monitoring via Uptime Kuma (Servers tab, sites pane — same overlay as `M`) |
328
+ | `M` | Site monitoring overlay — add monitors, `f` front-page check, `d` site doctor, `n` alert wiring, vanity refresh/rotation (Servers / Stacks / Search) |
329
+ | `a` | Server actions: reboot / restart a service (Servers / Search; needs a Read/Write token) |
330
+ | `c` | Create a new server (Servers tab; needs a Read/Write token) |
331
+ | `V` | Add a vanity site at the server's own hostname — DNS + site + HTTPS + SSH-key handoff (Servers tab; offered when no hostname site exists; needs a Read/Write token) |
332
+ | `C` | Clone a server's sites to a new/existing destination (Servers tab; needs a Read/Write token + sudo) |
333
+ | `S` | Connect sudo on a server for privileged writes — optionally remembered in the macOS Keychain (Servers tab) |
334
+ | `K` | Grant / revoke an SSH key on a site, or every site on the server (needs sudo connected) |
335
+ | `w` | Open the selected server/site in the SpinupWP web app |
336
+ | `/` | Jump to global search |
337
+ | `r` | Refresh data from the API |
338
+ | `i` | Explain the current screen (what each pane and key does) |
339
+ | `?` | Toggle the help overlay |
340
+ | `q` / `Ctrl+C` | Quit |
341
+
342
+ In the **Search** tab the box keeps keyboard focus while you type. Press **Tab**
343
+ (or **→**) to hand focus to the selected result's **action menu** — `o` / `w` /
344
+ `u` / `h` then act on that server or site — and **←** / **Esc** to return to the
345
+ search box.
346
+
347
+ ## Server health (SSH)
348
+
349
+ The SpinupWP API exposes no live metrics, so the health view (`h` in the
350
+ Servers tab) reaches the server directly over SSH using **your local SSH keys /
351
+ agent** — the same way you'd `ssh in` and run `htop`. It runs a single batched,
352
+ **read-only** command (`cat /proc/*`, `df`, `ps`) and renders the result.
353
+
354
+ - **Connection target** is derived from the API: it connects as one of the
355
+ server's `site_user`s at the server's IP (`site_user@ip`). No extra config
356
+ needed if `ssh site_user@ip` already works from your terminal.
357
+ - **Non-interactive:** it uses `BatchMode=yes`, so if key auth isn't already set
358
+ up it fails fast with a hint rather than prompting for a password.
359
+ - **Override the SSH user** (e.g. to use `root` or a sudo user) with the
360
+ `SPINUPWP_SSH_USER` environment variable, or `"sshUser"` in the config file.
361
+ - A persistent `ControlMaster` connection keeps repeated polls fast.
362
+
363
+ Nothing is ever written to the server.
364
+
365
+ ## Stack detection
366
+
367
+ The **Stacks** tab (`3`) breaks your fleet into buckets and helps you see what's
368
+ actually running where. It works in two tiers:
369
+
370
+ - **Tier 1 — instant, no SSH.** Every site is classified from data the API
371
+ already returns: **Non-WP**, **Bedrock** (WordPress with a `/web/` webroot), or
372
+ **Standard WP**. The left pane shows counts and bars; the right pane shows the
373
+ fleet-wide **PHP version distribution** with end-of-life versions flagged.
374
+
375
+ - **Tier 2 — on-demand SSH probe.** Press `d` on a site (in the Stacks or
376
+ Servers tab) to inspect its filesystem **read-only** and identify it precisely:
377
+ **WordPress** (with version), **Bedrock**, **WHMCS**, **Laravel**, or
378
+ **Static HTML**. Press `D` to probe an entire stack in list order (bounded SSH
379
+ concurrency). A conclusive probe **overrides** the Tier-1 guess — so a site the
380
+ API mislabels (e.g. WordPress installed outside SpinupWP's installer reports
381
+ `is_wordpress=false`) moves into its true bucket. The Non-WP bucket expands
382
+ into named sub-rows (WHMCS / Laravel / Static HTML / Unknown / unprobed).
383
+
384
+ Probes reuse the same SSH access as the health view (`site_user@ip`, your local
385
+ keys, `BatchMode`) and are **read-only**. Results are cached to
386
+ `~/.config/spinupwp-tui/stack-cache.json`, hydrated at startup, so detections
387
+ survive restarts without re-running SSH.
388
+
389
+ ## Installed plugins & themes
390
+
391
+ Press `p` on a site (Servers tab, sites pane) to list its installed plugins and
392
+ themes over SSH — the `wp plugin list` / `wp theme list` detail the SpinupWP API
393
+ never exposes (it only gives you *counts* of pending updates). You see every
394
+ plugin and theme with its **status** (active / inactive / must-use / dropin),
395
+ **current version**, and the **new version** when an update is available (`→ 1.2.3`
396
+ in gold; `✓ current` otherwise), grouped into `PLUGINS` and `THEMES` sections each
397
+ with an update badge.
398
+
399
+ It's strictly **read-only**: it runs wp-cli as the site user using your local SSH
400
+ keys (the same non-interactive auth the health view uses), and **detects the real
401
+ WordPress directory itself** rather than trusting the `public_folder` setting — so
402
+ it works on standard `/public/` installs and Bedrock (`web/wp`) alike, and even on
403
+ sites SpinupWP misclassifies as non-WordPress (it finds WordPress core over SSH).
404
+ Non-WordPress sites get a clear "no WordPress core found" message.
405
+
406
+ `↑↓` / `jk` scroll, `r` re-reads over SSH, `Esc` / `q` / `p` closes.
407
+
408
+ ## Upgrading PHP
409
+
410
+ Press `u` on a selected site (in the **Servers** or **Stacks** tab) to change its
411
+ PHP version. A picker lists the available versions — the current one is marked,
412
+ end-of-life versions are flagged, and the list is sourced from the live PHP
413
+ release schedule (so new versions like 8.5 appear automatically). After you
414
+ confirm, the app calls `PUT /sites/{id}/php` and polls the resulting event until
415
+ it finishes.
416
+
417
+ - **Needs a Read/Write token.** SpinupWP exposes no token-scope endpoint, so a
418
+ read-only token is detected when the upgrade comes back `403` — you'll get a
419
+ clear "token is read-only" message and nothing changes. Swap in a Read/Write
420
+ token (`spinuptui login`) to actually apply upgrades.
421
+ - **On-demand install.** If the chosen version isn't installed on the server yet,
422
+ SpinupWP installs it first; the event simply takes a little longer.
423
+ - **Pending platform upgrade.** If the site's server has a pending SpinupWP
424
+ platform upgrade, it can't be managed via the API until that runs — the picker
425
+ is blocked and points you to open the server in the web app (`w`).
426
+ - **Runs in the background.** The upgrade is tracked in the app's store, so you
427
+ can press `Esc` to close the modal and it keeps going — the site's row shows a
428
+ spinner and the target version (`→8.3`) until it settles, then refreshes to the
429
+ new version (or flags `⬆!` if it failed). The SiteDetail "PHP" field shows the
430
+ same in-progress state. You can launch upgrades on several sites at once. When an
431
+ upgrade finishes, a **toast** confirms it (`example.com upgraded to PHP 8.3`) —
432
+ useful since it often completes after you've closed the modal and moved on.
433
+
434
+ ## Server actions
435
+
436
+ Press `a` on a selected server (in the **Servers** tab, or a result in **Search**)
437
+ to open the server-actions overlay: **reboot** the server, or **restart** a single
438
+ service (Nginx / PHP-FPM / MySQL / Redis). Pick → confirm → the app calls
439
+ `POST /servers/{id}/reboot` or `/services/{svc}/restart` and tracks the event to
440
+ completion — same background behavior as PHP upgrades (close the overlay and the
441
+ server's row keeps a spinner).
442
+
443
+ - **Needs a Read/Write token** (like PHP upgrades).
444
+ - **Reboot visibility.** Servers with a pending reboot show a `↻rbt` badge in the
445
+ Servers list and on the Dashboard's "Needs attention" panel.
446
+ - **Why a reboot is pending.** The API only exposes a `reboot_required` boolean —
447
+ no reason. So when you open the overlay on a flagged server, the app reads
448
+ Ubuntu's `/var/run/reboot-required` + `.pkgs` over SSH (read-only, reusing the
449
+ health view's connection) and shows the pending packages — typically a
450
+ **kernel/security update**. This is labeled as OS-level context, not as
451
+ SpinupWP's internal logic (a fleet-wide check confirmed the boolean tracks that
452
+ file 1:1).
453
+ - **Reboot is the big one** — its confirmation calls out that it takes the whole
454
+ server down briefly (every site on it); a service restart is a brief blip.
455
+ - **A toast on completion.** A reboot can take minutes; when it (or a restart)
456
+ finishes, a non-focus-stealing toast tells you (`example.com rebooted` /
457
+ `Nginx restarted on example.com`), so you don't have to keep checking.
458
+
459
+ ## Site monitoring
460
+
461
+ Everything hangs off one overlay: press `M` on a site in any view (the Servers
462
+ tab also keeps its original `m`). It talks to your own
463
+ [Uptime Kuma](https://github.com/louislam/uptime-kuma) — Kuma watches 24/7 and
464
+ sends the alerts; Spinup creates the monitors *correctly* and diagnoses on
465
+ demand.
466
+
467
+ - **`a` — uptime monitors.** Regular sites get a homepage monitor (up/down +
468
+ cert-expiry). Vanity sites get the full server treatment: healthz + load
469
+ push + a **Redis sentinel** — the heartbeat cron runs `redis-cli ping` every
470
+ minute and reports up/down to a `{server} redis` monitor (registered only
471
+ after probing that Redis actually answers on that box). Why it matters: with
472
+ SpinupWP's default object-cache drop-in, a dead Redis is **fatal (HTTP 500)
473
+ for every request that misses the page cache** — cached pages keep serving
474
+ 200, so only the sentinel notices, within a minute.
475
+ - **`f` — the front-page check.** Spinup reads your *healthy* front page,
476
+ derives a template-identity fingerprint from WordPress's own `body_class()`
477
+ output (`class="home`, `page-id-N`, …, falling back to the canonical link),
478
+ validates it against a throwaway search render to prove it discriminates,
479
+ and registers a Kuma keyword monitor at your chosen window (5m default —
480
+ the check is served straight from the page cache, so it costs the site
481
+ nothing). A red check means "answering 200 but serving the WRONG page" —
482
+ the stale/corrupt-page-cache incident — and shows as `WRONG PAGE SERVED` in
483
+ Details. Re-running `f` recalibrates the same monitor in place (history
484
+ survives a redesign).
485
+ - **`d` — the site doctor.** Read-only, zero setup, works without Kuma. It
486
+ fetches the page twice — once normally, once with the cache-bypass cookie —
487
+ and lets the server's `fastcgi-cache: HIT/BYPASS` headers prove which layer
488
+ answered each request, then compares template identity between the two. It
489
+ also knocks on the wp-admin door (the login page — never cached; Bedrock's
490
+ relocated `/wp/` login handled). Verdicts: healthy · stale-cache (with a
491
+ copyable runbook; the Elementor one-command flush is suggested only when
492
+ Elementor is detected in the served markup) · **partial-outage** (cached
493
+ pages 200, fresh renders or wp-admin 5xx — Redis or a PHP fatal; visitors
494
+ look fine, admins are broken) · recalibrate (`f` jumps straight there) ·
495
+ down · inconclusive.
496
+ - **`n` — alert wiring.** Spinup detects the notification providers configured
497
+ in your Kuma (by name — Telegram, email, whatever you've set up) and shows
498
+ whether each is attached to this site's monitors (`✓` all / `◐` some / `○`
499
+ none); `⏎` toggles a provider across all of the site's checks at once.
500
+ Creating the provider (bot tokens etc.) is the one step that stays in Kuma.
501
+
502
+ The design principle across all of it: **the page cache makes sites look
503
+ healthy while things break behind it** — every check watches either *through*
504
+ the cache on purpose (is the right thing being served?) or deliberately
505
+ *around* it (is the machinery behind it alive?).
506
+
507
+ ## Creating & connecting servers
508
+
509
+ Two write actions on the **Servers** tab that stand up a new server and make it
510
+ usable — both **need a Read/Write token**.
511
+
512
+ **Create a server (`c`).** Opens a form pre-filled to **match** the selected
513
+ server's provider, region, and size (or from scratch on an empty fleet). It prices
514
+ the build from the provider's catalog (DigitalOcean / Vultr / Linode / Hetzner),
515
+ suggests a hostname from your fleet's naming convention, and lets you switch
516
+ provider (`p`), region (`g`), or size (`e`) and toggle backups (`b`) before
517
+ confirming. Because the SpinupWP API can't list your configured server providers,
518
+ the first time you build on a provider the overlay asks for its SpinupWP provider
519
+ id (Account Settings → Server Providers) and saves it — once per provider. The
520
+ build fires `POST /servers` and tracks the ~10-minute provision in the background.
521
+
522
+ **Connect it with a vanity site (`V`).** A brand-new server has **no site**, so
523
+ there's nothing to attach an SSH key to and no way for Spinup to reach it — empty
524
+ servers are flagged in **amber** in the Servers list. A busy server benefits from
525
+ the same thing (a status page at its own hostname + a site user to hold your key),
526
+ so `V` is offered on **any server that doesn't yet have a site at its own
527
+ hostname** — it also appears under **Manage** in the server's Details panel. It
528
+ builds the small placeholder ("vanity") site end to end:
529
+
530
+ 1. **DNS** — writes an `A` record for the hostname → the server IP (AWS Route 53),
531
+ using the connection from the DNS module.
532
+ 2. **Propagate** — waits for the record to resolve (so Let's Encrypt can issue);
533
+ after ~2 minutes it offers to skip SSL for now or keep waiting.
534
+ 3. **Site** — creates a blank site (`installation_method: "blank"`).
535
+ 4. **HTTPS** — enables a free Let's Encrypt certificate.
536
+ 5. **SSH key** — deep-links you to the site's **SFTP & SSH → Site User** to add
537
+ your key (the API can't do this), then waits for you to confirm.
538
+ 6. **Publish** — seeds a minimal, brand-neutral status page (it reads its own
539
+ hostname, so it's reusable on any server). Press `o` to open the live site.
540
+
541
+ The whole build is a **resumable background job**: close the overlay and a header
542
+ badge keeps tracking it; press `V` on the server to reopen it (even after the site
543
+ exists). It survives quitting and relaunching the app. If a step fails, the overlay
544
+ shows where, with `r` to retry or `x` to discard.
545
+
546
+ ## Privileged writes over SSH (sudo & SSH keys)
547
+
548
+ Some things the SpinupWP API simply can't do — it has **no** surface for SSH keys or
549
+ sudo users. Spinup does them directly over SSH instead, which means these actions use
550
+ **your own SSH access**, not the API token.
551
+
552
+ **Connect sudo (`S`).** Press `S` on a server and enter its SpinupWP **sudo user**
553
+ and that user's **sudo password** once. Spinup validates them against the live server
554
+ (`sudo -S -p '' true`) and then holds the password **in memory for the session only**
555
+ — the username persists to config, the password is never written to plaintext config.
556
+ A connected server shows a green `● sudo` badge on its row; `S` again disconnects.
557
+
558
+ - **Remember in the macOS Keychain (opt-in).** Tick the toggle when connecting and the
559
+ password is saved to your **login Keychain** (service `spinup-sudo`, one item per
560
+ server) — never to `config.json`, which only keeps the username and a `keychain`
561
+ marker. Next time you press `S` on that server, sudo **auto-unlocks** with no
562
+ retyping (the first read may show macOS's own "allow access" prompt — choose Always
563
+ Allow). Press `f` to forget the saved password; disconnecting (`x`) a saved server
564
+ offers a no-password **reconnect** rather than the credential form. Off macOS the
565
+ toggle is absent and sudo stays in-memory per session.
566
+
567
+ **Grant / revoke an SSH key (`K`).** With sudo connected, press `K` on a site to write
568
+ keys into the site user's `authorized_keys`:
569
+
570
+ - **Pick which keys** — any of **your personal keys** (discovered from `~/.ssh/*.pub`
571
+ and your ssh-agent, so you can SSH/SFTP as yourself) and/or Spinup's dedicated
572
+ **`spinup-tui` machine key** (an ed25519 identity generated once into the config dir,
573
+ deliberately **never added to your SpinupWP account** so SpinupWP's key reconciliation
574
+ can't clobber it). Your selection is remembered.
575
+ - **Pick the scope** — just this site, or **every site on the server** in one pass
576
+ (per-site progress, with a retry for any that fail).
577
+ - **Grant or remove** — `a`/`r` toggles the mode; removing pulls exactly the chosen
578
+ key lines and leaves every other key (including SpinupWP-managed ones) untouched. The
579
+ remote script is **idempotent** and a confirm overlay shows the exact command first.
580
+ - Site rows show what's granted at a glance — **👤** (your key) and/or **🔑** (the
581
+ machine key).
582
+
583
+ ## Cloning a server
584
+
585
+ Press **`C`** on a server to clone one or more of its sites onto a **new or existing**
586
+ destination server — a guided, two-pane wizard that lets you stage and verify a whole
587
+ migration and **only repoints DNS when you say so**. It needs a **Read/Write token**
588
+ (to create the destination sites) and **sudo connected on both ends** (the copy runs
589
+ over SSH). The steps:
590
+
591
+ 1. **Plan** — pick which of the source's sites to clone (all selected by default;
592
+ `space` toggles). Spinup sizes each one live (disk + database) into a payload total
593
+ so you know what you're moving; a concurrency cap protects the busy source.
594
+ 2. **Destination** — provision a fresh server pre-matched to the source (reusing the
595
+ `c` flow), or `d` to pick an existing server as the target.
596
+ 3. **Connect** — connect sudo on **both** servers. The clone is a server-to-server
597
+ **pull**: the destination pulls each site directly from the source over SSH (no
598
+ bytes routed through your laptop), authenticating with a key granted onto the source
599
+ for the job and **revoked when it's done**.
600
+ 4. **Git access** (only when a Bedrock site is selected) — each repo gets its **own
601
+ read-only deploy key**, generated locally and never persisted: the public half
602
+ goes on the repo (added for you via `gh` when it's installed and authed, or shown
603
+ for a manual add — `o` opens the repo's deploy-key settings, `y` copies the key),
604
+ and the pair rides the site create so SpinupWP installs it as the new site's git
605
+ identity. Per-site keys are what let any number of Bedrock repos land on one
606
+ server — GitHub allows a deploy key on only **one** repository account-wide, so a
607
+ shared server key stops working at the second repo.
608
+ 5. **Clone sites** — a live roster runs the sites concurrently, each advancing
609
+ `create → pull → config → verify → done` with **live transfer progress** (bytes,
610
+ rate, elapsed; database pulls show a true percent). Three stacks are handled:
611
+ **Standard WP** (files + database, with `wp-config` re-stamped for the
612
+ destination), **Bedrock** (git-native — created from the repo, `composer install`
613
+ over SSH, uploads + secrets pulled, `.env` re-stamped), and **files-only** for
614
+ non-WordPress sites (redirect shells, static/PHP — opt-in in Plan, no database).
615
+ The pull **detects each site's real webroot** rather than trusting settings —
616
+ `public/`-style layouts (with `wp-config.php` one level above the webroot) are
617
+ preserved, and mid-move layouts are normalized on the destination. **Additional
618
+ domains carry over** automatically (with their redirect settings), so the clone
619
+ answers for every hostname the source did.
620
+ 6. **Verify** — drill into any cloned site for a source-vs-clone comparison (wp-cli
621
+ facts + an HTTP check that hits the **new** server while DNS still points at the old
622
+ one; files-only sites compare file count, size, and HTTP instead).
623
+ 7. **DNS cutover** — the wizard **waits for your explicit go** (`c`) after the roster
624
+ settles, then repoints `A` records across each site's domains (apex + additional)
625
+ to the new server in one batched, partial-aware pass; `↑↓`/`space` include or
626
+ exclude individual records first. `www`-style records that follow the apex are
627
+ skipped, not clobbered. Cloudflare **proxied** records repoint automatically too
628
+ (their origin IP is always PATCHable even though their TTL stays fixed to
629
+ automatic). Records in zones you can't edit show your DNS access note and `⏎`
630
+ opens that zone's registrar console with the zone name copied — ready to paste.
631
+
632
+ The clone runs in the **background** — pressing `Esc` doesn't abandon it; a header
633
+ badge (`⠹ Cloning … — press C`) surfaces the in-flight job and `C` reopens the live
634
+ roster. `←` (or `h`) steps **back a screen**: the setup steps go back freely, and
635
+ once cloning has started the one back edge is DNS cutover → the clone roster — so
636
+ you can re-verify or retry a site before flipping live traffic. **Every job writes a full log** (`~/.config/spinupwp-tui/logs/`, passwords
637
+ redacted, self-pruning) — `⏎` on a failed site shows the complete error and `r`
638
+ retries just that site. Pairs naturally with the DNS module: **lower the TTLs**
639
+ (`n`/`N` → `⏎`) a day or two ahead so the cutover propagates fast.
640
+
641
+ ## Local working copies
642
+
643
+ Bridge your SpinupWP sites to the local checkouts you actually edit. Press `L` on
644
+ a site (Servers / Stacks / Search) to link a path and the local URL where you
645
+ serve it; the site's details gain a "Local" field, and you can open the copy with
646
+ `t` (a terminal at the path) or `v` (its local URL). All of this is **local-only**
647
+ — no SpinupWP writes.
648
+
649
+ - **Auto-discover (`S`, Stacks tab).** Scan one or more folders and match their
650
+ subdirectories to sites — by git remote, Bedrock `WP_HOME`, or folder name —
651
+ then batch-link the matches.
652
+ - **"Needs a local copy" report (`f`, Stacks tab).** Lists the managed sites you
653
+ have no usable local copy for (never linked, or a missing path), filterable by
654
+ stack.
655
+ - **Markers & drift.** Linked sites show `◆` in the lists; a linked, on-disk copy
656
+ shows its local git drift (`⇡N unpushed` / `● uncommitted`), read from the repo
657
+ with no network.
658
+
659
+ Config keys: `localRoots` (folders to scan) and `localSites` (per-site path +
660
+ local URL — tool-agnostic: Valet, Cove, LocalWP, Herd, DDEV, …).
661
+
662
+ ## Database backup & sync
663
+
664
+ For a WordPress site you've **linked** to a local copy, the Search tab can pull
665
+ the production database down — the same idea as a hand-rolled `wp db export` +
666
+ `scp`, without leaving the dashboard. Both actions are **read-only on production**
667
+ (they export; they never write to the live site) and run `ssh`/`scp`
668
+ non-interactively, so your key needs to be loaded in your agent.
669
+
670
+ - **Download a backup (`d`).** Exports the production database with `wp-cli` into
671
+ a stage file *outside* the public webroot, gzips it, downloads it into the linked
672
+ copy's `sql/` folder, and removes the remote copy. Needs **no local WP-CLI** —
673
+ the export runs on the server. Available whenever a WordPress site is linked. A
674
+ spinner on the site's row tracks an in-flight download even if you close the
675
+ overlay; the saved path and size are shown on completion.
676
+ - **Pull production → local (`p`, opt-in).** A full refresh of your **local**
677
+ database from production: it backs up the local DB first (to
678
+ `sql/local_<timestamp>.sql.gz`), exports + downloads production, imports it
679
+ locally, rewrites production URLs → your local URL (`wp search-replace`), and
680
+ runs an optional `bin/sync.d/post-import.sh` hook if the project has one.
681
+ **This overwrites your local database**, so it's **off by default** — enable it
682
+ with `localSync` (see "Optional settings"). It needs a working local WP-CLI; if
683
+ it's missing you get a clear error rather than a broken run.
684
+
685
+ Everything is detected automatically, for Standard WP **and** Bedrock:
686
+
687
+ - the **remote document root** from the API (`/sites/{domain}/files{public_folder}`),
688
+ - the **SSH target** from the site/server (`{site_user}@{server_ip}`),
689
+ - the **local WordPress root** from the linked path (where `wp` runs — wp-config
690
+ for Standard WP, `wp-cli.yml` for Bedrock),
691
+ - the **local URL** for the rewrite from the link's local URL, falling back to the
692
+ project's `.env` `WP_HOME`.
693
+
694
+ If your project already has a `bin/sync.d/post-import.sh` (e.g. Elementor URL
695
+ swaps, plugin toggles), it runs with `WEB_DIR`, `SYNC_REMOTE_HOST`, and
696
+ `SYNC_LOCAL_HOST` set — so existing per-project tweaks carry over with no extra
697
+ configuration. When a project has **no** hook yet, the `p` confirm screen
698
+ explains what the hook is and offers `s` to scaffold an **inert** sample
699
+ `bin/sync.d/post-import.sh` (every example commented out, documented with the env
700
+ vars above) for you to edit. Backups stay **gzipped** in `sql/`; decompress with
701
+ `gunzip` when you need one.
702
+
703
+ Both `d` and `p` show their progress as a **building checklist** inside the
704
+ overlay — each step gets a `✓` as it completes, the running step spins, and a
705
+ failure marks the exact step with `✕` — so you can see everything that happened,
706
+ ending with the saved paths. It keeps running if you close the overlay (`Esc`);
707
+ reopen with the same key to watch it through.
708
+
709
+ ## Production media fallback
710
+
711
+ A `p` sync refreshes your local **database**, but not the media library — so the
712
+ local site shows broken images. Syncing the files is often impractical (some
713
+ libraries are hundreds of thousands of items). Instead, press **`m`** on a linked
714
+ WordPress site to serve any image that's **missing locally** straight from
715
+ production.
716
+
717
+ It works by dropping a small, self-contained mu-plugin into your local copy
718
+ (`wp-content/mu-plugins/` or Bedrock's `web/app/mu-plugins/`). The plugin rewrites
719
+ any uploads URL whose file isn't present locally to the production origin —
720
+ covering the standard media functions, page-builder output (e.g. Elementor inline
721
+ CSS and gallery data), and **legacy paths** left over from a Standard-WP →
722
+ Bedrock conversion. Because it decides "missing" from the real document root and
723
+ redirects to the **same path** on production, your production routing (CDN/S3
724
+ redirects included) resolves whatever it's handed.
725
+
726
+ - **It runs in WordPress, not your web server**, so it works the same on Valet,
727
+ Herd, LocalWP, DDEV, MAMP — anything. No nginx/Apache config to get right.
728
+ - **Local-only and read-only on production** — it just hotlinks your own
729
+ production images. It self-disables when running on the production domain, so
730
+ it's inert if it ever gets deployed.
731
+ - **The plugin's presence is the on/off state** (no config flag). Press `m` to
732
+ toggle; `u` from the overlay updates it in place when a newer version ships.
733
+ - Needs production reachable and hotlinking allowed (it won't work behind staging
734
+ Basic-Auth). Images delivered by external CSS files or async JS/REST are the one
735
+ thing server-side rewriting can't catch.
736
+
737
+ ## DNS hosts, access & editing
738
+
739
+ A **server-migration lens** for DNS: see the records that move a site to another
740
+ server, and edit them in place. It is deliberately **not** a full zone editor — it
741
+ only ever shows and touches a site's own hosting records (its apex / `www` /
742
+ subdomains and additional domains), so your MX, TXT, DKIM, and other zone records are
743
+ never shown or changed. Moving a site can't take down its email.
744
+
745
+ - **The view.** Press `n` on a site for just that site's records, or `N` on a server
746
+ for every site on it; inside a site-scoped view, `a` expands to the whole server.
747
+ Each **site** is a line, labeled by its own domain (even when it's a subdomain),
748
+ with its hosting record's type, **TTL in seconds**, value, a `◀ here` flag when the
749
+ record points at this server, and a `+www` tag when `www` simply follows the apex.
750
+ A site's additional domains nest beneath it, so a domain portfolio reads as one
751
+ site, not many. TTLs come from the zone's authoritative nameserver (the configured
752
+ value, not a counted-down one), so they show even for hosts you haven't connected;
753
+ `r` refreshes.
754
+ - **Access (`✓ ↗ ○ ·`).** Each record's zone shows whether you can edit it: `✓`
755
+ editable, `↗` web-only handoff, `○` the provider has an API but you haven't
756
+ connected an account that holds the zone, `·` unknown. A zone is `✓` only when a
757
+ connected account of the provider that actually serves it (its live nameservers)
758
+ holds it — so a stale or duplicate zone elsewhere never shows a false green. With
759
+ two or more accounts connected, an **ACCOUNT** column names the owning one.
760
+ - **Edit a TTL (`⏎`).** On an editable record, `⏎` opens a focused editor — pick a
761
+ preset or a custom value, confirm, and it's written to **AWS Route 53** or
762
+ **Cloudflare** through your connected account. This is the prep step for a low-risk
763
+ migration: lower the TTL, cut over, then restore it. Before writing, an edit-time
764
+ check re-reads the live nameservers and **blocks** the change if the connected
765
+ account's zone isn't the one actually serving the domain. Route 53 changes are
766
+ followed to completion; the record shows an "updating" status that keeps ticking
767
+ even if you leave the view. Cloudflare **proxied** records keep their TTL fixed
768
+ to automatic, so it's not editable here — repointing still works (next).
769
+ - **Repoint a record (`p`).** The same focused editor, on the record's **value** —
770
+ where it points. The picker leads with **your SpinupWP servers** (name + IP, the
771
+ record's current home tagged), because pointing a record at one of your own boxes
772
+ is what this is for; a custom IP is the fallback. The same confirm and NS
773
+ pre-flight gate apply, the write is followed to completion in the background, and
774
+ the inventory row shows `→ new IP` while it applies. A Cloudflare **proxied**
775
+ record repoints its **origin** behind the proxy (visitors keep resolving
776
+ Cloudflare's IPs). CNAMEs aren't repointed — they follow their target, which is
777
+ the record to edit. This is the standalone version of the clone wizard's DNS
778
+ cutover: migrate a single site, finish a cutover by hand, or fix a stale record.
779
+ - **Connect a provider (`c`).** Manage credentials for the selected zone's
780
+ provider — **AWS Route 53** (an IAM access key), **Cloudflare** (a scoped token),
781
+ or **GoDaddy** (a Production API key). Multiple accounts per provider are
782
+ supported, with a drill-down into each account's zones. Credentials are verified
783
+ before they're stored, kept in `config.json` (chmod 600), and the matching
784
+ environment variables are honored (`CLOUDFLARE_API_TOKEN`, `AWS_ACCESS_KEY_ID` /
785
+ `AWS_SECRET_ACCESS_KEY`, `GODADDY_API_KEY` / `GODADDY_API_SECRET`). Secrets are
786
+ masked as you type. Listing hosts needs only read access (Cloudflare `Zone:Read`);
787
+ **editing (a TTL or a repoint)** needs write access — Route 53 record writes, or a
788
+ Cloudflare `Zone.DNS:Edit` token.
789
+ - **Web-only hosts (GoDaddy, Namecheap, Network Solutions, …).** A registrar with
790
+ no usable API shows `↗`; press `w` (in the inventory or the connect overlay) to
791
+ open its web console — for GoDaddy specifically, your Clients hub, with the
792
+ domain copied to your clipboard so you can paste it after logging in as the
793
+ client. Every `↗` zone also shows an **access note** — "Delegate Access" by
794
+ default (the assumed-normal case for a client's registrar), or your own
795
+ per-zone override (e.g. an IT vendor's contact) when that default doesn't hold.
796
+ Press `c` on any such zone to open **Manage Access**: it lists every zone
797
+ already known at that host, `n` edits the selected zone's note, and `r`
798
+ resolves your whole fleet's DNS to fill in any zones it hasn't seen yet.
799
+
800
+ Provider credentials are optional — without them you still get the full host
801
+ inventory and TTLs, just without the editable/account columns or in-place editing.
802
+
803
+ ## Development
804
+
805
+ ```sh
806
+ bun run dev # run from source
807
+ bun run typecheck # tsc --noEmit
808
+ ```
809
+
810
+ ### Dev Mode (demos with fake data)
811
+
812
+ ```sh
813
+ SPINUP_DEV_MODE=1 bun run dev
814
+ ```
815
+
816
+ Boots straight into the dashboard against a small in-memory example fleet
817
+ (`web1.example.com` … `web9.example.org`, a mix of Standard WP, Bedrock, and
818
+ non-WP sites) — no API token, no network calls, nothing that can touch a real
819
+ account. A purple **`DEV MODE`** badge in the header makes it unmistakable. Every
820
+ write action (PHP upgrade, HTTPS toggle, purge cache, reboot, create a server/site)
821
+ works against the fake data and shows the same in-progress/toast behavior as the
822
+ real thing, so it's useful for screenshots, walkthroughs, and UI work without a
823
+ live account on hand. The clone wizard's SSH-based file/DB pull is out of scope —
824
+ it always talks to real servers over SSH, independent of the API client.
825
+
826
+ The fixture fleet lives in `src/dev/fixtures.ts`; the fake client it's served
827
+ through is `src/dev/mockClient.ts`.
828
+
829
+ ### Project layout
830
+
831
+ ```
832
+ src/
833
+ index.tsx entry — boots OpenTUI, routes onboarding vs app
834
+ config.ts token resolution + persistence
835
+ api/
836
+ client.ts typed fetch client (reads + writes, errors, validation)
837
+ types.ts Server / Site / Event types
838
+ dev/ Dev Mode: fake fleet + client (SPINUP_DEV_MODE, see above)
839
+ lib/ formatting, theme, open-in-browser, SSH helpers
840
+ stack.ts Tier-1 stack classification + effective (probe-aware) bucket
841
+ probe.ts Tier-2 SSH stack probe (WHMCS / Bedrock / Laravel / WP / …)
842
+ stackCache.ts disk-backed probe cache (hydrate on start, write-through)
843
+ phpEol.ts PHP EOL dates + the version set offered by the upgrade picker
844
+ ubuntuEol.ts Ubuntu LTS EOL dates (same embedded+refresh pattern as phpEol.ts)
845
+ ui/
846
+ App.tsx shell: splash gating, key routing, layout
847
+ store.tsx React-context data store
848
+ Splash / Onboarding / Header / StatusBar / Help
849
+ List.tsx generic windowed keyboard list
850
+ Details.tsx shared server/site detail panels
851
+ views/ Dashboard, Browser, Stacks, Search, Events, Health, PhpUpgrade
852
+ ```
853
+
854
+ ## License
855
+
856
+ MIT — see [LICENSE](LICENSE).