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/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). Two modes:
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
- // Per the locked spec these are LOCAL conveniences only: we open a terminal and
10
- // the local URL — we never launch an editor and never run composer/git here.
11
- // The mutating maintenance loop (composer update → push → deploy) is a later phase.
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" : "edit"))
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 closes.
78
- if (name === "escape") return existing ? setMode("view") : close()
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
+ }