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.
- package/CHANGELOG.md +112 -1
- package/README.md +65 -10
- package/package.json +1 -1
- package/src/index.tsx +131 -11
- package/src/lib/cliPull.ts +382 -0
- package/src/lib/cliSsh.ts +74 -49
- package/src/lib/dbBackup.ts +7 -4
- package/src/lib/dbSync.ts +15 -1
- package/src/lib/links.ts +23 -0
- package/src/lib/localSetup.ts +164 -0
- package/src/lib/sshExec.ts +2 -1
- package/src/ui/App.tsx +6 -0
- package/src/ui/Splash.tsx +3 -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/views/ServerActions.tsx +30 -4
|
@@ -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
|
+
}
|
package/src/lib/sshExec.ts
CHANGED
|
@@ -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).
|
|
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" },
|