spinuptui 0.25.0 → 0.26.1
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 +103 -1
- package/README.md +61 -8
- package/package.json +3 -2
- package/src/index.tsx +134 -11
- package/src/lib/appUpdate.ts +2 -1
- package/src/lib/cliPull.ts +387 -0
- package/src/lib/cliSsh.ts +76 -50
- package/src/lib/cwdDotenv.ts +106 -0
- package/src/lib/dbBackup.ts +9 -5
- package/src/lib/dbSync.ts +15 -1
- package/src/lib/gitDeployKey.ts +0 -0
- package/src/lib/gitStatus.ts +2 -1
- package/src/lib/keychain.ts +3 -1
- package/src/lib/links.ts +23 -0
- package/src/lib/localSetup.ts +164 -0
- package/src/lib/open.ts +10 -8
- package/src/lib/probe.ts +2 -1
- package/src/lib/serverClone.ts +3 -2
- package/src/lib/spawn.ts +17 -0
- package/src/lib/ssh.ts +6 -5
- package/src/lib/sshExec.ts +4 -2
- package/src/lib/wpInventory.ts +2 -1
- package/src/ui/App.tsx +6 -0
- package/src/ui/store.tsx +70 -0
- package/src/ui/views/LocalLink.tsx +62 -11
- package/src/ui/views/LocalSetup.tsx +256 -0
- package/src/ui/views/Search.tsx +38 -66
package/src/ui/store.tsx
CHANGED
|
@@ -55,6 +55,7 @@ import { resolvePhpEolDates, refreshPhpEolDates, isPhpEol as isPhpEolWith, offer
|
|
|
55
55
|
import { resolveUbuntuEolDates, refreshUbuntuEolDates, isUbuntuEol as isUbuntuEolWith, type UbuntuEolDates } from "../lib/ubuntuEol.ts"
|
|
56
56
|
import { planDbBackup, runDbBackup, type DbBackupProgress, type PlanResult } from "../lib/dbBackup.ts"
|
|
57
57
|
import { planDbSync, runDbSync, type DbSyncProgress, type SyncPlanResult } from "../lib/dbSync.ts"
|
|
58
|
+
import { planLocalSetup, runLocalSetup, type LocalSetupProgress, type LocalSetupPlanResult } from "../lib/localSetup.ts"
|
|
58
59
|
import { planMediaFallback, type MediaFallbackResult } from "../lib/mediaFallback.ts"
|
|
59
60
|
|
|
60
61
|
export type Route = "dashboard" | "servers" | "stacks" | "search" | "events"
|
|
@@ -372,12 +373,14 @@ const phpJobId = (siteId: number) => `phpUpgrade:${siteId}`
|
|
|
372
373
|
const httpsJobId = (siteId: number) => `httpsToggle:${siteId}`
|
|
373
374
|
const dbSyncJobId = (siteId: number) => `dbSync:${siteId}`
|
|
374
375
|
const dbBackupJobId = (siteId: number) => `dbBackup:${siteId}`
|
|
376
|
+
const localSetupJobId = (siteId: number) => `localSetup:${siteId}`
|
|
375
377
|
|
|
376
378
|
// SSH-orchestrated jobs (no SpinupWP event to re-attach to) can't truly resume —
|
|
377
379
|
// their child processes died with the app. On restart we surface them as
|
|
378
380
|
// interrupted rather than pretend, since the local DB may be half-applied.
|
|
379
381
|
const INTERRUPTED_SYNC_MSG = "Interrupted by a restart — your local database may be partially imported. Re-run the sync (p)."
|
|
380
382
|
const INTERRUPTED_BACKUP_MSG = "Interrupted by a restart — the download didn't finish. Re-run the backup (d)."
|
|
383
|
+
const INTERRUPTED_LOCAL_SETUP_MSG = "Interrupted by a restart — the local copy may be partial. Re-run the clone."
|
|
381
384
|
// Server provisioning events settle with one of these (broader than PHP/site
|
|
382
385
|
// writes, whose terminal is "deployed"); finished_at also implies done.
|
|
383
386
|
const SERVER_DONE = new Set(["deployed", "completed", "provisioned", "finished", "success"])
|
|
@@ -715,6 +718,16 @@ interface StoreValue extends DataState {
|
|
|
715
718
|
startDbSync: (site: Site) => void
|
|
716
719
|
clearDbSync: (siteId: number) => void
|
|
717
720
|
|
|
721
|
+
// Set up a brand-new local working copy for a remote site (clone/rsync the
|
|
722
|
+
// code down, composer install if Bedrock/Radicle) — the missing first step
|
|
723
|
+
// before a link exists at all. Never touches the DB or webserver config.
|
|
724
|
+
localSetupSite: Site | null
|
|
725
|
+
setLocalSetupSite: (s: Site | null) => void
|
|
726
|
+
localSetups: Map<number, LocalSetupProgress>
|
|
727
|
+
planLocalSetupFor: (site: Site, destPath: string, destUrl: string) => LocalSetupPlanResult
|
|
728
|
+
startLocalSetup: (site: Site, destPath: string, destUrl: string) => void
|
|
729
|
+
clearLocalSetup: (siteId: number) => void
|
|
730
|
+
|
|
718
731
|
// Production media fallback overlay (mu-plugin in the linked local copy). State
|
|
719
732
|
// is the plugin file's presence, resolved fresh by planMediaFallbackFor.
|
|
720
733
|
mediaFallbackSite: Site | null
|
|
@@ -1003,6 +1016,8 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1003
1016
|
const [dbBackups, setDbBackups] = useState<Map<number, DbBackupProgress>>(new Map())
|
|
1004
1017
|
const [dbSyncSite, setDbSyncSite] = useState<Site | null>(null)
|
|
1005
1018
|
const [dbSyncs, setDbSyncs] = useState<Map<number, DbSyncProgress>>(new Map())
|
|
1019
|
+
const [localSetupSite, setLocalSetupSite] = useState<Site | null>(null)
|
|
1020
|
+
const [localSetups, setLocalSetups] = useState<Map<number, LocalSetupProgress>>(new Map())
|
|
1006
1021
|
const [localSync, setLocalSyncState] = useState<boolean>(() => cfgRef.current.localSync)
|
|
1007
1022
|
const [enableLocalSyncSite, setEnableLocalSyncSite] = useState<Site | null>(null)
|
|
1008
1023
|
const [mediaFallbackSite, setMediaFallbackSite] = useState<Site | null>(null)
|
|
@@ -2148,6 +2163,51 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
2148
2163
|
[dbSyncs, planDbSyncFor],
|
|
2149
2164
|
)
|
|
2150
2165
|
|
|
2166
|
+
// ---- New local working copy (clone/rsync + composer install) ----------
|
|
2167
|
+
|
|
2168
|
+
const setLocalSetup = (siteId: number, progress: LocalSetupProgress) =>
|
|
2169
|
+
setLocalSetups((prev) => new Map(prev).set(siteId, progress))
|
|
2170
|
+
|
|
2171
|
+
const clearLocalSetup = useCallback(
|
|
2172
|
+
(siteId: number) =>
|
|
2173
|
+
setLocalSetups((prev) => {
|
|
2174
|
+
if (!prev.has(siteId)) return prev
|
|
2175
|
+
const next = new Map(prev)
|
|
2176
|
+
next.delete(siteId)
|
|
2177
|
+
return next
|
|
2178
|
+
}),
|
|
2179
|
+
[],
|
|
2180
|
+
)
|
|
2181
|
+
|
|
2182
|
+
const planLocalSetupFor = useCallback(
|
|
2183
|
+
(site: Site, destPath: string, destUrl: string): LocalSetupPlanResult =>
|
|
2184
|
+
planLocalSetup(site, servers.find((s) => s.id === site.server_id), cfgRef.current.sshUser, destPath, destUrl),
|
|
2185
|
+
[servers],
|
|
2186
|
+
)
|
|
2187
|
+
|
|
2188
|
+
const startLocalSetup = useCallback(
|
|
2189
|
+
(site: Site, destPath: string, destUrl: string) => {
|
|
2190
|
+
const existing = localSetups.get(site.id)
|
|
2191
|
+
if (existing && existing.stage !== "done" && existing.stage !== "error") return
|
|
2192
|
+
const res = planLocalSetupFor(site, destPath, destUrl)
|
|
2193
|
+
if (!res.ok) {
|
|
2194
|
+
setLocalSetup(site.id, { stage: "error", domain: site.domain, error: res.error })
|
|
2195
|
+
return
|
|
2196
|
+
}
|
|
2197
|
+
// SSH/local-process orchestrated (no event): persist only so a restart can
|
|
2198
|
+
// flag it as interrupted; drop it the moment it settles.
|
|
2199
|
+
void saveJob({ id: localSetupJobId(site.id), kind: "localSetup", status: "running", startedAt: Date.now(), inputs: { siteId: site.id, domain: site.domain } })
|
|
2200
|
+
void runLocalSetup(res.plan, (p) => {
|
|
2201
|
+
setLocalSetup(site.id, p)
|
|
2202
|
+
// Link as soon as the files are down, same shape the manual `L` form
|
|
2203
|
+
// writes — dbSync (`p`) and mediaFallback (`m`) both require a link.
|
|
2204
|
+
if (p.stage === "done") linkSite(site.id, { domain: site.domain, path: res.plan.destPath, localUrl: res.plan.destUrl })
|
|
2205
|
+
if (p.stage === "done" || p.stage === "error") void removeJob(localSetupJobId(site.id))
|
|
2206
|
+
})
|
|
2207
|
+
},
|
|
2208
|
+
[localSetups, planLocalSetupFor, linkSite],
|
|
2209
|
+
)
|
|
2210
|
+
|
|
2151
2211
|
// Compute a linked site's git drift once (cached), fire-and-forget. Stable
|
|
2152
2212
|
// across renders (uses a ref for the dedup set), so views can call it freely
|
|
2153
2213
|
// from an effect when a linked site comes into view.
|
|
@@ -4151,6 +4211,10 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
4151
4211
|
if (inp.siteId != null) setSync(inp.siteId, { stage: "error", domain: inp.domain ?? "", error: INTERRUPTED_SYNC_MSG })
|
|
4152
4212
|
void removeJob(job.id)
|
|
4153
4213
|
break
|
|
4214
|
+
case "localSetup":
|
|
4215
|
+
if (inp.siteId != null) setLocalSetup(inp.siteId, { stage: "error", domain: inp.domain ?? "", error: INTERRUPTED_LOCAL_SETUP_MSG })
|
|
4216
|
+
void removeJob(job.id)
|
|
4217
|
+
break
|
|
4154
4218
|
case "dbBackup":
|
|
4155
4219
|
if (inp.siteId != null) setBackup(inp.siteId, { stage: "error", domain: inp.domain ?? "", error: INTERRUPTED_BACKUP_MSG })
|
|
4156
4220
|
void removeJob(job.id)
|
|
@@ -4333,6 +4397,12 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
4333
4397
|
planDbSyncFor,
|
|
4334
4398
|
startDbSync,
|
|
4335
4399
|
clearDbSync,
|
|
4400
|
+
localSetupSite,
|
|
4401
|
+
setLocalSetupSite,
|
|
4402
|
+
localSetups,
|
|
4403
|
+
planLocalSetupFor,
|
|
4404
|
+
startLocalSetup,
|
|
4405
|
+
clearLocalSetup,
|
|
4336
4406
|
drift,
|
|
4337
4407
|
ensureDrift,
|
|
4338
4408
|
rebootInfo,
|
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
// Local working-copy link overlay — Phase 1 (link + view, no mutation)
|
|
1
|
+
// Local working-copy link overlay — Phase 1 (link + view, no mutation) plus
|
|
2
|
+
// the entry point into Phase 2 (LocalSetup.tsx's guided clone).
|
|
2
3
|
//
|
|
3
|
-
// Opened with `L` on a selected site (Browser / Search results).
|
|
4
|
+
// Opened with `L` on a selected site (Browser / Search results). Modes:
|
|
5
|
+
// • choose — no link exists yet: pick manual entry (`e`) or a guided clone
|
|
6
|
+
// from production (`c`, hands off to LocalSetupOverlay).
|
|
4
7
|
// • view — the site is linked: shows the validated status (Bedrock / WordPress
|
|
5
8
|
// / missing) and offers open-locally actions: `t` opens a terminal at the
|
|
6
9
|
// path, `v` opens the stored local URL in the browser. `e` edits, `x` unlinks.
|
|
7
10
|
// • edit — a small form to enter the local path + local URL (manual entry).
|
|
8
11
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
+
// This file itself still only links — we open a terminal and the local URL, and
|
|
13
|
+
// never launch an editor. Getting code onto disk (git clone / rsync / composer)
|
|
14
|
+
// lives entirely in LocalSetup.tsx, kept separate from this file's original
|
|
15
|
+
// read-only-on-disk scope. The mutating maintenance loop (composer update → push
|
|
16
|
+
// → deploy) is still a later phase.
|
|
12
17
|
|
|
13
18
|
import { useEffect, useState } from "react"
|
|
14
19
|
import { useKeyboard } from "@opentui/react"
|
|
@@ -19,7 +24,7 @@ import { StatusBar } from "../StatusBar.tsx"
|
|
|
19
24
|
import { useStore } from "../store.tsx"
|
|
20
25
|
import { resolveLocalLink, type LocalKind } from "../../lib/local.ts"
|
|
21
26
|
|
|
22
|
-
type Mode = "view" | "edit"
|
|
27
|
+
type Mode = "choose" | "view" | "edit"
|
|
23
28
|
type EditField = "path" | "url"
|
|
24
29
|
|
|
25
30
|
function kindColor(kind: LocalKind, exists: boolean): string {
|
|
@@ -31,11 +36,11 @@ function kindColor(kind: LocalKind, exists: boolean): string {
|
|
|
31
36
|
|
|
32
37
|
export function LocalLinkOverlay() {
|
|
33
38
|
const store = useStore()
|
|
34
|
-
const { localLinkSite: site, setLocalLinkSite, localLinks, linkSite, unlinkSite, setInputMode, openLocalTerminal, openLocalUrl, linkReturnToForgotten, setLinkReturnToForgotten, setForgottenOpen } = store
|
|
39
|
+
const { localLinkSite: site, setLocalLinkSite, localLinks, linkSite, unlinkSite, setInputMode, openLocalTerminal, openLocalUrl, linkReturnToForgotten, setLinkReturnToForgotten, setForgottenOpen, setLocalSetupSite } = store
|
|
35
40
|
|
|
36
41
|
const existing = site ? localLinks.get(site.id) : undefined
|
|
37
42
|
|
|
38
|
-
const [mode, setMode] = useState<Mode>(() => (existing ? "view" : "
|
|
43
|
+
const [mode, setMode] = useState<Mode>(() => (existing ? "view" : "choose"))
|
|
39
44
|
const [field, setField] = useState<EditField>("path")
|
|
40
45
|
const [pathInput, setPathInput] = useState(existing?.path ?? "")
|
|
41
46
|
const [urlInput, setUrlInput] = useState(existing?.localUrl ?? "")
|
|
@@ -73,9 +78,28 @@ export function LocalLinkOverlay() {
|
|
|
73
78
|
useKeyboard((key) => {
|
|
74
79
|
const name = key.name ?? ""
|
|
75
80
|
|
|
81
|
+
if (mode === "choose") {
|
|
82
|
+
if (name === "escape" || name === "q") return close()
|
|
83
|
+
if (name === "e") {
|
|
84
|
+
setPathInput("")
|
|
85
|
+
setUrlInput("")
|
|
86
|
+
setField("path")
|
|
87
|
+
return setMode("edit")
|
|
88
|
+
}
|
|
89
|
+
if (name === "c" && site) {
|
|
90
|
+
// Not close() — that hands back to the "needs a local copy" report if we
|
|
91
|
+
// arrived from there, which doesn't make sense mid-clone. Just hand off.
|
|
92
|
+
setInputMode(false)
|
|
93
|
+
setLocalLinkSite(null)
|
|
94
|
+
setLocalSetupSite(site)
|
|
95
|
+
}
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
76
99
|
if (mode === "edit") {
|
|
77
|
-
// Esc backs out: to the view mode if a link already exists, else
|
|
78
|
-
|
|
100
|
+
// Esc backs out: to the view mode if a link already exists, else to the
|
|
101
|
+
// choose screen (there's no link yet, so there's nothing to "view").
|
|
102
|
+
if (name === "escape") return existing ? setMode("view") : setMode("choose")
|
|
79
103
|
// ↑/↓ switch fields (Enter advances/saves via the inputs' onSubmit).
|
|
80
104
|
if (name === "up") return setField("path")
|
|
81
105
|
if (name === "down") return setField("url")
|
|
@@ -138,12 +162,33 @@ export function LocalLinkOverlay() {
|
|
|
138
162
|
<text content={existing ? "linked" : "not linked"} fg={existing ? theme.good : theme.textFaint} style={{ flexShrink: 0 }} />
|
|
139
163
|
</box>
|
|
140
164
|
|
|
141
|
-
<Centered>{mode === "edit" ? renderEdit() : renderView()}</Centered>
|
|
165
|
+
<Centered>{mode === "choose" ? renderChoose() : mode === "edit" ? renderEdit() : renderView()}</Centered>
|
|
142
166
|
|
|
143
167
|
<StatusBar hints={hints()} message={flash ?? undefined} messageColor={theme.brand} showGlobal={false} />
|
|
144
168
|
</box>
|
|
145
169
|
)
|
|
146
170
|
|
|
171
|
+
function renderChoose() {
|
|
172
|
+
return (
|
|
173
|
+
<Panel title=" No local copy yet " active>
|
|
174
|
+
<box style={{ flexDirection: "column", width: 62, paddingTop: 1, paddingBottom: 1 }}>
|
|
175
|
+
<text content="How do you want to set this up?" fg={theme.text} wrapMode="none" />
|
|
176
|
+
<box style={{ height: 1 }} />
|
|
177
|
+
<box style={{ flexDirection: "row" }}>
|
|
178
|
+
<text content="c " fg={theme.accent} style={{ flexShrink: 0 }} />
|
|
179
|
+
<text content="Clone fullsite (DB + files) from production" fg={theme.text} wrapMode="none" />
|
|
180
|
+
</box>
|
|
181
|
+
<text content=" git clone / file pull, no uploads/ — guided" fg={theme.textFaint} wrapMode="none" />
|
|
182
|
+
<box style={{ height: 1 }} />
|
|
183
|
+
<box style={{ flexDirection: "row" }}>
|
|
184
|
+
<text content="e " fg={theme.accent} style={{ flexShrink: 0 }} />
|
|
185
|
+
<text content="Enter a path — you already have a copy" fg={theme.text} wrapMode="none" />
|
|
186
|
+
</box>
|
|
187
|
+
</box>
|
|
188
|
+
</Panel>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
147
192
|
function renderView() {
|
|
148
193
|
return (
|
|
149
194
|
<Panel title=" Linked working copy " active>
|
|
@@ -203,6 +248,12 @@ export function LocalLinkOverlay() {
|
|
|
203
248
|
}
|
|
204
249
|
|
|
205
250
|
function hints() {
|
|
251
|
+
if (mode === "choose")
|
|
252
|
+
return [
|
|
253
|
+
{ key: "c", label: "clone from production" },
|
|
254
|
+
{ key: "e", label: "enter a path" },
|
|
255
|
+
{ key: "esc", label: "cancel" },
|
|
256
|
+
]
|
|
206
257
|
if (mode === "edit")
|
|
207
258
|
return [
|
|
208
259
|
{ key: "↑↓", label: "field" },
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Clone a brand-new local working copy from production — the guided
|
|
2
|
+
// alternative to `L`'s manual "enter a path" form, offered when a site has no
|
|
3
|
+
// local link yet. Opened from LocalLink.tsx's choose screen.
|
|
4
|
+
//
|
|
5
|
+
// Gets the code down (git clone, or an rsync pull over SSH excluding uploads/
|
|
6
|
+
// for non-git sites), runs `composer install` for Bedrock/Radicle, then links
|
|
7
|
+
// the result exactly like the manual form would. It deliberately stops there —
|
|
8
|
+
// the local database and webserver config are NOT touched here. On success it
|
|
9
|
+
// hands off to the existing dbSync (`p`) flow unchanged (which itself gates on
|
|
10
|
+
// `localSync` being enabled), then that flow's own done screen already nudges
|
|
11
|
+
// into mediaFallback (`m`). Three existing, already-reviewed screens chained
|
|
12
|
+
// by keypresses, not one new mega state machine.
|
|
13
|
+
|
|
14
|
+
import { useState } from "react"
|
|
15
|
+
import { useKeyboard } from "@opentui/react"
|
|
16
|
+
import { theme } from "../../lib/theme.ts"
|
|
17
|
+
import { truncate, middleTruncate } from "../../lib/format.ts"
|
|
18
|
+
import { Panel, Centered, DestPath, Steps, type StepRow } from "../components.tsx"
|
|
19
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
20
|
+
import { useStore } from "../store.tsx"
|
|
21
|
+
import type { LocalSetupStage } from "../../lib/localSetup.ts"
|
|
22
|
+
|
|
23
|
+
const SETUP_STEPS: { stage: LocalSetupStage; label: string }[] = [
|
|
24
|
+
{ stage: "fetch", label: "Get the code (clone / pull)" },
|
|
25
|
+
{ stage: "build", label: "composer install" },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
export function LocalSetupOverlay() {
|
|
29
|
+
const { localSetupSite: site, setLocalSetupSite, serverById, planLocalSetupFor, localSetups, startLocalSetup, clearLocalSetup, localSync, setEnableLocalSyncSite, setDbSyncSite } = useStore()
|
|
30
|
+
|
|
31
|
+
const progress = site ? localSetups.get(site.id) : undefined
|
|
32
|
+
const [path, setPath] = useState("")
|
|
33
|
+
const [url, setUrl] = useState("")
|
|
34
|
+
const [field, setField] = useState<"path" | "url">("path")
|
|
35
|
+
const [formError, setFormError] = useState<string | null>(null)
|
|
36
|
+
|
|
37
|
+
const dp: "form" | "running" | "done" | "error" =
|
|
38
|
+
!progress ? "form" : progress.stage === "done" ? "done" : progress.stage === "error" ? "error" : "running"
|
|
39
|
+
|
|
40
|
+
const close = () => {
|
|
41
|
+
if (site && (progress?.stage === "done" || progress?.stage === "error")) clearLocalSetup(site.id)
|
|
42
|
+
setLocalSetupSite(null)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const submit = () => {
|
|
46
|
+
if (!site) return
|
|
47
|
+
const res = planLocalSetupFor(site, path, url)
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
setFormError(res.error)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
setFormError(null)
|
|
53
|
+
startLocalSetup(site, path, url)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const pullDb = () => {
|
|
57
|
+
if (!site) return
|
|
58
|
+
close()
|
|
59
|
+
if (!localSync) setEnableLocalSyncSite(site)
|
|
60
|
+
else setDbSyncSite(site)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
useKeyboard((key) => {
|
|
64
|
+
const name = key.name ?? ""
|
|
65
|
+
if (name === "escape" || name === "q") return close()
|
|
66
|
+
|
|
67
|
+
if (dp === "form") {
|
|
68
|
+
if (name === "up") return setField("path")
|
|
69
|
+
if (name === "down") return setField("url")
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
if (dp === "error") {
|
|
73
|
+
if (name === "r" && site) {
|
|
74
|
+
clearLocalSetup(site.id)
|
|
75
|
+
setFormError(null)
|
|
76
|
+
}
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (dp === "done") {
|
|
80
|
+
if (name === "return" || name === "p") return pullDb()
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
if (!site) return null
|
|
86
|
+
const server = serverById(site.server_id)
|
|
87
|
+
const isGit = !!site.git?.repo
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 210 }}>
|
|
91
|
+
<box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
|
|
92
|
+
<text content="⇩ Clone fullsite (DB + files) from production " fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
93
|
+
<text content={truncate(site.domain, 38)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
94
|
+
</box>
|
|
95
|
+
|
|
96
|
+
<Centered>{renderBody()}</Centered>
|
|
97
|
+
|
|
98
|
+
<StatusBar hints={hints()} showGlobal={false} />
|
|
99
|
+
</box>
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
function renderBody() {
|
|
103
|
+
if (dp === "form") {
|
|
104
|
+
return (
|
|
105
|
+
<Panel title=" Clone from production " active>
|
|
106
|
+
<box style={{ flexDirection: "column", width: 68, paddingTop: 1, paddingBottom: 1 }}>
|
|
107
|
+
<box style={{ flexDirection: "row" }}>
|
|
108
|
+
<text content="from " fg={theme.textFaint} />
|
|
109
|
+
<text content={truncate(site!.domain, 30)} fg={theme.accent} wrapMode="none" />
|
|
110
|
+
<text content=" on " fg={theme.textDim} />
|
|
111
|
+
<text content={truncate(server?.name ?? "—", 22)} fg={theme.textDim} wrapMode="none" />
|
|
112
|
+
</box>
|
|
113
|
+
<text content={isGit ? "source: git clone (this site's repo)" : "source: file pull over SSH (excludes uploads/)"} fg={theme.textFaint} wrapMode="none" />
|
|
114
|
+
<box style={{ height: 1 }} />
|
|
115
|
+
<text content="Local path for the new working copy" fg={field === "path" ? theme.accent : theme.textDim} />
|
|
116
|
+
<input
|
|
117
|
+
focused={field === "path"}
|
|
118
|
+
value={path}
|
|
119
|
+
placeholder={`~/code/${site!.domain}`}
|
|
120
|
+
onInput={setPath}
|
|
121
|
+
onSubmit={() => setField("url")}
|
|
122
|
+
style={{ backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }}
|
|
123
|
+
/>
|
|
124
|
+
<box style={{ height: 1 }} />
|
|
125
|
+
<text content="Local URL (optional) — Valet, Cove, LocalWP, Herd, DDEV…" fg={field === "url" ? theme.accent : theme.textDim} />
|
|
126
|
+
<input
|
|
127
|
+
focused={field === "url"}
|
|
128
|
+
value={url}
|
|
129
|
+
placeholder="https://example.test"
|
|
130
|
+
onInput={setUrl}
|
|
131
|
+
onSubmit={submit}
|
|
132
|
+
style={{ backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }}
|
|
133
|
+
/>
|
|
134
|
+
{formError ? (
|
|
135
|
+
<>
|
|
136
|
+
<box style={{ height: 1 }} />
|
|
137
|
+
<text content={`✕ ${formError}`} fg={theme.bad} wrapMode="none" />
|
|
138
|
+
</>
|
|
139
|
+
) : null}
|
|
140
|
+
<box style={{ height: 1 }} />
|
|
141
|
+
<text content="This only pulls code — your local DB/webserver aren't touched." fg={theme.textFaint} wrapMode="none" />
|
|
142
|
+
<text content="↑↓ switch field · Enter on the URL starts · Esc cancels" fg={theme.textFaint} wrapMode="none" />
|
|
143
|
+
</box>
|
|
144
|
+
</Panel>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// "build" (composer install) only applies to Bedrock/Radicle — unknown
|
|
149
|
+
// until the fetch stage finishes, so it stays out of the list entirely
|
|
150
|
+
// (like DbSync's optional hook row) until we actually know, rather than
|
|
151
|
+
// rendering a row that never ran as if it had.
|
|
152
|
+
const buildApplies =
|
|
153
|
+
progress?.stage === "build" ||
|
|
154
|
+
(progress?.stage === "done" && !!progress.ranComposer) ||
|
|
155
|
+
(progress?.stage === "error" && progress.failedStage === "build")
|
|
156
|
+
const steps = SETUP_STEPS.filter((s) => s.stage !== "build" || buildApplies)
|
|
157
|
+
const order = steps.map((s) => s.stage)
|
|
158
|
+
const curIdx = order.indexOf((progress?.stage ?? "fetch") as LocalSetupStage)
|
|
159
|
+
const failedIdx = progress?.failedStage ? order.indexOf(progress.failedStage) : order.length - 1
|
|
160
|
+
const rows: StepRow[] = steps.map(({ label }, i) => {
|
|
161
|
+
const state: StepRow["state"] =
|
|
162
|
+
dp === "done"
|
|
163
|
+
? "done"
|
|
164
|
+
: dp === "error"
|
|
165
|
+
? i < failedIdx
|
|
166
|
+
? "done"
|
|
167
|
+
: i === failedIdx
|
|
168
|
+
? "failed"
|
|
169
|
+
: "pending"
|
|
170
|
+
: i < curIdx
|
|
171
|
+
? "done"
|
|
172
|
+
: i === curIdx
|
|
173
|
+
? "active"
|
|
174
|
+
: "pending"
|
|
175
|
+
return { label, state }
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<Panel title=" Cloning from production " active>
|
|
180
|
+
<box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
|
|
181
|
+
<Steps rows={rows} />
|
|
182
|
+
<box style={{ height: 1 }} />
|
|
183
|
+
{dp === "done" ? (
|
|
184
|
+
<>
|
|
185
|
+
<box style={{ flexDirection: "row" }}>
|
|
186
|
+
<text content="✓ " fg={theme.good} />
|
|
187
|
+
<text content="Files are down and linked." fg={theme.text} wrapMode="none" />
|
|
188
|
+
</box>
|
|
189
|
+
<box style={{ height: 1 }} />
|
|
190
|
+
<DestPath path={middleTruncate(progress?.destPath ?? "", 62)} fileColor={theme.good} width={62} />
|
|
191
|
+
{progress?.ranComposer ? <text content="composer install ran" fg={theme.textFaint} wrapMode="none" /> : null}
|
|
192
|
+
<box style={{ height: 1 }} />
|
|
193
|
+
<text content="Next: create a local database and point your local tool at it." fg={theme.textFaint} wrapMode="none" />
|
|
194
|
+
{/* The file pull copies the site's real wp-config.php, production
|
|
195
|
+
DB credentials and all. Nothing local can reach production's
|
|
196
|
+
database through them (its DB_HOST is localhost), but local
|
|
197
|
+
wp-cli authenticates as the production user, so the DB pull
|
|
198
|
+
below is denied until they're localized. Say so here rather
|
|
199
|
+
than letting it surface as a bare "Access denied" later. */}
|
|
200
|
+
{isGit ? null : (
|
|
201
|
+
<>
|
|
202
|
+
<text content="wp-config.php came down with production's DB credentials —" fg={theme.warn} wrapMode="none" />
|
|
203
|
+
<text content="update DB_NAME / DB_USER / DB_PASSWORD / DB_HOST to match," fg={theme.warn} wrapMode="none" />
|
|
204
|
+
<text content="or the DB pull is denied as production's user." fg={theme.warn} wrapMode="none" />
|
|
205
|
+
</>
|
|
206
|
+
)}
|
|
207
|
+
{/* The mirror image on the git path: Bedrock/Radicle keep DB
|
|
208
|
+
credentials and WP_HOME in .env, which is deliberately not in
|
|
209
|
+
the repo — so a fresh clone has none and wp-cli runs with
|
|
210
|
+
empty settings. composer install having run is the signal. */}
|
|
211
|
+
{progress?.ranComposer ? (
|
|
212
|
+
<>
|
|
213
|
+
<text content=".env isn't in the repo, so this checkout has none — copy" fg={theme.warn} wrapMode="none" />
|
|
214
|
+
<text content=".env.example to .env and set DB_* + WP_HOME before pulling." fg={theme.warn} wrapMode="none" />
|
|
215
|
+
</>
|
|
216
|
+
) : null}
|
|
217
|
+
<text content="Press ⏎ / p to pull production's database now, once that's ready." fg={theme.purple} wrapMode="none" />
|
|
218
|
+
<text content="Esc to finish here" fg={theme.textFaint} wrapMode="none" />
|
|
219
|
+
</>
|
|
220
|
+
) : dp === "error" ? (
|
|
221
|
+
<>
|
|
222
|
+
<text content={`✕ ${progress?.error ?? "Something went wrong."}`} fg={theme.bad} />
|
|
223
|
+
<box style={{ height: 1 }} />
|
|
224
|
+
<text content="Press r to try again · Esc to close" fg={theme.textFaint} wrapMode="none" />
|
|
225
|
+
</>
|
|
226
|
+
) : (
|
|
227
|
+
<text content="You can press Esc — it keeps running in the background." fg={theme.textFaint} wrapMode="none" />
|
|
228
|
+
)}
|
|
229
|
+
</box>
|
|
230
|
+
</Panel>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function hints() {
|
|
235
|
+
switch (dp) {
|
|
236
|
+
case "form":
|
|
237
|
+
return [
|
|
238
|
+
{ key: "↑↓", label: "field" },
|
|
239
|
+
{ key: "⏎", label: "start" },
|
|
240
|
+
{ key: "esc", label: "cancel" },
|
|
241
|
+
]
|
|
242
|
+
case "error":
|
|
243
|
+
return [
|
|
244
|
+
{ key: "r", label: "retry" },
|
|
245
|
+
{ key: "esc", label: "close" },
|
|
246
|
+
]
|
|
247
|
+
case "done":
|
|
248
|
+
return [
|
|
249
|
+
{ key: "⏎/p", label: "pull DB" },
|
|
250
|
+
{ key: "esc", label: "close" },
|
|
251
|
+
]
|
|
252
|
+
default:
|
|
253
|
+
return [{ key: "esc", label: "close" }]
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|