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.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,15 @@ versions; such changes are called out here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.28.0] - 2026-09-23
15
+
16
+ ### Added
17
+ - **Multiple SpinupWP accounts.** Press `A` from anywhere to open **Accounts**: add another account (its token is checked with SpinupWP before it's saved), switch between accounts, rename them, or remove one. You work in one account at a time, never a merged fleet, so an action can't land on the wrong client's server, and the header always shows `Account: …`. Switching reloads the app on the new account, and it waits until running changes (PHP upgrades, DNS edits, clones, WordPress updates…) finish. Each account keeps its own token, DNS provider connections, local-copy links, sudo users and saved Keychain passwords, monitors, and cached probes. Machine-level settings (terminal app, local scan roots, the Uptime Kuma login) are shared by all of them. Your existing setup becomes the first account automatically; nothing moves on disk. Removing an account also deletes its Keychain passwords and caches, but never files in your local working copies. For commands, `SPINUPTUI_ACCOUNT=<id>` picks an account for one run, and `spinuptui where` lists the account ids.
18
+ - **Vanity Site in Stacks.** Non-WP now has a **Vanity Site** row for each server's own-hostname site, the same rule the vanity features use, so these show up without an SSH probe instead of sitting under Unknown or unprobed. Their rows read `Vanity Site` in place of `Unknown`.
19
+
20
+ ### Notes
21
+ - **Config migration.** The first time this version saves a setting, `config.json` is rewritten in the new accounts shape. A version before 0.28.0 can't read the token from it and would run first-time setup, so back up `~/.config/spinupwp-tui/config.json` if you might downgrade. Accounts are stored per machine; add them on each machine where you use SpinupTUI.
22
+
14
23
  ## [0.27.0] - 2026-09-23
15
24
 
16
25
  ### Added
@@ -1427,7 +1436,8 @@ Initial tagged release.
1427
1436
  ### Notes
1428
1437
  - Read-only release: works with a SpinupWP **Read Only** API token.
1429
1438
 
1430
- [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.27.0...HEAD
1439
+ [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.28.0...HEAD
1440
+ [0.28.0]: https://github.com/mwender/spinupwp-tui/compare/v0.27.0...v0.28.0
1431
1441
  [0.27.0]: https://github.com/mwender/spinupwp-tui/compare/v0.26.1...v0.27.0
1432
1442
  [0.26.1]: https://github.com/mwender/spinupwp-tui/compare/v0.26.0...v0.26.1
1433
1443
  [0.26.0]: https://github.com/mwender/spinupwp-tui/compare/v0.25.0...v0.26.0
package/README.md CHANGED
@@ -38,6 +38,7 @@
38
38
  probe for precise identification. → [docs/stack-detection.md](docs/stack-detection.md)
39
39
  - **Global search** — fuzzy search across every server and site by name, domain,
40
40
  or IP, with inline actions on the result.
41
+ - **Multiple SpinupWP accounts** (`A`) — keep several accounts (say, your agency's and a client's) and switch between them in-app; the header always names the one you're on. → [docs/multiple-accounts.md](docs/multiple-accounts.md)
41
42
  - **Events feed** — recent provisioning/operation activity with per-event detail.
42
43
  - **Live server health** (`h`) — real-time CPU/load/memory/disk over SSH.
43
44
  → [docs/server-health.md](docs/server-health.md)
@@ -262,13 +263,15 @@ The token is resolved in this order (first match wins):
262
263
  To reconfigure, delete the config file (the path is shown on the onboarding
263
264
  screen) and relaunch, or set the environment variable.
264
265
 
266
+ More than one SpinupWP account? Add the others in-app with `A`. Each keeps its own token and account settings; see [docs/multiple-accounts.md](docs/multiple-accounts.md).
267
+
265
268
  ### Optional settings
266
269
 
267
270
  These can be set in `config.json` or via an environment variable:
268
271
 
269
272
  - **`accountSlug`** / `SPINUPWP_ACCOUNT_SLUG` — your SpinupWP account/team slug
270
- (the first path segment in a SpinupWP URL, e.g. `wenmark-digital-solutions` in
271
- `https://spinupwp.app/wenmark-digital-solutions/servers/35633`). The API
273
+ (the first path segment in a SpinupWP URL, e.g. `acme-agency` in
274
+ `https://spinupwp.app/acme-agency/servers/12345`). The API
272
275
  doesn't expose it, so set it to enable the `w` deep links into the web app.
273
276
  Without it, `w` opens the SpinupWP dashboard root.
274
277
  - **`sshUser`** / `SPINUPWP_SSH_USER` — override the SSH user for the health view
@@ -333,6 +336,7 @@ These can be set in `config.json` or via an environment variable:
333
336
  | `K` | Grant / revoke an SSH key on a site, or every site on the server (Servers / Search; needs sudo connected) |
334
337
  | `w` | Open the selected server/site in the SpinupWP web app |
335
338
  | `/` | Jump to global search |
339
+ | `A` | Accounts — switch, add, rename, or remove SpinupWP accounts (from anywhere) |
336
340
  | `r` | Refresh data from the API |
337
341
  | `i` | Explain the current screen (what each pane and key does) |
338
342
  | `?` | Toggle the help overlay |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "SpinupTUI — a terminal dashboard for browsing, monitoring, and managing your SpinupWP servers and sites.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.ts CHANGED
@@ -6,6 +6,16 @@
6
6
  //
7
7
  // This lets the original author keep a project-local .env while letting other
8
8
  // users configure the tool globally after a `bun install -g`.
9
+ //
10
+ // Accounts (profiles): config.json can hold several SpinupWP accounts under
11
+ // `profiles`, one of them active. Everything that belongs to an account — its
12
+ // token, DNS connections, local-copy links, sudo users, jobs… (PROFILE_KEYS) —
13
+ // lives inside its profile; machine-level settings (terminal app, local scan
14
+ // roots, the Uptime Kuma login, last-seen version) stay at the top level and are
15
+ // shared. Callers never see the split: readStoredConfig() returns the active
16
+ // profile flattened over the globals, and saveConfig() routes each key back to
17
+ // where it belongs. A pre-profiles file is read as a single profile, "default",
18
+ // and rewritten in the new shape on its next save.
9
19
 
10
20
  import { homedir } from "node:os"
11
21
  import { join } from "node:path"
@@ -17,13 +27,16 @@ import { ALL_PROVIDERS, type Connection, type ConnProvider } from "./lib/provide
17
27
  export const DEFAULT_BASE_URL = "https://api.spinupwp.app/v1"
18
28
 
19
29
  export interface AppConfig {
30
+ // The active account's profile id and human label (see "Accounts" above).
31
+ profileId: string
32
+ profileLabel: string
20
33
  token: string
21
34
  baseUrl: string
22
35
  tokenSource: "env" | "file" | "none"
23
36
  // Optional override for the SSH user used by the server health view. When unset,
24
37
  // the health view derives the user from a site on the server (its `site_user`).
25
38
  sshUser: string | null
26
- // SpinupWP account/team slug (e.g. "wenmark-digital-solutions"), used to build
39
+ // SpinupWP account/team slug (e.g. "acme-agency"), used to build
27
40
  // deep links into the SpinupWP web app. The API doesn't expose it, so it's
28
41
  // configured. When unset, deep links fall back to the dashboard root.
29
42
  accountSlug: string | null
@@ -191,6 +204,64 @@ export interface StoredConfig {
191
204
  lastSeenVersion?: string
192
205
  }
193
206
 
207
+ // ---- Accounts (profiles) -------------------------------------------------------
208
+
209
+ // The keys that belong to one SpinupWP account. Server and site ids are assigned
210
+ // per account (two accounts can both have a server #42), so anything keyed by
211
+ // them MUST be here, as must DNS provider credentials (each client's own).
212
+ // Everything else in StoredConfig is machine-level and shared by every profile.
213
+ // `kumaMonitors` is per account, the Kuma login (`uptimeKuma`) is shared: one
214
+ // agency's Kuma watches every account's sites.
215
+ const PROFILE_KEYS = [
216
+ "token",
217
+ "baseUrl",
218
+ "accountSlug",
219
+ "sshUser",
220
+ "localSites",
221
+ "localSync",
222
+ "providers",
223
+ "serverProviders",
224
+ "jobs",
225
+ "sudoUsers",
226
+ "preferredGrantKeys",
227
+ "grantedKeys",
228
+ "zoneAccessNotes",
229
+ "vanityHealthKeys",
230
+ "kumaMonitors",
231
+ ] as const satisfies readonly (keyof StoredConfig)[]
232
+ type ProfileKey = (typeof PROFILE_KEYS)[number]
233
+ const isProfileKey = (k: string): k is ProfileKey => (PROFILE_KEYS as readonly string[]).includes(k)
234
+
235
+ export type StoredProfile = Pick<StoredConfig, ProfileKey> & { label?: string }
236
+
237
+ // The on-disk shape: globals at the top, accounts under `profiles`.
238
+ interface StoredFile extends Omit<StoredConfig, ProfileKey> {
239
+ activeProfile?: string
240
+ profiles?: Record<string, StoredProfile>
241
+ }
242
+
243
+ // The profile a pre-profiles config migrates into. Its Keychain entries and cache
244
+ // files keep their original (un-namespaced) names, so migrating moves nothing.
245
+ export const DEFAULT_PROFILE = "default"
246
+
247
+ export interface ProfileSummary {
248
+ id: string
249
+ label: string
250
+ accountSlug: string | null
251
+ active: boolean
252
+ // How many sudo passwords this account has saved in the Keychain — what a
253
+ // removal will scrub.
254
+ keychainServers: number[]
255
+ }
256
+
257
+ // Picks a profile for this process only (no write) — the SPINUPTUI_ACCOUNT env
258
+ // var, so a CLI command can target an account without switching the app's.
259
+ let processProfile: string | null = process.env.SPINUPTUI_ACCOUNT?.trim() || null
260
+
261
+ export function profileLabel(p: StoredProfile | undefined, id: string): string {
262
+ return p?.label?.trim() || p?.accountSlug?.trim() || id
263
+ }
264
+
194
265
  export function configDir(): string {
195
266
  const xdg = process.env.XDG_CONFIG_HOME
196
267
  return xdg ? join(xdg, "spinupwp-tui") : join(homedir(), ".config", "spinupwp-tui")
@@ -206,19 +277,142 @@ export function keysDir(): string {
206
277
  return join(configDir(), "keys")
207
278
  }
208
279
 
209
- function readStoredConfig(): StoredConfig {
280
+ function readRawFile(): Record<string, unknown> {
210
281
  try {
211
282
  const path = configPath()
212
283
  if (!existsSync(path)) return {}
213
- return JSON.parse(readFileSync(path, "utf8")) as StoredConfig
284
+ return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>
214
285
  } catch {
215
286
  return {}
216
287
  }
217
288
  }
218
289
 
290
+ // Read config.json in the profiles shape, migrating a flat (pre-profiles) file
291
+ // in memory: its account keys become the "default" profile.
292
+ function readStoredFile(): StoredFile {
293
+ const raw = readRawFile()
294
+ if (raw.profiles && typeof raw.profiles === "object") return raw as StoredFile
295
+ const file: Record<string, unknown> = {}
296
+ const profile: Record<string, unknown> = {}
297
+ for (const [k, v] of Object.entries(raw)) (isProfileKey(k) ? profile : file)[k] = v
298
+ // Nothing account-shaped yet (fresh install): no profile until one is saved.
299
+ if (Object.keys(profile).length === 0) return file as StoredFile
300
+ return { ...(file as StoredFile), activeProfile: DEFAULT_PROFILE, profiles: { [DEFAULT_PROFILE]: profile as StoredProfile } }
301
+ }
302
+
303
+ // The id of the profile this process reads and writes. Falls back to the first
304
+ // profile when the stored pointer is missing or dangling.
305
+ function resolveActive(file: StoredFile): string {
306
+ const ids = Object.keys(file.profiles ?? {})
307
+ for (const want of [processProfile, file.activeProfile]) if (want && ids.includes(want)) return want
308
+ return ids[0] ?? DEFAULT_PROFILE
309
+ }
310
+
311
+ export function activeProfileId(): string {
312
+ return resolveActive(readStoredFile())
313
+ }
314
+
315
+ // The active profile flattened over the machine-level settings — the shape every
316
+ // caller has always read.
317
+ function readStoredConfig(): StoredConfig {
318
+ const file = readStoredFile()
319
+ const { profiles, activeProfile: _active, ...globals } = file
320
+ const profile = profiles?.[resolveActive(file)] ?? {}
321
+ const { label: _label, ...fields } = profile
322
+ return { ...globals, ...fields }
323
+ }
324
+
325
+ // Where an account's disk caches (probe results, DNS, verified zones) live. The
326
+ // default profile keeps the original top-level paths, so migrating moves no
327
+ // files; any other account gets its own directory, since those caches are keyed
328
+ // by per-account site ids and zones.
329
+ export function profileDataDir(id = activeProfileId()): string {
330
+ return id === DEFAULT_PROFILE ? configDir() : join(configDir(), "profiles", id)
331
+ }
332
+
333
+ // The per-account cache files, by name, inside profileDataDir() — listed so
334
+ // removing an account can delete exactly these (the default profile's sit
335
+ // beside machine-level files in the config dir itself).
336
+ export const PROFILE_CACHE_FILES = ["stack-cache.json", "dns-cache.json", "providers-cache.json"]
337
+
338
+ export function listProfiles(): ProfileSummary[] {
339
+ const file = readStoredFile()
340
+ const active = resolveActive(file)
341
+ return Object.entries(file.profiles ?? {}).map(([id, p]) => ({
342
+ id,
343
+ label: profileLabel(p, id),
344
+ accountSlug: p.accountSlug?.trim() || null,
345
+ active: id === active,
346
+ keychainServers: Object.entries(p.sudoUsers ?? {})
347
+ .filter(([, u]) => u.keychain)
348
+ .map(([sid]) => Number(sid)),
349
+ }))
350
+ }
351
+
352
+ // A lowercase, dash-separated id from a label, unique among existing profiles.
353
+ function profileIdFor(label: string, taken: string[]): string {
354
+ const base = label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "account"
355
+ let id = base
356
+ for (let n = 2; taken.includes(id); n++) id = `${base}-${n}`
357
+ return id
358
+ }
359
+
360
+ // Add an account (token already validated by the caller). Does not switch to it.
361
+ export async function addProfile(p: { label: string; token: string; baseUrl?: string; accountSlug?: string }): Promise<string> {
362
+ const file = readStoredFile()
363
+ const profiles = { ...(file.profiles ?? {}) }
364
+ const id = profileIdFor(p.label, Object.keys(profiles))
365
+ profiles[id] = {
366
+ label: p.label.trim(),
367
+ token: p.token.trim(),
368
+ baseUrl: p.baseUrl ?? DEFAULT_BASE_URL,
369
+ ...(p.accountSlug?.trim() ? { accountSlug: p.accountSlug.trim() } : {}),
370
+ }
371
+ await writeStoredFile({ ...file, activeProfile: resolveActive(file), profiles })
372
+ return id
373
+ }
374
+
375
+ export async function renameProfile(id: string, label: string): Promise<void> {
376
+ const file = readStoredFile()
377
+ const p = file.profiles?.[id]
378
+ if (!p || !label.trim()) return
379
+ await writeStoredFile({ ...file, activeProfile: resolveActive(file), profiles: { ...file.profiles, [id]: { ...p, label: label.trim() } } })
380
+ }
381
+
382
+ // Drop an account's profile. The caller scrubs its Keychain entries and cache
383
+ // directory (see removeAccount in the Accounts overlay). The active account
384
+ // can't be removed — switch away first.
385
+ export async function removeProfile(id: string): Promise<void> {
386
+ const file = readStoredFile()
387
+ const active = resolveActive(file)
388
+ if (id === active || !file.profiles?.[id]) return
389
+ const { [id]: _gone, ...rest } = file.profiles
390
+ await writeStoredFile({ ...file, activeProfile: active, profiles: rest })
391
+ }
392
+
393
+ // Lock this process to the account active right now. The TUI calls it at
394
+ // launch: without it, a second instance on the same machine
395
+ // switching accounts would rewrite `activeProfile` in the shared file, and this
396
+ // process's call-time reads — Keychain names, cache dirs, jobs, local links —
397
+ // would quietly follow it while its screens still show the old account.
398
+ export function pinActiveProfile(): string {
399
+ processProfile = resolveActive(readStoredFile())
400
+ return processProfile
401
+ }
402
+
403
+ // Make `id` the active account, for this process and future launches.
404
+ export async function setActiveProfile(id: string): Promise<void> {
405
+ const file = readStoredFile()
406
+ if (!file.profiles?.[id]) return
407
+ processProfile = id
408
+ await writeStoredFile({ ...file, activeProfile: id })
409
+ }
410
+
219
411
  // Resolve the active config from env + stored file. Never throws.
220
412
  export function loadConfig(): AppConfig {
221
413
  const stored = readStoredConfig()
414
+ const file = readStoredFile()
415
+ const profileId = resolveActive(file)
222
416
  const envToken = process.env.SPINUPWP_ACCESS_TOKEN?.trim()
223
417
  const fileToken = stored.token?.trim()
224
418
 
@@ -257,11 +451,16 @@ export function loadConfig(): AppConfig {
257
451
  }
258
452
 
259
453
  return {
454
+ profileId,
455
+ profileLabel: profileLabel(file.profiles?.[profileId], profileId),
260
456
  token,
261
457
  baseUrl: (stored.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ""),
262
458
  tokenSource,
263
459
  sshUser: process.env.SPINUPWP_SSH_USER?.trim() || stored.sshUser?.trim() || null,
264
- accountSlug: process.env.SPINUPWP_ACCOUNT_SLUG?.trim() || stored.accountSlug?.trim() || null,
460
+ // The env slug describes the env token's account (or the one account a
461
+ // pre-profiles setup had) — never another profile the user switched to.
462
+ accountSlug:
463
+ ((tokenSource === "env" || profileId === DEFAULT_PROFILE) && process.env.SPINUPWP_ACCOUNT_SLUG?.trim()) || stored.accountSlug?.trim() || null,
265
464
  terminalApp: process.env.SPINUPWP_TERMINAL_APP?.trim() || stored.terminalApp?.trim() || null,
266
465
  localSync: ((): boolean => {
267
466
  // Env overrides the file when present; otherwise the stored flag (default off).
@@ -295,12 +494,37 @@ export function hasToken(): boolean {
295
494
  return loadConfig().token.length > 0
296
495
  }
297
496
 
298
- // Persist a token (and optional base URL) to the config file. Used by onboarding.
299
- export async function saveConfig(partial: StoredConfig): Promise<void> {
497
+ // Merge settings into the config file: account keys into the active profile
498
+ // (created as "default" if there is none yet — first-run onboarding), the rest
499
+ // at the top level. `profileId` pins the account keys to a specific profile —
500
+ // the app passes the one it was started for, so a write that lands after an
501
+ // account switch can't leak into the newly active account (and one for an
502
+ // account removed meanwhile is dropped).
503
+ export async function saveConfig(partial: StoredConfig, profileId?: string): Promise<void> {
504
+ const file = readStoredFile()
505
+ const active = resolveActive(file)
506
+ if (profileId && profileId !== active && !file.profiles?.[profileId]) {
507
+ partial = Object.fromEntries(Object.entries(partial).filter(([k]) => !isProfileKey(k)))
508
+ if (Object.keys(partial).length === 0) return
509
+ }
510
+ const target = profileId && file.profiles?.[profileId] ? profileId : active
511
+ const profile: StoredProfile = { ...(file.profiles?.[target] ?? {}) }
512
+ const next: StoredFile = { ...file }
513
+ for (const [k, v] of Object.entries(partial)) {
514
+ if (isProfileKey(k)) (profile as Record<string, unknown>)[k] = v
515
+ else (next as Record<string, unknown>)[k] = v
516
+ }
517
+ const hasProfile = file.profiles?.[target] || PROFILE_KEYS.some((k) => k in partial)
518
+ if (hasProfile) {
519
+ next.profiles = { ...(file.profiles ?? {}), [target]: profile }
520
+ next.activeProfile = active
521
+ }
522
+ await writeStoredFile(next)
523
+ }
524
+
525
+ async function writeStoredFile(next: StoredFile): Promise<void> {
300
526
  const dir = configDir()
301
527
  await mkdir(dir, { recursive: true })
302
- const current = readStoredConfig()
303
- const next: StoredConfig = { ...current, ...partial }
304
528
  const path = configPath()
305
529
  await Bun.write(path, JSON.stringify(next, null, 2) + "\n")
306
530
  // The file holds an API token — restrict it to the owner.
package/src/index.tsx CHANGED
@@ -6,13 +6,15 @@
6
6
  // First, before any config read or spawn: keep a site checkout's .env from
7
7
  // leaking into child processes (see the module).
8
8
  import "./lib/cwdDotenv.ts"
9
- import { useState } from "react"
9
+ import { useCallback, useMemo, useState } from "react"
10
10
  import { createCliRenderer } from "@opentui/core"
11
11
  import { createRoot } from "@opentui/react"
12
12
  import pkg from "../package.json" with { type: "json" }
13
- import { hasToken, configPath, loadConfig } from "./config.ts"
13
+ import { hasToken, configPath, loadConfig, pinActiveProfile, setActiveProfile, listProfiles } from "./config.ts"
14
14
  import { StoreProvider } from "./ui/store.tsx"
15
15
  import { App } from "./ui/App.tsx"
16
+ import { AccountsContext, type AccountsApi } from "./ui/accounts.tsx"
17
+ import { resetWpCoreJobs } from "./ui/wpCoreJobs.tsx"
16
18
  import { Onboarding } from "./ui/Onboarding.tsx"
17
19
  import { isDevMode } from "./dev/devMode.ts"
18
20
  import { SpinupWPClient } from "./api/client.ts"
@@ -94,7 +96,12 @@ Token resolution: SPINUPWP_ACCESS_TOKEN (env / .env) first, then the config
94
96
  file. Run \`spinuptui login\` once to save a token so \`spinuptui\` works from
95
97
  anywhere.
96
98
 
99
+ Accounts: add and switch SpinupWP accounts in the app with A. Commands use the
100
+ active one; SPINUPTUI_ACCOUNT=<id> picks another for a single run (ids are
101
+ listed by \`spinuptui where\`).
102
+
97
103
  Config file: ${configPath()}
104
+ Account: ${cfg.profileLabel} (${cfg.profileId})
98
105
  Token source: ${cfg.tokenSource}`)
99
106
  process.exit(0)
100
107
  }
@@ -107,7 +114,10 @@ if (args.includes("-v") || args.includes("--version") || command === "version")
107
114
  if (command === "where") {
108
115
  const cfg = loadConfig()
109
116
  console.log(`config: ${configPath()}`)
117
+ console.log(`account: ${cfg.profileLabel} (${cfg.profileId})`)
110
118
  console.log(`token source: ${cfg.tokenSource}`)
119
+ const others = listProfiles().filter((p) => !p.active)
120
+ if (others.length) console.log(`other accounts: ${others.map((p) => `${p.label} (${p.id})`).join(", ")}`)
111
121
  process.exit(0)
112
122
  }
113
123
 
@@ -240,13 +250,23 @@ const forceLogin = command === "login"
240
250
 
241
251
  function Root() {
242
252
  const [configured, setConfigured] = useState(isDevMode() ? true : forceLogin ? false : hasToken())
253
+ // The store is keyed by account: switching remounts it (see ui/accounts.tsx).
254
+ const [account, setAccount] = useState(() => pinActiveProfile())
255
+ const switchAccount = useCallback(async (id: string) => {
256
+ await setActiveProfile(id)
257
+ resetWpCoreJobs()
258
+ setAccount(id)
259
+ }, [])
260
+ const accounts = useMemo<AccountsApi>(() => ({ switchAccount }), [switchAccount])
243
261
  if (!configured) {
244
262
  return <Onboarding onComplete={() => setConfigured(true)} />
245
263
  }
246
264
  return (
247
- <StoreProvider>
248
- <App />
249
- </StoreProvider>
265
+ <AccountsContext.Provider value={accounts}>
266
+ <StoreProvider key={account}>
267
+ <App />
268
+ </StoreProvider>
269
+ </AccountsContext.Provider>
250
270
  )
251
271
  }
252
272
 
@@ -13,7 +13,7 @@
13
13
  import { join } from "node:path"
14
14
  import { existsSync, readFileSync } from "node:fs"
15
15
  import { chmod, mkdir } from "node:fs/promises"
16
- import { configDir } from "../config.ts"
16
+ import { profileDataDir } from "../config.ts"
17
17
  import type { ZoneHost } from "./dns.ts"
18
18
 
19
19
  const CACHE_VERSION = 1
@@ -29,11 +29,14 @@ interface CacheFile {
29
29
  entries: Record<string, CachedDns> // key: normalized domain
30
30
  }
31
31
 
32
- export function dnsCachePath(): string {
33
- return join(configDir(), "dns-cache.json")
32
+ export function dnsCachePath(dir = profileDataDir()): string {
33
+ return join(dir, "dns-cache.json")
34
34
  }
35
35
 
36
36
  export class DnsCache {
37
+ // Pinned to the account active when the cache was created, so a write that
38
+ // lands after an account switch still goes to its own account\'s file.
39
+ private readonly dir = profileDataDir()
37
40
  private entries = new Map<string, CachedDns>()
38
41
  // Serializes writes so concurrent batch lookups can't tear the file.
39
42
  private writeChain: Promise<void> = Promise.resolve()
@@ -42,7 +45,7 @@ export class DnsCache {
42
45
  // an empty cache.
43
46
  load(): void {
44
47
  try {
45
- const path = dnsCachePath()
48
+ const path = dnsCachePath(this.dir)
46
49
  if (!existsSync(path)) return
47
50
  const parsed = JSON.parse(readFileSync(path, "utf8")) as CacheFile
48
51
  if (parsed?.version !== CACHE_VERSION || !parsed.entries) return
@@ -80,10 +83,10 @@ export class DnsCache {
80
83
  }
81
84
 
82
85
  private async writeFile(): Promise<void> {
83
- await mkdir(configDir(), { recursive: true })
86
+ await mkdir(this.dir, { recursive: true })
84
87
  const file: CacheFile = { version: CACHE_VERSION, entries: {} }
85
88
  for (const [k, entry] of this.entries) file.entries[k] = entry
86
- const path = dnsCachePath()
89
+ const path = dnsCachePath(this.dir)
87
90
  await Bun.write(path, JSON.stringify(file, null, 2) + "\n")
88
91
  try {
89
92
  await chmod(path, 0o600)
@@ -8,6 +8,7 @@
8
8
  // the write — acceptable for an opt-in convenience on a personal box; documented here.
9
9
 
10
10
  import { spawn } from "./spawn.ts"
11
+ import { activeProfileId, DEFAULT_PROFILE } from "../config.ts"
11
12
 
12
13
  const SERVICE = "spinup-sudo"
13
14
 
@@ -15,8 +16,12 @@ export function keychainAvailable(): boolean {
15
16
  return process.platform === "darwin"
16
17
  }
17
18
 
18
- function account(serverId: number): string {
19
- return `server-${serverId}`
19
+ // Server ids are per SpinupWP account — two accounts can both have server #42 —
20
+ // so each account's entries are namespaced by its profile id. The default
21
+ // profile keeps the original un-namespaced name, so entries saved before
22
+ // accounts existed are still found.
23
+ function account(serverId: number, profileId = activeProfileId()): string {
24
+ return profileId === DEFAULT_PROFILE ? `server-${serverId}` : `${profileId}:server-${serverId}`
20
25
  }
21
26
 
22
27
  async function security(args: string[]): Promise<{ ok: boolean; stdout: string; stderr: string }> {
@@ -53,8 +58,9 @@ export async function getSudoPassword(serverId: number): Promise<string | null>
53
58
  return r.ok ? r.stdout.replace(/\n+$/, "") : null
54
59
  }
55
60
 
56
- // Remove a server's stored sudo password (best-effort).
57
- export async function deleteSudoPassword(serverId: number): Promise<void> {
61
+ // Remove a server's stored sudo password (best-effort) — the active account's,
62
+ // or another account's when removing that account.
63
+ export async function deleteSudoPassword(serverId: number, profileId?: string): Promise<void> {
58
64
  if (!keychainAvailable()) return
59
- await security(["delete-generic-password", "-s", SERVICE, "-a", account(serverId)])
65
+ await security(["delete-generic-password", "-s", SERVICE, "-a", account(serverId, profileId)])
60
66
  }
@@ -13,7 +13,7 @@
13
13
  import { join } from "node:path"
14
14
  import { existsSync, readFileSync } from "node:fs"
15
15
  import { chmod, mkdir } from "node:fs/promises"
16
- import { configDir } from "../config.ts"
16
+ import { profileDataDir } from "../config.ts"
17
17
  import type { VerifiedZone } from "./providers.ts"
18
18
 
19
19
  // v2: VerifiedZone gained `nameservers` + `account` (was accountId/accountName).
@@ -42,17 +42,20 @@ interface CacheFile {
42
42
  entries: Record<string, VerifiedConn> // key: connection id
43
43
  }
44
44
 
45
- export function providersCachePath(): string {
46
- return join(configDir(), "providers-cache.json")
45
+ export function providersCachePath(dir = profileDataDir()): string {
46
+ return join(dir, "providers-cache.json")
47
47
  }
48
48
 
49
49
  export class ProvidersCache {
50
+ // Pinned to the account active when the cache was created, so a write that
51
+ // lands after an account switch still goes to its own account\'s file.
52
+ private readonly dir = profileDataDir()
50
53
  private entries = new Map<string, VerifiedConn>()
51
54
  private writeChain: Promise<void> = Promise.resolve()
52
55
 
53
56
  load(): void {
54
57
  try {
55
- const path = providersCachePath()
58
+ const path = providersCachePath(this.dir)
56
59
  if (!existsSync(path)) return
57
60
  const parsed = JSON.parse(readFileSync(path, "utf8")) as CacheFile
58
61
  if (parsed?.version !== CACHE_VERSION || !parsed.entries) return
@@ -91,10 +94,10 @@ export class ProvidersCache {
91
94
  }
92
95
 
93
96
  private async writeFile(): Promise<void> {
94
- await mkdir(configDir(), { recursive: true })
97
+ await mkdir(this.dir, { recursive: true })
95
98
  const file: CacheFile = { version: CACHE_VERSION, entries: {} }
96
99
  for (const [k, entry] of this.entries) file.entries[k] = entry
97
- const path = providersCachePath()
100
+ const path = providersCachePath(this.dir)
98
101
  await Bun.write(path, JSON.stringify(file, null, 2) + "\n")
99
102
  try {
100
103
  await chmod(path, 0o600)
@@ -12,7 +12,7 @@
12
12
  import { join } from "node:path"
13
13
  import { existsSync, readFileSync } from "node:fs"
14
14
  import { chmod, mkdir } from "node:fs/promises"
15
- import { configDir } from "../config.ts"
15
+ import { profileDataDir } from "../config.ts"
16
16
  import type { ProbeResult } from "./probe.ts"
17
17
 
18
18
  const CACHE_VERSION = 1
@@ -28,8 +28,8 @@ interface CacheFile {
28
28
  entries: Record<string, CachedProbe> // key: String(site.id)
29
29
  }
30
30
 
31
- export function stackCachePath(): string {
32
- return join(configDir(), "stack-cache.json")
31
+ export function stackCachePath(dir = profileDataDir()): string {
32
+ return join(dir, "stack-cache.json")
33
33
  }
34
34
 
35
35
  // A cached probe is considered stale when the site's relevant shape changes.
@@ -40,6 +40,9 @@ export function siteSignature(site: { is_wordpress: boolean; public_folder: stri
40
40
  }
41
41
 
42
42
  export class StackCache {
43
+ // Pinned to the account active when the cache was created, so a write that
44
+ // lands after an account switch still goes to its own account\'s file.
45
+ private readonly dir = profileDataDir()
43
46
  private entries = new Map<number, CachedProbe>()
44
47
  // Serializes disk writes so concurrent probes (batch mode) can't tear the
45
48
  // file or drop each other's updates. Each persist() runs after the previous.
@@ -49,7 +52,7 @@ export class StackCache {
49
52
  // simply yields an empty cache.
50
53
  load(): void {
51
54
  try {
52
- const path = stackCachePath()
55
+ const path = stackCachePath(this.dir)
53
56
  if (!existsSync(path)) return
54
57
  const parsed = JSON.parse(readFileSync(path, "utf8")) as CacheFile
55
58
  if (parsed?.version !== CACHE_VERSION || !parsed.entries) return
@@ -99,10 +102,10 @@ export class StackCache {
99
102
  }
100
103
 
101
104
  private async writeFile(): Promise<void> {
102
- await mkdir(configDir(), { recursive: true })
105
+ await mkdir(this.dir, { recursive: true })
103
106
  const file: CacheFile = { version: CACHE_VERSION, entries: {} }
104
107
  for (const [id, entry] of this.entries) file.entries[String(id)] = entry
105
- const path = stackCachePath()
108
+ const path = stackCachePath(this.dir)
106
109
  await Bun.write(path, JSON.stringify(file, null, 2) + "\n")
107
110
  try {
108
111
  await chmod(path, 0o600)
package/src/lib/wpCli.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  // individual site's configured version, and can be missing extensions that
6
6
  // version's CLI SAPI never got configured with.
7
7
  //
8
- // Verified live 2026-07-09 on web4.wenmarkdigital.com: the system default had
8
+ // Verified live 2026-07-09 on a production server: the system default had
9
9
  // drifted to 8.4, whose CLI lacked `mysqli` (its FPM pool likely never needed
10
- // it — no site there runs 8.4), while lp.anchoredconstructiontn.com's actual
10
+ // it — no site there runs 8.4), while a site's actual
11
11
  // configured version (8.1) has a fully working php8.1-cli. Bare `wp` silently
12
12
  // ran under 8.4 and failed on anything touching $wpdb (post/user/plugin list,
13
13
  // option get) — while `wp core version`, and `wp db export/import/check/size`