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 +54 -1
- package/README.md +3 -4
- package/package.json +1 -1
- package/src/lib/dbSync.ts +12 -2
- package/src/lib/probe.ts +13 -3
- package/src/lib/serverClone.ts +127 -17
- package/src/lib/stack.ts +15 -9
- package/src/ui/Onboarding.tsx +1 -1
- package/src/ui/store.tsx +11 -2
- package/src/ui/views/CloneWizard.tsx +9 -12
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.
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
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
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)
|
|
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;
|
|
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",
|
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,
|
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
|
|
package/src/ui/Onboarding.tsx
CHANGED
|
@@ -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.
|
|
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
|
|
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:
|
|
583
|
-
<
|
|
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" ?
|
|
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:
|
|
692
|
-
<
|
|
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:
|
|
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" ?
|
|
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:
|
|
738
|
+
style={{ flexGrow: 1, flexShrink: 1 }}
|
|
742
739
|
/>
|
|
743
740
|
</box>
|
|
744
741
|
)
|