spinuptui 0.21.1 → 0.21.2

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,58 @@ versions; such changes are called out here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.21.2] - 2026-07-10
15
+
16
+ ### Fixed
17
+ - **Onboarding's API token link was a 404.** `https://spinupwp.app/account/api/`
18
+ no longer resolves, and the correct URL is account-specific (a company slug
19
+ we can't know before the user has a token) — replaced with generic
20
+ "Settings → API Tokens" navigation instructions in onboarding and the README.
21
+ - **Bedrock detection now works at any nesting depth, everywhere it matters.**
22
+ A Bedrock install whose project root isn't directly under the site's files
23
+ root (e.g. moved into its own subdirectory) could go undetected — the
24
+ plugins/themes view (`p`) reported "no WordPress core found" even though the
25
+ site worked fine and SpinupWP itself knew it was WordPress. Detection now
26
+ anchors on the site's configured Public Folder (falling back to a bounded
27
+ search only when that's unset) instead of a fixed, shallow search depth:
28
+ - `p` (plugins/themes) finds core correctly.
29
+ - `d` (identify app) correctly labels it "Bedrock" again, not just generic
30
+ WordPress.
31
+ - The clone wizard's Bedrock pull no longer hardcodes `web/`. It detects the
32
+ real project root independently on both source and destination — they're
33
+ not assumed to match, since the destination's layout comes from the git
34
+ repo's own committed content, not the source's on-disk state. A cheap
35
+ pre-flight check also catches a source whose files don't match its own
36
+ configured Public Folder *before* a destination site gets created for a
37
+ clone that can't succeed; the pull chain's own detection step is the
38
+ real, authoritative check either way.
39
+ - The Stacks tab's default (pre-probe) "Bedrock" label also recognizes a
40
+ webroot like `site/web`, not just an exact `web`.
41
+ - **A first-time local Bedrock pull now explains itself.** "Pull production →
42
+ local" (`p` in Search) backs up the local database before overwriting it —
43
+ on a fresh Bedrock checkout with no `vendor/` yet, that step failed with
44
+ wp-cli's generic "not a WordPress install" error. The message now adds
45
+ "Run `composer install` in your local checkout first" when that's the
46
+ likely cause.
47
+ - **Clone-wizard error messages no longer get cut off at a fixed, often-too-
48
+ short length.** The site roster, Git-access deploy-key list, and DNS
49
+ cutover roster all truncated error text to a small fixed character count
50
+ regardless of how much space was actually available. Errors now fill
51
+ whatever room the row has, so a full-sentence error like the Bedrock
52
+ layout mismatch above is far more likely to be readable inline instead of
53
+ needing a drill-down for basic context.
54
+
55
+ ### Notes
56
+ - Nested Bedrock layouts (where the project root isn't directly under the
57
+ site's files root) are now correctly detected in the plugins/themes view,
58
+ app identification, and the clone wizard's size estimate — all live-
59
+ tested. A standard (non-nested) Bedrock clone through the rewritten pull
60
+ chain was also live-tested end to end, including a real DNS cutover. The
61
+ clone wizard's pull for a *nested* Bedrock site detects a source/repo
62
+ mismatch and refuses cleanly before creating anything, but the success
63
+ path — a genuinely nested repo cloning through to completion — hasn't
64
+ been live-validated yet. If you hit this, we'd welcome a report.
65
+
14
66
  ## [0.21.1] - 2026-07-10
15
67
 
16
68
  ### Fixed
@@ -1073,7 +1125,8 @@ Initial tagged release.
1073
1125
  ### Notes
1074
1126
  - Read-only release: works with a SpinupWP **Read Only** API token.
1075
1127
 
1076
- [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.21.1...HEAD
1128
+ [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.21.2...HEAD
1129
+ [0.21.2]: https://github.com/mwender/spinupwp-tui/compare/v0.21.1...v0.21.2
1077
1130
  [0.21.1]: https://github.com/mwender/spinupwp-tui/compare/v0.21.0...v0.21.1
1078
1131
  [0.21.0]: https://github.com/mwender/spinupwp-tui/compare/v0.20.1...v0.21.0
1079
1132
  [0.20.1]: https://github.com/mwender/spinupwp-tui/compare/v0.20.0...v0.20.1
package/README.md CHANGED
@@ -96,10 +96,9 @@
96
96
  ```sh
97
97
  curl -fsSL https://bun.sh/install | bash
98
98
  ```
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.
99
+ - A SpinupWP API token — create one in your SpinupWP dashboard under
100
+ **Settings → API Tokens**. **Read Only** scope is enough to browse; use
101
+ **Read/Write** if you want to upgrade a site's PHP version.
103
102
 
104
103
  ## Install & run
105
104
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "0.21.1",
3
+ "version": "0.21.2",
4
4
  "description": "SpinupTUI — a terminal dashboard for browsing, monitoring, and managing your SpinupWP servers and sites.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/lib/dbSync.ts CHANGED
@@ -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",
@@ -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,
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
 
@@ -55,7 +55,7 @@ export function Onboarding({ onComplete }: { onComplete: () => void }) {
55
55
  >
56
56
  <text content="Let's connect this tool to your SpinupWP account." fg={theme.text} />
57
57
  <box style={{ height: 1 }} />
58
- <text content="1. Open https://spinupwp.app/account/api/" fg={theme.textDim} />
58
+ <text content="1. In SpinupWP: Settings → API Tokens" fg={theme.textDim} />
59
59
  <text content="2. Create a token (Read Only to browse; Read/Write to upgrade PHP)" fg={theme.textDim} />
60
60
  <text content="3. Paste it below and press Enter" fg={theme.textDim} />
61
61
  <box style={{ height: 1 }} />
package/src/ui/store.tsx CHANGED
@@ -45,7 +45,7 @@ import { withKuma, KumaClient, registerMonitors, registerFingerprintMonitor, reg
45
45
  import { deriveFingerprint } from "../lib/siteFingerprint.ts"
46
46
  import type { StoredProviders } from "../config.ts"
47
47
  import { fetchRebootInfo, grantSiteSshKey, revokeSiteSshKey, verifySudo, ensureSpinupKey, listPersonalKeys, keyBody, type RebootInfo } from "../lib/ssh.ts"
48
- import { estimateSourceSiteSizes, runStandardWpPull, runBedrockPull, runFilesOnlyPull, verifyClone, verifyFilesClone, type SudoCtx, type CloneStage, type CloneExecRecord, type VerifyResult as CloneVerifyResult } from "../lib/serverClone.ts"
48
+ import { estimateSourceSiteSizes, runStandardWpPull, runBedrockPull, runFilesOnlyPull, verifyClone, verifyFilesClone, preflightBedrockSource, type SudoCtx, type CloneStage, type CloneExecRecord, type VerifyResult as CloneVerifyResult } from "../lib/serverClone.ts"
49
49
  import { CloneLogger } from "../lib/cloneLog.ts"
50
50
  import { syncAdditionalDomains } from "../lib/cloneDomains.ts"
51
51
  import { parseRepo, deployKeysSettingsUrl, ghAvailable, ghDeployKeyPresent, ghAddDeployKey, generateDeployKeypair, type RepoHost } from "../lib/gitDeployKey.ts"
@@ -3679,6 +3679,15 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3679
3679
  }
3680
3680
  try {
3681
3681
  if (site.stack === "bedrock" && !site.gitRepo) return fail("create", "the source Bedrock site has no git repo to clone from.")
3682
+ // Cheap pre-flight, before creating anything: catches a source whose actual
3683
+ // files don't match its own configured Public Folder — the same mismatch
3684
+ // runBedrockPull's own detect step would hit later, just before wasting a
3685
+ // site-creation + auth cycle on a clone that can't succeed. Not a guarantee
3686
+ // (see preflightBedrockSource) — the real check is still the one after create.
3687
+ if (site.stack === "bedrock") {
3688
+ const pre = await preflightBedrockSource(source, { domain: site.domain, publicFolder: site.publicFolder })
3689
+ if (!pre.ok) return fail("create", pre.error)
3690
+ }
3682
3691
  // create — Bedrock → `git` site (SpinupWP clones the repo); Standard WP →
3683
3692
  // blank + database; files-only → blank with NO database (nothing to import).
3684
3693
  set((s) => ({ ...s, step: "create", error: undefined, failedStep: undefined, detail: undefined, stageStartedAt: Date.now() }))
@@ -3803,7 +3812,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3803
3812
  const onExec = logger ? (e: CloneExecRecord) => logger.log({ event: "exec", ...e }) : undefined
3804
3813
  const res =
3805
3814
  site.stack === "bedrock"
3806
- ? await runBedrockPull(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, destDbName: dbName, destDbUser: dbName, destDbPassword: dbPw, excludeUploads: site.excludeUploads }, onProgress, onExec, onTransfer)
3815
+ ? await runBedrockPull(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, destDbName: dbName, destDbUser: dbName, destDbPassword: dbPw, excludeUploads: site.excludeUploads, publicFolder: site.publicFolder }, onProgress, onExec, onTransfer)
3807
3816
  : site.stack === "files"
3808
3817
  ? await runFilesOnlyPull(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, approxFilesBytes: site.sizeWebBytes }, onProgress, onExec, onTransfer)
3809
3818
  : await runStandardWpPull(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, destDbName: dbName, destDbUser: dbName, destDbPassword: dbPw, publicFolder: site.publicFolder, approxFilesBytes: site.sizeWebBytes }, onProgress, onExec, onTransfer)
@@ -562,7 +562,7 @@ export function CloneWizard() {
562
562
  case "adding":
563
563
  return "adding…"
564
564
  case "error":
565
- return truncate(k.error ?? "failed", 36)
565
+ return k.error ?? "failed"
566
566
  default:
567
567
  return "add the key by hand, then r to re-check"
568
568
  }
@@ -579,9 +579,8 @@ export function CloneWizard() {
579
579
  return (
580
580
  <box key={k.repo} style={{ flexDirection: "row", height: 1 }}>
581
581
  <text content={`${g} `} fg={c} style={{ flexShrink: 0 }} />
582
- <text content={`${k.owner}/${k.name}`} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
583
- <box style={{ flexGrow: 1 }} />
584
- <text content={label(k)} fg={k.status === "error" ? theme.bad : k.status === "present" || k.status === "added" ? theme.good : theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
582
+ <text content={`${k.owner}/${k.name}`} fg={theme.text} wrapMode="none" style={{ flexShrink: 0 }} />
583
+ <text content={" " + label(k)} fg={k.status === "error" ? theme.bad : k.status === "present" || k.status === "added" ? theme.good : theme.textFaint} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
585
584
  </box>
586
585
  )
587
586
  })}
@@ -682,15 +681,14 @@ export function CloneWizard() {
682
681
  : st === "done" ? `now → ${r.targetValue ?? targetIp}`
683
682
  : st === "ready" ? `${r.currentValue ?? "?"} → ${r.targetValue ?? targetIp}${r.excluded ? " (skipped)" : ""}`
684
683
  : st === "manual" ? `manual: ${r.reason ?? "edit by hand"}`
685
- : st === "error" ? truncate(r.error ?? "failed", 28)
684
+ : st === "error" ? (r.error ?? "failed")
686
685
  : "pending"
687
686
  const dcolor = st === "error" ? theme.bad : st === "done" ? theme.good : st === "manual" ? theme.warn : cur ? theme.text : theme.textFaint
688
687
  return (
689
688
  <box key={`${siteId}|${r.name}`} style={{ flexDirection: "row", height: 1, backgroundColor: cur ? theme.bgAlt : undefined }}>
690
689
  {busy ? <Spinner color={theme.brand} /> : <text content={glyph} fg={gcolor} style={{ flexShrink: 0 }} />}
691
- <text content={` ${r.name}`} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
692
- <box style={{ flexGrow: 1 }} />
693
- <text content={detail} fg={dcolor} wrapMode="none" style={{ flexShrink: 0 }} />
690
+ <text content={` ${r.name}`} fg={theme.text} wrapMode="none" style={{ flexShrink: 0 }} />
691
+ <text content={" " + detail} fg={dcolor} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
694
692
  </box>
695
693
  )
696
694
  }
@@ -732,13 +730,12 @@ export function CloneWizard() {
732
730
  return (
733
731
  <box key={s.sourceSiteId} style={{ flexDirection: "row", height: 1, backgroundColor: cur ? theme.bgAlt : undefined }}>
734
732
  {active ? <Spinner color={theme.brand} /> : <text content={s.step === "done" ? "✓" : s.step === "error" ? "✕" : "○"} fg={s.step === "done" ? theme.good : s.step === "error" ? theme.bad : theme.textFaint} style={{ flexShrink: 0 }} />}
735
- <text content={` ${s.domain}`} fg={cur || s.step === "error" || s.step === "done" ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
736
- <box style={{ flexGrow: 1 }} />
733
+ <text content={` ${s.domain}`} fg={cur || s.step === "error" || s.step === "done" ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 0 }} />
737
734
  <text
738
- content={(s.step === "error" ? truncate(s.error ?? "failed", 24) : s.step === "done" ? "done" : s.step === "queued" ? "queued" : `${s.step}${s.detail ? " · " + s.detail : ""}${s.transferBytes != null ? ` · ${formatBytes(s.transferBytes)}${s.transferTarget ? ` of ${s.transferExact ? "" : "~"}${formatBytes(s.transferTarget)}` : ""}${s.transferExact && s.transferTarget ? ` (${Math.min(100, Math.round((s.transferBytes / s.transferTarget) * 100))}%)` : ""}${s.transferRate ? ` · ${formatBytes(s.transferRate)}/s` : ""}` : ""}${s.stageStartedAt ? " · " + fmtElapsed(now - s.stageStartedAt) : ""}`) + vmark}
735
+ content={" " + ((s.step === "error" ? s.error ?? "failed" : s.step === "done" ? "done" : s.step === "queued" ? "queued" : `${s.step}${s.detail ? " · " + s.detail : ""}${s.transferBytes != null ? ` · ${formatBytes(s.transferBytes)}${s.transferTarget ? ` of ${s.transferExact ? "" : "~"}${formatBytes(s.transferTarget)}` : ""}${s.transferExact && s.transferTarget ? ` (${Math.min(100, Math.round((s.transferBytes / s.transferTarget) * 100))}%)` : ""}${s.transferRate ? ` · ${formatBytes(s.transferRate)}/s` : ""}` : ""}${s.stageStartedAt ? " · " + fmtElapsed(now - s.stageStartedAt) : ""}`) + vmark)}
739
736
  fg={s.step === "error" || (s.verify && !s.verify.ok) ? theme.bad : s.verify?.ok ? theme.good : s.step === "done" ? theme.good : theme.textFaint}
740
737
  wrapMode="none"
741
- style={{ flexShrink: 0 }}
738
+ style={{ flexGrow: 1, flexShrink: 1 }}
742
739
  />
743
740
  </box>
744
741
  )