spinuptui 0.25.0 → 0.26.1
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 +103 -1
- package/README.md +61 -8
- package/package.json +3 -2
- package/src/index.tsx +134 -11
- package/src/lib/appUpdate.ts +2 -1
- package/src/lib/cliPull.ts +387 -0
- package/src/lib/cliSsh.ts +76 -50
- package/src/lib/cwdDotenv.ts +106 -0
- package/src/lib/dbBackup.ts +9 -5
- package/src/lib/dbSync.ts +15 -1
- package/src/lib/gitDeployKey.ts +0 -0
- package/src/lib/gitStatus.ts +2 -1
- package/src/lib/keychain.ts +3 -1
- package/src/lib/links.ts +23 -0
- package/src/lib/localSetup.ts +164 -0
- package/src/lib/open.ts +10 -8
- package/src/lib/probe.ts +2 -1
- package/src/lib/serverClone.ts +3 -2
- package/src/lib/spawn.ts +17 -0
- package/src/lib/ssh.ts +6 -5
- package/src/lib/sshExec.ts +4 -2
- package/src/lib/wpInventory.ts +2 -1
- package/src/ui/App.tsx +6 -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
|
@@ -0,0 +1,387 @@
|
|
|
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
|
+
// Standard WP: the overwhelmingly likely cause right after `pull files` is the
|
|
355
|
+
// copied wp-config.php still carrying production's DB credentials, so local
|
|
356
|
+
// wp-cli authenticates as the production user against the local MySQL.
|
|
357
|
+
// Bedrock/Radicle keep credentials in .env instead (never pulled), and a
|
|
358
|
+
// DB_* variable exported in the shell can shadow that file.
|
|
359
|
+
const deniedAs = /access denied for user '([^']+)'/i.exec(error)?.[1]
|
|
360
|
+
const envBased = plan.localKind === "bedrock" || plan.localKind === "radicle"
|
|
361
|
+
return {
|
|
362
|
+
...fail(
|
|
363
|
+
"sync_failed",
|
|
364
|
+
error,
|
|
365
|
+
!deniedAs
|
|
366
|
+
? undefined
|
|
367
|
+
: envBased
|
|
368
|
+
? `Local MySQL refused the user "${deniedAs}". This is a ${plan.localKind} checkout, so its database credentials live in ${join(plan.localRoot, ".env")} — point DB_NAME / DB_USER / DB_PASSWORD / DB_HOST there at your local database and re-run. A DB_* variable exported in your shell can override that file, so check \`env | grep ^DB_\` too.`
|
|
369
|
+
: `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.`,
|
|
370
|
+
),
|
|
371
|
+
...(result.failedStage ? { failedStage: result.failedStage } : {}),
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
ok: true,
|
|
377
|
+
command,
|
|
378
|
+
domain,
|
|
379
|
+
primaryDomain: site.domain,
|
|
380
|
+
path: expandPath(link.path),
|
|
381
|
+
localUrl: plan.localOrigin,
|
|
382
|
+
downloadPath: result.downloadPath ?? plan.downloadPath,
|
|
383
|
+
localBackupPath: result.localBackupPath ?? plan.localBackupPath,
|
|
384
|
+
ranHook: result.ranHook === true,
|
|
385
|
+
...(plan.prefixWarning ? { warnings: [plan.prefixWarning] } : {}),
|
|
386
|
+
}
|
|
387
|
+
}
|
package/src/lib/cliSsh.ts
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
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"
|
|
12
|
+
import { spawn } from "./spawn.ts"
|
|
11
13
|
|
|
12
14
|
export type SshAccessReason =
|
|
13
15
|
| "no_token"
|
|
@@ -53,38 +55,44 @@ export type SshTargetResolution =
|
|
|
53
55
|
| { ok: true; info: SshTargetInfo }
|
|
54
56
|
| { ok: false; result: SshAccessResult & { ok: false } }
|
|
55
57
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
58
|
+
// A resolved domain, before anything is done with it. Shared by every
|
|
59
|
+
// domain-addressed CLI command (`ssh`, `ssh-exec`, `pull`) so they all fail the
|
|
60
|
+
// same way — same reason codes, same messages — on an unknown or ambiguous
|
|
61
|
+
// domain, a rejected token, or a server with no IP.
|
|
62
|
+
export type SiteResolution =
|
|
63
|
+
| { ok: true; site: Site; server: Server }
|
|
64
|
+
| { ok: false; result: SshAccessResult & { ok: false } }
|
|
65
|
+
|
|
66
|
+
export async function resolveSiteByDomain(
|
|
60
67
|
domain: string,
|
|
61
68
|
client: SpinupWPClientLike,
|
|
62
69
|
cfg: AppConfig,
|
|
63
|
-
|
|
70
|
+
// Narrows an otherwise ambiguous domain to one server, by name — matching
|
|
71
|
+
// either the full name or its first label ("web1" for "web1.example.com"),
|
|
72
|
+
// which is what the ambiguity message itself prints. Callers that expose no
|
|
73
|
+
// way to pass it (ssh, ssh-exec) simply omit it and keep failing as before.
|
|
74
|
+
opts?: { server?: string | null },
|
|
75
|
+
): Promise<SiteResolution> {
|
|
76
|
+
const fail = (
|
|
77
|
+
partial: Omit<SshAccessResult & { ok: false }, "ok" | "domain">,
|
|
78
|
+
): SiteResolution => ({ ok: false, result: { ok: false, domain, ...partial } })
|
|
79
|
+
|
|
64
80
|
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
|
-
}
|
|
81
|
+
return fail({ reason: "no_token", message: "No API token configured. Run `spinuptui login`." })
|
|
69
82
|
}
|
|
70
83
|
|
|
71
|
-
let site
|
|
72
|
-
let server
|
|
84
|
+
let site: Site
|
|
85
|
+
let server: Server
|
|
73
86
|
try {
|
|
74
87
|
const sites = await client.listSites()
|
|
75
88
|
const matches = sites.filter(
|
|
76
89
|
(s) => s.domain === domain || s.additional_domains?.some((a) => a.domain === domain),
|
|
77
90
|
)
|
|
78
91
|
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
|
-
}
|
|
92
|
+
return fail({
|
|
93
|
+
reason: "site_not_found",
|
|
94
|
+
message: `No site matching "${domain}" found in this SpinupWP account.`,
|
|
95
|
+
})
|
|
88
96
|
}
|
|
89
97
|
if (matches.length > 1) {
|
|
90
98
|
const candidates: SshAccessCandidate[] = await Promise.all(
|
|
@@ -93,48 +101,65 @@ export async function resolveSshTargetInfo(
|
|
|
93
101
|
return { siteId: s.id, serverId: s.server_id, server: srv.name }
|
|
94
102
|
}),
|
|
95
103
|
)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
const wanted = opts?.server?.trim().toLowerCase()
|
|
105
|
+
const narrowed = wanted
|
|
106
|
+
? candidates.filter((c) => {
|
|
107
|
+
const name = c.server.toLowerCase()
|
|
108
|
+
return name === wanted || name.split(".")[0] === wanted
|
|
109
|
+
})
|
|
110
|
+
: candidates
|
|
111
|
+
if (narrowed.length !== 1) {
|
|
112
|
+
return fail({
|
|
101
113
|
reason: "multiple_matches",
|
|
102
|
-
message:
|
|
114
|
+
message: wanted
|
|
115
|
+
? `"${domain}" matches ${matches.length} sites in this account, and ${narrowed.length === 0 ? "none are" : `${narrowed.length} are`} on a server matching "${opts?.server}".`
|
|
116
|
+
: `"${domain}" matches ${matches.length} sites in this account — cannot pick one automatically.`,
|
|
117
|
+
// Every command that resolves a domain takes --server, so this is
|
|
118
|
+
// always actionable — say so here rather than in each of them.
|
|
119
|
+
remedy: `Pick one with --server, e.g. \`--server ${candidates[0]!.server}\`.`,
|
|
103
120
|
candidates,
|
|
104
|
-
}
|
|
121
|
+
})
|
|
105
122
|
}
|
|
123
|
+
site = matches.find((m) => m.id === narrowed[0]!.siteId)!
|
|
124
|
+
server = await client.getServer(site.server_id)
|
|
125
|
+
return server.ip_address
|
|
126
|
+
? { ok: true, site, server }
|
|
127
|
+
: fail({ reason: "server_has_no_ip", message: `Server "${server.name}" has no IP address on file.` })
|
|
106
128
|
}
|
|
107
|
-
site = matches[0]
|
|
129
|
+
site = matches[0]!
|
|
108
130
|
server = await client.getServer(site.server_id)
|
|
109
131
|
} catch (err) {
|
|
110
132
|
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
|
-
}
|
|
133
|
+
return fail({
|
|
134
|
+
reason: "token_rejected",
|
|
135
|
+
message: "Saved API token was rejected (401) — it may be expired or revoked.",
|
|
136
|
+
remedy: "Run `spinuptui login` to save a fresh token.",
|
|
137
|
+
})
|
|
121
138
|
}
|
|
122
139
|
const message = err instanceof ApiError ? err.message : `Unexpected error: ${(err as Error).message}`
|
|
123
|
-
return {
|
|
140
|
+
return fail({ reason: "api_error", message })
|
|
124
141
|
}
|
|
125
142
|
|
|
126
143
|
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
|
-
}
|
|
144
|
+
return fail({ reason: "server_has_no_ip", message: `Server "${server.name}" has no IP address on file.` })
|
|
136
145
|
}
|
|
137
146
|
|
|
147
|
+
return { ok: true, site, server }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Domain -> site -> server -> SSH target/port, with no live connectivity check.
|
|
151
|
+
// Split out of resolveSshAccess so callers that are about to run a real command
|
|
152
|
+
// (which is itself a connectivity test) don't pay for a redundant probe first.
|
|
153
|
+
export async function resolveSshTargetInfo(
|
|
154
|
+
domain: string,
|
|
155
|
+
client: SpinupWPClientLike,
|
|
156
|
+
cfg: AppConfig,
|
|
157
|
+
opts?: { server?: string | null },
|
|
158
|
+
): Promise<SshTargetResolution> {
|
|
159
|
+
const resolved = await resolveSiteByDomain(domain, client, cfg, opts)
|
|
160
|
+
if (!resolved.ok) return resolved
|
|
161
|
+
|
|
162
|
+
const { site, server } = resolved
|
|
138
163
|
const target = resolveSiteSshTarget(site, server, cfg.sshUser)
|
|
139
164
|
const port = server.ssh_port && server.ssh_port !== 22 ? server.ssh_port : null
|
|
140
165
|
|
|
@@ -148,15 +173,16 @@ export async function resolveSshAccess(
|
|
|
148
173
|
domain: string,
|
|
149
174
|
client: SpinupWPClientLike,
|
|
150
175
|
cfg: AppConfig,
|
|
176
|
+
opts?: { server?: string | null },
|
|
151
177
|
): Promise<SshAccessResult> {
|
|
152
|
-
const resolution = await resolveSshTargetInfo(domain, client, cfg)
|
|
178
|
+
const resolution = await resolveSshTargetInfo(domain, client, cfg, opts)
|
|
153
179
|
if (!resolution.ok) return resolution.result
|
|
154
180
|
const { primaryDomain, sshTarget: target, port, server: serverName } = resolution.info
|
|
155
181
|
const portOpt = port ? ["-p", String(port)] : []
|
|
156
182
|
|
|
157
183
|
let proc: ReturnType<typeof Bun.spawn>
|
|
158
184
|
try {
|
|
159
|
-
proc =
|
|
185
|
+
proc = spawn(["ssh", ...SSH_OPTS, ...portOpt, target, "true"], {
|
|
160
186
|
stdout: "pipe",
|
|
161
187
|
stderr: "pipe",
|
|
162
188
|
stdin: "ignore",
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Keep the current directory's .env out of the processes we spawn.
|
|
2
|
+
//
|
|
3
|
+
// Bun auto-loads .env files from the directory it was launched in, and every
|
|
4
|
+
// spawned child inherits process.env. Run spinuptui from inside a site's checkout
|
|
5
|
+
// and that site's own .env — DB_NAME, DB_USER, DB_PASSWORD, WP_HOME — rides along
|
|
6
|
+
// into `bash -lc "wp …"`. A Bedrock config then reads *nothing*: its phpdotenv
|
|
7
|
+
// repository is immutable, so it skips keys that already exist in the
|
|
8
|
+
// environment, while `Env::$options` with USE_ENV_ARRAY reads only $_ENV — which
|
|
9
|
+
// PHP leaves empty for inherited variables when variables_order lacks "E" (the
|
|
10
|
+
// Homebrew default). Every DB constant comes out blank, and `wp db export` fails
|
|
11
|
+
// as "Access denied for user '<login name>'@'localhost' (using password: NO)".
|
|
12
|
+
//
|
|
13
|
+
// So: drop every key those files defined, except the ones this app reads itself —
|
|
14
|
+
// its token and provider credentials may legitimately live in a project-local
|
|
15
|
+
// .env (see config.ts). The provider keys have generic names a site's .env uses
|
|
16
|
+
// too (Bedrock keeps S3 media credentials under AWS_*), so those are only trusted
|
|
17
|
+
// from spinuptui's own project directory. Deleting from process.env only reaches children that are
|
|
18
|
+
// handed process.env explicitly, which is what src/lib/spawn.ts is for. Must run
|
|
19
|
+
// before anything reads config or spawns, which is why index.tsx imports this
|
|
20
|
+
// module first.
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs"
|
|
23
|
+
import { join } from "node:path"
|
|
24
|
+
|
|
25
|
+
// Keys spinuptui reads from the environment on purpose. Only spinuptui uses the
|
|
26
|
+
// SPINUP* names, so they're kept from any directory's .env.
|
|
27
|
+
const OWN = /^SPINUP/
|
|
28
|
+
|
|
29
|
+
// Provider credentials spinuptui also reads (config.ts). Kept only from the .env
|
|
30
|
+
// in spinuptui's own project directory — anywhere else they're a site's keys.
|
|
31
|
+
const PROVIDER = /^(CLOUDFLARE_API_TOKEN|AWS_(ACCESS_KEY_ID|SECRET_ACCESS_KEY|REGION)|GODADDY_API_(KEY|SECRET))$/
|
|
32
|
+
|
|
33
|
+
// This file lives in <project>/src/lib.
|
|
34
|
+
const PROJECT_DIR = join(import.meta.dir, "..", "..")
|
|
35
|
+
|
|
36
|
+
function isProjectDir(dir: string): boolean {
|
|
37
|
+
try {
|
|
38
|
+
return realpathSync(dir) === realpathSync(PROJECT_DIR)
|
|
39
|
+
} catch {
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Never removed, whatever a .env says: the spawned shells need these to work.
|
|
45
|
+
const ESSENTIAL = new Set(["PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "TERM", "LANG", "SSH_AUTH_SOCK", "XDG_CONFIG_HOME"])
|
|
46
|
+
|
|
47
|
+
// The files Bun loads, in increasing precedence: .env, .env.<NODE_ENV> (default
|
|
48
|
+
// development), then .env.local (skipped under test).
|
|
49
|
+
function loadedFiles(cwd: string): string[] {
|
|
50
|
+
const mode = process.env.NODE_ENV || "development"
|
|
51
|
+
const names = [".env", `.env.${mode}`]
|
|
52
|
+
if (mode !== "test") names.push(".env.local")
|
|
53
|
+
return names.map((n) => join(cwd, n)).filter((p) => existsSync(p))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Each key with the value the file gives it, or null where that value can't be
|
|
57
|
+
// read with certainty (variable expansion, escapes, multi-line quotes). The value
|
|
58
|
+
// only matters for telling a .env key from a shell one; see scrubCwdDotenv.
|
|
59
|
+
export function dotenvEntries(text: string): Map<string, string | null> {
|
|
60
|
+
const out = new Map<string, string | null>()
|
|
61
|
+
for (const m of text.matchAll(/^[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t]*=(.*)$/gm)) {
|
|
62
|
+
out.set(m[1]!, dotenvValue(m[2]!.trim()))
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function dotenvValue(raw: string): string | null {
|
|
68
|
+
const q = raw[0]
|
|
69
|
+
if (q === "'" || q === "`" || q === '"') {
|
|
70
|
+
const end = raw.indexOf(q, 1)
|
|
71
|
+
if (end < 0) return null
|
|
72
|
+
const inner = raw.slice(1, end)
|
|
73
|
+
return q === '"' && /[$\\]/.test(inner) ? null : inner
|
|
74
|
+
}
|
|
75
|
+
const value = raw.replace(/\s+#.*$/, "").trim()
|
|
76
|
+
return value.includes("$") ? null : value
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Returns the keys removed, for tests and debugging.
|
|
80
|
+
export function scrubCwdDotenv(cwd = process.cwd()): string[] {
|
|
81
|
+
// Later files win, as they do in Bun.
|
|
82
|
+
const entries = new Map<string, string | null>()
|
|
83
|
+
for (const file of loadedFiles(cwd)) {
|
|
84
|
+
let text: string
|
|
85
|
+
try {
|
|
86
|
+
text = readFileSync(file, "utf8")
|
|
87
|
+
} catch {
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
for (const [key, value] of dotenvEntries(text)) entries.set(key, value)
|
|
91
|
+
}
|
|
92
|
+
const ownDir = isProjectDir(cwd)
|
|
93
|
+
const removed: string[] = []
|
|
94
|
+
for (const [key, value] of entries) {
|
|
95
|
+
if (OWN.test(key) || (ownDir && PROVIDER.test(key)) || ESSENTIAL.has(key) || !(key in process.env)) continue
|
|
96
|
+
// Bun never overrides a variable the shell already set, so a value that differs
|
|
97
|
+
// from the file's came from the shell — keep it. When the file's value can't be
|
|
98
|
+
// read with certainty, assume it's the file's and drop it.
|
|
99
|
+
if (value !== null && process.env[key] !== value) continue
|
|
100
|
+
delete process.env[key]
|
|
101
|
+
removed.push(key)
|
|
102
|
+
}
|
|
103
|
+
return removed
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
scrubCwdDotenv()
|