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/CHANGELOG.md +32 -4
- package/README.md +16 -4
- package/bun.lock +103 -0
- package/package.json +3 -2
- package/src/api/client.ts +33 -3
- package/src/config.ts +232 -8
- package/src/index.tsx +25 -5
- package/src/lib/appUpdate.ts +16 -4
- package/src/lib/dnsCache.ts +9 -6
- package/src/lib/keychain.ts +11 -5
- package/src/lib/providersCache.ts +9 -6
- package/src/lib/serverClone.ts +145 -12
- package/src/lib/spinupweb.ts +7 -0
- package/src/lib/stack.ts +7 -4
- package/src/lib/stackCache.ts +9 -6
- package/src/lib/wpCli.ts +54 -2
- package/src/lib/wpCore.ts +7 -18
- package/src/lib/wpInventory.ts +18 -14
- package/src/ui/App.tsx +10 -0
- package/src/ui/Header.tsx +31 -1
- package/src/ui/Help.tsx +4 -3
- package/src/ui/accounts.tsx +21 -0
- package/src/ui/store.tsx +117 -30
- package/src/ui/views/Accounts.tsx +370 -0
- package/src/ui/views/CloneWizard.tsx +33 -5
- package/src/ui/views/Stacks.tsx +31 -6
- package/src/ui/wpCoreJobs.tsx +18 -0
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:
|
|
181
|
-
// the JSON array
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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)
|
package/src/lib/wpInventory.ts
CHANGED
|
@@ -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
|
-
//
|
|
72
|
-
//
|
|
73
|
-
function
|
|
74
|
-
const
|
|
75
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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,
|
|
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()
|
|
194
|
+
{installChannel() !== "git" ? (
|
|
194
195
|
<>
|
|
195
196
|
{line("Manual:")}
|
|
196
|
-
{line(
|
|
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 {
|
|
57
|
-
import {
|
|
58
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3818
|
-
//
|
|
3819
|
-
//
|
|
3820
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3924
|
-
|
|
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,
|