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.
Files changed (88) hide show
  1. package/CHANGELOG.md +943 -0
  2. package/LICENSE +21 -0
  3. package/README.md +856 -0
  4. package/bin/spinuptui.js +13 -0
  5. package/package.json +55 -0
  6. package/src/api/client.ts +354 -0
  7. package/src/api/types.ts +206 -0
  8. package/src/config.ts +309 -0
  9. package/src/dev/devMode.ts +7 -0
  10. package/src/dev/fixtures.ts +238 -0
  11. package/src/dev/mockClient.ts +214 -0
  12. package/src/index.tsx +68 -0
  13. package/src/lib/appUpdate.ts +206 -0
  14. package/src/lib/awsSigV4.ts +133 -0
  15. package/src/lib/cloneDomains.ts +112 -0
  16. package/src/lib/cloneLog.ts +85 -0
  17. package/src/lib/dbBackup.ts +198 -0
  18. package/src/lib/dbSync.ts +253 -0
  19. package/src/lib/discover.ts +165 -0
  20. package/src/lib/dns.ts +144 -0
  21. package/src/lib/dnsCache.ts +94 -0
  22. package/src/lib/dnsQuery.ts +180 -0
  23. package/src/lib/dnsRecords.ts +411 -0
  24. package/src/lib/format.ts +106 -0
  25. package/src/lib/gitDeployKey.ts +0 -0
  26. package/src/lib/gitStatus.ts +37 -0
  27. package/src/lib/jobs.ts +32 -0
  28. package/src/lib/keychain.ts +49 -0
  29. package/src/lib/local.ts +116 -0
  30. package/src/lib/mediaFallback.ts +288 -0
  31. package/src/lib/open.ts +103 -0
  32. package/src/lib/phpEol.ts +151 -0
  33. package/src/lib/probe.ts +183 -0
  34. package/src/lib/providers.ts +284 -0
  35. package/src/lib/providersCache.ts +105 -0
  36. package/src/lib/releaseNotes.ts +67 -0
  37. package/src/lib/serverClone.ts +782 -0
  38. package/src/lib/serverCreate.ts +161 -0
  39. package/src/lib/siteDoctor.ts +260 -0
  40. package/src/lib/siteFingerprint.ts +108 -0
  41. package/src/lib/spinupweb.ts +33 -0
  42. package/src/lib/ssh.ts +669 -0
  43. package/src/lib/stack.ts +78 -0
  44. package/src/lib/stackCache.ts +113 -0
  45. package/src/lib/theme.ts +54 -0
  46. package/src/lib/ubuntuEol.ts +124 -0
  47. package/src/lib/uptimeKuma.ts +551 -0
  48. package/src/lib/vanitySite.ts +297 -0
  49. package/src/lib/wpInventory.ts +153 -0
  50. package/src/ui/App.tsx +265 -0
  51. package/src/ui/Details.tsx +403 -0
  52. package/src/ui/Explain.tsx +158 -0
  53. package/src/ui/Header.tsx +117 -0
  54. package/src/ui/Help.tsx +296 -0
  55. package/src/ui/List.tsx +95 -0
  56. package/src/ui/Onboarding.tsx +93 -0
  57. package/src/ui/Splash.tsx +51 -0
  58. package/src/ui/StatusBar.tsx +60 -0
  59. package/src/ui/components.tsx +392 -0
  60. package/src/ui/store.tsx +4253 -0
  61. package/src/ui/views/Browser.tsx +365 -0
  62. package/src/ui/views/CloneWizard.tsx +932 -0
  63. package/src/ui/views/Dashboard.tsx +149 -0
  64. package/src/ui/views/DbBackup.tsx +228 -0
  65. package/src/ui/views/DbSync.tsx +307 -0
  66. package/src/ui/views/Discover.tsx +271 -0
  67. package/src/ui/views/DnsInventory.tsx +580 -0
  68. package/src/ui/views/DnsRecords.tsx +562 -0
  69. package/src/ui/views/Events.tsx +98 -0
  70. package/src/ui/views/Forgotten.tsx +165 -0
  71. package/src/ui/views/GrantKey.tsx +504 -0
  72. package/src/ui/views/Health.tsx +322 -0
  73. package/src/ui/views/HttpsToggle.tsx +195 -0
  74. package/src/ui/views/KumaSite.tsx +571 -0
  75. package/src/ui/views/LocalLink.tsx +220 -0
  76. package/src/ui/views/MediaFallback.tsx +180 -0
  77. package/src/ui/views/NewServer.tsx +750 -0
  78. package/src/ui/views/PhpUpgrade.tsx +301 -0
  79. package/src/ui/views/ProviderConnect.tsx +527 -0
  80. package/src/ui/views/PurgeCache.tsx +186 -0
  81. package/src/ui/views/ReleaseNotes.tsx +84 -0
  82. package/src/ui/views/Search.tsx +448 -0
  83. package/src/ui/views/ServerActions.tsx +328 -0
  84. package/src/ui/views/Stacks.tsx +371 -0
  85. package/src/ui/views/SudoConnect.tsx +358 -0
  86. package/src/ui/views/VanityNewSite.tsx +407 -0
  87. package/src/ui/views/WpInventory.tsx +218 -0
  88. 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
+ }