spinuptui 1.0.0 → 1.1.1
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 +29 -1
- package/README.md +34 -10
- package/package.json +1 -1
- package/src/api/client.ts +65 -0
- package/src/api/types.ts +51 -2
- package/src/dev/mockClient.ts +56 -0
- package/src/index.tsx +28 -0
- package/src/lib/appUpdate.ts +8 -1
- package/src/lib/cliIncidents.ts +7 -0
- package/src/lib/cliSiblings.ts +181 -0
- package/src/lib/cliSsh.ts +45 -31
- package/src/lib/cloneSettings.ts +180 -0
- package/src/lib/serverClone.ts +33 -5
- package/src/lib/sshExecPolicy.ts +119 -7
- package/src/ui/Help.tsx +23 -1
- package/src/ui/store.tsx +79 -28
- package/src/ui/views/CloneWizard.tsx +24 -5
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,32 @@ version; such changes are called out here.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [1.1.1] - 2026-10-01
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- **`spinuptui ssh-exec` allows `wp user list`, `wp user get` and `wp user list-caps`.** The read-only guard used to refuse everything under `wp user`, reads included, which got in the way of checking when an account was registered. Creating, deleting and changing users is still denied.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- **`spinuptui ssh-exec` no longer lets Redis writes through.** The read-only guard had no patterns for Redis, so `redis-cli CONFIG SET …`, `FLUSHALL`, `FLUSHDB`, `BGSAVE` and plain `SET`/`DEL` all ran as if they were diagnostics. `redis-cli` is now allowed only with a known read — `ping`, `info`, `dbsize`, `lastsave`, `config get`, `memory doctor|stats|usage`, `latency latest|history`, `slowlog get|len`, `client list|info` and a few key-inspection reads — and anything else is denied, including unrecognised options and a bare `redis-cli` fed from stdin. The denial message names the verb or option that was refused.
|
|
21
|
+
- **`spinuptui ssh-exec` no longer lets unlisted WP-CLI writes through.** The guard denied a dozen named WP-CLI writes and allowed every other command, so `wp transient delete`, `wp search-replace`, `wp eval`, `wp plugin uninstall`, `wp cron event run` and more ran as diagnostics. `wp` is now allowed only with a known read (`plugin list`, `option get`, `core version`, `cache type`, `cron event list`, `db size` and the like), and the `--exec`, `--require`, `--ssh` and `--http` flags are refused on any command. Two reads that used to pass are now denied: `wp config get` and `wp config list`, because they print the database password into the audit log.
|
|
22
|
+
- **The update command now actually updates.** The About panel (`?`) now suggests `bun add -g spinuptui@latest --no-cache`. Without `--no-cache`, Bun can answer `@latest` from its cached copy of npm's package list, which right after a release can still name the previous version, so the command reported success and left you where you were.
|
|
23
|
+
|
|
24
|
+
## [1.1.0] - 2026-09-30
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- **Clones match the source's SpinupWP settings.** Creating a site through SpinupWP's API only switches page caching on or off, so a clone used to come out with SpinupWP's defaults for everything else. The wizard now matches the source's page cache duration and exclusions, the security toggles (PHP in uploads, `xmlrpc.php`, multisite subdirectory rewrites), its path redirects, and the WP cron interval (read from the site's crontab, since the API doesn't report it). Only what differs is changed, so a retry changes nothing, and nothing the clone already has is removed. The verify pane (`v`) lists what it matched, and anything that didn't take, with `w` to open the new site in SpinupWP.
|
|
28
|
+
- **Clones point out basic auth.** SpinupWP never reveals a site's basic-auth password, so a password-protected source can't be copied as-is. The verify pane says so and `w` opens the new site to set it.
|
|
29
|
+
- **`spinuptui siblings <domain>` — who else is on this server.** Prints every site sharing a server with the one you name, as JSON, along with the server's own IP, SSH port, size and Ubuntu version. Each site says whether it's the server's vanity host, whether it's WordPress, what PHP it runs, whether page cache is on, and whether SpinupTUI set Uptime Kuma monitors up for it. This is for the case where a symptom belongs to the whole box rather than to one site: a vanity host's `?healthz` trips on whatever load the busiest co-tenant is making, and site isolation means the vanity site's own user can't see any of it — so the first question is always "who else lives here". `--probe` also SSHes into each site user and reports which ones this device's key is accepted for, which is the same question as "whose logs can I read". A site that comes back `permission_denied` is one key grant (`K` in the Browser view) away from being readable.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
- **Clones put the deploy script back themselves.** SpinupWP clears a deploy script set when a site is created, and until now you had to paste it back into the new site's Git settings. SpinupWP's API can now update a site's Git settings, so the clone wizard sets the source's script again once the site is done, and fixes push-to-deploy the same way if it came out different. The verify pane (`v`) says when it did. The `g` copy-and-open fallback only appears if the script still doesn't stick. A clone that finishes within about two minutes of creating the site waits out the rest first, since an earlier write would be cleared along with the original.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- **`spinuptui ssh` reports a hung connection as `connection_failed`.** When the connectivity probe ran past its 15-second limit, it came back as an unexplained `ssh_error` with the kill signal's exit code. It now says the connection didn't finish in time.
|
|
36
|
+
|
|
37
|
+
### Notes
|
|
38
|
+
- **Backups don't carry over to a clone yet.** SpinupWP stores backups per domain, so a clone backing up before DNS moves would write into the production site's folder. Switch backups on for the new site after cutover; having the wizard do it at the cutover step is planned.
|
|
39
|
+
|
|
14
40
|
## [1.0.0] - 2026-09-24
|
|
15
41
|
|
|
16
42
|
### Added
|
|
@@ -1453,7 +1479,9 @@ Initial tagged release.
|
|
|
1453
1479
|
### Notes
|
|
1454
1480
|
- Read-only release: works with a SpinupWP **Read Only** API token.
|
|
1455
1481
|
|
|
1456
|
-
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.
|
|
1482
|
+
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.1.1...HEAD
|
|
1483
|
+
[1.1.1]: https://github.com/mwender/spinupwp-tui/compare/v1.1.0...v1.1.1
|
|
1484
|
+
[1.1.0]: https://github.com/mwender/spinupwp-tui/compare/v1.0.0...v1.1.0
|
|
1457
1485
|
[1.0.0]: https://github.com/mwender/spinupwp-tui/compare/v0.28.0...v1.0.0
|
|
1458
1486
|
[0.28.0]: https://github.com/mwender/spinupwp-tui/compare/v0.27.0...v0.28.0
|
|
1459
1487
|
[0.27.0]: https://github.com/mwender/spinupwp-tui/compare/v0.26.1...v0.27.0
|
package/README.md
CHANGED
|
@@ -82,12 +82,14 @@
|
|
|
82
82
|
if it's read-only — anything that looks like a write/restart/destructive
|
|
83
83
|
action is denied rather than run, so any agent using it inherits that
|
|
84
84
|
guarantee without building its own guard; `spinuptui incidents <domain>` /
|
|
85
|
-
`--all` surfaces Uptime Kuma's down/up history as JSON; `spinuptui
|
|
86
|
-
<domain
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
85
|
+
`--all` surfaces Uptime Kuma's down/up history as JSON; `spinuptui siblings
|
|
86
|
+
<domain>` lists every site sharing that site's server, so a page on a server's
|
|
87
|
+
vanity host leads straight to the co-tenants that could be causing it;
|
|
88
|
+
`spinuptui pull files <domain> [path]` and `spinuptui pull db <domain> --yes`
|
|
89
|
+
set up a local working copy from production without opening the TUI at all —
|
|
90
|
+
the same engines the guided flow drives. Built so another agent/script can go
|
|
91
|
+
from just a domain to "what's wrong and how do I get in", or to a working local
|
|
92
|
+
copy, with no manual lookup. → [CLI subcommands](#cli-subcommands)
|
|
91
93
|
- **Completion toasts** — a non-focus-stealing toast when a background write
|
|
92
94
|
(PHP upgrade, reboot, DNS resolve, …) finishes.
|
|
93
95
|
- **Release notes** — an in-app "what's new" after every update, sourced
|
|
@@ -163,10 +165,14 @@ to the version in the header (and in the `?` About panel).
|
|
|
163
165
|
|
|
164
166
|
- **Homebrew:** `brew upgrade spinuptui` (the About panel shows the same
|
|
165
167
|
command).
|
|
166
|
-
- **Package install:** `bun add -g spinuptui@latest` (the About
|
|
167
|
-
the same command).
|
|
168
|
-
|
|
169
|
-
release
|
|
168
|
+
- **Package install:** `bun add -g spinuptui@latest --no-cache` (the About
|
|
169
|
+
panel shows the same command). `--no-cache` matters: without it Bun can
|
|
170
|
+
answer `@latest` from its cached copy of the registry, which right after a
|
|
171
|
+
release may still name the previous version — the command reports success
|
|
172
|
+
and installs nothing new. Don't use `bun update -g` either — while spinuptui
|
|
173
|
+
was pre-1.0, it recorded a caret range (e.g. `^0.22.2`) that excludes every
|
|
174
|
+
later minor release, so `update -g` reports success but silently doesn't
|
|
175
|
+
update.
|
|
170
176
|
- **Source checkout:** press **`u`** in the About panel to update in place
|
|
171
177
|
(`git pull --ff-only`; refuses if you have uncommitted changes, and never
|
|
172
178
|
merges/rebases). It can't hot-reload the already-running process, so it tells
|
|
@@ -199,6 +205,24 @@ spinuptui ssh-exec <domain> [--server <name>] -- <command> Resolve the
|
|
|
199
205
|
attempt (allowed, denied, or unresolved) is appended to
|
|
200
206
|
<config dir>/logs/ssh-exec-audit.jsonl. Quote <command>
|
|
201
207
|
as one shell argument if it needs pipes or redirects.
|
|
208
|
+
spinuptui siblings <domain> [--server <name>] [--probe] Print every site
|
|
209
|
+
sharing a server with this one, as JSON, with the
|
|
210
|
+
server's own details (IP, SSH port, size, Ubuntu
|
|
211
|
+
version). Each entry carries its site user and SSH
|
|
212
|
+
target, whether it's WordPress, its PHP version, whether
|
|
213
|
+
page cache is on, whether it's the server's vanity host
|
|
214
|
+
(isVanity), whether SpinupTUI registered Uptime Kuma
|
|
215
|
+
monitors for it (monitored), and whether it's the domain
|
|
216
|
+
you asked about (isQuery). The vanity host sorts first,
|
|
217
|
+
then the queried site, then the rest alphabetically.
|
|
218
|
+
--probe additionally SSHes into each site user and
|
|
219
|
+
reports ssh:"ok" | "permission_denied" |
|
|
220
|
+
"connection_failed" | "ssh_error", which answers "whose
|
|
221
|
+
logs can I actually read on this box" in one call.
|
|
222
|
+
Written for whole-server symptoms: a vanity host's
|
|
223
|
+
?healthz trips on the load of whichever co-tenant is
|
|
224
|
+
busy, and site isolation means the vanity user can't see
|
|
225
|
+
it.
|
|
202
226
|
spinuptui incidents <domain> | --all [--hours N] Print Uptime Kuma down/up
|
|
203
227
|
incidents as JSON, scoped to sites SpinupTUI manages
|
|
204
228
|
monitoring for (config.json's kumaMonitors) — --all
|
package/package.json
CHANGED
package/src/api/client.ts
CHANGED
|
@@ -15,6 +15,10 @@ import type {
|
|
|
15
15
|
CreateSitePayload,
|
|
16
16
|
AdditionalDomain,
|
|
17
17
|
AddDomainPayload,
|
|
18
|
+
UpdateSiteGitPayload,
|
|
19
|
+
PageCachePayload,
|
|
20
|
+
NginxSettings,
|
|
21
|
+
PathRedirect,
|
|
18
22
|
} from "./types.ts"
|
|
19
23
|
|
|
20
24
|
// The restartable services SpinupWP exposes (POST /servers/{id}/services/{svc}/restart).
|
|
@@ -84,6 +88,16 @@ export interface SpinupWPClientLike {
|
|
|
84
88
|
listSites(serverId?: number): Promise<Site[]>
|
|
85
89
|
getSite(id: number): Promise<Site>
|
|
86
90
|
createSite(payload: CreateSitePayload): Promise<{ event_id: number }>
|
|
91
|
+
updateSiteGit(siteId: number, payload: UpdateSiteGitPayload): Promise<{ event_id: number | null } | undefined>
|
|
92
|
+
enablePageCache(siteId: number, payload?: PageCachePayload): Promise<{ event_id: number }>
|
|
93
|
+
updatePageCache(siteId: number, payload: PageCachePayload): Promise<{ event_id: number }>
|
|
94
|
+
disablePageCache(siteId: number): Promise<{ event_id: number }>
|
|
95
|
+
updateNginx(siteId: number, payload: NginxSettings): Promise<{ event_ids: number[] }>
|
|
96
|
+
listPathRedirects(siteId: number): Promise<PathRedirect[]>
|
|
97
|
+
addPathRedirect(siteId: number, payload: Omit<PathRedirect, "created_at">): Promise<{ event_id: number }>
|
|
98
|
+
enableWpCron(siteId: number, interval: number): Promise<{ event_id: number }>
|
|
99
|
+
updateWpCron(siteId: number, interval: number): Promise<{ event_id: number }>
|
|
100
|
+
disableWpCron(siteId: number): Promise<{ event_id: number }>
|
|
87
101
|
enableHttps(siteId: number): Promise<{ event_id: number }>
|
|
88
102
|
disableHttps(siteId: number): Promise<{ event_id: number } | undefined>
|
|
89
103
|
purgePageCache(siteId: number): Promise<{ event_id: number }>
|
|
@@ -351,6 +365,57 @@ export class SpinupWPClient implements SpinupWPClientLike {
|
|
|
351
365
|
return this.mutate<{ event_id: number }>(`/sites/${siteId}/domains`, "POST", payload)
|
|
352
366
|
}
|
|
353
367
|
|
|
368
|
+
// Update a site's Git settings (at least one field). Changing only the deploy
|
|
369
|
+
// script or push-to-deploy needs no server work, so SpinupWP answers with a null
|
|
370
|
+
// event_id — the new values are in place when this resolves. Needs a Read/Write
|
|
371
|
+
// token.
|
|
372
|
+
updateSiteGit(siteId: number, payload: UpdateSiteGitPayload): Promise<{ event_id: number | null } | undefined> {
|
|
373
|
+
return this.mutate<{ event_id: number | null } | undefined>(`/sites/${siteId}/git`, "PUT", payload)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Page cache on an existing site (the API added enable/disable/settings in
|
|
377
|
+
// 2026-09; before that only purge existed). Async → event_id. Lists in the
|
|
378
|
+
// payload are newline-separated. Needs a Read/Write token.
|
|
379
|
+
enablePageCache(siteId: number, payload: PageCachePayload = {}): Promise<{ event_id: number }> {
|
|
380
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/page-cache`, "POST", payload)
|
|
381
|
+
}
|
|
382
|
+
updatePageCache(siteId: number, payload: PageCachePayload): Promise<{ event_id: number }> {
|
|
383
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/page-cache`, "PUT", payload)
|
|
384
|
+
}
|
|
385
|
+
disablePageCache(siteId: number): Promise<{ event_id: number }> {
|
|
386
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/page-cache`, "DELETE")
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Nginx toggles (PHP in uploads, xmlrpc.php, multisite subdirectory rewrites;
|
|
390
|
+
// public_folder too, unused here). One event PER CHANGED SETTING comes back as
|
|
391
|
+
// `event_ids` — empty when nothing changed. Needs a Read/Write token.
|
|
392
|
+
updateNginx(siteId: number, payload: NginxSettings): Promise<{ event_ids: number[] }> {
|
|
393
|
+
return this.mutate<{ event_ids: number[] }>(`/sites/${siteId}/nginx`, "PUT", payload)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Path redirects (nginx-level, per site). Adding a from+to pair the site already
|
|
397
|
+
// has is rejected. Async → event_id. Needs a Read/Write token.
|
|
398
|
+
async listPathRedirects(siteId: number): Promise<PathRedirect[]> {
|
|
399
|
+
return this.listAll<PathRedirect>(`/sites/${siteId}/path-redirects`)
|
|
400
|
+
}
|
|
401
|
+
addPathRedirect(siteId: number, payload: Omit<PathRedirect, "created_at">): Promise<{ event_id: number }> {
|
|
402
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/path-redirects`, "POST", payload)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Server-level WP cron (a system cron job replacing WordPress's page-load cron).
|
|
406
|
+
// interval is minutes, one of WP_CRON_INTERVALS. The Site object doesn't report
|
|
407
|
+
// it — read the site user's crontab instead (wpCronInterval in serverClone.ts).
|
|
408
|
+
// Async → event_id. Needs a Read/Write token.
|
|
409
|
+
enableWpCron(siteId: number, interval: number): Promise<{ event_id: number }> {
|
|
410
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/cron`, "POST", { interval })
|
|
411
|
+
}
|
|
412
|
+
updateWpCron(siteId: number, interval: number): Promise<{ event_id: number }> {
|
|
413
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/cron`, "PUT", { interval })
|
|
414
|
+
}
|
|
415
|
+
disableWpCron(siteId: number): Promise<{ event_id: number }> {
|
|
416
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/cron`, "DELETE")
|
|
417
|
+
}
|
|
418
|
+
|
|
354
419
|
// Enable HTTPS on a site. `type: "webroot"` requests a Let's Encrypt cert (the
|
|
355
420
|
// domain must already resolve to the server for LE to issue, so this runs after
|
|
356
421
|
// the DNS A record has propagated). Async → returns an event_id to poll. Needs a
|
package/src/api/types.ts
CHANGED
|
@@ -66,6 +66,24 @@ export interface AdditionalDomain {
|
|
|
66
66
|
created_at: string
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Page cache as the Site object reports it. The exclusion lists come back as ONE
|
|
70
|
+
// pipe-joined regex ("/wp-admin/|/wp-json/|…") but are WRITTEN newline-separated —
|
|
71
|
+
// see splitCacheList in lib/cloneSettings.ts. Unset lists read back null.
|
|
72
|
+
export interface PageCacheSettings {
|
|
73
|
+
enabled: boolean
|
|
74
|
+
duration?: number | null
|
|
75
|
+
duration_unit?: string | null
|
|
76
|
+
path_exclusions?: string | null
|
|
77
|
+
cookie_exclusions?: string | null
|
|
78
|
+
ignored_query_params?: string | null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface NginxSettings {
|
|
82
|
+
uploads_directory_protected?: boolean
|
|
83
|
+
xmlrpc_protected?: boolean
|
|
84
|
+
subdirectory_rewrite_in_place?: boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
69
87
|
export interface Site {
|
|
70
88
|
id: number
|
|
71
89
|
server_id: number
|
|
@@ -75,9 +93,9 @@ export interface Site {
|
|
|
75
93
|
php_version: string | null
|
|
76
94
|
public_folder: string | null
|
|
77
95
|
is_wordpress: boolean
|
|
78
|
-
page_cache?:
|
|
96
|
+
page_cache?: PageCacheSettings
|
|
79
97
|
https?: { enabled: boolean }
|
|
80
|
-
nginx?:
|
|
98
|
+
nginx?: NginxSettings
|
|
81
99
|
database?: {
|
|
82
100
|
id: number | null
|
|
83
101
|
user_id: number | null
|
|
@@ -193,6 +211,37 @@ export interface CreateSitePayload {
|
|
|
193
211
|
}
|
|
194
212
|
}
|
|
195
213
|
|
|
214
|
+
// PUT /sites/{id}/git — at least one field. Unlike POST /sites, deploy_script sits
|
|
215
|
+
// alongside the git settings here, and the write key is push_to_deploy.
|
|
216
|
+
export interface UpdateSiteGitPayload {
|
|
217
|
+
repo?: string
|
|
218
|
+
branch?: string
|
|
219
|
+
deploy_script?: string
|
|
220
|
+
push_to_deploy?: boolean
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// POST/PUT /sites/{id}/page-cache. Lists are newline-separated on write. PUT keeps
|
|
224
|
+
// any omitted field.
|
|
225
|
+
export interface PageCachePayload {
|
|
226
|
+
duration?: number
|
|
227
|
+
duration_unit?: string
|
|
228
|
+
path_exclusions?: string
|
|
229
|
+
cookie_exclusions?: string
|
|
230
|
+
ignored_query_params?: string
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// A site's path redirect (GET /sites/{id}/path-redirects). type: "permanent" = 301,
|
|
234
|
+
// "redirect" = 302. Delete is keyed by from + to — there's no id.
|
|
235
|
+
export interface PathRedirect {
|
|
236
|
+
from: string
|
|
237
|
+
to: string
|
|
238
|
+
type: "permanent" | "redirect"
|
|
239
|
+
created_at?: string
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The server-level WP cron intervals SpinupWP accepts, in minutes.
|
|
243
|
+
export const WP_CRON_INTERVALS = [1, 2, 5, 10, 15, 30, 60] as const
|
|
244
|
+
|
|
196
245
|
export interface Event {
|
|
197
246
|
id: number
|
|
198
247
|
initiated_by: string | null
|
package/src/dev/mockClient.ts
CHANGED
|
@@ -132,6 +132,62 @@ export function createMockClient(): SpinupWPClientLike {
|
|
|
132
132
|
return { event_id }
|
|
133
133
|
},
|
|
134
134
|
|
|
135
|
+
async updateSiteGit(siteId, payload) {
|
|
136
|
+
const site = sites.find((s) => s.id === siteId)
|
|
137
|
+
if (site?.git) {
|
|
138
|
+
if (payload.repo != null) site.git.repo = payload.repo
|
|
139
|
+
if (payload.branch != null) site.git.branch = payload.branch
|
|
140
|
+
if (payload.deploy_script != null) site.git.deploy_script = payload.deploy_script
|
|
141
|
+
if (payload.push_to_deploy != null) site.git.push_enabled = payload.push_to_deploy
|
|
142
|
+
}
|
|
143
|
+
return { event_id: null }
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
async enablePageCache(siteId, payload = {}) {
|
|
147
|
+
const site = sites.find((s) => s.id === siteId)
|
|
148
|
+
if (site) site.page_cache = { ...site.page_cache, ...payload, enabled: true }
|
|
149
|
+
return { event_id: pushEvent("site.page_cache.enabled", site?.server_id ?? null, `Page cache enabled for ${site?.domain ?? siteId}`) }
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
async updatePageCache(siteId, payload) {
|
|
153
|
+
const site = sites.find((s) => s.id === siteId)
|
|
154
|
+
if (site) site.page_cache = { enabled: site.page_cache?.enabled ?? false, ...site.page_cache, ...payload }
|
|
155
|
+
return { event_id: pushEvent("site.page_cache.updated", site?.server_id ?? null, `Page cache updated for ${site?.domain ?? siteId}`) }
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async disablePageCache(siteId) {
|
|
159
|
+
const site = sites.find((s) => s.id === siteId)
|
|
160
|
+
if (site) site.page_cache = { enabled: false }
|
|
161
|
+
return { event_id: pushEvent("site.page_cache.disabled", site?.server_id ?? null, `Page cache disabled for ${site?.domain ?? siteId}`) }
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
async updateNginx(siteId, payload) {
|
|
165
|
+
const site = sites.find((s) => s.id === siteId)
|
|
166
|
+
if (site) site.nginx = { ...site.nginx, ...payload }
|
|
167
|
+
return { event_ids: [pushEvent("site.nginx.updated", site?.server_id ?? null, `Nginx settings updated for ${site?.domain ?? siteId}`)] }
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
async listPathRedirects() {
|
|
171
|
+
return []
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
async addPathRedirect(siteId, payload) {
|
|
175
|
+
const site = sites.find((s) => s.id === siteId)
|
|
176
|
+
return { event_id: pushEvent("site.redirect.added", site?.server_id ?? null, `Redirect ${payload.from} added to ${site?.domain ?? siteId}`) }
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
async enableWpCron(siteId) {
|
|
180
|
+
return { event_id: pushEvent("site.cron.enabled", null, `WP cron enabled for site ${siteId}`) }
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async updateWpCron(siteId) {
|
|
184
|
+
return { event_id: pushEvent("site.cron.updated", null, `WP cron updated for site ${siteId}`) }
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
async disableWpCron(siteId) {
|
|
188
|
+
return { event_id: pushEvent("site.cron.disabled", null, `WP cron disabled for site ${siteId}`) }
|
|
189
|
+
},
|
|
190
|
+
|
|
135
191
|
async enableHttps(siteId) {
|
|
136
192
|
const site = sites.find((s) => s.id === siteId)
|
|
137
193
|
if (site) site.https = { enabled: true }
|
package/src/index.tsx
CHANGED
|
@@ -20,6 +20,7 @@ import { isDevMode } from "./dev/devMode.ts"
|
|
|
20
20
|
import { SpinupWPClient } from "./api/client.ts"
|
|
21
21
|
import { resolveSshAccess } from "./lib/cliSsh.ts"
|
|
22
22
|
import { resolveIncidents } from "./lib/cliIncidents.ts"
|
|
23
|
+
import { resolveSiblings } from "./lib/cliSiblings.ts"
|
|
23
24
|
import { execSshCommand } from "./lib/sshExec.ts"
|
|
24
25
|
import { runPullFiles, runPullDb } from "./lib/cliPull.ts"
|
|
25
26
|
|
|
@@ -72,6 +73,11 @@ Usage:
|
|
|
72
73
|
spinuptui ssh-exec <domain> [--server <name>] -- <command> Run a read-only
|
|
73
74
|
command over SSH (JSON); denies anything that looks
|
|
74
75
|
like a remote write
|
|
76
|
+
spinuptui siblings <domain> [--server <name>] [--probe] List every site
|
|
77
|
+
sharing a server with this one (JSON), flagging the
|
|
78
|
+
server's vanity host and which sites Kuma watches.
|
|
79
|
+
--probe also SSHes into each to report which ones this
|
|
80
|
+
device's key can actually read.
|
|
75
81
|
spinuptui incidents <domain> | --all [--hours N] Print Uptime Kuma
|
|
76
82
|
down/up incidents for a site or the whole fleet (JSON)
|
|
77
83
|
spinuptui pull files <domain> [path] [--url <local url>] [--server <name>]
|
|
@@ -163,6 +169,28 @@ if (command === "ssh-exec") {
|
|
|
163
169
|
process.exit(result.ok ? 0 : 1)
|
|
164
170
|
}
|
|
165
171
|
|
|
172
|
+
if (command === "siblings") {
|
|
173
|
+
const parsed = parseFlags(args.slice(1))
|
|
174
|
+
const domain = parsed.positionals[0]
|
|
175
|
+
const server = parsed.flags.get("--server") ?? null
|
|
176
|
+
const probe = parsed.flags.has("--probe")
|
|
177
|
+
if (!domain || badFlagValue(parsed.flags, "--server")) {
|
|
178
|
+
console.error(
|
|
179
|
+
JSON.stringify({
|
|
180
|
+
ok: false,
|
|
181
|
+
reason: "usage",
|
|
182
|
+
message: "Usage: spinuptui siblings <domain> [--server <name>] [--probe]",
|
|
183
|
+
}),
|
|
184
|
+
)
|
|
185
|
+
process.exit(1)
|
|
186
|
+
}
|
|
187
|
+
const cfg = loadConfig()
|
|
188
|
+
const client = new SpinupWPClient(cfg)
|
|
189
|
+
const result = await resolveSiblings(domain, client, cfg, { server, probe })
|
|
190
|
+
console.log(JSON.stringify(result))
|
|
191
|
+
process.exit(result.ok ? 0 : 1)
|
|
192
|
+
}
|
|
193
|
+
|
|
166
194
|
if (command === "incidents") {
|
|
167
195
|
const rest = args.slice(1)
|
|
168
196
|
const hoursIdx = rest.indexOf("--hours")
|
package/src/lib/appUpdate.ts
CHANGED
|
@@ -162,7 +162,14 @@ export function updateCommand(): string | null {
|
|
|
162
162
|
// hits the same wall until the following minor bump. Verified live against a
|
|
163
163
|
// real stale install (0.22.2 -> 0.24.0): `update -g` exited 0 without updating,
|
|
164
164
|
// `add -g spinuptui@latest` updated correctly.
|
|
165
|
-
|
|
165
|
+
//
|
|
166
|
+
// `--no-cache` because Bun answers `@latest` from its own cached copy of the
|
|
167
|
+
// registry metadata when it has one. Right after a release that copy can still
|
|
168
|
+
// name the previous version, so the add "succeeds" without contacting npm and
|
|
169
|
+
// installs nothing new — seen live on 1.1.0's release day: two runs installed
|
|
170
|
+
// 1.0.0 while the registry already served 1.1.0; the same command with
|
|
171
|
+
// `--no-cache` installed 1.1.0 at once.
|
|
172
|
+
export const PACKAGE_UPDATE_CMD = "bun add -g spinuptui@latest --no-cache"
|
|
166
173
|
|
|
167
174
|
// The real checkout directory, resolved through Bun's module system rather
|
|
168
175
|
// than process.cwd() — `spinuptui` is typically a global symlink invoked from
|
package/src/lib/cliIncidents.ts
CHANGED
|
@@ -35,6 +35,13 @@ const KIND_FIELDS: Array<[keyof KumaMonitorRef, IncidentKind]> = [
|
|
|
35
35
|
["bypassId", "bypass"],
|
|
36
36
|
]
|
|
37
37
|
|
|
38
|
+
// Does this ref name at least one monitor `resolveIncidents` would query? A ref
|
|
39
|
+
// can outlive its monitors (removing one deletes its id, not the entry), so an
|
|
40
|
+
// entry's mere presence isn't the answer.
|
|
41
|
+
export function hasIncidentMonitors(ref: KumaMonitorRef | undefined): boolean {
|
|
42
|
+
return !!ref && KIND_FIELDS.some(([field]) => typeof ref[field] === "number")
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
interface Target {
|
|
39
46
|
domain: string
|
|
40
47
|
kind: IncidentKind
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// `spinuptui siblings <domain>` — every site sharing a server with this one,
|
|
2
|
+
// for external tooling (e.g. an incident-diagnostics agent handed one domain).
|
|
3
|
+
//
|
|
4
|
+
// The motivating case: a server's vanity host pages on `?healthz`, but the
|
|
5
|
+
// vanity site is an empty placeholder — the load is always a co-tenant, and
|
|
6
|
+
// site isolation means the vanity user can't read the neighbour's logs. The
|
|
7
|
+
// agent needs to know who else lives on the box, and which of them it can
|
|
8
|
+
// actually SSH into, before it can find the request-level cause. Answering that
|
|
9
|
+
// from the API takes two calls; discovering it by hand took weeks.
|
|
10
|
+
|
|
11
|
+
import type { AppConfig } from "../config.ts"
|
|
12
|
+
import type { Server, Site } from "../api/types.ts"
|
|
13
|
+
import type { SpinupWPClientLike } from "../api/client.ts"
|
|
14
|
+
import { ApiError } from "../api/client.ts"
|
|
15
|
+
import {
|
|
16
|
+
resolveSiteByDomain,
|
|
17
|
+
probeSshTarget,
|
|
18
|
+
type SshAccessReason,
|
|
19
|
+
type SshAccessCandidate,
|
|
20
|
+
type SshProbeStatus,
|
|
21
|
+
} from "./cliSsh.ts"
|
|
22
|
+
import { hasIncidentMonitors } from "./cliIncidents.ts"
|
|
23
|
+
import { resolveSiteSshTarget } from "./probe.ts"
|
|
24
|
+
import { isVanityPair } from "./vanitySite.ts"
|
|
25
|
+
|
|
26
|
+
// Why a co-tenant's logs may be out of reach. `ok` means this device's key is
|
|
27
|
+
// accepted for that site user; `permission_denied` is the one worth acting on
|
|
28
|
+
// (grant the key with K in the Browser view) and the usual reason a neighbour's
|
|
29
|
+
// access log can't be read.
|
|
30
|
+
export type SiblingSshStatus = SshProbeStatus
|
|
31
|
+
|
|
32
|
+
export interface SiblingSite {
|
|
33
|
+
domain: string
|
|
34
|
+
siteUser: string | null
|
|
35
|
+
sshTarget: string
|
|
36
|
+
isWordPress: boolean
|
|
37
|
+
phpVersion: string | null
|
|
38
|
+
pageCache: boolean | null
|
|
39
|
+
// The site living at the server's own hostname — a placeholder whose monitors
|
|
40
|
+
// report on the whole box, so its pages are rarely about itself.
|
|
41
|
+
isVanity: boolean
|
|
42
|
+
// Has Uptime Kuma monitors registered through SpinupTUI, so `spinuptui
|
|
43
|
+
// incidents <domain>` will return something for it.
|
|
44
|
+
monitored: boolean
|
|
45
|
+
// The domain that was asked about, so a caller can tell itself apart from the
|
|
46
|
+
// neighbours without re-matching on additional domains.
|
|
47
|
+
isQuery: boolean
|
|
48
|
+
// Only present with --probe.
|
|
49
|
+
ssh?: SiblingSshStatus
|
|
50
|
+
sshMessage?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SiblingsServer {
|
|
54
|
+
id: number
|
|
55
|
+
name: string
|
|
56
|
+
ip: string | null
|
|
57
|
+
// Needed alongside each site's sshTarget; null means the default, 22.
|
|
58
|
+
sshPort: number | null
|
|
59
|
+
provider: string | null
|
|
60
|
+
size: string | null
|
|
61
|
+
ubuntuVersion: string | null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type SiblingsResult =
|
|
65
|
+
| {
|
|
66
|
+
ok: true
|
|
67
|
+
domain: string
|
|
68
|
+
primaryDomain: string
|
|
69
|
+
server: SiblingsServer
|
|
70
|
+
siteCount: number
|
|
71
|
+
sites: SiblingSite[]
|
|
72
|
+
}
|
|
73
|
+
| {
|
|
74
|
+
ok: false
|
|
75
|
+
domain: string
|
|
76
|
+
reason: SshAccessReason
|
|
77
|
+
message: string
|
|
78
|
+
remedy?: string
|
|
79
|
+
candidates?: SshAccessCandidate[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Every probe hits the same sshd, so they're capped rather than all fired at
|
|
83
|
+
// once: OpenSSH's default MaxStartups (10:30:100) starts dropping connections
|
|
84
|
+
// that are still authenticating past 10, which would turn a big server's
|
|
85
|
+
// result into random connection failures. A small burst of refused logins is
|
|
86
|
+
// also gentler on any fail2ban-style guard watching that sshd.
|
|
87
|
+
const PROBE_CONCURRENCY = 4
|
|
88
|
+
|
|
89
|
+
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
|
90
|
+
const out = new Array<R>(items.length)
|
|
91
|
+
let next = 0
|
|
92
|
+
const worker = async () => {
|
|
93
|
+
while (next < items.length) {
|
|
94
|
+
const i = next++
|
|
95
|
+
out[i] = await fn(items[i]!)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function describe(site: Site, server: Server, cfg: AppConfig, queryId: number): SiblingSite {
|
|
103
|
+
return {
|
|
104
|
+
domain: site.domain,
|
|
105
|
+
siteUser: site.site_user,
|
|
106
|
+
sshTarget: resolveSiteSshTarget(site, server, cfg.sshUser),
|
|
107
|
+
isWordPress: site.is_wordpress,
|
|
108
|
+
phpVersion: site.php_version,
|
|
109
|
+
pageCache: site.page_cache ? site.page_cache.enabled : null,
|
|
110
|
+
isVanity: isVanityPair(site.domain, server.name),
|
|
111
|
+
monitored: hasIncidentMonitors(cfg.kumaMonitors[site.domain]),
|
|
112
|
+
isQuery: site.id === queryId,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function resolveSiblings(
|
|
117
|
+
domain: string,
|
|
118
|
+
client: SpinupWPClientLike,
|
|
119
|
+
cfg: AppConfig,
|
|
120
|
+
opts?: { server?: string | null; probe?: boolean },
|
|
121
|
+
): Promise<SiblingsResult> {
|
|
122
|
+
const resolved = await resolveSiteByDomain(domain, client, cfg, { server: opts?.server ?? null })
|
|
123
|
+
if (!resolved.ok) return resolved.result
|
|
124
|
+
|
|
125
|
+
const { site, server } = resolved
|
|
126
|
+
|
|
127
|
+
let siteList: Site[]
|
|
128
|
+
try {
|
|
129
|
+
siteList = await client.listSites(server.id)
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
domain,
|
|
135
|
+
reason: "token_rejected",
|
|
136
|
+
message: "Saved API token was rejected (401) — it may be expired or revoked.",
|
|
137
|
+
remedy: "Run `spinuptui login` to save a fresh token.",
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const message = err instanceof ApiError ? err.message : `Unexpected error: ${(err as Error).message}`
|
|
141
|
+
return { ok: false, domain, reason: "api_error", message }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The vanity host first, then the queried site, then alphabetically: a reader
|
|
145
|
+
// scanning the list sees what the box calls itself before its tenants.
|
|
146
|
+
const sorted = [...siteList].sort((a, b) => {
|
|
147
|
+
const rank = (s: Site) =>
|
|
148
|
+
isVanityPair(s.domain, server.name) ? 0 : s.id === site.id ? 1 : 2
|
|
149
|
+
return rank(a) - rank(b) || a.domain.localeCompare(b.domain)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const sites = sorted.map((s) => describe(s, server, cfg, site.id))
|
|
153
|
+
const port = server.ssh_port && server.ssh_port !== 22 ? server.ssh_port : null
|
|
154
|
+
|
|
155
|
+
// resolveSiteByDomain has already refused a server with no IP, so every
|
|
156
|
+
// sshTarget here is a real user@ip.
|
|
157
|
+
if (opts?.probe) {
|
|
158
|
+
const probes = await mapLimit(sites, PROBE_CONCURRENCY, (s) => probeSshTarget(s.sshTarget, port))
|
|
159
|
+
probes.forEach((p, i) => {
|
|
160
|
+
sites[i]!.ssh = p.status
|
|
161
|
+
if (p.message) sites[i]!.sshMessage = p.message
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
ok: true,
|
|
167
|
+
domain,
|
|
168
|
+
primaryDomain: site.domain,
|
|
169
|
+
server: {
|
|
170
|
+
id: server.id,
|
|
171
|
+
name: server.name,
|
|
172
|
+
ip: server.ip_address,
|
|
173
|
+
sshPort: port,
|
|
174
|
+
provider: server.provider_name,
|
|
175
|
+
size: server.size,
|
|
176
|
+
ubuntuVersion: server.ubuntu_version,
|
|
177
|
+
},
|
|
178
|
+
siteCount: sites.length,
|
|
179
|
+
sites,
|
|
180
|
+
}
|
|
181
|
+
}
|