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,365 @@
1
+ // Server & site browser: a three-pane master/detail navigator.
2
+ //
3
+ // [ Servers ] → [ Sites on server ] → [ Details of focused item ]
4
+ //
5
+ // Tab / → moves focus rightward, ← / Esc moves it back. ↑/↓ (or j/k) move the
6
+ // selection in the focused pane. `o` opens the selected site in the browser.
7
+
8
+ import { useEffect, useMemo, useState } from "react"
9
+ import { useKeyboard } from "@opentui/react"
10
+ import { theme, statusColor, statusDot } from "../../lib/theme.ts"
11
+ import { effectiveStack, stackColor, stackTag } from "../../lib/stack.ts"
12
+ import { truncate } from "../../lib/format.ts"
13
+ import { Panel, PhpVersionCell, Spinner, SiteStatusColumn } from "../components.tsx"
14
+ import { List, moveSelection } from "../List.tsx"
15
+ import { ServerDetail, SiteDetail, SiteContextStrip, SITE_CONTEXT_STRIP_HEIGHT } from "../Details.tsx"
16
+ import { StatusBar } from "../StatusBar.tsx"
17
+ import { openUrl } from "../../lib/open.ts"
18
+ import { serverWebUrl, siteWebUrl } from "../../lib/spinupweb.ts"
19
+ import { useStore, isServerOpInFlight } from "../store.tsx"
20
+ import { isVanityPair } from "../../lib/vanitySite.ts"
21
+
22
+ type Focus = "servers" | "sites"
23
+
24
+ export function Browser({ rows }: { rows: number }) {
25
+ const store = useStore()
26
+ const { servers, sitesForServer, route, inputMode, overlayOpen, setHealthServer, setWpInventorySite, runProbe, probes, accountSlug, setPhpUpgradeSite, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setGrantKeySite, setSudoConnectServer, isSudoConnected, grantedKeyKinds, setServerActionsServer, serverOps, setLocalLinkSite, openLocalTerminal, openLocalUrl, localLinks, sshSite, setDnsInventoryServer, setNewServerSource, setNewServerOpen, setVanityServer, vanityJob, beginClone, isServerOsEol, setKumaSite } = store
27
+
28
+ const [serverIndex, setServerIndex] = useState(0)
29
+ const [siteIndex, setSiteIndex] = useState(0)
30
+ const [focus, setFocus] = useState<Focus>("servers")
31
+ const [flash, setFlash] = useState<string | null>(null)
32
+
33
+ // Servers sorted alphabetically for predictable browsing.
34
+ const sortedServers = useMemo(
35
+ () => [...servers].sort((a, b) => a.name.localeCompare(b.name)),
36
+ [servers],
37
+ )
38
+ const server = sortedServers[Math.min(serverIndex, sortedServers.length - 1)]
39
+ const sites = useMemo(
40
+ () => (server ? [...sitesForServer(server.id)].sort((a, b) => a.domain.localeCompare(b.domain)) : []),
41
+ [server, sitesForServer],
42
+ )
43
+
44
+ // Reset site selection whenever the active server changes.
45
+ useEffect(() => {
46
+ setSiteIndex(0)
47
+ }, [serverIndex])
48
+
49
+ const isActive = route === "servers" && !inputMode && !overlayOpen
50
+
51
+ useKeyboard((key) => {
52
+ if (!isActive) return
53
+ // OpenTUI lowercases letters and sets a shift flag; normalize so capital
54
+ // shortcuts (e.g. G = jump to bottom) match.
55
+ const raw = key.name ?? ""
56
+ const name = key.shift && raw.length === 1 ? raw.toUpperCase() : raw
57
+
58
+ const moveBy = (delta: number) => {
59
+ if (focus === "servers") setServerIndex((i) => moveSelection(i, delta, sortedServers.length))
60
+ else setSiteIndex((i) => moveSelection(i, delta, sites.length))
61
+ }
62
+
63
+ switch (name) {
64
+ case "up":
65
+ case "k":
66
+ return moveBy(-1)
67
+ case "down":
68
+ case "j":
69
+ return moveBy(1)
70
+ case "g":
71
+ return focus === "servers" ? setServerIndex(0) : setSiteIndex(0)
72
+ case "G":
73
+ return focus === "servers" ? setServerIndex(sortedServers.length - 1) : setSiteIndex(sites.length - 1)
74
+ case "right":
75
+ case "l":
76
+ case "return":
77
+ case "tab":
78
+ if (focus === "servers" && sites.length > 0) setFocus("sites")
79
+ return
80
+ case "left":
81
+ case "escape":
82
+ if (focus === "sites") setFocus("servers")
83
+ return
84
+ case "o":
85
+ if (focus === "sites" && sites[siteIndex]) {
86
+ const s = sites[siteIndex]
87
+ openUrl((s.https?.enabled ? "https://" : "http://") + s.domain)
88
+ setFlash(`Opening ${s.domain}…`)
89
+ setTimeout(() => setFlash(null), 1500)
90
+ }
91
+ return
92
+ case "d":
93
+ // Detect the selected site's stack via SSH (Tier 2); shows in Details.
94
+ if (focus === "sites" && sites[siteIndex]) {
95
+ const s = sites[siteIndex]
96
+ runProbe(s)
97
+ setFlash(`Identifying the app on ${s.domain}…`)
98
+ setTimeout(() => setFlash(null), 1500)
99
+ }
100
+ return
101
+ case "u":
102
+ // Upgrade the selected site's PHP version (first write action).
103
+ if (focus === "sites" && sites[siteIndex]) setPhpUpgradeSite(sites[siteIndex])
104
+ return
105
+ case "H":
106
+ // Enable/disable HTTPS on the selected site (direction auto-derived).
107
+ if (focus === "sites" && sites[siteIndex]) setHttpsToggleSite(sites[siteIndex])
108
+ return
109
+ case "P":
110
+ // Purge page cache + object cache on the selected site.
111
+ if (focus === "sites" && sites[siteIndex]) setPurgeCacheSite(sites[siteIndex])
112
+ return
113
+ case "m":
114
+ case "M":
115
+ // Uptime Kuma monitoring for the selected site (connect on first use).
116
+ // M is an alias: it's the binding Search/Stacks use (their lowercase m
117
+ // is taken), so the capital works everywhere.
118
+ if (focus === "sites" && sites[siteIndex]) setKumaSite(sites[siteIndex])
119
+ return
120
+ case "K":
121
+ // Grant Spinup's machine key to the selected site over SSH (privileged
122
+ // write via the server's sudo user — the API can't do this). Capital K
123
+ // (like L/V/N) since lowercase k is the vim "move up" binding.
124
+ if (focus === "sites" && sites[siteIndex]) setGrantKeySite(sites[siteIndex])
125
+ return
126
+ case "L":
127
+ // Link / view the selected site's local working copy (Phase 1).
128
+ if (focus === "sites" && sites[siteIndex]) setLocalLinkSite(sites[siteIndex])
129
+ return
130
+ case "t":
131
+ // Open the selected site's local working copy in a terminal (inline).
132
+ if (focus === "sites" && sites[siteIndex]) {
133
+ setFlash(openLocalTerminal(sites[siteIndex].id))
134
+ setTimeout(() => setFlash(null), 1800)
135
+ }
136
+ return
137
+ case "v":
138
+ // Open the selected site's stored local URL in the browser.
139
+ if (focus === "sites" && sites[siteIndex]) {
140
+ setFlash(openLocalUrl(sites[siteIndex].id))
141
+ setTimeout(() => setFlash(null), 1800)
142
+ }
143
+ return
144
+ case "s":
145
+ // Open a terminal and SSH into the selected site.
146
+ if (focus === "sites" && sites[siteIndex]) {
147
+ setFlash(sshSite(sites[siteIndex].id))
148
+ setTimeout(() => setFlash(null), 2000)
149
+ }
150
+ return
151
+ case "n":
152
+ // DNS inventory scoped to the selected site (its domains + records).
153
+ if (focus === "sites" && sites[siteIndex] && server) setDnsInventoryServer(server, sites[siteIndex].id)
154
+ return
155
+ case "p":
156
+ // Plugins & themes (wp-cli over SSH) for the selected site.
157
+ if (focus === "sites" && sites[siteIndex]) setWpInventorySite(sites[siteIndex])
158
+ return
159
+ case "N":
160
+ // Server-wide DNS inventory — every site on the server.
161
+ if (server) setDnsInventoryServer(server)
162
+ return
163
+ case "S":
164
+ // Connect sudo on the selected server (open a privileged session for the
165
+ // rest of the run — S for Sudo). Server-scoped — works from either pane.
166
+ if (server) setSudoConnectServer(server)
167
+ return
168
+ case "a":
169
+ // Server actions (reboot / restart) are server-scoped, so only offered
170
+ // when the Servers pane is focused — when you've drilled into a site,
171
+ // the context is the site (server actions are dropped there).
172
+ if (focus === "servers" && server) setServerActionsServer(server)
173
+ return
174
+ case "c":
175
+ // Create a new server. Seed from the highlighted server when there is one;
176
+ // open from scratch otherwise (incl. an empty fleet). Servers-pane only.
177
+ if (focus === "servers") {
178
+ setNewServerSource(server ?? null)
179
+ setNewServerOpen(true)
180
+ }
181
+ return
182
+ case "C":
183
+ // Clone this server (all its sites) to a brand-new server. Servers-pane only,
184
+ // and only when the server actually has sites to clone.
185
+ if (focus === "servers" && server && sitesForServer(server.id).length > 0) {
186
+ beginClone(server)
187
+ }
188
+ return
189
+ case "V":
190
+ // Create the vanity site at the server's own hostname. Offered whenever
191
+ // the server doesn't already HAVE a site there — a busy server benefits
192
+ // just as much as an empty one (the hostname page + a key-holding site
193
+ // user). Also reopens an unfinished build for this server (in-flight,
194
+ // parked at the SSH-key step, or errored — all after the site may exist).
195
+ if (server) {
196
+ const resumable = vanityJob && vanityJob.step !== "done" && vanityJob.serverId === server.id
197
+ const hasVanity = sitesForServer(server.id).some((s) => s.domain.toLowerCase() === server.name.toLowerCase())
198
+ if (resumable || !hasVanity) setVanityServer(server)
199
+ }
200
+ return
201
+ case "h":
202
+ // Open the live health view for the current server (works from either pane).
203
+ if (server) setHealthServer(server)
204
+ return
205
+ case "w":
206
+ // Open the focused server/site in the SpinupWP web app — useful for
207
+ // actions the API can't do (e.g. running a pending server upgrade).
208
+ if (focus === "sites" && sites[siteIndex]) {
209
+ openUrl(siteWebUrl(sites[siteIndex].id, accountSlug))
210
+ setFlash(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
211
+ } else if (server) {
212
+ openUrl(serverWebUrl(server.id, accountSlug))
213
+ setFlash(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
214
+ }
215
+ setTimeout(() => setFlash(null), 2000)
216
+ return
217
+ }
218
+ })
219
+
220
+ const listRows = Math.max(3, rows - 6 - SITE_CONTEXT_STRIP_HEIGHT)
221
+ const focusedSite = sites[Math.min(siteIndex, Math.max(0, sites.length - 1))]
222
+
223
+ const hints =
224
+ focus === "servers"
225
+ ? [
226
+ { key: "↑↓/jk", label: "select" },
227
+ { key: "→/⏎", label: "view sites" },
228
+ { key: "c", label: "new server" },
229
+ { key: "S", label: "connect sudo" },
230
+ { key: "a", label: "server actions" },
231
+ { key: "N", label: "DNS hosts" },
232
+ { key: "h", label: "health" },
233
+ { key: "w", label: "SpinupWP" },
234
+ ]
235
+ : [
236
+ { key: "↑↓/jk", label: "select site" },
237
+ { key: "←/esc", label: "back" },
238
+ { key: "d", label: "identify app" },
239
+ { key: "n", label: "DNS host" },
240
+ { key: "u", label: "change PHP" },
241
+ { key: "m", label: "monitoring" },
242
+ { key: "K", label: "grant key" },
243
+ { key: "p", label: "plugins" },
244
+ { key: "o", label: "open" },
245
+ { key: "w", label: "SpinupWP" },
246
+ { key: "s", label: "ssh" },
247
+ { key: "h", label: "health" },
248
+ ]
249
+
250
+ return (
251
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
252
+ <box style={{ flexGrow: 1, flexDirection: "row", padding: 1, gap: 1 }}>
253
+ {/* Servers pane */}
254
+ <Panel title={` Servers (${sortedServers.length}) `} active={focus === "servers"} width={44}>
255
+ <List
256
+ items={sortedServers}
257
+ selectedIndex={serverIndex}
258
+ viewportRows={listRows}
259
+ focused={focus === "servers"}
260
+ keyFor={(s) => s.id}
261
+ emptyText="No servers yet — press c to create one"
262
+ renderRow={(s, selected) => {
263
+ const count = sitesForServer(s.id).length
264
+ const op = serverOps.get(s.id)
265
+ return (
266
+ <>
267
+ <text content={statusDot(s.connection_status) + " "} fg={statusColor(s.connection_status)} style={{ flexShrink: 0 }} />
268
+ <text content={truncate(s.name, 30)} fg={selected ? theme.text : count === 0 ? theme.warn : theme.textDim} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
269
+ {op && isServerOpInFlight(op) ? (
270
+ <box style={{ flexDirection: "row", flexShrink: 0 }}>
271
+ <text content=" " style={{ flexShrink: 0 }} />
272
+ <Spinner color={selected ? theme.text : theme.brand} interval={120} />
273
+ <text content={op.label} fg={selected ? theme.text : theme.warn} wrapMode="none" />
274
+ </box>
275
+ ) : op?.status === "failed" ? (
276
+ <text content=" op!" fg={selected ? theme.text : theme.bad} style={{ flexShrink: 0 }} />
277
+ ) : (
278
+ s.reboot_required && <text content=" ↻ rbt" fg={selected ? theme.text : theme.warn} style={{ flexShrink: 0 }} />
279
+ )}
280
+ {s.upgrade_required && <text content=" ⬆ upg" fg={selected ? theme.text : theme.warn} style={{ flexShrink: 0 }} />}
281
+ {isServerOsEol(s) && <text content=" ⚠ os" fg={selected ? theme.text : theme.bad} style={{ flexShrink: 0 }} />}
282
+ {isSudoConnected(s.id) && <text content=" ● sudo" fg={selected ? theme.text : theme.good} style={{ flexShrink: 0 }} />}
283
+ {/* A server with no sites is a dead end (can't connect/SSH until it
284
+ has a site) — flag the 0 in amber so it's easy to spot. */}
285
+ <text content={" " + count} fg={selected ? theme.text : count === 0 ? theme.warn : theme.textFaint} style={{ flexShrink: 0 }} />
286
+ </>
287
+ )
288
+ }}
289
+ />
290
+ </Panel>
291
+
292
+ {/* Sites pane */}
293
+ <Panel
294
+ title={
295
+ server
296
+ ? ` Sites · ${truncate(server.name, 20)} (${sites.length}) 👤🔑 keys · H·C·B https/cache/backup `
297
+ : " Sites "
298
+ }
299
+ active={focus === "sites"}
300
+ flexGrow={1}
301
+ >
302
+ <List
303
+ items={sites}
304
+ selectedIndex={siteIndex}
305
+ viewportRows={listRows}
306
+ focused={focus === "sites"}
307
+ keyFor={(s) => s.id}
308
+ emptyText="No sites yet — press V to create a vanity site and connect this server"
309
+ renderRow={(s, selected) => {
310
+ const updates = (s.wp_plugin_updates || 0) + (s.wp_theme_updates || 0) + (s.wp_core_update ? 1 : 0)
311
+ // Use the DETECTED stack (probe result) so the tag reflects `d identify`
312
+ // — SpinupWP's is_wordpress/git shape misclassifies some sites (e.g. a
313
+ // /public/ WP install shown as "app" until probed). Falls back to the
314
+ // API classification when unprobed. Matches the Stacks / Forgotten views.
315
+ const stack = effectiveStack(s, probes.get(s.id)?.result.kind)
316
+ const keyKinds = grantedKeyKinds(s.id)
317
+ // The server's own vanity/health page blends in perfectly when servers
318
+ // are named like domains — mark it (⌂ + brand tint) so it can't hide.
319
+ const vanity = !!server && isVanityPair(s.domain, server.name)
320
+ return (
321
+ <>
322
+ <text content={statusDot(s.status) + " "} fg={statusColor(s.status)} style={{ flexShrink: 0 }} />
323
+ <text
324
+ content={truncate((vanity ? "⌂ " : "") + s.domain, 40)}
325
+ fg={selected ? theme.text : vanity ? theme.brand : theme.textDim}
326
+ wrapMode="none"
327
+ style={{ flexGrow: 1, flexShrink: 1 }}
328
+ />
329
+ <SiteStatusColumn
330
+ linked={localLinks.has(s.id)}
331
+ personalKey={keyKinds.personal > 0}
332
+ machineKey={keyKinds.machine > 0}
333
+ https={!!s.https?.enabled}
334
+ cache={!!s.page_cache?.enabled}
335
+ backup={!!(s.backups?.files || s.backups?.database)}
336
+ updates={updates}
337
+ selected={selected}
338
+ />
339
+ {/* Fixed-width stack column (widest tag = "bedrock" = 7) so the
340
+ icon columns to its left, and the PHP column to its right,
341
+ stay vertically aligned across every row regardless of tag. */}
342
+ <text content={stackTag(stack).padEnd(7) + " "} fg={stackColor(stack, selected)} wrapMode="none" style={{ flexShrink: 0 }} />
343
+ <PhpVersionCell version={s.php_version} upgrade={phpUpgrades.get(s.id)} selected={selected} />
344
+ </>
345
+ )
346
+ }}
347
+ />
348
+ </Panel>
349
+
350
+ {/* Detail pane */}
351
+ <Panel title=" Details " width={44}>
352
+ {focus === "sites" && focusedSite ? (
353
+ <SiteDetail site={focusedSite} serverName={server?.name ?? "—"} />
354
+ ) : server ? (
355
+ <ServerDetail server={server} siteCount={sites.length} showControl />
356
+ ) : (
357
+ <text content="No data" fg={theme.textFaint} />
358
+ )}
359
+ </Panel>
360
+ </box>
361
+ <SiteContextStrip site={focus === "sites" ? focusedSite ?? null : null} />
362
+ <StatusBar hints={hints} message={flash ?? undefined} messageColor={theme.brand} />
363
+ </box>
364
+ )
365
+ }