spinuptui 0.19.0 → 0.20.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.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,67 @@ versions; such changes are called out here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.20.0] - 2026-07-08
15
+
16
+ ### Added
17
+ - **The clone wizard's existing-server picker now shows each server's current
18
+ site count.** Picking a destination previously showed only provider/region/
19
+ IP — no sense of how busy a candidate already is. Each row now leads with
20
+ `N site(s)`.
21
+
22
+ ### Fixed
23
+ - **The clone wizard no longer routes git-deployed non-WordPress sites through
24
+ a Bedrock pull.** `cloneStackFor` trusted `git.repo` alone as "this is
25
+ Bedrock" — true often enough (`is_wordpress` is unreliable for git sites),
26
+ but a git-deployed **static** site (confirmed live: a client's static site,
27
+ repo literally named `*-static-site`) would be routed through
28
+ `composer install`/wp-cli anyway, which it can't survive. A conclusive
29
+ non-WordPress probe (`whmcs`/`laravel`/`static`) now overrides `git.repo`;
30
+ a probe saying `bedrock`/`wordpress`, or no probe at all, still trusts it
31
+ (same "probe the site (`d`) before cloning" caveat as the sibling fix
32
+ above).
33
+ - **The clone wizard no longer silently skips the database for API-mislabeled
34
+ WordPress sites.** Its stack picker read `is_wordpress` straight from the
35
+ API, unlike every other view (Stacks, Browser), which corrects that flag
36
+ against a `d` probe result. A site the API wrongly reports as non-WordPress
37
+ (confirmed live against two real client sites) would default to a
38
+ files-only pull — filesystem copied, database never touched, so the
39
+ "clone" wasn't a working WordPress site. The wizard now consults the same
40
+ probe-corrected classification: **probe the site (`d`) before cloning** so
41
+ the wizard picks it up as WordPress.
42
+ - **wp-cli reads now use the site's own configured PHP version, not the
43
+ server's system default.** wp-cli's installed binary shebangs
44
+ `#!/usr/bin/env php`, which resolves through `PATH` to whichever PHP a
45
+ server's `update-alternatives` currently calls default — independent of
46
+ any individual site's configured version, and it can be missing extensions
47
+ that version's CLI never got configured with. Confirmed live: a site
48
+ configured for PHP 8.1 silently ran its wp-cli reads under the server's
49
+ default 8.4, whose CLI was missing `mysqli` — every WordPress-content read
50
+ (posts, pages, users, active plugins, site/home URL) failed silently,
51
+ while file-only or native-MySQL-client commands (`core version`, `db
52
+ export`/`import`/`check`) were unaffected. This broke the clone wizard's
53
+ **Verify** step (`v`) and the **installed plugins & themes** view (`p`) for
54
+ any site whose configured PHP differs from the server's system default —
55
+ both now pin wp-cli to the site's own PHP-CLI binary, falling back to the
56
+ system default only if that specific version isn't installed.
57
+ - **Verify now tells the difference between "the clone actually differs" and
58
+ "wp-cli couldn't read one side's database."** If `core version` succeeds
59
+ (a file read, no DB) but every database-backed fact comes back empty, that's
60
+ the missing-extension failure above, not a real content difference — Verify
61
+ now reports it plainly instead of a wall of misleading ✕ rows implying data
62
+ loss.
63
+
64
+ ## [0.19.1] - 2026-07-08
65
+
66
+ ### Fixed
67
+ - **Finished the SpinupTUI rebrand in the README and cover banner/social-preview
68
+ images.** The v0.19.0 rename swept the CLI banner, header wordmark, and app
69
+ chrome, but missed 14 mid-sentence "Spinup" mentions scattered through the
70
+ README's prose, and the cover banner/social-preview images still spelled out
71
+ "SPINUP" in block-letter art. Both are fixed now; `spinup-sudo` (a Keychain
72
+ service name) and the `spinup-tui` machine key (a literal SSH key identity)
73
+ are unaffected — they're real persisted identifiers, not branding.
74
+
14
75
  ## [0.19.0] - 2026-07-08
15
76
 
16
77
  ### Changed
@@ -918,7 +979,9 @@ Initial tagged release.
918
979
  ### Notes
919
980
  - Read-only release: works with a SpinupWP **Read Only** API token.
920
981
 
921
- [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.19.0...HEAD
982
+ [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.20.0...HEAD
983
+ [0.20.0]: https://github.com/mwender/spinupwp-tui/compare/v0.19.1...v0.20.0
984
+ [0.19.1]: https://github.com/mwender/spinupwp-tui/compare/v0.19.0...v0.19.1
922
985
  [0.19.0]: https://github.com/mwender/spinupwp-tui/compare/v0.18.0...v0.19.0
923
986
  [0.18.0]: https://github.com/mwender/spinupwp-tui/compare/v0.17.0...v0.18.0
924
987
  [0.17.0]: https://github.com/mwender/spinupwp-tui/compare/v0.16.1...v0.17.0
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src=".github/assets/banner.png" alt="SpinupWP TUI" width="100%">
2
+ <img src=".github/assets/banner.png" alt="SpinupTUI" width="100%">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">SpinupTUI</h1>
@@ -110,7 +110,7 @@ Once you're in, the dashboard looks like this:
110
110
  - **Create & connect a server** — press `c` on the Servers tab to provision a new
111
111
  server (DigitalOcean / Vultr / Linode / Hetzner), priced from the provider catalog
112
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
113
+ doesn't yet have a **site at its own hostname** to connect it end to end: SpinupTUI
114
114
  writes the DNS A record, creates a
115
115
  placeholder "vanity" site, enables HTTPS, hands you off to add your SSH key, and
116
116
  publishes a status page — turning a bare server into one you can actually work
@@ -123,10 +123,10 @@ Once you're in, the dashboard looks like this:
123
123
  verifies the clone against the source, and finally repoints DNS on your word.
124
124
  Runs in the background with a header badge. (See "Cloning a server" below.)
125
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
126
+ SSH keys or sudo users, so SpinupTUI does it directly. Press `S` on a server to
127
127
  **connect sudo** for the session (optionally **remembered in your macOS Keychain**
128
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
129
+ personal key and/or SpinupTUI's dedicated machine key — one site or every site on the
130
130
  server. (See "Privileged writes over SSH" below.)
131
131
  - **Uptime Kuma monitoring** — press `m` on a site to wire it into your own
132
132
  [Uptime Kuma](https://github.com/louislam/uptime-kuma) instance (one-time
@@ -146,7 +146,7 @@ Once you're in, the dashboard looks like this:
146
146
  (See `docs/uptime-kuma.md` for recipes.)
147
147
  - **Site monitoring: fingerprints, a doctor, and a Redis sentinel** — press `M`
148
148
  on any site (Servers / Stacks / Search) for the monitoring overlay. `f`
149
- calibrates a **front-page check**: Spinup reads your healthy front page,
149
+ calibrates a **front-page check**: SpinupTUI reads your healthy front page,
150
150
  derives a template-identity fingerprint from what WordPress itself stamps
151
151
  into the markup (not a headline someone might edit), proves it discriminates,
152
152
  and registers a Kuma keyword monitor at your chosen window (5m–1h) — catching
@@ -155,7 +155,7 @@ Once you're in, the dashboard looks like this:
155
155
  proven by the server's own `fastcgi-cache` headers) plus a wp-admin door
156
156
  probe, diagnosing stale caches and **partial outages** — cached pages fine
157
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
158
+ ending in a copyable runbook. `n` wires **alerts**: SpinupTUI detects the
159
159
  notification providers configured in Kuma by name and toggles them per site.
160
160
  And servers get a **Redis sentinel**: the heartbeat cron pings Redis every
161
161
  minute and reports up/down to its own `{server} redis` monitor.
@@ -163,7 +163,7 @@ Once you're in, the dashboard looks like this:
163
163
  - **Completion toasts** — background writes that take a while (a PHP upgrade, a
164
164
  server reboot/restart, resolving your fleet's DNS) raise a non-focus-stealing
165
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
166
+ - **Release notes** — after SpinupTUI updates to a new version, a one-time overlay
167
167
  shows what changed (sourced straight from that version's GitHub release —
168
168
  no separate feed to maintain). Press `n` in `?` Help to see the current
169
169
  version's notes again any time.
@@ -461,7 +461,7 @@ server's row keeps a spinner).
461
461
  Everything hangs off one overlay: press `M` on a site in any view (the Servers
462
462
  tab also keeps its original `m`). It talks to your own
463
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
464
+ sends the alerts; SpinupTUI creates the monitors *correctly* and diagnoses on
465
465
  demand.
466
466
 
467
467
  - **`a` — uptime monitors.** Regular sites get a homepage monitor (up/down +
@@ -472,7 +472,7 @@ demand.
472
472
  SpinupWP's default object-cache drop-in, a dead Redis is **fatal (HTTP 500)
473
473
  for every request that misses the page cache** — cached pages keep serving
474
474
  200, so only the sentinel notices, within a minute.
475
- - **`f` — the front-page check.** Spinup reads your *healthy* front page,
475
+ - **`f` — the front-page check.** SpinupTUI reads your *healthy* front page,
476
476
  derives a template-identity fingerprint from WordPress's own `body_class()`
477
477
  output (`class="home`, `page-id-N`, …, falling back to the canonical link),
478
478
  validates it against a throwaway search render to prove it discriminates,
@@ -493,7 +493,7 @@ demand.
493
493
  pages 200, fresh renders or wp-admin 5xx — Redis or a PHP fatal; visitors
494
494
  look fine, admins are broken) · recalibrate (`f` jumps straight there) ·
495
495
  down · inconclusive.
496
- - **`n` — alert wiring.** Spinup detects the notification providers configured
496
+ - **`n` — alert wiring.** SpinupTUI detects the notification providers configured
497
497
  in your Kuma (by name — Telegram, email, whatever you've set up) and shows
498
498
  whether each is attached to this site's monitors (`✓` all / `◐` some / `○`
499
499
  none); `⏎` toggles a provider across all of the site's checks at once.
@@ -520,7 +520,7 @@ id (Account Settings → Server Providers) and saves it — once per provider. T
520
520
  build fires `POST /servers` and tracks the ~10-minute provision in the background.
521
521
 
522
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
523
+ there's nothing to attach an SSH key to and no way for SpinupTUI to reach it — empty
524
524
  servers are flagged in **amber** in the Servers list. A busy server benefits from
525
525
  the same thing (a status page at its own hostname + a site user to hold your key),
526
526
  so `V` is offered on **any server that doesn't yet have a site at its own
@@ -546,11 +546,11 @@ shows where, with `r` to retry or `x` to discard.
546
546
  ## Privileged writes over SSH (sudo & SSH keys)
547
547
 
548
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
549
+ sudo users. SpinupTUI does them directly over SSH instead, which means these actions use
550
550
  **your own SSH access**, not the API token.
551
551
 
552
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
553
+ and that user's **sudo password** once. SpinupTUI validates them against the live server
554
554
  (`sudo -S -p '' true`) and then holds the password **in memory for the session only**
555
555
  — the username persists to config, the password is never written to plaintext config.
556
556
  A connected server shows a green `● sudo` badge on its row; `S` again disconnects.
@@ -568,7 +568,7 @@ A connected server shows a green `● sudo` badge on its row; `S` again disconne
568
568
  keys into the site user's `authorized_keys`:
569
569
 
570
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
571
+ and your ssh-agent, so you can SSH/SFTP as yourself) and/or SpinupTUI's dedicated
572
572
  **`spinup-tui` machine key** (an ed25519 identity generated once into the config dir,
573
573
  deliberately **never added to your SpinupWP account** so SpinupWP's key reconciliation
574
574
  can't clobber it). Your selection is remembered.
@@ -589,10 +589,11 @@ migration and **only repoints DNS when you say so**. It needs a **Read/Write tok
589
589
  over SSH). The steps:
590
590
 
591
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
592
+ `space` toggles). SpinupTUI sizes each one live (disk + database) into a payload total
593
593
  so you know what you're moving; a concurrency cap protects the busy source.
594
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.
595
+ `c` flow), or `d` to pick an existing server as the target — each listed with its
596
+ current site count, so you can see at a glance how busy a candidate already is.
596
597
  3. **Connect** — connect sudo on **both** servers. The clone is a server-to-server
597
598
  **pull**: the destination pulls each site directly from the source over SSH (no
598
599
  bytes routed through your laptop), authenticating with a key granted onto the source
package/bin/spinuptui.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "SpinupTUI — a terminal dashboard for browsing, monitoring, and managing your SpinupWP servers and sites.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  // that shape the Bedrock branch).
12
12
 
13
13
  import type { Server } from "../api/types.ts"
14
+ import { wpCliResolveScript } from "./wpCli.ts"
14
15
 
15
16
  const SSH_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-o", "StrictHostKeyChecking=accept-new"]
16
17
 
@@ -700,6 +701,7 @@ export interface VerifySpec {
700
701
  publicFolder?: string // configured public folder — fallback when the pull's rels are unknown
701
702
  sourceWebrootRel?: string // DETECTED source layout from the pull ("" = files root)
702
703
  destWebrootRel?: string // the CLONE's WP dir from the pull (post-normalization)
704
+ phpVersion?: string | null // the site's configured PHP version — see wpCli.ts
703
705
  }
704
706
 
705
707
  // One round-trip per side: collect labeled wp-cli facts as `key=value` lines.
@@ -707,10 +709,14 @@ export interface VerifySpec {
707
709
  // stalled outbound call once ate the whole budget and cut the facts off mid-script) —
708
710
  // and the flags are identical on both sides, so every count stays comparable. The
709
711
  // trailing `eof` sentinel is how verifyClone tells "read got cut off" from "differs".
710
- async function wpFacts(ctx: SudoCtx, root: string, user: string): Promise<Record<string, string>> {
712
+ // wp-cli is pinned to the site's OWN configured PHP-CLI (wpCliResolveScript) rather
713
+ // than bare `wp` — see wpCli.ts for why (a server's system-default PHP can silently
714
+ // differ from, and be missing extensions present in, the site's actual version).
715
+ async function wpFacts(ctx: SudoCtx, root: string, user: string, phpVersion?: string | null): Promise<Record<string, string>> {
711
716
  const script = [
712
717
  `cd ${shq(root)} 2>/dev/null || exit 0`,
713
- `u() { sudo -u ${shq(user)} -H wp --skip-plugins --skip-themes "$@" 2>/dev/null; }`,
718
+ wpCliResolveScript(phpVersion),
719
+ `u() { sudo -u ${shq(user)} -H "$PHP" "$WP" --skip-plugins --skip-themes "$@" 2>/dev/null; }`,
714
720
  `echo "core=$(u core version)"`,
715
721
  `echo "posts=$(u post list --post_type=any --format=count)"`,
716
722
  `echo "pages=$(u post list --post_type=page --format=count)"`,
@@ -753,8 +759,8 @@ export async function verifyClone(source: SudoCtx, dest: SudoCtx, spec: VerifySp
753
759
  const srcDir = dirFor(spec.sourceWebrootRel)
754
760
  const destDir = dirFor(spec.destWebrootRel)
755
761
  const [sf, cf, http] = await Promise.all([
756
- wpFacts(source, srcDir, spec.sourceSiteUser),
757
- wpFacts(dest, destDir, spec.destSiteUser),
762
+ wpFacts(source, srcDir, spec.sourceSiteUser, spec.phpVersion),
763
+ wpFacts(dest, destDir, spec.destSiteUser, spec.phpVersion),
758
764
  curlStatus(spec.domain, spec.destIp),
759
765
  ])
760
766
  // A missing tail sentinel means the facts script was cut off partway (timeout, a
@@ -764,6 +770,22 @@ export async function verifyClone(source: SudoCtx, dest: SudoCtx, spec: VerifySp
764
770
  const side = sf.eof !== "1" ? "source" : "clone"
765
771
  throw new Error(`couldn't read the ${side}'s WordPress facts (the wp-cli read was cut off) — press v to re-run`)
766
772
  }
773
+ // `core` succeeding (a file read, no DB) while EVERY database-backed fact comes
774
+ // back empty is a distinct signature from a real content difference (which shows
775
+ // differing NON-empty values, not blanks) — it means wp-cli's PHP-CLI couldn't
776
+ // reach the database on that side, commonly a missing `mysqli` extension for
777
+ // whatever PHP version it resolved to (see wpCli.ts). Surface that plainly
778
+ // instead of a wall of misleading ✕ rows implying the clone lost data.
779
+ const dbFields = ["posts", "pages", "users", "plugins", "siteurl", "home"] as const
780
+ const looksBroken = (f: Record<string, string>) => !!f.core && dbFields.every((k) => !f[k])
781
+ const srcBroken = looksBroken(sf)
782
+ const cloneBroken = looksBroken(cf)
783
+ if (srcBroken || cloneBroken) {
784
+ const side = srcBroken && cloneBroken ? "both the source and the clone" : srcBroken ? "the source" : "the clone"
785
+ throw new Error(
786
+ `wp-cli can't reach ${side}'s database over PHP (commonly a missing "mysqli" extension for whatever PHP-CLI it resolved to) — this looks like a server/PHP-CLI environment issue, not an actual data difference.`,
787
+ )
788
+ }
767
789
  const checks: VerifyCheck[] = []
768
790
  checks.push({ key: "http", label: "Clone serves (HTTP)", source: "—", clone: http, ok: /^[23]\d\d$/.test(http) })
769
791
  const cmp = (key: string, label: string) => {
package/src/lib/stack.ts CHANGED
@@ -49,6 +49,29 @@ export function effectiveStack(site: Site, probeKind?: ProbeKind | null): Stack
49
49
  }
50
50
  }
51
51
 
52
+ // Which pull the clone wizard runs for a site — the SAME effectiveStack a
53
+ // probe corrects elsewhere (Stacks tab, Browser), so an API-mislabeled site
54
+ // (is_wordpress=false but really WordPress — confirmed live against real
55
+ // client sites, 2026-07-08) gets a real database pull once probed, instead
56
+ // of silently defaulting to a files-only copy. git.repo still takes
57
+ // precedence for Bedrock detection (the pull needs the repo URL, which a
58
+ // probe alone can't supply) — is_wordpress is unreliable for git sites, so
59
+ // absent a probe (or a WP-family one) git.repo remains the best available
60
+ // signal. But git.repo alone only proves "deployed via git," not "is
61
+ // Bedrock" — a git-deployed STATIC site (confirmed live, 2026-07-09: a
62
+ // client's git-deployed static site, repo literally named
63
+ // "*-static-site") would otherwise be routed through a Bedrock pull
64
+ // (composer install, wp-cli) it can't survive. A CONCLUSIVE non-WordPress
65
+ // probe (whmcs/laravel/static) overrides git.repo; a probe saying
66
+ // "bedrock"/"wordpress", or no probe at all, still trusts git.repo.
67
+ export function cloneStackFor(site: Site, probeKind?: ProbeKind | null): "wp" | "bedrock" | "files" {
68
+ if (site.git?.repo) {
69
+ if (probeKind === "whmcs" || probeKind === "laravel" || probeKind === "static") return "files"
70
+ return "bedrock"
71
+ }
72
+ return effectiveStack(site, probeKind) === "Standard WP" ? "wp" : "files"
73
+ }
74
+
52
75
  // `onSelection` brightens the colors that read poorly on the focused
53
76
  // (bright-green) selection background (green-on-green Bedrock, the faint grey).
54
77
  export function stackColor(stack: Stack, onSelection = false): string {
@@ -0,0 +1,30 @@
1
+ // Resolve wp-cli through the SITE's own configured PHP-CLI, not the bare `wp`
2
+ // command. wp-cli's installed binary shebangs `#!/usr/bin/env php`, which
3
+ // resolves through PATH to the SERVER's system-DEFAULT PHP — which drifts
4
+ // toward whatever version was installed most recently, independent of any
5
+ // individual site's configured version, and can be missing extensions that
6
+ // version's CLI SAPI never got configured with.
7
+ //
8
+ // Verified live 2026-07-09 on web4.wenmarkdigital.com: the system default had
9
+ // drifted to 8.4, whose CLI lacked `mysqli` (its FPM pool likely never needed
10
+ // it — no site there runs 8.4), while lp.anchoredconstructiontn.com's actual
11
+ // configured version (8.1) has a fully working php8.1-cli. Bare `wp` silently
12
+ // ran under 8.4 and failed on anything touching $wpdb (post/user/plugin list,
13
+ // option get) — while `wp core version`, and `wp db export/import/check/size`
14
+ // (they shell out to native mysql client tools, not through mysqli) were
15
+ // unaffected. Confirmed the fix directly: `/usr/bin/php8.1 /usr/local/bin/wp
16
+ // post list ...` returned correct data where bare `wp` errored.
17
+ //
18
+ // Only applies to REMOTE (SSH) wp-cli calls against SpinupWP-managed servers,
19
+ // which have this specific multi-PHP-version-drift shape. Local dev-machine
20
+ // wp-cli (e.g. dbSync.ts's prod→local pull) is a different environment with
21
+ // its own PHP entirely and is not affected — don't apply this there.
22
+
23
+ // Emits POSIX shell that resolves $WP (the wp-cli binary) and $PHP (the
24
+ // version-pinned interpreter, falling back to the bare `php` on PATH if the
25
+ // versioned binary isn't installed — never worse than today's behavior).
26
+ // Callers invoke `"$PHP" "$WP" ...` in place of bare `wp ...`.
27
+ export function wpCliResolveScript(phpVersion?: string | null): string {
28
+ const phpBin = phpVersion ? `php${phpVersion}` : "php"
29
+ return [`WP=$(command -v wp 2>/dev/null || echo /usr/local/bin/wp)`, `PHP=$(command -v ${phpBin} 2>/dev/null || command -v php)`].join("\n")
30
+ }
@@ -11,6 +11,7 @@
11
11
  import type { Server, Site } from "../api/types.ts"
12
12
  import { SSH_OPTS, sshPort } from "./dbBackup.ts"
13
13
  import { detectWpDirScript } from "./serverClone.ts"
14
+ import { wpCliResolveScript } from "./wpCli.ts"
14
15
 
15
16
  // One plugin or theme, mirroring `wp {plugin,theme} list` columns.
16
17
  export interface WpItem {
@@ -100,13 +101,16 @@ export async function fetchWpInventory(
100
101
  const remote = [
101
102
  detectWpDirScript(root, site.public_folder ?? undefined),
102
103
  `if [ -z "$W" ]; then echo ===NOTWP; echo ===END; exit 0; fi`,
103
- `WP=$(command -v wp 2>/dev/null || echo /usr/local/bin/wp)`,
104
+ // Pinned to the site's OWN configured PHP-CLI — bare `wp` resolves through
105
+ // its shebang to the server's system-default PHP, which can differ from
106
+ // (and lack extensions present in) the site's actual version. See wpCli.ts.
107
+ wpCliResolveScript(site.php_version),
104
108
  `echo ===WPDIR; printf '%s\\n' "$W"`,
105
109
  // The trailing `echo` after each wp command is load-bearing: `wp --format=json`
106
110
  // emits NO trailing newline, so without it the next ===MARKER glues onto the end
107
111
  // of the JSON line — the marker never parses and JSON.parse chokes on it.
108
- `echo ===PLUGINS; "$WP" --path="$W" plugin list --fields=${fields} --format=json 2>/dev/null || true; echo`,
109
- `echo ===THEMES; "$WP" --path="$W" theme list --fields=${fields} --format=json 2>/dev/null || true; echo`,
112
+ `echo ===PLUGINS; "$PHP" "$WP" --path="$W" plugin list --fields=${fields} --format=json 2>/dev/null || true; echo`,
113
+ `echo ===THEMES; "$PHP" "$WP" --path="$W" theme list --fields=${fields} --format=json 2>/dev/null || true; echo`,
110
114
  `echo ===END`,
111
115
  ].join("\n")
112
116
 
package/src/ui/store.tsx CHANGED
@@ -17,7 +17,7 @@ import { APP_VERSION } from "../version.ts"
17
17
  import { cachedUpdateInfo, refreshUpdateInfo, type UpdateInfo } from "../lib/appUpdate.ts"
18
18
  import { fetchReleaseNotes, type ReleaseNotesInfo } from "../lib/releaseNotes.ts"
19
19
  import { resolveLocalLink, expandPath, normalizeLink, type LocalLink } from "../lib/local.ts"
20
- import type { Stack } from "../lib/stack.ts"
20
+ import { cloneStackFor, type Stack } from "../lib/stack.ts"
21
21
  import { openTerminalAt, openUrl, openSshSession } from "../lib/open.ts"
22
22
  import { gitDrift, type Drift } from "../lib/gitStatus.ts"
23
23
  import { probeSite } from "../lib/probe.ts"
@@ -344,12 +344,6 @@ export function cloneNeedsGitAccess(j: CloneJob): boolean {
344
344
  export function isCloneInFlight(j: CloneJob | null | undefined): boolean {
345
345
  return j != null && j.step !== "done" && j.step !== "error"
346
346
  }
347
- // Detect a site's stack the way the clone branches: a git repo → Bedrock
348
- // (is_wordpress is unreliable for git sites — see the API findings doc);
349
- // is_wordpress → Standard WP; anything else → files-only (no DB, no wp-cli).
350
- export function cloneStackFor(site: Site): "wp" | "bedrock" | "files" {
351
- return site.git?.repo ? "bedrock" : site.is_wordpress ? "wp" : "files"
352
- }
353
347
  // Alphanumeric token for generated dest DB passwords (never printed/persisted).
354
348
  function randomToken(n: number): string {
355
349
  const a = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
@@ -3335,8 +3329,11 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3335
3329
  }
3336
3330
  const srcSites = sitesForServer(server.id)
3337
3331
  const sites: CloneSiteState[] = srcSites.map((s) => {
3338
- const stack = cloneStackFor(s)
3339
- const isWordPress = s.is_wordpress
3332
+ // Probe-corrected: an API-mislabeled site (is_wordpress=false but really
3333
+ // WordPress) gets a real database pull once probed (`d`), instead of
3334
+ // silently defaulting to a files-only copy — see cloneStackFor's comment.
3335
+ const stack = cloneStackFor(s, probes.get(s.id)?.result.kind)
3336
+ const isWordPress = stack !== "files"
3340
3337
  return {
3341
3338
  sourceSiteId: s.id,
3342
3339
  domain: s.domain,
@@ -3371,7 +3368,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3371
3368
  setCloneServer(server)
3372
3369
  setCloneJob(draft)
3373
3370
  },
3374
- [cloneJob, sitesForServer, servers],
3371
+ [cloneJob, sitesForServer, servers, probes],
3375
3372
  )
3376
3373
 
3377
3374
  // Background an in-flight clone: hide the wizard but KEEP the job running (the
@@ -3770,7 +3767,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3770
3767
  try {
3771
3768
  const res = site.stack === "files"
3772
3769
  ? await verifyFilesClone(source, dest, { domain: site.domain, destIp })
3773
- : await verifyClone(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, destIp, publicFolder: site.stack === "wp" ? site.publicFolder : undefined, sourceWebrootRel: site.stack === "wp" ? site.sourceWebrootRel : undefined, destWebrootRel: site.stack === "wp" ? site.destWebrootRel : undefined })
3770
+ : await verifyClone(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, destIp, publicFolder: site.stack === "wp" ? site.publicFolder : undefined, sourceWebrootRel: site.stack === "wp" ? site.sourceWebrootRel : undefined, destWebrootRel: site.stack === "wp" ? site.destWebrootRel : undefined, phpVersion: site.phpVersion })
3774
3771
  setCloneSite(siteId, (s) => ({ ...s, verifying: false, verify: res }))
3775
3772
  } catch (err) {
3776
3773
  setCloneSite(siteId, (s) => ({ ...s, verifying: false, verifyError: (err as Error).message }))
@@ -48,6 +48,7 @@ export function CloneWizard() {
48
48
  cloneServer: server,
49
49
  cloneJob: job,
50
50
  servers,
51
+ sitesForServer,
51
52
  accountSlug,
52
53
  toggleCloneSite,
53
54
  toggleCloneSiteUploads,
@@ -473,7 +474,8 @@ export function CloneWizard() {
473
474
  <box style={{ height: 1 }} />
474
475
  {eligibleDests.map((s, i) => {
475
476
  const sel = i === destIdx
476
- const meta = `${s.provider_name || "—"} · ${s.region || "—"}${s.ip_address ? " " + s.ip_address : ""}`
477
+ const n = sitesForServer(s.id).length
478
+ const meta = `${n} site${n === 1 ? "" : "s"} · ${s.provider_name || "—"} · ${s.region || "—"}${s.ip_address ? " " + s.ip_address : ""}`
477
479
  return (
478
480
  <box key={s.id} style={{ flexDirection: "row", height: 1, backgroundColor: sel ? theme.bgAlt : undefined }}>
479
481
  <text content={sel ? "❯ " : " "} fg={theme.brand} style={{ flexShrink: 0 }} />