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,112 @@
1
+ // Carry a site's additional domains onto its clone. A freshly created dest site
2
+ // answers ONLY its primary domain (nginx server_name has nothing else — verified on
3
+ // the test boxes), so without this step the DNS cutover would repoint www/extra
4
+ // hostnames at a server that doesn't serve them. Redirect settings (enabled/type/
5
+ // destination) are copied verbatim from the source. Idempotent: domains already on
6
+ // the dest are skipped, so per-site retries re-run it safely.
7
+
8
+ import type { SpinupWPClientLike } from "../api/client.ts"
9
+ import type { AdditionalDomain } from "../api/types.ts"
10
+
11
+ const EVENT_DONE = new Set(["deployed", "completed", "provisioned", "finished", "success"])
12
+ const EVENT_FAIL = new Set(["failed", "errored", "error"])
13
+
14
+ type PollOutcome = { outcome: "done" } | { outcome: "failed" } | { outcome: "timeout"; lastStatus?: string }
15
+
16
+ // 5s cadence; a transient failure to READ the event is not the event failing —
17
+ // tolerate a few consecutive API errors (the client already absorbs 429 bursts).
18
+ // The timeout is a generous BACKSTOP, not an expectation: SpinupWP serializes
19
+ // events per server, so under a concurrent clone an add-domain event can sit
20
+ // queued for minutes behind site-creates. (The 2026-07-07 sparkmamas run sat
21
+ // queued 173s and a flat 180s deadline declared it failed 12s before it
22
+ // deployed — every timeout must be reported as a timeout, never as a failure.)
23
+ export async function pollEvent(client: SpinupWPClientLike, eventId: number, timeoutMs = 1_800_000, pollMs = 5000): Promise<PollOutcome> {
24
+ const deadline = Date.now() + timeoutMs
25
+ let apiErrs = 0
26
+ let lastStatus: string | undefined
27
+ while (Date.now() < deadline) {
28
+ await new Promise((r) => setTimeout(r, pollMs))
29
+ try {
30
+ const e = await client.getEvent(eventId)
31
+ apiErrs = 0
32
+ lastStatus = e.status
33
+ if (EVENT_FAIL.has(e.status)) return { outcome: "failed" }
34
+ if (EVENT_DONE.has(e.status) || e.finished_at) return { outcome: "done" }
35
+ } catch {
36
+ if (++apiErrs >= 3) return { outcome: "timeout", lastStatus }
37
+ }
38
+ }
39
+ // One last look before giving up — the event may have finished on the wire.
40
+ try {
41
+ const e = await client.getEvent(eventId)
42
+ if (EVENT_FAIL.has(e.status)) return { outcome: "failed" }
43
+ if (EVENT_DONE.has(e.status) || e.finished_at) return { outcome: "done" }
44
+ lastStatus = e.status
45
+ } catch {
46
+ /* keep the polled status */
47
+ }
48
+ return { outcome: "timeout", lastStatus }
49
+ }
50
+
51
+ export interface DomainSyncResult {
52
+ added: string[]
53
+ skipped: string[] // already present on the dest (retry path)
54
+ failed: { domain: string; error: string }[]
55
+ }
56
+
57
+ export async function syncAdditionalDomains(
58
+ client: SpinupWPClientLike,
59
+ destSiteId: number,
60
+ sourceDomains: AdditionalDomain[],
61
+ log?: (entry: Record<string, unknown>) => void,
62
+ ): Promise<DomainSyncResult> {
63
+ const result: DomainSyncResult = { added: [], skipped: [], failed: [] }
64
+ if (sourceDomains.length === 0) return result
65
+ // A failed listing falls back to "add everything" — a duplicate add errors
66
+ // per-domain and lands in `failed` with the API's message rather than hiding.
67
+ let existing = new Set<string>()
68
+ try {
69
+ existing = new Set((await client.listSiteDomains(destSiteId)).map((d) => d.domain))
70
+ } catch {
71
+ /* fall through */
72
+ }
73
+ for (const d of sourceDomains) {
74
+ if (existing.has(d.domain)) {
75
+ result.skipped.push(d.domain)
76
+ continue
77
+ }
78
+ try {
79
+ const ev = await client.addSiteDomain(destSiteId, {
80
+ domain: d.domain,
81
+ redirect: d.redirect ? { enabled: d.redirect.enabled, type: d.redirect.type, destination: d.redirect.destination } : undefined,
82
+ })
83
+ const res = await pollEvent(client, ev.event_id)
84
+ if (res.outcome === "done") {
85
+ result.added.push(d.domain)
86
+ log?.({ event: "domain-add", destSiteId, domain: d.domain, eventId: ev.event_id, ok: true })
87
+ } else if (res.outcome === "timeout") {
88
+ // NOT a SpinupWP failure — we stopped waiting. Retrying the site is safe
89
+ // (already-present domains are skipped), so point the user there.
90
+ const error = `gave up waiting for add-domain event ${ev.event_id}${res.lastStatus ? ` (still "${res.lastStatus}")` : ""} — it may yet finish on SpinupWP; retry the site to re-check`
91
+ result.failed.push({ domain: d.domain, error })
92
+ log?.({ event: "domain-add", destSiteId, domain: d.domain, eventId: ev.event_id, ok: false, outcome: "timeout", lastStatus: res.lastStatus })
93
+ } else {
94
+ // Pull the event's own output so the roster/log say WHY, not just "failed".
95
+ let out = ""
96
+ try {
97
+ out = ((await client.getEvent(ev.event_id)).output ?? "").trim()
98
+ } catch {
99
+ /* best-effort */
100
+ }
101
+ const error = `add-domain event ${ev.event_id} failed${out ? `: …${out.slice(-200)}` : " on SpinupWP"}`
102
+ result.failed.push({ domain: d.domain, error })
103
+ log?.({ event: "domain-add", destSiteId, domain: d.domain, eventId: ev.event_id, ok: false, output: out })
104
+ }
105
+ } catch (err) {
106
+ const error = (err as Error).message
107
+ result.failed.push({ domain: d.domain, error })
108
+ log?.({ event: "domain-add", destSiteId, domain: d.domain, ok: false, error })
109
+ }
110
+ }
111
+ return result
112
+ }
@@ -0,0 +1,85 @@
1
+ // Persistent clone-job logging (born from the 2026-07-02 web2→mercury failure,
2
+ // where all six errors were truncated to 24 chars in the roster and lost when the
3
+ // app closed). One JSONL file per clone job under <configDir>/logs/; every sudo
4
+ // script the pull chains run is recorded with its full stdout/stderr so a failed
5
+ // site can be diagnosed after the fact. Secrets (sudo + generated DB passwords)
6
+ // are redacted before anything touches disk.
7
+
8
+ import { appendFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs"
9
+ import { join } from "node:path"
10
+ import { configDir } from "../config.ts"
11
+
12
+ // Big outputs (composer, tar) are capped; keep the TAIL — errors come last.
13
+ const FIELD_CAP = 16_000
14
+
15
+ // Retention: swept every time a new job's logger is created (the only moment the
16
+ // app touches this dir), so nothing accumulates without a natural purge point. A
17
+ // log survives only while it's BOTH younger than MAX_AGE and among the newest
18
+ // MAX_LOGS. Copy a log elsewhere to keep it past retention.
19
+ const MAX_AGE_DAYS = 30
20
+ const MAX_LOGS = 20
21
+
22
+ function pruneOldLogs(dir: string): void {
23
+ try {
24
+ const cutoff = Date.now() - MAX_AGE_DAYS * 24 * 60 * 60 * 1000
25
+ const logs = readdirSync(dir)
26
+ .filter((f) => f.startsWith("clone-") && f.endsWith(".jsonl"))
27
+ .map((f) => {
28
+ const path = join(dir, f)
29
+ return { path, mtime: statSync(path).mtimeMs }
30
+ })
31
+ .sort((a, b) => b.mtime - a.mtime)
32
+ for (const [i, l] of logs.entries()) {
33
+ // The new job's log is about to join — keep MAX_LOGS-1 existing at most.
34
+ if (l.mtime < cutoff || i >= MAX_LOGS - 1) {
35
+ try {
36
+ unlinkSync(l.path)
37
+ } catch {
38
+ /* best-effort */
39
+ }
40
+ }
41
+ }
42
+ } catch {
43
+ /* pruning must never break a clone */
44
+ }
45
+ }
46
+
47
+ export class CloneLogger {
48
+ readonly path: string
49
+ private secrets: string[] = []
50
+
51
+ constructor(label: string) {
52
+ const dir = join(configDir(), "logs")
53
+ try {
54
+ mkdirSync(dir, { recursive: true })
55
+ } catch {
56
+ /* logging is best-effort */
57
+ }
58
+ pruneOldLogs(dir)
59
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
60
+ const slug = label.replace(/[^a-zA-Z0-9.-]+/g, "_").slice(0, 60)
61
+ this.path = join(dir, `clone-${stamp}-${slug}.jsonl`)
62
+ }
63
+
64
+ // Register strings that must never reach disk (passwords). Call before log().
65
+ redact(...secrets: (string | undefined)[]): void {
66
+ for (const s of secrets) if (s && s.length >= 4 && !this.secrets.includes(s)) this.secrets.push(s)
67
+ }
68
+
69
+ private clean(v: unknown): unknown {
70
+ if (typeof v !== "string") return v
71
+ let s = v
72
+ for (const secret of this.secrets) s = s.split(secret).join("[redacted]")
73
+ return s.length > FIELD_CAP ? `…[${s.length - FIELD_CAP} chars trimmed]…${s.slice(-FIELD_CAP)}` : s
74
+ }
75
+
76
+ log(entry: Record<string, unknown>): void {
77
+ try {
78
+ const out: Record<string, unknown> = { ts: new Date().toISOString() }
79
+ for (const [k, v] of Object.entries(entry)) out[k] = this.clean(v)
80
+ appendFileSync(this.path, JSON.stringify(out) + "\n")
81
+ } catch {
82
+ /* never let logging break a clone */
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,198 @@
1
+ // Download a production database backup over SSH.
2
+ //
3
+ // Mirrors the proven sync-prod-to-local pattern: export to a stage file OUTSIDE
4
+ // the public webroot (in $HOME), gzip it remotely, scp it down into the linked
5
+ // project's `sql/` dir, then remove the remote copy.
6
+ //
7
+ // We deliberately do NOT stream `wp db export -` to stdout: plugins print to
8
+ // stdout during WP bootstrap (e.g. GiveWP deprecation notices), which would
9
+ // corrupt the dump. `wp db export <file>` writes pure SQL regardless of that
10
+ // noise. SSH is non-interactive (BatchMode) — SpinupWP sites are key-only, so
11
+ // this works headlessly from the user's agent; a missing key fails fast.
12
+
13
+ import { existsSync, mkdirSync, statSync } from "node:fs"
14
+ import { join } from "node:path"
15
+ import type { Server, Site } from "../api/types.ts"
16
+ import { expandPath, type LocalLink } from "./local.ts"
17
+
18
+ export const SSH_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-o", "StrictHostKeyChecking=accept-new"]
19
+
20
+ // ssh uses -p for a non-default port, scp uses -P.
21
+ export const sshPort = (port: number | null | undefined) => (port && port !== 22 ? ["-p", String(port)] : [])
22
+ export const scpPort = (port: number | null | undefined) => (port && port !== 22 ? ["-P", String(port)] : [])
23
+
24
+ // Derive a SpinupWP site's absolute remote document root. SpinupWP serves each
25
+ // site from `/sites/{domain}/files{public_folder}`; the API's `public_folder` is
26
+ // only the suffix (e.g. "/public/" for standard WP, "/web/" for Bedrock), so we
27
+ // join it onto the fixed base. wp-cli finds the install from this cwd.
28
+ export function remoteDocRoot(domain: string, publicFolder: string | null): string {
29
+ const suffix = (publicFolder ?? "public").replace(/^\/+|\/+$/g, "") // strip surrounding slashes
30
+ const base = `/sites/${domain}/files`
31
+ return suffix ? `${base}/${suffix}` : base
32
+ }
33
+
34
+ const p2 = (n: number) => String(n).padStart(2, "0")
35
+
36
+ // Timestamped backup base name, e.g. "hoperedefined.org_2026-06-22_1113".
37
+ export function backupBaseName(domain: string, now: Date): string {
38
+ const d = `${now.getFullYear()}-${p2(now.getMonth() + 1)}-${p2(now.getDate())}`
39
+ const t = `${p2(now.getHours())}${p2(now.getMinutes())}`
40
+ return `${domain}_${d}_${t}`
41
+ }
42
+
43
+ export interface DbBackupPlan {
44
+ user: string
45
+ host: string
46
+ port: number | null
47
+ docroot: string
48
+ // The gzipped stage file, named relative to $HOME so scp can address it
49
+ // directly (e.g. "hoperedefined.org_2026-06-22_1113.sql.gz").
50
+ remoteGz: string
51
+ destDir: string // local <projectRoot>/sql
52
+ destPath: string // local absolute path of the downloaded .sql.gz
53
+ }
54
+
55
+ export type PlanResult = { ok: true; plan: DbBackupPlan } | { ok: false; error: string }
56
+
57
+ // Resolve everything needed to run a backup, or a human-readable reason it can't
58
+ // run. The destination lives in the linked working copy, so an unlinked (or
59
+ // missing) local path is a hard stop.
60
+ export function planDbBackup(
61
+ site: Site,
62
+ server: Server | undefined,
63
+ sshUser: string | null,
64
+ link: LocalLink | undefined,
65
+ now: Date,
66
+ ): PlanResult {
67
+ const host = server?.ip_address
68
+ const user = site.site_user ?? sshUser
69
+ if (!host || !user) return { ok: false, error: "Missing site user or server IP — can't reach the site." }
70
+ if (!link) return { ok: false, error: "Not linked — press L to link a local copy first." }
71
+ const dir = expandPath(link.path)
72
+ if (!existsSync(dir)) return { ok: false, error: "Local path is missing — press L to fix the link." }
73
+
74
+ // Drop the backup in the folder the user linked (where `t` opens a terminal),
75
+ // under a sql/ subdir — same convention as the sync-prod-to-local script.
76
+ const destDir = join(dir, "sql")
77
+ const remoteGz = `${backupBaseName(site.domain, now)}.sql.gz`
78
+ return {
79
+ ok: true,
80
+ plan: {
81
+ user,
82
+ host,
83
+ port: server?.ssh_port ?? null,
84
+ docroot: remoteDocRoot(site.domain, site.public_folder),
85
+ remoteGz,
86
+ destDir,
87
+ destPath: join(destDir, remoteGz),
88
+ },
89
+ }
90
+ }
91
+
92
+ export type DbBackupStage = "export" | "download" | "cleanup" | "done" | "error"
93
+
94
+ export interface DbBackupProgress {
95
+ stage: DbBackupStage
96
+ domain: string
97
+ destPath?: string
98
+ bytes?: number
99
+ error?: string
100
+ failedStage?: DbBackupStage // which step broke, so the UI can mark it ✕
101
+ }
102
+
103
+ export function isDbBackupInFlight(p: DbBackupProgress | undefined): boolean {
104
+ return p != null && p.stage !== "done" && p.stage !== "error"
105
+ }
106
+
107
+ // Run a child process to completion, capturing output with a hard timeout. An
108
+ // optional cwd lets callers run local `wp` in a project directory.
109
+ export async function runProcess(
110
+ cmd: string[],
111
+ timeoutMs: number,
112
+ cwd?: string,
113
+ env?: Record<string, string>,
114
+ ): Promise<{ code: number; stderr: string }> {
115
+ let proc: ReturnType<typeof Bun.spawn>
116
+ try {
117
+ proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe", stdin: "ignore", cwd, env })
118
+ } catch (err) {
119
+ return { code: -1, stderr: `Failed to launch ${cmd[0]}: ${(err as Error).message}` }
120
+ }
121
+ const timer = setTimeout(() => {
122
+ try {
123
+ proc.kill()
124
+ } catch {
125
+ /* already gone */
126
+ }
127
+ }, timeoutMs)
128
+ const code = await proc.exited
129
+ clearTimeout(timer)
130
+ const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text()
131
+ return { code, stderr }
132
+ }
133
+
134
+ // Pick a meaningful error line out of ssh/wp stderr, skipping the deprecation /
135
+ // warning noise WordPress plugins emit during bootstrap.
136
+ export function meaningfulError(stderr: string, fallback: string): string {
137
+ const lines = stderr
138
+ .split("\n")
139
+ .map((l) => l.trim())
140
+ .filter(Boolean)
141
+ .filter((l) => !/^(PHP\s+)?(Deprecated|Warning|Notice):/i.test(l))
142
+ return lines[lines.length - 1] || fallback
143
+ }
144
+
145
+ // Run the full backup, reporting each stage through `onProgress`. Resolves with
146
+ // the terminal progress (done | error). Never throws.
147
+ export async function runDbBackup(
148
+ plan: DbBackupPlan,
149
+ domain: string,
150
+ onProgress: (p: DbBackupProgress) => void,
151
+ ): Promise<DbBackupProgress> {
152
+ const target = `${plan.user}@${plan.host}`
153
+ const fail = (error: string, failedStage: DbBackupStage): DbBackupProgress => {
154
+ const p: DbBackupProgress = { stage: "error", domain, error, failedStage }
155
+ onProgress(p)
156
+ return p
157
+ }
158
+
159
+ // 1) Export on the remote into a $HOME stage file (outside the webroot) and
160
+ // gzip it. `wp db export <file>` writes pure SQL even when plugins print to
161
+ // stdout; --single-transaction avoids locking the live DB during the dump.
162
+ onProgress({ stage: "export", domain })
163
+ const sql = plan.remoteGz.replace(/\.gz$/, "") // <base>.sql
164
+ const remoteScript =
165
+ `set -e; cd '${plan.docroot}'; ` +
166
+ `wp db export "$HOME/${sql}" --single-transaction >/dev/null; ` +
167
+ `gzip -f "$HOME/${sql}"`
168
+ const exp = await runProcess(["ssh", ...SSH_OPTS, ...sshPort(plan.port), target, remoteScript], 300_000)
169
+ if (exp.code !== 0) return fail(`Remote export failed — ${meaningfulError(exp.stderr, `ssh exit ${exp.code}.`)}`, "export")
170
+
171
+ // 2) Download the gzip into the project's sql/ dir.
172
+ onProgress({ stage: "download", domain })
173
+ try {
174
+ mkdirSync(plan.destDir, { recursive: true })
175
+ } catch {
176
+ /* best-effort; scp surfaces a real write failure */
177
+ }
178
+ const dl = await runProcess(["scp", ...SSH_OPTS, ...scpPort(plan.port), `${target}:${plan.remoteGz}`, plan.destPath], 300_000)
179
+ if (dl.code !== 0) {
180
+ // Still try to remove the remote stage file so it doesn't linger.
181
+ void runProcess(["ssh", ...SSH_OPTS, ...sshPort(plan.port), target, `rm -f "$HOME/${plan.remoteGz}"`], 30_000)
182
+ return fail(`Download failed — ${meaningfulError(dl.stderr, `scp exit ${dl.code}.`)}`, "download")
183
+ }
184
+
185
+ // 3) Remove the remote stage file.
186
+ onProgress({ stage: "cleanup", domain })
187
+ await runProcess(["ssh", ...SSH_OPTS, ...sshPort(plan.port), target, `rm -f "$HOME/${plan.remoteGz}"`], 30_000)
188
+
189
+ let bytes: number | undefined
190
+ try {
191
+ bytes = statSync(plan.destPath).size
192
+ } catch {
193
+ /* file exists per scp exit 0; size is cosmetic */
194
+ }
195
+ const done: DbBackupProgress = { stage: "done", domain, destPath: plan.destPath, bytes }
196
+ onProgress(done)
197
+ return done
198
+ }
@@ -0,0 +1,253 @@
1
+ // Pull a production database down into the local working copy.
2
+ //
3
+ // Builds on the DB-backup pipeline: export prod (read-only), download the gzip,
4
+ // then IMPORT it into the LOCAL WordPress DB and rewrite URLs so the local copy
5
+ // mirrors production. This is destructive on LOCAL (it overwrites the local DB),
6
+ // but never writes to production — so we back up the local DB first.
7
+ //
8
+ // Works for Standard WP and Bedrock without configuration: local `wp` auto-
9
+ // discovers the install from the linked project dir, and the local URL / table
10
+ // prefix are detected from the link, the project .env, or wp-config.php. It even
11
+ // runs an existing bin/sync.d/post-import.sh hook with the same WEB_DIR /
12
+ // SYNC_REMOTE_HOST / SYNC_LOCAL_HOST env contract as the sync-prod-to-local
13
+ // script, so per-project tweaks (Elementor URL swaps, plugin toggles) carry over.
14
+
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs"
16
+ import { join, dirname } from "node:path"
17
+ import type { Server, Site } from "../api/types.ts"
18
+ import { expandPath, findProjectRoot, type LocalLink } from "./local.ts"
19
+ import { remoteDocRoot, backupBaseName, SSH_OPTS, sshPort, scpPort, runProcess, meaningfulError } from "./dbBackup.ts"
20
+
21
+ // Read a KEY=value from a dotenv file, tolerating surrounding quotes. null if absent.
22
+ function readEnvVar(envPath: string, key: string): string | null {
23
+ try {
24
+ const m = readFileSync(envPath, "utf8").match(new RegExp(`^\\s*${key}\\s*=\\s*(.*)\\s*$`, "m"))
25
+ if (!m) return null
26
+ return m[1].trim().replace(/^['"]|['"]$/g, "") || null
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ // Read $table_prefix from a wp-config.php (Standard WP). null if absent.
33
+ function readWpConfigPrefix(configPath: string): string | null {
34
+ try {
35
+ const m = readFileSync(configPath, "utf8").match(/\$table_prefix\s*=\s*['"]([^'"]+)['"]/)
36
+ return m ? m[1] : null
37
+ } catch {
38
+ return null
39
+ }
40
+ }
41
+
42
+ // Bare host of a URL (drops scheme, path, trailing slash).
43
+ function hostOf(url: string): string {
44
+ return url.replace(/^[a-z]+:\/\//i, "").replace(/\/.*$/, "").trim()
45
+ }
46
+
47
+ // An INERT starter post-import hook. Every real command is commented out and the
48
+ // only live line is an echo, so scaffolding-then-pulling can't surprise anyone —
49
+ // the user uncomments what they want deliberately. The comments document the env
50
+ // contract (WEB_DIR / SYNC_REMOTE_HOST / SYNC_LOCAL_HOST, see runDbSync) and the
51
+ // real-world tweaks (Elementor URL swaps, plugin toggles) the hook exists for.
52
+ const SAMPLE_HOOK = `#!/usr/bin/env bash
53
+ set -euo pipefail
54
+ # Runs after Spinup pulls production → local (DB imported, URLs rewritten).
55
+ # Spinup exports these for you:
56
+ # WEB_DIR local project root (this script starts here)
57
+ # SYNC_REMOTE_HOST production host, e.g. example.com
58
+ # SYNC_LOCAL_HOST local host, e.g. example.test
59
+ cd "$WEB_DIR"
60
+
61
+ # --- Examples — uncomment what your project needs ----------------------
62
+ # Elementor stores absolute URLs in serialized data; rewrite + reflush:
63
+ # wp elementor replace_urls "https://$SYNC_REMOTE_HOST" "https://$SYNC_LOCAL_HOST"
64
+ # wp elementor flush_css
65
+ #
66
+ # Turn off prod-only plugins locally (keep non-fatal with || true):
67
+ # wp plugin deactivate spinupwp limit-login-attempts-reloaded || true
68
+ # wp plugin activate localdev-switcher || true
69
+ # -----------------------------------------------------------------------
70
+
71
+ echo "post-import hook ran (nothing to do yet — edit bin/sync.d/post-import.sh)"
72
+ `
73
+
74
+ // Scaffold the inert sample hook at <localRoot>/bin/sync.d/post-import.sh and
75
+ // mark it executable. Returns the absolute path. Never overwrites an existing
76
+ // file (callers only offer this when no hook is present). Throws on write error.
77
+ export function writeSampleHook(localRoot: string): string {
78
+ const path = join(localRoot, "bin", "sync.d", "post-import.sh")
79
+ if (existsSync(path)) return path
80
+ mkdirSync(dirname(path), { recursive: true })
81
+ writeFileSync(path, SAMPLE_HOOK)
82
+ chmodSync(path, 0o755)
83
+ return path
84
+ }
85
+
86
+ export interface DbSyncPlan {
87
+ user: string
88
+ host: string
89
+ port: number | null
90
+ docroot: string
91
+ remoteGz: string // $HOME-relative gz produced on the remote
92
+ localRoot: string // where local `wp` runs (findProjectRoot of the link)
93
+ sqlDir: string // localRoot/sql
94
+ downloadPath: string // sqlDir/<base>.sql.gz — the prod dump, kept as a backup
95
+ localBackupPath: string // sqlDir/local_<ts>.sql.gz — pre-import safety dump
96
+ remoteOrigin: string // https://prod-domain (rewritten from)
97
+ localOrigin: string // local URL as entered (rewritten to)
98
+ remoteHost: string // bare prod host (hook env)
99
+ localHost: string // bare local host (hook env)
100
+ hookPath: string | null // bin/sync.d/post-import.sh, if present
101
+ prefixWarning: string | null // set when remote/local table prefixes differ
102
+ }
103
+
104
+ export type SyncPlanResult = { ok: true; plan: DbSyncPlan } | { ok: false; error: string }
105
+
106
+ export function planDbSync(
107
+ site: Site,
108
+ server: Server | undefined,
109
+ sshUser: string | null,
110
+ link: LocalLink | undefined,
111
+ now: Date,
112
+ ): SyncPlanResult {
113
+ const host = server?.ip_address
114
+ const user = site.site_user ?? sshUser
115
+ if (!host || !user) return { ok: false, error: "Missing site user or server IP — can't reach the site." }
116
+ if (!link) return { ok: false, error: "Not linked — press L to link a local copy first." }
117
+ const dir = expandPath(link.path)
118
+ if (!existsSync(dir)) return { ok: false, error: "Local path is missing — press L to fix the link." }
119
+
120
+ const localRoot = findProjectRoot(dir)
121
+ const envPath = join(localRoot, ".env")
122
+
123
+ // Local origin (URL rewrite target): the linked localUrl wins; fall back to the
124
+ // project .env WP_HOME (Bedrock). Required — without it we'd leave prod URLs.
125
+ const localUrlRaw = link.localUrl?.trim() || readEnvVar(envPath, "WP_HOME") || ""
126
+ if (!localUrlRaw) return { ok: false, error: "No local URL — press L to set one (needed to rewrite URLs)." }
127
+ const localOrigin = localUrlRaw.replace(/\/+$/, "")
128
+ const localHost = hostOf(localOrigin)
129
+ const remoteHost = site.domain
130
+ const remoteOrigin = `${site.https?.enabled ? "https" : "http"}://${remoteHost}`
131
+
132
+ // Prefix sanity: a remote↔local table-prefix mismatch makes the imported DB
133
+ // unusable by local wp (search-replace would target the wrong tables). Warn.
134
+ const remotePrefix = site.database?.table_prefix ?? null
135
+ const localPrefix = readEnvVar(envPath, "DB_PREFIX") ?? readWpConfigPrefix(join(localRoot, "wp-config.php"))
136
+ const prefixWarning =
137
+ remotePrefix && localPrefix && remotePrefix !== localPrefix
138
+ ? `Table prefix differs (remote "${remotePrefix}" vs local "${localPrefix}") — the import may not line up.`
139
+ : null
140
+
141
+ const sqlDir = join(localRoot, "sql")
142
+ const base = backupBaseName(site.domain, now)
143
+ const stamp = base.slice(base.indexOf("_") + 1) // the date_time part
144
+ return {
145
+ ok: true,
146
+ plan: {
147
+ user,
148
+ host,
149
+ port: server?.ssh_port ?? null,
150
+ docroot: remoteDocRoot(site.domain, site.public_folder),
151
+ remoteGz: `${base}.sql.gz`,
152
+ localRoot,
153
+ sqlDir,
154
+ downloadPath: join(sqlDir, `${base}.sql.gz`),
155
+ localBackupPath: join(sqlDir, `local_${stamp}.sql.gz`),
156
+ remoteOrigin,
157
+ localOrigin,
158
+ remoteHost,
159
+ localHost,
160
+ hookPath: existsSync(join(localRoot, "bin", "sync.d", "post-import.sh"))
161
+ ? join(localRoot, "bin", "sync.d", "post-import.sh")
162
+ : null,
163
+ prefixWarning,
164
+ },
165
+ }
166
+ }
167
+
168
+ export type DbSyncStage = "local-backup" | "export" | "download" | "import" | "replace" | "hook" | "done" | "error"
169
+
170
+ export interface DbSyncProgress {
171
+ stage: DbSyncStage
172
+ domain: string
173
+ downloadPath?: string
174
+ localBackupPath?: string
175
+ ranHook?: boolean
176
+ error?: string
177
+ failedStage?: DbSyncStage // which step broke, so the UI can mark it ✕
178
+ }
179
+
180
+ export function isDbSyncInFlight(p: DbSyncProgress | undefined): boolean {
181
+ return p != null && p.stage !== "done" && p.stage !== "error"
182
+ }
183
+
184
+ // Run the full pull, reporting each stage. Resolves with the terminal progress
185
+ // (done | error). Never throws. Read-only on production; destructive on local.
186
+ export async function runDbSync(plan: DbSyncPlan, domain: string, onProgress: (p: DbSyncProgress) => void): Promise<DbSyncProgress> {
187
+ const target = `${plan.user}@${plan.host}`
188
+ const fail = (error: string, failedStage: DbSyncStage = "local-backup"): DbSyncProgress => {
189
+ const p: DbSyncProgress = { stage: "error", domain, error, failedStage }
190
+ onProgress(p)
191
+ return p
192
+ }
193
+ // Prefix the surfaced error with the failing stage so it's self-locating
194
+ // (e.g. "Local DB backup failed — mysqldump: …" rather than a bare driver line).
195
+ const stageFail = (label: string, stderr: string, fallback: string, failedStage: DbSyncStage) =>
196
+ fail(`${label} — ${meaningfulError(stderr, fallback)}`, failedStage)
197
+ // Run local `wp` through a login shell so the user's PATH (composer/phar) resolves.
198
+ const wp = (cmd: string, timeoutMs: number, env?: Record<string, string>) => runProcess(["bash", "-lc", cmd], timeoutMs, plan.localRoot, env)
199
+
200
+ // 0) wp-cli must exist locally — fail with a clear message rather than cryptically.
201
+ if ((await wp("command -v wp", 15_000)).code !== 0) return fail("WP-CLI not found locally — install wp-cli to sync.", "local-backup")
202
+
203
+ // 1) Safety: back up the LOCAL DB before we overwrite it (file, then gzip).
204
+ onProgress({ stage: "local-backup", domain })
205
+ try {
206
+ mkdirSync(plan.sqlDir, { recursive: true })
207
+ } catch {
208
+ /* best-effort */
209
+ }
210
+ const localSql = plan.localBackupPath.replace(/\.gz$/, "")
211
+ const lb = await wp(`wp db export "${localSql}" >/dev/null && gzip -f "${localSql}"`, 300_000)
212
+ if (lb.code !== 0) return stageFail("Local DB backup failed", lb.stderr, "couldn't export the local database.", "local-backup")
213
+
214
+ // 2) Export prod + download the gzip (same remote pipeline as the backup).
215
+ onProgress({ stage: "export", domain })
216
+ const sql = plan.remoteGz.replace(/\.gz$/, "")
217
+ const remoteScript =
218
+ `set -e; cd '${plan.docroot}'; wp db export "$HOME/${sql}" --single-transaction >/dev/null; gzip -f "$HOME/${sql}"`
219
+ const exp = await runProcess(["ssh", ...SSH_OPTS, ...sshPort(plan.port), target, remoteScript], 300_000)
220
+ if (exp.code !== 0) return stageFail("Remote export failed", exp.stderr, `ssh exit ${exp.code}.`, "export")
221
+
222
+ onProgress({ stage: "download", domain })
223
+ const dl = await runProcess(["scp", ...SSH_OPTS, ...scpPort(plan.port), `${target}:${plan.remoteGz}`, plan.downloadPath], 300_000)
224
+ if (dl.code !== 0) {
225
+ void runProcess(["ssh", ...SSH_OPTS, ...sshPort(plan.port), target, `rm -f "$HOME/${plan.remoteGz}"`], 30_000)
226
+ return stageFail("Download failed", dl.stderr, `scp exit ${dl.code}.`, "download")
227
+ }
228
+ void runProcess(["ssh", ...SSH_OPTS, ...sshPort(plan.port), target, `rm -f "$HOME/${plan.remoteGz}"`], 30_000)
229
+
230
+ // 3) Import into local: stream gunzip → wp db cli (no temp extract).
231
+ onProgress({ stage: "import", domain })
232
+ const imp = await wp(`gunzip -c "${plan.downloadPath}" | wp db cli`, 600_000)
233
+ if (imp.code !== 0) return stageFail("Local import failed", imp.stderr, "wp db cli error.", "import")
234
+
235
+ // 4) Rewrite production URLs → local URLs.
236
+ onProgress({ stage: "replace", domain })
237
+ const sr = await wp(`wp search-replace "${plan.remoteOrigin}" "${plan.localOrigin}" --skip-columns=guid --format=count`, 300_000)
238
+ if (sr.code !== 0) return stageFail("URL search-replace failed", sr.stderr, "wp search-replace error.", "replace")
239
+
240
+ // 5) Optional per-project post-import hook (Elementor swaps, plugin toggles, …).
241
+ let ranHook = false
242
+ if (plan.hookPath) {
243
+ onProgress({ stage: "hook", domain })
244
+ const env = { ...process.env, WEB_DIR: plan.localRoot, SYNC_REMOTE_HOST: plan.remoteHost, SYNC_LOCAL_HOST: plan.localHost } as Record<string, string>
245
+ const hk = await runProcess(["bash", plan.hookPath], 300_000, plan.localRoot, env)
246
+ if (hk.code !== 0) return stageFail("Post-import hook failed", hk.stderr, "hook script error.", "hook")
247
+ ranHook = true
248
+ }
249
+
250
+ const done: DbSyncProgress = { stage: "done", domain, downloadPath: plan.downloadPath, localBackupPath: plan.localBackupPath, ranHook }
251
+ onProgress(done)
252
+ return done
253
+ }