spinuptui 0.24.4 → 0.26.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 +112 -1
- package/README.md +65 -10
- package/package.json +1 -1
- package/src/index.tsx +131 -11
- package/src/lib/cliPull.ts +382 -0
- package/src/lib/cliSsh.ts +74 -49
- package/src/lib/dbBackup.ts +7 -4
- package/src/lib/dbSync.ts +15 -1
- package/src/lib/links.ts +23 -0
- package/src/lib/localSetup.ts +164 -0
- package/src/lib/sshExec.ts +2 -1
- package/src/ui/App.tsx +6 -0
- package/src/ui/Splash.tsx +3 -0
- package/src/ui/store.tsx +70 -0
- package/src/ui/views/LocalLink.tsx +62 -11
- package/src/ui/views/LocalSetup.tsx +256 -0
- package/src/ui/views/Search.tsx +38 -66
- package/src/ui/views/ServerActions.tsx +30 -4
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
// `spinuptui pull files` / `spinuptui pull db` — the two local-working-copy
|
|
2
|
+
// engines, driven non-interactively.
|
|
3
|
+
//
|
|
4
|
+
// Same engines the TUI drives (lib/localSetup.ts and lib/dbSync.ts), same
|
|
5
|
+
// domain resolution as `ssh`/`ssh-exec` (lib/cliSsh.ts's resolveSiteByDomain),
|
|
6
|
+
// so behavior can't drift between the two front ends. Nothing here reimplements
|
|
7
|
+
// a step; this file is argument resolution, gating, and result shaping.
|
|
8
|
+
//
|
|
9
|
+
// Output contract, matching the other subcommands: the returned object is the
|
|
10
|
+
// machine-readable result (one JSON object on stdout under --json, exit code
|
|
11
|
+
// mirrors `ok`). Progress goes to the caller's `onStage` — stderr, always,
|
|
12
|
+
// human-readable — so a person watching a ten-minute rsync can see it isn't
|
|
13
|
+
// hung and an agent still has the trail when something fails.
|
|
14
|
+
//
|
|
15
|
+
// `pull files` is read-only on production. `pull db` OVERWRITES the local
|
|
16
|
+
// database, so it's gated twice: the `localSync` config opt-in (same gate the
|
|
17
|
+
// TUI enforces) and an explicit `--yes` on the invocation. Both are checked
|
|
18
|
+
// before any network call, so a typo'd domain in a script fails on the gate.
|
|
19
|
+
|
|
20
|
+
import { existsSync } from "node:fs"
|
|
21
|
+
import { join } from "node:path"
|
|
22
|
+
import { configPath, type AppConfig } from "../config.ts"
|
|
23
|
+
import type { SpinupWPClientLike } from "../api/client.ts"
|
|
24
|
+
import { resolveSiteByDomain, type SshAccessCandidate, type SshAccessReason } from "./cliSsh.ts"
|
|
25
|
+
import { planLocalSetup, runLocalSetup, type LocalSetupStage } from "./localSetup.ts"
|
|
26
|
+
import { planDbSync, runDbSync, type DbSyncStage } from "./dbSync.ts"
|
|
27
|
+
import { readLink, writeLink } from "./links.ts"
|
|
28
|
+
import { expandPath } from "./local.ts"
|
|
29
|
+
|
|
30
|
+
export type PullReason =
|
|
31
|
+
| SshAccessReason
|
|
32
|
+
| "no_dest"
|
|
33
|
+
| "ambiguous_dest"
|
|
34
|
+
| "already_linked"
|
|
35
|
+
| "dest_not_empty"
|
|
36
|
+
| "plan_failed"
|
|
37
|
+
| "fetch_failed"
|
|
38
|
+
| "composer_failed"
|
|
39
|
+
| "not_linked"
|
|
40
|
+
| "missing_env"
|
|
41
|
+
| "local_sync_disabled"
|
|
42
|
+
| "not_confirmed"
|
|
43
|
+
| "sync_failed"
|
|
44
|
+
|
|
45
|
+
export type PullCommand = "pull files" | "pull db"
|
|
46
|
+
|
|
47
|
+
export interface PullFailure {
|
|
48
|
+
ok: false
|
|
49
|
+
command: PullCommand
|
|
50
|
+
domain: string
|
|
51
|
+
reason: PullReason
|
|
52
|
+
message: string
|
|
53
|
+
remedy?: string
|
|
54
|
+
candidates?: SshAccessCandidate[]
|
|
55
|
+
failedStage?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PullFilesSuccess {
|
|
59
|
+
ok: true
|
|
60
|
+
command: "pull files"
|
|
61
|
+
domain: string
|
|
62
|
+
primaryDomain: string
|
|
63
|
+
path: string
|
|
64
|
+
localUrl: string
|
|
65
|
+
method: "git" | "rsync"
|
|
66
|
+
ranComposer: boolean
|
|
67
|
+
linked: true
|
|
68
|
+
// Non-fatal caveats about the copy that just landed — most importantly the
|
|
69
|
+
// things standing between it and a working `pull db`.
|
|
70
|
+
warnings?: string[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PullDbSuccess {
|
|
74
|
+
ok: true
|
|
75
|
+
command: "pull db"
|
|
76
|
+
domain: string
|
|
77
|
+
primaryDomain: string
|
|
78
|
+
path: string
|
|
79
|
+
localUrl: string
|
|
80
|
+
downloadPath: string
|
|
81
|
+
localBackupPath: string
|
|
82
|
+
ranHook: boolean
|
|
83
|
+
warnings?: string[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type PullFilesResult = PullFilesSuccess | PullFailure
|
|
87
|
+
export type PullDbResult = PullDbSuccess | PullFailure
|
|
88
|
+
|
|
89
|
+
export type StageReporter = (line: string) => void
|
|
90
|
+
|
|
91
|
+
const FILES_STAGE_LABELS: Record<LocalSetupStage, string> = {
|
|
92
|
+
fetch: "Getting the code down",
|
|
93
|
+
build: "composer install",
|
|
94
|
+
done: "Done",
|
|
95
|
+
error: "Failed",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const DB_STAGE_LABELS: Record<DbSyncStage, string> = {
|
|
99
|
+
"local-backup": "Backing up the local database first",
|
|
100
|
+
export: "Exporting the production database",
|
|
101
|
+
download: "Downloading the dump",
|
|
102
|
+
import: "Importing into the local database",
|
|
103
|
+
replace: "Rewriting production URLs to local",
|
|
104
|
+
hook: "Running bin/sync.d/post-import.sh",
|
|
105
|
+
done: "Done",
|
|
106
|
+
error: "Failed",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Shape a resolver failure (unknown domain, ambiguous domain, dead token, …)
|
|
110
|
+
// into this command's result type, preserving the shared reason codes.
|
|
111
|
+
function fromResolverFailure(command: PullCommand, failure: { ok: false } & Record<string, unknown>): PullFailure {
|
|
112
|
+
const candidates = failure.candidates as SshAccessCandidate[] | undefined
|
|
113
|
+
const remedy = failure.remedy != null ? String(failure.remedy) : undefined
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
command,
|
|
117
|
+
domain: String(failure.domain ?? ""),
|
|
118
|
+
reason: failure.reason as PullReason,
|
|
119
|
+
message: String(failure.message ?? ""),
|
|
120
|
+
...(remedy ? { remedy } : {}),
|
|
121
|
+
...(candidates ? { candidates } : {}),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function runPullFiles(
|
|
126
|
+
domain: string,
|
|
127
|
+
opts: { path?: string | null; url?: string | null; server?: string | null },
|
|
128
|
+
client: SpinupWPClientLike,
|
|
129
|
+
cfg: AppConfig,
|
|
130
|
+
onStage: StageReporter,
|
|
131
|
+
): Promise<PullFilesResult> {
|
|
132
|
+
const command: PullCommand = "pull files"
|
|
133
|
+
const fail = (reason: PullReason, message: string, remedy?: string): PullFailure => ({
|
|
134
|
+
ok: false,
|
|
135
|
+
command,
|
|
136
|
+
domain,
|
|
137
|
+
reason,
|
|
138
|
+
message,
|
|
139
|
+
...(remedy ? { remedy } : {}),
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
onStage(`Resolving ${domain} …`)
|
|
143
|
+
const resolved = await resolveSiteByDomain(domain, client, cfg, { server: opts.server })
|
|
144
|
+
if (!resolved.ok) return fromResolverFailure(command, resolved.result)
|
|
145
|
+
const { site, server } = resolved
|
|
146
|
+
onStage(`Site ${site.domain} on ${server.name} (${server.ip_address})`)
|
|
147
|
+
|
|
148
|
+
// A site that already has a local copy is not re-pulled: this engine only
|
|
149
|
+
// ever populates an empty directory, and re-syncing over an existing checkout
|
|
150
|
+
// would clobber local work. Refuse and say where the copy already is.
|
|
151
|
+
const existing = readLink(site.id)
|
|
152
|
+
if (existing) {
|
|
153
|
+
return fail(
|
|
154
|
+
"already_linked",
|
|
155
|
+
`${site.domain} already has a local copy at ${existing.path}.`,
|
|
156
|
+
"Pull the database into it with `spinuptui pull db`, or unlink it first (run `spinuptui`, select the site, press L then x).",
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Destination: the explicit argument, else derived as <localRoot>/<primary
|
|
161
|
+
// domain>, so an agent handed only a domain never has to invent a filesystem
|
|
162
|
+
// path. Only derived when there's exactly one root — with several configured
|
|
163
|
+
// there's no way to tell which one this site belongs in (they're commonly
|
|
164
|
+
// split by stack), and guessing would file a standard-WP site under Bedrock.
|
|
165
|
+
let dest = opts.path?.trim() ?? ""
|
|
166
|
+
if (!dest) {
|
|
167
|
+
if (cfg.localRoots.length === 0) {
|
|
168
|
+
return fail(
|
|
169
|
+
"no_dest",
|
|
170
|
+
"No destination path given, and no localRoots configured to derive one from.",
|
|
171
|
+
`Pass a path (\`spinuptui pull files ${domain} <path>\`), or set "localRoots": ["~/your/dev/dir"] in ${configPath()}.`,
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
if (cfg.localRoots.length > 1) {
|
|
175
|
+
return fail(
|
|
176
|
+
"ambiguous_dest",
|
|
177
|
+
`No destination path given, and ${cfg.localRoots.length} localRoots are configured (${cfg.localRoots.join(", ")}) — which one this site belongs in isn't knowable from here.`,
|
|
178
|
+
`Pass a path, e.g. \`spinuptui pull files ${domain} ${join(cfg.localRoots[0]!, site.domain)}\`.`,
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
dest = join(cfg.localRoots[0]!, site.domain)
|
|
182
|
+
onStage(`Destination derived from localRoots: ${dest}`)
|
|
183
|
+
}
|
|
184
|
+
const localUrl = opts.url?.trim() ?? ""
|
|
185
|
+
|
|
186
|
+
const planned = planLocalSetup(site, server, cfg.sshUser, dest, localUrl)
|
|
187
|
+
if (!planned.ok) {
|
|
188
|
+
const reason: PullReason = /isn't empty/i.test(planned.error) ? "dest_not_empty" : "plan_failed"
|
|
189
|
+
return fail(reason, planned.error)
|
|
190
|
+
}
|
|
191
|
+
const plan = planned.plan
|
|
192
|
+
|
|
193
|
+
onStage(
|
|
194
|
+
plan.isGit
|
|
195
|
+
? `Source: git clone ${plan.repoUrl}`
|
|
196
|
+
: `Source: rsync ${plan.user}@${plan.host}:${plan.filesRoot}/ (excluding uploads and the caching drop-ins)`,
|
|
197
|
+
)
|
|
198
|
+
onStage(`Destination: ${plan.destPath}`)
|
|
199
|
+
|
|
200
|
+
let lastStage: LocalSetupStage | null = null
|
|
201
|
+
const result = await runLocalSetup(plan, (p) => {
|
|
202
|
+
if (p.stage !== lastStage && p.stage !== "done" && p.stage !== "error") {
|
|
203
|
+
lastStage = p.stage
|
|
204
|
+
onStage(`${FILES_STAGE_LABELS[p.stage]} …`)
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
if (result.stage === "error") {
|
|
209
|
+
const reason: PullReason = result.failedStage === "build" ? "composer_failed" : "fetch_failed"
|
|
210
|
+
return {
|
|
211
|
+
...fail(reason, result.error ?? "The pull failed."),
|
|
212
|
+
...(result.failedStage ? { failedStage: result.failedStage } : {}),
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Link it exactly as the TUI's guided clone does — `pull db` and the TUI's
|
|
217
|
+
// own `p`/`m` flows all key off this link.
|
|
218
|
+
await writeLink(site.id, { domain: site.domain, path: plan.destPath, localUrl })
|
|
219
|
+
onStage(`Linked ${site.domain} → ${plan.destPath}`)
|
|
220
|
+
|
|
221
|
+
const warnings: string[] = []
|
|
222
|
+
if (!localUrl) {
|
|
223
|
+
warnings.push(
|
|
224
|
+
`No local URL recorded. \`spinuptui pull db ${site.domain}\` needs one to rewrite production URLs — pass --url on the next run.`,
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
// The rsync path copies the site's real wp-config.php, which holds
|
|
228
|
+
// PRODUCTION's database credentials. Harmless in itself (SpinupWP's DB_HOST
|
|
229
|
+
// is localhost, so nothing local can reach the production database through
|
|
230
|
+
// it) but it means local wp-cli — and therefore `pull db` — authenticates as
|
|
231
|
+
// the production DB user against the local server and is denied. Say so here
|
|
232
|
+
// rather than letting it surface as a bare "Access denied" three steps later.
|
|
233
|
+
// The mirror image on the git path: Bedrock/Radicle keep DB credentials and
|
|
234
|
+
// WP_HOME in .env, which is deliberately NOT in the repo — so a fresh clone
|
|
235
|
+
// has none, and wp-cli runs with empty DB settings (`mysqldump: unknown
|
|
236
|
+
// variable 'pass='`). composer install having run is exactly the signal that
|
|
237
|
+
// this is such a checkout.
|
|
238
|
+
if (result.ranComposer && !existsSync(join(plan.destPath, ".env"))) {
|
|
239
|
+
warnings.push(
|
|
240
|
+
`No .env in the checkout — Bedrock/Radicle keep database credentials and WP_HOME there, and it isn't in the repo. Copy .env.example to .env and fill in DB_NAME / DB_USER / DB_PASSWORD / WP_HOME before running \`spinuptui pull db ${site.domain}\`.`,
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
if (!plan.isGit && existsSync(join(plan.destPath, "wp-config.php"))) {
|
|
244
|
+
warnings.push(
|
|
245
|
+
`${join(plan.destPath, "wp-config.php")} came from production and still holds its database credentials. Point DB_NAME / DB_USER / DB_PASSWORD / DB_HOST at your local database before running \`spinuptui pull db ${site.domain}\`.`,
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
ok: true,
|
|
251
|
+
command,
|
|
252
|
+
domain,
|
|
253
|
+
primaryDomain: site.domain,
|
|
254
|
+
path: plan.destPath,
|
|
255
|
+
localUrl,
|
|
256
|
+
method: plan.isGit ? "git" : "rsync",
|
|
257
|
+
ranComposer: result.ranComposer === true,
|
|
258
|
+
linked: true,
|
|
259
|
+
...(warnings.length ? { warnings } : {}),
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function runPullDb(
|
|
264
|
+
domain: string,
|
|
265
|
+
opts: { url?: string | null; yes: boolean; server?: string | null },
|
|
266
|
+
client: SpinupWPClientLike,
|
|
267
|
+
cfg: AppConfig,
|
|
268
|
+
onStage: StageReporter,
|
|
269
|
+
): Promise<PullDbResult> {
|
|
270
|
+
const command: PullCommand = "pull db"
|
|
271
|
+
const fail = (reason: PullReason, message: string, remedy?: string): PullFailure => ({
|
|
272
|
+
ok: false,
|
|
273
|
+
command,
|
|
274
|
+
domain,
|
|
275
|
+
reason,
|
|
276
|
+
message,
|
|
277
|
+
...(remedy ? { remedy } : {}),
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// Both gates first, before any network call: a wrong domain in a script
|
|
281
|
+
// should bounce off the confirmation, not off a fleet lookup.
|
|
282
|
+
if (!cfg.localSync) {
|
|
283
|
+
return fail(
|
|
284
|
+
"local_sync_disabled",
|
|
285
|
+
"Local database sync is disabled for this install.",
|
|
286
|
+
`Set "localSync": true in ${configPath()} (or export SPINUPWP_LOCAL_SYNC=1) to enable it.`,
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
if (!opts.yes) {
|
|
290
|
+
return fail(
|
|
291
|
+
"not_confirmed",
|
|
292
|
+
`This overwrites the local database for ${domain} with production's.`,
|
|
293
|
+
`Re-run with --yes once you're sure: \`spinuptui pull db ${domain} --yes\`.`,
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
onStage(`Resolving ${domain} …`)
|
|
298
|
+
const resolved = await resolveSiteByDomain(domain, client, cfg, { server: opts.server })
|
|
299
|
+
if (!resolved.ok) return fromResolverFailure(command, resolved.result)
|
|
300
|
+
const { site, server } = resolved
|
|
301
|
+
|
|
302
|
+
let link = readLink(site.id)
|
|
303
|
+
if (!link) {
|
|
304
|
+
return fail(
|
|
305
|
+
"not_linked",
|
|
306
|
+
`${site.domain} has no local copy linked, so there's nothing to import into.`,
|
|
307
|
+
`Run \`spinuptui pull files ${domain} <path> --url <local url>\` first.`,
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
// `--url` here doubles as "set the local URL and then sync", which is how a
|
|
311
|
+
// copy pulled without one becomes syncable without a detour through the TUI.
|
|
312
|
+
const url = opts.url?.trim()
|
|
313
|
+
if (url && url !== link.localUrl) {
|
|
314
|
+
link = { ...link, localUrl: url }
|
|
315
|
+
await writeLink(site.id, link)
|
|
316
|
+
onStage(`Local URL set to ${url}`)
|
|
317
|
+
}
|
|
318
|
+
onStage(`Local copy: ${expandPath(link.path)}`)
|
|
319
|
+
|
|
320
|
+
const planned = planDbSync(site, server, cfg.sshUser, link, new Date())
|
|
321
|
+
if (!planned.ok) {
|
|
322
|
+
return fail(
|
|
323
|
+
"plan_failed",
|
|
324
|
+
planned.error,
|
|
325
|
+
/local URL/i.test(planned.error) ? `Pass --url <local url> (e.g. \`spinuptui pull db ${domain} --url https://example.test --yes\`).` : undefined,
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
const plan = planned.plan
|
|
329
|
+
|
|
330
|
+
// Fail on the real cause rather than letting empty DB settings surface three
|
|
331
|
+
// steps later as `mysqldump: unknown variable 'pass='`.
|
|
332
|
+
if ((plan.localKind === "bedrock" || plan.localKind === "radicle") && !existsSync(join(plan.localRoot, ".env"))) {
|
|
333
|
+
return fail(
|
|
334
|
+
"missing_env",
|
|
335
|
+
`No .env in ${plan.localRoot} — this is a ${plan.localKind} checkout, so its database credentials live there and it isn't in the repo.`,
|
|
336
|
+
"Copy .env.example to .env and fill in DB_NAME / DB_USER / DB_PASSWORD / WP_HOME, then re-run.",
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
onStage(`Rewriting ${plan.remoteOrigin} → ${plan.localOrigin}`)
|
|
341
|
+
if (plan.prefixWarning) onStage(`Warning: ${plan.prefixWarning}`)
|
|
342
|
+
onStage(`A pre-import backup of the local database is written to ${plan.localBackupPath}`)
|
|
343
|
+
|
|
344
|
+
let lastStage: DbSyncStage | null = null
|
|
345
|
+
const result = await runDbSync(plan, site.domain, (p) => {
|
|
346
|
+
if (p.stage !== lastStage && p.stage !== "done" && p.stage !== "error") {
|
|
347
|
+
lastStage = p.stage
|
|
348
|
+
onStage(`${DB_STAGE_LABELS[p.stage]} …`)
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
if (result.stage === "error") {
|
|
353
|
+
const error = result.error ?? "The sync failed."
|
|
354
|
+
// The overwhelmingly likely cause right after `pull files`: the copied
|
|
355
|
+
// wp-config.php still carries production's DB credentials, so local wp-cli
|
|
356
|
+
// authenticates as the production user against the local MySQL.
|
|
357
|
+
const deniedAs = /access denied for user '([^']+)'/i.exec(error)?.[1]
|
|
358
|
+
return {
|
|
359
|
+
...fail(
|
|
360
|
+
"sync_failed",
|
|
361
|
+
error,
|
|
362
|
+
deniedAs
|
|
363
|
+
? `Local MySQL refused the user "${deniedAs}". If this copy came from \`pull files\`, its wp-config.php still holds production's database credentials — point DB_NAME / DB_USER / DB_PASSWORD / DB_HOST at your local database and re-run.`
|
|
364
|
+
: undefined,
|
|
365
|
+
),
|
|
366
|
+
...(result.failedStage ? { failedStage: result.failedStage } : {}),
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
ok: true,
|
|
372
|
+
command,
|
|
373
|
+
domain,
|
|
374
|
+
primaryDomain: site.domain,
|
|
375
|
+
path: expandPath(link.path),
|
|
376
|
+
localUrl: plan.localOrigin,
|
|
377
|
+
downloadPath: result.downloadPath ?? plan.downloadPath,
|
|
378
|
+
localBackupPath: result.localBackupPath ?? plan.localBackupPath,
|
|
379
|
+
ranHook: result.ranHook === true,
|
|
380
|
+
...(plan.prefixWarning ? { warnings: [plan.prefixWarning] } : {}),
|
|
381
|
+
}
|
|
382
|
+
}
|
package/src/lib/cliSsh.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// trustworthy go/no-go rather than a guess.
|
|
6
6
|
|
|
7
7
|
import type { AppConfig } from "../config.ts"
|
|
8
|
+
import type { Server, Site } from "../api/types.ts"
|
|
8
9
|
import type { SpinupWPClientLike } from "../api/client.ts"
|
|
9
10
|
import { ApiError } from "../api/client.ts"
|
|
10
11
|
import { resolveSiteSshTarget, SSH_OPTS } from "./probe.ts"
|
|
@@ -53,38 +54,44 @@ export type SshTargetResolution =
|
|
|
53
54
|
| { ok: true; info: SshTargetInfo }
|
|
54
55
|
| { ok: false; result: SshAccessResult & { ok: false } }
|
|
55
56
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
57
|
+
// A resolved domain, before anything is done with it. Shared by every
|
|
58
|
+
// domain-addressed CLI command (`ssh`, `ssh-exec`, `pull`) so they all fail the
|
|
59
|
+
// same way — same reason codes, same messages — on an unknown or ambiguous
|
|
60
|
+
// domain, a rejected token, or a server with no IP.
|
|
61
|
+
export type SiteResolution =
|
|
62
|
+
| { ok: true; site: Site; server: Server }
|
|
63
|
+
| { ok: false; result: SshAccessResult & { ok: false } }
|
|
64
|
+
|
|
65
|
+
export async function resolveSiteByDomain(
|
|
60
66
|
domain: string,
|
|
61
67
|
client: SpinupWPClientLike,
|
|
62
68
|
cfg: AppConfig,
|
|
63
|
-
|
|
69
|
+
// Narrows an otherwise ambiguous domain to one server, by name — matching
|
|
70
|
+
// either the full name or its first label ("web1" for "web1.example.com"),
|
|
71
|
+
// which is what the ambiguity message itself prints. Callers that expose no
|
|
72
|
+
// way to pass it (ssh, ssh-exec) simply omit it and keep failing as before.
|
|
73
|
+
opts?: { server?: string | null },
|
|
74
|
+
): Promise<SiteResolution> {
|
|
75
|
+
const fail = (
|
|
76
|
+
partial: Omit<SshAccessResult & { ok: false }, "ok" | "domain">,
|
|
77
|
+
): SiteResolution => ({ ok: false, result: { ok: false, domain, ...partial } })
|
|
78
|
+
|
|
64
79
|
if (!cfg.token) {
|
|
65
|
-
return {
|
|
66
|
-
ok: false,
|
|
67
|
-
result: { ok: false, domain, reason: "no_token", message: "No API token configured. Run `spinuptui login`." },
|
|
68
|
-
}
|
|
80
|
+
return fail({ reason: "no_token", message: "No API token configured. Run `spinuptui login`." })
|
|
69
81
|
}
|
|
70
82
|
|
|
71
|
-
let site
|
|
72
|
-
let server
|
|
83
|
+
let site: Site
|
|
84
|
+
let server: Server
|
|
73
85
|
try {
|
|
74
86
|
const sites = await client.listSites()
|
|
75
87
|
const matches = sites.filter(
|
|
76
88
|
(s) => s.domain === domain || s.additional_domains?.some((a) => a.domain === domain),
|
|
77
89
|
)
|
|
78
90
|
if (matches.length === 0) {
|
|
79
|
-
return {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
domain,
|
|
84
|
-
reason: "site_not_found",
|
|
85
|
-
message: `No site matching "${domain}" found in this SpinupWP account.`,
|
|
86
|
-
},
|
|
87
|
-
}
|
|
91
|
+
return fail({
|
|
92
|
+
reason: "site_not_found",
|
|
93
|
+
message: `No site matching "${domain}" found in this SpinupWP account.`,
|
|
94
|
+
})
|
|
88
95
|
}
|
|
89
96
|
if (matches.length > 1) {
|
|
90
97
|
const candidates: SshAccessCandidate[] = await Promise.all(
|
|
@@ -93,48 +100,65 @@ export async function resolveSshTargetInfo(
|
|
|
93
100
|
return { siteId: s.id, serverId: s.server_id, server: srv.name }
|
|
94
101
|
}),
|
|
95
102
|
)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
103
|
+
const wanted = opts?.server?.trim().toLowerCase()
|
|
104
|
+
const narrowed = wanted
|
|
105
|
+
? candidates.filter((c) => {
|
|
106
|
+
const name = c.server.toLowerCase()
|
|
107
|
+
return name === wanted || name.split(".")[0] === wanted
|
|
108
|
+
})
|
|
109
|
+
: candidates
|
|
110
|
+
if (narrowed.length !== 1) {
|
|
111
|
+
return fail({
|
|
101
112
|
reason: "multiple_matches",
|
|
102
|
-
message:
|
|
113
|
+
message: wanted
|
|
114
|
+
? `"${domain}" matches ${matches.length} sites in this account, and ${narrowed.length === 0 ? "none are" : `${narrowed.length} are`} on a server matching "${opts?.server}".`
|
|
115
|
+
: `"${domain}" matches ${matches.length} sites in this account — cannot pick one automatically.`,
|
|
116
|
+
// Every command that resolves a domain takes --server, so this is
|
|
117
|
+
// always actionable — say so here rather than in each of them.
|
|
118
|
+
remedy: `Pick one with --server, e.g. \`--server ${candidates[0]!.server}\`.`,
|
|
103
119
|
candidates,
|
|
104
|
-
}
|
|
120
|
+
})
|
|
105
121
|
}
|
|
122
|
+
site = matches.find((m) => m.id === narrowed[0]!.siteId)!
|
|
123
|
+
server = await client.getServer(site.server_id)
|
|
124
|
+
return server.ip_address
|
|
125
|
+
? { ok: true, site, server }
|
|
126
|
+
: fail({ reason: "server_has_no_ip", message: `Server "${server.name}" has no IP address on file.` })
|
|
106
127
|
}
|
|
107
|
-
site = matches[0]
|
|
128
|
+
site = matches[0]!
|
|
108
129
|
server = await client.getServer(site.server_id)
|
|
109
130
|
} catch (err) {
|
|
110
131
|
if (err instanceof ApiError && err.status === 401) {
|
|
111
|
-
return {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
reason: "token_rejected",
|
|
117
|
-
message: "Saved API token was rejected (401) — it may be expired or revoked.",
|
|
118
|
-
remedy: "Run `spinuptui login` to save a fresh token.",
|
|
119
|
-
},
|
|
120
|
-
}
|
|
132
|
+
return fail({
|
|
133
|
+
reason: "token_rejected",
|
|
134
|
+
message: "Saved API token was rejected (401) — it may be expired or revoked.",
|
|
135
|
+
remedy: "Run `spinuptui login` to save a fresh token.",
|
|
136
|
+
})
|
|
121
137
|
}
|
|
122
138
|
const message = err instanceof ApiError ? err.message : `Unexpected error: ${(err as Error).message}`
|
|
123
|
-
return {
|
|
139
|
+
return fail({ reason: "api_error", message })
|
|
124
140
|
}
|
|
125
141
|
|
|
126
142
|
if (!server.ip_address) {
|
|
127
|
-
return {
|
|
128
|
-
ok: false,
|
|
129
|
-
result: {
|
|
130
|
-
ok: false,
|
|
131
|
-
domain,
|
|
132
|
-
reason: "server_has_no_ip",
|
|
133
|
-
message: `Server "${server.name}" has no IP address on file.`,
|
|
134
|
-
},
|
|
135
|
-
}
|
|
143
|
+
return fail({ reason: "server_has_no_ip", message: `Server "${server.name}" has no IP address on file.` })
|
|
136
144
|
}
|
|
137
145
|
|
|
146
|
+
return { ok: true, site, server }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Domain -> site -> server -> SSH target/port, with no live connectivity check.
|
|
150
|
+
// Split out of resolveSshAccess so callers that are about to run a real command
|
|
151
|
+
// (which is itself a connectivity test) don't pay for a redundant probe first.
|
|
152
|
+
export async function resolveSshTargetInfo(
|
|
153
|
+
domain: string,
|
|
154
|
+
client: SpinupWPClientLike,
|
|
155
|
+
cfg: AppConfig,
|
|
156
|
+
opts?: { server?: string | null },
|
|
157
|
+
): Promise<SshTargetResolution> {
|
|
158
|
+
const resolved = await resolveSiteByDomain(domain, client, cfg, opts)
|
|
159
|
+
if (!resolved.ok) return resolved
|
|
160
|
+
|
|
161
|
+
const { site, server } = resolved
|
|
138
162
|
const target = resolveSiteSshTarget(site, server, cfg.sshUser)
|
|
139
163
|
const port = server.ssh_port && server.ssh_port !== 22 ? server.ssh_port : null
|
|
140
164
|
|
|
@@ -148,8 +172,9 @@ export async function resolveSshAccess(
|
|
|
148
172
|
domain: string,
|
|
149
173
|
client: SpinupWPClientLike,
|
|
150
174
|
cfg: AppConfig,
|
|
175
|
+
opts?: { server?: string | null },
|
|
151
176
|
): Promise<SshAccessResult> {
|
|
152
|
-
const resolution = await resolveSshTargetInfo(domain, client, cfg)
|
|
177
|
+
const resolution = await resolveSshTargetInfo(domain, client, cfg, opts)
|
|
153
178
|
if (!resolution.ok) return resolution.result
|
|
154
179
|
const { primaryDomain, sshTarget: target, port, server: serverName } = resolution.info
|
|
155
180
|
const portOpt = port ? ["-p", String(port)] : []
|
package/src/lib/dbBackup.ts
CHANGED
|
@@ -111,12 +111,12 @@ export async function runProcess(
|
|
|
111
111
|
timeoutMs: number,
|
|
112
112
|
cwd?: string,
|
|
113
113
|
env?: Record<string, string>,
|
|
114
|
-
): Promise<{ code: number; stderr: string }> {
|
|
114
|
+
): Promise<{ code: number; stderr: string; stdout: string }> {
|
|
115
115
|
let proc: ReturnType<typeof Bun.spawn>
|
|
116
116
|
try {
|
|
117
117
|
proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe", stdin: "ignore", cwd, env })
|
|
118
118
|
} catch (err) {
|
|
119
|
-
return { code: -1, stderr: `Failed to launch ${cmd[0]}: ${(err as Error).message}
|
|
119
|
+
return { code: -1, stderr: `Failed to launch ${cmd[0]}: ${(err as Error).message}`, stdout: "" }
|
|
120
120
|
}
|
|
121
121
|
const timer = setTimeout(() => {
|
|
122
122
|
try {
|
|
@@ -127,8 +127,11 @@ export async function runProcess(
|
|
|
127
127
|
}, timeoutMs)
|
|
128
128
|
const code = await proc.exited
|
|
129
129
|
clearTimeout(timer)
|
|
130
|
-
const stderr = await
|
|
131
|
-
|
|
130
|
+
const [stdout, stderr] = await Promise.all([
|
|
131
|
+
new Response(proc.stdout as ReadableStream<Uint8Array>).text(),
|
|
132
|
+
new Response(proc.stderr as ReadableStream<Uint8Array>).text(),
|
|
133
|
+
])
|
|
134
|
+
return { code, stderr, stdout }
|
|
132
135
|
}
|
|
133
136
|
|
|
134
137
|
// Pick a meaningful error line out of ssh/wp stderr, skipping the deprecation /
|
package/src/lib/dbSync.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// SYNC_REMOTE_HOST / SYNC_LOCAL_HOST env contract as the sync-prod-to-local
|
|
13
13
|
// script, so per-project tweaks (Elementor URL swaps, plugin toggles) carry over.
|
|
14
14
|
|
|
15
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs"
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, rmSync } from "node:fs"
|
|
16
16
|
import { join, dirname } from "node:path"
|
|
17
17
|
import type { Server, Site } from "../api/types.ts"
|
|
18
18
|
import { expandPath, findProjectRoot, resolveLocalLink, type LocalKind, type LocalLink } from "./local.ts"
|
|
@@ -212,6 +212,20 @@ export async function runDbSync(plan: DbSyncPlan, domain: string, onProgress: (p
|
|
|
212
212
|
const localSql = plan.localBackupPath.replace(/\.gz$/, "")
|
|
213
213
|
const lb = await wp(`wp db export "${localSql}" >/dev/null && gzip -f "${localSql}"`, 300_000)
|
|
214
214
|
if (lb.code !== 0) {
|
|
215
|
+
// `wp db export` creates its output file before it knows the dump will
|
|
216
|
+
// succeed, and the `&& gzip` never runs when it doesn't — leaving a bare
|
|
217
|
+
// .sql behind (empty on an instant failure, PARTIAL if the dump dies
|
|
218
|
+
// midway) named exactly like a real backup. Nothing was restorable from it
|
|
219
|
+
// anyway: this step aborts before the local database is touched. Remove it
|
|
220
|
+
// rather than leave a file that reads as a safety net, the same way the
|
|
221
|
+
// download branch below cleans up the remote dump it orphaned. A
|
|
222
|
+
// successful run leaves only the .gz, and the name carries a
|
|
223
|
+
// to-the-minute timestamp, so this can only ever be this run's artifact.
|
|
224
|
+
try {
|
|
225
|
+
rmSync(localSql, { force: true })
|
|
226
|
+
} catch {
|
|
227
|
+
/* best-effort — a leftover file must not mask the real failure below */
|
|
228
|
+
}
|
|
215
229
|
// wp-cli's own "not a WordPress install" error is a common first-pull snag on a
|
|
216
230
|
// fresh checkout — a Bedrock or Radicle project needs `composer install` to
|
|
217
231
|
// build vendor/ + web/wp (or public/wp) before wp-cli (or wp-cli.yml) can find
|
package/src/lib/links.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Local working-copy links, read/written outside the React store.
|
|
2
|
+
//
|
|
3
|
+
// The TUI's store owns an authoritative in-memory Map and persists the whole
|
|
4
|
+
// map on every change (see store.tsx's persistLinks), so it doesn't need these.
|
|
5
|
+
// The CLI has no store: it reads the config once and writes a single link back,
|
|
6
|
+
// merging rather than replacing so a concurrent TUI session's other links
|
|
7
|
+
// survive. Both paths end up in the same config key (`localSites`).
|
|
8
|
+
|
|
9
|
+
import { loadConfig, saveConfig } from "../config.ts"
|
|
10
|
+
import { normalizeLink, type LocalLink } from "./local.ts"
|
|
11
|
+
|
|
12
|
+
// The link recorded for a site id, if any.
|
|
13
|
+
export function readLink(siteId: number): LocalLink | undefined {
|
|
14
|
+
const raw = loadConfig().localSites[String(siteId)]
|
|
15
|
+
return raw ? normalizeLink(raw) : undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Merge one link into the stored set. Re-reads the config immediately before
|
|
19
|
+
// writing so we don't clobber links made since this process started.
|
|
20
|
+
export async function writeLink(siteId: number, link: LocalLink): Promise<void> {
|
|
21
|
+
const current = loadConfig().localSites
|
|
22
|
+
await saveConfig({ localSites: { ...current, [String(siteId)]: normalizeLink(link) } })
|
|
23
|
+
}
|