spinuptui 0.27.0 → 1.0.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/lib/wpCore.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  import type { Server, Site } from "../api/types.ts"
18
18
  import { SSH_OPTS, sshPort } from "./dbBackup.ts"
19
19
  import { detectWpDirScript } from "./serverClone.ts"
20
- import { wpCliResolveScript } from "./wpCli.ts"
20
+ import { extractJsonArray, wpCliResolveScript } from "./wpCli.ts"
21
21
  import { spawn } from "./spawn.ts"
22
22
 
23
23
  export interface WpCoreOffer {
@@ -177,24 +177,13 @@ export async function checkWpCore(server: Server, site: Site, sshUser: string |
177
177
  const current = lastVersionLine(s.VERSION)
178
178
  if (!current) return { ok: false, target: p.target, error: "wp-cli couldn't read the WordPress version on this site." }
179
179
 
180
- // Belt and braces with --skip-plugins: scan back for the line that parses as
181
- // the JSON array, ignoring any other noise (a mu-plugin's notices still load).
180
+ // Belt and braces with --skip-plugins: a mu-plugin's notices still load, so
181
+ // pull the JSON array out of any surrounding noise.
182
182
  // No match = "Success: WordPress is at the latest version." (plain text).
183
- let offers: WpCoreOffer[] = []
184
- for (const line of [...(s.UPDATES ?? [])].reverse()) {
185
- if (!line.trim().startsWith("[")) continue
186
- let parsed: unknown
187
- try {
188
- parsed = JSON.parse(line.trim())
189
- } catch {
190
- continue
191
- }
192
- if (!Array.isArray(parsed)) continue
193
- offers = parsed
194
- .filter((o) => o && typeof o.version === "string")
195
- .map((o) => ({ version: o.version as string, updateType: typeof o.update_type === "string" ? o.update_type : "" }))
196
- break
197
- }
183
+ const parsed = extractJsonArray(s.UPDATES) ?? []
184
+ const offers: WpCoreOffer[] = parsed
185
+ .filter((o): o is { version: string; update_type?: unknown } => !!o && typeof (o as { version?: unknown }).version === "string")
186
+ .map((o) => ({ version: o.version, updateType: typeof o.update_type === "string" ? o.update_type : "" }))
198
187
 
199
188
  const comp = (s.COMPOSER ?? []).join("\n").trim()
200
189
  const locked = comp.match(/^locked (.+)$/m)
@@ -11,7 +11,7 @@
11
11
  import type { Server, Site } from "../api/types.ts"
12
12
  import { SSH_OPTS, sshPort } from "./dbBackup.ts"
13
13
  import { detectWpDirScript } from "./serverClone.ts"
14
- import { wpCliResolveScript } from "./wpCli.ts"
14
+ import { extractJsonArray, wpCliResolveScript } from "./wpCli.ts"
15
15
  import { spawn } from "./spawn.ts"
16
16
 
17
17
  // One plugin or theme, mirroring `wp {plugin,theme} list` columns.
@@ -68,17 +68,11 @@ function splitSections(out: string): Record<string, string[]> {
68
68
  return sections
69
69
  }
70
70
 
71
- // wp --format=json emits a single-line JSON array; tolerate blank/garbage by
72
- // returning [] rather than throwing (a fresh site can legitimately have 0 themes).
73
- function parseJsonArray(lines: string[] | undefined): RawItem[] {
74
- const text = (lines ?? []).join("").trim()
75
- if (!text) return []
76
- try {
77
- const parsed = JSON.parse(text)
78
- return Array.isArray(parsed) ? parsed : []
79
- } catch {
80
- return []
81
- }
71
+ // First non-blank line of a section — the likeliest culprit when wp-cli's JSON is
72
+ // missing (a PHP fatal or notice printed in its place).
73
+ function firstNoise(lines: string[] | undefined): string {
74
+ const line = (lines ?? []).map((l) => l.trim()).find(Boolean) ?? ""
75
+ return line.length > 160 ? `${line.slice(0, 159)}…` : line
82
76
  }
83
77
 
84
78
  export async function fetchWpInventory(
@@ -147,8 +141,18 @@ export async function fetchWpInventory(
147
141
  return { ok: false, target, error: `No WordPress core found under ${root} — is this a WordPress site?` }
148
142
  }
149
143
  const wpDir = (s.WPDIR?.[0] || root).trim()
150
- const plugins = normalize(parseJsonArray(s.PLUGINS))
151
- const themes = normalize(parseJsonArray(s.THEMES))
144
+ // A plugin's PHP notices can print to stdout alongside the JSON (see
145
+ // extractJsonArray). No JSON at all means wp-cli failed; say so instead of
146
+ // showing an empty list that reads as "no plugins installed".
147
+ const rawPlugins = extractJsonArray(s.PLUGINS)
148
+ const rawThemes = extractJsonArray(s.THEMES)
149
+ if (!rawPlugins || !rawThemes) {
150
+ const which = !rawPlugins ? "plugin" : "theme"
151
+ const noise = firstNoise(!rawPlugins ? s.PLUGINS : s.THEMES)
152
+ return { ok: false, target, error: `wp-cli didn't return the ${which} list${noise ? `: ${noise}` : "."}` }
153
+ }
154
+ const plugins = normalize(rawPlugins as RawItem[])
155
+ const themes = normalize(rawThemes as RawItem[])
152
156
  return { ok: true, target, wpDir, inventory: { plugins, themes } }
153
157
  }
154
158
 
package/src/ui/App.tsx CHANGED
@@ -39,6 +39,7 @@ import { WpInventory } from "./views/WpInventory.tsx"
39
39
  import { ProviderConnect } from "./views/ProviderConnect.tsx"
40
40
  import { DnsRecords } from "./views/DnsRecords.tsx"
41
41
  import { KumaSite } from "./views/KumaSite.tsx"
42
+ import { Accounts } from "./views/Accounts.tsx"
42
43
 
43
44
  const MIN_SPLASH_MS = 1200
44
45
 
@@ -49,6 +50,7 @@ export function App() {
49
50
  const [splashDone, setSplashDone] = useState(false)
50
51
  const [showHelp, setShowHelp] = useState(false)
51
52
  const [showExplain, setShowExplain] = useState(false)
53
+ const [showAccounts, setShowAccounts] = useState(false)
52
54
 
53
55
  // Enforce a minimum splash duration so the intro is visible even on fast loads.
54
56
  useEffect(() => {
@@ -60,6 +62,7 @@ export function App() {
60
62
  const overlayActive =
61
63
  showHelp ||
62
64
  showExplain ||
65
+ showAccounts ||
63
66
  store.releaseNotesInfo !== null ||
64
67
  store.healthServer !== null ||
65
68
  store.wpInventorySite !== null ||
@@ -119,6 +122,9 @@ export function App() {
119
122
  // While a text field is focused, let it consume everything else.
120
123
  if (store.inputMode) return
121
124
 
125
+ // The Accounts overlay owns the keyboard while open.
126
+ if (showAccounts) return
127
+
122
128
  // The release-notes overlay owns the keyboard while open (any key dismisses).
123
129
  if (store.releaseNotesInfo) return
124
130
 
@@ -194,6 +200,9 @@ export function App() {
194
200
  // The site-monitoring (Uptime Kuma) overlay owns the keyboard while open.
195
201
  if (store.kumaSite) return
196
202
 
203
+ // A (Shift+a): the Accounts overlay — the header advertises it from every tab.
204
+ if (key.shift && key.name === "a") return setShowAccounts(true)
205
+
197
206
  switch (key.name) {
198
207
  case "q":
199
208
  return quit()
@@ -274,6 +283,7 @@ export function App() {
274
283
  {store.connectZoneTarget && <ProviderConnect />}
275
284
  {store.dnsRecordsTarget && <DnsRecords />}
276
285
  {store.kumaSite && <KumaSite />}
286
+ {showAccounts && <Accounts onClose={() => setShowAccounts(false)} />}
277
287
  <ToastStack />
278
288
  </box>
279
289
  )
package/src/ui/Header.tsx CHANGED
@@ -6,6 +6,8 @@ import { timeAgo, truncate } from "../lib/format.ts"
6
6
  import { Spinner } from "./components.tsx"
7
7
  import { APP_NAME, APP_VERSION } from "../version.ts"
8
8
  import { isDevMode } from "../dev/devMode.ts"
9
+ import { RL_LOW_WATER, onRateLimitChange, rateLimitSnapshot, type RateLimitSnapshot } from "../api/client.ts"
10
+ import { useEffect, useState } from "react"
9
11
 
10
12
  const TABS: { route: Route; key: string; label: string }[] = [
11
13
  { route: "dashboard", key: "1", label: "Dashboard" },
@@ -26,8 +28,31 @@ const SUBTITLES: Record<Route, string> = {
26
28
  events: "Recent deploys, reboots, and operations across your account",
27
29
  }
28
30
 
31
+ // SpinupWP allows 60 API requests a minute. Show what's left of this minute so
32
+ // the wall is visible before it's hit: dim while there's plenty, amber past
33
+ // three-quarters spent, red once the client has started pacing requests (the
34
+ // point where the app slows down on purpose). Hidden until a response has
35
+ // reported the limit (and in Dev Mode, which never calls the API).
36
+ function ApiHeadroom() {
37
+ const [rl, setRl] = useState<RateLimitSnapshot | null>(rateLimitSnapshot)
38
+ useEffect(() => {
39
+ const refresh = () => setRl(rateLimitSnapshot())
40
+ const off = onRateLimitChange(refresh)
41
+ // The window rolls over with no request to announce it; re-read so a spent
42
+ // budget doesn't sit on screen after it has refilled.
43
+ const tick = setInterval(refresh, 5_000)
44
+ return () => {
45
+ off()
46
+ clearInterval(tick)
47
+ }
48
+ }, [])
49
+ if (!rl) return null
50
+ const color = rl.remaining <= RL_LOW_WATER ? theme.bad : rl.remaining <= rl.limit / 4 ? theme.warn : theme.textFaint
51
+ return <text content={`API ${rl.remaining}/${rl.limit} `} fg={color} style={{ flexShrink: 0 }} wrapMode="none" />
52
+ }
53
+
29
54
  export function Header() {
30
- const { route, servers, sites, loading, lastUpdated, updateInfo, newServerJob, vanityJob, cloneJob, cloneServer, kumaStatus } = useStore()
55
+ const { route, servers, sites, loading, lastUpdated, updateInfo, newServerJob, vanityJob, cloneJob, cloneServer, kumaStatus, profileLabel } = useStore()
31
56
  // Name the domain(s) when there are few enough to fit the header row; fall
32
57
  // back to a bare count once there are too many to list safely. Deliberately
33
58
  // still just the plain up/down signal (kumaStatus.up) — not expanding what
@@ -114,11 +139,16 @@ export function Header() {
114
139
  )}
115
140
  {loading && <Spinner interval={100} />}
116
141
  <text content={` ${servers.length} servers · ${sites.length} sites `} fg={theme.textDim} style={{ flexShrink: 0 }} />
142
+ <ApiHeadroom />
117
143
  <text content={lastUpdated ? `updated ${timeAgo(lastUpdated.toISOString())}` : "loading…"} fg={theme.textFaint} style={{ flexShrink: 0 }} />
118
144
  </box>
119
145
  <box style={{ flexDirection: "row", height: 1, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
120
146
  <text content="💡 " fg={theme.accent} style={{ flexShrink: 0 }} />
121
147
  <text content={SUBTITLES[route]} fg={theme.textDim} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
148
+ {/* Always visible, so it's never a guess which client's fleet this is. */}
149
+ <text content=" Account: " fg={theme.textFaint} style={{ flexShrink: 0 }} />
150
+ <text content={truncate(profileLabel, 32)} fg={theme.text} wrapMode="none" style={{ flexShrink: 0 }} />
151
+ <text content=" A switch" fg={theme.textFaint} style={{ flexShrink: 0 }} />
122
152
  </box>
123
153
  </box>
124
154
  )
package/src/ui/Help.tsx CHANGED
@@ -8,7 +8,7 @@ import { theme } from "../lib/theme.ts"
8
8
  import { APP_NAME, APP_VERSION, REPO_URL } from "../version.ts"
9
9
  import { Sparkle, Spinner } from "./components.tsx"
10
10
  import { useStore } from "./store.tsx"
11
- import { runSelfUpdate, installChannel, PACKAGE_UPDATE_CMD, type UpdateInfo, type SelfUpdateResult } from "../lib/appUpdate.ts"
11
+ import { runSelfUpdate, installChannel, updateCommand, type UpdateInfo, type SelfUpdateResult } from "../lib/appUpdate.ts"
12
12
 
13
13
  type Section = { title: string; keys: [string, string][] }
14
14
 
@@ -18,6 +18,7 @@ const GLOBAL: Section = {
18
18
  ["1 … 5", "Switch tabs"],
19
19
  ["r", "Refresh from the API"],
20
20
  ["/", "Global search"],
21
+ ["A", "Switch SpinupWP accounts"],
21
22
  ["i", "Explain the current screen"],
22
23
  ["?", "This help"],
23
24
  ["q · Ctrl+C", "Quit · force quit"],
@@ -190,10 +191,10 @@ function AboutColumn({
190
191
  {selfUpdate !== "idle" && selfUpdate !== "running" ? (
191
192
  <text content={selfUpdate.message} fg={selfUpdate.ok ? theme.good : theme.bad} />
192
193
  ) : null}
193
- {installChannel() === "package" ? (
194
+ {installChannel() !== "git" ? (
194
195
  <>
195
196
  {line("Manual:")}
196
- {line(PACKAGE_UPDATE_CMD, theme.text)}
197
+ {line(updateCommand() ?? "", theme.text)}
197
198
  </>
198
199
  ) : (
199
200
  <>
@@ -0,0 +1,21 @@
1
+ // Account switching lives ABOVE the store: switching remounts <StoreProvider>
2
+ // keyed by the account, which is what guarantees a clean slate — every effect,
3
+ // poller and interval of the outgoing account is torn down by React, and every
4
+ // id-keyed cache (servers, sites, probes, jobs…) starts empty for the new one.
5
+ // See config.ts "Accounts" for how the profiles are stored.
6
+
7
+ import { createContext, useContext } from "react"
8
+
9
+ export interface AccountsApi {
10
+ // Make `id` the active account and remount the app on it. The caller checks
11
+ // the store's switchBlocker() first — nothing here waits for running work.
12
+ switchAccount: (id: string) => Promise<void>
13
+ }
14
+
15
+ export const AccountsContext = createContext<AccountsApi | null>(null)
16
+
17
+ export function useAccounts(): AccountsApi {
18
+ const ctx = useContext(AccountsContext)
19
+ if (!ctx) throw new Error("useAccounts must be used within <AccountsContext.Provider>")
20
+ return ctx
21
+ }
package/src/ui/store.tsx CHANGED
@@ -11,7 +11,7 @@ import { SpinupWPClient, ApiError, type ServerService, type SpinupWPClientLike }
11
11
  import { isDevMode } from "../dev/devMode.ts"
12
12
  import { createMockClient } from "../dev/mockClient.ts"
13
13
  import type { Server, Site, Event, ProviderMetadata, CreateServerPayload, CreateSitePayload, AdditionalDomain } from "../api/types.ts"
14
- import { loadConfig, saveConfig, type ServerProviderRef, type KumaMonitorRef, type UptimeKumaConn } from "../config.ts"
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"
17
17
  import { cachedUpdateInfo, refreshUpdateInfo, type UpdateInfo } from "../lib/appUpdate.ts"
@@ -45,7 +45,7 @@ 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
50
  import { syncAdditionalDomains } from "../lib/cloneDomains.ts"
51
51
  import { parseRepo, deployKeysSettingsUrl, ghAvailable, ghDeployKeyPresent, ghAddDeployKey, generateDeployKeypair, type RepoHost } from "../lib/gitDeployKey.ts"
@@ -53,9 +53,10 @@ import { keychainAvailable, setSudoPassword, getSudoPassword, deleteSudoPassword
53
53
  import { StackCache, siteSignature, type CachedProbe } from "../lib/stackCache.ts"
54
54
  import { resolvePhpEolDates, refreshPhpEolDates, isPhpEol as isPhpEolWith, offeredPhpVersions as offeredPhpVersionsWith, type PhpEolDates } from "../lib/phpEol.ts"
55
55
  import { resolveUbuntuEolDates, refreshUbuntuEolDates, isUbuntuEol as isUbuntuEolWith, type UbuntuEolDates } from "../lib/ubuntuEol.ts"
56
- import { planDbBackup, runDbBackup, type DbBackupProgress, type PlanResult } from "../lib/dbBackup.ts"
57
- import { planDbSync, runDbSync, type DbSyncProgress, type SyncPlanResult } from "../lib/dbSync.ts"
58
- import { planLocalSetup, runLocalSetup, type LocalSetupProgress, type LocalSetupPlanResult } from "../lib/localSetup.ts"
56
+ import { wpCoreBusy } from "./wpCoreJobs.tsx"
57
+ import { planDbBackup, runDbBackup, isDbBackupInFlight, type DbBackupProgress, type PlanResult } from "../lib/dbBackup.ts"
58
+ import { planDbSync, runDbSync, isDbSyncInFlight, type DbSyncProgress, type SyncPlanResult } from "../lib/dbSync.ts"
59
+ import { planLocalSetup, runLocalSetup, isLocalSetupInFlight, type LocalSetupProgress, type LocalSetupPlanResult } from "../lib/localSetup.ts"
59
60
  import { planMediaFallback, type MediaFallbackResult } from "../lib/mediaFallback.ts"
60
61
 
61
62
  export type Route = "dashboard" | "servers" | "stacks" | "search" | "events"
@@ -267,6 +268,9 @@ export interface CloneSiteState {
267
268
  stack: "wp" | "bedrock" | "files" // git repo → bedrock; is_wordpress → wp; else files-only (redirect shells, static/PHP sites)
268
269
  gitRepo?: string // source git.repo (Bedrock) — the dest is created as a `git` site of it
269
270
  gitBranch?: string // source git.branch
271
+ deployScript?: string // source git deploy script — the dest's own, and Radicle's build step
272
+ pushEnabled?: boolean // source push-to-deploy on/off — the dest is created to match
273
+ pageCache?: boolean // source page cache on/off — the dest is created to match
270
274
  gitDeployKey?: { privateKey: string; publicKey: string } // unique per-site key (stamped leaving gitaccess) → create payload
271
275
  additionalDomains?: string[] // extra domains served by the site → extra cutover records
272
276
  additionalDomainConfigs?: AdditionalDomain[] // full source configs (redirects) — re-created on the dest
@@ -292,6 +296,13 @@ export interface CloneSiteState {
292
296
  verify?: CloneVerifyResult // source-vs-clone comparison + HTTP check
293
297
  verifyError?: string
294
298
  cutover?: CloneCutoverState // slice 6: DNS repoint state for this site's domain
299
+ cronAdded?: string[] // owner crontab lines carried to the dest (empty = none to carry)
300
+ 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.
304
+ deployScriptLost?: boolean
305
+ pushMismatch?: boolean // the dest's push-to-deploy reads back different from the source's
295
306
  }
296
307
  // Slice 6: per-site DNS cutover. Each A record among the site's domains (primary +
297
308
  // additional_domains) is repointed from the old server IP to the new one. www-style
@@ -754,6 +765,18 @@ interface StoreValue extends DataState {
754
765
  enableLocalSync: () => void
755
766
  // SpinupWP account slug (from env/config) for building web deep links.
756
767
  accountSlug: string | null
768
+ // The account (profile) this store is mounted for — see config.ts "Accounts".
769
+ // Switching remounts the whole store, so these never change under a view.
770
+ profileId: string
771
+ profileLabel: string
772
+ setProfileLabel: (label: string) => void // after a rename in the Accounts overlay
773
+ // The token came from SPINUPWP_ACCESS_TOKEN, which pins the account: switching
774
+ // would change nothing until it's unset.
775
+ tokenFromEnv: boolean
776
+ // What's still running that an account switch would orphan, in words — or
777
+ // null when it's safe to switch. Only work that writes (to SpinupWP, a server,
778
+ // DNS, or a local database) counts; reads and probes are simply dropped.
779
+ switchBlocker: () => string | null
757
780
  sitesForServer: (serverId: number) => Site[]
758
781
  serverById: (id: number | null | undefined) => Server | undefined
759
782
  // Tier-2 stack probes (on-demand SSH), hydrated from disk at startup.
@@ -895,6 +918,9 @@ export function useStore(): StoreValue {
895
918
 
896
919
  export function StoreProvider({ children }: { children: ReactNode }) {
897
920
  const cfgRef = useRef(loadConfig())
921
+ // Every config write from this store targets the account it was mounted for —
922
+ // a late write from an async flow can't land in an account switched to since.
923
+ const saveProfileConfig = (partial: StoredConfig) => saveConfig(partial, cfgRef.current.profileId)
898
924
  const clientRef = useRef<SpinupWPClientLike | null>(null)
899
925
  if (!clientRef.current) {
900
926
  clientRef.current = isDevMode() ? createMockClient() : new SpinupWPClient(cfgRef.current)
@@ -927,7 +953,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
927
953
  // Fresh install, or upgrading from a pre-this-feature version — nothing
928
954
  // meaningful to announce. Seed silently so future bumps trigger correctly.
929
955
  cfgRef.current.lastSeenVersion = APP_VERSION
930
- void saveConfig({ lastSeenVersion: APP_VERSION })
956
+ void saveProfileConfig({ lastSeenVersion: APP_VERSION })
931
957
  return
932
958
  }
933
959
  void fetchReleaseNotes(APP_VERSION).then((notes) => {
@@ -939,7 +965,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
939
965
  const dismissReleaseNotes = useCallback(() => {
940
966
  setReleaseNotesInfo(null)
941
967
  cfgRef.current.lastSeenVersion = APP_VERSION
942
- void saveConfig({ lastSeenVersion: APP_VERSION })
968
+ void saveProfileConfig({ lastSeenVersion: APP_VERSION })
943
969
  }, [])
944
970
  // On-demand replay (Help overlay's `n`) — bypasses the "seen" gate entirely,
945
971
  // since viewing the current version's notes again is always valid regardless
@@ -951,6 +977,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
951
977
  if (notes) setReleaseNotesInfo(notes)
952
978
  })
953
979
  }, [])
980
+ const [profileLabel, setProfileLabel] = useState(cfgRef.current.profileLabel)
954
981
  const [route, setRoute] = useState<Route>("dashboard")
955
982
  const [inputMode, setInputMode] = useState(false)
956
983
  const [overlayOpen, setOverlayOpen] = useState(false)
@@ -1640,7 +1667,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1640
1667
  const next = { ...prev, [providerKey]: { ...(prev[providerKey] ?? {}), id } }
1641
1668
  cfgRef.current.serverProviders = next
1642
1669
  setServerProviders(next)
1643
- void saveConfig({ serverProviders: next })
1670
+ void saveProfileConfig({ serverProviders: next })
1644
1671
  }, [])
1645
1672
 
1646
1673
  // Poll a server-create event to completion, mirroring its status into the job
@@ -1734,7 +1761,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1734
1761
  const record: Record<string, LocalLink> = {}
1735
1762
  for (const [id, link] of next) record[String(id)] = link
1736
1763
  cfgRef.current.localSites = record
1737
- void saveConfig({ localSites: record })
1764
+ void saveProfileConfig({ localSites: record })
1738
1765
  }, [])
1739
1766
 
1740
1767
  const linkSite = useCallback(
@@ -1766,14 +1793,14 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1766
1793
  if (prev.includes(trimmed)) return prev
1767
1794
  const next = [...prev, trimmed]
1768
1795
  cfgRef.current.localRoots = next
1769
- void saveConfig({ localRoots: next })
1796
+ void saveProfileConfig({ localRoots: next })
1770
1797
  return next
1771
1798
  })
1772
1799
  }, [])
1773
1800
 
1774
1801
  const enableLocalSync = useCallback(() => {
1775
1802
  cfgRef.current.localSync = true
1776
- void saveConfig({ localSync: true })
1803
+ void saveProfileConfig({ localSync: true })
1777
1804
  setLocalSyncState(true)
1778
1805
  }, [])
1779
1806
 
@@ -1823,7 +1850,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1823
1850
  const record: Record<string, { user: string; keychain?: boolean }> = {}
1824
1851
  for (const [id, u] of users) record[String(id)] = saved.has(id) ? { user: u, keychain: true } : { user: u }
1825
1852
  cfgRef.current.sudoUsers = record
1826
- void saveConfig({ sudoUsers: record })
1853
+ void saveProfileConfig({ sudoUsers: record })
1827
1854
  }, [])
1828
1855
  const isSudoConnected = useCallback((serverId: number) => sudoConnected.has(serverId), [sudoConnected])
1829
1856
  const sudoSavedFor = useCallback((serverId: number) => sudoSaved.has(serverId), [sudoSaved])
@@ -1920,7 +1947,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1920
1947
  const setPreferredGrantKeys = useCallback((ids: string[]) => {
1921
1948
  cfgRef.current.preferredGrantKeys = ids
1922
1949
  setPreferredGrantKeysState(ids)
1923
- void saveConfig({ preferredGrantKeys: ids })
1950
+ void saveProfileConfig({ preferredGrantKeys: ids })
1924
1951
  }, [])
1925
1952
 
1926
1953
  // Persist the granted-keys map (only non-empty entries) to config, write-through.
@@ -1928,7 +1955,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1928
1955
  const rec: Record<string, string[]> = {}
1929
1956
  for (const [id, set] of next) if (set.size) rec[String(id)] = [...set]
1930
1957
  cfgRef.current.grantedKeys = rec
1931
- void saveConfig({ grantedKeys: rec })
1958
+ void saveProfileConfig({ grantedKeys: rec })
1932
1959
  }, [])
1933
1960
 
1934
1961
  // Record / forget which keys (by body) Spinup has granted on a site.
@@ -1970,7 +1997,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1970
1997
  else next.delete(apex)
1971
1998
  const rec = Object.fromEntries(next)
1972
1999
  cfgRef.current.zoneAccessNotes = rec
1973
- void saveConfig({ zoneAccessNotes: rec })
2000
+ void saveProfileConfig({ zoneAccessNotes: rec })
1974
2001
  return next
1975
2002
  })
1976
2003
  }, [])
@@ -2447,7 +2474,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
2447
2474
  for (const p of ALL_PROVIDERS) {
2448
2475
  providers[p] = next[p].filter((c) => !c.env).map((c) => ({ id: c.id, label: c.label, creds: c.creds }))
2449
2476
  }
2450
- void saveConfig({ providers })
2477
+ void saveProfileConfig({ providers })
2451
2478
  }, [])
2452
2479
 
2453
2480
  // Verify a connection and write the result through to the cache.
@@ -2682,7 +2709,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
2682
2709
  if (keys[domain] !== key) {
2683
2710
  keys[domain] = key
2684
2711
  cfgRef.current.vanityHealthKeys = keys
2685
- void saveConfig({ vanityHealthKeys: keys })
2712
+ void saveProfileConfig({ vanityHealthKeys: keys })
2686
2713
  }
2687
2714
  return key
2688
2715
  }, [])
@@ -2696,7 +2723,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
2696
2723
  const newKey = crypto.randomUUID().replace(/-/g, "")
2697
2724
  keys[domain] = newKey
2698
2725
  cfgRef.current.vanityHealthKeys = keys
2699
- void saveConfig({ vanityHealthKeys: keys })
2726
+ void saveProfileConfig({ vanityHealthKeys: keys })
2700
2727
  return { oldKey, newKey }
2701
2728
  }, [])
2702
2729
 
@@ -2705,13 +2732,13 @@ export function StoreProvider({ children }: { children: ReactNode }) {
2705
2732
  const persistKumaAuth = useCallback((conn: UptimeKumaConn, jwt: string) => {
2706
2733
  if (conn.env || jwt === conn.jwt) return
2707
2734
  cfgRef.current.uptimeKuma = { ...conn, jwt }
2708
- void saveConfig({ uptimeKuma: { ...conn, jwt } })
2735
+ void saveProfileConfig({ uptimeKuma: { ...conn, jwt } })
2709
2736
  }, [])
2710
2737
 
2711
2738
  const persistKumaMonitors = useCallback((domain: string, ref: KumaMonitorRef) => {
2712
2739
  const map = { ...cfgRef.current.kumaMonitors, [domain]: ref }
2713
2740
  cfgRef.current.kumaMonitors = map
2714
- void saveConfig({ kumaMonitors: map })
2741
+ void saveProfileConfig({ kumaMonitors: map })
2715
2742
  }, [])
2716
2743
 
2717
2744
  // The step machine. Each step performs its work then hands off to the next; it
@@ -3127,7 +3154,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3127
3154
  if (!login.ok) return { ok: false, error: login.error, tokenRequired: login.tokenRequired }
3128
3155
  const stored = { url, username: conn.username, password: conn.password, jwt: login.jwt }
3129
3156
  cfgRef.current.uptimeKuma = stored
3130
- void saveConfig({ uptimeKuma: stored })
3157
+ void saveProfileConfig({ uptimeKuma: stored })
3131
3158
  setKumaEpoch((e) => e + 1) // start the status poll loop now
3132
3159
  await kuma.waitForVersion() // `info` can land after the login ack
3133
3160
  return { ok: true, version: kuma.version }
@@ -3556,6 +3583,9 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3556
3583
  stack,
3557
3584
  gitRepo: s.git?.repo ?? undefined,
3558
3585
  gitBranch: s.git?.branch ?? undefined,
3586
+ deployScript: s.git?.deploy_script ?? undefined,
3587
+ pushEnabled: s.git?.push_enabled ?? undefined,
3588
+ pageCache: s.page_cache?.enabled ?? undefined,
3559
3589
  additionalDomains: (s.additional_domains ?? []).map((d) => d.domain),
3560
3590
  additionalDomainConfigs: s.additional_domains ?? [],
3561
3591
  excludeUploads: false,
@@ -3784,7 +3814,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3784
3814
  // site-creation + auth cycle on a clone that can't succeed. Not a guarantee
3785
3815
  // (see preflightBedrockSource) — the real check is still the one after create.
3786
3816
  if (site.stack === "bedrock") {
3787
- const pre = await preflightBedrockSource(source, { domain: site.domain, publicFolder: site.publicFolder })
3817
+ const pre = await preflightBedrockSource(source, { domain: site.domain, publicFolder: site.publicFolder, deployScript: site.deployScript }, dest)
3788
3818
  if (!pre.ok) return fail("create", pre.error)
3789
3819
  }
3790
3820
  // create — Bedrock → `git` site (SpinupWP clones the repo); Standard WP →
@@ -3814,14 +3844,18 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3814
3844
  php_version: site.phpVersion,
3815
3845
  public_folder: site.publicFolder ?? "/web/",
3816
3846
  database: { name: dbName, username: dbName, password: dbPw, table_prefix: site.tablePrefix || "wp_" },
3817
- // deploy_script is TOP-LEVEL; we send the corrected canonical (the
3818
- // source's stored value has a known typo) for future push-to-deploy —
3819
- // we composer install over SSH regardless (git/deploy won't run it).
3820
- deploy_script: "composer install -o --no-dev",
3847
+ // deploy_script is TOP-LEVEL; we send the source's own (canonical
3848
+ // 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.
3853
+ deploy_script: site.deployScript?.trim() || "composer install -o --no-dev",
3821
3854
  git: {
3822
3855
  repo: site.gitRepo!,
3823
3856
  branch: site.gitBranch ?? "main",
3824
- push_to_deploy: true,
3857
+ // Match the source (on when unknown, the long-standing default).
3858
+ push_to_deploy: site.pushEnabled ?? true,
3825
3859
  // unique per-site key (gitaccess step) — the server-wide
3826
3860
  // git_publickey fits only ONE GitHub repo, so it can't be
3827
3861
  // reused; SpinupWP installs this pair as the site's git identity.
@@ -3846,6 +3880,10 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3846
3880
  public_folder: site.publicFolder,
3847
3881
  database: { name: dbName, username: dbName, password: dbPw, table_prefix: site.tablePrefix || "wp_" },
3848
3882
  }
3883
+ // 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.
3886
+ if (site.pageCache != null) payload.page_cache = { enabled: site.pageCache }
3849
3887
  ev = await client.createSite(payload)
3850
3888
  logger?.log({ event: "create-requested", domain: site.domain, eventId: ev?.event_id })
3851
3889
  } catch (err) {
@@ -3911,7 +3949,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3911
3949
  const onExec = logger ? (e: CloneExecRecord) => logger.log({ event: "exec", ...e }) : undefined
3912
3950
  const res =
3913
3951
  site.stack === "bedrock"
3914
- ? 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)
3952
+ ? 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)
3915
3953
  : site.stack === "files"
3916
3954
  ? await runFilesOnlyPull(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, approxFilesBytes: site.sizeWebBytes }, onProgress, onExec, onTransfer)
3917
3955
  : 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)
@@ -3920,8 +3958,29 @@ export function StoreProvider({ children }: { children: ReactNode }) {
3920
3958
  return fail(stageToStep(stage), res.error ?? "pull failed")
3921
3959
  }
3922
3960
  const { sourceWebrootRel, destWebrootRel } = res as { sourceWebrootRel?: string; destWebrootRel?: string }
3923
- logger?.log({ event: "site-done", domain: site.domain, sourceWebrootRel, destWebrootRel })
3924
- set((s) => ({ ...s, step: "done", detail: undefined, error: undefined, failedStep: undefined, sourceWebrootRel, destWebrootRel }))
3961
+ // Owner-added cron jobs (see syncCrontab). Best-effort: a failure is
3962
+ // reported on the site, never fails a clone whose files and DB are in place.
3963
+ set((s) => ({ ...s, detail: "crontab" }))
3964
+ 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
+ }
3981
+ }
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 }))
3925
3984
  } catch (err) {
3926
3985
  fail(site.step, (err as Error).message)
3927
3986
  }
@@ -4245,6 +4304,29 @@ export function StoreProvider({ children }: { children: ReactNode }) {
4245
4304
  }
4246
4305
  }, [trackServerEvent, trackPhpUpgradeEvent, trackHttpsToggleEvent, driveVanity])
4247
4306
 
4307
+ const switchBlocker = (): string | null => {
4308
+ const count = <T,>(m: Map<unknown, T>, busy: (p: T) => boolean) => [...m.values()].filter(busy).length
4309
+ const running: [number, string][] = [
4310
+ [count(phpUpgrades, isUpgradeInFlight), "PHP upgrade"],
4311
+ [count(httpsToggles, isHttpsToggleInFlight), "HTTPS change"],
4312
+ [count(purgeCacheProgress, isPurgeCacheInFlight), "cache purge"],
4313
+ [count(serverOps, isServerOpInFlight), "server reboot/restart"],
4314
+ [count(keyGrants, isKeyGrantInFlight), "SSH key grant"],
4315
+ [count(dbBackups, isDbBackupInFlight), "database backup"],
4316
+ [count(dbSyncs, isDbSyncInFlight), "database sync"],
4317
+ [count(localSetups, isLocalSetupInFlight), "local copy setup"],
4318
+ [count(recordWrites, isRecordWriteInFlight), "DNS change"],
4319
+ [count(kumaOps, (o) => o.status === "running"), "monitoring change"],
4320
+ [isNewServerInFlight(newServerJob) ? 1 : 0, "server build"],
4321
+ [isVanityInFlight(vanityJob) ? 1 : 0, "vanity-site build"],
4322
+ // A clone still being planned hasn't touched anything yet — only a launched one counts.
4323
+ [isCloneInFlight(cloneJob) && cloneJob!.step !== "plan" && cloneJob!.step !== "server" ? 1 : 0, "server clone"],
4324
+ [wpCoreBusy() ? 1 : 0, "WordPress update"],
4325
+ ]
4326
+ const busy = running.filter(([n]) => n > 0).map(([n, what]) => (n > 1 ? `${n} ${what}s` : `a ${what}`))
4327
+ return busy.length ? `Still running: ${busy.join(", ")}. Switch accounts once ${busy.length > 1 ? "they finish" : "it finishes"}.` : null
4328
+ }
4329
+
4248
4330
  const value: StoreValue = {
4249
4331
  servers,
4250
4332
  sites,
@@ -4437,6 +4519,11 @@ export function StoreProvider({ children }: { children: ReactNode }) {
4437
4519
  setEnableLocalSyncSite,
4438
4520
  enableLocalSync,
4439
4521
  accountSlug: cfgRef.current.accountSlug,
4522
+ profileId: cfgRef.current.profileId,
4523
+ profileLabel,
4524
+ setProfileLabel,
4525
+ tokenFromEnv: cfgRef.current.tokenSource === "env",
4526
+ switchBlocker,
4440
4527
  sitesForServer,
4441
4528
  serverById,
4442
4529
  probes,