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