spinuptui 0.21.1 → 0.22.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,99 @@ versions; such changes are called out here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.22.0] - 2026-07-13
15
+
16
+ ### Added
17
+ - **`spinuptui ssh-exec <domain> -- <command>`** — resolves the domain's SSH
18
+ target (same as `spinuptui ssh`) and runs `<command>` on it, but only if
19
+ it's read-only: anything matching a write/restart/destructive pattern
20
+ (plugin/theme changes, DB writes, service restarts, redirecting output to
21
+ a real file, package installs, etc.) is denied before it ever reaches the
22
+ server. Built for external tooling that needs to run diagnostic commands
23
+ unattended — any agent configured to touch a server only through this
24
+ command inherits the read-only guarantee without building its own guard.
25
+ Prints structured JSON (`stdout`/`stderr`/`exitCode` on success,
26
+ `reason:"command_denied"` on refusal) and logs every attempt — allowed,
27
+ denied, or unresolved — to `<config dir>/logs/ssh-exec-audit.jsonl`.
28
+ - **Refreshing a vanity page's HTML is now discoverable from where you'd look
29
+ for it.** A shared, grouped, key-chipped action list (`siteGroups()`) now
30
+ shows every per-site action — including `R` (refresh vanity page) for the
31
+ one site per server whose domain is the server's own hostname — in Search's
32
+ Actions pane and, on the Servers tab, in the bottom **Control strip**
33
+ (below). Previously `R` only lived inside the `m` monitoring overlay.
34
+ - **The Servers tab's bottom "Local" drawer is now a full-width Control
35
+ strip.** With a site selected it shows Site Control (Open/Remote/Local/
36
+ Vanity/Server); with a server selected (no site drilled into) it now shows
37
+ real **Server Control** (Clone/Access/Manage/Open) instead of a generic
38
+ placeholder. Each group's items flow and wrap across the full terminal
39
+ width instead of stacking one per line, so even a long group like Remote
40
+ fits in 1 line — each group's label sits in its own left column (pinned to
41
+ the top if its items wrap), and item labels are terser now that they don't
42
+ need to repeat their group's name (e.g. "In browser" under OPEN, not "Open
43
+ site in browser"). The Sites list also dropped its per-row status badges
44
+ (linked/keys/HTTPS/cache/backup/updates) — that info is already shown in
45
+ the Details pane for the selected site — to give the domain name more room;
46
+ Servers/Sites/Details are back to a simple 3-pane row.
47
+
48
+ ### Changed
49
+ - **Servers and Search footers no longer duplicate the action list.** Both
50
+ views' key hints for a selected site are now nav-only (`select` / `back`);
51
+ the full, always-in-sync action list lives in the Control strip (or
52
+ Search's Actions pane). Also fixed a stale Search hint (`⏎ open site`) that was
53
+ shown even for a selected server row, where Enter is a no-op.
54
+
55
+ ## [0.21.2] - 2026-07-10
56
+
57
+ ### Fixed
58
+ - **Onboarding's API token link was a 404.** `https://spinupwp.app/account/api/`
59
+ no longer resolves, and the correct URL is account-specific (a company slug
60
+ we can't know before the user has a token) — replaced with generic
61
+ "Settings → API Tokens" navigation instructions in onboarding and the README.
62
+ - **Bedrock detection now works at any nesting depth, everywhere it matters.**
63
+ A Bedrock install whose project root isn't directly under the site's files
64
+ root (e.g. moved into its own subdirectory) could go undetected — the
65
+ plugins/themes view (`p`) reported "no WordPress core found" even though the
66
+ site worked fine and SpinupWP itself knew it was WordPress. Detection now
67
+ anchors on the site's configured Public Folder (falling back to a bounded
68
+ search only when that's unset) instead of a fixed, shallow search depth:
69
+ - `p` (plugins/themes) finds core correctly.
70
+ - `d` (identify app) correctly labels it "Bedrock" again, not just generic
71
+ WordPress.
72
+ - The clone wizard's Bedrock pull no longer hardcodes `web/`. It detects the
73
+ real project root independently on both source and destination — they're
74
+ not assumed to match, since the destination's layout comes from the git
75
+ repo's own committed content, not the source's on-disk state. A cheap
76
+ pre-flight check also catches a source whose files don't match its own
77
+ configured Public Folder *before* a destination site gets created for a
78
+ clone that can't succeed; the pull chain's own detection step is the
79
+ real, authoritative check either way.
80
+ - The Stacks tab's default (pre-probe) "Bedrock" label also recognizes a
81
+ webroot like `site/web`, not just an exact `web`.
82
+ - **A first-time local Bedrock pull now explains itself.** "Pull production →
83
+ local" (`p` in Search) backs up the local database before overwriting it —
84
+ on a fresh Bedrock checkout with no `vendor/` yet, that step failed with
85
+ wp-cli's generic "not a WordPress install" error. The message now adds
86
+ "Run `composer install` in your local checkout first" when that's the
87
+ likely cause.
88
+ - **Clone-wizard error messages no longer get cut off at a fixed, often-too-
89
+ short length.** The site roster, Git-access deploy-key list, and DNS
90
+ cutover roster all truncated error text to a small fixed character count
91
+ regardless of how much space was actually available. Errors now fill
92
+ whatever room the row has, so a full-sentence error like the Bedrock
93
+ layout mismatch above is far more likely to be readable inline instead of
94
+ needing a drill-down for basic context.
95
+
96
+ ### Notes
97
+ - Nested Bedrock layouts (where the project root isn't directly under the
98
+ site's files root) are now correctly detected in the plugins/themes view,
99
+ app identification, and the clone wizard's size estimate — all live-
100
+ tested. A standard (non-nested) Bedrock clone through the rewritten pull
101
+ chain was also live-tested end to end, including a real DNS cutover. The
102
+ clone wizard's pull for a *nested* Bedrock site detects a source/repo
103
+ mismatch and refuses cleanly before creating anything, but the success
104
+ path — a genuinely nested repo cloning through to completion — hasn't
105
+ been live-validated yet. If you hit this, we'd welcome a report.
106
+
14
107
  ## [0.21.1] - 2026-07-10
15
108
 
16
109
  ### Fixed
@@ -1073,7 +1166,9 @@ Initial tagged release.
1073
1166
  ### Notes
1074
1167
  - Read-only release: works with a SpinupWP **Read Only** API token.
1075
1168
 
1076
- [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.21.1...HEAD
1169
+ [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.22.0...HEAD
1170
+ [0.22.0]: https://github.com/mwender/spinupwp-tui/compare/v0.21.2...v0.22.0
1171
+ [0.21.2]: https://github.com/mwender/spinupwp-tui/compare/v0.21.1...v0.21.2
1077
1172
  [0.21.1]: https://github.com/mwender/spinupwp-tui/compare/v0.21.0...v0.21.1
1078
1173
  [0.21.0]: https://github.com/mwender/spinupwp-tui/compare/v0.20.1...v0.21.0
1079
1174
  [0.20.1]: https://github.com/mwender/spinupwp-tui/compare/v0.20.0...v0.20.1
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
@@ -96,10 +101,9 @@
96
101
  ```sh
97
102
  curl -fsSL https://bun.sh/install | bash
98
103
  ```
99
- - A SpinupWP API token — create one at
100
- [spinupwp.app/account/api](https://spinupwp.app/account/api/). **Read Only**
101
- scope is enough to browse; use **Read/Write** if you want to upgrade a site's
102
- PHP version.
104
+ - A SpinupWP API token — create one in your SpinupWP dashboard under
105
+ **Settings → API Tokens**. **Read Only** scope is enough to browse; use
106
+ **Read/Write** if you want to upgrade a site's PHP version.
103
107
 
104
108
  ## Install & run
105
109
 
@@ -167,6 +171,17 @@ spinuptui ssh <domain> Print SSH access info for a site as JSON (resolves the
167
171
  site's server, builds its SSH target, and runs a live
168
172
  connectivity probe — for external tooling, e.g. an
169
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.
170
185
  spinuptui incidents <domain> | --all [--hours N] Print Uptime Kuma down/up
171
186
  incidents as JSON, scoped to sites SpinupTUI manages
172
187
  monitoring for (config.json's kumaMonitors) — --all
@@ -249,6 +264,7 @@ These can be set in `config.json` or via an environment variable:
249
264
  | `H` | Enable / disable HTTPS on a site (Servers / Stacks / Search; needs a Read/Write token) |
250
265
  | `P` | Purge a site's page cache + object cache (Servers / Stacks / Search; needs a Read/Write token) |
251
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 |
252
268
  | `a` | Server actions: reboot / restart a service (Servers / Search; needs a Read/Write token) |
253
269
  | `c` | Create a new server (Servers tab; needs a Read/Write token) |
254
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.1",
3
+ "version": "0.22.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/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)) {
package/src/lib/dbSync.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs"
16
16
  import { join, dirname } from "node:path"
17
17
  import type { Server, Site } from "../api/types.ts"
18
- import { expandPath, findProjectRoot, type LocalLink } from "./local.ts"
18
+ import { expandPath, findProjectRoot, resolveLocalLink, type LocalKind, type LocalLink } from "./local.ts"
19
19
  import { remoteDocRoot, backupBaseName, SSH_OPTS, sshPort, scpPort, runProcess, meaningfulError } from "./dbBackup.ts"
20
20
 
21
21
  // Read a KEY=value from a dotenv file, tolerating surrounding quotes. null if absent.
@@ -99,6 +99,7 @@ export interface DbSyncPlan {
99
99
  localHost: string // bare local host (hook env)
100
100
  hookPath: string | null // bin/sync.d/post-import.sh, if present
101
101
  prefixWarning: string | null // set when remote/local table prefixes differ
102
+ localKind: LocalKind // for a tailored hint if local wp-cli can't find an install (e.g. fresh Bedrock checkout)
102
103
  }
103
104
 
104
105
  export type SyncPlanResult = { ok: true; plan: DbSyncPlan } | { ok: false; error: string }
@@ -161,6 +162,7 @@ export function planDbSync(
161
162
  ? join(localRoot, "bin", "sync.d", "post-import.sh")
162
163
  : null,
163
164
  prefixWarning,
165
+ localKind: resolveLocalLink(link).kind,
164
166
  },
165
167
  }
166
168
  }
@@ -209,7 +211,15 @@ export async function runDbSync(plan: DbSyncPlan, domain: string, onProgress: (p
209
211
  }
210
212
  const localSql = plan.localBackupPath.replace(/\.gz$/, "")
211
213
  const lb = await wp(`wp db export "${localSql}" >/dev/null && gzip -f "${localSql}"`, 300_000)
212
- if (lb.code !== 0) return stageFail("Local DB backup failed", lb.stderr, "couldn't export the local database.", "local-backup")
214
+ if (lb.code !== 0) {
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." : ""
221
+ return fail(`Local DB backup failed — ${meaningfulError(lb.stderr, "couldn't export the local database.")}${hint}`, "local-backup")
222
+ }
213
223
 
214
224
  // 2) Export prod + download the gzip (same remote pipeline as the backup).
215
225
  onProgress({ stage: "export", domain })
package/src/lib/probe.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  // view's SSH usage.
18
18
 
19
19
  import type { Server, Site } from "../api/types.ts"
20
+ import { detectWpDirScript } from "./serverClone.ts"
20
21
  import { theme } from "./theme.ts"
21
22
 
22
23
  export type ProbeKind = "wordpress" | "bedrock" | "whmcs" | "laravel" | "static" | "unknown"
@@ -76,12 +77,21 @@ function safePublicFolder(pf: string | null): string {
76
77
  return pf
77
78
  }
78
79
 
79
- function buildRemoteScript(publicFolder: string | null): string {
80
+ function buildRemoteScript(domain: string, publicFolder: string | null): string {
80
81
  const pf = safePublicFolder(publicFolder)
82
+ const root = `/sites/${domain}/files`
81
83
  return [
84
+ // detectWpDirScript anchors on the configured public folder (falling back to a
85
+ // bounded find), so Bedrock's project root — one level above wherever its core
86
+ // actually lives — is found at any nesting depth, not just when composer.json
87
+ // sits directly in the files root. $D/$W from this are shadowed below by the
88
+ // probe's own "webroot per public_folder" variables, which the rest of this
89
+ // script (WHMCS/index/WP-version checks) intentionally keeps using as-is.
90
+ detectWpDirScript(root, publicFolder ?? undefined),
91
+ `BEDROCKROOT="$B"`,
82
92
  'F="$HOME/files"',
83
93
  `W="$HOME/files${pf}"`,
84
- 'echo ===BEDROCK; grep -q "roots/bedrock" "$F/composer.json" 2>/dev/null && echo yes || echo no',
94
+ 'echo ===BEDROCK; [ -n "$BEDROCKROOT" ] && echo yes || echo no',
85
95
  'echo ===APPLICATION; test -f "$F/config/application.php" && echo yes || echo no',
86
96
  'echo ===ARTISAN; test -f "$F/artisan" && echo yes || echo no',
87
97
  'echo ===WHMCSCONF; test -f "$W/configuration.php" && echo yes || echo no',
@@ -103,7 +113,7 @@ export async function probeSite(
103
113
 
104
114
  let proc: ReturnType<typeof Bun.spawn>
105
115
  try {
106
- proc = Bun.spawn(["ssh", ...SSH_OPTS, target, buildRemoteScript(site.public_folder)], {
116
+ proc = Bun.spawn(["ssh", ...SSH_OPTS, target, buildRemoteScript(site.domain, site.public_folder)], {
107
117
  stdout: "pipe",
108
118
  stderr: "pipe",
109
119
  stdin: "ignore",