spinuptui 0.24.3 → 0.25.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 CHANGED
@@ -11,6 +11,41 @@ versions; such changes are called out here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.25.0] - 2026-08-12
15
+
16
+ ### Added
17
+ - **Server actions now surfaces a pending SpinupWP platform upgrade**, not just
18
+ a silent badge. The overlay (`a`) shows a banner explaining the API only
19
+ exposes it as a flag with no actionable detail, and `w` jumps straight to the
20
+ server's page in the SpinupWP web app to run it.
21
+ - **App version on the splash screen**, shown below the tagline while the first
22
+ batch of data loads.
23
+
24
+ ### Fixed
25
+ - **The reboot-pending detail line now shows the real reason when the SSH probe
26
+ fails**, instead of a generic "couldn't read detail over SSH" message — e.g.
27
+ an SSH auth failure now shows the actual error. Both this and the new upgrade
28
+ banner wrap across lines instead of overflowing the panel on longer text.
29
+
30
+ ## [0.24.4] - 2026-08-06
31
+
32
+ ### Fixed
33
+ - **The `Unknown component type: toaster` startup crash is now actually
34
+ fixed for real installs**, not just local project installs. `0.24.3`'s
35
+ `package.json` `overrides` only take effect when this repo's own
36
+ `package.json` is the root of the install — they're silently ignored when
37
+ spinuptui is installed as a dependency of something else, which is exactly
38
+ what a global `bun add -g` / `npm install -g` does, so the crash persisted
39
+ for every real user. Root cause traces back to the toast library
40
+ (`@opentui-ui/toast`) itself: it's stuck on a peer-dependency range from
41
+ opentui's `0.1.x` days, and no version pin on our side can bind how a
42
+ downstream install resolves *its* transitive dependencies. Every call site
43
+ in this app only ever fires a single-line success message, so it's
44
+ replaced with a small first-party toast implementation (`src/ui/toast.ts`
45
+ + `src/ui/ToastStack.tsx`) built directly on the `@opentui/core`/
46
+ `@opentui/react` we already depend on — no external package, no peer-
47
+ dependency surface for a future version bump to break again.
48
+
14
49
  ## [0.24.3] - 2026-08-06
15
50
 
16
51
  ### Fixed
@@ -1280,7 +1315,9 @@ Initial tagged release.
1280
1315
  ### Notes
1281
1316
  - Read-only release: works with a SpinupWP **Read Only** API token.
1282
1317
 
1283
- [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.24.3...HEAD
1318
+ [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.25.0...HEAD
1319
+ [0.25.0]: https://github.com/mwender/spinupwp-tui/compare/v0.24.4...v0.25.0
1320
+ [0.24.4]: https://github.com/mwender/spinupwp-tui/compare/v0.24.3...v0.24.4
1284
1321
  [0.24.3]: https://github.com/mwender/spinupwp-tui/compare/v0.24.2...v0.24.3
1285
1322
  [0.24.2]: https://github.com/mwender/spinupwp-tui/compare/v0.24.1...v0.24.2
1286
1323
  [0.24.1]: https://github.com/mwender/spinupwp-tui/compare/v0.24.0...v0.24.1
package/README.md CHANGED
@@ -320,8 +320,10 @@ Full details: [docs/php-upgrade.md](docs/php-upgrade.md).
320
320
  ## Server actions
321
321
 
322
322
  `a` on a server reboots it or restarts a service (Nginx/PHP-FPM/MySQL/Redis),
323
- and shows *why* a reboot is pending (the actual OS packages, read over SSH).
324
- Needs a Read/Write token. Full details: [docs/server-actions.md](docs/server-actions.md).
323
+ shows *why* a reboot is pending (the actual OS packages, read over SSH), and for
324
+ a pending SpinupWP platform upgrade — which the API can't act on — offers `w` to
325
+ jump straight to it in the SpinupWP web app. Needs a Read/Write token. Full
326
+ details: [docs/server-actions.md](docs/server-actions.md).
325
327
 
326
328
  ## Site monitoring
327
329
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "0.24.3",
3
+ "version": "0.25.0",
4
4
  "description": "SpinupTUI — a terminal dashboard for browsing, monitoring, and managing your SpinupWP servers and sites.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,9 +41,8 @@
41
41
  "author": "Michael Wender",
42
42
  "license": "MIT",
43
43
  "dependencies": {
44
- "@opentui-ui/toast": "^0.0.5",
45
- "@opentui/core": "0.4.2",
46
- "@opentui/react": "0.4.2",
44
+ "@opentui/core": "^0.4.1",
45
+ "@opentui/react": "^0.4.1",
47
46
  "react": "^19.0.0",
48
47
  "socket.io-client": "^4.8.3"
49
48
  },
@@ -51,9 +50,5 @@
51
50
  "@types/bun": "^1.3.14",
52
51
  "@types/react": "^19.0.0",
53
52
  "typescript": "^5.6.0"
54
- },
55
- "overrides": {
56
- "@opentui/core": "0.4.2",
57
- "@opentui/react": "0.4.2"
58
53
  }
59
54
  }
package/src/ui/App.tsx CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useEffect, useState } from "react"
4
4
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
5
- import { Toaster } from "@opentui-ui/toast/react"
5
+ import { ToastStack } from "./ToastStack.tsx"
6
6
  import { theme } from "../lib/theme.ts"
7
7
  import { useStore } from "./store.tsx"
8
8
  import { Splash } from "./Splash.tsx"
@@ -262,10 +262,7 @@ export function App() {
262
262
  {store.connectZoneTarget && <ProviderConnect />}
263
263
  {store.dnsRecordsTarget && <DnsRecords />}
264
264
  {store.kumaSite && <KumaSite />}
265
- {/* Async-completion toasts (PHP upgrade, server reboot/restart). Mounted last
266
- so it draws over every view + overlay; top-right, nudged clear of the
267
- 2-row Header. It never takes keyboard focus. */}
268
- <Toaster position="top-right" offset={{ top: 2, right: 2 }} />
265
+ <ToastStack />
269
266
  </box>
270
267
  )
271
268
  }
package/src/ui/Splash.tsx CHANGED
@@ -4,6 +4,7 @@
4
4
  import { useEffect, useState } from "react"
5
5
  import { theme } from "../lib/theme.ts"
6
6
  import { Spinner } from "./components.tsx"
7
+ import { APP_VERSION } from "../version.ts"
7
8
 
8
9
  const TAGLINE = "Terminal control center for your SpinupWP fleet"
9
10
 
@@ -40,6 +41,8 @@ export function Splash({ status }: { status: string }) {
40
41
  <box style={{ height: 1 }} />
41
42
  <text content={TAGLINE} fg={theme.text} />
42
43
  <box style={{ height: 1 }} />
44
+ <text content={`Version ${APP_VERSION}`} fg={theme.brand} />
45
+ <box style={{ height: 1 }} />
43
46
  <Pulse />
44
47
  <box style={{ height: 2 }} />
45
48
  <box style={{ flexDirection: "row" }}>
@@ -0,0 +1,33 @@
1
+ // Renders the toast queue from ./toast.ts. See that file for why this is
2
+ // first-party instead of a dependency. Mounted last in App.tsx so it draws
3
+ // over every view + overlay; top-right, nudged clear of the 2-row Header.
4
+ // It never takes keyboard focus.
5
+
6
+ import { useSyncExternalStore } from "react"
7
+ import { theme } from "../lib/theme.ts"
8
+ import { getToasts, subscribeToasts } from "./toast.ts"
9
+
10
+ const TOAST_WIDTH = 60
11
+
12
+ export function ToastStack() {
13
+ const toasts = useSyncExternalStore(subscribeToasts, getToasts, getToasts)
14
+
15
+ if (toasts.length === 0) return null
16
+
17
+ return (
18
+ <box style={{ position: "absolute", top: 2, right: 2, flexDirection: "column", gap: 1, zIndex: 300 }}>
19
+ {toasts.map((t) => (
20
+ <box
21
+ key={t.id}
22
+ border
23
+ borderColor={theme.good}
24
+ backgroundColor={theme.bgPanel}
25
+ style={{ flexDirection: "row", gap: 1, maxWidth: TOAST_WIDTH, paddingLeft: 1, paddingRight: 1 }}
26
+ >
27
+ <text content="✓" fg={theme.good} />
28
+ <text content={t.message} fg={theme.text} />
29
+ </box>
30
+ ))}
31
+ </box>
32
+ )
33
+ }
package/src/ui/store.tsx CHANGED
@@ -6,7 +6,7 @@
6
6
  // trigger a refresh.
7
7
 
8
8
  import { createContext, useContext, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"
9
- import { toast } from "@opentui-ui/toast"
9
+ import { toast } from "./toast.ts"
10
10
  import { SpinupWPClient, ApiError, type ServerService, type SpinupWPClientLike } from "../api/client.ts"
11
11
  import { isDevMode } from "../dev/devMode.ts"
12
12
  import { createMockClient } from "../dev/mockClient.ts"
@@ -0,0 +1,49 @@
1
+ // Minimal first-party toast notifications.
2
+ //
3
+ // Replaces the @opentui-ui/toast package, which is stuck on a peer-dependency
4
+ // range from opentui's 0.1.x days and breaks against the @opentui/core 0.4.x
5
+ // line we're on (see CHANGELOG v0.24.2/v0.24.3). Every call site in this app
6
+ // only ever fires a single-line success message, so a small first-party
7
+ // implementation is simpler — and immune to upstream drift — than depending
8
+ // on a package built for a much larger feature set we don't use.
9
+ //
10
+ // A plain module-level subscriber list (rather than React state) so callers
11
+ // outside the component tree — store.tsx's async completion handlers — can
12
+ // fire a toast without needing a ref or context. ToastStack.tsx reads this
13
+ // via useSyncExternalStore.
14
+
15
+ export interface ToastItem {
16
+ id: number
17
+ message: string
18
+ }
19
+
20
+ const TOAST_DURATION_MS = 4000
21
+
22
+ let nextId = 1
23
+ let toasts: ToastItem[] = []
24
+ const listeners = new Set<() => void>()
25
+
26
+ function notify() {
27
+ for (const listener of listeners) listener()
28
+ }
29
+
30
+ export function subscribeToasts(listener: () => void): () => void {
31
+ listeners.add(listener)
32
+ return () => listeners.delete(listener)
33
+ }
34
+
35
+ export function getToasts(): ToastItem[] {
36
+ return toasts
37
+ }
38
+
39
+ export const toast = {
40
+ success(message: string) {
41
+ const id = nextId++
42
+ toasts = [...toasts, { id, message }]
43
+ notify()
44
+ setTimeout(() => {
45
+ toasts = toasts.filter((t) => t.id !== id)
46
+ notify()
47
+ }, TOAST_DURATION_MS)
48
+ },
49
+ }
@@ -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":