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 +96 -1
- package/README.md +26 -10
- package/package.json +1 -1
- package/src/index.tsx +20 -0
- package/src/lib/cliSsh.ts +65 -19
- package/src/lib/dbSync.ts +12 -2
- package/src/lib/probe.ts +13 -3
- package/src/lib/serverClone.ts +127 -17
- package/src/lib/sshExec.ts +93 -0
- package/src/lib/sshExecLog.ts +31 -0
- package/src/lib/sshExecPolicy.ts +51 -0
- package/src/lib/stack.ts +15 -9
- package/src/ui/Details.tsx +151 -53
- package/src/ui/Onboarding.tsx +1 -1
- package/src/ui/components.tsx +84 -14
- package/src/ui/store.tsx +11 -2
- package/src/ui/views/Browser.tsx +35 -43
- package/src/ui/views/CloneWizard.tsx +9 -12
- package/src/ui/views/Search.tsx +16 -49
package/src/lib/serverClone.ts
CHANGED
|
@@ -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
|
|
199
|
-
//
|
|
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).
|
|
421
|
-
//
|
|
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
|
|
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(`${
|
|
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(
|
|
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)
|
|
497
|
-
//
|
|
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(`${
|
|
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(`${
|
|
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(`${
|
|
609
|
+
`install -m 640 -o ${shq(du)} -g ${shq(du)} ${tmp}_env ${shq(`${destProjectRoot}/.env`)}`,
|
|
526
610
|
`rm -f ${tmp}_env`,
|
|
527
|
-
`cd ${shq(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
6
|
-
// is_wordpress && "
|
|
7
|
-
// is_wordpress && anything else
|
|
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
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
|
|
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
|
|