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.
@@ -189,21 +189,55 @@ export function pollTransferSize(ctx: SudoCtx, path: string, onBytes: (bytes: nu
189
189
  export function publicFolderRel(publicFolder?: string): string {
190
190
  return (publicFolder ?? "/").replace(/^\/+|\/+$/g, "")
191
191
  }
192
- function webrootFor(root: string, publicFolder?: string): string {
192
+ export function webrootFor(root: string, publicFolder?: string): string {
193
193
  const pf = publicFolderRel(publicFolder)
194
194
  return pf ? `${root}/${pf}` : root
195
195
  }
196
196
 
197
197
  // Bash fragment: set W to the dir under $D (the files root) that holds
198
- // wp-settings.php — configured-candidate first, then the root, then a bounded find.
199
- // Leaves W empty when no WordPress core exists. Exported for the test harness.
198
+ // wp-settings.php — configured-candidate first, then Bedrock's convention
199
+ // relative to that candidate, then the root, then a bounded find. Also sets B to
200
+ // the Bedrock PROJECT root (composer.json, .env, wp-cli.yml) when core was found
201
+ // nested inside a directory literally named "wp" — derived from W rather than a
202
+ // second blind search, so it's correct at whatever depth W was actually found,
203
+ // and confirmed against composer.json content so a coincidental "wp"-named dir
204
+ // in some other stack doesn't get misidentified.
205
+ // Leaves W (and B) empty when no WordPress core exists. Exported for the test harness.
200
206
  export function detectWpDirScript(root: string, publicFolder?: string): string {
201
207
  const candidate = webrootFor(root, publicFolder)
202
208
  return [
203
209
  `D=${shq(root)}; W=""`,
204
210
  `[ -f ${shq(candidate)}/wp-settings.php ] && W=${shq(candidate)}`,
211
+ // Bedrock never puts core directly in the public folder — it lives one level
212
+ // down, at {public_folder}/wp/ (the whole point of Bedrock's web/wp + web/app
213
+ // split). Anchoring on the CONFIGURED public folder here — rather than relying
214
+ // solely on the blind find below — finds Bedrock core at any nesting depth,
215
+ // not just the couple of levels the bounded find happens to reach.
216
+ `[ -z "$W" ] && [ -f ${shq(candidate)}/wp/wp-settings.php ] && W=${shq(candidate)}/wp`,
205
217
  `[ -z "$W" ] && [ -f "$D/wp-settings.php" ] && W="$D"`,
206
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"; }`,
221
+ ].join("; ")
222
+ }
223
+
224
+ // Bash fragment: set B to the Bedrock PROJECT root via composer.json — for a
225
+ // FRESH git clone, before `composer install` has run. detectWpDirScript's B
226
+ // anchors on WordPress core (a directory literally named "wp"), which doesn't
227
+ // exist yet at this point — Bedrock's core is a composer dependency, never
228
+ // committed to git. composer.json IS present immediately post-clone, so this
229
+ // anchors on that instead: the configured public folder's PARENT first (that's
230
+ // where composer.json sits, by Bedrock convention — one level above the public
231
+ // folder), then the files root itself, then a bounded find. Leaves B empty when
232
+ // no Bedrock project is found. Exported for the test harness.
233
+ export function detectBedrockRootScript(root: string, publicFolder?: string): string {
234
+ const candidate = webrootFor(root, publicFolder)
235
+ const parent = candidate === root ? root : candidate.slice(0, candidate.lastIndexOf("/"))
236
+ return [
237
+ `D=${shq(root)}; P=${shq(parent)}; B=""`,
238
+ `[ -f "$P/composer.json" ] && grep -q "roots/bedrock" "$P/composer.json" 2>/dev/null && B="$P"`,
239
+ `[ -z "$B" ] && [ "$P" != "$D" ] && [ -f "$D/composer.json" ] && grep -q "roots/bedrock" "$D/composer.json" 2>/dev/null && B="$D"`,
240
+ `[ -z "$B" ] && { F=$(find "$D" -maxdepth 3 -name composer.json -not -path "*/vendor/*" -print -quit 2>/dev/null); [ -n "$F" ] && grep -q "roots/bedrock" "$F" 2>/dev/null && B=$(dirname "$F"); }`,
207
241
  ].join("; ")
208
242
  }
209
243
 
@@ -417,8 +451,12 @@ export async function runStandardWpPull(
417
451
  // we `composer install` ourselves over SSH to build vendor/ + web/wp. Only the
418
452
  // gitignored artifacts come from the source: web/app/uploads, auth.json, .env, and the
419
453
  // DB. The .env is pulled verbatim and its DB_* creds swapped to the dest's (so salts /
420
- // WP_HOME / custom vars are preserved — a true clone of the same domain). wp-cli runs
421
- // from the Bedrock root (files/, where wp-cli.yml points at web/wp).
454
+ // WP_HOME / custom vars are preserved — a true clone of the same domain).
455
+ //
456
+ // The Bedrock project's actual location under files/ is DETECTED independently on
457
+ // each end, not assumed to match — see the "0. detect" step below. wp-cli runs from
458
+ // whichever project root was detected there (where wp-cli.yml lives), not a
459
+ // hardcoded files/.
422
460
 
423
461
  export interface BedrockPullSpec {
424
462
  domain: string
@@ -428,6 +466,7 @@ export interface BedrockPullSpec {
428
466
  destDbUser: string
429
467
  destDbPassword: string
430
468
  excludeUploads: boolean
469
+ publicFolder?: string // a signal, not a fact (CLAUDE.md) — re-verified below, independently, on both ends
431
470
  }
432
471
 
433
472
  export async function runBedrockPull(
@@ -439,7 +478,7 @@ export async function runBedrockPull(
439
478
  onTransfer?: CloneTransfer,
440
479
  ): Promise<{ ok: boolean; error?: string }> {
441
480
  const srcIp = source.server.ip_address ?? ""
442
- const root = `/sites/${spec.domain}/files` // Bedrock project root (composer.json, web/, .env)
481
+ const root = `/sites/${spec.domain}/files` // files root — the Bedrock project itself may be nested under this
443
482
  const home = `/sites/${spec.domain}`
444
483
  const su = spec.sourceSiteUser
445
484
  const du = spec.destSiteUser
@@ -460,6 +499,46 @@ export async function runBedrockPull(
460
499
  }
461
500
 
462
501
  try {
502
+ // 0. detect — locate the real Bedrock project root on BOTH ends independently
503
+ // (public_folder is a setting, not a fact — see CLAUDE.md). The two ends need
504
+ // different anchors: the SOURCE already has WordPress core built, so it
505
+ // anchors on wp-settings.php (detectWpDirScript). The DEST was just git-cloned
506
+ // and has NOT run `composer install` yet — core doesn't exist there (it's a
507
+ // composer dependency, never committed), so nothing to anchor detection on
508
+ // except composer.json, which IS present immediately post-clone
509
+ // (detectBedrockRootScript). We do NOT assume the dest mirrors whatever the
510
+ // source happened to detect — its structure comes from the git repo's own
511
+ // content, which can legitimately diverge from the source's on-disk state.
512
+ onProgress("detect", "start")
513
+ const det = await run("detect", source, `${detectWpDirScript(root, spec.publicFolder)}; echo "WPCORE:$W"; echo "BEDROCKROOT:$B"`, 30_000)
514
+ const wpCore = (det.stdout.match(/^WPCORE:(.*)$/m)?.[1] ?? "").trim()
515
+ const srcProjectRoot = (det.stdout.match(/^BEDROCKROOT:(.*)$/m)?.[1] ?? "").trim()
516
+ if (!det.ok || !wpCore || !srcProjectRoot) {
517
+ return fail("detect", `couldn't find a Bedrock project (composer.json + WordPress core) under ${root} on the source — is the public folder set correctly?`)
518
+ }
519
+ const webDir = wpCore.slice(0, wpCore.lastIndexOf("/")) // wpCore's parent — the public folder, whatever it's actually named
520
+
521
+ // The dest's webroot is fixed by what we told SpinupWP at site-creation time
522
+ // (public_folder), which is what nginx will actually serve — composer install
523
+ // MUST target here for the site to work, regardless of the repo's own internal
524
+ // wordpress-install-dir config. Its PARENT (the project root: composer.json,
525
+ // .env) is what needs re-detecting, since that's a fact about the repo's
526
+ // content, not something either of us configured.
527
+ const destWebDir = webrootFor(root, spec.publicFolder)
528
+ const destProjectRootExpected = destWebDir === root ? root : destWebDir.slice(0, destWebDir.lastIndexOf("/"))
529
+ const destDet = await run("detect", dest, `${detectBedrockRootScript(root, spec.publicFolder)}; echo "BEDROCKROOT:$B"`, 30_000)
530
+ const destProjectRoot = (destDet.stdout.match(/^BEDROCKROOT:(.*)$/m)?.[1] ?? "").trim()
531
+ if (!destDet.ok || !destProjectRoot) {
532
+ return fail("detect", `couldn't find a Bedrock project (composer.json) under ${root} on the destination — did the git clone complete?`)
533
+ }
534
+ if (destProjectRoot !== destProjectRootExpected) {
535
+ return fail(
536
+ "detect",
537
+ `the site's git repo layout doesn't match its configured Public Folder (repo has composer.json at ${destProjectRoot}, but Public Folder implies ${destProjectRootExpected}) — align them and retry`,
538
+ )
539
+ }
540
+ onProgress("detect", "ok")
541
+
463
542
  // 1. auth — ephemeral key on dest, granted onto the source site user.
464
543
  onProgress("auth", "start")
465
544
  const gen = await run("auth", dest, `rm -f ${KEY} ${KEY}.pub; ssh-keygen -t ed25519 -f ${KEY} -N "" -C ${MARKER} >/dev/null 2>&1 && cat ${KEY}.pub`, 30_000)
@@ -480,28 +559,33 @@ export async function runBedrockPull(
480
559
  const authPull = await run(
481
560
  "build",
482
561
  dest,
483
- `set -e; ${remote(`cat ${shq(`${root}/auth.json`)} 2>/dev/null`)} > ${tmp}_auth.json || true; if [ -s ${tmp}_auth.json ]; then install -m 640 -o ${shq(du)} -g ${shq(du)} ${tmp}_auth.json ${shq(`${root}/auth.json`)}; fi; rm -f ${tmp}_auth.json`,
562
+ `set -e; ${remote(`cat ${shq(`${srcProjectRoot}/auth.json`)} 2>/dev/null`)} > ${tmp}_auth.json || true; if [ -s ${tmp}_auth.json ]; then install -m 640 -o ${shq(du)} -g ${shq(du)} ${tmp}_auth.json ${shq(`${destProjectRoot}/auth.json`)}; fi; rm -f ${tmp}_auth.json`,
484
563
  120_000,
485
564
  )
486
565
  if (!authPull.ok) return fail("build", authPull.stderr.trim() || "auth.json pull failed")
487
566
  const composer = await run(
488
567
  "build",
489
568
  dest,
490
- `set -e; cd ${shq(root)}; sudo -u ${shq(du)} -H bash -lc 'composer install -o --no-dev --no-interaction' 2>&1; test -d ${shq(`${root}/web/wp`)}`,
569
+ `set -e; cd ${shq(destProjectRoot)}; sudo -u ${shq(du)} -H bash -lc 'composer install -o --no-dev --no-interaction' 2>&1; test -d ${shq(`${destWebDir}/wp`)}`,
491
570
  600_000,
492
571
  )
493
572
  if (!composer.ok) return fail("build", (composer.stdout + composer.stderr).trim().split("\n").slice(-3).join(" ") || "composer install failed")
494
573
  onProgress("build", "ok")
495
574
 
496
- // 3. files — pull web/app/uploads (unless excluded). Best-effort: a site with no
497
- // uploads dir yields an empty stream we simply skip.
575
+ // 3. files — pull {web}/app/uploads (unless excluded), `web` being whatever the
576
+ // detected public folder is actually named — which can differ in nesting
577
+ // depth between source and dest (the repo's own content vs. how deep the
578
+ // source's on-disk state happens to be). Archiving/extracting relative to
579
+ // EACH side's own web dir (not `root`) with a fixed member name sidesteps
580
+ // that — the two ends never need to agree on a shared relative path. Best-
581
+ // effort: a site with no uploads dir yields an empty stream we simply skip.
498
582
  onProgress("files", "start")
499
583
  if (!spec.excludeUploads) {
500
584
  const stopUpPoll = pollTransferSize(dest, `${tmp}_up.tgz`, (b) => onTransfer?.("files", b))
501
585
  const up = await run(
502
586
  "files",
503
587
  dest,
504
- `set -e; timeout -k 5 3600 ${remote(`[ -d ${shq(`${root}/web/app/uploads`)} ] && tar -C ${shq(root)} --warning=no-file-changed -czf - web/app/uploads || true`)} > ${tmp}_up.tgz; if [ -s ${tmp}_up.tgz ]; then tar -C ${shq(root)} -xzf ${tmp}_up.tgz && chown -R ${shq(du)}:${shq(du)} ${shq(`${root}/web/app/uploads`)}; fi; rm -f ${tmp}_up.tgz`,
588
+ `set -e; timeout -k 5 3600 ${remote(`[ -d ${shq(`${webDir}/app/uploads`)} ] && tar -C ${shq(webDir)} --warning=no-file-changed -czf - app/uploads || true`)} > ${tmp}_up.tgz; if [ -s ${tmp}_up.tgz ]; then tar -C ${shq(destWebDir)} -xzf ${tmp}_up.tgz && chown -R ${shq(du)}:${shq(du)} ${shq(`${destWebDir}/app/uploads`)}; fi; rm -f ${tmp}_up.tgz`,
505
589
  3_660_000, // outer must exceed the in-script `timeout 3600` so the inner reports a clean 124
506
590
  )
507
591
  stopUpPoll()
@@ -517,14 +601,14 @@ export async function runBedrockPull(
517
601
  dest,
518
602
  [
519
603
  `set -e`,
520
- `${remote(`cat ${shq(`${root}/.env`)}`)} > ${tmp}_env`,
604
+ `${remote(`cat ${shq(`${srcProjectRoot}/.env`)}`)} > ${tmp}_env`,
521
605
  `test -s ${tmp}_env`,
522
606
  // drop existing DB assignments (commented or not) + DATABASE_URL, then append canonical creds.
523
607
  `sed -i -E '/^[[:space:]]*#?[[:space:]]*(DB_NAME|DB_USER|DB_PASSWORD|DB_HOST|DATABASE_URL)[[:space:]]*=/d' ${tmp}_env`,
524
608
  `printf "\\nDB_NAME='%s'\\nDB_USER='%s'\\nDB_PASSWORD='%s'\\nDB_HOST='localhost'\\n" ${shq(spec.destDbName)} ${shq(spec.destDbUser)} ${shq(spec.destDbPassword)} >> ${tmp}_env`,
525
- `install -m 640 -o ${shq(du)} -g ${shq(du)} ${tmp}_env ${shq(`${root}/.env`)}`,
609
+ `install -m 640 -o ${shq(du)} -g ${shq(du)} ${tmp}_env ${shq(`${destProjectRoot}/.env`)}`,
526
610
  `rm -f ${tmp}_env`,
527
- `cd ${shq(root)}; sudo -u ${shq(du)} -H wp db check >/dev/null 2>&1`,
611
+ `cd ${shq(destProjectRoot)}; sudo -u ${shq(du)} -H wp db check >/dev/null 2>&1`,
528
612
  ].join("; "),
529
613
  60_000,
530
614
  )
@@ -536,7 +620,7 @@ export async function runBedrockPull(
536
620
  const dump = await run(
537
621
  "db",
538
622
  source,
539
- `sudo -u ${shq(su)} -H bash -c "cd ${shq(root)} && wp --skip-plugins --skip-themes db export ${shq(home)}/.clone_db.sql 2>/dev/null && gzip -f ${shq(home)}/.clone_db.sql && stat -c %s ${shq(home)}/.clone_db.sql.gz"`,
623
+ `sudo -u ${shq(su)} -H bash -c "cd ${shq(srcProjectRoot)} && wp --skip-plugins --skip-themes db export ${shq(home)}/.clone_db.sql 2>/dev/null && gzip -f ${shq(home)}/.clone_db.sql && stat -c %s ${shq(home)}/.clone_db.sql.gz"`,
540
624
  900_000,
541
625
  )
542
626
  if (!dump.ok) return fail("db", dump.stderr.trim() || "source db export failed")
@@ -545,7 +629,7 @@ export async function runBedrockPull(
545
629
  const imp = await run(
546
630
  "db",
547
631
  dest,
548
- `set -e; timeout -k 5 300 ${remote(`cat ${shq(`${home}/.clone_db.sql.gz`)}`)} > ${tmp}.sql.gz; gunzip -f ${tmp}.sql.gz; chmod 644 ${tmp}.sql; cd ${shq(root)}; sudo -u ${shq(du)} -H wp db import ${tmp}.sql >/dev/null; rm -f ${tmp}.sql`,
632
+ `set -e; timeout -k 5 300 ${remote(`cat ${shq(`${home}/.clone_db.sql.gz`)}`)} > ${tmp}.sql.gz; gunzip -f ${tmp}.sql.gz; chmod 644 ${tmp}.sql; cd ${shq(destProjectRoot)}; sudo -u ${shq(du)} -H wp db import ${tmp}.sql >/dev/null; rm -f ${tmp}.sql`,
549
633
  360_000, // outer must exceed the in-script `timeout 300`
550
634
  )
551
635
  stopDbPoll()
@@ -554,7 +638,7 @@ export async function runBedrockPull(
554
638
 
555
639
  // 6. verify — wp-cli on the dest (HTTP --resolve is the caller's).
556
640
  onProgress("verify", "start")
557
- const ver = await run("verify", dest, `cd ${shq(root)}; sudo -u ${shq(du)} -H wp core is-installed`, 30_000)
641
+ const ver = await run("verify", dest, `cd ${shq(destProjectRoot)}; sudo -u ${shq(du)} -H wp core is-installed`, 30_000)
558
642
  if (!ver.ok) return fail("verify", "wp core is-installed returned false on the dest")
559
643
  onProgress("verify", "ok")
560
644
 
@@ -568,6 +652,32 @@ export async function runBedrockPull(
568
652
  }
569
653
  }
570
654
 
655
+ // Cheap pre-flight, before a Bedrock clone creates anything: does the SOURCE's
656
+ // on-disk project match its OWN configured Public Folder? A heuristic, not a
657
+ // guarantee — the destination's real structure comes from the git repo's
658
+ // committed content, which this can't inspect directly, so it can't promise the
659
+ // clone will succeed. But in the ordinary case (the source's files came from
660
+ // composer-installing that same repo) the two agree, so a mismatch here reliably
661
+ // predicts the same failure runBedrockPull's own "detect" step would hit later —
662
+ // just BEFORE a destination site (and its git clone) gets created for nothing.
663
+ // Silent no-op for anything that isn't a detectable Bedrock install; that's
664
+ // covered by the pull chain's own detection, not this pre-check's job.
665
+ export async function preflightBedrockSource(source: SudoCtx, spec: { domain: string; publicFolder?: string }): Promise<{ ok: true } | { ok: false; error: string }> {
666
+ const root = `/sites/${spec.domain}/files`
667
+ const expectedWebDir = webrootFor(root, spec.publicFolder)
668
+ const expectedProjectRoot = expectedWebDir === root ? root : expectedWebDir.slice(0, expectedWebDir.lastIndexOf("/"))
669
+ const r = await exec(source, `${detectWpDirScript(root, spec.publicFolder)}; echo "BEDROCKROOT:$B"`, 30_000)
670
+ const detected = (r.stdout.match(/^BEDROCKROOT:(.*)$/m)?.[1] ?? "").trim()
671
+ if (!r.ok || !detected) return { ok: true }
672
+ if (detected !== expectedProjectRoot) {
673
+ return {
674
+ ok: false,
675
+ error: `this site's actual files (Bedrock project at ${detected}) don't match its configured Public Folder (implies ${expectedProjectRoot}) — align them and retry`,
676
+ }
677
+ }
678
+ return { ok: true }
679
+ }
680
+
571
681
  // ---- Files-only pull chain (non-WP sites: redirect shells, static/PHP sites) --
572
682
  //
573
683
  // The same hardened transport as the Standard-WP files stage (per-site key,
@@ -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
+ }
package/src/lib/stack.ts CHANGED
@@ -2,20 +2,25 @@
2
2
  // fetch (no SSH, no extra API calls). Signals: `is_wordpress` and
3
3
  // `public_folder` on the Site object, validated against the live fleet.
4
4
  //
5
- // is_wordpress === false → Non-WP
6
- // is_wordpress && "/web/" → Bedrock
7
- // is_wordpress && anything else → Standard WP
5
+ // is_wordpress === false → Non-WP
6
+ // is_wordpress && webroot ends "web" → Bedrock
7
+ // is_wordpress && anything else → Standard WP
8
8
  //
9
9
  // Important: `public_folder` is the user-set "Public Folder" (the suffix after
10
10
  // `~/sites/{domain}/files`), NOT a SpinupWP-enforced stack flag. So this is a
11
- // heuristic keyed on a convention: webroot `/web/` means Bedrock. The
12
- // is_wordpress guard matters — a couple of non-WP custom apps also use "/web/",
13
- // so Bedrock requires WP too. Any other WP webroot (incl. the bare `~/files`
14
- // default, `/public/`, `/`) is treated as Standard WP. Tier-2 (on-demand SSH
15
- // probe that inspects the filesystem) is the authoritative correction layer.
11
+ // heuristic keyed on a convention: a webroot named `web` means Bedrock — checked
12
+ // by its LAST path segment (not an exact "/web/" match), so a nested layout like
13
+ // `/site/web/` (the Bedrock project moved into a subdirectory — a real, seen
14
+ // layout, see the WP-core-detection fix in serverClone.ts) still buckets
15
+ // correctly instead of falling through to Standard WP. The is_wordpress guard
16
+ // matters — a couple of non-WP custom apps also use a `web` webroot, so Bedrock
17
+ // requires WP too. Any other WP webroot (incl. the bare `~/files` default,
18
+ // `/public/`, `/`) is treated as Standard WP. Tier-2 (on-demand SSH probe that
19
+ // inspects the filesystem) is the authoritative correction layer regardless.
16
20
 
17
21
  import type { Site } from "../api/types.ts"
18
22
  import type { ProbeKind } from "./probe.ts"
23
+ import { publicFolderRel } from "./serverClone.ts"
19
24
  import { theme } from "./theme.ts"
20
25
 
21
26
  export type Stack = "Standard WP" | "Bedrock" | "Non-WP"
@@ -25,7 +30,8 @@ export const STACKS: Stack[] = ["Standard WP", "Bedrock", "Non-WP"]
25
30
 
26
31
  export function classifyStack(site: Site): Stack {
27
32
  if (!site.is_wordpress) return "Non-WP"
28
- if (site.public_folder === "/web/") return "Bedrock"
33
+ const rel = publicFolderRel(site.public_folder ?? undefined) // e.g. "web" or "site/web"
34
+ if (rel === "web" || rel.endsWith("/web")) return "Bedrock"
29
35
  return "Standard WP"
30
36
  }
31
37