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,750 @@
1
+ // "Create a new server" overlay (backlog item 5, build 1) — the standalone
2
+ // server-creation action the clone wizard will later reuse.
3
+ //
4
+ // Opened with `c` on a selected server (Servers tab). Seeds the form by MATCHING
5
+ // the selected server's provider/region/size, prices it from the provider
6
+ // metadata, suggests a hostname from the fleet's naming convention, then confirms
7
+ // before firing POST /servers. The actual create + ~10-min event poll live in the
8
+ // store (`startNewServer`), so closing this modal (Esc) doesn't abandon the build.
9
+ //
10
+ // You can switch to any supported provider (DigitalOcean / Vultr / Linode /
11
+ // Hetzner). The API exposes no endpoint to list an account's server providers, so
12
+ // each provider's numeric id is captured in-app the first time it's used and saved
13
+ // to config.json (the same way accountSlug is configured). Region + size are
14
+ // provider-specific, so switching provider re-defaults them from that provider's
15
+ // catalog.
16
+
17
+ import { useEffect, useState } from "react"
18
+ import { useKeyboard } from "@opentui/react"
19
+ import { theme } from "../../lib/theme.ts"
20
+ import { truncate } from "../../lib/format.ts"
21
+ import { Panel, Spinner, Centered, Field } from "../components.tsx"
22
+ import { StatusBar } from "../StatusBar.tsx"
23
+ import { useStore, isNewServerInFlight } from "../store.tsx"
24
+ import { moveSelection } from "../List.tsx"
25
+ import { openUrl } from "../../lib/open.ts"
26
+ import { serverProvidersSettingsUrl } from "../../lib/spinupweb.ts"
27
+ import {
28
+ providerKeyFromName,
29
+ providerLabel,
30
+ sizeBySlug,
31
+ regionBySlug,
32
+ sizesForRegion,
33
+ matchSizeBySpec,
34
+ parseSizeSpec,
35
+ allRegions,
36
+ firstRegion,
37
+ formatSize,
38
+ formatCost,
39
+ suggestServerName,
40
+ PROVIDER_KEYS,
41
+ type ProviderKey,
42
+ } from "../../lib/serverCreate.ts"
43
+ import type { CreateServerPayload } from "../../api/types.ts"
44
+
45
+ type Phase =
46
+ | "providers"
47
+ | "provider"
48
+ | "loading"
49
+ | "form"
50
+ | "regions"
51
+ | "sizes"
52
+ | "name"
53
+ | "confirm"
54
+ | "tracking"
55
+ | "done"
56
+ | "error"
57
+
58
+ // m:ss elapsed since a build was fired.
59
+ function fmtElapsed(ms: number): string {
60
+ const s = Math.max(0, Math.floor(ms / 1000))
61
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`
62
+ }
63
+
64
+ export function NewServer() {
65
+ const store = useStore()
66
+ const {
67
+ newServerOpen,
68
+ setNewServerOpen,
69
+ newServerSource: source,
70
+ setNewServerSource,
71
+ servers,
72
+ serverProviders,
73
+ providerMetadata,
74
+ providerMetadataLoading,
75
+ providerMetadataError,
76
+ loadProviderMetadata,
77
+ newServerJob,
78
+ startNewServer,
79
+ clearNewServer,
80
+ saveServerProviderId,
81
+ accountSlug,
82
+ setInputMode,
83
+ } = store
84
+
85
+ const sourceProviderKey = providerKeyFromName(source?.provider_name)
86
+
87
+ // The active provider (may differ from the source). Null only when the source's
88
+ // provider can't be mapped — then we open the picker so the user chooses one.
89
+ const [selectedProviderKey, setSelectedProviderKey] = useState<ProviderKey | null>(sourceProviderKey)
90
+ // Which provider the id-capture form is collecting for (transient).
91
+ const [providerCaptureKey, setProviderCaptureKey] = useState<ProviderKey | null>(null)
92
+
93
+ const providerRef = selectedProviderKey ? serverProviders[selectedProviderKey] : undefined
94
+ const md = selectedProviderKey ? providerMetadata.get(selectedProviderKey) : undefined
95
+ const mdLoading = selectedProviderKey ? providerMetadataLoading.has(selectedProviderKey) : false
96
+ const mdError = selectedProviderKey ? providerMetadataError.get(selectedProviderKey) : undefined
97
+
98
+ const [phase, setPhase] = useState<Phase>(() => {
99
+ if (newServerJob && isNewServerInFlight(newServerJob)) return "tracking"
100
+ // Land on the form (defaulting to the source's provider) when it's already
101
+ // linked; otherwise open the picker so the user can choose or add a provider.
102
+ if (sourceProviderKey && serverProviders[sourceProviderKey]) return "loading"
103
+ return "providers"
104
+ })
105
+ // Region + size start empty; seeded with real catalog values once metadata loads
106
+ // (a Server's region/size are display codes, not slugs — see the seeding effect).
107
+ const [regionSlug, setRegionSlug] = useState<string>("")
108
+ const [sizeSlug, setSizeSlug] = useState<string>("")
109
+ // The desired specs to carry across provider/region switches. Seeded from the
110
+ // source server; updated whenever the user picks a size. So switching providers
111
+ // keeps "the same size" (closest match in that provider's catalog) rather than
112
+ // resetting to the cheapest, and a manual size choice sticks across switches.
113
+ const [desiredSpec, setDesiredSpec] = useState(() => parseSizeSpec(source?.size))
114
+ const [backups, setBackups] = useState(false)
115
+ const [hostname, setHostname] = useState<string>("")
116
+ const [providerIndex, setProviderIndex] = useState(() => {
117
+ const i = sourceProviderKey ? PROVIDER_KEYS.indexOf(sourceProviderKey) : 0
118
+ return i >= 0 ? i : 0
119
+ })
120
+ const [regionIndex, setRegionIndex] = useState(0)
121
+ const [sizeIndex, setSizeIndex] = useState(0)
122
+ const [providerIdInput, setProviderIdInput] = useState("")
123
+
124
+ // Seed a hostname suggestion from the fleet, but never clobber a value the user
125
+ // has already typed/submitted. The servers list refreshes mid-build, and an
126
+ // unguarded re-seed here would overwrite the in-flight name with a new suggestion.
127
+ useEffect(() => {
128
+ setHostname((h) => h || suggestServerName(servers))
129
+ }, [servers])
130
+
131
+ // Kick off the metadata fetch while in "loading"; flip to the form once it's in.
132
+ useEffect(() => {
133
+ if (phase !== "loading" || !selectedProviderKey) return
134
+ if (md) setPhase("form")
135
+ else if (!mdLoading && !mdError) loadProviderMetadata(selectedProviderKey)
136
+ }, [phase, selectedProviderKey, md, mdLoading, mdError, loadProviderMetadata])
137
+
138
+ // Text sub-forms own the keyboard (suppress global shortcuts while typing).
139
+ useEffect(() => {
140
+ setInputMode(phase === "name" || phase === "provider")
141
+ return () => setInputMode(false)
142
+ }, [phase, setInputMode])
143
+
144
+ // Default/validate region + size against the active provider's catalog. Runs on
145
+ // load and whenever the provider or region changes. Source region/size are
146
+ // display codes, so for the same provider we match by code/specs; for a
147
+ // different provider we fall back to that catalog's first region + size.
148
+ useEffect(() => {
149
+ if (!md) return
150
+ let region = regionBySlug(md, regionSlug)
151
+ if (!region) {
152
+ // Prefer the source server's region only when we're on its provider (a DO
153
+ // region slug won't exist in Hetzner's catalog, and vice versa).
154
+ const preferSource = selectedProviderKey === sourceProviderKey
155
+ region = (preferSource ? regionBySlug(md, source?.region) : undefined) ?? firstRegion(md)
156
+ }
157
+ const rSlug = region?.slug ?? ""
158
+ if (rSlug !== regionSlug) setRegionSlug(rSlug)
159
+
160
+ const sizes = sizesForRegion(md, rSlug)
161
+ if (!sizes.find((s) => s.slug === sizeSlug)) {
162
+ // Carry the desired spec into this region's sizes (closest match); only fall
163
+ // back to the cheapest when nothing matches. Region-scoped so we never pick a
164
+ // size the region doesn't offer.
165
+ const matched = matchSizeBySpec(sizes, desiredSpec.vcpus, desiredSpec.memoryMb)
166
+ const next = matched ?? sizes[0]?.slug ?? ""
167
+ if (next !== sizeSlug) setSizeSlug(next)
168
+ }
169
+ }, [md, selectedProviderKey, regionSlug, desiredSpec]) // eslint-disable-line react-hooks/exhaustive-deps
170
+
171
+ // Follow the store job once we've fired (mirrors PhpUpgrade): a settled failure
172
+ // shows the error; "done" shows success; anything else is still in flight.
173
+ const dp: Phase =
174
+ phase !== "tracking"
175
+ ? phase
176
+ : !newServerJob
177
+ ? "tracking"
178
+ : newServerJob.status === "failed"
179
+ ? "error"
180
+ : newServerJob.status === "done"
181
+ ? "done"
182
+ : "tracking"
183
+
184
+ const region = regionBySlug(md, regionSlug)
185
+ const size = sizeBySlug(md, sizeSlug)
186
+ const regionSizes = sizesForRegion(md, regionSlug)
187
+ const regionList = allRegions(md)
188
+
189
+ // What to DISPLAY as the server name: once fired, the job holds the actual
190
+ // submitted name (source of truth); before that, it's the editable field.
191
+ const displayName = newServerJob?.hostname || hostname
192
+
193
+ // Tick once a second while a build is in flight so the elapsed readout advances.
194
+ const [now, setNow] = useState(() => Date.now())
195
+ useEffect(() => {
196
+ if (dp !== "tracking") return
197
+ const id = setInterval(() => setNow(Date.now()), 1000)
198
+ return () => clearInterval(id)
199
+ }, [dp])
200
+
201
+ const close = () => {
202
+ setInputMode(false)
203
+ if (newServerJob && !isNewServerInFlight(newServerJob)) clearNewServer()
204
+ setNewServerOpen(false)
205
+ setNewServerSource(null)
206
+ }
207
+
208
+ // Switch to (or add) a provider from the picker.
209
+ const chooseProvider = (key: ProviderKey) => {
210
+ if (!serverProviders[key]) {
211
+ // Not linked yet → capture its id first.
212
+ setProviderCaptureKey(key)
213
+ setProviderIdInput("")
214
+ return setPhase("provider")
215
+ }
216
+ if (key !== selectedProviderKey) {
217
+ setSelectedProviderKey(key)
218
+ setRegionSlug("") // re-default region + size for the new provider's catalog
219
+ setSizeSlug("")
220
+ return setPhase(providerMetadata.has(key) ? "form" : "loading")
221
+ }
222
+ setPhase("form")
223
+ }
224
+
225
+ const saveProviderId = () => {
226
+ const id = parseInt(providerIdInput.trim(), 10)
227
+ const key = providerCaptureKey
228
+ if (!key || !Number.isFinite(id) || id <= 0) return
229
+ saveServerProviderId(key, id)
230
+ setSelectedProviderKey(key)
231
+ setRegionSlug("")
232
+ setSizeSlug("")
233
+ setPhase("loading") // metadata loads next, then the form
234
+ }
235
+
236
+ const fire = () => {
237
+ if (!providerRef || !regionSlug || !sizeSlug) return
238
+ const name = hostname.trim()
239
+ if (!name) return setPhase("name")
240
+ const payload: CreateServerPayload = {
241
+ server_provider: {
242
+ id: providerRef.id,
243
+ region: region?.slug ?? regionSlug,
244
+ size: sizeSlug,
245
+ enable_backups: backups,
246
+ },
247
+ hostname: name,
248
+ ...(source?.timezone ? { timezone: source.timezone } : {}),
249
+ ...(providerRef.databaseProviderId ? { database_provider: { id: providerRef.databaseProviderId } } : {}),
250
+ }
251
+ startNewServer(payload, name)
252
+ setPhase("tracking")
253
+ }
254
+
255
+ useKeyboard((key) => {
256
+ const name = key.name ?? ""
257
+
258
+ // Text sub-forms: the <input> handles text + Enter (onSubmit); we only cancel.
259
+ if (dp === "provider") {
260
+ if (name === "escape") return setPhase("providers")
261
+ return
262
+ }
263
+ if (dp === "name") {
264
+ if (name === "escape") return setPhase("form")
265
+ return
266
+ }
267
+
268
+ // Esc steps back one level from the list sub-screens (q still abandons the
269
+ // whole overlay via the catch-all below). regions/sizes are only reachable
270
+ // from the form, so Esc always returns there; the providers screen doubles as
271
+ // the entry point when no provider is linked, so it only steps back when we
272
+ // came from a loaded form (same guard ←/h uses).
273
+ if (name === "escape") {
274
+ if (dp === "regions" || dp === "sizes" || dp === "confirm") return setPhase("form")
275
+ if (dp === "providers") return selectedProviderKey && md ? setPhase("form") : close()
276
+ }
277
+
278
+ if (name === "escape" || name === "q") return close()
279
+
280
+ if (dp === "loading") {
281
+ if (name === "r" && selectedProviderKey && mdError) loadProviderMetadata(selectedProviderKey)
282
+ return
283
+ }
284
+
285
+ if (dp === "providers") {
286
+ switch (name) {
287
+ case "up":
288
+ case "k":
289
+ return setProviderIndex((i) => moveSelection(i, -1, PROVIDER_KEYS.length))
290
+ case "down":
291
+ case "j":
292
+ return setProviderIndex((i) => moveSelection(i, 1, PROVIDER_KEYS.length))
293
+ case "return":
294
+ case "right":
295
+ case "l":
296
+ return chooseProvider(PROVIDER_KEYS[providerIndex])
297
+ case "w":
298
+ if (accountSlug) openUrl(serverProvidersSettingsUrl(accountSlug))
299
+ return
300
+ case "left":
301
+ case "h":
302
+ if (selectedProviderKey && md) return setPhase("form")
303
+ return
304
+ }
305
+ return
306
+ }
307
+
308
+ if (dp === "form") {
309
+ switch (name) {
310
+ case "p":
311
+ setProviderIndex(Math.max(0, PROVIDER_KEYS.indexOf(selectedProviderKey as ProviderKey)))
312
+ return setPhase("providers")
313
+ case "g":
314
+ if (!md) return
315
+ setRegionIndex(Math.max(0, regionList.findIndex((r) => r.slug === regionSlug)))
316
+ return setPhase("regions")
317
+ case "e":
318
+ if (!md) return
319
+ setSizeIndex(Math.max(0, regionSizes.findIndex((s) => s.slug === sizeSlug)))
320
+ return setPhase("sizes")
321
+ case "b":
322
+ return setBackups((v) => !v)
323
+ case "r":
324
+ return setPhase("name")
325
+ case "return":
326
+ case "right":
327
+ case "l":
328
+ if (regionSlug && sizeSlug) setPhase("confirm")
329
+ return
330
+ }
331
+ return
332
+ }
333
+
334
+ if (dp === "regions") {
335
+ switch (name) {
336
+ case "up":
337
+ case "k":
338
+ return setRegionIndex((i) => moveSelection(i, -1, regionList.length))
339
+ case "down":
340
+ case "j":
341
+ return setRegionIndex((i) => moveSelection(i, 1, regionList.length))
342
+ case "return":
343
+ case "right":
344
+ case "l": {
345
+ const picked = regionList[regionIndex]
346
+ if (picked) setRegionSlug(picked.slug)
347
+ return setPhase("form")
348
+ }
349
+ case "left":
350
+ case "h":
351
+ return setPhase("form")
352
+ }
353
+ return
354
+ }
355
+
356
+ if (dp === "sizes") {
357
+ switch (name) {
358
+ case "up":
359
+ case "k":
360
+ return setSizeIndex((i) => moveSelection(i, -1, regionSizes.length))
361
+ case "down":
362
+ case "j":
363
+ return setSizeIndex((i) => moveSelection(i, 1, regionSizes.length))
364
+ case "return":
365
+ case "right":
366
+ case "l": {
367
+ const picked = regionSizes[sizeIndex]
368
+ if (picked) {
369
+ setSizeSlug(picked.slug)
370
+ // Remember this choice so switching providers keeps the same size.
371
+ setDesiredSpec({ vcpus: picked.vcpus, memoryMb: picked.memory })
372
+ }
373
+ return setPhase("form")
374
+ }
375
+ case "left":
376
+ case "h":
377
+ return setPhase("form")
378
+ }
379
+ return
380
+ }
381
+
382
+ if (dp === "confirm") {
383
+ if (name === "y") return fire()
384
+ if (name === "left" || name === "h") return setPhase("form")
385
+ return
386
+ }
387
+
388
+ if (dp === "error") {
389
+ if (name === "r") {
390
+ clearNewServer()
391
+ setPhase("form")
392
+ }
393
+ return
394
+ }
395
+ })
396
+
397
+ if (!newServerOpen) return null
398
+
399
+ const titleProvider = selectedProviderKey ? providerLabel(selectedProviderKey) : "choose a provider"
400
+
401
+ return (
402
+ <box
403
+ style={{
404
+ position: "absolute",
405
+ top: 0,
406
+ left: 0,
407
+ width: "100%",
408
+ height: "100%",
409
+ flexDirection: "column",
410
+ backgroundColor: theme.bg,
411
+ // 240 so it layers ABOVE the clone wizard (218) when reused as that flow's
412
+ // "New server" step; standalone use is unaffected.
413
+ zIndex: 240,
414
+ }}
415
+ >
416
+ {/* Title bar */}
417
+ <box
418
+ style={{
419
+ flexDirection: "row",
420
+ height: 1,
421
+ backgroundColor: theme.bgAlt,
422
+ paddingLeft: 1,
423
+ paddingRight: 1,
424
+ alignItems: "center",
425
+ }}
426
+ >
427
+ <text content="✦ New server " fg={theme.brand} style={{ flexShrink: 0 }} />
428
+ <text content={displayName || "name it below"} fg={displayName ? theme.text : theme.textFaint} wrapMode="none" style={{ flexShrink: 1 }} />
429
+ <box style={{ flexGrow: 1 }} />
430
+ <text content={truncate(titleProvider, 24)} fg={theme.textFaint} style={{ flexShrink: 0 }} />
431
+ </box>
432
+
433
+ <Centered>{renderBody()}</Centered>
434
+
435
+ <StatusBar hints={hints()} showGlobal={false} />
436
+ </box>
437
+ )
438
+
439
+ function renderBody() {
440
+ if (dp === "providers") return renderProviders()
441
+ if (dp === "provider") return renderProvider()
442
+
443
+ if (dp === "loading") {
444
+ return (
445
+ <box style={{ flexDirection: "column", alignItems: "center" }}>
446
+ {mdError ? (
447
+ <>
448
+ <text content={`✕ ${mdError}`} fg={theme.bad} wrapMode="none" />
449
+ <box style={{ height: 1 }} />
450
+ <text content="r retry · Esc to close" fg={theme.textFaint} />
451
+ </>
452
+ ) : (
453
+ <box style={{ flexDirection: "row" }}>
454
+ <Spinner />
455
+ <text content={` Loading ${selectedProviderKey ? providerLabel(selectedProviderKey) : ""} sizes & pricing…`} fg={theme.textDim} />
456
+ </box>
457
+ )}
458
+ </box>
459
+ )
460
+ }
461
+
462
+ if (dp === "regions") return renderRegions()
463
+ if (dp === "sizes") return renderSizes()
464
+ if (dp === "name") return renderName()
465
+ if (dp === "confirm") return renderConfirm()
466
+ if (dp === "tracking") return renderTracking()
467
+ if (dp === "done") return renderDone()
468
+ if (dp === "error") return renderError()
469
+ return renderForm()
470
+ }
471
+
472
+ function renderForm() {
473
+ return (
474
+ <Panel title=" New server " active>
475
+ <box style={{ flexDirection: "column", width: 62, paddingTop: 1, paddingBottom: 1 }}>
476
+ <Field label="Provider" value={selectedProviderKey ? providerLabel(selectedProviderKey) : "—"} />
477
+ <Field label="Region" value={region ? `${region.slug} (${region.name})` : (regionSlug || "—")} />
478
+ <Field label="Size" value={size ? `${size.slug} — ${formatSize(size)}` : (sizeSlug || "—")} />
479
+ <Field label="Cost" value={formatCost(size, backups)} valueColor={theme.good} />
480
+ <Field label="Backups" value={backups ? "on" : "off"} valueColor={backups ? theme.good : theme.textFaint} />
481
+ <box style={{ height: 1 }} />
482
+ <box style={{ flexDirection: "row" }}>
483
+ <text content="Name " fg={theme.textDim} />
484
+ <text content={hostname || "—"} fg={hostname ? theme.text : theme.textFaint} wrapMode="none" style={{ flexShrink: 1 }} />
485
+ </box>
486
+ <box style={{ height: 1 }} />
487
+ <text content="p provider · g region · e size" fg={theme.textFaint} wrapMode="none" />
488
+ <text content="b backups · r rename · ⏎ review" fg={theme.textFaint} wrapMode="none" />
489
+ </box>
490
+ </Panel>
491
+ )
492
+ }
493
+
494
+ function renderProviders() {
495
+ return (
496
+ <Panel title=" Choose a provider " active>
497
+ <box style={{ flexDirection: "column", width: 60 }}>
498
+ {PROVIDER_KEYS.map((key, i) => {
499
+ const selected = i === providerIndex
500
+ const ref = serverProviders[key]
501
+ const isCurrent = key === selectedProviderKey
502
+ const fg = selected ? theme.text : isCurrent ? theme.accent : theme.textDim
503
+ const status = ref ? `id ${ref.id}` : "needs id"
504
+ const statusFg = selected ? theme.text : ref ? theme.textFaint : theme.warn
505
+ return (
506
+ <box key={key} style={{ flexDirection: "row", height: 1, backgroundColor: selected ? theme.selectedBg : undefined }}>
507
+ <text content={(selected ? "❯ " : " ") + providerLabel(key) + (isCurrent ? " ●" : "")} fg={fg} style={{ flexGrow: 1, flexShrink: 1 }} wrapMode="none" />
508
+ <text content={status} fg={statusFg} style={{ flexShrink: 0 }} wrapMode="none" />
509
+ </box>
510
+ )
511
+ })}
512
+ <box style={{ height: 1 }} />
513
+ <text content="Pick a linked provider, or one marked “needs id” to add it." fg={theme.textFaint} wrapMode="none" />
514
+ <text
515
+ content={accountSlug ? "↑↓ choose · ⏎ select / add · w find ids in SpinupWP" : "↑↓ choose · ⏎ select / add"}
516
+ fg={theme.textFaint}
517
+ wrapMode="none"
518
+ />
519
+ </box>
520
+ </Panel>
521
+ )
522
+ }
523
+
524
+ function renderRegions() {
525
+ return (
526
+ <Panel title=" Choose a region " active>
527
+ <box style={{ flexDirection: "column", width: 60 }}>
528
+ {regionList.map((rg, i) => {
529
+ const selected = i === regionIndex
530
+ const isCurrent = rg.slug === regionSlug
531
+ const unavailable = rg.available === false
532
+ const fg = selected ? theme.text : unavailable ? theme.textFaint : isCurrent ? theme.accent : theme.textDim
533
+ return (
534
+ <box key={rg.slug} style={{ flexDirection: "row", height: 1, backgroundColor: selected ? theme.selectedBg : undefined }}>
535
+ <text content={(selected ? "❯ " : " ") + rg.slug} fg={fg} style={{ flexShrink: 0 }} wrapMode="none" />
536
+ <text content={` ${rg.name}`} fg={selected ? theme.text : theme.textFaint} style={{ flexGrow: 1, flexShrink: 1 }} wrapMode="none" />
537
+ {unavailable ? <text content="full" fg={selected ? theme.text : theme.warn} style={{ flexShrink: 0 }} /> : null}
538
+ </box>
539
+ )
540
+ })}
541
+ <box style={{ height: 1 }} />
542
+ <text content="↑↓ choose · ⏎ select · ←/esc back" fg={theme.textFaint} wrapMode="none" />
543
+ </box>
544
+ </Panel>
545
+ )
546
+ }
547
+
548
+ function renderSizes() {
549
+ return (
550
+ <Panel title=" Choose a size " active>
551
+ <box style={{ flexDirection: "column", width: 62 }}>
552
+ {regionSizes.map((s, i) => {
553
+ const selected = i === sizeIndex
554
+ const isCurrent = s.slug === sizeSlug
555
+ const fg = selected ? theme.text : isCurrent ? theme.accent : theme.textDim
556
+ return (
557
+ <box key={s.slug} style={{ flexDirection: "row", height: 1, backgroundColor: selected ? theme.selectedBg : undefined }}>
558
+ <text content={(selected ? "❯ " : " ") + s.slug} fg={fg} style={{ flexGrow: 1, flexShrink: 1 }} wrapMode="none" />
559
+ <text content={formatSize(s) + " "} fg={selected ? theme.text : theme.textFaint} style={{ flexShrink: 0 }} wrapMode="none" />
560
+ <text content={formatCost(s, backups)} fg={selected ? theme.text : theme.good} style={{ flexShrink: 0 }} />
561
+ </box>
562
+ )
563
+ })}
564
+ <box style={{ height: 1 }} />
565
+ <text content="↑↓ choose · ⏎ select · ←/esc back" fg={theme.textFaint} wrapMode="none" />
566
+ </box>
567
+ </Panel>
568
+ )
569
+ }
570
+
571
+ function renderName() {
572
+ return (
573
+ <Panel title=" Name the server " active>
574
+ <box style={{ flexDirection: "column", width: 62, paddingTop: 1, paddingBottom: 1 }}>
575
+ <text content="Hostname (letters, numbers, dashes, periods)" fg={theme.accent} wrapMode="none" />
576
+ <input
577
+ focused
578
+ value={hostname}
579
+ placeholder="web1.example.com"
580
+ onInput={setHostname}
581
+ onSubmit={() => setPhase("form")}
582
+ style={{ backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }}
583
+ />
584
+ <box style={{ height: 1 }} />
585
+ <text content="A conventional name like web12.example.com keeps your fleet tidy." fg={theme.textFaint} />
586
+ <text content="Enter to accept · Esc to go back" fg={theme.textFaint} wrapMode="none" />
587
+ </box>
588
+ </Panel>
589
+ )
590
+ }
591
+
592
+ function renderConfirm() {
593
+ return (
594
+ <Panel title=" Confirm — this provisions a real server " active>
595
+ <box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
596
+ <box style={{ flexDirection: "row" }}>
597
+ <text content="Create " fg={theme.text} />
598
+ <text content={hostname} fg={theme.accent} wrapMode="none" />
599
+ </box>
600
+ <box style={{ height: 1 }} />
601
+ <Field label="Provider" value={selectedProviderKey ? providerLabel(selectedProviderKey) : "—"} />
602
+ <Field label="Region" value={region ? `${region.slug} (${region.name})` : regionSlug} />
603
+ <Field label="Size" value={size ? `${size.slug} — ${formatSize(size)}` : sizeSlug} />
604
+ <Field label="Backups" value={backups ? "on" : "off"} valueColor={backups ? theme.good : theme.textFaint} />
605
+ <box style={{ flexDirection: "row" }}>
606
+ <text content="Cost " fg={theme.textDim} />
607
+ <text content={formatCost(size, backups)} fg={theme.good} />
608
+ <text content=" — billed by your provider" fg={theme.textFaint} wrapMode="none" />
609
+ </box>
610
+ <box style={{ height: 1 }} />
611
+ <text content="Provisioning takes ~10 minutes." fg={theme.textDim} wrapMode="none" />
612
+ <text content="Press y to create · ←/esc to go back · q to cancel" fg={theme.textFaint} wrapMode="none" />
613
+ </box>
614
+ </Panel>
615
+ )
616
+ }
617
+
618
+ function renderTracking() {
619
+ const status = newServerJob?.status ?? "queued"
620
+ const elapsed = newServerJob?.startedAt ? fmtElapsed(now - newServerJob.startedAt) : null
621
+ return (
622
+ <box style={{ flexDirection: "column", alignItems: "center" }}>
623
+ <box style={{ flexDirection: "row" }}>
624
+ <Spinner />
625
+ <text content={` Provisioning ${displayName}…`} fg={theme.textDim} />
626
+ </box>
627
+ <box style={{ height: 1 }} />
628
+ <box style={{ flexDirection: "row" }}>
629
+ <text content="Status " fg={theme.textFaint} />
630
+ <text content={status} fg={theme.text} wrapMode="none" />
631
+ {elapsed ? <text content={` · elapsed ${elapsed}`} fg={theme.textFaint} wrapMode="none" /> : null}
632
+ </box>
633
+ <box style={{ height: 1 }} />
634
+ <text content="Typically ~10 min. Press Esc — it keeps running in the background." fg={theme.textFaint} wrapMode="none" />
635
+ </box>
636
+ )
637
+ }
638
+
639
+ function renderDone() {
640
+ return (
641
+ <Panel title=" Server created " active>
642
+ <box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
643
+ <box style={{ flexDirection: "row" }}>
644
+ <text content="✓ " fg={theme.good} style={{ flexShrink: 0 }} />
645
+ <text content={displayName} fg={theme.accent} wrapMode="none" style={{ flexShrink: 1 }} />
646
+ </box>
647
+ <text content="It's up — and in your Servers list now." fg={theme.text} wrapMode="none" />
648
+ <box style={{ height: 1 }} />
649
+ <text content="Next: connect it with a vanity site" fg={theme.accent} wrapMode="none" />
650
+ <text content="A brand-new server has no site, so there's nothing to attach an" fg={theme.textDim} />
651
+ <text content="SSH key to and no way to reach it from SpinupTUI. Creating a vanity" fg={theme.textDim} />
652
+ <text content="site — a tiny placeholder at the server's own hostname — gives" fg={theme.textDim} />
653
+ <text content="you that foothold. Open the server in your Servers list to do it." fg={theme.textDim} />
654
+ <box style={{ height: 1 }} />
655
+ <text content="Esc to close" fg={theme.textFaint} />
656
+ </box>
657
+ </Panel>
658
+ )
659
+ }
660
+
661
+ function renderError() {
662
+ return (
663
+ <Panel title=" Create failed " active>
664
+ <box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
665
+ <text content={`✕ ${newServerJob?.error ?? "Something went wrong."}`} fg={theme.bad} wrapMode="none" />
666
+ <box style={{ height: 1 }} />
667
+ <text content="Press r to adjust and try again · Esc to close" fg={theme.textFaint} wrapMode="none" />
668
+ </box>
669
+ </Panel>
670
+ )
671
+ }
672
+
673
+ function renderProvider() {
674
+ const key = providerCaptureKey ?? selectedProviderKey
675
+ return (
676
+ <Panel title=" Link your server provider " active>
677
+ <box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
678
+ <text content={`${key ? providerLabel(key) : "This provider"} isn't linked in SpinupTUI yet.`} fg={theme.text} wrapMode="none" />
679
+ <text content="Paste its SpinupWP provider id (Account Settings →" fg={theme.textDim} wrapMode="none" />
680
+ <text content="Server Providers — the ID column):" fg={theme.textDim} wrapMode="none" />
681
+ <box style={{ height: 1 }} />
682
+ <input
683
+ focused
684
+ value={providerIdInput}
685
+ placeholder="e.g. 7577"
686
+ onInput={setProviderIdInput}
687
+ onSubmit={saveProviderId}
688
+ style={{ backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }}
689
+ />
690
+ <box style={{ height: 1 }} />
691
+ <text content="It's saved to your config — you only do this once per provider." fg={theme.textFaint} wrapMode="none" />
692
+ <text content="⏎ save · Esc back to providers" fg={theme.textFaint} wrapMode="none" />
693
+ </box>
694
+ </Panel>
695
+ )
696
+ }
697
+
698
+ function hints() {
699
+ switch (dp) {
700
+ case "providers": {
701
+ const canGoBack = !!(selectedProviderKey && md)
702
+ return [
703
+ { key: "↑↓/jk", label: "provider" },
704
+ { key: "⏎", label: "select / add" },
705
+ ...(accountSlug ? [{ key: "w", label: "SpinupWP" }] : []),
706
+ canGoBack ? { key: "←/esc", label: "back" } : { key: "esc", label: "cancel" },
707
+ ]
708
+ }
709
+ case "provider":
710
+ return [
711
+ { key: "⏎", label: "save" },
712
+ { key: "esc", label: "back" },
713
+ ]
714
+ case "form":
715
+ return [
716
+ { key: "p", label: "provider" },
717
+ { key: "g", label: "region" },
718
+ { key: "e", label: "size" },
719
+ { key: "b", label: "backups" },
720
+ { key: "r", label: "rename" },
721
+ { key: "⏎", label: "review" },
722
+ ]
723
+ case "regions":
724
+ case "sizes":
725
+ return [
726
+ { key: "↑↓/jk", label: dp === "regions" ? "region" : "size" },
727
+ { key: "⏎", label: "select" },
728
+ { key: "←/esc", label: "back" },
729
+ ]
730
+ case "name":
731
+ return [
732
+ { key: "⏎", label: "accept" },
733
+ { key: "esc", label: "back" },
734
+ ]
735
+ case "confirm":
736
+ return [
737
+ { key: "y", label: "create" },
738
+ { key: "←/esc", label: "back" },
739
+ { key: "q", label: "cancel" },
740
+ ]
741
+ case "error":
742
+ return [
743
+ { key: "r", label: "retry" },
744
+ { key: "esc", label: "close" },
745
+ ]
746
+ default:
747
+ return [{ key: "esc", label: "close" }]
748
+ }
749
+ }
750
+ }