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 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.0.0...HEAD
1472
+ [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.1.0...HEAD
1473
+ [1.1.0]: https://github.com/mwender/spinupwp-tui/compare/v1.0.0...v1.1.0
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 pull files
86
- <domain> [path]` and `spinuptui pull db <domain> --yes` set up a local working
87
- copy from production without opening the TUI at all — the same engines the
88
- guided flow drives. Built so another agent/script can go from just a domain to
89
- "what's wrong and how do I get in", or to a working local copy, with no manual
90
- lookup. → [CLI subcommands](#cli-subcommands)
85
+ `--all` surfaces Uptime Kuma's down/up history as JSON; `spinuptui siblings
86
+ <domain>` lists every site sharing that site's server, so a page on a server's
87
+ vanity host leads straight to the co-tenants that could be causing it;
88
+ `spinuptui pull files <domain> [path]` and `spinuptui pull db <domain> --yes`
89
+ set up a local working copy from production without opening the TUI at all —
90
+ the same engines the guided flow drives. Built so another agent/script can go
91
+ from just a domain to "what's wrong and how do I get in", or to a working local
92
+ copy, with no manual lookup. → [CLI subcommands](#cli-subcommands)
91
93
  - **Completion toasts** — a non-focus-stealing toast when a background write
92
94
  (PHP upgrade, reboot, DNS resolve, …) finishes.
93
95
  - **Release notes** — an in-app "what's new" after every update, sourced
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "SpinupTUI — a terminal dashboard for browsing, monitoring, and managing your SpinupWP servers and sites.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api/client.ts CHANGED
@@ -15,6 +15,10 @@ import type {
15
15
  CreateSitePayload,
16
16
  AdditionalDomain,
17
17
  AddDomainPayload,
18
+ UpdateSiteGitPayload,
19
+ PageCachePayload,
20
+ NginxSettings,
21
+ PathRedirect,
18
22
  } from "./types.ts"
19
23
 
20
24
  // The restartable services SpinupWP exposes (POST /servers/{id}/services/{svc}/restart).
@@ -84,6 +88,16 @@ export interface SpinupWPClientLike {
84
88
  listSites(serverId?: number): Promise<Site[]>
85
89
  getSite(id: number): Promise<Site>
86
90
  createSite(payload: CreateSitePayload): Promise<{ event_id: number }>
91
+ updateSiteGit(siteId: number, payload: UpdateSiteGitPayload): Promise<{ event_id: number | null } | undefined>
92
+ enablePageCache(siteId: number, payload?: PageCachePayload): Promise<{ event_id: number }>
93
+ updatePageCache(siteId: number, payload: PageCachePayload): Promise<{ event_id: number }>
94
+ disablePageCache(siteId: number): Promise<{ event_id: number }>
95
+ updateNginx(siteId: number, payload: NginxSettings): Promise<{ event_ids: number[] }>
96
+ listPathRedirects(siteId: number): Promise<PathRedirect[]>
97
+ addPathRedirect(siteId: number, payload: Omit<PathRedirect, "created_at">): Promise<{ event_id: number }>
98
+ enableWpCron(siteId: number, interval: number): Promise<{ event_id: number }>
99
+ updateWpCron(siteId: number, interval: number): Promise<{ event_id: number }>
100
+ disableWpCron(siteId: number): Promise<{ event_id: number }>
87
101
  enableHttps(siteId: number): Promise<{ event_id: number }>
88
102
  disableHttps(siteId: number): Promise<{ event_id: number } | undefined>
89
103
  purgePageCache(siteId: number): Promise<{ event_id: number }>
@@ -351,6 +365,57 @@ export class SpinupWPClient implements SpinupWPClientLike {
351
365
  return this.mutate<{ event_id: number }>(`/sites/${siteId}/domains`, "POST", payload)
352
366
  }
353
367
 
368
+ // Update a site's Git settings (at least one field). Changing only the deploy
369
+ // script or push-to-deploy needs no server work, so SpinupWP answers with a null
370
+ // event_id — the new values are in place when this resolves. Needs a Read/Write
371
+ // token.
372
+ updateSiteGit(siteId: number, payload: UpdateSiteGitPayload): Promise<{ event_id: number | null } | undefined> {
373
+ return this.mutate<{ event_id: number | null } | undefined>(`/sites/${siteId}/git`, "PUT", payload)
374
+ }
375
+
376
+ // Page cache on an existing site (the API added enable/disable/settings in
377
+ // 2026-09; before that only purge existed). Async → event_id. Lists in the
378
+ // payload are newline-separated. Needs a Read/Write token.
379
+ enablePageCache(siteId: number, payload: PageCachePayload = {}): Promise<{ event_id: number }> {
380
+ return this.mutate<{ event_id: number }>(`/sites/${siteId}/page-cache`, "POST", payload)
381
+ }
382
+ updatePageCache(siteId: number, payload: PageCachePayload): Promise<{ event_id: number }> {
383
+ return this.mutate<{ event_id: number }>(`/sites/${siteId}/page-cache`, "PUT", payload)
384
+ }
385
+ disablePageCache(siteId: number): Promise<{ event_id: number }> {
386
+ return this.mutate<{ event_id: number }>(`/sites/${siteId}/page-cache`, "DELETE")
387
+ }
388
+
389
+ // Nginx toggles (PHP in uploads, xmlrpc.php, multisite subdirectory rewrites;
390
+ // public_folder too, unused here). One event PER CHANGED SETTING comes back as
391
+ // `event_ids` — empty when nothing changed. Needs a Read/Write token.
392
+ updateNginx(siteId: number, payload: NginxSettings): Promise<{ event_ids: number[] }> {
393
+ return this.mutate<{ event_ids: number[] }>(`/sites/${siteId}/nginx`, "PUT", payload)
394
+ }
395
+
396
+ // Path redirects (nginx-level, per site). Adding a from+to pair the site already
397
+ // has is rejected. Async → event_id. Needs a Read/Write token.
398
+ async listPathRedirects(siteId: number): Promise<PathRedirect[]> {
399
+ return this.listAll<PathRedirect>(`/sites/${siteId}/path-redirects`)
400
+ }
401
+ addPathRedirect(siteId: number, payload: Omit<PathRedirect, "created_at">): Promise<{ event_id: number }> {
402
+ return this.mutate<{ event_id: number }>(`/sites/${siteId}/path-redirects`, "POST", payload)
403
+ }
404
+
405
+ // Server-level WP cron (a system cron job replacing WordPress's page-load cron).
406
+ // interval is minutes, one of WP_CRON_INTERVALS. The Site object doesn't report
407
+ // it — read the site user's crontab instead (wpCronInterval in serverClone.ts).
408
+ // Async → event_id. Needs a Read/Write token.
409
+ enableWpCron(siteId: number, interval: number): Promise<{ event_id: number }> {
410
+ return this.mutate<{ event_id: number }>(`/sites/${siteId}/cron`, "POST", { interval })
411
+ }
412
+ updateWpCron(siteId: number, interval: number): Promise<{ event_id: number }> {
413
+ return this.mutate<{ event_id: number }>(`/sites/${siteId}/cron`, "PUT", { interval })
414
+ }
415
+ disableWpCron(siteId: number): Promise<{ event_id: number }> {
416
+ return this.mutate<{ event_id: number }>(`/sites/${siteId}/cron`, "DELETE")
417
+ }
418
+
354
419
  // Enable HTTPS on a site. `type: "webroot"` requests a Let's Encrypt cert (the
355
420
  // domain must already resolve to the server for LE to issue, so this runs after
356
421
  // the DNS A record has propagated). Async → returns an event_id to poll. Needs a
package/src/api/types.ts CHANGED
@@ -66,6 +66,24 @@ export interface AdditionalDomain {
66
66
  created_at: string
67
67
  }
68
68
 
69
+ // Page cache as the Site object reports it. The exclusion lists come back as ONE
70
+ // pipe-joined regex ("/wp-admin/|/wp-json/|…") but are WRITTEN newline-separated —
71
+ // see splitCacheList in lib/cloneSettings.ts. Unset lists read back null.
72
+ export interface PageCacheSettings {
73
+ enabled: boolean
74
+ duration?: number | null
75
+ duration_unit?: string | null
76
+ path_exclusions?: string | null
77
+ cookie_exclusions?: string | null
78
+ ignored_query_params?: string | null
79
+ }
80
+
81
+ export interface NginxSettings {
82
+ uploads_directory_protected?: boolean
83
+ xmlrpc_protected?: boolean
84
+ subdirectory_rewrite_in_place?: boolean
85
+ }
86
+
69
87
  export interface Site {
70
88
  id: number
71
89
  server_id: number
@@ -75,9 +93,9 @@ export interface Site {
75
93
  php_version: string | null
76
94
  public_folder: string | null
77
95
  is_wordpress: boolean
78
- page_cache?: { enabled: boolean }
96
+ page_cache?: PageCacheSettings
79
97
  https?: { enabled: boolean }
80
- nginx?: Record<string, unknown>
98
+ nginx?: NginxSettings
81
99
  database?: {
82
100
  id: number | null
83
101
  user_id: number | null
@@ -193,6 +211,37 @@ export interface CreateSitePayload {
193
211
  }
194
212
  }
195
213
 
214
+ // PUT /sites/{id}/git — at least one field. Unlike POST /sites, deploy_script sits
215
+ // alongside the git settings here, and the write key is push_to_deploy.
216
+ export interface UpdateSiteGitPayload {
217
+ repo?: string
218
+ branch?: string
219
+ deploy_script?: string
220
+ push_to_deploy?: boolean
221
+ }
222
+
223
+ // POST/PUT /sites/{id}/page-cache. Lists are newline-separated on write. PUT keeps
224
+ // any omitted field.
225
+ export interface PageCachePayload {
226
+ duration?: number
227
+ duration_unit?: string
228
+ path_exclusions?: string
229
+ cookie_exclusions?: string
230
+ ignored_query_params?: string
231
+ }
232
+
233
+ // A site's path redirect (GET /sites/{id}/path-redirects). type: "permanent" = 301,
234
+ // "redirect" = 302. Delete is keyed by from + to — there's no id.
235
+ export interface PathRedirect {
236
+ from: string
237
+ to: string
238
+ type: "permanent" | "redirect"
239
+ created_at?: string
240
+ }
241
+
242
+ // The server-level WP cron intervals SpinupWP accepts, in minutes.
243
+ export const WP_CRON_INTERVALS = [1, 2, 5, 10, 15, 30, 60] as const
244
+
196
245
  export interface Event {
197
246
  id: number
198
247
  initiated_by: string | null
@@ -132,6 +132,62 @@ export function createMockClient(): SpinupWPClientLike {
132
132
  return { event_id }
133
133
  },
134
134
 
135
+ async updateSiteGit(siteId, payload) {
136
+ const site = sites.find((s) => s.id === siteId)
137
+ if (site?.git) {
138
+ if (payload.repo != null) site.git.repo = payload.repo
139
+ if (payload.branch != null) site.git.branch = payload.branch
140
+ if (payload.deploy_script != null) site.git.deploy_script = payload.deploy_script
141
+ if (payload.push_to_deploy != null) site.git.push_enabled = payload.push_to_deploy
142
+ }
143
+ return { event_id: null }
144
+ },
145
+
146
+ async enablePageCache(siteId, payload = {}) {
147
+ const site = sites.find((s) => s.id === siteId)
148
+ if (site) site.page_cache = { ...site.page_cache, ...payload, enabled: true }
149
+ return { event_id: pushEvent("site.page_cache.enabled", site?.server_id ?? null, `Page cache enabled for ${site?.domain ?? siteId}`) }
150
+ },
151
+
152
+ async updatePageCache(siteId, payload) {
153
+ const site = sites.find((s) => s.id === siteId)
154
+ if (site) site.page_cache = { enabled: site.page_cache?.enabled ?? false, ...site.page_cache, ...payload }
155
+ return { event_id: pushEvent("site.page_cache.updated", site?.server_id ?? null, `Page cache updated for ${site?.domain ?? siteId}`) }
156
+ },
157
+
158
+ async disablePageCache(siteId) {
159
+ const site = sites.find((s) => s.id === siteId)
160
+ if (site) site.page_cache = { enabled: false }
161
+ return { event_id: pushEvent("site.page_cache.disabled", site?.server_id ?? null, `Page cache disabled for ${site?.domain ?? siteId}`) }
162
+ },
163
+
164
+ async updateNginx(siteId, payload) {
165
+ const site = sites.find((s) => s.id === siteId)
166
+ if (site) site.nginx = { ...site.nginx, ...payload }
167
+ return { event_ids: [pushEvent("site.nginx.updated", site?.server_id ?? null, `Nginx settings updated for ${site?.domain ?? siteId}`)] }
168
+ },
169
+
170
+ async listPathRedirects() {
171
+ return []
172
+ },
173
+
174
+ async addPathRedirect(siteId, payload) {
175
+ const site = sites.find((s) => s.id === siteId)
176
+ return { event_id: pushEvent("site.redirect.added", site?.server_id ?? null, `Redirect ${payload.from} added to ${site?.domain ?? siteId}`) }
177
+ },
178
+
179
+ async enableWpCron(siteId) {
180
+ return { event_id: pushEvent("site.cron.enabled", null, `WP cron enabled for site ${siteId}`) }
181
+ },
182
+
183
+ async updateWpCron(siteId) {
184
+ return { event_id: pushEvent("site.cron.updated", null, `WP cron updated for site ${siteId}`) }
185
+ },
186
+
187
+ async disableWpCron(siteId) {
188
+ return { event_id: pushEvent("site.cron.disabled", null, `WP cron disabled for site ${siteId}`) }
189
+ },
190
+
135
191
  async enableHttps(siteId) {
136
192
  const site = sites.find((s) => s.id === siteId)
137
193
  if (site) site.https = { enabled: true }
package/src/index.tsx CHANGED
@@ -20,6 +20,7 @@ import { isDevMode } from "./dev/devMode.ts"
20
20
  import { SpinupWPClient } from "./api/client.ts"
21
21
  import { resolveSshAccess } from "./lib/cliSsh.ts"
22
22
  import { resolveIncidents } from "./lib/cliIncidents.ts"
23
+ import { resolveSiblings } from "./lib/cliSiblings.ts"
23
24
  import { execSshCommand } from "./lib/sshExec.ts"
24
25
  import { runPullFiles, runPullDb } from "./lib/cliPull.ts"
25
26
 
@@ -72,6 +73,11 @@ Usage:
72
73
  spinuptui ssh-exec <domain> [--server <name>] -- <command> Run a read-only
73
74
  command over SSH (JSON); denies anything that looks
74
75
  like a remote write
76
+ spinuptui siblings <domain> [--server <name>] [--probe] List every site
77
+ sharing a server with this one (JSON), flagging the
78
+ server's vanity host and which sites Kuma watches.
79
+ --probe also SSHes into each to report which ones this
80
+ device's key can actually read.
75
81
  spinuptui incidents <domain> | --all [--hours N] Print Uptime Kuma
76
82
  down/up incidents for a site or the whole fleet (JSON)
77
83
  spinuptui pull files <domain> [path] [--url <local url>] [--server <name>]
@@ -163,6 +169,28 @@ if (command === "ssh-exec") {
163
169
  process.exit(result.ok ? 0 : 1)
164
170
  }
165
171
 
172
+ if (command === "siblings") {
173
+ const parsed = parseFlags(args.slice(1))
174
+ const domain = parsed.positionals[0]
175
+ const server = parsed.flags.get("--server") ?? null
176
+ const probe = parsed.flags.has("--probe")
177
+ if (!domain || badFlagValue(parsed.flags, "--server")) {
178
+ console.error(
179
+ JSON.stringify({
180
+ ok: false,
181
+ reason: "usage",
182
+ message: "Usage: spinuptui siblings <domain> [--server <name>] [--probe]",
183
+ }),
184
+ )
185
+ process.exit(1)
186
+ }
187
+ const cfg = loadConfig()
188
+ const client = new SpinupWPClient(cfg)
189
+ const result = await resolveSiblings(domain, client, cfg, { server, probe })
190
+ console.log(JSON.stringify(result))
191
+ process.exit(result.ok ? 0 : 1)
192
+ }
193
+
166
194
  if (command === "incidents") {
167
195
  const rest = args.slice(1)
168
196
  const hoursIdx = rest.indexOf("--hours")
@@ -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
- export async function resolveSshAccess(
173
- domain: string,
174
- client: SpinupWPClientLike,
175
- cfg: AppConfig,
176
- opts?: { server?: string | null },
177
- ): Promise<SshAccessResult> {
178
- const resolution = await resolveSshTargetInfo(domain, client, cfg, opts)
179
- if (!resolution.ok) return resolution.result
180
- const { primaryDomain, sshTarget: target, port, server: serverName } = resolution.info
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 { ok: false, domain, reason: "ssh_error", message: `Failed to launch ssh: ${(err as Error).message}` }
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
- }, 15000)
204
+ }, PROBE_TIMEOUT_MS)
201
205
 
202
206
  const exitCode = await proc.exited
203
207
  clearTimeout(timeout)
204
- const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text()
208
+ if (exitCode === 0) return { status: "ok" }
205
209
 
206
- if (exitCode === 0) {
207
- return { ok: true, domain, primaryDomain, sshTarget: target, port, server: serverName }
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
- if (/permission denied/i.test(stderr)) {
211
- return {
212
- ok: false,
213
- domain,
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
- ok: false,
222
- domain,
223
- reason: "connection_failed",
224
- message: stderr.trim().split("\n").slice(-2).join(" ") || `ssh exited with code ${exitCode}`,
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: "ssh_error",
231
- message: stderr.trim().split("\n").slice(-2).join(" ") || `ssh exited with code ${exitCode}`,
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
+ }
@@ -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
- if (wanted.length === 0) return { ok: true, added: [] }
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
- // 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.
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 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.
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, and
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
- // 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
- }
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
- 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 }))
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