spinuptui 0.28.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -4
- package/README.md +36 -8
- package/bun.lock +103 -0
- package/package.json +3 -2
- package/src/api/client.ts +98 -3
- package/src/api/types.ts +51 -2
- package/src/dev/mockClient.ts +56 -0
- package/src/index.tsx +28 -0
- package/src/lib/appUpdate.ts +16 -4
- package/src/lib/cliIncidents.ts +7 -0
- package/src/lib/cliSiblings.ts +181 -0
- package/src/lib/cliSsh.ts +45 -31
- package/src/lib/cloneSettings.ts +180 -0
- package/src/lib/serverClone.ts +173 -12
- package/src/lib/spinupweb.ts +7 -0
- package/src/lib/stack.ts +7 -4
- package/src/lib/wpCli.ts +52 -0
- package/src/lib/wpCore.ts +7 -18
- package/src/lib/wpInventory.ts +18 -14
- package/src/ui/Header.tsx +26 -0
- package/src/ui/Help.tsx +3 -3
- package/src/ui/store.tsx +104 -11
- package/src/ui/views/CloneWizard.tsx +52 -5
package/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
|
+
}
|