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 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.22.2...HEAD
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 probe for
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** (`p`) — real `wp plugin`/`theme list` over SSH,
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 tab) — download or pull a
52
- production DB into a linked local copy. → [docs/database-backup-sync.md](docs/database-backup-sync.md)
53
- - **Production media fallback** (`m`, Search tab) — serve missing-locally images
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** (`m`) — a two-pane browser of a site's Uptime
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
- | `p` | List a site's installed plugins & themes over SSH — version + updates (Servers tab, sites pane) |
259
- | `d` | Detect a site's stack via SSH (Servers / Stacks tabs) |
260
- | `D` | Detect every site in the selected stack (Stacks tab) |
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
- | `m` / `M` | Site/server monitoring — a two-pane browser of this site's Uptime Kuma monitors (`M` is an alias, since lowercase `m` is taken in Search/Stacks). 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) |
267
- | `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 |
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, or Non-WP
295
- from API data alone, then lets you SSH-probe (`d` / `D`) for a precise ID (WHMCS,
296
- Laravel, Static HTML, WordPress version) that overrides a mislabeled guess. Full
297
- details: [docs/stack-detection.md](docs/stack-detection.md).
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
- `p` on a site (Servers tab) lists its real `wp plugin`/`theme list` over SSH —
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 production DB
362
- backup into `sql/`; `p` (opt-in via `localSync`) pulls production into your
363
- **local** database, backing it up first and rewriting URLs. Works with
364
- Standard WP and Bedrock. Full details:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "0.22.2",
3
+ "version": "0.23.0",
4
4
  "description": "SpinupTUI — a terminal dashboard for browsing, monitoring, and managing your SpinupWP servers and sites.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/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 build vendor/ +
217
- // web/wp before wp-cli (or wp-cli.yml) can find anything there at all. Appended
218
- // after meaningfulError's own pick (not passed as its fallback), since that's
219
- // only used when stderr has NO meaningful line — not the case here.
220
- const hint = plan.localKind === "bedrock" && /does not seem to be a WordPress install/i.test(lb.stderr) ? " Run `composer install` in your local checkout first." : ""
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
- if (readFileSync(composer, "utf8").includes("roots/bedrock")) return "bedrock"
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",
@@ -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
- `[ -n "$W" ] && [ "$(basename "$W")" = "wp" ] && { P=$(dirname "$(dirname "$W")"); [ -f "$P/composer.json" ] && grep -q "roots/bedrock" "$P/composer.json" 2>/dev/null && B="$P"; }`,
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
  }
@@ -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: "bedrock" | "wp" | "unknown"): string {
324
- return kind === "bedrock" ? theme.good : kind === "wp" ? theme.accent : theme.warn
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.
@@ -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 apply to WordPress sites — omitted
431
- // for non-WP sites (their `d`/`p` keypresses are also guarded in the handler). DNS
432
- // (`n`) and the rest apply to any site. `isVanity` (the site's domain equals its
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"],
@@ -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
- // Detect the selected site's stack via SSH (Tier 2); shows in Details.
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
- // M is an alias: it's the binding Search/Stacks use (their lowercase m
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) are server-scoped, so only offered
186
- // when the Servers pane is focused — when you've drilled into a site,
187
- // the context is the site (server actions are dropped there).
188
- if (focus === "servers" && server) setServerActionsServer(server)
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 { classifyStack } from "../../lib/stack.ts"
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 relocates the login under /wp/ — aim the door probe there.
375
- loginPath: classifyStack(site) === "Bedrock" ? "/wp/wp-login.php" : "/wp-login.php",
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
 
@@ -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"