spinuptui 1.0.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 +18 -1
- package/README.md +26 -6
- package/package.json +1 -1
- package/src/api/client.ts +65 -0
- package/src/api/types.ts +51 -2
- package/src/dev/mockClient.ts +56 -0
- package/src/index.tsx +28 -0
- package/src/lib/cliIncidents.ts +7 -0
- package/src/lib/cliSiblings.ts +181 -0
- package/src/lib/cliSsh.ts +45 -31
- package/src/lib/cloneSettings.ts +180 -0
- package/src/lib/serverClone.ts +33 -5
- package/src/ui/store.tsx +79 -28
- package/src/ui/views/CloneWizard.tsx +24 -5
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,22 @@ 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
|
+
|
|
14
30
|
## [1.0.0] - 2026-09-24
|
|
15
31
|
|
|
16
32
|
### Added
|
|
@@ -1453,7 +1469,8 @@ Initial tagged release.
|
|
|
1453
1469
|
### Notes
|
|
1454
1470
|
- Read-only release: works with a SpinupWP **Read Only** API token.
|
|
1455
1471
|
|
|
1456
|
-
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.
|
|
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
|
|
1457
1474
|
[1.0.0]: https://github.com/mwender/spinupwp-tui/compare/v0.28.0...v1.0.0
|
|
1458
1475
|
[0.28.0]: https://github.com/mwender/spinupwp-tui/compare/v0.27.0...v0.28.0
|
|
1459
1476
|
[0.27.0]: https://github.com/mwender/spinupwp-tui/compare/v0.26.1...v0.27.0
|
package/README.md
CHANGED
|
@@ -82,12 +82,14 @@
|
|
|
82
82
|
if it's read-only — anything that looks like a write/restart/destructive
|
|
83
83
|
action is denied rather than run, so any agent using it inherits that
|
|
84
84
|
guarantee without building its own guard; `spinuptui incidents <domain>` /
|
|
85
|
-
`--all` surfaces Uptime Kuma's down/up history as JSON; `spinuptui
|
|
86
|
-
<domain
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
85
|
+
`--all` surfaces Uptime Kuma's down/up history as JSON; `spinuptui siblings
|
|
86
|
+
<domain>` lists every site sharing that site's server, so a page on a server's
|
|
87
|
+
vanity host leads straight to the co-tenants that could be causing it;
|
|
88
|
+
`spinuptui pull files <domain> [path]` and `spinuptui pull db <domain> --yes`
|
|
89
|
+
set up a local working copy from production without opening the TUI at all —
|
|
90
|
+
the same engines the guided flow drives. Built so another agent/script can go
|
|
91
|
+
from just a domain to "what's wrong and how do I get in", or to a working local
|
|
92
|
+
copy, with no manual lookup. → [CLI subcommands](#cli-subcommands)
|
|
91
93
|
- **Completion toasts** — a non-focus-stealing toast when a background write
|
|
92
94
|
(PHP upgrade, reboot, DNS resolve, …) finishes.
|
|
93
95
|
- **Release notes** — an in-app "what's new" after every update, sourced
|
|
@@ -199,6 +201,24 @@ spinuptui ssh-exec <domain> [--server <name>] -- <command> Resolve the
|
|
|
199
201
|
attempt (allowed, denied, or unresolved) is appended to
|
|
200
202
|
<config dir>/logs/ssh-exec-audit.jsonl. Quote <command>
|
|
201
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.
|
|
202
222
|
spinuptui incidents <domain> | --all [--hours N] Print Uptime Kuma down/up
|
|
203
223
|
incidents as JSON, scoped to sites SpinupTUI manages
|
|
204
224
|
monitoring for (config.json's kumaMonitors) — --all
|
package/package.json
CHANGED
package/src/api/client.ts
CHANGED
|
@@ -15,6 +15,10 @@ import type {
|
|
|
15
15
|
CreateSitePayload,
|
|
16
16
|
AdditionalDomain,
|
|
17
17
|
AddDomainPayload,
|
|
18
|
+
UpdateSiteGitPayload,
|
|
19
|
+
PageCachePayload,
|
|
20
|
+
NginxSettings,
|
|
21
|
+
PathRedirect,
|
|
18
22
|
} from "./types.ts"
|
|
19
23
|
|
|
20
24
|
// The restartable services SpinupWP exposes (POST /servers/{id}/services/{svc}/restart).
|
|
@@ -84,6 +88,16 @@ export interface SpinupWPClientLike {
|
|
|
84
88
|
listSites(serverId?: number): Promise<Site[]>
|
|
85
89
|
getSite(id: number): Promise<Site>
|
|
86
90
|
createSite(payload: CreateSitePayload): Promise<{ event_id: number }>
|
|
91
|
+
updateSiteGit(siteId: number, payload: UpdateSiteGitPayload): Promise<{ event_id: number | null } | undefined>
|
|
92
|
+
enablePageCache(siteId: number, payload?: PageCachePayload): Promise<{ event_id: number }>
|
|
93
|
+
updatePageCache(siteId: number, payload: PageCachePayload): Promise<{ event_id: number }>
|
|
94
|
+
disablePageCache(siteId: number): Promise<{ event_id: number }>
|
|
95
|
+
updateNginx(siteId: number, payload: NginxSettings): Promise<{ event_ids: number[] }>
|
|
96
|
+
listPathRedirects(siteId: number): Promise<PathRedirect[]>
|
|
97
|
+
addPathRedirect(siteId: number, payload: Omit<PathRedirect, "created_at">): Promise<{ event_id: number }>
|
|
98
|
+
enableWpCron(siteId: number, interval: number): Promise<{ event_id: number }>
|
|
99
|
+
updateWpCron(siteId: number, interval: number): Promise<{ event_id: number }>
|
|
100
|
+
disableWpCron(siteId: number): Promise<{ event_id: number }>
|
|
87
101
|
enableHttps(siteId: number): Promise<{ event_id: number }>
|
|
88
102
|
disableHttps(siteId: number): Promise<{ event_id: number } | undefined>
|
|
89
103
|
purgePageCache(siteId: number): Promise<{ event_id: number }>
|
|
@@ -351,6 +365,57 @@ export class SpinupWPClient implements SpinupWPClientLike {
|
|
|
351
365
|
return this.mutate<{ event_id: number }>(`/sites/${siteId}/domains`, "POST", payload)
|
|
352
366
|
}
|
|
353
367
|
|
|
368
|
+
// Update a site's Git settings (at least one field). Changing only the deploy
|
|
369
|
+
// script or push-to-deploy needs no server work, so SpinupWP answers with a null
|
|
370
|
+
// event_id — the new values are in place when this resolves. Needs a Read/Write
|
|
371
|
+
// token.
|
|
372
|
+
updateSiteGit(siteId: number, payload: UpdateSiteGitPayload): Promise<{ event_id: number | null } | undefined> {
|
|
373
|
+
return this.mutate<{ event_id: number | null } | undefined>(`/sites/${siteId}/git`, "PUT", payload)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Page cache on an existing site (the API added enable/disable/settings in
|
|
377
|
+
// 2026-09; before that only purge existed). Async → event_id. Lists in the
|
|
378
|
+
// payload are newline-separated. Needs a Read/Write token.
|
|
379
|
+
enablePageCache(siteId: number, payload: PageCachePayload = {}): Promise<{ event_id: number }> {
|
|
380
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/page-cache`, "POST", payload)
|
|
381
|
+
}
|
|
382
|
+
updatePageCache(siteId: number, payload: PageCachePayload): Promise<{ event_id: number }> {
|
|
383
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/page-cache`, "PUT", payload)
|
|
384
|
+
}
|
|
385
|
+
disablePageCache(siteId: number): Promise<{ event_id: number }> {
|
|
386
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/page-cache`, "DELETE")
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Nginx toggles (PHP in uploads, xmlrpc.php, multisite subdirectory rewrites;
|
|
390
|
+
// public_folder too, unused here). One event PER CHANGED SETTING comes back as
|
|
391
|
+
// `event_ids` — empty when nothing changed. Needs a Read/Write token.
|
|
392
|
+
updateNginx(siteId: number, payload: NginxSettings): Promise<{ event_ids: number[] }> {
|
|
393
|
+
return this.mutate<{ event_ids: number[] }>(`/sites/${siteId}/nginx`, "PUT", payload)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Path redirects (nginx-level, per site). Adding a from+to pair the site already
|
|
397
|
+
// has is rejected. Async → event_id. Needs a Read/Write token.
|
|
398
|
+
async listPathRedirects(siteId: number): Promise<PathRedirect[]> {
|
|
399
|
+
return this.listAll<PathRedirect>(`/sites/${siteId}/path-redirects`)
|
|
400
|
+
}
|
|
401
|
+
addPathRedirect(siteId: number, payload: Omit<PathRedirect, "created_at">): Promise<{ event_id: number }> {
|
|
402
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/path-redirects`, "POST", payload)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Server-level WP cron (a system cron job replacing WordPress's page-load cron).
|
|
406
|
+
// interval is minutes, one of WP_CRON_INTERVALS. The Site object doesn't report
|
|
407
|
+
// it — read the site user's crontab instead (wpCronInterval in serverClone.ts).
|
|
408
|
+
// Async → event_id. Needs a Read/Write token.
|
|
409
|
+
enableWpCron(siteId: number, interval: number): Promise<{ event_id: number }> {
|
|
410
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/cron`, "POST", { interval })
|
|
411
|
+
}
|
|
412
|
+
updateWpCron(siteId: number, interval: number): Promise<{ event_id: number }> {
|
|
413
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/cron`, "PUT", { interval })
|
|
414
|
+
}
|
|
415
|
+
disableWpCron(siteId: number): Promise<{ event_id: number }> {
|
|
416
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/cron`, "DELETE")
|
|
417
|
+
}
|
|
418
|
+
|
|
354
419
|
// Enable HTTPS on a site. `type: "webroot"` requests a Let's Encrypt cert (the
|
|
355
420
|
// domain must already resolve to the server for LE to issue, so this runs after
|
|
356
421
|
// the DNS A record has propagated). Async → returns an event_id to poll. Needs a
|
package/src/api/types.ts
CHANGED
|
@@ -66,6 +66,24 @@ export interface AdditionalDomain {
|
|
|
66
66
|
created_at: string
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Page cache as the Site object reports it. The exclusion lists come back as ONE
|
|
70
|
+
// pipe-joined regex ("/wp-admin/|/wp-json/|…") but are WRITTEN newline-separated —
|
|
71
|
+
// see splitCacheList in lib/cloneSettings.ts. Unset lists read back null.
|
|
72
|
+
export interface PageCacheSettings {
|
|
73
|
+
enabled: boolean
|
|
74
|
+
duration?: number | null
|
|
75
|
+
duration_unit?: string | null
|
|
76
|
+
path_exclusions?: string | null
|
|
77
|
+
cookie_exclusions?: string | null
|
|
78
|
+
ignored_query_params?: string | null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface NginxSettings {
|
|
82
|
+
uploads_directory_protected?: boolean
|
|
83
|
+
xmlrpc_protected?: boolean
|
|
84
|
+
subdirectory_rewrite_in_place?: boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
69
87
|
export interface Site {
|
|
70
88
|
id: number
|
|
71
89
|
server_id: number
|
|
@@ -75,9 +93,9 @@ export interface Site {
|
|
|
75
93
|
php_version: string | null
|
|
76
94
|
public_folder: string | null
|
|
77
95
|
is_wordpress: boolean
|
|
78
|
-
page_cache?:
|
|
96
|
+
page_cache?: PageCacheSettings
|
|
79
97
|
https?: { enabled: boolean }
|
|
80
|
-
nginx?:
|
|
98
|
+
nginx?: NginxSettings
|
|
81
99
|
database?: {
|
|
82
100
|
id: number | null
|
|
83
101
|
user_id: number | null
|
|
@@ -193,6 +211,37 @@ export interface CreateSitePayload {
|
|
|
193
211
|
}
|
|
194
212
|
}
|
|
195
213
|
|
|
214
|
+
// PUT /sites/{id}/git — at least one field. Unlike POST /sites, deploy_script sits
|
|
215
|
+
// alongside the git settings here, and the write key is push_to_deploy.
|
|
216
|
+
export interface UpdateSiteGitPayload {
|
|
217
|
+
repo?: string
|
|
218
|
+
branch?: string
|
|
219
|
+
deploy_script?: string
|
|
220
|
+
push_to_deploy?: boolean
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// POST/PUT /sites/{id}/page-cache. Lists are newline-separated on write. PUT keeps
|
|
224
|
+
// any omitted field.
|
|
225
|
+
export interface PageCachePayload {
|
|
226
|
+
duration?: number
|
|
227
|
+
duration_unit?: string
|
|
228
|
+
path_exclusions?: string
|
|
229
|
+
cookie_exclusions?: string
|
|
230
|
+
ignored_query_params?: string
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// A site's path redirect (GET /sites/{id}/path-redirects). type: "permanent" = 301,
|
|
234
|
+
// "redirect" = 302. Delete is keyed by from + to — there's no id.
|
|
235
|
+
export interface PathRedirect {
|
|
236
|
+
from: string
|
|
237
|
+
to: string
|
|
238
|
+
type: "permanent" | "redirect"
|
|
239
|
+
created_at?: string
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The server-level WP cron intervals SpinupWP accepts, in minutes.
|
|
243
|
+
export const WP_CRON_INTERVALS = [1, 2, 5, 10, 15, 30, 60] as const
|
|
244
|
+
|
|
196
245
|
export interface Event {
|
|
197
246
|
id: number
|
|
198
247
|
initiated_by: string | null
|
package/src/dev/mockClient.ts
CHANGED
|
@@ -132,6 +132,62 @@ export function createMockClient(): SpinupWPClientLike {
|
|
|
132
132
|
return { event_id }
|
|
133
133
|
},
|
|
134
134
|
|
|
135
|
+
async updateSiteGit(siteId, payload) {
|
|
136
|
+
const site = sites.find((s) => s.id === siteId)
|
|
137
|
+
if (site?.git) {
|
|
138
|
+
if (payload.repo != null) site.git.repo = payload.repo
|
|
139
|
+
if (payload.branch != null) site.git.branch = payload.branch
|
|
140
|
+
if (payload.deploy_script != null) site.git.deploy_script = payload.deploy_script
|
|
141
|
+
if (payload.push_to_deploy != null) site.git.push_enabled = payload.push_to_deploy
|
|
142
|
+
}
|
|
143
|
+
return { event_id: null }
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
async enablePageCache(siteId, payload = {}) {
|
|
147
|
+
const site = sites.find((s) => s.id === siteId)
|
|
148
|
+
if (site) site.page_cache = { ...site.page_cache, ...payload, enabled: true }
|
|
149
|
+
return { event_id: pushEvent("site.page_cache.enabled", site?.server_id ?? null, `Page cache enabled for ${site?.domain ?? siteId}`) }
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
async updatePageCache(siteId, payload) {
|
|
153
|
+
const site = sites.find((s) => s.id === siteId)
|
|
154
|
+
if (site) site.page_cache = { enabled: site.page_cache?.enabled ?? false, ...site.page_cache, ...payload }
|
|
155
|
+
return { event_id: pushEvent("site.page_cache.updated", site?.server_id ?? null, `Page cache updated for ${site?.domain ?? siteId}`) }
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async disablePageCache(siteId) {
|
|
159
|
+
const site = sites.find((s) => s.id === siteId)
|
|
160
|
+
if (site) site.page_cache = { enabled: false }
|
|
161
|
+
return { event_id: pushEvent("site.page_cache.disabled", site?.server_id ?? null, `Page cache disabled for ${site?.domain ?? siteId}`) }
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
async updateNginx(siteId, payload) {
|
|
165
|
+
const site = sites.find((s) => s.id === siteId)
|
|
166
|
+
if (site) site.nginx = { ...site.nginx, ...payload }
|
|
167
|
+
return { event_ids: [pushEvent("site.nginx.updated", site?.server_id ?? null, `Nginx settings updated for ${site?.domain ?? siteId}`)] }
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
async listPathRedirects() {
|
|
171
|
+
return []
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
async addPathRedirect(siteId, payload) {
|
|
175
|
+
const site = sites.find((s) => s.id === siteId)
|
|
176
|
+
return { event_id: pushEvent("site.redirect.added", site?.server_id ?? null, `Redirect ${payload.from} added to ${site?.domain ?? siteId}`) }
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
async enableWpCron(siteId) {
|
|
180
|
+
return { event_id: pushEvent("site.cron.enabled", null, `WP cron enabled for site ${siteId}`) }
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async updateWpCron(siteId) {
|
|
184
|
+
return { event_id: pushEvent("site.cron.updated", null, `WP cron updated for site ${siteId}`) }
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
async disableWpCron(siteId) {
|
|
188
|
+
return { event_id: pushEvent("site.cron.disabled", null, `WP cron disabled for site ${siteId}`) }
|
|
189
|
+
},
|
|
190
|
+
|
|
135
191
|
async enableHttps(siteId) {
|
|
136
192
|
const site = sites.find((s) => s.id === siteId)
|
|
137
193
|
if (site) site.https = { enabled: true }
|
package/src/index.tsx
CHANGED
|
@@ -20,6 +20,7 @@ import { isDevMode } from "./dev/devMode.ts"
|
|
|
20
20
|
import { SpinupWPClient } from "./api/client.ts"
|
|
21
21
|
import { resolveSshAccess } from "./lib/cliSsh.ts"
|
|
22
22
|
import { resolveIncidents } from "./lib/cliIncidents.ts"
|
|
23
|
+
import { resolveSiblings } from "./lib/cliSiblings.ts"
|
|
23
24
|
import { execSshCommand } from "./lib/sshExec.ts"
|
|
24
25
|
import { runPullFiles, runPullDb } from "./lib/cliPull.ts"
|
|
25
26
|
|
|
@@ -72,6 +73,11 @@ Usage:
|
|
|
72
73
|
spinuptui ssh-exec <domain> [--server <name>] -- <command> Run a read-only
|
|
73
74
|
command over SSH (JSON); denies anything that looks
|
|
74
75
|
like a remote write
|
|
76
|
+
spinuptui siblings <domain> [--server <name>] [--probe] List every site
|
|
77
|
+
sharing a server with this one (JSON), flagging the
|
|
78
|
+
server's vanity host and which sites Kuma watches.
|
|
79
|
+
--probe also SSHes into each to report which ones this
|
|
80
|
+
device's key can actually read.
|
|
75
81
|
spinuptui incidents <domain> | --all [--hours N] Print Uptime Kuma
|
|
76
82
|
down/up incidents for a site or the whole fleet (JSON)
|
|
77
83
|
spinuptui pull files <domain> [path] [--url <local url>] [--server <name>]
|
|
@@ -163,6 +169,28 @@ if (command === "ssh-exec") {
|
|
|
163
169
|
process.exit(result.ok ? 0 : 1)
|
|
164
170
|
}
|
|
165
171
|
|
|
172
|
+
if (command === "siblings") {
|
|
173
|
+
const parsed = parseFlags(args.slice(1))
|
|
174
|
+
const domain = parsed.positionals[0]
|
|
175
|
+
const server = parsed.flags.get("--server") ?? null
|
|
176
|
+
const probe = parsed.flags.has("--probe")
|
|
177
|
+
if (!domain || badFlagValue(parsed.flags, "--server")) {
|
|
178
|
+
console.error(
|
|
179
|
+
JSON.stringify({
|
|
180
|
+
ok: false,
|
|
181
|
+
reason: "usage",
|
|
182
|
+
message: "Usage: spinuptui siblings <domain> [--server <name>] [--probe]",
|
|
183
|
+
}),
|
|
184
|
+
)
|
|
185
|
+
process.exit(1)
|
|
186
|
+
}
|
|
187
|
+
const cfg = loadConfig()
|
|
188
|
+
const client = new SpinupWPClient(cfg)
|
|
189
|
+
const result = await resolveSiblings(domain, client, cfg, { server, probe })
|
|
190
|
+
console.log(JSON.stringify(result))
|
|
191
|
+
process.exit(result.ok ? 0 : 1)
|
|
192
|
+
}
|
|
193
|
+
|
|
166
194
|
if (command === "incidents") {
|
|
167
195
|
const rest = args.slice(1)
|
|
168
196
|
const hoursIdx = rest.indexOf("--hours")
|
package/src/lib/cliIncidents.ts
CHANGED
|
@@ -35,6 +35,13 @@ const KIND_FIELDS: Array<[keyof KumaMonitorRef, IncidentKind]> = [
|
|
|
35
35
|
["bypassId", "bypass"],
|
|
36
36
|
]
|
|
37
37
|
|
|
38
|
+
// Does this ref name at least one monitor `resolveIncidents` would query? A ref
|
|
39
|
+
// can outlive its monitors (removing one deletes its id, not the entry), so an
|
|
40
|
+
// entry's mere presence isn't the answer.
|
|
41
|
+
export function hasIncidentMonitors(ref: KumaMonitorRef | undefined): boolean {
|
|
42
|
+
return !!ref && KIND_FIELDS.some(([field]) => typeof ref[field] === "number")
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
interface Target {
|
|
39
46
|
domain: string
|
|
40
47
|
kind: IncidentKind
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// `spinuptui siblings <domain>` — every site sharing a server with this one,
|
|
2
|
+
// for external tooling (e.g. an incident-diagnostics agent handed one domain).
|
|
3
|
+
//
|
|
4
|
+
// The motivating case: a server's vanity host pages on `?healthz`, but the
|
|
5
|
+
// vanity site is an empty placeholder — the load is always a co-tenant, and
|
|
6
|
+
// site isolation means the vanity user can't read the neighbour's logs. The
|
|
7
|
+
// agent needs to know who else lives on the box, and which of them it can
|
|
8
|
+
// actually SSH into, before it can find the request-level cause. Answering that
|
|
9
|
+
// from the API takes two calls; discovering it by hand took weeks.
|
|
10
|
+
|
|
11
|
+
import type { AppConfig } from "../config.ts"
|
|
12
|
+
import type { Server, Site } from "../api/types.ts"
|
|
13
|
+
import type { SpinupWPClientLike } from "../api/client.ts"
|
|
14
|
+
import { ApiError } from "../api/client.ts"
|
|
15
|
+
import {
|
|
16
|
+
resolveSiteByDomain,
|
|
17
|
+
probeSshTarget,
|
|
18
|
+
type SshAccessReason,
|
|
19
|
+
type SshAccessCandidate,
|
|
20
|
+
type SshProbeStatus,
|
|
21
|
+
} from "./cliSsh.ts"
|
|
22
|
+
import { hasIncidentMonitors } from "./cliIncidents.ts"
|
|
23
|
+
import { resolveSiteSshTarget } from "./probe.ts"
|
|
24
|
+
import { isVanityPair } from "./vanitySite.ts"
|
|
25
|
+
|
|
26
|
+
// Why a co-tenant's logs may be out of reach. `ok` means this device's key is
|
|
27
|
+
// accepted for that site user; `permission_denied` is the one worth acting on
|
|
28
|
+
// (grant the key with K in the Browser view) and the usual reason a neighbour's
|
|
29
|
+
// access log can't be read.
|
|
30
|
+
export type SiblingSshStatus = SshProbeStatus
|
|
31
|
+
|
|
32
|
+
export interface SiblingSite {
|
|
33
|
+
domain: string
|
|
34
|
+
siteUser: string | null
|
|
35
|
+
sshTarget: string
|
|
36
|
+
isWordPress: boolean
|
|
37
|
+
phpVersion: string | null
|
|
38
|
+
pageCache: boolean | null
|
|
39
|
+
// The site living at the server's own hostname — a placeholder whose monitors
|
|
40
|
+
// report on the whole box, so its pages are rarely about itself.
|
|
41
|
+
isVanity: boolean
|
|
42
|
+
// Has Uptime Kuma monitors registered through SpinupTUI, so `spinuptui
|
|
43
|
+
// incidents <domain>` will return something for it.
|
|
44
|
+
monitored: boolean
|
|
45
|
+
// The domain that was asked about, so a caller can tell itself apart from the
|
|
46
|
+
// neighbours without re-matching on additional domains.
|
|
47
|
+
isQuery: boolean
|
|
48
|
+
// Only present with --probe.
|
|
49
|
+
ssh?: SiblingSshStatus
|
|
50
|
+
sshMessage?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SiblingsServer {
|
|
54
|
+
id: number
|
|
55
|
+
name: string
|
|
56
|
+
ip: string | null
|
|
57
|
+
// Needed alongside each site's sshTarget; null means the default, 22.
|
|
58
|
+
sshPort: number | null
|
|
59
|
+
provider: string | null
|
|
60
|
+
size: string | null
|
|
61
|
+
ubuntuVersion: string | null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type SiblingsResult =
|
|
65
|
+
| {
|
|
66
|
+
ok: true
|
|
67
|
+
domain: string
|
|
68
|
+
primaryDomain: string
|
|
69
|
+
server: SiblingsServer
|
|
70
|
+
siteCount: number
|
|
71
|
+
sites: SiblingSite[]
|
|
72
|
+
}
|
|
73
|
+
| {
|
|
74
|
+
ok: false
|
|
75
|
+
domain: string
|
|
76
|
+
reason: SshAccessReason
|
|
77
|
+
message: string
|
|
78
|
+
remedy?: string
|
|
79
|
+
candidates?: SshAccessCandidate[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Every probe hits the same sshd, so they're capped rather than all fired at
|
|
83
|
+
// once: OpenSSH's default MaxStartups (10:30:100) starts dropping connections
|
|
84
|
+
// that are still authenticating past 10, which would turn a big server's
|
|
85
|
+
// result into random connection failures. A small burst of refused logins is
|
|
86
|
+
// also gentler on any fail2ban-style guard watching that sshd.
|
|
87
|
+
const PROBE_CONCURRENCY = 4
|
|
88
|
+
|
|
89
|
+
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
|
90
|
+
const out = new Array<R>(items.length)
|
|
91
|
+
let next = 0
|
|
92
|
+
const worker = async () => {
|
|
93
|
+
while (next < items.length) {
|
|
94
|
+
const i = next++
|
|
95
|
+
out[i] = await fn(items[i]!)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function describe(site: Site, server: Server, cfg: AppConfig, queryId: number): SiblingSite {
|
|
103
|
+
return {
|
|
104
|
+
domain: site.domain,
|
|
105
|
+
siteUser: site.site_user,
|
|
106
|
+
sshTarget: resolveSiteSshTarget(site, server, cfg.sshUser),
|
|
107
|
+
isWordPress: site.is_wordpress,
|
|
108
|
+
phpVersion: site.php_version,
|
|
109
|
+
pageCache: site.page_cache ? site.page_cache.enabled : null,
|
|
110
|
+
isVanity: isVanityPair(site.domain, server.name),
|
|
111
|
+
monitored: hasIncidentMonitors(cfg.kumaMonitors[site.domain]),
|
|
112
|
+
isQuery: site.id === queryId,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function resolveSiblings(
|
|
117
|
+
domain: string,
|
|
118
|
+
client: SpinupWPClientLike,
|
|
119
|
+
cfg: AppConfig,
|
|
120
|
+
opts?: { server?: string | null; probe?: boolean },
|
|
121
|
+
): Promise<SiblingsResult> {
|
|
122
|
+
const resolved = await resolveSiteByDomain(domain, client, cfg, { server: opts?.server ?? null })
|
|
123
|
+
if (!resolved.ok) return resolved.result
|
|
124
|
+
|
|
125
|
+
const { site, server } = resolved
|
|
126
|
+
|
|
127
|
+
let siteList: Site[]
|
|
128
|
+
try {
|
|
129
|
+
siteList = await client.listSites(server.id)
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
domain,
|
|
135
|
+
reason: "token_rejected",
|
|
136
|
+
message: "Saved API token was rejected (401) — it may be expired or revoked.",
|
|
137
|
+
remedy: "Run `spinuptui login` to save a fresh token.",
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const message = err instanceof ApiError ? err.message : `Unexpected error: ${(err as Error).message}`
|
|
141
|
+
return { ok: false, domain, reason: "api_error", message }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The vanity host first, then the queried site, then alphabetically: a reader
|
|
145
|
+
// scanning the list sees what the box calls itself before its tenants.
|
|
146
|
+
const sorted = [...siteList].sort((a, b) => {
|
|
147
|
+
const rank = (s: Site) =>
|
|
148
|
+
isVanityPair(s.domain, server.name) ? 0 : s.id === site.id ? 1 : 2
|
|
149
|
+
return rank(a) - rank(b) || a.domain.localeCompare(b.domain)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const sites = sorted.map((s) => describe(s, server, cfg, site.id))
|
|
153
|
+
const port = server.ssh_port && server.ssh_port !== 22 ? server.ssh_port : null
|
|
154
|
+
|
|
155
|
+
// resolveSiteByDomain has already refused a server with no IP, so every
|
|
156
|
+
// sshTarget here is a real user@ip.
|
|
157
|
+
if (opts?.probe) {
|
|
158
|
+
const probes = await mapLimit(sites, PROBE_CONCURRENCY, (s) => probeSshTarget(s.sshTarget, port))
|
|
159
|
+
probes.forEach((p, i) => {
|
|
160
|
+
sites[i]!.ssh = p.status
|
|
161
|
+
if (p.message) sites[i]!.sshMessage = p.message
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
ok: true,
|
|
167
|
+
domain,
|
|
168
|
+
primaryDomain: site.domain,
|
|
169
|
+
server: {
|
|
170
|
+
id: server.id,
|
|
171
|
+
name: server.name,
|
|
172
|
+
ip: server.ip_address,
|
|
173
|
+
sshPort: port,
|
|
174
|
+
provider: server.provider_name,
|
|
175
|
+
size: server.size,
|
|
176
|
+
ubuntuVersion: server.ubuntu_version,
|
|
177
|
+
},
|
|
178
|
+
siteCount: sites.length,
|
|
179
|
+
sites,
|
|
180
|
+
}
|
|
181
|
+
}
|
package/src/lib/cliSsh.ts
CHANGED
|
@@ -169,15 +169,17 @@ export async function resolveSshTargetInfo(
|
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
172
|
+
// One BatchMode `ssh … true` against an already-resolved target, classified.
|
|
173
|
+
// Shared by `ssh` (one domain) and `siblings --probe` (every site on a server)
|
|
174
|
+
// so both read a refused key, an unreachable box and anything else the same way.
|
|
175
|
+
export type SshProbeStatus = "ok" | "permission_denied" | "connection_failed" | "ssh_error"
|
|
176
|
+
|
|
177
|
+
const PROBE_TIMEOUT_MS = 15_000
|
|
178
|
+
|
|
179
|
+
export async function probeSshTarget(
|
|
180
|
+
target: string,
|
|
181
|
+
port: number | null,
|
|
182
|
+
): Promise<{ status: SshProbeStatus; message?: string }> {
|
|
181
183
|
const portOpt = port ? ["-p", String(port)] : []
|
|
182
184
|
|
|
183
185
|
let proc: ReturnType<typeof Bun.spawn>
|
|
@@ -188,46 +190,58 @@ export async function resolveSshAccess(
|
|
|
188
190
|
stdin: "ignore",
|
|
189
191
|
})
|
|
190
192
|
} catch (err) {
|
|
191
|
-
return {
|
|
193
|
+
return { status: "ssh_error", message: `Failed to launch ssh: ${(err as Error).message}` }
|
|
192
194
|
}
|
|
193
195
|
|
|
196
|
+
let timedOut = false
|
|
194
197
|
const timeout = setTimeout(() => {
|
|
198
|
+
timedOut = true
|
|
195
199
|
try {
|
|
196
200
|
proc.kill()
|
|
197
201
|
} catch {
|
|
198
202
|
/* already gone */
|
|
199
203
|
}
|
|
200
|
-
},
|
|
204
|
+
}, PROBE_TIMEOUT_MS)
|
|
201
205
|
|
|
202
206
|
const exitCode = await proc.exited
|
|
203
207
|
clearTimeout(timeout)
|
|
204
|
-
|
|
208
|
+
if (exitCode === 0) return { status: "ok" }
|
|
205
209
|
|
|
206
|
-
|
|
207
|
-
|
|
210
|
+
// Killed by our own timer: ssh never got to say why, so say it here rather
|
|
211
|
+
// than reporting the kill signal's exit code as an unexplained ssh error.
|
|
212
|
+
if (timedOut) {
|
|
213
|
+
return { status: "connection_failed", message: `ssh did not finish within ${PROBE_TIMEOUT_MS / 1000}s.` }
|
|
208
214
|
}
|
|
209
215
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
reason: "permission_denied",
|
|
215
|
-
message: stderr.trim().split("\n").slice(-2).join(" ") || "Permission denied.",
|
|
216
|
-
remedy: GRANT_KEY_REMEDY,
|
|
217
|
-
}
|
|
218
|
-
}
|
|
216
|
+
const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text()
|
|
217
|
+
const tail = stderr.trim().split("\n").slice(-2).join(" ")
|
|
218
|
+
|
|
219
|
+
if (/permission denied/i.test(stderr)) return { status: "permission_denied", message: tail || "Permission denied." }
|
|
219
220
|
if (/(connection timed out|operation timed out|connection refused|could not resolve)/i.test(stderr)) {
|
|
220
|
-
return {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
221
|
+
return { status: "connection_failed", message: tail || `ssh exited with code ${exitCode}` }
|
|
222
|
+
}
|
|
223
|
+
return { status: "ssh_error", message: tail || `ssh exited with code ${exitCode}` }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function resolveSshAccess(
|
|
227
|
+
domain: string,
|
|
228
|
+
client: SpinupWPClientLike,
|
|
229
|
+
cfg: AppConfig,
|
|
230
|
+
opts?: { server?: string | null },
|
|
231
|
+
): Promise<SshAccessResult> {
|
|
232
|
+
const resolution = await resolveSshTargetInfo(domain, client, cfg, opts)
|
|
233
|
+
if (!resolution.ok) return resolution.result
|
|
234
|
+
const { primaryDomain, sshTarget: target, port, server: serverName } = resolution.info
|
|
235
|
+
|
|
236
|
+
const probe = await probeSshTarget(target, port)
|
|
237
|
+
if (probe.status === "ok") {
|
|
238
|
+
return { ok: true, domain, primaryDomain, sshTarget: target, port, server: serverName }
|
|
226
239
|
}
|
|
227
240
|
return {
|
|
228
241
|
ok: false,
|
|
229
242
|
domain,
|
|
230
|
-
reason:
|
|
231
|
-
message:
|
|
243
|
+
reason: probe.status,
|
|
244
|
+
message: probe.message ?? "ssh failed.",
|
|
245
|
+
...(probe.status === "permission_denied" ? { remedy: GRANT_KEY_REMEDY } : {}),
|
|
232
246
|
}
|
|
233
247
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Carry a site's SpinupWP settings onto its clone: page cache (on/off, duration,
|
|
2
|
+
// exclusions), the nginx security toggles, path redirects, and the server-level WP
|
|
3
|
+
// cron interval. The create payload can only say "page cache on", so without this
|
|
4
|
+
// a clone comes out with SpinupWP's defaults for everything else. Each setting is
|
|
5
|
+
// compared first and only a difference is written, so a retry re-runs it safely.
|
|
6
|
+
//
|
|
7
|
+
// Not carried here: basic auth (the password can't be read from the API — the
|
|
8
|
+
// result flags it instead) and backups (stored per DOMAIN in the bucket, so a clone
|
|
9
|
+
// backing up before DNS cutover would write over production's files; they're
|
|
10
|
+
// applied at cutover).
|
|
11
|
+
|
|
12
|
+
import type { SpinupWPClientLike } from "../api/client.ts"
|
|
13
|
+
import { WP_CRON_INTERVALS, type NginxSettings, type PageCachePayload, type PageCacheSettings, type PathRedirect, type Site } from "../api/types.ts"
|
|
14
|
+
import { pollEvent } from "./cloneDomains.ts"
|
|
15
|
+
|
|
16
|
+
export interface SettingsCarryResult {
|
|
17
|
+
carried: string[] // short labels for what was written ("page cache", "3 redirects", …)
|
|
18
|
+
failed: { what: string; error: string }[]
|
|
19
|
+
basicAuthMissing: boolean // the source is password-protected, the clone isn't
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type Log = (entry: Record<string, unknown>) => void
|
|
23
|
+
|
|
24
|
+
// Page-cache exclusion lists read back as one pipe-joined regex but are written one
|
|
25
|
+
// entry per line. Split on top-level pipes only, so an alternation inside a group
|
|
26
|
+
// or class — "(a|b)", "[|]" — stays one entry. Exported for the test harness.
|
|
27
|
+
export function splitCacheList(joined: string): string[] {
|
|
28
|
+
const out: string[] = []
|
|
29
|
+
let depth = 0
|
|
30
|
+
let cur = ""
|
|
31
|
+
for (let i = 0; i < joined.length; i++) {
|
|
32
|
+
const ch = joined[i]!
|
|
33
|
+
if (ch === "\\") {
|
|
34
|
+
cur += ch + (joined[i + 1] ?? "")
|
|
35
|
+
i++
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
if (ch === "(" || ch === "[" || ch === "{") depth++
|
|
39
|
+
else if ((ch === ")" || ch === "]" || ch === "}") && depth > 0) depth--
|
|
40
|
+
if (ch === "|" && depth === 0) {
|
|
41
|
+
out.push(cur)
|
|
42
|
+
cur = ""
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
cur += ch
|
|
46
|
+
}
|
|
47
|
+
out.push(cur)
|
|
48
|
+
return out.filter((e) => e !== "")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const CACHE_LISTS = ["path_exclusions", "cookie_exclusions", "ignored_query_params"] as const
|
|
52
|
+
|
|
53
|
+
// What to send so the dest's page cache matches the source's: only the fields that
|
|
54
|
+
// differ, and never a list the source leaves unset (null reads as "SpinupWP's
|
|
55
|
+
// default", which the dest already has).
|
|
56
|
+
function pageCacheDiff(src: PageCacheSettings, dst: PageCacheSettings | undefined): PageCachePayload {
|
|
57
|
+
const out: PageCachePayload = {}
|
|
58
|
+
if (src.duration != null && src.duration_unit != null && (src.duration !== dst?.duration || src.duration_unit !== dst?.duration_unit)) {
|
|
59
|
+
out.duration = src.duration
|
|
60
|
+
out.duration_unit = src.duration_unit
|
|
61
|
+
}
|
|
62
|
+
for (const k of CACHE_LISTS) {
|
|
63
|
+
const v = src[k]
|
|
64
|
+
if (v != null && v !== dst?.[k]) out[k] = splitCacheList(v).join("\n")
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Wait for each event and turn the outcome into an error string (undefined = done).
|
|
70
|
+
async function settle(client: SpinupWPClientLike, eventIds: number[]): Promise<string | undefined> {
|
|
71
|
+
for (const id of eventIds) {
|
|
72
|
+
const r = await pollEvent(client, id)
|
|
73
|
+
if (r.outcome === "failed") return `event ${id} failed on SpinupWP`
|
|
74
|
+
if (r.outcome === "timeout") return `gave up waiting for event ${id}${r.lastStatus ? ` (still "${r.lastStatus}")` : ""} — retry the site to re-check`
|
|
75
|
+
}
|
|
76
|
+
return undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const redirectKey = (r: Pick<PathRedirect, "from" | "to">) => `${r.from}\u0000${r.to}`
|
|
80
|
+
|
|
81
|
+
export async function carrySiteSettings(
|
|
82
|
+
client: SpinupWPClientLike,
|
|
83
|
+
sourceSiteId: number,
|
|
84
|
+
destSiteId: number,
|
|
85
|
+
wpCron: { source?: number | null; dest?: number | null },
|
|
86
|
+
log?: Log,
|
|
87
|
+
): Promise<SettingsCarryResult> {
|
|
88
|
+
const result: SettingsCarryResult = { carried: [], failed: [], basicAuthMissing: false }
|
|
89
|
+
let src: Site
|
|
90
|
+
let dst: Site
|
|
91
|
+
try {
|
|
92
|
+
;[src, dst] = await Promise.all([client.getSite(sourceSiteId), client.getSite(destSiteId)])
|
|
93
|
+
} catch (err) {
|
|
94
|
+
result.failed.push({ what: "settings", error: `couldn't read the sites: ${(err as Error).message}` })
|
|
95
|
+
return result
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Each step records its own outcome; one failing never stops the rest.
|
|
99
|
+
const step = async (what: string, run: () => Promise<number[] | null>) => {
|
|
100
|
+
try {
|
|
101
|
+
const events = await run()
|
|
102
|
+
if (events == null) return // nothing to change
|
|
103
|
+
const error = await settle(client, events)
|
|
104
|
+
if (error) {
|
|
105
|
+
result.failed.push({ what, error })
|
|
106
|
+
log?.({ event: "settings-carry", what, ok: false, error })
|
|
107
|
+
} else {
|
|
108
|
+
result.carried.push(what)
|
|
109
|
+
log?.({ event: "settings-carry", what, ok: true })
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
const error = (err as Error).message
|
|
113
|
+
result.failed.push({ what, error })
|
|
114
|
+
log?.({ event: "settings-carry", what, ok: false, error })
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Page cache
|
|
119
|
+
const sc = src.page_cache
|
|
120
|
+
if (sc) {
|
|
121
|
+
await step("page cache", async () => {
|
|
122
|
+
if (!sc.enabled) return dst.page_cache?.enabled ? [(await client.disablePageCache(destSiteId)).event_id] : null
|
|
123
|
+
const diff = pageCacheDiff(sc, dst.page_cache)
|
|
124
|
+
if (!dst.page_cache?.enabled) return [(await client.enablePageCache(destSiteId, diff)).event_id]
|
|
125
|
+
return Object.keys(diff).length > 0 ? [(await client.updatePageCache(destSiteId, diff)).event_id] : null
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Nginx security toggles (public_folder is set at create and handled by the pull)
|
|
130
|
+
const sn = src.nginx ?? {}
|
|
131
|
+
const nginx: NginxSettings = {}
|
|
132
|
+
for (const k of ["uploads_directory_protected", "xmlrpc_protected", "subdirectory_rewrite_in_place"] as const) {
|
|
133
|
+
if (sn[k] != null && sn[k] !== dst.nginx?.[k]) nginx[k] = sn[k]
|
|
134
|
+
}
|
|
135
|
+
if (Object.keys(nginx).length > 0) {
|
|
136
|
+
await step("security settings", async () => (await client.updateNginx(destSiteId, nginx)).event_ids)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Path redirects: add the source's that the dest lacks. The dest's own extras
|
|
140
|
+
// are left alone (a clone shouldn't delete anything).
|
|
141
|
+
let srcRedirects: PathRedirect[] = []
|
|
142
|
+
try {
|
|
143
|
+
srcRedirects = await client.listPathRedirects(sourceSiteId)
|
|
144
|
+
} catch (err) {
|
|
145
|
+
result.failed.push({ what: "redirects", error: `couldn't list the source's redirects: ${(err as Error).message}` })
|
|
146
|
+
}
|
|
147
|
+
if (srcRedirects.length > 0) {
|
|
148
|
+
let have = new Set<string>()
|
|
149
|
+
try {
|
|
150
|
+
have = new Set((await client.listPathRedirects(destSiteId)).map(redirectKey))
|
|
151
|
+
} catch {
|
|
152
|
+
/* add them all; a duplicate is rejected per redirect and reported */
|
|
153
|
+
}
|
|
154
|
+
const missing = srcRedirects.filter((r) => !have.has(redirectKey(r)))
|
|
155
|
+
if (missing.length > 0) {
|
|
156
|
+
await step(`${missing.length} redirect${missing.length === 1 ? "" : "s"}`, async () => {
|
|
157
|
+
const events: number[] = []
|
|
158
|
+
for (const r of missing) events.push((await client.addPathRedirect(destSiteId, { from: r.from, to: r.to, type: r.type })).event_id)
|
|
159
|
+
return events
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// WP cron: both ends' intervals come from their crontabs (undefined = unread,
|
|
165
|
+
// null = no managed job recognized). Only an interval is ever carried: a source
|
|
166
|
+
// with no recognizable job might have WP cron off, or a line shape we don't
|
|
167
|
+
// parse, and switching the clone's off on a guess is worse than leaving it.
|
|
168
|
+
const want = wpCron.source
|
|
169
|
+
const have = wpCron.dest
|
|
170
|
+
if (want != null && have !== undefined && want !== have) {
|
|
171
|
+
if (!(WP_CRON_INTERVALS as readonly number[]).includes(want)) {
|
|
172
|
+
result.failed.push({ what: "WP cron", error: `the source runs WP cron every ${want} min, which SpinupWP's API doesn't offer` })
|
|
173
|
+
} else {
|
|
174
|
+
await step(`WP cron every ${want} min`, async () => [(have == null ? await client.enableWpCron(destSiteId, want) : await client.updateWpCron(destSiteId, want)).event_id])
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
result.basicAuthMissing = !!src.basic_auth?.enabled && !dst.basic_auth?.enabled
|
|
179
|
+
return result
|
|
180
|
+
}
|
package/src/lib/serverClone.ts
CHANGED
|
@@ -757,6 +757,10 @@ export interface CrontabSyncResult {
|
|
|
757
757
|
ok: boolean
|
|
758
758
|
added: string[] // the lines appended to the dest crontab (empty = nothing to carry)
|
|
759
759
|
error?: string
|
|
760
|
+
// SpinupWP's server-level WP cron on each end, in minutes (see wpCronInterval):
|
|
761
|
+
// null = no managed WP cron job, undefined = that crontab wasn't read.
|
|
762
|
+
sourceWpCron?: number | null
|
|
763
|
+
destWpCron?: number | null
|
|
760
764
|
}
|
|
761
765
|
|
|
762
766
|
// The owner-added lines of a crontab: everything except SpinupWP's `#Ansible:`
|
|
@@ -778,6 +782,27 @@ export function ownerCrontabLines(crontab: string): string[] {
|
|
|
778
782
|
return out
|
|
779
783
|
}
|
|
780
784
|
|
|
785
|
+
// The interval, in minutes, of SpinupWP's managed WP cron job — the line after the
|
|
786
|
+
// `#Ansible:` marker. The API doesn't report it, so the crontab is the only place
|
|
787
|
+
// to read it. SpinupWP staggers the start minute per site, so every-5 is written
|
|
788
|
+
// "4,9,14,…,59 * * * *": the gap between the first two minutes is the interval, a
|
|
789
|
+
// single minute is hourly, "*" every minute. null = no managed WP cron job (or one
|
|
790
|
+
// in a shape we don't recognize). Exported for the test harness.
|
|
791
|
+
export function wpCronInterval(crontab: string): number | null {
|
|
792
|
+
const lines = crontab.split("\n").map((l) => l.replace(/\r$/, ""))
|
|
793
|
+
const at = lines.findIndex((l) => /^#Ansible:/.test(l))
|
|
794
|
+
const job = at >= 0 ? lines[at + 1] : undefined
|
|
795
|
+
if (!job || !/wp cron event run/.test(job)) return null
|
|
796
|
+
const [minute, hour] = job.trim().split(/\s+/)
|
|
797
|
+
if (hour !== "*" || !minute) return null
|
|
798
|
+
if (minute === "*") return 1
|
|
799
|
+
const step = minute.match(/^\*\/(\d+)$/)
|
|
800
|
+
if (step) return Number(step[1])
|
|
801
|
+
const mins = minute.split(",").map(Number)
|
|
802
|
+
if (mins.some((m) => !Number.isInteger(m))) return null
|
|
803
|
+
return mins.length === 1 ? 60 : mins[1]! - mins[0]!
|
|
804
|
+
}
|
|
805
|
+
|
|
781
806
|
const envName = (line: string): string | null => line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/)?.[1] ?? null
|
|
782
807
|
|
|
783
808
|
export async function syncCrontab(
|
|
@@ -796,15 +821,18 @@ export async function syncCrontab(
|
|
|
796
821
|
const src = await read(source, spec.sourceSiteUser)
|
|
797
822
|
if (!src.ok) return { ok: false, added: [], error: src.stderr.trim() || "couldn't read the source crontab" }
|
|
798
823
|
const wanted = ownerCrontabLines(src.stdout)
|
|
799
|
-
|
|
824
|
+
const sourceWpCron = wpCronInterval(src.stdout)
|
|
800
825
|
|
|
826
|
+
// Read even when there's nothing to append: the caller compares WP cron intervals.
|
|
801
827
|
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" }
|
|
828
|
+
if (!dst.ok) return { ok: false, added: [], error: dst.stderr.trim() || "couldn't read the destination crontab", sourceWpCron }
|
|
829
|
+
const destWpCron = wpCronInterval(dst.stdout)
|
|
830
|
+
if (wanted.length === 0) return { ok: true, added: [], sourceWpCron, destWpCron }
|
|
803
831
|
const have = new Set(dst.stdout.split("\n").map((l) => l.replace(/\r$/, "")))
|
|
804
832
|
const haveEnv = new Set([...have].map(envName).filter((n): n is string => n != null))
|
|
805
833
|
// An env line the dest already sets (SpinupWP's own PATH, typically) stays the dest's.
|
|
806
834
|
const added = wanted.filter((l) => !have.has(l) && !(envName(l) && haveEnv.has(envName(l)!)))
|
|
807
|
-
if (added.length === 0) return { ok: true, added: [] }
|
|
835
|
+
if (added.length === 0) return { ok: true, added: [], sourceWpCron, destWpCron }
|
|
808
836
|
|
|
809
837
|
const block = ["", `# Carried over from ${source.server.name} by the SpinupTUI clone`, ...added].join("\n") + "\n"
|
|
810
838
|
const b64 = Buffer.from(block).toString("base64")
|
|
@@ -812,8 +840,8 @@ export async function syncCrontab(
|
|
|
812
840
|
const t0 = Date.now()
|
|
813
841
|
const w = await exec(dest, script, 30_000)
|
|
814
842
|
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 }
|
|
843
|
+
if (!w.ok) return { ok: false, added: [], error: w.stderr.trim() || "couldn't write the destination crontab", sourceWpCron, destWpCron }
|
|
844
|
+
return { ok: true, added, sourceWpCron, destWpCron }
|
|
817
845
|
}
|
|
818
846
|
|
|
819
847
|
// ---- Files-only pull chain (non-WP sites: redirect shells, static/PHP sites) --
|
package/src/ui/store.tsx
CHANGED
|
@@ -10,7 +10,7 @@ import { toast } from "./toast.ts"
|
|
|
10
10
|
import { SpinupWPClient, ApiError, type ServerService, type SpinupWPClientLike } from "../api/client.ts"
|
|
11
11
|
import { isDevMode } from "../dev/devMode.ts"
|
|
12
12
|
import { createMockClient } from "../dev/mockClient.ts"
|
|
13
|
-
import type { Server, Site, Event, ProviderMetadata, CreateServerPayload, CreateSitePayload, AdditionalDomain } from "../api/types.ts"
|
|
13
|
+
import type { Server, Site, Event, ProviderMetadata, CreateServerPayload, CreateSitePayload, AdditionalDomain, UpdateSiteGitPayload } from "../api/types.ts"
|
|
14
14
|
import { loadConfig, saveConfig, type StoredConfig, type ServerProviderRef, type KumaMonitorRef, type UptimeKumaConn } from "../config.ts"
|
|
15
15
|
import { saveJob, removeJob } from "../lib/jobs.ts"
|
|
16
16
|
import { APP_VERSION } from "../version.ts"
|
|
@@ -47,6 +47,7 @@ import type { StoredProviders } from "../config.ts"
|
|
|
47
47
|
import { fetchRebootInfo, grantSiteSshKey, revokeSiteSshKey, verifySudo, ensureSpinupKey, listPersonalKeys, keyBody, type RebootInfo } from "../lib/ssh.ts"
|
|
48
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
|
+
import { carrySiteSettings, type SettingsCarryResult } from "../lib/cloneSettings.ts"
|
|
50
51
|
import { syncAdditionalDomains } from "../lib/cloneDomains.ts"
|
|
51
52
|
import { parseRepo, deployKeysSettingsUrl, ghAvailable, ghDeployKeyPresent, ghAddDeployKey, generateDeployKeypair, type RepoHost } from "../lib/gitDeployKey.ts"
|
|
52
53
|
import { keychainAvailable, setSudoPassword, getSudoPassword, deleteSudoPassword } from "../lib/keychain.ts"
|
|
@@ -298,10 +299,14 @@ export interface CloneSiteState {
|
|
|
298
299
|
cutover?: CloneCutoverState // slice 6: DNS repoint state for this site's domain
|
|
299
300
|
cronAdded?: string[] // owner crontab lines carried to the dest (empty = none to carry)
|
|
300
301
|
cronError?: string // crontab carry-over failed (the clone itself still succeeded)
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
302
|
+
settingsCarried?: string[] // SpinupWP settings written to match the source (see carrySiteSettings)
|
|
303
|
+
settingsFailed?: { what: string; error: string }[] // …and the ones that didn't take
|
|
304
|
+
basicAuthMissing?: boolean // the source is password-protected; the clone can't be (password unreadable)
|
|
305
|
+
// The source had a deploy script but the dest still reads back none after we set
|
|
306
|
+
// it again (SpinupWP drops a script set on create). The verify pane offers it for
|
|
307
|
+
// copy with a link to the dest's Git settings.
|
|
304
308
|
deployScriptLost?: boolean
|
|
309
|
+
deployScriptRestored?: boolean // SpinupWP dropped the script and we set it again
|
|
305
310
|
pushMismatch?: boolean // the dest's push-to-deploy reads back different from the source's
|
|
306
311
|
}
|
|
307
312
|
// Slice 6: per-site DNS cutover. Each A record among the site's domains (primary +
|
|
@@ -397,6 +402,52 @@ const INTERRUPTED_LOCAL_SETUP_MSG = "Interrupted by a restart — the local copy
|
|
|
397
402
|
const SERVER_DONE = new Set(["deployed", "completed", "provisioned", "finished", "success"])
|
|
398
403
|
const SERVER_FAIL = new Set(["failed", "errored", "error"])
|
|
399
404
|
|
|
405
|
+
// SpinupWP clears a deploy script sent with POST /sites a minute or two after the
|
|
406
|
+
// create (seen at ~90s, 2026-09-24). A PUT made before that would be wiped along
|
|
407
|
+
// with it, so the restore below waits until this long after the create.
|
|
408
|
+
const DEPLOY_SCRIPT_WIPE_MS = 150_000
|
|
409
|
+
|
|
410
|
+
// Put a cloned git site's deploy script and push-to-deploy back to the source's
|
|
411
|
+
// values if the dest reads back different, then re-read so only what's still
|
|
412
|
+
// wrong is reported. Best-effort: a failed write or read never fails the clone.
|
|
413
|
+
async function restoreDestGit(
|
|
414
|
+
client: SpinupWPClientLike,
|
|
415
|
+
destSiteId: number,
|
|
416
|
+
site: Pick<CloneSiteState, "domain" | "deployScript" | "pushEnabled">,
|
|
417
|
+
createdAt: number | undefined,
|
|
418
|
+
logger: CloneLogger | null | undefined,
|
|
419
|
+
onWait: () => void,
|
|
420
|
+
): Promise<{ deployScriptLost: boolean; deployScriptRestored: boolean; pushMismatch: boolean } | undefined> {
|
|
421
|
+
const wantScript = site.deployScript?.trim()
|
|
422
|
+
const diff = (dest: Site) => ({
|
|
423
|
+
script: !!wantScript && !dest.git?.deploy_script?.trim(),
|
|
424
|
+
push: site.pushEnabled != null && dest.git?.push_enabled !== site.pushEnabled,
|
|
425
|
+
})
|
|
426
|
+
try {
|
|
427
|
+
const wait = createdAt != null ? DEPLOY_SCRIPT_WIPE_MS - (Date.now() - createdAt) : 0
|
|
428
|
+
if (wait > 0) {
|
|
429
|
+
onWait()
|
|
430
|
+
await new Promise((r) => setTimeout(r, wait))
|
|
431
|
+
}
|
|
432
|
+
const before = diff(await client.getSite(destSiteId))
|
|
433
|
+
if (!before.script && !before.push) return { deployScriptLost: false, deployScriptRestored: false, pushMismatch: false }
|
|
434
|
+
const payload: UpdateSiteGitPayload = {
|
|
435
|
+
...(before.script ? { deploy_script: wantScript } : {}),
|
|
436
|
+
...(before.push ? { push_to_deploy: site.pushEnabled } : {}),
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
await client.updateSiteGit(destSiteId, payload)
|
|
440
|
+
logger?.log({ event: "git-restore", domain: site.domain, fields: Object.keys(payload) })
|
|
441
|
+
} catch (err) {
|
|
442
|
+
logger?.log({ event: "git-restore", domain: site.domain, fields: Object.keys(payload), error: (err as Error).message })
|
|
443
|
+
}
|
|
444
|
+
const after = diff(await client.getSite(destSiteId))
|
|
445
|
+
return { deployScriptLost: after.script, deployScriptRestored: before.script && !after.script, pushMismatch: after.push }
|
|
446
|
+
} catch {
|
|
447
|
+
return undefined // unknown — say nothing rather than guess
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
400
451
|
export function isNewServerInFlight(j: NewServerJob | null | undefined): boolean {
|
|
401
452
|
return j != null && j.status !== "done" && j.status !== "failed"
|
|
402
453
|
}
|
|
@@ -3831,6 +3882,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
3831
3882
|
// DB password can't be recovered to re-stamp wp-config, so a leftover from a
|
|
3832
3883
|
// prior run must be removed first (create will surface a clear conflict).
|
|
3833
3884
|
let destSiteId = site.destSiteId
|
|
3885
|
+
let createdAt: number | undefined // when THIS run created the dest (see the git restore below)
|
|
3834
3886
|
if (destSiteId == null) {
|
|
3835
3887
|
let ev
|
|
3836
3888
|
try {
|
|
@@ -3846,10 +3898,10 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
3846
3898
|
database: { name: dbName, username: dbName, password: dbPw, table_prefix: site.tablePrefix || "wp_" },
|
|
3847
3899
|
// deploy_script is TOP-LEVEL; we send the source's own (canonical
|
|
3848
3900
|
// 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
|
|
3850
|
-
//
|
|
3851
|
-
// depends on it: the pull runs composer (and a Radicle source's
|
|
3852
|
-
// script) over SSH.
|
|
3901
|
+
// it, echoes it back, then clears it a minute or two later — so it's
|
|
3902
|
+
// set again with PUT /git once the site is done (see below). Nothing
|
|
3903
|
+
// here depends on it: the pull runs composer (and a Radicle source's
|
|
3904
|
+
// own script) over SSH.
|
|
3853
3905
|
deploy_script: site.deployScript?.trim() || "composer install -o --no-dev",
|
|
3854
3906
|
git: {
|
|
3855
3907
|
repo: site.gitRepo!,
|
|
@@ -3881,10 +3933,10 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
3881
3933
|
database: { name: dbName, username: dbName, password: dbPw, table_prefix: site.tablePrefix || "wp_" },
|
|
3882
3934
|
}
|
|
3883
3935
|
// 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
|
|
3885
|
-
// it can't be switched on through the API after the create.
|
|
3936
|
+
// (`wp spinupwp cache purge-site`) fails on a site where it's off.
|
|
3886
3937
|
if (site.pageCache != null) payload.page_cache = { enabled: site.pageCache }
|
|
3887
3938
|
ev = await client.createSite(payload)
|
|
3939
|
+
createdAt = Date.now()
|
|
3888
3940
|
logger?.log({ event: "create-requested", domain: site.domain, eventId: ev?.event_id })
|
|
3889
3941
|
} catch (err) {
|
|
3890
3942
|
return fail("create", err instanceof ApiError ? err.message : (err as Error).message)
|
|
@@ -3962,25 +4014,24 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
3962
4014
|
// reported on the site, never fails a clone whose files and DB are in place.
|
|
3963
4015
|
set((s) => ({ ...s, detail: "crontab" }))
|
|
3964
4016
|
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
|
-
//
|
|
3967
|
-
//
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
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
|
-
}
|
|
4017
|
+
logger?.log({ event: "crontab", domain: site.domain, ok: cron.ok, added: cron.added, error: cron.error, sourceWpCron: cron.sourceWpCron, destWpCron: cron.destWpCron })
|
|
4018
|
+
// SpinupWP settings the create can't carry: page cache details, the nginx
|
|
4019
|
+
// security toggles, redirects, WP cron interval. Best-effort like the crontab.
|
|
4020
|
+
let settings: SettingsCarryResult | undefined
|
|
4021
|
+
if (destSiteId != null) {
|
|
4022
|
+
set((s) => ({ ...s, step: "config", detail: "settings" }))
|
|
4023
|
+
settings = await carrySiteSettings(client, site.sourceSiteId, destSiteId, { source: cron.sourceWpCron, dest: cron.destWpCron }, logger ? (e) => logger.log({ domain: site.domain, ...e }) : undefined)
|
|
3981
4024
|
}
|
|
3982
|
-
|
|
3983
|
-
|
|
4025
|
+
// Did the dest keep the deploy script we sent on create? It usually doesn't
|
|
4026
|
+
// (see the create payload), so re-read and, if it's gone, set it again with
|
|
4027
|
+
// PUT /git — push-to-deploy too, should it read back different. Only what's
|
|
4028
|
+
// still wrong after that is flagged for the user.
|
|
4029
|
+
const git = site.gitRepo && destSiteId != null ? await restoreDestGit(client, destSiteId, site, createdAt, logger, () => set((s) => ({ ...s, step: "config", detail: "restoring deploy script", stageStartedAt: Date.now() }))) : undefined
|
|
4030
|
+
const deployScriptLost = git?.deployScriptLost ?? false
|
|
4031
|
+
const pushMismatch = git?.pushMismatch ?? false
|
|
4032
|
+
const deployScriptRestored = git?.deployScriptRestored ?? false
|
|
4033
|
+
logger?.log({ event: "site-done", domain: site.domain, sourceWebrootRel, destWebrootRel, deployScriptLost, deployScriptRestored, pushMismatch, settingsCarried: settings?.carried, settingsFailed: settings?.failed, basicAuthMissing: settings?.basicAuthMissing })
|
|
4034
|
+
set((s) => ({ ...s, step: "done", detail: undefined, error: undefined, failedStep: undefined, sourceWebrootRel, destWebrootRel, cronAdded: cron.added, cronError: cron.ok ? undefined : cron.error, deployScriptLost, deployScriptRestored, pushMismatch, settingsCarried: settings?.carried, settingsFailed: settings?.failed, basicAuthMissing: settings?.basicAuthMissing }))
|
|
3984
4035
|
} catch (err) {
|
|
3985
4036
|
fail(site.step, (err as Error).message)
|
|
3986
4037
|
}
|
|
@@ -16,7 +16,7 @@ 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, siteGitUrl } from "../../lib/spinupweb.ts"
|
|
19
|
+
import { serverWebUrl, siteGitUrl, siteWebUrl } from "../../lib/spinupweb.ts"
|
|
20
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
|
|
|
@@ -36,6 +36,12 @@ const STEP_GITACCESS = { step: "gitaccess" as CloneStep, label: "Git access" }
|
|
|
36
36
|
const STEP_CLONE = { step: "clone" as CloneStep, label: "Clone sites" }
|
|
37
37
|
const STEP_CUTOVER = { step: "cutover" as CloneStep, label: "DNS cutover" }
|
|
38
38
|
// The Git-access step only appears when a Bedrock site is selected (deploy-key onboarding).
|
|
39
|
+
// A clone setting the user has to finish in SpinupWP: basic auth (its password
|
|
40
|
+
// can't be read), or a setting whose carry-over failed.
|
|
41
|
+
function needsSettingsHand(s: CloneSiteState): boolean {
|
|
42
|
+
return !!s.basicAuthMissing || !!s.settingsFailed?.length
|
|
43
|
+
}
|
|
44
|
+
|
|
39
45
|
function stepsFor(needsGit: boolean): { step: CloneStep; label: string }[] {
|
|
40
46
|
return needsGit
|
|
41
47
|
? [STEP_PLAN, STEP_SERVER, STEP_TRUST, STEP_GITACCESS, STEP_CLONE, STEP_CUTOVER]
|
|
@@ -312,6 +318,9 @@ export function CloneWizard() {
|
|
|
312
318
|
if (drilled.deployScriptLost && drilled.deployScript) copyToClipboard(drilled.deployScript)
|
|
313
319
|
return openUrl(siteGitUrl(drilled.destSiteId, accountSlug))
|
|
314
320
|
}
|
|
321
|
+
// w — a setting needs doing by hand (basic auth, or one that didn't carry):
|
|
322
|
+
// open the new site in SpinupWP.
|
|
323
|
+
if (name === "w" && drilled && needsSettingsHand(drilled) && drilled.destSiteId != null) return openUrl(siteWebUrl(drilled.destSiteId, accountSlug))
|
|
315
324
|
return
|
|
316
325
|
}
|
|
317
326
|
const n = sel.length
|
|
@@ -733,7 +742,7 @@ export function CloneWizard() {
|
|
|
733
742
|
{selected.map((s, i) => {
|
|
734
743
|
const active = !["queued", "done", "error"].includes(s.step)
|
|
735
744
|
const cur = i === idx
|
|
736
|
-
const vmark = (s.verify ? (s.verify.ok ? " ✓verified" : " ✕mismatch") : s.verifying ? " verifying…" : "") + (s.deployScriptLost || s.pushMismatch || s.cronError ? " · ⚠ see verify" : "")
|
|
745
|
+
const vmark = (s.verify ? (s.verify.ok ? " ✓verified" : " ✕mismatch") : s.verifying ? " verifying…" : "") + (s.deployScriptLost || s.pushMismatch || s.cronError || needsSettingsHand(s) ? " · ⚠ see verify" : "")
|
|
737
746
|
return (
|
|
738
747
|
<box key={s.sourceSiteId} style={{ flexDirection: "row", height: 1, backgroundColor: cur ? theme.bgAlt : undefined }}>
|
|
739
748
|
{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 }} />}
|
|
@@ -801,7 +810,7 @@ export function CloneWizard() {
|
|
|
801
810
|
<box style={{ flexGrow: 1 }} />
|
|
802
811
|
<text content={v.ok ? "✓ Clone matches the source." : "✕ Differences found — review the ✕ rows above."} fg={v.ok ? theme.good : theme.bad} wrapMode="none" />
|
|
803
812
|
{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" />
|
|
813
|
+
<text content={`v re-run${site.deployScriptLost ? " · g copy deploy script + open Git settings" : site.pushMismatch ? " · g open Git settings" : ""}${needsSettingsHand(site) ? " · w open in SpinupWP" : ""} · ← back to roster`} fg={theme.textFaint} wrapMode="none" />
|
|
805
814
|
</>
|
|
806
815
|
) : (
|
|
807
816
|
<text content="Press v to compare this clone against its source." fg={theme.textDim} wrapMode="none" />
|
|
@@ -821,11 +830,21 @@ export function CloneWizard() {
|
|
|
821
830
|
return (
|
|
822
831
|
<>
|
|
823
832
|
{cron ? <text content={cron.text} fg={cron.fg} wrapMode="none" /> : null}
|
|
833
|
+
{site.settingsCarried?.length ? <text content={`✓ Settings matched to the source: ${site.settingsCarried.join(" · ")}`} fg={theme.good} wrapMode="none" /> : null}
|
|
834
|
+
{site.settingsFailed?.map((f) => (
|
|
835
|
+
<text key={f.what} content={`✕ Couldn't match ${f.what}: ${f.error} — press w to set it in SpinupWP`} fg={theme.bad} wrapMode="none" />
|
|
836
|
+
))}
|
|
837
|
+
{site.basicAuthMissing ? (
|
|
838
|
+
<text content="⚠ The source is password-protected (basic auth) and the clone isn't — SpinupWP won't reveal the password; press w to set it" fg={theme.warn} wrapMode="none" />
|
|
839
|
+
) : null}
|
|
824
840
|
{site.pushMismatch ? (
|
|
825
841
|
<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
842
|
) : null}
|
|
843
|
+
{site.deployScriptRestored ? (
|
|
844
|
+
<text content="✓ Deploy script: SpinupWP dropped the one set on create, so it was set again from the source" fg={theme.good} wrapMode="none" />
|
|
845
|
+
) : null}
|
|
827
846
|
{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" />
|
|
847
|
+
<text content="⚠ SpinupWP didn't keep this site's deploy script, even when set again — press g to copy the source's and paste it into the new site's Git settings" fg={theme.warn} wrapMode="none" />
|
|
829
848
|
) : null}
|
|
830
849
|
</>
|
|
831
850
|
)
|
|
@@ -884,7 +903,7 @@ export function CloneWizard() {
|
|
|
884
903
|
if (verifyOpen != null) {
|
|
885
904
|
const drilled = job!.sites.find((s) => s.sourceSiteId === verifyOpen)
|
|
886
905
|
if (drilled?.step === "error") return [{ key: "r", label: "retry site" }, { 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" }]
|
|
906
|
+
return [{ key: "v", label: "re-run" }, ...(drilled?.deployScriptLost || drilled?.pushMismatch ? [{ key: "g", label: "Git settings" }] : []), ...(drilled && needsSettingsHand(drilled) ? [{ key: "w", label: "SpinupWP" }] : []), { key: "←", label: "back" }, { key: "esc", label: "close" }]
|
|
888
907
|
}
|
|
889
908
|
const done = selected.filter((s) => s.step === "done").length
|
|
890
909
|
const errored = selected.filter((s) => s.step === "error").length
|