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.
@@ -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
+ }
@@ -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 = Bun.spawn(["ssh", ...SSH_OPTS, ...sshPort(server.ssh_port ?? null), target, remote], {
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 { PhpUpgrade } from "./views/PhpUpgrade.tsx"
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 && <PhpUpgrade />}
255
+ {store.phpUpgradeSite && <SiteUpdate />}
256
+ {store.bulkWpGroup && <BulkWpUpdate />}
251
257
  {store.httpsToggleSite && <HttpsToggle />}
252
258
  {store.purgeCacheSite && <PurgeCache />}
253
259
  {store.grantKeySite && <GrantKey />}
@@ -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", "Change a site's PHP version"],
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 expands into WHMCS / Laravel / …"],
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", "Change a site's PHP version"],
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 · change PHP"],
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 change PHP · L link a local copy",
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", "Change PHP version"],
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)"],
@@ -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
  ))}
@@ -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 ver."])
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
- export const toast = {
40
- success(message: string) {
41
- const id = nextId++
42
- toasts = [...toasts, { id, message }]
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
- setTimeout(() => {
45
- toasts = toasts.filter((t) => t.id !== id)
46
- notify()
47
- }, TOAST_DURATION_MS)
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
  }
@@ -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
  </>