spinuptui 1.0.0 → 1.1.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
@@ -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"
@@ -47,6 +47,7 @@ import type { StoredProviders } from "../config.ts"
47
47
  import { fetchRebootInfo, grantSiteSshKey, revokeSiteSshKey, verifySudo, ensureSpinupKey, listPersonalKeys, keyBody, type RebootInfo } from "../lib/ssh.ts"
48
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"
@@ -298,10 +299,14 @@ export interface CloneSiteState {
298
299
  cutover?: CloneCutoverState // slice 6: DNS repoint state for this site's domain
299
300
  cronAdded?: string[] // owner crontab lines carried to the dest (empty = none to carry)
300
301
  cronError?: string // crontab carry-over failed (the clone itself still succeeded)
301
- // The source had a deploy script but the dest reads back none — SpinupWP drops a
302
- // script set on create, and there's no API to set it after. The verify pane
303
- // offers it for copy with a link to the dest's Git settings.
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.
304
308
  deployScriptLost?: boolean
309
+ deployScriptRestored?: boolean // SpinupWP dropped the script and we set it again
305
310
  pushMismatch?: boolean // the dest's push-to-deploy reads back different from the source's
306
311
  }
307
312
  // Slice 6: per-site DNS cutover. Each A record among the site's domains (primary +
@@ -397,6 +402,52 @@ const INTERRUPTED_LOCAL_SETUP_MSG = "Interrupted by a restart — the local copy
397
402
  const SERVER_DONE = new Set(["deployed", "completed", "provisioned", "finished", "success"])
398
403
  const SERVER_FAIL = new Set(["failed", "errored", "error"])
399
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
+
400
451
  export function isNewServerInFlight(j: NewServerJob | null | undefined): boolean {
401
452
  return j != null && j.status !== "done" && j.status !== "failed"
402
453
  }
@@ -3831,6 +3882,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3831
3882
  // DB password can't be recovered to re-stamp wp-config, so a leftover from a
3832
3883
  // prior run must be removed first (create will surface a clear conflict).
3833
3884
  let destSiteId = site.destSiteId
3885
+ let createdAt: number | undefined // when THIS run created the dest (see the git restore below)
3834
3886
  if (destSiteId == null) {
3835
3887
  let ev
3836
3888
  try {
@@ -3846,10 +3898,10 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3846
3898
  database: { name: dbName, username: dbName, password: dbPw, table_prefix: site.tablePrefix || "wp_" },
3847
3899
  // deploy_script is TOP-LEVEL; we send the source's own (canonical
3848
3900
  // only as the fallback). CAVEAT, verified 2026-09-24: SpinupWP stores
3849
- // it, echoes it back, then clears it a minute or two later — so the
3850
- // dest's script has to be re-entered in the dashboard. Nothing here
3851
- // depends on it: the pull runs composer (and a Radicle source's own
3852
- // script) over SSH.
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.
3853
3905
  deploy_script: site.deployScript?.trim() || "composer install -o --no-dev",
3854
3906
  git: {
3855
3907
  repo: site.gitRepo!,
@@ -3881,10 +3933,10 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3881
3933
  database: { name: dbName, username: dbName, password: dbPw, table_prefix: site.tablePrefix || "wp_" },
3882
3934
  }
3883
3935
  // Match the source's page cache — a deploy script that purges it
3884
- // (`wp spinupwp cache purge-site`) fails on a site where it's off, and
3885
- // it can't be switched on through the API after the create.
3936
+ // (`wp spinupwp cache purge-site`) fails on a site where it's off.
3886
3937
  if (site.pageCache != null) payload.page_cache = { enabled: site.pageCache }
3887
3938
  ev = await client.createSite(payload)
3939
+ createdAt = Date.now()
3888
3940
  logger?.log({ event: "create-requested", domain: site.domain, eventId: ev?.event_id })
3889
3941
  } catch (err) {
3890
3942
  return fail("create", err instanceof ApiError ? err.message : (err as Error).message)
@@ -3962,25 +4014,24 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3962
4014
  // reported on the site, never fails a clone whose files and DB are in place.
3963
4015
  set((s) => ({ ...s, detail: "crontab" }))
3964
4016
  const cron = await syncCrontab(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser }, onExec)
3965
- logger?.log({ event: "crontab", domain: site.domain, ok: cron.ok, added: cron.added, error: cron.error })
3966
- // Did the dest keep the deploy script we sent on create? (It usually
3967
- // doesn't — see the create payload.) Re-read rather than assume, so this
3968
- // goes quiet on its own if SpinupWP fixes it.
3969
- // Same re-read for push-to-deploy (which does stick, as of 2026-09-24 —
3970
- // checked anyway, since the create is the only chance to set it).
3971
- let deployScriptLost = false
3972
- let pushMismatch = false
3973
- if (site.gitRepo && destSiteId != null) {
3974
- try {
3975
- const destSite = await client.getSite(destSiteId)
3976
- deployScriptLost = !!site.deployScript?.trim() && !destSite.git?.deploy_script?.trim()
3977
- pushMismatch = site.pushEnabled != null && destSite.git?.push_enabled !== site.pushEnabled
3978
- } catch {
3979
- /* unknown — say nothing rather than guess */
3980
- }
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)
3981
4024
  }
3982
- logger?.log({ event: "site-done", domain: site.domain, sourceWebrootRel, destWebrootRel, deployScriptLost, pushMismatch })
3983
- set((s) => ({ ...s, step: "done", detail: undefined, error: undefined, failedStep: undefined, sourceWebrootRel, destWebrootRel, cronAdded: cron.added, cronError: cron.ok ? undefined : cron.error, deployScriptLost, pushMismatch }))
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 }))
3984
4035
  } catch (err) {
3985
4036
  fail(site.step, (err as Error).message)
3986
4037
  }
@@ -16,7 +16,7 @@ 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, siteGitUrl } from "../../lib/spinupweb.ts"
19
+ import { serverWebUrl, siteGitUrl, siteWebUrl } from "../../lib/spinupweb.ts"
20
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
 
@@ -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]
@@ -312,6 +318,9 @@ export function CloneWizard() {
312
318
  if (drilled.deployScriptLost && drilled.deployScript) copyToClipboard(drilled.deployScript)
313
319
  return openUrl(siteGitUrl(drilled.destSiteId, accountSlug))
314
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))
315
324
  return
316
325
  }
317
326
  const n = sel.length
@@ -733,7 +742,7 @@ export function CloneWizard() {
733
742
  {selected.map((s, i) => {
734
743
  const active = !["queued", "done", "error"].includes(s.step)
735
744
  const cur = i === idx
736
- const vmark = (s.verify ? (s.verify.ok ? " ✓verified" : " ✕mismatch") : s.verifying ? " verifying…" : "") + (s.deployScriptLost || s.pushMismatch || s.cronError ? " · ⚠ see verify" : "")
745
+ const vmark = (s.verify ? (s.verify.ok ? " ✓verified" : " ✕mismatch") : s.verifying ? " verifying…" : "") + (s.deployScriptLost || s.pushMismatch || s.cronError || needsSettingsHand(s) ? " · ⚠ see verify" : "")
737
746
  return (
738
747
  <box key={s.sourceSiteId} style={{ flexDirection: "row", height: 1, backgroundColor: cur ? theme.bgAlt : undefined }}>
739
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 }} />}
@@ -801,7 +810,7 @@ export function CloneWizard() {
801
810
  <box style={{ flexGrow: 1 }} />
802
811
  <text content={v.ok ? "✓ Clone matches the source." : "✕ Differences found — review the ✕ rows above."} fg={v.ok ? theme.good : theme.bad} wrapMode="none" />
803
812
  {cloneNotes(site)}
804
- <text content={`v re-run${site.deployScriptLost ? " · g copy deploy script + open Git settings" : site.pushMismatch ? " · g open Git settings" : ""} · ← back to roster`} fg={theme.textFaint} wrapMode="none" />
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" />
805
814
  </>
806
815
  ) : (
807
816
  <text content="Press v to compare this clone against its source." fg={theme.textDim} wrapMode="none" />
@@ -821,11 +830,21 @@ export function CloneWizard() {
821
830
  return (
822
831
  <>
823
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}
824
840
  {site.pushMismatch ? (
825
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" />
826
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}
827
846
  {site.deployScriptLost ? (
828
- <text content="⚠ SpinupWP didn't keep this site's deploy script — press g to copy the source's and paste it into the new site's Git settings" fg={theme.warn} wrapMode="none" />
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" />
829
848
  ) : null}
830
849
  </>
831
850
  )
@@ -884,7 +903,7 @@ export function CloneWizard() {
884
903
  if (verifyOpen != null) {
885
904
  const drilled = job!.sites.find((s) => s.sourceSiteId === verifyOpen)
886
905
  if (drilled?.step === "error") return [{ key: "r", label: "retry site" }, { key: "←", label: "back" }, { key: "esc", label: "close" }]
887
- return [{ key: "v", label: "re-run" }, ...(drilled?.deployScriptLost || drilled?.pushMismatch ? [{ key: "g", label: "Git settings" }] : []), { 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" }]
888
907
  }
889
908
  const done = selected.filter((s) => s.step === "done").length
890
909
  const errored = selected.filter((s) => s.step === "error").length