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 CHANGED
@@ -11,6 +11,106 @@ versions; such changes are called out here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.26.1] - 2026-09-18
15
+
16
+ ### Fixed
17
+ - **`pull db` (and every other local command) now works when spinuptui is launched from inside a site's checkout.** Bun auto-loads the current directory's `.env`, and child processes inherited it, so a Bedrock site's own `DB_*` values reached `wp` as environment variables. Bedrock then read blank database credentials, and the pre-import backup failed with `Access denied for user '<you>'@'localhost' (using password: NO)`. Keys from that `.env` are now dropped at startup, except the ones spinuptui reads itself (its token and Uptime Kuma settings; provider credentials are covered below).
18
+ - **`pull db`'s "Access denied" hint now points Bedrock and Radicle sites at their `.env`.** It used to blame `wp-config.php` for every site, but those stacks keep their database credentials in `.env`. The hint also mentions that a `DB_*` variable exported in the shell can override that file.
19
+ - **A site's `.env` can no longer supply spinuptui's DNS provider credentials.** Launched from inside a site checkout, spinuptui read that site's `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` (which Bedrock sites commonly use for S3 media) as its own Route 53 credentials, silently overriding the ones in config. Provider keys (`AWS_*`, `CLOUDFLARE_API_TOKEN`, `GODADDY_API_*`) from a `.env` are now honored only in spinuptui's own project directory; keys exported in the shell work from anywhere, as before.
20
+
21
+ ## [0.26.0] - 2026-08-26
22
+
23
+ ### Added
24
+ - **Clone a site's full local working copy from production** — `L` on a site
25
+ with no local copy yet now opens a choose screen: `e` is the existing manual
26
+ "enter a path" form, and the new `c` runs a guided clone. It pulls the code
27
+ down (a `git clone` for git-deployed sites, an `rsync` over SSH for everything
28
+ else), runs `composer install` when the checkout turns out to be
29
+ Bedrock/Radicle, links the result exactly as the manual form would, and then
30
+ hands off to the existing DB sync (`p`) and production-media (`m`) flows
31
+ unchanged — three already-reviewed screens chained by keypresses rather than
32
+ one new mega-flow. The local database and webserver config are never touched.
33
+ - The rsync pull always takes the site's whole files root (so a `public/`-style
34
+ layout brings `wp-config.php` down from one level above the webroot), and the
35
+ real webroot is **detected** on the server rather than trusting
36
+ `public_folder`.
37
+ - `wp-content/uploads` is excluded — production media is served through the
38
+ media-fallback mu-plugin (`m`) instead of copied.
39
+ - `object-cache.php` and `advanced-cache.php` are excluded outright: SpinupWP's
40
+ Redis drop-in doesn't no-op when Redis is unreachable, it 500s on a cache
41
+ miss, so copying them down fatals the local site.
42
+ - The done screen flags that the copied `wp-config.php` is **production's**,
43
+ credentials included, on the file-pull path. Nothing local can reach the
44
+ production database through them (its `DB_HOST` is localhost), but local
45
+ wp-cli authenticates as the production DB user, so the DB pull offered on
46
+ the same screen is denied until they're pointed at a local database. A
47
+ Bedrock/Radicle clone gets the mirror-image note: `.env` holds its database
48
+ credentials and isn't in the repo, so a fresh checkout has none.
49
+ - **`spinuptui pull files` and `spinuptui pull db`** — the local-working-copy
50
+ setup and DB sync, driven non-interactively from the command line, on the
51
+ same engines the TUI's guided flow uses. `pull files <domain> [path]` clones a
52
+ site's code down and links it; `pull db <domain> --yes` imports production's
53
+ database into that copy. Built for an agentic workflow ("pull site X down for
54
+ local dev") as much as for a person: progress goes to stderr, and stdout
55
+ carries the machine contract — with `--json`, a single result object, exit
56
+ code mirroring `ok`, and failures carrying both a stable `reason` code and a
57
+ `remedy` naming the exact command that fixes it.
58
+ - **`pull db` is gated twice**, since it overwrites the local database:
59
+ `"localSync": true` in the config (the same opt-in the TUI enforces) *and*
60
+ `--yes` on the invocation. Both are checked before any network call, so a
61
+ typo'd domain in a script bounces off the gate rather than off a lookup.
62
+ - **`pull files` never re-syncs over an existing copy** — a site that's
63
+ already linked is refused with its current path, rather than clobbering
64
+ local work.
65
+ - **`pull files` warns that the copied `wp-config.php` is production's**, and
66
+ a `pull db` that's denied by local MySQL says why. The rsync path brings
67
+ down the site's real `wp-config.php`, credentials and all — harmless (their
68
+ `DB_HOST` is localhost, so nothing local can reach production's database
69
+ through them) but it means local wp-cli authenticates as the *production*
70
+ DB user, and `pull db` fails on its very first step until they're pointed
71
+ at a local database.
72
+ - **`--server <name>` picks one site when a domain exists on more than one
73
+ server**, and the ambiguity itself now lists what matched (in both output
74
+ modes) instead of only saying there was more than one.
75
+ - **A Bedrock/Radicle clone is flagged for its missing `.env`** — that's where
76
+ its database credentials live and it's deliberately not in the repo, so
77
+ `pull db` would otherwise die on `mysqldump: unknown variable 'pass='`.
78
+ It's now caught up front, by name.
79
+ - The path argument is optional only when exactly one `localRoots` entry is
80
+ configured. With several, it's required: which root a site belongs in isn't
81
+ knowable from here, and guessing would file a standard-WP site under
82
+ Bedrock.
83
+ - **`--server <name>` on `ssh` and `ssh-exec` too**, not just the `pull`
84
+ commands. A domain that exists on more than one server — a clone-wizard
85
+ destination, a staging twin — could previously be reported as ambiguous by
86
+ these commands but never resolved, leaving it simply unreachable from the
87
+ CLI. The ambiguity now also lists the candidates and names the flag that
88
+ picks one, everywhere it's raised.
89
+
90
+ ### Changed
91
+ - **Search now shows a full-width Site/Server Control drawer**, matching the
92
+ Servers tab, instead of swapping the right-hand panel between Details and
93
+ Actions. The Details pane stays put while you browse, and the drawer below
94
+ always reflects the currently selected result — reusing the exact same
95
+ control-list component the Servers tab uses, so the two views can't drift
96
+ out of sync again.
97
+
98
+ ### Fixed
99
+ - **`K` (Grant SSH key) and `V` (create/resume vanity site) now work on server
100
+ results in Search**, not just site results. Both were already advertised in
101
+ the legend but silently did nothing on a server before this — `K` falls back
102
+ to the server's first key-eligible site as the anchor, matching the Servers
103
+ tab's behavior.
104
+ - **A failed local-database backup no longer leaves a fake backup behind.** The
105
+ DB pull (`p`, and the same engine everywhere else) starts by dumping the
106
+ local database as a safety net; `wp db export` creates its output file before
107
+ it knows the dump will succeed, and the follow-up `gzip` never runs when it
108
+ doesn't — leaving a bare `.sql` in `sql/`, named exactly like a real backup.
109
+ Empty when the failure is instant (bad credentials), **partial** when the
110
+ dump dies midway, which is the case that matters: it reads as a safety net
111
+ and isn't one. Nothing was lost by removing it — the pull aborts at that step,
112
+ before the local database is touched at all.
113
+
14
114
  ## [0.25.0] - 2026-08-12
15
115
 
16
116
  ### Added
@@ -1315,7 +1415,9 @@ Initial tagged release.
1315
1415
  ### Notes
1316
1416
  - Read-only release: works with a SpinupWP **Read Only** API token.
1317
1417
 
1318
- [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.25.0...HEAD
1418
+ [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.26.1...HEAD
1419
+ [0.26.1]: https://github.com/mwender/spinupwp-tui/compare/v0.26.0...v0.26.1
1420
+ [0.26.0]: https://github.com/mwender/spinupwp-tui/compare/v0.25.0...v0.26.0
1319
1421
  [0.25.0]: https://github.com/mwender/spinupwp-tui/compare/v0.24.4...v0.25.0
1320
1422
  [0.24.4]: https://github.com/mwender/spinupwp-tui/compare/v0.24.3...v0.24.4
1321
1423
  [0.24.3]: https://github.com/mwender/spinupwp-tui/compare/v0.24.2...v0.24.3
package/README.md CHANGED
@@ -44,8 +44,9 @@
44
44
  - **Installed plugins & themes** (`e`) — real `wp plugin`/`theme list` over SSH,
45
45
  with per-item version and update status. → [docs/plugins-themes.md](docs/plugins-themes.md)
46
46
  - **Open in browser** (`o`) / **SSH into a site** (`s`) — one-key shortcuts.
47
- - **Link local working copies** (`L`) — link a site to its local checkout, with
48
- auto-discovery and git-drift indicators. → [docs/local-working-copies.md](docs/local-working-copies.md)
47
+ - **Local working copies** (`L`) — link a site to its local checkout, or clone
48
+ one from production (`c`) when you don't have it yet; with auto-discovery and
49
+ git-drift indicators. → [docs/local-working-copies.md](docs/local-working-copies.md)
49
50
  - **DNS migration lens** (`n` / `N`) — see and edit a site's hosting records,
50
51
  repoint it to another server. → [docs/dns-access-editing.md](docs/dns-access-editing.md)
51
52
  - **Database backup & sync** (`d` / `p`, Servers / Search tabs) — download or
@@ -80,9 +81,12 @@
80
81
  if it's read-only — anything that looks like a write/restart/destructive
81
82
  action is denied rather than run, so any agent using it inherits that
82
83
  guarantee without building its own guard; `spinuptui incidents <domain>` /
83
- `--all` surfaces Uptime Kuma's down/up history as JSON. Built so another
84
- agent/script can go from just a domain to "what's wrong and how do I get
85
- in" with no manual lookup. → [CLI subcommands](#cli-subcommands)
84
+ `--all` surfaces Uptime Kuma's down/up history as JSON; `spinuptui pull files
85
+ <domain> [path]` and `spinuptui pull db <domain> --yes` set up a local working
86
+ copy from production without opening the TUI at all — the same engines the
87
+ guided flow drives. Built so another agent/script can go from just a domain to
88
+ "what's wrong and how do I get in", or to a working local copy, with no manual
89
+ lookup. → [CLI subcommands](#cli-subcommands)
86
90
  - **Completion toasts** — a non-focus-stealing toast when a background write
87
91
  (PHP upgrade, reboot, DNS resolve, …) finishes.
88
92
  - **Release notes** — an in-app "what's new" after every update, sourced
@@ -169,11 +173,13 @@ to the version in the header (and in the `?` About panel).
169
173
  spinuptui Launch the dashboard
170
174
  spinuptui login Set or update your saved API token
171
175
  spinuptui where Show the config path and which source the token came from
172
- spinuptui ssh <domain> Print SSH access info for a site as JSON (resolves the
176
+ spinuptui ssh <domain> [--server <name>] Print SSH access info for a site
177
+ as JSON (resolves the
173
178
  site's server, builds its SSH target, and runs a live
174
179
  connectivity probe — for external tooling, e.g. an
175
180
  incident-diagnostics agent handed only a domain)
176
- spinuptui ssh-exec <domain> -- <command> Resolve the domain's SSH target and
181
+ spinuptui ssh-exec <domain> [--server <name>] -- <command> Resolve the
182
+ domain's SSH target and
177
183
  run <command> on it, but only if the command is
178
184
  read-only — anything matching a write/restart/destructive
179
185
  pattern (plugin/theme changes, DB writes, service
@@ -189,10 +195,57 @@ spinuptui incidents <domain> | --all [--hours N] Print Uptime Kuma down/up
189
195
  monitoring for (config.json's kumaMonitors) — --all
190
196
  sweeps every such site in one Kuma connection, --hours
191
197
  sets the lookback window (default 24)
198
+ spinuptui pull files <domain> [path] [--url <local url>] [--server <name>]
199
+ [--json]
200
+ Set up a new local working copy of a site: `git clone` for
201
+ git-deployed sites, `rsync` over SSH otherwise (excluding
202
+ uploads and the caching drop-ins), `composer install` for
203
+ Bedrock/Radicle, then link it. Read-only on production.
204
+ Refuses a path that exists and isn't empty, and refuses a
205
+ site that already has a local copy rather than re-syncing
206
+ over your work. With exactly one `localRoots` entry
207
+ configured the path is optional (defaults to
208
+ `<that root>/<domain>`); with several, it's required,
209
+ since which root a site belongs in isn't knowable. Note
210
+ the rsync path copies the site's real `wp-config.php` —
211
+ production credentials and all — so point its DB settings
212
+ at your local database before `pull db` (the command says
213
+ so on success, and again if the import is denied). A
214
+ Bedrock/Radicle clone lands without its `.env` — which is
215
+ where its DB credentials live and is deliberately not in
216
+ the repo — and says so the same way. `--server <name>`
217
+ picks one site when a domain exists on more than one
218
+ server; the ambiguity message lists the candidates.
219
+ spinuptui pull db <domain> [--url <local url>] [--server <name>] --yes [--json]
220
+ Import production's database into the already-linked local
221
+ copy, rewriting production URLs to the local URL, after
222
+ dumping the current local database as a backup. This
223
+ **overwrites your local database**, so it's gated twice:
224
+ `"localSync": true` must be set in the config (or
225
+ `SPINUPWP_LOCAL_SYNC=1`) *and* `--yes` passed on the
226
+ invocation. Both are checked before any network call.
227
+ `--url` doubles as "set the local URL, then sync".
192
228
  spinuptui --version Print the version
193
229
  spinuptui --help Show help
194
230
  ```
195
231
 
232
+ `--server <name>` works on every domain-addressed subcommand (`ssh`,
233
+ `ssh-exec`, `pull files`, `pull db`). A domain can legitimately exist on more
234
+ than one server — a clone-wizard destination, a staging twin — and without it
235
+ those commands could only report the ambiguity, not resolve it. It matches the
236
+ full server name or its first label (`web1` for `web1.example.com`), and the
237
+ ambiguity message lists the candidates to pick from.
238
+
239
+ Both `pull` commands write human-readable progress to **stderr** and their
240
+ result to **stdout** — with `--json`, a single result object (`{ok:true, …}` or
241
+ `{ok:false, reason, message, remedy}`), and the exit code mirrors `ok`. That
242
+ keeps stdout parseable for an agent while a person watching a ten-minute rsync
243
+ can still see it isn't hung. Failures carry a machine-readable `reason`
244
+ (`site_not_found`, `multiple_matches`, `already_linked`, `dest_not_empty`,
245
+ `ambiguous_dest`, `not_linked`, `missing_env`, `local_sync_disabled`,
246
+ `not_confirmed`, …) and, where there's a
247
+ clear next step, a `remedy` naming the exact command to run.
248
+
196
249
  ## Configuration
197
250
 
198
251
  The token is resolved in this order (first match wins):
@@ -256,7 +309,7 @@ These can be set in `config.json` or via an environment variable:
256
309
  | `d` | Download a production DB backup into the linked copy (Servers / Search; WordPress + linked) |
257
310
  | `p` | Pull the production DB into the linked copy — overwrites local; opt-in via `localSync` (Servers / Search) |
258
311
  | `m` | Production media fallback: serve missing-locally images from production (Servers / Search; WordPress + linked) |
259
- | `L` | Link / edit a site's local working copy |
312
+ | `L` | Link / edit a site's local working copy — on an unlinked site, `e` enters a path by hand and `c` clones one from production |
260
313
  | `t` / `v` | Open the linked copy in a terminal / its local URL in your browser |
261
314
  | `n` | DNS migration view for a site — its records + TTLs (`⏎` edits a TTL; `p` repoints the record; `a` shows the whole server) |
262
315
  | `N` | DNS migration view for the whole server |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "0.25.0",
3
+ "version": "0.26.1",
4
4
  "description": "SpinupTUI — a terminal dashboard for browsing, monitoring, and managing your SpinupWP servers and sites.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,8 @@
24
24
  "scripts": {
25
25
  "dev": "bun run src/index.tsx",
26
26
  "start": "bun run src/index.tsx",
27
- "typecheck": "tsc --noEmit",
27
+ "typecheck": "tsc --noEmit && bun run check:spawn",
28
+ "check:spawn": "! grep -rnaE \"Bun\\\\.spawn(Sync)?\\\\(|child_process\" src --exclude=spawn.ts || (echo \"Use spawn() from src/lib/spawn.ts instead (see that file)\" >&2; exit 1)",
28
29
  "link-global": "bun link",
29
30
  "unlink-global": "bun unlink",
30
31
  "build:binary": "bun build src/index.tsx --compile --outfile spinuptui"
package/src/index.tsx CHANGED
@@ -3,6 +3,9 @@
3
3
  // OpenTUI renderer and mounts either the first-run onboarding wizard (no token
4
4
  // yet) or the main app wrapped in the data store.
5
5
 
6
+ // First, before any config read or spawn: keep a site checkout's .env from
7
+ // leaking into child processes (see the module).
8
+ import "./lib/cwdDotenv.ts"
6
9
  import { useState } from "react"
7
10
  import { createCliRenderer } from "@opentui/core"
8
11
  import { createRoot } from "@opentui/react"
@@ -16,11 +19,44 @@ import { SpinupWPClient } from "./api/client.ts"
16
19
  import { resolveSshAccess } from "./lib/cliSsh.ts"
17
20
  import { resolveIncidents } from "./lib/cliIncidents.ts"
18
21
  import { execSshCommand } from "./lib/sshExec.ts"
22
+ import { runPullFiles, runPullDb } from "./lib/cliPull.ts"
19
23
 
20
24
  const args = process.argv.slice(2)
21
25
  const positionals = args.filter((a) => !a.startsWith("-"))
22
26
  const command = positionals[0]
23
27
 
28
+ // Flags that take a following value. Listed so that value can be skipped when
29
+ // collecting positionals — otherwise `--server web1 <domain>` reads "web1" as
30
+ // the domain, and a URL (which doesn't look like a flag) reads as a path.
31
+ const VALUE_FLAGS = new Set(["--url", "--server"])
32
+
33
+ function parseFlags(list: string[]): { positionals: string[]; flags: Map<string, string> } {
34
+ const out: string[] = []
35
+ const flags = new Map<string, string>()
36
+ for (let i = 0; i < list.length; i++) {
37
+ const a = list[i]!
38
+ if (a.startsWith("-")) {
39
+ if (VALUE_FLAGS.has(a)) {
40
+ flags.set(a, list[i + 1] ?? "")
41
+ i++
42
+ } else {
43
+ flags.set(a, "")
44
+ }
45
+ continue
46
+ }
47
+ out.push(a)
48
+ }
49
+ return { positionals: out, flags }
50
+ }
51
+
52
+ // A value-taking flag with nothing usable after it is a mistake worth naming,
53
+ // not a silently-empty value.
54
+ function badFlagValue(flags: Map<string, string>, name: string): boolean {
55
+ if (!flags.has(name)) return false
56
+ const v = flags.get(name)!
57
+ return !v || v.startsWith("-")
58
+ }
59
+
24
60
  if (args.includes("-h") || args.includes("--help") || command === "help") {
25
61
  const cfg = loadConfig()
26
62
  console.log(`SpinupTUI v${pkg.version} — terminal dashboard for your SpinupWP account
@@ -29,14 +65,31 @@ Usage:
29
65
  spinuptui Launch the dashboard
30
66
  spinuptui login Set or update your saved API token
31
67
  spinuptui where Print the config file path and token source
32
- spinuptui ssh <domain> Print SSH access info for a site (JSON)
33
- spinuptui ssh-exec <domain> -- <command> Run a read-only command over SSH
34
- (JSON); denies anything that looks like a remote write
68
+ spinuptui ssh <domain> [--server <name>] Print SSH access info for a site
69
+ (JSON)
70
+ spinuptui ssh-exec <domain> [--server <name>] -- <command> Run a read-only
71
+ command over SSH (JSON); denies anything that looks
72
+ like a remote write
35
73
  spinuptui incidents <domain> | --all [--hours N] Print Uptime Kuma
36
74
  down/up incidents for a site or the whole fleet (JSON)
75
+ spinuptui pull files <domain> [path] [--url <local url>] [--server <name>]
76
+ Clone a site's code to a new local working copy and link
77
+ it. Read-only on production; refuses a non-empty path.
78
+ With exactly one localRoots entry configured, the path
79
+ defaults to <that root>/<domain>.
80
+ spinuptui pull db <domain> [--url <local url>] [--server <name>] --yes
81
+ Import production's database into the linked local copy,
82
+ rewriting URLs. OVERWRITES your local database: needs
83
+ both --yes and "localSync": true in the config.
37
84
  spinuptui --version Print the version
38
85
  spinuptui --help Show this help
39
86
 
87
+ --server picks one site when a domain exists on more than one server (the
88
+ ambiguity message lists them); it works on ssh, ssh-exec and both pull
89
+ commands. Add --json to either pull command for a single
90
+ machine-readable result object on stdout; progress always goes to stderr, so a
91
+ long pull never looks hung.
92
+
40
93
  Token resolution: SPINUPWP_ACCESS_TOKEN (env / .env) first, then the config
41
94
  file. Run \`spinuptui login\` once to save a token so \`spinuptui\` works from
42
95
  anywhere.
@@ -59,31 +112,43 @@ if (command === "where") {
59
112
  }
60
113
 
61
114
  if (command === "ssh") {
62
- const domain = positionals[1]
63
- if (!domain) {
64
- console.error(JSON.stringify({ ok: false, reason: "usage", message: "Usage: spinuptui ssh <domain>" }))
115
+ const parsed = parseFlags(args.slice(1))
116
+ const domain = parsed.positionals[0]
117
+ const server = parsed.flags.get("--server") ?? null
118
+ if (!domain || badFlagValue(parsed.flags, "--server")) {
119
+ console.error(
120
+ JSON.stringify({ ok: false, reason: "usage", message: "Usage: spinuptui ssh <domain> [--server <name>]" }),
121
+ )
65
122
  process.exit(1)
66
123
  }
67
124
  const cfg = loadConfig()
68
125
  const client = new SpinupWPClient(cfg)
69
- const result = await resolveSshAccess(domain, client, cfg)
126
+ const result = await resolveSshAccess(domain, client, cfg, { server })
70
127
  console.log(JSON.stringify(result))
71
128
  process.exit(result.ok ? 0 : 1)
72
129
  }
73
130
 
74
131
  if (command === "ssh-exec") {
75
132
  const dashIdx = args.indexOf("--")
76
- const domain = positionals[1]
133
+ // Only the arguments BEFORE `--` are ours; everything after is the remote
134
+ // command, which may legitimately contain things that look like our flags.
135
+ const parsed = parseFlags(dashIdx === -1 ? args.slice(1) : args.slice(1, dashIdx))
136
+ const domain = parsed.positionals[0]
137
+ const server = parsed.flags.get("--server") ?? null
77
138
  const remoteCmd = dashIdx !== -1 ? args.slice(dashIdx + 1).join(" ") : ""
78
- if (!domain || dashIdx === -1 || !remoteCmd.trim()) {
139
+ if (!domain || dashIdx === -1 || !remoteCmd.trim() || badFlagValue(parsed.flags, "--server")) {
79
140
  console.error(
80
- JSON.stringify({ ok: false, reason: "usage", message: "Usage: spinuptui ssh-exec <domain> -- <command>" }),
141
+ JSON.stringify({
142
+ ok: false,
143
+ reason: "usage",
144
+ message: "Usage: spinuptui ssh-exec <domain> [--server <name>] -- <command>",
145
+ }),
81
146
  )
82
147
  process.exit(1)
83
148
  }
84
149
  const cfg = loadConfig()
85
150
  const client = new SpinupWPClient(cfg)
86
- const result = await execSshCommand(domain, remoteCmd, client, cfg)
151
+ const result = await execSshCommand(domain, remoteCmd, client, cfg, { server })
87
152
  console.log(JSON.stringify(result))
88
153
  process.exit(result.ok ? 0 : 1)
89
154
  }
@@ -111,6 +176,64 @@ if (command === "incidents") {
111
176
  process.exit(result.ok ? 0 : 1)
112
177
  }
113
178
 
179
+ if (command === "pull") {
180
+ const { positionals: pos, flags } = parseFlags(args.slice(1))
181
+ const json = flags.has("--json")
182
+ const yes = flags.has("--yes")
183
+ const url = flags.get("--url") ?? null
184
+ const server = flags.get("--server") ?? null
185
+ const sub = pos[0]
186
+ const domain = pos[1]
187
+ const destPath = pos[2] ?? null
188
+
189
+ const usage = (message: string) => {
190
+ console.error(JSON.stringify({ ok: false, reason: "usage", message }))
191
+ process.exit(1)
192
+ }
193
+
194
+ if (sub !== "files" && sub !== "db") {
195
+ usage("Usage: spinuptui pull files <domain> [path] [--url <local url>] | spinuptui pull db <domain> [--url <local url>] --yes")
196
+ }
197
+ if (!domain) usage(`Usage: spinuptui pull ${sub} <domain>`)
198
+ if (badFlagValue(flags, "--url")) usage("--url needs a value, e.g. --url https://example.test")
199
+ if (badFlagValue(flags, "--server")) usage("--server needs a value, e.g. --server web1.example.com")
200
+ if (sub === "db" && destPath) usage("`pull db` imports into the already-linked copy and takes no path. Use --url to set the local URL.")
201
+
202
+ const cfg = loadConfig()
203
+ const client = new SpinupWPClient(cfg)
204
+ // Progress is a human channel on stderr in both modes: it keeps a long rsync
205
+ // from looking hung, and leaves stdout as the machine contract.
206
+ const onStage = (line: string) => process.stderr.write(`\u2192 ${line}\n`)
207
+
208
+ const result =
209
+ sub === "files"
210
+ ? await runPullFiles(domain!, { path: destPath, url, server }, client, cfg, onStage)
211
+ : await runPullDb(domain!, { url, yes, server }, client, cfg, onStage)
212
+
213
+ if (json) {
214
+ console.log(JSON.stringify(result))
215
+ } else if (result.ok) {
216
+ if (result.command === "pull files") {
217
+ const built = result.ranComposer ? ", composer install ran" : ""
218
+ console.log(`Pulled ${result.primaryDomain} to ${result.path} (${result.method}${built}) and linked it.`)
219
+ console.log(
220
+ result.localUrl
221
+ ? `Next: spinuptui pull db ${result.primaryDomain} --yes`
222
+ : `Next: spinuptui pull db ${result.primaryDomain} --url <local url> --yes`,
223
+ )
224
+ } else {
225
+ console.log(`Imported production's database for ${result.primaryDomain} into ${result.path}.`)
226
+ console.log(`Local database backed up first to ${result.localBackupPath}`)
227
+ }
228
+ for (const warning of result.warnings ?? []) console.error(`Warning: ${warning}`)
229
+ } else {
230
+ console.error(result.message)
231
+ for (const c of result.candidates ?? []) console.error(` · ${c.server} (site ${c.siteId})`)
232
+ if (result.remedy) console.error(result.remedy)
233
+ }
234
+ process.exit(result.ok ? 0 : 1)
235
+ }
236
+
114
237
  // `spinuptui login` forces the onboarding wizard even when a token already exists,
115
238
  // so the token can be (re)saved to the config file for global use.
116
239
  const forceLogin = command === "login"
@@ -11,6 +11,7 @@ import { join, sep } from "node:path"
11
11
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
12
12
  import { configDir } from "../config.ts"
13
13
  import { REPO_SLUG } from "../version.ts"
14
+ import { spawn } from "./spawn.ts"
14
15
 
15
16
  const CACHE_VERSION = 1
16
17
  const TTL_MS = 6 * 60 * 60 * 1000 // 6 hours
@@ -161,7 +162,7 @@ function checkoutRoot(): string {
161
162
  }
162
163
 
163
164
  async function git(cwd: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
164
- const proc = Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "pipe", stdin: "ignore" })
165
+ const proc = spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "pipe", stdin: "ignore" })
165
166
  const [stdout, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
166
167
  return { code, stdout: stdout.trim(), stderr: stderr.trim() }
167
168
  }