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 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.0.0...HEAD
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 pull files
86
- <domain> [path]` and `spinuptui pull db <domain> --yes` set up a local working
87
- copy from production without opening the TUI at all — the same engines the
88
- guided flow drives. Built so another agent/script can go from just a domain to
89
- "what's wrong and how do I get in", or to a working local copy, with no manual
90
- lookup. → [CLI subcommands](#cli-subcommands)
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 panel shows
167
- the same command). Don't use `bun update -g` — while spinuptui is pre-1.0,
168
- it records a caret range (e.g. `^0.22.2`) that excludes every later minor
169
- release, so `update -g` reports success but silently doesn't update.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
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/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?: { enabled: boolean }
96
+ page_cache?: PageCacheSettings
79
97
  https?: { enabled: boolean }
80
- nginx?: Record<string, unknown>
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
@@ -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")
@@ -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
- export const PACKAGE_UPDATE_CMD = "bun add -g spinuptui@latest"
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
@@ -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
+ }