spinuptui 0.21.2 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,57 @@ versions; such changes are called out here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.22.1] - 2026-07-13
15
+
16
+ ### Fixed
17
+ - **`K` (grant/revoke SSH key) is discoverable again.** The v0.22.0 Control-strip
18
+ rebuild dropped it from both Site Control and Server Control's action lists —
19
+ the keybinding itself kept working, but you had to already know it existed.
20
+ It's back in Site Control's Remote group and Server Control's Access group,
21
+ and now also works from the Servers pane (falling back to the server's first
22
+ key-eligible site), matching how `S`/`h` already work from either pane.
23
+
24
+ ## [0.22.0] - 2026-07-13
25
+
26
+ ### Added
27
+ - **`spinuptui ssh-exec <domain> -- <command>`** — resolves the domain's SSH
28
+ target (same as `spinuptui ssh`) and runs `<command>` on it, but only if
29
+ it's read-only: anything matching a write/restart/destructive pattern
30
+ (plugin/theme changes, DB writes, service restarts, redirecting output to
31
+ a real file, package installs, etc.) is denied before it ever reaches the
32
+ server. Built for external tooling that needs to run diagnostic commands
33
+ unattended — any agent configured to touch a server only through this
34
+ command inherits the read-only guarantee without building its own guard.
35
+ Prints structured JSON (`stdout`/`stderr`/`exitCode` on success,
36
+ `reason:"command_denied"` on refusal) and logs every attempt — allowed,
37
+ denied, or unresolved — to `<config dir>/logs/ssh-exec-audit.jsonl`.
38
+ - **Refreshing a vanity page's HTML is now discoverable from where you'd look
39
+ for it.** A shared, grouped, key-chipped action list (`siteGroups()`) now
40
+ shows every per-site action — including `R` (refresh vanity page) for the
41
+ one site per server whose domain is the server's own hostname — in Search's
42
+ Actions pane and, on the Servers tab, in the bottom **Control strip**
43
+ (below). Previously `R` only lived inside the `m` monitoring overlay.
44
+ - **The Servers tab's bottom "Local" drawer is now a full-width Control
45
+ strip.** With a site selected it shows Site Control (Open/Remote/Local/
46
+ Vanity/Server); with a server selected (no site drilled into) it now shows
47
+ real **Server Control** (Clone/Access/Manage/Open) instead of a generic
48
+ placeholder. Each group's items flow and wrap across the full terminal
49
+ width instead of stacking one per line, so even a long group like Remote
50
+ fits in 1 line — each group's label sits in its own left column (pinned to
51
+ the top if its items wrap), and item labels are terser now that they don't
52
+ need to repeat their group's name (e.g. "In browser" under OPEN, not "Open
53
+ site in browser"). The Sites list also dropped its per-row status badges
54
+ (linked/keys/HTTPS/cache/backup/updates) — that info is already shown in
55
+ the Details pane for the selected site — to give the domain name more room;
56
+ Servers/Sites/Details are back to a simple 3-pane row.
57
+
58
+ ### Changed
59
+ - **Servers and Search footers no longer duplicate the action list.** Both
60
+ views' key hints for a selected site are now nav-only (`select` / `back`);
61
+ the full, always-in-sync action list lives in the Control strip (or
62
+ Search's Actions pane). Also fixed a stale Search hint (`⏎ open site`) that was
63
+ shown even for a selected server row, where Enter is a no-op.
64
+
14
65
  ## [0.21.2] - 2026-07-10
15
66
 
16
67
  ### Fixed
@@ -1125,7 +1176,9 @@ Initial tagged release.
1125
1176
  ### Notes
1126
1177
  - Read-only release: works with a SpinupWP **Read Only** API token.
1127
1178
 
1128
- [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.21.2...HEAD
1179
+ [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.22.1...HEAD
1180
+ [0.22.1]: https://github.com/mwender/spinupwp-tui/compare/v0.22.0...v0.22.1
1181
+ [0.22.0]: https://github.com/mwender/spinupwp-tui/compare/v0.21.2...v0.22.0
1129
1182
  [0.21.2]: https://github.com/mwender/spinupwp-tui/compare/v0.21.1...v0.21.2
1130
1183
  [0.21.1]: https://github.com/mwender/spinupwp-tui/compare/v0.21.0...v0.21.1
1131
1184
  [0.21.0]: https://github.com/mwender/spinupwp-tui/compare/v0.20.1...v0.21.0
package/README.md CHANGED
@@ -29,8 +29,10 @@
29
29
 
30
30
  - **Fleet dashboard** — connection status, disk usage, pending reboots/upgrades,
31
31
  WP update counts, EOL Ubuntu flags, and a recent-activity feed for every server.
32
- - **Server & site browser** — a three-pane navigator with an at-a-glance status
33
- column (SSH keys, HTTPS, cache, backups, linked copy, pending updates).
32
+ - **Server & site browser** — a three-pane navigator (servers → sites →
33
+ details) with a full-width Control strip below showing every action for
34
+ whatever's selected — Site Control or Server Control, grouped and always
35
+ visible, no hidden key combos.
34
36
  - **Stack detection & fleet composition** — classifies every site as Standard WP,
35
37
  Bedrock, or Non-WP, with a PHP-version breakdown and an on-demand SSH probe for
36
38
  precise identification. → [docs/stack-detection.md](docs/stack-detection.md)
@@ -74,10 +76,13 @@
74
76
  [docs/uptime-kuma.md](docs/uptime-kuma.md)
75
77
  - **Non-interactive CLI subcommands for external tooling** — `spinuptui ssh <domain>`
76
78
  resolves a domain to a live SSH target with a live connectivity probe;
77
- `spinuptui incidents <domain>` / `--all` surfaces Uptime Kuma's down/up
78
- history as JSON. Built so another agent/script can go from just a domain to
79
- "what's wrong and how do I get in" with no manual lookup.
80
- → [CLI subcommands](#cli-subcommands)
79
+ `spinuptui ssh-exec <domain> -- <command>` runs a command over SSH, but only
80
+ if it's read-only — anything that looks like a write/restart/destructive
81
+ action is denied rather than run, so any agent using it inherits that
82
+ guarantee without building its own guard; `spinuptui incidents <domain>` /
83
+ `--all` surfaces Uptime Kuma's down/up history as JSON. Built so another
84
+ agent/script can go from just a domain to "what's wrong and how do I get
85
+ in" with no manual lookup. → [CLI subcommands](#cli-subcommands)
81
86
  - **Completion toasts** — a non-focus-stealing toast when a background write
82
87
  (PHP upgrade, reboot, DNS resolve, …) finishes.
83
88
  - **Release notes** — an in-app "what's new" after every update, sourced
@@ -166,6 +171,17 @@ spinuptui ssh <domain> Print SSH access info for a site as JSON (resolves the
166
171
  site's server, builds its SSH target, and runs a live
167
172
  connectivity probe — for external tooling, e.g. an
168
173
  incident-diagnostics agent handed only a domain)
174
+ spinuptui ssh-exec <domain> -- <command> Resolve the domain's SSH target and
175
+ run <command> on it, but only if the command is
176
+ read-only — anything matching a write/restart/destructive
177
+ pattern (plugin/theme changes, DB writes, service
178
+ restarts, file redirection to a real path, etc.) is
179
+ denied instead of run. Prints JSON: on success,
180
+ {ok:true, exitCode, stdout, stderr, ...}; on denial,
181
+ {ok:false, reason:"command_denied", message}. Every
182
+ attempt (allowed, denied, or unresolved) is appended to
183
+ <config dir>/logs/ssh-exec-audit.jsonl. Quote <command>
184
+ as one shell argument if it needs pipes or redirects.
169
185
  spinuptui incidents <domain> | --all [--hours N] Print Uptime Kuma down/up
170
186
  incidents as JSON, scoped to sites SpinupTUI manages
171
187
  monitoring for (config.json's kumaMonitors) — --all
@@ -248,6 +264,7 @@ These can be set in `config.json` or via an environment variable:
248
264
  | `H` | Enable / disable HTTPS on a site (Servers / Stacks / Search; needs a Read/Write token) |
249
265
  | `P` | Purge a site's page cache + object cache (Servers / Stacks / Search; needs a Read/Write token) |
250
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 |
251
268
  | `a` | Server actions: reboot / restart a service (Servers / Search; needs a Read/Write token) |
252
269
  | `c` | Create a new server (Servers tab; needs a Read/Write token) |
253
270
  | `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) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "0.21.2",
3
+ "version": "0.22.1",
4
4
  "description": "SpinupTUI — a terminal dashboard for browsing, monitoring, and managing your SpinupWP servers and sites.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/index.tsx CHANGED
@@ -15,6 +15,7 @@ import { isDevMode } from "./dev/devMode.ts"
15
15
  import { SpinupWPClient } from "./api/client.ts"
16
16
  import { resolveSshAccess } from "./lib/cliSsh.ts"
17
17
  import { resolveIncidents } from "./lib/cliIncidents.ts"
18
+ import { execSshCommand } from "./lib/sshExec.ts"
18
19
 
19
20
  const args = process.argv.slice(2)
20
21
  const positionals = args.filter((a) => !a.startsWith("-"))
@@ -29,6 +30,8 @@ Usage:
29
30
  spinuptui login Set or update your saved API token
30
31
  spinuptui where Print the config file path and token source
31
32
  spinuptui ssh <domain> Print SSH access info for a site (JSON)
33
+ spinuptui ssh-exec <domain> -- <command> Run a read-only command over SSH
34
+ (JSON); denies anything that looks like a remote write
32
35
  spinuptui incidents <domain> | --all [--hours N] Print Uptime Kuma
33
36
  down/up incidents for a site or the whole fleet (JSON)
34
37
  spinuptui --version Print the version
@@ -68,6 +71,23 @@ if (command === "ssh") {
68
71
  process.exit(result.ok ? 0 : 1)
69
72
  }
70
73
 
74
+ if (command === "ssh-exec") {
75
+ const dashIdx = args.indexOf("--")
76
+ const domain = positionals[1]
77
+ const remoteCmd = dashIdx !== -1 ? args.slice(dashIdx + 1).join(" ") : ""
78
+ if (!domain || dashIdx === -1 || !remoteCmd.trim()) {
79
+ console.error(
80
+ JSON.stringify({ ok: false, reason: "usage", message: "Usage: spinuptui ssh-exec <domain> -- <command>" }),
81
+ )
82
+ process.exit(1)
83
+ }
84
+ const cfg = loadConfig()
85
+ const client = new SpinupWPClient(cfg)
86
+ const result = await execSshCommand(domain, remoteCmd, client, cfg)
87
+ console.log(JSON.stringify(result))
88
+ process.exit(result.ok ? 0 : 1)
89
+ }
90
+
71
91
  if (command === "incidents") {
72
92
  const rest = args.slice(1)
73
93
  const hoursIdx = rest.indexOf("--hours")
package/src/lib/cliSsh.ts CHANGED
@@ -40,13 +40,32 @@ export type SshAccessResult =
40
40
  const GRANT_KEY_REMEDY =
41
41
  "Run `spinuptui`, select this site in the Browser view, and press K to grant this device's key (sudo must be connected on the server first — press S)."
42
42
 
43
- export async function resolveSshAccess(
43
+ // Resolved SSH connection details for a domain, without having tested them.
44
+ export interface SshTargetInfo {
45
+ domain: string
46
+ primaryDomain: string
47
+ sshTarget: string
48
+ port: number | null
49
+ server: string
50
+ }
51
+
52
+ export type SshTargetResolution =
53
+ | { ok: true; info: SshTargetInfo }
54
+ | { ok: false; result: SshAccessResult & { ok: false } }
55
+
56
+ // Domain -> site -> server -> SSH target/port, with no live connectivity check.
57
+ // Split out of resolveSshAccess so callers that are about to run a real command
58
+ // (which is itself a connectivity test) don't pay for a redundant probe first.
59
+ export async function resolveSshTargetInfo(
44
60
  domain: string,
45
61
  client: SpinupWPClientLike,
46
62
  cfg: AppConfig,
47
- ): Promise<SshAccessResult> {
63
+ ): Promise<SshTargetResolution> {
48
64
  if (!cfg.token) {
49
- return { ok: false, domain, reason: "no_token", message: "No API token configured. Run `spinuptui login`." }
65
+ return {
66
+ ok: false,
67
+ result: { ok: false, domain, reason: "no_token", message: "No API token configured. Run `spinuptui login`." },
68
+ }
50
69
  }
51
70
 
52
71
  let site
@@ -59,9 +78,12 @@ export async function resolveSshAccess(
59
78
  if (matches.length === 0) {
60
79
  return {
61
80
  ok: false,
62
- domain,
63
- reason: "site_not_found",
64
- message: `No site matching "${domain}" found in this SpinupWP account.`,
81
+ result: {
82
+ ok: false,
83
+ domain,
84
+ reason: "site_not_found",
85
+ message: `No site matching "${domain}" found in this SpinupWP account.`,
86
+ },
65
87
  }
66
88
  }
67
89
  if (matches.length > 1) {
@@ -73,10 +95,13 @@ export async function resolveSshAccess(
73
95
  )
74
96
  return {
75
97
  ok: false,
76
- domain,
77
- reason: "multiple_matches",
78
- message: `"${domain}" matches ${matches.length} sites in this account — cannot pick one automatically.`,
79
- candidates,
98
+ result: {
99
+ ok: false,
100
+ domain,
101
+ reason: "multiple_matches",
102
+ message: `"${domain}" matches ${matches.length} sites in this account — cannot pick one automatically.`,
103
+ candidates,
104
+ },
80
105
  }
81
106
  }
82
107
  site = matches[0]
@@ -85,27 +110,48 @@ export async function resolveSshAccess(
85
110
  if (err instanceof ApiError && err.status === 401) {
86
111
  return {
87
112
  ok: false,
88
- domain,
89
- reason: "token_rejected",
90
- message: "Saved API token was rejected (401) — it may be expired or revoked.",
91
- remedy: "Run `spinuptui login` to save a fresh token.",
113
+ result: {
114
+ ok: false,
115
+ domain,
116
+ reason: "token_rejected",
117
+ message: "Saved API token was rejected (401) — it may be expired or revoked.",
118
+ remedy: "Run `spinuptui login` to save a fresh token.",
119
+ },
92
120
  }
93
121
  }
94
122
  const message = err instanceof ApiError ? err.message : `Unexpected error: ${(err as Error).message}`
95
- return { ok: false, domain, reason: "api_error", message }
123
+ return { ok: false, result: { ok: false, domain, reason: "api_error", message } }
96
124
  }
97
125
 
98
126
  if (!server.ip_address) {
99
127
  return {
100
128
  ok: false,
101
- domain,
102
- reason: "server_has_no_ip",
103
- message: `Server "${server.name}" has no IP address on file.`,
129
+ result: {
130
+ ok: false,
131
+ domain,
132
+ reason: "server_has_no_ip",
133
+ message: `Server "${server.name}" has no IP address on file.`,
134
+ },
104
135
  }
105
136
  }
106
137
 
107
138
  const target = resolveSiteSshTarget(site, server, cfg.sshUser)
108
139
  const port = server.ssh_port && server.ssh_port !== 22 ? server.ssh_port : null
140
+
141
+ return {
142
+ ok: true,
143
+ info: { domain, primaryDomain: site.domain, sshTarget: target, port, server: server.name },
144
+ }
145
+ }
146
+
147
+ export async function resolveSshAccess(
148
+ domain: string,
149
+ client: SpinupWPClientLike,
150
+ cfg: AppConfig,
151
+ ): Promise<SshAccessResult> {
152
+ const resolution = await resolveSshTargetInfo(domain, client, cfg)
153
+ if (!resolution.ok) return resolution.result
154
+ const { primaryDomain, sshTarget: target, port, server: serverName } = resolution.info
109
155
  const portOpt = port ? ["-p", String(port)] : []
110
156
 
111
157
  let proc: ReturnType<typeof Bun.spawn>
@@ -132,7 +178,7 @@ export async function resolveSshAccess(
132
178
  const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text()
133
179
 
134
180
  if (exitCode === 0) {
135
- return { ok: true, domain, primaryDomain: site.domain, sshTarget: target, port, server: server.name }
181
+ return { ok: true, domain, primaryDomain, sshTarget: target, port, server: serverName }
136
182
  }
137
183
 
138
184
  if (/permission denied/i.test(stderr)) {
@@ -0,0 +1,93 @@
1
+ // `spinuptui ssh-exec <domain> -- <command>` — gated, read-only SSH command
2
+ // execution for external tooling (e.g. a diagnostics agent). Resolves domain ->
3
+ // SSH target the same way `spinuptui ssh` does, checks the command against the
4
+ // read-only allowlist (sshExecPolicy.ts), and only then runs it. Every attempt —
5
+ // allowed, denied, or a resolution failure — is written to the audit log
6
+ // (sshExecLog.ts) so the answer to "did this ever touch a server" doesn't depend
7
+ // on the calling agent's own report.
8
+
9
+ import type { AppConfig } from "../config.ts"
10
+ import type { SpinupWPClientLike } from "../api/client.ts"
11
+ import { resolveSshTargetInfo, type SshAccessReason, type SshAccessCandidate } from "./cliSsh.ts"
12
+ import { SSH_OPTS } from "./probe.ts"
13
+ import { classifySshCommand } from "./sshExecPolicy.ts"
14
+ import { logSshExecAttempt } from "./sshExecLog.ts"
15
+
16
+ export type SshExecReason = SshAccessReason | "command_denied"
17
+
18
+ export type SshExecResult =
19
+ | {
20
+ ok: true
21
+ domain: string
22
+ primaryDomain: string
23
+ sshTarget: string
24
+ command: string
25
+ exitCode: number
26
+ stdout: string
27
+ stderr: string
28
+ durationMs: number
29
+ }
30
+ | {
31
+ ok: false
32
+ domain: string
33
+ reason: SshExecReason
34
+ message: string
35
+ command?: string
36
+ remedy?: string
37
+ candidates?: SshAccessCandidate[]
38
+ }
39
+
40
+ const EXEC_TIMEOUT_MS = 30_000
41
+
42
+ export async function execSshCommand(
43
+ domain: string,
44
+ command: string,
45
+ client: SpinupWPClientLike,
46
+ cfg: AppConfig,
47
+ ): Promise<SshExecResult> {
48
+ const resolution = await resolveSshTargetInfo(domain, client, cfg)
49
+ if (!resolution.ok) {
50
+ logSshExecAttempt({ domain, command, decision: "error", reason: resolution.result.reason, message: resolution.result.message })
51
+ return { ...resolution.result, command }
52
+ }
53
+ const { primaryDomain, sshTarget, port } = resolution.info
54
+
55
+ const verdict = classifySshCommand(command)
56
+ if (verdict.decision === "deny") {
57
+ logSshExecAttempt({ domain: primaryDomain, command, decision: "denied", reason: verdict.reason })
58
+ return { ok: false, domain, reason: "command_denied", message: verdict.reason, command }
59
+ }
60
+
61
+ const portOpt = port ? ["-p", String(port)] : []
62
+ const start = Date.now()
63
+ let proc: ReturnType<typeof Bun.spawn>
64
+ try {
65
+ proc = Bun.spawn(["ssh", ...SSH_OPTS, ...portOpt, sshTarget, command], {
66
+ stdout: "pipe",
67
+ stderr: "pipe",
68
+ stdin: "ignore",
69
+ })
70
+ } catch (err) {
71
+ const message = `Failed to launch ssh: ${(err as Error).message}`
72
+ logSshExecAttempt({ domain: primaryDomain, command, decision: "error", reason: "ssh_error", message })
73
+ return { ok: false, domain, reason: "ssh_error", message, command }
74
+ }
75
+
76
+ const timeout = setTimeout(() => {
77
+ try {
78
+ proc.kill()
79
+ } catch {
80
+ /* already gone */
81
+ }
82
+ }, EXEC_TIMEOUT_MS)
83
+
84
+ const exitCode = await proc.exited
85
+ clearTimeout(timeout)
86
+ const stdout = await new Response(proc.stdout as ReadableStream<Uint8Array>).text()
87
+ const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text()
88
+ const durationMs = Date.now() - start
89
+
90
+ logSshExecAttempt({ domain: primaryDomain, command, decision: "allowed", exitCode, stdout, stderr, durationMs })
91
+
92
+ return { ok: true, domain, primaryDomain, sshTarget, command, exitCode, stdout, stderr, durationMs }
93
+ }
@@ -0,0 +1,31 @@
1
+ // Audit trail for `spinuptui ssh-exec` — one JSONL line per attempt (allowed,
2
+ // denied, or a resolution failure), so "did this ever write to a server" has a
3
+ // durable answer independent of whatever the calling agent reports. Modeled on
4
+ // src/lib/cloneLog.ts's JSONL-under-configDir/logs pattern.
5
+
6
+ import { appendFileSync, mkdirSync } from "node:fs"
7
+ import { join } from "node:path"
8
+ import { configDir } from "../config.ts"
9
+
10
+ const FIELD_CAP = 2_000
11
+
12
+ function auditLogPath(): string {
13
+ return join(configDir(), "logs", "ssh-exec-audit.jsonl")
14
+ }
15
+
16
+ function cap(v: unknown): unknown {
17
+ if (typeof v !== "string" || v.length <= FIELD_CAP) return v
18
+ return `${v.slice(0, FIELD_CAP)}…[${v.length - FIELD_CAP} chars trimmed]`
19
+ }
20
+
21
+ export function logSshExecAttempt(entry: Record<string, unknown>): void {
22
+ try {
23
+ const dir = join(configDir(), "logs")
24
+ mkdirSync(dir, { recursive: true })
25
+ const out: Record<string, unknown> = { ts: new Date().toISOString() }
26
+ for (const [k, v] of Object.entries(entry)) out[k] = cap(v)
27
+ appendFileSync(auditLogPath(), JSON.stringify(out) + "\n")
28
+ } catch {
29
+ /* audit logging is best-effort — must never break a diagnostic command */
30
+ }
31
+ }
@@ -0,0 +1,51 @@
1
+ // Read-only allowlist for `spinuptui ssh-exec`. Ported from the WordPress Site
2
+ // Diagnostics playbook's Claude-Code-specific PreToolUse hook (guard-ssh-hook.py),
3
+ // which classified raw `ssh ...` Bash invocations for an unattended agent. Moving
4
+ // this into SpinupTUI itself means the guarantee holds for any agent that's
5
+ // configured to touch servers only through `ssh-exec`, not just Claude Code.
6
+
7
+ const DANGEROUS_PATTERNS: RegExp[] = [
8
+ /\b(rm|mv|chmod|chown|reboot|shutdown|kill|truncate)\b/i,
9
+ /systemctl\s+(restart|stop|reload)\b/i,
10
+ /service\s+\S+\s+restart\b/i,
11
+ /wp\s+plugin\s+(activate|deactivate|install|update|delete)\b/i,
12
+ /wp\s+theme\s+(activate|delete|install)\b/i,
13
+ /wp\s+db\s+(query|import|reset)\b/i,
14
+ /wp\s+option\s+(update|delete|add)\b/i,
15
+ /wp\s+user\b/i,
16
+ /wp\s+core\s+(update|install)\b/i,
17
+ /wp\s+(rewrite\s+flush|cache\s+flush)\b/i,
18
+ /crontab\s+-[re]\b/i,
19
+ /sed\s+-i\b/i,
20
+ /\b(DROP\s+TABLE|INSERT\s+INTO|DELETE\s+FROM)\b/i,
21
+ /UPDATE\s+\S+\s+SET\b/i,
22
+ /\b(apt(-get)?|yum|brew)\s+install\b/i,
23
+ ]
24
+
25
+ // The Python hook's original check `(?<!=)>{1,2}` denied any `>`/`>>` unless
26
+ // immediately preceded by `=` — which wrongly caught `2>/dev/null`/`2>&1` (the
27
+ // character before `>` there is the fd digit, not `=`). Fix: strip known-safe
28
+ // redirects (discard to /dev/null, or an fd-to-fd dup like `2>&1`) first, then
29
+ // flag anything that still looks like a write to a real path.
30
+ function hasUnsafeRedirect(cmd: string): boolean {
31
+ const stripped = cmd.replace(/\d?>{1,2}\s*(\/dev\/null\b|&\d+)/g, "")
32
+ return />/.test(stripped)
33
+ }
34
+
35
+ export interface SshCommandVerdict {
36
+ decision: "allow" | "deny"
37
+ reason: string
38
+ }
39
+
40
+ export function classifySshCommand(cmd: string): SshCommandVerdict {
41
+ if (DANGEROUS_PATTERNS.some((re) => re.test(cmd)) || hasUnsafeRedirect(cmd)) {
42
+ return {
43
+ decision: "deny",
44
+ reason: "This command looks like a remote write/restart/destructive action — spinuptui ssh-exec only runs read-only diagnostic commands.",
45
+ }
46
+ }
47
+ return {
48
+ decision: "allow",
49
+ reason: "Read-only diagnostic command, no write/restart/destructive pattern detected.",
50
+ }
51
+ }
@@ -3,17 +3,18 @@
3
3
  import { useEffect } from "react"
4
4
  import { theme, statusColor, diskColor } from "../lib/theme.ts"
5
5
  import { formatBytes, diskUsedPct, bar, formatDate, timeAgo, truncate } from "../lib/format.ts"
6
- import { Field, StatusBadge, ControlPanel, type ActionGroup } from "./components.tsx"
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 } from "../lib/local.ts"
9
+ import { resolveLocalLink, type LocalLink } from "../lib/local.ts"
10
10
  import { normalizeDomain } from "../lib/dns.ts"
11
+ import { isVanityPair } from "../lib/vanitySite.ts"
11
12
  import type { Drift } from "../lib/gitStatus.ts"
12
13
  import { useStore, isUpgradeInFlight, isServerOpInFlight, isHttpsToggleInFlight, isPurgeCacheInFlight, purgeCacheFailed } from "./store.tsx"
13
14
  import type { Server, Site } from "../api/types.ts"
14
15
 
15
- export function ServerDetail({ server, siteCount, showControl = false }: { server: Server; siteCount: number; showControl?: boolean }) {
16
- const { rebootInfo, serverOps, isServerOsEol, sitesForServer, vanityJob } = useStore()
16
+ export function ServerDetail({ server, siteCount }: { server: Server; siteCount: number }) {
17
+ const { rebootInfo, serverOps, isServerOsEol } = useStore()
17
18
  const ds = server.disk_space
18
19
  const pct = diskUsedPct(ds?.used, ds?.total)
19
20
  const op = serverOps.get(server.id)
@@ -74,48 +75,53 @@ export function ServerDetail({ server, siteCount, showControl = false }: { serve
74
75
  valueColor={server.upgrade_required ? theme.warn : theme.good}
75
76
  />
76
77
  <Field label="Created" value={formatDate(server.created_at)} />
77
-
78
- {/* Server Control — mirrors Search's Site Control. Only rendered where the keys
79
- actually fire (the Browser's Servers pane), via showControl; Search's query
80
- mode passes it off and surfaces the pressable list in its actions focus. */}
81
- {showControl ? (
82
- <>
83
- <box style={{ height: 1 }} />
84
- <ControlPanel heading="Server Control" groups={serverControlGroups()} />
85
- </>
86
- ) : null}
87
78
  </box>
88
79
  )
80
+ }
89
81
 
90
- // The static groups, plus a Manage "V" row whenever the V key would actually do
91
- // something: the server has no site at its own hostname yet (busy servers
92
- // benefit from a vanity site just like empty ones), or an unfinished build for
93
- // this server can be reopened.
94
- function serverControlGroups(): ActionGroup[] {
95
- const resumable = vanityJob != null && vanityJob.step !== "done" && vanityJob.serverId === server.id
96
- const hasVanity = sitesForServer(server.id).some((s) => s.domain.toLowerCase() === server.name.toLowerCase())
97
- if (!resumable && hasVanity) return SERVER_CONTROL
98
- const vanityItem: [string, string] = ["V", resumable ? "Resume vanity-site build" : "Vanity site at hostname"]
99
- return SERVER_CONTROL.map((g) => (g.title === "Manage" ? { ...g, items: [...g.items, vanityItem] } : g))
100
- }
82
+ // Server Control groups, rendered by the Browser's bottom ControlStrip (layout
83
+ // "flow") or Search's Actions pane (layout "list", the default). Search's
84
+ // query-focus Details still shows ServerDetail without this.
85
+ export function ServerControl({ server, layout }: { server: Server; layout?: "list" | "flow" }) {
86
+ const { vanityJob, sitesForServer } = useStore()
87
+ return <ControlPanel heading="Server Control" groups={serverControlGroups(server, vanityJob, sitesForServer)} layout={layout} />
101
88
  }
102
89
 
90
+ // The static groups, plus a Manage "V" row whenever the V key would actually do
91
+ // something: the server has no site at its own hostname yet (busy servers
92
+ // benefit from a vanity site just like empty ones), or an unfinished build for
93
+ // this server can be reopened.
94
+ function serverControlGroups(
95
+ server: Server,
96
+ vanityJob: ReturnType<typeof useStore>["vanityJob"],
97
+ sitesForServer: ReturnType<typeof useStore>["sitesForServer"],
98
+ ): ActionGroup[] {
99
+ const resumable = vanityJob != null && vanityJob.step !== "done" && vanityJob.serverId === server.id
100
+ const hasVanity = sitesForServer(server.id).some((s) => s.domain.toLowerCase() === server.name.toLowerCase())
101
+ if (!resumable && hasVanity) return SERVER_CONTROL
102
+ const vanityItem: [string, string] = ["V", resumable ? "Resume vanity build" : "Vanity at hostname"]
103
+ return SERVER_CONTROL.map((g) => (g.title === "Manage" ? { ...g, items: [...g.items, vanityItem] } : g))
104
+ }
105
+
106
+ // Labels stay terse and avoid repeating their group's title — see siteGroups().
103
107
  export const SERVER_CONTROL: ActionGroup[] = [
104
- { title: "Clone", items: [["C", "Clone this server →"]] },
105
- { title: "Access", items: [["S", "Connect sudo"]] },
108
+ { title: "Clone", items: [["C", "Clone →"]] },
109
+ { title: "Access", items: [["S", "Sudo"], ["K", "Grant SSH key"]] },
106
110
  {
107
111
  title: "Manage",
108
112
  items: [
109
- ["a", "Reboot / restart services"],
110
- ["h", "Server health"],
111
- ["N", "DNS records (migrate lens)"],
113
+ ["a", "Reboot / restart"],
114
+ ["h", "Health"],
115
+ ["N", "DNS"],
112
116
  ],
113
117
  },
114
- { title: "Open", items: [["w", "Open in SpinupWP"]] },
118
+ { title: "Open", items: [["w", "In SpinupWP"]] },
115
119
  ]
116
120
 
117
121
  export function SiteDetail({ site, serverName }: { site: Site; serverName: string }) {
118
- const { probes, probingIds, isProbeStale, phpUpgrades, httpsToggles, purgeCacheProgress, localLinks, grantedKeyKinds, kumaStatus, kumaMonitorFor } = useStore()
122
+ const { probes, probingIds, isProbeStale, phpUpgrades, httpsToggles, purgeCacheProgress, localLinks, grantedKeyKinds, kumaStatus, kumaMonitorFor, kumaOps, localSync } = useStore()
123
+ const isVanity = isVanityPair(site.domain, serverName)
124
+ const reseedOp = kumaOps.get(site.id)
119
125
  const kuma = kumaStatus.get(site.domain)
120
126
  // The fingerprint check failing while HTTP is up means the site is answering
121
127
  // 200 with the WRONG page (stale/corrupt page cache) — a distinct, worse state
@@ -195,6 +201,8 @@ export function SiteDetail({ site, serverName }: { site: Site; serverName: strin
195
201
  <Field label="Granted" value={keyValue} valueColor={keyParts.length ? theme.good : theme.textFaint} />
196
202
  <Field label="Public dir" value={site.public_folder ?? "/"} />
197
203
  <Field label="Monitor" value={kumaValue} valueColor={kumaColor} />
204
+ {isVanity && reseedOp?.status === "running" && <Field label="Refresh" value="publishing…" valueColor={theme.warn} />}
205
+ {isVanity && reseedOp?.status === "error" && <Field label="Refresh" value={`failed: ${reseedOp.error ?? "unknown error"}`} valueColor={theme.bad} />}
198
206
  <Field label="Local" value={linkValue} valueColor={linkColor} />
199
207
  <SiteDnsSection site={site} />
200
208
  <Field
@@ -265,6 +273,16 @@ export function SiteDetail({ site, serverName }: { site: Site; serverName: strin
265
273
  )
266
274
  }
267
275
 
276
+ // Site Control groups — see ServerControl above. Recomputes stack/vanity
277
+ // itself since it's not a child of SiteDetail; both derivations are cheap.
278
+ export function SiteControl({ site, serverName, layout }: { site: Site; serverName: string; layout?: "list" | "flow" }) {
279
+ const { probes, localSync } = useStore()
280
+ const stack = effectiveStack(site, probes.get(site.id)?.result.kind)
281
+ const isWordPress = stack !== "Non-WP"
282
+ const isVanity = isVanityPair(site.domain, serverName)
283
+ return <ControlPanel heading="Site Control" groups={siteGroups(isWordPress, localSync, isVanity)} layout={layout} />
284
+ }
285
+
268
286
  // DNS zone-host lines for a site's domains, populated on demand (key `n`). Each
269
287
  // distinct zone (www + apex collapsed) shows its resolved host; a separate-TLD
270
288
  // additional domain surfaces as its own line with its own host. Read-only.
@@ -325,6 +343,38 @@ function Act({ keyName, label, on }: { keyName: string; label: string; on: boole
325
343
  )
326
344
  }
327
345
 
346
+ // Shared status line: the selected site's local-link state (not linked / missing
347
+ // / linked, with path + local URL + git drift). Used by both SiteContextStrip
348
+ // (Stacks — status + inline t/v/L only) and ControlStrip (Browser — status +
349
+ // the full action-group list) so the two don't drift apart.
350
+ function siteLinkStatusLine(site: Site, link: LocalLink | undefined, state: ReturnType<typeof resolveLocalLink> | null, d: Drift | null | undefined) {
351
+ if (!link) {
352
+ return (
353
+ <box style={{ flexDirection: "row" }}>
354
+ <text content={truncate(site.domain, 44)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
355
+ <text content=" not linked" fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
356
+ </box>
357
+ )
358
+ }
359
+ if (!state!.exists) {
360
+ return (
361
+ <box style={{ flexDirection: "row" }}>
362
+ <text content="missing " fg={theme.bad} style={{ flexShrink: 0 }} />
363
+ <text content={link.path} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
364
+ </box>
365
+ )
366
+ }
367
+ return (
368
+ <box style={{ flexDirection: "row" }}>
369
+ <text content={state!.label} fg={localLabelColor(state!.kind)} style={{ flexShrink: 0 }} />
370
+ <text content=" · " fg={theme.textFaint} style={{ flexShrink: 0 }} />
371
+ <text content={link.path} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
372
+ {link.localUrl ? <text content={" " + link.localUrl} fg={theme.accent} wrapMode="none" style={{ flexShrink: 1 }} /> : null}
373
+ {d && (d.ahead > 0 || d.dirty) ? <text content={" " + driftLabel(d)} fg={theme.warn} wrapMode="none" style={{ flexShrink: 0 }} /> : null}
374
+ </box>
375
+ )
376
+ }
377
+
328
378
  // Height (incl. border) of the contextual strip — views subtract this from their
329
379
  // list viewport so the panes shrink to make room. Border eats 2 rows → 2 content
330
380
  // lines (status + actions).
@@ -335,6 +385,10 @@ export const SITE_CONTEXT_STRIP_HEIGHT = 4
335
385
  // open-locally actions (which the host view's keyboard fires on the selection).
336
386
  // The space is always reserved (a placeholder when nothing is selected) so the
337
387
  // layout doesn't jump as the cursor moves.
388
+ //
389
+ // Used by views (Stacks) whose keyboard handler only implements a subset of
390
+ // siteGroups()'s keys — ControlStrip below, which advertises the FULL action
391
+ // list, would be misleading there (hints for keys that don't actually fire).
338
392
  export function SiteContextStrip({ site }: { site: Site | null }) {
339
393
  const { localLinks, drift, ensureDrift } = useStore()
340
394
  const link = site ? localLinks.get(site.id) : undefined
@@ -364,28 +418,8 @@ export function SiteContextStrip({ site }: { site: Site | null }) {
364
418
  <text content="↑N " fg={theme.warn} style={{ flexShrink: 0 }} />
365
419
  <text content="pending WordPress updates" fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
366
420
  </box>
367
- ) : !link ? (
368
- <box style={{ flexDirection: "row" }}>
369
- <text content={truncate(site.domain, 44)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
370
- <text content=" not linked" fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
371
- </box>
372
- ) : !state!.exists ? (
373
- <box style={{ flexDirection: "row" }}>
374
- <text content="missing " fg={theme.bad} style={{ flexShrink: 0 }} />
375
- <text content={link.path} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
376
- </box>
377
421
  ) : (
378
- <box style={{ flexDirection: "row" }}>
379
- <text content={state!.label} fg={localLabelColor(state!.kind)} style={{ flexShrink: 0 }} />
380
- <text content=" · " fg={theme.textFaint} style={{ flexShrink: 0 }} />
381
- <text content={link.path} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
382
- {link.localUrl ? (
383
- <text content={" " + link.localUrl} fg={theme.accent} wrapMode="none" style={{ flexShrink: 1 }} />
384
- ) : null}
385
- {d && (d.ahead > 0 || d.dirty) ? (
386
- <text content={" " + driftLabel(d)} fg={theme.warn} wrapMode="none" style={{ flexShrink: 0 }} />
387
- ) : null}
388
- </box>
422
+ siteLinkStatusLine(site, link, state, d)
389
423
  )}
390
424
 
391
425
  {/* Line 2 — inline actions (fired by the host view on the selection) */}
@@ -401,3 +435,67 @@ export function SiteContextStrip({ site }: { site: Site | null }) {
401
435
  </box>
402
436
  )
403
437
  }
438
+
439
+ // Height (incl. border) of the bottom control strip — views subtract this from
440
+ // their list viewport so the panes shrink to make room. `ControlPanel`'s
441
+ // layout="flow" skips its own heading (the strip's box title already says
442
+ // "Site Control"/"Server Control") and puts each group's title in its own
443
+ // column rather than its own line — both to keep this budget small even
444
+ // though every group also gets a 1-row marginBottom for breathing room.
445
+ // Sized for the worst realistic case (a WP + local-sync + vanity site — 5
446
+ // groups: Open/Remote/Local/Vanity/Server, each ≤1 line of items given how
447
+ // terse the labels are + its trailing blank row): 2 border + 1 status + 1
448
+ // spacer + 5 groups × 2 (content + blank). A lighter site/server just leaves
449
+ // blank rows — fixed and generous beats measuring actual wrapped height,
450
+ // which would need a measure-then-rerender pass this app has no precedent for.
451
+ export const CONTROL_STRIP_HEIGHT = 14
452
+
453
+ // A full-width strip below the three panes (Browser's Servers tab only — see
454
+ // the SiteContextStrip note above for why) showing whichever action list
455
+ // applies to what's focused: the selected site's Site Control (status line +
456
+ // Open/Remote/Local/Vanity/Server, `layout="flow"`) or, with focus on the
457
+ // Servers pane, the selected server's Server Control (status line + Clone/
458
+ // Access/Manage/Open). The space is always reserved (a placeholder when
459
+ // nothing is selected) so the layout doesn't jump as the cursor moves.
460
+ export function ControlStrip({ site, server, serverName }: { site: Site | null; server: Server | null; serverName: string }) {
461
+ const { localLinks, drift, ensureDrift } = useStore()
462
+ const link = site ? localLinks.get(site.id) : undefined
463
+ const state = link ? resolveLocalLink(link) : null
464
+ const d = site ? drift.get(site.id) : undefined
465
+
466
+ // Auto-compute (and cache) git drift when a linked, on-disk copy is shown.
467
+ useEffect(() => {
468
+ if (site && link && state?.exists) ensureDrift(site.id, link.path)
469
+ }, [site?.id, link?.path, state?.exists, ensureDrift])
470
+
471
+ return (
472
+ <box
473
+ title={site ? " Site Control " : server ? " Server Control " : " Control "}
474
+ titleColor={theme.brand}
475
+ border
476
+ borderColor={link && !state!.exists ? theme.bad : theme.border}
477
+ style={{ height: CONTROL_STRIP_HEIGHT, flexDirection: "column", paddingLeft: 1, paddingRight: 1 }}
478
+ >
479
+ {/* Status line */}
480
+ {site ? (
481
+ siteLinkStatusLine(site, link, state, d)
482
+ ) : server ? (
483
+ <box style={{ flexDirection: "row" }}>
484
+ <text content={truncate(server.name, 44)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
485
+ <text content={" " + (server.ip_address ?? "—")} fg={theme.accent} wrapMode="none" style={{ flexShrink: 0 }} />
486
+ <text content={" · " + server.connection_status} fg={statusColor(server.connection_status)} wrapMode="none" style={{ flexShrink: 0 }} />
487
+ </box>
488
+ ) : (
489
+ <text content="Select a server or site to see what you can do with it" fg={theme.textFaint} wrapMode="none" />
490
+ )}
491
+ <box style={{ height: 1 }} />
492
+
493
+ {/* Action groups */}
494
+ {site ? (
495
+ <SiteControl site={site} serverName={serverName} layout="flow" />
496
+ ) : server ? (
497
+ <ServerControl server={server} layout="flow" />
498
+ ) : null}
499
+ </box>
500
+ )
501
+ }
@@ -368,25 +368,96 @@ export function Centered({ children }: { children: ReactNode }) {
368
368
  )
369
369
  }
370
370
 
371
+ // Widest group title ("REMOTE"/"SERVER"/"VANITY"/"ACCESS"/"MANAGE" = 6 chars)
372
+ // plus a little padding — the fixed label column width for layout="flow".
373
+ const FLOW_LABEL_WIDTH = 8
374
+
371
375
  // A grouped, key-chipped action legend ("Site Control" / "Server Control"): a heading,
372
- // then dim group titles each over a list of `key → outcome` rows. Shared by the Search
373
- // actions pane and the Servers detail pane so the suite reads the same everywhere.
376
+ // then dim group titles each over its `key → outcome` items. Shared by the Search
377
+ // actions pane and the Servers tab's bottom Control strip so the suite reads the
378
+ // same everywhere. `layout="list"` (default) puts the title on its own line above
379
+ // one item per line — right for a narrow column. `layout="flow"` puts the title in
380
+ // its own fixed-width left column (`alignItems: "flex-start"` keeps it pinned to
381
+ // the top rather than stretching/centering when the row wraps) with items flowing
382
+ // and wrapping in the remaining width — right for a full-width strip, where a long
383
+ // group (e.g. REMOTE) can fit several items per line instead of one. Each item is
384
+ // always its own box so "flow" wraps between whole items, never mid-label.
374
385
  export type ActionGroup = { title: string; items: [string, string][] }
375
- export function ControlPanel({ heading, groups }: { heading: string; groups: ActionGroup[] }) {
386
+ export function ControlPanel({ heading, groups, layout = "list" }: { heading: string; groups: ActionGroup[]; layout?: "list" | "flow" }) {
376
387
  return (
377
388
  <box style={{ flexDirection: "column" }}>
378
- <text content={heading} fg={theme.accent} />
379
- {groups.map((group) => (
380
- <box key={group.title} style={{ flexDirection: "column" }}>
381
- <text content={group.title.toUpperCase()} fg={theme.textFaint} wrapMode="none" />
382
- {group.items.map(([k, label]) => (
383
- <box key={k} style={{ flexDirection: "row" }}>
384
- <text content={` ${k} `} fg={theme.bg} bg={theme.brandDim} style={{ flexShrink: 0 }} />
385
- <text content={` ${label}`} fg={theme.text} wrapMode="none" />
389
+ {/* "flow" is only ever used inside a bordered box whose own title already
390
+ says "Site Control"/"Server Control" — skip the redundant heading line. */}
391
+ {layout === "list" && <text content={heading} fg={theme.accent} />}
392
+ {groups.map((group) =>
393
+ layout === "flow" ? (
394
+ <box key={group.title} style={{ flexDirection: "row", alignItems: "flex-start", marginBottom: 1 }}>
395
+ <box style={{ width: FLOW_LABEL_WIDTH, flexShrink: 0 }}>
396
+ <text content={group.title.toUpperCase()} fg={theme.textFaint} wrapMode="none" />
386
397
  </box>
387
- ))}
388
- </box>
389
- ))}
398
+ <box style={{ flexDirection: "row", flexWrap: "wrap", flexGrow: 1 }}>
399
+ {group.items.map(([k, label]) => (
400
+ <box key={k} style={{ flexDirection: "row", marginRight: 2 }}>
401
+ <text content={` ${k} `} fg={theme.bg} bg={theme.brandDim} style={{ flexShrink: 0 }} />
402
+ <text content={` ${label}`} fg={theme.text} wrapMode="none" />
403
+ </box>
404
+ ))}
405
+ </box>
406
+ </box>
407
+ ) : (
408
+ <box key={group.title} style={{ flexDirection: "column" }}>
409
+ <text content={group.title.toUpperCase()} fg={theme.textFaint} wrapMode="none" />
410
+ {group.items.map(([k, label]) => (
411
+ <box key={k} style={{ flexDirection: "row" }}>
412
+ <text content={` ${k} `} fg={theme.bg} bg={theme.brandDim} style={{ flexShrink: 0 }} />
413
+ <text content={` ${label}`} fg={theme.text} wrapMode="none" />
414
+ </box>
415
+ ))}
416
+ </box>
417
+ ),
418
+ )}
390
419
  </box>
391
420
  )
392
421
  }
422
+
423
+ // Site Control groups for ControlPanel — shared by Search's Actions pane and the
424
+ // Browser tab's Details pane so the suite reads the same, and is discoverable,
425
+ // everywhere a site can be selected. Labels are kept terse and avoid repeating
426
+ // their group's title (e.g. "In browser" under OPEN, not "Open site in browser")
427
+ // since the group label is always visible right next to them (as its own column
428
+ // in layout="flow", or its own line in layout="list").
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
433
+ // own server's name, per `isVanityPair`) adds the one-off page-refresh action —
434
+ // vanity pages seeded before this app existed need a way to pick up the current
435
+ // bundled HTML.
436
+ export function siteGroups(isWordpress: boolean, localSync: boolean, isVanity: boolean): ActionGroup[] {
437
+ const remote: [string, string][] = [
438
+ ["s", "SSH"],
439
+ ["K", "Grant SSH key"],
440
+ ["n", "DNS"],
441
+ ]
442
+ if (isWordpress) {
443
+ remote.push(["d", "↓ DB backup"])
444
+ if (localSync) remote.push(["p", "Pull prod. DB"])
445
+ }
446
+ const local: [string, string][] = [
447
+ ["t", "Terminal"],
448
+ ["v", "Local URL"],
449
+ ["L", "Link / edit"],
450
+ ]
451
+ if (isWordpress) local.push(["m", "Media fallback"])
452
+ remote.push(["u", "PHP ver."])
453
+ remote.push(["H", "Toggle HTTPS"])
454
+ remote.push(["P", "Purge cache"])
455
+ remote.push(["M", "Monitoring"])
456
+ return [
457
+ { title: "Open", items: [["o", "In browser"], ["w", "In SpinupWP"]] },
458
+ { title: "Remote", items: remote },
459
+ { title: "Local", items: local },
460
+ ...(isVanity ? [{ title: "Vanity", items: [["R", "Refresh"]] as [string, string][] }] : []),
461
+ { title: "Server", items: [["a", "Reboot / restart"], ["h", "Health"]] },
462
+ ]
463
+ }
@@ -10,9 +10,9 @@ import { useKeyboard } from "@opentui/react"
10
10
  import { theme, statusColor, statusDot } from "../../lib/theme.ts"
11
11
  import { effectiveStack, stackColor, stackTag } from "../../lib/stack.ts"
12
12
  import { truncate } from "../../lib/format.ts"
13
- import { Panel, PhpVersionCell, Spinner, SiteStatusColumn } from "../components.tsx"
13
+ import { Panel, PhpVersionCell, Spinner } from "../components.tsx"
14
14
  import { List, moveSelection } from "../List.tsx"
15
- import { ServerDetail, SiteDetail, SiteContextStrip, SITE_CONTEXT_STRIP_HEIGHT } from "../Details.tsx"
15
+ import { ServerDetail, SiteDetail, ControlStrip, CONTROL_STRIP_HEIGHT } from "../Details.tsx"
16
16
  import { StatusBar } from "../StatusBar.tsx"
17
17
  import { openUrl } from "../../lib/open.ts"
18
18
  import { serverWebUrl, siteWebUrl } from "../../lib/spinupweb.ts"
@@ -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, grantedKeyKinds, setServerActionsServer, serverOps, setLocalLinkSite, openLocalTerminal, openLocalUrl, localLinks, sshSite, setDnsInventoryServer, setNewServerSource, setNewServerOpen, setVanityServer, vanityJob, beginClone, isServerOsEol, setKumaSite } = 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 } = store
27
27
 
28
28
  const [serverIndex, setServerIndex] = useState(0)
29
29
  const [siteIndex, setSiteIndex] = useState(0)
@@ -117,12 +117,28 @@ export function Browser({ rows }: { rows: number }) {
117
117
  // is taken), so the capital works everywhere.
118
118
  if (focus === "sites" && sites[siteIndex]) setKumaSite(sites[siteIndex])
119
119
  return
120
- case "K":
121
- // Grant Spinup's machine key to the selected site over SSH (privileged
122
- // write via the server's sudo user — the API can't do this). Capital K
123
- // (like L/V/N) since lowercase k is the vim "move up" binding.
124
- if (focus === "sites" && sites[siteIndex]) setGrantKeySite(sites[siteIndex])
120
+ case "R": {
121
+ // Refresh a vanity page's HTML — only the one site per server whose
122
+ // domain equals the server's own name. Same binding/behavior as the
123
+ // m-overlay's R (KumaSite.tsx): fires directly, no confirm, since it
124
+ // just republishes the same bundled page that's already live.
125
+ const s = sites[siteIndex]
126
+ if (focus === "sites" && s && server && isVanityPair(s.domain, server.name)) {
127
+ return kumaConfigured ? startKumaSetup(s, { reseed: true }) : startVanityReseed(s)
128
+ }
129
+ return
130
+ }
131
+ case "K": {
132
+ // Grant Spinup's machine key to a site over SSH (privileged write via the
133
+ // server's sudo user — the API can't do this). Capital K (like L/V/N)
134
+ // since lowercase k is the vim "move up" binding. Site-anchored (the
135
+ // overlay itself picks the scope: just this site, or every site on the
136
+ // server) but works from either pane like S/h — the Servers pane falls
137
+ // back to the server's first key-eligible site as the anchor.
138
+ const anchor = focus === "sites" ? sites[siteIndex] : server ? sitesForServer(server.id).find((s) => s.site_user) : undefined
139
+ if (anchor) setGrantKeySite(anchor)
125
140
  return
141
+ }
126
142
  case "L":
127
143
  // Link / view the selected site's local working copy (Phase 1).
128
144
  if (focus === "sites" && sites[siteIndex]) setLocalLinkSite(sites[siteIndex])
@@ -217,41 +233,32 @@ export function Browser({ rows }: { rows: number }) {
217
233
  }
218
234
  })
219
235
 
220
- const listRows = Math.max(3, rows - 6 - SITE_CONTEXT_STRIP_HEIGHT)
236
+ const listRows = Math.max(3, rows - 6 - CONTROL_STRIP_HEIGHT)
221
237
  const focusedSite = sites[Math.min(siteIndex, Math.max(0, sites.length - 1))]
222
238
 
223
239
  const hints =
224
240
  focus === "servers"
225
241
  ? [
242
+ // The Control strip's Server Control list now shows every per-server
243
+ // key (S/a/N/h/w) — only nav + list-level actions stay here so the
244
+ // two can't drift out of sync. `c` isn't in Server Control (it's not
245
+ // an action ON the selected server, it creates a new one).
226
246
  { key: "↑↓/jk", label: "select" },
227
247
  { key: "→/⏎", label: "view sites" },
228
248
  { key: "c", label: "new server" },
229
- { key: "S", label: "connect sudo" },
230
- { key: "a", label: "server actions" },
231
- { key: "N", label: "DNS hosts" },
232
- { key: "h", label: "health" },
233
- { key: "w", label: "SpinupWP" },
234
249
  ]
235
250
  : [
251
+ // The Control strip's Site Control list now shows every per-site
252
+ // key — this footer is nav-only so the two can't drift out of sync.
236
253
  { key: "↑↓/jk", label: "select site" },
237
254
  { key: "←/esc", label: "back" },
238
- { key: "d", label: "identify app" },
239
- { key: "n", label: "DNS host" },
240
- { key: "u", label: "change PHP" },
241
- { key: "m", label: "monitoring" },
242
- { key: "K", label: "grant key" },
243
- { key: "p", label: "plugins" },
244
- { key: "o", label: "open" },
245
- { key: "w", label: "SpinupWP" },
246
- { key: "s", label: "ssh" },
247
- { key: "h", label: "health" },
248
255
  ]
249
256
 
250
257
  return (
251
258
  <box style={{ flexGrow: 1, flexDirection: "column" }}>
252
259
  <box style={{ flexGrow: 1, flexDirection: "row", padding: 1, gap: 1 }}>
253
260
  {/* Servers pane */}
254
- <Panel title={` Servers (${sortedServers.length}) `} active={focus === "servers"} width={44}>
261
+ <Panel title={` Servers (${sortedServers.length}) `} active={focus === "servers"} width="18%">
255
262
  <List
256
263
  items={sortedServers}
257
264
  selectedIndex={serverIndex}
@@ -291,11 +298,7 @@ export function Browser({ rows }: { rows: number }) {
291
298
 
292
299
  {/* Sites pane */}
293
300
  <Panel
294
- title={
295
- server
296
- ? ` Sites · ${truncate(server.name, 20)} (${sites.length}) 👤🔑 keys · H·C·B https/cache/backup `
297
- : " Sites "
298
- }
301
+ title={server ? ` Sites · ${truncate(server.name, 20)} (${sites.length}) ` : " Sites "}
299
302
  active={focus === "sites"}
300
303
  flexGrow={1}
301
304
  >
@@ -307,13 +310,11 @@ export function Browser({ rows }: { rows: number }) {
307
310
  keyFor={(s) => s.id}
308
311
  emptyText="No sites yet — press V to create a vanity site and connect this server"
309
312
  renderRow={(s, selected) => {
310
- const updates = (s.wp_plugin_updates || 0) + (s.wp_theme_updates || 0) + (s.wp_core_update ? 1 : 0)
311
313
  // Use the DETECTED stack (probe result) so the tag reflects `d identify`
312
314
  // — SpinupWP's is_wordpress/git shape misclassifies some sites (e.g. a
313
315
  // /public/ WP install shown as "app" until probed). Falls back to the
314
316
  // API classification when unprobed. Matches the Stacks / Forgotten views.
315
317
  const stack = effectiveStack(s, probes.get(s.id)?.result.kind)
316
- const keyKinds = grantedKeyKinds(s.id)
317
318
  // The server's own vanity/health page blends in perfectly when servers
318
319
  // are named like domains — mark it (⌂ + brand tint) so it can't hide.
319
320
  const vanity = !!server && isVanityPair(s.domain, server.name)
@@ -326,19 +327,11 @@ export function Browser({ rows }: { rows: number }) {
326
327
  wrapMode="none"
327
328
  style={{ flexGrow: 1, flexShrink: 1 }}
328
329
  />
329
- <SiteStatusColumn
330
- linked={localLinks.has(s.id)}
331
- personalKey={keyKinds.personal > 0}
332
- machineKey={keyKinds.machine > 0}
333
- https={!!s.https?.enabled}
334
- cache={!!s.page_cache?.enabled}
335
- backup={!!(s.backups?.files || s.backups?.database)}
336
- updates={updates}
337
- selected={selected}
338
- />
339
- {/* Fixed-width stack column (widest tag = "bedrock" = 7) so the
340
- icon columns to its left, and the PHP column to its right,
341
- stay vertically aligned across every row regardless of tag. */}
330
+ {/* Fixed-width stack column (widest tag = "bedrock" = 7) so it and
331
+ the PHP column stay vertically aligned across every row. Per-site
332
+ linked/key/HTTPS/cache/backup/updates badges moved to the Details
333
+ pane (already shown there for the selected site) to give the
334
+ domain name room on narrower terminals. */}
342
335
  <text content={stackTag(stack).padEnd(7) + " "} fg={stackColor(stack, selected)} wrapMode="none" style={{ flexShrink: 0 }} />
343
336
  <PhpVersionCell version={s.php_version} upgrade={phpUpgrades.get(s.id)} selected={selected} />
344
337
  </>
@@ -348,17 +341,21 @@ export function Browser({ rows }: { rows: number }) {
348
341
  </Panel>
349
342
 
350
343
  {/* Detail pane */}
351
- <Panel title=" Details " width={44}>
344
+ <Panel title=" Details " width="32%">
352
345
  {focus === "sites" && focusedSite ? (
353
346
  <SiteDetail site={focusedSite} serverName={server?.name ?? "—"} />
354
347
  ) : server ? (
355
- <ServerDetail server={server} siteCount={sites.length} showControl />
348
+ <ServerDetail server={server} siteCount={sites.length} />
356
349
  ) : (
357
350
  <text content="No data" fg={theme.textFaint} />
358
351
  )}
359
352
  </Panel>
360
353
  </box>
361
- <SiteContextStrip site={focus === "sites" ? focusedSite ?? null : null} />
354
+ <ControlStrip
355
+ site={focus === "sites" ? focusedSite ?? null : null}
356
+ server={focus === "servers" ? server ?? null : null}
357
+ serverName={server?.name ?? "—"}
358
+ />
362
359
  <StatusBar hints={hints} message={flash ?? undefined} messageColor={theme.brand} />
363
360
  </box>
364
361
  )
@@ -9,7 +9,7 @@ import { useKeyboard } from "@opentui/react"
9
9
  import { theme, statusColor, statusDot } from "../../lib/theme.ts"
10
10
  import { classifyStack, stackColor, stackTag } from "../../lib/stack.ts"
11
11
  import { truncate } from "../../lib/format.ts"
12
- import { Panel, Field, StatusBadge, SiteMetaCell, Spinner, ControlPanel } from "../components.tsx"
12
+ import { Panel, Field, StatusBadge, SiteMetaCell, Spinner, ControlPanel, siteGroups } from "../components.tsx"
13
13
  import { isDbBackupInFlight } from "../../lib/dbBackup.ts"
14
14
  import { isDbSyncInFlight } from "../../lib/dbSync.ts"
15
15
  import { List, moveSelection } from "../List.tsx"
@@ -17,6 +17,7 @@ import { ServerDetail, SiteDetail, SERVER_CONTROL } from "../Details.tsx"
17
17
  import { StatusBar } from "../StatusBar.tsx"
18
18
  import { openUrl } from "../../lib/open.ts"
19
19
  import { serverWebUrl, siteWebUrl } from "../../lib/spinupweb.ts"
20
+ import { isVanityPair } from "../../lib/vanitySite.ts"
20
21
  import { useStore } from "../store.tsx"
21
22
  import type { Server, Site } from "../../api/types.ts"
22
23
 
@@ -34,7 +35,7 @@ function score(haystack: string, q: string): number | null {
34
35
 
35
36
  export function Search({ rows }: { rows: number }) {
36
37
  const store = useStore()
37
- 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 } = 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 } = store
38
39
  const [query, setQuery] = useState("")
39
40
  const [selected, setSelected] = useState(0)
40
41
  // "query" = typing/filtering (input focused); "actions" = input blurred so the
@@ -195,6 +196,14 @@ export function Search({ rows }: { rows: number }) {
195
196
  // fallback here; M matches the same overlay's binding in Stacks.
196
197
  if (current?.kind === "site") setKumaSite(current.site)
197
198
  return
199
+ case "R":
200
+ // Refresh a vanity page's HTML (site-only, and only for the one site per
201
+ // server whose domain equals the server's own name). Same binding/behavior
202
+ // as the m-overlay's R (KumaSite.tsx) — fires directly, no confirm, since
203
+ // it just republishes the same bundled page that's already live.
204
+ if (current?.kind === "site" && isVanityPair(current.site.domain, serverById(current.site.server_id)?.name ?? ""))
205
+ return kumaConfigured ? startKumaSetup(current.site, { reseed: true }) : startVanityReseed(current.site)
206
+ return
198
207
  case "n":
199
208
  // DNS inventory scoped to this site (its domains + records) — the migrate
200
209
  // lens for moving one site to another server.
@@ -241,7 +250,7 @@ export function Search({ rows }: { rows: number }) {
241
250
  focus === "query"
242
251
  ? [
243
252
  { key: "↑↓", label: "select" },
244
- { key: "⏎", label: "open site" },
253
+ ...(current?.kind === "site" ? [{ key: "⏎", label: "open site" }] : []),
245
254
  { key: "Tab/→", label: "actions" },
246
255
  { key: "esc", label: "dashboard" },
247
256
  ]
@@ -254,15 +263,9 @@ export function Search({ rows }: { rows: number }) {
254
263
  { key: "←/esc", label: "back" },
255
264
  ]
256
265
  : [
266
+ // The Actions panel (Details pane) already lists every per-site key —
267
+ // this footer is nav-only now, so the two can't drift out of sync.
257
268
  { key: "↑↓", label: "select" },
258
- { key: "o", label: "open" },
259
- { key: "s", label: "SSH" },
260
- { key: "n", label: "DNS" },
261
- { key: "d", label: "DB backup" },
262
- ...(localSync ? [{ key: "p", label: "pull to local" }] : []),
263
- ...(current?.kind === "site" && current.site.is_wordpress && localLinks.has(current.site.id) ? [{ key: "m", label: "media fallback" }] : []),
264
- { key: "M", label: "monitoring" },
265
- { key: "a", label: "server actions" },
266
269
  { key: "←/esc", label: "back" },
267
270
  ]
268
271
 
@@ -352,7 +355,7 @@ export function Search({ rows }: { rows: number }) {
352
355
  />
353
356
  </Panel>
354
357
 
355
- <Panel title={focus === "actions" ? " Actions " : " Details "} active={focus === "actions"} width={44}>
358
+ <Panel title={focus === "actions" ? " Actions " : " Details "} active={focus === "actions"} width={64}>
356
359
  {!current ? (
357
360
  <text content="No selection" fg={theme.textFaint} />
358
361
  ) : focus === "actions" ? (
@@ -374,48 +377,12 @@ export function Search({ rows }: { rows: number }) {
374
377
  )
375
378
  }
376
379
 
377
- // Site Control panel shown in the Details pane while in "actions" focus. Groups
378
- // the live single-key actions (Open / Remote / Local / Server) so the suite is
379
- // discoverable and scales as more actions land. A site gets the full set; a
380
- // server gets just the open + server actions.
381
- type ActionGroup = { title: string; items: [string, string][] }
382
-
383
- // The DB backup/sync use wp-cli, so they only apply to WordPress sites — omitted
384
- // for non-WP sites (their `d`/`p` keypresses are also guarded in the handler). DNS
385
- // (`n`) and the rest apply to any site.
386
- function siteGroups(isWordpress: boolean, localSync: boolean): ActionGroup[] {
387
- const remote: [string, string][] = [
388
- ["s", "SSH into the site"],
389
- ["n", "DNS records (migrate lens)"],
390
- ]
391
- if (isWordpress) {
392
- remote.push(["d", "Download DB backup"])
393
- if (localSync) remote.push(["p", "Pull production DB to local"])
394
- }
395
- const local: [string, string][] = [
396
- ["t", "Open working copy in a terminal"],
397
- ["v", "Open the local URL"],
398
- ["L", "Link / edit local copy"],
399
- ]
400
- if (isWordpress) local.push(["m", "Media: serve missing images from production"])
401
- remote.push(["u", "Upgrade PHP version"])
402
- remote.push(["H", "Enable/disable HTTPS"])
403
- remote.push(["P", "Purge page + object cache"])
404
- remote.push(["M", "Monitoring (Uptime Kuma + front-page check)"])
405
- return [
406
- { title: "Open", items: [["o", "Open site in browser"], ["w", "Open in SpinupWP"]] },
407
- { title: "Remote", items: remote },
408
- { title: "Local", items: local },
409
- { title: "Server", items: [["a", "Server actions (reboot / restart)"], ["h", "Server health"]] },
410
- ]
411
- }
412
-
413
380
  function ActionsCard({ result, serverName, localSync }: { result: Result; serverName: string; localSync: boolean }) {
414
381
  const isSite = result.kind === "site"
415
382
  const name = isSite ? result.site.domain : result.server.name
416
383
  const status = isSite ? result.site.status : result.server.connection_status
417
384
  // Server results reuse the SAME Server Control suite as the Browser detail pane.
418
- const groups = isSite ? siteGroups(result.site.is_wordpress, localSync) : SERVER_CONTROL
385
+ const groups = isSite ? siteGroups(result.site.is_wordpress, localSync, isVanityPair(result.site.domain, serverName)) : SERVER_CONTROL
419
386
  return (
420
387
  <box style={{ flexDirection: "column" }}>
421
388
  <box style={{ flexDirection: "row" }}>