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,149 @@
1
+ // Fleet dashboard: at-a-glance health of every server and site in the account.
2
+
3
+ import { useMemo } from "react"
4
+ import { theme, diskColor } from "../../lib/theme.ts"
5
+ import { formatBytes, diskUsedPct, bar, truncate, timeAgo } from "../../lib/format.ts"
6
+ import { Panel } from "../components.tsx"
7
+ import { useStore } from "../store.tsx"
8
+ import type { Server } from "../../api/types.ts"
9
+
10
+ // A single headline metric card. Height 5 = 2 border rows + 3 content lines.
11
+ function Stat({ label, value, color = theme.text, sub }: { label: string; value: string; color?: string; sub?: string }) {
12
+ return (
13
+ <box
14
+ border
15
+ borderColor={theme.border}
16
+ style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 1, paddingRight: 1, height: 5 }}
17
+ >
18
+ <text content={label} fg={theme.textDim} />
19
+ <text content={value} fg={color} attributes={1} />
20
+ <text content={sub ?? ""} fg={theme.textFaint} />
21
+ </box>
22
+ )
23
+ }
24
+
25
+ function serverDiskFraction(s: Server): number {
26
+ return diskUsedPct(s.disk_space?.used, s.disk_space?.total) / 100
27
+ }
28
+
29
+ export function Dashboard({ rows }: { rows: number }) {
30
+ const { servers, sites, events, isServerOsEol } = useStore()
31
+
32
+ const agg = useMemo(() => {
33
+ const connected = servers.filter((s) => s.connection_status === "connected").length
34
+ const reboot = servers.filter((s) => s.reboot_required).length
35
+ const upgrade = servers.filter((s) => s.upgrade_required).length
36
+ let used = 0
37
+ let total = 0
38
+ for (const s of servers) {
39
+ if (s.disk_space) {
40
+ used += s.disk_space.used || 0
41
+ total += s.disk_space.total || 0
42
+ }
43
+ }
44
+ const wp = sites.filter((s) => s.is_wordpress).length
45
+ const pluginUpd = sites.reduce((n, s) => n + (s.wp_plugin_updates || 0), 0)
46
+ const themeUpd = sites.reduce((n, s) => n + (s.wp_theme_updates || 0), 0)
47
+ const coreUpd = sites.filter((s) => s.wp_core_update).length
48
+ const https = sites.filter((s) => s.https?.enabled).length
49
+ return { connected, reboot, upgrade, used, total, wp, pluginUpd, themeUpd, coreUpd, https }
50
+ }, [servers, sites])
51
+
52
+ // Servers sorted by disk pressure (busiest first) for the watchlist.
53
+ const byDisk = useMemo(
54
+ () => [...servers].sort((a, b) => serverDiskFraction(b) - serverDiskFraction(a)),
55
+ [servers],
56
+ )
57
+
58
+ // Things that want attention: reboots, upgrades, pending WP updates.
59
+ const attention = useMemo(() => {
60
+ const items: { text: string; color: string }[] = []
61
+ for (const s of servers) {
62
+ if (s.reboot_required) items.push({ text: `${s.name} — reboot required`, color: theme.warn })
63
+ if (s.upgrade_required) items.push({ text: `${s.name} — SpinupWP upgrade required`, color: theme.warn })
64
+ if (isServerOsEol(s)) items.push({ text: `${s.name} — Ubuntu ${s.ubuntu_version} is EOL, clone to a newer server`, color: theme.bad })
65
+ }
66
+ return items
67
+ }, [servers, isServerOsEol])
68
+
69
+ const diskPct = agg.total > 0 ? (agg.used / agg.total) * 100 : 0
70
+ const listRows = Math.max(3, rows - 9) // minus the stat-card band (5), status bar, and panel chrome
71
+
72
+ return (
73
+ <box style={{ flexGrow: 1, flexDirection: "column", padding: 1 }}>
74
+ {/* Headline metrics */}
75
+ <box style={{ flexDirection: "row", gap: 1 }}>
76
+ <Stat label="Servers" value={String(servers.length)} color={theme.brand} sub={`${agg.connected} connected`} />
77
+ <Stat label="Sites" value={String(sites.length)} color={theme.accent} sub={`${agg.wp} WordPress`} />
78
+ <Stat
79
+ label="Fleet Disk"
80
+ value={`${diskPct.toFixed(0)}%`}
81
+ color={diskColor(diskPct)}
82
+ sub={`${formatBytes(agg.used)} / ${formatBytes(agg.total)}`}
83
+ />
84
+ <Stat
85
+ label="WP Updates"
86
+ value={String(agg.pluginUpd + agg.themeUpd + agg.coreUpd)}
87
+ color={agg.pluginUpd + agg.themeUpd + agg.coreUpd > 0 ? theme.warn : theme.good}
88
+ sub={`${agg.pluginUpd} plugin · ${agg.coreUpd} core`}
89
+ />
90
+ <Stat
91
+ label="Maintenance"
92
+ value={String(agg.reboot + agg.upgrade)}
93
+ color={agg.reboot + agg.upgrade > 0 ? theme.warn : theme.good}
94
+ sub={`${agg.reboot} reboot · ${agg.upgrade} upgrade`}
95
+ />
96
+ </box>
97
+
98
+ {/* Two columns: disk watchlist + attention/activity */}
99
+ <box style={{ flexGrow: 1, flexDirection: "row", gap: 1, marginTop: 1 }}>
100
+ <Panel title=" Disk usage by server " flexGrow={1}>
101
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
102
+ {byDisk.slice(0, listRows).map((s) => {
103
+ const frac = serverDiskFraction(s)
104
+ const pct = frac * 100
105
+ return (
106
+ <box key={s.id} style={{ flexDirection: "row", height: 1 }}>
107
+ <text content={truncate(s.name, 30)} fg={theme.text} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
108
+ <text content={bar(frac, 10)} fg={diskColor(pct)} style={{ flexShrink: 0 }} />
109
+ <text content={` ${pct.toFixed(0)}%`.padStart(5)} fg={diskColor(pct)} style={{ flexShrink: 0 }} />
110
+ <text content={s.disk_space ? ` ${formatBytes(s.disk_space.used)}` : " —"} fg={theme.textDim} style={{ flexShrink: 0 }} />
111
+ </box>
112
+ )
113
+ })}
114
+ </box>
115
+ </Panel>
116
+
117
+ <box style={{ flexDirection: "column", flexGrow: 1, gap: 1 }}>
118
+ <Panel title={` Needs attention (${attention.length}) `} flexGrow={1}>
119
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
120
+ {attention.length === 0 ? (
121
+ <text content="✓ All servers healthy" fg={theme.good} />
122
+ ) : (
123
+ attention.slice(0, Math.max(1, Math.floor(listRows / 2))).map((a, i) => (
124
+ <box key={i} style={{ flexDirection: "row", height: 1 }}>
125
+ <text content={`• ${truncate(a.text, 60)}`} fg={a.color} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
126
+ </box>
127
+ ))
128
+ )}
129
+ </box>
130
+ </Panel>
131
+ <Panel title=" Recent activity " flexGrow={1}>
132
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
133
+ {events.length === 0 ? (
134
+ <text content="No recent events" fg={theme.textFaint} />
135
+ ) : (
136
+ events.slice(0, Math.max(1, Math.floor(listRows / 2))).map((e) => (
137
+ <box key={e.id} style={{ flexDirection: "row", height: 1 }}>
138
+ <text content={truncate(e.name, 44)} fg={theme.text} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
139
+ <text content={" " + timeAgo(e.created_at)} fg={theme.textFaint} style={{ flexShrink: 0 }} />
140
+ </box>
141
+ ))
142
+ )}
143
+ </box>
144
+ </Panel>
145
+ </box>
146
+ </box>
147
+ </box>
148
+ )
149
+ }
@@ -0,0 +1,228 @@
1
+ // Production DB-backup overlay.
2
+ //
3
+ // Opened with `d` on a linked site (Search). Confirms what will run, then exports
4
+ // the remote database, downloads it (gzipped) into the local project's sql/ dir,
5
+ // and cleans up the remote copy. The export + download run in the store
6
+ // (`startDbBackup`), so closing this modal (Esc/q) doesn't abandon them — the
7
+ // download keeps going and the result is still in the store when reopened.
8
+ //
9
+ // The whole operation is read-only on production (an export, never a write).
10
+
11
+ import { useState } from "react"
12
+ import { useKeyboard } from "@opentui/react"
13
+ import { theme } from "../../lib/theme.ts"
14
+ import { truncate, formatBytes, middleTruncate } from "../../lib/format.ts"
15
+ import { Panel, Centered, DestPath, Steps, type StepRow } from "../components.tsx"
16
+ import { StatusBar } from "../StatusBar.tsx"
17
+ import { useStore } from "../store.tsx"
18
+ import type { DbBackupStage } from "../../lib/dbBackup.ts"
19
+
20
+ // The backup's stages in run order — rendered as a building checklist.
21
+ const BACKUP_STEPS: { stage: DbBackupStage; label: string }[] = [
22
+ { stage: "export", label: "Export database on server" },
23
+ { stage: "download", label: "Download the backup" },
24
+ { stage: "cleanup", label: "Clean up remote copy" },
25
+ ]
26
+
27
+ export function DbBackup() {
28
+ const { dbBackupSite: site, setDbBackupSite, serverById, planDbBackupFor, dbBackups, startDbBackup, clearDbBackup } = useStore()
29
+
30
+ // Resolve the plan up front so the confirm screen can show source + destination
31
+ // (and surface an unrunnable link before the user commits).
32
+ const planResult = site ? planDbBackupFor(site) : null
33
+ const progress = site ? dbBackups.get(site.id) : undefined
34
+
35
+ const [started, setStarted] = useState(false)
36
+
37
+ // Display phase: a planning error blocks before anything runs; otherwise the
38
+ // screen follows the store's progress once we've fired the backup.
39
+ const dp: "blocked" | "confirm" | "running" | "done" | "error" =
40
+ planResult && !planResult.ok && !progress
41
+ ? "blocked"
42
+ : !started && !progress
43
+ ? "confirm"
44
+ : progress?.stage === "done"
45
+ ? "done"
46
+ : progress?.stage === "error"
47
+ ? "error"
48
+ : "running"
49
+
50
+ const close = () => {
51
+ // Drop a settled (done/failed) entry so reopening starts fresh; leave an
52
+ // in-flight download running with its progress intact.
53
+ if (site && (progress?.stage === "done" || progress?.stage === "error")) clearDbBackup(site.id)
54
+ setDbBackupSite(null)
55
+ }
56
+
57
+ useKeyboard((key) => {
58
+ const name = key.name ?? ""
59
+ if (name === "escape" || name === "q") return close()
60
+
61
+ if (dp === "confirm") {
62
+ if (name === "return" || name === "y") {
63
+ if (site) {
64
+ startDbBackup(site)
65
+ setStarted(true)
66
+ }
67
+ }
68
+ return
69
+ }
70
+
71
+ if (dp === "error") {
72
+ if (name === "r") {
73
+ if (site) clearDbBackup(site.id)
74
+ setStarted(false)
75
+ }
76
+ return
77
+ }
78
+ })
79
+
80
+ if (!site) return null
81
+ const server = serverById(site.server_id)
82
+
83
+ return (
84
+ <box
85
+ style={{
86
+ position: "absolute",
87
+ top: 0,
88
+ left: 0,
89
+ width: "100%",
90
+ height: "100%",
91
+ flexDirection: "column",
92
+ backgroundColor: theme.bg,
93
+ zIndex: 210,
94
+ }}
95
+ >
96
+ <box
97
+ style={{
98
+ flexDirection: "row",
99
+ height: 1,
100
+ backgroundColor: theme.bgAlt,
101
+ paddingLeft: 1,
102
+ paddingRight: 1,
103
+ alignItems: "center",
104
+ }}
105
+ >
106
+ <text content="⬇ Download DB backup " fg={theme.brand} style={{ flexShrink: 0 }} />
107
+ <text content={truncate(site.domain, 40)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
108
+ <box style={{ flexGrow: 1 }} />
109
+ <text content="read-only export" fg={theme.textFaint} style={{ flexShrink: 0 }} />
110
+ </box>
111
+
112
+ <Centered>{renderBody()}</Centered>
113
+
114
+ <StatusBar hints={hints()} showGlobal={false} />
115
+ </box>
116
+ )
117
+
118
+ function renderBody() {
119
+ if (dp === "blocked") {
120
+ return (
121
+ <Panel title=" Can't back up yet " active>
122
+ <box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
123
+ <text content={planResult && !planResult.ok ? planResult.error : "Unavailable."} fg={theme.warn} wrapMode="none" />
124
+ <box style={{ height: 1 }} />
125
+ <text content="The backup downloads into the linked working copy's sql/ folder." fg={theme.textDim} wrapMode="none" />
126
+ <box style={{ height: 1 }} />
127
+ <text content="Esc to close" fg={theme.textFaint} />
128
+ </box>
129
+ </Panel>
130
+ )
131
+ }
132
+
133
+ if (dp === "confirm") {
134
+ const plan = planResult && planResult.ok ? planResult.plan : null
135
+ return (
136
+ <Panel title=" Download production database " active>
137
+ <box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
138
+ <box style={{ flexDirection: "row" }}>
139
+ <text content="Export " fg={theme.text} />
140
+ <text content={truncate(site!.domain, 36)} fg={theme.accent} wrapMode="none" />
141
+ <text content=" on " fg={theme.textDim} />
142
+ <text content={truncate(server?.name ?? "—", 24)} fg={theme.textDim} wrapMode="none" />
143
+ </box>
144
+ <box style={{ height: 1 }} />
145
+ <text content="from" fg={theme.textFaint} />
146
+ <text content={` ${middleTruncate(plan?.docroot ?? "—", 62)}`} fg={theme.textDim} wrapMode="none" />
147
+ <text content="to" fg={theme.textFaint} />
148
+ {plan ? <DestPath path={plan.destPath} fileColor={theme.good} width={62} /> : <text content=" —" fg={theme.textDim} />}
149
+ <box style={{ height: 1 }} />
150
+ <text content="Exports with wp-cli, gzips it, downloads it, then removes the" fg={theme.textDim} wrapMode="none" />
151
+ <text content="remote copy. Nothing on production is modified." fg={theme.textDim} wrapMode="none" />
152
+ <box style={{ height: 1 }} />
153
+ <text content="Press y / ⏎ to download · Esc to cancel" fg={theme.textFaint} wrapMode="none" />
154
+ </box>
155
+ </Panel>
156
+ )
157
+ }
158
+
159
+ // running | done | error share one bordered checklist so the whole backup
160
+ // reads as a building stack; the footer carries the live/result/error status.
161
+ const order = BACKUP_STEPS.map((s) => s.stage)
162
+ const curIdx = order.indexOf((progress?.stage ?? "export") as DbBackupStage)
163
+ const failedIdx = progress?.failedStage ? order.indexOf(progress.failedStage) : order.length - 1
164
+ const rows: StepRow[] = BACKUP_STEPS.map(({ label }, i) => {
165
+ const state: StepRow["state"] =
166
+ dp === "done"
167
+ ? "done"
168
+ : dp === "error"
169
+ ? i < failedIdx
170
+ ? "done"
171
+ : i === failedIdx
172
+ ? "failed"
173
+ : "pending"
174
+ : i < curIdx
175
+ ? "done"
176
+ : i === curIdx
177
+ ? "active"
178
+ : "pending"
179
+ return { label, state }
180
+ })
181
+
182
+ return (
183
+ <Panel title=" Download production database " active>
184
+ <box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
185
+ <Steps rows={rows} />
186
+ <box style={{ height: 1 }} />
187
+ {dp === "done" ? (
188
+ <>
189
+ <box style={{ flexDirection: "row" }}>
190
+ <text content="✓ Done — saved " fg={theme.good} />
191
+ <text content={progress?.bytes != null ? formatBytes(progress.bytes) : ""} fg={theme.text} />
192
+ </box>
193
+ <box style={{ height: 1 }} />
194
+ {progress?.destPath ? <DestPath path={progress.destPath} fileColor={theme.accent} width={62} /> : null}
195
+ <box style={{ height: 1 }} />
196
+ <text content="Esc to close" fg={theme.textFaint} />
197
+ </>
198
+ ) : dp === "error" ? (
199
+ <>
200
+ <text content={`✕ ${progress?.error ?? "Something went wrong."}`} fg={theme.bad} />
201
+ <box style={{ height: 1 }} />
202
+ <text content="Press r to try again · Esc to close" fg={theme.textFaint} wrapMode="none" />
203
+ </>
204
+ ) : (
205
+ <text content="You can press Esc — the download keeps running in the background." fg={theme.textFaint} wrapMode="none" />
206
+ )}
207
+ </box>
208
+ </Panel>
209
+ )
210
+ }
211
+
212
+ function hints() {
213
+ switch (dp) {
214
+ case "confirm":
215
+ return [
216
+ { key: "y/⏎", label: "download" },
217
+ { key: "esc", label: "cancel" },
218
+ ]
219
+ case "error":
220
+ return [
221
+ { key: "r", label: "retry" },
222
+ { key: "esc", label: "close" },
223
+ ]
224
+ default:
225
+ return [{ key: "esc", label: "close" }]
226
+ }
227
+ }
228
+ }
@@ -0,0 +1,307 @@
1
+ // Production → local DB sync overlay.
2
+ //
3
+ // Opened with `p` on a linked WordPress site (Search). Pulls the production
4
+ // database into the local working copy: backs up local first, exports/downloads
5
+ // prod (read-only), imports it, rewrites URLs, and runs an optional post-import
6
+ // hook. DESTRUCTIVE on local — so the confirm screen says so plainly. The work
7
+ // runs in the store (`startDbSync`), so closing the modal (Esc/q) doesn't abandon
8
+ // it; the row keeps a spinner and reopening shows live progress.
9
+
10
+ import { useState } from "react"
11
+ import { useKeyboard } from "@opentui/react"
12
+ import { theme } from "../../lib/theme.ts"
13
+ import { truncate, middleTruncate } from "../../lib/format.ts"
14
+ import { Panel, Centered, DestPath, Steps, type StepRow } from "../components.tsx"
15
+ import { StatusBar } from "../StatusBar.tsx"
16
+ import { useStore } from "../store.tsx"
17
+ import { writeSampleHook, type DbSyncStage } from "../../lib/dbSync.ts"
18
+
19
+ // The pull's stages in run order — rendered as a building checklist. The hook
20
+ // row is included only when the plan found a bin/sync.d/post-import.sh.
21
+ const SYNC_STEPS: { stage: DbSyncStage; label: string }[] = [
22
+ { stage: "local-backup", label: "Back up local database" },
23
+ { stage: "export", label: "Export production database" },
24
+ { stage: "download", label: "Download the dump" },
25
+ { stage: "import", label: "Import into local" },
26
+ { stage: "replace", label: "Rewrite production URLs → local" },
27
+ { stage: "hook", label: "Run post-import hook" },
28
+ ]
29
+
30
+ export function DbSync() {
31
+ const { dbSyncSite: site, setDbSyncSite, serverById, planDbSyncFor, dbSyncs, startDbSync, clearDbSync, planMediaFallbackFor, setMediaFallbackSite } = useStore()
32
+
33
+ const planResult = site ? planDbSyncFor(site) : null
34
+ const progress = site ? dbSyncs.get(site.id) : undefined
35
+ const [started, setStarted] = useState(false)
36
+ // Result of scaffolding a sample post-import hook (s on the confirm screen).
37
+ const [scaffold, setScaffold] = useState<{ ok: true; path: string } | { ok: false; error: string } | null>(null)
38
+
39
+ const dp: "blocked" | "confirm" | "running" | "done" | "error" =
40
+ planResult && !planResult.ok && !progress
41
+ ? "blocked"
42
+ : !started && !progress
43
+ ? "confirm"
44
+ : progress?.stage === "done"
45
+ ? "done"
46
+ : progress?.stage === "error"
47
+ ? "error"
48
+ : "running"
49
+
50
+ const close = () => {
51
+ if (site && (progress?.stage === "done" || progress?.stage === "error")) clearDbSync(site.id)
52
+ setDbSyncSite(null)
53
+ }
54
+
55
+ useKeyboard((key) => {
56
+ const name = key.name ?? ""
57
+ if (name === "escape" || name === "q") return close()
58
+ if (dp === "confirm") {
59
+ if (name === "return" || name === "y") {
60
+ if (site) {
61
+ startDbSync(site)
62
+ setStarted(true)
63
+ }
64
+ return
65
+ }
66
+ // s: scaffold a sample hook — only when none exists yet.
67
+ if (name === "s" && plan && !plan.hookPath) {
68
+ try {
69
+ setScaffold({ ok: true, path: writeSampleHook(plan.localRoot) })
70
+ } catch (e) {
71
+ setScaffold({ ok: false, error: e instanceof Error ? e.message : "Couldn't write the sample hook." })
72
+ }
73
+ }
74
+ return
75
+ }
76
+ if (dp === "error") {
77
+ if (name === "r") {
78
+ if (site) clearDbSync(site.id)
79
+ setStarted(false)
80
+ }
81
+ return
82
+ }
83
+ if (dp === "done") {
84
+ // m: hand off to the media-fallback overlay for the just-synced site.
85
+ if (name === "m" && site) {
86
+ close()
87
+ setMediaFallbackSite(site)
88
+ }
89
+ return
90
+ }
91
+ })
92
+
93
+ if (!site) return null
94
+ const server = serverById(site.server_id)
95
+ const plan = planResult && planResult.ok ? planResult.plan : null
96
+ // Whether this site already has the production media-fallback plugin (shapes the
97
+ // Done-screen nudge). Only meaningful on the done screen.
98
+ const mfPlan = dp === "done" ? planMediaFallbackFor(site) : null
99
+ const mediaFallbackEnabled = !!(mfPlan && mfPlan.ok && mfPlan.plan.enabled)
100
+
101
+ return (
102
+ <box
103
+ style={{
104
+ position: "absolute",
105
+ top: 0,
106
+ left: 0,
107
+ width: "100%",
108
+ height: "100%",
109
+ flexDirection: "column",
110
+ backgroundColor: theme.bg,
111
+ zIndex: 210,
112
+ }}
113
+ >
114
+ <box
115
+ style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}
116
+ >
117
+ <text content="⇣ Pull production → local " fg={theme.brand} style={{ flexShrink: 0 }} />
118
+ <text content={truncate(site.domain, 38)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
119
+ <box style={{ flexGrow: 1 }} />
120
+ <text content="overwrites local DB" fg={theme.warn} style={{ flexShrink: 0 }} />
121
+ </box>
122
+
123
+ <Centered>{renderBody()}</Centered>
124
+
125
+ <StatusBar hints={hints()} showGlobal={false} />
126
+ </box>
127
+ )
128
+
129
+ // The post-import-hook line(s) on the confirm screen. Three states: a fresh
130
+ // scaffold result (wins, so the user sees their action land), an existing hook
131
+ // (it'll run), or none — in which case we explain the extension point in
132
+ // context and offer `s` to write an inert sample.
133
+ function renderHookSection() {
134
+ if (scaffold?.ok) {
135
+ return (
136
+ <>
137
+ <box style={{ height: 1 }} />
138
+ <box style={{ flexDirection: "row" }}>
139
+ <text content="✓ " fg={theme.good} />
140
+ <text content="wrote bin/sync.d/post-import.sh — edit it, then pull." fg={theme.text} wrapMode="none" />
141
+ </box>
142
+ <text content=" (it's inert until you uncomment a command)" fg={theme.textFaint} wrapMode="none" />
143
+ </>
144
+ )
145
+ }
146
+ if (scaffold && !scaffold.ok) {
147
+ return (
148
+ <>
149
+ <box style={{ height: 1 }} />
150
+ <text content={`✕ ${scaffold.error}`} fg={theme.bad} wrapMode="none" />
151
+ </>
152
+ )
153
+ }
154
+ if (plan?.hookPath) {
155
+ return (
156
+ <>
157
+ <box style={{ height: 1 }} />
158
+ <text content="↪ post-import hook will run (your per-project tweaks)" fg={theme.purple} wrapMode="none" />
159
+ </>
160
+ )
161
+ }
162
+ return (
163
+ <>
164
+ <box style={{ height: 1 }} />
165
+ <text content="No post-import hook set up for this project." fg={theme.textFaint} wrapMode="none" />
166
+ <text content="SpinupTUI can run bin/sync.d/post-import.sh after each pull —" fg={theme.textFaint} wrapMode="none" />
167
+ <text content="e.g. swap Elementor URLs, toggle dev-only plugins." fg={theme.textFaint} wrapMode="none" />
168
+ <text content=" s write a sample hook" fg={theme.textDim} wrapMode="none" />
169
+ </>
170
+ )
171
+ }
172
+
173
+ function renderBody() {
174
+ if (dp === "blocked") {
175
+ return (
176
+ <Panel title=" Can't sync yet " active>
177
+ <box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
178
+ <text content={planResult && !planResult.ok ? planResult.error : "Unavailable."} fg={theme.warn} wrapMode="none" />
179
+ <box style={{ height: 1 }} />
180
+ <text content="Esc to close" fg={theme.textFaint} />
181
+ </box>
182
+ </Panel>
183
+ )
184
+ }
185
+
186
+ if (dp === "confirm") {
187
+ return (
188
+ <Panel title=" Pull production database to local " active>
189
+ <box style={{ flexDirection: "column", width: 68, paddingTop: 1, paddingBottom: 1 }}>
190
+ <box style={{ flexDirection: "row" }}>
191
+ <text content="⚠ " fg={theme.warn} />
192
+ <text content="This OVERWRITES your local database." fg={theme.text} wrapMode="none" />
193
+ </box>
194
+ <text content=" (your local DB is backed up first, just in case)" fg={theme.textFaint} wrapMode="none" />
195
+ <box style={{ height: 1 }} />
196
+ <box style={{ flexDirection: "row" }}>
197
+ <text content="from " fg={theme.textFaint} />
198
+ <text content={truncate(site!.domain, 30)} fg={theme.accent} wrapMode="none" />
199
+ <text content=" on " fg={theme.textDim} />
200
+ <text content={truncate(server?.name ?? "—", 22)} fg={theme.textDim} wrapMode="none" />
201
+ </box>
202
+ <text content="into" fg={theme.textFaint} />
203
+ {plan ? <DestPath path={plan.localRoot} fileColor={theme.good} width={64} /> : null}
204
+ <box style={{ height: 1 }} />
205
+ <text content="rewrite URLs" fg={theme.textFaint} />
206
+ <text content={` ${plan ? middleTruncate(`${plan.remoteOrigin} → ${plan.localOrigin}`, 64) : "—"}`} fg={theme.textDim} wrapMode="none" />
207
+ {renderHookSection()}
208
+ {plan?.prefixWarning ? (
209
+ <>
210
+ <box style={{ height: 1 }} />
211
+ <text content={`⚠ ${plan.prefixWarning}`} fg={theme.warn} wrapMode="none" />
212
+ </>
213
+ ) : null}
214
+ <box style={{ height: 1 }} />
215
+ <text content="Production is only read from — nothing there is modified." fg={theme.textFaint} wrapMode="none" />
216
+ <text content="Press y / ⏎ to pull · Esc to cancel" fg={theme.textFaint} wrapMode="none" />
217
+ </box>
218
+ </Panel>
219
+ )
220
+ }
221
+
222
+ // running | done | error share one bordered checklist so the whole pull
223
+ // reads as a building stack; the footer carries the live/result/error status.
224
+ const steps = SYNC_STEPS.filter((s) => s.stage !== "hook" || !!plan?.hookPath)
225
+ const order = steps.map((s) => s.stage)
226
+ const curIdx = order.indexOf((progress?.stage ?? "local-backup") as DbSyncStage)
227
+ const failedIdx = progress?.failedStage ? order.indexOf(progress.failedStage) : order.length - 1
228
+ const rows: StepRow[] = steps.map(({ label }, i) => {
229
+ const state: StepRow["state"] =
230
+ dp === "done"
231
+ ? "done"
232
+ : dp === "error"
233
+ ? i < failedIdx
234
+ ? "done"
235
+ : i === failedIdx
236
+ ? "failed"
237
+ : "pending"
238
+ : i < curIdx
239
+ ? "done"
240
+ : i === curIdx
241
+ ? "active"
242
+ : "pending"
243
+ return { label, state }
244
+ })
245
+
246
+ return (
247
+ <Panel title=" Pull production → local " active>
248
+ <box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
249
+ <Steps rows={rows} />
250
+ <box style={{ height: 1 }} />
251
+ {dp === "done" ? (
252
+ <>
253
+ <box style={{ flexDirection: "row" }}>
254
+ <text content="✓ " fg={theme.good} />
255
+ <text content="Done — local now mirrors production." fg={theme.text} wrapMode="none" />
256
+ </box>
257
+ <box style={{ height: 1 }} />
258
+ <text content="prod dump saved" fg={theme.textFaint} />
259
+ {progress?.downloadPath ? <DestPath path={progress.downloadPath} fileColor={theme.accent} width={62} /> : null}
260
+ <text content="local DB backup" fg={theme.textFaint} />
261
+ {progress?.localBackupPath ? <DestPath path={progress.localBackupPath} fileColor={theme.textDim} width={62} /> : null}
262
+ <box style={{ height: 1 }} />
263
+ {mediaFallbackEnabled ? (
264
+ <text content="✓ Missing images load from production (media fallback on)." fg={theme.textFaint} wrapMode="none" />
265
+ ) : (
266
+ <text content="Images 404ing locally? Press m to serve them from production." fg={theme.purple} wrapMode="none" />
267
+ )}
268
+ <text content="Esc to close" fg={theme.textFaint} />
269
+ </>
270
+ ) : dp === "error" ? (
271
+ <>
272
+ <text content={`✕ ${progress?.error ?? "Something went wrong."}`} fg={theme.bad} />
273
+ <box style={{ height: 1 }} />
274
+ <text content="Your local DB backup is in sql/ if you need to restore." fg={theme.textFaint} wrapMode="none" />
275
+ <text content="Press r to try again · Esc to close" fg={theme.textFaint} wrapMode="none" />
276
+ </>
277
+ ) : (
278
+ <text content="You can press Esc — it keeps running in the background." fg={theme.textFaint} wrapMode="none" />
279
+ )}
280
+ </box>
281
+ </Panel>
282
+ )
283
+ }
284
+
285
+ function hints() {
286
+ switch (dp) {
287
+ case "confirm":
288
+ return [
289
+ { key: "y/⏎", label: "pull" },
290
+ ...(plan && !plan.hookPath ? [{ key: "s", label: "sample hook" }] : []),
291
+ { key: "esc", label: "cancel" },
292
+ ]
293
+ case "error":
294
+ return [
295
+ { key: "r", label: "retry" },
296
+ { key: "esc", label: "close" },
297
+ ]
298
+ case "done":
299
+ return [
300
+ ...(mediaFallbackEnabled ? [] : [{ key: "m", label: "media fallback" }]),
301
+ { key: "esc", label: "close" },
302
+ ]
303
+ default:
304
+ return [{ key: "esc", label: "close" }]
305
+ }
306
+ }
307
+ }