spinuptui 0.26.0 → 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 +22 -1
- package/README.md +3 -3
- package/package.json +3 -2
- package/src/api/client.ts +38 -2
- package/src/index.tsx +3 -0
- package/src/lib/appUpdate.ts +2 -1
- package/src/lib/cliPull.ts +11 -6
- package/src/lib/cliSsh.ts +2 -1
- package/src/lib/cwdDotenv.ts +106 -0
- package/src/lib/dbBackup.ts +2 -1
- package/src/lib/gitDeployKey.ts +0 -0
- package/src/lib/gitStatus.ts +2 -1
- package/src/lib/keychain.ts +3 -1
- package/src/lib/open.ts +10 -8
- package/src/lib/probe.ts +18 -1
- package/src/lib/serverClone.ts +3 -2
- package/src/lib/spawn.ts +17 -0
- package/src/lib/ssh.ts +6 -5
- package/src/lib/sshExec.ts +2 -1
- package/src/lib/wpCore.ts +273 -0
- package/src/lib/wpInventory.ts +2 -1
- 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
|
@@ -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/lib/wpInventory.ts
CHANGED
|
@@ -12,6 +12,7 @@ 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
14
|
import { wpCliResolveScript } from "./wpCli.ts"
|
|
15
|
+
import { spawn } from "./spawn.ts"
|
|
15
16
|
|
|
16
17
|
// One plugin or theme, mirroring `wp {plugin,theme} list` columns.
|
|
17
18
|
export interface WpItem {
|
|
@@ -116,7 +117,7 @@ export async function fetchWpInventory(
|
|
|
116
117
|
|
|
117
118
|
let proc: ReturnType<typeof Bun.spawn>
|
|
118
119
|
try {
|
|
119
|
-
proc =
|
|
120
|
+
proc = spawn(["ssh", ...SSH_OPTS, ...sshPort(server.ssh_port ?? null), target, remote], {
|
|
120
121
|
stdout: "pipe",
|
|
121
122
|
stderr: "pipe",
|
|
122
123
|
stdin: "ignore",
|
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,
|
package/src/ui/toast.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Replaces the @opentui-ui/toast package, which is stuck on a peer-dependency
|
|
4
4
|
// range from opentui's 0.1.x days and breaks against the @opentui/core 0.4.x
|
|
5
5
|
// line we're on (see CHANGELOG v0.24.2/v0.24.3). Every call site in this app
|
|
6
|
-
// only ever fires a single-line success message, so a small first-party
|
|
6
|
+
// only ever fires a single-line success or error message, so a small first-party
|
|
7
7
|
// implementation is simpler — and immune to upstream drift — than depending
|
|
8
8
|
// on a package built for a much larger feature set we don't use.
|
|
9
9
|
//
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
export interface ToastItem {
|
|
16
16
|
id: number
|
|
17
17
|
message: string
|
|
18
|
+
kind: "success" | "error"
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
const TOAST_DURATION_MS = 4000
|
|
@@ -36,14 +37,19 @@ export function getToasts(): ToastItem[] {
|
|
|
36
37
|
return toasts
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
function push(message: string, kind: ToastItem["kind"]) {
|
|
41
|
+
const id = nextId++
|
|
42
|
+
toasts = [...toasts, { id, message, kind }]
|
|
43
|
+
notify()
|
|
44
|
+
setTimeout(() => {
|
|
45
|
+
toasts = toasts.filter((t) => t.id !== id)
|
|
43
46
|
notify()
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
}, TOAST_DURATION_MS)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const toast = {
|
|
51
|
+
success: (message: string) => push(message, "success"),
|
|
52
|
+
// For a background job that failed after its overlay was closed — points the
|
|
53
|
+
// user back at where the details are.
|
|
54
|
+
error: (message: string) => push(message, "error"),
|
|
49
55
|
}
|
package/src/ui/views/Browser.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import { theme, statusColor, statusDot } from "../../lib/theme.ts"
|
|
|
11
11
|
import { effectiveStack, stackColor, stackTag } from "../../lib/stack.ts"
|
|
12
12
|
import { truncate } from "../../lib/format.ts"
|
|
13
13
|
import { Panel, PhpVersionCell, Spinner } from "../components.tsx"
|
|
14
|
+
import { WpCoreRowMark } from "../wpCoreJobs.tsx"
|
|
14
15
|
import { List, moveSelection } from "../List.tsx"
|
|
15
16
|
import { ServerDetail, SiteDetail, ControlStrip, CONTROL_STRIP_HEIGHT } from "../Details.tsx"
|
|
16
17
|
import { StatusBar } from "../StatusBar.tsx"
|
|
@@ -387,6 +388,7 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
387
388
|
linked/key/HTTPS/cache/backup/updates badges moved to the Details
|
|
388
389
|
pane (already shown there for the selected site) to give the
|
|
389
390
|
domain name room on narrower terminals. */}
|
|
391
|
+
<WpCoreRowMark siteId={s.id} selected={selected} compact />
|
|
390
392
|
<text content={stackTag(stack).padEnd(7) + " "} fg={stackColor(stack, selected)} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
391
393
|
<PhpVersionCell version={s.php_version} upgrade={phpUpgrades.get(s.id)} selected={selected} />
|
|
392
394
|
</>
|