spinuptui 0.19.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 +943 -0
- package/LICENSE +21 -0
- package/README.md +856 -0
- package/bin/spinuptui.js +13 -0
- package/package.json +55 -0
- package/src/api/client.ts +354 -0
- package/src/api/types.ts +206 -0
- package/src/config.ts +309 -0
- package/src/dev/devMode.ts +7 -0
- package/src/dev/fixtures.ts +238 -0
- package/src/dev/mockClient.ts +214 -0
- package/src/index.tsx +68 -0
- package/src/lib/appUpdate.ts +206 -0
- package/src/lib/awsSigV4.ts +133 -0
- package/src/lib/cloneDomains.ts +112 -0
- package/src/lib/cloneLog.ts +85 -0
- package/src/lib/dbBackup.ts +198 -0
- package/src/lib/dbSync.ts +253 -0
- package/src/lib/discover.ts +165 -0
- package/src/lib/dns.ts +144 -0
- package/src/lib/dnsCache.ts +94 -0
- package/src/lib/dnsQuery.ts +180 -0
- package/src/lib/dnsRecords.ts +411 -0
- package/src/lib/format.ts +106 -0
- package/src/lib/gitDeployKey.ts +0 -0
- package/src/lib/gitStatus.ts +37 -0
- package/src/lib/jobs.ts +32 -0
- package/src/lib/keychain.ts +49 -0
- package/src/lib/local.ts +116 -0
- package/src/lib/mediaFallback.ts +288 -0
- package/src/lib/open.ts +103 -0
- package/src/lib/phpEol.ts +151 -0
- package/src/lib/probe.ts +183 -0
- package/src/lib/providers.ts +284 -0
- package/src/lib/providersCache.ts +105 -0
- package/src/lib/releaseNotes.ts +67 -0
- package/src/lib/serverClone.ts +782 -0
- package/src/lib/serverCreate.ts +161 -0
- package/src/lib/siteDoctor.ts +260 -0
- package/src/lib/siteFingerprint.ts +108 -0
- package/src/lib/spinupweb.ts +33 -0
- package/src/lib/ssh.ts +669 -0
- package/src/lib/stack.ts +78 -0
- package/src/lib/stackCache.ts +113 -0
- package/src/lib/theme.ts +54 -0
- package/src/lib/ubuntuEol.ts +124 -0
- package/src/lib/uptimeKuma.ts +551 -0
- package/src/lib/vanitySite.ts +297 -0
- package/src/lib/wpInventory.ts +153 -0
- package/src/ui/App.tsx +265 -0
- package/src/ui/Details.tsx +403 -0
- package/src/ui/Explain.tsx +158 -0
- package/src/ui/Header.tsx +117 -0
- package/src/ui/Help.tsx +296 -0
- package/src/ui/List.tsx +95 -0
- package/src/ui/Onboarding.tsx +93 -0
- package/src/ui/Splash.tsx +51 -0
- package/src/ui/StatusBar.tsx +60 -0
- package/src/ui/components.tsx +392 -0
- package/src/ui/store.tsx +4253 -0
- package/src/ui/views/Browser.tsx +365 -0
- package/src/ui/views/CloneWizard.tsx +932 -0
- package/src/ui/views/Dashboard.tsx +149 -0
- package/src/ui/views/DbBackup.tsx +228 -0
- package/src/ui/views/DbSync.tsx +307 -0
- package/src/ui/views/Discover.tsx +271 -0
- package/src/ui/views/DnsInventory.tsx +580 -0
- package/src/ui/views/DnsRecords.tsx +562 -0
- package/src/ui/views/Events.tsx +98 -0
- package/src/ui/views/Forgotten.tsx +165 -0
- package/src/ui/views/GrantKey.tsx +504 -0
- package/src/ui/views/Health.tsx +322 -0
- package/src/ui/views/HttpsToggle.tsx +195 -0
- package/src/ui/views/KumaSite.tsx +571 -0
- package/src/ui/views/LocalLink.tsx +220 -0
- package/src/ui/views/MediaFallback.tsx +180 -0
- package/src/ui/views/NewServer.tsx +750 -0
- package/src/ui/views/PhpUpgrade.tsx +301 -0
- package/src/ui/views/ProviderConnect.tsx +527 -0
- package/src/ui/views/PurgeCache.tsx +186 -0
- package/src/ui/views/ReleaseNotes.tsx +84 -0
- package/src/ui/views/Search.tsx +448 -0
- package/src/ui/views/ServerActions.tsx +328 -0
- package/src/ui/views/Stacks.tsx +371 -0
- package/src/ui/views/SudoConnect.tsx +358 -0
- package/src/ui/views/VanityNewSite.tsx +407 -0
- package/src/ui/views/WpInventory.tsx +218 -0
- package/src/version.ts +8 -0
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
// Orchestrator for "clone a server to a new server" (backlog item 5). Built on the
|
|
2
|
+
// sudo-over-SSH transport (mirrors ssh.ts runSudoSiteOp): SSH to the server's sudo
|
|
3
|
+
// user with your key, then `sudo -S` reads the password from stdin so nothing is
|
|
4
|
+
// injected or persisted. The privileged password is passed in by the caller (the
|
|
5
|
+
// store holds it in memory; a headless harness reads a dev one) — never stored here.
|
|
6
|
+
//
|
|
7
|
+
// SLICE 4a (this file): the transport + source-read sizing for the Plan step. The
|
|
8
|
+
// per-site pull chain (create → pull → config → deploy → verify) lands next; it
|
|
9
|
+
// extends this same transport. See docs/2026-06-24_clone-to-server-spec.md and
|
|
10
|
+
// docs/2026-06-27_site-creation-api-findings.md (the create-only/git-deploy findings
|
|
11
|
+
// that shape the Bedrock branch).
|
|
12
|
+
|
|
13
|
+
import type { Server } from "../api/types.ts"
|
|
14
|
+
|
|
15
|
+
const SSH_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-o", "StrictHostKeyChecking=accept-new"]
|
|
16
|
+
|
|
17
|
+
function shq(s: string): string {
|
|
18
|
+
return `'${s.replace(/'/g, `'\\''`)}'`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SudoResult {
|
|
22
|
+
ok: boolean
|
|
23
|
+
stdout: string
|
|
24
|
+
stderr: string
|
|
25
|
+
code: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Run a bash script as root on `server` via its sudo user. `script` is fed on stdin
|
|
29
|
+
// after the password line, so `sudo -S -p ''` consumes the password and bash reads
|
|
30
|
+
// the rest. Key-based SSH to the sudo user is assumed (same as SudoConnect).
|
|
31
|
+
export async function sudoExec(
|
|
32
|
+
server: Server,
|
|
33
|
+
sudoUser: string,
|
|
34
|
+
sudoPassword: string,
|
|
35
|
+
script: string,
|
|
36
|
+
timeoutMs = 120_000,
|
|
37
|
+
): Promise<SudoResult> {
|
|
38
|
+
const ip = server.ip_address ?? ""
|
|
39
|
+
const target = `${sudoUser}@${ip}`
|
|
40
|
+
const portOpt = server.ssh_port ? ["-p", String(server.ssh_port)] : []
|
|
41
|
+
const ctrl = new AbortController()
|
|
42
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
|
|
43
|
+
try {
|
|
44
|
+
const proc = Bun.spawn(["ssh", ...SSH_OPTS, ...portOpt, target, "sudo -S -p '' bash -s"], {
|
|
45
|
+
stdin: "pipe",
|
|
46
|
+
stdout: "pipe",
|
|
47
|
+
stderr: "pipe",
|
|
48
|
+
signal: ctrl.signal,
|
|
49
|
+
})
|
|
50
|
+
proc.stdin.write(`${sudoPassword}\n${script}`)
|
|
51
|
+
await proc.stdin.end()
|
|
52
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
53
|
+
new Response(proc.stdout).text(),
|
|
54
|
+
new Response(proc.stderr).text(),
|
|
55
|
+
proc.exited,
|
|
56
|
+
])
|
|
57
|
+
return { ok: code === 0, stdout, stderr, code }
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return { ok: false, stdout: "", stderr: (err as Error).message, code: -1 }
|
|
60
|
+
} finally {
|
|
61
|
+
clearTimeout(timer)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SiteSizeInput {
|
|
66
|
+
siteId: number
|
|
67
|
+
domain: string
|
|
68
|
+
siteUser: string
|
|
69
|
+
publicFolder?: string // wp-cli runs from files<public_folder> (files/ when "/")
|
|
70
|
+
}
|
|
71
|
+
export interface SiteSizeEstimate {
|
|
72
|
+
web: number // webroot bytes (uncompressed du) — the files-transfer soft ceiling
|
|
73
|
+
db: number
|
|
74
|
+
total: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Estimate each source site's payload (webroot bytes + DB bytes) in ONE round trip.
|
|
78
|
+
// Webroot via `du -sb /sites/<domain>/files`; DB via `wp db size --size_format=b`
|
|
79
|
+
// run as the site user from the site's WP dir (wp-cli.yml resolves Bedrock's web/wp
|
|
80
|
+
// path and reads .env). Sites whose probe fails are simply omitted from the map.
|
|
81
|
+
export async function estimateSourceSiteSizes(
|
|
82
|
+
server: Server,
|
|
83
|
+
sudoUser: string,
|
|
84
|
+
sudoPassword: string,
|
|
85
|
+
sites: SiteSizeInput[],
|
|
86
|
+
): Promise<Map<number, SiteSizeEstimate>> {
|
|
87
|
+
if (sites.length === 0) return new Map()
|
|
88
|
+
const lines = sites.map((s) => {
|
|
89
|
+
const root = `/sites/${s.domain}/files`
|
|
90
|
+
return [
|
|
91
|
+
detectWpDirScript(root, s.publicFolder), // sets D (files root) + W (real WP dir, may be empty)
|
|
92
|
+
`wb=$(du -sb "$D" 2>/dev/null | cut -f1)`,
|
|
93
|
+
`db=$(cd "$W" 2>/dev/null && sudo -u ${shq(s.siteUser)} -H wp db size --size_format=b 2>/dev/null | tr -dc 0-9)`,
|
|
94
|
+
`echo "${s.siteId} \${wb:-0} \${db:-0}"`,
|
|
95
|
+
].join("; ")
|
|
96
|
+
})
|
|
97
|
+
const res = await sudoExec(server, sudoUser, sudoPassword, lines.join("\n"), 120_000)
|
|
98
|
+
const out = new Map<number, SiteSizeEstimate>()
|
|
99
|
+
if (!res.ok) return out
|
|
100
|
+
for (const line of res.stdout.split("\n")) {
|
|
101
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)$/)
|
|
102
|
+
if (!m) continue
|
|
103
|
+
const id = Number(m[1])
|
|
104
|
+
const web = Number(m[2])
|
|
105
|
+
const db = Number(m[3])
|
|
106
|
+
if (web + db > 0) out.set(id, { web, db, total: web + db })
|
|
107
|
+
}
|
|
108
|
+
return out
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- Standard WP per-site pull chain --------------------------------------
|
|
112
|
+
//
|
|
113
|
+
// Proven live web1→web2 (2026-06-27). Key lessons baked in:
|
|
114
|
+
// • rsync-over-ssh HANGS in this transport — use tar-over-ssh for files and
|
|
115
|
+
// cat-over-ssh for the DB dump, each bounded with `timeout -k 5 N`.
|
|
116
|
+
// • order is files → re-stamp wp-config → DB import (import needs wp-config
|
|
117
|
+
// already pointing at the dest DB).
|
|
118
|
+
// • the dest authenticates into the SOURCE as the site user via an ephemeral
|
|
119
|
+
// key granted onto the source site user; revoke by the unique comment marker
|
|
120
|
+
// (a full-key grep match is brittle through shell quoting).
|
|
121
|
+
|
|
122
|
+
export interface SudoCtx {
|
|
123
|
+
server: Server
|
|
124
|
+
sudoUser: string
|
|
125
|
+
sudoPassword: string
|
|
126
|
+
}
|
|
127
|
+
export type CloneStage = "detect" | "auth" | "build" | "files" | "config" | "db" | "verify" | "revoke"
|
|
128
|
+
export type CloneProgress = (stage: CloneStage, status: "start" | "ok" | "fail", detail?: string) => void
|
|
129
|
+
|
|
130
|
+
// Every sudo script a pull chain runs, reported to the caller for persistent
|
|
131
|
+
// logging (the roster truncates errors; the log keeps the full stdout/stderr).
|
|
132
|
+
export interface CloneExecRecord {
|
|
133
|
+
domain: string
|
|
134
|
+
stage: CloneStage
|
|
135
|
+
host: string // which end ran it (source/dest server name)
|
|
136
|
+
ok: boolean
|
|
137
|
+
code: number
|
|
138
|
+
ms: number
|
|
139
|
+
script: string
|
|
140
|
+
stdout: string
|
|
141
|
+
stderr: string
|
|
142
|
+
}
|
|
143
|
+
export type CloneExecLog = (e: CloneExecRecord) => void
|
|
144
|
+
|
|
145
|
+
// Byte-level transfer progress: the pull materializes a growing file (the tarball
|
|
146
|
+
// or DB dump being received), so a SIDECAR poll — an independent, read-only `stat`
|
|
147
|
+
// over its own SSH session every few seconds — reads exact bytes-so-far without
|
|
148
|
+
// touching the transfer pipeline (progress display must never be able to break a
|
|
149
|
+
// transfer). Best-effort: a failed poll is silently skipped.
|
|
150
|
+
// `target` is the expected final size when known; `exact` distinguishes a true
|
|
151
|
+
// percent (the DB dump is staged+gzipped BEFORE the pull, so its size is a fact)
|
|
152
|
+
// from a soft ceiling (files stream gzip-compressed, so the uncompressed du size
|
|
153
|
+
// only bounds the transfer — a percent against it would lie).
|
|
154
|
+
export type CloneTransfer = (stage: CloneStage, bytes: number, target?: number, exact?: boolean) => void
|
|
155
|
+
|
|
156
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
157
|
+
|
|
158
|
+
// Poll `path` on `ctx` until stopped; report each growing size. Returns a stop().
|
|
159
|
+
export function pollTransferSize(ctx: SudoCtx, path: string, onBytes: (bytes: number) => void): () => void {
|
|
160
|
+
let live = true
|
|
161
|
+
void (async () => {
|
|
162
|
+
await sleep(4000) // let the transfer begin
|
|
163
|
+
while (live) {
|
|
164
|
+
const r = await exec(ctx, `stat -c %s ${shq(path)} 2>/dev/null || true`, 15_000)
|
|
165
|
+
if (!live) break
|
|
166
|
+
const n = Number(r.stdout.trim())
|
|
167
|
+
if (r.ok && Number.isFinite(n) && n > 0) onBytes(n)
|
|
168
|
+
await sleep(5000)
|
|
169
|
+
}
|
|
170
|
+
})()
|
|
171
|
+
return () => {
|
|
172
|
+
live = false
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- Webroot: configured vs REAL ------------------------------------------
|
|
177
|
+
//
|
|
178
|
+
// SpinupWP's public_folder is a *setting* (nginx root = files<public_folder>) — the
|
|
179
|
+
// panel never moves files (its UI warns users to move them by hand), so where
|
|
180
|
+
// WordPress ACTUALLY lives is a separate fact. The 2026-07-02 web2→mercury failure
|
|
181
|
+
// taught us not to hardcode files/; trusting the setting alone is the same trap one
|
|
182
|
+
// level up. So the pull chain DETECTS the real WP dir on the source (wp-settings.php
|
|
183
|
+
// is the core marker) and, on the dest, normalizes the tree so the files sit where
|
|
184
|
+
// the configured public folder expects them — completing the move-the-files step
|
|
185
|
+
// SpinupWP leaves to users, instead of cloning a 404.
|
|
186
|
+
|
|
187
|
+
// "/public/" → "public", "/" or undefined → "" (relative to files/).
|
|
188
|
+
export function publicFolderRel(publicFolder?: string): string {
|
|
189
|
+
return (publicFolder ?? "/").replace(/^\/+|\/+$/g, "")
|
|
190
|
+
}
|
|
191
|
+
function webrootFor(root: string, publicFolder?: string): string {
|
|
192
|
+
const pf = publicFolderRel(publicFolder)
|
|
193
|
+
return pf ? `${root}/${pf}` : root
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Bash fragment: set W to the dir under $D (the files root) that holds
|
|
197
|
+
// wp-settings.php — configured-candidate first, then the root, then a bounded find.
|
|
198
|
+
// Leaves W empty when no WordPress core exists. Exported for the test harness.
|
|
199
|
+
export function detectWpDirScript(root: string, publicFolder?: string): string {
|
|
200
|
+
const candidate = webrootFor(root, publicFolder)
|
|
201
|
+
return [
|
|
202
|
+
`D=${shq(root)}; W=""`,
|
|
203
|
+
`[ -f ${shq(candidate)}/wp-settings.php ] && W=${shq(candidate)}`,
|
|
204
|
+
`[ -z "$W" ] && [ -f "$D/wp-settings.php" ] && W="$D"`,
|
|
205
|
+
`[ -z "$W" ] && { F=$(find "$D" -maxdepth 3 -name wp-settings.php -not -path "*/wp-content/*" -print -quit 2>/dev/null); [ -n "$F" ] && W=$(dirname "$F"); }`,
|
|
206
|
+
].join("; ")
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Bash fragment: sweep everything under `root` into `destWpDir` (the configured
|
|
210
|
+
// public folder) — the move-the-files step SpinupWP leaves to users. wp-config.php
|
|
211
|
+
// is then placed ONE LEVEL ABOVE the webroot: WordPress's long-standing config-
|
|
212
|
+
// outside-the-docroot hardening, the whole point of a public/ layout, and a
|
|
213
|
+
// deliberate product stance (see CLAUDE.md "WordPress layout rules"). Root-webroot
|
|
214
|
+
// sites never reach this path — their config stays alongside core. Idempotent: a
|
|
215
|
+
// tree that already has core at destWpDir is left alone. Exported for the test harness.
|
|
216
|
+
export function normalizeWebrootScript(root: string, destWpDir: string): string {
|
|
217
|
+
const parent = destWpDir.slice(0, destWpDir.lastIndexOf("/"))
|
|
218
|
+
return `if [ ! -f ${shq(destWpDir)}/wp-settings.php ]; then cd ${shq(root)}; rm -rf .spinup_normalize; mkdir .spinup_normalize; find . -mindepth 1 -maxdepth 1 ! -name .spinup_normalize -exec mv {} .spinup_normalize/ \\; ; mkdir -p ${shq(destWpDir)}; find .spinup_normalize -mindepth 1 -maxdepth 1 -exec mv {} ${shq(destWpDir)}/ \\; ; rmdir .spinup_normalize; if [ -f ${shq(destWpDir)}/wp-config.php ]; then mv ${shq(destWpDir)}/wp-config.php ${shq(parent)}/wp-config.php; fi; fi`
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Decide what the clone does about the webroot, from the DETECTED source layout vs
|
|
222
|
+
// the CONFIGURED public folder (both relative to files/, "" = files root):
|
|
223
|
+
// • match → clone as-is.
|
|
224
|
+
// • detected at root, setting deeper → the mid-move SOP state: normalize the dest
|
|
225
|
+
// (sweep files under the setting) so nginx serves the clone.
|
|
226
|
+
// • detected deeper, setting at root → a subdirectory install (root index.php
|
|
227
|
+
// bootstraps core in a subfolder) — a working layout; clone as-is, wp-cli runs
|
|
228
|
+
// from the core dir.
|
|
229
|
+
// • two different non-root dirs → too custom to guess; refuse with a clear error.
|
|
230
|
+
// Exported (pure) for the test harness.
|
|
231
|
+
export function planWebroot(detectedRel: string, configuredRel: string): { destRel: string; normalize: boolean } | { error: string } {
|
|
232
|
+
if (detectedRel === configuredRel) return { destRel: detectedRel, normalize: false }
|
|
233
|
+
if (detectedRel === "") return { destRel: configuredRel, normalize: true }
|
|
234
|
+
if (configuredRel === "") return { destRel: detectedRel, normalize: false }
|
|
235
|
+
return { error: `WordPress lives at files/${detectedRel} on the source but the public folder setting is /${configuredRel}/ — can't auto-normalize; align them in SpinupWP (or move the files) and retry` }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface StandardWpPullSpec {
|
|
239
|
+
domain: string
|
|
240
|
+
sourceSiteUser: string
|
|
241
|
+
destSiteUser: string
|
|
242
|
+
destDbName: string
|
|
243
|
+
destDbUser: string
|
|
244
|
+
destDbPassword: string
|
|
245
|
+
publicFolder?: string // source site's public_folder (dest is created with the same)
|
|
246
|
+
approxFilesBytes?: number // Plan's uncompressed webroot size — soft ceiling for the files meter
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// The ephemeral pull key and its authorized_keys marker are PER SITE. They were
|
|
250
|
+
// once a single shared /root/.clone_pull — with concurrent sites each auth stage
|
|
251
|
+
// regenerated it and each revoke deleted it, breaking every other in-flight site's
|
|
252
|
+
// SSH mid-chain (proven live 2026-07-02: one site's db pull got Permission denied
|
|
253
|
+
// seconds after another site's auth replaced the key).
|
|
254
|
+
function cloneKeyFor(domain: string): string {
|
|
255
|
+
return `/root/.clone_pull_${domain}`
|
|
256
|
+
}
|
|
257
|
+
function keyMarkerFor(domain: string): string {
|
|
258
|
+
return `spinup-clone-pull-${domain}`
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function exec(ctx: SudoCtx, script: string, timeoutMs = 600_000) {
|
|
262
|
+
return sudoExec(ctx.server, ctx.sudoUser, ctx.sudoPassword, script, timeoutMs)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// The dest pulls the site's files + DB straight from the source (server-to-server,
|
|
266
|
+
// nothing through the orchestrator). Files/DB land owned by the dest site user with a
|
|
267
|
+
// wp-config re-stamped to the dest DB. Verifies `wp core is-installed`; the HTTP
|
|
268
|
+
// `--resolve` check is the caller's (it knows the dest IP). The granted pull key is
|
|
269
|
+
// always revoked (best-effort) before returning.
|
|
270
|
+
//
|
|
271
|
+
// Webroot handling: the REAL source WP dir is detected (stage 0), all source-side
|
|
272
|
+
// wp-cli runs there; planWebroot decides whether the dest tree is normalized to the
|
|
273
|
+
// CONFIGURED public folder (stage 2); all dest-side wp-cli runs from the resulting
|
|
274
|
+
// dir. The result reports both layouts (`sourceWebrootRel`/`destWebrootRel`) for verify.
|
|
275
|
+
export async function runStandardWpPull(
|
|
276
|
+
source: SudoCtx,
|
|
277
|
+
dest: SudoCtx,
|
|
278
|
+
spec: StandardWpPullSpec,
|
|
279
|
+
onProgress: CloneProgress = () => {},
|
|
280
|
+
onExec?: CloneExecLog,
|
|
281
|
+
onTransfer?: CloneTransfer,
|
|
282
|
+
): Promise<{ ok: boolean; error?: string; sourceWebrootRel?: string; destWebrootRel?: string }> {
|
|
283
|
+
const srcIp = source.server.ip_address ?? ""
|
|
284
|
+
const root = `/sites/${spec.domain}/files`
|
|
285
|
+
const home = `/sites/${spec.domain}`
|
|
286
|
+
const expRel = publicFolderRel(spec.publicFolder) // configured layout — nginx's root on both ends
|
|
287
|
+
const su = spec.sourceSiteUser
|
|
288
|
+
const du = spec.destSiteUser
|
|
289
|
+
const KEY = cloneKeyFor(spec.domain)
|
|
290
|
+
const MARKER = keyMarkerFor(spec.domain)
|
|
291
|
+
const sshk = `ssh -i ${KEY} -o StrictHostKeyChecking=accept-new -o BatchMode=yes -o ConnectTimeout=10`
|
|
292
|
+
const fail = (stage: CloneStage, msg: string) => {
|
|
293
|
+
onProgress(stage, "fail", msg)
|
|
294
|
+
return { ok: false as const, error: `${stage}: ${msg}` }
|
|
295
|
+
}
|
|
296
|
+
const run = async (stage: CloneStage, ctx: SudoCtx, script: string, timeoutMs?: number) => {
|
|
297
|
+
const t0 = Date.now()
|
|
298
|
+
const r = await exec(ctx, script, timeoutMs)
|
|
299
|
+
onExec?.({ domain: spec.domain, stage, host: ctx.server.name, ok: r.ok, code: r.code, ms: Date.now() - t0, script, stdout: r.stdout, stderr: r.stderr })
|
|
300
|
+
return r
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 0. detect — find the REAL WP dir on the source (setting and reality can
|
|
304
|
+
// disagree; SpinupWP never moves files). Fails fast before any key is granted.
|
|
305
|
+
onProgress("detect", "start")
|
|
306
|
+
const det = await run("detect", source, `${detectWpDirScript(root, spec.publicFolder)}; echo "WPDIR:$W"`, 30_000)
|
|
307
|
+
const srcWpDir = (det.stdout.match(/^WPDIR:(.*)$/m)?.[1] ?? "").trim()
|
|
308
|
+
if (!det.ok || !srcWpDir) return fail("detect", det.stderr.trim() || `no WordPress core (wp-settings.php) found under ${root} — is this a WordPress site?`)
|
|
309
|
+
const srcRel = srcWpDir === root ? "" : srcWpDir.startsWith(`${root}/`) ? srcWpDir.slice(root.length + 1) : ""
|
|
310
|
+
const plan = planWebroot(srcRel, expRel)
|
|
311
|
+
if ("error" in plan) return fail("detect", plan.error)
|
|
312
|
+
const destWpDir = plan.destRel ? `${root}/${plan.destRel}` : root
|
|
313
|
+
onProgress("detect", "ok", srcRel ? `files/${srcRel}` : "files/")
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
// 1. auth — ephemeral key on dest, granted onto the source site user.
|
|
317
|
+
onProgress("auth", "start")
|
|
318
|
+
const gen = await run("auth", dest, `rm -f ${KEY} ${KEY}.pub; ssh-keygen -t ed25519 -f ${KEY} -N "" -C ${MARKER} >/dev/null 2>&1 && cat ${KEY}.pub`, 30_000)
|
|
319
|
+
const pub = gen.stdout.trim()
|
|
320
|
+
if (!gen.ok || !pub.startsWith("ssh-")) return fail("auth", gen.stderr.trim() || "couldn't generate pull key")
|
|
321
|
+
const grant = await run(
|
|
322
|
+
"auth",
|
|
323
|
+
source,
|
|
324
|
+
`set -e; SSHDIR=${shq(home)}/.ssh; AK=$SSHDIR/authorized_keys; install -d -m 700 -o ${shq(su)} -g ${shq(su)} "$SSHDIR"; touch "$AK"; chown ${shq(su)}:${shq(su)} "$AK"; chmod 600 "$AK"; grep -qxF ${shq(pub)} "$AK" || echo ${shq(pub)} >> "$AK"`,
|
|
325
|
+
30_000,
|
|
326
|
+
)
|
|
327
|
+
if (!grant.ok) return fail("auth", grant.stderr.trim() || "couldn't grant pull key on source")
|
|
328
|
+
onProgress("auth", "ok")
|
|
329
|
+
|
|
330
|
+
// 2. files — tar-over-ssh pull → extract → normalize → chown. When the source
|
|
331
|
+
// keeps WP at the files root but the public folder setting is deeper (the
|
|
332
|
+
// mid-move SOP state), the dest tree is swept under the configured folder so
|
|
333
|
+
// nginx (rooted at files<public_folder>) actually serves the clone. wp-config.php
|
|
334
|
+
// may sit in the webroot OR one level above it, so the assertion accepts either.
|
|
335
|
+
onProgress("files", "start")
|
|
336
|
+
const normalize = plan.normalize ? `${normalizeWebrootScript(root, destWpDir)}; ` : ""
|
|
337
|
+
const stopFilesPoll = pollTransferSize(dest, `/tmp/clone_${spec.domain}.tgz`, (b) => onTransfer?.("files", b, spec.approxFilesBytes, false))
|
|
338
|
+
// wp-config.php may live in the webroot or one level above it (the
|
|
339
|
+
// config-above-webroot convention — see CLAUDE.md); never above files/.
|
|
340
|
+
const cfgDirs = [...new Set([destWpDir, destWpDir.slice(0, destWpDir.lastIndexOf("/")), root])].filter((d) => d.length >= root.length)
|
|
341
|
+
const cfgAssert = cfgDirs.map((d) => `test -f ${shq(`${d}/wp-config.php`)}`).join(" || ")
|
|
342
|
+
// tar exit 1 = "some files differ" (a live site touched a file mid-read —
|
|
343
|
+
// every-minute WP cron + bot traffic make this routine); tolerate it but fail
|
|
344
|
+
// on >=2 (real tar errors), 124 (timeout), 255 (ssh). No --warning suppression:
|
|
345
|
+
// the which-file-changed diagnostics belong in the clone log.
|
|
346
|
+
const files = await run(
|
|
347
|
+
"files",
|
|
348
|
+
dest,
|
|
349
|
+
`set -e; rc=0; timeout -k 5 3600 ${sshk} ${shq(su)}@${srcIp} "tar -C ${shq(root)} -czf - --exclude=wp-content/cache ." </dev/null > /tmp/clone_${spec.domain}.tgz || rc=$?; [ "$rc" -ne 124 ] || { echo "file transfer timed out after 60 minutes" >&2; exit 124; }; [ "$rc" -le 1 ] || { echo "tar-over-ssh failed with exit $rc" >&2; exit "$rc"; }; tar -C ${shq(root)} -xzf /tmp/clone_${spec.domain}.tgz; rm -f /tmp/clone_${spec.domain}.tgz; ${normalize}chown -R ${shq(du)}:${shq(du)} ${shq(root)}; test -f ${shq(destWpDir)}/wp-settings.php || { echo "no WordPress core at ${destWpDir} after pull" >&2; exit 65; }; ${cfgAssert} || { echo "no wp-config.php in the webroot or one level above it (under ${root})" >&2; exit 65; }`,
|
|
350
|
+
3_660_000, // outer must exceed the in-script `timeout 3600` so the inner reports a clean 124
|
|
351
|
+
)
|
|
352
|
+
stopFilesPoll()
|
|
353
|
+
if (!files.ok) return fail("files", files.stderr.trim() || "file pull failed")
|
|
354
|
+
onProgress("files", "ok")
|
|
355
|
+
|
|
356
|
+
// 3. config — re-stamp DB creds to the dest's. Run from the dest WP dir: wp-cli
|
|
357
|
+
// walks up to find wp-config.php, covering both layouts.
|
|
358
|
+
onProgress("config", "start")
|
|
359
|
+
const cfg = await run(
|
|
360
|
+
"config",
|
|
361
|
+
dest,
|
|
362
|
+
`set -e; cd ${shq(destWpDir)}; sudo -u ${shq(du)} -H wp config set DB_NAME ${shq(spec.destDbName)} --type=constant >/dev/null; sudo -u ${shq(du)} -H wp config set DB_USER ${shq(spec.destDbUser)} --type=constant >/dev/null; sudo -u ${shq(du)} -H wp config set DB_PASSWORD ${shq(spec.destDbPassword)} --type=constant >/dev/null; sudo -u ${shq(du)} -H wp db check >/dev/null`,
|
|
363
|
+
60_000,
|
|
364
|
+
)
|
|
365
|
+
if (!cfg.ok) return fail("config", cfg.stderr.trim() || "re-stamp / db check failed")
|
|
366
|
+
onProgress("config", "ok")
|
|
367
|
+
|
|
368
|
+
// 4. db — stage on source (clean stream → file), pull, import. The dump runs
|
|
369
|
+
// from the DETECTED source dir; the import from the normalized dest dir.
|
|
370
|
+
onProgress("db", "start")
|
|
371
|
+
const dump = await run(
|
|
372
|
+
"db",
|
|
373
|
+
source,
|
|
374
|
+
`sudo -u ${shq(su)} -H bash -c "cd ${shq(srcWpDir)} && wp --skip-plugins --skip-themes db export ${shq(home)}/.clone_db.sql 2>/dev/null && gzip -f ${shq(home)}/.clone_db.sql && stat -c %s ${shq(home)}/.clone_db.sql.gz"`,
|
|
375
|
+
900_000,
|
|
376
|
+
)
|
|
377
|
+
if (!dump.ok) return fail("db", dump.stderr.trim() || "source db export failed")
|
|
378
|
+
// The staged dump's size is exact — the db meter gets a true percent.
|
|
379
|
+
const dbTarget = Number(dump.stdout.trim().match(/(\d+)\s*$/)?.[1]) || undefined
|
|
380
|
+
const stopDbPoll = pollTransferSize(dest, `/tmp/clone_${spec.domain}.sql.gz`, (b) => onTransfer?.("db", b, dbTarget, true))
|
|
381
|
+
const imp = await run(
|
|
382
|
+
"db",
|
|
383
|
+
dest,
|
|
384
|
+
`set -e; timeout -k 5 300 ${sshk} ${shq(su)}@${srcIp} "cat ${shq(home)}/.clone_db.sql.gz" </dev/null > /tmp/clone_${spec.domain}.sql.gz; gunzip -f /tmp/clone_${spec.domain}.sql.gz; chmod 644 /tmp/clone_${spec.domain}.sql; cd ${shq(destWpDir)}; sudo -u ${shq(du)} -H wp db import /tmp/clone_${spec.domain}.sql >/dev/null; rm -f /tmp/clone_${spec.domain}.sql`,
|
|
385
|
+
360_000, // outer must exceed the in-script `timeout 300`
|
|
386
|
+
)
|
|
387
|
+
stopDbPoll()
|
|
388
|
+
if (!imp.ok) return fail("db", imp.stderr.trim() || "db pull/import failed")
|
|
389
|
+
onProgress("db", "ok")
|
|
390
|
+
|
|
391
|
+
// 5. verify — wp-cli on the dest (HTTP --resolve is the caller's).
|
|
392
|
+
onProgress("verify", "start")
|
|
393
|
+
const ver = await run("verify", dest, `cd ${shq(destWpDir)}; sudo -u ${shq(du)} -H wp core is-installed`, 30_000)
|
|
394
|
+
if (!ver.ok) return fail("verify", "wp core is-installed returned false on the dest")
|
|
395
|
+
onProgress("verify", "ok")
|
|
396
|
+
|
|
397
|
+
return { ok: true, sourceWebrootRel: srcRel, destWebrootRel: plan.destRel }
|
|
398
|
+
} finally {
|
|
399
|
+
// 6. revoke — always: drop the pull key (by marker) on source, remove the
|
|
400
|
+
// ephemeral key + staged dump on dest. Best-effort.
|
|
401
|
+
onProgress("revoke", "start")
|
|
402
|
+
// sed (not grep -v): grep returns exit 1 when it filters out the only line, which
|
|
403
|
+
// would skip the rewrite and leave the key behind — exactly the case where the
|
|
404
|
+
// source site user has no other keys.
|
|
405
|
+
await run("revoke", source, `AK=${shq(home)}/.ssh/authorized_keys; [ -f "$AK" ] && sed -i ${shq(`/${MARKER}/d`)} "$AK" && chown ${shq(su)}:${shq(su)} "$AK"; rm -f ${shq(home)}/.clone_db.sql.gz`, 30_000).catch(() => {})
|
|
406
|
+
await run("revoke", dest, `rm -f ${KEY} ${KEY}.pub /tmp/clone_${spec.domain}.tgz /tmp/clone_${spec.domain}.sql.gz /tmp/clone_${spec.domain}.sql`, 30_000).catch(() => {})
|
|
407
|
+
onProgress("revoke", "ok")
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---- Bedrock per-site pull chain ------------------------------------------
|
|
412
|
+
//
|
|
413
|
+
// Differs from Standard WP: the dest is created as a `git` site (the CALLER does the
|
|
414
|
+
// POST /sites installation_method:"git"), so SpinupWP clones the repo + the configured
|
|
415
|
+
// deploy_script — BUT git/deploy never runs the script (site-creation findings doc), so
|
|
416
|
+
// we `composer install` ourselves over SSH to build vendor/ + web/wp. Only the
|
|
417
|
+
// gitignored artifacts come from the source: web/app/uploads, auth.json, .env, and the
|
|
418
|
+
// DB. The .env is pulled verbatim and its DB_* creds swapped to the dest's (so salts /
|
|
419
|
+
// WP_HOME / custom vars are preserved — a true clone of the same domain). wp-cli runs
|
|
420
|
+
// from the Bedrock root (files/, where wp-cli.yml points at web/wp).
|
|
421
|
+
|
|
422
|
+
export interface BedrockPullSpec {
|
|
423
|
+
domain: string
|
|
424
|
+
sourceSiteUser: string
|
|
425
|
+
destSiteUser: string
|
|
426
|
+
destDbName: string
|
|
427
|
+
destDbUser: string
|
|
428
|
+
destDbPassword: string
|
|
429
|
+
excludeUploads: boolean
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export async function runBedrockPull(
|
|
433
|
+
source: SudoCtx,
|
|
434
|
+
dest: SudoCtx,
|
|
435
|
+
spec: BedrockPullSpec,
|
|
436
|
+
onProgress: CloneProgress = () => {},
|
|
437
|
+
onExec?: CloneExecLog,
|
|
438
|
+
onTransfer?: CloneTransfer,
|
|
439
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
440
|
+
const srcIp = source.server.ip_address ?? ""
|
|
441
|
+
const root = `/sites/${spec.domain}/files` // Bedrock project root (composer.json, web/, .env)
|
|
442
|
+
const home = `/sites/${spec.domain}`
|
|
443
|
+
const su = spec.sourceSiteUser
|
|
444
|
+
const du = spec.destSiteUser
|
|
445
|
+
const tmp = `/tmp/clone_${spec.domain}`
|
|
446
|
+
const KEY = cloneKeyFor(spec.domain)
|
|
447
|
+
const MARKER = keyMarkerFor(spec.domain)
|
|
448
|
+
const sshk = `ssh -i ${KEY} -o StrictHostKeyChecking=accept-new -o BatchMode=yes -o ConnectTimeout=10`
|
|
449
|
+
const remote = (cmd: string) => `${sshk} ${shq(su)}@${srcIp} ${shq(cmd)} </dev/null`
|
|
450
|
+
const fail = (stage: CloneStage, msg: string) => {
|
|
451
|
+
onProgress(stage, "fail", msg)
|
|
452
|
+
return { ok: false as const, error: `${stage}: ${msg}` }
|
|
453
|
+
}
|
|
454
|
+
const run = async (stage: CloneStage, ctx: SudoCtx, script: string, timeoutMs?: number) => {
|
|
455
|
+
const t0 = Date.now()
|
|
456
|
+
const r = await exec(ctx, script, timeoutMs)
|
|
457
|
+
onExec?.({ domain: spec.domain, stage, host: ctx.server.name, ok: r.ok, code: r.code, ms: Date.now() - t0, script, stdout: r.stdout, stderr: r.stderr })
|
|
458
|
+
return r
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
try {
|
|
462
|
+
// 1. auth — ephemeral key on dest, granted onto the source site user.
|
|
463
|
+
onProgress("auth", "start")
|
|
464
|
+
const gen = await run("auth", dest, `rm -f ${KEY} ${KEY}.pub; ssh-keygen -t ed25519 -f ${KEY} -N "" -C ${MARKER} >/dev/null 2>&1 && cat ${KEY}.pub`, 30_000)
|
|
465
|
+
const pub = gen.stdout.trim()
|
|
466
|
+
if (!gen.ok || !pub.startsWith("ssh-")) return fail("auth", gen.stderr.trim() || "couldn't generate pull key")
|
|
467
|
+
const grant = await run(
|
|
468
|
+
"auth",
|
|
469
|
+
source,
|
|
470
|
+
`set -e; SSHDIR=${shq(home)}/.ssh; AK=$SSHDIR/authorized_keys; install -d -m 700 -o ${shq(su)} -g ${shq(su)} "$SSHDIR"; touch "$AK"; chown ${shq(su)}:${shq(su)} "$AK"; chmod 600 "$AK"; grep -qxF ${shq(pub)} "$AK" || echo ${shq(pub)} >> "$AK"`,
|
|
471
|
+
30_000,
|
|
472
|
+
)
|
|
473
|
+
if (!grant.ok) return fail("auth", grant.stderr.trim() || "couldn't grant pull key on source")
|
|
474
|
+
onProgress("auth", "ok")
|
|
475
|
+
|
|
476
|
+
// 2. build — pull auth.json first (private composer repos need it), then
|
|
477
|
+
// `composer install` to build vendor/ + web/wp (git/deploy won't, per findings).
|
|
478
|
+
onProgress("build", "start")
|
|
479
|
+
const authPull = await run(
|
|
480
|
+
"build",
|
|
481
|
+
dest,
|
|
482
|
+
`set -e; ${remote(`cat ${shq(`${root}/auth.json`)} 2>/dev/null`)} > ${tmp}_auth.json || true; if [ -s ${tmp}_auth.json ]; then install -m 640 -o ${shq(du)} -g ${shq(du)} ${tmp}_auth.json ${shq(`${root}/auth.json`)}; fi; rm -f ${tmp}_auth.json`,
|
|
483
|
+
120_000,
|
|
484
|
+
)
|
|
485
|
+
if (!authPull.ok) return fail("build", authPull.stderr.trim() || "auth.json pull failed")
|
|
486
|
+
const composer = await run(
|
|
487
|
+
"build",
|
|
488
|
+
dest,
|
|
489
|
+
`set -e; cd ${shq(root)}; sudo -u ${shq(du)} -H bash -lc 'composer install -o --no-dev --no-interaction' 2>&1; test -d ${shq(`${root}/web/wp`)}`,
|
|
490
|
+
600_000,
|
|
491
|
+
)
|
|
492
|
+
if (!composer.ok) return fail("build", (composer.stdout + composer.stderr).trim().split("\n").slice(-3).join(" ") || "composer install failed")
|
|
493
|
+
onProgress("build", "ok")
|
|
494
|
+
|
|
495
|
+
// 3. files — pull web/app/uploads (unless excluded). Best-effort: a site with no
|
|
496
|
+
// uploads dir yields an empty stream we simply skip.
|
|
497
|
+
onProgress("files", "start")
|
|
498
|
+
if (!spec.excludeUploads) {
|
|
499
|
+
const stopUpPoll = pollTransferSize(dest, `${tmp}_up.tgz`, (b) => onTransfer?.("files", b))
|
|
500
|
+
const up = await run(
|
|
501
|
+
"files",
|
|
502
|
+
dest,
|
|
503
|
+
`set -e; timeout -k 5 3600 ${remote(`[ -d ${shq(`${root}/web/app/uploads`)} ] && tar -C ${shq(root)} --warning=no-file-changed -czf - web/app/uploads || true`)} > ${tmp}_up.tgz; if [ -s ${tmp}_up.tgz ]; then tar -C ${shq(root)} -xzf ${tmp}_up.tgz && chown -R ${shq(du)}:${shq(du)} ${shq(`${root}/web/app/uploads`)}; fi; rm -f ${tmp}_up.tgz`,
|
|
504
|
+
3_660_000, // outer must exceed the in-script `timeout 3600` so the inner reports a clean 124
|
|
505
|
+
)
|
|
506
|
+
stopUpPoll()
|
|
507
|
+
if (!up.ok) return fail("files", up.stderr.trim() || "uploads pull failed")
|
|
508
|
+
}
|
|
509
|
+
onProgress("files", "ok")
|
|
510
|
+
|
|
511
|
+
// 4. config — pull source .env verbatim, swap DB_* (and any DATABASE_URL) to the
|
|
512
|
+
// dest creds, keep everything else (salts, WP_HOME, custom vars). Then db check.
|
|
513
|
+
onProgress("config", "start")
|
|
514
|
+
const cfg = await run(
|
|
515
|
+
"config",
|
|
516
|
+
dest,
|
|
517
|
+
[
|
|
518
|
+
`set -e`,
|
|
519
|
+
`${remote(`cat ${shq(`${root}/.env`)}`)} > ${tmp}_env`,
|
|
520
|
+
`test -s ${tmp}_env`,
|
|
521
|
+
// drop existing DB assignments (commented or not) + DATABASE_URL, then append canonical creds.
|
|
522
|
+
`sed -i -E '/^[[:space:]]*#?[[:space:]]*(DB_NAME|DB_USER|DB_PASSWORD|DB_HOST|DATABASE_URL)[[:space:]]*=/d' ${tmp}_env`,
|
|
523
|
+
`printf "\\nDB_NAME='%s'\\nDB_USER='%s'\\nDB_PASSWORD='%s'\\nDB_HOST='localhost'\\n" ${shq(spec.destDbName)} ${shq(spec.destDbUser)} ${shq(spec.destDbPassword)} >> ${tmp}_env`,
|
|
524
|
+
`install -m 640 -o ${shq(du)} -g ${shq(du)} ${tmp}_env ${shq(`${root}/.env`)}`,
|
|
525
|
+
`rm -f ${tmp}_env`,
|
|
526
|
+
`cd ${shq(root)}; sudo -u ${shq(du)} -H wp db check >/dev/null 2>&1`,
|
|
527
|
+
].join("; "),
|
|
528
|
+
60_000,
|
|
529
|
+
)
|
|
530
|
+
if (!cfg.ok) return fail("config", cfg.stderr.trim() || ".env re-stamp / db check failed")
|
|
531
|
+
onProgress("config", "ok")
|
|
532
|
+
|
|
533
|
+
// 5. db — stage on source (clean stream → file), pull, import.
|
|
534
|
+
onProgress("db", "start")
|
|
535
|
+
const dump = await run(
|
|
536
|
+
"db",
|
|
537
|
+
source,
|
|
538
|
+
`sudo -u ${shq(su)} -H bash -c "cd ${shq(root)} && wp --skip-plugins --skip-themes db export ${shq(home)}/.clone_db.sql 2>/dev/null && gzip -f ${shq(home)}/.clone_db.sql && stat -c %s ${shq(home)}/.clone_db.sql.gz"`,
|
|
539
|
+
900_000,
|
|
540
|
+
)
|
|
541
|
+
if (!dump.ok) return fail("db", dump.stderr.trim() || "source db export failed")
|
|
542
|
+
const dbTarget = Number(dump.stdout.trim().match(/(\d+)\s*$/)?.[1]) || undefined
|
|
543
|
+
const stopDbPoll = pollTransferSize(dest, `${tmp}.sql.gz`, (b) => onTransfer?.("db", b, dbTarget, true))
|
|
544
|
+
const imp = await run(
|
|
545
|
+
"db",
|
|
546
|
+
dest,
|
|
547
|
+
`set -e; timeout -k 5 300 ${remote(`cat ${shq(`${home}/.clone_db.sql.gz`)}`)} > ${tmp}.sql.gz; gunzip -f ${tmp}.sql.gz; chmod 644 ${tmp}.sql; cd ${shq(root)}; sudo -u ${shq(du)} -H wp db import ${tmp}.sql >/dev/null; rm -f ${tmp}.sql`,
|
|
548
|
+
360_000, // outer must exceed the in-script `timeout 300`
|
|
549
|
+
)
|
|
550
|
+
stopDbPoll()
|
|
551
|
+
if (!imp.ok) return fail("db", imp.stderr.trim() || "db pull/import failed")
|
|
552
|
+
onProgress("db", "ok")
|
|
553
|
+
|
|
554
|
+
// 6. verify — wp-cli on the dest (HTTP --resolve is the caller's).
|
|
555
|
+
onProgress("verify", "start")
|
|
556
|
+
const ver = await run("verify", dest, `cd ${shq(root)}; sudo -u ${shq(du)} -H wp core is-installed`, 30_000)
|
|
557
|
+
if (!ver.ok) return fail("verify", "wp core is-installed returned false on the dest")
|
|
558
|
+
onProgress("verify", "ok")
|
|
559
|
+
|
|
560
|
+
return { ok: true }
|
|
561
|
+
} finally {
|
|
562
|
+
// 7. revoke — drop the pull key (by marker) on source, clean dest temp + staged dump.
|
|
563
|
+
onProgress("revoke", "start")
|
|
564
|
+
await run("revoke", source, `AK=${shq(home)}/.ssh/authorized_keys; [ -f "$AK" ] && sed -i ${shq(`/${MARKER}/d`)} "$AK" && chown ${shq(su)}:${shq(su)} "$AK"; rm -f ${shq(home)}/.clone_db.sql.gz`, 30_000).catch(() => {})
|
|
565
|
+
await run("revoke", dest, `rm -f ${KEY} ${KEY}.pub ${tmp}_auth.json ${tmp}_up.tgz ${tmp}_env ${tmp}.sql.gz ${tmp}.sql`, 30_000).catch(() => {})
|
|
566
|
+
onProgress("revoke", "ok")
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ---- Files-only pull chain (non-WP sites: redirect shells, static/PHP sites) --
|
|
571
|
+
//
|
|
572
|
+
// The same hardened transport as the Standard-WP files stage (per-site key,
|
|
573
|
+
// tolerant tar, 60-min budget, sidecar byte meter) with everything WordPress
|
|
574
|
+
// removed: no detect, no wp-config re-stamp, no database. The dest site is
|
|
575
|
+
// created blank with NO database block (the caller's job). Verification is
|
|
576
|
+
// file-count/size + HTTP, not wp-cli — see verifyFilesClone.
|
|
577
|
+
|
|
578
|
+
export interface FilesOnlyPullSpec {
|
|
579
|
+
domain: string
|
|
580
|
+
sourceSiteUser: string
|
|
581
|
+
destSiteUser: string
|
|
582
|
+
approxFilesBytes?: number // Plan's uncompressed size — soft ceiling for the meter
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export async function runFilesOnlyPull(
|
|
586
|
+
source: SudoCtx,
|
|
587
|
+
dest: SudoCtx,
|
|
588
|
+
spec: FilesOnlyPullSpec,
|
|
589
|
+
onProgress: CloneProgress = () => {},
|
|
590
|
+
onExec?: CloneExecLog,
|
|
591
|
+
onTransfer?: CloneTransfer,
|
|
592
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
593
|
+
const srcIp = source.server.ip_address ?? ""
|
|
594
|
+
const root = `/sites/${spec.domain}/files`
|
|
595
|
+
const home = `/sites/${spec.domain}`
|
|
596
|
+
const su = spec.sourceSiteUser
|
|
597
|
+
const du = spec.destSiteUser
|
|
598
|
+
const KEY = cloneKeyFor(spec.domain)
|
|
599
|
+
const MARKER = keyMarkerFor(spec.domain)
|
|
600
|
+
const sshk = `ssh -i ${KEY} -o StrictHostKeyChecking=accept-new -o BatchMode=yes -o ConnectTimeout=10`
|
|
601
|
+
const fail = (stage: CloneStage, msg: string) => {
|
|
602
|
+
onProgress(stage, "fail", msg)
|
|
603
|
+
return { ok: false as const, error: `${stage}: ${msg}` }
|
|
604
|
+
}
|
|
605
|
+
const run = async (stage: CloneStage, ctx: SudoCtx, script: string, timeoutMs?: number) => {
|
|
606
|
+
const t0 = Date.now()
|
|
607
|
+
const r = await exec(ctx, script, timeoutMs)
|
|
608
|
+
onExec?.({ domain: spec.domain, stage, host: ctx.server.name, ok: r.ok, code: r.code, ms: Date.now() - t0, script, stdout: r.stdout, stderr: r.stderr })
|
|
609
|
+
return r
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
try {
|
|
613
|
+
// 1. auth — ephemeral per-site key on dest, granted onto the source site user.
|
|
614
|
+
onProgress("auth", "start")
|
|
615
|
+
const gen = await run("auth", dest, `rm -f ${KEY} ${KEY}.pub; ssh-keygen -t ed25519 -f ${KEY} -N "" -C ${MARKER} >/dev/null 2>&1 && cat ${KEY}.pub`, 30_000)
|
|
616
|
+
const pub = gen.stdout.trim()
|
|
617
|
+
if (!gen.ok || !pub.startsWith("ssh-")) return fail("auth", gen.stderr.trim() || "couldn't generate pull key")
|
|
618
|
+
const grant = await run(
|
|
619
|
+
"auth",
|
|
620
|
+
source,
|
|
621
|
+
`set -e; SSHDIR=${shq(home)}/.ssh; AK=$SSHDIR/authorized_keys; install -d -m 700 -o ${shq(su)} -g ${shq(su)} "$SSHDIR"; touch "$AK"; chown ${shq(su)}:${shq(su)} "$AK"; chmod 600 "$AK"; grep -qxF ${shq(pub)} "$AK" || echo ${shq(pub)} >> "$AK"`,
|
|
622
|
+
30_000,
|
|
623
|
+
)
|
|
624
|
+
if (!grant.ok) return fail("auth", grant.stderr.trim() || "couldn't grant pull key on source")
|
|
625
|
+
onProgress("auth", "ok")
|
|
626
|
+
|
|
627
|
+
// 2. files — the whole files/ tree, verbatim (whatever layout the site uses).
|
|
628
|
+
onProgress("files", "start")
|
|
629
|
+
const stopPoll = pollTransferSize(dest, `/tmp/clone_${spec.domain}.tgz`, (b) => onTransfer?.("files", b, spec.approxFilesBytes, false))
|
|
630
|
+
const files = await run(
|
|
631
|
+
"files",
|
|
632
|
+
dest,
|
|
633
|
+
`set -e; rc=0; timeout -k 5 3600 ${sshk} ${shq(su)}@${srcIp} "tar -C ${shq(root)} -czf - ." </dev/null > /tmp/clone_${spec.domain}.tgz || rc=$?; [ "$rc" -ne 124 ] || { echo "file transfer timed out after 60 minutes" >&2; exit 124; }; [ "$rc" -le 1 ] || { echo "tar-over-ssh failed with exit $rc" >&2; exit "$rc"; }; tar -C ${shq(root)} -xzf /tmp/clone_${spec.domain}.tgz; rm -f /tmp/clone_${spec.domain}.tgz; chown -R ${shq(du)}:${shq(du)} ${shq(root)}; [ -n "$(ls -A ${shq(root)} 2>/dev/null)" ] || { echo "files/ is empty after pull" >&2; exit 65; }`,
|
|
634
|
+
3_660_000, // outer must exceed the in-script `timeout 3600`
|
|
635
|
+
)
|
|
636
|
+
stopPoll()
|
|
637
|
+
if (!files.ok) return fail("files", files.stderr.trim() || "file pull failed")
|
|
638
|
+
onProgress("files", "ok")
|
|
639
|
+
|
|
640
|
+
return { ok: true }
|
|
641
|
+
} finally {
|
|
642
|
+
onProgress("revoke", "start")
|
|
643
|
+
await run("revoke", source, `AK=${shq(home)}/.ssh/authorized_keys; [ -f "$AK" ] && sed -i ${shq(`/${MARKER}/d`)} "$AK" && chown ${shq(su)}:${shq(su)} "$AK"`, 30_000).catch(() => {})
|
|
644
|
+
await run("revoke", dest, `rm -f ${KEY} ${KEY}.pub /tmp/clone_${spec.domain}.tgz`, 30_000).catch(() => {})
|
|
645
|
+
onProgress("revoke", "ok")
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Verify a files-only clone: file count + total bytes on each side, plus the HTTP
|
|
650
|
+
// check via the dest IP. Counts must match exactly; bytes get a small tolerance
|
|
651
|
+
// (a growing log on the live source shouldn't flunk the clone).
|
|
652
|
+
export async function verifyFilesClone(source: SudoCtx, dest: SudoCtx, spec: { domain: string; destIp: string }): Promise<VerifyResult> {
|
|
653
|
+
const root = `/sites/${spec.domain}/files`
|
|
654
|
+
const script = `echo "files=$(find ${shq(root)} -type f 2>/dev/null | wc -l)"; echo "bytes=$(du -sb ${shq(root)} 2>/dev/null | cut -f1)"`
|
|
655
|
+
const parse = (out: string): Record<string, string> => {
|
|
656
|
+
const o: Record<string, string> = {}
|
|
657
|
+
for (const line of out.split("\n")) {
|
|
658
|
+
const m = line.match(/^(\w+)=(.*)$/)
|
|
659
|
+
if (m) o[m[1]!] = (m[2] ?? "").trim()
|
|
660
|
+
}
|
|
661
|
+
return o
|
|
662
|
+
}
|
|
663
|
+
const [sr, dr, http] = await Promise.all([exec(source, script, 60_000), exec(dest, script, 60_000), curlStatus(spec.domain, spec.destIp)])
|
|
664
|
+
const sf = parse(sr.stdout)
|
|
665
|
+
const cf = parse(dr.stdout)
|
|
666
|
+
const checks: VerifyCheck[] = []
|
|
667
|
+
checks.push({ key: "http", label: "Clone serves (HTTP)", source: "—", clone: http, ok: /^[23]\d\d$/.test(http) })
|
|
668
|
+
checks.push({ key: "files", label: "Files", source: sf.files ?? "—", clone: cf.files ?? "—", ok: !!sf.files && sf.files === cf.files })
|
|
669
|
+
const sb = Number(sf.bytes)
|
|
670
|
+
const cb = Number(cf.bytes)
|
|
671
|
+
const bytesOk = Number.isFinite(sb) && Number.isFinite(cb) && sb > 0 && Math.abs(sb - cb) <= Math.max(4096, sb * 0.01)
|
|
672
|
+
checks.push({ key: "bytes", label: "Total size", source: sf.bytes ?? "—", clone: cf.bytes ?? "—", ok: bytesOk })
|
|
673
|
+
return { ok: checks.every((c) => c.ok), checks }
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ---- Verify a cloned site (slice 5) ---------------------------------------
|
|
677
|
+
//
|
|
678
|
+
// Read-only confirmation that the clone matches the source: compare a handful of
|
|
679
|
+
// wp-cli facts on each side (run as the respective site user from the Bedrock/Std-WP
|
|
680
|
+
// root, identical to the pull) plus an HTTP --resolve fetch of the clone via the dest
|
|
681
|
+
// IP. Nothing is mutated; safe to re-run. wp-cli runs the same from both stacks
|
|
682
|
+
// because both resolve via the site's wp-cli.yml / wp-config from /sites/{domain}/files.
|
|
683
|
+
|
|
684
|
+
export interface VerifyCheck {
|
|
685
|
+
key: string
|
|
686
|
+
label: string
|
|
687
|
+
source: string
|
|
688
|
+
clone: string
|
|
689
|
+
ok: boolean
|
|
690
|
+
}
|
|
691
|
+
export interface VerifyResult {
|
|
692
|
+
ok: boolean
|
|
693
|
+
checks: VerifyCheck[]
|
|
694
|
+
}
|
|
695
|
+
export interface VerifySpec {
|
|
696
|
+
domain: string
|
|
697
|
+
sourceSiteUser: string
|
|
698
|
+
destSiteUser: string
|
|
699
|
+
destIp: string
|
|
700
|
+
publicFolder?: string // configured public folder — fallback when the pull's rels are unknown
|
|
701
|
+
sourceWebrootRel?: string // DETECTED source layout from the pull ("" = files root)
|
|
702
|
+
destWebrootRel?: string // the CLONE's WP dir from the pull (post-normalization)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// One round-trip per side: collect labeled wp-cli facts as `key=value` lines.
|
|
706
|
+
// --skip-plugins/--skip-themes: no plugin code runs per invocation (a plugin making a
|
|
707
|
+
// stalled outbound call once ate the whole budget and cut the facts off mid-script) —
|
|
708
|
+
// and the flags are identical on both sides, so every count stays comparable. The
|
|
709
|
+
// trailing `eof` sentinel is how verifyClone tells "read got cut off" from "differs".
|
|
710
|
+
async function wpFacts(ctx: SudoCtx, root: string, user: string): Promise<Record<string, string>> {
|
|
711
|
+
const script = [
|
|
712
|
+
`cd ${shq(root)} 2>/dev/null || exit 0`,
|
|
713
|
+
`u() { sudo -u ${shq(user)} -H wp --skip-plugins --skip-themes "$@" 2>/dev/null; }`,
|
|
714
|
+
`echo "core=$(u core version)"`,
|
|
715
|
+
`echo "posts=$(u post list --post_type=any --format=count)"`,
|
|
716
|
+
`echo "pages=$(u post list --post_type=page --format=count)"`,
|
|
717
|
+
`echo "users=$(u user list --format=count)"`,
|
|
718
|
+
`echo "plugins=$(u plugin list --status=active --format=count)"`,
|
|
719
|
+
`echo "siteurl=$(u option get siteurl)"`,
|
|
720
|
+
`echo "home=$(u option get home)"`,
|
|
721
|
+
`echo "eof=1"`,
|
|
722
|
+
].join("\n")
|
|
723
|
+
const res = await exec(ctx, script, 120_000)
|
|
724
|
+
const out: Record<string, string> = {}
|
|
725
|
+
for (const line of res.stdout.split("\n")) {
|
|
726
|
+
const m = line.match(/^(\w+)=(.*)$/)
|
|
727
|
+
if (m) out[m[1]] = (m[2] ?? "").trim()
|
|
728
|
+
}
|
|
729
|
+
return out
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// HTTP status of the clone fetched via the dest IP (DNS-independent, like the manual
|
|
733
|
+
// `curl --resolve` checks). 2xx/3xx counts as serving.
|
|
734
|
+
async function curlStatus(domain: string, ip: string): Promise<string> {
|
|
735
|
+
try {
|
|
736
|
+
const proc = Bun.spawn(
|
|
737
|
+
["curl", "-sS", "-o", "/dev/null", "-m", "20", "-w", "%{http_code}", "-L", "--resolve", `${domain}:80:${ip}`, "--resolve", `${domain}:443:${ip}`, `http://${domain}/`],
|
|
738
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
739
|
+
)
|
|
740
|
+
const code = (await new Response(proc.stdout).text()).trim()
|
|
741
|
+
await proc.exited
|
|
742
|
+
return code || "—"
|
|
743
|
+
} catch {
|
|
744
|
+
return "err"
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export async function verifyClone(source: SudoCtx, dest: SudoCtx, spec: VerifySpec): Promise<VerifyResult> {
|
|
749
|
+
const root = `/sites/${spec.domain}/files`
|
|
750
|
+
// Source facts read from where WP was DETECTED during the pull; clone facts from
|
|
751
|
+
// the pull's resulting dir — after a normalizing pull these differ.
|
|
752
|
+
const dirFor = (rel: string | undefined) => (rel != null ? (rel ? `${root}/${rel}` : root) : webrootFor(root, spec.publicFolder))
|
|
753
|
+
const srcDir = dirFor(spec.sourceWebrootRel)
|
|
754
|
+
const destDir = dirFor(spec.destWebrootRel)
|
|
755
|
+
const [sf, cf, http] = await Promise.all([
|
|
756
|
+
wpFacts(source, srcDir, spec.sourceSiteUser),
|
|
757
|
+
wpFacts(dest, destDir, spec.destSiteUser),
|
|
758
|
+
curlStatus(spec.domain, spec.destIp),
|
|
759
|
+
])
|
|
760
|
+
// A missing tail sentinel means the facts script was cut off partway (timeout, a
|
|
761
|
+
// stalled command) — fail the verify loudly instead of rendering the missing tail
|
|
762
|
+
// as misleading ✕ "–" mismatch rows (which read as "the clone differs").
|
|
763
|
+
if (sf.eof !== "1" || cf.eof !== "1") {
|
|
764
|
+
const side = sf.eof !== "1" ? "source" : "clone"
|
|
765
|
+
throw new Error(`couldn't read the ${side}'s WordPress facts (the wp-cli read was cut off) — press v to re-run`)
|
|
766
|
+
}
|
|
767
|
+
const checks: VerifyCheck[] = []
|
|
768
|
+
checks.push({ key: "http", label: "Clone serves (HTTP)", source: "—", clone: http, ok: /^[23]\d\d$/.test(http) })
|
|
769
|
+
const cmp = (key: string, label: string) => {
|
|
770
|
+
const s = sf[key] ?? ""
|
|
771
|
+
const c = cf[key] ?? ""
|
|
772
|
+
checks.push({ key, label, source: s || "—", clone: c || "—", ok: s !== "" && s === c })
|
|
773
|
+
}
|
|
774
|
+
cmp("core", "WordPress version")
|
|
775
|
+
cmp("posts", "Posts")
|
|
776
|
+
cmp("pages", "Pages")
|
|
777
|
+
cmp("users", "Users")
|
|
778
|
+
cmp("plugins", "Active plugins")
|
|
779
|
+
cmp("siteurl", "Site URL")
|
|
780
|
+
cmp("home", "Home URL")
|
|
781
|
+
return { ok: checks.every((c) => c.ok), checks }
|
|
782
|
+
}
|