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,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
|
+
}
|