spinuptui 0.28.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,12 +5,45 @@ All notable changes to this project are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- While the project is in `0.x`, the public surface (CLI subcommands/flags, config
9
- file location and format, and token-resolution behavior) may change between minor
10
- versions; such changes are called out here.
8
+ From 1.0.0, the public surface (CLI subcommands/flags, config file location and
9
+ format, and token-resolution behavior) changes incompatibly only in a major
10
+ version; such changes are called out here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.1.0] - 2026-09-30
15
+
16
+ ### Added
17
+ - **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.
18
+ - **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.
19
+ - **`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.
20
+
21
+ ### Changed
22
+ - **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.
23
+
24
+ ### Fixed
25
+ - **`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.
26
+
27
+ ### Notes
28
+ - **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.
29
+
30
+ ## [1.0.0] - 2026-09-24
31
+
32
+ ### Added
33
+ - **Install with Homebrew.** `brew install mwender/tap/spinuptui` installs SpinupTUI along with Bun, and `brew upgrade spinuptui` updates it. The About panel (`?`) recognizes a Homebrew install and shows `brew upgrade spinuptui` as its update command instead of the Bun one.
34
+ - **API headroom in the header.** The top bar now shows how many SpinupWP API requests are left in the current minute (`API 52/60`). It stays dim while there's plenty, turns amber once three-quarters of the minute's budget is spent, and red when SpinupTUI starts spacing out requests to avoid hitting the limit, so a slow moment has a visible reason. It refills on its own when the minute rolls over.
35
+ - **Clone Radicle sites.** The clone wizard now clones git-deployed Radicle sites properly. Uploads come across from `content/uploads`, and after the database import the site's own deploy script runs on the new server, so the front-end is built (`npm ci`, `npm run build`, Acorn caches) the way a normal deploy would. Before creating anything, the wizard stops if the Radicle site has no deploy script, or if its script needs Node and the destination server doesn't have it. SpinupWP servers don't come with Node, so install it on the destination first.
36
+ - **Clones keep your cron jobs.** Lines you added to a site's crontab (a feed import, a report, an Acorn or Laravel command) are copied to the clone under a `# Carried over from …` comment. Lines you commented out are copied as they are, SpinupWP's own WordPress cron entry is left to SpinupWP, and a retry never adds a line twice. The verify pane (`v`) says how many lines came across.
37
+
38
+ ### Changed
39
+ - **Clones match the source's page cache and push-to-deploy** instead of using fixed defaults, and send the source's own deploy script.
40
+
41
+ ### Fixed
42
+ - **Plugins & themes (`e`) no longer shows an empty list on sites with noisy plugins.** Some plugins print PHP notices into wp-cli's output, sometimes on the same line as the plugin list, and the view read that as "no plugins installed". The list is now found wherever it sits in the output, and if wp-cli returns no list at all the view says so, with the first line of what it printed, instead of showing zero plugins. The WordPress core update check uses the same parsing.
43
+
44
+ ### Notes
45
+ - **SpinupWP clears a deploy script set through its API** a minute or two after the site is created, and there's no API call to set it afterwards. After a clone, the wizard checks the new site; if the script is gone, the roster marks the site and the verify pane offers `g`, which copies the source's script and opens the new site's Git settings so you can paste it back. The same check flags a push-to-deploy setting that didn't carry over.
46
+
14
47
  ## [0.28.0] - 2026-09-23
15
48
 
16
49
  ### Added
@@ -1436,7 +1469,9 @@ Initial tagged release.
1436
1469
  ### Notes
1437
1470
  - Read-only release: works with a SpinupWP **Read Only** API token.
1438
1471
 
1439
- [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.28.0...HEAD
1472
+ [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.1.0...HEAD
1473
+ [1.1.0]: https://github.com/mwender/spinupwp-tui/compare/v1.0.0...v1.1.0
1474
+ [1.0.0]: https://github.com/mwender/spinupwp-tui/compare/v0.28.0...v1.0.0
1440
1475
  [0.28.0]: https://github.com/mwender/spinupwp-tui/compare/v0.27.0...v0.28.0
1441
1476
  [0.27.0]: https://github.com/mwender/spinupwp-tui/compare/v0.26.1...v0.27.0
1442
1477
  [0.26.1]: https://github.com/mwender/spinupwp-tui/compare/v0.26.0...v0.26.1
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
@@ -112,14 +114,20 @@
112
114
 
113
115
  ## Install & run
114
116
 
115
- The easy way — install the published package globally:
117
+ The easy way — Homebrew (it installs Bun for you):
116
118
 
117
119
  ```sh
118
- bun install -g spinuptui
120
+ brew install mwender/tap/spinuptui
119
121
  spinuptui login # save your API token to the config file (once)
120
122
  spinuptui # launch from any directory
121
123
  ```
122
124
 
125
+ Or, if you already have Bun, install the published package globally:
126
+
127
+ ```sh
128
+ bun install -g spinuptui
129
+ ```
130
+
123
131
  On first launch, if no token is configured you'll be guided through a short
124
132
  onboarding flow that validates your token and saves it locally.
125
133
 
@@ -155,6 +163,8 @@ bun run build:binary # produces ./spinuptui — move it onto your PATH
155
163
  The app tells you when a newer release exists — a gold `✦ vX.Y.Z` appears next
156
164
  to the version in the header (and in the `?` About panel).
157
165
 
166
+ - **Homebrew:** `brew upgrade spinuptui` (the About panel shows the same
167
+ command).
158
168
  - **Package install:** `bun add -g spinuptui@latest` (the About panel shows
159
169
  the same command). Don't use `bun update -g` — while spinuptui is pre-1.0,
160
170
  it records a caret range (e.g. `^0.22.2`) that excludes every later minor
@@ -191,6 +201,24 @@ spinuptui ssh-exec <domain> [--server <name>] -- <command> Resolve the
191
201
  attempt (allowed, denied, or unresolved) is appended to
192
202
  <config dir>/logs/ssh-exec-audit.jsonl. Quote <command>
193
203
  as one shell argument if it needs pipes or redirects.
204
+ spinuptui siblings <domain> [--server <name>] [--probe] Print every site
205
+ sharing a server with this one, as JSON, with the
206
+ server's own details (IP, SSH port, size, Ubuntu
207
+ version). Each entry carries its site user and SSH
208
+ target, whether it's WordPress, its PHP version, whether
209
+ page cache is on, whether it's the server's vanity host
210
+ (isVanity), whether SpinupTUI registered Uptime Kuma
211
+ monitors for it (monitored), and whether it's the domain
212
+ you asked about (isQuery). The vanity host sorts first,
213
+ then the queried site, then the rest alphabetically.
214
+ --probe additionally SSHes into each site user and
215
+ reports ssh:"ok" | "permission_denied" |
216
+ "connection_failed" | "ssh_error", which answers "whose
217
+ logs can I actually read on this box" in one call.
218
+ Written for whole-server symptoms: a vanity host's
219
+ ?healthz trips on the load of whichever co-tenant is
220
+ busy, and site isolation means the vanity user can't see
221
+ it.
194
222
  spinuptui incidents <domain> | --all [--hours N] Print Uptime Kuma down/up
195
223
  incidents as JSON, scoped to sites SpinupTUI manages
196
224
  monitoring for (config.json's kumaMonitors) — --all
package/bun.lock ADDED
@@ -0,0 +1,103 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "spinuptui",
7
+ "dependencies": {
8
+ "@opentui/core": "^0.4.1",
9
+ "@opentui/react": "^0.4.1",
10
+ "react": "^19.0.0",
11
+ "socket.io-client": "^4.8.3",
12
+ },
13
+ "devDependencies": {
14
+ "@types/bun": "^1.3.14",
15
+ "@types/react": "^19.0.0",
16
+ "typescript": "^5.6.0",
17
+ },
18
+ },
19
+ },
20
+ "packages": {
21
+ "@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="],
22
+
23
+ "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
24
+
25
+ "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
26
+
27
+ "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
28
+
29
+ "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="],
30
+
31
+ "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
32
+
33
+ "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
34
+
35
+ "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
36
+
37
+ "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
38
+
39
+ "@opentui/react": ["@opentui/react@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5", "react-reconciler": "^0.33.0" }, "peerDependencies": { "react": ">=19.2.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-n88Vx0cMmAu++/S14WscjvdcT46IDcxve02jMtcfHQ9j6cySRHfILfGEpOB7xxc8RpCwQceEyOjkKkfzPzm6Jg=="],
40
+
41
+ "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
42
+
43
+ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
44
+
45
+ "@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="],
46
+
47
+ "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
48
+
49
+ "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
50
+
51
+ "bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
52
+
53
+ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
54
+
55
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
56
+
57
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
58
+
59
+ "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
60
+
61
+ "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
62
+
63
+ "engine.io-client": ["engine.io-client@6.6.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q=="],
64
+
65
+ "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
66
+
67
+ "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
68
+
69
+ "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
70
+
71
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
72
+
73
+ "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
74
+
75
+ "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="],
76
+
77
+ "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
78
+
79
+ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
80
+
81
+ "shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="],
82
+
83
+ "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
84
+
85
+ "socket.io-parser": ["socket.io-parser@4.2.7", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg=="],
86
+
87
+ "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
88
+
89
+ "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
90
+
91
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
92
+
93
+ "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
94
+
95
+ "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
96
+
97
+ "ws": ["ws@8.21.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw=="],
98
+
99
+ "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
100
+
101
+ "react-devtools-core/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="],
102
+ }
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "0.28.0",
3
+ "version": "1.1.0",
4
4
  "description": "SpinupTUI — a terminal dashboard for browsing, monitoring, and managing your SpinupWP servers and sites.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,8 @@
11
11
  "src",
12
12
  "README.md",
13
13
  "CHANGELOG.md",
14
- "LICENSE"
14
+ "LICENSE",
15
+ "bun.lock"
15
16
  ],
16
17
  "repository": {
17
18
  "type": "git",
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 }>
@@ -110,15 +124,44 @@ const MAX_PAGES = 100 // hard safety cap when auto-paginating
110
124
  // event polling burned the window and made SUCCESSFUL operations look failed.
111
125
  let rlRemaining = Infinity
112
126
  let rlResetAt = 0
113
- const RL_LOW_WATER = 8 // start pacing when fewer than this many requests remain
127
+ let rlLimit = 0 // X-RateLimit-Limit as last seen; 0 = no response yet
128
+ export const RL_LOW_WATER = 8 // start pacing when fewer than this many requests remain
114
129
  const RL_MAX_RETRIES = 3
115
130
  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
116
131
 
117
132
  function rlUpdate(headers: Headers): void {
118
- const rem = Number(headers.get("x-ratelimit-remaining"))
119
- if (Number.isFinite(rem)) rlRemaining = rem
133
+ // Number(null) is 0, so a missing header must not read as "0 left".
134
+ const remHeader = headers.get("x-ratelimit-remaining")
135
+ const rem = Number(remHeader)
136
+ if (remHeader !== null && Number.isFinite(rem)) rlRemaining = rem
137
+ const limit = Number(headers.get("x-ratelimit-limit"))
138
+ if (Number.isFinite(limit) && limit > 0) rlLimit = limit
120
139
  // No reset header outside 429s — assume the standard 1-minute window rolls over.
121
140
  if (rlResetAt < Date.now()) rlResetAt = Date.now() + 60_000
141
+ rlNotify()
142
+ }
143
+
144
+ // Read side for the header's headroom indicator: what's left of this minute's
145
+ // budget, so a user sees the wall coming before a 429 (or before pacing makes
146
+ // the app feel slow). null until the first response has told us the limit.
147
+ export interface RateLimitSnapshot {
148
+ remaining: number
149
+ limit: number
150
+ resetAt: number
151
+ }
152
+ const rlListeners = new Set<() => void>()
153
+ function rlNotify(): void {
154
+ for (const l of rlListeners) l()
155
+ }
156
+ export function rateLimitSnapshot(): RateLimitSnapshot | null {
157
+ if (!rlLimit || !Number.isFinite(rlRemaining)) return null
158
+ // Past the window's end the last count is stale: a fresh window is full.
159
+ const remaining = rlResetAt < Date.now() ? rlLimit : rlRemaining
160
+ return { remaining, limit: rlLimit, resetAt: rlResetAt }
161
+ }
162
+ export function onRateLimitChange(listener: () => void): () => void {
163
+ rlListeners.add(listener)
164
+ return () => rlListeners.delete(listener)
122
165
  }
123
166
 
124
167
  async function rlGate(): Promise<void> {
@@ -154,6 +197,7 @@ export class SpinupWPClient implements SpinupWPClientLike {
154
197
  const waitMs = (Number.isFinite(ra) && ra > 0 ? ra : 20) * 1000
155
198
  rlRemaining = 0
156
199
  rlResetAt = Date.now() + waitMs
200
+ rlNotify()
157
201
  await sleep(waitMs + Math.random() * 500)
158
202
  continue
159
203
  }
@@ -321,6 +365,57 @@ export class SpinupWPClient implements SpinupWPClientLike {
321
365
  return this.mutate<{ event_id: number }>(`/sites/${siteId}/domains`, "POST", payload)
322
366
  }
323
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
+
324
419
  // Enable HTTPS on a site. `type: "webroot"` requests a Let's Encrypt cert (the
325
420
  // domain must already resolve to the server for LE to issue, so this runs after
326
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")
@@ -129,15 +129,27 @@ export async function refreshUpdateInfo(current: string): Promise<UpdateInfo | n
129
129
  // ---- In-app updater (`u` in the Help/About overlay) ------------------------
130
130
 
131
131
  // How this copy of the app was installed, which decides the update path shown
132
- // in Help/About: a package-manager install (bun/npm global) lives under a
133
- // node_modules dir and updates via `bun update -g spinuptui`; anything else is
132
+ // in Help/About: a Homebrew install lives in the Cellar (the formula unpacks the
133
+ // package into its libexec) and updates via `brew upgrade`; a package-manager
134
+ // install (bun/npm global) lives under a node_modules dir; anything else is
134
135
  // treated as a git checkout and updates via `git pull` (runSelfUpdate).
135
- export type InstallChannel = "git" | "package"
136
+ export type InstallChannel = "git" | "package" | "brew"
136
137
 
137
138
  export function installChannel(): InstallChannel {
139
+ if (import.meta.dir.includes(`${sep}Cellar${sep}spinuptui${sep}`)) return "brew"
138
140
  return import.meta.dir.includes(`${sep}node_modules${sep}`) ? "package" : "git"
139
141
  }
140
142
 
143
+ // Homebrew owns its copy — `bun add -g` beside it would install a second one
144
+ // that the `spinuptui` on PATH never runs.
145
+ export const BREW_UPDATE_CMD = "brew upgrade spinuptui"
146
+
147
+ // The manual update command for this install (null for a git checkout).
148
+ export function updateCommand(): string | null {
149
+ const ch = installChannel()
150
+ return ch === "brew" ? BREW_UPDATE_CMD : ch === "package" ? PACKAGE_UPDATE_CMD : null
151
+ }
152
+
141
153
  // The one-liner Help shows for a package install (also used by runSelfUpdate's
142
154
  // "not a checkout" message so every path names the same command).
143
155
  //
@@ -187,7 +199,7 @@ export async function runSelfUpdate(cwd: string = checkoutRoot()): Promise<SelfU
187
199
  try {
188
200
  const isRepo = await git(cwd, ["rev-parse", "--is-inside-work-tree"])
189
201
  if (isRepo.code !== 0 || isRepo.stdout !== "true") {
190
- return { ok: false, message: `This install isn't a git checkout — update with \`${PACKAGE_UPDATE_CMD}\`.`, needsInstall: false }
202
+ return { ok: false, message: `This install isn't a git checkout — update with \`${updateCommand() ?? PACKAGE_UPDATE_CMD}\`.`, needsInstall: false }
191
203
  }
192
204
  const status = await git(cwd, ["status", "--porcelain"])
193
205
  if (status.stdout.length > 0) {