spinuptui 0.27.0 → 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
+ }
@@ -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">[],