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.
- package/CHANGELOG.md +24 -1
- package/README.md +9 -5
- package/package.json +1 -1
- package/src/api/client.ts +38 -2
- package/src/config.ts +232 -8
- package/src/index.tsx +25 -5
- package/src/lib/dnsCache.ts +9 -6
- package/src/lib/keychain.ts +11 -5
- package/src/lib/probe.ts +16 -0
- package/src/lib/providersCache.ts +9 -6
- package/src/lib/stackCache.ts +9 -6
- package/src/lib/wpCli.ts +2 -2
- package/src/lib/wpCore.ts +273 -0
- package/src/ui/App.tsx +18 -2
- package/src/ui/Explain.tsx +5 -4
- package/src/ui/Header.tsx +6 -2
- package/src/ui/Help.tsx +2 -1
- package/src/ui/ToastStack.tsx +2 -2
- package/src/ui/accounts.tsx +21 -0
- package/src/ui/components.tsx +1 -1
- package/src/ui/store.tsx +89 -21
- package/src/ui/toast.ts +16 -10
- package/src/ui/views/Accounts.tsx +370 -0
- package/src/ui/views/Browser.tsx +2 -0
- package/src/ui/views/BulkWpUpdate.tsx +396 -0
- package/src/ui/views/PhpUpgrade.tsx +5 -2
- package/src/ui/views/Search.tsx +2 -0
- package/src/ui/views/SiteUpdate.tsx +124 -0
- package/src/ui/views/Stacks.tsx +113 -15
- package/src/ui/views/WpCoreUpdate.tsx +322 -0
- package/src/ui/wpCoreJobs.tsx +253 -0
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,27 @@ 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
|
+
|
|
23
|
+
## [0.27.0] - 2026-09-23
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- **Update WordPress core from `u`.** `u` is now **Update**, a menu with two choices: PHP version, the flow you already know, and WordPress core. The WordPress option checks the site over SSH with wp-cli and lists the available versions, with minor (security) releases picked first. After you confirm, it runs `wp core update` and then the database upgrade. The update keeps running if you close the overlay, and a toast reports when it's done. Bedrock and Radicle sites are included; for those, the confirm screen names the version `composer.lock` pins, and warns that the next `composer install` will put that version back until you run `composer update`.
|
|
27
|
+
- **Stacks groups WordPress sites by core version.** Under Standard WP, Bedrock and Radicle, one indented row per WordPress version, newest first, shows how many sites run it (for example `└ WP 7.0.2 51`). The newest version in the fleet is highlighted green. Select a row to list its sites, which makes it easy to find the ones still waiting for a security release. `D` on a version row re-probes just those sites. Sites with no known version are grouped under `no version`, and the left pane now scrolls when the list is taller than the terminal.
|
|
28
|
+
- **Update WordPress on a whole version group.** In Stacks, `u` on a version row (`└ WP 7.0.2`) checks every site in that group live over SSH, then shows a plan you can edit. The default target is the newest release in each site's own line (the security hotfix), and `m` switches to the newest release overall. `space` leaves a site out, and Bedrock and Radicle sites are marked `⚙` because of the `composer.lock` caveat. After you confirm, a canary site updates alone first, and if it fails nothing else is touched. The rest follow up to 4 at a time, one per server, stopping after 3 failures; `x` stops starting new sites. It keeps running if you close the overlay, and a single toast reports the result.
|
|
29
|
+
- **Update progress shows on the site's row.** While a WordPress core update runs, the Stacks row replaces the version with a spinner and the target version (`⠹ → WP 7.1.2`). Servers and Search show a compact `WP` marker. If the update fails, the row keeps a `⬆!` marker until you open the result, and the toast that reports the failure is red instead of green.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- **The WordPress version on a site's row now follows the Update check.** Stacks and Servers show the version from the last `d` probe, so it went stale as soon as core changed. Opening **Update → WordPress core** reads the live version over SSH, and that value, along with the result of any update, now replaces the stored one.
|
|
33
|
+
- **SpinupWP API errors now say why.** A rejected write used to show only "SpinupWP API error (HTTP 400)." The message now includes the reason from the API's response, such as which field it rejected.
|
|
34
|
+
|
|
14
35
|
## [0.26.1] - 2026-09-18
|
|
15
36
|
|
|
16
37
|
### Fixed
|
|
@@ -1415,7 +1436,9 @@ Initial tagged release.
|
|
|
1415
1436
|
### Notes
|
|
1416
1437
|
- Read-only release: works with a SpinupWP **Read Only** API token.
|
|
1417
1438
|
|
|
1418
|
-
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.
|
|
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
|
|
1441
|
+
[0.27.0]: https://github.com/mwender/spinupwp-tui/compare/v0.26.1...v0.27.0
|
|
1419
1442
|
[0.26.1]: https://github.com/mwender/spinupwp-tui/compare/v0.26.0...v0.26.1
|
|
1420
1443
|
[0.26.0]: https://github.com/mwender/spinupwp-tui/compare/v0.25.0...v0.26.0
|
|
1421
1444
|
[0.25.0]: https://github.com/mwender/spinupwp-tui/compare/v0.24.4...v0.25.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)
|
|
@@ -53,8 +54,8 @@
|
|
|
53
54
|
pull a production DB into a linked local copy. → [docs/database-backup-sync.md](docs/database-backup-sync.md)
|
|
54
55
|
- **Production media fallback** (`m`, Servers / Search tabs) — serve missing-locally images
|
|
55
56
|
from production after a DB pull. → [docs/production-media-fallback.md](docs/production-media-fallback.md)
|
|
56
|
-
- **
|
|
57
|
-
|
|
57
|
+
- **Update a site** (`u`) — change its PHP version, or update WordPress core over
|
|
58
|
+
SSH (Standard WP, Bedrock and Radicle). → [docs/php-upgrade.md](docs/php-upgrade.md)
|
|
58
59
|
- **Enable / disable HTTPS** (`H`) — toggle a site's certificate.
|
|
59
60
|
- **Purge cache** (`P`) — clear page + object cache together.
|
|
60
61
|
- **Server actions** (`a`) — reboot or restart a service, with the real reason a
|
|
@@ -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. `
|
|
271
|
-
`https://spinupwp.app/
|
|
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
|
|
@@ -320,7 +323,7 @@ These can be set in `config.json` or via an environment variable:
|
|
|
320
323
|
| `D` | Identify every site in the selected stack (Stacks tab) |
|
|
321
324
|
| `S` | Auto-discover & batch-link local copies (Stacks tab) |
|
|
322
325
|
| `f` | Report sites with no usable local copy (Stacks tab only — `f` identifies a site's stack in Servers/Search, above) |
|
|
323
|
-
| `u` |
|
|
326
|
+
| `u` | Update a site: PHP version (needs a Read/Write token) or WordPress core (over SSH) (Servers / Stacks / Search) |
|
|
324
327
|
| `H` | Enable / disable HTTPS on a site (Servers / Stacks / Search; needs a Read/Write token) |
|
|
325
328
|
| `P` | Purge a site's page cache + object cache (Servers / Stacks / Search; needs a Read/Write token) |
|
|
326
329
|
| `M` | Site/server monitoring — a two-pane browser of this site's Uptime Kuma monitors (capital, since lowercase `m` is media fallback in Servers/Search). Inside: `↑`/`↓` selects a monitor, `a` registers/recalibrates/repairs it (Front page and Cache bypass open a check-window picker), `x` removes Front page or Cache bypass, `o` opens the selected monitor in Kuma, `d` runs the site doctor, `n` shows/edits alert wiring, and vanity sites add `R`/`r` for page refresh/secret rotation (Servers / Stacks / Search) |
|
|
@@ -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
package/src/api/client.ts
CHANGED
|
@@ -31,6 +31,42 @@ export class ApiError extends Error {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// Pull the human-readable reason out of a SpinupWP error body. The API answers
|
|
35
|
+
// validation failures Laravel-style — {"message": "...", "errors": {field: [..]}}
|
|
36
|
+
// — and a bare "HTTP 400" hides exactly the part the user needs (which field,
|
|
37
|
+
// and why). Returns "" when the body has nothing usable, so callers can fall
|
|
38
|
+
// back to the status-only message.
|
|
39
|
+
export function apiErrorDetail(body: string | undefined): string {
|
|
40
|
+
const text = (body ?? "").trim()
|
|
41
|
+
if (!text) return ""
|
|
42
|
+
let json: unknown
|
|
43
|
+
try {
|
|
44
|
+
json = JSON.parse(text)
|
|
45
|
+
} catch {
|
|
46
|
+
// Not JSON (an HTML error page, say) — a short plain-text body is still worth
|
|
47
|
+
// showing; anything long is noise.
|
|
48
|
+
return text.length <= 200 && !text.startsWith("<") ? text : ""
|
|
49
|
+
}
|
|
50
|
+
if (!json || typeof json !== "object") return ""
|
|
51
|
+
const j = json as { message?: unknown; error?: unknown; errors?: unknown }
|
|
52
|
+
const parts: string[] = []
|
|
53
|
+
const message = typeof j.message === "string" ? j.message : typeof j.error === "string" ? j.error : ""
|
|
54
|
+
if (j.errors && typeof j.errors === "object") {
|
|
55
|
+
for (const v of Object.values(j.errors as Record<string, unknown>)) {
|
|
56
|
+
for (const m of Array.isArray(v) ? v : [v]) if (typeof m === "string" && !parts.includes(m)) parts.push(m)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Laravel's top-level message usually just repeats the first field error
|
|
60
|
+
// ("The php version is invalid. (and 1 more error)") — prefer the field list.
|
|
61
|
+
if (parts.length) return parts.join(" ")
|
|
62
|
+
return message
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function httpErrorMessage(status: number, body: string): string {
|
|
66
|
+
const detail = apiErrorDetail(body)
|
|
67
|
+
return detail ? `SpinupWP API error (HTTP ${status}): ${detail}` : `SpinupWP API error (HTTP ${status}).`
|
|
68
|
+
}
|
|
69
|
+
|
|
34
70
|
export interface ClientOptions {
|
|
35
71
|
token: string
|
|
36
72
|
baseUrl: string
|
|
@@ -152,7 +188,7 @@ export class SpinupWPClient implements SpinupWPClientLike {
|
|
|
152
188
|
}
|
|
153
189
|
if (!res.ok) {
|
|
154
190
|
const body = await res.text().catch(() => "")
|
|
155
|
-
throw new ApiError(
|
|
191
|
+
throw new ApiError(httpErrorMessage(res.status, body), res.status, body)
|
|
156
192
|
}
|
|
157
193
|
|
|
158
194
|
return (await res.json()) as T
|
|
@@ -189,7 +225,7 @@ export class SpinupWPClient implements SpinupWPClientLike {
|
|
|
189
225
|
}
|
|
190
226
|
if (!res.ok) {
|
|
191
227
|
const errBody = await res.text().catch(() => "")
|
|
192
|
-
throw new ApiError(
|
|
228
|
+
throw new ApiError(httpErrorMessage(res.status, errBody), res.status, errBody)
|
|
193
229
|
}
|
|
194
230
|
|
|
195
231
|
// Some write endpoints (e.g. PHP upgrade) return a bare body, others 204.
|
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. "
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
299
|
-
|
|
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
|
-
<
|
|
248
|
-
<
|
|
249
|
-
|
|
265
|
+
<AccountsContext.Provider value={accounts}>
|
|
266
|
+
<StoreProvider key={account}>
|
|
267
|
+
<App />
|
|
268
|
+
</StoreProvider>
|
|
269
|
+
</AccountsContext.Provider>
|
|
250
270
|
)
|
|
251
271
|
}
|
|
252
272
|
|
package/src/lib/dnsCache.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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(
|
|
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)
|
package/src/lib/keychain.ts
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/lib/probe.ts
CHANGED
|
@@ -167,6 +167,22 @@ function parseSections(out: string): Record<string, string> {
|
|
|
167
167
|
return flat
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
// The same probe result with a newly learned WordPress core version, relabeled the
|
|
171
|
+
// way classify() labels it. Returns null for kinds that don't carry a WP version
|
|
172
|
+
// (a WHMCS/Laravel/static site's cached identity must never be rewritten).
|
|
173
|
+
export function withWpVersion(r: ProbeResult, version: string): ProbeResult | null {
|
|
174
|
+
switch (r.kind) {
|
|
175
|
+
case "wordpress":
|
|
176
|
+
return { ...r, version, label: `WordPress ${version}` }
|
|
177
|
+
case "bedrock":
|
|
178
|
+
return { ...r, version, label: `Bedrock · WP ${version}` }
|
|
179
|
+
case "radicle":
|
|
180
|
+
return { ...r, version, label: `Radicle · WP ${version}` }
|
|
181
|
+
default:
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
170
186
|
// Turn raw signals into a named stack. Order matters: name the distinctive apps
|
|
171
187
|
// (WHMCS, Bedrock, Laravel) before falling back to a generic WordPress version.
|
|
172
188
|
export function classify(sig: Record<string, string>): ProbeResult {
|