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,527 @@
1
+ // DNS provider connect / manage overlay — Phase 2 (access detection, read-only).
2
+ //
3
+ // Opened with `c` from the DNS inventory on a zone whose host is an API provider
4
+ // (Route 53 → AWS, Cloudflare). It's BOTH the connect form and the per-provider
5
+ // manager: it lists every credential ("connection") for that provider — because a
6
+ // dev has many accounts — and lets you add another, re-verify, or remove one.
7
+ //
8
+ // Connecting = verify the credential by listing the account's zones; that same
9
+ // data drives the inventory's ACCESS column. Verification runs before anything is
10
+ // stored, so a bad credential never persists. Secrets live in config.json (0600).
11
+ //
12
+ // Secret fields (CF token, AWS secret key) use the masked SecretInput; once saved
13
+ // a secret is never shown again (the list shows label + zone count + status only).
14
+
15
+ import { useEffect, useMemo, useState } from "react"
16
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react"
17
+ import { theme } from "../../lib/theme.ts"
18
+ import { truncate, timeAgo } from "../../lib/format.ts"
19
+ import { Panel, SecretInput, Spinner } from "../components.tsx"
20
+ import { List, moveSelection } from "../List.tsx"
21
+ import { StatusBar } from "../StatusBar.tsx"
22
+ import { useStore } from "../store.tsx"
23
+ import { openUrl, copyToClipboard } from "../../lib/open.ts"
24
+ import { apiProviderFor, consoleForHost, DEFAULT_WEB_ACCESS_NOTE, PROVIDER_REGISTRY, type Connection } from "../../lib/providers.ts"
25
+ import { normalizeDomain } from "../../lib/dns.ts"
26
+
27
+ type Mode = "list" | "add" | "note"
28
+ type Pane = "connections" | "zones"
29
+
30
+ // Fixed width (incl. the leading "● "/" " marker) for the delegate-zones apex
31
+ // column, so every row's note lands at the same column — sized for the longest
32
+ // domains actually seen (e.g. "collisionsafetyconsultantstn.com").
33
+ const ZONE_APEX_W = 38
34
+
35
+ export function ProviderConnect() {
36
+ const store = useStore()
37
+ const {
38
+ connectZoneTarget,
39
+ setConnectZoneTarget,
40
+ connectionsFor,
41
+ providerZones,
42
+ addConnection,
43
+ removeConnection,
44
+ verifyConnectionById,
45
+ setInputMode,
46
+ zonesForHostKey,
47
+ zoneAccessNotes,
48
+ setZoneAccessNote,
49
+ resolveAllFleetDomains,
50
+ dnsResolving,
51
+ dnsZones,
52
+ } = store
53
+
54
+ const target = connectZoneTarget
55
+ const provider = target ? apiProviderFor(target.hostKey) : null
56
+ const descriptor = provider ? PROVIDER_REGISTRY[provider] : null
57
+ const fields = descriptor?.fields ?? []
58
+ const connections: Connection[] = provider ? connectionsFor(provider) : []
59
+ // The web handoff for this host — an API provider's console (GoDaddy's Clients
60
+ // hub) OR a pure web-only host with no API at all (Namecheap, Network
61
+ // Solutions, ...). Its presence is what makes this a "delegate access" host.
62
+ const consoleInfo = target ? consoleForHost(target.hostKey) : null
63
+ const isDelegateMode = !!consoleInfo
64
+ // Display name: the ConnProvider's proper name when there is one, else the
65
+ // human label already resolved for this zone (e.g. "Namecheap"), else the
66
+ // raw host key as a last resort.
67
+ const providerName =
68
+ descriptor?.name ?? (target ? dnsZones.get(normalizeDomain(target.apex))?.zone?.providerLabel : undefined) ?? target?.hostKey ?? ""
69
+
70
+ // Jump straight to the add form when there are no connections — unless the
71
+ // host has a web handoff (GoDaddy, or any pure web-only host), where the
72
+ // list's `a`/`w` choice (add a key vs open the web console) should be visible first.
73
+ const [mode, setMode] = useState<Mode>(connections.length === 0 && !isDelegateMode ? "add" : "list")
74
+ const openConsole = () => {
75
+ if (!consoleInfo) return
76
+ if (target) copyToClipboard(target.apex)
77
+ openUrl(consoleInfo.url)
78
+ note(target ? `${consoleInfo.label} opened · ${target.apex} copied — Login as the client, then paste.` : `${consoleInfo.label} opened.`)
79
+ }
80
+ const [sel, setSel] = useState(0)
81
+ // Delegate-access hosts have no connections to select on the left — the Zones
82
+ // pane on the right is the whole point of this screen — so land there
83
+ // directly instead of hiding its `n`/`r` hints behind a → press.
84
+ const [pane, setPane] = useState<Pane>(() => (isDelegateMode ? "zones" : "connections"))
85
+ const [zoneIndex, setZoneIndex] = useState(0)
86
+ const [field, setField] = useState(0) // 0 = label; 1..N = descriptor.fields
87
+ const [label, setLabel] = useState("")
88
+ const [creds, setCreds] = useState<Record<string, string>>({})
89
+ const setCred = (name: string, val: string) => setCreds((c) => ({ ...c, [name]: val }))
90
+ const [busy, setBusy] = useState(false)
91
+ const [error, setError] = useState<string | null>(null)
92
+ const [flash, setFlash] = useState<string | null>(null)
93
+ const [noteDraft, setNoteDraft] = useState("")
94
+ const [resolvingAll, setResolvingAll] = useState(false)
95
+
96
+ const { height } = useTerminalDimensions()
97
+ const safeSel = Math.min(sel, Math.max(0, connections.length - 1))
98
+ const selectedConn = connections[safeSel]
99
+ const selectedVerified = selectedConn ? providerZones.get(selectedConn.id) : undefined
100
+ // The selected connection's zones (idea B: the right-pane drill-down).
101
+ const zones = useMemo(
102
+ () => (selectedVerified?.ok ? [...selectedVerified.zones].sort((a, b) => a.apex.localeCompare(b.apex)) : []),
103
+ [selectedVerified],
104
+ )
105
+
106
+ // Delegate-access hosts (no API, or API we're not connected to) have no
107
+ // API-verified zones list. Instead, list every zone already known (fleet-wide,
108
+ // from prior DNS lookups) to be hosted here — this pane doubles as the edit
109
+ // surface for each zone's access note.
110
+ const delegateZones = useMemo(
111
+ () => (isDelegateMode && target ? zonesForHostKey(target.hostKey) : []),
112
+ [isDelegateMode, target, zonesForHostKey],
113
+ )
114
+ // Apex-only view of whichever zone list is active, for navigation/selection.
115
+ const zoneRows = isDelegateMode ? delegateZones : zones.map((z) => z.apex)
116
+ const noteFor = (apex: string): { text: string; isOverride: boolean } => {
117
+ const override = zoneAccessNotes.get(apex)
118
+ return override ? { text: override, isOverride: true } : { text: DEFAULT_WEB_ACCESS_NOTE, isOverride: false }
119
+ }
120
+
121
+ // Stop the "resolving…" indicator once nothing's in flight anymore.
122
+ useEffect(() => {
123
+ if (resolvingAll && dnsResolving.size === 0) setResolvingAll(false)
124
+ }, [resolvingAll, dnsResolving])
125
+
126
+ // The form owns the keyboard while adding or editing a note (so typing doesn't navigate).
127
+ useEffect(() => {
128
+ setInputMode(mode === "add" || mode === "note")
129
+ return () => setInputMode(false)
130
+ }, [mode, setInputMode])
131
+
132
+ // Reset the zone scroll when the selected connection changes.
133
+ useEffect(() => {
134
+ setZoneIndex(0)
135
+ }, [safeSel])
136
+
137
+ // Land the zones-pane cursor on the zone this screen was opened FROM (marked
138
+ // ●) rather than always the top of a list that can run 40+ deep — otherwise
139
+ // `n` right after opening silently edits the wrong (alphabetically-first) zone.
140
+ useEffect(() => {
141
+ if (!target) return
142
+ const idx = zoneRows.indexOf(target.apex)
143
+ if (idx >= 0) setZoneIndex(idx)
144
+ // eslint-disable-next-line react-hooks/exhaustive-deps
145
+ }, [target?.apex])
146
+
147
+ const close = () => {
148
+ setInputMode(false)
149
+ setConnectZoneTarget(null)
150
+ }
151
+
152
+ const note = (msg: string) => {
153
+ setFlash(msg)
154
+ setTimeout(() => setFlash(null), 2500)
155
+ }
156
+
157
+ const resetForm = () => {
158
+ setLabel("")
159
+ setCreds({})
160
+ setField(0)
161
+ setError(null)
162
+ }
163
+
164
+ const submit = async () => {
165
+ if (busy || !provider) return
166
+ setError(null)
167
+ const missing = fields.find((f) => !f.optional && !(creds[f.name] ?? "").trim())
168
+ if (missing) return setError(`${missing.label.replace(/ \(.*\)$/, "")} is required.`)
169
+ setBusy(true)
170
+ const res = await addConnection(provider, label, creds)
171
+ setBusy(false)
172
+ if (res.ok) {
173
+ note(`Connected — ${res.zones.length} zone${res.zones.length === 1 ? "" : "s"}`)
174
+ resetForm()
175
+ setMode("list")
176
+ } else {
177
+ const base = res.error || "Verification failed."
178
+ setError(consoleInfo ? `${base} — Esc, then w for the ${consoleInfo.label}.` : base)
179
+ }
180
+ }
181
+
182
+ const fieldCount = fields.length + 1 // + the label field
183
+
184
+ const selectedZoneApex = pane === "zones" ? zoneRows[Math.min(zoneIndex, zoneRows.length - 1)] : undefined
185
+
186
+ const saveNote = () => {
187
+ if (selectedZoneApex) setZoneAccessNote(selectedZoneApex, noteDraft)
188
+ setMode("list")
189
+ }
190
+
191
+ useKeyboard((key) => {
192
+ const raw = key.name ?? ""
193
+ if (busy) return // ignore input while verifying
194
+
195
+ if (mode === "add") {
196
+ if (raw === "escape") return connections.length > 0 || isDelegateMode ? (resetForm(), setMode("list")) : close()
197
+ if (raw === "up") return setField((f) => (f - 1 + fieldCount) % fieldCount)
198
+ if (raw === "down") return setField((f) => (f + 1) % fieldCount)
199
+ return
200
+ }
201
+
202
+ if (mode === "note") {
203
+ if (raw === "escape") setMode("list")
204
+ return
205
+ }
206
+
207
+ // list mode — two panes: connections (left) and that account's zones (right)
208
+ switch (raw) {
209
+ case "escape":
210
+ return pane === "zones" ? setPane("connections") : close()
211
+ case "q":
212
+ return close()
213
+ case "up":
214
+ case "k":
215
+ return pane === "zones"
216
+ ? setZoneIndex((i) => moveSelection(i, -1, zoneRows.length))
217
+ : setSel((i) => moveSelection(i, -1, connections.length))
218
+ case "down":
219
+ case "j":
220
+ return pane === "zones"
221
+ ? setZoneIndex((i) => moveSelection(i, 1, zoneRows.length))
222
+ : setSel((i) => moveSelection(i, 1, connections.length))
223
+ case "right":
224
+ case "l":
225
+ case "tab":
226
+ if (pane === "connections" && zoneRows.length > 0) {
227
+ setZoneIndex(0)
228
+ setPane("zones")
229
+ }
230
+ return
231
+ case "left":
232
+ return pane === "zones" ? setPane("connections") : undefined
233
+ case "a":
234
+ // Adding a credential only makes sense for a real API provider.
235
+ if (pane !== "connections" || !provider) return
236
+ resetForm()
237
+ return setMode("add")
238
+ case "n":
239
+ // Edit the selected zone's access-note override (delegate-access providers only).
240
+ if (!isDelegateMode || pane !== "zones" || !selectedZoneApex) return
241
+ setNoteDraft(zoneAccessNotes.get(selectedZoneApex) ?? "")
242
+ return setMode("note")
243
+ case "r":
244
+ // Resolve every fleet domain so this pane's zone list can become complete.
245
+ if (!isDelegateMode) return
246
+ setResolvingAll(true)
247
+ resolveAllFleetDomains()
248
+ note("Resolving fleet DNS…")
249
+ return
250
+ case "w":
251
+ // Web-console handoff (e.g. GoDaddy without API access, or any pure
252
+ // web-only host like Namecheap/Network Solutions).
253
+ if (consoleInfo) openConsole()
254
+ return
255
+ case "v": {
256
+ if (pane !== "connections") return
257
+ if (selectedConn) {
258
+ verifyConnectionById(selectedConn.id)
259
+ note(`Re-verifying ${selectedConn.label || selectedConn.id}…`)
260
+ }
261
+ return
262
+ }
263
+ case "x": {
264
+ if (pane !== "connections") return
265
+ if (selectedConn && !selectedConn.env) {
266
+ removeConnection(selectedConn.id)
267
+ setSel(0)
268
+ setPane("connections")
269
+ note("Removed connection")
270
+ } else if (selectedConn?.env) {
271
+ note("Env connection — unset the variable to remove")
272
+ }
273
+ return
274
+ }
275
+ }
276
+ })
277
+
278
+ // Render as long as there's something to manage: a real API provider, or at
279
+ // least a web handoff (any recognized delegate-access-style host).
280
+ if (!target || (!provider && !isDelegateMode)) return null
281
+
282
+ return (
283
+ <box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 230 }}>
284
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
285
+ <text content={`${provider ? "🔑 Connect" : "📝"} ${providerName} `} fg={theme.brand} wrapMode="none" style={{ flexShrink: 0 }} />
286
+ <text content={`for ${truncate(target.apex, 40)}`} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
287
+ </box>
288
+
289
+ <box style={{ flexGrow: 1, flexDirection: "column", padding: 1 }}>
290
+ {mode === "list" ? renderList() : mode === "note" ? renderNoteEditor() : renderAdd()}
291
+ </box>
292
+
293
+ <StatusBar hints={hints()} message={busy ? "Verifying…" : (flash ?? error ?? undefined)} messageColor={error && !flash ? theme.bad : theme.brand} showGlobal={false} />
294
+ </box>
295
+ )
296
+
297
+ function renderList() {
298
+ const zoneTitle = isDelegateMode
299
+ ? ` Zones${delegateZones.length ? ` (${delegateZones.length})` : ""} `
300
+ : selectedConn
301
+ ? ` Zones · ${truncate(selectedConn.label || selectedConn.id, 18)}${selectedVerified?.ok ? ` (${zones.length})` : ""} `
302
+ : " Zones "
303
+ return (
304
+ <box style={{ flexGrow: 1, flexDirection: "row", gap: 1 }}>
305
+ {/* Left: the provider's connections (accounts) */}
306
+ <Panel title={provider ? ` ${providerName} connections ` : ` ${providerName} `} active={pane === "connections"} width={48}>
307
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
308
+ {descriptor?.accessNote ? (
309
+ <box style={{ flexDirection: "column", marginBottom: 1 }}>
310
+ <text content={descriptor.accessNote} fg={theme.textDim} />
311
+ <box style={{ height: 1 }} />
312
+ </box>
313
+ ) : null}
314
+ {provider && connections.length === 0 ? (
315
+ <text
316
+ content={consoleInfo ? `No API key — press a to add one, or w for the ${consoleInfo.label}.` : "No connections yet — press a to add one."}
317
+ fg={theme.textFaint}
318
+ />
319
+ ) : null}
320
+ {!provider ? <text content={`No API access for ${providerName}.`} fg={theme.textFaint} /> : null}
321
+ {isDelegateMode ? (
322
+ <box style={{ flexDirection: "column", marginTop: 1 }}>
323
+ <text
324
+ content={`Every zone on the right is a domain SpinupTUI has already seen hosted here. Each shows an access note — "${DEFAULT_WEB_ACCESS_NOTE}" by default, or your own note (e.g. a third-party IT contact) once you set one.`}
325
+ fg={theme.textFaint}
326
+ />
327
+ <box style={{ height: 1 }} />
328
+ <text content="n edits the selected zone's note · r resolves your whole fleet's DNS to fill in any missing zones." fg={theme.textFaint} />
329
+ </box>
330
+ ) : null}
331
+ {connections.length > 0 && (
332
+ connections.map((c, i) => {
333
+ const v = providerZones.get(c.id)
334
+ const selected = i === safeSel
335
+ const status = !v
336
+ ? "not verified · v"
337
+ : v.ok
338
+ ? `${v.zones.length} zone${v.zones.length === 1 ? "" : "s"} · ${timeAgo(new Date(v.verifiedAt).toISOString())}`
339
+ : `error: ${truncate(v.error ?? "failed", 22)}`
340
+ const statusColor = !v ? theme.textFaint : v.ok ? theme.good : theme.bad
341
+ return (
342
+ <box key={c.id} style={{ flexDirection: "row", backgroundColor: selected && pane === "connections" ? theme.selectedBg : undefined }}>
343
+ <text content={selected ? "› " : " "} fg={theme.brand} style={{ flexShrink: 0 }} />
344
+ <text content={truncate(c.label || c.id, 16).padEnd(17)} fg={selected ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 0 }} />
345
+ {c.env ? <text content="env " fg={theme.purple} style={{ flexShrink: 0 }} /> : null}
346
+ <text content={status} fg={selected ? theme.text : statusColor} wrapMode="none" style={{ flexShrink: 1 }} />
347
+ </box>
348
+ )
349
+ })
350
+ )}
351
+ <box style={{ flexGrow: 1 }} />
352
+ <text
353
+ content={`${provider ? "a add · v re-verify · x remove · " : ""}→ zones${consoleInfo ? ` · w ${consoleInfo.label}` : ""}`}
354
+ fg={theme.textFaint}
355
+ wrapMode="none"
356
+ />
357
+ </box>
358
+ </Panel>
359
+
360
+ {/* Right: the selected connection's zones (idea B drill-down) */}
361
+ <Panel title={zoneTitle} active={pane === "zones"} flexGrow={1}>
362
+ {renderZones()}
363
+ </Panel>
364
+ </box>
365
+ )
366
+ }
367
+
368
+ function renderZones() {
369
+ if (isDelegateMode) return renderDelegateZones()
370
+ if (!selectedConn) return <text content="—" fg={theme.textFaint} />
371
+ if (!selectedVerified) return <text content="Not verified yet — press v on the connection." fg={theme.textFaint} wrapMode="none" />
372
+ if (!selectedVerified.ok) return <text content={`Verification failed: ${selectedVerified.error ?? "unknown error"}`} fg={theme.bad} wrapMode="none" />
373
+ if (zones.length === 0) return <text content="No DNS zones in this account." fg={theme.textFaint} wrapMode="none" />
374
+ return (
375
+ <List
376
+ items={zones}
377
+ selectedIndex={Math.min(zoneIndex, zones.length - 1)}
378
+ viewportRows={Math.max(3, height - 7)}
379
+ focused={pane === "zones"}
380
+ keyFor={(z) => z.apex}
381
+ renderRow={(z, selected) => {
382
+ const isTarget = z.apex === target!.apex
383
+ return (
384
+ <text
385
+ content={(isTarget ? "● " : " ") + truncate(z.apex, 48)}
386
+ fg={selected ? theme.text : isTarget ? theme.brand : theme.textDim}
387
+ wrapMode="none"
388
+ />
389
+ )
390
+ }}
391
+ />
392
+ )
393
+ }
394
+
395
+ // "Delegate Access"-style providers have no API to list zones with, so this
396
+ // draws from every zone this host has already been seen hosting, fleet-wide
397
+ // (zonesForHostKey — the same DNS cache DnsInventory uses). Doubles as the
398
+ // per-zone access-note editor (n).
399
+ function renderDelegateZones() {
400
+ return (
401
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
402
+ {resolvingAll ? (
403
+ <box style={{ flexDirection: "row", height: 1, marginBottom: 1 }}>
404
+ <Spinner color={theme.brand} interval={120} />
405
+ <text content=" resolving fleet DNS…" fg={theme.textDim} wrapMode="none" />
406
+ </box>
407
+ ) : null}
408
+ {delegateZones.length === 0 ? (
409
+ <text content="No known zones yet for this host — press r to resolve your fleet's DNS." fg={theme.textFaint} wrapMode="none" />
410
+ ) : (
411
+ <List
412
+ items={delegateZones}
413
+ selectedIndex={Math.min(zoneIndex, delegateZones.length - 1)}
414
+ viewportRows={Math.max(3, height - (resolvingAll ? 8 : 7))}
415
+ focused={pane === "zones"}
416
+ keyFor={(apex) => apex}
417
+ renderRow={(apex, selected) => {
418
+ const isTarget = apex === target!.apex
419
+ const { text: noteText, isOverride } = noteFor(apex)
420
+ const noteColor = selected ? theme.text : isOverride ? theme.warn : theme.textDim
421
+ // Fixed-width apex column (baked into the content, like DnsInventory's
422
+ // NAME/HOST columns) so the note column lands at the same spot on
423
+ // every row — a flex-grow apex doesn't reserve trailing space.
424
+ return (
425
+ <box style={{ flexDirection: "row" }}>
426
+ <text
427
+ content={((isTarget ? "● " : " ") + truncate(apex, ZONE_APEX_W - 4)).padEnd(ZONE_APEX_W)}
428
+ fg={selected ? theme.text : isTarget ? theme.brand : theme.textDim}
429
+ wrapMode="none"
430
+ style={{ flexShrink: 0 }}
431
+ />
432
+ <text content={noteText} fg={noteColor} wrapMode="none" style={{ flexShrink: 1 }} />
433
+ </box>
434
+ )
435
+ }}
436
+ />
437
+ )}
438
+ </box>
439
+ )
440
+ }
441
+
442
+ function renderNoteEditor() {
443
+ const inputStyle = { backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }
444
+ return (
445
+ <Panel title=" Access note " active>
446
+ <box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
447
+ <text content={truncate(selectedZoneApex ?? "", 60)} fg={theme.text} wrapMode="none" />
448
+ <box style={{ height: 1 }} />
449
+ <text content={`Who has access, if not the default (${DEFAULT_WEB_ACCESS_NOTE})`} fg={theme.textDim} wrapMode="none" />
450
+ <input
451
+ focused
452
+ value={noteDraft}
453
+ placeholder={DEFAULT_WEB_ACCESS_NOTE}
454
+ onInput={setNoteDraft}
455
+ onSubmit={saveNote}
456
+ style={inputStyle}
457
+ />
458
+ <box style={{ height: 1 }} />
459
+ <text content="Leave blank to clear the override and fall back to the default." fg={theme.textFaint} wrapMode="none" />
460
+ </box>
461
+ </Panel>
462
+ )
463
+ }
464
+
465
+ function renderAdd() {
466
+ const fieldFg = (i: number) => (field === i ? theme.accent : theme.textDim)
467
+ const inputStyle = { backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }
468
+ return (
469
+ <Panel title={` Add ${providerName} connection `} active>
470
+ <box style={{ flexDirection: "column", width: 72, paddingTop: 1, paddingBottom: 1 }}>
471
+ <text content="Label (optional — defaults to the account name)" fg={fieldFg(0)} />
472
+ <input focused={field === 0} value={label} placeholder="e.g. main account" onInput={setLabel} onSubmit={() => setField(1)} style={inputStyle} />
473
+ <box style={{ height: 1 }} />
474
+
475
+ {fields.map((f, j) => {
476
+ const idx = j + 1
477
+ const last = idx === fieldCount - 1
478
+ const onSubmit = last ? submit : () => setField(idx + 1)
479
+ const labelText = f.label + (f.secret ? " (masked)" : "")
480
+ return (
481
+ <box key={f.name} style={{ flexDirection: "column" }}>
482
+ <text content={labelText} fg={fieldFg(idx)} wrapMode="none" />
483
+ {f.secret ? (
484
+ <SecretInput focused={field === idx} value={creds[f.name] ?? ""} placeholder={f.placeholder} onChange={(v) => setCred(f.name, v)} onSubmit={onSubmit} />
485
+ ) : (
486
+ <input focused={field === idx} value={creds[f.name] ?? ""} placeholder={f.placeholder} onInput={(v) => setCred(f.name, v)} onSubmit={onSubmit} style={inputStyle} />
487
+ )}
488
+ <box style={{ height: 1 }} />
489
+ </box>
490
+ )
491
+ })}
492
+ <text content={descriptor!.guidance} fg={theme.textFaint} />
493
+ </box>
494
+ </Panel>
495
+ )
496
+ }
497
+
498
+ function hints() {
499
+ if (mode === "add")
500
+ return [
501
+ { key: "↑↓", label: "field" },
502
+ { key: "⏎", label: "next / connect" },
503
+ { key: "esc", label: connections.length > 0 ? "back" : "cancel" },
504
+ ]
505
+ if (mode === "note")
506
+ return [
507
+ { key: "⏎", label: "save" },
508
+ { key: "esc", label: "cancel" },
509
+ ]
510
+ if (pane === "zones")
511
+ return [
512
+ { key: "↑↓/jk", label: "scroll zones" },
513
+ ...(isDelegateMode ? [{ key: "n", label: "access note" }, { key: "r", label: "resolve all" }] : []),
514
+ { key: "←", label: isDelegateMode ? "back" : "back to accounts" },
515
+ { key: "esc", label: "back" },
516
+ ]
517
+ return [
518
+ { key: "↑↓", label: "account" },
519
+ { key: "→", label: "its zones" },
520
+ ...(provider ? [{ key: "a", label: "add" }] : []),
521
+ ...(consoleInfo ? [{ key: "w", label: consoleInfo.label }] : []),
522
+ ...(isDelegateMode ? [{ key: "r", label: "resolve all" }] : []),
523
+ ...(provider ? [{ key: "v", label: "re-verify" }, { key: "x", label: "remove" }] : []),
524
+ { key: "esc", label: "close" },
525
+ ]
526
+ }
527
+ }