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 +64 -1
- package/README.md +17 -16
- package/bin/spinuptui.js +0 -0
- package/package.json +1 -1
- package/src/lib/serverClone.ts +26 -4
- package/src/lib/stack.ts +23 -0
- package/src/lib/wpCli.ts +30 -0
- package/src/lib/wpInventory.ts +7 -3
- package/src/ui/store.tsx +8 -11
- package/src/ui/views/CloneWizard.tsx +3 -1
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.
|
|
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="
|
|
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:
|
|
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
|
|
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
|
|
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**:
|
|
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**:
|
|
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
|
|
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;
|
|
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.**
|
|
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.**
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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).
|
|
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
package/src/lib/serverClone.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/lib/wpCli.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/wpInventory.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
3339
|
-
|
|
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
|
|
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 }} />
|