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,165 @@
1
+ // "Needs a local copy" report — Phase 4a (the forgotten report).
2
+ //
3
+ // The inverse of discovery (`S`, which finds copies that exist on disk): this
4
+ // lists WordPress/Bedrock sites you have NO usable local copy for — either never
5
+ // linked, or linked to a path that's gone missing. It's the direct answer to the
6
+ // original pain ("I forget which managed sites have a local copy and miss
7
+ // composer updates"). Read-only; from a row you jump straight into linking it.
8
+ //
9
+ // Defaults to the ACTIONABLE set (sites with pending updates, plus missing-path
10
+ // links) so it's a short to-do list rather than a dump of every unlinked site;
11
+ // `a` toggles the full list. Opened with `f` (Stacks). Drift (git ahead/behind,
12
+ // PHP mismatch) is a later pass.
13
+
14
+ import { useMemo, useState } from "react"
15
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react"
16
+ import { theme, statusColor, statusDot } from "../../lib/theme.ts"
17
+ import { truncate } from "../../lib/format.ts"
18
+ import { effectiveStack, stackColor, stackTag, type Stack } from "../../lib/stack.ts"
19
+ import { resolveLocalLink } from "../../lib/local.ts"
20
+ import { Panel } from "../components.tsx"
21
+ import { List, moveSelection } from "../List.tsx"
22
+ import { StatusBar } from "../StatusBar.tsx"
23
+ import { useStore } from "../store.tsx"
24
+ import type { Site } from "../../api/types.ts"
25
+
26
+ interface Entry {
27
+ site: Site
28
+ updates: number
29
+ missing: boolean // linked, but the path is gone
30
+ }
31
+
32
+ // Stack filter options the report can cycle through (← / →). null = all stacks.
33
+ const STACK_FILTERS: (Stack | null)[] = [null, "Standard WP", "Bedrock", "Non-WP"]
34
+
35
+ export function Forgotten() {
36
+ const { sites, localLinks, probes, setForgottenOpen, setLocalLinkSite, forgottenStack, setLinkReturnToForgotten } = useStore()
37
+ const { height } = useTerminalDimensions()
38
+ // Default shows every site in the filter without a local copy; this trims to
39
+ // the "needs attention" subset (pending updates or a missing path).
40
+ const [needsAttentionOnly, setNeedsAttentionOnly] = useState(false)
41
+ const [index, setIndex] = useState(0)
42
+ // Stack filter is adjustable in-report (← / →), seeded from the Stacks group
43
+ // that was selected when opened (Non-WP / no WP group → "All").
44
+ const [stackFilter, setStackFilter] = useState<Stack | null>(forgottenStack)
45
+
46
+ // WP/Bedrock sites without a usable local copy.
47
+ const all = useMemo<Entry[]>(() => {
48
+ const out: Entry[] = []
49
+ for (const s of sites) {
50
+ const stack = effectiveStack(s, probes.get(s.id)?.result.kind)
51
+ if (stackFilter && stack !== stackFilter) continue // active stack filter (adjustable)
52
+ const link = localLinks.get(s.id)
53
+ let missing = false
54
+ if (link) {
55
+ if (resolveLocalLink(link).exists) continue // has a copy → not forgotten
56
+ missing = true
57
+ }
58
+ const updates = (s.wp_plugin_updates || 0) + (s.wp_theme_updates || 0) + (s.wp_core_update ? 1 : 0)
59
+ out.push({ site: s, updates, missing })
60
+ }
61
+ // Alphabetical by domain (consistent with the discovery list).
62
+ out.sort((a, b) => a.site.domain.localeCompare(b.site.domain))
63
+ return out
64
+ }, [sites, localLinks, probes, stackFilter])
65
+
66
+ const actionable = useMemo(() => all.filter((e) => e.updates > 0 || e.missing), [all])
67
+ const shown = needsAttentionOnly ? actionable : all
68
+ const safeIndex = Math.min(index, Math.max(0, shown.length - 1))
69
+
70
+ const close = () => setForgottenOpen(false)
71
+
72
+ useKeyboard((key) => {
73
+ const raw = key.name ?? ""
74
+ const name = key.shift && raw.length === 1 ? raw.toUpperCase() : raw
75
+ switch (name) {
76
+ case "escape":
77
+ case "q":
78
+ return close()
79
+ case "up":
80
+ case "k":
81
+ return setIndex((i) => moveSelection(i, -1, shown.length))
82
+ case "down":
83
+ case "j":
84
+ return setIndex((i) => moveSelection(i, 1, shown.length))
85
+ case "left":
86
+ case "right": {
87
+ const dir = name === "right" ? 1 : -1
88
+ const i = STACK_FILTERS.findIndex((s) => s === stackFilter)
89
+ setStackFilter(STACK_FILTERS[(i + dir + STACK_FILTERS.length) % STACK_FILTERS.length])
90
+ return setIndex(0)
91
+ }
92
+ case "a":
93
+ setNeedsAttentionOnly((v) => !v)
94
+ return setIndex(0)
95
+ case "L":
96
+ case "return": {
97
+ const e = shown[safeIndex]
98
+ if (e) {
99
+ close()
100
+ setLinkReturnToForgotten(true) // come back here when the link overlay closes
101
+ setLocalLinkSite(e.site) // jump straight into linking it
102
+ }
103
+ return
104
+ }
105
+ }
106
+ })
107
+
108
+ const listRows = Math.max(3, height - 5)
109
+
110
+ return (
111
+ <box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 215 }}>
112
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
113
+ <text content={`📦 Needs a local copy · ${stackFilter ?? "All"} `} fg={theme.brand} wrapMode="none" style={{ flexShrink: 0 }} />
114
+ <text content={needsAttentionOnly ? `${actionable.length} need attention` : `${all.length} without a local copy`} fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 1 }} />
115
+ <box style={{ flexGrow: 1 }} />
116
+ </box>
117
+
118
+ <box style={{ flexGrow: 1, flexDirection: "column", padding: 1 }}>
119
+ {shown.length === 0 ? (
120
+ <text
121
+ content={
122
+ all.length === 0
123
+ ? "✓ Every site here has a local copy."
124
+ : "✓ Nothing needs attention — press a to show all without a local copy."
125
+ }
126
+ fg={theme.good}
127
+ wrapMode="none"
128
+ />
129
+ ) : (
130
+ <List
131
+ items={shown}
132
+ selectedIndex={safeIndex}
133
+ viewportRows={listRows}
134
+ focused
135
+ keyFor={(e) => e.site.id}
136
+ emptyText="—"
137
+ renderRow={(e, selected) => {
138
+ const stack = effectiveStack(e.site, probes.get(e.site.id)?.result.kind)
139
+ return (
140
+ <>
141
+ <text content={statusDot(e.site.status) + " "} fg={statusColor(e.site.status)} style={{ flexShrink: 0 }} />
142
+ <text content={truncate(e.site.domain, 44)} fg={selected ? theme.text : theme.textDim} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
143
+ {e.missing && <text content="missing copy " fg={selected ? theme.text : theme.bad} style={{ flexShrink: 0 }} />}
144
+ {e.updates > 0 && <text content={`↑${e.updates} `} fg={selected ? theme.text : theme.warn} style={{ flexShrink: 0 }} />}
145
+ <text content={stackTag(stack) + " "} fg={stackColor(stack, selected)} style={{ flexShrink: 0 }} />
146
+ </>
147
+ )
148
+ }}
149
+ />
150
+ )}
151
+ </box>
152
+
153
+ <StatusBar
154
+ hints={[
155
+ { key: "↑↓/jk", label: "select" },
156
+ { key: "←→", label: "stack" },
157
+ { key: "L/⏎", label: "link this site" },
158
+ { key: "a", label: needsAttentionOnly ? "show all" : "needs attention only" },
159
+ { key: "esc", label: "close" },
160
+ ]}
161
+ showGlobal={false}
162
+ />
163
+ </box>
164
+ )
165
+ }
@@ -0,0 +1,504 @@
1
+ // Grant SSH key overlay — a privileged write-over-SSH on a sudo-connected server.
2
+ //
3
+ // Opened with `K` on a selected site (Browser). Pick which key(s) to deploy — your
4
+ // PERSONAL key(s) (so you can SSH/SFTP as yourself) and/or Spinup's dedicated
5
+ // `spinup-tui` MACHINE key (for unattended automation) — choose the scope (just
6
+ // this site, or every site on the server), confirm, and grant via the server's
7
+ // SUDO session. Requires sudo to be connected first (press `S` on the server). The
8
+ // grant runs in the store (`startGrantKey`), so closing the modal doesn't abandon it.
9
+ // See docs/2026-06-26_sudo-ssh-key-provisioning-spec.md.
10
+
11
+ import { useEffect, useState } from "react"
12
+ import { useKeyboard } from "@opentui/react"
13
+ import { theme } from "../../lib/theme.ts"
14
+ import { truncate } from "../../lib/format.ts"
15
+ import { Panel, Centered, Spinner } from "../components.tsx"
16
+ import { StatusBar } from "../StatusBar.tsx"
17
+ import { useStore, isKeyGrantInFlight } from "../store.tsx"
18
+ import { ensureSpinupKey, listPersonalKeys, buildGrantScript, buildRevokeScript, keyBody, type GrantableKey } from "../../lib/ssh.ts"
19
+ import { moveSelection } from "../List.tsx"
20
+ import type { Site } from "../../api/types.ts"
21
+
22
+ type Phase = "disconnected" | "loading" | "pick" | "scope" | "confirm" | "running" | "done" | "error"
23
+
24
+ export function GrantKey() {
25
+ const store = useStore()
26
+ const {
27
+ grantKeySite: site,
28
+ setGrantKeySite,
29
+ serverById,
30
+ sitesForServer,
31
+ keyGrants,
32
+ startGrantKey,
33
+ startRevokeKey,
34
+ clearGrantKey,
35
+ preferredGrantKeys,
36
+ setPreferredGrantKeys,
37
+ grantedKeys,
38
+ isSudoConnected,
39
+ sudoUserFor,
40
+ setSudoConnectServer,
41
+ } = store
42
+
43
+ const server = serverById(site?.server_id)
44
+ const connected = server ? isSudoConnected(server.id) : false
45
+ const sudoUser = server ? sudoUserFor(server.id) : undefined
46
+
47
+ // Sites on this server that can receive a key (have a site_user). The anchor
48
+ // site is always a valid single target.
49
+ const serverSites = server ? sitesForServer(server.id).filter((s) => s.site_user) : []
50
+ const canScope = serverSites.length > 1
51
+
52
+ const [phase, setPhase] = useState<Phase>(() => {
53
+ if (site && keyGrants.has(site.id)) return "running"
54
+ return connected ? "loading" : "disconnected"
55
+ })
56
+ const [keys, setKeys] = useState<GrantableKey[]>([])
57
+ const [selected, setSelected] = useState<Set<string>>(new Set())
58
+ const [index, setIndex] = useState(0)
59
+ const [loadError, setLoadError] = useState<string | null>(null)
60
+ const [scope, setScope] = useState<"site" | "server">("site")
61
+ const [scopeIndex, setScopeIndex] = useState(0)
62
+ // "grant" adds the selected keys; "revoke" removes them. Toggled on the pick screen.
63
+ const [mode, setMode] = useState<"grant" | "revoke">("grant")
64
+
65
+ // Resolve the grantable keys once (machine key + discovered personal keys).
66
+ useEffect(() => {
67
+ if (phase !== "loading") return
68
+ let cancelled = false
69
+ void Promise.all([ensureSpinupKey(), listPersonalKeys()])
70
+ .then(([machine, personal]) => {
71
+ if (cancelled) return
72
+ const machineKey: GrantableKey = { id: keyBody(machine.pub), kind: "machine", label: machine.comment, line: machine.pub, source: "spinup" }
73
+ const list = [...personal.filter((p) => p.id !== machineKey.id), machineKey]
74
+ setKeys(list)
75
+ const remembered = list.filter((k) => preferredGrantKeys.includes(k.id)).map((k) => k.id)
76
+ setSelected(new Set(remembered.length > 0 ? remembered : [personal[0]?.id ?? machineKey.id]))
77
+ setPhase("pick")
78
+ })
79
+ .catch((err) => {
80
+ if (!cancelled) {
81
+ setKeys([])
82
+ setLoadError((err as Error).message)
83
+ setPhase("error")
84
+ }
85
+ })
86
+ return () => {
87
+ cancelled = true
88
+ }
89
+ }, [phase])
90
+
91
+ const selectedKeys = keys.filter((k) => selected.has(k.id))
92
+ const targetSites: Site[] = scope === "server" ? serverSites : site ? [site] : []
93
+ // Keys Spinup has recorded as granted on the anchor site (for revoke defaults +
94
+ // the "on site" annotation).
95
+ const grantedForSite = site ? grantedKeys.get(site.id) ?? new Set<string>() : new Set<string>()
96
+
97
+ // Switch grant/revoke, re-defaulting the selection: grant → remembered keys;
98
+ // revoke → the keys recorded as granted on this site.
99
+ const switchMode = (m: "grant" | "revoke") => {
100
+ setMode(m)
101
+ if (m === "revoke") {
102
+ setSelected(new Set(keys.filter((k) => grantedForSite.has(k.id)).map((k) => k.id)))
103
+ } else {
104
+ const remembered = keys.filter((k) => preferredGrantKeys.includes(k.id)).map((k) => k.id)
105
+ setSelected(new Set(remembered.length ? remembered : keys[0] ? [keys[0].id] : []))
106
+ }
107
+ }
108
+
109
+ // Batch progress over the target sites.
110
+ const targetProgress = targetSites.map((s) => keyGrants.get(s.id))
111
+ const doneCount = targetProgress.filter((p) => p?.status === "done").length
112
+ const failedSites = targetSites.filter((s) => keyGrants.get(s.id)?.status === "error")
113
+ const settledCount = doneCount + failedSites.length
114
+ const allSettled = targetSites.length > 0 && settledCount === targetSites.length
115
+
116
+ const dp: Phase = phase !== "running" ? phase : allSettled ? "done" : "running"
117
+
118
+ const close = () => {
119
+ // Drop settled markers for the targets so they don't linger after close.
120
+ if (phase === "running" && allSettled) for (const s of targetSites) clearGrantKey(s.id)
121
+ setGrantKeySite(null)
122
+ }
123
+
124
+ const fire = (sites: Site[]) => {
125
+ if (sites.length > 0 && selectedKeys.length > 0) {
126
+ const lines = selectedKeys.map((k) => k.line)
127
+ if (mode === "grant") {
128
+ startGrantKey(sites, lines)
129
+ setPreferredGrantKeys(selectedKeys.map((k) => k.id)) // remember the choice
130
+ } else {
131
+ startRevokeKey(sites, lines)
132
+ }
133
+ }
134
+ setPhase("running")
135
+ }
136
+
137
+ useKeyboard((key) => {
138
+ const name = key.name ?? ""
139
+ if (name === "escape" || name === "q") return close()
140
+
141
+ if (dp === "disconnected") {
142
+ if (name === "s" && server) {
143
+ setGrantKeySite(null)
144
+ setSudoConnectServer(server)
145
+ }
146
+ return
147
+ }
148
+
149
+ if (dp === "pick") {
150
+ switch (name) {
151
+ case "up":
152
+ case "k":
153
+ return setIndex((i) => moveSelection(i, -1, keys.length))
154
+ case "down":
155
+ case "j":
156
+ return setIndex((i) => moveSelection(i, 1, keys.length))
157
+ case "space": {
158
+ const k = keys[index]
159
+ if (!k) return
160
+ return setSelected((prev) => {
161
+ const next = new Set(prev)
162
+ if (next.has(k.id)) next.delete(k.id)
163
+ else next.add(k.id)
164
+ return next
165
+ })
166
+ }
167
+ case "a":
168
+ return switchMode("grant")
169
+ case "r":
170
+ return switchMode("revoke")
171
+ case "return":
172
+ case "right":
173
+ case "l":
174
+ if (selectedKeys.length === 0) return
175
+ return setPhase(canScope ? "scope" : "confirm")
176
+ }
177
+ return
178
+ }
179
+
180
+ if (dp === "scope") {
181
+ switch (name) {
182
+ case "up":
183
+ case "k":
184
+ setScopeIndex(0)
185
+ return setScope("site")
186
+ case "down":
187
+ case "j":
188
+ setScopeIndex(1)
189
+ return setScope("server")
190
+ case "return":
191
+ case "right":
192
+ case "l":
193
+ return setPhase("confirm")
194
+ case "left":
195
+ case "h":
196
+ return setPhase("pick")
197
+ }
198
+ return
199
+ }
200
+
201
+ if (dp === "confirm") {
202
+ if (name === "y") return fire(targetSites)
203
+ if (name === "left" || name === "h") return setPhase(canScope ? "scope" : "pick")
204
+ return
205
+ }
206
+
207
+ if (dp === "done") {
208
+ if (name === "r" && failedSites.length > 0) return fire(failedSites)
209
+ return
210
+ }
211
+
212
+ if (dp === "error") {
213
+ if (name === "r") {
214
+ setLoadError(null)
215
+ return setPhase("loading")
216
+ }
217
+ return
218
+ }
219
+ })
220
+
221
+ if (!site) return null
222
+
223
+ const siteUser = site.site_user ?? "(no site user)"
224
+
225
+ return (
226
+ <box
227
+ style={{
228
+ position: "absolute",
229
+ top: 0,
230
+ left: 0,
231
+ width: "100%",
232
+ height: "100%",
233
+ flexDirection: "column",
234
+ backgroundColor: theme.bg,
235
+ zIndex: 210,
236
+ }}
237
+ >
238
+ {/* Title bar */}
239
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
240
+ <text content={`🔑 ${mode === "revoke" ? "Remove" : "Grant"} SSH key `} fg={theme.brand} style={{ flexShrink: 0 }} />
241
+ <text content={truncate(site.domain, 40)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
242
+ <box style={{ flexGrow: 1 }} />
243
+ <text content={connected ? "● " : "○ "} fg={connected ? theme.good : theme.textFaint} style={{ flexShrink: 0 }} />
244
+ <text content={server?.name ?? "—"} fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
245
+ </box>
246
+
247
+ <Centered>{renderBody()}</Centered>
248
+
249
+ <StatusBar hints={hints()} showGlobal={false} />
250
+ </box>
251
+ )
252
+
253
+ function renderBody() {
254
+ if (dp === "disconnected") {
255
+ return (
256
+ <Panel title=" Sudo not connected " active>
257
+ <box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
258
+ <box style={{ flexDirection: "row" }}>
259
+ <text content="○ Sudo isn't connected on " fg={theme.warn} wrapMode="none" />
260
+ <text content={server?.name ?? "this server"} fg={theme.accent} wrapMode="none" />
261
+ </box>
262
+ <box style={{ height: 1 }} />
263
+ <text content="Why: each site runs as its own Linux user, and its SSH" fg={theme.textDim} wrapMode="none" />
264
+ <text content="keys live in that user's authorized_keys. Writing another" fg={theme.textDim} wrapMode="none" />
265
+ <text content="user's file needs root — so SpinupTUI logs in as the server's" fg={theme.textDim} wrapMode="none" />
266
+ <text content="sudo user. (The SpinupWP API can't manage SSH keys.)" fg={theme.textDim} wrapMode="none" />
267
+ <box style={{ height: 1 }} />
268
+ <text content="Press s to connect sudo now (or S on the server), then" fg={theme.textFaint} wrapMode="none" />
269
+ <text content="press K on the site again." fg={theme.textFaint} wrapMode="none" />
270
+ </box>
271
+ </Panel>
272
+ )
273
+ }
274
+
275
+ if (dp === "loading") {
276
+ return (
277
+ <box style={{ flexDirection: "row" }}>
278
+ <Spinner />
279
+ <text content=" Finding your SSH keys…" fg={theme.textDim} />
280
+ </box>
281
+ )
282
+ }
283
+
284
+ if (dp === "pick") {
285
+ const revoke = mode === "revoke"
286
+ return (
287
+ <Panel title={revoke ? " Choose keys to remove " : " Choose keys to grant "} active>
288
+ <box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
289
+ <box style={{ flexDirection: "row" }}>
290
+ <text content={revoke ? "✕ Remove mode — pick key(s) to remove" : "+ Grant mode — pick key(s) to grant"} fg={revoke ? theme.bad : theme.good} wrapMode="none" />
291
+ <box style={{ flexGrow: 1 }} />
292
+ <text content="a/r switch" fg={theme.textFaint} wrapMode="none" />
293
+ </box>
294
+ <box style={{ height: 1 }} />
295
+ {keys.map((k, i) => {
296
+ const sel = i === index
297
+ const checked = selected.has(k.id)
298
+ const onSite = grantedForSite.has(k.id)
299
+ const tag = k.kind === "machine" ? "machine" : "personal"
300
+ const tagFg = sel ? theme.text : k.kind === "machine" ? theme.brand : theme.accent
301
+ const boxFg = revoke ? theme.bad : theme.good
302
+ return (
303
+ <box key={k.id} style={{ flexDirection: "row", height: 1, backgroundColor: sel ? theme.selectedBg : undefined }}>
304
+ <text content={(sel ? "❯ " : " ") + (checked ? "[x] " : "[ ] ")} fg={checked ? boxFg : theme.textDim} style={{ flexShrink: 0 }} />
305
+ <text content={truncate(k.label || k.source, 38)} fg={sel ? theme.text : theme.textDim} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
306
+ <text content={onSite ? "✓on site " : " "} fg={sel ? theme.text : theme.good} style={{ flexShrink: 0 }} wrapMode="none" />
307
+ <text content={tag} fg={tagFg} style={{ flexShrink: 0 }} />
308
+ </box>
309
+ )
310
+ })}
311
+ <box style={{ height: 1 }} />
312
+ {revoke ? (
313
+ <>
314
+ <text content="Removes the checked key(s) from authorized_keys. Other keys" fg={theme.textFaint} wrapMode="none" />
315
+ <text content="(incl. SpinupWP-managed ones) are left untouched." fg={theme.textFaint} wrapMode="none" />
316
+ </>
317
+ ) : (
318
+ <>
319
+ <text content="Personal keys log you in as yourself; the machine key is" fg={theme.textFaint} wrapMode="none" />
320
+ <text content="SpinupTUI's own identity for unattended automation." fg={theme.textFaint} wrapMode="none" />
321
+ </>
322
+ )}
323
+ </box>
324
+ </Panel>
325
+ )
326
+ }
327
+
328
+ if (dp === "scope") {
329
+ const opts = [
330
+ { label: "Just this site", sub: site!.domain },
331
+ { label: `All ${serverSites.length} sites on this server`, sub: server?.name ?? "" },
332
+ ]
333
+ return (
334
+ <Panel title={mode === "revoke" ? " Remove from " : " Grant to "} active>
335
+ <box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
336
+ {opts.map((o, i) => {
337
+ const sel = i === scopeIndex
338
+ return (
339
+ <box key={i} style={{ flexDirection: "row", height: 1, backgroundColor: sel ? theme.selectedBg : undefined }}>
340
+ <text content={(sel ? "❯ " : " ") + (sel ? "(•) " : "( ) ")} fg={sel ? theme.good : theme.textDim} style={{ flexShrink: 0 }} />
341
+ <text content={o.label} fg={sel ? theme.text : theme.textDim} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
342
+ <text content={truncate(o.sub, 26) + " "} fg={sel ? theme.text : theme.textFaint} style={{ flexShrink: 0 }} wrapMode="none" />
343
+ </box>
344
+ )
345
+ })}
346
+ <box style={{ height: 1 }} />
347
+ <text content="All sites: the same idempotent append runs on each — keys" fg={theme.textFaint} wrapMode="none" />
348
+ <text content="already present are skipped." fg={theme.textFaint} wrapMode="none" />
349
+ </box>
350
+ </Panel>
351
+ )
352
+ }
353
+
354
+ if (dp === "confirm") {
355
+ const revoke = mode === "revoke"
356
+ const n = targetSites.length
357
+ const single = n === 1
358
+ const displayLines = selectedKeys.map((k) => `…${k.kind === "machine" ? "spinup-tui" : "ed25519"}… ${k.label}`)
359
+ const script = single ? (revoke ? buildRevokeScript(siteUser, targetSites[0].domain, displayLines) : buildGrantScript(siteUser, targetSites[0].domain, displayLines)) : null
360
+ return (
361
+ <Panel title={revoke ? " Confirm — remove keys " : " Confirm — privileged write "} active>
362
+ <box style={{ flexDirection: "column", width: 72, paddingTop: 1, paddingBottom: 1 }}>
363
+ <box style={{ flexDirection: "row" }}>
364
+ <text content={`${revoke ? "Remove" : "Add"} ${selectedKeys.length} key${selectedKeys.length === 1 ? "" : "s"} ${revoke ? "from" : "to"} `} fg={theme.text} wrapMode="none" />
365
+ <text content={single ? `${siteUser}@${targetSites[0].domain}` : `${n} sites on ${server?.name ?? ""}`} fg={theme.accent} wrapMode="none" />
366
+ </box>
367
+ <box style={{ flexDirection: "row" }}>
368
+ <text content="via sudo as " fg={theme.textDim} wrapMode="none" />
369
+ <text content={sudoUser ?? "—"} fg={theme.good} wrapMode="none" />
370
+ </box>
371
+ {selectedKeys.map((k) => (
372
+ <box key={k.id} style={{ flexDirection: "row" }}>
373
+ <text content=" • " fg={theme.textFaint} wrapMode="none" />
374
+ <text content={truncate(k.label || k.source, 44)} fg={theme.textDim} wrapMode="none" />
375
+ <text content={` (${k.kind})`} fg={k.kind === "machine" ? theme.brand : theme.accent} wrapMode="none" />
376
+ </box>
377
+ ))}
378
+ <box style={{ height: 1 }} />
379
+ {single ? (
380
+ <>
381
+ <text content={`Runs on the server (idempotent — re-running is a no-op):`} fg={theme.textFaint} wrapMode="none" />
382
+ <box style={{ flexDirection: "column", backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1 }}>
383
+ {script!.split("\n").map((line, i) => (
384
+ <text key={i} content={line} fg={theme.textDim} wrapMode="none" />
385
+ ))}
386
+ </box>
387
+ </>
388
+ ) : (
389
+ <text content={`The same idempotent ${revoke ? "removal" : "append"} runs on each of the ${n} sites.`} fg={theme.textFaint} wrapMode="none" />
390
+ )}
391
+ <box style={{ height: 1 }} />
392
+ <text content={revoke ? "This removes SSH access for those keys. Press y to continue." : "This is SpinupTUI's most powerful write. Press y to continue."} fg={theme.warn} wrapMode="none" />
393
+ <text content="y confirm · ← back · Esc cancel" fg={theme.textFaint} wrapMode="none" />
394
+ </box>
395
+ </Panel>
396
+ )
397
+ }
398
+
399
+ if (dp === "running") {
400
+ const revoke = mode === "revoke"
401
+ const n = targetSites.length
402
+ return (
403
+ <Panel title={revoke ? " Removing " : " Granting "} active>
404
+ <box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
405
+ <box style={{ flexDirection: "row" }}>
406
+ <Spinner />
407
+ <text content={` ${revoke ? "Removing from" : "Granting to"} ${n} site${n === 1 ? "" : "s"} — ${settledCount}/${n} done`} fg={theme.textDim} wrapMode="none" />
408
+ </box>
409
+ {failedSites.length > 0 && (
410
+ <text content={`${doneCount} ok · ${failedSites.length} failed so far`} fg={theme.textFaint} wrapMode="none" />
411
+ )}
412
+ <box style={{ height: 1 }} />
413
+ <text content="You can press Esc — it keeps running in the background." fg={theme.textFaint} wrapMode="none" />
414
+ </box>
415
+ </Panel>
416
+ )
417
+ }
418
+
419
+ if (dp === "done") {
420
+ const n = targetSites.length
421
+ return (
422
+ <Panel title={failedSites.length > 0 ? " Done — with errors " : " Done "} active>
423
+ <box style={{ flexDirection: "column", width: 70, paddingTop: 1, paddingBottom: 1 }}>
424
+ <box style={{ flexDirection: "row" }}>
425
+ <text content={failedSites.length > 0 ? "⚠ " : "✓ "} fg={failedSites.length > 0 ? theme.warn : theme.good} />
426
+ <text content={`${mode === "revoke" ? "Removed from" : "Granted on"} ${doneCount} of ${n} site${n === 1 ? "" : "s"}`} fg={theme.text} wrapMode="none" />
427
+ </box>
428
+ {failedSites.length > 0 && (
429
+ <>
430
+ <box style={{ height: 1 }} />
431
+ <text content="Failed:" fg={theme.bad} wrapMode="none" />
432
+ {failedSites.slice(0, 6).map((s) => (
433
+ <box key={s.id} style={{ flexDirection: "row" }}>
434
+ <text content={` ✕ ${truncate(s.domain, 30)} `} fg={theme.bad} wrapMode="none" />
435
+ <text content={truncate(keyGrants.get(s.id)?.error ?? "", 30)} fg={theme.textFaint} wrapMode="none" />
436
+ </box>
437
+ ))}
438
+ {failedSites.length > 6 && <text content={` …${failedSites.length - 6} more`} fg={theme.textFaint} wrapMode="none" />}
439
+ </>
440
+ )}
441
+ <box style={{ height: 1 }} />
442
+ <text content={failedSites.length > 0 ? "r retry the failed · Esc close" : "Esc to close"} fg={theme.textFaint} wrapMode="none" />
443
+ </box>
444
+ </Panel>
445
+ )
446
+ }
447
+
448
+ // error (loading keys failed)
449
+ return (
450
+ <Panel title=" Grant failed " active>
451
+ <box style={{ flexDirection: "column", width: 72, paddingTop: 1, paddingBottom: 1 }}>
452
+ <text content={`✕ ${loadError ?? "Something went wrong."}`} fg={theme.bad} wrapMode="none" />
453
+ <box style={{ height: 1 }} />
454
+ <text content="r retry · Esc close" fg={theme.textFaint} wrapMode="none" />
455
+ </box>
456
+ </Panel>
457
+ )
458
+ }
459
+
460
+ function hints() {
461
+ switch (dp) {
462
+ case "disconnected":
463
+ return [
464
+ { key: "s", label: "connect sudo" },
465
+ { key: "esc", label: "close" },
466
+ ]
467
+ case "pick":
468
+ return [
469
+ { key: "↑↓", label: "key" },
470
+ { key: "space", label: "toggle" },
471
+ { key: "a/r", label: mode === "revoke" ? "remove mode" : "grant mode" },
472
+ { key: "⏎", label: canScope ? "scope" : "next" },
473
+ { key: "esc", label: "cancel" },
474
+ ]
475
+ case "scope":
476
+ return [
477
+ { key: "↑↓", label: "scope" },
478
+ { key: "⏎", label: "next" },
479
+ { key: "←", label: "back" },
480
+ { key: "esc", label: "cancel" },
481
+ ]
482
+ case "confirm":
483
+ return [
484
+ { key: "y", label: "confirm" },
485
+ { key: "←", label: "back" },
486
+ { key: "esc", label: "cancel" },
487
+ ]
488
+ case "done":
489
+ return failedSites.length > 0
490
+ ? [
491
+ { key: "r", label: "retry failed" },
492
+ { key: "esc", label: "close" },
493
+ ]
494
+ : [{ key: "esc", label: "close" }]
495
+ case "error":
496
+ return [
497
+ { key: "r", label: "retry" },
498
+ { key: "esc", label: "close" },
499
+ ]
500
+ default:
501
+ return [{ key: "esc", label: "close" }]
502
+ }
503
+ }
504
+ }