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 +39 -4
- package/README.md +36 -8
- package/bun.lock +103 -0
- package/package.json +3 -2
- package/src/api/client.ts +98 -3
- 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 +16 -4
- 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 +173 -12
- package/src/lib/spinupweb.ts +7 -0
- package/src/lib/stack.ts +7 -4
- package/src/lib/wpCli.ts +52 -0
- package/src/lib/wpCore.ts +7 -18
- package/src/lib/wpInventory.ts +18 -14
- package/src/ui/Header.tsx +26 -0
- package/src/ui/Help.tsx +3 -3
- package/src/ui/store.tsx +104 -11
- package/src/ui/views/CloneWizard.tsx +52 -5
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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/
|
|
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
|
|
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
|
|
@@ -112,14 +114,20 @@
|
|
|
112
114
|
|
|
113
115
|
## Install & run
|
|
114
116
|
|
|
115
|
-
The easy way —
|
|
117
|
+
The easy way — Homebrew (it installs Bun for you):
|
|
116
118
|
|
|
117
119
|
```sh
|
|
118
|
-
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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?:
|
|
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
|
@@ -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
|
|
133
|
-
//
|
|
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) {
|