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/CHANGELOG.md +29 -1
- package/README.md +34 -10
- package/package.json +1 -1
- package/src/api/client.ts +65 -0
- package/src/api/types.ts +51 -2
- package/src/dev/mockClient.ts +56 -0
- package/src/index.tsx +28 -0
- package/src/lib/appUpdate.ts +8 -1
- package/src/lib/cliIncidents.ts +7 -0
- package/src/lib/cliSiblings.ts +181 -0
- package/src/lib/cliSsh.ts +45 -31
- package/src/lib/cloneSettings.ts +180 -0
- package/src/lib/serverClone.ts +33 -5
- package/src/lib/sshExecPolicy.ts +119 -7
- package/src/ui/Help.tsx +23 -1
- package/src/ui/store.tsx +79 -28
- package/src/ui/views/CloneWizard.tsx +24 -5
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
3850
|
-
//
|
|
3851
|
-
// depends on it: the pull runs composer (and a Radicle source's
|
|
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
|
|
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
|
-
//
|
|
3967
|
-
//
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
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
|
-
|
|
3983
|
-
|
|
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
|