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.
- package/CHANGELOG.md +943 -0
- package/LICENSE +21 -0
- package/README.md +856 -0
- package/bin/spinuptui.js +13 -0
- package/package.json +55 -0
- package/src/api/client.ts +354 -0
- package/src/api/types.ts +206 -0
- package/src/config.ts +309 -0
- package/src/dev/devMode.ts +7 -0
- package/src/dev/fixtures.ts +238 -0
- package/src/dev/mockClient.ts +214 -0
- package/src/index.tsx +68 -0
- package/src/lib/appUpdate.ts +206 -0
- package/src/lib/awsSigV4.ts +133 -0
- package/src/lib/cloneDomains.ts +112 -0
- package/src/lib/cloneLog.ts +85 -0
- package/src/lib/dbBackup.ts +198 -0
- package/src/lib/dbSync.ts +253 -0
- package/src/lib/discover.ts +165 -0
- package/src/lib/dns.ts +144 -0
- package/src/lib/dnsCache.ts +94 -0
- package/src/lib/dnsQuery.ts +180 -0
- package/src/lib/dnsRecords.ts +411 -0
- package/src/lib/format.ts +106 -0
- package/src/lib/gitDeployKey.ts +0 -0
- package/src/lib/gitStatus.ts +37 -0
- package/src/lib/jobs.ts +32 -0
- package/src/lib/keychain.ts +49 -0
- package/src/lib/local.ts +116 -0
- package/src/lib/mediaFallback.ts +288 -0
- package/src/lib/open.ts +103 -0
- package/src/lib/phpEol.ts +151 -0
- package/src/lib/probe.ts +183 -0
- package/src/lib/providers.ts +284 -0
- package/src/lib/providersCache.ts +105 -0
- package/src/lib/releaseNotes.ts +67 -0
- package/src/lib/serverClone.ts +782 -0
- package/src/lib/serverCreate.ts +161 -0
- package/src/lib/siteDoctor.ts +260 -0
- package/src/lib/siteFingerprint.ts +108 -0
- package/src/lib/spinupweb.ts +33 -0
- package/src/lib/ssh.ts +669 -0
- package/src/lib/stack.ts +78 -0
- package/src/lib/stackCache.ts +113 -0
- package/src/lib/theme.ts +54 -0
- package/src/lib/ubuntuEol.ts +124 -0
- package/src/lib/uptimeKuma.ts +551 -0
- package/src/lib/vanitySite.ts +297 -0
- package/src/lib/wpInventory.ts +153 -0
- package/src/ui/App.tsx +265 -0
- package/src/ui/Details.tsx +403 -0
- package/src/ui/Explain.tsx +158 -0
- package/src/ui/Header.tsx +117 -0
- package/src/ui/Help.tsx +296 -0
- package/src/ui/List.tsx +95 -0
- package/src/ui/Onboarding.tsx +93 -0
- package/src/ui/Splash.tsx +51 -0
- package/src/ui/StatusBar.tsx +60 -0
- package/src/ui/components.tsx +392 -0
- package/src/ui/store.tsx +4253 -0
- package/src/ui/views/Browser.tsx +365 -0
- package/src/ui/views/CloneWizard.tsx +932 -0
- package/src/ui/views/Dashboard.tsx +149 -0
- package/src/ui/views/DbBackup.tsx +228 -0
- package/src/ui/views/DbSync.tsx +307 -0
- package/src/ui/views/Discover.tsx +271 -0
- package/src/ui/views/DnsInventory.tsx +580 -0
- package/src/ui/views/DnsRecords.tsx +562 -0
- package/src/ui/views/Events.tsx +98 -0
- package/src/ui/views/Forgotten.tsx +165 -0
- package/src/ui/views/GrantKey.tsx +504 -0
- package/src/ui/views/Health.tsx +322 -0
- package/src/ui/views/HttpsToggle.tsx +195 -0
- package/src/ui/views/KumaSite.tsx +571 -0
- package/src/ui/views/LocalLink.tsx +220 -0
- package/src/ui/views/MediaFallback.tsx +180 -0
- package/src/ui/views/NewServer.tsx +750 -0
- package/src/ui/views/PhpUpgrade.tsx +301 -0
- package/src/ui/views/ProviderConnect.tsx +527 -0
- package/src/ui/views/PurgeCache.tsx +186 -0
- package/src/ui/views/ReleaseNotes.tsx +84 -0
- package/src/ui/views/Search.tsx +448 -0
- package/src/ui/views/ServerActions.tsx +328 -0
- package/src/ui/views/Stacks.tsx +371 -0
- package/src/ui/views/SudoConnect.tsx +358 -0
- package/src/ui/views/VanityNewSite.tsx +407 -0
- package/src/ui/views/WpInventory.tsx +218 -0
- package/src/version.ts +8 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
// "Connect this server with a vanity site" overlay (backlog item 5). Opened from
|
|
2
|
+
// the Sites-panel empty state on a 0-site server. Orchestrates the multi-step
|
|
3
|
+
// build (DNS A record → propagate → create site → HTTPS → SSH-key handoff → seed
|
|
4
|
+
// index.php) via the store's resumable VanityJob; this view is just the window
|
|
5
|
+
// onto it. Confirm gate up front (real production DNS + site writes).
|
|
6
|
+
|
|
7
|
+
import { useEffect, useState } from "react"
|
|
8
|
+
import { useKeyboard } from "@opentui/react"
|
|
9
|
+
import { theme } from "../../lib/theme.ts"
|
|
10
|
+
import { Panel, Centered, Field, Spinner, Steps, type StepRow } from "../components.tsx"
|
|
11
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
12
|
+
import { useStore, isKeyGrantInFlight, VANITY_PROPAGATE_TIMEOUT_MS, type VanityStep } from "../store.tsx"
|
|
13
|
+
import { openUrl } from "../../lib/open.ts"
|
|
14
|
+
import { siteSftpUrl } from "../../lib/spinupweb.ts"
|
|
15
|
+
import { deriveSiteUser } from "../../lib/vanitySite.ts"
|
|
16
|
+
|
|
17
|
+
// Display order of the steps in the checklist (https omitted when SSL is skipped).
|
|
18
|
+
const STEP_LABELS: { step: VanityStep; label: string }[] = [
|
|
19
|
+
{ step: "dns", label: "Write DNS A record" },
|
|
20
|
+
{ step: "propagate", label: "Wait for DNS to propagate" },
|
|
21
|
+
{ step: "site", label: "Create the site" },
|
|
22
|
+
{ step: "https", label: "Enable HTTPS (Let's Encrypt)" },
|
|
23
|
+
{ step: "sshkey", label: "Add your SSH key" },
|
|
24
|
+
{ step: "seed", label: "Publish the page" },
|
|
25
|
+
{ step: "monitor", label: "Register Uptime Kuma monitors" },
|
|
26
|
+
{ step: "cron", label: "Install the heartbeat cron" },
|
|
27
|
+
]
|
|
28
|
+
const STEP_ORDER: VanityStep[] = STEP_LABELS.map((s) => s.step)
|
|
29
|
+
|
|
30
|
+
// ms → "m:ss" for the propagation timer.
|
|
31
|
+
function fmtClock(ms: number): string {
|
|
32
|
+
const total = Math.round(ms / 1000)
|
|
33
|
+
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function VanityNewSite() {
|
|
37
|
+
const { vanityServer: server, setVanityServer, vanityJob: job, startVanity, vanitySshKeyDone, vanitySkipSsl, vanityKeepWaiting, vanityStopWaiting, vanityRetry, vanitySkipKuma, clearVanity, vanityHealthKeyFor, kumaConfigured, accountSlug, setInputMode, sites, isSudoConnected, keyGrants, startGrantRemembered, preferredGrantKeys, clearGrantKey, sudoConnectServer, setSudoConnectServer } = useStore()
|
|
38
|
+
|
|
39
|
+
const [siteUser, setSiteUser] = useState(() => (server ? deriveSiteUser(server.name) : ""))
|
|
40
|
+
const [editingUser, setEditingUser] = useState(false)
|
|
41
|
+
|
|
42
|
+
// Tick once a second while we're waiting on DNS, so the propagate row's timer
|
|
43
|
+
// (count-down before timeout, count-up once "keep waiting") stays live.
|
|
44
|
+
const [now, setNow] = useState(() => Date.now())
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (job?.step !== "propagate") return
|
|
47
|
+
const id = setInterval(() => setNow(Date.now()), 1000)
|
|
48
|
+
return () => clearInterval(id)
|
|
49
|
+
}, [job?.step])
|
|
50
|
+
const propElapsed = job?.propagateStartedAt ? Math.max(0, now - job.propagateStartedAt) : 0
|
|
51
|
+
const propRemaining = Math.max(0, VANITY_PROPAGATE_TIMEOUT_MS - propElapsed)
|
|
52
|
+
|
|
53
|
+
// Auto-grant context for the SSH-key step: the just-created site, whether sudo is
|
|
54
|
+
// connected on the server, and any in-flight/settled grant for that site.
|
|
55
|
+
const newSite = job?.siteId ? sites.find((s) => s.id === job.siteId) : undefined
|
|
56
|
+
const sudoOn = server ? isSudoConnected(server.id) : false
|
|
57
|
+
const grant = newSite ? keyGrants.get(newSite.id) : undefined
|
|
58
|
+
const granting = !!grant && isKeyGrantInFlight(grant)
|
|
59
|
+
const canAutoGrant = sudoOn && preferredGrantKeys.length > 0 && !!newSite
|
|
60
|
+
|
|
61
|
+
// Once the auto-grant lands the key on the new site, advance to publish — no
|
|
62
|
+
// SpinupWP round-trip needed (the seed step can now SSH in).
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (job?.step === "sshkey" && job.siteId && keyGrants.get(job.siteId)?.status === "done") {
|
|
65
|
+
clearGrantKey(job.siteId)
|
|
66
|
+
vanitySshKeyDone()
|
|
67
|
+
}
|
|
68
|
+
}, [job, keyGrants, vanitySshKeyDone, clearGrantKey])
|
|
69
|
+
|
|
70
|
+
const close = () => {
|
|
71
|
+
setInputMode(false)
|
|
72
|
+
// Forget only a finished build on close. An errored/incomplete one is kept so
|
|
73
|
+
// you can return to it (header badge / press V on its server); discard with x.
|
|
74
|
+
if (job?.step === "done") clearVanity()
|
|
75
|
+
setVanityServer(null)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const discard = () => {
|
|
79
|
+
setInputMode(false)
|
|
80
|
+
clearVanity()
|
|
81
|
+
setVanityServer(null)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
useKeyboard((key) => {
|
|
85
|
+
// While the connect-sudo overlay is layered on top, let it own the keyboard.
|
|
86
|
+
if (sudoConnectServer) return
|
|
87
|
+
// Normalize shift+letter to uppercase so `S` (connect sudo) matches, like the
|
|
88
|
+
// server browser does.
|
|
89
|
+
const raw = key.name ?? ""
|
|
90
|
+
const name = key.shift && raw.length === 1 ? raw.toUpperCase() : raw
|
|
91
|
+
if (editingUser) {
|
|
92
|
+
if (name === "escape" || name === "return") {
|
|
93
|
+
setEditingUser(false)
|
|
94
|
+
setInputMode(false)
|
|
95
|
+
}
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// No job yet → the confirm screen.
|
|
100
|
+
if (!job) {
|
|
101
|
+
// S — connect sudo on this server now (so the key auto-grants later). Opens
|
|
102
|
+
// the Connect-sudo overlay layered on top; only offered when not connected.
|
|
103
|
+
if (name === "S" && server && !sudoOn) return setSudoConnectServer(server)
|
|
104
|
+
if (name === "e") {
|
|
105
|
+
setEditingUser(true)
|
|
106
|
+
setInputMode(true)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
if (name === "y") {
|
|
110
|
+
if (server && siteUser.trim()) startVanity(server, { siteUser: siteUser.trim() })
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
if (name === "escape" || name === "q") return close()
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Job in flight / settled.
|
|
118
|
+
if (job.step === "propagate" && job.propagateTimedOut) {
|
|
119
|
+
if (name === "s") return vanitySkipSsl()
|
|
120
|
+
if (name === "w") return vanityKeepWaiting()
|
|
121
|
+
}
|
|
122
|
+
// Keep-waiting (count-up) mode: continue the normal flow now, or skip SSL.
|
|
123
|
+
if (job.step === "propagate" && job.keepWaiting && !job.propagateTimedOut) {
|
|
124
|
+
if (name === "c") return vanityStopWaiting()
|
|
125
|
+
if (name === "s") return vanitySkipSsl()
|
|
126
|
+
}
|
|
127
|
+
if (job.step === "sshkey") {
|
|
128
|
+
// S — connect sudo here if it isn't yet (then `g` becomes available).
|
|
129
|
+
if (name === "S" && server && !sudoOn) return setSudoConnectServer(server)
|
|
130
|
+
// g — grant the saved keys via the connected sudo session (then auto-publish).
|
|
131
|
+
// Available once sudo is connected and you have a saved key choice; retries on error.
|
|
132
|
+
if (name === "g" && canAutoGrant && newSite && (!grant || grant.status === "error")) {
|
|
133
|
+
return startGrantRemembered([newSite])
|
|
134
|
+
}
|
|
135
|
+
if (name === "o") {
|
|
136
|
+
if (accountSlug && job.siteId) openUrl(siteSftpUrl(job.siteId, accountSlug))
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
if (name === "return") return vanitySshKeyDone()
|
|
140
|
+
}
|
|
141
|
+
if (job.step === "error") {
|
|
142
|
+
if (name === "r") return vanityRetry()
|
|
143
|
+
// Monitoring failures are skippable — the site itself is already live.
|
|
144
|
+
if (name === "s" && (job.failedStep === "monitor" || job.failedStep === "cron")) return vanitySkipKuma()
|
|
145
|
+
if (name === "x") return discard()
|
|
146
|
+
}
|
|
147
|
+
if (job.step === "done" && name === "o") {
|
|
148
|
+
openUrl((job.sslSkipped ? "http://" : "https://") + job.hostname)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
if (name === "escape" || name === "q") return close()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
if (!server) return null
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<box
|
|
158
|
+
style={{
|
|
159
|
+
position: "absolute",
|
|
160
|
+
top: 0,
|
|
161
|
+
left: 0,
|
|
162
|
+
width: "100%",
|
|
163
|
+
height: "100%",
|
|
164
|
+
flexDirection: "column",
|
|
165
|
+
backgroundColor: theme.bg,
|
|
166
|
+
zIndex: 215,
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
<box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
|
|
170
|
+
<text content="✦ Connect server " fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
171
|
+
<text content={server.name} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
172
|
+
</box>
|
|
173
|
+
|
|
174
|
+
<Centered>{renderBody()}</Centered>
|
|
175
|
+
|
|
176
|
+
<StatusBar hints={hints()} showGlobal={false} />
|
|
177
|
+
</box>
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
function stepRows(): StepRow[] {
|
|
181
|
+
const cur = job?.step
|
|
182
|
+
const failed = job?.step === "error" ? job.failedStep : undefined
|
|
183
|
+
return STEP_LABELS.filter((s) => !(s.step === "https" && job?.sslSkipped))
|
|
184
|
+
.filter((s) => !((s.step === "monitor" || s.step === "cron") && !kumaConfigured))
|
|
185
|
+
.map(({ step, label }) => {
|
|
186
|
+
let state: StepRow["state"] = "pending"
|
|
187
|
+
if (failed === step) state = "failed"
|
|
188
|
+
else if (cur === "done") state = "done"
|
|
189
|
+
else if (cur && step === cur) {
|
|
190
|
+
// The SSH-key step waits on the user (unless a grant is actively running),
|
|
191
|
+
// so mark it "waiting" rather than spinning like the automated steps.
|
|
192
|
+
state = step === "sshkey" && !granting ? "waiting" : "active"
|
|
193
|
+
} else if (cur && STEP_ORDER.indexOf(step) < STEP_ORDER.indexOf(cur)) state = "done"
|
|
194
|
+
// Live timer on the propagate row: count-down to the timeout, then (once the
|
|
195
|
+
// user keeps waiting) count up from that same baseline.
|
|
196
|
+
let detail: string | undefined
|
|
197
|
+
if (step === "propagate" && cur === "propagate" && !job?.propagateTimedOut) {
|
|
198
|
+
detail = job?.keepWaiting ? `${fmtClock(propElapsed)} elapsed` : `${fmtClock(propRemaining)} left`
|
|
199
|
+
}
|
|
200
|
+
return { label, state, detail }
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function renderBody() {
|
|
205
|
+
// Confirm gate (no job yet).
|
|
206
|
+
if (!job) {
|
|
207
|
+
return (
|
|
208
|
+
<Panel title=" Connect this server with a vanity site " active>
|
|
209
|
+
<box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
|
|
210
|
+
<text content="A tiny placeholder site at the server's own hostname — so there's" fg={theme.textDim} />
|
|
211
|
+
<text content="a site user to hold your SSH key and SpinupTUI can work with it." fg={theme.textDim} />
|
|
212
|
+
<box style={{ height: 1 }} />
|
|
213
|
+
<Field label="Domain" value={server!.name} />
|
|
214
|
+
<Field label="Points to" value={server!.ip_address ?? "—"} />
|
|
215
|
+
<box style={{ flexDirection: "row" }}>
|
|
216
|
+
<text content="Site user " fg={theme.textDim} />
|
|
217
|
+
{editingUser ? (
|
|
218
|
+
<input
|
|
219
|
+
focused
|
|
220
|
+
value={siteUser}
|
|
221
|
+
onInput={setSiteUser}
|
|
222
|
+
onSubmit={() => {
|
|
223
|
+
setEditingUser(false)
|
|
224
|
+
setInputMode(false)
|
|
225
|
+
}}
|
|
226
|
+
style={{ backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }}
|
|
227
|
+
/>
|
|
228
|
+
) : (
|
|
229
|
+
<text content={siteUser || "—"} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
230
|
+
)}
|
|
231
|
+
</box>
|
|
232
|
+
<box style={{ height: 1 }} />
|
|
233
|
+
<text content="We'll write an A record (Route 53), create the site, enable HTTPS," fg={theme.textFaint} wrapMode="none" />
|
|
234
|
+
<text content="add your SSH key, then publish the page." fg={theme.textFaint} wrapMode="none" />
|
|
235
|
+
{!sudoOn && (
|
|
236
|
+
<>
|
|
237
|
+
<box style={{ height: 1 }} />
|
|
238
|
+
<text content="○ Sudo not connected — press S to connect now" fg={theme.warn} wrapMode="none" />
|
|
239
|
+
<text content=" so Spinup can add your SSH key for you (no SpinupWP step)." fg={theme.textFaint} wrapMode="none" />
|
|
240
|
+
</>
|
|
241
|
+
)}
|
|
242
|
+
<box style={{ height: 1 }} />
|
|
243
|
+
<text content={editingUser ? "⏎ done editing" : sudoOn ? "e edit user · y create · q cancel" : "S connect sudo · e edit user · y create · q cancel"} fg={theme.textFaint} wrapMode="none" />
|
|
244
|
+
</box>
|
|
245
|
+
</Panel>
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Done.
|
|
250
|
+
if (job.step === "done") {
|
|
251
|
+
const healthKey = vanityHealthKeyFor(job.hostname)
|
|
252
|
+
return (
|
|
253
|
+
<Panel title=" Server connected " active>
|
|
254
|
+
<box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
|
|
255
|
+
<box style={{ flexDirection: "row" }}>
|
|
256
|
+
<text content="✓ " fg={theme.good} style={{ flexShrink: 0 }} />
|
|
257
|
+
<text content={job.hostname} fg={theme.accent} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
258
|
+
</box>
|
|
259
|
+
<text content="The vanity site is live and the server now has a site." fg={theme.textDim} wrapMode="none" />
|
|
260
|
+
{healthKey && (
|
|
261
|
+
<>
|
|
262
|
+
<box style={{ height: 1 }} />
|
|
263
|
+
<text content="The page doubles as a health endpoint for any uptime tool:" fg={theme.textDim} wrapMode="none" />
|
|
264
|
+
<text content=" /?healthz — up/down (503 on high load or low disk)" fg={theme.textFaint} wrapMode="none" />
|
|
265
|
+
<text content={` /?format=json&key=${healthKey}`} fg={theme.textFaint} wrapMode="none" />
|
|
266
|
+
</>
|
|
267
|
+
)}
|
|
268
|
+
<box style={{ height: 1 }} />
|
|
269
|
+
<text content="o open in browser · Esc to close" fg={theme.textFaint} wrapMode="none" />
|
|
270
|
+
</box>
|
|
271
|
+
</Panel>
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Error.
|
|
276
|
+
if (job.step === "error") {
|
|
277
|
+
return (
|
|
278
|
+
<Panel title=" Vanity build failed " active>
|
|
279
|
+
<box style={{ flexDirection: "column", width: 68, paddingTop: 1, paddingBottom: 1 }}>
|
|
280
|
+
<Steps rows={stepRows()} />
|
|
281
|
+
<box style={{ height: 1 }} />
|
|
282
|
+
<text content={`✕ ${job.error ?? "Something went wrong."}`} fg={theme.bad} wrapMode="none" />
|
|
283
|
+
<box style={{ height: 1 }} />
|
|
284
|
+
<text
|
|
285
|
+
content={job.failedStep === "monitor" || job.failedStep === "cron" ? "r retry · s skip monitoring (site is live) · x discard · Esc later" : "r retry this step · x discard · Esc keep for later"}
|
|
286
|
+
fg={theme.textFaint}
|
|
287
|
+
wrapMode="none"
|
|
288
|
+
/>
|
|
289
|
+
</box>
|
|
290
|
+
</Panel>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// SSH-key handoff. This step WAITS on you (it won't auto-fire), so the primary
|
|
295
|
+
// action is a bright ❯ call-to-action; secondary options stay faint below.
|
|
296
|
+
if (job.step === "sshkey") {
|
|
297
|
+
const grantFailed = grant?.status === "error"
|
|
298
|
+
return (
|
|
299
|
+
<Panel title=" Add your SSH key — your turn " active>
|
|
300
|
+
<box style={{ flexDirection: "column", width: 68, paddingTop: 1, paddingBottom: 1 }}>
|
|
301
|
+
<Steps rows={stepRows()} />
|
|
302
|
+
<box style={{ height: 1 }} />
|
|
303
|
+
<text content="The site exists, but SpinupTUI can't SSH in until your key is on the" fg={theme.textDim} wrapMode="none" />
|
|
304
|
+
<text content="site user." fg={theme.textDim} wrapMode="none" />
|
|
305
|
+
<box style={{ height: 1 }} />
|
|
306
|
+
{granting ? (
|
|
307
|
+
<box style={{ flexDirection: "row" }}>
|
|
308
|
+
<Spinner />
|
|
309
|
+
<text content=" Granting your saved key(s) via sudo…" fg={theme.textDim} wrapMode="none" />
|
|
310
|
+
</box>
|
|
311
|
+
) : grantFailed ? (
|
|
312
|
+
<>
|
|
313
|
+
<text content={`✕ ${grant?.error ?? "Grant failed."}`} fg={theme.bad} wrapMode="none" />
|
|
314
|
+
<box style={{ height: 1 }} />
|
|
315
|
+
<text content="❯ Press g to try the grant again" fg={theme.brand} wrapMode="none" />
|
|
316
|
+
<box style={{ height: 1 }} />
|
|
317
|
+
<text content="or o SpinupWP · ⏎ I added it manually · Esc later" fg={theme.textFaint} wrapMode="none" />
|
|
318
|
+
</>
|
|
319
|
+
) : canAutoGrant ? (
|
|
320
|
+
<>
|
|
321
|
+
<box style={{ flexDirection: "row" }}>
|
|
322
|
+
<text content="● sudo connected — " fg={theme.good} wrapMode="none" />
|
|
323
|
+
<text content="ready to add your saved key(s)." fg={theme.textDim} wrapMode="none" />
|
|
324
|
+
</box>
|
|
325
|
+
<box style={{ height: 1 }} />
|
|
326
|
+
<text content="❯ Press g to grant your key(s) & publish" fg={theme.brand} wrapMode="none" />
|
|
327
|
+
<box style={{ height: 1 }} />
|
|
328
|
+
<text content="or o SpinupWP · ⏎ I added it manually · Esc later" fg={theme.textFaint} wrapMode="none" />
|
|
329
|
+
</>
|
|
330
|
+
) : (
|
|
331
|
+
<>
|
|
332
|
+
<text content="Connect sudo and SpinupTUI adds your key for you — or add it in" fg={theme.textDim} wrapMode="none" />
|
|
333
|
+
<text content="SpinupWP (the site's SFTP & SSH → Site User), then come back." fg={theme.textFaint} wrapMode="none" />
|
|
334
|
+
<box style={{ height: 1 }} />
|
|
335
|
+
<text content="❯ Press S to connect sudo" fg={theme.brand} wrapMode="none" />
|
|
336
|
+
<box style={{ height: 1 }} />
|
|
337
|
+
<text content={accountSlug && job.siteId ? "or o SpinupWP · ⏎ I've added it · Esc later" : "or ⏎ I've added it · Esc later"} fg={theme.textFaint} wrapMode="none" />
|
|
338
|
+
</>
|
|
339
|
+
)}
|
|
340
|
+
</box>
|
|
341
|
+
</Panel>
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Propagation timed out → skip/keep-waiting prompt.
|
|
346
|
+
if (job.step === "propagate" && job.propagateTimedOut) {
|
|
347
|
+
return (
|
|
348
|
+
<Panel title=" DNS is taking a while " active>
|
|
349
|
+
<box style={{ flexDirection: "column", width: 68, paddingTop: 1, paddingBottom: 1 }}>
|
|
350
|
+
<Steps rows={stepRows()} />
|
|
351
|
+
<box style={{ height: 1 }} />
|
|
352
|
+
<text content={`${job.hostname} hasn't resolved to ${job.serverIp} yet.`} fg={theme.textDim} wrapMode="none" />
|
|
353
|
+
<text content="HTTPS needs it to resolve first. You can skip SSL for now (the" fg={theme.textFaint} wrapMode="none" />
|
|
354
|
+
<text content="site stays HTTP; enable SSL later) or keep waiting." fg={theme.textFaint} wrapMode="none" />
|
|
355
|
+
<box style={{ height: 1 }} />
|
|
356
|
+
<text content="s skip SSL for now · w keep waiting · Esc later" fg={theme.textFaint} wrapMode="none" />
|
|
357
|
+
</box>
|
|
358
|
+
</Panel>
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// In-flight checklist (dns / propagate / site / https / seed).
|
|
363
|
+
const keepWaiting = job.step === "propagate" && job.keepWaiting
|
|
364
|
+
return (
|
|
365
|
+
<Panel title=" Connecting the server " active>
|
|
366
|
+
<box style={{ flexDirection: "column", width: 60, paddingTop: 1, paddingBottom: 1 }}>
|
|
367
|
+
<Steps rows={stepRows()} />
|
|
368
|
+
<box style={{ height: 1 }} />
|
|
369
|
+
{keepWaiting ? (
|
|
370
|
+
<>
|
|
371
|
+
<text content={`Still waiting for ${job.hostname} to resolve (${fmtClock(propElapsed)}).`} fg={theme.textDim} wrapMode="none" />
|
|
372
|
+
<text content="c continue now · s skip SSL · Esc background" fg={theme.textFaint} wrapMode="none" />
|
|
373
|
+
</>
|
|
374
|
+
) : (
|
|
375
|
+
<text content="Esc — this keeps running in the background." fg={theme.textFaint} wrapMode="none" />
|
|
376
|
+
)}
|
|
377
|
+
</box>
|
|
378
|
+
</Panel>
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function hints() {
|
|
383
|
+
if (!job) {
|
|
384
|
+
if (editingUser) return [{ key: "⏎", label: "done" }]
|
|
385
|
+
return [...(sudoOn ? [] : [{ key: "S", label: "connect sudo" }]), { key: "e", label: "edit user" }, { key: "y", label: "create" }, { key: "q", label: "cancel" }]
|
|
386
|
+
}
|
|
387
|
+
if (job.step === "done") return [{ key: "o", label: "open site" }, { key: "esc", label: "close" }]
|
|
388
|
+
if (job.step === "error") {
|
|
389
|
+
const skippable = job.failedStep === "monitor" || job.failedStep === "cron"
|
|
390
|
+
return [{ key: "r", label: "retry" }, ...(skippable ? [{ key: "s", label: "skip monitoring" }] : []), { key: "x", label: "discard" }, { key: "esc", label: "later" }]
|
|
391
|
+
}
|
|
392
|
+
if (job.step === "sshkey") {
|
|
393
|
+
const granting = grant && isKeyGrantInFlight(grant)
|
|
394
|
+
if (granting) return [{ key: "esc", label: "background" }]
|
|
395
|
+
const grantHint = canAutoGrant || grant?.status === "error" ? [{ key: "g", label: "grant my key(s)" }] : []
|
|
396
|
+
const connectHint = !sudoOn ? [{ key: "S", label: "connect sudo" }] : []
|
|
397
|
+
return [...grantHint, ...connectHint, ...(accountSlug && job.siteId ? [{ key: "o", label: "SpinupWP" }] : []), { key: "⏎", label: "added it" }, { key: "esc", label: "later" }]
|
|
398
|
+
}
|
|
399
|
+
if (job.step === "propagate" && job.propagateTimedOut) {
|
|
400
|
+
return [{ key: "s", label: "skip SSL" }, { key: "w", label: "wait" }, { key: "esc", label: "later" }]
|
|
401
|
+
}
|
|
402
|
+
if (job.step === "propagate" && job.keepWaiting) {
|
|
403
|
+
return [{ key: "c", label: "continue now" }, { key: "s", label: "skip SSL" }, { key: "esc", label: "background" }]
|
|
404
|
+
}
|
|
405
|
+
return [{ key: "esc", label: "background" }]
|
|
406
|
+
}
|
|
407
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// A site's installed plugins & themes (full-screen overlay).
|
|
2
|
+
//
|
|
3
|
+
// Opened from the Browser with `p`. Runs `wp plugin list` / `wp theme list` over
|
|
4
|
+
// SSH (read-only) and renders them as one combined, scrollable list with PLUGINS
|
|
5
|
+
// and THEMES section headers — the `wp plugin list` detail (current version +
|
|
6
|
+
// update-available) the SpinupWP API never exposes. Esc/q/p closes it.
|
|
7
|
+
|
|
8
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
9
|
+
import { useKeyboard, useTerminalDimensions } from "@opentui/react"
|
|
10
|
+
import { theme } from "../../lib/theme.ts"
|
|
11
|
+
import { truncate } from "../../lib/format.ts"
|
|
12
|
+
import { Spinner, Centered } from "../components.tsx"
|
|
13
|
+
import { List, moveSelection } from "../List.tsx"
|
|
14
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
15
|
+
import { useStore } from "../store.tsx"
|
|
16
|
+
import { fetchWpInventory, updateCount, type WpInventory as Inventory, type WpItem } from "../../lib/wpInventory.ts"
|
|
17
|
+
|
|
18
|
+
// A flattened row: a section header, or one plugin/theme item.
|
|
19
|
+
type Row =
|
|
20
|
+
| { kind: "header"; label: string; count: number; updates: number }
|
|
21
|
+
| { kind: "item"; item: WpItem }
|
|
22
|
+
|
|
23
|
+
function buildRows(inv: Inventory): Row[] {
|
|
24
|
+
const rows: Row[] = []
|
|
25
|
+
rows.push({ kind: "header", label: "PLUGINS", count: inv.plugins.length, updates: updateCount(inv.plugins) })
|
|
26
|
+
for (const item of inv.plugins) rows.push({ kind: "item", item })
|
|
27
|
+
rows.push({ kind: "header", label: "THEMES", count: inv.themes.length, updates: updateCount(inv.themes) })
|
|
28
|
+
for (const item of inv.themes) rows.push({ kind: "item", item })
|
|
29
|
+
return rows
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Active items get a filled dot; inactive an open one; must-use/dropin a diamond.
|
|
33
|
+
function statusGlyph(status: string): { glyph: string; color: string } {
|
|
34
|
+
if (status === "inactive") return { glyph: "○", color: theme.textFaint }
|
|
35
|
+
if (status === "must-use" || status === "dropin") return { glyph: "◆", color: theme.textDim }
|
|
36
|
+
return { glyph: "●", color: theme.good } // active / active-network / parent
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ItemRow({ item, selected }: { item: WpItem; selected: boolean }) {
|
|
40
|
+
const s = statusGlyph(item.status)
|
|
41
|
+
const hasUpdate = item.update === "available"
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
<text content=" " style={{ flexShrink: 0 }} />
|
|
45
|
+
<text content={`${s.glyph} `} fg={selected ? theme.text : s.color} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
46
|
+
<text
|
|
47
|
+
content={truncate(item.name, 34)}
|
|
48
|
+
fg={selected ? theme.text : theme.text}
|
|
49
|
+
wrapMode="none"
|
|
50
|
+
style={{ flexGrow: 1, flexShrink: 1 }}
|
|
51
|
+
/>
|
|
52
|
+
<text content={(item.status || "—").padEnd(10)} fg={selected ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
53
|
+
<text content={(item.version || "—").padEnd(12)} fg={selected ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
54
|
+
{hasUpdate ? (
|
|
55
|
+
<text content={`→ ${item.updateVersion ?? "update"}`} fg={selected ? theme.text : theme.update} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
56
|
+
) : (
|
|
57
|
+
<text content="✓ current" fg={selected ? theme.text : theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
58
|
+
)}
|
|
59
|
+
<text content=" " style={{ flexShrink: 0 }} />
|
|
60
|
+
</>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function HeaderRow({ row }: { row: Extract<Row, { kind: "header" }> }) {
|
|
65
|
+
return (
|
|
66
|
+
<>
|
|
67
|
+
<text content={` ${row.label} `} fg={theme.brand} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
68
|
+
<text content={`(${row.count})`} fg={theme.textDim} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
|
|
69
|
+
{row.updates > 0 ? (
|
|
70
|
+
<text content={`${row.updates} update${row.updates === 1 ? "" : "s"} `} fg={theme.update} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
71
|
+
) : (
|
|
72
|
+
<text content="up to date " fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
73
|
+
)}
|
|
74
|
+
</>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function WpInventory() {
|
|
79
|
+
const { wpInventorySite, setWpInventorySite, serverById, sshUser } = useStore()
|
|
80
|
+
const { height } = useTerminalDimensions()
|
|
81
|
+
const site = wpInventorySite
|
|
82
|
+
const server = useMemo(() => serverById(site?.server_id), [serverById, site?.server_id])
|
|
83
|
+
|
|
84
|
+
const [inv, setInv] = useState<Inventory | null>(null)
|
|
85
|
+
const [error, setError] = useState<string | null>(null)
|
|
86
|
+
const [target, setTarget] = useState("")
|
|
87
|
+
const [loading, setLoading] = useState(true)
|
|
88
|
+
const [selected, setSelected] = useState(0)
|
|
89
|
+
const busy = useRef(false)
|
|
90
|
+
|
|
91
|
+
const load = useCallback(async () => {
|
|
92
|
+
if (!site || !server || busy.current) return
|
|
93
|
+
busy.current = true
|
|
94
|
+
setLoading(true)
|
|
95
|
+
const res = await fetchWpInventory(server, site, sshUser)
|
|
96
|
+
busy.current = false
|
|
97
|
+
setTarget(res.target)
|
|
98
|
+
setLoading(false)
|
|
99
|
+
if (res.ok) {
|
|
100
|
+
setInv(res.inventory)
|
|
101
|
+
setError(null)
|
|
102
|
+
} else {
|
|
103
|
+
setError(res.error)
|
|
104
|
+
setInv(null)
|
|
105
|
+
}
|
|
106
|
+
}, [site, server, sshUser])
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
setInv(null)
|
|
110
|
+
setError(null)
|
|
111
|
+
setSelected(0)
|
|
112
|
+
void load()
|
|
113
|
+
}, [load])
|
|
114
|
+
|
|
115
|
+
const rows = useMemo(() => (inv ? buildRows(inv) : []), [inv])
|
|
116
|
+
|
|
117
|
+
useKeyboard((key) => {
|
|
118
|
+
if (key.name === "escape" || key.name === "q" || key.name === "p") return setWpInventorySite(null)
|
|
119
|
+
if (key.name === "r") return void load()
|
|
120
|
+
if (rows.length === 0) return
|
|
121
|
+
if (key.name === "up" || key.name === "k") setSelected((i) => moveSelection(i, -1, rows.length))
|
|
122
|
+
else if (key.name === "down" || key.name === "j") setSelected((i) => moveSelection(i, 1, rows.length))
|
|
123
|
+
else if (key.name === "g") setSelected(0)
|
|
124
|
+
else if (key.name === "G") setSelected(rows.length - 1)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
if (!site) return null
|
|
128
|
+
|
|
129
|
+
const totalUpdates = inv ? updateCount(inv.plugins) + updateCount(inv.themes) : 0
|
|
130
|
+
const listRows = Math.max(3, height - 4)
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<box
|
|
134
|
+
style={{
|
|
135
|
+
position: "absolute",
|
|
136
|
+
top: 0,
|
|
137
|
+
left: 0,
|
|
138
|
+
width: "100%",
|
|
139
|
+
height: "100%",
|
|
140
|
+
flexDirection: "column",
|
|
141
|
+
backgroundColor: theme.bg,
|
|
142
|
+
zIndex: 200,
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
{/* Title bar */}
|
|
146
|
+
<box
|
|
147
|
+
style={{
|
|
148
|
+
flexDirection: "row",
|
|
149
|
+
height: 1,
|
|
150
|
+
backgroundColor: theme.bgAlt,
|
|
151
|
+
paddingLeft: 1,
|
|
152
|
+
paddingRight: 1,
|
|
153
|
+
alignItems: "center",
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
<text content="⧉ Plugins & Themes " fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
157
|
+
<text content={truncate(site.domain, 40)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
158
|
+
{inv && totalUpdates > 0 ? (
|
|
159
|
+
<text content={` ↑${totalUpdates} update${totalUpdates === 1 ? "" : "s"}`} fg={theme.update} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
160
|
+
) : null}
|
|
161
|
+
<box style={{ flexGrow: 1 }} />
|
|
162
|
+
{loading && <Spinner interval={120} />}
|
|
163
|
+
<text content={target ? ` ${target}` : " connecting…"} fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
164
|
+
</box>
|
|
165
|
+
|
|
166
|
+
{loading && !inv && !error ? (
|
|
167
|
+
<Centered>
|
|
168
|
+
<box style={{ flexDirection: "row" }}>
|
|
169
|
+
<Spinner />
|
|
170
|
+
<text content={` Reading plugins & themes over SSH…`} fg={theme.textDim} />
|
|
171
|
+
</box>
|
|
172
|
+
</Centered>
|
|
173
|
+
) : error ? (
|
|
174
|
+
<Centered>
|
|
175
|
+
<box
|
|
176
|
+
title=" Couldn't read this site "
|
|
177
|
+
titleColor={theme.bad}
|
|
178
|
+
border
|
|
179
|
+
borderColor={theme.bad}
|
|
180
|
+
style={{ flexDirection: "column", width: 72, paddingLeft: 2, paddingRight: 2, paddingTop: 1, paddingBottom: 1 }}
|
|
181
|
+
>
|
|
182
|
+
<text content={`✕ ${error}`} fg={theme.bad} wrapMode="none" />
|
|
183
|
+
<box style={{ height: 1 }} />
|
|
184
|
+
<text content={`Tried: ssh ${target}`} fg={theme.textDim} wrapMode="none" />
|
|
185
|
+
<text content="Reads via your local SSH keys as the site user (no password prompts)." fg={theme.textFaint} wrapMode="none" />
|
|
186
|
+
<text content={`Verify first: ssh ${target} 'wp --path=/sites/${site.domain}/files plugin list'`} fg={theme.textFaint} wrapMode="none" />
|
|
187
|
+
<box style={{ height: 1 }} />
|
|
188
|
+
<text content="Press r to retry · Esc to close" fg={theme.textDim} />
|
|
189
|
+
</box>
|
|
190
|
+
</Centered>
|
|
191
|
+
) : inv ? (
|
|
192
|
+
<box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}>
|
|
193
|
+
<List
|
|
194
|
+
items={rows}
|
|
195
|
+
selectedIndex={selected}
|
|
196
|
+
viewportRows={listRows}
|
|
197
|
+
focused
|
|
198
|
+
keyFor={(_r, i) => i}
|
|
199
|
+
emptyText="No plugins or themes found."
|
|
200
|
+
renderRow={(row, isSel) =>
|
|
201
|
+
row.kind === "header" ? <HeaderRow row={row} /> : <ItemRow item={row.item} selected={isSel} />
|
|
202
|
+
}
|
|
203
|
+
/>
|
|
204
|
+
</box>
|
|
205
|
+
) : null}
|
|
206
|
+
|
|
207
|
+
<StatusBar
|
|
208
|
+
hints={[
|
|
209
|
+
{ key: "↑↓/jk", label: "scroll" },
|
|
210
|
+
{ key: "r", label: "refresh" },
|
|
211
|
+
{ key: "esc/q", label: "close" },
|
|
212
|
+
]}
|
|
213
|
+
message={inv ? `${inv.plugins.length} plugins · ${inv.themes.length} themes` : undefined}
|
|
214
|
+
showGlobal={false}
|
|
215
|
+
/>
|
|
216
|
+
</box>
|
|
217
|
+
)
|
|
218
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The app's own identity. "SpinupTUI" is the app (a control center for your
|
|
2
|
+
// SpinupWP account); keep that distinct from "SpinupWP", the service it talks to.
|
|
3
|
+
import pkg from "../package.json" with { type: "json" }
|
|
4
|
+
|
|
5
|
+
export const APP_NAME = "SpinupTUI"
|
|
6
|
+
export const APP_VERSION = pkg.version
|
|
7
|
+
export const REPO_SLUG = "mwender/spinupwp-tui"
|
|
8
|
+
export const REPO_URL = `https://github.com/${REPO_SLUG}`
|