spinuptui 0.24.4 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,256 @@
1
+ // Clone a brand-new local working copy from production — the guided
2
+ // alternative to `L`'s manual "enter a path" form, offered when a site has no
3
+ // local link yet. Opened from LocalLink.tsx's choose screen.
4
+ //
5
+ // Gets the code down (git clone, or an rsync pull over SSH excluding uploads/
6
+ // for non-git sites), runs `composer install` for Bedrock/Radicle, then links
7
+ // the result exactly like the manual form would. It deliberately stops there —
8
+ // the local database and webserver config are NOT touched here. On success it
9
+ // hands off to the existing dbSync (`p`) flow unchanged (which itself gates on
10
+ // `localSync` being enabled), then that flow's own done screen already nudges
11
+ // into mediaFallback (`m`). Three existing, already-reviewed screens chained
12
+ // by keypresses, not one new mega state machine.
13
+
14
+ import { useState } from "react"
15
+ import { useKeyboard } from "@opentui/react"
16
+ import { theme } from "../../lib/theme.ts"
17
+ import { truncate, middleTruncate } from "../../lib/format.ts"
18
+ import { Panel, Centered, DestPath, Steps, type StepRow } from "../components.tsx"
19
+ import { StatusBar } from "../StatusBar.tsx"
20
+ import { useStore } from "../store.tsx"
21
+ import type { LocalSetupStage } from "../../lib/localSetup.ts"
22
+
23
+ const SETUP_STEPS: { stage: LocalSetupStage; label: string }[] = [
24
+ { stage: "fetch", label: "Get the code (clone / pull)" },
25
+ { stage: "build", label: "composer install" },
26
+ ]
27
+
28
+ export function LocalSetupOverlay() {
29
+ const { localSetupSite: site, setLocalSetupSite, serverById, planLocalSetupFor, localSetups, startLocalSetup, clearLocalSetup, localSync, setEnableLocalSyncSite, setDbSyncSite } = useStore()
30
+
31
+ const progress = site ? localSetups.get(site.id) : undefined
32
+ const [path, setPath] = useState("")
33
+ const [url, setUrl] = useState("")
34
+ const [field, setField] = useState<"path" | "url">("path")
35
+ const [formError, setFormError] = useState<string | null>(null)
36
+
37
+ const dp: "form" | "running" | "done" | "error" =
38
+ !progress ? "form" : progress.stage === "done" ? "done" : progress.stage === "error" ? "error" : "running"
39
+
40
+ const close = () => {
41
+ if (site && (progress?.stage === "done" || progress?.stage === "error")) clearLocalSetup(site.id)
42
+ setLocalSetupSite(null)
43
+ }
44
+
45
+ const submit = () => {
46
+ if (!site) return
47
+ const res = planLocalSetupFor(site, path, url)
48
+ if (!res.ok) {
49
+ setFormError(res.error)
50
+ return
51
+ }
52
+ setFormError(null)
53
+ startLocalSetup(site, path, url)
54
+ }
55
+
56
+ const pullDb = () => {
57
+ if (!site) return
58
+ close()
59
+ if (!localSync) setEnableLocalSyncSite(site)
60
+ else setDbSyncSite(site)
61
+ }
62
+
63
+ useKeyboard((key) => {
64
+ const name = key.name ?? ""
65
+ if (name === "escape" || name === "q") return close()
66
+
67
+ if (dp === "form") {
68
+ if (name === "up") return setField("path")
69
+ if (name === "down") return setField("url")
70
+ return
71
+ }
72
+ if (dp === "error") {
73
+ if (name === "r" && site) {
74
+ clearLocalSetup(site.id)
75
+ setFormError(null)
76
+ }
77
+ return
78
+ }
79
+ if (dp === "done") {
80
+ if (name === "return" || name === "p") return pullDb()
81
+ return
82
+ }
83
+ })
84
+
85
+ if (!site) return null
86
+ const server = serverById(site.server_id)
87
+ const isGit = !!site.git?.repo
88
+
89
+ return (
90
+ <box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 210 }}>
91
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
92
+ <text content="⇩ Clone fullsite (DB + files) from production " fg={theme.brand} style={{ flexShrink: 0 }} />
93
+ <text content={truncate(site.domain, 38)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
94
+ </box>
95
+
96
+ <Centered>{renderBody()}</Centered>
97
+
98
+ <StatusBar hints={hints()} showGlobal={false} />
99
+ </box>
100
+ )
101
+
102
+ function renderBody() {
103
+ if (dp === "form") {
104
+ return (
105
+ <Panel title=" Clone from production " active>
106
+ <box style={{ flexDirection: "column", width: 68, paddingTop: 1, paddingBottom: 1 }}>
107
+ <box style={{ flexDirection: "row" }}>
108
+ <text content="from " fg={theme.textFaint} />
109
+ <text content={truncate(site!.domain, 30)} fg={theme.accent} wrapMode="none" />
110
+ <text content=" on " fg={theme.textDim} />
111
+ <text content={truncate(server?.name ?? "—", 22)} fg={theme.textDim} wrapMode="none" />
112
+ </box>
113
+ <text content={isGit ? "source: git clone (this site's repo)" : "source: file pull over SSH (excludes uploads/)"} fg={theme.textFaint} wrapMode="none" />
114
+ <box style={{ height: 1 }} />
115
+ <text content="Local path for the new working copy" fg={field === "path" ? theme.accent : theme.textDim} />
116
+ <input
117
+ focused={field === "path"}
118
+ value={path}
119
+ placeholder={`~/code/${site!.domain}`}
120
+ onInput={setPath}
121
+ onSubmit={() => setField("url")}
122
+ style={{ backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }}
123
+ />
124
+ <box style={{ height: 1 }} />
125
+ <text content="Local URL (optional) — Valet, Cove, LocalWP, Herd, DDEV…" fg={field === "url" ? theme.accent : theme.textDim} />
126
+ <input
127
+ focused={field === "url"}
128
+ value={url}
129
+ placeholder="https://example.test"
130
+ onInput={setUrl}
131
+ onSubmit={submit}
132
+ style={{ backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }}
133
+ />
134
+ {formError ? (
135
+ <>
136
+ <box style={{ height: 1 }} />
137
+ <text content={`✕ ${formError}`} fg={theme.bad} wrapMode="none" />
138
+ </>
139
+ ) : null}
140
+ <box style={{ height: 1 }} />
141
+ <text content="This only pulls code — your local DB/webserver aren't touched." fg={theme.textFaint} wrapMode="none" />
142
+ <text content="↑↓ switch field · Enter on the URL starts · Esc cancels" fg={theme.textFaint} wrapMode="none" />
143
+ </box>
144
+ </Panel>
145
+ )
146
+ }
147
+
148
+ // "build" (composer install) only applies to Bedrock/Radicle — unknown
149
+ // until the fetch stage finishes, so it stays out of the list entirely
150
+ // (like DbSync's optional hook row) until we actually know, rather than
151
+ // rendering a row that never ran as if it had.
152
+ const buildApplies =
153
+ progress?.stage === "build" ||
154
+ (progress?.stage === "done" && !!progress.ranComposer) ||
155
+ (progress?.stage === "error" && progress.failedStage === "build")
156
+ const steps = SETUP_STEPS.filter((s) => s.stage !== "build" || buildApplies)
157
+ const order = steps.map((s) => s.stage)
158
+ const curIdx = order.indexOf((progress?.stage ?? "fetch") as LocalSetupStage)
159
+ const failedIdx = progress?.failedStage ? order.indexOf(progress.failedStage) : order.length - 1
160
+ const rows: StepRow[] = steps.map(({ label }, i) => {
161
+ const state: StepRow["state"] =
162
+ dp === "done"
163
+ ? "done"
164
+ : dp === "error"
165
+ ? i < failedIdx
166
+ ? "done"
167
+ : i === failedIdx
168
+ ? "failed"
169
+ : "pending"
170
+ : i < curIdx
171
+ ? "done"
172
+ : i === curIdx
173
+ ? "active"
174
+ : "pending"
175
+ return { label, state }
176
+ })
177
+
178
+ return (
179
+ <Panel title=" Cloning from production " active>
180
+ <box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
181
+ <Steps rows={rows} />
182
+ <box style={{ height: 1 }} />
183
+ {dp === "done" ? (
184
+ <>
185
+ <box style={{ flexDirection: "row" }}>
186
+ <text content="✓ " fg={theme.good} />
187
+ <text content="Files are down and linked." fg={theme.text} wrapMode="none" />
188
+ </box>
189
+ <box style={{ height: 1 }} />
190
+ <DestPath path={middleTruncate(progress?.destPath ?? "", 62)} fileColor={theme.good} width={62} />
191
+ {progress?.ranComposer ? <text content="composer install ran" fg={theme.textFaint} wrapMode="none" /> : null}
192
+ <box style={{ height: 1 }} />
193
+ <text content="Next: create a local database and point your local tool at it." fg={theme.textFaint} wrapMode="none" />
194
+ {/* The file pull copies the site's real wp-config.php, production
195
+ DB credentials and all. Nothing local can reach production's
196
+ database through them (its DB_HOST is localhost), but local
197
+ wp-cli authenticates as the production user, so the DB pull
198
+ below is denied until they're localized. Say so here rather
199
+ than letting it surface as a bare "Access denied" later. */}
200
+ {isGit ? null : (
201
+ <>
202
+ <text content="wp-config.php came down with production's DB credentials —" fg={theme.warn} wrapMode="none" />
203
+ <text content="update DB_NAME / DB_USER / DB_PASSWORD / DB_HOST to match," fg={theme.warn} wrapMode="none" />
204
+ <text content="or the DB pull is denied as production's user." fg={theme.warn} wrapMode="none" />
205
+ </>
206
+ )}
207
+ {/* The mirror image on the git path: Bedrock/Radicle keep DB
208
+ credentials and WP_HOME in .env, which is deliberately not in
209
+ the repo — so a fresh clone has none and wp-cli runs with
210
+ empty settings. composer install having run is the signal. */}
211
+ {progress?.ranComposer ? (
212
+ <>
213
+ <text content=".env isn't in the repo, so this checkout has none — copy" fg={theme.warn} wrapMode="none" />
214
+ <text content=".env.example to .env and set DB_* + WP_HOME before pulling." fg={theme.warn} wrapMode="none" />
215
+ </>
216
+ ) : null}
217
+ <text content="Press ⏎ / p to pull production's database now, once that's ready." fg={theme.purple} wrapMode="none" />
218
+ <text content="Esc to finish here" fg={theme.textFaint} wrapMode="none" />
219
+ </>
220
+ ) : dp === "error" ? (
221
+ <>
222
+ <text content={`✕ ${progress?.error ?? "Something went wrong."}`} fg={theme.bad} />
223
+ <box style={{ height: 1 }} />
224
+ <text content="Press r to try again · Esc to close" fg={theme.textFaint} wrapMode="none" />
225
+ </>
226
+ ) : (
227
+ <text content="You can press Esc — it keeps running in the background." fg={theme.textFaint} wrapMode="none" />
228
+ )}
229
+ </box>
230
+ </Panel>
231
+ )
232
+ }
233
+
234
+ function hints() {
235
+ switch (dp) {
236
+ case "form":
237
+ return [
238
+ { key: "↑↓", label: "field" },
239
+ { key: "⏎", label: "start" },
240
+ { key: "esc", label: "cancel" },
241
+ ]
242
+ case "error":
243
+ return [
244
+ { key: "r", label: "retry" },
245
+ { key: "esc", label: "close" },
246
+ ]
247
+ case "done":
248
+ return [
249
+ { key: "⏎/p", label: "pull DB" },
250
+ { key: "esc", label: "close" },
251
+ ]
252
+ default:
253
+ return [{ key: "esc", label: "close" }]
254
+ }
255
+ }
256
+ }
@@ -9,11 +9,11 @@ import { useKeyboard } from "@opentui/react"
9
9
  import { theme, statusColor, statusDot } from "../../lib/theme.ts"
10
10
  import { classifyStack, stackColor, stackTag } from "../../lib/stack.ts"
11
11
  import { truncate } from "../../lib/format.ts"
12
- import { Panel, Field, StatusBadge, SiteMetaCell, Spinner, ControlPanel, siteGroups } from "../components.tsx"
12
+ import { Panel, SiteMetaCell, Spinner } from "../components.tsx"
13
13
  import { isDbBackupInFlight } from "../../lib/dbBackup.ts"
14
14
  import { isDbSyncInFlight } from "../../lib/dbSync.ts"
15
15
  import { List, moveSelection } from "../List.tsx"
16
- import { ServerDetail, SiteDetail, SERVER_CONTROL } from "../Details.tsx"
16
+ import { ServerDetail, SiteDetail, ControlStrip, CONTROL_STRIP_HEIGHT } from "../Details.tsx"
17
17
  import { StatusBar } from "../StatusBar.tsx"
18
18
  import { openUrl } from "../../lib/open.ts"
19
19
  import { serverWebUrl, siteWebUrl } from "../../lib/spinupweb.ts"
@@ -35,11 +35,12 @@ function score(haystack: string, q: string): number | null {
35
35
 
36
36
  export function Search({ rows }: { rows: number }) {
37
37
  const store = useStore()
38
- const { servers, sites, serverById, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setServerActionsServer, accountSlug, localLinks, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setDbBackupSite, dbBackups, setDbSyncSite, dbSyncs, localSync, setMediaFallbackSite, beginClone, setSudoConnectServer, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setWpInventorySite, setGrantKeySite, runProbe, setEnableLocalSyncSite } = store
38
+ const { servers, sites, serverById, sitesForServer, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setServerActionsServer, accountSlug, localLinks, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setDbBackupSite, dbBackups, setDbSyncSite, dbSyncs, localSync, setMediaFallbackSite, beginClone, setSudoConnectServer, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setWpInventorySite, setGrantKeySite, runProbe, setEnableLocalSyncSite, setVanityServer, vanityJob } = store
39
39
  const [query, setQuery] = useState("")
40
40
  const [selected, setSelected] = useState(0)
41
41
  // "query" = typing/filtering (input focused); "actions" = input blurred so the
42
- // selected result's single-key actions (o/w/u/h) fire. Tab/→ enters, ←/Esc exits.
42
+ // selected result's single-key actions fire — listed in the Site/Server
43
+ // Control drawer below, same as the Servers tab. Tab/→ enters, ←/Esc exits.
43
44
  const [focus, setFocus] = useState<"query" | "actions">("query")
44
45
  const [flash, setFlash] = useState<string | null>(null)
45
46
  const flashMsg = (m: string) => {
@@ -160,11 +161,15 @@ export function Search({ rows }: { rows: number }) {
160
161
  case "L":
161
162
  if (current?.kind === "site") setLocalLinkSite(current.site)
162
163
  return
163
- case "K":
164
- // Grant Spinup's machine key to the selected site over SSH — matches
165
- // Browser's K binding, which was already in the shared legend here.
166
- if (current?.kind === "site") setGrantKeySite(current.site)
164
+ case "K": {
165
+ // Grant Spinup's machine key over SSH — matches Browser's K binding,
166
+ // which is site-anchored but works on a server result too (falls back
167
+ // to the server's first key-eligible site, mirroring Browser.tsx).
168
+ const anchor =
169
+ current?.kind === "site" ? current.site : current?.kind === "server" ? sitesForServer(current.server.id).find((s) => s.site_user) : undefined
170
+ if (anchor) setGrantKeySite(anchor)
167
171
  return
172
+ }
168
173
  case "s":
169
174
  if (current?.kind === "site") flashMsg(sshSite(current.site.id))
170
175
  return
@@ -262,6 +267,17 @@ export function Search({ rows }: { rows: number }) {
262
267
  case "N":
263
268
  if (current?.kind === "server") setDnsInventoryServer(current.server)
264
269
  return
270
+ case "V": {
271
+ // Create the vanity site at the server's own hostname, or resume an
272
+ // unfinished build — same gating as Browser's V.
273
+ if (current?.kind === "server") {
274
+ const s = current.server
275
+ const resumable = vanityJob && vanityJob.step !== "done" && vanityJob.serverId === s.id
276
+ const hasVanity = sitesForServer(s.id).some((site) => site.domain.toLowerCase() === s.name.toLowerCase())
277
+ if (resumable || !hasVanity) setVanityServer(s)
278
+ }
279
+ return
280
+ }
265
281
  }
266
282
  })
267
283
 
@@ -273,22 +289,14 @@ export function Search({ rows }: { rows: number }) {
273
289
  { key: "Tab/→", label: "actions" },
274
290
  { key: "esc", label: "dashboard" },
275
291
  ]
276
- : current?.kind === "server"
277
- ? [
278
- { key: "↑↓", label: "select" },
279
- { key: "w", label: "SpinupWP" },
280
- { key: "a", label: "actions" },
281
- { key: "h", label: "health" },
282
- { key: "←/esc", label: "back" },
283
- ]
284
- : [
285
- // The Actions panel (Details pane) already lists every per-site key —
286
- // this footer is nav-only now, so the two can't drift out of sync.
287
- { key: "↑↓", label: "select" },
288
- { key: "←/esc", label: "back" },
289
- ]
292
+ : [
293
+ // The Control drawer below already lists every key for the selected
294
+ // result — this footer stays nav-only so the two can't drift out of sync.
295
+ { key: "↑↓", label: "select" },
296
+ { key: "←/esc", label: "back" },
297
+ ]
290
298
 
291
- const listRows = Math.max(3, rows - 8) // input box (3) + status bar (1) + chrome
299
+ const listRows = Math.max(3, rows - 9 - CONTROL_STRIP_HEIGHT) // input box (3) + status bar (1) + chrome + control drawer
292
300
 
293
301
  return (
294
302
  <box style={{ flexGrow: 1, flexDirection: "column" }}>
@@ -374,14 +382,9 @@ export function Search({ rows }: { rows: number }) {
374
382
  />
375
383
  </Panel>
376
384
 
377
- <Panel title={focus === "actions" ? " Actions " : " Details "} active={focus === "actions"} width={64}>
385
+ <Panel title=" Details " width={64}>
378
386
  {!current ? (
379
387
  <text content="No selection" fg={theme.textFaint} />
380
- ) : focus === "actions" ? (
381
- <ActionsCard
382
- result={current}
383
- serverName={current.kind === "site" ? (serverById(current.site.server_id)?.name ?? "—") : current.server.name}
384
- />
385
388
  ) : current.kind === "server" ? (
386
389
  <ServerDetail server={current.server} siteCount={store.sitesForServer(current.server.id).length} />
387
390
  ) : (
@@ -390,44 +393,13 @@ export function Search({ rows }: { rows: number }) {
390
393
  </Panel>
391
394
  </box>
392
395
 
393
- <StatusBar hints={hints} message={flash ?? undefined} showGlobal={false} />
394
- </box>
395
- )
396
- }
396
+ <ControlStrip
397
+ site={current?.kind === "site" ? current.site : null}
398
+ server={current?.kind === "server" ? current.server : null}
399
+ serverName={current?.kind === "site" ? (serverById(current.site.server_id)?.name ?? "—") : current?.kind === "server" ? current.server.name : "—"}
400
+ />
397
401
 
398
- function ActionsCard({ result, serverName }: { result: Result; serverName: string }) {
399
- const isSite = result.kind === "site"
400
- const name = isSite ? result.site.domain : result.server.name
401
- const status = isSite ? result.site.status : result.server.connection_status
402
- // Server results reuse the SAME Server Control suite as the Browser detail pane.
403
- const groups = isSite ? siteGroups(result.site.is_wordpress, isVanityPair(result.site.domain, serverName)) : SERVER_CONTROL
404
- return (
405
- <box style={{ flexDirection: "column" }}>
406
- <box style={{ flexDirection: "row" }}>
407
- <text content={truncate(name, 30)} fg={theme.text} attributes={1} wrapMode="none" />
408
- <box style={{ flexGrow: 1 }} />
409
- <StatusBadge status={status} />
410
- </box>
411
- {isSite ? (
412
- <>
413
- <text content={(result.site.https?.enabled ? "https://" : "http://") + result.site.domain} fg={theme.accent} wrapMode="none" />
414
- <box style={{ height: 1 }} />
415
- <Field label="Server" value={truncate(serverName, 28)} labelWidth={8} />
416
- <Field label="PHP" value={result.site.php_version ?? "—"} labelWidth={8} />
417
- <Field label="Stack" value={classifyStack(result.site)} valueColor={stackColor(classifyStack(result.site))} labelWidth={8} />
418
- </>
419
- ) : (
420
- <>
421
- <text content={result.server.ip_address ?? "—"} fg={theme.accent} wrapMode="none" />
422
- <box style={{ height: 1 }} />
423
- <Field label="Provider" value={result.server.provider_name ?? "—"} labelWidth={9} />
424
- <Field label="Region" value={result.server.region ?? "—"} labelWidth={9} />
425
- </>
426
- )}
427
- <box style={{ height: 1 }} />
428
- <ControlPanel heading={isSite ? "Site Control" : "Server Control"} groups={groups} />
429
- <box style={{ height: 1 }} />
430
- <text content="↑↓ select · ←/Esc back to search" fg={theme.textFaint} wrapMode="none" />
402
+ <StatusBar hints={hints} message={flash ?? undefined} showGlobal={false} />
431
403
  </box>
432
404
  )
433
405
  }
@@ -15,6 +15,9 @@ import { Panel, Spinner, Centered } from "../components.tsx"
15
15
  import { StatusBar } from "../StatusBar.tsx"
16
16
  import { useStore } from "../store.tsx"
17
17
  import { moveSelection } from "../List.tsx"
18
+ import { openUrl } from "../../lib/open.ts"
19
+ import { serverWebUrl } from "../../lib/spinupweb.ts"
20
+ import { toast } from "../toast.ts"
18
21
  import type { RebootInfo } from "../../lib/ssh.ts"
19
22
  import type { ServerOpKind } from "../store.tsx"
20
23
 
@@ -56,9 +59,11 @@ export function ServerActions() {
56
59
  rebootInfoLoading,
57
60
  rebootInfoErrors,
58
61
  loadRebootInfo,
62
+ accountSlug,
59
63
  } = store
60
64
 
61
65
  const rebootRequired = !!server?.reboot_required
66
+ const upgradeRequired = !!server?.upgrade_required
62
67
  const [phase, setPhase] = useState<Phase>("pick")
63
68
  // Cursor starts on Reboot when one is pending, else on the first restart.
64
69
  const [index, setIndex] = useState(() => (server?.reboot_required ? 0 : 1))
@@ -108,6 +113,12 @@ export function ServerActions() {
108
113
  setTarget(ACTIONS[index])
109
114
  setPhase("confirm")
110
115
  return
116
+ case "w":
117
+ if (upgradeRequired && server) {
118
+ openUrl(serverWebUrl(server.id, accountSlug))
119
+ toast.success(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
120
+ }
121
+ return
111
122
  }
112
123
  return
113
124
  }
@@ -179,17 +190,17 @@ export function ServerActions() {
179
190
  {/* Reboot "why" context */}
180
191
  {rebootRequired && (
181
192
  <>
182
- <box style={{ flexDirection: "row", height: 1 }}>
183
- <text content="⚠ reboot pending — " fg={theme.warn} style={{ flexShrink: 0 }} />
193
+ <box style={{ flexDirection: "column" }}>
194
+ <text content="⚠ reboot pending" fg={theme.warn} wrapMode="none" />
184
195
  {infoLoading ? (
185
196
  <box style={{ flexDirection: "row" }}>
186
197
  <Spinner interval={120} />
187
198
  <text content=" checking what's pending…" fg={theme.textDim} wrapMode="none" />
188
199
  </box>
189
200
  ) : info ? (
190
- <text content={rebootSummary(info)} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
201
+ <text content={rebootSummary(info)} fg={theme.textDim} />
191
202
  ) : infoError ? (
192
- <text content="couldn't read detail over SSH" fg={theme.textFaint} wrapMode="none" />
203
+ <text content={infoError} fg={theme.textFaint} />
193
204
  ) : (
194
205
  <text content="reboot required" fg={theme.textDim} wrapMode="none" />
195
206
  )}
@@ -197,6 +208,20 @@ export function ServerActions() {
197
208
  <box style={{ height: 1 }} />
198
209
  </>
199
210
  )}
211
+ {/* SpinupWP platform upgrade — the API only exposes this as a flag,
212
+ no detail; the only actionable path is the web app. Two lines
213
+ (not a height:1 row) so the explanation can wrap instead of
214
+ overflowing the panel — see the flexGrow/flexShrink gotcha in
215
+ CLAUDE.md. */}
216
+ {upgradeRequired && (
217
+ <>
218
+ <box style={{ flexDirection: "column" }}>
219
+ <text content="⬆ SpinupWP upgrade pending" fg={theme.warn} wrapMode="none" />
220
+ <text content="Not doable from here — press w to open it in SpinupWP." fg={theme.textDim} />
221
+ </box>
222
+ <box style={{ height: 1 }} />
223
+ </>
224
+ )}
200
225
  {ACTIONS.map((a, i) => {
201
226
  const selected = i === index
202
227
  const recommended = a.kind === "reboot" && rebootRequired
@@ -308,6 +333,7 @@ export function ServerActions() {
308
333
  return [
309
334
  { key: "↑↓/jk", label: "action" },
310
335
  { key: "⏎", label: "choose" },
336
+ ...(upgradeRequired ? [{ key: "w", label: "open in SpinupWP" }] : []),
311
337
  { key: "esc", label: "cancel" },
312
338
  ]
313
339
  case "confirm":