spinuptui 0.19.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +943 -0
  2. package/LICENSE +21 -0
  3. package/README.md +856 -0
  4. package/bin/spinuptui.js +13 -0
  5. package/package.json +55 -0
  6. package/src/api/client.ts +354 -0
  7. package/src/api/types.ts +206 -0
  8. package/src/config.ts +309 -0
  9. package/src/dev/devMode.ts +7 -0
  10. package/src/dev/fixtures.ts +238 -0
  11. package/src/dev/mockClient.ts +214 -0
  12. package/src/index.tsx +68 -0
  13. package/src/lib/appUpdate.ts +206 -0
  14. package/src/lib/awsSigV4.ts +133 -0
  15. package/src/lib/cloneDomains.ts +112 -0
  16. package/src/lib/cloneLog.ts +85 -0
  17. package/src/lib/dbBackup.ts +198 -0
  18. package/src/lib/dbSync.ts +253 -0
  19. package/src/lib/discover.ts +165 -0
  20. package/src/lib/dns.ts +144 -0
  21. package/src/lib/dnsCache.ts +94 -0
  22. package/src/lib/dnsQuery.ts +180 -0
  23. package/src/lib/dnsRecords.ts +411 -0
  24. package/src/lib/format.ts +106 -0
  25. package/src/lib/gitDeployKey.ts +0 -0
  26. package/src/lib/gitStatus.ts +37 -0
  27. package/src/lib/jobs.ts +32 -0
  28. package/src/lib/keychain.ts +49 -0
  29. package/src/lib/local.ts +116 -0
  30. package/src/lib/mediaFallback.ts +288 -0
  31. package/src/lib/open.ts +103 -0
  32. package/src/lib/phpEol.ts +151 -0
  33. package/src/lib/probe.ts +183 -0
  34. package/src/lib/providers.ts +284 -0
  35. package/src/lib/providersCache.ts +105 -0
  36. package/src/lib/releaseNotes.ts +67 -0
  37. package/src/lib/serverClone.ts +782 -0
  38. package/src/lib/serverCreate.ts +161 -0
  39. package/src/lib/siteDoctor.ts +260 -0
  40. package/src/lib/siteFingerprint.ts +108 -0
  41. package/src/lib/spinupweb.ts +33 -0
  42. package/src/lib/ssh.ts +669 -0
  43. package/src/lib/stack.ts +78 -0
  44. package/src/lib/stackCache.ts +113 -0
  45. package/src/lib/theme.ts +54 -0
  46. package/src/lib/ubuntuEol.ts +124 -0
  47. package/src/lib/uptimeKuma.ts +551 -0
  48. package/src/lib/vanitySite.ts +297 -0
  49. package/src/lib/wpInventory.ts +153 -0
  50. package/src/ui/App.tsx +265 -0
  51. package/src/ui/Details.tsx +403 -0
  52. package/src/ui/Explain.tsx +158 -0
  53. package/src/ui/Header.tsx +117 -0
  54. package/src/ui/Help.tsx +296 -0
  55. package/src/ui/List.tsx +95 -0
  56. package/src/ui/Onboarding.tsx +93 -0
  57. package/src/ui/Splash.tsx +51 -0
  58. package/src/ui/StatusBar.tsx +60 -0
  59. package/src/ui/components.tsx +392 -0
  60. package/src/ui/store.tsx +4253 -0
  61. package/src/ui/views/Browser.tsx +365 -0
  62. package/src/ui/views/CloneWizard.tsx +932 -0
  63. package/src/ui/views/Dashboard.tsx +149 -0
  64. package/src/ui/views/DbBackup.tsx +228 -0
  65. package/src/ui/views/DbSync.tsx +307 -0
  66. package/src/ui/views/Discover.tsx +271 -0
  67. package/src/ui/views/DnsInventory.tsx +580 -0
  68. package/src/ui/views/DnsRecords.tsx +562 -0
  69. package/src/ui/views/Events.tsx +98 -0
  70. package/src/ui/views/Forgotten.tsx +165 -0
  71. package/src/ui/views/GrantKey.tsx +504 -0
  72. package/src/ui/views/Health.tsx +322 -0
  73. package/src/ui/views/HttpsToggle.tsx +195 -0
  74. package/src/ui/views/KumaSite.tsx +571 -0
  75. package/src/ui/views/LocalLink.tsx +220 -0
  76. package/src/ui/views/MediaFallback.tsx +180 -0
  77. package/src/ui/views/NewServer.tsx +750 -0
  78. package/src/ui/views/PhpUpgrade.tsx +301 -0
  79. package/src/ui/views/ProviderConnect.tsx +527 -0
  80. package/src/ui/views/PurgeCache.tsx +186 -0
  81. package/src/ui/views/ReleaseNotes.tsx +84 -0
  82. package/src/ui/views/Search.tsx +448 -0
  83. package/src/ui/views/ServerActions.tsx +328 -0
  84. package/src/ui/views/Stacks.tsx +371 -0
  85. package/src/ui/views/SudoConnect.tsx +358 -0
  86. package/src/ui/views/VanityNewSite.tsx +407 -0
  87. package/src/ui/views/WpInventory.tsx +218 -0
  88. package/src/version.ts +8 -0
@@ -0,0 +1,93 @@
1
+ // First-run onboarding wizard.
2
+ //
3
+ // Shown when no token is found in the environment or config file. Collects an
4
+ // API token, validates it against the live API, persists it to ~/.config, then
5
+ // hands control back to the app.
6
+
7
+ import { useState } from "react"
8
+ import { theme } from "../lib/theme.ts"
9
+ import { Spinner, SecretInput } from "./components.tsx"
10
+ import { SpinupWPClient } from "../api/client.ts"
11
+ import { DEFAULT_BASE_URL, saveConfig, configPath } from "../config.ts"
12
+
13
+ type Phase = "input" | "validating" | "error"
14
+
15
+ export function Onboarding({ onComplete }: { onComplete: () => void }) {
16
+ const [value, setValue] = useState("")
17
+ const [phase, setPhase] = useState<Phase>("input")
18
+ const [error, setError] = useState<string | null>(null)
19
+
20
+ async function submit(token: string) {
21
+ const trimmed = token.trim()
22
+ if (!trimmed) return
23
+ setPhase("validating")
24
+ setError(null)
25
+ const client = new SpinupWPClient({ token: trimmed, baseUrl: DEFAULT_BASE_URL })
26
+ const result = await client.validateToken()
27
+ if (result.ok) {
28
+ await saveConfig({ token: trimmed, baseUrl: DEFAULT_BASE_URL })
29
+ onComplete()
30
+ } else {
31
+ setError(result.reason)
32
+ setPhase("error")
33
+ }
34
+ }
35
+
36
+ return (
37
+ <box
38
+ style={{
39
+ width: "100%",
40
+ height: "100%",
41
+ flexDirection: "column",
42
+ justifyContent: "center",
43
+ alignItems: "center",
44
+ backgroundColor: theme.bg,
45
+ }}
46
+ >
47
+ <ascii-font text="SPINUPTUI" font="block" color={[theme.brand, theme.accent]} />
48
+ <box style={{ height: 1 }} />
49
+ <box
50
+ title=" Welcome "
51
+ titleColor={theme.brand}
52
+ border
53
+ borderColor={theme.border}
54
+ style={{ flexDirection: "column", width: 70, paddingLeft: 2, paddingRight: 2, paddingTop: 1, paddingBottom: 1 }}
55
+ >
56
+ <text content="Let's connect this tool to your SpinupWP account." fg={theme.text} />
57
+ <box style={{ height: 1 }} />
58
+ <text content="1. Open https://spinupwp.app/account/api/" fg={theme.textDim} />
59
+ <text content="2. Create a token (Read Only to browse; Read/Write to upgrade PHP)" fg={theme.textDim} />
60
+ <text content="3. Paste it below and press Enter" fg={theme.textDim} />
61
+ <box style={{ height: 1 }} />
62
+
63
+ <text content="API Access Token" fg={theme.accent} />
64
+ <SecretInput
65
+ focused={phase !== "validating"}
66
+ value={value}
67
+ placeholder="paste your token hereโ€ฆ"
68
+ onChange={setValue}
69
+ onSubmit={() => submit(value)}
70
+ />
71
+ <box style={{ height: 1 }} />
72
+
73
+ {phase === "validating" && (
74
+ <box style={{ flexDirection: "row" }}>
75
+ <Spinner />
76
+ <text content=" Validating tokenโ€ฆ" fg={theme.textDim} />
77
+ </box>
78
+ )}
79
+ {phase === "error" && (
80
+ <box style={{ flexDirection: "column" }}>
81
+ <text content={"โœ• " + (error ?? "Validation failed")} fg={theme.bad} />
82
+ <text content="Press Enter to try again." fg={theme.textFaint} />
83
+ </box>
84
+ )}
85
+ {phase === "input" && (
86
+ <text content="Your token is stored locally only, never transmitted elsewhere." fg={theme.textFaint} />
87
+ )}
88
+ </box>
89
+ <box style={{ height: 1 }} />
90
+ <text content={`Config will be saved to ${configPath()}`} fg={theme.textFaint} />
91
+ </box>
92
+ )
93
+ }
@@ -0,0 +1,51 @@
1
+ // Animated splash shown while the first batch of data loads.
2
+ // Renders a gradient ASCII logo, a tagline, and a live loading status.
3
+
4
+ import { useEffect, useState } from "react"
5
+ import { theme } from "../lib/theme.ts"
6
+ import { Spinner } from "./components.tsx"
7
+
8
+ const TAGLINE = "Terminal control center for your SpinupWP fleet"
9
+
10
+ // A subtle animated "scanline" of dots beneath the logo for some life.
11
+ function Pulse() {
12
+ const [t, setT] = useState(0)
13
+ useEffect(() => {
14
+ const id = setInterval(() => setT((v) => v + 1), 120)
15
+ return () => clearInterval(id)
16
+ }, [])
17
+ const width = 24
18
+ const pos = t % width
19
+ let line = ""
20
+ for (let i = 0; i < width; i++) {
21
+ const dist = Math.abs(i - pos)
22
+ line += dist === 0 ? "โ—" : dist <= 1 ? "โ€ข" : "ยท"
23
+ }
24
+ return <text content={line} fg={theme.brandDim} />
25
+ }
26
+
27
+ export function Splash({ status }: { status: string }) {
28
+ return (
29
+ <box
30
+ style={{
31
+ width: "100%",
32
+ height: "100%",
33
+ flexDirection: "column",
34
+ justifyContent: "center",
35
+ alignItems: "center",
36
+ backgroundColor: theme.bg,
37
+ }}
38
+ >
39
+ <ascii-font text="SPINUPTUI" font="block" color={[theme.brand, theme.accent]} />
40
+ <box style={{ height: 1 }} />
41
+ <text content={TAGLINE} fg={theme.text} />
42
+ <box style={{ height: 1 }} />
43
+ <Pulse />
44
+ <box style={{ height: 2 }} />
45
+ <box style={{ flexDirection: "row" }}>
46
+ <Spinner />
47
+ <text content={" " + status} fg={theme.textDim} />
48
+ </box>
49
+ </box>
50
+ )
51
+ }
@@ -0,0 +1,60 @@
1
+ // Bottom status bar showing context-sensitive key hints plus always-present
2
+ // global hints (help / quit). Views render their own StatusBar so hints can be
3
+ // contextual; the global set is appended automatically for consistency.
4
+
5
+ import { theme } from "../lib/theme.ts"
6
+
7
+ export interface KeyHint {
8
+ key: string
9
+ label: string
10
+ }
11
+
12
+ const GLOBAL_HINTS: KeyHint[] = [
13
+ { key: "r", label: "refresh" },
14
+ { key: "i", label: "explain" },
15
+ { key: "?", label: "help" },
16
+ { key: "q", label: "quit" },
17
+ ]
18
+
19
+ function HintGroup({ hints, dim = false }: { hints: KeyHint[]; dim?: boolean }) {
20
+ return (
21
+ <>
22
+ {hints.map((h, i) => (
23
+ <box key={i} style={{ flexDirection: "row" }}>
24
+ <text content={` ${h.key} `} fg={theme.bg} bg={dim ? theme.textFaint : theme.brandDim} />
25
+ <text content={` ${h.label} `} fg={theme.textDim} />
26
+ </box>
27
+ ))}
28
+ </>
29
+ )
30
+ }
31
+
32
+ export function StatusBar({
33
+ hints,
34
+ message,
35
+ messageColor,
36
+ showGlobal = true,
37
+ }: {
38
+ hints: KeyHint[]
39
+ message?: string
40
+ messageColor?: string
41
+ showGlobal?: boolean
42
+ }) {
43
+ return (
44
+ <box
45
+ style={{
46
+ flexDirection: "row",
47
+ height: 1,
48
+ backgroundColor: theme.bgAlt,
49
+ paddingLeft: 1,
50
+ paddingRight: 1,
51
+ alignItems: "center",
52
+ }}
53
+ >
54
+ <HintGroup hints={hints} />
55
+ {message && <text content={message} fg={messageColor ?? theme.brand} />}
56
+ <box style={{ flexGrow: 1 }} />
57
+ {showGlobal && <HintGroup hints={GLOBAL_HINTS} dim />}
58
+ </box>
59
+ )
60
+ }
@@ -0,0 +1,392 @@
1
+ // Small shared presentational components used across views.
2
+
3
+ import { useEffect, useRef, useState, type ReactNode } from "react"
4
+ import { useKeyboard, usePaste } from "@opentui/react"
5
+ import { theme, statusColor, statusDot } from "../lib/theme.ts"
6
+ import { middleTruncate } from "../lib/format.ts"
7
+ import { isUpgradeInFlight, type PhpUpgradeProgress } from "./store.tsx"
8
+
9
+ // Shorten a home-relative absolute path for display (~/project/sql/file.sql.gz).
10
+ export function shortPath(path: string): string {
11
+ const home = process.env.HOME
12
+ return home && path.startsWith(home + "/") ? "~/" + path.slice(home.length + 1) : path
13
+ }
14
+
15
+ // A filesystem destination shown as two lines โ€” folder (dim, middle-truncated)
16
+ // above the filename (colored, middle-truncated) โ€” so the filename always stays
17
+ // readable instead of being clipped off the end of one long line.
18
+ export function DestPath({ path, fileColor, width = 62 }: { path: string; fileColor: string; width?: number }) {
19
+ const s = shortPath(path)
20
+ const i = s.lastIndexOf("/")
21
+ const dir = i < 0 ? "" : s.slice(0, i + 1)
22
+ const file = i < 0 ? s : s.slice(i + 1)
23
+ return (
24
+ <box style={{ flexDirection: "column" }}>
25
+ {dir ? <text content={` ${middleTruncate(dir, width)}`} fg={theme.textDim} wrapMode="none" /> : null}
26
+ <text content={` ${middleTruncate(file, width)}`} fg={fileColor} wrapMode="none" />
27
+ </box>
28
+ )
29
+ }
30
+
31
+ const SPINNER_FRAMES = ["โ ‹", "โ ™", "โ น", "โ ธ", "โ ผ", "โ ด", "โ ฆ", "โ ง", "โ ‡", "โ "]
32
+
33
+ // An animated braille spinner. `interval` ms per frame.
34
+ export function Spinner({ color = theme.brand, interval = 80 }: { color?: string; interval?: number }) {
35
+ const [frame, setFrame] = useState(0)
36
+ useEffect(() => {
37
+ const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), interval)
38
+ return () => clearInterval(id)
39
+ }, [interval])
40
+ return <text content={SPINNER_FRAMES[frame]} fg={color} />
41
+ }
42
+
43
+ const SPARKLE_FRAMES = ["โœฆ", "โœง", "โœถ", "โœง"]
44
+
45
+ // A gentle twinkling glyph for drawing the eye to a notice (e.g. an available
46
+ // update) without the urgency of a spinner.
47
+ export function Sparkle({ color = theme.brand, interval = 360 }: { color?: string; interval?: number }) {
48
+ const [frame, setFrame] = useState(0)
49
+ useEffect(() => {
50
+ const id = setInterval(() => setFrame((f) => (f + 1) % SPARKLE_FRAMES.length), interval)
51
+ return () => clearInterval(id)
52
+ }, [interval])
53
+ return <text content={SPARKLE_FRAMES[frame]} fg={color} />
54
+ }
55
+
56
+ // A vertical checklist of stages that fills in as work progresses: completed
57
+ // rows get a green โœ“, the in-flight row spins, not-yet-reached rows are faint
58
+ // โ—‹, and a failed row gets a red โœ•. A `waiting` row (paused for the user to act)
59
+ // gets an amber โฏ โ€” distinct from the spinner so it reads as "your turn", not
60
+ // "system working". Used by the DB backup/sync + vanity overlays.
61
+ export type StepState = "done" | "active" | "pending" | "failed" | "waiting"
62
+ export interface StepRow {
63
+ label: string
64
+ state: StepState
65
+ detail?: string // optional trailing text (e.g. a timer), shown dim after the label
66
+ }
67
+
68
+ export function Steps({ rows }: { rows: StepRow[] }) {
69
+ return (
70
+ <box style={{ flexDirection: "column" }}>
71
+ {rows.map((r, i) => (
72
+ <box key={i} style={{ flexDirection: "row", height: 1 }}>
73
+ {r.state === "active" ? (
74
+ <Spinner color={theme.brand} />
75
+ ) : (
76
+ <text
77
+ content={r.state === "done" ? "โœ“" : r.state === "failed" ? "โœ•" : r.state === "waiting" ? "โฏ" : "โ—‹"}
78
+ fg={r.state === "done" ? theme.good : r.state === "failed" ? theme.bad : r.state === "waiting" ? theme.warn : theme.textFaint}
79
+ />
80
+ )}
81
+ <text
82
+ content={` ${r.label}`}
83
+ fg={r.state === "pending" ? theme.textFaint : r.state === "waiting" ? theme.warn : theme.text}
84
+ wrapMode="none"
85
+ />
86
+ {r.detail ? <text content={` ${r.detail}`} fg={theme.textDim} wrapMode="none" /> : null}
87
+ </box>
88
+ ))}
89
+ </box>
90
+ )
91
+ }
92
+
93
+ // A bordered panel with a consistent title style. `active` highlights the border.
94
+ export function Panel({
95
+ title,
96
+ active = false,
97
+ children,
98
+ flexGrow,
99
+ width,
100
+ bottomTitle,
101
+ style,
102
+ }: {
103
+ title?: string
104
+ active?: boolean
105
+ children?: ReactNode
106
+ flexGrow?: number
107
+ width?: number | `${number}%`
108
+ bottomTitle?: string
109
+ style?: Record<string, unknown>
110
+ }) {
111
+ return (
112
+ <box
113
+ title={title}
114
+ titleColor={active ? theme.brand : theme.textDim}
115
+ bottomTitle={bottomTitle}
116
+ bottomTitleAlignment="right"
117
+ border
118
+ borderColor={active ? theme.borderActive : theme.border}
119
+ flexGrow={flexGrow}
120
+ width={width}
121
+ style={{ flexDirection: "column", paddingLeft: 1, paddingRight: 1, ...style }}
122
+ >
123
+ {children}
124
+ </box>
125
+ )
126
+ }
127
+
128
+ // A label / value row used in detail panels. Label is dimmed and fixed-width.
129
+ export function Field({
130
+ label,
131
+ value,
132
+ valueColor = theme.text,
133
+ labelWidth = 16,
134
+ }: {
135
+ label: string
136
+ value: ReactNode
137
+ valueColor?: string
138
+ labelWidth?: number
139
+ }) {
140
+ return (
141
+ <box style={{ flexDirection: "row" }}>
142
+ <text content={label.padEnd(labelWidth)} fg={theme.textDim} />
143
+ {typeof value === "string" || typeof value === "number" ? (
144
+ <text content={String(value)} fg={valueColor} />
145
+ ) : (
146
+ value
147
+ )}
148
+ </box>
149
+ )
150
+ }
151
+
152
+ // A colored status dot followed by its label (e.g. "โ— connected").
153
+ export function StatusBadge({ status, label }: { status: string | null | undefined; label?: string }) {
154
+ const color = statusColor(status)
155
+ return (
156
+ <box style={{ flexDirection: "row" }}>
157
+ <text content={statusDot(status) + " "} fg={color} />
158
+ <text content={label ?? status ?? "unknown"} fg={color} />
159
+ </box>
160
+ )
161
+ }
162
+
163
+ // A small inline pill, e.g. for "reboot required" warnings.
164
+ export function Pill({ text, color }: { text: string; color: string }) {
165
+ return <text content={` ${text} `} fg={theme.bg} bg={color} />
166
+ }
167
+
168
+ // Trailing PHP-version cell for a site row. Normally the version string; while a
169
+ // PHP upgrade is in flight it shows a spinner + the target (e.g. "โ นโ†’8.3"); a
170
+ // failed upgrade flags the version with "โฌ†!". Shared by the Servers & Stacks rows.
171
+ export function PhpVersionCell({
172
+ version,
173
+ upgrade,
174
+ selected,
175
+ eol = false,
176
+ }: {
177
+ version: string | null | undefined
178
+ upgrade?: PhpUpgradeProgress
179
+ selected?: boolean
180
+ eol?: boolean
181
+ }) {
182
+ if (upgrade && isUpgradeInFlight(upgrade)) {
183
+ return (
184
+ <box style={{ flexDirection: "row", flexShrink: 0 }}>
185
+ <Spinner color={selected ? theme.text : theme.brand} interval={120} />
186
+ <text content={`โ†’${upgrade.target}`} fg={selected ? theme.text : theme.warn} wrapMode="none" />
187
+ </box>
188
+ )
189
+ }
190
+ if (upgrade?.status === "failed") {
191
+ return <text content={`${version ?? "โ€”"} โฌ†!`} fg={selected ? theme.text : theme.bad} style={{ flexShrink: 0 }} />
192
+ }
193
+ return (
194
+ <text
195
+ content={version ?? "โ€”"}
196
+ fg={selected ? theme.text : eol ? theme.bad : theme.textFaint}
197
+ style={{ flexShrink: 0 }}
198
+ />
199
+ )
200
+ }
201
+
202
+ // Glanceable per-row meta for a site list, shared across the Servers, Stacks,
203
+ // and Search rows so the convention is identical everywhere:
204
+ // โ—† = a local working copy is linked (blank keeps columns aligned)
205
+ // โ†‘N = N pending WordPress updates
206
+ // Presence-only by design โ€” the inspector strip shows the full detail (path /
207
+ // URL / on-disk validity) for the focused row.
208
+ export function SiteMetaCell({
209
+ linked,
210
+ updates,
211
+ personalKey = false,
212
+ machineKey = false,
213
+ selected = false,
214
+ }: {
215
+ linked: boolean
216
+ updates: number
217
+ personalKey?: boolean // ๐Ÿ‘ค your key is on the site
218
+ machineKey?: boolean // ๐Ÿ”‘ the spinup-tui machine key is on the site
219
+ selected?: boolean
220
+ }) {
221
+ const keyMark = (personalKey ? "๐Ÿ‘ค" : "") + (machineKey ? "๐Ÿ”‘" : "")
222
+ return (
223
+ <box style={{ flexDirection: "row", flexShrink: 0 }}>
224
+ <text content={linked ? "โ—† " : " "} fg={selected ? theme.text : theme.good} wrapMode="none" />
225
+ {keyMark ? <text content={keyMark + " "} wrapMode="none" /> : null}
226
+ {updates > 0 ? <text content={`โ†‘${updates} `} fg={selected ? theme.text : theme.warn} wrapMode="none" /> : null}
227
+ </box>
228
+ )
229
+ }
230
+
231
+ // The main Sites-row status column: a FIXED-slot capability strip so the stack /
232
+ // PHP columns after it line up across every row (unlike SiteMetaCell, whose
233
+ // present-or-absent marks shove later columns around). Each slot reserves the
234
+ // same width whether or not the capability is on โ€” a lit letter when on, a faint
235
+ // "ยท" when off โ€” giving a scannable H/C/B matrix down the list:
236
+ // โ—† local working copy linked
237
+ // ๐Ÿ‘ค๐Ÿ”‘ personal / machine SSH key on the site (2-emoji-wide slot, always)
238
+ // H HTTPS enabled C page cache enabled B backups (files or db) enabled
239
+ // โ†‘N N pending WordPress updates
240
+ // Detail (which keys, backup schedule, etc.) lives in the Details pane / context
241
+ // strip; this strip is glance-only.
242
+ export function SiteStatusColumn({
243
+ linked,
244
+ personalKey = false,
245
+ machineKey = false,
246
+ https,
247
+ cache,
248
+ backup,
249
+ updates,
250
+ selected = false,
251
+ }: {
252
+ linked: boolean
253
+ personalKey?: boolean
254
+ machineKey?: boolean
255
+ https: boolean
256
+ cache: boolean
257
+ backup: boolean
258
+ updates: number
259
+ selected?: boolean
260
+ }) {
261
+ const onColor = selected ? theme.text : theme.good
262
+ const offColor = selected ? theme.textDim : theme.textFaint
263
+ // A fixed-width toggle slot: the letter when on, a faint dot when off.
264
+ const flag = (label: string, on: boolean) => (
265
+ <text content={`${on ? label : "ยท"} `} fg={on ? onColor : offColor} wrapMode="none" style={{ flexShrink: 0 }} />
266
+ )
267
+ // Key slot is always two emoji-cells wide (pad the empties) so H/C/B never shift.
268
+ const keyCell = (personalKey ? "๐Ÿ‘ค" : " ") + (machineKey ? "๐Ÿ”‘" : " ")
269
+ return (
270
+ <box style={{ flexDirection: "row", flexShrink: 0 }}>
271
+ <text content={`${linked ? "โ—†" : " "} `} fg={selected ? theme.text : theme.good} wrapMode="none" style={{ flexShrink: 0 }} />
272
+ <text content={`${keyCell} `} wrapMode="none" style={{ flexShrink: 0 }} />
273
+ {flag("H", https)}
274
+ {flag("C", cache)}
275
+ {flag("B", backup)}
276
+ <text
277
+ content={updates > 0 ? `โ†‘${updates} `.padEnd(4) : " "}
278
+ fg={selected ? theme.text : theme.warn}
279
+ wrapMode="none"
280
+ style={{ flexShrink: 0 }}
281
+ />
282
+ </box>
283
+ )
284
+ }
285
+
286
+ // A masked text field for secrets/tokens. OpenTUI's <input> keeps its own editor
287
+ // buffer and has no masking/password mode, so a controlled "show dots" value
288
+ // fights that buffer (the secret flashes, then the field clears). Instead this
289
+ // owns the value itself: it captures keypresses + paste while focused and renders
290
+ // only dots โ€” the secret is never drawn in cleartext. We deliberately do NOT use
291
+ // <input> here. Editing is append/backspace at the end (paste-and-go for tokens).
292
+ const MASK = "โ€ข"
293
+ export function SecretInput({
294
+ value,
295
+ onChange,
296
+ focused,
297
+ placeholder,
298
+ onSubmit,
299
+ width,
300
+ }: {
301
+ value: string
302
+ onChange: (v: string) => void
303
+ focused?: boolean
304
+ placeholder?: string
305
+ onSubmit?: () => void
306
+ width?: number // fixed box width; omit for content-sized (existing call sites)
307
+ }) {
308
+ // Mirror the value in a ref so a rapid burst of key events (or a paste split
309
+ // into per-char events) accumulates instead of each handler closing over a
310
+ // stale `value` and overwriting the last โ€” we update the ref synchronously.
311
+ const valueRef = useRef(value)
312
+ valueRef.current = value
313
+ const setValue = (next: string) => {
314
+ valueRef.current = next
315
+ onChange(next)
316
+ }
317
+
318
+ useKeyboard((key) => {
319
+ if (!focused) return
320
+ const name = key.name ?? ""
321
+ if (name === "return" || name === "enter") return onSubmit?.()
322
+ if (name === "backspace" || name === "delete") return setValue(valueRef.current.slice(0, -1))
323
+ // Let the host overlay handle navigation/dismiss keys.
324
+ if (["up", "down", "left", "right", "tab", "escape"].includes(name)) return
325
+ if (key.ctrl || key.meta) return
326
+ // A single printable character โ€” append it.
327
+ const seq: string = (key as { sequence?: string; raw?: string }).sequence ?? (key as { raw?: string }).raw ?? ""
328
+ if (seq.length === 1 && seq >= " ") setValue(valueRef.current + seq)
329
+ })
330
+
331
+ usePaste((event: { bytes?: Uint8Array }) => {
332
+ if (!focused) return
333
+ const text = event?.bytes ? new TextDecoder().decode(event.bytes) : ""
334
+ const cleaned = text.replace(/[\r\n]+/g, "") // tokens are one line; drop any newlines
335
+ if (cleaned) setValue(valueRef.current + cleaned)
336
+ })
337
+
338
+ // The field always keeps a visible bgAlt box (same as the regular text inputs);
339
+ // focus is carried by the green โฏ marker + caret, not by dimming the box.
340
+ const hasValue = value.length > 0
341
+ return (
342
+ <box
343
+ style={{
344
+ height: 1,
345
+ flexDirection: "row",
346
+ backgroundColor: theme.bgAlt,
347
+ paddingLeft: 1,
348
+ paddingRight: 1,
349
+ ...(width != null ? { width } : {}),
350
+ }}
351
+ >
352
+ <text content={focused ? "โฏ " : " "} fg={focused ? theme.brand : theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
353
+ {hasValue ? <text content={MASK.repeat(value.length)} fg={theme.text} wrapMode="none" style={{ flexShrink: 0 }} /> : null}
354
+ {focused ? <text content="โ–" fg={theme.brand} wrapMode="none" style={{ flexShrink: 0 }} /> : null}
355
+ {!hasValue ? (
356
+ <text content={placeholder ?? ""} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
357
+ ) : null}
358
+ </box>
359
+ )
360
+ }
361
+
362
+ // Centered helper text, often for empty/loading states inside a panel.
363
+ export function Centered({ children }: { children: ReactNode }) {
364
+ return (
365
+ <box style={{ flexGrow: 1, justifyContent: "center", alignItems: "center", flexDirection: "column" }}>
366
+ {children}
367
+ </box>
368
+ )
369
+ }
370
+
371
+ // A grouped, key-chipped action legend ("Site Control" / "Server Control"): a heading,
372
+ // then dim group titles each over a list of `key โ†’ outcome` rows. Shared by the Search
373
+ // actions pane and the Servers detail pane so the suite reads the same everywhere.
374
+ export type ActionGroup = { title: string; items: [string, string][] }
375
+ export function ControlPanel({ heading, groups }: { heading: string; groups: ActionGroup[] }) {
376
+ return (
377
+ <box style={{ flexDirection: "column" }}>
378
+ <text content={heading} fg={theme.accent} />
379
+ {groups.map((group) => (
380
+ <box key={group.title} style={{ flexDirection: "column" }}>
381
+ <text content={group.title.toUpperCase()} fg={theme.textFaint} wrapMode="none" />
382
+ {group.items.map(([k, label]) => (
383
+ <box key={k} style={{ flexDirection: "row" }}>
384
+ <text content={` ${k} `} fg={theme.bg} bg={theme.brandDim} style={{ flexShrink: 0 }} />
385
+ <text content={` ${label}`} fg={theme.text} wrapMode="none" />
386
+ </box>
387
+ ))}
388
+ </box>
389
+ ))}
390
+ </box>
391
+ )
392
+ }