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/CHANGELOG.md ADDED
@@ -0,0 +1,943 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ While the project is in `0.x`, the public surface (CLI subcommands/flags, config
9
+ file location and format, and token-resolution behavior) may change between minor
10
+ versions; such changes are called out here.
11
+
12
+ ## [Unreleased]
13
+
14
+ ## [0.19.0] - 2026-07-08
15
+
16
+ ### Changed
17
+ - **The app is now "SpinupTUI", and the command is `spinuptui`.** The rename
18
+ distinguishes the app from the SpinupWP service it talks to (and `spinup` was
19
+ already taken on npm). Everything else is unchanged — the config file stays at
20
+ `~/.config/spinupwp-tui/config.json`, and no settings move. **Source-checkout
21
+ users:** re-run `bun run link-global` to get the `spinuptui` command (and
22
+ `bun unlink` the old `spinup` link if you had one).
23
+
24
+ ### Added
25
+ - **Installable from npm.** `bun install -g spinuptui` installs the global
26
+ `spinuptui` command; update any time with `bun update -g spinuptui`. The
27
+ package runs from source under Bun (OpenTUI needs Bun's FFI) — running the
28
+ bin under Node prints a friendly "install Bun" pointer instead of a crash.
29
+ The in-app update notice is now channel-aware: a package install is shown
30
+ the `bun update -g spinuptui` one-liner, while a git checkout keeps the
31
+ in-place `u` (git pull) updater.
32
+
33
+ ### Fixed
34
+ - **The header no longer shows a perpetual "Connecting {server}…" spinner when a
35
+ vanity-site build is just waiting for you.** The connect flow's SSH-key step is
36
+ manual by design (add your key, confirm in the overlay), but the resumable job
37
+ surfaced it with the same animated spinner as active work — so a build parked
38
+ at that step looked stuck-connecting on every launch, with no hint of what it
39
+ wanted. That state now gets its own badge: `○ {server} needs your SSH key —
40
+ press V`. The active "Connecting…" badge also gained the "press V" pointer the
41
+ clone badge always had.
42
+ - **Clone wizard no longer falsely fails a site when SpinupWP is slow to run its
43
+ add-domain event.** SpinupWP serializes events per server, so under a concurrent
44
+ clone an additional-domain add can sit queued for minutes behind site-creates.
45
+ The wizard's poll gave up after a flat 3 minutes and reported "failed on
46
+ SpinupWP" — aborting the site before any files were copied — even when the event
47
+ went on to succeed seconds later (2026-07-07 post-mortem, event 40454437). The
48
+ poll now waits out queueing with a generous 30-minute backstop, takes one final
49
+ look at the event before giving up, and when it does time out it says so
50
+ honestly ("gave up waiting … retry the site to re-check") instead of blaming
51
+ SpinupWP — a per-site retry safely resumes, since already-added domains are
52
+ skipped.
53
+
54
+ ## [0.18.0] - 2026-07-07
55
+
56
+ ### Added
57
+ - **At-a-glance status column in the Servers list.** Each site row now carries a
58
+ fixed-width capability strip — `👤🔑` granted SSH keys, `H` HTTPS, `C` page cache,
59
+ `B` backups (lit when on, faint `·` when off), plus `◆` linked and `↑N` pending
60
+ updates — aligned into clean columns across every row (the site stack tag is now
61
+ fixed-width so nothing jitters). A compact key sits in the Sites panel title.
62
+ - **Installed plugins & themes (`p`).** Press `p` on a site to list its real
63
+ `wp plugin list` / `wp theme list` over SSH: every plugin and theme with its
64
+ status, current version, and available update (`→ 1.2.3`) — the detail the API
65
+ only exposes as bare counts. Read-only, combined scrollable list with per-section
66
+ update badges. Detects the WordPress directory itself, so it works on `/public/`
67
+ and Bedrock installs alike. (See "Installed plugins & themes" in the README.)
68
+
69
+ ### Fixed
70
+ - **Site stack & type now follow the `d`-identify probe, not SpinupWP's flag.**
71
+ SpinupWP's `is_wordpress` is unreliable — false for Bedrock/git sites and for
72
+ Standard-WP `/public/` installs it imported as "Generic" — so a real WordPress
73
+ site could show as `app` in the Servers list, and its Details "Type" as Generic,
74
+ even after `d` detected WordPress. The row stack tag and the Details "Stack" /
75
+ "Type" fields now reflect the **detected** stack, and the plugins/themes view no
76
+ longer refuses a misclassified WordPress site (it confirms WordPress core over
77
+ SSH instead of trusting the flag).
78
+
79
+ ## [0.17.0] - 2026-07-06
80
+
81
+ ### Changed
82
+ - **Clone wizard: every Bedrock site now gets its own read-only deploy key.** The
83
+ Git-access step generates a unique keypair per repo locally (held in memory only,
84
+ redacted from job logs), puts the public half on the repo (auto via `gh`, or
85
+ manually — `o` opens the repo's deploy-key settings, `y` copies the key), and
86
+ sends the pair with the site create so SpinupWP installs it as the new site's
87
+ git identity. Verified live end to end against a private repo.
88
+
89
+ ### Fixed
90
+ - **Adding deploy keys for a second Bedrock repo no longer fails.** The wizard
91
+ previously reused the destination server's single deploy key, but GitHub allows
92
+ a deploy key on only **one** repository account-wide — the first Bedrock repo
93
+ cloned to a server claimed it, and every later repo's add failed with
94
+ "key is already in use". Per-site keys remove the limit entirely.
95
+
96
+ ## [0.16.1] - 2026-07-05
97
+
98
+ ### Fixed
99
+ - **Alert wiring (`n`) now really covers *all* of a site's monitors.** The
100
+ toggle attached/detached providers on a hardcoded list (healthz, load push,
101
+ front-page check) that predated the Redis sentinel, so `⏎` silently skipped
102
+ the `{server} redis` monitor. The site's monitors are now collected by
103
+ convention from the saved monitor refs, so the Redis sentinel — and any
104
+ monitor kind added in the future — is wired automatically. If you toggled a
105
+ provider on before this fix, open `n` again: it shows `◐ some monitors only`
106
+ and one `⏎` attaches it to the rest.
107
+ - The server-create wizard's monitor step no longer drops previously saved
108
+ Redis/front-page monitor references from the config when re-run.
109
+
110
+ ## [0.16.0] - 2026-07-04
111
+
112
+ ### Added
113
+ - **Front-page check (`f` in the site-monitoring overlay) — site monitoring
114
+ Phase 1.** Catches the failure plain up/down monitors sleep through: the page
115
+ cache serving the *wrong page* while HTTP stays 200 (two real incidents showed
116
+ a search-results template where the home page belonged). Instead of a
117
+ hand-picked headline keyword — which breaks the day someone edits the copy —
118
+ Spinup reads the live front page while it's healthy and derives a
119
+ *template-identity* fingerprint (WP's `body_class()` output: `home` /
120
+ `page-id-N` / `front-page`, falling back to the canonical link), validates it
121
+ against a throwaway search render to prove it actually discriminates, and
122
+ registers an Uptime Kuma keyword monitor asserting it. Check window is
123
+ selectable (5m default · 15m · 30m · 1h — the fetch is served straight from
124
+ the page cache, so even 5m costs the site nothing). Re-running `f`
125
+ recalibrates the *existing* monitor row in place (history and notification
126
+ wiring survive a redesign). A failing check surfaces as `WRONG PAGE SERVED`
127
+ in the site's Details pane and the overlay.
128
+ - **`M` opens site monitoring from every site view.** Search and Stacks now bind
129
+ capital `M` (their lowercase `m` was taken by media fallback); the Servers tab
130
+ keeps `m` and gains `M` as an alias, so one muscle-memory key works everywhere.
131
+
132
+ - **Alert wiring from inside Spinup (`n` in the site-monitoring overlay).**
133
+ Lists the notification providers actually configured in your Uptime Kuma —
134
+ detected by name (e.g. "Telegram Alerts"), not assumed — and shows whether
135
+ each is attached to this site's Spinup monitors (`✓` all / `◐` some / `○`
136
+ none). `⏎` toggles a provider across all of the site's checks at once,
137
+ editing each monitor in place (history, tags and other notification wiring
138
+ survive). If Kuma has no providers yet, the overlay says so and points to
139
+ Kuma → Settings → Notifications — creating the provider itself (bot tokens
140
+ etc.) is the one step that stays in Kuma.
141
+
142
+ - **The site doctor (`d` in the site-monitoring overlay) — site monitoring
143
+ Phase 2.** A read-only, zero-setup diagnosis of the "200 OK but wrong page"
144
+ failure class, built on the cache differential: the page is fetched twice —
145
+ once normally (cache-eligible) and once with the default
146
+ `wordpress_no_cache` bypass cookie — and WP's template identity is compared
147
+ between the two. SpinupWP's `fastcgi-cache: HIT/BYPASS` headers prove which
148
+ layer answered each request, so a mismatch is *proof* the page cache is
149
+ serving a different template than PHP renders (the purge-fixable condition),
150
+ not a guess. Verdicts: healthy · stale-cache (with a copyable runbook —
151
+ `wp elementor flush_css` appears only when Elementor is positively detected
152
+ in the served markup, and that one command also purges SpinupWP's caches via
153
+ the plugin's compat hook) · recalibrate (the page changed, not the cache —
154
+ `f` jumps straight there) · **partial-outage** · down · inconclusive. Works
155
+ without a Kuma connection, degrades honestly when the page cache is off, and
156
+ never writes anything — diagnosis ends at the runbook by design.
157
+ - **The doctor catches partial outages cached-page monitors sleep through.**
158
+ Two probes, two real failure shapes: (1) cached page 200 but a **fresh render
159
+ throws 5xx** — verified live: with SpinupWP's default object-cache drop-in a
160
+ dead Redis is *fatal* on every page-cache miss, and a plugin/theme fatal
161
+ looks identical, so visitors see cached pages while admins, logged-in users
162
+ and everything uncached fails; (2) the **wp-admin door probe** — the "site
163
+ serves fine but wp-admin shows the critical-error screen" incident shape —
164
+ fetches the login page (never page-cached; Bedrock's relocated `/wp/` login
165
+ handled), where a 5xx means an admin-facing fatal. Hardened sites that hide
166
+ or protect their login answer 401/403/404 and are reported as "can't judge",
167
+ never false-alarmed. Both produce the `partial-outage` verdict with a runbook
168
+ pointing at the site's error logs and the server's Redis monitor.
169
+ - **Server-wide Redis sentinel — site monitoring Phase 3a.** The vanity
170
+ heartbeat cron gains a second marker-managed line: `redis-cli ping` every
171
+ minute, pushed to a new `{server} redis` monitor in Kuma that alerts
172
+ independently (and rides the `r` secret rotation like everything else).
173
+ Registered automatically by `a`/`R` on a vanity site — after probing that
174
+ Redis actually answers on that box, so a Redis-less server never gets a
175
+ permanently-red monitor. A red sentinel while the server beats fine surfaces
176
+ as `REDIS DOWN` on the server's Details row. Live-verified the full cycle on
177
+ the test box: stop redis → down beat within the minute → restart → recovery
178
+ beat. That test also surfaced why this matters more than expected: with
179
+ SpinupWP's default object-cache drop-in, Redis being down is **fatal (HTTP
180
+ 500) for every request that misses the page cache** — cached pages keep
181
+ serving 200, so page-watching monitors sleep through a real partial outage
182
+ that this sentinel catches in a minute.
183
+
184
+ ### Fixed
185
+ - **The site-monitoring overlay no longer gets stuck on a finished op's
186
+ message.** A completed action's `✓`/`✕` result used to replace the action
187
+ menu for the rest of the session — reopening the overlay showed only the old
188
+ message, hiding `a`/`f`/`n` (the keys still worked, invisibly). Results now
189
+ render above the menu, and a settled result is forgotten when the overlay
190
+ closes, so it always reopens fresh.
191
+ - **Help/Explain overlays could deadlock open over a focused input.** Opening
192
+ `?` or `i` in the beat before a view's text input grabbed focus (easy to hit
193
+ by typing fast right after switching to Search) left an overlay that Esc
194
+ couldn't close — the global handler bailed on `inputMode` before reaching the
195
+ overlay's dismiss keys. Dismissal is now checked first.
196
+
197
+ ## [0.15.0] - 2026-07-04
198
+
199
+ ### Added
200
+ - **Rotate monitoring secrets (`r` in the `m` overlay, vanity sites).** Made for
201
+ screencasts: record with the push URL or metrics-JSON key on screen, then press
202
+ `r` right after and both secrets die. A new push token is edited into the
203
+ *existing* Kuma push monitor — same monitor row, so heartbeat history, uptime
204
+ stats and notification wiring all survive (no delete/re-create) — the heartbeat
205
+ cron is rewritten to the new URL over SSH, a new health key is re-seeded into
206
+ the vanity page, and any Kuma monitor URL still carrying the old key (the
207
+ JSON-query recipe) is re-keyed automatically. Confirm-gated; works without a
208
+ Kuma connection too (then just the health key rotates).
209
+ - **Vanity sites stand out in the Servers tab.** The server's own vanity/health
210
+ page is marked with a `⌂` glyph and brand-green tint in the sites list — with
211
+ servers named like domains (the vanity convention), it otherwise hides among
212
+ regular sites.
213
+
214
+ ## [0.14.0] - 2026-07-03
215
+
216
+ ### Added
217
+ - **Uptime Kuma integration — monitors register themselves.** Connect your Kuma
218
+ instance once (`m` on any site walks you through it; creds verified by a real
219
+ login and stored 0600, or via `SPINUP_KUMA_URL`/`_USERNAME`/`_PASSWORD`) and:
220
+ - The **vanity wizard grows two steps**: after publishing the page it registers
221
+ a healthz monitor + a load push monitor in Kuma, then installs a once-a-minute
222
+ heartbeat cron in the site user's crontab. The cron sends the 1-min load as
223
+ the push `ping`, so Kuma graphs server load — and a silent cron (server down,
224
+ cron dead, egress broken) flips the monitor: dead-man's-switch semantics.
225
+ Both steps auto-skip when Kuma isn't connected, and a failure there is
226
+ skippable — the site is already live.
227
+ - **`m` on any site** manages monitoring in place: vanity sites get the full
228
+ treatment (incl. `R` to re-publish pages seeded before the health-endpoint
229
+ feature existed), regular sites get a homepage monitor (up/down + cert-expiry
230
+ alerts) — client site files are never touched. **`R` works without Kuma
231
+ too**: unconnected, it simply pushes the current page (a vanity-page
232
+ refresh); connected, it also registers monitors + the cron. The connect
233
+ form is opt-in (`c`), never a gate.
234
+ - The client speaks Kuma's socket.io API (its only management API), adopts
235
+ same-named monitors instead of duplicating them, reuses push tokens, and
236
+ handles both Kuma 1.x and 2.x monitor schemas.
237
+ - **Kuma metrics live inside Spinup.** With a connection configured, the store
238
+ polls Kuma once a minute and the data surfaces where you already look:
239
+ - **Health view** gains a "Monitor (Uptime Kuma)" panel — up/down, 24h uptime %,
240
+ a response-time sparkline, and a server-load sparkline fed by the heartbeat
241
+ cron (real history that survives reopening the view, which the SSH-sampled
242
+ CPU history can't).
243
+ - **Details pane** shows a Monitor row per site (up/down + 24h uptime, or
244
+ "not monitored · m"); the **header** shows a "▼ N monitors down" badge —
245
+ only bad news earns header space.
246
+ - **Reboots never page you**: firing a reboot first wraps the server's
247
+ monitors in a Kuma maintenance window and removes it when the reboot event
248
+ settles. Strictly best-effort — Kuma being unreachable never blocks the
249
+ reboot — and the confirm screen says it's happening.
250
+ - Load pushes are sent ×100 as integers (some Kuma builds silently drop float
251
+ pings — found the hard way); Spinup's views scale them back.
252
+ - **Vanity pages are now health endpoints any uptime monitor can watch.** The page
253
+ the `V` wizard seeds gains two machine modes: `?healthz` returns plain
254
+ `200 ok` / `503 unhealthy: …` (1-min load per core > 2, or disk free < 10%) so a
255
+ plain HTTP monitor gets resource alerting for free, and `?format=json&key=…`
256
+ returns full metrics (load, cores, uptime, disk, memory, PHP version) guarded by
257
+ a per-site key generated at seed time (shown on the wizard's done screen, kept in
258
+ `vanityHealthKeys` in config.json so re-seeds keep monitor URLs stable). The
259
+ human page and its WHMCS-parseable `<load>`/`<uptime>` tags are unchanged.
260
+ Monitoring recipes — including Uptime Kuma keyword/JSON-query/cert-expiry
261
+ setups — live in `docs/uptime-kuma.md`.
262
+
263
+ ## [0.13.0] - 2026-07-03
264
+
265
+ ### Added
266
+ - **A vanity site can now be added to a server that already has sites.** `V` was
267
+ only offered on empty servers (its original "connect a fresh box" role), so a
268
+ busy server had no way to get its hostname page + key-holding site user. The
269
+ gate is now "does the server have a site at its own hostname yet" — and when
270
+ it doesn't, a `V — Vanity site at hostname` row appears under Manage in the
271
+ server's Details panel (or "Resume vanity-site build" for an unfinished one).
272
+ - **The DNS view can now repoint a record — press `p` to choose where it points.**
273
+ Alongside the TTL editor, the `N` view's single-record editor gains a "Point
274
+ at…" mode: pick from a list of your SpinupWP servers (name + IP, with the
275
+ current one tagged) — pointing a record at one of your own boxes is what this
276
+ is for — or enter a custom IP. Same confirm-before-firing flow as every other
277
+ live write, and the change keeps applying in the background (Route 53 polls to
278
+ INSYNC) if you close the editor. The inventory's VALUE column shows the
279
+ in-flight repoint (`→ new IP`) and the settled value, mirroring the TTL cell.
280
+ Guardrails carried over from the cutover work: a CNAME row explains it follows
281
+ its target instead of opening, a Cloudflare *proxied* record repoints its
282
+ origin (with a note that visitors keep resolving Cloudflare's IPs), a
283
+ multi-value record warns it'll be collapsed to the one new value, and the
284
+ NS-mismatch pre-flight blocks edits through a stale duplicate zone.
285
+ - **The clone wizard can now go back a screen with `←` (or `h`).** The setup
286
+ steps (Plan ↔ Destination ↔ Connect dest ↔ Git access) step back freely —
287
+ nothing has executed yet, so re-picking sites or the destination is safe.
288
+ Once the per-site fan-out has fired, the one back edge is **DNS cutover →
289
+ the clone roster**, so a verify can be re-run or a failed site retried
290
+ before flipping live traffic. Cutover state survives the round trip: rows
291
+ already checked, flipped, or mid-flight are never reset, and a site that
292
+ becomes done via retry gets its DNS records read on re-entry.
293
+
294
+ ### Fixed
295
+ - **The DNS view's `◀ here` marker is now always relative to the server you're
296
+ viewing.** The "points here" flag was baked into each hostname's cached
297
+ resolution at lookup time, relative to whichever server's inventory triggered
298
+ it — so after checking DNS on the new server post-migration, reopening the
299
+ view on the OLD server showed records that point at the new box flagged
300
+ `◀ here` (with the wrong "N point here" tally). The cache now stores the
301
+ resolved IPs neutrally and computes "here" at render against the current
302
+ server; a just-repointed record also reads correctly immediately, before any
303
+ refresh.
304
+ - **Clone verification no longer reports a cut-off read as a mismatch.** The
305
+ source-vs-clone facts run as one wp-cli script per side; if it was
306
+ interrupted partway (a timeout, or a plugin stalling on an outbound call),
307
+ the missing tail rendered as `–` mismatch rows — reading as "the clone
308
+ differs" when nothing had been compared. Verification now runs wp-cli with
309
+ `--skip-plugins --skip-themes` (no plugin code stalls the read, and both
310
+ sides use identical flags so every count stays comparable), allows the
311
+ facts read twice the time, and a read that still comes back incomplete
312
+ fails loudly as a verify error naming the side, instead of masquerading as
313
+ differences.
314
+
315
+ ## [0.12.0] - 2026-07-02
316
+
317
+ ### Fixed
318
+ - **The clone wizard now detects each site's real webroot instead of assuming
319
+ it.** The first clone of a long-lived production server failed every site:
320
+ they all used a `/public/` public folder while the pull chain hardcoded
321
+ `files/` as the webroot. And the `public_folder` *setting* alone can't be
322
+ trusted either — SpinupWP never moves files when you configure one (its UI
323
+ tells you to move them yourself). The Standard-WP pull now finds where
324
+ WordPress actually lives on the source, and when a site is mid-move (core
325
+ still at the files root, setting pointing deeper) the clone completes the
326
+ move on the destination — placing `wp-config.php` one directory **above**
327
+ the webroot, the long-standing config-outside-the-docroot hardening Spinup
328
+ is deliberately opinionated about. Root-webroot sites keep the stock layout
329
+ untouched; subdirectory installs clone as-is; anything unrecognizable
330
+ refuses with a clear message instead of a mystery failure.
331
+ - **Sites SpinupWP marks as not-WordPress (redirect shells, static/PHP
332
+ sites) now clone as "files only"** — opt-in in the Plan step (tagged, default
333
+ off). The destination is created with no database, the whole files tree
334
+ transfers verbatim over the same hardened transport (with byte progress),
335
+ and verification compares file count, total size, and HTTP response instead
336
+ of WordPress facts. Previously these sites failed mid-pull with a cryptic
337
+ error; an interim fix merely excluded them.
338
+ - **Cloned databases keep the source's table prefix** — the destination DB
339
+ was previously always created with `wp_`, breaking sites with custom
340
+ prefixes.
341
+ - **Cloned sites now carry over the source's additional domains** (with their
342
+ redirect settings). A freshly created site only answers for its primary
343
+ domain, so `www.` and every extra hostname would have pointed at a server
344
+ that didn't serve them after DNS cutover. The wizard now re-creates the
345
+ full set on the destination right after the site is created — verified by
346
+ the destination's nginx `server_name` picking up every hostname.
347
+
348
+ ### Fixed (during live migration hardening)
349
+ - **Concurrent clones no longer sabotage each other's SSH.** The server-to-server
350
+ pull authenticated every site with one shared temporary key, so with three
351
+ sites in flight each site's setup/cleanup replaced or deleted the key the
352
+ others were actively using — at most one site per run could survive. Each
353
+ site now gets its own key, created and revoked independently.
354
+ - **A file changing on the live source mid-transfer no longer kills the whole
355
+ site's clone.** tar reports "file changed as we read it" with a warning exit
356
+ code that was being treated as fatal — and the wizard's own flags suppressed
357
+ the message that would have explained it. Warnings are now tolerated (real
358
+ transfer errors still fail) and logged with the exact file named.
359
+
360
+ - **Large file transfers no longer get killed by the wizard's own timeout.**
361
+ A ~15-minute production pull was cut off at exactly 900s by two stacked
362
+ timeouts — and the error surfaced a harmless tar warning instead of the
363
+ real cause. The file-transfer budget is now 60 minutes, the outer session
364
+ timeout strictly exceeds the in-script one, and an actual timeout says
365
+ "file transfer timed out after 60 minutes" in so many words.
366
+ - **Manual DNS records in the cutover now show your access note** ("Delegate
367
+ Access" by default, or whatever you've written for that zone) instead of
368
+ wrongly telling you to connect an account for registrars that are managed
369
+ by hand on purpose. API-connectable hosts (Cloudflare, Route 53) keep the
370
+ actionable "connect an account" message.
371
+
372
+ ### Changed
373
+ - **The DNS cutover screen grew real controls.** ↑↓ moves a cursor over the
374
+ records; space includes/excludes any ready record from the batch flip
375
+ (◉/◯, same as the Plan step); ⏎ on a record opens its zone's registrar
376
+ web console with the zone name copied to the clipboard (the
377
+ delegate-access flow); `c` cuts over exactly the included records; finish
378
+ moved to `f`.
379
+ - **In-flight clone stages show a live elapsed timer** in the roster, so a
380
+ long file pull reads as working rather than frozen.
381
+ - **File and database transfers show live byte progress** — the roster reads
382
+ `pull · files · 1.2 GB · 18 MB/s · 4m32s` while a transfer runs. Measured
383
+ by a read-only sidecar that stats the growing archive over its own SSH
384
+ session every few seconds, so the progress display can never interfere
385
+ with the transfer itself. File transfers show the Plan-measured size as a
386
+ soft ceiling (`1.2 GB of ~2.9 GB` — approximate because the archive
387
+ compresses in flight), and database pulls show a true percent (the dump
388
+ is staged and gzipped on the source first, so its final size is a fact).
389
+ - **The clone wizard no longer jumps to DNS cutover on its own.** When the
390
+ clone roster settles, the wizard now stays put so you can eyeball every
391
+ site's final state — press `c` to continue to the cutover step (which moves
392
+ live traffic) when you're ready.
393
+
394
+ ### Added
395
+ - **Dev Mode (`SPINUP_DEV_MODE=1`).** Boots straight into the dashboard against
396
+ a small in-memory example fleet — no API token, no network calls, nothing
397
+ that can touch a real account. Every write action (PHP upgrade, HTTPS
398
+ toggle, purge cache, reboot, create a server/site) behaves the same way it
399
+ would against a real account — spinners, toasts, in-progress states — so
400
+ it's useful for demos, screenshots, and UI work without a live account on
401
+ hand. A purple `DEV MODE` badge in the header makes it unmistakable.
402
+ - **The API client is now rate-limit aware.** It watches
403
+ `X-RateLimit-Remaining` on every response and paces itself as the window
404
+ runs low, and a 429 is absorbed with a Retry-After backoff instead of
405
+ surfacing as a failure. This matters: during a real migration, rate-limited
406
+ polling made three *successful* operations report as failures. Event
407
+ polling is also slower-cadence and tolerates transient API errors, and a
408
+ genuinely failed domain-add now includes SpinupWP's own event output in the
409
+ error.
410
+ - **Every clone job now writes a full log** to `~/.config/spinupwp-tui/logs/`
411
+ (one JSONL file per job: every remote script, exit code, and complete
412
+ output, with passwords redacted). Previously errors were truncated to the
413
+ roster's width and lost when the app closed. Logs self-prune when a new
414
+ job starts: anything older than 30 days or beyond the 20 most recent job
415
+ logs is removed (copy a log elsewhere to keep it longer).
416
+ - **Press `⏎` on a failed site in the clone roster** to see the full
417
+ untruncated error, the failing step, and the log path — and `r` there
418
+ retries just that site.
419
+
420
+ ## [0.11.0] - 2026-07-01
421
+
422
+ ### Added
423
+ - **"What's new" release notes.** After Spinup updates to a new version, a
424
+ one-time overlay shows what changed — sourced straight from that version's
425
+ GitHub release (the same API the update-check hint already polls), so
426
+ there's no separate feed to maintain. Shows once per version, dismisses with
427
+ any key. Press `n` in `?` Help any time to replay the current version's notes.
428
+ - **Update in place with `u`.** When the gold `✦ vX.Y.Z` hint shows a newer
429
+ release, open `?` Help and press `u` to `git pull --ff-only` right there —
430
+ no more leaving the app to update manually. Refuses on a dirty checkout
431
+ (never overwrites uncommitted work) or diverged history (never merges/
432
+ rebases); since the running process can't hot-reload, it always tells you
433
+ plainly to restart afterward, with a `bun install` nudge when dependencies
434
+ changed. Manual `git pull` still works exactly as before.
435
+ - **Enable / disable HTTPS on a site (`H`).** The direction is auto-derived
436
+ from the site's current state — press `H`, confirm, and it's done, tracked
437
+ in the background the same way a PHP upgrade is. Disabling explicitly warns
438
+ that https:// visitors will see errors until it's re-enabled.
439
+ - **Purge page + object cache (`P`).** SpinupWP has no enable/disable for
440
+ either cache on an existing site (only at creation time) — purge is the one
441
+ available write, so `P` fires both together under a single confirm and
442
+ tracks them to completion. Low-risk and fully reversible by nature (both
443
+ caches just rebuild on the next page load).
444
+
445
+ ### Fixed
446
+ - **Cloudflare proxied records no longer block DNS cutover.** The clone
447
+ wizard's cutover step used to mark every Cloudflare *proxied* (orange-cloud)
448
+ record as "manual" — but a proxied record's TTL is what's actually
449
+ uneditable (forced to automatic); its origin IP was always safely PATCHable
450
+ through the same partial write already used for cutover. Proxied records now
451
+ flow through the automatic batch flip like any other editable record; the
452
+ TTL editor is unaffected and still correctly refuses to touch a proxied
453
+ record's TTL.
454
+ - **Release notes overlay: text no longer overlaps on wrapped bullets.** The
455
+ box's height was computed from logical line count, undercounting bullets
456
+ that word-wrap into several visual rows — the box now sizes to whatever
457
+ content actually renders instead.
458
+
459
+ ## [0.10.0] - 2026-07-01
460
+
461
+ ### Added
462
+ - **Servers running an end-of-life Ubuntu release are flagged.** SpinupWP's own
463
+ web app warns when a site's Ubuntu version can no longer get new PHP installs —
464
+ Spinup now surfaces the same signal proactively, fleet-wide. A server whose
465
+ Ubuntu LTS release is past Canonical's published EOL date shows ` ⚠ os` in the
466
+ Servers list, a red-flagged "Ubuntu … EOL, clone to newer (C)" line in its detail
467
+ panel, and a "Needs attention" entry on the Dashboard pointing at the clone
468
+ wizard (`C`). EOL dates come from the same embedded-table-plus-endoflife.date-
469
+ refresh approach already used for PHP EOL, so it stays current without a
470
+ Spinup release (`src/lib/ubuntuEol.ts`).
471
+ - **Access notes for zones you can't reach via API — any `↗ web`-only host, not
472
+ just GoDaddy.** DNS providers with no API (GoDaddy's is gated to large/reseller
473
+ accounts; Namecheap, Network Solutions, and others never had one) used to show
474
+ as an undifferentiated `↗ web` in the DNS inventory, with no way to tell "I have
475
+ delegate access to this myself" from "a third party manages this domain." The
476
+ inventory's NOTE column now shows a global assumed default ("Delegate Access" —
477
+ most registrars are managed the same way) or, when set, a per-zone override in
478
+ amber — e.g. a note pointing at the third-party IT vendor for the one client
479
+ domain you don't manage directly. Set/clear the override from `c` "Manage
480
+ Access" on any inventory row (widened from GoDaddy-only to any host with a web
481
+ handoff): the previously-empty Zones pane lists every zone already known
482
+ fleet-wide for that host (from prior DNS lookups), landing the cursor on the
483
+ zone you opened it from; press `n` to edit its note, or `r` to resolve every
484
+ fleet domain and fill in any gaps. Only exceptions are ever stored — the common
485
+ case costs zero config.
486
+
487
+ ## [0.9.1] - 2026-06-30
488
+
489
+ ### Changed
490
+ - **The "update available" hint is easier to spot.** The `✦ vX.Y.Z` nudge in the
491
+ header (and the `?` About panel) is now **bright gold** and bold, instead of the
492
+ same green as the wordmark — so a newer release stands out at a glance.
493
+
494
+ ## [0.9.0] - 2026-06-30
495
+
496
+ ### Added
497
+ - **Completion toasts for background writes.** When a **PHP upgrade** or a **server
498
+ reboot / service restart** finishes, a non-focus-stealing toast slides in at the
499
+ top-right — `web3.example.com upgraded to PHP 8.3`, `web1.example.com rebooted`,
500
+ `Nginx restarted on web1.example.com`. These operations keep tracking in the
501
+ background after you close their overlay, so the toast is the "it's done" signal you
502
+ would otherwise have to go looking for; it auto-dismisses (~4s) and never takes
503
+ keyboard focus. Built on `@opentui-ui/toast`.
504
+ - **Privileged writes over SSH: connect sudo (`S`) + grant Spinup's SSH key (`K`).**
505
+ The first things Spinup writes that the SpinupWP API simply can't do (it has no
506
+ SSH-key or sudo-user surface). Press **`S`** on a server to **connect sudo** for the
507
+ session: enter the SpinupWP sudo user + its sudo password once, Spinup validates
508
+ them against the live server, and then holds them **in memory for the session
509
+ only** (the username persists to config; the **password is never written to
510
+ disk**). A connected server shows a green **● sudo** on its row; press `S` again to
511
+ disconnect. With sudo connected, press **`K`** on a site to drop Spinup's
512
+ **dedicated machine key**
513
+ into that site user's `authorized_keys` over sudo — an ed25519 identity generated
514
+ once into the config dir (`keys/spinup-tui`), commented `spinup-tui@<your-host>`,
515
+ and deliberately **never added to your SpinupWP account** so SpinupWP's
516
+ `authorized_keys` reconciliation leaves it untouched. The remote script is
517
+ **idempotent** (`grep -qxF` — re-running never duplicates the line), and a confirm
518
+ overlay shows the exact remote command before anything fires. **`K` shows a key
519
+ picker:** choose any of **your personal keys** (discovered from `~/.ssh/*.pub` and
520
+ the ssh-agent — so you can SSH/SFTP as yourself) and/or the **machine key**, deploy
521
+ several at once, and your selection is remembered for next time. **Choose the scope**
522
+ too: just this site, or **every site on the server** in one pass (the same idempotent
523
+ append runs on each, with a per-site progress readout and a retry for any that fail).
524
+ And when you **connect a new server (vanity flow)**, you can connect sudo right from
525
+ the flow (`S`, same as on a server); with sudo connected, the SSH-key step grants your
526
+ saved keys there (`g`) and publishes — no more manual "add your key in SpinupWP"
527
+ round-trip. The site list shows which keys are on a site at a glance — **👤 your key**
528
+ and/or **🔑 the spinup-tui machine key** — and the Details panel spells it out
529
+ (`Granted your key + spinup-tui`). The same `K` overlay can **remove** keys too
530
+ (`a`/`r` to toggle grant/remove) — the reverse of grant, leaving every other key (incl.
531
+ SpinupWP-managed ones) untouched. See `docs/2026-06-26_sudo-ssh-key-provisioning-spec.md`.
532
+ - **Save your sudo password to the macOS Keychain (opt-in).** Connecting sudo (`S`)
533
+ now offers a **"Remember in macOS Keychain"** toggle. Tick it and the next time you
534
+ open `S` on that server, sudo **auto-unlocks** — no re-typing the password. The
535
+ password lives **only** in your login Keychain (service `spinup-sudo`, one item per
536
+ server) and the in-memory session; **`config.json` never holds it** — only the
537
+ username and a `keychain: true` marker. Manage it from the connected panel: **`f`**
538
+ forgets the saved password (the live session stays connected), and disconnecting
539
+ (**`x`**) a saved server shows a **no-password reconnect** panel (`⏎` to reconnect,
540
+ `f` to forget) instead of the credential form — you never re-enter a password
541
+ Spinup already holds. The first auto-unlock may surface macOS's own "allow access"
542
+ prompt; choose **Always Allow** and it stays silent after. Off macOS this is absent
543
+ and sudo stays in-memory per session, exactly as before. Uses the built-in
544
+ `security` CLI — no new dependencies.
545
+ - **Create a new server (`c`).** Press `c` on a server in the Servers tab to
546
+ provision a new one, pre-filled to **match** the selected server's provider,
547
+ region, and size. The form prices the build from the provider's catalog
548
+ (DigitalOcean / Vultr / Linode / Hetzner) so you see a monthly cost before
549
+ confirming, suggests a hostname from your fleet's naming convention (e.g. the
550
+ next `webN.example.com`), and lets you switch provider (`p`), region (`g`), or
551
+ size (`e`) and toggle backups. The
552
+ build fires `POST /servers` and tracks the ~10-minute provisioning in the
553
+ background, so closing the overlay doesn't abandon it. Because the SpinupWP API
554
+ can't list your configured server providers, the first time you create on a
555
+ provider the overlay asks for its SpinupWP provider id (from Account Settings →
556
+ Server Providers) and saves it for you — once per provider, no hand-editing
557
+ config. First step of the clone-to-new-server workflow (see
558
+ `docs/2026-06-24_clone-to-server-spec.md`).
559
+ - **Clone a whole server to a new one (`C`).** Press `C` on a server to open the
560
+ clone wizard — a guided, two-pane journey that lifts one or more of a server's
561
+ sites onto a fresh (or existing) destination, **without touching DNS until you
562
+ say so**, so you can stage and verify a migration before cutting over. The steps:
563
+ **Plan** — pick which of the source's sites to clone (all selected by default;
564
+ `space` toggles), and Spinup sizes each one live (`du` + `wp db size`) into a
565
+ payload total so you know what you're moving; a concurrency throttle protects the
566
+ source. **Destination** — provision a new server pre-matched to the source, or
567
+ `d` to pick an existing server as the target. **Connect** — connect sudo on
568
+ **both** ends (the clone is a server-to-server **pull**: the destination pulls
569
+ from the source over SSH using a granted key + agent forwarding, and the source
570
+ key is revoked when it's done). **Clone sites** — a live roster runs the sites
571
+ concurrently, each advancing `create → pull → config → verify → done`. Both
572
+ stacks are handled: **Standard WP** (files via tar-over-ssh, DB via cat-over-ssh,
573
+ wp-config re-stamped for the dest) and **Bedrock** (git-native — create from the
574
+ repo, `composer install` over SSH, pull `uploads` + `auth.json`, re-stamp `.env`
575
+ keeping custom vars). **Verify** — a source-vs-clone drill-down (wp-cli value
576
+ diff + a `--resolve` HTTP check that hits the new server while DNS still points at
577
+ the old one). **DNS cutover** — when you're satisfied, repoint **every** A record
578
+ across each site's domains (apex + additional) to the new server in one batched,
579
+ partial-aware pass; www-style CNAMEs that follow the apex are skipped, not
580
+ clobbered. The clone runs in the **background** — `esc` doesn't abandon it; a
581
+ header badge (`⠹ Cloning <server> — press C`) surfaces the in-flight job and `C`
582
+ on the source reopens the live roster. See
583
+ `docs/2026-06-24_clone-to-server-spec.md`.
584
+ - **Connect a new server with a vanity site (`V`).** A freshly-created server has
585
+ no site, so there's nothing to attach an SSH key to and no way for Spinup to
586
+ reach it. Press `V` on a server with no sites to build a small placeholder site
587
+ at the server's own hostname: Spinup writes the DNS A record (Route 53), waits
588
+ for it to resolve, creates the site, enables HTTPS (a free Let's Encrypt
589
+ certificate), hands you off to add your SSH key (the site's **SFTP & SSH → Site
590
+ User**), then publishes a minimal, brand-neutral status page. Press `o` on the
591
+ success screen to open the live site. The whole build runs in the background and
592
+ survives closing the overlay — reopen it with `V`, and a header badge tracks it.
593
+ - **Background jobs survive a restart.** Long-running work — a server provision, a
594
+ PHP upgrade, a vanity-site build — is now persisted, so quitting and relaunching
595
+ Spinup reconnects to it instead of forgetting it. SSH-based jobs that can't be
596
+ reconnected (a production DB backup/sync) are clearly flagged as **interrupted**
597
+ on restart rather than silently lost.
598
+
599
+ ### Changed
600
+ - **New-server progress shows a live elapsed timer**, and a header badge surfaces
601
+ an in-flight provision (or vanity-site build) from any tab.
602
+ - **Servers with no sites are flagged in amber** in the Servers list — an empty
603
+ server is a dead end until it has a site (press `V` to create one).
604
+ - **The vanity build's "Wait for DNS to propagate" step now shows a timer.** A
605
+ live count-down (`m:ss left`) ticks through the ~2-minute window; if it lapses you
606
+ can **keep waiting** (`w`) and the timer flips to a count-up (`m:ss elapsed`) from
607
+ that same point so you can see exactly how long it's been — without being prompted
608
+ again. From there, **`c` continues now** (create the site and move on) or **`s`
609
+ skips SSL** (publish over HTTP and add the certificate later).
610
+ - **DNS connection resolution re-checks itself instead of trusting a stale cache.**
611
+ Writing the vanity A record (and, later, the clone DNS cutover) used a cached list
612
+ of which zones each connected account serves; a zone you registered *today* wasn't
613
+ in it, so the build failed with a misleading "no editable DNS connection serves
614
+ this zone." Now, when no account matches but one *is* connected for that provider,
615
+ Spinup re-verifies that provider's accounts and retries before giving up — so a
616
+ freshly-registered zone just works, no manual refresh. The cache also expires
617
+ entries after 24h so they refresh on their own. The error messages now distinguish
618
+ "no account connected" from "connected but it doesn't serve this zone."
619
+
620
+ ## [0.8.0] - 2026-06-23
621
+
622
+ ### Added
623
+ - **Production media fallback (`m`).** After a `p` DB pull, your local site shows
624
+ broken images because the media library isn't synced. Press `m` on a linked
625
+ WordPress site to drop a small, self-contained mu-plugin that serves any
626
+ **missing-locally** upload straight from production — so images resolve without
627
+ copying a single file. It runs in WordPress (pure PHP), so it works the same on
628
+ any local stack (Valet / Herd / LocalWP / DDEV / MAMP) with no web-server config.
629
+ It decides "missing" from the real document root and redirects to the **same
630
+ path** on production, so it covers page-builder URLs (Elementor inline CSS &
631
+ gallery data), **legacy `/wp-content/uploads` paths** left from a Bedrock
632
+ conversion, and CDN/S3 redirects (production's own routing resolves them).
633
+ Local-only and read-only on production; self-disables on the production domain
634
+ so it's inert if ever deployed. The plugin's presence is its on/off state (no
635
+ config); `m` toggles it and the overlay offers an in-place update when a newer
636
+ version ships. (See "Production media fallback" in the README.)
637
+ - **App version, About, and update check.** The header now shows the running
638
+ version next to the wordmark (`◆ Spinup vX.Y.Z`). The `?` help overlay is
639
+ redesigned into a responsive multi-column layout with an **About** column
640
+ (version, how to update, repo). On launch the app checks the latest GitHub
641
+ release (cached on disk for 6h — no background polling) and, when a newer
642
+ release exists, shows a `✦ vX.Y.Z` hint in the header and the About panel.
643
+
644
+ ### Changed
645
+ - **The app is now called "Spinup".** Renamed the app's own branding — the
646
+ header wordmark, the help/About panel, and the CLI banner — to **Spinup**, since
647
+ it's a control center for your *SpinupWP account*. References to the SpinupWP
648
+ *service* (the web app via `w`, the API, deep links) are unchanged.
649
+
650
+ ## [0.7.1] - 2026-06-23
651
+
652
+ ### Added
653
+ - **Scaffold a post-import hook from the sync screen.** When you press `p` on a
654
+ linked site that has no `bin/sync.d/post-import.sh`, the confirm screen now
655
+ explains what the hook is (it runs after the import + URL rewrite) and offers
656
+ `s` to write an **inert** sample for you — a documented `post-import.sh` with
657
+ the `WEB_DIR` / `SYNC_REMOTE_HOST` / `SYNC_LOCAL_HOST` env contract and common
658
+ examples (Elementor URL swaps, plugin toggles), all commented out so nothing
659
+ runs until you uncomment it. It never overwrites an existing hook.
660
+
661
+ ### Changed
662
+ - **Backup (`d`) and sync (`p`) now show progress as a building checklist.** Each
663
+ step (back up local, export, download, import, rewrite URLs, run hook) is listed
664
+ in one bordered panel and gets a `✓` as it finishes, the running step spins, and
665
+ the final result (saved paths, size) appears below the completed stack — so the
666
+ whole operation, including the "done" summary, reads in a single frame. A
667
+ failure marks the exact step that broke with `✕` and shows the error inline.
668
+
669
+ ## [0.7.0] - 2026-06-22
670
+
671
+ ### Added
672
+ - **Edit DNS record TTLs — the first DNS write.** From the DNS view, press `⏎` on a
673
+ site's hosting record to open a focused editor and change its TTL (a preset or a
674
+ custom value). This is the prep step for a low-risk server migration: lower the TTL
675
+ before you cut over, then restore it after. Writes go to **AWS Route 53** or
676
+ **Cloudflare** through your connected account, behind a confirm step. An edit-time
677
+ safety check re-reads the live nameservers and **blocks the write** if the account's
678
+ zone isn't the one actually serving the domain — so you can't edit a stale or
679
+ duplicate copy. Route 53 changes are followed to completion; the record shows an
680
+ "updating" status that keeps ticking even if you leave the view.
681
+ - **Open the DNS view scoped to one site.** Press `n` on a site (Servers or Search)
682
+ to open the inventory for just that site's domains and records — the view you want
683
+ when migrating a single site. Press `a` inside to expand to the whole server.
684
+ - **Download a production database backup.** On a linked WordPress site, press
685
+ `d` in Search to export the production database with `wp-cli` (into a stage file
686
+ *outside* the public webroot), gzip it, download it into the linked copy's
687
+ `sql/` folder, and remove the remote copy. **Read-only on production** and needs
688
+ no local WP-CLI — the export runs on the server. The remote document root and
689
+ SSH target are derived from the API; SSH/scp run non-interactively. A spinner on
690
+ the site's row tracks an in-flight download even after the overlay is closed.
691
+ (See "Database backup & sync" in the README.)
692
+ - **Pull production → local DB sync (opt-in).** Press `p` on a linked WordPress
693
+ site to refresh your **local** database from production: it backs up the local
694
+ DB first, exports + downloads production, imports it locally, rewrites
695
+ production URLs → your local URL (`wp search-replace`), and runs an optional
696
+ `bin/sync.d/post-import.sh` hook if the project has one. Works with Standard WP
697
+ and Bedrock, detecting the local WordPress root, local URL (link or `.env`
698
+ `WP_HOME`), and table prefix automatically — no per-site config. **It overwrites
699
+ your local database, so it's off by default**; enable it with `localSync` (a
700
+ `config.json` field, preferred, or the `SPINUPWP_LOCAL_SYNC` env var). Read-only
701
+ on production.
702
+ - **Site Control panel.** The Search result's action menu is now grouped
703
+ (Open / Remote / Local / Server) so the growing set of per-site actions stays
704
+ scannable.
705
+
706
+ ### Changed
707
+ - **The DNS view is now a migration lens, organized by site.** It shows only the
708
+ records that "count" when you move a site — each **site** on its own line (labeled
709
+ by the site's own domain, even when it's a subdomain), its hosting record's
710
+ type / TTL / value, a `◀ here` flag when the record points at this server, and a
711
+ `+www` tag when `www` simply follows the apex. A site's additional domains nest
712
+ beneath it, so a domain portfolio reads as one site, not many. TTLs are shown in
713
+ **seconds** (e.g. `3600`), and editing happens in place — there's no full-zone
714
+ record list to get lost in.
715
+
716
+ ### Notes
717
+ - Only the **TTL** is editable so far (repointing a record's target is a later step).
718
+ Cloudflare **proxied** records are read-only (their TTL is managed by Cloudflare).
719
+ By design the DNS view only ever touches a site's own hosting records — your MX,
720
+ TXT, and other zone records are never shown or modified, so moving a site can't
721
+ take down its email.
722
+ - Editing a Cloudflare record needs a token scoped to `Zone.DNS:Edit` (the read-only
723
+ `Zone:Read` token from the host inventory isn't enough).
724
+ - The DB backup/sync actions only appear for **WordPress** sites (they use
725
+ `wp-cli`), and only when the site is **linked** to a local copy (the backup's
726
+ destination). The destructive sync additionally requires the `localSync` opt-in.
727
+
728
+ ## [0.6.0] - 2026-06-20
729
+
730
+ ### Added
731
+ - **DNS host inventory.** A new read-only DNS module that answers "where is each
732
+ domain's DNS hosted?" — the inventory you'd otherwise build by hand to migrate
733
+ or clone a site. Press `n` on a site to look up its domains' hosts; press `N`
734
+ on a server for a full, zone-keyed inventory of every domain on it. The unit is
735
+ the **zone**: `www` and the apex collapse together, and a separate-TLD redirect
736
+ (e.g. an alternate domain) surfaces as its own zone with its own host, so a full
737
+ portfolio move doesn't miss anything. Hosts are detected from a live nameserver
738
+ lookup (no `dig` dependency) and labeled (Cloudflare, AWS Route 53, GoDaddy,
739
+ …), falling back to the nameserver domain for anything unrecognized. Results
740
+ are cached with a visible age; `r` refreshes.
741
+ - **DNS access detection.** Each zone also shows whether you can **edit** it:
742
+ `✓` editable · `↗` web only · `○` needs key · `·` unknown. A zone is `✓` only
743
+ when a connected account of the provider that actually serves it (its live
744
+ nameservers) holds it — so a stale or duplicate zone in another account doesn't
745
+ give a false green. With more than one account connected, an **ACCOUNT** column
746
+ names the owning account.
747
+ - **Connect DNS providers.** Press `c` on a zone to manage credentials for its
748
+ provider — **AWS Route 53** (an access key for an IAM user scoped to Route 53
749
+ reads), **Cloudflare** (a `Zone:Read` scoped token), or **GoDaddy** (a
750
+ Production API key). Multiple accounts per provider are supported, with a
751
+ two-pane drill-down into each account's zones. Credentials are verified on the
752
+ spot (only stored if they work) and kept in `config.json` (chmod 600); standard
753
+ environment variables (`CLOUDFLARE_API_TOKEN`, `AWS_ACCESS_KEY_ID` /
754
+ `AWS_SECRET_ACCESS_KEY`, `GODADDY_API_KEY` / `GODADDY_API_SECRET`) are honored too.
755
+ - **GoDaddy web fallback.** GoDaddy's API is only available to larger accounts, so
756
+ a GoDaddy zone shows `↗` and `w` (from the inventory or the connect overlay)
757
+ opens your GoDaddy **Clients hub** and copies the domain to your clipboard —
758
+ with an in-context note on the assumed Delegate-Access workflow.
759
+ - **Masked secret entry.** Tokens and secret keys are masked as you type, in both
760
+ the DNS provider forms and the first-run **API-token onboarding** screen.
761
+
762
+ ### Notes
763
+ - The DNS module is **entirely read-only** — it lists hosts and verifies access;
764
+ it does not change any DNS records (editing is a later phase). Provider
765
+ credentials are optional: without them you still get the full host inventory,
766
+ just without the editable/account columns.
767
+
768
+ ## [0.5.0] - 2026-06-19
769
+
770
+ ### Added
771
+ - **Link local working copies.** Press `L` on a site (Servers / Stacks / Search)
772
+ to link it to its local checkout — a path plus the local URL where you serve it.
773
+ The site's details gain a "Local" field, and you can open the copy with `t` (a
774
+ terminal at the path) or `v` (its local URL in your browser).
775
+ - **Auto-discover local copies.** Press `S` in the Stacks tab to scan one or more
776
+ folders and match their subdirectories to sites — by git remote, Bedrock
777
+ `WP_HOME`, or folder name — then batch-link the matches. Add scan folders inline
778
+ (comma-separated) the first time.
779
+ - **"Needs a local copy" report.** Press `f` in the Stacks tab for the managed
780
+ sites you have no usable local copy for (never linked, or a missing path),
781
+ alphabetical and filterable by stack with `←/→`; `a` trims to the ones needing
782
+ attention (pending updates).
783
+ - **Local drift at a glance.** A linked, on-disk site shows `⇡N unpushed` /
784
+ `● uncommitted` in the context strip (read from the local repo; no network).
785
+ - **SSH into a site.** Press `s` on a site to open a new terminal already running
786
+ `ssh` into it (`{site_user}@{server_ip}`).
787
+ - **Glanceable row markers.** `◆` marks sites with a linked local copy and `↑N`
788
+ shows pending WordPress updates, across the Servers / Stacks / Search lists.
789
+ - **"Explain this screen."** Press `i` on any view for a plain-language guide to
790
+ what each pane is and what every key does there.
791
+ - **Per-view subtitles** under the tab bar describing each screen and its key actions.
792
+
793
+ ### Changed
794
+ - **Outcome-oriented key labels:** "detect" → "identify app", "scan local" →
795
+ "find local copies", "upgrade PHP" → "change PHP".
796
+ - **Terminal actions open *your* terminal**, detected from `$TERM_PROGRAM` (e.g.
797
+ iTerm), not a hardcoded Terminal.app; override with a `terminalApp` config/env value.
798
+ - **New config keys:** `localRoots` (folders to scan) and `localSites` (per-site
799
+ link: path + local URL). The local URL is tool-agnostic (Valet, Cove, LocalWP,
800
+ Herd, DDEV, …); an older `valetUrl` key is read and migrated automatically.
801
+
802
+ ### Notes
803
+ - All new actions are read/local-only — no new SpinupWP API writes.
804
+ - "Open a terminal" and SSH script iTerm or Terminal on macOS; other terminals
805
+ fall back to Terminal.app.
806
+
807
+ ## [0.4.0] - 2026-06-18
808
+
809
+ ### Added
810
+ - **Server actions: reboot + service restarts.** Press `a` on a server (Servers
811
+ tab or a Search result) to reboot it (`POST /servers/{id}/reboot`) or restart a
812
+ service — Nginx / PHP-FPM / MySQL / Redis (`POST /servers/{id}/services/{svc}/restart`).
813
+ Pick → confirm → the event is tracked to completion in the store, so closing the
814
+ overlay leaves it running and the server's row keeps a spinner (same model as PHP
815
+ upgrades). Reboot's confirmation calls out whole-server downtime for all sites on
816
+ it; a service restart is a brief blip. Needs a Read/Write token.
817
+ - **Reboot visibility + "why".** Servers needing a reboot show a `↻rbt` badge in the
818
+ Servers list (the Dashboard already listed them). Because the API only exposes a
819
+ `reboot_required` boolean with no reason, the actions overlay reads Ubuntu's
820
+ `/var/run/reboot-required` + `.pkgs` over SSH (read-only, reusing the health
821
+ connection) and shows the pending packages — typically a kernel/security update —
822
+ labeled as OS-level context, not SpinupWP's internal logic. ServerDetail's
823
+ "Reboot" field shows the same summary once probed.
824
+
825
+ ## [0.3.0] - 2026-06-18
826
+
827
+ ### Added
828
+ - **Upgrade a site's PHP version — the first write action.** Press `u` on a site
829
+ (Servers / Stacks / Search) to pick a PHP version and apply it
830
+ (`PUT /sites/{id}/php`); the resulting upgrade event is polled to completion.
831
+ The picker is built dynamically from the (network-refreshed)
832
+ [endoflife.date](https://endoflife.date) schedule — so new releases appear on
833
+ their own — with the current version marked and EOL versions flagged; it is
834
+ **not** filtered to versions already installed, since SpinupWP installs one on
835
+ demand. Tracking lives in the app store, so closing the modal leaves the
836
+ upgrade running: the site's row shows a spinner and target version (`→8.3`)
837
+ until it settles (or flags `⬆!` on failure), and the detail panel mirrors the
838
+ in-progress state. A site whose server has a pending platform upgrade is
839
+ blocked with a pointer to the `w` web deep link.
840
+ - **Search actions mode.** In the Search tab, press `Tab` (or `→`) to hand focus
841
+ from the search box to the selected result's action menu — `o` open, `w`
842
+ SpinupWP, `u` PHP upgrade, `h` health — and `←` / `Esc` to return to typing.
843
+ - **Server upgrade visibility + SpinupWP deep links.** Servers with a pending
844
+ SpinupWP platform upgrade show an `⬆upg` badge in the Servers list and a
845
+ "SpinupWP upgrade" field in the detail panel. Press `w` to open the selected
846
+ server (or site) directly in the SpinupWP web app — useful for actions the API
847
+ can't perform, like running the upgrade. Requires `accountSlug` in config
848
+ (`SPINUPWP_ACCOUNT_SLUG`); falls back to the dashboard root if unset.
849
+
850
+ ### Changed
851
+ - **PHP EOL flagging is now date-driven, not a hard-coded version cutoff.** A
852
+ version is flagged once its real end-of-life date is past the current date, so
853
+ it self-corrects as the calendar advances (e.g. 8.2 flips on 2027-01-01 with
854
+ no code change). Dates come from an embedded php.net table (offline default)
855
+ refreshed from [endoflife.date](https://endoflife.date) and cached to
856
+ `~/.config/spinupwp-tui/php-eol.json`; unknown versions are never guessed.
857
+
858
+ ### Fixed
859
+ - The `upgrade_required` server field was mislabeled "OS upgrade"; it's a
860
+ SpinupWP **platform** upgrade (not Ubuntu), now labeled accordingly.
861
+
862
+ ### Notes
863
+ - The PHP upgrade is the first **write** feature and needs a SpinupWP
864
+ **Read/Write** token. With a Read Only token the action returns a clear "token
865
+ is read-only" message and nothing changes; the entire read path keeps working.
866
+
867
+ ## [0.2.0] - 2026-06-18
868
+
869
+ ### Added
870
+ - **Site stack detection + Stacks tab (Tier 1).** Classifies every site as
871
+ Standard WP, Bedrock, or Non-WP from data already fetched (no SSH): non-WP →
872
+ Non-WP, WP with a `/web/` webroot → Bedrock, any other WP → Standard WP. Adds
873
+ a color-coded stack tag to the Servers and Search site lists, a "Stack" field
874
+ in the site detail panel, and a new **Stacks** tab (key `3`) showing fleet
875
+ composition (counts + bars), drill-down into the sites of a selected stack,
876
+ and the fleet-wide PHP version distribution with end-of-life versions flagged.
877
+ - **Tier-2 stack probe (`d` / `D`).** On-demand, read-only SSH probe that
878
+ inspects a site's filesystem to name what the API can't: WordPress (with
879
+ version), Bedrock, WHMCS, Laravel, and Static HTML. `d` probes the selected
880
+ site (in the Stacks and Servers tabs); `D` probes the entire selected stack,
881
+ in list order, with bounded SSH concurrency. Detected results show in the
882
+ Stacks list, the Non-WP breakdown, and the site detail panel.
883
+ - **Probe-aware composition.** A conclusive probe overrides the API's
884
+ classification, so sites the API mislabels (e.g. WordPress installed outside
885
+ SpinupWP's installer reports `is_wordpress=false`) move into their true
886
+ bucket; counts shift toward reality as you probe. The Non-WP bucket expands
887
+ into named sub-rows (WHMCS / Laravel / Static HTML / Unknown / unprobed).
888
+ - **Probe cache.** Results are cached to disk
889
+ (`~/.config/spinupwp-tui/stack-cache.json`), hydrated at startup and written
890
+ through (serialized) per probe, so detections survive restarts without
891
+ re-running SSH.
892
+
893
+ ### Changed
894
+ - Tab keys shifted to make room for Stacks: Search is now `4`, Events is `5`.
895
+
896
+ ### Fixed
897
+ - Capital-letter shortcuts (`D`, and the previously-dead `G` jump-to-bottom) now
898
+ register correctly — OpenTUI delivers letters lowercased with a shift flag, so
899
+ key matching is normalized accordingly.
900
+
901
+ ## [0.1.0] - 2026-06-18
902
+
903
+ Initial tagged release.
904
+
905
+ ### Added
906
+ - Fleet dashboard: connection status, disk-usage bars, pending reboots/OS
907
+ upgrades, WordPress update counts, and a recent activity feed.
908
+ - Server & site browser: three-pane navigator with full site details (PHP
909
+ version, HTTPS, page cache, backups, Git deployment, WP updates).
910
+ - Global fuzzy search across every server and site by name, domain, or IP.
911
+ - Events feed with per-event detail and output.
912
+ - Live server health view (press `h`): CPU aggregate/per-core/sparkline, load,
913
+ memory/swap, disk mounts, and top processes over SSH.
914
+ - Open-in-browser for sites (press `o`).
915
+ - Global `spinup` command with `login`, `where`, `help`, and `version`
916
+ subcommands, plus first-run onboarding that validates and saves a token.
917
+
918
+ ### Notes
919
+ - Read-only release: works with a SpinupWP **Read Only** API token.
920
+
921
+ [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.19.0...HEAD
922
+ [0.19.0]: https://github.com/mwender/spinupwp-tui/compare/v0.18.0...v0.19.0
923
+ [0.18.0]: https://github.com/mwender/spinupwp-tui/compare/v0.17.0...v0.18.0
924
+ [0.17.0]: https://github.com/mwender/spinupwp-tui/compare/v0.16.1...v0.17.0
925
+ [0.16.1]: https://github.com/mwender/spinupwp-tui/compare/v0.16.0...v0.16.1
926
+ [0.16.0]: https://github.com/mwender/spinupwp-tui/compare/v0.15.0...v0.16.0
927
+ [0.15.0]: https://github.com/mwender/spinupwp-tui/compare/v0.14.0...v0.15.0
928
+ [0.14.0]: https://github.com/mwender/spinupwp-tui/compare/v0.13.0...v0.14.0
929
+ [0.13.0]: https://github.com/mwender/spinupwp-tui/compare/v0.12.0...v0.13.0
930
+ [0.12.0]: https://github.com/mwender/spinupwp-tui/compare/v0.11.0...v0.12.0
931
+ [0.11.0]: https://github.com/mwender/spinupwp-tui/compare/v0.10.0...v0.11.0
932
+ [0.10.0]: https://github.com/mwender/spinupwp-tui/compare/v0.9.1...v0.10.0
933
+ [0.9.1]: https://github.com/mwender/spinupwp-tui/compare/v0.9.0...v0.9.1
934
+ [0.9.0]: https://github.com/mwender/spinupwp-tui/compare/v0.8.0...v0.9.0
935
+ [0.8.0]: https://github.com/mwender/spinupwp-tui/compare/v0.7.1...v0.8.0
936
+ [0.7.1]: https://github.com/mwender/spinupwp-tui/compare/v0.7.0...v0.7.1
937
+ [0.7.0]: https://github.com/mwender/spinupwp-tui/compare/v0.6.0...v0.7.0
938
+ [0.6.0]: https://github.com/mwender/spinupwp-tui/compare/v0.5.0...v0.6.0
939
+ [0.5.0]: https://github.com/mwender/spinupwp-tui/compare/v0.4.0...v0.5.0
940
+ [0.4.0]: https://github.com/mwender/spinupwp-tui/compare/v0.3.0...v0.4.0
941
+ [0.3.0]: https://github.com/mwender/spinupwp-tui/compare/v0.2.0...v0.3.0
942
+ [0.2.0]: https://github.com/mwender/spinupwp-tui/compare/v0.1.0...v0.2.0
943
+ [0.1.0]: https://github.com/mwender/spinupwp-tui/releases/tag/v0.1.0