spinuptui 0.22.2 → 0.23.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 +44 -1
- package/README.md +28 -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/Details.tsx +12 -3
- package/src/ui/components.tsx +6 -3
- package/src/ui/views/Browser.tsx +63 -11
- package/src/ui/views/Forgotten.tsx +1 -1
- package/src/ui/views/KumaSite.tsx +10 -4
- package/src/ui/views/Search.tsx +18 -1
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,47 @@ versions; such changes are called out here.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [0.23.0] - 2026-07-15
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **Radicle sites are now detected and labeled as their own stack**, instead of
|
|
18
|
+
being misclassified as Bedrock. Radicle (Roots' Bedrock+Sage+Acorn+Trellis
|
|
19
|
+
starter) shares Bedrock's `roots/bedrock-autoloader` dependency, which was
|
|
20
|
+
tripping a naive `roots/bedrock` string match; detection now keys off
|
|
21
|
+
`roots/acorn` instead — the package that actually makes a site Radicle.
|
|
22
|
+
Radicle only surfaces via the on-demand SSH probe (`f` in the Servers/Search
|
|
23
|
+
tabs, `d`/`D` in Stacks) — its `/public/` webroot alone is indistinguishable
|
|
24
|
+
from a hardened Standard-WP `/public/` layout. DB backup/sync, plugin/theme
|
|
25
|
+
inventory, and local working-copy linking already worked on Radicle sites
|
|
26
|
+
(their path detection was already stack-agnostic); this release just adds
|
|
27
|
+
the missing name. Clone-wizard support for Radicle is a separate, not-yet-
|
|
28
|
+
built follow-up.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- A few README/docs mentions of `p` (plugins & themes) and `m` (monitoring)
|
|
32
|
+
that slipped through the previous release's key-unification work —
|
|
33
|
+
corrected to `e` and `M`, and scope notes broadened from "Search tab" to
|
|
34
|
+
"Servers / Search tabs" where applicable.
|
|
35
|
+
|
|
36
|
+
## [0.22.3] - 2026-07-15
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
- **Site Control keys now behave the same in the Servers tab and Search.** The
|
|
40
|
+
shared Site Control legend was written against Search's bindings, but the
|
|
41
|
+
Servers tab silently diverged on several of them:
|
|
42
|
+
- `p` pulls the production DB in both views (Servers tab had it opening the
|
|
43
|
+
plugins/themes inventory instead, despite the legend saying "Pull prod.
|
|
44
|
+
DB"); plugins/themes moves to `e` in both.
|
|
45
|
+
- `d` runs a DB backup in both views (Servers tab had it running the Tier-2
|
|
46
|
+
stack-identify probe instead); that probe moves to `f`, added to Search
|
|
47
|
+
too (previously Servers-tab-only and not listed anywhere).
|
|
48
|
+
- `m` runs the production media-fallback action in both views (Servers tab
|
|
49
|
+
had no such binding at all; `m` aliased to Monitoring there instead).
|
|
50
|
+
- `a` (reboot/restart) now works with a site focused in the Servers tab, not
|
|
51
|
+
just the top-level server list — it was a silent no-op in that context.
|
|
52
|
+
- `K` (grant SSH key) is now wired up in Search — it was in the legend but
|
|
53
|
+
had no handler there at all.
|
|
54
|
+
|
|
14
55
|
## [0.22.2] - 2026-07-13
|
|
15
56
|
|
|
16
57
|
### Fixed
|
|
@@ -1185,7 +1226,9 @@ Initial tagged release.
|
|
|
1185
1226
|
### Notes
|
|
1186
1227
|
- Read-only release: works with a SpinupWP **Read Only** API token.
|
|
1187
1228
|
|
|
1188
|
-
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.
|
|
1229
|
+
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.23.0...HEAD
|
|
1230
|
+
[0.23.0]: https://github.com/mwender/spinupwp-tui/compare/v0.22.3...v0.23.0
|
|
1231
|
+
[0.22.3]: https://github.com/mwender/spinupwp-tui/compare/v0.22.2...v0.22.3
|
|
1189
1232
|
[0.22.2]: https://github.com/mwender/spinupwp-tui/compare/v0.22.1...v0.22.2
|
|
1190
1233
|
[0.22.1]: https://github.com/mwender/spinupwp-tui/compare/v0.22.0...v0.22.1
|
|
1191
1234
|
[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
|
|
@@ -247,30 +247,31 @@ These can be set in `config.json` or via an environment variable:
|
|
|
247
247
|
| `g` / `G` | Jump to top / bottom |
|
|
248
248
|
| `o` | Open the selected site in your browser |
|
|
249
249
|
| `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) |
|
|
250
|
+
| `d` | Download a production DB backup into the linked copy (Servers / Search; WordPress + linked) |
|
|
251
|
+
| `p` | Pull the production DB into the linked copy — overwrites local; opt-in via `localSync` (Servers / Search) |
|
|
252
|
+
| `m` | Production media fallback: serve missing-locally images from production (Servers / Search; WordPress + linked) |
|
|
253
253
|
| `L` | Link / edit a site's local working copy |
|
|
254
254
|
| `t` / `v` | Open the linked copy in a terminal / its local URL in your browser |
|
|
255
255
|
| `n` | DNS migration view for a site — its records + TTLs (`⏎` edits a TTL; `p` repoints the record; `a` shows the whole server) |
|
|
256
256
|
| `N` | DNS migration view for the whole server |
|
|
257
257
|
| `h` | Live server health (CPU/mem/disk over SSH) |
|
|
258
|
-
| `
|
|
259
|
-
| `
|
|
260
|
-
| `
|
|
258
|
+
| `e` | List a site's installed plugins & themes over SSH — version + updates (Servers / Search) |
|
|
259
|
+
| `f` | Identify a site's stack via SSH (Servers / Search) — same action as Stacks' `d`/`D` below |
|
|
260
|
+
| `d` | Identify a site's stack via SSH (Stacks tab only — `d` is DB backup in Servers/Search, above) |
|
|
261
|
+
| `D` | Identify every site in the selected stack (Stacks tab) |
|
|
261
262
|
| `S` | Auto-discover & batch-link local copies (Stacks tab) |
|
|
262
|
-
| `f` | Report sites with no usable local copy (Stacks tab) |
|
|
263
|
+
| `f` | Report sites with no usable local copy (Stacks tab only — `f` identifies a site's stack in Servers/Search, above) |
|
|
263
264
|
| `u` | Upgrade a site's PHP version (Servers / Stacks / Search; needs a Read/Write token) |
|
|
264
265
|
| `H` | Enable / disable HTTPS on a site (Servers / Stacks / Search; needs a Read/Write token) |
|
|
265
266
|
| `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 `
|
|
267
|
+
| `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) |
|
|
268
|
+
| `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
269
|
| `a` | Server actions: reboot / restart a service (Servers / Search; needs a Read/Write token) |
|
|
269
270
|
| `c` | Create a new server (Servers tab; needs a Read/Write token) |
|
|
270
271
|
| `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
272
|
| `C` | Clone a server's sites to a new/existing destination (Servers tab; needs a Read/Write token + sudo) |
|
|
272
273
|
| `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) |
|
|
274
|
+
| `K` | Grant / revoke an SSH key on a site, or every site on the server (Servers / Search; needs sudo connected) |
|
|
274
275
|
| `w` | Open the selected server/site in the SpinupWP web app |
|
|
275
276
|
| `/` | Jump to global search |
|
|
276
277
|
| `r` | Refresh data from the API |
|
|
@@ -291,14 +292,15 @@ processes — read-only, using your local SSH keys. Full details:
|
|
|
291
292
|
|
|
292
293
|
## Stack detection
|
|
293
294
|
|
|
294
|
-
The **Stacks** tab (`3`) classifies every site as Standard WP, Bedrock,
|
|
295
|
-
from API data alone
|
|
296
|
-
|
|
297
|
-
|
|
295
|
+
The **Stacks** tab (`3`) classifies every site as Standard WP, Bedrock, Radicle,
|
|
296
|
+
or Non-WP from API data alone (Radicle needs a probe to surface — see below),
|
|
297
|
+
then lets you SSH-probe (`d` / `D`) for a precise ID (WHMCS, Laravel, Static
|
|
298
|
+
HTML, WordPress version) that overrides a mislabeled guess. Full details:
|
|
299
|
+
[docs/stack-detection.md](docs/stack-detection.md).
|
|
298
300
|
|
|
299
301
|
## Installed plugins & themes
|
|
300
302
|
|
|
301
|
-
`
|
|
303
|
+
`e` on a site (Servers / Search tabs) lists its real `wp plugin`/`theme list` over SSH —
|
|
302
304
|
status, version, and available updates — the detail the API only gives as bare
|
|
303
305
|
counts. Read-only, works on Bedrock and misclassified sites too. Full details:
|
|
304
306
|
[docs/plugins-themes.md](docs/plugins-themes.md).
|
|
@@ -358,10 +360,10 @@ Stacks tab can auto-discover copies (`S`) and report sites still missing one
|
|
|
358
360
|
|
|
359
361
|
## Database backup & sync
|
|
360
362
|
|
|
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
|
|
363
|
+
On a linked WordPress site (Servers or Search tab), `d` downloads a gzipped
|
|
364
|
+
production DB backup into `sql/`; `p` (opt-in via `localSync`) pulls production
|
|
365
|
+
into your **local** database, backing it up first and rewriting URLs. Works
|
|
366
|
+
with Standard WP, Bedrock, and Radicle. Full details:
|
|
365
367
|
[docs/database-backup-sync.md](docs/database-backup-sync.md).
|
|
366
368
|
|
|
367
369
|
## 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/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"
|
|
@@ -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,9 +427,10 @@ 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.
|
|
@@ -438,10 +439,12 @@ export function siteGroups(isWordpress: boolean, localSync: boolean, isVanity: b
|
|
|
438
439
|
["s", "SSH"],
|
|
439
440
|
["K", "Grant SSH key"],
|
|
440
441
|
["n", "DNS"],
|
|
442
|
+
["f", "Identify app"],
|
|
441
443
|
]
|
|
442
444
|
if (isWordpress) {
|
|
443
445
|
remote.push(["d", "↓ DB backup"])
|
|
444
446
|
if (localSync) remote.push(["p", "Pull prod. DB"])
|
|
447
|
+
remote.push(["e", "Plugins & themes"])
|
|
445
448
|
}
|
|
446
449
|
const local: [string, string][] = [
|
|
447
450
|
["t", "Terminal"],
|
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 } = 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,27 @@ 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.
|
|
207
|
+
const s = focus === "sites" ? sites[siteIndex] : undefined
|
|
208
|
+
if (!s) return
|
|
209
|
+
if (!localSync) {
|
|
210
|
+
setFlash('Local sync is off — set "localSync": true in config to enable')
|
|
211
|
+
} else if (!s.is_wordpress) {
|
|
212
|
+
setFlash("Sync needs WordPress (wp-cli) — this isn't a WP site")
|
|
213
|
+
} else if (!localLinks.has(s.id)) {
|
|
214
|
+
setFlash("Not linked — press L to link a local copy")
|
|
215
|
+
} else {
|
|
216
|
+
setDbSyncSite(s)
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
setTimeout(() => setFlash(null), 1500)
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
case "e":
|
|
172
223
|
// Plugins & themes (wp-cli over SSH) for the selected site.
|
|
173
224
|
if (focus === "sites" && sites[siteIndex]) setWpInventorySite(sites[siteIndex])
|
|
174
225
|
return
|
|
@@ -182,10 +233,11 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
182
233
|
if (server) setSudoConnectServer(server)
|
|
183
234
|
return
|
|
184
235
|
case "a":
|
|
185
|
-
// Server actions (reboot / restart)
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
236
|
+
// Server actions (reboot / restart) — on the server, or a site's server
|
|
237
|
+
// (mirrors Search's a, which derives the server from whichever result
|
|
238
|
+
// kind is selected). `server` is already scoped to whichever server the
|
|
239
|
+
// focused pane's rows belong to, so this works unchanged from either pane.
|
|
240
|
+
if (server) setServerActionsServer(server)
|
|
189
241
|
return
|
|
190
242
|
case "c":
|
|
191
243
|
// Create a new server. Seed from the highlighted server when there is one;
|
|
@@ -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 } = 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
|
|
@@ -182,6 +187,10 @@ export function Search({ rows }: { rows: number }) {
|
|
|
182
187
|
else setDbSyncSite(current.site)
|
|
183
188
|
}
|
|
184
189
|
return
|
|
190
|
+
case "e":
|
|
191
|
+
// Plugins & themes (wp-cli over SSH) for the selected site.
|
|
192
|
+
if (current?.kind === "site") setWpInventorySite(current.site)
|
|
193
|
+
return
|
|
185
194
|
case "m":
|
|
186
195
|
// Production media fallback: drops a mu-plugin into the linked local copy,
|
|
187
196
|
// so it needs a WordPress site and a local link (same gate as backup).
|
|
@@ -212,6 +221,14 @@ export function Search({ rows }: { rows: number }) {
|
|
|
212
221
|
if (srv) setDnsInventoryServer(srv, current.site.id)
|
|
213
222
|
}
|
|
214
223
|
return
|
|
224
|
+
case "f":
|
|
225
|
+
// Identify the selected site's stack via SSH (Tier 2) — same action as
|
|
226
|
+
// Browser's f and Stacks' d/D.
|
|
227
|
+
if (current?.kind === "site") {
|
|
228
|
+
runProbe(current.site)
|
|
229
|
+
flashMsg(`Identifying the app on ${current.site.domain}…`)
|
|
230
|
+
}
|
|
231
|
+
return
|
|
215
232
|
case "h": {
|
|
216
233
|
const srv =
|
|
217
234
|
current?.kind === "server"
|