spinuptui 0.26.1 → 0.28.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
+ }
@@ -11,6 +11,7 @@ import { theme, statusColor, statusDot } from "../../lib/theme.ts"
11
11
  import { effectiveStack, stackColor, stackTag } from "../../lib/stack.ts"
12
12
  import { truncate } from "../../lib/format.ts"
13
13
  import { Panel, PhpVersionCell, Spinner } from "../components.tsx"
14
+ import { WpCoreRowMark } from "../wpCoreJobs.tsx"
14
15
  import { List, moveSelection } from "../List.tsx"
15
16
  import { ServerDetail, SiteDetail, ControlStrip, CONTROL_STRIP_HEIGHT } from "../Details.tsx"
16
17
  import { StatusBar } from "../StatusBar.tsx"
@@ -387,6 +388,7 @@ export function Browser({ rows }: { rows: number }) {
387
388
  linked/key/HTTPS/cache/backup/updates badges moved to the Details
388
389
  pane (already shown there for the selected site) to give the
389
390
  domain name room on narrower terminals. */}
391
+ <WpCoreRowMark siteId={s.id} selected={selected} compact />
390
392
  <text content={stackTag(stack).padEnd(7) + " "} fg={stackColor(stack, selected)} wrapMode="none" style={{ flexShrink: 0 }} />
391
393
  <PhpVersionCell version={s.php_version} upgrade={phpUpgrades.get(s.id)} selected={selected} />
392
394
  </>