spinuptui 0.24.4 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,164 @@
1
+ // Set up a brand-new local working copy for a remote SpinupWP site — the
2
+ // missing first step before dbSync (`p`) and mediaFallback (`m`) can run.
3
+ //
4
+ // Gets the CODE down (git clone for git-deployed sites, an rsync pull over SSH
5
+ // for everything else — excluding uploads/ and the server-specific caching
6
+ // drop-ins, object-cache.php/advanced-cache.php: they're wired to production's
7
+ // Redis/disk cache, and object-cache.php doesn't just no-op without it, it
8
+ // fatals) and runs `composer install` locally when the checkout turns out to
9
+ // be Bedrock/Radicle. Deliberately stops there: it never touches the local
10
+ // database or webserver config, and never links the site itself — the caller
11
+ // does that (reusing `linkSite`) once this succeeds, then hands off to the
12
+ // existing dbSync (`p`) and mediaFallback (`m`) flows unchanged.
13
+ //
14
+ // For a public/-style webroot, wp-config.php lives ONE level above it (see
15
+ // CLAUDE.md's WordPress layout rules) — so the rsync path always pulls the
16
+ // site's whole files root, not just the detected webroot, so wp-config.php
17
+ // (and anything else living beside it) comes down too. `public_folder` is a
18
+ // SETTING, not a fact (SpinupWP never enforces it), so the real webroot is
19
+ // detected on the source via `detectWpDirScript` rather than trusted.
20
+
21
+ import { existsSync, mkdirSync, readdirSync } from "node:fs"
22
+ import { dirname } from "node:path"
23
+ import type { Server, Site } from "../api/types.ts"
24
+ import { expandPath, resolveLocalLink } from "./local.ts"
25
+ import { SSH_OPTS, sshPort, runProcess, meaningfulError } from "./dbBackup.ts"
26
+ import { detectWpDirScript } from "./serverClone.ts"
27
+
28
+ export interface LocalSetupPlan {
29
+ domain: string
30
+ isGit: boolean
31
+ repoUrl: string | null // git clone source, when isGit
32
+ user: string // SSH user — only needed for the non-git rsync path
33
+ host: string
34
+ port: number | null
35
+ filesRoot: string // remote /sites/{domain}/files — rsync source root
36
+ publicFolder: string | null // as configured; the real webroot is still detected, not trusted
37
+ destPath: string // expanded local destination (must not exist, or be empty)
38
+ destUrl: string
39
+ }
40
+
41
+ export type LocalSetupPlanResult = { ok: true; plan: LocalSetupPlan } | { ok: false; error: string }
42
+
43
+ export function planLocalSetup(
44
+ site: Site,
45
+ server: Server | undefined,
46
+ sshUser: string | null,
47
+ destPath: string,
48
+ destUrl: string,
49
+ ): LocalSetupPlanResult {
50
+ const path = destPath.trim()
51
+ if (!path) return { ok: false, error: "Enter a local path." }
52
+ const dir = expandPath(path)
53
+ if (existsSync(dir) && readdirSync(dir).length > 0) return { ok: false, error: "That path already exists and isn't empty." }
54
+
55
+ const isGit = !!site.git?.repo
56
+ const host = server?.ip_address
57
+ const user = site.site_user ?? sshUser
58
+ // Non-git sites are pulled over SSH, so they need a reachable server + user.
59
+ // Git sites clone straight from the repo and never touch the server.
60
+ if (!isGit && (!host || !user)) return { ok: false, error: "Missing site user or server IP — can't reach the site." }
61
+
62
+ return {
63
+ ok: true,
64
+ plan: {
65
+ domain: site.domain,
66
+ isGit,
67
+ repoUrl: site.git?.repo ?? null,
68
+ user: user ?? "",
69
+ host: host ?? "",
70
+ port: server?.ssh_port ?? null,
71
+ filesRoot: `/sites/${site.domain}/files`,
72
+ publicFolder: site.public_folder,
73
+ destPath: dir,
74
+ destUrl: destUrl.trim(),
75
+ },
76
+ }
77
+ }
78
+
79
+ export type LocalSetupStage = "fetch" | "build" | "done" | "error"
80
+
81
+ export interface LocalSetupProgress {
82
+ stage: LocalSetupStage
83
+ domain: string
84
+ destPath?: string
85
+ error?: string
86
+ failedStage?: LocalSetupStage
87
+ ranComposer?: boolean
88
+ }
89
+
90
+ export function isLocalSetupInFlight(p: LocalSetupProgress | undefined): boolean {
91
+ return p != null && p.stage !== "done" && p.stage !== "error"
92
+ }
93
+
94
+ // rsync the source's whole files root down, excluding uploads wherever they
95
+ // land (relative to the detected webroot, not assumed at the files root).
96
+ async function fetchByRsync(plan: LocalSetupPlan): Promise<{ code: number; stderr: string }> {
97
+ const target = `${plan.user}@${plan.host}`
98
+ const probeScript = `${detectWpDirScript(plan.filesRoot, plan.publicFolder ?? undefined)}; echo "D:$D"; echo "W:$W"`
99
+ const probe = await runProcess(["ssh", ...SSH_OPTS, ...sshPort(plan.port), target, probeScript], 30_000)
100
+ if (probe.code !== 0) return { code: probe.code, stderr: probe.stderr }
101
+ const d = probe.stdout.match(/^D:(.*)$/m)?.[1]?.trim()
102
+ const w = probe.stdout.match(/^W:(.*)$/m)?.[1]?.trim()
103
+ if (!d) return { code: 1, stderr: "couldn't determine the site's files root on the server." }
104
+ if (!w) return { code: 1, stderr: "couldn't find a WordPress install under this site on the server." }
105
+
106
+ // Paths relative to the files root we're about to rsync, e.g.
107
+ // "public/wp-content/uploads" when the webroot sits below the files root, or
108
+ // bare "wp-content/uploads" when it doesn't.
109
+ const webrootRel = w === d ? "" : w.slice(d.length).replace(/^\/+/, "")
110
+ const contentRel = (name: string) => (webrootRel ? `${webrootRel}/wp-content/${name}` : `wp-content/${name}`)
111
+ // object-cache.php (and advanced-cache.php, same family) are server drop-ins
112
+ // wired to production's Redis/disk cache — they don't just no-op without it,
113
+ // they fatal (confirmed: SpinupWP's object-cache drop-in 500s on a cache MISS
114
+ // when Redis is unreachable). Local dev doesn't want production caching
115
+ // anyway, so these are excluded outright rather than copied and hoping.
116
+ const excludes = ["uploads", "object-cache.php", "advanced-cache.php"].map((name) => `--exclude=${contentRel(name)}`)
117
+
118
+ const sshCmd = ["ssh", ...SSH_OPTS, ...sshPort(plan.port)].join(" ")
119
+ return runProcess(["rsync", "-az", "-e", sshCmd, ...excludes, `${target}:${d}/`, `${plan.destPath}/`], 600_000)
120
+ }
121
+
122
+ export async function runLocalSetup(plan: LocalSetupPlan, onProgress: (p: LocalSetupProgress) => void): Promise<LocalSetupProgress> {
123
+ const domain = plan.domain
124
+ const fail = (error: string, failedStage: LocalSetupStage = "fetch"): LocalSetupProgress => {
125
+ const p: LocalSetupProgress = { stage: "error", domain, error, failedStage }
126
+ onProgress(p)
127
+ return p
128
+ }
129
+ const stageFail = (label: string, stderr: string, fallback: string, failedStage: LocalSetupStage) =>
130
+ fail(`${label} — ${meaningfulError(stderr, fallback)}`, failedStage)
131
+
132
+ onProgress({ stage: "fetch", domain })
133
+ try {
134
+ mkdirSync(dirname(plan.destPath), { recursive: true })
135
+ } catch {
136
+ // best-effort — the actual clone/rsync surfaces a real error if this truly can't be created
137
+ }
138
+
139
+ if (plan.isGit) {
140
+ if (!plan.repoUrl) return fail("This site has no git repo configured in SpinupWP.", "fetch")
141
+ const cl = await runProcess(["git", "clone", plan.repoUrl, plan.destPath], 300_000)
142
+ if (cl.code !== 0) return stageFail("git clone failed", cl.stderr, `exit ${cl.code}.`, "fetch")
143
+ } else {
144
+ mkdirSync(plan.destPath, { recursive: true })
145
+ const rs = await fetchByRsync(plan)
146
+ if (rs.code !== 0) return stageFail("File pull failed", rs.stderr, `exit ${rs.code}.`, "fetch")
147
+ }
148
+
149
+ // Bedrock/Radicle needs `composer install` to build vendor/ + web/wp before
150
+ // anything (wp-cli included) can find a WordPress install in the checkout.
151
+ // Standard WP ships its core in the tree already, so this is a no-op there.
152
+ const kind = resolveLocalLink({ domain, path: plan.destPath, localUrl: plan.destUrl }).kind
153
+ let ranComposer = false
154
+ if (kind === "bedrock" || kind === "radicle") {
155
+ onProgress({ stage: "build", domain })
156
+ const ci = await runProcess(["bash", "-lc", "composer install"], 300_000, plan.destPath)
157
+ if (ci.code !== 0) return stageFail("composer install failed", ci.stderr, "composer error.", "build")
158
+ ranComposer = true
159
+ }
160
+
161
+ const done: LocalSetupProgress = { stage: "done", domain, destPath: plan.destPath, ranComposer }
162
+ onProgress(done)
163
+ return done
164
+ }
@@ -44,8 +44,9 @@ export async function execSshCommand(
44
44
  command: string,
45
45
  client: SpinupWPClientLike,
46
46
  cfg: AppConfig,
47
+ opts?: { server?: string | null },
47
48
  ): Promise<SshExecResult> {
48
- const resolution = await resolveSshTargetInfo(domain, client, cfg)
49
+ const resolution = await resolveSshTargetInfo(domain, client, cfg, opts)
49
50
  if (!resolution.ok) {
50
51
  logSshExecAttempt({ domain, command, decision: "error", reason: resolution.result.reason, message: resolution.result.message })
51
52
  return { ...resolution.result, command }
package/src/ui/App.tsx CHANGED
@@ -30,6 +30,7 @@ import { NewServer } from "./views/NewServer.tsx"
30
30
  import { VanityNewSite } from "./views/VanityNewSite.tsx"
31
31
  import { CloneWizard } from "./views/CloneWizard.tsx"
32
32
  import { LocalLinkOverlay } from "./views/LocalLink.tsx"
33
+ import { LocalSetupOverlay } from "./views/LocalSetup.tsx"
33
34
  import { Discover } from "./views/Discover.tsx"
34
35
  import { Forgotten } from "./views/Forgotten.tsx"
35
36
  import { DnsInventory } from "./views/DnsInventory.tsx"
@@ -75,6 +76,7 @@ export function App() {
75
76
  store.vanityServer !== null ||
76
77
  store.cloneServer !== null ||
77
78
  store.localLinkSite !== null ||
79
+ store.localSetupSite !== null ||
78
80
  store.discoverOpen ||
79
81
  store.forgottenOpen ||
80
82
  store.dnsInventoryServer !== null ||
@@ -166,6 +168,9 @@ export function App() {
166
168
  // The local-link overlay owns the keyboard while open.
167
169
  if (store.localLinkSite) return
168
170
 
171
+ // The new-local-copy (clone/rsync) overlay owns the keyboard while open.
172
+ if (store.localSetupSite) return
173
+
169
174
  // The discovery overlay owns the keyboard while open.
170
175
  if (store.discoverOpen) return
171
176
 
@@ -256,6 +261,7 @@ export function App() {
256
261
  {store.vanityServer && <VanityNewSite />}
257
262
  {store.cloneServer && <CloneWizard />}
258
263
  {store.localLinkSite && <LocalLinkOverlay />}
264
+ {store.localSetupSite && <LocalSetupOverlay />}
259
265
  {store.discoverOpen && <Discover />}
260
266
  {store.forgottenOpen && <Forgotten />}
261
267
  {store.dnsInventoryServer && <DnsInventory />}
package/src/ui/Splash.tsx CHANGED
@@ -4,6 +4,7 @@
4
4
  import { useEffect, useState } from "react"
5
5
  import { theme } from "../lib/theme.ts"
6
6
  import { Spinner } from "./components.tsx"
7
+ import { APP_VERSION } from "../version.ts"
7
8
 
8
9
  const TAGLINE = "Terminal control center for your SpinupWP fleet"
9
10
 
@@ -40,6 +41,8 @@ export function Splash({ status }: { status: string }) {
40
41
  <box style={{ height: 1 }} />
41
42
  <text content={TAGLINE} fg={theme.text} />
42
43
  <box style={{ height: 1 }} />
44
+ <text content={`Version ${APP_VERSION}`} fg={theme.brand} />
45
+ <box style={{ height: 1 }} />
43
46
  <Pulse />
44
47
  <box style={{ height: 2 }} />
45
48
  <box style={{ flexDirection: "row" }}>
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" },