spinuptui 0.25.0 → 0.26.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 +103 -1
- package/README.md +61 -8
- package/package.json +3 -2
- package/src/index.tsx +134 -11
- package/src/lib/appUpdate.ts +2 -1
- package/src/lib/cliPull.ts +387 -0
- package/src/lib/cliSsh.ts +76 -50
- package/src/lib/cwdDotenv.ts +106 -0
- package/src/lib/dbBackup.ts +9 -5
- package/src/lib/dbSync.ts +15 -1
- package/src/lib/gitDeployKey.ts +0 -0
- package/src/lib/gitStatus.ts +2 -1
- package/src/lib/keychain.ts +3 -1
- package/src/lib/links.ts +23 -0
- package/src/lib/localSetup.ts +164 -0
- package/src/lib/open.ts +10 -8
- package/src/lib/probe.ts +2 -1
- package/src/lib/serverClone.ts +3 -2
- package/src/lib/spawn.ts +17 -0
- package/src/lib/ssh.ts +6 -5
- package/src/lib/sshExec.ts +4 -2
- package/src/lib/wpInventory.ts +2 -1
- package/src/ui/App.tsx +6 -0
- package/src/ui/store.tsx +70 -0
- package/src/ui/views/LocalLink.tsx +62 -11
- package/src/ui/views/LocalSetup.tsx +256 -0
- package/src/ui/views/Search.tsx +38 -66
package/src/lib/dbBackup.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { existsSync, mkdirSync, statSync } from "node:fs"
|
|
|
14
14
|
import { join } from "node:path"
|
|
15
15
|
import type { Server, Site } from "../api/types.ts"
|
|
16
16
|
import { expandPath, type LocalLink } from "./local.ts"
|
|
17
|
+
import { spawn } from "./spawn.ts"
|
|
17
18
|
|
|
18
19
|
export const SSH_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-o", "StrictHostKeyChecking=accept-new"]
|
|
19
20
|
|
|
@@ -111,12 +112,12 @@ export async function runProcess(
|
|
|
111
112
|
timeoutMs: number,
|
|
112
113
|
cwd?: string,
|
|
113
114
|
env?: Record<string, string>,
|
|
114
|
-
): Promise<{ code: number; stderr: string }> {
|
|
115
|
+
): Promise<{ code: number; stderr: string; stdout: string }> {
|
|
115
116
|
let proc: ReturnType<typeof Bun.spawn>
|
|
116
117
|
try {
|
|
117
|
-
proc =
|
|
118
|
+
proc = spawn(cmd, { stdout: "pipe", stderr: "pipe", stdin: "ignore", cwd, env })
|
|
118
119
|
} catch (err) {
|
|
119
|
-
return { code: -1, stderr: `Failed to launch ${cmd[0]}: ${(err as Error).message}
|
|
120
|
+
return { code: -1, stderr: `Failed to launch ${cmd[0]}: ${(err as Error).message}`, stdout: "" }
|
|
120
121
|
}
|
|
121
122
|
const timer = setTimeout(() => {
|
|
122
123
|
try {
|
|
@@ -127,8 +128,11 @@ export async function runProcess(
|
|
|
127
128
|
}, timeoutMs)
|
|
128
129
|
const code = await proc.exited
|
|
129
130
|
clearTimeout(timer)
|
|
130
|
-
const stderr = await
|
|
131
|
-
|
|
131
|
+
const [stdout, stderr] = await Promise.all([
|
|
132
|
+
new Response(proc.stdout as ReadableStream<Uint8Array>).text(),
|
|
133
|
+
new Response(proc.stderr as ReadableStream<Uint8Array>).text(),
|
|
134
|
+
])
|
|
135
|
+
return { code, stderr, stdout }
|
|
132
136
|
}
|
|
133
137
|
|
|
134
138
|
// Pick a meaningful error line out of ssh/wp stderr, skipping the deprecation /
|
package/src/lib/dbSync.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// SYNC_REMOTE_HOST / SYNC_LOCAL_HOST env contract as the sync-prod-to-local
|
|
13
13
|
// script, so per-project tweaks (Elementor URL swaps, plugin toggles) carry over.
|
|
14
14
|
|
|
15
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs"
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, rmSync } from "node:fs"
|
|
16
16
|
import { join, dirname } from "node:path"
|
|
17
17
|
import type { Server, Site } from "../api/types.ts"
|
|
18
18
|
import { expandPath, findProjectRoot, resolveLocalLink, type LocalKind, type LocalLink } from "./local.ts"
|
|
@@ -212,6 +212,20 @@ export async function runDbSync(plan: DbSyncPlan, domain: string, onProgress: (p
|
|
|
212
212
|
const localSql = plan.localBackupPath.replace(/\.gz$/, "")
|
|
213
213
|
const lb = await wp(`wp db export "${localSql}" >/dev/null && gzip -f "${localSql}"`, 300_000)
|
|
214
214
|
if (lb.code !== 0) {
|
|
215
|
+
// `wp db export` creates its output file before it knows the dump will
|
|
216
|
+
// succeed, and the `&& gzip` never runs when it doesn't — leaving a bare
|
|
217
|
+
// .sql behind (empty on an instant failure, PARTIAL if the dump dies
|
|
218
|
+
// midway) named exactly like a real backup. Nothing was restorable from it
|
|
219
|
+
// anyway: this step aborts before the local database is touched. Remove it
|
|
220
|
+
// rather than leave a file that reads as a safety net, the same way the
|
|
221
|
+
// download branch below cleans up the remote dump it orphaned. A
|
|
222
|
+
// successful run leaves only the .gz, and the name carries a
|
|
223
|
+
// to-the-minute timestamp, so this can only ever be this run's artifact.
|
|
224
|
+
try {
|
|
225
|
+
rmSync(localSql, { force: true })
|
|
226
|
+
} catch {
|
|
227
|
+
/* best-effort — a leftover file must not mask the real failure below */
|
|
228
|
+
}
|
|
215
229
|
// wp-cli's own "not a WordPress install" error is a common first-pull snag on a
|
|
216
230
|
// fresh checkout — a Bedrock or Radicle project needs `composer install` to
|
|
217
231
|
// build vendor/ + web/wp (or public/wp) before wp-cli (or wp-cli.yml) can find
|
package/src/lib/gitDeployKey.ts
CHANGED
|
Binary file
|
package/src/lib/gitStatus.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// needs a network fetch + possibly SSH auth, against the app's no-slow-ops rule).
|
|
8
8
|
|
|
9
9
|
import { expandPath, findProjectRoot } from "./local.ts"
|
|
10
|
+
import { spawn } from "./spawn.ts"
|
|
10
11
|
|
|
11
12
|
export interface Drift {
|
|
12
13
|
dirty: boolean // uncommitted working-tree changes
|
|
@@ -15,7 +16,7 @@ export interface Drift {
|
|
|
15
16
|
|
|
16
17
|
async function git(cwd: string, args: string[]): Promise<{ ok: boolean; out: string }> {
|
|
17
18
|
try {
|
|
18
|
-
const proc =
|
|
19
|
+
const proc = spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore", stdin: "ignore" })
|
|
19
20
|
const out = await new Response(proc.stdout).text()
|
|
20
21
|
const code = await proc.exited
|
|
21
22
|
return { ok: code === 0, out: out.trim() }
|
package/src/lib/keychain.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// as an argv value (-w), so it's briefly visible to `ps` on the local machine during
|
|
8
8
|
// the write — acceptable for an opt-in convenience on a personal box; documented here.
|
|
9
9
|
|
|
10
|
+
import { spawn } from "./spawn.ts"
|
|
11
|
+
|
|
10
12
|
const SERVICE = "spinup-sudo"
|
|
11
13
|
|
|
12
14
|
export function keychainAvailable(): boolean {
|
|
@@ -19,7 +21,7 @@ function account(serverId: number): string {
|
|
|
19
21
|
|
|
20
22
|
async function security(args: string[]): Promise<{ ok: boolean; stdout: string; stderr: string }> {
|
|
21
23
|
try {
|
|
22
|
-
const proc =
|
|
24
|
+
const proc = spawn(["security", ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe" })
|
|
23
25
|
const [stdout, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
|
|
24
26
|
return { ok: code === 0, stdout, stderr }
|
|
25
27
|
} catch (err) {
|
package/src/lib/links.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Local working-copy links, read/written outside the React store.
|
|
2
|
+
//
|
|
3
|
+
// The TUI's store owns an authoritative in-memory Map and persists the whole
|
|
4
|
+
// map on every change (see store.tsx's persistLinks), so it doesn't need these.
|
|
5
|
+
// The CLI has no store: it reads the config once and writes a single link back,
|
|
6
|
+
// merging rather than replacing so a concurrent TUI session's other links
|
|
7
|
+
// survive. Both paths end up in the same config key (`localSites`).
|
|
8
|
+
|
|
9
|
+
import { loadConfig, saveConfig } from "../config.ts"
|
|
10
|
+
import { normalizeLink, type LocalLink } from "./local.ts"
|
|
11
|
+
|
|
12
|
+
// The link recorded for a site id, if any.
|
|
13
|
+
export function readLink(siteId: number): LocalLink | undefined {
|
|
14
|
+
const raw = loadConfig().localSites[String(siteId)]
|
|
15
|
+
return raw ? normalizeLink(raw) : undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Merge one link into the stored set. Re-reads the config immediately before
|
|
19
|
+
// writing so we don't clobber links made since this process started.
|
|
20
|
+
export async function writeLink(siteId: number, link: LocalLink): Promise<void> {
|
|
21
|
+
const current = loadConfig().localSites
|
|
22
|
+
await saveConfig({ localSites: { ...current, [String(siteId)]: normalizeLink(link) } })
|
|
23
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Set up a brand-new local working copy for a remote SpinupWP site — the
|
|
2
|
+
// missing first step before dbSync (`p`) and mediaFallback (`m`) can run.
|
|
3
|
+
//
|
|
4
|
+
// Gets the CODE down (git clone for git-deployed sites, an rsync pull over SSH
|
|
5
|
+
// for everything else — excluding uploads/ and the server-specific caching
|
|
6
|
+
// drop-ins, object-cache.php/advanced-cache.php: they're wired to production's
|
|
7
|
+
// Redis/disk cache, and object-cache.php doesn't just no-op without it, it
|
|
8
|
+
// fatals) and runs `composer install` locally when the checkout turns out to
|
|
9
|
+
// be Bedrock/Radicle. Deliberately stops there: it never touches the local
|
|
10
|
+
// database or webserver config, and never links the site itself — the caller
|
|
11
|
+
// does that (reusing `linkSite`) once this succeeds, then hands off to the
|
|
12
|
+
// existing dbSync (`p`) and mediaFallback (`m`) flows unchanged.
|
|
13
|
+
//
|
|
14
|
+
// For a public/-style webroot, wp-config.php lives ONE level above it (see
|
|
15
|
+
// CLAUDE.md's WordPress layout rules) — so the rsync path always pulls the
|
|
16
|
+
// site's whole files root, not just the detected webroot, so wp-config.php
|
|
17
|
+
// (and anything else living beside it) comes down too. `public_folder` is a
|
|
18
|
+
// SETTING, not a fact (SpinupWP never enforces it), so the real webroot is
|
|
19
|
+
// detected on the source via `detectWpDirScript` rather than trusted.
|
|
20
|
+
|
|
21
|
+
import { existsSync, mkdirSync, readdirSync } from "node:fs"
|
|
22
|
+
import { dirname } from "node:path"
|
|
23
|
+
import type { Server, Site } from "../api/types.ts"
|
|
24
|
+
import { expandPath, resolveLocalLink } from "./local.ts"
|
|
25
|
+
import { SSH_OPTS, sshPort, runProcess, meaningfulError } from "./dbBackup.ts"
|
|
26
|
+
import { detectWpDirScript } from "./serverClone.ts"
|
|
27
|
+
|
|
28
|
+
export interface LocalSetupPlan {
|
|
29
|
+
domain: string
|
|
30
|
+
isGit: boolean
|
|
31
|
+
repoUrl: string | null // git clone source, when isGit
|
|
32
|
+
user: string // SSH user — only needed for the non-git rsync path
|
|
33
|
+
host: string
|
|
34
|
+
port: number | null
|
|
35
|
+
filesRoot: string // remote /sites/{domain}/files — rsync source root
|
|
36
|
+
publicFolder: string | null // as configured; the real webroot is still detected, not trusted
|
|
37
|
+
destPath: string // expanded local destination (must not exist, or be empty)
|
|
38
|
+
destUrl: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type LocalSetupPlanResult = { ok: true; plan: LocalSetupPlan } | { ok: false; error: string }
|
|
42
|
+
|
|
43
|
+
export function planLocalSetup(
|
|
44
|
+
site: Site,
|
|
45
|
+
server: Server | undefined,
|
|
46
|
+
sshUser: string | null,
|
|
47
|
+
destPath: string,
|
|
48
|
+
destUrl: string,
|
|
49
|
+
): LocalSetupPlanResult {
|
|
50
|
+
const path = destPath.trim()
|
|
51
|
+
if (!path) return { ok: false, error: "Enter a local path." }
|
|
52
|
+
const dir = expandPath(path)
|
|
53
|
+
if (existsSync(dir) && readdirSync(dir).length > 0) return { ok: false, error: "That path already exists and isn't empty." }
|
|
54
|
+
|
|
55
|
+
const isGit = !!site.git?.repo
|
|
56
|
+
const host = server?.ip_address
|
|
57
|
+
const user = site.site_user ?? sshUser
|
|
58
|
+
// Non-git sites are pulled over SSH, so they need a reachable server + user.
|
|
59
|
+
// Git sites clone straight from the repo and never touch the server.
|
|
60
|
+
if (!isGit && (!host || !user)) return { ok: false, error: "Missing site user or server IP — can't reach the site." }
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
ok: true,
|
|
64
|
+
plan: {
|
|
65
|
+
domain: site.domain,
|
|
66
|
+
isGit,
|
|
67
|
+
repoUrl: site.git?.repo ?? null,
|
|
68
|
+
user: user ?? "",
|
|
69
|
+
host: host ?? "",
|
|
70
|
+
port: server?.ssh_port ?? null,
|
|
71
|
+
filesRoot: `/sites/${site.domain}/files`,
|
|
72
|
+
publicFolder: site.public_folder,
|
|
73
|
+
destPath: dir,
|
|
74
|
+
destUrl: destUrl.trim(),
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type LocalSetupStage = "fetch" | "build" | "done" | "error"
|
|
80
|
+
|
|
81
|
+
export interface LocalSetupProgress {
|
|
82
|
+
stage: LocalSetupStage
|
|
83
|
+
domain: string
|
|
84
|
+
destPath?: string
|
|
85
|
+
error?: string
|
|
86
|
+
failedStage?: LocalSetupStage
|
|
87
|
+
ranComposer?: boolean
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isLocalSetupInFlight(p: LocalSetupProgress | undefined): boolean {
|
|
91
|
+
return p != null && p.stage !== "done" && p.stage !== "error"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// rsync the source's whole files root down, excluding uploads wherever they
|
|
95
|
+
// land (relative to the detected webroot, not assumed at the files root).
|
|
96
|
+
async function fetchByRsync(plan: LocalSetupPlan): Promise<{ code: number; stderr: string }> {
|
|
97
|
+
const target = `${plan.user}@${plan.host}`
|
|
98
|
+
const probeScript = `${detectWpDirScript(plan.filesRoot, plan.publicFolder ?? undefined)}; echo "D:$D"; echo "W:$W"`
|
|
99
|
+
const probe = await runProcess(["ssh", ...SSH_OPTS, ...sshPort(plan.port), target, probeScript], 30_000)
|
|
100
|
+
if (probe.code !== 0) return { code: probe.code, stderr: probe.stderr }
|
|
101
|
+
const d = probe.stdout.match(/^D:(.*)$/m)?.[1]?.trim()
|
|
102
|
+
const w = probe.stdout.match(/^W:(.*)$/m)?.[1]?.trim()
|
|
103
|
+
if (!d) return { code: 1, stderr: "couldn't determine the site's files root on the server." }
|
|
104
|
+
if (!w) return { code: 1, stderr: "couldn't find a WordPress install under this site on the server." }
|
|
105
|
+
|
|
106
|
+
// Paths relative to the files root we're about to rsync, e.g.
|
|
107
|
+
// "public/wp-content/uploads" when the webroot sits below the files root, or
|
|
108
|
+
// bare "wp-content/uploads" when it doesn't.
|
|
109
|
+
const webrootRel = w === d ? "" : w.slice(d.length).replace(/^\/+/, "")
|
|
110
|
+
const contentRel = (name: string) => (webrootRel ? `${webrootRel}/wp-content/${name}` : `wp-content/${name}`)
|
|
111
|
+
// object-cache.php (and advanced-cache.php, same family) are server drop-ins
|
|
112
|
+
// wired to production's Redis/disk cache — they don't just no-op without it,
|
|
113
|
+
// they fatal (confirmed: SpinupWP's object-cache drop-in 500s on a cache MISS
|
|
114
|
+
// when Redis is unreachable). Local dev doesn't want production caching
|
|
115
|
+
// anyway, so these are excluded outright rather than copied and hoping.
|
|
116
|
+
const excludes = ["uploads", "object-cache.php", "advanced-cache.php"].map((name) => `--exclude=${contentRel(name)}`)
|
|
117
|
+
|
|
118
|
+
const sshCmd = ["ssh", ...SSH_OPTS, ...sshPort(plan.port)].join(" ")
|
|
119
|
+
return runProcess(["rsync", "-az", "-e", sshCmd, ...excludes, `${target}:${d}/`, `${plan.destPath}/`], 600_000)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function runLocalSetup(plan: LocalSetupPlan, onProgress: (p: LocalSetupProgress) => void): Promise<LocalSetupProgress> {
|
|
123
|
+
const domain = plan.domain
|
|
124
|
+
const fail = (error: string, failedStage: LocalSetupStage = "fetch"): LocalSetupProgress => {
|
|
125
|
+
const p: LocalSetupProgress = { stage: "error", domain, error, failedStage }
|
|
126
|
+
onProgress(p)
|
|
127
|
+
return p
|
|
128
|
+
}
|
|
129
|
+
const stageFail = (label: string, stderr: string, fallback: string, failedStage: LocalSetupStage) =>
|
|
130
|
+
fail(`${label} — ${meaningfulError(stderr, fallback)}`, failedStage)
|
|
131
|
+
|
|
132
|
+
onProgress({ stage: "fetch", domain })
|
|
133
|
+
try {
|
|
134
|
+
mkdirSync(dirname(plan.destPath), { recursive: true })
|
|
135
|
+
} catch {
|
|
136
|
+
// best-effort — the actual clone/rsync surfaces a real error if this truly can't be created
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (plan.isGit) {
|
|
140
|
+
if (!plan.repoUrl) return fail("This site has no git repo configured in SpinupWP.", "fetch")
|
|
141
|
+
const cl = await runProcess(["git", "clone", plan.repoUrl, plan.destPath], 300_000)
|
|
142
|
+
if (cl.code !== 0) return stageFail("git clone failed", cl.stderr, `exit ${cl.code}.`, "fetch")
|
|
143
|
+
} else {
|
|
144
|
+
mkdirSync(plan.destPath, { recursive: true })
|
|
145
|
+
const rs = await fetchByRsync(plan)
|
|
146
|
+
if (rs.code !== 0) return stageFail("File pull failed", rs.stderr, `exit ${rs.code}.`, "fetch")
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Bedrock/Radicle needs `composer install` to build vendor/ + web/wp before
|
|
150
|
+
// anything (wp-cli included) can find a WordPress install in the checkout.
|
|
151
|
+
// Standard WP ships its core in the tree already, so this is a no-op there.
|
|
152
|
+
const kind = resolveLocalLink({ domain, path: plan.destPath, localUrl: plan.destUrl }).kind
|
|
153
|
+
let ranComposer = false
|
|
154
|
+
if (kind === "bedrock" || kind === "radicle") {
|
|
155
|
+
onProgress({ stage: "build", domain })
|
|
156
|
+
const ci = await runProcess(["bash", "-lc", "composer install"], 300_000, plan.destPath)
|
|
157
|
+
if (ci.code !== 0) return stageFail("composer install failed", ci.stderr, "composer error.", "build")
|
|
158
|
+
ranComposer = true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const done: LocalSetupProgress = { stage: "done", domain, destPath: plan.destPath, ranComposer }
|
|
162
|
+
onProgress(done)
|
|
163
|
+
return done
|
|
164
|
+
}
|
package/src/lib/open.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// Open a URL in the user's default browser, cross-platform. Best-effort; never throws.
|
|
2
2
|
|
|
3
|
+
import { spawn } from "./spawn.ts"
|
|
4
|
+
|
|
3
5
|
export function openUrl(url: string): void {
|
|
4
6
|
try {
|
|
5
7
|
const platform = process.platform
|
|
6
8
|
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"
|
|
7
9
|
const args = platform === "win32" ? ["/c", "start", "", url] : [url]
|
|
8
|
-
|
|
10
|
+
spawn([cmd, ...args], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
|
|
9
11
|
} catch {
|
|
10
12
|
// ignore — opening a browser is a convenience, not critical
|
|
11
13
|
}
|
|
@@ -18,7 +20,7 @@ export function copyToClipboard(text: string): void {
|
|
|
18
20
|
try {
|
|
19
21
|
const platform = process.platform
|
|
20
22
|
const cmd = platform === "darwin" ? ["pbcopy"] : platform === "win32" ? ["clip"] : ["xclip", "-selection", "clipboard"]
|
|
21
|
-
const proc =
|
|
23
|
+
const proc = spawn(cmd, { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
|
22
24
|
proc.stdin.write(text)
|
|
23
25
|
void proc.stdin.end()
|
|
24
26
|
} catch {
|
|
@@ -71,11 +73,11 @@ export function openSshSession(user: string, host: string, port?: number | null,
|
|
|
71
73
|
app === "iTerm"
|
|
72
74
|
? ["-e", `tell application "iTerm"\ncreate window with default profile command "${esc}"\nactivate\nend tell`]
|
|
73
75
|
: ["-e", `tell application "Terminal" to do script "${esc}"`, "-e", `tell application "Terminal" to activate`]
|
|
74
|
-
|
|
76
|
+
spawn(["osascript", ...args], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
|
|
75
77
|
} else if (platform === "win32") {
|
|
76
|
-
|
|
78
|
+
spawn(["cmd", "/c", "start", "cmd", "/k", cmd], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
|
|
77
79
|
} else {
|
|
78
|
-
|
|
80
|
+
spawn(["x-terminal-emulator", "-e", cmd], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
|
|
79
81
|
}
|
|
80
82
|
} catch {
|
|
81
83
|
// ignore — opening a shell is a convenience, not critical
|
|
@@ -90,12 +92,12 @@ export function openTerminalAt(dir: string, terminalApp?: string | null): void {
|
|
|
90
92
|
try {
|
|
91
93
|
const platform = process.platform
|
|
92
94
|
if (platform === "darwin") {
|
|
93
|
-
|
|
95
|
+
spawn(["open", "-a", resolveTerminalApp(terminalApp), dir], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
|
|
94
96
|
} else if (platform === "win32") {
|
|
95
|
-
|
|
97
|
+
spawn(["cmd", "/c", "start", "cmd", "/k", `cd /d ${dir}`], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
|
|
96
98
|
} else {
|
|
97
99
|
// Try a few common terminal emulators; the first that exists wins.
|
|
98
|
-
|
|
100
|
+
spawn(["x-terminal-emulator", "--working-directory", dir], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
|
|
99
101
|
}
|
|
100
102
|
} catch {
|
|
101
103
|
// ignore — opening a terminal is a convenience, not critical
|
package/src/lib/probe.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import type { Server, Site } from "../api/types.ts"
|
|
20
20
|
import { detectWpDirScript } from "./serverClone.ts"
|
|
21
21
|
import { theme } from "./theme.ts"
|
|
22
|
+
import { spawn } from "./spawn.ts"
|
|
22
23
|
|
|
23
24
|
export type ProbeKind = "wordpress" | "bedrock" | "radicle" | "whmcs" | "laravel" | "static" | "unknown"
|
|
24
25
|
|
|
@@ -117,7 +118,7 @@ export async function probeSite(
|
|
|
117
118
|
|
|
118
119
|
let proc: ReturnType<typeof Bun.spawn>
|
|
119
120
|
try {
|
|
120
|
-
proc =
|
|
121
|
+
proc = spawn(["ssh", ...SSH_OPTS, target, buildRemoteScript(site.domain, site.public_folder)], {
|
|
121
122
|
stdout: "pipe",
|
|
122
123
|
stderr: "pipe",
|
|
123
124
|
stdin: "ignore",
|
package/src/lib/serverClone.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import type { Server } from "../api/types.ts"
|
|
14
14
|
import { wpCliResolveScript } from "./wpCli.ts"
|
|
15
|
+
import { spawn } from "./spawn.ts"
|
|
15
16
|
|
|
16
17
|
const SSH_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-o", "StrictHostKeyChecking=accept-new"]
|
|
17
18
|
|
|
@@ -42,7 +43,7 @@ export async function sudoExec(
|
|
|
42
43
|
const ctrl = new AbortController()
|
|
43
44
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
|
|
44
45
|
try {
|
|
45
|
-
const proc =
|
|
46
|
+
const proc = spawn(["ssh", ...SSH_OPTS, ...portOpt, target, "sudo -S -p '' bash -s"], {
|
|
46
47
|
stdin: "pipe",
|
|
47
48
|
stdout: "pipe",
|
|
48
49
|
stderr: "pipe",
|
|
@@ -853,7 +854,7 @@ async function wpFacts(ctx: SudoCtx, root: string, user: string, phpVersion?: st
|
|
|
853
854
|
// `curl --resolve` checks). 2xx/3xx counts as serving.
|
|
854
855
|
async function curlStatus(domain: string, ip: string): Promise<string> {
|
|
855
856
|
try {
|
|
856
|
-
const proc =
|
|
857
|
+
const proc = spawn(
|
|
857
858
|
["curl", "-sS", "-o", "/dev/null", "-m", "20", "-w", "%{http_code}", "-L", "--resolve", `${domain}:80:${ip}`, "--resolve", `${domain}:443:${ip}`, `http://${domain}/`],
|
|
858
859
|
{ stdout: "pipe", stderr: "pipe" },
|
|
859
860
|
)
|
package/src/lib/spawn.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The one way this app starts a child process.
|
|
2
|
+
//
|
|
3
|
+
// Bun.spawn with no `env` option hands the child Bun's *startup* environment,
|
|
4
|
+
// ignoring anything deleted from process.env since — and so do Bun.spawnSync and
|
|
5
|
+
// node:child_process (all confirmed on Bun 1.3.14). That defeats the startup scrub
|
|
6
|
+
// of the launch directory's .env (see cwdDotenv.ts): a site checkout's DB_* values
|
|
7
|
+
// would still reach `wp`. Passing `env: process.env` does honor the deletions, so
|
|
8
|
+
// this wrapper defaults it. A caller's explicit `env` still wins.
|
|
9
|
+
//
|
|
10
|
+
// `bun run typecheck` fails on any direct Bun.spawn/spawnSync or child_process use
|
|
11
|
+
// outside this file, so new call sites can't quietly bypass it.
|
|
12
|
+
|
|
13
|
+
export const spawn = ((cmd: unknown, opts?: { env?: unknown }) => {
|
|
14
|
+
if (Array.isArray(cmd)) return Bun.spawn(cmd, { ...opts, env: opts?.env ?? process.env } as never)
|
|
15
|
+
const o = cmd as { env?: unknown }
|
|
16
|
+
return Bun.spawn({ ...o, env: o.env ?? process.env } as never)
|
|
17
|
+
}) as typeof Bun.spawn
|
package/src/lib/ssh.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs"
|
|
|
15
15
|
import { mkdir, chmod } from "node:fs/promises"
|
|
16
16
|
import { keysDir } from "../config.ts"
|
|
17
17
|
import type { Server, Site } from "../api/types.ts"
|
|
18
|
+
import { spawn } from "./spawn.ts"
|
|
18
19
|
|
|
19
20
|
export interface CpuCore {
|
|
20
21
|
idx: number
|
|
@@ -122,7 +123,7 @@ export async function fetchRebootInfo(
|
|
|
122
123
|
|
|
123
124
|
let proc: ReturnType<typeof Bun.spawn>
|
|
124
125
|
try {
|
|
125
|
-
proc =
|
|
126
|
+
proc = spawn(["ssh", ...SSH_OPTS, target, REBOOT_SCRIPT], { stdout: "pipe", stderr: "pipe", stdin: "ignore" })
|
|
126
127
|
} catch (err) {
|
|
127
128
|
return { ok: false, target, error: `Failed to launch ssh: ${(err as Error).message}` }
|
|
128
129
|
}
|
|
@@ -161,7 +162,7 @@ export async function fetchServerHealth(
|
|
|
161
162
|
|
|
162
163
|
let proc: ReturnType<typeof Bun.spawn>
|
|
163
164
|
try {
|
|
164
|
-
proc =
|
|
165
|
+
proc = spawn(["ssh", ...SSH_OPTS, target, REMOTE_SCRIPT], {
|
|
165
166
|
stdout: "pipe",
|
|
166
167
|
stderr: "pipe",
|
|
167
168
|
stdin: "ignore",
|
|
@@ -258,7 +259,7 @@ export async function ensureSpinupKey(): Promise<{ path: string; pub: string; co
|
|
|
258
259
|
return { path, pub: readFileSync(pub, "utf8").trim(), comment }
|
|
259
260
|
}
|
|
260
261
|
await mkdir(keysDir(), { recursive: true })
|
|
261
|
-
const proc =
|
|
262
|
+
const proc = spawn(
|
|
262
263
|
["ssh-keygen", "-t", "ed25519", "-N", "", "-C", comment, "-f", path],
|
|
263
264
|
{ stdout: "pipe", stderr: "pipe", stdin: "ignore" },
|
|
264
265
|
)
|
|
@@ -360,7 +361,7 @@ async function runSshStdin(
|
|
|
360
361
|
const portOpt = port && port !== 22 ? ["-p", String(port)] : []
|
|
361
362
|
let proc: ReturnType<typeof Bun.spawn>
|
|
362
363
|
try {
|
|
363
|
-
proc =
|
|
364
|
+
proc = spawn(["ssh", ...SSH_OPTS, ...portOpt, target, remoteCmd], {
|
|
364
365
|
stdout: "pipe",
|
|
365
366
|
stderr: "pipe",
|
|
366
367
|
stdin: stdinPayload == null ? "ignore" : "pipe",
|
|
@@ -576,7 +577,7 @@ export async function listPersonalKeys(): Promise<GrantableKey[]> {
|
|
|
576
577
|
|
|
577
578
|
// ssh-agent (keys the user has loaded). Best-effort; ignore if no agent.
|
|
578
579
|
try {
|
|
579
|
-
const proc =
|
|
580
|
+
const proc = spawn(["ssh-add", "-L"], { stdout: "pipe", stderr: "ignore", stdin: "ignore" })
|
|
580
581
|
const code = await proc.exited
|
|
581
582
|
if (code === 0) {
|
|
582
583
|
const out = await new Response(proc.stdout as ReadableStream<Uint8Array>).text()
|
package/src/lib/sshExec.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { resolveSshTargetInfo, type SshAccessReason, type SshAccessCandidate } f
|
|
|
12
12
|
import { SSH_OPTS } from "./probe.ts"
|
|
13
13
|
import { classifySshCommand } from "./sshExecPolicy.ts"
|
|
14
14
|
import { logSshExecAttempt } from "./sshExecLog.ts"
|
|
15
|
+
import { spawn } from "./spawn.ts"
|
|
15
16
|
|
|
16
17
|
export type SshExecReason = SshAccessReason | "command_denied"
|
|
17
18
|
|
|
@@ -44,8 +45,9 @@ export async function execSshCommand(
|
|
|
44
45
|
command: string,
|
|
45
46
|
client: SpinupWPClientLike,
|
|
46
47
|
cfg: AppConfig,
|
|
48
|
+
opts?: { server?: string | null },
|
|
47
49
|
): Promise<SshExecResult> {
|
|
48
|
-
const resolution = await resolveSshTargetInfo(domain, client, cfg)
|
|
50
|
+
const resolution = await resolveSshTargetInfo(domain, client, cfg, opts)
|
|
49
51
|
if (!resolution.ok) {
|
|
50
52
|
logSshExecAttempt({ domain, command, decision: "error", reason: resolution.result.reason, message: resolution.result.message })
|
|
51
53
|
return { ...resolution.result, command }
|
|
@@ -62,7 +64,7 @@ export async function execSshCommand(
|
|
|
62
64
|
const start = Date.now()
|
|
63
65
|
let proc: ReturnType<typeof Bun.spawn>
|
|
64
66
|
try {
|
|
65
|
-
proc =
|
|
67
|
+
proc = spawn(["ssh", ...SSH_OPTS, ...portOpt, sshTarget, command], {
|
|
66
68
|
stdout: "pipe",
|
|
67
69
|
stderr: "pipe",
|
|
68
70
|
stdin: "ignore",
|
package/src/lib/wpInventory.ts
CHANGED
|
@@ -12,6 +12,7 @@ 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
14
|
import { wpCliResolveScript } from "./wpCli.ts"
|
|
15
|
+
import { spawn } from "./spawn.ts"
|
|
15
16
|
|
|
16
17
|
// One plugin or theme, mirroring `wp {plugin,theme} list` columns.
|
|
17
18
|
export interface WpItem {
|
|
@@ -116,7 +117,7 @@ export async function fetchWpInventory(
|
|
|
116
117
|
|
|
117
118
|
let proc: ReturnType<typeof Bun.spawn>
|
|
118
119
|
try {
|
|
119
|
-
proc =
|
|
120
|
+
proc = spawn(["ssh", ...SSH_OPTS, ...sshPort(server.ssh_port ?? null), target, remote], {
|
|
120
121
|
stdout: "pipe",
|
|
121
122
|
stderr: "pipe",
|
|
122
123
|
stdin: "ignore",
|
package/src/ui/App.tsx
CHANGED
|
@@ -30,6 +30,7 @@ import { NewServer } from "./views/NewServer.tsx"
|
|
|
30
30
|
import { VanityNewSite } from "./views/VanityNewSite.tsx"
|
|
31
31
|
import { CloneWizard } from "./views/CloneWizard.tsx"
|
|
32
32
|
import { LocalLinkOverlay } from "./views/LocalLink.tsx"
|
|
33
|
+
import { LocalSetupOverlay } from "./views/LocalSetup.tsx"
|
|
33
34
|
import { Discover } from "./views/Discover.tsx"
|
|
34
35
|
import { Forgotten } from "./views/Forgotten.tsx"
|
|
35
36
|
import { DnsInventory } from "./views/DnsInventory.tsx"
|
|
@@ -75,6 +76,7 @@ export function App() {
|
|
|
75
76
|
store.vanityServer !== null ||
|
|
76
77
|
store.cloneServer !== null ||
|
|
77
78
|
store.localLinkSite !== null ||
|
|
79
|
+
store.localSetupSite !== null ||
|
|
78
80
|
store.discoverOpen ||
|
|
79
81
|
store.forgottenOpen ||
|
|
80
82
|
store.dnsInventoryServer !== null ||
|
|
@@ -166,6 +168,9 @@ export function App() {
|
|
|
166
168
|
// The local-link overlay owns the keyboard while open.
|
|
167
169
|
if (store.localLinkSite) return
|
|
168
170
|
|
|
171
|
+
// The new-local-copy (clone/rsync) overlay owns the keyboard while open.
|
|
172
|
+
if (store.localSetupSite) return
|
|
173
|
+
|
|
169
174
|
// The discovery overlay owns the keyboard while open.
|
|
170
175
|
if (store.discoverOpen) return
|
|
171
176
|
|
|
@@ -256,6 +261,7 @@ export function App() {
|
|
|
256
261
|
{store.vanityServer && <VanityNewSite />}
|
|
257
262
|
{store.cloneServer && <CloneWizard />}
|
|
258
263
|
{store.localLinkSite && <LocalLinkOverlay />}
|
|
264
|
+
{store.localSetupSite && <LocalSetupOverlay />}
|
|
259
265
|
{store.discoverOpen && <Discover />}
|
|
260
266
|
{store.forgottenOpen && <Forgotten />}
|
|
261
267
|
{store.dnsInventoryServer && <DnsInventory />}
|