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 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.22.2...HEAD
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 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
@@ -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
- | `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) |
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
- | `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 |
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, 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).
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
- `p` on a site (Servers tab) lists its real `wp plugin`/`theme list` over SSH —
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 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:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "0.22.2",
3
+ "version": "0.24.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
  }
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 />}
@@ -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, localSync } = useStore()
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, localSync, isVanity)} layout={layout} />
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: "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,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 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.
436
- export function siteGroups(isWordpress: boolean, localSync: boolean, isVanity: boolean): ActionGroup[] {
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
- if (localSync) remote.push(["p", "Pull prod. DB"])
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: cfgRef.current.localSync,
4343
+ localSync,
4344
+ enableLocalSyncSite,
4345
+ setEnableLocalSyncSite,
4346
+ enableLocalSync,
4330
4347
  accountSlug: cfgRef.current.accountSlug,
4331
4348
  sitesForServer,
4332
4349
  serverById,
@@ -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
- // 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,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) 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)
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 { 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, 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) flashMsg('Local sync is off — set "localSync": true in config to enable')
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, localSync }: { result: Result; serverName: string; localSync: boolean }) {
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, localSync, isVanityPair(result.site.domain, serverName)) : SERVER_CONTROL
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" }}>