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.
@@ -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
- // Domain -> site -> server -> SSH target/port, with no live connectivity check.
57
- // Split out of resolveSshAccess so callers that are about to run a real command
58
- // (which is itself a connectivity test) don't pay for a redundant probe first.
59
- export async function resolveSshTargetInfo(
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
- ): Promise<SshTargetResolution> {
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
- ok: false,
81
- result: {
82
- ok: false,
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
- return {
97
- ok: false,
98
- result: {
99
- ok: false,
100
- domain,
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: `"${domain}" matches ${matches.length} sites in this account — cannot pick one automatically.`,
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
- ok: false,
113
- result: {
114
- ok: false,
115
- domain,
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 { ok: false, result: { ok: false, domain, reason: "api_error", message } }
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 = Bun.spawn(["ssh", ...SSH_OPTS, ...portOpt, target, "true"], {
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()