spinuptui 0.26.1 → 0.27.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 +14 -1
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/api/client.ts +38 -2
- package/src/lib/probe.ts +16 -0
- package/src/lib/wpCore.ts +273 -0
- package/src/ui/App.tsx +8 -2
- package/src/ui/Explain.tsx +5 -4
- package/src/ui/Header.tsx +1 -1
- package/src/ui/Help.tsx +1 -1
- package/src/ui/ToastStack.tsx +2 -2
- package/src/ui/components.tsx +1 -1
- package/src/ui/store.tsx +24 -1
- package/src/ui/toast.ts +16 -10
- package/src/ui/views/Browser.tsx +2 -0
- package/src/ui/views/BulkWpUpdate.tsx +396 -0
- package/src/ui/views/PhpUpgrade.tsx +5 -2
- package/src/ui/views/Search.tsx +2 -0
- package/src/ui/views/SiteUpdate.tsx +124 -0
- package/src/ui/views/Stacks.tsx +83 -10
- package/src/ui/views/WpCoreUpdate.tsx +322 -0
- package/src/ui/wpCoreJobs.tsx +235 -0
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,18 @@ versions; such changes are called out here.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [0.27.0] - 2026-09-23
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **Update WordPress core from `u`.** `u` is now **Update**, a menu with two choices: PHP version, the flow you already know, and WordPress core. The WordPress option checks the site over SSH with wp-cli and lists the available versions, with minor (security) releases picked first. After you confirm, it runs `wp core update` and then the database upgrade. The update keeps running if you close the overlay, and a toast reports when it's done. Bedrock and Radicle sites are included; for those, the confirm screen names the version `composer.lock` pins, and warns that the next `composer install` will put that version back until you run `composer update`.
|
|
18
|
+
- **Stacks groups WordPress sites by core version.** Under Standard WP, Bedrock and Radicle, one indented row per WordPress version, newest first, shows how many sites run it (for example `└ WP 7.0.2 51`). The newest version in the fleet is highlighted green. Select a row to list its sites, which makes it easy to find the ones still waiting for a security release. `D` on a version row re-probes just those sites. Sites with no known version are grouped under `no version`, and the left pane now scrolls when the list is taller than the terminal.
|
|
19
|
+
- **Update WordPress on a whole version group.** In Stacks, `u` on a version row (`└ WP 7.0.2`) checks every site in that group live over SSH, then shows a plan you can edit. The default target is the newest release in each site's own line (the security hotfix), and `m` switches to the newest release overall. `space` leaves a site out, and Bedrock and Radicle sites are marked `⚙` because of the `composer.lock` caveat. After you confirm, a canary site updates alone first, and if it fails nothing else is touched. The rest follow up to 4 at a time, one per server, stopping after 3 failures; `x` stops starting new sites. It keeps running if you close the overlay, and a single toast reports the result.
|
|
20
|
+
- **Update progress shows on the site's row.** While a WordPress core update runs, the Stacks row replaces the version with a spinner and the target version (`⠹ → WP 7.1.2`). Servers and Search show a compact `WP` marker. If the update fails, the row keeps a `⬆!` marker until you open the result, and the toast that reports the failure is red instead of green.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **The WordPress version on a site's row now follows the Update check.** Stacks and Servers show the version from the last `d` probe, so it went stale as soon as core changed. Opening **Update → WordPress core** reads the live version over SSH, and that value, along with the result of any update, now replaces the stored one.
|
|
24
|
+
- **SpinupWP API errors now say why.** A rejected write used to show only "SpinupWP API error (HTTP 400)." The message now includes the reason from the API's response, such as which field it rejected.
|
|
25
|
+
|
|
14
26
|
## [0.26.1] - 2026-09-18
|
|
15
27
|
|
|
16
28
|
### Fixed
|
|
@@ -1415,7 +1427,8 @@ Initial tagged release.
|
|
|
1415
1427
|
### Notes
|
|
1416
1428
|
- Read-only release: works with a SpinupWP **Read Only** API token.
|
|
1417
1429
|
|
|
1418
|
-
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.
|
|
1430
|
+
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.27.0...HEAD
|
|
1431
|
+
[0.27.0]: https://github.com/mwender/spinupwp-tui/compare/v0.26.1...v0.27.0
|
|
1419
1432
|
[0.26.1]: https://github.com/mwender/spinupwp-tui/compare/v0.26.0...v0.26.1
|
|
1420
1433
|
[0.26.0]: https://github.com/mwender/spinupwp-tui/compare/v0.25.0...v0.26.0
|
|
1421
1434
|
[0.25.0]: https://github.com/mwender/spinupwp-tui/compare/v0.24.4...v0.25.0
|
package/README.md
CHANGED
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
pull a production DB into a linked local copy. → [docs/database-backup-sync.md](docs/database-backup-sync.md)
|
|
54
54
|
- **Production media fallback** (`m`, Servers / Search tabs) — serve missing-locally images
|
|
55
55
|
from production after a DB pull. → [docs/production-media-fallback.md](docs/production-media-fallback.md)
|
|
56
|
-
- **
|
|
57
|
-
|
|
56
|
+
- **Update a site** (`u`) — change its PHP version, or update WordPress core over
|
|
57
|
+
SSH (Standard WP, Bedrock and Radicle). → [docs/php-upgrade.md](docs/php-upgrade.md)
|
|
58
58
|
- **Enable / disable HTTPS** (`H`) — toggle a site's certificate.
|
|
59
59
|
- **Purge cache** (`P`) — clear page + object cache together.
|
|
60
60
|
- **Server actions** (`a`) — reboot or restart a service, with the real reason a
|
|
@@ -320,7 +320,7 @@ These can be set in `config.json` or via an environment variable:
|
|
|
320
320
|
| `D` | Identify every site in the selected stack (Stacks tab) |
|
|
321
321
|
| `S` | Auto-discover & batch-link local copies (Stacks tab) |
|
|
322
322
|
| `f` | Report sites with no usable local copy (Stacks tab only — `f` identifies a site's stack in Servers/Search, above) |
|
|
323
|
-
| `u` |
|
|
323
|
+
| `u` | Update a site: PHP version (needs a Read/Write token) or WordPress core (over SSH) (Servers / Stacks / Search) |
|
|
324
324
|
| `H` | Enable / disable HTTPS on a site (Servers / Stacks / Search; needs a Read/Write token) |
|
|
325
325
|
| `P` | Purge a site's page cache + object cache (Servers / Stacks / Search; needs a Read/Write token) |
|
|
326
326
|
| `M` | Site/server monitoring — a two-pane browser of this site's Uptime Kuma monitors (capital, since lowercase `m` is media fallback in Servers/Search). Inside: `↑`/`↓` selects a monitor, `a` registers/recalibrates/repairs it (Front page and Cache bypass open a check-window picker), `x` removes Front page or Cache bypass, `o` opens the selected monitor in Kuma, `d` runs the site doctor, `n` shows/edits alert wiring, and vanity sites add `R`/`r` for page refresh/secret rotation (Servers / Stacks / Search) |
|
package/package.json
CHANGED
package/src/api/client.ts
CHANGED
|
@@ -31,6 +31,42 @@ export class ApiError extends Error {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// Pull the human-readable reason out of a SpinupWP error body. The API answers
|
|
35
|
+
// validation failures Laravel-style — {"message": "...", "errors": {field: [..]}}
|
|
36
|
+
// — and a bare "HTTP 400" hides exactly the part the user needs (which field,
|
|
37
|
+
// and why). Returns "" when the body has nothing usable, so callers can fall
|
|
38
|
+
// back to the status-only message.
|
|
39
|
+
export function apiErrorDetail(body: string | undefined): string {
|
|
40
|
+
const text = (body ?? "").trim()
|
|
41
|
+
if (!text) return ""
|
|
42
|
+
let json: unknown
|
|
43
|
+
try {
|
|
44
|
+
json = JSON.parse(text)
|
|
45
|
+
} catch {
|
|
46
|
+
// Not JSON (an HTML error page, say) — a short plain-text body is still worth
|
|
47
|
+
// showing; anything long is noise.
|
|
48
|
+
return text.length <= 200 && !text.startsWith("<") ? text : ""
|
|
49
|
+
}
|
|
50
|
+
if (!json || typeof json !== "object") return ""
|
|
51
|
+
const j = json as { message?: unknown; error?: unknown; errors?: unknown }
|
|
52
|
+
const parts: string[] = []
|
|
53
|
+
const message = typeof j.message === "string" ? j.message : typeof j.error === "string" ? j.error : ""
|
|
54
|
+
if (j.errors && typeof j.errors === "object") {
|
|
55
|
+
for (const v of Object.values(j.errors as Record<string, unknown>)) {
|
|
56
|
+
for (const m of Array.isArray(v) ? v : [v]) if (typeof m === "string" && !parts.includes(m)) parts.push(m)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Laravel's top-level message usually just repeats the first field error
|
|
60
|
+
// ("The php version is invalid. (and 1 more error)") — prefer the field list.
|
|
61
|
+
if (parts.length) return parts.join(" ")
|
|
62
|
+
return message
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function httpErrorMessage(status: number, body: string): string {
|
|
66
|
+
const detail = apiErrorDetail(body)
|
|
67
|
+
return detail ? `SpinupWP API error (HTTP ${status}): ${detail}` : `SpinupWP API error (HTTP ${status}).`
|
|
68
|
+
}
|
|
69
|
+
|
|
34
70
|
export interface ClientOptions {
|
|
35
71
|
token: string
|
|
36
72
|
baseUrl: string
|
|
@@ -152,7 +188,7 @@ export class SpinupWPClient implements SpinupWPClientLike {
|
|
|
152
188
|
}
|
|
153
189
|
if (!res.ok) {
|
|
154
190
|
const body = await res.text().catch(() => "")
|
|
155
|
-
throw new ApiError(
|
|
191
|
+
throw new ApiError(httpErrorMessage(res.status, body), res.status, body)
|
|
156
192
|
}
|
|
157
193
|
|
|
158
194
|
return (await res.json()) as T
|
|
@@ -189,7 +225,7 @@ export class SpinupWPClient implements SpinupWPClientLike {
|
|
|
189
225
|
}
|
|
190
226
|
if (!res.ok) {
|
|
191
227
|
const errBody = await res.text().catch(() => "")
|
|
192
|
-
throw new ApiError(
|
|
228
|
+
throw new ApiError(httpErrorMessage(res.status, errBody), res.status, errBody)
|
|
193
229
|
}
|
|
194
230
|
|
|
195
231
|
// Some write endpoints (e.g. PHP upgrade) return a bare body, others 204.
|
package/src/lib/probe.ts
CHANGED
|
@@ -167,6 +167,22 @@ function parseSections(out: string): Record<string, string> {
|
|
|
167
167
|
return flat
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
// The same probe result with a newly learned WordPress core version, relabeled the
|
|
171
|
+
// way classify() labels it. Returns null for kinds that don't carry a WP version
|
|
172
|
+
// (a WHMCS/Laravel/static site's cached identity must never be rewritten).
|
|
173
|
+
export function withWpVersion(r: ProbeResult, version: string): ProbeResult | null {
|
|
174
|
+
switch (r.kind) {
|
|
175
|
+
case "wordpress":
|
|
176
|
+
return { ...r, version, label: `WordPress ${version}` }
|
|
177
|
+
case "bedrock":
|
|
178
|
+
return { ...r, version, label: `Bedrock · WP ${version}` }
|
|
179
|
+
case "radicle":
|
|
180
|
+
return { ...r, version, label: `Radicle · WP ${version}` }
|
|
181
|
+
default:
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
170
186
|
// Turn raw signals into a named stack. Order matters: name the distinctive apps
|
|
171
187
|
// (WHMCS, Bedrock, Laravel) before falling back to a generic WordPress version.
|
|
172
188
|
export function classify(sig: Record<string, string>): ProbeResult {
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// WordPress core updates over SSH — `wp core check-update` / `wp core update`,
|
|
2
|
+
// run as the site user in the site's real WordPress directory. The SpinupWP API
|
|
3
|
+
// has no core-update endpoint (it only reports a `wp_core_update` flag), so this
|
|
4
|
+
// is the same key-based SSH path the Plugins & themes view reads through.
|
|
5
|
+
//
|
|
6
|
+
// The WP dir is DETECTED, never assumed (see CLAUDE.md "WordPress layout rules"):
|
|
7
|
+
// detectWpDirScript finds core on /public/, root-webroot, and Bedrock (web/wp)
|
|
8
|
+
// layouts alike.
|
|
9
|
+
//
|
|
10
|
+
// Composer-managed core (Bedrock, Radicle) is allowed, deliberately: for a
|
|
11
|
+
// security hotfix, getting the patched files onto the server NOW matters more
|
|
12
|
+
// than keeping composer.lock in step. But it's a real divergence — the lock
|
|
13
|
+
// still pins the old version, so the next `composer install` (a git deploy, a
|
|
14
|
+
// clone) silently reverts the patch. The check reports the locked version so
|
|
15
|
+
// the UI can say exactly that before anything runs.
|
|
16
|
+
|
|
17
|
+
import type { Server, Site } from "../api/types.ts"
|
|
18
|
+
import { SSH_OPTS, sshPort } from "./dbBackup.ts"
|
|
19
|
+
import { detectWpDirScript } from "./serverClone.ts"
|
|
20
|
+
import { wpCliResolveScript } from "./wpCli.ts"
|
|
21
|
+
import { spawn } from "./spawn.ts"
|
|
22
|
+
|
|
23
|
+
export interface WpCoreOffer {
|
|
24
|
+
version: string
|
|
25
|
+
updateType: string // "minor" | "major" (wp-cli's update_type)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WpCoreStatus {
|
|
29
|
+
wpDir: string
|
|
30
|
+
current: string
|
|
31
|
+
offers: WpCoreOffer[] // newest first, as wp-cli lists them; [] = up to date
|
|
32
|
+
multisite: boolean
|
|
33
|
+
// Set when core is installed by Composer (roots/wordpress, johnpbloch/wordpress…):
|
|
34
|
+
// the version composer.lock pins, which a later `composer install` restores.
|
|
35
|
+
composer: { lockedVersion: string | null } | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type WpCoreCheckResult =
|
|
39
|
+
| { ok: true; target: string; status: WpCoreStatus }
|
|
40
|
+
| { ok: false; target: string; error: string }
|
|
41
|
+
|
|
42
|
+
export type WpCoreUpdateResult =
|
|
43
|
+
| { ok: true; before: string; after: string; log: string }
|
|
44
|
+
| { ok: false; error: string; log: string }
|
|
45
|
+
|
|
46
|
+
// Numeric, segment-wise version compare ("7.0" < "7.0.2" < "7.1"); non-numeric
|
|
47
|
+
// tails are ignored, which is fine for WordPress release numbers.
|
|
48
|
+
export function compareVersions(a: string, b: string): number {
|
|
49
|
+
const pa = a.split(".").map((n) => parseInt(n, 10) || 0)
|
|
50
|
+
const pb = b.split(".").map((n) => parseInt(n, 10) || 0)
|
|
51
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
52
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0)
|
|
53
|
+
if (d !== 0) return d
|
|
54
|
+
}
|
|
55
|
+
return 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The two targets a bulk update can aim for. "line" = the newest release in the
|
|
59
|
+
// site's own major.minor line (7.0.2 → 7.0.x): WordPress backports security
|
|
60
|
+
// fixes there, so it's the low-risk hotfix. "newest" = the newest release,
|
|
61
|
+
// period (a major update for anyone on an older line). null = nothing to do.
|
|
62
|
+
export type WpTargetMode = "line" | "newest"
|
|
63
|
+
|
|
64
|
+
export function wpTarget(current: string, offers: WpCoreOffer[], mode: WpTargetMode): string | null {
|
|
65
|
+
const line = (v: string) => v.split(".").slice(0, 2).join(".")
|
|
66
|
+
const pool = mode === "line" ? offers.filter((o) => line(o.version) === line(current)) : offers
|
|
67
|
+
let best: string | null = null
|
|
68
|
+
for (const o of pool) if (compareVersions(o.version, current) > 0 && (!best || compareVersions(o.version, best) > 0)) best = o.version
|
|
69
|
+
return best
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Composer packages that install WordPress core itself.
|
|
73
|
+
const CORE_PACKAGES = ["roots/wordpress", "roots/wordpress-no-content", "roots/wordpress-full", "johnpbloch/wordpress", "johnpbloch/wordpress-core"]
|
|
74
|
+
|
|
75
|
+
interface Prepared {
|
|
76
|
+
target: string
|
|
77
|
+
args: string[]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function prepare(server: Server, site: Site, sshUser: string | null): Prepared | { error: string; target: string } {
|
|
81
|
+
const ip = server.ip_address
|
|
82
|
+
const user = site.site_user ?? sshUser
|
|
83
|
+
if (!ip) return { target: "(no IP)", error: "Server has no IP address." }
|
|
84
|
+
if (!user) return { target: ip, error: "Site has no site user — can't SSH in to run wp-cli." }
|
|
85
|
+
const target = `${user}@${ip}`
|
|
86
|
+
return { target, args: ["ssh", ...SSH_OPTS, ...sshPort(server.ssh_port ?? null), target] }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Shared preamble: locate core (sets $W, bails with ===NOTWP when there's none),
|
|
90
|
+
// then resolve $PHP/$WP pinned to the site's own PHP-CLI (see wpCli.ts).
|
|
91
|
+
function preamble(site: Site): string {
|
|
92
|
+
const root = `/sites/${site.domain}/files`
|
|
93
|
+
return [
|
|
94
|
+
detectWpDirScript(root, site.public_folder ?? undefined),
|
|
95
|
+
`if [ -z "$W" ]; then echo ===NOTWP; echo ===END; exit 0; fi`,
|
|
96
|
+
wpCliResolveScript(site.php_version),
|
|
97
|
+
// Every core command runs with plugins and themes unloaded: none of them need
|
|
98
|
+
// either, and a plugin's PHP notices land on STDOUT under the CLI SAPI —
|
|
99
|
+
// verified live on an Elementor Pro site under PHP 8.4, where a wall of
|
|
100
|
+
// "Implicitly marking parameter … as nullable is deprecated" swallowed the
|
|
101
|
+
// check-update JSON and the site read as up to date while two versions behind.
|
|
102
|
+
// It also keeps a fataling plugin from blocking the core update that fixes it.
|
|
103
|
+
`wpc() { "$PHP" "$WP" --path="$W" --skip-plugins --skip-themes "$@"; }`,
|
|
104
|
+
].join("\n")
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function run(args: string[], remote: string, timeoutMs: number): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
108
|
+
const proc = spawn([...args, remote], { stdout: "pipe", stderr: "pipe", stdin: "ignore" })
|
|
109
|
+
const timer = setTimeout(() => {
|
|
110
|
+
try {
|
|
111
|
+
proc.kill()
|
|
112
|
+
} catch {
|
|
113
|
+
/* already gone */
|
|
114
|
+
}
|
|
115
|
+
}, timeoutMs)
|
|
116
|
+
const code = await proc.exited
|
|
117
|
+
clearTimeout(timer)
|
|
118
|
+
const stdout = await new Response(proc.stdout as ReadableStream<Uint8Array>).text()
|
|
119
|
+
const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text()
|
|
120
|
+
return { code, stdout, stderr }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function splitSections(out: string): Record<string, string[]> {
|
|
124
|
+
const sections: Record<string, string[]> = {}
|
|
125
|
+
let current = ""
|
|
126
|
+
for (const rawLine of out.split("\n")) {
|
|
127
|
+
const line = rawLine.replace(/\r$/, "")
|
|
128
|
+
const m = line.match(/^===([A-Z]+)$/)
|
|
129
|
+
if (m) {
|
|
130
|
+
current = m[1]
|
|
131
|
+
sections[current] = []
|
|
132
|
+
} else if (current) {
|
|
133
|
+
sections[current].push(line)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return sections
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function sshFailure(code: number, stderr: string): string {
|
|
140
|
+
return stderr.trim().split("\n").slice(-2).join(" ") || `ssh exited with code ${code}`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Read the installed version, available updates, multisite-ness, and whether
|
|
144
|
+
// Composer owns core. Strictly read-only.
|
|
145
|
+
export async function checkWpCore(server: Server, site: Site, sshUser: string | null): Promise<WpCoreCheckResult> {
|
|
146
|
+
const p = prepare(server, site, sshUser)
|
|
147
|
+
if ("error" in p) return { ok: false, target: p.target, error: p.error }
|
|
148
|
+
|
|
149
|
+
// composer.lock sits at the project root: $W/../.. for Bedrock (web/wp),
|
|
150
|
+
// $W/.. for a public/ layout, or $W itself. The first lock that lists a core
|
|
151
|
+
// package wins; PHP parses it (jq isn't guaranteed on the box).
|
|
152
|
+
const pkgs = CORE_PACKAGES.map((n) => `'${n}'`).join(",")
|
|
153
|
+
const lockPhp = `$l=json_decode(file_get_contents($argv[1]),true);foreach(array_merge($l["packages"]??[],$l["packages-dev"]??[]) as $p){if(in_array($p["name"],[${pkgs}],true)){echo $p["version"];exit(0);}}exit(1);`
|
|
154
|
+
const remote = [
|
|
155
|
+
preamble(site),
|
|
156
|
+
`echo ===WPDIR; printf '%s\\n' "$W"`,
|
|
157
|
+
// Trailing `echo`s are load-bearing: wp's JSON/plain output has no trailing
|
|
158
|
+
// newline, so the next ===MARKER would glue onto it (see wpInventory.ts).
|
|
159
|
+
`echo ===VERSION; wpc core version 2>/dev/null || true; echo`,
|
|
160
|
+
`echo ===UPDATES; wpc core check-update --format=json 2>/dev/null || true; echo`,
|
|
161
|
+
`echo ===MULTISITE; wpc core is-installed --network >/dev/null 2>&1 && echo yes || echo no`,
|
|
162
|
+
`echo ===COMPOSER`,
|
|
163
|
+
`for d in "$W/../.." "$W/.." "$W"; do if [ -f "$d/composer.lock" ]; then v=$("$PHP" -r ${shq(lockPhp)} "$d/composer.lock" 2>/dev/null) && { echo "locked $v"; break; }; fi; if [ -f "$d/composer.json" ] && grep -qE '"(${CORE_PACKAGES.join("|")})"' "$d/composer.json"; then echo "managed"; break; fi; done`,
|
|
164
|
+
`echo ===END`,
|
|
165
|
+
].join("\n")
|
|
166
|
+
|
|
167
|
+
let res: Awaited<ReturnType<typeof run>>
|
|
168
|
+
try {
|
|
169
|
+
res = await run(p.args, remote, 60_000)
|
|
170
|
+
} catch (err) {
|
|
171
|
+
return { ok: false, target: p.target, error: `Failed to launch ssh: ${(err as Error).message}` }
|
|
172
|
+
}
|
|
173
|
+
if (res.code !== 0 && !res.stdout.includes("===END")) return { ok: false, target: p.target, error: sshFailure(res.code, res.stderr) }
|
|
174
|
+
|
|
175
|
+
const s = splitSections(res.stdout)
|
|
176
|
+
if ("NOTWP" in s) return { ok: false, target: p.target, error: "No WordPress core found under the site's files — is this a WordPress site?" }
|
|
177
|
+
const current = lastVersionLine(s.VERSION)
|
|
178
|
+
if (!current) return { ok: false, target: p.target, error: "wp-cli couldn't read the WordPress version on this site." }
|
|
179
|
+
|
|
180
|
+
// Belt and braces with --skip-plugins: scan back for the line that parses as
|
|
181
|
+
// the JSON array, ignoring any other noise (a mu-plugin's notices still load).
|
|
182
|
+
// No match = "Success: WordPress is at the latest version." (plain text).
|
|
183
|
+
let offers: WpCoreOffer[] = []
|
|
184
|
+
for (const line of [...(s.UPDATES ?? [])].reverse()) {
|
|
185
|
+
if (!line.trim().startsWith("[")) continue
|
|
186
|
+
let parsed: unknown
|
|
187
|
+
try {
|
|
188
|
+
parsed = JSON.parse(line.trim())
|
|
189
|
+
} catch {
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
if (!Array.isArray(parsed)) continue
|
|
193
|
+
offers = parsed
|
|
194
|
+
.filter((o) => o && typeof o.version === "string")
|
|
195
|
+
.map((o) => ({ version: o.version as string, updateType: typeof o.update_type === "string" ? o.update_type : "" }))
|
|
196
|
+
break
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const comp = (s.COMPOSER ?? []).join("\n").trim()
|
|
200
|
+
const locked = comp.match(/^locked (.+)$/m)
|
|
201
|
+
const composer = locked ? { lockedVersion: locked[1].replace(/^v/, "") } : comp.includes("managed") ? { lockedVersion: null } : null
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
ok: true,
|
|
205
|
+
target: p.target,
|
|
206
|
+
status: {
|
|
207
|
+
wpDir: (s.WPDIR?.[0] || "").trim(),
|
|
208
|
+
current,
|
|
209
|
+
offers,
|
|
210
|
+
multisite: (s.MULTISITE ?? []).join("").trim() === "yes",
|
|
211
|
+
composer,
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Update core to `version`, then run the database upgrade (network-wide on
|
|
217
|
+
// multisite) and read the version back. If the update dies mid-way, WordPress
|
|
218
|
+
// leaves a .maintenance file that keeps the site on "Briefly unavailable" for
|
|
219
|
+
// up to 10 minutes — we remove it so a failure doesn't turn into downtime.
|
|
220
|
+
export async function updateWpCore(
|
|
221
|
+
server: Server,
|
|
222
|
+
site: Site,
|
|
223
|
+
sshUser: string | null,
|
|
224
|
+
version: string,
|
|
225
|
+
multisite: boolean,
|
|
226
|
+
): Promise<WpCoreUpdateResult> {
|
|
227
|
+
const p = prepare(server, site, sshUser)
|
|
228
|
+
if ("error" in p) return { ok: false, error: p.error, log: "" }
|
|
229
|
+
if (!/^[0-9][0-9A-Za-z.-]*$/.test(version)) return { ok: false, error: `Refusing odd version string "${version}".`, log: "" }
|
|
230
|
+
|
|
231
|
+
const remote = [
|
|
232
|
+
preamble(site),
|
|
233
|
+
`echo ===BEFORE; wpc core version 2>/dev/null; echo`,
|
|
234
|
+
`echo ===LOG`,
|
|
235
|
+
`wpc core update --version=${version} 2>&1; rc=$?`,
|
|
236
|
+
`if [ $rc -eq 0 ]; then wpc core update-db${multisite ? " --network" : ""} 2>&1 || rc=$?; fi`,
|
|
237
|
+
`[ -f "$W/.maintenance" ] && rm -f "$W/.maintenance" && echo "Removed a leftover .maintenance file."`,
|
|
238
|
+
`echo; echo ===RC; echo $rc`,
|
|
239
|
+
`echo ===AFTER; wpc core version 2>/dev/null; echo`,
|
|
240
|
+
`echo ===END`,
|
|
241
|
+
].join("\n")
|
|
242
|
+
|
|
243
|
+
let res: Awaited<ReturnType<typeof run>>
|
|
244
|
+
try {
|
|
245
|
+
res = await run(p.args, remote, 300_000)
|
|
246
|
+
} catch (err) {
|
|
247
|
+
return { ok: false, error: `Failed to launch ssh: ${(err as Error).message}`, log: "" }
|
|
248
|
+
}
|
|
249
|
+
if (!res.stdout.includes("===END")) {
|
|
250
|
+
return { ok: false, error: res.code === 0 ? "The update didn't finish (timed out?)." : sshFailure(res.code, res.stderr), log: res.stdout.trim() }
|
|
251
|
+
}
|
|
252
|
+
const s = splitSections(res.stdout)
|
|
253
|
+
if ("NOTWP" in s) return { ok: false, error: "No WordPress core found under the site's files.", log: "" }
|
|
254
|
+
const log = (s.LOG ?? []).join("\n").trim()
|
|
255
|
+
const rc = Number((s.RC ?? []).join("").trim())
|
|
256
|
+
const before = lastVersionLine(s.BEFORE)
|
|
257
|
+
const after = lastVersionLine(s.AFTER)
|
|
258
|
+
if (rc !== 0) {
|
|
259
|
+
const lastErr = log.split("\n").filter((l) => /error|warning/i.test(l)).pop()
|
|
260
|
+
return { ok: false, error: lastErr ?? `wp core update exited with code ${rc}.`, log }
|
|
261
|
+
}
|
|
262
|
+
if (after !== version) return { ok: false, error: `wp-cli reported success, but the site now reports ${after || "no version"} (wanted ${version}).`, log }
|
|
263
|
+
return { ok: true, before, after, log }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// `wp core version` prints a bare version; pick that line out of any noise.
|
|
267
|
+
function lastVersionLine(lines: string[] | undefined): string {
|
|
268
|
+
return [...(lines ?? [])].reverse().map((l) => l.trim()).find((l) => /^\d+\.\d+(\.\d+)?([-.][0-9A-Za-z.-]+)?$/.test(l)) ?? ""
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function shq(s: string): string {
|
|
272
|
+
return `'${s.replace(/'/g, `'\\''`)}'`
|
|
273
|
+
}
|
package/src/ui/App.tsx
CHANGED
|
@@ -15,7 +15,8 @@ import { Stacks } from "./views/Stacks.tsx"
|
|
|
15
15
|
import { Search } from "./views/Search.tsx"
|
|
16
16
|
import { Events } from "./views/Events.tsx"
|
|
17
17
|
import { Health } from "./views/Health.tsx"
|
|
18
|
-
import {
|
|
18
|
+
import { SiteUpdate } from "./views/SiteUpdate.tsx"
|
|
19
|
+
import { BulkWpUpdate } from "./views/BulkWpUpdate.tsx"
|
|
19
20
|
import { HttpsToggle } from "./views/HttpsToggle.tsx"
|
|
20
21
|
import { PurgeCache } from "./views/PurgeCache.tsx"
|
|
21
22
|
import { ReleaseNotes } from "./views/ReleaseNotes.tsx"
|
|
@@ -63,6 +64,7 @@ export function App() {
|
|
|
63
64
|
store.healthServer !== null ||
|
|
64
65
|
store.wpInventorySite !== null ||
|
|
65
66
|
store.phpUpgradeSite !== null ||
|
|
67
|
+
store.bulkWpGroup !== null ||
|
|
66
68
|
store.httpsToggleSite !== null ||
|
|
67
69
|
store.purgeCacheSite !== null ||
|
|
68
70
|
store.grantKeySite !== null ||
|
|
@@ -129,6 +131,9 @@ export function App() {
|
|
|
129
131
|
// The PHP-upgrade overlay owns the keyboard while open.
|
|
130
132
|
if (store.phpUpgradeSite) return
|
|
131
133
|
|
|
134
|
+
// The bulk WordPress-update overlay owns the keyboard while open.
|
|
135
|
+
if (store.bulkWpGroup) return
|
|
136
|
+
|
|
132
137
|
// The HTTPS-toggle overlay owns the keyboard while open.
|
|
133
138
|
if (store.httpsToggleSite) return
|
|
134
139
|
|
|
@@ -247,7 +252,8 @@ export function App() {
|
|
|
247
252
|
{store.releaseNotesInfo && <ReleaseNotes />}
|
|
248
253
|
{store.healthServer && <Health />}
|
|
249
254
|
{store.wpInventorySite && <WpInventory />}
|
|
250
|
-
{store.phpUpgradeSite && <
|
|
255
|
+
{store.phpUpgradeSite && <SiteUpdate />}
|
|
256
|
+
{store.bulkWpGroup && <BulkWpUpdate />}
|
|
251
257
|
{store.httpsToggleSite && <HttpsToggle />}
|
|
252
258
|
{store.purgeCacheSite && <PurgeCache />}
|
|
253
259
|
{store.grantKeySite && <GrantKey />}
|
package/src/ui/Explain.tsx
CHANGED
|
@@ -44,7 +44,7 @@ const GUIDES: Record<Route, Guide> = {
|
|
|
44
44
|
["h", "Server health over SSH (CPU / memory / disk)"],
|
|
45
45
|
["a", "Server actions: reboot or restart a service"],
|
|
46
46
|
["d", "Identify the app running on a site (SSH)"],
|
|
47
|
-
["u", "
|
|
47
|
+
["u", "Update a site: PHP version or WordPress core"],
|
|
48
48
|
["o / w", "Open the site · open it in the SpinupWP web app"],
|
|
49
49
|
["s", "Open a terminal and SSH into the site"],
|
|
50
50
|
["t / v / L", "Local copy: open a terminal · open the local URL · link or edit"],
|
|
@@ -54,7 +54,7 @@ const GUIDES: Record<Route, Guide> = {
|
|
|
54
54
|
title: "Stacks",
|
|
55
55
|
summary: "Your fleet grouped by app type (Standard WP / Bedrock / Non-WP), with a fleet-wide PHP version breakdown.",
|
|
56
56
|
panes: [
|
|
57
|
-
["Groups", "App-type buckets; Non-WP
|
|
57
|
+
["Groups", "App-type buckets; WordPress stacks expand by core version (newest highlighted), Non-WP into WHMCS / Laravel / …"],
|
|
58
58
|
["Sites", "The sites in the selected group"],
|
|
59
59
|
["PHP versions", "How many sites run each PHP version (EOL flagged)"],
|
|
60
60
|
],
|
|
@@ -63,7 +63,8 @@ const GUIDES: Record<Route, Guide> = {
|
|
|
63
63
|
["D", "Identify every app in the selected group"],
|
|
64
64
|
["S", "Find local copies on your disk and link them"],
|
|
65
65
|
["f", "Report sites with no local copy (filter by stack with ←/→)"],
|
|
66
|
-
["u", "
|
|
66
|
+
["u", "Update a site: PHP version or WordPress core"],
|
|
67
|
+
["u (on a WP x.y.z row)", "Update WordPress on every site in that version group — canary first"],
|
|
67
68
|
["t / v / L", "Local copy: terminal · local URL · link or edit"],
|
|
68
69
|
["o / w", "Open the site · open it in SpinupWP"],
|
|
69
70
|
["s", "Open a terminal and SSH into the site"],
|
|
@@ -79,7 +80,7 @@ const GUIDES: Record<Route, Guide> = {
|
|
|
79
80
|
actions: [
|
|
80
81
|
["type", "Filter results"],
|
|
81
82
|
["Tab / →", "Hand focus from the box to the result's actions"],
|
|
82
|
-
["o / w / u", "Open · open in SpinupWP ·
|
|
83
|
+
["o / w / u", "Open · open in SpinupWP · update PHP or WordPress"],
|
|
83
84
|
["s", "SSH into the site (opens a terminal)"],
|
|
84
85
|
["t / v / L", "Local copy: terminal · local URL · link or edit"],
|
|
85
86
|
["d / p / m", "DB backup · pull prod→local · production media fallback"],
|
package/src/ui/Header.tsx
CHANGED
|
@@ -20,7 +20,7 @@ const TABS: { route: Route; key: string; label: string }[] = [
|
|
|
20
20
|
// actions a user would reach for. Shown directly under the nav strip.
|
|
21
21
|
const SUBTITLES: Record<Route, string> = {
|
|
22
22
|
dashboard: "Your account at a glance — fleet health, what needs attention, recent activity",
|
|
23
|
-
servers: "Browse servers and their sites · h server health · u
|
|
23
|
+
servers: "Browse servers and their sites · h server health · u update PHP/WP · L link a local copy",
|
|
24
24
|
stacks: "Your fleet grouped by app type · d identify an app (SSH) · S find local copies on your disk",
|
|
25
25
|
search: "Jump to any server or site by name · Tab hands focus to the result's actions",
|
|
26
26
|
events: "Recent deploys, reboots, and operations across your account",
|
package/src/ui/Help.tsx
CHANGED
|
@@ -40,7 +40,7 @@ const NAV: Section = {
|
|
|
40
40
|
["c", "Create a new server"],
|
|
41
41
|
["C", "Clone this server to a new one"],
|
|
42
42
|
["S", "Connect sudo on the server"],
|
|
43
|
-
["u", "
|
|
43
|
+
["u", "Update PHP version or WordPress core"],
|
|
44
44
|
["H", "Enable/disable HTTPS"],
|
|
45
45
|
["P", "Purge page + object cache"],
|
|
46
46
|
["K", "Grant SpinupTUI's SSH key (sudo)"],
|
package/src/ui/ToastStack.tsx
CHANGED
|
@@ -20,11 +20,11 @@ export function ToastStack() {
|
|
|
20
20
|
<box
|
|
21
21
|
key={t.id}
|
|
22
22
|
border
|
|
23
|
-
borderColor={theme.good}
|
|
23
|
+
borderColor={t.kind === "error" ? theme.bad : theme.good}
|
|
24
24
|
backgroundColor={theme.bgPanel}
|
|
25
25
|
style={{ flexDirection: "row", gap: 1, maxWidth: TOAST_WIDTH, paddingLeft: 1, paddingRight: 1 }}
|
|
26
26
|
>
|
|
27
|
-
<text content="✓" fg={theme.good} />
|
|
27
|
+
<text content={t.kind === "error" ? "✕" : "✓"} fg={t.kind === "error" ? theme.bad : theme.good} />
|
|
28
28
|
<text content={t.message} fg={theme.text} />
|
|
29
29
|
</box>
|
|
30
30
|
))}
|
package/src/ui/components.tsx
CHANGED
|
@@ -459,7 +459,7 @@ export function siteGroups(isWordpress: boolean, isVanity: boolean): ActionGroup
|
|
|
459
459
|
["L", "Link / edit"],
|
|
460
460
|
]
|
|
461
461
|
if (isWordpress) local.push(["m", "Media fallback"])
|
|
462
|
-
remote.push(["u", "PHP
|
|
462
|
+
remote.push(["u", "Update PHP / WP"])
|
|
463
463
|
remote.push(["H", "Toggle HTTPS"])
|
|
464
464
|
remote.push(["P", "Purge cache"])
|
|
465
465
|
remote.push(["M", "Monitoring"])
|
package/src/ui/store.tsx
CHANGED
|
@@ -20,7 +20,7 @@ import { resolveLocalLink, expandPath, normalizeLink, type LocalLink } from "../
|
|
|
20
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
|
-
import { probeSite } from "../lib/probe.ts"
|
|
23
|
+
import { probeSite, withWpVersion } from "../lib/probe.ts"
|
|
24
24
|
import { resolveZone, normalizeDomain, candidateHostnames, type ZoneHost } from "../lib/dns.ts"
|
|
25
25
|
import { queryAuthoritative } from "../lib/dnsQuery.ts"
|
|
26
26
|
import { DnsCache, type CachedDns } from "../lib/dnsCache.ts"
|
|
@@ -484,6 +484,11 @@ interface StoreValue extends DataState {
|
|
|
484
484
|
// The site whose PHP-upgrade overlay is open, or null. Set by site views.
|
|
485
485
|
phpUpgradeSite: Site | null
|
|
486
486
|
setPhpUpgradeSite: (s: Site | null) => void
|
|
487
|
+
// The Stacks version group whose bulk WordPress-update overlay is open, or null.
|
|
488
|
+
// The sites are a snapshot taken when it opened (the group itself reshuffles as
|
|
489
|
+
// updates land and versions change).
|
|
490
|
+
bulkWpGroup: { title: string; sites: Site[] } | null
|
|
491
|
+
setBulkWpGroup: (g: { title: string; sites: Site[] } | null) => void
|
|
487
492
|
// In-flight (and just-failed) PHP upgrades, keyed by site id. Tracked in the
|
|
488
493
|
// store — not the overlay — so progress survives closing the modal; site rows
|
|
489
494
|
// and detail panels read this to show a spinner/marker.
|
|
@@ -757,6 +762,9 @@ interface StoreValue extends DataState {
|
|
|
757
762
|
probeErrors: Map<number, string> // last error per site id
|
|
758
763
|
// Probe a single site over SSH (fire-and-forget); write-through to the cache.
|
|
759
764
|
runProbe: (site: Site) => void
|
|
765
|
+
// Fold a WP core version read elsewhere (the `u` → WordPress check/update) into
|
|
766
|
+
// the cached probe, so rows showing "WordPress x.y.z" don't lag behind it.
|
|
767
|
+
noteWpVersion: (site: Site, version: string) => void
|
|
760
768
|
// Probe many sites with a bounded concurrency pool (skips in-flight sites).
|
|
761
769
|
runProbeMany: (sites: Site[]) => void
|
|
762
770
|
// Whether a cached probe for this site is stale (site shape changed since).
|
|
@@ -949,6 +957,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
949
957
|
const [healthServer, setHealthServer] = useState<Server | null>(null)
|
|
950
958
|
const [wpInventorySite, setWpInventorySite] = useState<Site | null>(null)
|
|
951
959
|
const [phpUpgradeSite, setPhpUpgradeSite] = useState<Site | null>(null)
|
|
960
|
+
const [bulkWpGroup, setBulkWpGroup] = useState<{ title: string; sites: Site[] } | null>(null)
|
|
952
961
|
const [phpUpgrades, setPhpUpgrades] = useState<Map<number, PhpUpgradeProgress>>(new Map())
|
|
953
962
|
const [httpsToggleSite, setHttpsToggleSite] = useState<Site | null>(null)
|
|
954
963
|
const [httpsToggles, setHttpsToggles] = useState<Map<number, HttpsToggleProgress>>(new Map())
|
|
@@ -1147,6 +1156,17 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1147
1156
|
|
|
1148
1157
|
// Probe one site and reconcile state + cache. Concurrency-safe (all state
|
|
1149
1158
|
// updates are functional), so the batch runner can pool several at once.
|
|
1159
|
+
const noteWpVersion = useCallback((site: Site, version: string) => {
|
|
1160
|
+
const cache = cacheRef.current!
|
|
1161
|
+
const entry = cache.get(site.id)
|
|
1162
|
+
// Only refresh an existing WP-family probe: with no probe we don't know the
|
|
1163
|
+
// stack (Standard vs Bedrock vs Radicle), and that's the probe's job.
|
|
1164
|
+
if (!entry || entry.result.version === version) return
|
|
1165
|
+
const next = withWpVersion(entry.result, version)
|
|
1166
|
+
if (!next) return
|
|
1167
|
+
void cache.set(site.id, next, siteSignature(site)).then(() => setProbes(cache.snapshot()))
|
|
1168
|
+
}, [])
|
|
1169
|
+
|
|
1150
1170
|
const probeOne = useCallback(
|
|
1151
1171
|
async (site: Site) => {
|
|
1152
1172
|
const cache = cacheRef.current!
|
|
@@ -4249,6 +4269,8 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
4249
4269
|
setWpInventorySite,
|
|
4250
4270
|
phpUpgradeSite,
|
|
4251
4271
|
setPhpUpgradeSite,
|
|
4272
|
+
bulkWpGroup,
|
|
4273
|
+
setBulkWpGroup,
|
|
4252
4274
|
phpUpgrades,
|
|
4253
4275
|
startPhpUpgrade,
|
|
4254
4276
|
clearPhpUpgrade,
|
|
@@ -4421,6 +4443,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
4421
4443
|
probingIds,
|
|
4422
4444
|
probeErrors,
|
|
4423
4445
|
runProbe,
|
|
4446
|
+
noteWpVersion,
|
|
4424
4447
|
runProbeMany,
|
|
4425
4448
|
isProbeStale,
|
|
4426
4449
|
dnsZones,
|