spinuptui 0.24.4 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,115 @@ versions; such changes are called out here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.26.0] - 2026-08-26
15
+
16
+ ### Added
17
+ - **Clone a site's full local working copy from production** — `L` on a site
18
+ with no local copy yet now opens a choose screen: `e` is the existing manual
19
+ "enter a path" form, and the new `c` runs a guided clone. It pulls the code
20
+ down (a `git clone` for git-deployed sites, an `rsync` over SSH for everything
21
+ else), runs `composer install` when the checkout turns out to be
22
+ Bedrock/Radicle, links the result exactly as the manual form would, and then
23
+ hands off to the existing DB sync (`p`) and production-media (`m`) flows
24
+ unchanged — three already-reviewed screens chained by keypresses rather than
25
+ one new mega-flow. The local database and webserver config are never touched.
26
+ - The rsync pull always takes the site's whole files root (so a `public/`-style
27
+ layout brings `wp-config.php` down from one level above the webroot), and the
28
+ real webroot is **detected** on the server rather than trusting
29
+ `public_folder`.
30
+ - `wp-content/uploads` is excluded — production media is served through the
31
+ media-fallback mu-plugin (`m`) instead of copied.
32
+ - `object-cache.php` and `advanced-cache.php` are excluded outright: SpinupWP's
33
+ Redis drop-in doesn't no-op when Redis is unreachable, it 500s on a cache
34
+ miss, so copying them down fatals the local site.
35
+ - The done screen flags that the copied `wp-config.php` is **production's**,
36
+ credentials included, on the file-pull path. Nothing local can reach the
37
+ production database through them (its `DB_HOST` is localhost), but local
38
+ wp-cli authenticates as the production DB user, so the DB pull offered on
39
+ the same screen is denied until they're pointed at a local database. A
40
+ Bedrock/Radicle clone gets the mirror-image note: `.env` holds its database
41
+ credentials and isn't in the repo, so a fresh checkout has none.
42
+ - **`spinuptui pull files` and `spinuptui pull db`** — the local-working-copy
43
+ setup and DB sync, driven non-interactively from the command line, on the
44
+ same engines the TUI's guided flow uses. `pull files <domain> [path]` clones a
45
+ site's code down and links it; `pull db <domain> --yes` imports production's
46
+ database into that copy. Built for an agentic workflow ("pull site X down for
47
+ local dev") as much as for a person: progress goes to stderr, and stdout
48
+ carries the machine contract — with `--json`, a single result object, exit
49
+ code mirroring `ok`, and failures carrying both a stable `reason` code and a
50
+ `remedy` naming the exact command that fixes it.
51
+ - **`pull db` is gated twice**, since it overwrites the local database:
52
+ `"localSync": true` in the config (the same opt-in the TUI enforces) *and*
53
+ `--yes` on the invocation. Both are checked before any network call, so a
54
+ typo'd domain in a script bounces off the gate rather than off a lookup.
55
+ - **`pull files` never re-syncs over an existing copy** — a site that's
56
+ already linked is refused with its current path, rather than clobbering
57
+ local work.
58
+ - **`pull files` warns that the copied `wp-config.php` is production's**, and
59
+ a `pull db` that's denied by local MySQL says why. The rsync path brings
60
+ down the site's real `wp-config.php`, credentials and all — harmless (their
61
+ `DB_HOST` is localhost, so nothing local can reach production's database
62
+ through them) but it means local wp-cli authenticates as the *production*
63
+ DB user, and `pull db` fails on its very first step until they're pointed
64
+ at a local database.
65
+ - **`--server <name>` picks one site when a domain exists on more than one
66
+ server**, and the ambiguity itself now lists what matched (in both output
67
+ modes) instead of only saying there was more than one.
68
+ - **A Bedrock/Radicle clone is flagged for its missing `.env`** — that's where
69
+ its database credentials live and it's deliberately not in the repo, so
70
+ `pull db` would otherwise die on `mysqldump: unknown variable 'pass='`.
71
+ It's now caught up front, by name.
72
+ - The path argument is optional only when exactly one `localRoots` entry is
73
+ configured. With several, it's required: which root a site belongs in isn't
74
+ knowable from here, and guessing would file a standard-WP site under
75
+ Bedrock.
76
+ - **`--server <name>` on `ssh` and `ssh-exec` too**, not just the `pull`
77
+ commands. A domain that exists on more than one server — a clone-wizard
78
+ destination, a staging twin — could previously be reported as ambiguous by
79
+ these commands but never resolved, leaving it simply unreachable from the
80
+ CLI. The ambiguity now also lists the candidates and names the flag that
81
+ picks one, everywhere it's raised.
82
+
83
+ ### Changed
84
+ - **Search now shows a full-width Site/Server Control drawer**, matching the
85
+ Servers tab, instead of swapping the right-hand panel between Details and
86
+ Actions. The Details pane stays put while you browse, and the drawer below
87
+ always reflects the currently selected result — reusing the exact same
88
+ control-list component the Servers tab uses, so the two views can't drift
89
+ out of sync again.
90
+
91
+ ### Fixed
92
+ - **`K` (Grant SSH key) and `V` (create/resume vanity site) now work on server
93
+ results in Search**, not just site results. Both were already advertised in
94
+ the legend but silently did nothing on a server before this — `K` falls back
95
+ to the server's first key-eligible site as the anchor, matching the Servers
96
+ tab's behavior.
97
+ - **A failed local-database backup no longer leaves a fake backup behind.** The
98
+ DB pull (`p`, and the same engine everywhere else) starts by dumping the
99
+ local database as a safety net; `wp db export` creates its output file before
100
+ it knows the dump will succeed, and the follow-up `gzip` never runs when it
101
+ doesn't — leaving a bare `.sql` in `sql/`, named exactly like a real backup.
102
+ Empty when the failure is instant (bad credentials), **partial** when the
103
+ dump dies midway, which is the case that matters: it reads as a safety net
104
+ and isn't one. Nothing was lost by removing it — the pull aborts at that step,
105
+ before the local database is touched at all.
106
+
107
+ ## [0.25.0] - 2026-08-12
108
+
109
+ ### Added
110
+ - **Server actions now surfaces a pending SpinupWP platform upgrade**, not just
111
+ a silent badge. The overlay (`a`) shows a banner explaining the API only
112
+ exposes it as a flag with no actionable detail, and `w` jumps straight to the
113
+ server's page in the SpinupWP web app to run it.
114
+ - **App version on the splash screen**, shown below the tagline while the first
115
+ batch of data loads.
116
+
117
+ ### Fixed
118
+ - **The reboot-pending detail line now shows the real reason when the SSH probe
119
+ fails**, instead of a generic "couldn't read detail over SSH" message — e.g.
120
+ an SSH auth failure now shows the actual error. Both this and the new upgrade
121
+ banner wrap across lines instead of overflowing the panel on longer text.
122
+
14
123
  ## [0.24.4] - 2026-08-06
15
124
 
16
125
  ### Fixed
@@ -1299,7 +1408,9 @@ Initial tagged release.
1299
1408
  ### Notes
1300
1409
  - Read-only release: works with a SpinupWP **Read Only** API token.
1301
1410
 
1302
- [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.24.4...HEAD
1411
+ [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.26.0...HEAD
1412
+ [0.26.0]: https://github.com/mwender/spinupwp-tui/compare/v0.25.0...v0.26.0
1413
+ [0.25.0]: https://github.com/mwender/spinupwp-tui/compare/v0.24.4...v0.25.0
1303
1414
  [0.24.4]: https://github.com/mwender/spinupwp-tui/compare/v0.24.3...v0.24.4
1304
1415
  [0.24.3]: https://github.com/mwender/spinupwp-tui/compare/v0.24.2...v0.24.3
1305
1416
  [0.24.2]: https://github.com/mwender/spinupwp-tui/compare/v0.24.1...v0.24.2
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 |
@@ -320,8 +373,10 @@ Full details: [docs/php-upgrade.md](docs/php-upgrade.md).
320
373
  ## Server actions
321
374
 
322
375
  `a` on a server reboots it or restarts a service (Nginx/PHP-FPM/MySQL/Redis),
323
- and shows *why* a reboot is pending (the actual OS packages, read over SSH).
324
- Needs a Read/Write token. Full details: [docs/server-actions.md](docs/server-actions.md).
376
+ shows *why* a reboot is pending (the actual OS packages, read over SSH), and for
377
+ a pending SpinupWP platform upgrade — which the API can't act on — offers `w` to
378
+ jump straight to it in the SpinupWP web app. Needs a Read/Write token. Full
379
+ details: [docs/server-actions.md](docs/server-actions.md).
325
380
 
326
381
  ## Site monitoring
327
382
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "0.24.4",
3
+ "version": "0.26.0",
4
4
  "description": "SpinupTUI — a terminal dashboard for browsing, monitoring, and managing your SpinupWP servers and sites.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/index.tsx CHANGED
@@ -16,11 +16,44 @@ import { SpinupWPClient } from "./api/client.ts"
16
16
  import { resolveSshAccess } from "./lib/cliSsh.ts"
17
17
  import { resolveIncidents } from "./lib/cliIncidents.ts"
18
18
  import { execSshCommand } from "./lib/sshExec.ts"
19
+ import { runPullFiles, runPullDb } from "./lib/cliPull.ts"
19
20
 
20
21
  const args = process.argv.slice(2)
21
22
  const positionals = args.filter((a) => !a.startsWith("-"))
22
23
  const command = positionals[0]
23
24
 
25
+ // Flags that take a following value. Listed so that value can be skipped when
26
+ // collecting positionals — otherwise `--server web1 <domain>` reads "web1" as
27
+ // the domain, and a URL (which doesn't look like a flag) reads as a path.
28
+ const VALUE_FLAGS = new Set(["--url", "--server"])
29
+
30
+ function parseFlags(list: string[]): { positionals: string[]; flags: Map<string, string> } {
31
+ const out: string[] = []
32
+ const flags = new Map<string, string>()
33
+ for (let i = 0; i < list.length; i++) {
34
+ const a = list[i]!
35
+ if (a.startsWith("-")) {
36
+ if (VALUE_FLAGS.has(a)) {
37
+ flags.set(a, list[i + 1] ?? "")
38
+ i++
39
+ } else {
40
+ flags.set(a, "")
41
+ }
42
+ continue
43
+ }
44
+ out.push(a)
45
+ }
46
+ return { positionals: out, flags }
47
+ }
48
+
49
+ // A value-taking flag with nothing usable after it is a mistake worth naming,
50
+ // not a silently-empty value.
51
+ function badFlagValue(flags: Map<string, string>, name: string): boolean {
52
+ if (!flags.has(name)) return false
53
+ const v = flags.get(name)!
54
+ return !v || v.startsWith("-")
55
+ }
56
+
24
57
  if (args.includes("-h") || args.includes("--help") || command === "help") {
25
58
  const cfg = loadConfig()
26
59
  console.log(`SpinupTUI v${pkg.version} — terminal dashboard for your SpinupWP account
@@ -29,14 +62,31 @@ Usage:
29
62
  spinuptui Launch the dashboard
30
63
  spinuptui login Set or update your saved API token
31
64
  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
65
+ spinuptui ssh <domain> [--server <name>] Print SSH access info for a site
66
+ (JSON)
67
+ spinuptui ssh-exec <domain> [--server <name>] -- <command> Run a read-only
68
+ command over SSH (JSON); denies anything that looks
69
+ like a remote write
35
70
  spinuptui incidents <domain> | --all [--hours N] Print Uptime Kuma
36
71
  down/up incidents for a site or the whole fleet (JSON)
72
+ spinuptui pull files <domain> [path] [--url <local url>] [--server <name>]
73
+ Clone a site's code to a new local working copy and link
74
+ it. Read-only on production; refuses a non-empty path.
75
+ With exactly one localRoots entry configured, the path
76
+ defaults to <that root>/<domain>.
77
+ spinuptui pull db <domain> [--url <local url>] [--server <name>] --yes
78
+ Import production's database into the linked local copy,
79
+ rewriting URLs. OVERWRITES your local database: needs
80
+ both --yes and "localSync": true in the config.
37
81
  spinuptui --version Print the version
38
82
  spinuptui --help Show this help
39
83
 
84
+ --server picks one site when a domain exists on more than one server (the
85
+ ambiguity message lists them); it works on ssh, ssh-exec and both pull
86
+ commands. Add --json to either pull command for a single
87
+ machine-readable result object on stdout; progress always goes to stderr, so a
88
+ long pull never looks hung.
89
+
40
90
  Token resolution: SPINUPWP_ACCESS_TOKEN (env / .env) first, then the config
41
91
  file. Run \`spinuptui login\` once to save a token so \`spinuptui\` works from
42
92
  anywhere.
@@ -59,31 +109,43 @@ if (command === "where") {
59
109
  }
60
110
 
61
111
  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>" }))
112
+ const parsed = parseFlags(args.slice(1))
113
+ const domain = parsed.positionals[0]
114
+ const server = parsed.flags.get("--server") ?? null
115
+ if (!domain || badFlagValue(parsed.flags, "--server")) {
116
+ console.error(
117
+ JSON.stringify({ ok: false, reason: "usage", message: "Usage: spinuptui ssh <domain> [--server <name>]" }),
118
+ )
65
119
  process.exit(1)
66
120
  }
67
121
  const cfg = loadConfig()
68
122
  const client = new SpinupWPClient(cfg)
69
- const result = await resolveSshAccess(domain, client, cfg)
123
+ const result = await resolveSshAccess(domain, client, cfg, { server })
70
124
  console.log(JSON.stringify(result))
71
125
  process.exit(result.ok ? 0 : 1)
72
126
  }
73
127
 
74
128
  if (command === "ssh-exec") {
75
129
  const dashIdx = args.indexOf("--")
76
- const domain = positionals[1]
130
+ // Only the arguments BEFORE `--` are ours; everything after is the remote
131
+ // command, which may legitimately contain things that look like our flags.
132
+ const parsed = parseFlags(dashIdx === -1 ? args.slice(1) : args.slice(1, dashIdx))
133
+ const domain = parsed.positionals[0]
134
+ const server = parsed.flags.get("--server") ?? null
77
135
  const remoteCmd = dashIdx !== -1 ? args.slice(dashIdx + 1).join(" ") : ""
78
- if (!domain || dashIdx === -1 || !remoteCmd.trim()) {
136
+ if (!domain || dashIdx === -1 || !remoteCmd.trim() || badFlagValue(parsed.flags, "--server")) {
79
137
  console.error(
80
- JSON.stringify({ ok: false, reason: "usage", message: "Usage: spinuptui ssh-exec <domain> -- <command>" }),
138
+ JSON.stringify({
139
+ ok: false,
140
+ reason: "usage",
141
+ message: "Usage: spinuptui ssh-exec <domain> [--server <name>] -- <command>",
142
+ }),
81
143
  )
82
144
  process.exit(1)
83
145
  }
84
146
  const cfg = loadConfig()
85
147
  const client = new SpinupWPClient(cfg)
86
- const result = await execSshCommand(domain, remoteCmd, client, cfg)
148
+ const result = await execSshCommand(domain, remoteCmd, client, cfg, { server })
87
149
  console.log(JSON.stringify(result))
88
150
  process.exit(result.ok ? 0 : 1)
89
151
  }
@@ -111,6 +173,64 @@ if (command === "incidents") {
111
173
  process.exit(result.ok ? 0 : 1)
112
174
  }
113
175
 
176
+ if (command === "pull") {
177
+ const { positionals: pos, flags } = parseFlags(args.slice(1))
178
+ const json = flags.has("--json")
179
+ const yes = flags.has("--yes")
180
+ const url = flags.get("--url") ?? null
181
+ const server = flags.get("--server") ?? null
182
+ const sub = pos[0]
183
+ const domain = pos[1]
184
+ const destPath = pos[2] ?? null
185
+
186
+ const usage = (message: string) => {
187
+ console.error(JSON.stringify({ ok: false, reason: "usage", message }))
188
+ process.exit(1)
189
+ }
190
+
191
+ if (sub !== "files" && sub !== "db") {
192
+ usage("Usage: spinuptui pull files <domain> [path] [--url <local url>] | spinuptui pull db <domain> [--url <local url>] --yes")
193
+ }
194
+ if (!domain) usage(`Usage: spinuptui pull ${sub} <domain>`)
195
+ if (badFlagValue(flags, "--url")) usage("--url needs a value, e.g. --url https://example.test")
196
+ if (badFlagValue(flags, "--server")) usage("--server needs a value, e.g. --server web1.example.com")
197
+ if (sub === "db" && destPath) usage("`pull db` imports into the already-linked copy and takes no path. Use --url to set the local URL.")
198
+
199
+ const cfg = loadConfig()
200
+ const client = new SpinupWPClient(cfg)
201
+ // Progress is a human channel on stderr in both modes: it keeps a long rsync
202
+ // from looking hung, and leaves stdout as the machine contract.
203
+ const onStage = (line: string) => process.stderr.write(`\u2192 ${line}\n`)
204
+
205
+ const result =
206
+ sub === "files"
207
+ ? await runPullFiles(domain!, { path: destPath, url, server }, client, cfg, onStage)
208
+ : await runPullDb(domain!, { url, yes, server }, client, cfg, onStage)
209
+
210
+ if (json) {
211
+ console.log(JSON.stringify(result))
212
+ } else if (result.ok) {
213
+ if (result.command === "pull files") {
214
+ const built = result.ranComposer ? ", composer install ran" : ""
215
+ console.log(`Pulled ${result.primaryDomain} to ${result.path} (${result.method}${built}) and linked it.`)
216
+ console.log(
217
+ result.localUrl
218
+ ? `Next: spinuptui pull db ${result.primaryDomain} --yes`
219
+ : `Next: spinuptui pull db ${result.primaryDomain} --url <local url> --yes`,
220
+ )
221
+ } else {
222
+ console.log(`Imported production's database for ${result.primaryDomain} into ${result.path}.`)
223
+ console.log(`Local database backed up first to ${result.localBackupPath}`)
224
+ }
225
+ for (const warning of result.warnings ?? []) console.error(`Warning: ${warning}`)
226
+ } else {
227
+ console.error(result.message)
228
+ for (const c of result.candidates ?? []) console.error(` · ${c.server} (site ${c.siteId})`)
229
+ if (result.remedy) console.error(result.remedy)
230
+ }
231
+ process.exit(result.ok ? 0 : 1)
232
+ }
233
+
114
234
  // `spinuptui login` forces the onboarding wizard even when a token already exists,
115
235
  // so the token can be (re)saved to the config file for global use.
116
236
  const forceLogin = command === "login"