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/index.tsx
CHANGED
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
// First, before any config read or spawn: keep a site checkout's .env from
|
|
7
7
|
// leaking into child processes (see the module).
|
|
8
8
|
import "./lib/cwdDotenv.ts"
|
|
9
|
-
import { useState } from "react"
|
|
9
|
+
import { useCallback, useMemo, useState } from "react"
|
|
10
10
|
import { createCliRenderer } from "@opentui/core"
|
|
11
11
|
import { createRoot } from "@opentui/react"
|
|
12
12
|
import pkg from "../package.json" with { type: "json" }
|
|
13
|
-
import { hasToken, configPath, loadConfig } from "./config.ts"
|
|
13
|
+
import { hasToken, configPath, loadConfig, pinActiveProfile, setActiveProfile, listProfiles } from "./config.ts"
|
|
14
14
|
import { StoreProvider } from "./ui/store.tsx"
|
|
15
15
|
import { App } from "./ui/App.tsx"
|
|
16
|
+
import { AccountsContext, type AccountsApi } from "./ui/accounts.tsx"
|
|
17
|
+
import { resetWpCoreJobs } from "./ui/wpCoreJobs.tsx"
|
|
16
18
|
import { Onboarding } from "./ui/Onboarding.tsx"
|
|
17
19
|
import { isDevMode } from "./dev/devMode.ts"
|
|
18
20
|
import { SpinupWPClient } from "./api/client.ts"
|
|
@@ -94,7 +96,12 @@ Token resolution: SPINUPWP_ACCESS_TOKEN (env / .env) first, then the config
|
|
|
94
96
|
file. Run \`spinuptui login\` once to save a token so \`spinuptui\` works from
|
|
95
97
|
anywhere.
|
|
96
98
|
|
|
99
|
+
Accounts: add and switch SpinupWP accounts in the app with A. Commands use the
|
|
100
|
+
active one; SPINUPTUI_ACCOUNT=<id> picks another for a single run (ids are
|
|
101
|
+
listed by \`spinuptui where\`).
|
|
102
|
+
|
|
97
103
|
Config file: ${configPath()}
|
|
104
|
+
Account: ${cfg.profileLabel} (${cfg.profileId})
|
|
98
105
|
Token source: ${cfg.tokenSource}`)
|
|
99
106
|
process.exit(0)
|
|
100
107
|
}
|
|
@@ -107,7 +114,10 @@ if (args.includes("-v") || args.includes("--version") || command === "version")
|
|
|
107
114
|
if (command === "where") {
|
|
108
115
|
const cfg = loadConfig()
|
|
109
116
|
console.log(`config: ${configPath()}`)
|
|
117
|
+
console.log(`account: ${cfg.profileLabel} (${cfg.profileId})`)
|
|
110
118
|
console.log(`token source: ${cfg.tokenSource}`)
|
|
119
|
+
const others = listProfiles().filter((p) => !p.active)
|
|
120
|
+
if (others.length) console.log(`other accounts: ${others.map((p) => `${p.label} (${p.id})`).join(", ")}`)
|
|
111
121
|
process.exit(0)
|
|
112
122
|
}
|
|
113
123
|
|
|
@@ -240,13 +250,23 @@ const forceLogin = command === "login"
|
|
|
240
250
|
|
|
241
251
|
function Root() {
|
|
242
252
|
const [configured, setConfigured] = useState(isDevMode() ? true : forceLogin ? false : hasToken())
|
|
253
|
+
// The store is keyed by account: switching remounts it (see ui/accounts.tsx).
|
|
254
|
+
const [account, setAccount] = useState(() => pinActiveProfile())
|
|
255
|
+
const switchAccount = useCallback(async (id: string) => {
|
|
256
|
+
await setActiveProfile(id)
|
|
257
|
+
resetWpCoreJobs()
|
|
258
|
+
setAccount(id)
|
|
259
|
+
}, [])
|
|
260
|
+
const accounts = useMemo<AccountsApi>(() => ({ switchAccount }), [switchAccount])
|
|
243
261
|
if (!configured) {
|
|
244
262
|
return <Onboarding onComplete={() => setConfigured(true)} />
|
|
245
263
|
}
|
|
246
264
|
return (
|
|
247
|
-
<
|
|
248
|
-
<
|
|
249
|
-
|
|
265
|
+
<AccountsContext.Provider value={accounts}>
|
|
266
|
+
<StoreProvider key={account}>
|
|
267
|
+
<App />
|
|
268
|
+
</StoreProvider>
|
|
269
|
+
</AccountsContext.Provider>
|
|
250
270
|
)
|
|
251
271
|
}
|
|
252
272
|
|
package/src/lib/appUpdate.ts
CHANGED
|
@@ -129,15 +129,27 @@ export async function refreshUpdateInfo(current: string): Promise<UpdateInfo | n
|
|
|
129
129
|
// ---- In-app updater (`u` in the Help/About overlay) ------------------------
|
|
130
130
|
|
|
131
131
|
// How this copy of the app was installed, which decides the update path shown
|
|
132
|
-
// in Help/About: a
|
|
133
|
-
//
|
|
132
|
+
// in Help/About: a Homebrew install lives in the Cellar (the formula unpacks the
|
|
133
|
+
// package into its libexec) and updates via `brew upgrade`; a package-manager
|
|
134
|
+
// install (bun/npm global) lives under a node_modules dir; anything else is
|
|
134
135
|
// treated as a git checkout and updates via `git pull` (runSelfUpdate).
|
|
135
|
-
export type InstallChannel = "git" | "package"
|
|
136
|
+
export type InstallChannel = "git" | "package" | "brew"
|
|
136
137
|
|
|
137
138
|
export function installChannel(): InstallChannel {
|
|
139
|
+
if (import.meta.dir.includes(`${sep}Cellar${sep}spinuptui${sep}`)) return "brew"
|
|
138
140
|
return import.meta.dir.includes(`${sep}node_modules${sep}`) ? "package" : "git"
|
|
139
141
|
}
|
|
140
142
|
|
|
143
|
+
// Homebrew owns its copy — `bun add -g` beside it would install a second one
|
|
144
|
+
// that the `spinuptui` on PATH never runs.
|
|
145
|
+
export const BREW_UPDATE_CMD = "brew upgrade spinuptui"
|
|
146
|
+
|
|
147
|
+
// The manual update command for this install (null for a git checkout).
|
|
148
|
+
export function updateCommand(): string | null {
|
|
149
|
+
const ch = installChannel()
|
|
150
|
+
return ch === "brew" ? BREW_UPDATE_CMD : ch === "package" ? PACKAGE_UPDATE_CMD : null
|
|
151
|
+
}
|
|
152
|
+
|
|
141
153
|
// The one-liner Help shows for a package install (also used by runSelfUpdate's
|
|
142
154
|
// "not a checkout" message so every path names the same command).
|
|
143
155
|
//
|
|
@@ -187,7 +199,7 @@ export async function runSelfUpdate(cwd: string = checkoutRoot()): Promise<SelfU
|
|
|
187
199
|
try {
|
|
188
200
|
const isRepo = await git(cwd, ["rev-parse", "--is-inside-work-tree"])
|
|
189
201
|
if (isRepo.code !== 0 || isRepo.stdout !== "true") {
|
|
190
|
-
return { ok: false, message: `This install isn't a git checkout — update with \`${PACKAGE_UPDATE_CMD}\`.`, needsInstall: false }
|
|
202
|
+
return { ok: false, message: `This install isn't a git checkout — update with \`${updateCommand() ?? PACKAGE_UPDATE_CMD}\`.`, needsInstall: false }
|
|
191
203
|
}
|
|
192
204
|
const status = await git(cwd, ["status", "--porcelain"])
|
|
193
205
|
if (status.stdout.length > 0) {
|
package/src/lib/dnsCache.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { join } from "node:path"
|
|
14
14
|
import { existsSync, readFileSync } from "node:fs"
|
|
15
15
|
import { chmod, mkdir } from "node:fs/promises"
|
|
16
|
-
import {
|
|
16
|
+
import { profileDataDir } from "../config.ts"
|
|
17
17
|
import type { ZoneHost } from "./dns.ts"
|
|
18
18
|
|
|
19
19
|
const CACHE_VERSION = 1
|
|
@@ -29,11 +29,14 @@ interface CacheFile {
|
|
|
29
29
|
entries: Record<string, CachedDns> // key: normalized domain
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export function dnsCachePath(): string {
|
|
33
|
-
return join(
|
|
32
|
+
export function dnsCachePath(dir = profileDataDir()): string {
|
|
33
|
+
return join(dir, "dns-cache.json")
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export class DnsCache {
|
|
37
|
+
// Pinned to the account active when the cache was created, so a write that
|
|
38
|
+
// lands after an account switch still goes to its own account\'s file.
|
|
39
|
+
private readonly dir = profileDataDir()
|
|
37
40
|
private entries = new Map<string, CachedDns>()
|
|
38
41
|
// Serializes writes so concurrent batch lookups can't tear the file.
|
|
39
42
|
private writeChain: Promise<void> = Promise.resolve()
|
|
@@ -42,7 +45,7 @@ export class DnsCache {
|
|
|
42
45
|
// an empty cache.
|
|
43
46
|
load(): void {
|
|
44
47
|
try {
|
|
45
|
-
const path = dnsCachePath()
|
|
48
|
+
const path = dnsCachePath(this.dir)
|
|
46
49
|
if (!existsSync(path)) return
|
|
47
50
|
const parsed = JSON.parse(readFileSync(path, "utf8")) as CacheFile
|
|
48
51
|
if (parsed?.version !== CACHE_VERSION || !parsed.entries) return
|
|
@@ -80,10 +83,10 @@ export class DnsCache {
|
|
|
80
83
|
}
|
|
81
84
|
|
|
82
85
|
private async writeFile(): Promise<void> {
|
|
83
|
-
await mkdir(
|
|
86
|
+
await mkdir(this.dir, { recursive: true })
|
|
84
87
|
const file: CacheFile = { version: CACHE_VERSION, entries: {} }
|
|
85
88
|
for (const [k, entry] of this.entries) file.entries[k] = entry
|
|
86
|
-
const path = dnsCachePath()
|
|
89
|
+
const path = dnsCachePath(this.dir)
|
|
87
90
|
await Bun.write(path, JSON.stringify(file, null, 2) + "\n")
|
|
88
91
|
try {
|
|
89
92
|
await chmod(path, 0o600)
|
package/src/lib/keychain.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// the write — acceptable for an opt-in convenience on a personal box; documented here.
|
|
9
9
|
|
|
10
10
|
import { spawn } from "./spawn.ts"
|
|
11
|
+
import { activeProfileId, DEFAULT_PROFILE } from "../config.ts"
|
|
11
12
|
|
|
12
13
|
const SERVICE = "spinup-sudo"
|
|
13
14
|
|
|
@@ -15,8 +16,12 @@ export function keychainAvailable(): boolean {
|
|
|
15
16
|
return process.platform === "darwin"
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
// Server ids are per SpinupWP account — two accounts can both have server #42 —
|
|
20
|
+
// so each account's entries are namespaced by its profile id. The default
|
|
21
|
+
// profile keeps the original un-namespaced name, so entries saved before
|
|
22
|
+
// accounts existed are still found.
|
|
23
|
+
function account(serverId: number, profileId = activeProfileId()): string {
|
|
24
|
+
return profileId === DEFAULT_PROFILE ? `server-${serverId}` : `${profileId}:server-${serverId}`
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
async function security(args: string[]): Promise<{ ok: boolean; stdout: string; stderr: string }> {
|
|
@@ -53,8 +58,9 @@ export async function getSudoPassword(serverId: number): Promise<string | null>
|
|
|
53
58
|
return r.ok ? r.stdout.replace(/\n+$/, "") : null
|
|
54
59
|
}
|
|
55
60
|
|
|
56
|
-
// Remove a server's stored sudo password (best-effort)
|
|
57
|
-
|
|
61
|
+
// Remove a server's stored sudo password (best-effort) — the active account's,
|
|
62
|
+
// or another account's when removing that account.
|
|
63
|
+
export async function deleteSudoPassword(serverId: number, profileId?: string): Promise<void> {
|
|
58
64
|
if (!keychainAvailable()) return
|
|
59
|
-
await security(["delete-generic-password", "-s", SERVICE, "-a", account(serverId)])
|
|
65
|
+
await security(["delete-generic-password", "-s", SERVICE, "-a", account(serverId, profileId)])
|
|
60
66
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { join } from "node:path"
|
|
14
14
|
import { existsSync, readFileSync } from "node:fs"
|
|
15
15
|
import { chmod, mkdir } from "node:fs/promises"
|
|
16
|
-
import {
|
|
16
|
+
import { profileDataDir } from "../config.ts"
|
|
17
17
|
import type { VerifiedZone } from "./providers.ts"
|
|
18
18
|
|
|
19
19
|
// v2: VerifiedZone gained `nameservers` + `account` (was accountId/accountName).
|
|
@@ -42,17 +42,20 @@ interface CacheFile {
|
|
|
42
42
|
entries: Record<string, VerifiedConn> // key: connection id
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
export function providersCachePath(): string {
|
|
46
|
-
return join(
|
|
45
|
+
export function providersCachePath(dir = profileDataDir()): string {
|
|
46
|
+
return join(dir, "providers-cache.json")
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export class ProvidersCache {
|
|
50
|
+
// Pinned to the account active when the cache was created, so a write that
|
|
51
|
+
// lands after an account switch still goes to its own account\'s file.
|
|
52
|
+
private readonly dir = profileDataDir()
|
|
50
53
|
private entries = new Map<string, VerifiedConn>()
|
|
51
54
|
private writeChain: Promise<void> = Promise.resolve()
|
|
52
55
|
|
|
53
56
|
load(): void {
|
|
54
57
|
try {
|
|
55
|
-
const path = providersCachePath()
|
|
58
|
+
const path = providersCachePath(this.dir)
|
|
56
59
|
if (!existsSync(path)) return
|
|
57
60
|
const parsed = JSON.parse(readFileSync(path, "utf8")) as CacheFile
|
|
58
61
|
if (parsed?.version !== CACHE_VERSION || !parsed.entries) return
|
|
@@ -91,10 +94,10 @@ export class ProvidersCache {
|
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
private async writeFile(): Promise<void> {
|
|
94
|
-
await mkdir(
|
|
97
|
+
await mkdir(this.dir, { recursive: true })
|
|
95
98
|
const file: CacheFile = { version: CACHE_VERSION, entries: {} }
|
|
96
99
|
for (const [k, entry] of this.entries) file.entries[k] = entry
|
|
97
|
-
const path = providersCachePath()
|
|
100
|
+
const path = providersCachePath(this.dir)
|
|
98
101
|
await Bun.write(path, JSON.stringify(file, null, 2) + "\n")
|
|
99
102
|
try {
|
|
100
103
|
await chmod(path, 0o600)
|
package/src/lib/serverClone.ts
CHANGED
|
@@ -472,6 +472,13 @@ export interface BedrockPullSpec {
|
|
|
472
472
|
destDbPassword: string
|
|
473
473
|
excludeUploads: boolean
|
|
474
474
|
publicFolder?: string // a signal, not a fact (CLAUDE.md) — re-verified below, independently, on both ends
|
|
475
|
+
// The source site's own git deploy script. Run on the dest after the DB import
|
|
476
|
+
// when the source is Radicle: its front-end (Vite build → public/build) and
|
|
477
|
+
// Acorn caches are gitignored, so composer install alone leaves the theme
|
|
478
|
+
// unbuilt, and the owner's deploy script is their own definition of "build this
|
|
479
|
+
// site". It runs last because Radicle deploy scripts call wp-cli (acorn
|
|
480
|
+
// optimize, cache purge, rewrite flush), which needs the imported database.
|
|
481
|
+
deployScript?: string
|
|
475
482
|
}
|
|
476
483
|
|
|
477
484
|
export async function runBedrockPull(
|
|
@@ -515,9 +522,12 @@ export async function runBedrockPull(
|
|
|
515
522
|
// source happened to detect — its structure comes from the git repo's own
|
|
516
523
|
// content, which can legitimately diverge from the source's on-disk state.
|
|
517
524
|
onProgress("detect", "start")
|
|
518
|
-
const det = await run("detect", source, `${detectWpDirScript(root, spec.publicFolder)}; echo "WPCORE:$W"; echo "BEDROCKROOT:$B"`, 30_000)
|
|
525
|
+
const det = await run("detect", source, `${detectWpDirScript(root, spec.publicFolder)}; echo "WPCORE:$W"; echo "BEDROCKROOT:$B"; echo "RADICLEROOT:$RD"`, 30_000)
|
|
519
526
|
const wpCore = (det.stdout.match(/^WPCORE:(.*)$/m)?.[1] ?? "").trim()
|
|
520
527
|
const srcProjectRoot = (det.stdout.match(/^BEDROCKROOT:(.*)$/m)?.[1] ?? "").trim()
|
|
528
|
+
// Radicle requires roots/bedrock-autoloader, so it passes the Bedrock check
|
|
529
|
+
// above too — this is what tells the two apart.
|
|
530
|
+
const radicle = (det.stdout.match(/^RADICLEROOT:(.*)$/m)?.[1] ?? "").trim() !== ""
|
|
521
531
|
if (!det.ok || !wpCore || !srcProjectRoot) {
|
|
522
532
|
return fail("detect", `couldn't find a Bedrock project (composer.json + WordPress core) under ${root} on the source — is the public folder set correctly?`)
|
|
523
533
|
}
|
|
@@ -577,20 +587,23 @@ export async function runBedrockPull(
|
|
|
577
587
|
if (!composer.ok) return fail("build", (composer.stdout + composer.stderr).trim().split("\n").slice(-3).join(" ") || "composer install failed")
|
|
578
588
|
onProgress("build", "ok")
|
|
579
589
|
|
|
580
|
-
// 3. files — pull
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
//
|
|
590
|
+
// 3. files — pull the uploads dir under the public folder: Bedrock's
|
|
591
|
+
// app/uploads or Radicle's content/uploads, whichever the source has.
|
|
592
|
+
// `web` is whatever the detected public folder is actually named, and it
|
|
593
|
+
// can differ in nesting depth between source and dest (the repo's own
|
|
594
|
+
// content vs. how deep the source's on-disk state happens to be).
|
|
595
|
+
// Archiving/extracting relative to EACH side's own web dir (not `root`)
|
|
596
|
+
// keeps the member name (app/uploads or content/uploads) the same on both
|
|
597
|
+
// ends, so they never need to agree on a shared relative path. Best-effort:
|
|
598
|
+
// a site with no uploads dir yields an empty stream we simply skip.
|
|
587
599
|
onProgress("files", "start")
|
|
588
600
|
if (!spec.excludeUploads) {
|
|
589
601
|
const stopUpPoll = pollTransferSize(dest, `${tmp}_up.tgz`, (b) => onTransfer?.("files", b))
|
|
602
|
+
const srcUploads = `cd ${shq(webDir)} && for d in app/uploads content/uploads; do if [ -d "$d" ]; then tar --warning=no-file-changed -czf - "$d"; rc=$?; [ $rc -le 1 ] && exit 0; exit $rc; fi; done; true`
|
|
590
603
|
const up = await run(
|
|
591
604
|
"files",
|
|
592
605
|
dest,
|
|
593
|
-
`set -e; timeout -k 5 3600 ${remote(
|
|
606
|
+
`set -e; timeout -k 5 3600 ${remote(srcUploads)} > ${tmp}_up.tgz; if [ -s ${tmp}_up.tgz ]; then tar -C ${shq(destWebDir)} -xzf ${tmp}_up.tgz && for d in app/uploads content/uploads; do [ -d ${shq(destWebDir)}/$d ] && chown -R ${shq(du)}:${shq(du)} ${shq(destWebDir)}/$d; done; true; fi; rm -f ${tmp}_up.tgz`,
|
|
594
607
|
3_660_000, // outer must exceed the in-script `timeout 3600` so the inner reports a clean 124
|
|
595
608
|
)
|
|
596
609
|
stopUpPoll()
|
|
@@ -641,6 +654,23 @@ export async function runBedrockPull(
|
|
|
641
654
|
if (!imp.ok) return fail("db", imp.stderr.trim() || "db pull/import failed")
|
|
642
655
|
onProgress("db", "ok")
|
|
643
656
|
|
|
657
|
+
// 5b. deploy — Radicle only: run the source's own deploy script (see
|
|
658
|
+
// BedrockPullSpec.deployScript). Staged as a file owned by the site user
|
|
659
|
+
// (base64 in, so no quoting of the owner's script is ever needed) and run
|
|
660
|
+
// from the project root, exactly where SpinupWP's own deploys run it.
|
|
661
|
+
if (radicle && spec.deployScript?.trim()) {
|
|
662
|
+
onProgress("build", "start", "deploy script")
|
|
663
|
+
const b64 = Buffer.from(spec.deployScript.replace(/\r\n/g, "\n")).toString("base64")
|
|
664
|
+
const deploy = await run(
|
|
665
|
+
"build",
|
|
666
|
+
dest,
|
|
667
|
+
`set -e; echo ${shq(b64)} | base64 -d > ${tmp}_deploy.sh; chown ${shq(du)}:${shq(du)} ${tmp}_deploy.sh; chmod 600 ${tmp}_deploy.sh; cd ${shq(destProjectRoot)}; rc=0; sudo -u ${shq(du)} -H bash -lc ${shq(`cd ${shq(destProjectRoot)} && bash ${tmp}_deploy.sh`)} 2>&1 || rc=$?; rm -f ${tmp}_deploy.sh; exit $rc`,
|
|
668
|
+
1_200_000,
|
|
669
|
+
)
|
|
670
|
+
if (!deploy.ok) return fail("build", `the site's deploy script failed: ${(deploy.stdout + deploy.stderr).trim().split("\n").slice(-3).join(" ") || `exit ${deploy.code}`}`)
|
|
671
|
+
onProgress("build", "ok")
|
|
672
|
+
}
|
|
673
|
+
|
|
644
674
|
// 6. verify — wp-cli on the dest (HTTP --resolve is the caller's).
|
|
645
675
|
onProgress("verify", "start")
|
|
646
676
|
const ver = await run("verify", dest, `cd ${shq(destProjectRoot)}; sudo -u ${shq(du)} -H wp core is-installed`, 30_000)
|
|
@@ -652,7 +682,7 @@ export async function runBedrockPull(
|
|
|
652
682
|
// 7. revoke — drop the pull key (by marker) on source, clean dest temp + staged dump.
|
|
653
683
|
onProgress("revoke", "start")
|
|
654
684
|
await run("revoke", source, `AK=${shq(home)}/.ssh/authorized_keys; [ -f "$AK" ] && sed -i ${shq(`/${MARKER}/d`)} "$AK" && chown ${shq(su)}:${shq(su)} "$AK"; rm -f ${shq(home)}/.clone_db.sql.gz`, 30_000).catch(() => {})
|
|
655
|
-
await run("revoke", dest, `rm -f ${KEY} ${KEY}.pub ${tmp}_auth.json ${tmp}_up.tgz ${tmp}_env ${tmp}.sql.gz ${tmp}.sql`, 30_000).catch(() => {})
|
|
685
|
+
await run("revoke", dest, `rm -f ${KEY} ${KEY}.pub ${tmp}_auth.json ${tmp}_up.tgz ${tmp}_env ${tmp}.sql.gz ${tmp}.sql ${tmp}_deploy.sh`, 30_000).catch(() => {})
|
|
656
686
|
onProgress("revoke", "ok")
|
|
657
687
|
}
|
|
658
688
|
}
|
|
@@ -667,11 +697,20 @@ export async function runBedrockPull(
|
|
|
667
697
|
// just BEFORE a destination site (and its git clone) gets created for nothing.
|
|
668
698
|
// Silent no-op for anything that isn't a detectable Bedrock install; that's
|
|
669
699
|
// covered by the pull chain's own detection, not this pre-check's job.
|
|
670
|
-
|
|
700
|
+
//
|
|
701
|
+
// For a Radicle source whose deploy script builds the front-end with npm/yarn/
|
|
702
|
+
// pnpm, it also checks the destination has Node: a stock SpinupWP server doesn't,
|
|
703
|
+
// and finding out after the create would leave a half-built site behind. We stop
|
|
704
|
+
// and say so rather than install it — the server's packages aren't ours to manage.
|
|
705
|
+
export async function preflightBedrockSource(
|
|
706
|
+
source: SudoCtx,
|
|
707
|
+
spec: { domain: string; publicFolder?: string; deployScript?: string },
|
|
708
|
+
dest?: SudoCtx,
|
|
709
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
671
710
|
const root = `/sites/${spec.domain}/files`
|
|
672
711
|
const expectedWebDir = webrootFor(root, spec.publicFolder)
|
|
673
712
|
const expectedProjectRoot = expectedWebDir === root ? root : expectedWebDir.slice(0, expectedWebDir.lastIndexOf("/"))
|
|
674
|
-
const r = await exec(source, `${detectWpDirScript(root, spec.publicFolder)}; echo "BEDROCKROOT:$B"`, 30_000)
|
|
713
|
+
const r = await exec(source, `${detectWpDirScript(root, spec.publicFolder)}; echo "BEDROCKROOT:$B"; echo "RADICLEROOT:$RD"`, 30_000)
|
|
675
714
|
const detected = (r.stdout.match(/^BEDROCKROOT:(.*)$/m)?.[1] ?? "").trim()
|
|
676
715
|
if (!r.ok || !detected) return { ok: true }
|
|
677
716
|
if (detected !== expectedProjectRoot) {
|
|
@@ -680,9 +719,103 @@ export async function preflightBedrockSource(source: SudoCtx, spec: { domain: st
|
|
|
680
719
|
error: `this site's actual files (Bedrock project at ${detected}) don't match its configured Public Folder (implies ${expectedProjectRoot}) — align them and retry`,
|
|
681
720
|
}
|
|
682
721
|
}
|
|
722
|
+
const radicle = (r.stdout.match(/^RADICLEROOT:(.*)$/m)?.[1] ?? "").trim() !== ""
|
|
723
|
+
// Radicle's front-end build lives only in the deploy script (see
|
|
724
|
+
// BedrockPullSpec.deployScript) — without one the clone would come up with no
|
|
725
|
+
// built assets and answer 500. SpinupWP can drop a script set through its API
|
|
726
|
+
// (one sent on create is gone a minute or two later), so this is reachable.
|
|
727
|
+
if (radicle && !spec.deployScript?.trim()) {
|
|
728
|
+
return {
|
|
729
|
+
ok: false,
|
|
730
|
+
error: "this Radicle site has no deploy script in SpinupWP, so there's nothing to build its front-end with — add one (Site → Git) and retry",
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (radicle && dest && /\b(npm|npx|yarn|pnpm|node)\b/.test(spec.deployScript ?? "")) {
|
|
734
|
+
const n = await exec(dest, `command -v node >/dev/null 2>&1 && echo HASNODE || echo NONODE`, 30_000)
|
|
735
|
+
if (n.stdout.includes("NONODE")) {
|
|
736
|
+
return {
|
|
737
|
+
ok: false,
|
|
738
|
+
error: `this Radicle site's deploy script builds its front-end with Node, and ${dest.server.name} doesn't have Node installed — install Node on it and retry`,
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
683
742
|
return { ok: true }
|
|
684
743
|
}
|
|
685
744
|
|
|
745
|
+
// ---- Crontab carry-over --------------------------------------------------------
|
|
746
|
+
//
|
|
747
|
+
// SpinupWP writes each site user's crontab itself: a PATH line and an
|
|
748
|
+
// `#Ansible: <domain>` block running WP cron. A new site gets its own copy at
|
|
749
|
+
// create, so those are never copied. Anything else in the source user's crontab
|
|
750
|
+
// was added by the site owner (a feed import, an Acorn/Laravel command, a report
|
|
751
|
+
// job), and without this step a clone silently stops running it. Lines are copied
|
|
752
|
+
// verbatim, commented-out ones included (a disabled job stays disabled), which is
|
|
753
|
+
// safe because a clone keeps the domain and site user, so every path matches.
|
|
754
|
+
// Idempotent: a line the dest already has is skipped, so a retry adds nothing.
|
|
755
|
+
|
|
756
|
+
export interface CrontabSyncResult {
|
|
757
|
+
ok: boolean
|
|
758
|
+
added: string[] // the lines appended to the dest crontab (empty = nothing to carry)
|
|
759
|
+
error?: string
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// The owner-added lines of a crontab: everything except SpinupWP's `#Ansible:`
|
|
763
|
+
// marker + the job line after it, and blank lines. Environment lines (PATH=…,
|
|
764
|
+
// MAILTO=…) are kept here; the caller drops the ones the dest already sets.
|
|
765
|
+
// Exported for the test harness.
|
|
766
|
+
export function ownerCrontabLines(crontab: string): string[] {
|
|
767
|
+
const out: string[] = []
|
|
768
|
+
const lines = crontab.split("\n").map((l) => l.replace(/\r$/, ""))
|
|
769
|
+
for (let i = 0; i < lines.length; i++) {
|
|
770
|
+
const line = lines[i]
|
|
771
|
+
if (/^#Ansible:/.test(line)) {
|
|
772
|
+
i++ // the managed job line that follows the marker
|
|
773
|
+
continue
|
|
774
|
+
}
|
|
775
|
+
if (line.trim() === "") continue
|
|
776
|
+
out.push(line)
|
|
777
|
+
}
|
|
778
|
+
return out
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const envName = (line: string): string | null => line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/)?.[1] ?? null
|
|
782
|
+
|
|
783
|
+
export async function syncCrontab(
|
|
784
|
+
source: SudoCtx,
|
|
785
|
+
dest: SudoCtx,
|
|
786
|
+
spec: { domain: string; sourceSiteUser: string; destSiteUser: string },
|
|
787
|
+
onExec?: CloneExecLog,
|
|
788
|
+
): Promise<CrontabSyncResult> {
|
|
789
|
+
const read = async (ctx: SudoCtx, user: string) => {
|
|
790
|
+
const t0 = Date.now()
|
|
791
|
+
const script = `crontab -l -u ${shq(user)} 2>/dev/null || true`
|
|
792
|
+
const r = await exec(ctx, script, 30_000)
|
|
793
|
+
onExec?.({ domain: spec.domain, stage: "config", host: ctx.server.name, ok: r.ok, code: r.code, ms: Date.now() - t0, script, stdout: r.stdout, stderr: r.stderr })
|
|
794
|
+
return r
|
|
795
|
+
}
|
|
796
|
+
const src = await read(source, spec.sourceSiteUser)
|
|
797
|
+
if (!src.ok) return { ok: false, added: [], error: src.stderr.trim() || "couldn't read the source crontab" }
|
|
798
|
+
const wanted = ownerCrontabLines(src.stdout)
|
|
799
|
+
if (wanted.length === 0) return { ok: true, added: [] }
|
|
800
|
+
|
|
801
|
+
const dst = await read(dest, spec.destSiteUser)
|
|
802
|
+
if (!dst.ok) return { ok: false, added: [], error: dst.stderr.trim() || "couldn't read the destination crontab" }
|
|
803
|
+
const have = new Set(dst.stdout.split("\n").map((l) => l.replace(/\r$/, "")))
|
|
804
|
+
const haveEnv = new Set([...have].map(envName).filter((n): n is string => n != null))
|
|
805
|
+
// An env line the dest already sets (SpinupWP's own PATH, typically) stays the dest's.
|
|
806
|
+
const added = wanted.filter((l) => !have.has(l) && !(envName(l) && haveEnv.has(envName(l)!)))
|
|
807
|
+
if (added.length === 0) return { ok: true, added: [] }
|
|
808
|
+
|
|
809
|
+
const block = ["", `# Carried over from ${source.server.name} by the SpinupTUI clone`, ...added].join("\n") + "\n"
|
|
810
|
+
const b64 = Buffer.from(block).toString("base64")
|
|
811
|
+
const script = `set -e; { crontab -l -u ${shq(spec.destSiteUser)} 2>/dev/null || true; echo ${shq(b64)} | base64 -d; } | crontab -u ${shq(spec.destSiteUser)} -`
|
|
812
|
+
const t0 = Date.now()
|
|
813
|
+
const w = await exec(dest, script, 30_000)
|
|
814
|
+
onExec?.({ domain: spec.domain, stage: "config", host: dest.server.name, ok: w.ok, code: w.code, ms: Date.now() - t0, script, stdout: w.stdout, stderr: w.stderr })
|
|
815
|
+
if (!w.ok) return { ok: false, added: [], error: w.stderr.trim() || "couldn't write the destination crontab" }
|
|
816
|
+
return { ok: true, added }
|
|
817
|
+
}
|
|
818
|
+
|
|
686
819
|
// ---- Files-only pull chain (non-WP sites: redirect shells, static/PHP sites) --
|
|
687
820
|
//
|
|
688
821
|
// The same hardened transport as the Standard-WP files stage (per-site key,
|
package/src/lib/spinupweb.ts
CHANGED
|
@@ -31,3 +31,10 @@ export function serverProvidersSettingsUrl(accountSlug: string | null): string {
|
|
|
31
31
|
export function siteSftpUrl(id: number, accountSlug: string | null): string {
|
|
32
32
|
return accountSlug ? `${WEB_BASE}/${accountSlug}/sites/${id}#sftp` : WEB_BASE
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
// A site's Git settings (repo, branch, deploy script). The clone wizard links here
|
|
36
|
+
// when a dest site's deploy script needs re-entering: SpinupWP drops a script
|
|
37
|
+
// set through the API (see the create payload in store.tsx).
|
|
38
|
+
export function siteGitUrl(id: number, accountSlug: string | null): string {
|
|
39
|
+
return accountSlug ? `${WEB_BASE}/${accountSlug}/sites/${id}#git` : WEB_BASE
|
|
40
|
+
}
|
package/src/lib/stack.ts
CHANGED
|
@@ -80,10 +80,13 @@ export function cloneStackFor(site: Site, probeKind?: ProbeKind | null): "wp" |
|
|
|
80
80
|
if (probeKind === "whmcs" || probeKind === "laravel" || probeKind === "static") return "files"
|
|
81
81
|
return "bedrock"
|
|
82
82
|
}
|
|
83
|
-
// A
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
83
|
+
// A git-deployed Radicle site took the branch above: the Bedrock pull handles
|
|
84
|
+
// it (it detects Radicle itself, pulls content/uploads, and runs the site's
|
|
85
|
+
// deploy script to build the front-end). A "radicle" probe with no git repo
|
|
86
|
+
// never falls into "wp" here since effectiveStack maps it to "Radicle", not
|
|
87
|
+
// "Standard WP" — deliberate: the Standard-WP pull chain assumes a flat webroot
|
|
88
|
+
// and would corrupt a Radicle clone, and without the repo there's nothing to
|
|
89
|
+
// rebuild it from.
|
|
87
90
|
return effectiveStack(site, probeKind) === "Standard WP" ? "wp" : "files"
|
|
88
91
|
}
|
|
89
92
|
|
package/src/lib/stackCache.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { join } from "node:path"
|
|
13
13
|
import { existsSync, readFileSync } from "node:fs"
|
|
14
14
|
import { chmod, mkdir } from "node:fs/promises"
|
|
15
|
-
import {
|
|
15
|
+
import { profileDataDir } from "../config.ts"
|
|
16
16
|
import type { ProbeResult } from "./probe.ts"
|
|
17
17
|
|
|
18
18
|
const CACHE_VERSION = 1
|
|
@@ -28,8 +28,8 @@ interface CacheFile {
|
|
|
28
28
|
entries: Record<string, CachedProbe> // key: String(site.id)
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export function stackCachePath(): string {
|
|
32
|
-
return join(
|
|
31
|
+
export function stackCachePath(dir = profileDataDir()): string {
|
|
32
|
+
return join(dir, "stack-cache.json")
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
// A cached probe is considered stale when the site's relevant shape changes.
|
|
@@ -40,6 +40,9 @@ export function siteSignature(site: { is_wordpress: boolean; public_folder: stri
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export class StackCache {
|
|
43
|
+
// Pinned to the account active when the cache was created, so a write that
|
|
44
|
+
// lands after an account switch still goes to its own account\'s file.
|
|
45
|
+
private readonly dir = profileDataDir()
|
|
43
46
|
private entries = new Map<number, CachedProbe>()
|
|
44
47
|
// Serializes disk writes so concurrent probes (batch mode) can't tear the
|
|
45
48
|
// file or drop each other's updates. Each persist() runs after the previous.
|
|
@@ -49,7 +52,7 @@ export class StackCache {
|
|
|
49
52
|
// simply yields an empty cache.
|
|
50
53
|
load(): void {
|
|
51
54
|
try {
|
|
52
|
-
const path = stackCachePath()
|
|
55
|
+
const path = stackCachePath(this.dir)
|
|
53
56
|
if (!existsSync(path)) return
|
|
54
57
|
const parsed = JSON.parse(readFileSync(path, "utf8")) as CacheFile
|
|
55
58
|
if (parsed?.version !== CACHE_VERSION || !parsed.entries) return
|
|
@@ -99,10 +102,10 @@ export class StackCache {
|
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
private async writeFile(): Promise<void> {
|
|
102
|
-
await mkdir(
|
|
105
|
+
await mkdir(this.dir, { recursive: true })
|
|
103
106
|
const file: CacheFile = { version: CACHE_VERSION, entries: {} }
|
|
104
107
|
for (const [id, entry] of this.entries) file.entries[String(id)] = entry
|
|
105
|
-
const path = stackCachePath()
|
|
108
|
+
const path = stackCachePath(this.dir)
|
|
106
109
|
await Bun.write(path, JSON.stringify(file, null, 2) + "\n")
|
|
107
110
|
try {
|
|
108
111
|
await chmod(path, 0o600)
|
package/src/lib/wpCli.ts
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
// individual site's configured version, and can be missing extensions that
|
|
6
6
|
// version's CLI SAPI never got configured with.
|
|
7
7
|
//
|
|
8
|
-
// Verified live 2026-07-09 on
|
|
8
|
+
// Verified live 2026-07-09 on a production server: the system default had
|
|
9
9
|
// drifted to 8.4, whose CLI lacked `mysqli` (its FPM pool likely never needed
|
|
10
|
-
// it — no site there runs 8.4), while
|
|
10
|
+
// it — no site there runs 8.4), while a site's actual
|
|
11
11
|
// configured version (8.1) has a fully working php8.1-cli. Bare `wp` silently
|
|
12
12
|
// ran under 8.4 and failed on anything touching $wpdb (post/user/plugin list,
|
|
13
13
|
// option get) — while `wp core version`, and `wp db export/import/check/size`
|
|
@@ -28,3 +28,55 @@ export function wpCliResolveScript(phpVersion?: string | null): string {
|
|
|
28
28
|
const phpBin = phpVersion ? `php${phpVersion}` : "php"
|
|
29
29
|
return [`WP=$(command -v wp 2>/dev/null || echo /usr/local/bin/wp)`, `PHP=$(command -v ${phpBin} 2>/dev/null || command -v php)`].join("\n")
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
// Pull wp-cli's `--format=json` array out of a section of remote stdout that may
|
|
33
|
+
// also carry noise: under the CLI SAPI a plugin's PHP notices/deprecations print
|
|
34
|
+
// to STDOUT (2>/dev/null doesn't catch them), and with no newline of their own
|
|
35
|
+
// they can sit on the same line as the JSON — before it, or after it when they
|
|
36
|
+
// fire at shutdown (seen live: an Elementor Pro deprecation glued to the end of
|
|
37
|
+
// `plugin list` output). So: from every `[`, bracket-match to its closing `]`
|
|
38
|
+
// (skipping brackets inside JSON strings), and keep the longest slice that parses
|
|
39
|
+
// as an array of objects — which is what every wp-cli list is, and what notice
|
|
40
|
+
// text like "on line [3]" or "[array (" never is.
|
|
41
|
+
// Returns null when nothing parses, so callers can tell "wp-cli printed no list"
|
|
42
|
+
// (an error worth surfacing) from a genuinely empty `[]`.
|
|
43
|
+
export function extractJsonArray(lines: string[] | undefined): unknown[] | null {
|
|
44
|
+
const text = (lines ?? []).join("\n")
|
|
45
|
+
let best: unknown[] | null = null
|
|
46
|
+
let bestLen = -1
|
|
47
|
+
for (let start = text.indexOf("["); start !== -1; start = text.indexOf("[", start + 1)) {
|
|
48
|
+
const end = matchingBracket(text, start)
|
|
49
|
+
if (end === -1 || end - start <= bestLen) continue
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(text.slice(start, end + 1))
|
|
52
|
+
if (Array.isArray(parsed) && parsed.every((o) => o !== null && typeof o === "object")) {
|
|
53
|
+
best = parsed
|
|
54
|
+
bestLen = end - start
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
/* not JSON from here — try the next `[` */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return best
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Index of the `]` closing the `[` at `start`, or -1. Tracks JSON string state so
|
|
64
|
+
// a bracket inside a value (a plugin name, a URL) doesn't end the match early.
|
|
65
|
+
function matchingBracket(text: string, start: number): number {
|
|
66
|
+
let depth = 0
|
|
67
|
+
let inString = false
|
|
68
|
+
for (let i = start; i < text.length; i++) {
|
|
69
|
+
const ch = text[i]
|
|
70
|
+
if (inString) {
|
|
71
|
+
if (ch === "\\") i++
|
|
72
|
+
else if (ch === '"') inString = false
|
|
73
|
+
} else if (ch === '"') inString = true
|
|
74
|
+
else if (ch === "[" || ch === "{") depth++
|
|
75
|
+
else if (ch === "]" || ch === "}") {
|
|
76
|
+
depth--
|
|
77
|
+
if (depth === 0) return ch === "]" ? i : -1
|
|
78
|
+
if (depth < 0) return -1
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return -1
|
|
82
|
+
}
|