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,371 @@
1
+ // Fleet composition: site stacks + PHP version spread, with Tier-2 probing.
2
+ //
3
+ // Left pane: selectable composition groups. The three top-level buckets
4
+ // (Standard WP / Bedrock / Non-WP) use the EFFECTIVE stack — a conclusive Tier-2
5
+ // probe overrides the API heuristic — so counts drift toward reality as you
6
+ // probe. Non-WP expands into named sub-rows (WHMCS / Laravel / Static HTML /
7
+ // Unknown / unprobed) from cached probes.
8
+ //
9
+ // Middle pane: the sites in the selected group. `d` probes the selected site;
10
+ // `D` probes the whole selected group (bounded concurrency). Right pane: the
11
+ // fleet-wide PHP version distribution with EOL versions flagged.
12
+
13
+ import { useEffect, useMemo, useState } from "react"
14
+ import { useKeyboard } from "@opentui/react"
15
+ import { theme, statusColor, statusDot } from "../../lib/theme.ts"
16
+ import { bar, truncate } from "../../lib/format.ts"
17
+ import { STACKS, effectiveStack, stackColor, type Stack } from "../../lib/stack.ts"
18
+ import { phpSortKey } from "../../lib/phpEol.ts"
19
+ import { probeKindColor, type ProbeKind } from "../../lib/probe.ts"
20
+ import { Panel, Spinner, PhpVersionCell, SiteMetaCell } from "../components.tsx"
21
+ import { List, moveSelection } from "../List.tsx"
22
+ import { StatusBar } from "../StatusBar.tsx"
23
+ import { SiteContextStrip, SITE_CONTEXT_STRIP_HEIGHT } from "../Details.tsx"
24
+ import { openUrl } from "../../lib/open.ts"
25
+ import { siteWebUrl } from "../../lib/spinupweb.ts"
26
+ import { useStore } from "../store.tsx"
27
+ import type { Site } from "../../api/types.ts"
28
+
29
+ type Focus = "groups" | "sites"
30
+
31
+ // A selectable row in the composition pane: a top-level bucket (level 0) or a
32
+ // Non-WP sub-category (level 1).
33
+ interface Group {
34
+ id: string
35
+ label: string
36
+ level: 0 | 1
37
+ color: string
38
+ sites: Site[]
39
+ }
40
+
41
+ // Non-WP sub-categories, in display order. `kind: null` = not yet probed.
42
+ const NONWP_SUBS: { kind: ProbeKind | null; label: string }[] = [
43
+ { kind: "whmcs", label: "WHMCS" },
44
+ { kind: "laravel", label: "Laravel" },
45
+ { kind: "static", label: "Static HTML" },
46
+ { kind: "unknown", label: "Unknown" },
47
+ { kind: null, label: "unprobed" },
48
+ ]
49
+
50
+ export function Stacks({ rows }: { rows: number }) {
51
+ const store = useStore()
52
+ const { sites, serverById, route, inputMode, overlayOpen, probes, probingIds, probeErrors, runProbe, runProbeMany, isProbeStale, isPhpEol, accountSlug, setPhpUpgradeSite, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setLocalLinkSite, openLocalTerminal, openLocalUrl, localLinks, setDiscoverOpen, setForgottenOpen, setForgottenStack, sshSite, setKumaSite } =
53
+ store
54
+
55
+ const [groupIndex, setGroupIndex] = useState(0)
56
+ const [siteIndex, setSiteIndex] = useState(0)
57
+ const [focus, setFocus] = useState<Focus>("groups")
58
+ const [flash, setFlash] = useState<string | null>(null)
59
+
60
+ const flashMsg = (m: string) => {
61
+ setFlash(m)
62
+ setTimeout(() => setFlash(null), 1500)
63
+ }
64
+
65
+ // Bucket every site by EFFECTIVE stack (probe overrides Tier-1), build the
66
+ // selectable groups (with Non-WP sub-rows), and the fleet PHP histogram.
67
+ const { groups, php } = useMemo(() => {
68
+ const byStack = new Map<Stack, Site[]>(STACKS.map((s) => [s, []]))
69
+ const phpCounts = new Map<string, number>()
70
+ for (const site of sites) {
71
+ const st = effectiveStack(site, probes.get(site.id)?.result.kind)
72
+ byStack.get(st)!.push(site)
73
+ const v = site.php_version ?? "—"
74
+ phpCounts.set(v, (phpCounts.get(v) ?? 0) + 1)
75
+ }
76
+ for (const list of byStack.values()) list.sort((a, b) => a.domain.localeCompare(b.domain))
77
+
78
+ const groups: Group[] = []
79
+ for (const st of STACKS) {
80
+ const bucket = byStack.get(st)!
81
+ groups.push({ id: st, label: st, level: 0, color: stackColor(st), sites: bucket })
82
+ if (st === "Non-WP") {
83
+ for (const sub of NONWP_SUBS) {
84
+ const subSites = bucket.filter((site) => (probes.get(site.id)?.result.kind ?? null) === sub.kind)
85
+ if (subSites.length === 0) continue
86
+ groups.push({
87
+ id: `nonwp:${sub.label}`,
88
+ label: sub.label,
89
+ level: 1,
90
+ color: sub.kind ? probeKindColor(sub.kind) : theme.textFaint,
91
+ sites: subSites,
92
+ })
93
+ }
94
+ }
95
+ }
96
+ const php = [...phpCounts.entries()].sort((a, b) => phpSortKey(b[0]) - phpSortKey(a[0]))
97
+ return { groups, php }
98
+ }, [sites, probes])
99
+
100
+ // Keep selection in range as groups appear/disappear with probing.
101
+ const safeGroupIndex = Math.min(groupIndex, groups.length - 1)
102
+ const selectedGroup = groups[safeGroupIndex]
103
+ const groupSites = selectedGroup?.sites ?? []
104
+ const total = sites.length || 1
105
+
106
+ useEffect(() => {
107
+ setSiteIndex(0)
108
+ }, [safeGroupIndex])
109
+
110
+ const isActive = route === "stacks" && !inputMode && !overlayOpen
111
+
112
+ useKeyboard((key) => {
113
+ if (!isActive) return
114
+
115
+ // OpenTUI delivers letters lowercased with a `shift` flag (Shift+D → name
116
+ // "d", shift true), so normalize to an uppercase letter when shift is held
117
+ // before matching. This is what makes D distinct from d (and G from g).
118
+ const name = key.name ?? ""
119
+ const k = key.shift && name.length === 1 ? name.toUpperCase() : name
120
+
121
+ const moveBy = (delta: number) => {
122
+ if (focus === "groups") setGroupIndex((i) => moveSelection(i, delta, groups.length))
123
+ else setSiteIndex((i) => moveSelection(i, delta, groupSites.length))
124
+ }
125
+
126
+ switch (k) {
127
+ case "up":
128
+ case "k":
129
+ return moveBy(-1)
130
+ case "down":
131
+ case "j":
132
+ return moveBy(1)
133
+ case "g":
134
+ return focus === "groups" ? setGroupIndex(0) : setSiteIndex(0)
135
+ case "G":
136
+ return focus === "groups" ? setGroupIndex(groups.length - 1) : setSiteIndex(groupSites.length - 1)
137
+ case "right":
138
+ case "l":
139
+ case "return":
140
+ case "tab":
141
+ if (focus === "groups" && groupSites.length > 0) setFocus("sites")
142
+ return
143
+ case "left":
144
+ case "escape":
145
+ if (focus === "sites") setFocus("groups")
146
+ return
147
+ case "o":
148
+ if (focus === "sites" && groupSites[siteIndex]) {
149
+ const s = groupSites[siteIndex]
150
+ openUrl((s.https?.enabled ? "https://" : "http://") + s.domain)
151
+ flashMsg(`Opening ${s.domain}…`)
152
+ }
153
+ return
154
+ case "w":
155
+ // Open the selected site in the SpinupWP web app.
156
+ if (focus === "sites" && groupSites[siteIndex]) {
157
+ openUrl(siteWebUrl(groupSites[siteIndex].id, accountSlug))
158
+ flashMsg(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
159
+ }
160
+ return
161
+ case "d":
162
+ // Single probe of the selected site (sites pane only).
163
+ if (focus === "sites" && groupSites[siteIndex]) {
164
+ const s = groupSites[siteIndex]
165
+ runProbe(s)
166
+ flashMsg(`Identifying the app on ${s.domain}…`)
167
+ }
168
+ return
169
+ case "u":
170
+ // Upgrade the selected site's PHP version (sites pane only).
171
+ if (focus === "sites" && groupSites[siteIndex]) setPhpUpgradeSite(groupSites[siteIndex])
172
+ return
173
+ case "H":
174
+ // Enable/disable HTTPS on the selected site (sites pane only).
175
+ if (focus === "sites" && groupSites[siteIndex]) setHttpsToggleSite(groupSites[siteIndex])
176
+ return
177
+ case "P":
178
+ // Purge page cache + object cache on the selected site (sites pane only).
179
+ if (focus === "sites" && groupSites[siteIndex]) setPurgeCacheSite(groupSites[siteIndex])
180
+ return
181
+ case "M":
182
+ // Site monitoring (Uptime Kuma) — capital to match Search, where
183
+ // lowercase m means media fallback.
184
+ if (focus === "sites" && groupSites[siteIndex]) setKumaSite(groupSites[siteIndex])
185
+ return
186
+ case "t":
187
+ // Open the selected site's local working copy in a terminal (inline; no
188
+ // modal — the sweep workflow). The strip below shows availability.
189
+ if (focus === "sites" && groupSites[siteIndex]) flashMsg(openLocalTerminal(groupSites[siteIndex].id))
190
+ return
191
+ case "v":
192
+ // Open the selected site's stored local URL in the browser.
193
+ if (focus === "sites" && groupSites[siteIndex]) flashMsg(openLocalUrl(groupSites[siteIndex].id))
194
+ return
195
+ case "s":
196
+ // Open a terminal and SSH into the selected site.
197
+ if (focus === "sites" && groupSites[siteIndex]) flashMsg(sshSite(groupSites[siteIndex].id))
198
+ return
199
+ case "L":
200
+ // Link / edit the selected site's local working copy (form overlay).
201
+ if (focus === "sites" && groupSites[siteIndex]) setLocalLinkSite(groupSites[siteIndex])
202
+ return
203
+ case "S":
204
+ // Scan configured roots to auto-discover & batch-link local copies.
205
+ return setDiscoverOpen(true)
206
+ case "f": {
207
+ // Report: sites with no usable local copy (the inverse of S). Seed the
208
+ // report's stack filter from the selected group — a top-level stack maps
209
+ // to itself; a Non-WP sub-group (WHMCS/Laravel/…) maps to Non-WP.
210
+ const filter: Stack | null = selectedGroup?.level === 0 ? (selectedGroup.id as Stack) : selectedGroup ? "Non-WP" : null
211
+ setForgottenStack(filter)
212
+ return setForgottenOpen(true)
213
+ }
214
+ case "D":
215
+ // Probe the ENTIRE selected group, in list order (top→down), regardless
216
+ // of cursor or focus. runProbeMany skips any already in flight; target
217
+ // only un-probed sites by selecting the "unprobed" sub-group first.
218
+ if (selectedGroup && selectedGroup.sites.length > 0) {
219
+ runProbeMany(selectedGroup.sites)
220
+ flashMsg(`Identifying apps in ${selectedGroup.label} (${selectedGroup.sites.length})…`)
221
+ }
222
+ return
223
+ }
224
+ })
225
+
226
+ const listRows = Math.max(3, rows - 6 - SITE_CONTEXT_STRIP_HEIGHT)
227
+ const maxPhp = Math.max(1, ...php.map(([, n]) => n))
228
+
229
+ const hints =
230
+ focus === "groups"
231
+ ? [
232
+ { key: "↑↓/jk", label: "select" },
233
+ { key: "→/⏎", label: "view sites" },
234
+ { key: "D", label: "identify all" },
235
+ { key: "S", label: "find local copies" },
236
+ { key: "f", label: "needs local copy" },
237
+ ]
238
+ : [
239
+ { key: "↑↓/jk", label: "select site" },
240
+ { key: "←/esc", label: "back" },
241
+ { key: "d", label: "identify app" },
242
+ { key: "u", label: "change PHP" },
243
+ { key: "o", label: "open" },
244
+ { key: "w", label: "SpinupWP" },
245
+ { key: "s", label: "ssh" },
246
+ ]
247
+
248
+ // Status priority: transient flash > batch progress > selected site's error.
249
+ const selectedSite = focus === "sites" ? groupSites[siteIndex] : undefined
250
+ const selectedError = selectedSite ? probeErrors.get(selectedSite.id) : undefined
251
+ const statusMessage = flash ?? (probingIds.size > 0 ? `⟳ probing ${probingIds.size}…` : selectedError ? `⚠ ${selectedError}` : undefined)
252
+ const statusColorMsg = selectedError && !flash && probingIds.size === 0 ? theme.bad : theme.brand
253
+
254
+ return (
255
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
256
+ <box style={{ flexGrow: 1, flexDirection: "row", padding: 1, gap: 1 }}>
257
+ {/* Composition groups (top-level buckets + Non-WP sub-rows) */}
258
+ <Panel title={` Stacks (${sites.length}) `} active={focus === "groups"} width={36}>
259
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
260
+ {groups.map((grp, i) => {
261
+ const n = grp.sites.length
262
+ const selected = i === safeGroupIndex
263
+ const sel = selected && focus === "groups"
264
+ const rowBg = selected ? (focus === "groups" ? theme.selectedBg : theme.bgAlt) : undefined
265
+ if (grp.level === 1) {
266
+ // Indented sub-row: label + count (no bar).
267
+ return (
268
+ <box key={grp.id} style={{ flexDirection: "row", height: 1, backgroundColor: rowBg }}>
269
+ <text content=" └ " fg={theme.textFaint} style={{ flexShrink: 0 }} />
270
+ <text
271
+ content={grp.label}
272
+ fg={sel ? theme.text : grp.color}
273
+ wrapMode="none"
274
+ style={{ flexGrow: 1, flexShrink: 1 }}
275
+ />
276
+ <text content={String(n).padStart(4)} fg={sel ? theme.text : theme.textDim} style={{ flexShrink: 0 }} />
277
+ </box>
278
+ )
279
+ }
280
+ const frac = n / total
281
+ return (
282
+ <box key={grp.id} style={{ flexDirection: "row", height: 1, backgroundColor: rowBg }}>
283
+ <text content={grp.label.padEnd(12)} fg={sel ? theme.text : grp.color} wrapMode="none" style={{ flexShrink: 0 }} />
284
+ <text content={bar(frac, 8)} fg={sel ? theme.text : grp.color} style={{ flexShrink: 0 }} />
285
+ <text content={String(n).padStart(4)} fg={sel ? theme.text : theme.text} style={{ flexShrink: 0 }} />
286
+ <text content={`${(frac * 100).toFixed(0)}%`.padStart(5)} fg={sel ? theme.text : theme.textFaint} style={{ flexShrink: 0 }} />
287
+ </box>
288
+ )
289
+ })}
290
+ </box>
291
+ </Panel>
292
+
293
+ {/* Sites in the selected group */}
294
+ <Panel title={` ${selectedGroup?.label ?? "—"} · sites (${groupSites.length}) `} active={focus === "sites"} flexGrow={1}>
295
+ <List
296
+ items={groupSites}
297
+ selectedIndex={siteIndex}
298
+ viewportRows={listRows}
299
+ focused={focus === "sites"}
300
+ keyFor={(s) => s.id}
301
+ emptyText="No sites in this group"
302
+ renderRow={(s, selected) => {
303
+ const cached = probes.get(s.id)
304
+ const probing = probingIds.has(s.id)
305
+ const errored = probeErrors.has(s.id)
306
+ const faint = selected ? theme.text : theme.textFaint
307
+ const updates = (s.wp_plugin_updates || 0) + (s.wp_theme_updates || 0) + (s.wp_core_update ? 1 : 0)
308
+ return (
309
+ <>
310
+ <text content={statusDot(s.status) + " "} fg={statusColor(s.status)} style={{ flexShrink: 0 }} />
311
+ <text
312
+ content={truncate(s.domain, 40)}
313
+ fg={selected ? theme.text : theme.textDim}
314
+ wrapMode="none"
315
+ style={{ flexGrow: 1, flexShrink: 1 }}
316
+ />
317
+ <box style={{ marginLeft: 1 }}>
318
+ <SiteMetaCell linked={localLinks.has(s.id)} updates={updates} selected={selected} />
319
+ </box>
320
+ <box style={{ flexShrink: 0, flexDirection: "row", marginLeft: 1 }}>
321
+ {probing ? (
322
+ <Spinner color={selected ? theme.text : theme.brand} />
323
+ ) : cached ? (
324
+ <text
325
+ content={truncate(cached.result.label, 20) + (isProbeStale(s) ? "*" : "")}
326
+ fg={probeKindColor(cached.result.kind, selected)}
327
+ wrapMode="none"
328
+ />
329
+ ) : errored ? (
330
+ <text content="probe failed" fg={selected ? theme.text : theme.bad} wrapMode="none" />
331
+ ) : (
332
+ <text content="· press d" fg={faint} wrapMode="none" />
333
+ )}
334
+ </box>
335
+ <text
336
+ content={truncate(serverById(s.server_id)?.name ?? "", 16)}
337
+ fg={faint}
338
+ wrapMode="none"
339
+ style={{ flexShrink: 0, marginLeft: 1 }}
340
+ />
341
+ <box style={{ flexShrink: 0, marginLeft: 1 }}>
342
+ <PhpVersionCell version={s.php_version} upgrade={phpUpgrades.get(s.id)} selected={selected} eol={isPhpEol(s.php_version)} />
343
+ </box>
344
+ </>
345
+ )
346
+ }}
347
+ />
348
+ </Panel>
349
+
350
+ {/* PHP version distribution (fleet-wide) */}
351
+ <Panel title=" PHP versions " width={30}>
352
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
353
+ {php.map(([v, n]) => {
354
+ const eol = isPhpEol(v)
355
+ return (
356
+ <box key={v} style={{ flexDirection: "row", height: 1 }}>
357
+ <text content={v.padEnd(6)} fg={eol ? theme.bad : theme.text} style={{ flexShrink: 0 }} />
358
+ <text content={bar(n / maxPhp, 8)} fg={eol ? theme.bad : theme.brandDim} style={{ flexShrink: 0 }} />
359
+ <text content={String(n).padStart(4)} fg={theme.textDim} style={{ flexShrink: 0 }} />
360
+ <text content={eol ? " EOL" : ""} fg={theme.bad} style={{ flexShrink: 0 }} />
361
+ </box>
362
+ )
363
+ })}
364
+ </box>
365
+ </Panel>
366
+ </box>
367
+ <SiteContextStrip site={selectedSite ?? null} />
368
+ <StatusBar hints={hints} message={statusMessage} messageColor={statusColorMsg} />
369
+ </box>
370
+ )
371
+ }