spinuptui 0.22.2 → 0.24.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 +58 -1
- package/README.md +32 -26
- package/package.json +1 -1
- package/src/lib/dbSync.ts +7 -5
- package/src/lib/local.ts +10 -2
- package/src/lib/probe.ts +15 -1
- package/src/lib/serverClone.ts +6 -2
- package/src/lib/stack.ts +15 -2
- package/src/ui/App.tsx +6 -0
- package/src/ui/Details.tsx +14 -5
- package/src/ui/components.tsx +15 -5
- package/src/ui/store.tsx +18 -1
- package/src/ui/views/Browser.tsx +66 -11
- package/src/ui/views/EnableLocalSync.tsx +88 -0
- package/src/ui/views/Forgotten.tsx +1 -1
- package/src/ui/views/KumaSite.tsx +10 -4
- package/src/ui/views/Search.tsx +24 -6
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,60 @@ versions; such changes are called out here.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [0.24.0] - 2026-07-15
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **`p` (Pull prod. DB) is now discoverable even when `localSync` is off.**
|
|
18
|
+
Previously the Site Control legend hid the entry entirely until `localSync`
|
|
19
|
+
was enabled in config — the only one of `p`'s three preconditions
|
|
20
|
+
(WordPress, linked copy, localSync) that hid the entry instead of flashing
|
|
21
|
+
a message on press. Now `p` always shows for WordPress sites; pressing it
|
|
22
|
+
with `localSync` off opens an in-app "Enable local sync?" confirm instead.
|
|
23
|
+
Confirming just saves the setting — you press `p` again afterward to
|
|
24
|
+
actually pull, so the destructive action itself stays behind two
|
|
25
|
+
deliberate presses, same as before.
|
|
26
|
+
|
|
27
|
+
## [0.23.0] - 2026-07-15
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- **Radicle sites are now detected and labeled as their own stack**, instead of
|
|
31
|
+
being misclassified as Bedrock. Radicle (Roots' Bedrock+Sage+Acorn+Trellis
|
|
32
|
+
starter) shares Bedrock's `roots/bedrock-autoloader` dependency, which was
|
|
33
|
+
tripping a naive `roots/bedrock` string match; detection now keys off
|
|
34
|
+
`roots/acorn` instead — the package that actually makes a site Radicle.
|
|
35
|
+
Radicle only surfaces via the on-demand SSH probe (`f` in the Servers/Search
|
|
36
|
+
tabs, `d`/`D` in Stacks) — its `/public/` webroot alone is indistinguishable
|
|
37
|
+
from a hardened Standard-WP `/public/` layout. DB backup/sync, plugin/theme
|
|
38
|
+
inventory, and local working-copy linking already worked on Radicle sites
|
|
39
|
+
(their path detection was already stack-agnostic); this release just adds
|
|
40
|
+
the missing name. Clone-wizard support for Radicle is a separate, not-yet-
|
|
41
|
+
built follow-up.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
- A few README/docs mentions of `p` (plugins & themes) and `m` (monitoring)
|
|
45
|
+
that slipped through the previous release's key-unification work —
|
|
46
|
+
corrected to `e` and `M`, and scope notes broadened from "Search tab" to
|
|
47
|
+
"Servers / Search tabs" where applicable.
|
|
48
|
+
|
|
49
|
+
## [0.22.3] - 2026-07-15
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
- **Site Control keys now behave the same in the Servers tab and Search.** The
|
|
53
|
+
shared Site Control legend was written against Search's bindings, but the
|
|
54
|
+
Servers tab silently diverged on several of them:
|
|
55
|
+
- `p` pulls the production DB in both views (Servers tab had it opening the
|
|
56
|
+
plugins/themes inventory instead, despite the legend saying "Pull prod.
|
|
57
|
+
DB"); plugins/themes moves to `e` in both.
|
|
58
|
+
- `d` runs a DB backup in both views (Servers tab had it running the Tier-2
|
|
59
|
+
stack-identify probe instead); that probe moves to `f`, added to Search
|
|
60
|
+
too (previously Servers-tab-only and not listed anywhere).
|
|
61
|
+
- `m` runs the production media-fallback action in both views (Servers tab
|
|
62
|
+
had no such binding at all; `m` aliased to Monitoring there instead).
|
|
63
|
+
- `a` (reboot/restart) now works with a site focused in the Servers tab, not
|
|
64
|
+
just the top-level server list — it was a silent no-op in that context.
|
|
65
|
+
- `K` (grant SSH key) is now wired up in Search — it was in the legend but
|
|
66
|
+
had no handler there at all.
|
|
67
|
+
|
|
14
68
|
## [0.22.2] - 2026-07-13
|
|
15
69
|
|
|
16
70
|
### Fixed
|
|
@@ -1185,7 +1239,10 @@ Initial tagged release.
|
|
|
1185
1239
|
### Notes
|
|
1186
1240
|
- Read-only release: works with a SpinupWP **Read Only** API token.
|
|
1187
1241
|
|
|
1188
|
-
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.
|
|
1242
|
+
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.24.0...HEAD
|
|
1243
|
+
[0.24.0]: https://github.com/mwender/spinupwp-tui/compare/v0.23.0...v0.24.0
|
|
1244
|
+
[0.23.0]: https://github.com/mwender/spinupwp-tui/compare/v0.22.3...v0.23.0
|
|
1245
|
+
[0.22.3]: https://github.com/mwender/spinupwp-tui/compare/v0.22.2...v0.22.3
|
|
1189
1246
|
[0.22.2]: https://github.com/mwender/spinupwp-tui/compare/v0.22.1...v0.22.2
|
|
1190
1247
|
[0.22.1]: https://github.com/mwender/spinupwp-tui/compare/v0.22.0...v0.22.1
|
|
1191
1248
|
[0.22.0]: https://github.com/mwender/spinupwp-tui/compare/v0.21.2...v0.22.0
|
package/README.md
CHANGED
|
@@ -34,23 +34,23 @@
|
|
|
34
34
|
whatever's selected — Site Control or Server Control, grouped and always
|
|
35
35
|
visible, no hidden key combos.
|
|
36
36
|
- **Stack detection & fleet composition** — classifies every site as Standard WP,
|
|
37
|
-
Bedrock, or Non-WP, with a PHP-version breakdown and an on-demand SSH
|
|
38
|
-
precise identification. → [docs/stack-detection.md](docs/stack-detection.md)
|
|
37
|
+
Bedrock, Radicle, or Non-WP, with a PHP-version breakdown and an on-demand SSH
|
|
38
|
+
probe for precise identification. → [docs/stack-detection.md](docs/stack-detection.md)
|
|
39
39
|
- **Global search** — fuzzy search across every server and site by name, domain,
|
|
40
40
|
or IP, with inline actions on the result.
|
|
41
41
|
- **Events feed** — recent provisioning/operation activity with per-event detail.
|
|
42
42
|
- **Live server health** (`h`) — real-time CPU/load/memory/disk over SSH.
|
|
43
43
|
→ [docs/server-health.md](docs/server-health.md)
|
|
44
|
-
- **Installed plugins & themes** (`
|
|
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
47
|
- **Link local working copies** (`L`) — link a site to its local checkout, with
|
|
48
48
|
auto-discovery and git-drift indicators. → [docs/local-working-copies.md](docs/local-working-copies.md)
|
|
49
49
|
- **DNS migration lens** (`n` / `N`) — see and edit a site's hosting records,
|
|
50
50
|
repoint it to another server. → [docs/dns-access-editing.md](docs/dns-access-editing.md)
|
|
51
|
-
- **Database backup & sync** (`d` / `p`, Search
|
|
52
|
-
production DB into a linked local copy. → [docs/database-backup-sync.md](docs/database-backup-sync.md)
|
|
53
|
-
- **Production media fallback** (`m`, Search
|
|
51
|
+
- **Database backup & sync** (`d` / `p`, Servers / Search tabs) — download or
|
|
52
|
+
pull a production DB into a linked local copy. → [docs/database-backup-sync.md](docs/database-backup-sync.md)
|
|
53
|
+
- **Production media fallback** (`m`, Servers / Search tabs) — serve missing-locally images
|
|
54
54
|
from production after a DB pull. → [docs/production-media-fallback.md](docs/production-media-fallback.md)
|
|
55
55
|
- **Upgrade a site's PHP version** (`u`) — pick a version, apply it, watch the
|
|
56
56
|
event complete. → [docs/php-upgrade.md](docs/php-upgrade.md)
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
[how it works](docs/clone-wizard-explained.md)
|
|
68
68
|
- **Privileged writes over SSH** (`S` / `K`) — grant or revoke SSH keys directly,
|
|
69
69
|
since the API can't. → [docs/privileged-ssh-writes.md](docs/privileged-ssh-writes.md)
|
|
70
|
-
- **Site & server monitoring** (`
|
|
70
|
+
- **Site & server monitoring** (`M`) — a two-pane browser of a site's Uptime
|
|
71
71
|
Kuma monitors (health, front-page fingerprint, an opt-in cache-bypass
|
|
72
72
|
check, and, for vanity/server sites, load/Redis/PHP-fatal sentinels), each
|
|
73
73
|
with a live status dot, an in-context explanation, and one action key to
|
|
@@ -220,6 +220,10 @@ These can be set in `config.json` or via an environment variable:
|
|
|
220
220
|
and stack probes (see "Server health" below).
|
|
221
221
|
- **`localSync`** / `SPINUPWP_LOCAL_SYNC` — opt-in for the **Pull production →
|
|
222
222
|
local** DB sync (`p`); off by default because it overwrites your local database.
|
|
223
|
+
You don't need to set this by hand: pressing `p` on a WordPress site while
|
|
224
|
+
it's off opens an in-app "Enable local sync?" prompt that flips and saves it
|
|
225
|
+
for you — press `p` again afterward to actually pull. Setting it directly is
|
|
226
|
+
still supported for scripted/first-run setups.
|
|
223
227
|
**Prefer `"localSync": true` in `config.json`** — it's read from a fixed path
|
|
224
228
|
(`~/.config/spinupwp-tui/config.json`) so it applies wherever you launch `spinuptui`
|
|
225
229
|
from. The environment variable works too, but note a `.env` is only loaded when
|
|
@@ -247,30 +251,31 @@ These can be set in `config.json` or via an environment variable:
|
|
|
247
251
|
| `g` / `G` | Jump to top / bottom |
|
|
248
252
|
| `o` | Open the selected site in your browser |
|
|
249
253
|
| `s` | Open a terminal and SSH into the selected site |
|
|
250
|
-
| `d` | Download a production DB backup into the linked copy (Search; WordPress + linked) |
|
|
251
|
-
| `p` | Pull the production DB into the linked copy — overwrites local; opt-in via `localSync` (Search) |
|
|
252
|
-
| `m` | Production media fallback: serve missing-locally images from production (Search; WordPress + linked) |
|
|
254
|
+
| `d` | Download a production DB backup into the linked copy (Servers / Search; WordPress + linked) |
|
|
255
|
+
| `p` | Pull the production DB into the linked copy — overwrites local; opt-in via `localSync` (Servers / Search) |
|
|
256
|
+
| `m` | Production media fallback: serve missing-locally images from production (Servers / Search; WordPress + linked) |
|
|
253
257
|
| `L` | Link / edit a site's local working copy |
|
|
254
258
|
| `t` / `v` | Open the linked copy in a terminal / its local URL in your browser |
|
|
255
259
|
| `n` | DNS migration view for a site — its records + TTLs (`⏎` edits a TTL; `p` repoints the record; `a` shows the whole server) |
|
|
256
260
|
| `N` | DNS migration view for the whole server |
|
|
257
261
|
| `h` | Live server health (CPU/mem/disk over SSH) |
|
|
258
|
-
| `
|
|
259
|
-
| `
|
|
260
|
-
| `
|
|
262
|
+
| `e` | List a site's installed plugins & themes over SSH — version + updates (Servers / Search) |
|
|
263
|
+
| `f` | Identify a site's stack via SSH (Servers / Search) — same action as Stacks' `d`/`D` below |
|
|
264
|
+
| `d` | Identify a site's stack via SSH (Stacks tab only — `d` is DB backup in Servers/Search, above) |
|
|
265
|
+
| `D` | Identify every site in the selected stack (Stacks tab) |
|
|
261
266
|
| `S` | Auto-discover & batch-link local copies (Stacks tab) |
|
|
262
|
-
| `f` | Report sites with no usable local copy (Stacks tab) |
|
|
267
|
+
| `f` | Report sites with no usable local copy (Stacks tab only — `f` identifies a site's stack in Servers/Search, above) |
|
|
263
268
|
| `u` | Upgrade a site's PHP version (Servers / Stacks / Search; needs a Read/Write token) |
|
|
264
269
|
| `H` | Enable / disable HTTPS on a site (Servers / Stacks / Search; needs a Read/Write token) |
|
|
265
270
|
| `P` | Purge a site's page cache + object cache (Servers / Stacks / Search; needs a Read/Write token) |
|
|
266
|
-
| `
|
|
267
|
-
| `R` | Refresh a vanity page's HTML to the currently bundled version — no need to open `
|
|
271
|
+
| `M` | Site/server monitoring — a two-pane browser of this site's Uptime Kuma monitors (capital, since lowercase `m` is media fallback in Servers/Search). Inside: `↑`/`↓` selects a monitor, `a` registers/recalibrates/repairs it (Front page and Cache bypass open a check-window picker), `x` removes Front page or Cache bypass, `o` opens the selected monitor in Kuma, `d` runs the site doctor, `n` shows/edits alert wiring, and vanity sites add `R`/`r` for page refresh/secret rotation (Servers / Stacks / Search) |
|
|
272
|
+
| `R` | Refresh a vanity page's HTML to the currently bundled version — no need to open `M` first. Shown under Vanity in the Servers tab's Control strip and Search's Actions list, for the one site per server whose domain is the server's own hostname |
|
|
268
273
|
| `a` | Server actions: reboot / restart a service (Servers / Search; needs a Read/Write token) |
|
|
269
274
|
| `c` | Create a new server (Servers tab; needs a Read/Write token) |
|
|
270
275
|
| `V` | Add a vanity site at the server's own hostname — DNS + site + HTTPS + SSH-key handoff (Servers tab; offered when no hostname site exists; needs a Read/Write token) |
|
|
271
276
|
| `C` | Clone a server's sites to a new/existing destination (Servers tab; needs a Read/Write token + sudo) |
|
|
272
277
|
| `S` | Connect sudo on a server for privileged writes — optionally remembered in the macOS Keychain (Servers tab) |
|
|
273
|
-
| `K` | Grant / revoke an SSH key on a site, or every site on the server (needs sudo connected) |
|
|
278
|
+
| `K` | Grant / revoke an SSH key on a site, or every site on the server (Servers / Search; needs sudo connected) |
|
|
274
279
|
| `w` | Open the selected server/site in the SpinupWP web app |
|
|
275
280
|
| `/` | Jump to global search |
|
|
276
281
|
| `r` | Refresh data from the API |
|
|
@@ -291,14 +296,15 @@ processes — read-only, using your local SSH keys. Full details:
|
|
|
291
296
|
|
|
292
297
|
## Stack detection
|
|
293
298
|
|
|
294
|
-
The **Stacks** tab (`3`) classifies every site as Standard WP, Bedrock,
|
|
295
|
-
from API data alone
|
|
296
|
-
|
|
297
|
-
|
|
299
|
+
The **Stacks** tab (`3`) classifies every site as Standard WP, Bedrock, Radicle,
|
|
300
|
+
or Non-WP from API data alone (Radicle needs a probe to surface — see below),
|
|
301
|
+
then lets you SSH-probe (`d` / `D`) for a precise ID (WHMCS, Laravel, Static
|
|
302
|
+
HTML, WordPress version) that overrides a mislabeled guess. Full details:
|
|
303
|
+
[docs/stack-detection.md](docs/stack-detection.md).
|
|
298
304
|
|
|
299
305
|
## Installed plugins & themes
|
|
300
306
|
|
|
301
|
-
`
|
|
307
|
+
`e` on a site (Servers / Search tabs) lists its real `wp plugin`/`theme list` over SSH —
|
|
302
308
|
status, version, and available updates — the detail the API only gives as bare
|
|
303
309
|
counts. Read-only, works on Bedrock and misclassified sites too. Full details:
|
|
304
310
|
[docs/plugins-themes.md](docs/plugins-themes.md).
|
|
@@ -358,10 +364,10 @@ Stacks tab can auto-discover copies (`S`) and report sites still missing one
|
|
|
358
364
|
|
|
359
365
|
## Database backup & sync
|
|
360
366
|
|
|
361
|
-
On a linked WordPress site (Search tab), `d` downloads a gzipped
|
|
362
|
-
backup into `sql/`; `p` (opt-in via `localSync`) pulls production
|
|
363
|
-
**local** database, backing it up first and rewriting URLs. Works
|
|
364
|
-
Standard WP and
|
|
367
|
+
On a linked WordPress site (Servers or Search tab), `d` downloads a gzipped
|
|
368
|
+
production DB backup into `sql/`; `p` (opt-in via `localSync`) pulls production
|
|
369
|
+
into your **local** database, backing it up first and rewriting URLs. Works
|
|
370
|
+
with Standard WP, Bedrock, and Radicle. Full details:
|
|
365
371
|
[docs/database-backup-sync.md](docs/database-backup-sync.md).
|
|
366
372
|
|
|
367
373
|
## Production media fallback
|
package/package.json
CHANGED
package/src/lib/dbSync.ts
CHANGED
|
@@ -213,11 +213,13 @@ export async function runDbSync(plan: DbSyncPlan, domain: string, onProgress: (p
|
|
|
213
213
|
const lb = await wp(`wp db export "${localSql}" >/dev/null && gzip -f "${localSql}"`, 300_000)
|
|
214
214
|
if (lb.code !== 0) {
|
|
215
215
|
// wp-cli's own "not a WordPress install" error is a common first-pull snag on a
|
|
216
|
-
// fresh checkout — a Bedrock project needs `composer install` to
|
|
217
|
-
// web/wp before wp-cli (or wp-cli.yml) can find
|
|
218
|
-
// after meaningfulError's own pick (not passed
|
|
219
|
-
// only used when stderr has NO meaningful line
|
|
220
|
-
|
|
216
|
+
// fresh checkout — a Bedrock or Radicle project needs `composer install` to
|
|
217
|
+
// build vendor/ + web/wp (or public/wp) before wp-cli (or wp-cli.yml) can find
|
|
218
|
+
// anything there at all. Appended after meaningfulError's own pick (not passed
|
|
219
|
+
// as its fallback), since that's only used when stderr has NO meaningful line
|
|
220
|
+
// — not the case here.
|
|
221
|
+
const needsComposer = plan.localKind === "bedrock" || plan.localKind === "radicle"
|
|
222
|
+
const hint = needsComposer && /does not seem to be a WordPress install/i.test(lb.stderr) ? " Run `composer install` in your local checkout first." : ""
|
|
221
223
|
return fail(`Local DB backup failed — ${meaningfulError(lb.stderr, "couldn't export the local database.")}${hint}`, "local-backup")
|
|
222
224
|
}
|
|
223
225
|
|
package/src/lib/local.ts
CHANGED
|
@@ -34,7 +34,7 @@ export function normalizeLink(raw: LocalLink & { valetUrl?: string }): LocalLink
|
|
|
34
34
|
|
|
35
35
|
// Resolution of a link against the filesystem, computed on demand (cheap — one
|
|
36
36
|
// focused site at a time). `kind` mirrors the stack buckets used elsewhere.
|
|
37
|
-
export type LocalKind = "bedrock" | "wp" | "unknown"
|
|
37
|
+
export type LocalKind = "bedrock" | "radicle" | "wp" | "unknown"
|
|
38
38
|
export interface LocalState {
|
|
39
39
|
exists: boolean
|
|
40
40
|
kind: LocalKind
|
|
@@ -82,6 +82,11 @@ export function findProjectRoot(dir: string): string {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
// Classify a local working copy by reading a few marker files (read-only):
|
|
85
|
+
// Radicle = composer.json requiring roots/acorn (checked first: Radicle's
|
|
86
|
+
// composer.json also requires roots/bedrock-autoloader, so a bare
|
|
87
|
+
// "roots/bedrock" substring match below would misclassify it as Bedrock —
|
|
88
|
+
// roots/acorn is the package that actually makes a site Radicle, since
|
|
89
|
+
// plain Bedrock never requires it)
|
|
85
90
|
// Bedrock = composer.json requiring roots/bedrock, or config/application.php
|
|
86
91
|
// WordPress = wp-config.php at the root (or a public/ webroot)
|
|
87
92
|
// Anything that exists but matches neither is "unknown".
|
|
@@ -89,7 +94,9 @@ function classify(dir: string): LocalKind {
|
|
|
89
94
|
const composer = join(dir, "composer.json")
|
|
90
95
|
if (existsSync(composer)) {
|
|
91
96
|
try {
|
|
92
|
-
|
|
97
|
+
const contents = readFileSync(composer, "utf8")
|
|
98
|
+
if (contents.includes("roots/acorn")) return "radicle"
|
|
99
|
+
if (contents.includes("roots/bedrock")) return "bedrock"
|
|
93
100
|
} catch {
|
|
94
101
|
// unreadable composer.json — fall through to other markers
|
|
95
102
|
}
|
|
@@ -101,6 +108,7 @@ function classify(dir: string): LocalKind {
|
|
|
101
108
|
|
|
102
109
|
const KIND_LABEL: Record<LocalKind, string> = {
|
|
103
110
|
bedrock: "Bedrock",
|
|
111
|
+
radicle: "Radicle",
|
|
104
112
|
wp: "WordPress",
|
|
105
113
|
unknown: "unrecognized",
|
|
106
114
|
}
|
package/src/lib/probe.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type { Server, Site } from "../api/types.ts"
|
|
|
20
20
|
import { detectWpDirScript } from "./serverClone.ts"
|
|
21
21
|
import { theme } from "./theme.ts"
|
|
22
22
|
|
|
23
|
-
export type ProbeKind = "wordpress" | "bedrock" | "whmcs" | "laravel" | "static" | "unknown"
|
|
23
|
+
export type ProbeKind = "wordpress" | "bedrock" | "radicle" | "whmcs" | "laravel" | "static" | "unknown"
|
|
24
24
|
|
|
25
25
|
// `onSelection` brightens the colors that are too low-contrast on the focused
|
|
26
26
|
// (bright-green) selection background — green-on-green and the faint greys.
|
|
@@ -30,6 +30,8 @@ export function probeKindColor(kind: ProbeKind, onSelection = false): string {
|
|
|
30
30
|
return theme.accent
|
|
31
31
|
case "bedrock":
|
|
32
32
|
return onSelection ? theme.text : theme.good
|
|
33
|
+
case "radicle":
|
|
34
|
+
return onSelection ? theme.text : theme.purple
|
|
33
35
|
case "whmcs":
|
|
34
36
|
return theme.purple
|
|
35
37
|
case "laravel":
|
|
@@ -89,8 +91,10 @@ function buildRemoteScript(domain: string, publicFolder: string | null): string
|
|
|
89
91
|
// script (WHMCS/index/WP-version checks) intentionally keeps using as-is.
|
|
90
92
|
detectWpDirScript(root, publicFolder ?? undefined),
|
|
91
93
|
`BEDROCKROOT="$B"`,
|
|
94
|
+
`RADICLEROOT="$RD"`,
|
|
92
95
|
'F="$HOME/files"',
|
|
93
96
|
`W="$HOME/files${pf}"`,
|
|
97
|
+
'echo ===RADICLE; [ -n "$RADICLEROOT" ] && echo yes || echo no',
|
|
94
98
|
'echo ===BEDROCK; [ -n "$BEDROCKROOT" ] && echo yes || echo no',
|
|
95
99
|
'echo ===APPLICATION; test -f "$F/config/application.php" && echo yes || echo no',
|
|
96
100
|
'echo ===ARTISAN; test -f "$F/artisan" && echo yes || echo no',
|
|
@@ -170,6 +174,16 @@ export function classify(sig: Record<string, string>): ProbeResult {
|
|
|
170
174
|
if (sig.WHMCSCONF === "yes" && sig.WHMCSVENDOR === "yes") {
|
|
171
175
|
return { kind: "whmcs", app: "WHMCS", version: null, label: "WHMCS" }
|
|
172
176
|
}
|
|
177
|
+
// Checked before BEDROCK: Radicle's composer.json also requires
|
|
178
|
+
// roots/bedrock-autoloader, so it would otherwise match the Bedrock signal too.
|
|
179
|
+
if (sig.RADICLE === "yes") {
|
|
180
|
+
return {
|
|
181
|
+
kind: "radicle",
|
|
182
|
+
app: "Radicle",
|
|
183
|
+
version: wpVersion,
|
|
184
|
+
label: wpVersion ? `Radicle · WP ${wpVersion}` : "Radicle",
|
|
185
|
+
}
|
|
186
|
+
}
|
|
173
187
|
if (sig.BEDROCK === "yes" || sig.APPLICATION === "yes") {
|
|
174
188
|
return {
|
|
175
189
|
kind: "bedrock",
|
package/src/lib/serverClone.ts
CHANGED
|
@@ -216,8 +216,12 @@ export function detectWpDirScript(root: string, publicFolder?: string): string {
|
|
|
216
216
|
`[ -z "$W" ] && [ -f ${shq(candidate)}/wp/wp-settings.php ] && W=${shq(candidate)}/wp`,
|
|
217
217
|
`[ -z "$W" ] && [ -f "$D/wp-settings.php" ] && W="$D"`,
|
|
218
218
|
`[ -z "$W" ] && { F=$(find "$D" -maxdepth 3 -name wp-settings.php -not -path "*/wp-content/*" -print -quit 2>/dev/null); [ -n "$F" ] && W=$(dirname "$F"); }`,
|
|
219
|
-
`B=""`,
|
|
220
|
-
|
|
219
|
+
`B=""; RD=""`,
|
|
220
|
+
// Radicle's composer.json also requires roots/bedrock-autoloader, so a bare
|
|
221
|
+
// "roots/bedrock" substring match (below) can't tell the two apart — check
|
|
222
|
+
// for roots/acorn first, the package that actually makes a site Radicle
|
|
223
|
+
// (plain Bedrock never requires it). $P is set by the same lookup either way.
|
|
224
|
+
`[ -n "$W" ] && [ "$(basename "$W")" = "wp" ] && { P=$(dirname "$(dirname "$W")"); [ -f "$P/composer.json" ] && { grep -q "roots/acorn" "$P/composer.json" 2>/dev/null && RD="$P"; grep -q "roots/bedrock" "$P/composer.json" 2>/dev/null && B="$P"; }; }`,
|
|
221
225
|
].join("; ")
|
|
222
226
|
}
|
|
223
227
|
|
package/src/lib/stack.ts
CHANGED
|
@@ -23,10 +23,10 @@ import type { ProbeKind } from "./probe.ts"
|
|
|
23
23
|
import { publicFolderRel } from "./serverClone.ts"
|
|
24
24
|
import { theme } from "./theme.ts"
|
|
25
25
|
|
|
26
|
-
export type Stack = "Standard WP" | "Bedrock" | "Non-WP"
|
|
26
|
+
export type Stack = "Standard WP" | "Bedrock" | "Radicle" | "Non-WP"
|
|
27
27
|
|
|
28
28
|
// All buckets in display order (kept stable so the Stacks view doesn't reflow).
|
|
29
|
-
export const STACKS: Stack[] = ["Standard WP", "Bedrock", "Non-WP"]
|
|
29
|
+
export const STACKS: Stack[] = ["Standard WP", "Bedrock", "Radicle", "Non-WP"]
|
|
30
30
|
|
|
31
31
|
export function classifyStack(site: Site): Stack {
|
|
32
32
|
if (!site.is_wordpress) return "Non-WP"
|
|
@@ -46,6 +46,11 @@ export function effectiveStack(site: Site, probeKind?: ProbeKind | null): Stack
|
|
|
46
46
|
return "Standard WP"
|
|
47
47
|
case "bedrock":
|
|
48
48
|
return "Bedrock"
|
|
49
|
+
case "radicle":
|
|
50
|
+
// Radicle's public/ webroot is indistinguishable from a hardened
|
|
51
|
+
// Standard-WP public/ layout at Tier-1 (public_folder alone), so this
|
|
52
|
+
// bucket only exists once Tier-2 (SSH probe) confirms roots/acorn.
|
|
53
|
+
return "Radicle"
|
|
49
54
|
case "whmcs":
|
|
50
55
|
case "laravel":
|
|
51
56
|
case "static":
|
|
@@ -75,6 +80,10 @@ export function cloneStackFor(site: Site, probeKind?: ProbeKind | null): "wp" |
|
|
|
75
80
|
if (probeKind === "whmcs" || probeKind === "laravel" || probeKind === "static") return "files"
|
|
76
81
|
return "bedrock"
|
|
77
82
|
}
|
|
83
|
+
// A "radicle" probe never falls into "wp" here since effectiveStack maps it
|
|
84
|
+
// to "Radicle", not "Standard WP" — deliberate: the Standard-WP pull chain
|
|
85
|
+
// assumes a flat webroot and would corrupt a Radicle clone. Real Radicle
|
|
86
|
+
// clone-wizard support is a separate, not-yet-built phase (issue #38).
|
|
78
87
|
return effectiveStack(site, probeKind) === "Standard WP" ? "wp" : "files"
|
|
79
88
|
}
|
|
80
89
|
|
|
@@ -86,6 +95,8 @@ export function stackColor(stack: Stack, onSelection = false): string {
|
|
|
86
95
|
return theme.accent // blue
|
|
87
96
|
case "Bedrock":
|
|
88
97
|
return onSelection ? theme.text : theme.good // green
|
|
98
|
+
case "Radicle":
|
|
99
|
+
return onSelection ? theme.text : theme.purple
|
|
89
100
|
case "Non-WP":
|
|
90
101
|
return onSelection ? theme.text : theme.textDim // gray
|
|
91
102
|
}
|
|
@@ -98,6 +109,8 @@ export function stackTag(stack: Stack): string {
|
|
|
98
109
|
return "wp"
|
|
99
110
|
case "Bedrock":
|
|
100
111
|
return "bedrock"
|
|
112
|
+
case "Radicle":
|
|
113
|
+
return "radicle"
|
|
101
114
|
case "Non-WP":
|
|
102
115
|
return "app"
|
|
103
116
|
}
|
package/src/ui/App.tsx
CHANGED
|
@@ -23,6 +23,7 @@ import { GrantKey } from "./views/GrantKey.tsx"
|
|
|
23
23
|
import { SudoConnect } from "./views/SudoConnect.tsx"
|
|
24
24
|
import { DbBackup } from "./views/DbBackup.tsx"
|
|
25
25
|
import { DbSync } from "./views/DbSync.tsx"
|
|
26
|
+
import { EnableLocalSync } from "./views/EnableLocalSync.tsx"
|
|
26
27
|
import { MediaFallback } from "./views/MediaFallback.tsx"
|
|
27
28
|
import { ServerActions } from "./views/ServerActions.tsx"
|
|
28
29
|
import { NewServer } from "./views/NewServer.tsx"
|
|
@@ -67,6 +68,7 @@ export function App() {
|
|
|
67
68
|
store.sudoConnectServer !== null ||
|
|
68
69
|
store.dbBackupSite !== null ||
|
|
69
70
|
store.dbSyncSite !== null ||
|
|
71
|
+
store.enableLocalSyncSite !== null ||
|
|
70
72
|
store.mediaFallbackSite !== null ||
|
|
71
73
|
store.serverActionsServer !== null ||
|
|
72
74
|
store.newServerOpen ||
|
|
@@ -143,6 +145,9 @@ export function App() {
|
|
|
143
145
|
// The DB-sync overlay owns the keyboard while open.
|
|
144
146
|
if (store.dbSyncSite) return
|
|
145
147
|
|
|
148
|
+
// The "enable local sync?" confirm overlay owns the keyboard while open.
|
|
149
|
+
if (store.enableLocalSyncSite) return
|
|
150
|
+
|
|
146
151
|
// The media-fallback overlay owns the keyboard while open.
|
|
147
152
|
if (store.mediaFallbackSite) return
|
|
148
153
|
|
|
@@ -244,6 +249,7 @@ export function App() {
|
|
|
244
249
|
{store.sudoConnectServer && <SudoConnect />}
|
|
245
250
|
{store.dbBackupSite && <DbBackup />}
|
|
246
251
|
{store.dbSyncSite && <DbSync />}
|
|
252
|
+
{store.enableLocalSyncSite && <EnableLocalSync />}
|
|
247
253
|
{store.mediaFallbackSite && <MediaFallback />}
|
|
248
254
|
{store.serverActionsServer && <ServerActions />}
|
|
249
255
|
{store.newServerOpen && <NewServer />}
|
package/src/ui/Details.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { formatBytes, diskUsedPct, bar, formatDate, timeAgo, truncate } from "..
|
|
|
6
6
|
import { Field, StatusBadge, ControlPanel, siteGroups, type ActionGroup } from "./components.tsx"
|
|
7
7
|
import { effectiveStack, stackColor } from "../lib/stack.ts"
|
|
8
8
|
import { probeKindColor } from "../lib/probe.ts"
|
|
9
|
-
import { resolveLocalLink, type LocalLink } from "../lib/local.ts"
|
|
9
|
+
import { resolveLocalLink, type LocalLink, type LocalKind } from "../lib/local.ts"
|
|
10
10
|
import { normalizeDomain } from "../lib/dns.ts"
|
|
11
11
|
import { isVanityPair } from "../lib/vanitySite.ts"
|
|
12
12
|
import type { Drift } from "../lib/gitStatus.ts"
|
|
@@ -276,11 +276,11 @@ export function SiteDetail({ site, serverName }: { site: Site; serverName: strin
|
|
|
276
276
|
// Site Control groups — see ServerControl above. Recomputes stack/vanity
|
|
277
277
|
// itself since it's not a child of SiteDetail; both derivations are cheap.
|
|
278
278
|
export function SiteControl({ site, serverName, layout }: { site: Site; serverName: string; layout?: "list" | "flow" }) {
|
|
279
|
-
const { probes
|
|
279
|
+
const { probes } = useStore()
|
|
280
280
|
const stack = effectiveStack(site, probes.get(site.id)?.result.kind)
|
|
281
281
|
const isWordPress = stack !== "Non-WP"
|
|
282
282
|
const isVanity = isVanityPair(site.domain, serverName)
|
|
283
|
-
return <ControlPanel heading="Site Control" groups={siteGroups(isWordPress,
|
|
283
|
+
return <ControlPanel heading="Site Control" groups={siteGroups(isWordPress, isVanity)} layout={layout} />
|
|
284
284
|
}
|
|
285
285
|
|
|
286
286
|
// DNS zone-host lines for a site's domains, populated on demand (key `n`). Each
|
|
@@ -320,8 +320,17 @@ function SiteDnsSection({ site }: { site: Site }) {
|
|
|
320
320
|
}
|
|
321
321
|
|
|
322
322
|
// Color for a resolved local stack label.
|
|
323
|
-
function localLabelColor(kind:
|
|
324
|
-
|
|
323
|
+
function localLabelColor(kind: LocalKind): string {
|
|
324
|
+
switch (kind) {
|
|
325
|
+
case "bedrock":
|
|
326
|
+
return theme.good
|
|
327
|
+
case "radicle":
|
|
328
|
+
return theme.purple
|
|
329
|
+
case "wp":
|
|
330
|
+
return theme.accent
|
|
331
|
+
case "unknown":
|
|
332
|
+
return theme.warn
|
|
333
|
+
}
|
|
325
334
|
}
|
|
326
335
|
|
|
327
336
|
// Compact, self-explaining git-drift label for a linked copy's line, e.g.
|
package/src/ui/components.tsx
CHANGED
|
@@ -427,21 +427,31 @@ export function ControlPanel({ heading, groups, layout = "list" }: { heading: st
|
|
|
427
427
|
// since the group label is always visible right next to them (as its own column
|
|
428
428
|
// in layout="flow", or its own line in layout="list").
|
|
429
429
|
//
|
|
430
|
-
// The DB backup/sync use wp-cli, so they only
|
|
431
|
-
// for non-WP sites (their `d`/`p`
|
|
432
|
-
// (`n`) and the rest apply to
|
|
430
|
+
// The DB backup/sync and plugins/themes inventory all use wp-cli, so they only
|
|
431
|
+
// apply to WordPress sites — omitted for non-WP sites (their `d`/`p`/`e`
|
|
432
|
+
// keypresses are also guarded in the handler). DNS (`n`) and the rest apply to
|
|
433
|
+
// any site. `isVanity` (the site's domain equals its
|
|
433
434
|
// own server's name, per `isVanityPair`) adds the one-off page-refresh action —
|
|
434
435
|
// vanity pages seeded before this app existed need a way to pick up the current
|
|
435
436
|
// bundled HTML.
|
|
436
|
-
|
|
437
|
+
//
|
|
438
|
+
// `p` (Pull prod. DB) is always shown for WordPress sites, even when the
|
|
439
|
+
// localSync config flag is off — pressing it then opens a confirm overlay to
|
|
440
|
+
// enable the flag, rather than hiding the entry entirely. That matches how
|
|
441
|
+
// the OTHER two gates on this same action already behave (is_wordpress and
|
|
442
|
+
// "not linked" both flash on press instead of hiding the entry); localSync
|
|
443
|
+
// used to be the odd one out.
|
|
444
|
+
export function siteGroups(isWordpress: boolean, isVanity: boolean): ActionGroup[] {
|
|
437
445
|
const remote: [string, string][] = [
|
|
438
446
|
["s", "SSH"],
|
|
439
447
|
["K", "Grant SSH key"],
|
|
440
448
|
["n", "DNS"],
|
|
449
|
+
["f", "Identify app"],
|
|
441
450
|
]
|
|
442
451
|
if (isWordpress) {
|
|
443
452
|
remote.push(["d", "↓ DB backup"])
|
|
444
|
-
|
|
453
|
+
remote.push(["p", "Pull prod. DB"])
|
|
454
|
+
remote.push(["e", "Plugins & themes"])
|
|
445
455
|
}
|
|
446
456
|
const local: [string, string][] = [
|
|
447
457
|
["t", "Terminal"],
|
package/src/ui/store.tsx
CHANGED
|
@@ -728,6 +728,12 @@ interface StoreValue extends DataState {
|
|
|
728
728
|
sshUser: string | null
|
|
729
729
|
// Opt-in for the destructive production→local DB sync (`p`); default off.
|
|
730
730
|
localSync: boolean
|
|
731
|
+
// The site `p` was pressed on while localSync was off — drives the "enable
|
|
732
|
+
// local sync?" confirm overlay. null when the overlay is closed.
|
|
733
|
+
enableLocalSyncSite: Site | null
|
|
734
|
+
setEnableLocalSyncSite: (s: Site | null) => void
|
|
735
|
+
// Persists localSync=true to config.json and flips the reactive flag above.
|
|
736
|
+
enableLocalSync: () => void
|
|
731
737
|
// SpinupWP account slug (from env/config) for building web deep links.
|
|
732
738
|
accountSlug: string | null
|
|
733
739
|
sitesForServer: (serverId: number) => Site[]
|
|
@@ -997,6 +1003,8 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
997
1003
|
const [dbBackups, setDbBackups] = useState<Map<number, DbBackupProgress>>(new Map())
|
|
998
1004
|
const [dbSyncSite, setDbSyncSite] = useState<Site | null>(null)
|
|
999
1005
|
const [dbSyncs, setDbSyncs] = useState<Map<number, DbSyncProgress>>(new Map())
|
|
1006
|
+
const [localSync, setLocalSyncState] = useState<boolean>(() => cfgRef.current.localSync)
|
|
1007
|
+
const [enableLocalSyncSite, setEnableLocalSyncSite] = useState<Site | null>(null)
|
|
1000
1008
|
const [mediaFallbackSite, setMediaFallbackSite] = useState<Site | null>(null)
|
|
1001
1009
|
const [localRoots, setLocalRoots] = useState<string[]>(() => [...cfgRef.current.localRoots])
|
|
1002
1010
|
const [discoverOpen, setDiscoverOpen] = useState(false)
|
|
@@ -1728,6 +1736,12 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1728
1736
|
})
|
|
1729
1737
|
}, [])
|
|
1730
1738
|
|
|
1739
|
+
const enableLocalSync = useCallback(() => {
|
|
1740
|
+
cfgRef.current.localSync = true
|
|
1741
|
+
void saveConfig({ localSync: true })
|
|
1742
|
+
setLocalSyncState(true)
|
|
1743
|
+
}, [])
|
|
1744
|
+
|
|
1731
1745
|
const openLocalTerminal = useCallback(
|
|
1732
1746
|
(siteId: number) => {
|
|
1733
1747
|
const link = localLinks.get(siteId)
|
|
@@ -4326,7 +4340,10 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
4326
4340
|
rebootInfoErrors,
|
|
4327
4341
|
loadRebootInfo,
|
|
4328
4342
|
sshUser: cfgRef.current.sshUser,
|
|
4329
|
-
localSync
|
|
4343
|
+
localSync,
|
|
4344
|
+
enableLocalSyncSite,
|
|
4345
|
+
setEnableLocalSyncSite,
|
|
4346
|
+
enableLocalSync,
|
|
4330
4347
|
accountSlug: cfgRef.current.accountSlug,
|
|
4331
4348
|
sitesForServer,
|
|
4332
4349
|
serverById,
|
package/src/ui/views/Browser.tsx
CHANGED
|
@@ -23,7 +23,7 @@ type Focus = "servers" | "sites"
|
|
|
23
23
|
|
|
24
24
|
export function Browser({ rows }: { rows: number }) {
|
|
25
25
|
const store = useStore()
|
|
26
|
-
const { servers, sitesForServer, route, inputMode, overlayOpen, setHealthServer, setWpInventorySite, runProbe, probes, accountSlug, setPhpUpgradeSite, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setGrantKeySite, setSudoConnectServer, isSudoConnected, setServerActionsServer, serverOps, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setNewServerSource, setNewServerOpen, setVanityServer, vanityJob, beginClone, isServerOsEol, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed } = store
|
|
26
|
+
const { servers, sitesForServer, route, inputMode, overlayOpen, setHealthServer, setWpInventorySite, runProbe, probes, accountSlug, setPhpUpgradeSite, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setGrantKeySite, setSudoConnectServer, isSudoConnected, setServerActionsServer, serverOps, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setNewServerSource, setNewServerOpen, setVanityServer, vanityJob, beginClone, isServerOsEol, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setDbSyncSite, localSync, localLinks, setDbBackupSite, setMediaFallbackSite, setEnableLocalSyncSite } = store
|
|
27
27
|
|
|
28
28
|
const [serverIndex, setServerIndex] = useState(0)
|
|
29
29
|
const [siteIndex, setSiteIndex] = useState(0)
|
|
@@ -89,8 +89,25 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
89
89
|
setTimeout(() => setFlash(null), 1500)
|
|
90
90
|
}
|
|
91
91
|
return
|
|
92
|
-
case "d":
|
|
93
|
-
//
|
|
92
|
+
case "d": {
|
|
93
|
+
// DB backup uses wp-cli on the remote and downloads into the linked copy,
|
|
94
|
+
// so it needs a WordPress site and a local link. Same gate as Search's d.
|
|
95
|
+
const s = focus === "sites" ? sites[siteIndex] : undefined
|
|
96
|
+
if (!s) return
|
|
97
|
+
if (!s.is_wordpress) {
|
|
98
|
+
setFlash("Database backup needs WordPress (wp-cli) — this isn't a WP site")
|
|
99
|
+
} else if (!localLinks.has(s.id)) {
|
|
100
|
+
setFlash("Not linked — press L to link a local copy")
|
|
101
|
+
} else {
|
|
102
|
+
setDbBackupSite(s)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
setTimeout(() => setFlash(null), 1500)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
case "f":
|
|
109
|
+
// Identify the selected site's stack via SSH (Tier 2); shows in Details.
|
|
110
|
+
// Same action as Search's f and Stacks' d/D.
|
|
94
111
|
if (focus === "sites" && sites[siteIndex]) {
|
|
95
112
|
const s = sites[siteIndex]
|
|
96
113
|
runProbe(s)
|
|
@@ -110,13 +127,27 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
110
127
|
// Purge page cache + object cache on the selected site.
|
|
111
128
|
if (focus === "sites" && sites[siteIndex]) setPurgeCacheSite(sites[siteIndex])
|
|
112
129
|
return
|
|
113
|
-
case "m":
|
|
114
130
|
case "M":
|
|
115
131
|
// Uptime Kuma monitoring for the selected site (connect on first use).
|
|
116
|
-
//
|
|
117
|
-
// is taken), so the capital works everywhere.
|
|
132
|
+
// Capital because lowercase m is media fallback here, matching Search.
|
|
118
133
|
if (focus === "sites" && sites[siteIndex]) setKumaSite(sites[siteIndex])
|
|
119
134
|
return
|
|
135
|
+
case "m": {
|
|
136
|
+
// Production media fallback: drops a mu-plugin into the linked local copy,
|
|
137
|
+
// so it needs a WordPress site and a local link (same gate as d).
|
|
138
|
+
const s = focus === "sites" ? sites[siteIndex] : undefined
|
|
139
|
+
if (!s) return
|
|
140
|
+
if (!s.is_wordpress) {
|
|
141
|
+
setFlash("Media fallback needs WordPress — this isn't a WP site")
|
|
142
|
+
} else if (!localLinks.has(s.id)) {
|
|
143
|
+
setFlash("Not linked — press L to link a local copy")
|
|
144
|
+
} else {
|
|
145
|
+
setMediaFallbackSite(s)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
setTimeout(() => setFlash(null), 1500)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
120
151
|
case "R": {
|
|
121
152
|
// Refresh a vanity page's HTML — only the one site per server whose
|
|
122
153
|
// domain equals the server's own name. Same binding/behavior as the
|
|
@@ -168,7 +199,30 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
168
199
|
// DNS inventory scoped to the selected site (its domains + records).
|
|
169
200
|
if (focus === "sites" && sites[siteIndex] && server) setDnsInventoryServer(server, sites[siteIndex].id)
|
|
170
201
|
return
|
|
171
|
-
case "p":
|
|
202
|
+
case "p": {
|
|
203
|
+
// Pull production → local (opt-in, overwrites the local DB): needs a
|
|
204
|
+
// WordPress site, localSync enabled, and a linked local copy. Same
|
|
205
|
+
// gate as Search's `p` (Search.tsx) so the Site Control legend's
|
|
206
|
+
// "Pull prod. DB" label is accurate here too. A localSync-off site
|
|
207
|
+
// opens a confirm overlay to enable it (rather than a dead-end flash)
|
|
208
|
+
// since it's now always shown in the legend, not hidden until enabled.
|
|
209
|
+
const s = focus === "sites" ? sites[siteIndex] : undefined
|
|
210
|
+
if (!s) return
|
|
211
|
+
if (!localSync) {
|
|
212
|
+
setEnableLocalSyncSite(s)
|
|
213
|
+
return
|
|
214
|
+
} else if (!s.is_wordpress) {
|
|
215
|
+
setFlash("Sync needs WordPress (wp-cli) — this isn't a WP site")
|
|
216
|
+
} else if (!localLinks.has(s.id)) {
|
|
217
|
+
setFlash("Not linked — press L to link a local copy")
|
|
218
|
+
} else {
|
|
219
|
+
setDbSyncSite(s)
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
setTimeout(() => setFlash(null), 1500)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
case "e":
|
|
172
226
|
// Plugins & themes (wp-cli over SSH) for the selected site.
|
|
173
227
|
if (focus === "sites" && sites[siteIndex]) setWpInventorySite(sites[siteIndex])
|
|
174
228
|
return
|
|
@@ -182,10 +236,11 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
182
236
|
if (server) setSudoConnectServer(server)
|
|
183
237
|
return
|
|
184
238
|
case "a":
|
|
185
|
-
// Server actions (reboot / restart)
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
239
|
+
// Server actions (reboot / restart) — on the server, or a site's server
|
|
240
|
+
// (mirrors Search's a, which derives the server from whichever result
|
|
241
|
+
// kind is selected). `server` is already scoped to whichever server the
|
|
242
|
+
// focused pane's rows belong to, so this works unchanged from either pane.
|
|
243
|
+
if (server) setServerActionsServer(server)
|
|
189
244
|
return
|
|
190
245
|
case "c":
|
|
191
246
|
// Create a new server. Seed from the highlighted server when there is one;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// "Enable local sync?" confirm overlay — opened when `p` (Pull prod. DB) is
|
|
2
|
+
// pressed on a site while the localSync config flag is off. localSync is a
|
|
3
|
+
// deliberate opt-in (pulling production overwrites the local database), so
|
|
4
|
+
// confirming here only flips the flag and saves it — it does NOT chain into
|
|
5
|
+
// the pull itself. The user presses `p` again afterward to actually run it,
|
|
6
|
+
// which still has its own confirm-before-firing step. Keeps the destructive
|
|
7
|
+
// action behind two deliberate presses, same safety margin as before, while
|
|
8
|
+
// making the feature discoverable instead of a silent config-file-only switch.
|
|
9
|
+
|
|
10
|
+
import { useState } from "react"
|
|
11
|
+
import { useKeyboard } from "@opentui/react"
|
|
12
|
+
import { theme } from "../../lib/theme.ts"
|
|
13
|
+
import { truncate } from "../../lib/format.ts"
|
|
14
|
+
import { Panel, Centered } from "../components.tsx"
|
|
15
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
16
|
+
import { useStore } from "../store.tsx"
|
|
17
|
+
|
|
18
|
+
type Phase = "confirm" | "done"
|
|
19
|
+
|
|
20
|
+
export function EnableLocalSync() {
|
|
21
|
+
const { enableLocalSyncSite: site, setEnableLocalSyncSite, enableLocalSync } = useStore()
|
|
22
|
+
const [phase, setPhase] = useState<Phase>("confirm")
|
|
23
|
+
|
|
24
|
+
const close = () => setEnableLocalSyncSite(null)
|
|
25
|
+
|
|
26
|
+
useKeyboard((key) => {
|
|
27
|
+
const name = key.name ?? ""
|
|
28
|
+
if (phase === "confirm") {
|
|
29
|
+
if (name === "y") {
|
|
30
|
+
enableLocalSync()
|
|
31
|
+
setPhase("done")
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
if (name === "escape" || name === "q") return close()
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
// "done" — any key closes.
|
|
38
|
+
return close()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
if (!site) return null
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 210 }}>
|
|
45
|
+
<box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
|
|
46
|
+
<text content="↓ Local sync " fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
47
|
+
<text content={truncate(site.domain, 40)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
48
|
+
</box>
|
|
49
|
+
|
|
50
|
+
<Centered>{renderBody()}</Centered>
|
|
51
|
+
|
|
52
|
+
<StatusBar hints={phase === "confirm" ? [{ key: "y", label: "enable" }, { key: "esc", label: "cancel" }] : [{ key: "any", label: "close" }]} showGlobal={false} />
|
|
53
|
+
</box>
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
function renderBody() {
|
|
57
|
+
if (phase === "confirm") {
|
|
58
|
+
return (
|
|
59
|
+
<Panel title=" Enable local sync? " active>
|
|
60
|
+
<box style={{ flexDirection: "column", width: 62, paddingTop: 1, paddingBottom: 1 }}>
|
|
61
|
+
<text content="Local sync is off — it's opt-in because pulling production" fg={theme.text} wrapMode="none" />
|
|
62
|
+
<text content="overwrites your LOCAL database." fg={theme.text} wrapMode="none" />
|
|
63
|
+
<box style={{ height: 1 }} />
|
|
64
|
+
<text content="Enabling it just flips the setting — it won't pull anything" fg={theme.textDim} wrapMode="none" />
|
|
65
|
+
<text content="yet. Press p again afterward to actually pull production." fg={theme.textDim} wrapMode="none" />
|
|
66
|
+
<box style={{ height: 1 }} />
|
|
67
|
+
<text content="Press y to enable · Esc to cancel" fg={theme.textFaint} wrapMode="none" />
|
|
68
|
+
</box>
|
|
69
|
+
</Panel>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Panel title=" Done " active>
|
|
75
|
+
<box style={{ flexDirection: "column", width: 56, paddingTop: 1, paddingBottom: 1 }}>
|
|
76
|
+
<box style={{ flexDirection: "row" }}>
|
|
77
|
+
<text content="✓ " fg={theme.good} />
|
|
78
|
+
<text content="Local sync enabled" fg={theme.text} wrapMode="none" />
|
|
79
|
+
</box>
|
|
80
|
+
<box style={{ height: 1 }} />
|
|
81
|
+
<text content="Press p again to pull production into your local copy." fg={theme.textDim} wrapMode="none" />
|
|
82
|
+
<box style={{ height: 1 }} />
|
|
83
|
+
<text content="Press any key to close" fg={theme.textFaint} />
|
|
84
|
+
</box>
|
|
85
|
+
</Panel>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -30,7 +30,7 @@ interface Entry {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
// Stack filter options the report can cycle through (← / →). null = all stacks.
|
|
33
|
-
const STACK_FILTERS: (Stack | null)[] = [null, "Standard WP", "Bedrock", "Non-WP"]
|
|
33
|
+
const STACK_FILTERS: (Stack | null)[] = [null, "Standard WP", "Bedrock", "Radicle", "Non-WP"]
|
|
34
34
|
|
|
35
35
|
export function Forgotten() {
|
|
36
36
|
const { sites, localLinks, probes, setForgottenOpen, setLocalLinkSite, forgottenStack, setLinkReturnToForgotten } = useStore()
|
|
@@ -40,7 +40,7 @@ import { useStore, type KumaAlertProvider } from "../store.tsx"
|
|
|
40
40
|
import { isVanityPair } from "../../lib/vanitySite.ts"
|
|
41
41
|
import { openUrl } from "../../lib/open.ts"
|
|
42
42
|
import { runSiteDoctor, type DoctorReport } from "../../lib/siteDoctor.ts"
|
|
43
|
-
import {
|
|
43
|
+
import { effectiveStack } from "../../lib/stack.ts"
|
|
44
44
|
|
|
45
45
|
const BASE_FIELDS = ["url", "username", "password"] as const
|
|
46
46
|
// Fixed input width: the panel body is 66 wide, the label gutter is 12 ("❯ " +
|
|
@@ -152,7 +152,7 @@ type AlertsState =
|
|
|
152
152
|
type AlertSummary = null | { kind: "loading" } | { kind: "error"; error: string } | { kind: "ready"; providers: KumaAlertProvider[] }
|
|
153
153
|
|
|
154
154
|
export function KumaSite() {
|
|
155
|
-
const { kumaSite: site, setKumaSite, kumaConfigured, kumaUrl, connectKuma, kumaMonitorFor, kumaOps, startKumaSetup, startVanityReseed, startKumaRotate, startFingerprintSetup, startBypassMonitorSetup, removeSiteMonitor, fetchKumaAlerts, toggleKumaAlert, clearKumaOp, kumaStatus, servers, setInputMode } = useStore()
|
|
155
|
+
const { kumaSite: site, setKumaSite, kumaConfigured, kumaUrl, connectKuma, kumaMonitorFor, kumaOps, startKumaSetup, startVanityReseed, startKumaRotate, startFingerprintSetup, startBypassMonitorSetup, removeSiteMonitor, fetchKumaAlerts, toggleKumaAlert, clearKumaOp, kumaStatus, servers, setInputMode, probes } = useStore()
|
|
156
156
|
|
|
157
157
|
const [draft, setDraft] = useState({ url: "", username: "", password: "" })
|
|
158
158
|
const [fieldIdx, setFieldIdx] = useState(0)
|
|
@@ -371,8 +371,14 @@ export function KumaSite() {
|
|
|
371
371
|
pageCacheEnabled: site.page_cache?.enabled ?? null,
|
|
372
372
|
sshTarget: site.site_user && server ? `${site.site_user}@${server.name}` : null,
|
|
373
373
|
isWordPress: !!site.is_wordpress,
|
|
374
|
-
// Bedrock
|
|
375
|
-
|
|
374
|
+
// Bedrock and Radicle both nest core under a wp/ folder, relocating the
|
|
375
|
+
// login there — aim the door probe at it. Uses effectiveStack (Tier-2
|
|
376
|
+
// probe when available) since Radicle's public/ webroot alone is
|
|
377
|
+
// indistinguishable at Tier-1 from a hardened Standard-WP public/ layout.
|
|
378
|
+
loginPath: (() => {
|
|
379
|
+
const stack = effectiveStack(site, probes.get(site.id)?.result.kind)
|
|
380
|
+
return stack === "Bedrock" || stack === "Radicle" ? "/wp/wp-login.php" : "/wp-login.php"
|
|
381
|
+
})(),
|
|
376
382
|
}).then((report) => setDoctor((prev) => (prev ? { kind: "ready", report } : prev)))
|
|
377
383
|
}
|
|
378
384
|
|
package/src/ui/views/Search.tsx
CHANGED
|
@@ -35,7 +35,7 @@ function score(haystack: string, q: string): number | null {
|
|
|
35
35
|
|
|
36
36
|
export function Search({ rows }: { rows: number }) {
|
|
37
37
|
const store = useStore()
|
|
38
|
-
const { servers, sites, serverById, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setServerActionsServer, accountSlug, localLinks, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setDbBackupSite, dbBackups, setDbSyncSite, dbSyncs, localSync, setMediaFallbackSite, beginClone, setSudoConnectServer, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed } = store
|
|
38
|
+
const { servers, sites, serverById, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setServerActionsServer, accountSlug, localLinks, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setDbBackupSite, dbBackups, setDbSyncSite, dbSyncs, localSync, setMediaFallbackSite, beginClone, setSudoConnectServer, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setWpInventorySite, setGrantKeySite, runProbe, setEnableLocalSyncSite } = store
|
|
39
39
|
const [query, setQuery] = useState("")
|
|
40
40
|
const [selected, setSelected] = useState(0)
|
|
41
41
|
// "query" = typing/filtering (input focused); "actions" = input blurred so the
|
|
@@ -160,6 +160,11 @@ export function Search({ rows }: { rows: number }) {
|
|
|
160
160
|
case "L":
|
|
161
161
|
if (current?.kind === "site") setLocalLinkSite(current.site)
|
|
162
162
|
return
|
|
163
|
+
case "K":
|
|
164
|
+
// Grant Spinup's machine key to the selected site over SSH — matches
|
|
165
|
+
// Browser's K binding, which was already in the shared legend here.
|
|
166
|
+
if (current?.kind === "site") setGrantKeySite(current.site)
|
|
167
|
+
return
|
|
163
168
|
case "s":
|
|
164
169
|
if (current?.kind === "site") flashMsg(sshSite(current.site.id))
|
|
165
170
|
return
|
|
@@ -174,14 +179,20 @@ export function Search({ rows }: { rows: number }) {
|
|
|
174
179
|
return
|
|
175
180
|
case "p":
|
|
176
181
|
// Pull production → local: opt-in (it overwrites the local DB), and needs
|
|
177
|
-
// a WordPress site + a local link.
|
|
182
|
+
// a WordPress site + a local link. A localSync-off site opens a confirm
|
|
183
|
+
// overlay to enable it (rather than a dead-end flash) since it's now
|
|
184
|
+
// always shown in the legend, not hidden until enabled.
|
|
178
185
|
if (current?.kind === "site") {
|
|
179
|
-
if (!localSync)
|
|
186
|
+
if (!localSync) setEnableLocalSyncSite(current.site)
|
|
180
187
|
else if (!current.site.is_wordpress) flashMsg("Sync needs WordPress (wp-cli) — this isn't a WP site")
|
|
181
188
|
else if (!localLinks.has(current.site.id)) flashMsg("Not linked — press L to link a local copy")
|
|
182
189
|
else setDbSyncSite(current.site)
|
|
183
190
|
}
|
|
184
191
|
return
|
|
192
|
+
case "e":
|
|
193
|
+
// Plugins & themes (wp-cli over SSH) for the selected site.
|
|
194
|
+
if (current?.kind === "site") setWpInventorySite(current.site)
|
|
195
|
+
return
|
|
185
196
|
case "m":
|
|
186
197
|
// Production media fallback: drops a mu-plugin into the linked local copy,
|
|
187
198
|
// so it needs a WordPress site and a local link (same gate as backup).
|
|
@@ -212,6 +223,14 @@ export function Search({ rows }: { rows: number }) {
|
|
|
212
223
|
if (srv) setDnsInventoryServer(srv, current.site.id)
|
|
213
224
|
}
|
|
214
225
|
return
|
|
226
|
+
case "f":
|
|
227
|
+
// Identify the selected site's stack via SSH (Tier 2) — same action as
|
|
228
|
+
// Browser's f and Stacks' d/D.
|
|
229
|
+
if (current?.kind === "site") {
|
|
230
|
+
runProbe(current.site)
|
|
231
|
+
flashMsg(`Identifying the app on ${current.site.domain}…`)
|
|
232
|
+
}
|
|
233
|
+
return
|
|
215
234
|
case "h": {
|
|
216
235
|
const srv =
|
|
217
236
|
current?.kind === "server"
|
|
@@ -362,7 +381,6 @@ export function Search({ rows }: { rows: number }) {
|
|
|
362
381
|
<ActionsCard
|
|
363
382
|
result={current}
|
|
364
383
|
serverName={current.kind === "site" ? (serverById(current.site.server_id)?.name ?? "—") : current.server.name}
|
|
365
|
-
localSync={localSync}
|
|
366
384
|
/>
|
|
367
385
|
) : current.kind === "server" ? (
|
|
368
386
|
<ServerDetail server={current.server} siteCount={store.sitesForServer(current.server.id).length} />
|
|
@@ -377,12 +395,12 @@ export function Search({ rows }: { rows: number }) {
|
|
|
377
395
|
)
|
|
378
396
|
}
|
|
379
397
|
|
|
380
|
-
function ActionsCard({ result, serverName
|
|
398
|
+
function ActionsCard({ result, serverName }: { result: Result; serverName: string }) {
|
|
381
399
|
const isSite = result.kind === "site"
|
|
382
400
|
const name = isSite ? result.site.domain : result.server.name
|
|
383
401
|
const status = isSite ? result.site.status : result.server.connection_status
|
|
384
402
|
// Server results reuse the SAME Server Control suite as the Browser detail pane.
|
|
385
|
-
const groups = isSite ? siteGroups(result.site.is_wordpress,
|
|
403
|
+
const groups = isSite ? siteGroups(result.site.is_wordpress, isVanityPair(result.site.domain, serverName)) : SERVER_CONTROL
|
|
386
404
|
return (
|
|
387
405
|
<box style={{ flexDirection: "column" }}>
|
|
388
406
|
<box style={{ flexDirection: "row" }}>
|