spinuptui 0.28.0 → 1.0.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 +22 -4
- package/README.md +10 -2
- package/bun.lock +103 -0
- package/package.json +3 -2
- package/src/api/client.ts +33 -3
- package/src/lib/appUpdate.ts +16 -4
- package/src/lib/serverClone.ts +145 -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 +52 -10
- package/src/ui/views/CloneWizard.tsx +33 -5
package/CHANGELOG.md
CHANGED
|
@@ -5,12 +5,29 @@ 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.0.0] - 2026-09-24
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **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.
|
|
18
|
+
- **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.
|
|
19
|
+
- **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.
|
|
20
|
+
- **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.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- **Clones match the source's page cache and push-to-deploy** instead of using fixed defaults, and send the source's own deploy script.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- **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.
|
|
27
|
+
|
|
28
|
+
### Notes
|
|
29
|
+
- **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.
|
|
30
|
+
|
|
14
31
|
## [0.28.0] - 2026-09-23
|
|
15
32
|
|
|
16
33
|
### Added
|
|
@@ -1436,7 +1453,8 @@ Initial tagged release.
|
|
|
1436
1453
|
### Notes
|
|
1437
1454
|
- Read-only release: works with a SpinupWP **Read Only** API token.
|
|
1438
1455
|
|
|
1439
|
-
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/
|
|
1456
|
+
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.0.0...HEAD
|
|
1457
|
+
[1.0.0]: https://github.com/mwender/spinupwp-tui/compare/v0.28.0...v1.0.0
|
|
1440
1458
|
[0.28.0]: https://github.com/mwender/spinupwp-tui/compare/v0.27.0...v0.28.0
|
|
1441
1459
|
[0.27.0]: https://github.com/mwender/spinupwp-tui/compare/v0.26.1...v0.27.0
|
|
1442
1460
|
[0.26.1]: https://github.com/mwender/spinupwp-tui/compare/v0.26.0...v0.26.1
|
package/README.md
CHANGED
|
@@ -112,14 +112,20 @@
|
|
|
112
112
|
|
|
113
113
|
## Install & run
|
|
114
114
|
|
|
115
|
-
The easy way —
|
|
115
|
+
The easy way — Homebrew (it installs Bun for you):
|
|
116
116
|
|
|
117
117
|
```sh
|
|
118
|
-
|
|
118
|
+
brew install mwender/tap/spinuptui
|
|
119
119
|
spinuptui login # save your API token to the config file (once)
|
|
120
120
|
spinuptui # launch from any directory
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
+
Or, if you already have Bun, install the published package globally:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
bun install -g spinuptui
|
|
127
|
+
```
|
|
128
|
+
|
|
123
129
|
On first launch, if no token is configured you'll be guided through a short
|
|
124
130
|
onboarding flow that validates your token and saves it locally.
|
|
125
131
|
|
|
@@ -155,6 +161,8 @@ bun run build:binary # produces ./spinuptui — move it onto your PATH
|
|
|
155
161
|
The app tells you when a newer release exists — a gold `✦ vX.Y.Z` appears next
|
|
156
162
|
to the version in the header (and in the `?` About panel).
|
|
157
163
|
|
|
164
|
+
- **Homebrew:** `brew upgrade spinuptui` (the About panel shows the same
|
|
165
|
+
command).
|
|
158
166
|
- **Package install:** `bun add -g spinuptui@latest` (the About panel shows
|
|
159
167
|
the same command). Don't use `bun update -g` — while spinuptui is pre-1.0,
|
|
160
168
|
it records a caret range (e.g. `^0.22.2`) that excludes every later minor
|
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.
|
|
3
|
+
"version": "1.0.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
|
@@ -110,15 +110,44 @@ const MAX_PAGES = 100 // hard safety cap when auto-paginating
|
|
|
110
110
|
// event polling burned the window and made SUCCESSFUL operations look failed.
|
|
111
111
|
let rlRemaining = Infinity
|
|
112
112
|
let rlResetAt = 0
|
|
113
|
-
|
|
113
|
+
let rlLimit = 0 // X-RateLimit-Limit as last seen; 0 = no response yet
|
|
114
|
+
export const RL_LOW_WATER = 8 // start pacing when fewer than this many requests remain
|
|
114
115
|
const RL_MAX_RETRIES = 3
|
|
115
116
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
116
117
|
|
|
117
118
|
function rlUpdate(headers: Headers): void {
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
// Number(null) is 0, so a missing header must not read as "0 left".
|
|
120
|
+
const remHeader = headers.get("x-ratelimit-remaining")
|
|
121
|
+
const rem = Number(remHeader)
|
|
122
|
+
if (remHeader !== null && Number.isFinite(rem)) rlRemaining = rem
|
|
123
|
+
const limit = Number(headers.get("x-ratelimit-limit"))
|
|
124
|
+
if (Number.isFinite(limit) && limit > 0) rlLimit = limit
|
|
120
125
|
// No reset header outside 429s — assume the standard 1-minute window rolls over.
|
|
121
126
|
if (rlResetAt < Date.now()) rlResetAt = Date.now() + 60_000
|
|
127
|
+
rlNotify()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Read side for the header's headroom indicator: what's left of this minute's
|
|
131
|
+
// budget, so a user sees the wall coming before a 429 (or before pacing makes
|
|
132
|
+
// the app feel slow). null until the first response has told us the limit.
|
|
133
|
+
export interface RateLimitSnapshot {
|
|
134
|
+
remaining: number
|
|
135
|
+
limit: number
|
|
136
|
+
resetAt: number
|
|
137
|
+
}
|
|
138
|
+
const rlListeners = new Set<() => void>()
|
|
139
|
+
function rlNotify(): void {
|
|
140
|
+
for (const l of rlListeners) l()
|
|
141
|
+
}
|
|
142
|
+
export function rateLimitSnapshot(): RateLimitSnapshot | null {
|
|
143
|
+
if (!rlLimit || !Number.isFinite(rlRemaining)) return null
|
|
144
|
+
// Past the window's end the last count is stale: a fresh window is full.
|
|
145
|
+
const remaining = rlResetAt < Date.now() ? rlLimit : rlRemaining
|
|
146
|
+
return { remaining, limit: rlLimit, resetAt: rlResetAt }
|
|
147
|
+
}
|
|
148
|
+
export function onRateLimitChange(listener: () => void): () => void {
|
|
149
|
+
rlListeners.add(listener)
|
|
150
|
+
return () => rlListeners.delete(listener)
|
|
122
151
|
}
|
|
123
152
|
|
|
124
153
|
async function rlGate(): Promise<void> {
|
|
@@ -154,6 +183,7 @@ export class SpinupWPClient implements SpinupWPClientLike {
|
|
|
154
183
|
const waitMs = (Number.isFinite(ra) && ra > 0 ? ra : 20) * 1000
|
|
155
184
|
rlRemaining = 0
|
|
156
185
|
rlResetAt = Date.now() + waitMs
|
|
186
|
+
rlNotify()
|
|
157
187
|
await sleep(waitMs + Math.random() * 500)
|
|
158
188
|
continue
|
|
159
189
|
}
|
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) {
|
package/src/lib/serverClone.ts
CHANGED
|
@@ -472,6 +472,13 @@ export interface BedrockPullSpec {
|
|
|
472
472
|
destDbPassword: string
|
|
473
473
|
excludeUploads: boolean
|
|
474
474
|
publicFolder?: string // a signal, not a fact (CLAUDE.md) — re-verified below, independently, on both ends
|
|
475
|
+
// The source site's own git deploy script. Run on the dest after the DB import
|
|
476
|
+
// when the source is Radicle: its front-end (Vite build → public/build) and
|
|
477
|
+
// Acorn caches are gitignored, so composer install alone leaves the theme
|
|
478
|
+
// unbuilt, and the owner's deploy script is their own definition of "build this
|
|
479
|
+
// site". It runs last because Radicle deploy scripts call wp-cli (acorn
|
|
480
|
+
// optimize, cache purge, rewrite flush), which needs the imported database.
|
|
481
|
+
deployScript?: string
|
|
475
482
|
}
|
|
476
483
|
|
|
477
484
|
export async function runBedrockPull(
|
|
@@ -515,9 +522,12 @@ export async function runBedrockPull(
|
|
|
515
522
|
// source happened to detect — its structure comes from the git repo's own
|
|
516
523
|
// content, which can legitimately diverge from the source's on-disk state.
|
|
517
524
|
onProgress("detect", "start")
|
|
518
|
-
const det = await run("detect", source, `${detectWpDirScript(root, spec.publicFolder)}; echo "WPCORE:$W"; echo "BEDROCKROOT:$B"`, 30_000)
|
|
525
|
+
const det = await run("detect", source, `${detectWpDirScript(root, spec.publicFolder)}; echo "WPCORE:$W"; echo "BEDROCKROOT:$B"; echo "RADICLEROOT:$RD"`, 30_000)
|
|
519
526
|
const wpCore = (det.stdout.match(/^WPCORE:(.*)$/m)?.[1] ?? "").trim()
|
|
520
527
|
const srcProjectRoot = (det.stdout.match(/^BEDROCKROOT:(.*)$/m)?.[1] ?? "").trim()
|
|
528
|
+
// Radicle requires roots/bedrock-autoloader, so it passes the Bedrock check
|
|
529
|
+
// above too — this is what tells the two apart.
|
|
530
|
+
const radicle = (det.stdout.match(/^RADICLEROOT:(.*)$/m)?.[1] ?? "").trim() !== ""
|
|
521
531
|
if (!det.ok || !wpCore || !srcProjectRoot) {
|
|
522
532
|
return fail("detect", `couldn't find a Bedrock project (composer.json + WordPress core) under ${root} on the source — is the public folder set correctly?`)
|
|
523
533
|
}
|
|
@@ -577,20 +587,23 @@ export async function runBedrockPull(
|
|
|
577
587
|
if (!composer.ok) return fail("build", (composer.stdout + composer.stderr).trim().split("\n").slice(-3).join(" ") || "composer install failed")
|
|
578
588
|
onProgress("build", "ok")
|
|
579
589
|
|
|
580
|
-
// 3. files — pull
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
//
|
|
590
|
+
// 3. files — pull the uploads dir under the public folder: Bedrock's
|
|
591
|
+
// app/uploads or Radicle's content/uploads, whichever the source has.
|
|
592
|
+
// `web` is whatever the detected public folder is actually named, and it
|
|
593
|
+
// can differ in nesting depth between source and dest (the repo's own
|
|
594
|
+
// content vs. how deep the source's on-disk state happens to be).
|
|
595
|
+
// Archiving/extracting relative to EACH side's own web dir (not `root`)
|
|
596
|
+
// keeps the member name (app/uploads or content/uploads) the same on both
|
|
597
|
+
// ends, so they never need to agree on a shared relative path. Best-effort:
|
|
598
|
+
// a site with no uploads dir yields an empty stream we simply skip.
|
|
587
599
|
onProgress("files", "start")
|
|
588
600
|
if (!spec.excludeUploads) {
|
|
589
601
|
const stopUpPoll = pollTransferSize(dest, `${tmp}_up.tgz`, (b) => onTransfer?.("files", b))
|
|
602
|
+
const srcUploads = `cd ${shq(webDir)} && for d in app/uploads content/uploads; do if [ -d "$d" ]; then tar --warning=no-file-changed -czf - "$d"; rc=$?; [ $rc -le 1 ] && exit 0; exit $rc; fi; done; true`
|
|
590
603
|
const up = await run(
|
|
591
604
|
"files",
|
|
592
605
|
dest,
|
|
593
|
-
`set -e; timeout -k 5 3600 ${remote(
|
|
606
|
+
`set -e; timeout -k 5 3600 ${remote(srcUploads)} > ${tmp}_up.tgz; if [ -s ${tmp}_up.tgz ]; then tar -C ${shq(destWebDir)} -xzf ${tmp}_up.tgz && for d in app/uploads content/uploads; do [ -d ${shq(destWebDir)}/$d ] && chown -R ${shq(du)}:${shq(du)} ${shq(destWebDir)}/$d; done; true; fi; rm -f ${tmp}_up.tgz`,
|
|
594
607
|
3_660_000, // outer must exceed the in-script `timeout 3600` so the inner reports a clean 124
|
|
595
608
|
)
|
|
596
609
|
stopUpPoll()
|
|
@@ -641,6 +654,23 @@ export async function runBedrockPull(
|
|
|
641
654
|
if (!imp.ok) return fail("db", imp.stderr.trim() || "db pull/import failed")
|
|
642
655
|
onProgress("db", "ok")
|
|
643
656
|
|
|
657
|
+
// 5b. deploy — Radicle only: run the source's own deploy script (see
|
|
658
|
+
// BedrockPullSpec.deployScript). Staged as a file owned by the site user
|
|
659
|
+
// (base64 in, so no quoting of the owner's script is ever needed) and run
|
|
660
|
+
// from the project root, exactly where SpinupWP's own deploys run it.
|
|
661
|
+
if (radicle && spec.deployScript?.trim()) {
|
|
662
|
+
onProgress("build", "start", "deploy script")
|
|
663
|
+
const b64 = Buffer.from(spec.deployScript.replace(/\r\n/g, "\n")).toString("base64")
|
|
664
|
+
const deploy = await run(
|
|
665
|
+
"build",
|
|
666
|
+
dest,
|
|
667
|
+
`set -e; echo ${shq(b64)} | base64 -d > ${tmp}_deploy.sh; chown ${shq(du)}:${shq(du)} ${tmp}_deploy.sh; chmod 600 ${tmp}_deploy.sh; cd ${shq(destProjectRoot)}; rc=0; sudo -u ${shq(du)} -H bash -lc ${shq(`cd ${shq(destProjectRoot)} && bash ${tmp}_deploy.sh`)} 2>&1 || rc=$?; rm -f ${tmp}_deploy.sh; exit $rc`,
|
|
668
|
+
1_200_000,
|
|
669
|
+
)
|
|
670
|
+
if (!deploy.ok) return fail("build", `the site's deploy script failed: ${(deploy.stdout + deploy.stderr).trim().split("\n").slice(-3).join(" ") || `exit ${deploy.code}`}`)
|
|
671
|
+
onProgress("build", "ok")
|
|
672
|
+
}
|
|
673
|
+
|
|
644
674
|
// 6. verify — wp-cli on the dest (HTTP --resolve is the caller's).
|
|
645
675
|
onProgress("verify", "start")
|
|
646
676
|
const ver = await run("verify", dest, `cd ${shq(destProjectRoot)}; sudo -u ${shq(du)} -H wp core is-installed`, 30_000)
|
|
@@ -652,7 +682,7 @@ export async function runBedrockPull(
|
|
|
652
682
|
// 7. revoke — drop the pull key (by marker) on source, clean dest temp + staged dump.
|
|
653
683
|
onProgress("revoke", "start")
|
|
654
684
|
await run("revoke", source, `AK=${shq(home)}/.ssh/authorized_keys; [ -f "$AK" ] && sed -i ${shq(`/${MARKER}/d`)} "$AK" && chown ${shq(su)}:${shq(su)} "$AK"; rm -f ${shq(home)}/.clone_db.sql.gz`, 30_000).catch(() => {})
|
|
655
|
-
await run("revoke", dest, `rm -f ${KEY} ${KEY}.pub ${tmp}_auth.json ${tmp}_up.tgz ${tmp}_env ${tmp}.sql.gz ${tmp}.sql`, 30_000).catch(() => {})
|
|
685
|
+
await run("revoke", dest, `rm -f ${KEY} ${KEY}.pub ${tmp}_auth.json ${tmp}_up.tgz ${tmp}_env ${tmp}.sql.gz ${tmp}.sql ${tmp}_deploy.sh`, 30_000).catch(() => {})
|
|
656
686
|
onProgress("revoke", "ok")
|
|
657
687
|
}
|
|
658
688
|
}
|
|
@@ -667,11 +697,20 @@ export async function runBedrockPull(
|
|
|
667
697
|
// just BEFORE a destination site (and its git clone) gets created for nothing.
|
|
668
698
|
// Silent no-op for anything that isn't a detectable Bedrock install; that's
|
|
669
699
|
// covered by the pull chain's own detection, not this pre-check's job.
|
|
670
|
-
|
|
700
|
+
//
|
|
701
|
+
// For a Radicle source whose deploy script builds the front-end with npm/yarn/
|
|
702
|
+
// pnpm, it also checks the destination has Node: a stock SpinupWP server doesn't,
|
|
703
|
+
// and finding out after the create would leave a half-built site behind. We stop
|
|
704
|
+
// and say so rather than install it — the server's packages aren't ours to manage.
|
|
705
|
+
export async function preflightBedrockSource(
|
|
706
|
+
source: SudoCtx,
|
|
707
|
+
spec: { domain: string; publicFolder?: string; deployScript?: string },
|
|
708
|
+
dest?: SudoCtx,
|
|
709
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
671
710
|
const root = `/sites/${spec.domain}/files`
|
|
672
711
|
const expectedWebDir = webrootFor(root, spec.publicFolder)
|
|
673
712
|
const expectedProjectRoot = expectedWebDir === root ? root : expectedWebDir.slice(0, expectedWebDir.lastIndexOf("/"))
|
|
674
|
-
const r = await exec(source, `${detectWpDirScript(root, spec.publicFolder)}; echo "BEDROCKROOT:$B"`, 30_000)
|
|
713
|
+
const r = await exec(source, `${detectWpDirScript(root, spec.publicFolder)}; echo "BEDROCKROOT:$B"; echo "RADICLEROOT:$RD"`, 30_000)
|
|
675
714
|
const detected = (r.stdout.match(/^BEDROCKROOT:(.*)$/m)?.[1] ?? "").trim()
|
|
676
715
|
if (!r.ok || !detected) return { ok: true }
|
|
677
716
|
if (detected !== expectedProjectRoot) {
|
|
@@ -680,9 +719,103 @@ export async function preflightBedrockSource(source: SudoCtx, spec: { domain: st
|
|
|
680
719
|
error: `this site's actual files (Bedrock project at ${detected}) don't match its configured Public Folder (implies ${expectedProjectRoot}) — align them and retry`,
|
|
681
720
|
}
|
|
682
721
|
}
|
|
722
|
+
const radicle = (r.stdout.match(/^RADICLEROOT:(.*)$/m)?.[1] ?? "").trim() !== ""
|
|
723
|
+
// Radicle's front-end build lives only in the deploy script (see
|
|
724
|
+
// BedrockPullSpec.deployScript) — without one the clone would come up with no
|
|
725
|
+
// built assets and answer 500. SpinupWP can drop a script set through its API
|
|
726
|
+
// (one sent on create is gone a minute or two later), so this is reachable.
|
|
727
|
+
if (radicle && !spec.deployScript?.trim()) {
|
|
728
|
+
return {
|
|
729
|
+
ok: false,
|
|
730
|
+
error: "this Radicle site has no deploy script in SpinupWP, so there's nothing to build its front-end with — add one (Site → Git) and retry",
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (radicle && dest && /\b(npm|npx|yarn|pnpm|node)\b/.test(spec.deployScript ?? "")) {
|
|
734
|
+
const n = await exec(dest, `command -v node >/dev/null 2>&1 && echo HASNODE || echo NONODE`, 30_000)
|
|
735
|
+
if (n.stdout.includes("NONODE")) {
|
|
736
|
+
return {
|
|
737
|
+
ok: false,
|
|
738
|
+
error: `this Radicle site's deploy script builds its front-end with Node, and ${dest.server.name} doesn't have Node installed — install Node on it and retry`,
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
683
742
|
return { ok: true }
|
|
684
743
|
}
|
|
685
744
|
|
|
745
|
+
// ---- Crontab carry-over --------------------------------------------------------
|
|
746
|
+
//
|
|
747
|
+
// SpinupWP writes each site user's crontab itself: a PATH line and an
|
|
748
|
+
// `#Ansible: <domain>` block running WP cron. A new site gets its own copy at
|
|
749
|
+
// create, so those are never copied. Anything else in the source user's crontab
|
|
750
|
+
// was added by the site owner (a feed import, an Acorn/Laravel command, a report
|
|
751
|
+
// job), and without this step a clone silently stops running it. Lines are copied
|
|
752
|
+
// verbatim, commented-out ones included (a disabled job stays disabled), which is
|
|
753
|
+
// safe because a clone keeps the domain and site user, so every path matches.
|
|
754
|
+
// Idempotent: a line the dest already has is skipped, so a retry adds nothing.
|
|
755
|
+
|
|
756
|
+
export interface CrontabSyncResult {
|
|
757
|
+
ok: boolean
|
|
758
|
+
added: string[] // the lines appended to the dest crontab (empty = nothing to carry)
|
|
759
|
+
error?: string
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// The owner-added lines of a crontab: everything except SpinupWP's `#Ansible:`
|
|
763
|
+
// marker + the job line after it, and blank lines. Environment lines (PATH=…,
|
|
764
|
+
// MAILTO=…) are kept here; the caller drops the ones the dest already sets.
|
|
765
|
+
// Exported for the test harness.
|
|
766
|
+
export function ownerCrontabLines(crontab: string): string[] {
|
|
767
|
+
const out: string[] = []
|
|
768
|
+
const lines = crontab.split("\n").map((l) => l.replace(/\r$/, ""))
|
|
769
|
+
for (let i = 0; i < lines.length; i++) {
|
|
770
|
+
const line = lines[i]
|
|
771
|
+
if (/^#Ansible:/.test(line)) {
|
|
772
|
+
i++ // the managed job line that follows the marker
|
|
773
|
+
continue
|
|
774
|
+
}
|
|
775
|
+
if (line.trim() === "") continue
|
|
776
|
+
out.push(line)
|
|
777
|
+
}
|
|
778
|
+
return out
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const envName = (line: string): string | null => line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/)?.[1] ?? null
|
|
782
|
+
|
|
783
|
+
export async function syncCrontab(
|
|
784
|
+
source: SudoCtx,
|
|
785
|
+
dest: SudoCtx,
|
|
786
|
+
spec: { domain: string; sourceSiteUser: string; destSiteUser: string },
|
|
787
|
+
onExec?: CloneExecLog,
|
|
788
|
+
): Promise<CrontabSyncResult> {
|
|
789
|
+
const read = async (ctx: SudoCtx, user: string) => {
|
|
790
|
+
const t0 = Date.now()
|
|
791
|
+
const script = `crontab -l -u ${shq(user)} 2>/dev/null || true`
|
|
792
|
+
const r = await exec(ctx, script, 30_000)
|
|
793
|
+
onExec?.({ domain: spec.domain, stage: "config", host: ctx.server.name, ok: r.ok, code: r.code, ms: Date.now() - t0, script, stdout: r.stdout, stderr: r.stderr })
|
|
794
|
+
return r
|
|
795
|
+
}
|
|
796
|
+
const src = await read(source, spec.sourceSiteUser)
|
|
797
|
+
if (!src.ok) return { ok: false, added: [], error: src.stderr.trim() || "couldn't read the source crontab" }
|
|
798
|
+
const wanted = ownerCrontabLines(src.stdout)
|
|
799
|
+
if (wanted.length === 0) return { ok: true, added: [] }
|
|
800
|
+
|
|
801
|
+
const dst = await read(dest, spec.destSiteUser)
|
|
802
|
+
if (!dst.ok) return { ok: false, added: [], error: dst.stderr.trim() || "couldn't read the destination crontab" }
|
|
803
|
+
const have = new Set(dst.stdout.split("\n").map((l) => l.replace(/\r$/, "")))
|
|
804
|
+
const haveEnv = new Set([...have].map(envName).filter((n): n is string => n != null))
|
|
805
|
+
// An env line the dest already sets (SpinupWP's own PATH, typically) stays the dest's.
|
|
806
|
+
const added = wanted.filter((l) => !have.has(l) && !(envName(l) && haveEnv.has(envName(l)!)))
|
|
807
|
+
if (added.length === 0) return { ok: true, added: [] }
|
|
808
|
+
|
|
809
|
+
const block = ["", `# Carried over from ${source.server.name} by the SpinupTUI clone`, ...added].join("\n") + "\n"
|
|
810
|
+
const b64 = Buffer.from(block).toString("base64")
|
|
811
|
+
const script = `set -e; { crontab -l -u ${shq(spec.destSiteUser)} 2>/dev/null || true; echo ${shq(b64)} | base64 -d; } | crontab -u ${shq(spec.destSiteUser)} -`
|
|
812
|
+
const t0 = Date.now()
|
|
813
|
+
const w = await exec(dest, script, 30_000)
|
|
814
|
+
onExec?.({ domain: spec.domain, stage: "config", host: dest.server.name, ok: w.ok, code: w.code, ms: Date.now() - t0, script, stdout: w.stdout, stderr: w.stderr })
|
|
815
|
+
if (!w.ok) return { ok: false, added: [], error: w.stderr.trim() || "couldn't write the destination crontab" }
|
|
816
|
+
return { ok: true, added }
|
|
817
|
+
}
|
|
818
|
+
|
|
686
819
|
// ---- Files-only pull chain (non-WP sites: redirect shells, static/PHP sites) --
|
|
687
820
|
//
|
|
688
821
|
// The same hardened transport as the Standard-WP files stage (per-site key,
|
package/src/lib/spinupweb.ts
CHANGED
|
@@ -31,3 +31,10 @@ export function serverProvidersSettingsUrl(accountSlug: string | null): string {
|
|
|
31
31
|
export function siteSftpUrl(id: number, accountSlug: string | null): string {
|
|
32
32
|
return accountSlug ? `${WEB_BASE}/${accountSlug}/sites/${id}#sftp` : WEB_BASE
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
// A site's Git settings (repo, branch, deploy script). The clone wizard links here
|
|
36
|
+
// when a dest site's deploy script needs re-entering: SpinupWP drops a script
|
|
37
|
+
// set through the API (see the create payload in store.tsx).
|
|
38
|
+
export function siteGitUrl(id: number, accountSlug: string | null): string {
|
|
39
|
+
return accountSlug ? `${WEB_BASE}/${accountSlug}/sites/${id}#git` : WEB_BASE
|
|
40
|
+
}
|
package/src/lib/stack.ts
CHANGED
|
@@ -80,10 +80,13 @@ export function cloneStackFor(site: Site, probeKind?: ProbeKind | null): "wp" |
|
|
|
80
80
|
if (probeKind === "whmcs" || probeKind === "laravel" || probeKind === "static") return "files"
|
|
81
81
|
return "bedrock"
|
|
82
82
|
}
|
|
83
|
-
// A
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
83
|
+
// A git-deployed Radicle site took the branch above: the Bedrock pull handles
|
|
84
|
+
// it (it detects Radicle itself, pulls content/uploads, and runs the site's
|
|
85
|
+
// deploy script to build the front-end). A "radicle" probe with no git repo
|
|
86
|
+
// never falls into "wp" here since effectiveStack maps it to "Radicle", not
|
|
87
|
+
// "Standard WP" — deliberate: the Standard-WP pull chain assumes a flat webroot
|
|
88
|
+
// and would corrupt a Radicle clone, and without the repo there's nothing to
|
|
89
|
+
// rebuild it from.
|
|
87
90
|
return effectiveStack(site, probeKind) === "Standard WP" ? "wp" : "files"
|
|
88
91
|
}
|
|
89
92
|
|
package/src/lib/wpCli.ts
CHANGED
|
@@ -28,3 +28,55 @@ export function wpCliResolveScript(phpVersion?: string | null): string {
|
|
|
28
28
|
const phpBin = phpVersion ? `php${phpVersion}` : "php"
|
|
29
29
|
return [`WP=$(command -v wp 2>/dev/null || echo /usr/local/bin/wp)`, `PHP=$(command -v ${phpBin} 2>/dev/null || command -v php)`].join("\n")
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
// Pull wp-cli's `--format=json` array out of a section of remote stdout that may
|
|
33
|
+
// also carry noise: under the CLI SAPI a plugin's PHP notices/deprecations print
|
|
34
|
+
// to STDOUT (2>/dev/null doesn't catch them), and with no newline of their own
|
|
35
|
+
// they can sit on the same line as the JSON — before it, or after it when they
|
|
36
|
+
// fire at shutdown (seen live: an Elementor Pro deprecation glued to the end of
|
|
37
|
+
// `plugin list` output). So: from every `[`, bracket-match to its closing `]`
|
|
38
|
+
// (skipping brackets inside JSON strings), and keep the longest slice that parses
|
|
39
|
+
// as an array of objects — which is what every wp-cli list is, and what notice
|
|
40
|
+
// text like "on line [3]" or "[array (" never is.
|
|
41
|
+
// Returns null when nothing parses, so callers can tell "wp-cli printed no list"
|
|
42
|
+
// (an error worth surfacing) from a genuinely empty `[]`.
|
|
43
|
+
export function extractJsonArray(lines: string[] | undefined): unknown[] | null {
|
|
44
|
+
const text = (lines ?? []).join("\n")
|
|
45
|
+
let best: unknown[] | null = null
|
|
46
|
+
let bestLen = -1
|
|
47
|
+
for (let start = text.indexOf("["); start !== -1; start = text.indexOf("[", start + 1)) {
|
|
48
|
+
const end = matchingBracket(text, start)
|
|
49
|
+
if (end === -1 || end - start <= bestLen) continue
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(text.slice(start, end + 1))
|
|
52
|
+
if (Array.isArray(parsed) && parsed.every((o) => o !== null && typeof o === "object")) {
|
|
53
|
+
best = parsed
|
|
54
|
+
bestLen = end - start
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
/* not JSON from here — try the next `[` */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return best
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Index of the `]` closing the `[` at `start`, or -1. Tracks JSON string state so
|
|
64
|
+
// a bracket inside a value (a plugin name, a URL) doesn't end the match early.
|
|
65
|
+
function matchingBracket(text: string, start: number): number {
|
|
66
|
+
let depth = 0
|
|
67
|
+
let inString = false
|
|
68
|
+
for (let i = start; i < text.length; i++) {
|
|
69
|
+
const ch = text[i]
|
|
70
|
+
if (inString) {
|
|
71
|
+
if (ch === "\\") i++
|
|
72
|
+
else if (ch === '"') inString = false
|
|
73
|
+
} else if (ch === '"') inString = true
|
|
74
|
+
else if (ch === "[" || ch === "{") depth++
|
|
75
|
+
else if (ch === "]" || ch === "}") {
|
|
76
|
+
depth--
|
|
77
|
+
if (depth === 0) return ch === "]" ? i : -1
|
|
78
|
+
if (depth < 0) return -1
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return -1
|
|
82
|
+
}
|
package/src/lib/wpCore.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import type { Server, Site } from "../api/types.ts"
|
|
18
18
|
import { SSH_OPTS, sshPort } from "./dbBackup.ts"
|
|
19
19
|
import { detectWpDirScript } from "./serverClone.ts"
|
|
20
|
-
import { wpCliResolveScript } from "./wpCli.ts"
|
|
20
|
+
import { extractJsonArray, wpCliResolveScript } from "./wpCli.ts"
|
|
21
21
|
import { spawn } from "./spawn.ts"
|
|
22
22
|
|
|
23
23
|
export interface WpCoreOffer {
|
|
@@ -177,24 +177,13 @@ export async function checkWpCore(server: Server, site: Site, sshUser: string |
|
|
|
177
177
|
const current = lastVersionLine(s.VERSION)
|
|
178
178
|
if (!current) return { ok: false, target: p.target, error: "wp-cli couldn't read the WordPress version on this site." }
|
|
179
179
|
|
|
180
|
-
// Belt and braces with --skip-plugins:
|
|
181
|
-
// the JSON array
|
|
180
|
+
// Belt and braces with --skip-plugins: a mu-plugin's notices still load, so
|
|
181
|
+
// pull the JSON array out of any surrounding noise.
|
|
182
182
|
// No match = "Success: WordPress is at the latest version." (plain text).
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
try {
|
|
188
|
-
parsed = JSON.parse(line.trim())
|
|
189
|
-
} catch {
|
|
190
|
-
continue
|
|
191
|
-
}
|
|
192
|
-
if (!Array.isArray(parsed)) continue
|
|
193
|
-
offers = parsed
|
|
194
|
-
.filter((o) => o && typeof o.version === "string")
|
|
195
|
-
.map((o) => ({ version: o.version as string, updateType: typeof o.update_type === "string" ? o.update_type : "" }))
|
|
196
|
-
break
|
|
197
|
-
}
|
|
183
|
+
const parsed = extractJsonArray(s.UPDATES) ?? []
|
|
184
|
+
const offers: WpCoreOffer[] = parsed
|
|
185
|
+
.filter((o): o is { version: string; update_type?: unknown } => !!o && typeof (o as { version?: unknown }).version === "string")
|
|
186
|
+
.map((o) => ({ version: o.version, updateType: typeof o.update_type === "string" ? o.update_type : "" }))
|
|
198
187
|
|
|
199
188
|
const comp = (s.COMPOSER ?? []).join("\n").trim()
|
|
200
189
|
const locked = comp.match(/^locked (.+)$/m)
|
package/src/lib/wpInventory.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import type { Server, Site } from "../api/types.ts"
|
|
12
12
|
import { SSH_OPTS, sshPort } from "./dbBackup.ts"
|
|
13
13
|
import { detectWpDirScript } from "./serverClone.ts"
|
|
14
|
-
import { wpCliResolveScript } from "./wpCli.ts"
|
|
14
|
+
import { extractJsonArray, wpCliResolveScript } from "./wpCli.ts"
|
|
15
15
|
import { spawn } from "./spawn.ts"
|
|
16
16
|
|
|
17
17
|
// One plugin or theme, mirroring `wp {plugin,theme} list` columns.
|
|
@@ -68,17 +68,11 @@ function splitSections(out: string): Record<string, string[]> {
|
|
|
68
68
|
return sections
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
function
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
const parsed = JSON.parse(text)
|
|
78
|
-
return Array.isArray(parsed) ? parsed : []
|
|
79
|
-
} catch {
|
|
80
|
-
return []
|
|
81
|
-
}
|
|
71
|
+
// First non-blank line of a section — the likeliest culprit when wp-cli's JSON is
|
|
72
|
+
// missing (a PHP fatal or notice printed in its place).
|
|
73
|
+
function firstNoise(lines: string[] | undefined): string {
|
|
74
|
+
const line = (lines ?? []).map((l) => l.trim()).find(Boolean) ?? ""
|
|
75
|
+
return line.length > 160 ? `${line.slice(0, 159)}…` : line
|
|
82
76
|
}
|
|
83
77
|
|
|
84
78
|
export async function fetchWpInventory(
|
|
@@ -147,8 +141,18 @@ export async function fetchWpInventory(
|
|
|
147
141
|
return { ok: false, target, error: `No WordPress core found under ${root} — is this a WordPress site?` }
|
|
148
142
|
}
|
|
149
143
|
const wpDir = (s.WPDIR?.[0] || root).trim()
|
|
150
|
-
|
|
151
|
-
|
|
144
|
+
// A plugin's PHP notices can print to stdout alongside the JSON (see
|
|
145
|
+
// extractJsonArray). No JSON at all means wp-cli failed; say so instead of
|
|
146
|
+
// showing an empty list that reads as "no plugins installed".
|
|
147
|
+
const rawPlugins = extractJsonArray(s.PLUGINS)
|
|
148
|
+
const rawThemes = extractJsonArray(s.THEMES)
|
|
149
|
+
if (!rawPlugins || !rawThemes) {
|
|
150
|
+
const which = !rawPlugins ? "plugin" : "theme"
|
|
151
|
+
const noise = firstNoise(!rawPlugins ? s.PLUGINS : s.THEMES)
|
|
152
|
+
return { ok: false, target, error: `wp-cli didn't return the ${which} list${noise ? `: ${noise}` : "."}` }
|
|
153
|
+
}
|
|
154
|
+
const plugins = normalize(rawPlugins as RawItem[])
|
|
155
|
+
const themes = normalize(rawThemes as RawItem[])
|
|
152
156
|
return { ok: true, target, wpDir, inventory: { plugins, themes } }
|
|
153
157
|
}
|
|
154
158
|
|
package/src/ui/Header.tsx
CHANGED
|
@@ -6,6 +6,8 @@ import { timeAgo, truncate } from "../lib/format.ts"
|
|
|
6
6
|
import { Spinner } from "./components.tsx"
|
|
7
7
|
import { APP_NAME, APP_VERSION } from "../version.ts"
|
|
8
8
|
import { isDevMode } from "../dev/devMode.ts"
|
|
9
|
+
import { RL_LOW_WATER, onRateLimitChange, rateLimitSnapshot, type RateLimitSnapshot } from "../api/client.ts"
|
|
10
|
+
import { useEffect, useState } from "react"
|
|
9
11
|
|
|
10
12
|
const TABS: { route: Route; key: string; label: string }[] = [
|
|
11
13
|
{ route: "dashboard", key: "1", label: "Dashboard" },
|
|
@@ -26,6 +28,29 @@ const SUBTITLES: Record<Route, string> = {
|
|
|
26
28
|
events: "Recent deploys, reboots, and operations across your account",
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
// SpinupWP allows 60 API requests a minute. Show what's left of this minute so
|
|
32
|
+
// the wall is visible before it's hit: dim while there's plenty, amber past
|
|
33
|
+
// three-quarters spent, red once the client has started pacing requests (the
|
|
34
|
+
// point where the app slows down on purpose). Hidden until a response has
|
|
35
|
+
// reported the limit (and in Dev Mode, which never calls the API).
|
|
36
|
+
function ApiHeadroom() {
|
|
37
|
+
const [rl, setRl] = useState<RateLimitSnapshot | null>(rateLimitSnapshot)
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const refresh = () => setRl(rateLimitSnapshot())
|
|
40
|
+
const off = onRateLimitChange(refresh)
|
|
41
|
+
// The window rolls over with no request to announce it; re-read so a spent
|
|
42
|
+
// budget doesn't sit on screen after it has refilled.
|
|
43
|
+
const tick = setInterval(refresh, 5_000)
|
|
44
|
+
return () => {
|
|
45
|
+
off()
|
|
46
|
+
clearInterval(tick)
|
|
47
|
+
}
|
|
48
|
+
}, [])
|
|
49
|
+
if (!rl) return null
|
|
50
|
+
const color = rl.remaining <= RL_LOW_WATER ? theme.bad : rl.remaining <= rl.limit / 4 ? theme.warn : theme.textFaint
|
|
51
|
+
return <text content={`API ${rl.remaining}/${rl.limit} `} fg={color} style={{ flexShrink: 0 }} wrapMode="none" />
|
|
52
|
+
}
|
|
53
|
+
|
|
29
54
|
export function Header() {
|
|
30
55
|
const { route, servers, sites, loading, lastUpdated, updateInfo, newServerJob, vanityJob, cloneJob, cloneServer, kumaStatus, profileLabel } = useStore()
|
|
31
56
|
// Name the domain(s) when there are few enough to fit the header row; fall
|
|
@@ -114,6 +139,7 @@ export function Header() {
|
|
|
114
139
|
)}
|
|
115
140
|
{loading && <Spinner interval={100} />}
|
|
116
141
|
<text content={` ${servers.length} servers · ${sites.length} sites `} fg={theme.textDim} style={{ flexShrink: 0 }} />
|
|
142
|
+
<ApiHeadroom />
|
|
117
143
|
<text content={lastUpdated ? `updated ${timeAgo(lastUpdated.toISOString())}` : "loading…"} fg={theme.textFaint} style={{ flexShrink: 0 }} />
|
|
118
144
|
</box>
|
|
119
145
|
<box style={{ flexDirection: "row", height: 1, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
|
package/src/ui/Help.tsx
CHANGED
|
@@ -8,7 +8,7 @@ import { theme } from "../lib/theme.ts"
|
|
|
8
8
|
import { APP_NAME, APP_VERSION, REPO_URL } from "../version.ts"
|
|
9
9
|
import { Sparkle, Spinner } from "./components.tsx"
|
|
10
10
|
import { useStore } from "./store.tsx"
|
|
11
|
-
import { runSelfUpdate, installChannel,
|
|
11
|
+
import { runSelfUpdate, installChannel, updateCommand, type UpdateInfo, type SelfUpdateResult } from "../lib/appUpdate.ts"
|
|
12
12
|
|
|
13
13
|
type Section = { title: string; keys: [string, string][] }
|
|
14
14
|
|
|
@@ -191,10 +191,10 @@ function AboutColumn({
|
|
|
191
191
|
{selfUpdate !== "idle" && selfUpdate !== "running" ? (
|
|
192
192
|
<text content={selfUpdate.message} fg={selfUpdate.ok ? theme.good : theme.bad} />
|
|
193
193
|
) : null}
|
|
194
|
-
{installChannel()
|
|
194
|
+
{installChannel() !== "git" ? (
|
|
195
195
|
<>
|
|
196
196
|
{line("Manual:")}
|
|
197
|
-
{line(
|
|
197
|
+
{line(updateCommand() ?? "", theme.text)}
|
|
198
198
|
</>
|
|
199
199
|
) : (
|
|
200
200
|
<>
|
package/src/ui/store.tsx
CHANGED
|
@@ -45,7 +45,7 @@ import { withKuma, KumaClient, registerMonitors, registerFingerprintMonitor, reg
|
|
|
45
45
|
import { deriveFingerprint } from "../lib/siteFingerprint.ts"
|
|
46
46
|
import type { StoredProviders } from "../config.ts"
|
|
47
47
|
import { fetchRebootInfo, grantSiteSshKey, revokeSiteSshKey, verifySudo, ensureSpinupKey, listPersonalKeys, keyBody, type RebootInfo } from "../lib/ssh.ts"
|
|
48
|
-
import { estimateSourceSiteSizes, runStandardWpPull, runBedrockPull, runFilesOnlyPull, verifyClone, verifyFilesClone, preflightBedrockSource, type SudoCtx, type CloneStage, type CloneExecRecord, type VerifyResult as CloneVerifyResult } from "../lib/serverClone.ts"
|
|
48
|
+
import { estimateSourceSiteSizes, runStandardWpPull, runBedrockPull, runFilesOnlyPull, verifyClone, verifyFilesClone, preflightBedrockSource, syncCrontab, type SudoCtx, type CloneStage, type CloneExecRecord, type VerifyResult as CloneVerifyResult } from "../lib/serverClone.ts"
|
|
49
49
|
import { CloneLogger } from "../lib/cloneLog.ts"
|
|
50
50
|
import { syncAdditionalDomains } from "../lib/cloneDomains.ts"
|
|
51
51
|
import { parseRepo, deployKeysSettingsUrl, ghAvailable, ghDeployKeyPresent, ghAddDeployKey, generateDeployKeypair, type RepoHost } from "../lib/gitDeployKey.ts"
|
|
@@ -268,6 +268,9 @@ export interface CloneSiteState {
|
|
|
268
268
|
stack: "wp" | "bedrock" | "files" // git repo → bedrock; is_wordpress → wp; else files-only (redirect shells, static/PHP sites)
|
|
269
269
|
gitRepo?: string // source git.repo (Bedrock) — the dest is created as a `git` site of it
|
|
270
270
|
gitBranch?: string // source git.branch
|
|
271
|
+
deployScript?: string // source git deploy script — the dest's own, and Radicle's build step
|
|
272
|
+
pushEnabled?: boolean // source push-to-deploy on/off — the dest is created to match
|
|
273
|
+
pageCache?: boolean // source page cache on/off — the dest is created to match
|
|
271
274
|
gitDeployKey?: { privateKey: string; publicKey: string } // unique per-site key (stamped leaving gitaccess) → create payload
|
|
272
275
|
additionalDomains?: string[] // extra domains served by the site → extra cutover records
|
|
273
276
|
additionalDomainConfigs?: AdditionalDomain[] // full source configs (redirects) — re-created on the dest
|
|
@@ -293,6 +296,13 @@ export interface CloneSiteState {
|
|
|
293
296
|
verify?: CloneVerifyResult // source-vs-clone comparison + HTTP check
|
|
294
297
|
verifyError?: string
|
|
295
298
|
cutover?: CloneCutoverState // slice 6: DNS repoint state for this site's domain
|
|
299
|
+
cronAdded?: string[] // owner crontab lines carried to the dest (empty = none to carry)
|
|
300
|
+
cronError?: string // crontab carry-over failed (the clone itself still succeeded)
|
|
301
|
+
// The source had a deploy script but the dest reads back none — SpinupWP drops a
|
|
302
|
+
// script set on create, and there's no API to set it after. The verify pane
|
|
303
|
+
// offers it for copy with a link to the dest's Git settings.
|
|
304
|
+
deployScriptLost?: boolean
|
|
305
|
+
pushMismatch?: boolean // the dest's push-to-deploy reads back different from the source's
|
|
296
306
|
}
|
|
297
307
|
// Slice 6: per-site DNS cutover. Each A record among the site's domains (primary +
|
|
298
308
|
// additional_domains) is repointed from the old server IP to the new one. www-style
|
|
@@ -3573,6 +3583,9 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
3573
3583
|
stack,
|
|
3574
3584
|
gitRepo: s.git?.repo ?? undefined,
|
|
3575
3585
|
gitBranch: s.git?.branch ?? undefined,
|
|
3586
|
+
deployScript: s.git?.deploy_script ?? undefined,
|
|
3587
|
+
pushEnabled: s.git?.push_enabled ?? undefined,
|
|
3588
|
+
pageCache: s.page_cache?.enabled ?? undefined,
|
|
3576
3589
|
additionalDomains: (s.additional_domains ?? []).map((d) => d.domain),
|
|
3577
3590
|
additionalDomainConfigs: s.additional_domains ?? [],
|
|
3578
3591
|
excludeUploads: false,
|
|
@@ -3801,7 +3814,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
3801
3814
|
// site-creation + auth cycle on a clone that can't succeed. Not a guarantee
|
|
3802
3815
|
// (see preflightBedrockSource) — the real check is still the one after create.
|
|
3803
3816
|
if (site.stack === "bedrock") {
|
|
3804
|
-
const pre = await preflightBedrockSource(source, { domain: site.domain, publicFolder: site.publicFolder })
|
|
3817
|
+
const pre = await preflightBedrockSource(source, { domain: site.domain, publicFolder: site.publicFolder, deployScript: site.deployScript }, dest)
|
|
3805
3818
|
if (!pre.ok) return fail("create", pre.error)
|
|
3806
3819
|
}
|
|
3807
3820
|
// create — Bedrock → `git` site (SpinupWP clones the repo); Standard WP →
|
|
@@ -3831,14 +3844,18 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
3831
3844
|
php_version: site.phpVersion,
|
|
3832
3845
|
public_folder: site.publicFolder ?? "/web/",
|
|
3833
3846
|
database: { name: dbName, username: dbName, password: dbPw, table_prefix: site.tablePrefix || "wp_" },
|
|
3834
|
-
// deploy_script is TOP-LEVEL; we send the
|
|
3835
|
-
//
|
|
3836
|
-
//
|
|
3837
|
-
|
|
3847
|
+
// deploy_script is TOP-LEVEL; we send the source's own (canonical
|
|
3848
|
+
// only as the fallback). CAVEAT, verified 2026-09-24: SpinupWP stores
|
|
3849
|
+
// it, echoes it back, then clears it a minute or two later — so the
|
|
3850
|
+
// dest's script has to be re-entered in the dashboard. Nothing here
|
|
3851
|
+
// depends on it: the pull runs composer (and a Radicle source's own
|
|
3852
|
+
// script) over SSH.
|
|
3853
|
+
deploy_script: site.deployScript?.trim() || "composer install -o --no-dev",
|
|
3838
3854
|
git: {
|
|
3839
3855
|
repo: site.gitRepo!,
|
|
3840
3856
|
branch: site.gitBranch ?? "main",
|
|
3841
|
-
|
|
3857
|
+
// Match the source (on when unknown, the long-standing default).
|
|
3858
|
+
push_to_deploy: site.pushEnabled ?? true,
|
|
3842
3859
|
// unique per-site key (gitaccess step) — the server-wide
|
|
3843
3860
|
// git_publickey fits only ONE GitHub repo, so it can't be
|
|
3844
3861
|
// reused; SpinupWP installs this pair as the site's git identity.
|
|
@@ -3863,6 +3880,10 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
3863
3880
|
public_folder: site.publicFolder,
|
|
3864
3881
|
database: { name: dbName, username: dbName, password: dbPw, table_prefix: site.tablePrefix || "wp_" },
|
|
3865
3882
|
}
|
|
3883
|
+
// Match the source's page cache — a deploy script that purges it
|
|
3884
|
+
// (`wp spinupwp cache purge-site`) fails on a site where it's off, and
|
|
3885
|
+
// it can't be switched on through the API after the create.
|
|
3886
|
+
if (site.pageCache != null) payload.page_cache = { enabled: site.pageCache }
|
|
3866
3887
|
ev = await client.createSite(payload)
|
|
3867
3888
|
logger?.log({ event: "create-requested", domain: site.domain, eventId: ev?.event_id })
|
|
3868
3889
|
} catch (err) {
|
|
@@ -3928,7 +3949,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
3928
3949
|
const onExec = logger ? (e: CloneExecRecord) => logger.log({ event: "exec", ...e }) : undefined
|
|
3929
3950
|
const res =
|
|
3930
3951
|
site.stack === "bedrock"
|
|
3931
|
-
? await runBedrockPull(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, destDbName: dbName, destDbUser: dbName, destDbPassword: dbPw, excludeUploads: site.excludeUploads, publicFolder: site.publicFolder }, onProgress, onExec, onTransfer)
|
|
3952
|
+
? await runBedrockPull(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, destDbName: dbName, destDbUser: dbName, destDbPassword: dbPw, excludeUploads: site.excludeUploads, publicFolder: site.publicFolder, deployScript: site.deployScript }, onProgress, onExec, onTransfer)
|
|
3932
3953
|
: site.stack === "files"
|
|
3933
3954
|
? await runFilesOnlyPull(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, approxFilesBytes: site.sizeWebBytes }, onProgress, onExec, onTransfer)
|
|
3934
3955
|
: await runStandardWpPull(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, destDbName: dbName, destDbUser: dbName, destDbPassword: dbPw, publicFolder: site.publicFolder, approxFilesBytes: site.sizeWebBytes }, onProgress, onExec, onTransfer)
|
|
@@ -3937,8 +3958,29 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
3937
3958
|
return fail(stageToStep(stage), res.error ?? "pull failed")
|
|
3938
3959
|
}
|
|
3939
3960
|
const { sourceWebrootRel, destWebrootRel } = res as { sourceWebrootRel?: string; destWebrootRel?: string }
|
|
3940
|
-
|
|
3941
|
-
|
|
3961
|
+
// Owner-added cron jobs (see syncCrontab). Best-effort: a failure is
|
|
3962
|
+
// reported on the site, never fails a clone whose files and DB are in place.
|
|
3963
|
+
set((s) => ({ ...s, detail: "crontab" }))
|
|
3964
|
+
const cron = await syncCrontab(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser }, onExec)
|
|
3965
|
+
logger?.log({ event: "crontab", domain: site.domain, ok: cron.ok, added: cron.added, error: cron.error })
|
|
3966
|
+
// Did the dest keep the deploy script we sent on create? (It usually
|
|
3967
|
+
// doesn't — see the create payload.) Re-read rather than assume, so this
|
|
3968
|
+
// goes quiet on its own if SpinupWP fixes it.
|
|
3969
|
+
// Same re-read for push-to-deploy (which does stick, as of 2026-09-24 —
|
|
3970
|
+
// checked anyway, since the create is the only chance to set it).
|
|
3971
|
+
let deployScriptLost = false
|
|
3972
|
+
let pushMismatch = false
|
|
3973
|
+
if (site.gitRepo && destSiteId != null) {
|
|
3974
|
+
try {
|
|
3975
|
+
const destSite = await client.getSite(destSiteId)
|
|
3976
|
+
deployScriptLost = !!site.deployScript?.trim() && !destSite.git?.deploy_script?.trim()
|
|
3977
|
+
pushMismatch = site.pushEnabled != null && destSite.git?.push_enabled !== site.pushEnabled
|
|
3978
|
+
} catch {
|
|
3979
|
+
/* unknown — say nothing rather than guess */
|
|
3980
|
+
}
|
|
3981
|
+
}
|
|
3982
|
+
logger?.log({ event: "site-done", domain: site.domain, sourceWebrootRel, destWebrootRel, deployScriptLost, pushMismatch })
|
|
3983
|
+
set((s) => ({ ...s, step: "done", detail: undefined, error: undefined, failedStep: undefined, sourceWebrootRel, destWebrootRel, cronAdded: cron.added, cronError: cron.ok ? undefined : cron.error, deployScriptLost, pushMismatch }))
|
|
3942
3984
|
} catch (err) {
|
|
3943
3985
|
fail(site.step, (err as Error).message)
|
|
3944
3986
|
}
|
|
@@ -16,8 +16,8 @@ import { Panel, Spinner } from "../components.tsx"
|
|
|
16
16
|
import { StatusBar } from "../StatusBar.tsx"
|
|
17
17
|
import { openUrl, copyToClipboard } from "../../lib/open.ts"
|
|
18
18
|
import { consoleForHost } from "../../lib/providers.ts"
|
|
19
|
-
import { serverWebUrl } from "../../lib/spinupweb.ts"
|
|
20
|
-
import { useStore, cloneNeedsGitAccess, type CloneStep, type CloneSiteStep, type RepoKeyState } from "../store.tsx"
|
|
19
|
+
import { serverWebUrl, siteGitUrl } from "../../lib/spinupweb.ts"
|
|
20
|
+
import { useStore, cloneNeedsGitAccess, type CloneStep, type CloneSiteStep, type CloneSiteState, type RepoKeyState } from "../store.tsx"
|
|
21
21
|
import type { VerifyCheck } from "../../lib/serverClone.ts"
|
|
22
22
|
|
|
23
23
|
// Choosing an existing server as the dest is a first-class feature now (the `d` picker
|
|
@@ -305,6 +305,13 @@ export function CloneWizard() {
|
|
|
305
305
|
return cloneRetrySite(verifyOpen)
|
|
306
306
|
}
|
|
307
307
|
if (name === "v" && drilled?.step === "done") return verifyCloneSite(verifyOpen)
|
|
308
|
+
// g — the dest's Git settings need a hand (lost deploy script, push-to-deploy
|
|
309
|
+
// mismatch): open them, with the source's script on the clipboard when it's
|
|
310
|
+
// missing, so re-entering it is one paste.
|
|
311
|
+
if (name === "g" && (drilled?.deployScriptLost || drilled?.pushMismatch) && drilled.destSiteId != null) {
|
|
312
|
+
if (drilled.deployScriptLost && drilled.deployScript) copyToClipboard(drilled.deployScript)
|
|
313
|
+
return openUrl(siteGitUrl(drilled.destSiteId, accountSlug))
|
|
314
|
+
}
|
|
308
315
|
return
|
|
309
316
|
}
|
|
310
317
|
const n = sel.length
|
|
@@ -726,7 +733,7 @@ export function CloneWizard() {
|
|
|
726
733
|
{selected.map((s, i) => {
|
|
727
734
|
const active = !["queued", "done", "error"].includes(s.step)
|
|
728
735
|
const cur = i === idx
|
|
729
|
-
const vmark = s.verify ? (s.verify.ok ? " ✓verified" : " ✕mismatch") : s.verifying ? " verifying…" : ""
|
|
736
|
+
const vmark = (s.verify ? (s.verify.ok ? " ✓verified" : " ✕mismatch") : s.verifying ? " verifying…" : "") + (s.deployScriptLost || s.pushMismatch || s.cronError ? " · ⚠ see verify" : "")
|
|
730
737
|
return (
|
|
731
738
|
<box key={s.sourceSiteId} style={{ flexDirection: "row", height: 1, backgroundColor: cur ? theme.bgAlt : undefined }}>
|
|
732
739
|
{active ? <Spinner color={theme.brand} /> : <text content={s.step === "done" ? "✓" : s.step === "error" ? "✕" : "○"} fg={s.step === "done" ? theme.good : s.step === "error" ? theme.bad : theme.textFaint} style={{ flexShrink: 0 }} />}
|
|
@@ -793,7 +800,8 @@ export function CloneWizard() {
|
|
|
793
800
|
{v.checks.map(row)}
|
|
794
801
|
<box style={{ flexGrow: 1 }} />
|
|
795
802
|
<text content={v.ok ? "✓ Clone matches the source." : "✕ Differences found — review the ✕ rows above."} fg={v.ok ? theme.good : theme.bad} wrapMode="none" />
|
|
796
|
-
|
|
803
|
+
{cloneNotes(site)}
|
|
804
|
+
<text content={`v re-run${site.deployScriptLost ? " · g copy deploy script + open Git settings" : site.pushMismatch ? " · g open Git settings" : ""} · ← back to roster`} fg={theme.textFaint} wrapMode="none" />
|
|
797
805
|
</>
|
|
798
806
|
) : (
|
|
799
807
|
<text content="Press v to compare this clone against its source." fg={theme.textDim} wrapMode="none" />
|
|
@@ -803,6 +811,26 @@ export function CloneWizard() {
|
|
|
803
811
|
)
|
|
804
812
|
}
|
|
805
813
|
|
|
814
|
+
// What the clone did beyond files + DB, and anything left for the user to do.
|
|
815
|
+
function cloneNotes(site: CloneSiteState) {
|
|
816
|
+
const cron = site.cronError
|
|
817
|
+
? { text: `✕ Crontab not carried over: ${site.cronError}`, fg: theme.bad }
|
|
818
|
+
: site.cronAdded?.length
|
|
819
|
+
? { text: `✓ Crontab: carried ${site.cronAdded.length} line${site.cronAdded.length === 1 ? "" : "s"} you added on the source`, fg: theme.good }
|
|
820
|
+
: null
|
|
821
|
+
return (
|
|
822
|
+
<>
|
|
823
|
+
{cron ? <text content={cron.text} fg={cron.fg} wrapMode="none" /> : null}
|
|
824
|
+
{site.pushMismatch ? (
|
|
825
|
+
<text content={`⚠ Push-to-deploy is ${site.pushEnabled ? "off" : "on"} here but ${site.pushEnabled ? "on" : "off"} on the source — switch it in the new site's Git settings (g)`} fg={theme.warn} wrapMode="none" />
|
|
826
|
+
) : null}
|
|
827
|
+
{site.deployScriptLost ? (
|
|
828
|
+
<text content="⚠ SpinupWP didn't keep this site's deploy script — press g to copy the source's and paste it into the new site's Git settings" fg={theme.warn} wrapMode="none" />
|
|
829
|
+
) : null}
|
|
830
|
+
</>
|
|
831
|
+
)
|
|
832
|
+
}
|
|
833
|
+
|
|
806
834
|
// Failure drill-down — the FULL error for one failed site (the roster truncates it),
|
|
807
835
|
// plus where the complete stage-by-stage log lives on disk.
|
|
808
836
|
function failurePane(siteId: number) {
|
|
@@ -856,7 +884,7 @@ export function CloneWizard() {
|
|
|
856
884
|
if (verifyOpen != null) {
|
|
857
885
|
const drilled = job!.sites.find((s) => s.sourceSiteId === verifyOpen)
|
|
858
886
|
if (drilled?.step === "error") return [{ key: "r", label: "retry site" }, { key: "←", label: "back" }, { key: "esc", label: "close" }]
|
|
859
|
-
return [{ key: "v", label: "re-run" }, { key: "←", label: "back" }, { key: "esc", label: "close" }]
|
|
887
|
+
return [{ key: "v", label: "re-run" }, ...(drilled?.deployScriptLost || drilled?.pushMismatch ? [{ key: "g", label: "Git settings" }] : []), { key: "←", label: "back" }, { key: "esc", label: "close" }]
|
|
860
888
|
}
|
|
861
889
|
const done = selected.filter((s) => s.step === "done").length
|
|
862
890
|
const errored = selected.filter((s) => s.step === "error").length
|