spinuptui 0.28.0 → 1.1.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/src/ui/store.tsx CHANGED
@@ -10,7 +10,7 @@ import { toast } from "./toast.ts"
10
10
  import { SpinupWPClient, ApiError, type ServerService, type SpinupWPClientLike } from "../api/client.ts"
11
11
  import { isDevMode } from "../dev/devMode.ts"
12
12
  import { createMockClient } from "../dev/mockClient.ts"
13
- import type { Server, Site, Event, ProviderMetadata, CreateServerPayload, CreateSitePayload, AdditionalDomain } from "../api/types.ts"
13
+ import type { Server, Site, Event, ProviderMetadata, CreateServerPayload, CreateSitePayload, AdditionalDomain, UpdateSiteGitPayload } from "../api/types.ts"
14
14
  import { loadConfig, saveConfig, type StoredConfig, type ServerProviderRef, type KumaMonitorRef, type UptimeKumaConn } from "../config.ts"
15
15
  import { saveJob, removeJob } from "../lib/jobs.ts"
16
16
  import { APP_VERSION } from "../version.ts"
@@ -45,8 +45,9 @@ import { withKuma, KumaClient, registerMonitors, registerFingerprintMonitor, reg
45
45
  import { deriveFingerprint } from "../lib/siteFingerprint.ts"
46
46
  import type { StoredProviders } from "../config.ts"
47
47
  import { fetchRebootInfo, grantSiteSshKey, revokeSiteSshKey, verifySudo, ensureSpinupKey, listPersonalKeys, keyBody, type RebootInfo } from "../lib/ssh.ts"
48
- import { estimateSourceSiteSizes, runStandardWpPull, runBedrockPull, runFilesOnlyPull, verifyClone, verifyFilesClone, preflightBedrockSource, type SudoCtx, type CloneStage, type CloneExecRecord, type VerifyResult as CloneVerifyResult } from "../lib/serverClone.ts"
48
+ import { estimateSourceSiteSizes, runStandardWpPull, runBedrockPull, runFilesOnlyPull, verifyClone, verifyFilesClone, preflightBedrockSource, syncCrontab, type SudoCtx, type CloneStage, type CloneExecRecord, type VerifyResult as CloneVerifyResult } from "../lib/serverClone.ts"
49
49
  import { CloneLogger } from "../lib/cloneLog.ts"
50
+ import { carrySiteSettings, type SettingsCarryResult } from "../lib/cloneSettings.ts"
50
51
  import { syncAdditionalDomains } from "../lib/cloneDomains.ts"
51
52
  import { parseRepo, deployKeysSettingsUrl, ghAvailable, ghDeployKeyPresent, ghAddDeployKey, generateDeployKeypair, type RepoHost } from "../lib/gitDeployKey.ts"
52
53
  import { keychainAvailable, setSudoPassword, getSudoPassword, deleteSudoPassword } from "../lib/keychain.ts"
@@ -268,6 +269,9 @@ export interface CloneSiteState {
268
269
  stack: "wp" | "bedrock" | "files" // git repo → bedrock; is_wordpress → wp; else files-only (redirect shells, static/PHP sites)
269
270
  gitRepo?: string // source git.repo (Bedrock) — the dest is created as a `git` site of it
270
271
  gitBranch?: string // source git.branch
272
+ deployScript?: string // source git deploy script — the dest's own, and Radicle's build step
273
+ pushEnabled?: boolean // source push-to-deploy on/off — the dest is created to match
274
+ pageCache?: boolean // source page cache on/off — the dest is created to match
271
275
  gitDeployKey?: { privateKey: string; publicKey: string } // unique per-site key (stamped leaving gitaccess) → create payload
272
276
  additionalDomains?: string[] // extra domains served by the site → extra cutover records
273
277
  additionalDomainConfigs?: AdditionalDomain[] // full source configs (redirects) — re-created on the dest
@@ -293,6 +297,17 @@ export interface CloneSiteState {
293
297
  verify?: CloneVerifyResult // source-vs-clone comparison + HTTP check
294
298
  verifyError?: string
295
299
  cutover?: CloneCutoverState // slice 6: DNS repoint state for this site's domain
300
+ cronAdded?: string[] // owner crontab lines carried to the dest (empty = none to carry)
301
+ cronError?: string // crontab carry-over failed (the clone itself still succeeded)
302
+ settingsCarried?: string[] // SpinupWP settings written to match the source (see carrySiteSettings)
303
+ settingsFailed?: { what: string; error: string }[] // …and the ones that didn't take
304
+ basicAuthMissing?: boolean // the source is password-protected; the clone can't be (password unreadable)
305
+ // The source had a deploy script but the dest still reads back none after we set
306
+ // it again (SpinupWP drops a script set on create). The verify pane offers it for
307
+ // copy with a link to the dest's Git settings.
308
+ deployScriptLost?: boolean
309
+ deployScriptRestored?: boolean // SpinupWP dropped the script and we set it again
310
+ pushMismatch?: boolean // the dest's push-to-deploy reads back different from the source's
296
311
  }
297
312
  // Slice 6: per-site DNS cutover. Each A record among the site's domains (primary +
298
313
  // additional_domains) is repointed from the old server IP to the new one. www-style
@@ -387,6 +402,52 @@ const INTERRUPTED_LOCAL_SETUP_MSG = "Interrupted by a restart — the local copy
387
402
  const SERVER_DONE = new Set(["deployed", "completed", "provisioned", "finished", "success"])
388
403
  const SERVER_FAIL = new Set(["failed", "errored", "error"])
389
404
 
405
+ // SpinupWP clears a deploy script sent with POST /sites a minute or two after the
406
+ // create (seen at ~90s, 2026-09-24). A PUT made before that would be wiped along
407
+ // with it, so the restore below waits until this long after the create.
408
+ const DEPLOY_SCRIPT_WIPE_MS = 150_000
409
+
410
+ // Put a cloned git site's deploy script and push-to-deploy back to the source's
411
+ // values if the dest reads back different, then re-read so only what's still
412
+ // wrong is reported. Best-effort: a failed write or read never fails the clone.
413
+ async function restoreDestGit(
414
+ client: SpinupWPClientLike,
415
+ destSiteId: number,
416
+ site: Pick<CloneSiteState, "domain" | "deployScript" | "pushEnabled">,
417
+ createdAt: number | undefined,
418
+ logger: CloneLogger | null | undefined,
419
+ onWait: () => void,
420
+ ): Promise<{ deployScriptLost: boolean; deployScriptRestored: boolean; pushMismatch: boolean } | undefined> {
421
+ const wantScript = site.deployScript?.trim()
422
+ const diff = (dest: Site) => ({
423
+ script: !!wantScript && !dest.git?.deploy_script?.trim(),
424
+ push: site.pushEnabled != null && dest.git?.push_enabled !== site.pushEnabled,
425
+ })
426
+ try {
427
+ const wait = createdAt != null ? DEPLOY_SCRIPT_WIPE_MS - (Date.now() - createdAt) : 0
428
+ if (wait > 0) {
429
+ onWait()
430
+ await new Promise((r) => setTimeout(r, wait))
431
+ }
432
+ const before = diff(await client.getSite(destSiteId))
433
+ if (!before.script && !before.push) return { deployScriptLost: false, deployScriptRestored: false, pushMismatch: false }
434
+ const payload: UpdateSiteGitPayload = {
435
+ ...(before.script ? { deploy_script: wantScript } : {}),
436
+ ...(before.push ? { push_to_deploy: site.pushEnabled } : {}),
437
+ }
438
+ try {
439
+ await client.updateSiteGit(destSiteId, payload)
440
+ logger?.log({ event: "git-restore", domain: site.domain, fields: Object.keys(payload) })
441
+ } catch (err) {
442
+ logger?.log({ event: "git-restore", domain: site.domain, fields: Object.keys(payload), error: (err as Error).message })
443
+ }
444
+ const after = diff(await client.getSite(destSiteId))
445
+ return { deployScriptLost: after.script, deployScriptRestored: before.script && !after.script, pushMismatch: after.push }
446
+ } catch {
447
+ return undefined // unknown — say nothing rather than guess
448
+ }
449
+ }
450
+
390
451
  export function isNewServerInFlight(j: NewServerJob | null | undefined): boolean {
391
452
  return j != null && j.status !== "done" && j.status !== "failed"
392
453
  }
@@ -3573,6 +3634,9 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3573
3634
  stack,
3574
3635
  gitRepo: s.git?.repo ?? undefined,
3575
3636
  gitBranch: s.git?.branch ?? undefined,
3637
+ deployScript: s.git?.deploy_script ?? undefined,
3638
+ pushEnabled: s.git?.push_enabled ?? undefined,
3639
+ pageCache: s.page_cache?.enabled ?? undefined,
3576
3640
  additionalDomains: (s.additional_domains ?? []).map((d) => d.domain),
3577
3641
  additionalDomainConfigs: s.additional_domains ?? [],
3578
3642
  excludeUploads: false,
@@ -3801,7 +3865,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3801
3865
  // site-creation + auth cycle on a clone that can't succeed. Not a guarantee
3802
3866
  // (see preflightBedrockSource) — the real check is still the one after create.
3803
3867
  if (site.stack === "bedrock") {
3804
- const pre = await preflightBedrockSource(source, { domain: site.domain, publicFolder: site.publicFolder })
3868
+ const pre = await preflightBedrockSource(source, { domain: site.domain, publicFolder: site.publicFolder, deployScript: site.deployScript }, dest)
3805
3869
  if (!pre.ok) return fail("create", pre.error)
3806
3870
  }
3807
3871
  // create — Bedrock → `git` site (SpinupWP clones the repo); Standard WP →
@@ -3818,6 +3882,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3818
3882
  // DB password can't be recovered to re-stamp wp-config, so a leftover from a
3819
3883
  // prior run must be removed first (create will surface a clear conflict).
3820
3884
  let destSiteId = site.destSiteId
3885
+ let createdAt: number | undefined // when THIS run created the dest (see the git restore below)
3821
3886
  if (destSiteId == null) {
3822
3887
  let ev
3823
3888
  try {
@@ -3831,14 +3896,18 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3831
3896
  php_version: site.phpVersion,
3832
3897
  public_folder: site.publicFolder ?? "/web/",
3833
3898
  database: { name: dbName, username: dbName, password: dbPw, table_prefix: site.tablePrefix || "wp_" },
3834
- // deploy_script is TOP-LEVEL; we send the corrected canonical (the
3835
- // source's stored value has a known typo) for future push-to-deploy —
3836
- // we composer install over SSH regardless (git/deploy won't run it).
3837
- deploy_script: "composer install -o --no-dev",
3899
+ // deploy_script is TOP-LEVEL; we send the source's own (canonical
3900
+ // only as the fallback). CAVEAT, verified 2026-09-24: SpinupWP stores
3901
+ // it, echoes it back, then clears it a minute or two later — so it's
3902
+ // set again with PUT /git once the site is done (see below). Nothing
3903
+ // here depends on it: the pull runs composer (and a Radicle source's
3904
+ // own script) over SSH.
3905
+ deploy_script: site.deployScript?.trim() || "composer install -o --no-dev",
3838
3906
  git: {
3839
3907
  repo: site.gitRepo!,
3840
3908
  branch: site.gitBranch ?? "main",
3841
- push_to_deploy: true,
3909
+ // Match the source (on when unknown, the long-standing default).
3910
+ push_to_deploy: site.pushEnabled ?? true,
3842
3911
  // unique per-site key (gitaccess step) — the server-wide
3843
3912
  // git_publickey fits only ONE GitHub repo, so it can't be
3844
3913
  // reused; SpinupWP installs this pair as the site's git identity.
@@ -3863,7 +3932,11 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3863
3932
  public_folder: site.publicFolder,
3864
3933
  database: { name: dbName, username: dbName, password: dbPw, table_prefix: site.tablePrefix || "wp_" },
3865
3934
  }
3935
+ // Match the source's page cache — a deploy script that purges it
3936
+ // (`wp spinupwp cache purge-site`) fails on a site where it's off.
3937
+ if (site.pageCache != null) payload.page_cache = { enabled: site.pageCache }
3866
3938
  ev = await client.createSite(payload)
3939
+ createdAt = Date.now()
3867
3940
  logger?.log({ event: "create-requested", domain: site.domain, eventId: ev?.event_id })
3868
3941
  } catch (err) {
3869
3942
  return fail("create", err instanceof ApiError ? err.message : (err as Error).message)
@@ -3928,7 +4001,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3928
4001
  const onExec = logger ? (e: CloneExecRecord) => logger.log({ event: "exec", ...e }) : undefined
3929
4002
  const res =
3930
4003
  site.stack === "bedrock"
3931
- ? await runBedrockPull(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, destDbName: dbName, destDbUser: dbName, destDbPassword: dbPw, excludeUploads: site.excludeUploads, publicFolder: site.publicFolder }, onProgress, onExec, onTransfer)
4004
+ ? await runBedrockPull(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, destDbName: dbName, destDbUser: dbName, destDbPassword: dbPw, excludeUploads: site.excludeUploads, publicFolder: site.publicFolder, deployScript: site.deployScript }, onProgress, onExec, onTransfer)
3932
4005
  : site.stack === "files"
3933
4006
  ? await runFilesOnlyPull(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, approxFilesBytes: site.sizeWebBytes }, onProgress, onExec, onTransfer)
3934
4007
  : await runStandardWpPull(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, destDbName: dbName, destDbUser: dbName, destDbPassword: dbPw, publicFolder: site.publicFolder, approxFilesBytes: site.sizeWebBytes }, onProgress, onExec, onTransfer)
@@ -3937,8 +4010,28 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3937
4010
  return fail(stageToStep(stage), res.error ?? "pull failed")
3938
4011
  }
3939
4012
  const { sourceWebrootRel, destWebrootRel } = res as { sourceWebrootRel?: string; destWebrootRel?: string }
3940
- logger?.log({ event: "site-done", domain: site.domain, sourceWebrootRel, destWebrootRel })
3941
- set((s) => ({ ...s, step: "done", detail: undefined, error: undefined, failedStep: undefined, sourceWebrootRel, destWebrootRel }))
4013
+ // Owner-added cron jobs (see syncCrontab). Best-effort: a failure is
4014
+ // reported on the site, never fails a clone whose files and DB are in place.
4015
+ set((s) => ({ ...s, detail: "crontab" }))
4016
+ const cron = await syncCrontab(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser }, onExec)
4017
+ logger?.log({ event: "crontab", domain: site.domain, ok: cron.ok, added: cron.added, error: cron.error, sourceWpCron: cron.sourceWpCron, destWpCron: cron.destWpCron })
4018
+ // SpinupWP settings the create can't carry: page cache details, the nginx
4019
+ // security toggles, redirects, WP cron interval. Best-effort like the crontab.
4020
+ let settings: SettingsCarryResult | undefined
4021
+ if (destSiteId != null) {
4022
+ set((s) => ({ ...s, step: "config", detail: "settings" }))
4023
+ settings = await carrySiteSettings(client, site.sourceSiteId, destSiteId, { source: cron.sourceWpCron, dest: cron.destWpCron }, logger ? (e) => logger.log({ domain: site.domain, ...e }) : undefined)
4024
+ }
4025
+ // Did the dest keep the deploy script we sent on create? It usually doesn't
4026
+ // (see the create payload), so re-read and, if it's gone, set it again with
4027
+ // PUT /git — push-to-deploy too, should it read back different. Only what's
4028
+ // still wrong after that is flagged for the user.
4029
+ const git = site.gitRepo && destSiteId != null ? await restoreDestGit(client, destSiteId, site, createdAt, logger, () => set((s) => ({ ...s, step: "config", detail: "restoring deploy script", stageStartedAt: Date.now() }))) : undefined
4030
+ const deployScriptLost = git?.deployScriptLost ?? false
4031
+ const pushMismatch = git?.pushMismatch ?? false
4032
+ const deployScriptRestored = git?.deployScriptRestored ?? false
4033
+ logger?.log({ event: "site-done", domain: site.domain, sourceWebrootRel, destWebrootRel, deployScriptLost, deployScriptRestored, pushMismatch, settingsCarried: settings?.carried, settingsFailed: settings?.failed, basicAuthMissing: settings?.basicAuthMissing })
4034
+ set((s) => ({ ...s, step: "done", detail: undefined, error: undefined, failedStep: undefined, sourceWebrootRel, destWebrootRel, cronAdded: cron.added, cronError: cron.ok ? undefined : cron.error, deployScriptLost, deployScriptRestored, pushMismatch, settingsCarried: settings?.carried, settingsFailed: settings?.failed, basicAuthMissing: settings?.basicAuthMissing }))
3942
4035
  } catch (err) {
3943
4036
  fail(site.step, (err as Error).message)
3944
4037
  }
@@ -16,8 +16,8 @@ import { Panel, Spinner } from "../components.tsx"
16
16
  import { StatusBar } from "../StatusBar.tsx"
17
17
  import { openUrl, copyToClipboard } from "../../lib/open.ts"
18
18
  import { consoleForHost } from "../../lib/providers.ts"
19
- import { serverWebUrl } from "../../lib/spinupweb.ts"
20
- import { useStore, cloneNeedsGitAccess, type CloneStep, type CloneSiteStep, type RepoKeyState } from "../store.tsx"
19
+ import { serverWebUrl, siteGitUrl, siteWebUrl } from "../../lib/spinupweb.ts"
20
+ import { useStore, cloneNeedsGitAccess, type CloneStep, type CloneSiteStep, type CloneSiteState, type RepoKeyState } from "../store.tsx"
21
21
  import type { VerifyCheck } from "../../lib/serverClone.ts"
22
22
 
23
23
  // Choosing an existing server as the dest is a first-class feature now (the `d` picker
@@ -36,6 +36,12 @@ const STEP_GITACCESS = { step: "gitaccess" as CloneStep, label: "Git access" }
36
36
  const STEP_CLONE = { step: "clone" as CloneStep, label: "Clone sites" }
37
37
  const STEP_CUTOVER = { step: "cutover" as CloneStep, label: "DNS cutover" }
38
38
  // The Git-access step only appears when a Bedrock site is selected (deploy-key onboarding).
39
+ // A clone setting the user has to finish in SpinupWP: basic auth (its password
40
+ // can't be read), or a setting whose carry-over failed.
41
+ function needsSettingsHand(s: CloneSiteState): boolean {
42
+ return !!s.basicAuthMissing || !!s.settingsFailed?.length
43
+ }
44
+
39
45
  function stepsFor(needsGit: boolean): { step: CloneStep; label: string }[] {
40
46
  return needsGit
41
47
  ? [STEP_PLAN, STEP_SERVER, STEP_TRUST, STEP_GITACCESS, STEP_CLONE, STEP_CUTOVER]
@@ -305,6 +311,16 @@ export function CloneWizard() {
305
311
  return cloneRetrySite(verifyOpen)
306
312
  }
307
313
  if (name === "v" && drilled?.step === "done") return verifyCloneSite(verifyOpen)
314
+ // g — the dest's Git settings need a hand (lost deploy script, push-to-deploy
315
+ // mismatch): open them, with the source's script on the clipboard when it's
316
+ // missing, so re-entering it is one paste.
317
+ if (name === "g" && (drilled?.deployScriptLost || drilled?.pushMismatch) && drilled.destSiteId != null) {
318
+ if (drilled.deployScriptLost && drilled.deployScript) copyToClipboard(drilled.deployScript)
319
+ return openUrl(siteGitUrl(drilled.destSiteId, accountSlug))
320
+ }
321
+ // w — a setting needs doing by hand (basic auth, or one that didn't carry):
322
+ // open the new site in SpinupWP.
323
+ if (name === "w" && drilled && needsSettingsHand(drilled) && drilled.destSiteId != null) return openUrl(siteWebUrl(drilled.destSiteId, accountSlug))
308
324
  return
309
325
  }
310
326
  const n = sel.length
@@ -726,7 +742,7 @@ export function CloneWizard() {
726
742
  {selected.map((s, i) => {
727
743
  const active = !["queued", "done", "error"].includes(s.step)
728
744
  const cur = i === idx
729
- const vmark = s.verify ? (s.verify.ok ? " ✓verified" : " ✕mismatch") : s.verifying ? " verifying…" : ""
745
+ const vmark = (s.verify ? (s.verify.ok ? " ✓verified" : " ✕mismatch") : s.verifying ? " verifying…" : "") + (s.deployScriptLost || s.pushMismatch || s.cronError || needsSettingsHand(s) ? " · ⚠ see verify" : "")
730
746
  return (
731
747
  <box key={s.sourceSiteId} style={{ flexDirection: "row", height: 1, backgroundColor: cur ? theme.bgAlt : undefined }}>
732
748
  {active ? <Spinner color={theme.brand} /> : <text content={s.step === "done" ? "✓" : s.step === "error" ? "✕" : "○"} fg={s.step === "done" ? theme.good : s.step === "error" ? theme.bad : theme.textFaint} style={{ flexShrink: 0 }} />}
@@ -793,7 +809,8 @@ export function CloneWizard() {
793
809
  {v.checks.map(row)}
794
810
  <box style={{ flexGrow: 1 }} />
795
811
  <text content={v.ok ? "✓ Clone matches the source." : "✕ Differences found — review the ✕ rows above."} fg={v.ok ? theme.good : theme.bad} wrapMode="none" />
796
- <text content="v re-run · ← back to roster" fg={theme.textFaint} wrapMode="none" />
812
+ {cloneNotes(site)}
813
+ <text content={`v re-run${site.deployScriptLost ? " · g copy deploy script + open Git settings" : site.pushMismatch ? " · g open Git settings" : ""}${needsSettingsHand(site) ? " · w open in SpinupWP" : ""} · ← back to roster`} fg={theme.textFaint} wrapMode="none" />
797
814
  </>
798
815
  ) : (
799
816
  <text content="Press v to compare this clone against its source." fg={theme.textDim} wrapMode="none" />
@@ -803,6 +820,36 @@ export function CloneWizard() {
803
820
  )
804
821
  }
805
822
 
823
+ // What the clone did beyond files + DB, and anything left for the user to do.
824
+ function cloneNotes(site: CloneSiteState) {
825
+ const cron = site.cronError
826
+ ? { text: `✕ Crontab not carried over: ${site.cronError}`, fg: theme.bad }
827
+ : site.cronAdded?.length
828
+ ? { text: `✓ Crontab: carried ${site.cronAdded.length} line${site.cronAdded.length === 1 ? "" : "s"} you added on the source`, fg: theme.good }
829
+ : null
830
+ return (
831
+ <>
832
+ {cron ? <text content={cron.text} fg={cron.fg} wrapMode="none" /> : null}
833
+ {site.settingsCarried?.length ? <text content={`✓ Settings matched to the source: ${site.settingsCarried.join(" · ")}`} fg={theme.good} wrapMode="none" /> : null}
834
+ {site.settingsFailed?.map((f) => (
835
+ <text key={f.what} content={`✕ Couldn't match ${f.what}: ${f.error} — press w to set it in SpinupWP`} fg={theme.bad} wrapMode="none" />
836
+ ))}
837
+ {site.basicAuthMissing ? (
838
+ <text content="⚠ The source is password-protected (basic auth) and the clone isn't — SpinupWP won't reveal the password; press w to set it" fg={theme.warn} wrapMode="none" />
839
+ ) : null}
840
+ {site.pushMismatch ? (
841
+ <text content={`⚠ Push-to-deploy is ${site.pushEnabled ? "off" : "on"} here but ${site.pushEnabled ? "on" : "off"} on the source — switch it in the new site's Git settings (g)`} fg={theme.warn} wrapMode="none" />
842
+ ) : null}
843
+ {site.deployScriptRestored ? (
844
+ <text content="✓ Deploy script: SpinupWP dropped the one set on create, so it was set again from the source" fg={theme.good} wrapMode="none" />
845
+ ) : null}
846
+ {site.deployScriptLost ? (
847
+ <text content="⚠ SpinupWP didn't keep this site's deploy script, even when set again — press g to copy the source's and paste it into the new site's Git settings" fg={theme.warn} wrapMode="none" />
848
+ ) : null}
849
+ </>
850
+ )
851
+ }
852
+
806
853
  // Failure drill-down — the FULL error for one failed site (the roster truncates it),
807
854
  // plus where the complete stage-by-stage log lives on disk.
808
855
  function failurePane(siteId: number) {
@@ -856,7 +903,7 @@ export function CloneWizard() {
856
903
  if (verifyOpen != null) {
857
904
  const drilled = job!.sites.find((s) => s.sourceSiteId === verifyOpen)
858
905
  if (drilled?.step === "error") return [{ key: "r", label: "retry site" }, { key: "←", label: "back" }, { key: "esc", label: "close" }]
859
- return [{ key: "v", label: "re-run" }, { key: "←", label: "back" }, { key: "esc", label: "close" }]
906
+ return [{ key: "v", label: "re-run" }, ...(drilled?.deployScriptLost || drilled?.pushMismatch ? [{ key: "g", label: "Git settings" }] : []), ...(drilled && needsSettingsHand(drilled) ? [{ key: "w", label: "SpinupWP" }] : []), { key: "←", label: "back" }, { key: "esc", label: "close" }]
860
907
  }
861
908
  const done = selected.filter((s) => s.step === "done").length
862
909
  const errored = selected.filter((s) => s.step === "error").length