spinuptui 0.19.1 → 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,56 @@ 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
+
14
64
  ## [0.19.1] - 2026-07-08
15
65
 
16
66
  ### Fixed
@@ -929,7 +979,8 @@ Initial tagged release.
929
979
  ### Notes
930
980
  - Read-only release: works with a SpinupWP **Read Only** API token.
931
981
 
932
- [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.19.1...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
933
984
  [0.19.1]: https://github.com/mwender/spinupwp-tui/compare/v0.19.0...v0.19.1
934
985
  [0.19.0]: https://github.com/mwender/spinupwp-tui/compare/v0.18.0...v0.19.0
935
986
  [0.18.0]: https://github.com/mwender/spinupwp-tui/compare/v0.17.0...v0.18.0
package/README.md CHANGED
@@ -592,7 +592,8 @@ over SSH). The steps:
592
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "0.19.1",
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 }} />