spinuptui 0.26.1 → 0.28.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.
@@ -13,7 +13,7 @@
13
13
  import { join } from "node:path"
14
14
  import { existsSync, readFileSync } from "node:fs"
15
15
  import { chmod, mkdir } from "node:fs/promises"
16
- import { configDir } from "../config.ts"
16
+ import { profileDataDir } from "../config.ts"
17
17
  import type { VerifiedZone } from "./providers.ts"
18
18
 
19
19
  // v2: VerifiedZone gained `nameservers` + `account` (was accountId/accountName).
@@ -42,17 +42,20 @@ interface CacheFile {
42
42
  entries: Record<string, VerifiedConn> // key: connection id
43
43
  }
44
44
 
45
- export function providersCachePath(): string {
46
- return join(configDir(), "providers-cache.json")
45
+ export function providersCachePath(dir = profileDataDir()): string {
46
+ return join(dir, "providers-cache.json")
47
47
  }
48
48
 
49
49
  export class ProvidersCache {
50
+ // Pinned to the account active when the cache was created, so a write that
51
+ // lands after an account switch still goes to its own account\'s file.
52
+ private readonly dir = profileDataDir()
50
53
  private entries = new Map<string, VerifiedConn>()
51
54
  private writeChain: Promise<void> = Promise.resolve()
52
55
 
53
56
  load(): void {
54
57
  try {
55
- const path = providersCachePath()
58
+ const path = providersCachePath(this.dir)
56
59
  if (!existsSync(path)) return
57
60
  const parsed = JSON.parse(readFileSync(path, "utf8")) as CacheFile
58
61
  if (parsed?.version !== CACHE_VERSION || !parsed.entries) return
@@ -91,10 +94,10 @@ export class ProvidersCache {
91
94
  }
92
95
 
93
96
  private async writeFile(): Promise<void> {
94
- await mkdir(configDir(), { recursive: true })
97
+ await mkdir(this.dir, { recursive: true })
95
98
  const file: CacheFile = { version: CACHE_VERSION, entries: {} }
96
99
  for (const [k, entry] of this.entries) file.entries[k] = entry
97
- const path = providersCachePath()
100
+ const path = providersCachePath(this.dir)
98
101
  await Bun.write(path, JSON.stringify(file, null, 2) + "\n")
99
102
  try {
100
103
  await chmod(path, 0o600)
@@ -12,7 +12,7 @@
12
12
  import { join } from "node:path"
13
13
  import { existsSync, readFileSync } from "node:fs"
14
14
  import { chmod, mkdir } from "node:fs/promises"
15
- import { configDir } from "../config.ts"
15
+ import { profileDataDir } from "../config.ts"
16
16
  import type { ProbeResult } from "./probe.ts"
17
17
 
18
18
  const CACHE_VERSION = 1
@@ -28,8 +28,8 @@ interface CacheFile {
28
28
  entries: Record<string, CachedProbe> // key: String(site.id)
29
29
  }
30
30
 
31
- export function stackCachePath(): string {
32
- return join(configDir(), "stack-cache.json")
31
+ export function stackCachePath(dir = profileDataDir()): string {
32
+ return join(dir, "stack-cache.json")
33
33
  }
34
34
 
35
35
  // A cached probe is considered stale when the site's relevant shape changes.
@@ -40,6 +40,9 @@ export function siteSignature(site: { is_wordpress: boolean; public_folder: stri
40
40
  }
41
41
 
42
42
  export class StackCache {
43
+ // Pinned to the account active when the cache was created, so a write that
44
+ // lands after an account switch still goes to its own account\'s file.
45
+ private readonly dir = profileDataDir()
43
46
  private entries = new Map<number, CachedProbe>()
44
47
  // Serializes disk writes so concurrent probes (batch mode) can't tear the
45
48
  // file or drop each other's updates. Each persist() runs after the previous.
@@ -49,7 +52,7 @@ export class StackCache {
49
52
  // simply yields an empty cache.
50
53
  load(): void {
51
54
  try {
52
- const path = stackCachePath()
55
+ const path = stackCachePath(this.dir)
53
56
  if (!existsSync(path)) return
54
57
  const parsed = JSON.parse(readFileSync(path, "utf8")) as CacheFile
55
58
  if (parsed?.version !== CACHE_VERSION || !parsed.entries) return
@@ -99,10 +102,10 @@ export class StackCache {
99
102
  }
100
103
 
101
104
  private async writeFile(): Promise<void> {
102
- await mkdir(configDir(), { recursive: true })
105
+ await mkdir(this.dir, { recursive: true })
103
106
  const file: CacheFile = { version: CACHE_VERSION, entries: {} }
104
107
  for (const [id, entry] of this.entries) file.entries[String(id)] = entry
105
- const path = stackCachePath()
108
+ const path = stackCachePath(this.dir)
106
109
  await Bun.write(path, JSON.stringify(file, null, 2) + "\n")
107
110
  try {
108
111
  await chmod(path, 0o600)
package/src/lib/wpCli.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  // individual site's configured version, and can be missing extensions that
6
6
  // version's CLI SAPI never got configured with.
7
7
  //
8
- // Verified live 2026-07-09 on web4.wenmarkdigital.com: the system default had
8
+ // Verified live 2026-07-09 on a production server: the system default had
9
9
  // drifted to 8.4, whose CLI lacked `mysqli` (its FPM pool likely never needed
10
- // it — no site there runs 8.4), while lp.anchoredconstructiontn.com's actual
10
+ // it — no site there runs 8.4), while a site's actual
11
11
  // configured version (8.1) has a fully working php8.1-cli. Bare `wp` silently
12
12
  // ran under 8.4 and failed on anything touching $wpdb (post/user/plugin list,
13
13
  // option get) — while `wp core version`, and `wp db export/import/check/size`
@@ -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 { 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"
@@ -38,6 +39,7 @@ import { WpInventory } from "./views/WpInventory.tsx"
38
39
  import { ProviderConnect } from "./views/ProviderConnect.tsx"
39
40
  import { DnsRecords } from "./views/DnsRecords.tsx"
40
41
  import { KumaSite } from "./views/KumaSite.tsx"
42
+ import { Accounts } from "./views/Accounts.tsx"
41
43
 
42
44
  const MIN_SPLASH_MS = 1200
43
45
 
@@ -48,6 +50,7 @@ export function App() {
48
50
  const [splashDone, setSplashDone] = useState(false)
49
51
  const [showHelp, setShowHelp] = useState(false)
50
52
  const [showExplain, setShowExplain] = useState(false)
53
+ const [showAccounts, setShowAccounts] = useState(false)
51
54
 
52
55
  // Enforce a minimum splash duration so the intro is visible even on fast loads.
53
56
  useEffect(() => {
@@ -59,10 +62,12 @@ export function App() {
59
62
  const overlayActive =
60
63
  showHelp ||
61
64
  showExplain ||
65
+ showAccounts ||
62
66
  store.releaseNotesInfo !== null ||
63
67
  store.healthServer !== null ||
64
68
  store.wpInventorySite !== null ||
65
69
  store.phpUpgradeSite !== null ||
70
+ store.bulkWpGroup !== null ||
66
71
  store.httpsToggleSite !== null ||
67
72
  store.purgeCacheSite !== null ||
68
73
  store.grantKeySite !== null ||
@@ -117,6 +122,9 @@ export function App() {
117
122
  // While a text field is focused, let it consume everything else.
118
123
  if (store.inputMode) return
119
124
 
125
+ // The Accounts overlay owns the keyboard while open.
126
+ if (showAccounts) return
127
+
120
128
  // The release-notes overlay owns the keyboard while open (any key dismisses).
121
129
  if (store.releaseNotesInfo) return
122
130
 
@@ -129,6 +137,9 @@ export function App() {
129
137
  // The PHP-upgrade overlay owns the keyboard while open.
130
138
  if (store.phpUpgradeSite) return
131
139
 
140
+ // The bulk WordPress-update overlay owns the keyboard while open.
141
+ if (store.bulkWpGroup) return
142
+
132
143
  // The HTTPS-toggle overlay owns the keyboard while open.
133
144
  if (store.httpsToggleSite) return
134
145
 
@@ -189,6 +200,9 @@ export function App() {
189
200
  // The site-monitoring (Uptime Kuma) overlay owns the keyboard while open.
190
201
  if (store.kumaSite) return
191
202
 
203
+ // A (Shift+a): the Accounts overlay — the header advertises it from every tab.
204
+ if (key.shift && key.name === "a") return setShowAccounts(true)
205
+
192
206
  switch (key.name) {
193
207
  case "q":
194
208
  return quit()
@@ -247,7 +261,8 @@ export function App() {
247
261
  {store.releaseNotesInfo && <ReleaseNotes />}
248
262
  {store.healthServer && <Health />}
249
263
  {store.wpInventorySite && <WpInventory />}
250
- {store.phpUpgradeSite && <PhpUpgrade />}
264
+ {store.phpUpgradeSite && <SiteUpdate />}
265
+ {store.bulkWpGroup && <BulkWpUpdate />}
251
266
  {store.httpsToggleSite && <HttpsToggle />}
252
267
  {store.purgeCacheSite && <PurgeCache />}
253
268
  {store.grantKeySite && <GrantKey />}
@@ -268,6 +283,7 @@ export function App() {
268
283
  {store.connectZoneTarget && <ProviderConnect />}
269
284
  {store.dnsRecordsTarget && <DnsRecords />}
270
285
  {store.kumaSite && <KumaSite />}
286
+ {showAccounts && <Accounts onClose={() => setShowAccounts(false)} />}
271
287
  <ToastStack />
272
288
  </box>
273
289
  )
@@ -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,14 +20,14 @@ 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",
27
27
  }
28
28
 
29
29
  export function Header() {
30
- const { route, servers, sites, loading, lastUpdated, updateInfo, newServerJob, vanityJob, cloneJob, cloneServer, kumaStatus } = useStore()
30
+ const { route, servers, sites, loading, lastUpdated, updateInfo, newServerJob, vanityJob, cloneJob, cloneServer, kumaStatus, profileLabel } = useStore()
31
31
  // Name the domain(s) when there are few enough to fit the header row; fall
32
32
  // back to a bare count once there are too many to list safely. Deliberately
33
33
  // still just the plain up/down signal (kumaStatus.up) — not expanding what
@@ -119,6 +119,10 @@ export function Header() {
119
119
  <box style={{ flexDirection: "row", height: 1, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
120
120
  <text content="💡 " fg={theme.accent} style={{ flexShrink: 0 }} />
121
121
  <text content={SUBTITLES[route]} fg={theme.textDim} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
122
+ {/* Always visible, so it's never a guess which client's fleet this is. */}
123
+ <text content=" Account: " fg={theme.textFaint} style={{ flexShrink: 0 }} />
124
+ <text content={truncate(profileLabel, 32)} fg={theme.text} wrapMode="none" style={{ flexShrink: 0 }} />
125
+ <text content=" A switch" fg={theme.textFaint} style={{ flexShrink: 0 }} />
122
126
  </box>
123
127
  </box>
124
128
  )
package/src/ui/Help.tsx CHANGED
@@ -18,6 +18,7 @@ const GLOBAL: Section = {
18
18
  ["1 … 5", "Switch tabs"],
19
19
  ["r", "Refresh from the API"],
20
20
  ["/", "Global search"],
21
+ ["A", "Switch SpinupWP accounts"],
21
22
  ["i", "Explain the current screen"],
22
23
  ["?", "This help"],
23
24
  ["q · Ctrl+C", "Quit · force quit"],
@@ -40,7 +41,7 @@ const NAV: Section = {
40
41
  ["c", "Create a new server"],
41
42
  ["C", "Clone this server to a new one"],
42
43
  ["S", "Connect sudo on the server"],
43
- ["u", "Change PHP version"],
44
+ ["u", "Update PHP version or WordPress core"],
44
45
  ["H", "Enable/disable HTTPS"],
45
46
  ["P", "Purge page + object cache"],
46
47
  ["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
  ))}
@@ -0,0 +1,21 @@
1
+ // Account switching lives ABOVE the store: switching remounts <StoreProvider>
2
+ // keyed by the account, which is what guarantees a clean slate — every effect,
3
+ // poller and interval of the outgoing account is torn down by React, and every
4
+ // id-keyed cache (servers, sites, probes, jobs…) starts empty for the new one.
5
+ // See config.ts "Accounts" for how the profiles are stored.
6
+
7
+ import { createContext, useContext } from "react"
8
+
9
+ export interface AccountsApi {
10
+ // Make `id` the active account and remount the app on it. The caller checks
11
+ // the store's switchBlocker() first — nothing here waits for running work.
12
+ switchAccount: (id: string) => Promise<void>
13
+ }
14
+
15
+ export const AccountsContext = createContext<AccountsApi | null>(null)
16
+
17
+ export function useAccounts(): AccountsApi {
18
+ const ctx = useContext(AccountsContext)
19
+ if (!ctx) throw new Error("useAccounts must be used within <AccountsContext.Provider>")
20
+ return ctx
21
+ }
@@ -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"])