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,932 @@
1
+ // "Clone a server to a new server" wizard (backlog item 5). A two-pane overlay: a
2
+ // fixed left Journey Rail (the five server-level steps + a src→dst footer) and a
3
+ // right pane that changes per step. The two heavy steps (Clone sites, DNS cutover)
4
+ // expand into a per-site roster. Driven by the store's CloneJob whose fan-out lives
5
+ // in `sites[]`. See docs/2026-06-24_clone-to-server-spec.md.
6
+ //
7
+ // SLICE 2 (this file): the shell + the Plan step (site-select + pre-flight + the two
8
+ // "all sites" toggles). The New-server / Connect-dest / Clone / Cutover steps render
9
+ // as scaffolding (rail advances, rosters preview) — their orchestration lands in
10
+ // later slices.
11
+
12
+ import { useEffect, useRef, useState } from "react"
13
+ import { useKeyboard } from "@opentui/react"
14
+ import { theme } from "../../lib/theme.ts"
15
+ import { Panel, Spinner } from "../components.tsx"
16
+ import { StatusBar } from "../StatusBar.tsx"
17
+ import { openUrl, copyToClipboard } from "../../lib/open.ts"
18
+ import { consoleForHost } from "../../lib/providers.ts"
19
+ import { serverWebUrl } from "../../lib/spinupweb.ts"
20
+ import { useStore, cloneNeedsGitAccess, type CloneStep, type CloneSiteStep, type RepoKeyState } from "../store.tsx"
21
+ import type { VerifyCheck } from "../../lib/serverClone.ts"
22
+
23
+ // Choosing an existing server as the dest is a first-class feature now (the `d` picker
24
+ // on the Destination step). SPINUP_DEV_CLONE_DEST just pre-points the picker cursor at
25
+ // a given server id — a dev convenience for repeated testing, never required.
26
+ const DEV_CLONE_DEST = Number(process.env.SPINUP_DEV_CLONE_DEST) || null
27
+ // Dev-only: auto-connect sudo on both ends from .env (SPINUP_DEV_SUDO_SOURCE/DEST,
28
+ // each "user:password"), so the full clone is testable headlessly without typing
29
+ // passwords into SudoConnect. The real flow never reads these.
30
+ const DEV_SUDO = { source: process.env.SPINUP_DEV_SUDO_SOURCE || "", dest: process.env.SPINUP_DEV_SUDO_DEST || "" }
31
+
32
+ const STEP_PLAN = { step: "plan" as CloneStep, label: "Plan" }
33
+ const STEP_SERVER = { step: "server" as CloneStep, label: "Destination" }
34
+ const STEP_TRUST = { step: "trust" as CloneStep, label: "Connect dest" }
35
+ const STEP_GITACCESS = { step: "gitaccess" as CloneStep, label: "Git access" }
36
+ const STEP_CLONE = { step: "clone" as CloneStep, label: "Clone sites" }
37
+ const STEP_CUTOVER = { step: "cutover" as CloneStep, label: "DNS cutover" }
38
+ // The Git-access step only appears when a Bedrock site is selected (deploy-key onboarding).
39
+ function stepsFor(needsGit: boolean): { step: CloneStep; label: string }[] {
40
+ return needsGit
41
+ ? [STEP_PLAN, STEP_SERVER, STEP_TRUST, STEP_GITACCESS, STEP_CLONE, STEP_CUTOVER]
42
+ : [STEP_PLAN, STEP_SERVER, STEP_TRUST, STEP_CLONE, STEP_CUTOVER]
43
+ }
44
+ const RAIL_W = 24
45
+
46
+ export function CloneWizard() {
47
+ const {
48
+ cloneServer: server,
49
+ cloneJob: job,
50
+ servers,
51
+ accountSlug,
52
+ toggleCloneSite,
53
+ toggleCloneSiteUploads,
54
+ cloneAdvanceFromPlan,
55
+ cloneSetDest,
56
+ cloneTrustContinue,
57
+ cloneDetectRepoKeys,
58
+ cloneAddRepoKey,
59
+ cloneGitAccessContinue,
60
+ cloneSizeSites,
61
+ startClone,
62
+ cloneRetrySite,
63
+ cloneContinueToCutover,
64
+ cloneGoBack,
65
+ toggleCutoverExclude,
66
+ verifyCloneSite,
67
+ cutoverCheck,
68
+ startCutover,
69
+ cloneCutoverFinish,
70
+ backgroundClone,
71
+ toggleCloneLowerTtl,
72
+ clearClone,
73
+ isSudoConnected,
74
+ setSudoConnectServer,
75
+ connectSudo,
76
+ newServerOpen,
77
+ newServerJob,
78
+ setNewServerOpen,
79
+ setNewServerSource,
80
+ clearNewServer,
81
+ sudoConnectServer,
82
+ } = useStore()
83
+
84
+ const [idx, setIdx] = useState(0)
85
+ const [sizeTried, setSizeTried] = useState(false)
86
+ const [verifyOpen, setVerifyOpen] = useState<number | null>(null) // slice 5: drilled-into site
87
+ const [pickExisting, setPickExisting] = useState(false) // dest step: choosing an existing server
88
+ const [destIdx, setDestIdx] = useState(0) // cursor in the existing-server picker
89
+
90
+ // Kick the fan-out once, when we land on the Clone step. Guarded by the job's
91
+ // fanoutStarted flag (not component state) so reopening a backgrounded wizard
92
+ // never re-queues a clone that's already running in the store.
93
+ useEffect(() => {
94
+ if (job?.step === "clone" && !job.fanoutStarted) {
95
+ setIdx(0)
96
+ startClone()
97
+ }
98
+ }, [job?.step, job?.fanoutStarted, startClone])
99
+
100
+ // Detect deploy keys once, when we land on the Git-access step.
101
+ const detectTried = useRef(false)
102
+ useEffect(() => {
103
+ if (job?.step === "gitaccess" && !detectTried.current) {
104
+ detectTried.current = true
105
+ void cloneDetectRepoKeys()
106
+ }
107
+ }, [job?.step, cloneDetectRepoKeys])
108
+
109
+ // Every screen owns its own cursor run — reset it whenever the step changes
110
+ // (forward or back), so a cursor position from a longer roster never lands
111
+ // out-of-range on the next screen.
112
+ useEffect(() => {
113
+ setIdx(0)
114
+ }, [job?.step])
115
+
116
+ // Tick once a second while any selected site is mid-stage so the roster's
117
+ // elapsed readout counts up (long file pulls otherwise look frozen).
118
+ const [now, setNow] = useState(Date.now())
119
+ useEffect(() => {
120
+ if (job?.step !== "clone") return
121
+ if (!job.sites.some((s) => s.selected && !["queued", "done", "error"].includes(s.step))) return
122
+ const t = setInterval(() => setNow(Date.now()), 1000)
123
+ return () => clearInterval(t)
124
+ }, [job?.step, job?.sites])
125
+
126
+ // Read + classify each site's DNS records when we land on the cutover step — but
127
+ // per-site, only the ones never checked, so reopening a backgrounded wizard (or
128
+ // returning after ← back → retry) never resets in-progress flips, and a site that
129
+ // became "done" via retry after backing out still gets its records read.
130
+ useEffect(() => {
131
+ if (job?.step !== "cutover") return
132
+ const unchecked = job.sites.filter((s) => s.selected && s.step === "done" && !s.cutover)
133
+ if (unchecked.length > 0) void cutoverCheck(unchecked.map((s) => s.sourceSiteId))
134
+ }, [job?.step, job?.sites, cutoverCheck])
135
+
136
+ // Dev-only auto-sudo (both ends) from .env so the flow is testable headlessly.
137
+ const devSudoTried = useRef(new Set<number>())
138
+ useEffect(() => {
139
+ if (!server || !job) return
140
+ const tryConn = (srv: typeof server | undefined, cred: string) => {
141
+ if (!srv || !cred || isSudoConnected(srv.id) || devSudoTried.current.has(srv.id)) return
142
+ const [u, ...r] = cred.split(":")
143
+ if (!u) return
144
+ devSudoTried.current.add(srv.id)
145
+ void connectSudo(srv, u, r.join(":"))
146
+ }
147
+ tryConn(servers.find((s) => s.id === job.sourceServerId), DEV_SUDO.source)
148
+ const destId = job.destServerId ?? DEV_CLONE_DEST
149
+ tryConn(destId != null ? servers.find((s) => s.id === destId) : undefined, DEV_SUDO.dest)
150
+ }, [server, job, servers, isSudoConnected, connectSudo])
151
+
152
+ const devDest = DEV_CLONE_DEST != null ? servers.find((s) => s.id === DEV_CLONE_DEST) ?? null : null
153
+
154
+ // Measure source site sizes once, the moment source sudo is connected (Plan total
155
+ // + per-site GB). Runs a single du+wp-db-size pass over the sudo connection.
156
+ useEffect(() => {
157
+ if (!server || sizeTried) return
158
+ if (job?.step === "plan" && isSudoConnected(server.id) && job.sites.length > 0) {
159
+ setSizeTried(true)
160
+ void cloneSizeSites()
161
+ }
162
+ }, [server, job, sizeTried, isSudoConnected, cloneSizeSites])
163
+
164
+ // Bridge: when the reused NewServer flow finishes provisioning, capture the new
165
+ // box as the clone dest and close it. (Standalone NewServer use sets no cloneJob,
166
+ // so this only fires inside the wizard's "server" step.)
167
+ useEffect(() => {
168
+ if (job?.step !== "server" || job.destServerId != null) return
169
+ if (newServerJob?.status === "done" && newServerJob.serverId != null) {
170
+ const created = servers.find((s) => s.id === newServerJob.serverId)
171
+ if (created) {
172
+ cloneSetDest(created)
173
+ clearNewServer()
174
+ setNewServerOpen(false)
175
+ }
176
+ }
177
+ }, [job, newServerJob, servers, cloneSetDest, clearNewServer, setNewServerOpen])
178
+
179
+ const close = () => clearClone()
180
+
181
+ // Existing servers eligible as a clone destination (anything but the source),
182
+ // name-sorted. The DEV override (SPINUP_DEV_CLONE_DEST) just pre-points the cursor.
183
+ const eligibleDests = servers.filter((s) => s.id !== job?.sourceServerId).slice().sort((a, b) => a.name.localeCompare(b.name))
184
+
185
+ useKeyboard((key) => {
186
+ if (!job || !server) return
187
+ // A layered overlay (NewServer / SudoConnect) owns the keyboard while open.
188
+ if (newServerOpen || sudoConnectServer) return
189
+ const raw = key.name ?? ""
190
+ const name = key.shift && raw.length === 1 ? raw.toUpperCase() : raw
191
+
192
+ if (name === "escape" || name === "q") {
193
+ if (verifyOpen != null) return setVerifyOpen(null) // back out of the verify drill-down first
194
+ if (pickExisting) return setPickExisting(false) // back out of the server picker first
195
+ // Once the fan-out has launched, esc BACKGROUNDS the clone (it keeps running);
196
+ // reopen with C on the source server. Before launch / at the summary, esc cancels.
197
+ if (job.step === "clone" || job.step === "cutover") return backgroundClone()
198
+ return close()
199
+ }
200
+
201
+ // ← / h — back one screen (layered views close first, matching esc). The config
202
+ // steps go back freely; after the fan-out has fired, the only back edge is
203
+ // cutover → the clone roster (re-verify / retry) — cloneGoBack enforces this.
204
+ if (name === "left" || name === "h") {
205
+ if (verifyOpen != null) return setVerifyOpen(null)
206
+ if (pickExisting) return setPickExisting(false)
207
+ return cloneGoBack()
208
+ }
209
+
210
+ if (job.step === "plan") {
211
+ // S — connect sudo on the SOURCE (pre-flight; the pull needs it later).
212
+ if (name === "S" && !isSudoConnected(server.id)) return setSudoConnectServer(server)
213
+ const n = job.sites.length
214
+ if (name === "up" || name === "k") return setIdx((i) => (i - 1 + n) % n)
215
+ if (name === "down" || name === "j") return setIdx((i) => (i + 1) % n)
216
+ const cur = job.sites[idx]
217
+ if (name === "space" && cur) return toggleCloneSite(cur.sourceSiteId)
218
+ if (name === "u" && cur) return toggleCloneSiteUploads(cur.sourceSiteId)
219
+ if (name === "t") return toggleCloneLowerTtl()
220
+ if (name === "return") return cloneAdvanceFromPlan()
221
+ }
222
+
223
+ if (job.step === "server") {
224
+ if (pickExisting) {
225
+ const n = eligibleDests.length
226
+ if (n === 0) return
227
+ if (name === "up" || name === "k") return setDestIdx((i) => (i - 1 + n) % n)
228
+ if (name === "down" || name === "j") return setDestIdx((i) => (i + 1) % n)
229
+ if (name === "return") {
230
+ const chosen = eligibleDests[destIdx]
231
+ if (chosen) {
232
+ setPickExisting(false)
233
+ cloneSetDest(chosen)
234
+ }
235
+ }
236
+ return
237
+ }
238
+ // d — choose an existing server as the destination (open the picker).
239
+ if (name === "d" && eligibleDests.length > 0) {
240
+ const start = devDest ? Math.max(0, eligibleDests.findIndex((s) => s.id === devDest.id)) : 0
241
+ setDestIdx(start)
242
+ return setPickExisting(true)
243
+ }
244
+ if (name === "return") {
245
+ // Reuse the standalone New-server flow, seeded from the source.
246
+ setNewServerSource(server)
247
+ setNewServerOpen(true)
248
+ }
249
+ }
250
+
251
+ if (job.step === "trust") {
252
+ const destServer = job.destServerId != null ? servers.find((s) => s.id === job.destServerId) ?? null : null
253
+ // S — connect sudo on the DEST; G — re-check/connect sudo on the SOURCE.
254
+ if (name === "S" && destServer && !isSudoConnected(destServer.id)) return setSudoConnectServer(destServer)
255
+ if (name === "G" && !isSudoConnected(server.id)) return setSudoConnectServer(server)
256
+ if (name === "w" && destServer && accountSlug) return openUrl(serverWebUrl(destServer.id, accountSlug))
257
+ if (name === "return") {
258
+ const bothConnected = isSudoConnected(server.id) && destServer != null && isSudoConnected(destServer.id)
259
+ if (bothConnected) return cloneTrustContinue()
260
+ }
261
+ }
262
+
263
+ if (job.step === "gitaccess") {
264
+ const keys = job.repoKeys ?? []
265
+ // a — add the dest key to every repo we can auto-add (gh + GitHub) that's missing.
266
+ if (name === "a") {
267
+ for (const k of keys) if (k.auto && (k.status === "missing" || k.status === "error")) void cloneAddRepoKey(k.repo)
268
+ return
269
+ }
270
+ // r — re-detect (after a manual add, or to retry a check).
271
+ if (name === "r") {
272
+ detectTried.current = false
273
+ void cloneDetectRepoKeys()
274
+ return
275
+ }
276
+ // o — open the first repo's deploy-keys settings page (manual path).
277
+ if (name === "o") {
278
+ const url = keys.find((k) => k.settingsUrl)?.settingsUrl
279
+ if (url) return openUrl(url)
280
+ return
281
+ }
282
+ // y — copy a generated public key (first manual repo, else first repo).
283
+ if (name === "y") {
284
+ const k = keys.find((x) => !x.auto && x.publicKey) ?? keys.find((x) => x.publicKey)
285
+ if (k?.publicKey) copyToClipboard(k.publicKey)
286
+ return
287
+ }
288
+ // ⏎ — continue once no auto repo is still missing/in-flight (create verifies the rest).
289
+ if (name === "return") {
290
+ const blocked = keys.some((k) => k.status === "checking" || k.status === "adding" || (k.auto && k.status === "missing"))
291
+ if (!blocked) return cloneGitAccessContinue()
292
+ }
293
+ return
294
+ }
295
+
296
+ if (job.step === "clone" || job.step === "done") {
297
+ const sel = job.sites.filter((s) => s.selected)
298
+ // While drilled into a verify/failure view: v re-runs verify (done sites),
299
+ // r retries the drilled FAILED site, ←/h/Esc (handled above) goes back.
300
+ if (verifyOpen != null) {
301
+ const drilled = job.sites.find((s) => s.sourceSiteId === verifyOpen)
302
+ if (name === "r" && drilled?.step === "error") {
303
+ setVerifyOpen(null)
304
+ return cloneRetrySite(verifyOpen)
305
+ }
306
+ if (name === "v" && drilled?.step === "done") return verifyCloneSite(verifyOpen)
307
+ return
308
+ }
309
+ const n = sel.length
310
+ if (n > 0 && (name === "up" || name === "k")) return setIdx((i) => (i - 1 + n) % n)
311
+ if (n > 0 && (name === "down" || name === "j")) return setIdx((i) => (i + 1) % n)
312
+ const cur = sel[idx]
313
+ // v / Enter — verify the highlighted DONE site (drill in + run the comparison).
314
+ if ((name === "v" || name === "return") && cur && cur.step === "done") {
315
+ setVerifyOpen(cur.sourceSiteId)
316
+ verifyCloneSite(cur.sourceSiteId)
317
+ return
318
+ }
319
+ // Enter on a FAILED site — drill into the full (untruncated) error.
320
+ if (name === "return" && cur && cur.step === "error") {
321
+ setVerifyOpen(cur.sourceSiteId)
322
+ return
323
+ }
324
+ // r — retry every failed site.
325
+ if (name === "r") {
326
+ for (const s of job.sites) if (s.selected && s.step === "error") cloneRetrySite(s.sourceSiteId)
327
+ return
328
+ }
329
+ // c — the explicit continue to DNS cutover (only once the roster settled
330
+ // with at least one success; cutover moves live traffic).
331
+ if (name === "c" && job.step === "clone") {
332
+ const settled = sel.length > 0 && sel.every((s) => s.step === "done" || s.step === "error")
333
+ if (settled && sel.some((s) => s.step === "done")) return cloneContinueToCutover()
334
+ }
335
+ }
336
+
337
+ if (job.step === "cutover") {
338
+ const recs = job.sites.filter((s) => s.selected && s.step === "done").flatMap((s) => (s.cutover?.records ?? []).map((r) => ({ r, siteId: s.sourceSiteId })))
339
+ const n = recs.length
340
+ if (n > 0 && (name === "up" || name === "k")) return setIdx((i) => (i - 1 + n) % n)
341
+ if (n > 0 && (name === "down" || name === "j")) return setIdx((i) => (i + 1) % n)
342
+ const cur = recs[Math.min(idx, Math.max(0, n - 1))]
343
+ // space — include/exclude a ready record from the batch flip.
344
+ if (name === "space" && cur && cur.r.status === "ready") return toggleCutoverExclude(cur.siteId, cur.r.name)
345
+ // ⏎ — open the zone's registrar/web console (zone copied for the
346
+ // delegate-access paste), for the manual rows the API can't flip.
347
+ if (name === "return" && cur) {
348
+ const con = cur.r.hostKey ? consoleForHost(cur.r.hostKey) : null
349
+ if (con) {
350
+ copyToClipboard(cur.r.apex ?? cur.r.name)
351
+ openUrl(con.url)
352
+ }
353
+ return
354
+ }
355
+ // c — flip every included "ready" record to the new server (the live-traffic write).
356
+ if (name === "c") return startCutover()
357
+ if (name === "r") return void cutoverCheck()
358
+ if (name === "f") return cloneCutoverFinish()
359
+ }
360
+ })
361
+
362
+ if (!server || !job) return null
363
+
364
+ const selected = job.sites.filter((s) => s.selected)
365
+ const sudoOn = isSudoConnected(server.id)
366
+ const steps = stepsFor(cloneNeedsGitAccess(job))
367
+ const curStepIdx = job.step === "done" ? steps.length : steps.findIndex((s) => s.step === job.step)
368
+
369
+ return (
370
+ <box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 218 }}>
371
+ {/* header */}
372
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
373
+ <text content="✦ Clone server " fg={theme.brand} style={{ flexShrink: 0 }} />
374
+ <text content={`${server.name} (${selected.length} of ${job.sites.length} sites) → ${job.destServerName || "destination"}`} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
375
+ </box>
376
+
377
+ {/* two panes */}
378
+ <box style={{ flexDirection: "row", flexGrow: 1 }}>
379
+ {railPane()}
380
+ <box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}>{rightPane()}</box>
381
+ </box>
382
+
383
+ <StatusBar hints={hints()} showGlobal={false} />
384
+ </box>
385
+ )
386
+
387
+ function railPane() {
388
+ return (
389
+ <box style={{ width: RAIL_W, flexShrink: 0, flexDirection: "column", borderStyle: "single", borderColor: theme.border, paddingLeft: 1, paddingRight: 1 }}>
390
+ <text content="JOURNEY" fg={theme.textFaint} wrapMode="none" />
391
+ <box style={{ height: 1 }} />
392
+ {steps.map((s, i) => {
393
+ const state = i < curStepIdx ? "done" : i === curStepIdx ? "active" : "pending"
394
+ const glyph = state === "done" ? "✓" : state === "active" ? "❯" : "○"
395
+ const color = state === "done" ? theme.good : state === "active" ? theme.brand : theme.textFaint
396
+ // N-fraction badge on the two roster steps.
397
+ const badge = s.step === "clone" || s.step === "cutover" ? ` ${countDone(s.step)}/${selected.length}` : ""
398
+ return (
399
+ <box key={s.step} style={{ flexDirection: "row", height: 1 }}>
400
+ <text content={`${glyph} `} fg={color} style={{ flexShrink: 0 }} />
401
+ <text content={s.label} fg={state === "pending" ? theme.textFaint : theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
402
+ {badge ? <text content={badge} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 0 }} /> : null}
403
+ </box>
404
+ )
405
+ })}
406
+ <box style={{ flexGrow: 1 }} />
407
+ <text content={"─".repeat(RAIL_W - 2)} fg={theme.border} wrapMode="none" />
408
+ <box style={{ flexDirection: "row", height: 1 }}>
409
+ <text content="src " fg={theme.textFaint} style={{ flexShrink: 0 }} />
410
+ <text content={truncate(server!.name, RAIL_W - 6)} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
411
+ </box>
412
+ <box style={{ flexDirection: "row", height: 1 }}>
413
+ <text content="dst " fg={theme.textFaint} style={{ flexShrink: 0 }} />
414
+ <text content={job!.destServerName || job!.destServerIp || "—"} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
415
+ </box>
416
+ </box>
417
+ )
418
+ }
419
+
420
+ function rightPane() {
421
+ if (job!.step === "plan") return planPane()
422
+ if (job!.step === "server") return serverPane()
423
+ if (job!.step === "trust") return trustPane()
424
+ if (job!.step === "gitaccess") return gitAccessPane()
425
+ if ((job!.step === "clone" || job!.step === "done") && verifyOpen != null) {
426
+ const drilled = job!.sites.find((s) => s.sourceSiteId === verifyOpen)
427
+ return drilled?.step === "error" ? failurePane(verifyOpen) : verifyPane(verifyOpen)
428
+ }
429
+ if (job!.step === "clone" || job!.step === "done") return clonePane()
430
+ if (job!.step === "cutover") return cutoverPane()
431
+ // error — scaffolding for now.
432
+ return (
433
+ <Panel title={` ${steps[curStepIdx]?.label ?? job!.step} `} active>
434
+ <box style={{ flexDirection: "column", paddingTop: 1, paddingBottom: 1 }}>
435
+ <text content="This step is built in a later slice of the wizard." fg={theme.textDim} wrapMode="none" />
436
+ <text content="The Plan is captured; the rail shows where we are." fg={theme.textFaint} wrapMode="none" />
437
+ </box>
438
+ </Panel>
439
+ )
440
+ }
441
+
442
+ function serverPane() {
443
+ if (pickExisting) return pickExistingPane()
444
+ const sp = job!.specs
445
+ const canPick = eligibleDests.length > 0
446
+ return (
447
+ <Panel title=" Destination — provision new or pick existing " active>
448
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
449
+ <text content="Where should the cloned sites land?" fg={theme.textDim} wrapMode="none" />
450
+ <box style={{ height: 1 }} />
451
+ <text content="❯ Enter — provision a NEW server" fg={theme.brand} wrapMode="none" />
452
+ <text content={` matched to source: ${sp.providerName || "—"} · ${sp.region || "—"} · ${sp.size || "—"}`} fg={theme.textFaint} wrapMode="none" />
453
+ <box style={{ height: 1 }} />
454
+ {canPick ? (
455
+ <text content={`❯ d — use an EXISTING server (${eligibleDests.length} available)`} fg={theme.brand} wrapMode="none" />
456
+ ) : (
457
+ <text content="d — use an existing server (none available — provision one)" fg={theme.textFaint} wrapMode="none" />
458
+ )}
459
+ <text content=" e.g. a pre-built box, or consolidate onto an existing server" fg={theme.textFaint} wrapMode="none" />
460
+ <box style={{ flexGrow: 1 }} />
461
+ <text content="Provisioning runs in the background and the wizard waits for it." fg={theme.textFaint} wrapMode="none" />
462
+ </box>
463
+ </Panel>
464
+ )
465
+ }
466
+
467
+ // Existing-server picker (the promoted "use an existing destination" feature).
468
+ function pickExistingPane() {
469
+ return (
470
+ <Panel title=" Destination — pick an existing server " active>
471
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
472
+ <text content="Clone INTO one of your existing servers:" fg={theme.textDim} wrapMode="none" />
473
+ <box style={{ height: 1 }} />
474
+ {eligibleDests.map((s, i) => {
475
+ const sel = i === destIdx
476
+ const meta = `${s.provider_name || "—"} · ${s.region || "—"}${s.ip_address ? " " + s.ip_address : ""}`
477
+ return (
478
+ <box key={s.id} style={{ flexDirection: "row", height: 1, backgroundColor: sel ? theme.bgAlt : undefined }}>
479
+ <text content={sel ? "❯ " : " "} fg={theme.brand} style={{ flexShrink: 0 }} />
480
+ <text content={s.name} fg={sel ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
481
+ <box style={{ flexGrow: 1 }} />
482
+ <text content={meta} fg={sel ? theme.text : theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
483
+ </box>
484
+ )
485
+ })}
486
+ <box style={{ flexGrow: 1 }} />
487
+ <text content="The dest needs a sudo connection next (and a deploy key for Bedrock) — the wizard walks you through it." fg={theme.textFaint} wrapMode="none" />
488
+ <text content="↑↓ select · ⏎ use this server · esc back" fg={theme.textFaint} wrapMode="none" />
489
+ </box>
490
+ </Panel>
491
+ )
492
+ }
493
+
494
+ function trustPane() {
495
+ const destServer = job!.destServerId != null ? servers.find((s) => s.id === job!.destServerId) ?? null : null
496
+ const srcOn = isSudoConnected(server!.id)
497
+ const destOn = destServer != null && isSudoConnected(destServer.id)
498
+ const both = srcOn && destOn
499
+ const row = (ok: boolean, label: string) => (
500
+ <box style={{ flexDirection: "row", height: 1 }}>
501
+ <text content={ok ? "✓ " : "○ "} fg={ok ? theme.good : theme.warn} style={{ flexShrink: 0 }} />
502
+ <text content={label} fg={ok ? theme.textDim : theme.warn} wrapMode="none" style={{ flexShrink: 1 }} />
503
+ </box>
504
+ )
505
+ return (
506
+ <Panel title=" Connect dest — the privileged foothold " active>
507
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
508
+ <text content={`Dest: ${job!.destServerName || "—"} ${job!.destServerIp || ""}`} fg={theme.text} wrapMode="none" />
509
+ <box style={{ height: 1 }} />
510
+ <text content="SpinupTUI needs a sudo connection on BOTH ends: root on the source" fg={theme.textFaint} wrapMode="none" />
511
+ <text content="to read each site, root on the dest to write + import." fg={theme.textFaint} wrapMode="none" />
512
+ <box style={{ height: 1 }} />
513
+ {row(srcOn, srcOn ? "Source sudo connected" : "Source sudo not connected — press G")}
514
+ {row(destOn, destOn ? "Dest sudo connected" : "Dest sudo not connected — create a sudo user (w), then press S")}
515
+ <box style={{ flexGrow: 1 }} />
516
+ {both ? (
517
+ <text content="❯ Enter — both ends connected, continue to clone" fg={theme.brand} wrapMode="none" />
518
+ ) : (
519
+ <text content="Connect sudo on both ends to continue." fg={theme.textFaint} wrapMode="none" />
520
+ )}
521
+ </box>
522
+ </Panel>
523
+ )
524
+ }
525
+
526
+ // Git-access step — deploy-key onboarding for Bedrock dests (hybrid: gh auto-add
527
+ // when possible, manual key + settings link otherwise). Each repo gets its OWN
528
+ // generated keypair — a GitHub deploy key fits exactly one repo, so the server-wide
529
+ // key can never cover a second repo on the same server.
530
+ function gitAccessPane() {
531
+ const keys = job!.repoKeys ?? []
532
+ const anyManual = keys.some((k) => !k.auto)
533
+ const blocked = keys.some((k) => k.status === "checking" || k.status === "adding" || (k.auto && k.status === "missing"))
534
+ const glyph = (k: RepoKeyState): { g: string; c: string } => {
535
+ switch (k.status) {
536
+ case "present":
537
+ case "added":
538
+ return { g: "✓", c: theme.good }
539
+ case "missing":
540
+ return { g: "○", c: theme.warn }
541
+ case "checking":
542
+ case "adding":
543
+ return { g: "⠹", c: theme.brand }
544
+ case "error":
545
+ return { g: "✕", c: theme.bad }
546
+ default:
547
+ return { g: "•", c: theme.textFaint }
548
+ }
549
+ }
550
+ const label = (k: RepoKeyState): string => {
551
+ switch (k.status) {
552
+ case "present":
553
+ return "deploy key present"
554
+ case "added":
555
+ return "deploy key added"
556
+ case "missing":
557
+ return "deploy key missing — press a to add"
558
+ case "checking":
559
+ return "checking…"
560
+ case "adding":
561
+ return "adding…"
562
+ case "error":
563
+ return truncate(k.error ?? "failed", 36)
564
+ default:
565
+ return "add the key by hand, then r to re-check"
566
+ }
567
+ }
568
+ return (
569
+ <Panel title=" Git access — authorize the new server to pull your repos " active>
570
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
571
+ <text content="A Bedrock dest is cloned from git at create time. Each repo gets its" fg={theme.textFaint} wrapMode="none" />
572
+ <text content="own read-only deploy key (generated here; installed on the new site)." fg={theme.textFaint} wrapMode="none" />
573
+ <box style={{ height: 1 }} />
574
+ {keys.length === 0 ? <text content="Checking repositories…" fg={theme.textDim} wrapMode="none" /> : null}
575
+ {keys.map((k) => {
576
+ const { g, c } = glyph(k)
577
+ return (
578
+ <box key={k.repo} style={{ flexDirection: "row", height: 1 }}>
579
+ <text content={`${g} `} fg={c} style={{ flexShrink: 0 }} />
580
+ <text content={`${k.owner}/${k.name}`} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
581
+ <box style={{ flexGrow: 1 }} />
582
+ <text content={label(k)} fg={k.status === "error" ? theme.bad : k.status === "present" || k.status === "added" ? theme.good : theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
583
+ </box>
584
+ )
585
+ })}
586
+ {anyManual ? (
587
+ <>
588
+ <box style={{ height: 1 }} />
589
+ <text content="Add each repo's generated key in its settings (o opens it, y copies it):" fg={theme.textFaint} wrapMode="none" />
590
+ {keys
591
+ .filter((k) => !k.auto && k.publicKey)
592
+ .map((k) => (
593
+ <box key={`pub-${k.repo}`} style={{ flexDirection: "row", height: 1 }}>
594
+ <text content={`${k.owner}/${k.name} `} fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
595
+ <text content={k.publicKey!} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
596
+ </box>
597
+ ))}
598
+ </>
599
+ ) : null}
600
+ <box style={{ flexGrow: 1 }} />
601
+ {blocked ? (
602
+ <text content="Add the missing deploy keys (a), then continue." fg={theme.textFaint} wrapMode="none" />
603
+ ) : (
604
+ <text content="❯ Enter — repos authorized, continue to clone" fg={theme.brand} wrapMode="none" />
605
+ )}
606
+ </box>
607
+ </Panel>
608
+ )
609
+ }
610
+
611
+ function planPane() {
612
+ return (
613
+ <Panel title=" Plan — choose sites to clone " active>
614
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
615
+ <box style={{ flexDirection: "row" }}>
616
+ <text content="Bring which sites?" fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
617
+ <box style={{ flexGrow: 1 }} />
618
+ <text content={`${selected.length} of ${job!.sites.length} selected`} fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
619
+ </box>
620
+ <box style={{ height: 1 }} />
621
+ {job!.sites.map((s, i) => {
622
+ const sel = i === idx
623
+ const mark = s.selected ? "◉" : "◯"
624
+ const markColor = s.selected ? theme.good : theme.textFaint
625
+ const size = s.sizeBytes != null ? formatBytes(s.sizeBytes) : "—"
626
+ const tag = s.stack === "bedrock" ? "bedrock" : s.stack === "files" ? "files only" : ""
627
+ const note = !s.selected ? " (skipped)" : s.excludeUploads ? " (no uploads)" : ""
628
+ return (
629
+ <box key={s.sourceSiteId} style={{ flexDirection: "row", height: 1, backgroundColor: sel ? theme.bgAlt : undefined }}>
630
+ <text content={` ${mark} `} fg={markColor} style={{ flexShrink: 0 }} />
631
+ <text content={s.domain} fg={sel ? theme.text : s.selected ? theme.text : theme.textFaint} wrapMode="none" style={{ flexShrink: 1 }} />
632
+ <box style={{ flexGrow: 1 }} />
633
+ {tag ? <text content={`${tag} `} fg={sel ? theme.text : theme.accent} wrapMode="none" style={{ flexShrink: 0 }} /> : null}
634
+ <text content={size} fg={sel ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 0 }} />
635
+ {note ? <text content={note} fg={sel ? theme.text : theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} /> : null}
636
+ </box>
637
+ )
638
+ })}
639
+ <box style={{ flexGrow: 1 }} />
640
+ <text content={payloadLine()} fg={theme.textFaint} wrapMode="none" />
641
+ <box style={{ height: 1 }} />
642
+ {/* pre-flight */}
643
+ <box style={{ flexDirection: "row", height: 1 }}>
644
+ <text content={sudoOn ? "✓ " : "○ "} fg={sudoOn ? theme.good : theme.warn} style={{ flexShrink: 0 }} />
645
+ <text content={sudoOn ? "Sudo connected on the source" : "Sudo not connected on the source — press S"} fg={sudoOn ? theme.textDim : theme.warn} wrapMode="none" style={{ flexShrink: 1 }} />
646
+ </box>
647
+ <box style={{ flexDirection: "row", height: 1 }}>
648
+ <text content={job!.lowerTtlEarly ? "◉ " : "◯ "} fg={job!.lowerTtlEarly ? theme.good : theme.textFaint} style={{ flexShrink: 0 }} />
649
+ <text content="Lower DNS TTL now (t) — makes cutover near-instant later" fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 1 }} />
650
+ </box>
651
+ </box>
652
+ </Panel>
653
+ )
654
+ }
655
+
656
+ // DNS cutover roster (slice 6) — one row per A record across the cloned sites'
657
+ // domains (primary + additional). www CNAMEs that follow the apex aren't listed.
658
+ function cutoverPane() {
659
+ const cloned = selected.filter((s) => s.step === "done")
660
+ const recs = cloned.flatMap((s) => (s.cutover?.records ?? []).map((r) => ({ r, siteId: s.sourceSiteId })))
661
+ const count = (st: string) => recs.filter((x) => x.r.status === st).length
662
+ const ready = recs.filter((x) => x.r.status === "ready" && !x.r.excluded).length
663
+ const skipped = recs.filter((x) => x.r.status === "ready" && x.r.excluded).length
664
+ const working = count("checking") + count("flipping")
665
+ const cutDone = count("done")
666
+ const manual = count("manual")
667
+ const errored = count("error")
668
+ const targetIp = job!.destServerIp || (job!.destServerId != null ? servers.find((s) => s.id === job!.destServerId)?.ip_address ?? "" : "") || "the new server"
669
+ const rowFor = ({ r, siteId }: (typeof recs)[number], i: number) => {
670
+ const st = r.status
671
+ const cur = i === idx
672
+ const busy = st === "checking" || st === "flipping"
673
+ // Ready rows read as include/exclude toggles (◉/◯, like Plan); the rest keep
674
+ // their status glyphs.
675
+ const glyph = st === "done" ? "✓" : st === "manual" ? "!" : st === "error" ? "✕" : st === "ready" ? (r.excluded ? "◯" : "◉") : "·"
676
+ const gcolor = st === "done" ? theme.good : st === "manual" ? theme.warn : st === "error" ? theme.bad : st === "ready" ? (r.excluded ? theme.textFaint : theme.brand) : theme.textFaint
677
+ const detail =
678
+ st === "checking" ? "reading DNS…"
679
+ : st === "flipping" ? "updating…"
680
+ : st === "done" ? `now → ${r.targetValue ?? targetIp}`
681
+ : st === "ready" ? `${r.currentValue ?? "?"} → ${r.targetValue ?? targetIp}${r.excluded ? " (skipped)" : ""}`
682
+ : st === "manual" ? `manual: ${r.reason ?? "edit by hand"}`
683
+ : st === "error" ? truncate(r.error ?? "failed", 28)
684
+ : "pending"
685
+ const dcolor = st === "error" ? theme.bad : st === "done" ? theme.good : st === "manual" ? theme.warn : cur ? theme.text : theme.textFaint
686
+ return (
687
+ <box key={`${siteId}|${r.name}`} style={{ flexDirection: "row", height: 1, backgroundColor: cur ? theme.bgAlt : undefined }}>
688
+ {busy ? <Spinner color={theme.brand} /> : <text content={glyph} fg={gcolor} style={{ flexShrink: 0 }} />}
689
+ <text content={` ${r.name}`} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
690
+ <box style={{ flexGrow: 1 }} />
691
+ <text content={detail} fg={dcolor} wrapMode="none" style={{ flexShrink: 0 }} />
692
+ </box>
693
+ )
694
+ }
695
+ return (
696
+ <Panel title=" DNS cutover — point traffic at the new server " active>
697
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
698
+ <text content={`Repoint each A record → ${targetIp}. This moves LIVE traffic (www CNAMEs follow the apex).`} fg={theme.textDim} wrapMode="none" />
699
+ <box style={{ height: 1 }} />
700
+ {recs.length === 0 ? <text content="Reading DNS…" fg={theme.textDim} wrapMode="none" /> : recs.map(rowFor)}
701
+ <box style={{ flexGrow: 1 }} />
702
+ <text content={`✓ ${cutDone} cut over · ◉ ${ready} ready${skipped ? ` · ◯ ${skipped} skipped` : ""} · ⠹ ${working} working${manual ? ` · ! ${manual} manual` : ""}${errored ? ` · ✕ ${errored} failed` : ""}`} fg={theme.textDim} wrapMode="none" />
703
+ {manual > 0 ? <text content="Manual records can't be API-edited — ⏎ opens that zone's registrar (zone name copied for the delegate-access paste)." fg={theme.textFaint} wrapMode="none" /> : null}
704
+ {ready > 0 ? (
705
+ <text content={`❯ c — cut over ${ready} record${ready === 1 ? "" : "s"} to the new server (live traffic)`} fg={theme.brand} wrapMode="none" />
706
+ ) : (
707
+ <text content="f — finish" fg={theme.textFaint} wrapMode="none" />
708
+ )}
709
+ </box>
710
+ </Panel>
711
+ )
712
+ }
713
+
714
+ // Live clone roster — one row per selected site, sub-step + spinner/✓/✕.
715
+ function clonePane() {
716
+ const done = selected.filter((s) => s.step === "done").length
717
+ const settled = selected.length > 0 && selected.every((s) => s.step === "done" || s.step === "error")
718
+ const running = selected.filter((s) => !["queued", "done", "error"].includes(s.step)).length
719
+ const errored = selected.filter((s) => s.step === "error").length
720
+ const queued = selected.filter((s) => s.step === "queued").length
721
+ // "· 3 at a time" only earns its place when there's actually a queue.
722
+ const title = selected.length === 1 ? " Clone site " : selected.length > job!.concurrency ? ` Clone sites · up to ${job!.concurrency} at a time ` : " Clone sites "
723
+ return (
724
+ <Panel title={title} active>
725
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
726
+ {selected.map((s, i) => {
727
+ const active = !["queued", "done", "error"].includes(s.step)
728
+ const cur = i === idx
729
+ const vmark = s.verify ? (s.verify.ok ? " ✓verified" : " ✕mismatch") : s.verifying ? " verifying…" : ""
730
+ return (
731
+ <box key={s.sourceSiteId} style={{ flexDirection: "row", height: 1, backgroundColor: cur ? theme.bgAlt : undefined }}>
732
+ {active ? <Spinner color={theme.brand} /> : <text content={s.step === "done" ? "✓" : s.step === "error" ? "✕" : "○"} fg={s.step === "done" ? theme.good : s.step === "error" ? theme.bad : theme.textFaint} style={{ flexShrink: 0 }} />}
733
+ <text content={` ${s.domain}`} fg={cur || s.step === "error" || s.step === "done" ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
734
+ <box style={{ flexGrow: 1 }} />
735
+ <text
736
+ content={(s.step === "error" ? truncate(s.error ?? "failed", 24) : s.step === "done" ? "done" : s.step === "queued" ? "queued" : `${s.step}${s.detail ? " · " + s.detail : ""}${s.transferBytes != null ? ` · ${formatBytes(s.transferBytes)}${s.transferTarget ? ` of ${s.transferExact ? "" : "~"}${formatBytes(s.transferTarget)}` : ""}${s.transferExact && s.transferTarget ? ` (${Math.min(100, Math.round((s.transferBytes / s.transferTarget) * 100))}%)` : ""}${s.transferRate ? ` · ${formatBytes(s.transferRate)}/s` : ""}` : ""}${s.stageStartedAt ? " · " + fmtElapsed(now - s.stageStartedAt) : ""}`) + vmark}
737
+ fg={s.step === "error" || (s.verify && !s.verify.ok) ? theme.bad : s.verify?.ok ? theme.good : s.step === "done" ? theme.good : theme.textFaint}
738
+ wrapMode="none"
739
+ style={{ flexShrink: 0 }}
740
+ />
741
+ </box>
742
+ )
743
+ })}
744
+ <box style={{ flexGrow: 1 }} />
745
+ <text content={`✓ ${done} done · ⠹ ${running} running · ${queued} queued${errored ? ` · ✕ ${errored} failed` : ""}`} fg={theme.textDim} wrapMode="none" />
746
+ {done > 0 ? <text content="↑↓ select · v verify a done site" fg={theme.textFaint} wrapMode="none" /> : null}
747
+ {errored > 0 ? <text content={`⏎ on a failed site — full error${job!.logPath ? ` · log: ${job!.logPath}` : ""}`} fg={theme.textFaint} wrapMode="none" /> : null}
748
+ {settled && done > 0 && job!.step === "clone" ? (
749
+ <text content={`❯ c — continue to DNS cutover (${done} of ${selected.length} cloned; cutover moves LIVE traffic)`} fg={theme.brand} wrapMode="none" />
750
+ ) : null}
751
+ {job!.step === "clone" ? (
752
+ <text content={errored > 0 ? "r retry failed · esc — clone keeps running in the background" : "esc — clone keeps running in the background (reopen with C)"} fg={theme.textFaint} wrapMode="none" />
753
+ ) : (
754
+ <text content={errored > 0 ? "r retry failed · esc close" : "esc — close"} fg={theme.textFaint} wrapMode="none" />
755
+ )}
756
+ </box>
757
+ </Panel>
758
+ )
759
+ }
760
+
761
+ // Verify drill-down (slice 5) — source-vs-clone facts + HTTP for one done site.
762
+ function verifyPane(siteId: number) {
763
+ const site = job!.sites.find((s) => s.sourceSiteId === siteId)
764
+ if (!site) return null
765
+ const v = site.verify
766
+ const row = (c: VerifyCheck) => (
767
+ <box key={c.key} style={{ flexDirection: "row", height: 1 }}>
768
+ <text content={c.ok ? "✓ " : "✕ "} fg={c.ok ? theme.good : theme.bad} style={{ flexShrink: 0 }} />
769
+ <text content={c.label} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
770
+ <box style={{ flexGrow: 1 }} />
771
+ <text content={truncate(c.source, 22)} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 0, width: 24 }} />
772
+ <text content={truncate(c.clone, 22)} fg={c.ok ? theme.textDim : theme.bad} wrapMode="none" style={{ flexShrink: 0, width: 24 }} />
773
+ </box>
774
+ )
775
+ return (
776
+ <Panel title={` Verify · ${site.domain} `} active>
777
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
778
+ {site.verifying ? (
779
+ <box style={{ flexDirection: "row", height: 1 }}>
780
+ <Spinner color={theme.brand} />
781
+ <text content=" Comparing source vs clone…" fg={theme.textDim} wrapMode="none" />
782
+ </box>
783
+ ) : site.verifyError ? (
784
+ <text content={`Verify failed: ${site.verifyError}`} fg={theme.bad} wrapMode="none" />
785
+ ) : v ? (
786
+ <>
787
+ <box style={{ flexDirection: "row", height: 1 }}>
788
+ <text content=" " style={{ flexShrink: 0 }} />
789
+ <text content="check" fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 1 }} />
790
+ <box style={{ flexGrow: 1 }} />
791
+ <text content="source" fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0, width: 24 }} />
792
+ <text content="clone" fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0, width: 24 }} />
793
+ </box>
794
+ {v.checks.map(row)}
795
+ <box style={{ flexGrow: 1 }} />
796
+ <text content={v.ok ? "✓ Clone matches the source." : "✕ Differences found — review the ✕ rows above."} fg={v.ok ? theme.good : theme.bad} wrapMode="none" />
797
+ <text content="v re-run · ← back to roster" fg={theme.textFaint} wrapMode="none" />
798
+ </>
799
+ ) : (
800
+ <text content="Press v to compare this clone against its source." fg={theme.textDim} wrapMode="none" />
801
+ )}
802
+ </box>
803
+ </Panel>
804
+ )
805
+ }
806
+
807
+ // Failure drill-down — the FULL error for one failed site (the roster truncates it),
808
+ // plus where the complete stage-by-stage log lives on disk.
809
+ function failurePane(siteId: number) {
810
+ const site = job!.sites.find((s) => s.sourceSiteId === siteId)
811
+ if (!site) return null
812
+ return (
813
+ <Panel title={` Failed · ${site.domain} `} active>
814
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
815
+ <box style={{ flexDirection: "row", height: 1 }}>
816
+ <text content="✕ failed at: " fg={theme.bad} style={{ flexShrink: 0 }} />
817
+ <text content={site.failedStep ?? "?"} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
818
+ </box>
819
+ <box style={{ height: 1 }} />
820
+ <text content={site.error ?? "no error captured"} fg={theme.text} />
821
+ <box style={{ flexGrow: 1 }} />
822
+ {job!.logPath ? <text content={`Full stage-by-stage output: ${job!.logPath}`} fg={theme.textFaint} /> : null}
823
+ <text content="r retry this site · ← back to roster" fg={theme.textFaint} wrapMode="none" />
824
+ </box>
825
+ </Panel>
826
+ )
827
+ }
828
+
829
+ function hints() {
830
+ if (job!.step === "plan") {
831
+ return [
832
+ { key: "space", label: "toggle" },
833
+ { key: "u", label: "uploads" },
834
+ { key: "t", label: "lower TTL" },
835
+ ...(sudoOn ? [] : [{ key: "S", label: "connect sudo" }]),
836
+ ...(selected.length > 0 ? [{ key: "⏎", label: "continue" }] : []),
837
+ { key: "esc", label: "close" },
838
+ ]
839
+ }
840
+ if (job!.step === "server") {
841
+ if (pickExisting) return [{ key: "↑↓", label: "select" }, { key: "⏎", label: "use server" }, { key: "esc", label: "back" }]
842
+ return [{ key: "⏎", label: "provision new" }, ...(eligibleDests.length > 0 ? [{ key: "d", label: "use existing" }] : []), { key: "←", label: "back" }, { key: "esc", label: "close" }]
843
+ }
844
+ if (job!.step === "trust") {
845
+ const destServer = job!.destServerId != null ? servers.find((s) => s.id === job!.destServerId) ?? null : null
846
+ const both = isSudoConnected(server!.id) && destServer != null && isSudoConnected(destServer.id)
847
+ return [
848
+ { key: "S", label: "sudo dest" },
849
+ { key: "G", label: "sudo source" },
850
+ { key: "w", label: "SpinupWP" },
851
+ ...(both ? [{ key: "⏎", label: "continue" }] : []),
852
+ { key: "←", label: "back" },
853
+ { key: "esc", label: "close" },
854
+ ]
855
+ }
856
+ if (job!.step === "clone" || job!.step === "done") {
857
+ if (verifyOpen != null) {
858
+ const drilled = job!.sites.find((s) => s.sourceSiteId === verifyOpen)
859
+ if (drilled?.step === "error") return [{ key: "r", label: "retry site" }, { key: "←", label: "back" }, { key: "esc", label: "close" }]
860
+ return [{ key: "v", label: "re-run" }, { key: "←", label: "back" }, { key: "esc", label: "close" }]
861
+ }
862
+ const done = selected.filter((s) => s.step === "done").length
863
+ const errored = selected.filter((s) => s.step === "error").length
864
+ const settled = selected.length > 0 && selected.every((s) => s.step === "done" || s.step === "error")
865
+ return [
866
+ ...(settled && done > 0 && job!.step === "clone" ? [{ key: "c", label: "DNS cutover" }] : []),
867
+ ...(done > 0 ? [{ key: "↑↓", label: "select" }, { key: "v", label: "verify" }] : []),
868
+ ...(errored > 0 ? [{ key: "r", label: "retry failed" }] : []),
869
+ { key: "esc", label: job!.step === "clone" ? "background" : "close" },
870
+ ]
871
+ }
872
+ if (job!.step === "cutover") {
873
+ const recs = selected.filter((s) => s.step === "done").flatMap((s) => s.cutover?.records ?? [])
874
+ const ready = recs.filter((r) => r.status === "ready" && !r.excluded).length
875
+ const anyConsole = recs.some((r) => r.hostKey && consoleForHost(r.hostKey))
876
+ return [
877
+ { key: "↑↓", label: "select" },
878
+ ...(recs.some((r) => r.status === "ready") ? [{ key: "space", label: "skip/include" }] : []),
879
+ ...(anyConsole ? [{ key: "⏎", label: "registrar" }] : []),
880
+ ...(ready > 0 ? [{ key: "c", label: "cut over" }] : []),
881
+ { key: "r", label: "re-check" },
882
+ { key: "←", label: "clone roster" },
883
+ { key: "f", label: "finish" },
884
+ { key: "esc", label: "background" },
885
+ ]
886
+ }
887
+ if (job!.step === "gitaccess") {
888
+ const keys = job!.repoKeys ?? []
889
+ const canAdd = keys.some((k) => k.auto && (k.status === "missing" || k.status === "error"))
890
+ const hasManual = keys.some((k) => k.settingsUrl)
891
+ const blocked = keys.some((k) => k.status === "checking" || k.status === "adding" || (k.auto && k.status === "missing"))
892
+ return [
893
+ ...(canAdd ? [{ key: "a", label: "add key" }] : []),
894
+ ...(hasManual ? [{ key: "o", label: "open settings" }] : []),
895
+ { key: "r", label: "re-check" },
896
+ ...(!blocked ? [{ key: "⏎", label: "continue" }] : []),
897
+ { key: "←", label: "back" },
898
+ { key: "esc", label: "close" },
899
+ ]
900
+ }
901
+ return [{ key: "esc", label: "close" }]
902
+ }
903
+
904
+ function countDone(step: "clone" | "cutover") {
905
+ if (step === "clone") return job!.sites.filter((s) => s.selected && s.step === "done").length
906
+ // cutover badge counts records repointed (or already on the new IP).
907
+ return job!.sites.filter((s) => s.selected && s.cutover?.status === "done").length
908
+ }
909
+ function totalSize() {
910
+ const known = job!.sites.filter((s) => s.selected && s.sizeBytes != null).reduce((a, s) => a + (s.sizeBytes ?? 0), 0)
911
+ return known > 0 ? formatBytes(known) : "—"
912
+ }
913
+ function payloadLine() {
914
+ const anySized = job!.sites.some((s) => s.sizeBytes != null)
915
+ if (!sudoOn) return "Payload size — connect sudo (S) to measure webroot + DB"
916
+ if (!anySized) return "Measuring webroot + DB sizes over sudo…"
917
+ return `Payload ~${totalSize()} (selected sites' webroot + DB)`
918
+ }
919
+ }
920
+
921
+ function fmtElapsed(ms: number): string {
922
+ const sec = Math.max(0, Math.floor(ms / 1000))
923
+ return sec < 60 ? `${sec}s` : `${Math.floor(sec / 60)}m${String(sec % 60).padStart(2, "0")}s`
924
+ }
925
+ function truncate(s: string, n: number): string {
926
+ return s.length <= n ? s : s.slice(0, Math.max(0, n - 1)) + "…"
927
+ }
928
+ function formatBytes(b: number): string {
929
+ if (b >= 1e9) return `${(b / 1e9).toFixed(1)} GB`
930
+ if (b >= 1e6) return `${(b / 1e6).toFixed(0)} MB`
931
+ return `${(b / 1e3).toFixed(0)} KB`
932
+ }