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
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.
|
|
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
|
-
- **
|
|
48
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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({
|
|
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"
|
package/src/lib/appUpdate.ts
CHANGED
|
@@ -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 =
|
|
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
|
}
|