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.
- package/CHANGELOG.md +24 -1
- package/README.md +9 -5
- package/package.json +1 -1
- package/src/api/client.ts +38 -2
- package/src/config.ts +232 -8
- package/src/index.tsx +25 -5
- package/src/lib/dnsCache.ts +9 -6
- package/src/lib/keychain.ts +11 -5
- package/src/lib/probe.ts +16 -0
- package/src/lib/providersCache.ts +9 -6
- package/src/lib/stackCache.ts +9 -6
- package/src/lib/wpCli.ts +2 -2
- package/src/lib/wpCore.ts +273 -0
- package/src/ui/App.tsx +18 -2
- package/src/ui/Explain.tsx +5 -4
- package/src/ui/Header.tsx +6 -2
- package/src/ui/Help.tsx +2 -1
- package/src/ui/ToastStack.tsx +2 -2
- package/src/ui/accounts.tsx +21 -0
- package/src/ui/components.tsx +1 -1
- package/src/ui/store.tsx +89 -21
- package/src/ui/toast.ts +16 -10
- package/src/ui/views/Accounts.tsx +370 -0
- 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 +113 -15
- package/src/ui/views/WpCoreUpdate.tsx +322 -0
- package/src/ui/wpCoreJobs.tsx +253 -0
|
@@ -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 {
|
|
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(
|
|
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(
|
|
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)
|
package/src/lib/stackCache.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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 {
|
|
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 && <
|
|
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
|
)
|
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,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
|
|
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", "
|
|
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)"],
|
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
|
))}
|
|
@@ -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
|
+
}
|
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"])
|