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.
@@ -3,56 +3,83 @@
3
3
  // Left pane: selectable composition groups. The three top-level buckets
4
4
  // (Standard WP / Bedrock / Non-WP) use the EFFECTIVE stack — a conclusive Tier-2
5
5
  // probe overrides the API heuristic — so counts drift toward reality as you
6
- // probe. Non-WP expands into named sub-rows (WHMCS / Laravel / Static HTML /
7
- // Unknown / unprobed) from cached probes.
6
+ // probe. The WordPress buckets (Standard WP / Bedrock / Radicle) expand into one
7
+ // sub-row per probed core version, newest first — the "who still needs the
8
+ // security release" view; Non-WP expands into named sub-rows (WHMCS / Laravel /
9
+ // Static HTML / Vanity Site / Unknown / unprobed). Both come from cached probes,
10
+ // except Vanity Site, which is derived with no SSH (see `nonWpSub`).
8
11
  //
9
12
  // Middle pane: the sites in the selected group. `d` probes the selected site;
10
13
  // `D` probes the whole selected group (bounded concurrency). Right pane: the
11
14
  // fleet-wide PHP version distribution with EOL versions flagged.
12
15
 
13
- import { useEffect, useMemo, useState } from "react"
16
+ import { useEffect, useMemo, useRef, useState } from "react"
14
17
  import { useKeyboard } from "@opentui/react"
15
18
  import { theme, statusColor, statusDot } from "../../lib/theme.ts"
16
19
  import { bar, truncate } from "../../lib/format.ts"
17
20
  import { STACKS, effectiveStack, stackColor, type Stack } from "../../lib/stack.ts"
18
21
  import { phpSortKey } from "../../lib/phpEol.ts"
19
22
  import { probeKindColor, type ProbeKind } from "../../lib/probe.ts"
23
+ import { compareVersions } from "../../lib/wpCore.ts"
20
24
  import { Panel, Spinner, PhpVersionCell, SiteMetaCell } from "../components.tsx"
25
+ import { WpCoreRowMark, clearBulk, peekBulk, useWpCoreJobs } from "../wpCoreJobs.tsx"
21
26
  import { List, moveSelection } from "../List.tsx"
22
27
  import { StatusBar } from "../StatusBar.tsx"
23
28
  import { SiteContextStrip, SITE_CONTEXT_STRIP_HEIGHT } from "../Details.tsx"
24
29
  import { openUrl } from "../../lib/open.ts"
25
30
  import { siteWebUrl } from "../../lib/spinupweb.ts"
31
+ import { isVanityPair } from "../../lib/vanitySite.ts"
26
32
  import { useStore } from "../store.tsx"
27
33
  import type { Site } from "../../api/types.ts"
28
34
 
29
35
  type Focus = "groups" | "sites"
30
36
 
31
37
  // A selectable row in the composition pane: a top-level bucket (level 0) or a
32
- // Non-WP sub-category (level 1).
38
+ // sub-row (level 1) — a WP core version under a WordPress stack, or a Non-WP
39
+ // sub-category.
33
40
  interface Group {
34
41
  id: string
35
42
  label: string
43
+ title?: string // sites-pane title when the bare label lacks context ("7.0.2" → "Bedrock · WP 7.0.2")
36
44
  level: 0 | 1
37
45
  color: string
38
46
  sites: Site[]
39
47
  }
40
48
 
41
- // Non-WP sub-categories, in display order. `kind: null` = not yet probed.
42
- const NONWP_SUBS: { kind: ProbeKind | null; label: string }[] = [
49
+ const WP_STACKS: Stack[] = ["Standard WP", "Bedrock", "Radicle"]
50
+
51
+
52
+ // Non-WP sub-categories, in display order. `kind: null` = not yet probed;
53
+ // "vanity" is the site at its server's own hostname (see `nonWpSub`).
54
+ type NonWpSub = ProbeKind | "vanity" | null
55
+ const NONWP_SUBS: { kind: NonWpSub; label: string }[] = [
43
56
  { kind: "whmcs", label: "WHMCS" },
44
57
  { kind: "laravel", label: "Laravel" },
45
58
  { kind: "static", label: "Static HTML" },
59
+ { kind: "vanity", label: "Vanity Site" },
46
60
  { kind: "unknown", label: "Unknown" },
47
61
  { kind: null, label: "unprobed" },
48
62
  ]
63
+ const VANITY_COLOR = theme.info
64
+
65
+ // Which Non-WP sub-row a site belongs in. A vanity site is known from the API
66
+ // alone (domain === its server's hostname, the same `isVanityPair` every other
67
+ // vanity feature keys off), so it claims the site whether it's unprobed or the
68
+ // probe came back Unknown/Static — the placeholder page reads as neither. A
69
+ // positively identified app (WHMCS/Laravel) living at the hostname still wins.
70
+ function nonWpSub(kind: ProbeKind | null, vanity: boolean): NonWpSub {
71
+ if (vanity && (kind === null || kind === "unknown" || kind === "static")) return "vanity"
72
+ return kind
73
+ }
49
74
 
50
75
  export function Stacks({ rows }: { rows: number }) {
51
76
  const store = useStore()
52
- const { sites, serverById, route, inputMode, overlayOpen, probes, probingIds, probeErrors, runProbe, runProbeMany, isProbeStale, isPhpEol, accountSlug, setPhpUpgradeSite, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setLocalLinkSite, openLocalTerminal, openLocalUrl, localLinks, setDiscoverOpen, setForgottenOpen, setForgottenStack, sshSite, setKumaSite } =
77
+ const wpCoreJobs = useWpCoreJobs()
78
+ const { sites, serverById, route, inputMode, overlayOpen, probes, probingIds, probeErrors, runProbe, runProbeMany, isProbeStale, isPhpEol, accountSlug, setPhpUpgradeSite, setBulkWpGroup, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setLocalLinkSite, openLocalTerminal, openLocalUrl, localLinks, setDiscoverOpen, setForgottenOpen, setForgottenStack, sshSite, setKumaSite } =
53
79
  store
54
80
 
55
81
  const [groupIndex, setGroupIndex] = useState(0)
82
+ const groupTopRef = useRef(0)
56
83
  const [siteIndex, setSiteIndex] = useState(0)
57
84
  const [focus, setFocus] = useState<Focus>("groups")
58
85
  const [flash, setFlash] = useState<string | null>(null)
@@ -75,19 +102,54 @@ export function Stacks({ rows }: { rows: number }) {
75
102
  }
76
103
  for (const list of byStack.values()) list.sort((a, b) => a.domain.localeCompare(b.domain))
77
104
 
105
+ // The newest core version seen anywhere in the fleet — the one every
106
+ // version row is measured against (highlighted; older rows are dimmed).
107
+ let newestWp: string | null = null
108
+ for (const st of WP_STACKS) {
109
+ for (const site of byStack.get(st)!) {
110
+ const v = probes.get(site.id)?.result.version
111
+ if (v && (!newestWp || compareVersions(v, newestWp) > 0)) newestWp = v
112
+ }
113
+ }
114
+
78
115
  const groups: Group[] = []
79
116
  for (const st of STACKS) {
80
117
  const bucket = byStack.get(st)!
81
118
  groups.push({ id: st, label: st, level: 0, color: stackColor(st), sites: bucket })
119
+ if (WP_STACKS.includes(st)) {
120
+ const byVersion = new Map<string, Site[]>()
121
+ const noVersion: Site[] = []
122
+ for (const site of bucket) {
123
+ const v = probes.get(site.id)?.result.version
124
+ if (v) byVersion.set(v, [...(byVersion.get(v) ?? []), site])
125
+ else noVersion.push(site)
126
+ }
127
+ for (const v of [...byVersion.keys()].sort((a, b) => compareVersions(b, a))) {
128
+ groups.push({
129
+ id: `wp:${st}:${v}`,
130
+ label: `WP ${v}`,
131
+ title: `${st} · WP ${v}`,
132
+ level: 1,
133
+ color: v === newestWp ? theme.good : theme.textDim,
134
+ sites: byVersion.get(v)!,
135
+ })
136
+ }
137
+ // Only worth a row when some are versioned — otherwise it'd just repeat the bucket.
138
+ if (noVersion.length && byVersion.size) {
139
+ groups.push({ id: `wp:${st}:none`, label: "no version", title: `${st} · version unknown`, level: 1, color: theme.textFaint, sites: noVersion })
140
+ }
141
+ }
82
142
  if (st === "Non-WP") {
83
143
  for (const sub of NONWP_SUBS) {
84
- const subSites = bucket.filter((site) => (probes.get(site.id)?.result.kind ?? null) === sub.kind)
144
+ const subSites = bucket.filter(
145
+ (site) => nonWpSub(probes.get(site.id)?.result.kind ?? null, isVanityPair(site.domain, serverById(site.server_id)?.name ?? "")) === sub.kind,
146
+ )
85
147
  if (subSites.length === 0) continue
86
148
  groups.push({
87
149
  id: `nonwp:${sub.label}`,
88
150
  label: sub.label,
89
151
  level: 1,
90
- color: sub.kind ? probeKindColor(sub.kind) : theme.textFaint,
152
+ color: sub.kind === "vanity" ? VANITY_COLOR : sub.kind ? probeKindColor(sub.kind) : theme.textFaint,
91
153
  sites: subSites,
92
154
  })
93
155
  }
@@ -95,12 +157,14 @@ export function Stacks({ rows }: { rows: number }) {
95
157
  }
96
158
  const php = [...phpCounts.entries()].sort((a, b) => phpSortKey(b[0]) - phpSortKey(a[0]))
97
159
  return { groups, php }
98
- }, [sites, probes])
160
+ }, [sites, probes, serverById])
99
161
 
100
162
  // Keep selection in range as groups appear/disappear with probing.
101
163
  const safeGroupIndex = Math.min(groupIndex, groups.length - 1)
102
164
  const selectedGroup = groups[safeGroupIndex]
103
165
  const groupSites = selectedGroup?.sites ?? []
166
+ // A "WP x.y.z" (or "no version") row under a WordPress stack — bulk-updatable.
167
+ const isVersionGroup = !!selectedGroup && selectedGroup.id.startsWith("wp:")
104
168
  const total = sites.length || 1
105
169
 
106
170
  useEffect(() => {
@@ -167,8 +231,20 @@ export function Stacks({ rows }: { rows: number }) {
167
231
  }
168
232
  return
169
233
  case "u":
170
- // Upgrade the selected site's PHP version (sites pane only).
234
+ // Sites pane: the selected site's Update menu. Groups pane, on a WP
235
+ // version row: bulk-update that whole group (a running or unseen bulk
236
+ // run takes over the overlay instead — only one exists at a time).
171
237
  if (focus === "sites" && groupSites[siteIndex]) setPhpUpgradeSite(groupSites[siteIndex])
238
+ else if (focus === "groups" && isVersionGroup && groupSites.length) {
239
+ const title = selectedGroup.title ?? selectedGroup.label
240
+ // A finished run's summary belongs to its own group: reopening THAT
241
+ // group shows it, any other group starts fresh (and the old summary is
242
+ // dropped). A run still in progress takes over regardless — one at a time.
243
+ const prev = peekBulk()
244
+ if (prev?.phase === "done" && prev.title !== title) clearBulk()
245
+ setBulkWpGroup({ title, sites: groupSites })
246
+ }
247
+ else if (focus === "groups") flashMsg("u updates a WordPress version group — pick a “WP x.y.z” row, or a site")
172
248
  return
173
249
  case "H":
174
250
  // Enable/disable HTTPS on the selected site (sites pane only).
@@ -224,6 +300,13 @@ export function Stacks({ rows }: { rows: number }) {
224
300
  })
225
301
 
226
302
  const listRows = Math.max(3, rows - 6 - SITE_CONTEXT_STRIP_HEIGHT)
303
+ // Version sub-rows can outgrow a short terminal — scroll the groups pane just
304
+ // enough to keep the selection visible (same viewport as the sites list).
305
+ // Only moves when the selection leaves the viewport, like List does.
306
+ let groupTop = Math.min(groupTopRef.current, Math.max(0, groups.length - listRows))
307
+ if (safeGroupIndex < groupTop) groupTop = safeGroupIndex
308
+ else if (safeGroupIndex >= groupTop + listRows) groupTop = safeGroupIndex - listRows + 1
309
+ groupTopRef.current = groupTop
227
310
  const maxPhp = Math.max(1, ...php.map(([, n]) => n))
228
311
 
229
312
  const hints =
@@ -231,6 +314,7 @@ export function Stacks({ rows }: { rows: number }) {
231
314
  ? [
232
315
  { key: "↑↓/jk", label: "select" },
233
316
  { key: "→/⏎", label: "view sites" },
317
+ ...(isVersionGroup ? [{ key: "u", label: `update ${groupSites.length} ${groupSites.length === 1 ? "site" : "sites"}` }] : []),
234
318
  { key: "D", label: "identify all" },
235
319
  { key: "S", label: "find local copies" },
236
320
  { key: "f", label: "needs local copy" },
@@ -239,7 +323,7 @@ export function Stacks({ rows }: { rows: number }) {
239
323
  { key: "↑↓/jk", label: "select site" },
240
324
  { key: "←/esc", label: "back" },
241
325
  { key: "d", label: "identify app" },
242
- { key: "u", label: "change PHP" },
326
+ { key: "u", label: "update" },
243
327
  { key: "o", label: "open" },
244
328
  { key: "w", label: "SpinupWP" },
245
329
  { key: "s", label: "ssh" },
@@ -257,7 +341,8 @@ export function Stacks({ rows }: { rows: number }) {
257
341
  {/* Composition groups (top-level buckets + Non-WP sub-rows) */}
258
342
  <Panel title={` Stacks (${sites.length}) `} active={focus === "groups"} width={36}>
259
343
  <box style={{ flexGrow: 1, flexDirection: "column" }}>
260
- {groups.map((grp, i) => {
344
+ {groups.slice(groupTop, groupTop + listRows).map((grp, j) => {
345
+ const i = groupTop + j
261
346
  const n = grp.sites.length
262
347
  const selected = i === safeGroupIndex
263
348
  const sel = selected && focus === "groups"
@@ -291,7 +376,7 @@ export function Stacks({ rows }: { rows: number }) {
291
376
  </Panel>
292
377
 
293
378
  {/* Sites in the selected group */}
294
- <Panel title={` ${selectedGroup?.label ?? "—"} · sites (${groupSites.length}) `} active={focus === "sites"} flexGrow={1}>
379
+ <Panel title={` ${selectedGroup?.title ?? selectedGroup?.label ?? "—"} · sites (${groupSites.length}) `} active={focus === "sites"} flexGrow={1}>
295
380
  <List
296
381
  items={groupSites}
297
382
  selectedIndex={siteIndex}
@@ -303,7 +388,13 @@ export function Stacks({ rows }: { rows: number }) {
303
388
  const cached = probes.get(s.id)
304
389
  const probing = probingIds.has(s.id)
305
390
  const errored = probeErrors.has(s.id)
391
+ const wpRunning = wpCoreJobs.get(s.id)?.result === null
306
392
  const faint = selected ? theme.text : theme.textFaint
393
+ // Vanity rows say so instead of the probe's "Unknown"/"Static HTML"
394
+ // (or "· press d" — there's nothing to identify).
395
+ const vanity =
396
+ effectiveStack(s, cached?.result.kind) === "Non-WP" &&
397
+ nonWpSub(cached?.result.kind ?? null, isVanityPair(s.domain, serverById(s.server_id)?.name ?? "")) === "vanity"
307
398
  const updates = (s.wp_plugin_updates || 0) + (s.wp_theme_updates || 0) + (s.wp_core_update ? 1 : 0)
308
399
  return (
309
400
  <>
@@ -318,8 +409,13 @@ export function Stacks({ rows }: { rows: number }) {
318
409
  <SiteMetaCell linked={localLinks.has(s.id)} updates={updates} selected={selected} />
319
410
  </box>
320
411
  <box style={{ flexShrink: 0, flexDirection: "row", marginLeft: 1 }}>
321
- {probing ? (
412
+ {wpRunning ? (
413
+ // A core update in flight replaces the version it's about to change.
414
+ <WpCoreRowMark siteId={s.id} selected={selected} />
415
+ ) : probing ? (
322
416
  <Spinner color={selected ? theme.text : theme.brand} />
417
+ ) : vanity ? (
418
+ <text content="Vanity Site" fg={selected ? theme.text : VANITY_COLOR} wrapMode="none" />
323
419
  ) : cached ? (
324
420
  <text
325
421
  content={truncate(cached.result.label, 20) + (isProbeStale(s) ? "*" : "")}
@@ -331,6 +427,8 @@ export function Stacks({ rows }: { rows: number }) {
331
427
  ) : (
332
428
  <text content="· press d" fg={faint} wrapMode="none" />
333
429
  )}
430
+ {/* Failed: keep the (unchanged) version, flag it — like PHP's ⬆!. */}
431
+ {!wpRunning && <WpCoreRowMark siteId={s.id} selected={selected} />}
334
432
  </box>
335
433
  <text
336
434
  content={truncate(serverById(s.server_id)?.name ?? "", 16)}
@@ -0,0 +1,322 @@
1
+ // WordPress core update — the "WordPress core" branch of the `u` Update menu.
2
+ //
3
+ // check (SSH, read-only) → pick a version → confirm → update → done/failed. The
4
+ // work itself lives in lib/wpCore.ts; the job is tracked in ../wpCoreJobs.tsx, so
5
+ // Esc doesn't abandon it: the SSH run keeps going, a toast reports the outcome,
6
+ // and reopening the overlay on the same site picks the job back up.
7
+
8
+ import { useEffect, useState } from "react"
9
+ import { useKeyboard } from "@opentui/react"
10
+ import { theme } from "../../lib/theme.ts"
11
+ import { truncate } from "../../lib/format.ts"
12
+ import { Panel, Spinner, Centered } from "../components.tsx"
13
+ import { StatusBar } from "../StatusBar.tsx"
14
+ import { useStore } from "../store.tsx"
15
+ import { moveSelection } from "../List.tsx"
16
+ import type { Site } from "../../api/types.ts"
17
+ import { checkWpCore, type WpCoreStatus } from "../../lib/wpCore.ts"
18
+ import { setCoreJob, startCoreUpdate, useWpCoreJob } from "../wpCoreJobs.tsx"
19
+
20
+ // ---- view -------------------------------------------------------------------
21
+
22
+ type Phase = "checking" | "checkError" | "upToDate" | "pick" | "confirm"
23
+
24
+ export function WpCoreUpdate({ site, onBack, onClose }: { site: Site; onBack: () => void; onClose: () => void }) {
25
+ const { serverById, sshUser, noteWpVersion } = useStore()
26
+ const server = serverById(site.server_id)
27
+ const job = useWpCoreJob(site.id)
28
+
29
+ const [phase, setPhase] = useState<Phase>("checking")
30
+ const [status, setStatus] = useState<WpCoreStatus | null>(null)
31
+ const [checkError, setCheckError] = useState("")
32
+ const [index, setIndex] = useState(0)
33
+ const [nonce, setNonce] = useState(0) // bump to re-check
34
+
35
+ useEffect(() => {
36
+ if (!server) {
37
+ setCheckError("This site's server isn't loaded.")
38
+ setPhase("checkError")
39
+ return
40
+ }
41
+ let live = true
42
+ setPhase("checking")
43
+ void checkWpCore(server, site, sshUser).then((res) => {
44
+ if (!live) return
45
+ if (!res.ok) {
46
+ setCheckError(`${res.error} (${res.target})`)
47
+ setPhase("checkError")
48
+ return
49
+ }
50
+ setStatus(res.status)
51
+ // A fresh live read — correct any stale row while we're here, update or not.
52
+ noteWpVersion(site, res.status.current)
53
+ // Land on the first minor (security/maintenance) release when there is one
54
+ // — the usual reason to be here — rather than a major jump.
55
+ const minor = res.status.offers.findIndex((o) => o.updateType === "minor")
56
+ setIndex(minor >= 0 ? minor : 0)
57
+ setPhase(res.status.offers.length ? "pick" : "upToDate")
58
+ })
59
+ return () => {
60
+ live = false
61
+ }
62
+ // Keyed on ids, not objects: a background store refresh hands back a new
63
+ // server object, and re-checking then would yank the user out of pick/confirm.
64
+ // eslint-disable-next-line react-hooks/exhaustive-deps
65
+ }, [server?.id, site.id, sshUser, nonce])
66
+
67
+ const offers = status?.offers ?? []
68
+ const target = offers[index]
69
+ // A job for this site overrides the local flow: running → progress; settled → its result.
70
+ const view: Phase | "running" | "done" | "failed" = job ? (job.result === null ? "running" : job.result.ok ? "done" : "failed") : phase
71
+
72
+ useKeyboard((key) => {
73
+ const name = key.name ?? ""
74
+ if (name === "escape" || name === "q") {
75
+ if (job?.result) setCoreJob(site.id, null) // the outcome's been seen — don't replay it next time
76
+ return onClose()
77
+ }
78
+ if (view === "done" || view === "failed") {
79
+ if (name === "r" || (view === "done" && (name === "left" || name === "h"))) {
80
+ setCoreJob(site.id, null)
81
+ setNonce((n) => n + 1)
82
+ }
83
+ return
84
+ }
85
+ if (view === "running") return
86
+ if (view === "checking" || view === "checkError" || view === "upToDate") {
87
+ if (name === "left" || name === "h") return onBack()
88
+ if (name === "r" && view !== "checking") setNonce((n) => n + 1)
89
+ return
90
+ }
91
+ if (view === "pick") {
92
+ switch (name) {
93
+ case "up":
94
+ case "k":
95
+ return setIndex((i) => moveSelection(i, -1, offers.length))
96
+ case "down":
97
+ case "j":
98
+ return setIndex((i) => moveSelection(i, 1, offers.length))
99
+ case "left":
100
+ case "h":
101
+ return onBack()
102
+ case "return":
103
+ case "right":
104
+ case "l":
105
+ if (target) setPhase("confirm")
106
+ return
107
+ }
108
+ return
109
+ }
110
+ if (view === "confirm") {
111
+ if (name === "y" && server && status && target) startCoreUpdate(server, site, sshUser, target.version, status.multisite, noteWpVersion)
112
+ else if (name === "left" || name === "h") setPhase("pick")
113
+ }
114
+ })
115
+
116
+ return (
117
+ <box
118
+ style={{
119
+ position: "absolute",
120
+ top: 0,
121
+ left: 0,
122
+ width: "100%",
123
+ height: "100%",
124
+ flexDirection: "column",
125
+ backgroundColor: theme.bg,
126
+ zIndex: 210,
127
+ }}
128
+ >
129
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
130
+ <text content="⬆ Update WordPress " fg={theme.brand} style={{ flexShrink: 0 }} />
131
+ <text content={truncate(site.domain, 40)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
132
+ <box style={{ flexGrow: 1 }} />
133
+ <text content={status ? `current WP ${status.current}` : ""} fg={theme.textFaint} style={{ flexShrink: 0 }} />
134
+ </box>
135
+
136
+ <Centered>{renderBody()}</Centered>
137
+
138
+ <StatusBar hints={hints()} showGlobal={false} />
139
+ </box>
140
+ )
141
+
142
+ function composerLines(): string[] {
143
+ if (!status?.composer) return []
144
+ const pin = status.composer.lockedVersion ? `composer.lock pins ${status.composer.lockedVersion}` : "Composer manages core"
145
+ return [
146
+ `Core is installed by Composer (${pin}). This patches the`,
147
+ "files on the server only — the next composer install (a deploy,",
148
+ "a clone) puts the locked version back. Run composer update and",
149
+ "commit the lock to make it stick.",
150
+ ]
151
+ }
152
+
153
+ function renderBody() {
154
+ if (view === "checking") {
155
+ return (
156
+ <box style={{ flexDirection: "row" }}>
157
+ <Spinner />
158
+ <text content=" Checking WordPress over SSH…" fg={theme.textDim} />
159
+ </box>
160
+ )
161
+ }
162
+
163
+ if (view === "checkError") {
164
+ return (
165
+ <Panel title=" Couldn't check WordPress " active>
166
+ <box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
167
+ <text content={`✕ ${checkError}`} fg={theme.bad} />
168
+ <box style={{ height: 1 }} />
169
+ <text content="r to retry · ← back · Esc to close" fg={theme.textFaint} wrapMode="none" />
170
+ </box>
171
+ </Panel>
172
+ )
173
+ }
174
+
175
+ if (view === "upToDate") {
176
+ return (
177
+ <Panel title=" Up to date " active>
178
+ <box style={{ flexDirection: "column", width: 56, paddingTop: 1, paddingBottom: 1 }}>
179
+ <box style={{ flexDirection: "row" }}>
180
+ <text content="✓ " fg={theme.good} />
181
+ <text content={`WordPress ${status?.current} is the latest version.`} fg={theme.text} wrapMode="none" />
182
+ </box>
183
+ <box style={{ height: 1 }} />
184
+ <text content="r to re-check · ← back · Esc to close" fg={theme.textFaint} wrapMode="none" />
185
+ </box>
186
+ </Panel>
187
+ )
188
+ }
189
+
190
+ if (view === "pick") {
191
+ return (
192
+ <Panel title=" Choose a WordPress version " active>
193
+ <box style={{ flexDirection: "column", width: 56 }}>
194
+ {offers.map((o, i) => {
195
+ const selected = i === index
196
+ const tag = o.updateType ? ` (${o.updateType})` : ""
197
+ return (
198
+ <box key={o.version} style={{ flexDirection: "row", height: 1, backgroundColor: selected ? theme.selectedBg : undefined }}>
199
+ <text content={(selected ? "❯ " : " ") + `WordPress ${o.version}`} fg={selected ? theme.text : theme.textDim} style={{ flexGrow: 1 }} wrapMode="none" />
200
+ <text content={tag} fg={selected ? theme.text : o.updateType === "major" ? theme.warn : theme.textFaint} style={{ flexShrink: 0 }} />
201
+ </box>
202
+ )
203
+ })}
204
+ <box style={{ height: 1 }} />
205
+ <text content={`Installed: WordPress ${status?.current}`} fg={theme.textFaint} wrapMode="none" />
206
+ {status?.composer ? <text content="Composer-managed core — see the note on the next screen." fg={theme.warn} wrapMode="none" /> : null}
207
+ </box>
208
+ </Panel>
209
+ )
210
+ }
211
+
212
+ if (view === "confirm" && target && status) {
213
+ return (
214
+ <Panel title=" Confirm update " active>
215
+ <box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
216
+ <box style={{ flexDirection: "row" }}>
217
+ <text content="Update " fg={theme.text} />
218
+ <text content={truncate(site.domain, 40)} fg={theme.accent} wrapMode="none" />
219
+ </box>
220
+ <box style={{ flexDirection: "row" }}>
221
+ <text content={`from WordPress ${status.current}`} fg={theme.textDim} />
222
+ <text content=" → " fg={theme.textFaint} />
223
+ <text content={`WordPress ${target.version}`} fg={theme.good} />
224
+ </box>
225
+ <box style={{ height: 1 }} />
226
+ <text content={`Runs wp core update, then the database upgrade${status.multisite ? " (network-wide)" : ""}.`} fg={theme.textDim} wrapMode="none" />
227
+ {target.updateType === "major" ? <text content="Major release — test plugins and themes against it." fg={theme.warn} wrapMode="none" /> : null}
228
+ {composerLines().length ? <box style={{ height: 1 }} /> : null}
229
+ {composerLines().map((l) => (
230
+ <text key={l} content={l} fg={theme.warn} wrapMode="none" />
231
+ ))}
232
+ <box style={{ height: 1 }} />
233
+ <text content="Press y to confirm · ← to go back · Esc to cancel" fg={theme.textFaint} wrapMode="none" />
234
+ </box>
235
+ </Panel>
236
+ )
237
+ }
238
+
239
+ if (view === "running") {
240
+ return (
241
+ <box style={{ flexDirection: "column", alignItems: "center" }}>
242
+ <box style={{ flexDirection: "row" }}>
243
+ <Spinner />
244
+ <text content={` Updating to WordPress ${job?.version}…`} fg={theme.textDim} />
245
+ </box>
246
+ <box style={{ height: 1 }} />
247
+ <text content="You can press Esc — it keeps running in the background." fg={theme.textFaint} wrapMode="none" />
248
+ </box>
249
+ )
250
+ }
251
+
252
+ if (view === "done" && job?.result?.ok) {
253
+ return (
254
+ <Panel title=" Done " active>
255
+ <box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
256
+ <box style={{ flexDirection: "row" }}>
257
+ <text content="✓ " fg={theme.good} />
258
+ <text content={truncate(site.domain, 32)} fg={theme.accent} wrapMode="none" />
259
+ <text content={` is now on WordPress ${job.result.after}`} fg={theme.text} wrapMode="none" />
260
+ </box>
261
+ <text content={`(was ${job.result.before})`} fg={theme.textFaint} wrapMode="none" />
262
+ {composerLines().length ? <box style={{ height: 1 }} /> : null}
263
+ {composerLines().map((l) => (
264
+ <text key={l} content={l} fg={theme.warn} wrapMode="none" />
265
+ ))}
266
+ <box style={{ height: 1 }} />
267
+ <text content="Esc to close" fg={theme.textFaint} />
268
+ </box>
269
+ </Panel>
270
+ )
271
+ }
272
+
273
+ // failed
274
+ const result = job?.result && !job.result.ok ? job.result : null
275
+ const tail = (result?.log ?? "").split("\n").filter(Boolean).slice(-8)
276
+ return (
277
+ <Panel title=" Update failed " active>
278
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
279
+ <text content={`✕ ${result?.error ?? "Something went wrong."}`} fg={theme.bad} />
280
+ {tail.length ? <box style={{ height: 1 }} /> : null}
281
+ {tail.map((l, i) => (
282
+ <text key={i} content={truncate(l, 74)} fg={theme.textDim} wrapMode="none" />
283
+ ))}
284
+ <box style={{ height: 1 }} />
285
+ <text content="Press r to re-check the site · Esc to close" fg={theme.textFaint} wrapMode="none" />
286
+ </box>
287
+ </Panel>
288
+ )
289
+ }
290
+
291
+ function hints() {
292
+ switch (view) {
293
+ case "pick":
294
+ return [
295
+ { key: "↑↓/jk", label: "version" },
296
+ { key: "⏎", label: "choose" },
297
+ { key: "←", label: "back" },
298
+ { key: "esc", label: "cancel" },
299
+ ]
300
+ case "confirm":
301
+ return [
302
+ { key: "y", label: "confirm" },
303
+ { key: "←", label: "back" },
304
+ { key: "esc", label: "cancel" },
305
+ ]
306
+ case "checkError":
307
+ case "upToDate":
308
+ return [
309
+ { key: "r", label: "re-check" },
310
+ { key: "←", label: "back" },
311
+ { key: "esc", label: "close" },
312
+ ]
313
+ case "failed":
314
+ return [
315
+ { key: "r", label: "re-check" },
316
+ { key: "esc", label: "close" },
317
+ ]
318
+ default:
319
+ return [{ key: "esc", label: "close" }]
320
+ }
321
+ }
322
+ }