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,358 @@
1
+ // Connect sudo overlay — open a privileged sudo session on a server.
2
+ //
3
+ // Opened with `S` on a selected server (Servers tab). Enter the SpinupWP sudo
4
+ // user + its sudo password once; Spinup validates them against the live server and
5
+ // then holds them IN MEMORY for the rest of the session (never written to disk),
6
+ // so every privileged action on that server (e.g. `K` grant SSH key) just works
7
+ // without re-prompting. A connected server shows a green ● on its row; you can
8
+ // disconnect here. See docs/2026-06-26_sudo-ssh-key-provisioning-spec.md.
9
+
10
+ import { useEffect, useRef, useState } from "react"
11
+ import { useKeyboard } from "@opentui/react"
12
+ import { theme } from "../../lib/theme.ts"
13
+ import { truncate } from "../../lib/format.ts"
14
+ import { Panel, Centered, Spinner, SecretInput } from "../components.tsx"
15
+ import { StatusBar } from "../StatusBar.tsx"
16
+ import { useStore } from "../store.tsx"
17
+
18
+ type Phase = "connected" | "reconnect" | "unlocking" | "user" | "password" | "verifying" | "error"
19
+ type Field = "user" | "password" | "remember"
20
+
21
+ export function SudoConnect() {
22
+ const store = useStore()
23
+ const {
24
+ sudoConnectServer: server,
25
+ setSudoConnectServer,
26
+ sudoUserFor,
27
+ isSudoConnected,
28
+ connectSudo,
29
+ connectSudoFromKeychain,
30
+ disconnectSudo,
31
+ sudoSavedFor,
32
+ forgetSudoKeychain,
33
+ keychainAvailable,
34
+ sitesForServer,
35
+ setInputMode,
36
+ } = store
37
+
38
+ const connected = server ? isSudoConnected(server.id) : false
39
+ const savedUser = server ? sudoUserFor(server.id) : undefined
40
+ const saved = server ? sudoSavedFor(server.id) : false
41
+
42
+ // A saved Keychain password auto-unlocks on open; otherwise straight to the form.
43
+ const [phase, setPhase] = useState<Phase>(() => (connected ? "connected" : saved && keychainAvailable ? "unlocking" : "user"))
44
+ const [field, setField] = useState<Field>("user")
45
+ const [userInput, setUserInput] = useState(savedUser ?? "")
46
+ const [pwInput, setPwInput] = useState("")
47
+ const [remember, setRemember] = useState(saved) // keep the box checked for already-saved servers
48
+ const [error, setError] = useState<string | null>(null)
49
+
50
+ // Auto-unlock from the Keychain once, when we open on a saved server.
51
+ const unlockTried = useRef(false)
52
+ useEffect(() => {
53
+ if (unlockTried.current || phase !== "unlocking" || !server) return
54
+ unlockTried.current = true
55
+ void connectSudoFromKeychain(server).then((res) => {
56
+ if (res.ok) setPhase("connected")
57
+ else {
58
+ setError(`Keychain unlock failed: ${res.error}`)
59
+ setUserInput(savedUser ?? "")
60
+ setField("password")
61
+ setPhase("user")
62
+ }
63
+ })
64
+ }, [phase, server, connectSudoFromKeychain, savedUser])
65
+
66
+ // The credential form owns the keyboard (suppress global shortcuts) — except while
67
+ // the "remember" toggle is focused, where no text input is active.
68
+ useEffect(() => {
69
+ setInputMode((phase === "user" || phase === "password") && field !== "remember")
70
+ return () => setInputMode(false)
71
+ }, [phase, field, setInputMode])
72
+
73
+ const close = () => {
74
+ setInputMode(false)
75
+ setSudoConnectServer(null)
76
+ }
77
+
78
+ const submit = () => {
79
+ if (!server) return
80
+ const u = userInput.trim()
81
+ if (!u || !pwInput) return
82
+ setPhase("verifying")
83
+ void connectSudo(server, u, pwInput, remember && keychainAvailable).then((res) => {
84
+ setPwInput("") // don't keep the secret in component state past the attempt
85
+ if (res.ok) {
86
+ setError(null)
87
+ setPhase("connected")
88
+ } else {
89
+ setError(res.error)
90
+ setPhase("error")
91
+ }
92
+ })
93
+ }
94
+
95
+ useKeyboard((key) => {
96
+ const name = key.name ?? ""
97
+ if (name === "escape") return close()
98
+
99
+ if (phase === "connected") {
100
+ if ((name === "x" || name === "d") && server) {
101
+ disconnectSudo(server.id)
102
+ // Saved in the Keychain → drop the session but offer a no-password
103
+ // reconnect instead of making them re-type a password we already hold.
104
+ if (saved && keychainAvailable) return setPhase("reconnect")
105
+ setUserInput(savedUser ?? "")
106
+ setRemember(saved)
107
+ setField("password")
108
+ return setPhase("user")
109
+ }
110
+ // f — forget the Keychain-saved password (stays connected this session).
111
+ if (name === "f" && server && saved) return void forgetSudoKeychain(server.id)
112
+ if (name === "q") return close()
113
+ return
114
+ }
115
+
116
+ // Disconnected but still saved: reconnect from the Keychain (no password) or forget.
117
+ if (phase === "reconnect") {
118
+ if (name === "return" && server) {
119
+ unlockTried.current = false // re-arm the auto-unlock effect
120
+ return setPhase("unlocking")
121
+ }
122
+ if (name === "f" && server) {
123
+ void forgetSudoKeychain(server.id)
124
+ // Nothing saved anymore → the form is now the only way back.
125
+ setUserInput(savedUser ?? "")
126
+ setRemember(false)
127
+ setField("password")
128
+ return setPhase("user")
129
+ }
130
+ if (name === "q") return close()
131
+ return
132
+ }
133
+
134
+ if (phase === "user" || phase === "password") {
135
+ // ↑/↓ cycle fields (user → password → remember); inputs' onSubmit advances.
136
+ const order: Field[] = keychainAvailable ? ["user", "password", "remember"] : ["user", "password"]
137
+ if (name === "up") return setField((f) => order[Math.max(0, order.indexOf(f) - 1)]!)
138
+ if (name === "down") return setField((f) => order[Math.min(order.length - 1, order.indexOf(f) + 1)]!)
139
+ // On the remember toggle (no input focused): space toggles, Enter connects.
140
+ if (field === "remember") {
141
+ if (name === "space") return setRemember((r) => !r)
142
+ if (name === "return") return submit()
143
+ }
144
+ return
145
+ }
146
+
147
+ if (phase === "error") {
148
+ if (name === "r") {
149
+ setError(null)
150
+ setUserInput(savedUser ?? userInput)
151
+ setField("password")
152
+ return setPhase("password")
153
+ }
154
+ return
155
+ }
156
+ })
157
+
158
+ if (!server) return null
159
+
160
+ const siteCount = sitesForServer(server.id).length
161
+
162
+ return (
163
+ <box
164
+ style={{
165
+ position: "absolute",
166
+ top: 0,
167
+ left: 0,
168
+ width: "100%",
169
+ height: "100%",
170
+ flexDirection: "column",
171
+ backgroundColor: theme.bg,
172
+ // Above every other overlay (incl. the vanity build at 215) so it can be
173
+ // layered on top — e.g. "connect sudo" from inside the vanity flow.
174
+ zIndex: 230,
175
+ }}
176
+ >
177
+ {/* Title bar */}
178
+ <box
179
+ style={{
180
+ flexDirection: "row",
181
+ height: 1,
182
+ backgroundColor: theme.bgAlt,
183
+ paddingLeft: 1,
184
+ paddingRight: 1,
185
+ alignItems: "center",
186
+ }}
187
+ >
188
+ <text content={connected ? "● " : "○ "} fg={connected ? theme.good : theme.textFaint} style={{ flexShrink: 0 }} />
189
+ <text content="Sudo " fg={theme.brand} style={{ flexShrink: 0 }} />
190
+ <text content={truncate(server.name, 40)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
191
+ <box style={{ flexGrow: 1 }} />
192
+ <text content={`${siteCount} site${siteCount === 1 ? "" : "s"}`} fg={theme.textFaint} style={{ flexShrink: 0 }} />
193
+ </box>
194
+
195
+ <Centered>{renderBody()}</Centered>
196
+
197
+ <StatusBar hints={hints()} showGlobal={false} />
198
+ </box>
199
+ )
200
+
201
+ function renderBody() {
202
+ if (phase === "connected") {
203
+ return (
204
+ <Panel title=" Sudo connected " active>
205
+ <box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
206
+ <box style={{ flexDirection: "row" }}>
207
+ <text content="● Connected as " fg={theme.good} wrapMode="none" />
208
+ <text content={`${savedUser}@${server!.name}`} fg={theme.accent} wrapMode="none" />
209
+ </box>
210
+ <box style={{ height: 1 }} />
211
+ <text content="Privileged actions on this server run for the rest of this" fg={theme.textDim} wrapMode="none" />
212
+ <text content="session — press K on a site to grant SpinupTUI's SSH key." fg={theme.textDim} wrapMode="none" />
213
+ <box style={{ height: 1 }} />
214
+ {saved ? (
215
+ <>
216
+ <text content="Saved in the macOS Keychain — sudo unlocks automatically" fg={theme.textFaint} wrapMode="none" />
217
+ <text content="next time you open this server. Press f to forget it." fg={theme.textFaint} wrapMode="none" />
218
+ </>
219
+ ) : (
220
+ <>
221
+ <text content="The sudo password is kept in memory only — never saved, and" fg={theme.textFaint} wrapMode="none" />
222
+ <text content="forgotten when you disconnect or quit." fg={theme.textFaint} wrapMode="none" />
223
+ </>
224
+ )}
225
+ <box style={{ height: 1 }} />
226
+ <text content={`x — disconnect${saved ? " · f — forget saved" : ""} Esc — close (stays connected)`} fg={theme.textDim} wrapMode="none" />
227
+ </box>
228
+ </Panel>
229
+ )
230
+ }
231
+
232
+ if (phase === "unlocking") {
233
+ return (
234
+ <box style={{ flexDirection: "column", alignItems: "center" }}>
235
+ <box style={{ flexDirection: "row" }}>
236
+ <Spinner />
237
+ <text content=" Unlocking sudo from the Keychain…" fg={theme.textDim} />
238
+ </box>
239
+ </box>
240
+ )
241
+ }
242
+
243
+ if (phase === "reconnect") {
244
+ return (
245
+ <Panel title=" Disconnected " active>
246
+ <box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
247
+ <box style={{ flexDirection: "row" }}>
248
+ <text content="○ Disconnected — " fg={theme.textDim} wrapMode="none" />
249
+ <text content={server!.name} fg={theme.accent} wrapMode="none" />
250
+ </box>
251
+ <box style={{ height: 1 }} />
252
+ <text content="Your sudo password is still saved in the macOS Keychain," fg={theme.textFaint} wrapMode="none" />
253
+ <text content="so you can reconnect without re-entering it." fg={theme.textFaint} wrapMode="none" />
254
+ <box style={{ height: 1 }} />
255
+ <text content="⏎ — reconnect (no password) f — forget saved Esc — close" fg={theme.textDim} wrapMode="none" />
256
+ </box>
257
+ </Panel>
258
+ )
259
+ }
260
+
261
+ if (phase === "verifying") {
262
+ return (
263
+ <box style={{ flexDirection: "column", alignItems: "center" }}>
264
+ <box style={{ flexDirection: "row" }}>
265
+ <Spinner />
266
+ <text content=" Checking the sudo connection…" fg={theme.textDim} />
267
+ </box>
268
+ </box>
269
+ )
270
+ }
271
+
272
+ if (phase === "error") {
273
+ return (
274
+ <Panel title=" Couldn't connect " active>
275
+ <box style={{ flexDirection: "column", width: 68, paddingTop: 1, paddingBottom: 1 }}>
276
+ <text content={`✕ ${error ?? "Verification failed."}`} fg={theme.bad} wrapMode="none" />
277
+ <box style={{ height: 1 }} />
278
+ <text content="Check the sudo user has your SSH key (key login) and that" fg={theme.textFaint} wrapMode="none" />
279
+ <text content="the sudo password is correct." fg={theme.textFaint} wrapMode="none" />
280
+ <box style={{ height: 1 }} />
281
+ <text content="r — re-enter the password · Esc — close" fg={theme.textDim} wrapMode="none" />
282
+ </box>
283
+ </Panel>
284
+ )
285
+ }
286
+
287
+ // user / password form
288
+ return (
289
+ <Panel title=" Connect sudo " active>
290
+ <box style={{ flexDirection: "column", width: 68, paddingTop: 1, paddingBottom: 1 }}>
291
+ <box style={{ flexDirection: "row" }}>
292
+ <text content="Server " fg={theme.textDim} wrapMode="none" />
293
+ <text content={server!.name} fg={theme.accent} wrapMode="none" />
294
+ </box>
295
+ <box style={{ height: 1 }} />
296
+ <text content="Why: writing an SSH key into a site user's authorized_keys" fg={theme.textDim} wrapMode="none" />
297
+ <text content="needs root, so SpinupTUI logs in as your sudo user and" fg={theme.textDim} wrapMode="none" />
298
+ <text content="escalates. (The SpinupWP API can't manage SSH keys.)" fg={theme.textDim} wrapMode="none" />
299
+ <box style={{ height: 1 }} />
300
+ <text content="Enter this server's SpinupWP sudo user and its sudo" fg={theme.textDim} wrapMode="none" />
301
+ <text content="password. Held in memory for the session only." fg={theme.textDim} wrapMode="none" />
302
+ <box style={{ height: 1 }} />
303
+ <text content={(field === "user" ? "❯ " : " ") + "Sudo user"} fg={field === "user" ? theme.brand : theme.textDim} />
304
+ <input
305
+ focused={field === "user"}
306
+ value={userInput}
307
+ placeholder="e.g. spinup02"
308
+ onInput={setUserInput}
309
+ onSubmit={() => setField("password")}
310
+ style={{ backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }}
311
+ />
312
+ <box style={{ height: 1 }} />
313
+ <text content={(field === "password" ? "❯ " : " ") + "Sudo password"} fg={field === "password" ? theme.brand : theme.textDim} />
314
+ <SecretInput focused={field === "password"} value={pwInput} placeholder="sudo password" onChange={setPwInput} onSubmit={submit} />
315
+ {keychainAvailable ? (
316
+ <>
317
+ <box style={{ height: 1 }} />
318
+ <box style={{ flexDirection: "row" }}>
319
+ <text content={(field === "remember" ? "❯ " : " ") + (remember ? "◉" : "◯") + " Remember in macOS Keychain"} fg={field === "remember" ? theme.brand : remember ? theme.good : theme.textDim} wrapMode="none" />
320
+ <text content=" (space toggles)" fg={theme.textFaint} wrapMode="none" />
321
+ </box>
322
+ <text content=" Auto-unlocks sudo next session — stored in your login Keychain, not on disk." fg={theme.textFaint} wrapMode="none" />
323
+ </>
324
+ ) : null}
325
+ <box style={{ height: 1 }} />
326
+ <text content="Enter → next field (connects on the password) · ↑↓ switch · Esc cancels" fg={theme.textFaint} wrapMode="none" />
327
+ </box>
328
+ </Panel>
329
+ )
330
+ }
331
+
332
+ function hints() {
333
+ if (phase === "connected")
334
+ return [
335
+ { key: "x", label: "disconnect" },
336
+ ...(saved ? [{ key: "f", label: "forget saved" }] : []),
337
+ { key: "esc", label: "close (stays connected)" },
338
+ ]
339
+ if (phase === "reconnect")
340
+ return [
341
+ { key: "⏎", label: "reconnect" },
342
+ { key: "f", label: "forget saved" },
343
+ { key: "esc", label: "close" },
344
+ ]
345
+ if (phase === "error")
346
+ return [
347
+ { key: "r", label: "re-enter" },
348
+ { key: "esc", label: "close" },
349
+ ]
350
+ if (phase === "unlocking" || phase === "verifying") return [{ key: "esc", label: "cancel" }]
351
+ return [
352
+ { key: "↑↓", label: "field" },
353
+ ...(field === "remember" ? [{ key: "space", label: "toggle" }] : []),
354
+ { key: "⏎", label: "connect" },
355
+ { key: "esc", label: "cancel" },
356
+ ]
357
+ }
358
+ }