spinuptui 0.27.0 → 1.0.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.
@@ -0,0 +1,370 @@
1
+ // Accounts overlay (`A`, from anywhere): the SpinupWP accounts this machine
2
+ // knows, which one is active, and switching between them — deliberately one
3
+ // account at a time, never a merged fleet, so an action can't land on the wrong
4
+ // client's server. Also adds (token validated live, like onboarding), renames,
5
+ // and removes accounts. See config.ts "Accounts" for storage and ui/accounts.tsx
6
+ // for how a switch remounts the app.
7
+
8
+ import { useEffect, useState } from "react"
9
+ import { useKeyboard } from "@opentui/react"
10
+ import { rm } from "node:fs/promises"
11
+ import { join } from "node:path"
12
+ import { theme } from "../../lib/theme.ts"
13
+ import { truncate } from "../../lib/format.ts"
14
+ import { Panel, Centered, Spinner, SecretInput } from "../components.tsx"
15
+ import { StatusBar, type KeyHint } from "../StatusBar.tsx"
16
+ import { useStore } from "../store.tsx"
17
+ import { useAccounts } from "../accounts.tsx"
18
+ import { SpinupWPClient } from "../../api/client.ts"
19
+ import { deleteSudoPassword } from "../../lib/keychain.ts"
20
+ import {
21
+ DEFAULT_BASE_URL,
22
+ DEFAULT_PROFILE,
23
+ PROFILE_CACHE_FILES,
24
+ addProfile,
25
+ listProfiles,
26
+ profileDataDir,
27
+ removeProfile,
28
+ renameProfile,
29
+ type ProfileSummary,
30
+ } from "../../config.ts"
31
+
32
+ type Phase =
33
+ | { kind: "list" }
34
+ | { kind: "add-label" }
35
+ | { kind: "add-token" }
36
+ | { kind: "add-slug" }
37
+ | { kind: "validating" }
38
+ | { kind: "added"; id: string; label: string }
39
+ | { kind: "rename"; id: string }
40
+ | { kind: "confirm-remove"; profile: ProfileSummary }
41
+ | { kind: "switching"; label: string }
42
+
43
+ // A SpinupWP account slug is the team name, lowercased and dashed — a good first
44
+ // guess the user can correct ("Acme Agency" → acme-agency).
45
+ const slugGuess = (label: string) =>
46
+ label
47
+ .toLowerCase()
48
+ .replace(/[^a-z0-9]+/g, "-")
49
+ .replace(/^-+|-+$/g, "")
50
+
51
+ const INPUT_STYLE = { backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }
52
+
53
+ export function Accounts({ onClose }: { onClose: () => void }) {
54
+ const { profileId, tokenFromEnv, switchBlocker, setInputMode, setProfileLabel } = useStore()
55
+ const { switchAccount } = useAccounts()
56
+ const [profiles, setProfiles] = useState<ProfileSummary[]>(() => listProfiles())
57
+ const [index, setIndex] = useState(() => Math.max(0, profiles.findIndex((p) => p.id === profileId)))
58
+ const [phase, setPhase] = useState<Phase>({ kind: "list" })
59
+ const [message, setMessage] = useState<{ text: string; color: string } | null>(null)
60
+ const [label, setLabel] = useState("")
61
+ const [token, setToken] = useState("")
62
+ const [slug, setSlug] = useState("")
63
+
64
+ // The last row is "+ Add an account".
65
+ const rowCount = profiles.length + 1
66
+ const selected = index < profiles.length ? profiles[index] : null
67
+ const typing = phase.kind === "add-label" || phase.kind === "add-token" || phase.kind === "add-slug" || phase.kind === "rename"
68
+
69
+ // Text fields own the keyboard (global shortcuts off) while they're focused.
70
+ useEffect(() => {
71
+ setInputMode(typing)
72
+ return () => setInputMode(false)
73
+ }, [typing, setInputMode])
74
+
75
+ const reload = () => setProfiles(listProfiles())
76
+ const say = (text: string, color: string = theme.textDim) => setMessage({ text, color })
77
+
78
+ async function doSwitch(p: ProfileSummary) {
79
+ if (p.active) return say(`Already on ${p.label}.`)
80
+ if (tokenFromEnv) return say("SPINUPWP_ACCESS_TOKEN is set, which pins the account — unset it to switch.", theme.warn)
81
+ const blocker = switchBlocker()
82
+ if (blocker) return say(blocker, theme.warn)
83
+ setPhase({ kind: "switching", label: p.label })
84
+ // Remounts the whole app on the new account — this overlay goes with it.
85
+ await switchAccount(p.id)
86
+ }
87
+
88
+ async function submitToken() {
89
+ const t = token.trim()
90
+ if (!t) return
91
+ setPhase({ kind: "validating" })
92
+ const result = await new SpinupWPClient({ token: t, baseUrl: DEFAULT_BASE_URL }).validateToken()
93
+ if (!result.ok) {
94
+ say(result.reason, theme.bad)
95
+ setPhase({ kind: "add-token" })
96
+ return
97
+ }
98
+ setMessage(null)
99
+ setSlug(slugGuess(label))
100
+ setPhase({ kind: "add-slug" })
101
+ }
102
+
103
+ async function finishAdd() {
104
+ const id = await addProfile({ label: label.trim(), token: token.trim(), accountSlug: slug.trim() || undefined })
105
+ reload()
106
+ setIndex(listProfiles().findIndex((p) => p.id === id))
107
+ setToken("")
108
+ setPhase({ kind: "added", id, label: label.trim() })
109
+ }
110
+
111
+ // Remove = forget the account on this machine: its profile (token, DNS
112
+ // connections, local-copy links…), its saved sudo passwords, and its caches.
113
+ // Files in local working copies are never touched.
114
+ async function doRemove(p: ProfileSummary) {
115
+ for (const sid of p.keychainServers) await deleteSudoPassword(sid, p.id)
116
+ // The default account's caches sit beside machine-level files in the config
117
+ // dir itself, so only its named files go; any other account owns its folder.
118
+ const dir = profileDataDir(p.id)
119
+ if (p.id === DEFAULT_PROFILE) {
120
+ for (const f of PROFILE_CACHE_FILES) await rm(join(dir, f), { force: true }).catch(() => {})
121
+ } else {
122
+ await rm(dir, { recursive: true, force: true }).catch(() => {})
123
+ }
124
+ await removeProfile(p.id)
125
+ reload()
126
+ setIndex(0)
127
+ setPhase({ kind: "list" })
128
+ say(`Removed ${p.label} from this machine.`, theme.good)
129
+ }
130
+
131
+ useKeyboard((key) => {
132
+ const name = key.name ?? ""
133
+ switch (phase.kind) {
134
+ case "list": {
135
+ if (name === "escape" || name === "q") return onClose()
136
+ if (name === "up" || name === "k") return setIndex((i) => (i - 1 + rowCount) % rowCount)
137
+ if (name === "down" || name === "j") return setIndex((i) => (i + 1) % rowCount)
138
+ setMessage(null)
139
+ if (name === "return" || name === "enter") {
140
+ if (!selected) {
141
+ setLabel("")
142
+ setToken("")
143
+ return setPhase({ kind: "add-label" })
144
+ }
145
+ return void doSwitch(selected)
146
+ }
147
+ if (name === "e" && selected) {
148
+ setLabel(selected.label)
149
+ return setPhase({ kind: "rename", id: selected.id })
150
+ }
151
+ if (name === "x" && selected) {
152
+ if (selected.active) return say("That's the account you're on — switch to another before removing it.", theme.warn)
153
+ return setPhase({ kind: "confirm-remove", profile: selected })
154
+ }
155
+ return
156
+ }
157
+ case "add-label":
158
+ case "add-token":
159
+ case "add-slug":
160
+ case "rename":
161
+ if (name === "escape") {
162
+ setMessage(null)
163
+ return setPhase({ kind: "list" })
164
+ }
165
+ return
166
+ case "added":
167
+ if (name === "return" || name === "enter") {
168
+ const p = listProfiles().find((x) => x.id === phase.id)
169
+ if (p) return void doSwitch(p)
170
+ }
171
+ if (name === "escape" || name === "q") return setPhase({ kind: "list" })
172
+ return
173
+ case "confirm-remove":
174
+ if (name === "y") return void doRemove(phase.profile)
175
+ if (name === "escape" || name === "n" || name === "q") return setPhase({ kind: "list" })
176
+ return
177
+ }
178
+ })
179
+
180
+ const hints: KeyHint[] =
181
+ phase.kind === "list"
182
+ ? [
183
+ { key: "↑↓/jk", label: "select" },
184
+ ...(selected?.active ? [] : [{ key: "⏎", label: selected ? "switch" : "add" }]),
185
+ ...(selected ? [{ key: "e", label: "rename" }] : []),
186
+ // The active account can't be removed (switch away first), so don't offer it.
187
+ ...(selected && !selected.active ? [{ key: "x", label: "remove" }] : []),
188
+ { key: "esc", label: "close" },
189
+ ]
190
+ : phase.kind === "confirm-remove"
191
+ ? [
192
+ { key: "y", label: "remove" },
193
+ { key: "esc", label: "cancel" },
194
+ ]
195
+ : phase.kind === "added"
196
+ ? [
197
+ { key: "⏎", label: "switch now" },
198
+ { key: "esc", label: "stay here" },
199
+ ]
200
+ : typing
201
+ ? [
202
+ { key: "⏎", label: "next" },
203
+ { key: "esc", label: "cancel" },
204
+ ]
205
+ : []
206
+
207
+ return (
208
+ <box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 210 }}>
209
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
210
+ <text content="◆ Accounts" fg={theme.brand} style={{ flexShrink: 0 }} />
211
+ <text content=" one SpinupWP account at a time — switching reloads everything" fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 1 }} />
212
+ </box>
213
+ <Centered>{renderBody()}</Centered>
214
+ <StatusBar hints={hints} message={message?.text} messageColor={message?.color} showGlobal={false} />
215
+ </box>
216
+ )
217
+
218
+ function rowHelp(): [string, string][] {
219
+ if (!selected) return [["⏎", "add another SpinupWP account"]]
220
+ const name = truncate(selected.label, 24)
221
+ if (selected.active) return [["⏎", "you're on this one"], ["e", "rename it"]]
222
+ return [
223
+ ["⏎", `switch to ${name}`],
224
+ ["e", "rename"],
225
+ ["x", "remove"],
226
+ ]
227
+ }
228
+
229
+ function renderBody() {
230
+ switch (phase.kind) {
231
+ case "list":
232
+ return (
233
+ <Panel title=" Accounts " active>
234
+ <box style={{ flexDirection: "column", width: 70, paddingTop: 1, paddingBottom: 1 }}>
235
+ {profiles.map((p, i) => {
236
+ const sel = i === index
237
+ return (
238
+ <box key={p.id} style={{ flexDirection: "row", height: 1, backgroundColor: sel ? theme.selectedBg : undefined }}>
239
+ <text content={p.active ? " ● " : " "} fg={sel ? theme.text : theme.brand} style={{ flexShrink: 0 }} />
240
+ <text content={truncate(p.label, 36)} fg={sel ? theme.text : p.active ? theme.text : theme.textDim} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
241
+ <text content={truncate(p.accountSlug ?? "", 26)} fg={sel ? theme.text : theme.textFaint} wrapMode="none" style={{ flexShrink: 0, marginLeft: 1 }} />
242
+ <text content={p.active ? " active " : " "} fg={sel ? theme.text : theme.good} style={{ flexShrink: 0 }} />
243
+ </box>
244
+ )
245
+ })}
246
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: index === profiles.length ? theme.selectedBg : undefined }}>
247
+ <text content=" + Add an account" fg={index === profiles.length ? theme.text : theme.accent} wrapMode="none" />
248
+ </box>
249
+ <box style={{ height: 1 }} />
250
+ {/* What the keys do to THIS row — the bottom bar lists them, this says what they'll mean. */}
251
+ <box style={{ flexDirection: "row", height: 1 }}>
252
+ {rowHelp().map(([k, what], i) => (
253
+ <box key={i} style={{ flexDirection: "row", flexShrink: 0 }}>
254
+ <text content={i ? " " : ""} />
255
+ <text content={k} fg={theme.accent} />
256
+ <text content={` ${what}`} fg={theme.textDim} wrapMode="none" />
257
+ </box>
258
+ ))}
259
+ </box>
260
+ <box style={{ height: 1 }} />
261
+ <text content="Each account keeps its own token, DNS connections, local copies and" fg={theme.textFaint} wrapMode="none" />
262
+ <text content="saved sudo passwords. The Uptime Kuma login is shared by all of them." fg={theme.textFaint} wrapMode="none" />
263
+ </box>
264
+ </Panel>
265
+ )
266
+ case "add-label":
267
+ return (
268
+ <Panel title=" Add an account · 1 of 3 " active>
269
+ <box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
270
+ <text content="A name for this account — shown in the header while you're on it" fg={theme.accent} />
271
+ <input
272
+ focused
273
+ value={label}
274
+ placeholder="e.g. Acme Agency"
275
+ onInput={setLabel}
276
+ onSubmit={() => label.trim() && setPhase({ kind: "add-token" })}
277
+ style={INPUT_STYLE}
278
+ />
279
+ </box>
280
+ </Panel>
281
+ )
282
+ case "add-token":
283
+ case "validating":
284
+ return (
285
+ <Panel title=" Add an account · 2 of 3 " active>
286
+ <box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
287
+ <text content={`API token for ${truncate(label.trim(), 40)}`} fg={theme.accent} />
288
+ <text content="SpinupWP → Settings → API Tokens (Read/Write for changes)" fg={theme.textFaint} wrapMode="none" />
289
+ <SecretInput focused={phase.kind === "add-token"} value={token} placeholder="paste the token here…" onChange={setToken} onSubmit={() => void submitToken()} />
290
+ {phase.kind === "validating" && (
291
+ <box style={{ flexDirection: "row", marginTop: 1 }}>
292
+ <Spinner />
293
+ <text content=" Checking the token with SpinupWP…" fg={theme.textDim} />
294
+ </box>
295
+ )}
296
+ </box>
297
+ </Panel>
298
+ )
299
+ case "add-slug":
300
+ return (
301
+ <Panel title=" Add an account · 3 of 3 " active>
302
+ <box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
303
+ <text content="✓ Token works." fg={theme.good} />
304
+ <box style={{ height: 1 }} />
305
+ <text content="Account slug — the part after spinupwp.app/ in the dashboard URL." fg={theme.accent} wrapMode="none" />
306
+ <text content="Used for w (open in SpinupWP). Leave empty to skip." fg={theme.textFaint} wrapMode="none" />
307
+ <input focused value={slug} placeholder="acme-agency" onInput={setSlug} onSubmit={() => void finishAdd()} style={INPUT_STYLE} />
308
+ </box>
309
+ </Panel>
310
+ )
311
+ case "added":
312
+ return (
313
+ <Panel title=" Account added " active>
314
+ <box style={{ flexDirection: "column", width: 60, paddingTop: 1, paddingBottom: 1 }}>
315
+ <text content={`✓ ${truncate(phase.label, 50)} is ready.`} fg={theme.good} wrapMode="none" />
316
+ <box style={{ height: 1 }} />
317
+ <text content="Press Enter to switch to it now, or Esc to stay where you are." fg={theme.textDim} wrapMode="none" />
318
+ </box>
319
+ </Panel>
320
+ )
321
+ case "rename":
322
+ return (
323
+ <Panel title=" Rename account " active>
324
+ <box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
325
+ <text content="Name shown in the header" fg={theme.accent} />
326
+ <input
327
+ focused
328
+ value={label}
329
+ onInput={setLabel}
330
+ onSubmit={() => {
331
+ const id = phase.id
332
+ void renameProfile(id, label).then(() => {
333
+ reload()
334
+ setPhase({ kind: "list" })
335
+ if (id === profileId) setProfileLabel(label.trim())
336
+ })
337
+ }}
338
+ style={INPUT_STYLE}
339
+ />
340
+ </box>
341
+ </Panel>
342
+ )
343
+ case "confirm-remove": {
344
+ const p = phase.profile
345
+ const n = p.keychainServers.length
346
+ return (
347
+ <Panel title=" Remove account? " active>
348
+ <box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
349
+ <text content={`Forget ${truncate(p.label, 44)} on this machine:`} fg={theme.text} wrapMode="none" />
350
+ <text content=" · its API token, DNS connections and local-copy links" fg={theme.textDim} wrapMode="none" />
351
+ <text content={` · ${n} saved sudo password${n === 1 ? "" : "s"} in the Keychain`} fg={theme.textDim} wrapMode="none" />
352
+ <text content=" · its cached app probes and DNS lookups" fg={theme.textDim} wrapMode="none" />
353
+ <box style={{ height: 1 }} />
354
+ <text content="Nothing changes in SpinupWP, and your local working-copy files stay." fg={theme.textFaint} wrapMode="none" />
355
+ <box style={{ height: 1 }} />
356
+ <text content="Press y to remove · Esc to cancel" fg={theme.textFaint} wrapMode="none" />
357
+ </box>
358
+ </Panel>
359
+ )
360
+ }
361
+ case "switching":
362
+ return (
363
+ <box style={{ flexDirection: "row" }}>
364
+ <Spinner />
365
+ <text content={` Switching to ${truncate(phase.label, 40)}…`} fg={theme.textDim} />
366
+ </box>
367
+ )
368
+ }
369
+ }
370
+ }
@@ -16,8 +16,8 @@ import { Panel, Spinner } from "../components.tsx"
16
16
  import { StatusBar } from "../StatusBar.tsx"
17
17
  import { openUrl, copyToClipboard } from "../../lib/open.ts"
18
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"
19
+ import { serverWebUrl, siteGitUrl } from "../../lib/spinupweb.ts"
20
+ import { useStore, cloneNeedsGitAccess, type CloneStep, type CloneSiteStep, type CloneSiteState, type RepoKeyState } from "../store.tsx"
21
21
  import type { VerifyCheck } from "../../lib/serverClone.ts"
22
22
 
23
23
  // Choosing an existing server as the dest is a first-class feature now (the `d` picker
@@ -305,6 +305,13 @@ export function CloneWizard() {
305
305
  return cloneRetrySite(verifyOpen)
306
306
  }
307
307
  if (name === "v" && drilled?.step === "done") return verifyCloneSite(verifyOpen)
308
+ // g — the dest's Git settings need a hand (lost deploy script, push-to-deploy
309
+ // mismatch): open them, with the source's script on the clipboard when it's
310
+ // missing, so re-entering it is one paste.
311
+ if (name === "g" && (drilled?.deployScriptLost || drilled?.pushMismatch) && drilled.destSiteId != null) {
312
+ if (drilled.deployScriptLost && drilled.deployScript) copyToClipboard(drilled.deployScript)
313
+ return openUrl(siteGitUrl(drilled.destSiteId, accountSlug))
314
+ }
308
315
  return
309
316
  }
310
317
  const n = sel.length
@@ -726,7 +733,7 @@ export function CloneWizard() {
726
733
  {selected.map((s, i) => {
727
734
  const active = !["queued", "done", "error"].includes(s.step)
728
735
  const cur = i === idx
729
- const vmark = s.verify ? (s.verify.ok ? " ✓verified" : " ✕mismatch") : s.verifying ? " verifying…" : ""
736
+ const vmark = (s.verify ? (s.verify.ok ? " ✓verified" : " ✕mismatch") : s.verifying ? " verifying…" : "") + (s.deployScriptLost || s.pushMismatch || s.cronError ? " · ⚠ see verify" : "")
730
737
  return (
731
738
  <box key={s.sourceSiteId} style={{ flexDirection: "row", height: 1, backgroundColor: cur ? theme.bgAlt : undefined }}>
732
739
  {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 }} />}
@@ -793,7 +800,8 @@ export function CloneWizard() {
793
800
  {v.checks.map(row)}
794
801
  <box style={{ flexGrow: 1 }} />
795
802
  <text content={v.ok ? "✓ Clone matches the source." : "✕ Differences found — review the ✕ rows above."} fg={v.ok ? theme.good : theme.bad} wrapMode="none" />
796
- <text content="v re-run · ← back to roster" fg={theme.textFaint} wrapMode="none" />
803
+ {cloneNotes(site)}
804
+ <text content={`v re-run${site.deployScriptLost ? " · g copy deploy script + open Git settings" : site.pushMismatch ? " · g open Git settings" : ""} · ← back to roster`} fg={theme.textFaint} wrapMode="none" />
797
805
  </>
798
806
  ) : (
799
807
  <text content="Press v to compare this clone against its source." fg={theme.textDim} wrapMode="none" />
@@ -803,6 +811,26 @@ export function CloneWizard() {
803
811
  )
804
812
  }
805
813
 
814
+ // What the clone did beyond files + DB, and anything left for the user to do.
815
+ function cloneNotes(site: CloneSiteState) {
816
+ const cron = site.cronError
817
+ ? { text: `✕ Crontab not carried over: ${site.cronError}`, fg: theme.bad }
818
+ : site.cronAdded?.length
819
+ ? { text: `✓ Crontab: carried ${site.cronAdded.length} line${site.cronAdded.length === 1 ? "" : "s"} you added on the source`, fg: theme.good }
820
+ : null
821
+ return (
822
+ <>
823
+ {cron ? <text content={cron.text} fg={cron.fg} wrapMode="none" /> : null}
824
+ {site.pushMismatch ? (
825
+ <text content={`⚠ Push-to-deploy is ${site.pushEnabled ? "off" : "on"} here but ${site.pushEnabled ? "on" : "off"} on the source — switch it in the new site's Git settings (g)`} fg={theme.warn} wrapMode="none" />
826
+ ) : null}
827
+ {site.deployScriptLost ? (
828
+ <text content="⚠ SpinupWP didn't keep this site's deploy script — press g to copy the source's and paste it into the new site's Git settings" fg={theme.warn} wrapMode="none" />
829
+ ) : null}
830
+ </>
831
+ )
832
+ }
833
+
806
834
  // Failure drill-down — the FULL error for one failed site (the roster truncates it),
807
835
  // plus where the complete stage-by-stage log lives on disk.
808
836
  function failurePane(siteId: number) {
@@ -856,7 +884,7 @@ export function CloneWizard() {
856
884
  if (verifyOpen != null) {
857
885
  const drilled = job!.sites.find((s) => s.sourceSiteId === verifyOpen)
858
886
  if (drilled?.step === "error") return [{ key: "r", label: "retry site" }, { key: "←", label: "back" }, { key: "esc", label: "close" }]
859
- return [{ key: "v", label: "re-run" }, { key: "←", label: "back" }, { key: "esc", label: "close" }]
887
+ return [{ key: "v", label: "re-run" }, ...(drilled?.deployScriptLost || drilled?.pushMismatch ? [{ key: "g", label: "Git settings" }] : []), { key: "←", label: "back" }, { key: "esc", label: "close" }]
860
888
  }
861
889
  const done = selected.filter((s) => s.step === "done").length
862
890
  const errored = selected.filter((s) => s.step === "error").length
@@ -6,7 +6,8 @@
6
6
  // probe. The WordPress buckets (Standard WP / Bedrock / Radicle) expand into one
7
7
  // sub-row per probed core version, newest first — the "who still needs the
8
8
  // security release" view; Non-WP expands into named sub-rows (WHMCS / Laravel /
9
- // Static HTML / Unknown / unprobed). Both come from cached probes.
9
+ // Static HTML / Vanity Site / Unknown / unprobed). Both come from cached probes,
10
+ // except Vanity Site, which is derived with no SSH (see `nonWpSub`).
10
11
  //
11
12
  // Middle pane: the sites in the selected group. `d` probes the selected site;
12
13
  // `D` probes the whole selected group (bounded concurrency). Right pane: the
@@ -27,6 +28,7 @@ import { StatusBar } from "../StatusBar.tsx"
27
28
  import { SiteContextStrip, SITE_CONTEXT_STRIP_HEIGHT } from "../Details.tsx"
28
29
  import { openUrl } from "../../lib/open.ts"
29
30
  import { siteWebUrl } from "../../lib/spinupweb.ts"
31
+ import { isVanityPair } from "../../lib/vanitySite.ts"
30
32
  import { useStore } from "../store.tsx"
31
33
  import type { Site } from "../../api/types.ts"
32
34
 
@@ -47,14 +49,28 @@ interface Group {
47
49
  const WP_STACKS: Stack[] = ["Standard WP", "Bedrock", "Radicle"]
48
50
 
49
51
 
50
- // Non-WP sub-categories, in display order. `kind: null` = not yet probed.
51
- const NONWP_SUBS: { kind: ProbeKind | null; label: string }[] = [
52
+ // Non-WP sub-categories, in display order. `kind: null` = not yet probed;
53
+ // "vanity" is the site at its server's own hostname (see `nonWpSub`).
54
+ type NonWpSub = ProbeKind | "vanity" | null
55
+ const NONWP_SUBS: { kind: NonWpSub; label: string }[] = [
52
56
  { kind: "whmcs", label: "WHMCS" },
53
57
  { kind: "laravel", label: "Laravel" },
54
58
  { kind: "static", label: "Static HTML" },
59
+ { kind: "vanity", label: "Vanity Site" },
55
60
  { kind: "unknown", label: "Unknown" },
56
61
  { kind: null, label: "unprobed" },
57
62
  ]
63
+ const VANITY_COLOR = theme.info
64
+
65
+ // Which Non-WP sub-row a site belongs in. A vanity site is known from the API
66
+ // alone (domain === its server's hostname, the same `isVanityPair` every other
67
+ // vanity feature keys off), so it claims the site whether it's unprobed or the
68
+ // probe came back Unknown/Static — the placeholder page reads as neither. A
69
+ // positively identified app (WHMCS/Laravel) living at the hostname still wins.
70
+ function nonWpSub(kind: ProbeKind | null, vanity: boolean): NonWpSub {
71
+ if (vanity && (kind === null || kind === "unknown" || kind === "static")) return "vanity"
72
+ return kind
73
+ }
58
74
 
59
75
  export function Stacks({ rows }: { rows: number }) {
60
76
  const store = useStore()
@@ -125,13 +141,15 @@ export function Stacks({ rows }: { rows: number }) {
125
141
  }
126
142
  if (st === "Non-WP") {
127
143
  for (const sub of NONWP_SUBS) {
128
- const subSites = bucket.filter((site) => (probes.get(site.id)?.result.kind ?? null) === sub.kind)
144
+ const subSites = bucket.filter(
145
+ (site) => nonWpSub(probes.get(site.id)?.result.kind ?? null, isVanityPair(site.domain, serverById(site.server_id)?.name ?? "")) === sub.kind,
146
+ )
129
147
  if (subSites.length === 0) continue
130
148
  groups.push({
131
149
  id: `nonwp:${sub.label}`,
132
150
  label: sub.label,
133
151
  level: 1,
134
- color: sub.kind ? probeKindColor(sub.kind) : theme.textFaint,
152
+ color: sub.kind === "vanity" ? VANITY_COLOR : sub.kind ? probeKindColor(sub.kind) : theme.textFaint,
135
153
  sites: subSites,
136
154
  })
137
155
  }
@@ -139,7 +157,7 @@ export function Stacks({ rows }: { rows: number }) {
139
157
  }
140
158
  const php = [...phpCounts.entries()].sort((a, b) => phpSortKey(b[0]) - phpSortKey(a[0]))
141
159
  return { groups, php }
142
- }, [sites, probes])
160
+ }, [sites, probes, serverById])
143
161
 
144
162
  // Keep selection in range as groups appear/disappear with probing.
145
163
  const safeGroupIndex = Math.min(groupIndex, groups.length - 1)
@@ -372,6 +390,11 @@ export function Stacks({ rows }: { rows: number }) {
372
390
  const errored = probeErrors.has(s.id)
373
391
  const wpRunning = wpCoreJobs.get(s.id)?.result === null
374
392
  const faint = selected ? theme.text : theme.textFaint
393
+ // Vanity rows say so instead of the probe's "Unknown"/"Static HTML"
394
+ // (or "· press d" — there's nothing to identify).
395
+ const vanity =
396
+ effectiveStack(s, cached?.result.kind) === "Non-WP" &&
397
+ nonWpSub(cached?.result.kind ?? null, isVanityPair(s.domain, serverById(s.server_id)?.name ?? "")) === "vanity"
375
398
  const updates = (s.wp_plugin_updates || 0) + (s.wp_theme_updates || 0) + (s.wp_core_update ? 1 : 0)
376
399
  return (
377
400
  <>
@@ -391,6 +414,8 @@ export function Stacks({ rows }: { rows: number }) {
391
414
  <WpCoreRowMark siteId={s.id} selected={selected} />
392
415
  ) : probing ? (
393
416
  <Spinner color={selected ? theme.text : theme.brand} />
417
+ ) : vanity ? (
418
+ <text content="Vanity Site" fg={selected ? theme.text : VANITY_COLOR} wrapMode="none" />
394
419
  ) : cached ? (
395
420
  <text
396
421
  content={truncate(cached.result.label, 20) + (isProbeStale(s) ? "*" : "")}
@@ -160,6 +160,24 @@ export function clearBulk() {
160
160
  emitBulk()
161
161
  }
162
162
 
163
+ // Is any core update still running? Switching accounts waits for these — the
164
+ // job maps are keyed by site id, which only means something within one account.
165
+ export function wpCoreBusy(): boolean {
166
+ if (bulk && bulk.phase !== "done") return true
167
+ for (const j of jobs.values()) if (j.result === null) return true
168
+ return false
169
+ }
170
+
171
+ // Forget every job and finished bulk run — on an account switch, once nothing
172
+ // is running (wpCoreBusy), so the new account's rows don't inherit markers.
173
+ export function resetWpCoreJobs() {
174
+ jobs = new Map()
175
+ bulk = null
176
+ stopRequested = false
177
+ for (const l of jobListeners) l()
178
+ emitBulk()
179
+ }
180
+
163
181
  export function startBulkCoreUpdate(
164
182
  title: string,
165
183
  plan: Omit<BulkItem, "state" | "error">[],