spinuptui 0.26.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 CHANGED
@@ -11,6 +11,13 @@ versions; such changes are called out here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.26.1] - 2026-09-18
15
+
16
+ ### Fixed
17
+ - **`pull db` (and every other local command) now works when spinuptui is launched from inside a site's checkout.** Bun auto-loads the current directory's `.env`, and child processes inherited it, so a Bedrock site's own `DB_*` values reached `wp` as environment variables. Bedrock then read blank database credentials, and the pre-import backup failed with `Access denied for user '<you>'@'localhost' (using password: NO)`. Keys from that `.env` are now dropped at startup, except the ones spinuptui reads itself (its token and Uptime Kuma settings; provider credentials are covered below).
18
+ - **`pull db`'s "Access denied" hint now points Bedrock and Radicle sites at their `.env`.** It used to blame `wp-config.php` for every site, but those stacks keep their database credentials in `.env`. The hint also mentions that a `DB_*` variable exported in the shell can override that file.
19
+ - **A site's `.env` can no longer supply spinuptui's DNS provider credentials.** Launched from inside a site checkout, spinuptui read that site's `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` (which Bedrock sites commonly use for S3 media) as its own Route 53 credentials, silently overriding the ones in config. Provider keys (`AWS_*`, `CLOUDFLARE_API_TOKEN`, `GODADDY_API_*`) from a `.env` are now honored only in spinuptui's own project directory; keys exported in the shell work from anywhere, as before.
20
+
14
21
  ## [0.26.0] - 2026-08-26
15
22
 
16
23
  ### Added
@@ -1408,7 +1415,8 @@ Initial tagged release.
1408
1415
  ### Notes
1409
1416
  - Read-only release: works with a SpinupWP **Read Only** API token.
1410
1417
 
1411
- [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.26.0...HEAD
1418
+ [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.26.1...HEAD
1419
+ [0.26.1]: https://github.com/mwender/spinupwp-tui/compare/v0.26.0...v0.26.1
1412
1420
  [0.26.0]: https://github.com/mwender/spinupwp-tui/compare/v0.25.0...v0.26.0
1413
1421
  [0.25.0]: https://github.com/mwender/spinupwp-tui/compare/v0.24.4...v0.25.0
1414
1422
  [0.24.4]: https://github.com/mwender/spinupwp-tui/compare/v0.24.3...v0.24.4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "0.26.0",
3
+ "version": "0.26.1",
4
4
  "description": "SpinupTUI — a terminal dashboard for browsing, monitoring, and managing your SpinupWP servers and sites.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,8 @@
24
24
  "scripts": {
25
25
  "dev": "bun run src/index.tsx",
26
26
  "start": "bun run src/index.tsx",
27
- "typecheck": "tsc --noEmit",
27
+ "typecheck": "tsc --noEmit && bun run check:spawn",
28
+ "check:spawn": "! grep -rnaE \"Bun\\\\.spawn(Sync)?\\\\(|child_process\" src --exclude=spawn.ts || (echo \"Use spawn() from src/lib/spawn.ts instead (see that file)\" >&2; exit 1)",
28
29
  "link-global": "bun link",
29
30
  "unlink-global": "bun unlink",
30
31
  "build:binary": "bun build src/index.tsx --compile --outfile spinuptui"
package/src/index.tsx CHANGED
@@ -3,6 +3,9 @@
3
3
  // OpenTUI renderer and mounts either the first-run onboarding wizard (no token
4
4
  // yet) or the main app wrapped in the data store.
5
5
 
6
+ // First, before any config read or spawn: keep a site checkout's .env from
7
+ // leaking into child processes (see the module).
8
+ import "./lib/cwdDotenv.ts"
6
9
  import { useState } from "react"
7
10
  import { createCliRenderer } from "@opentui/core"
8
11
  import { createRoot } from "@opentui/react"
@@ -11,6 +11,7 @@ import { join, sep } from "node:path"
11
11
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
12
12
  import { configDir } from "../config.ts"
13
13
  import { REPO_SLUG } from "../version.ts"
14
+ import { spawn } from "./spawn.ts"
14
15
 
15
16
  const CACHE_VERSION = 1
16
17
  const TTL_MS = 6 * 60 * 60 * 1000 // 6 hours
@@ -161,7 +162,7 @@ function checkoutRoot(): string {
161
162
  }
162
163
 
163
164
  async function git(cwd: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
164
- const proc = Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "pipe", stdin: "ignore" })
165
+ const proc = spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "pipe", stdin: "ignore" })
165
166
  const [stdout, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
166
167
  return { code, stdout: stdout.trim(), stderr: stderr.trim() }
167
168
  }
@@ -351,17 +351,22 @@ export async function runPullDb(
351
351
 
352
352
  if (result.stage === "error") {
353
353
  const error = result.error ?? "The sync failed."
354
- // The overwhelmingly likely cause right after `pull files`: the copied
355
- // wp-config.php still carries production's DB credentials, so local wp-cli
356
- // authenticates as the production user against the local MySQL.
354
+ // Standard WP: the overwhelmingly likely cause right after `pull files` is the
355
+ // copied wp-config.php still carrying production's DB credentials, so local
356
+ // wp-cli authenticates as the production user against the local MySQL.
357
+ // Bedrock/Radicle keep credentials in .env instead (never pulled), and a
358
+ // DB_* variable exported in the shell can shadow that file.
357
359
  const deniedAs = /access denied for user '([^']+)'/i.exec(error)?.[1]
360
+ const envBased = plan.localKind === "bedrock" || plan.localKind === "radicle"
358
361
  return {
359
362
  ...fail(
360
363
  "sync_failed",
361
364
  error,
362
- deniedAs
363
- ? `Local MySQL refused the user "${deniedAs}". If this copy came from \`pull files\`, its wp-config.php still holds production's database credentials — point DB_NAME / DB_USER / DB_PASSWORD / DB_HOST at your local database and re-run.`
364
- : undefined,
365
+ !deniedAs
366
+ ? undefined
367
+ : envBased
368
+ ? `Local MySQL refused the user "${deniedAs}". This is a ${plan.localKind} checkout, so its database credentials live in ${join(plan.localRoot, ".env")} — point DB_NAME / DB_USER / DB_PASSWORD / DB_HOST there at your local database and re-run. A DB_* variable exported in your shell can override that file, so check \`env | grep ^DB_\` too.`
369
+ : `Local MySQL refused the user "${deniedAs}". If this copy came from \`pull files\`, its wp-config.php still holds production's database credentials — point DB_NAME / DB_USER / DB_PASSWORD / DB_HOST at your local database and re-run.`,
365
370
  ),
366
371
  ...(result.failedStage ? { failedStage: result.failedStage } : {}),
367
372
  }
package/src/lib/cliSsh.ts CHANGED
@@ -9,6 +9,7 @@ import type { Server, Site } from "../api/types.ts"
9
9
  import type { SpinupWPClientLike } from "../api/client.ts"
10
10
  import { ApiError } from "../api/client.ts"
11
11
  import { resolveSiteSshTarget, SSH_OPTS } from "./probe.ts"
12
+ import { spawn } from "./spawn.ts"
12
13
 
13
14
  export type SshAccessReason =
14
15
  | "no_token"
@@ -181,7 +182,7 @@ export async function resolveSshAccess(
181
182
 
182
183
  let proc: ReturnType<typeof Bun.spawn>
183
184
  try {
184
- proc = Bun.spawn(["ssh", ...SSH_OPTS, ...portOpt, target, "true"], {
185
+ proc = spawn(["ssh", ...SSH_OPTS, ...portOpt, target, "true"], {
185
186
  stdout: "pipe",
186
187
  stderr: "pipe",
187
188
  stdin: "ignore",
@@ -0,0 +1,106 @@
1
+ // Keep the current directory's .env out of the processes we spawn.
2
+ //
3
+ // Bun auto-loads .env files from the directory it was launched in, and every
4
+ // spawned child inherits process.env. Run spinuptui from inside a site's checkout
5
+ // and that site's own .env — DB_NAME, DB_USER, DB_PASSWORD, WP_HOME — rides along
6
+ // into `bash -lc "wp …"`. A Bedrock config then reads *nothing*: its phpdotenv
7
+ // repository is immutable, so it skips keys that already exist in the
8
+ // environment, while `Env::$options` with USE_ENV_ARRAY reads only $_ENV — which
9
+ // PHP leaves empty for inherited variables when variables_order lacks "E" (the
10
+ // Homebrew default). Every DB constant comes out blank, and `wp db export` fails
11
+ // as "Access denied for user '<login name>'@'localhost' (using password: NO)".
12
+ //
13
+ // So: drop every key those files defined, except the ones this app reads itself —
14
+ // its token and provider credentials may legitimately live in a project-local
15
+ // .env (see config.ts). The provider keys have generic names a site's .env uses
16
+ // too (Bedrock keeps S3 media credentials under AWS_*), so those are only trusted
17
+ // from spinuptui's own project directory. Deleting from process.env only reaches children that are
18
+ // handed process.env explicitly, which is what src/lib/spawn.ts is for. Must run
19
+ // before anything reads config or spawns, which is why index.tsx imports this
20
+ // module first.
21
+
22
+ import { existsSync, readFileSync, realpathSync } from "node:fs"
23
+ import { join } from "node:path"
24
+
25
+ // Keys spinuptui reads from the environment on purpose. Only spinuptui uses the
26
+ // SPINUP* names, so they're kept from any directory's .env.
27
+ const OWN = /^SPINUP/
28
+
29
+ // Provider credentials spinuptui also reads (config.ts). Kept only from the .env
30
+ // in spinuptui's own project directory — anywhere else they're a site's keys.
31
+ const PROVIDER = /^(CLOUDFLARE_API_TOKEN|AWS_(ACCESS_KEY_ID|SECRET_ACCESS_KEY|REGION)|GODADDY_API_(KEY|SECRET))$/
32
+
33
+ // This file lives in <project>/src/lib.
34
+ const PROJECT_DIR = join(import.meta.dir, "..", "..")
35
+
36
+ function isProjectDir(dir: string): boolean {
37
+ try {
38
+ return realpathSync(dir) === realpathSync(PROJECT_DIR)
39
+ } catch {
40
+ return false
41
+ }
42
+ }
43
+
44
+ // Never removed, whatever a .env says: the spawned shells need these to work.
45
+ const ESSENTIAL = new Set(["PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "TERM", "LANG", "SSH_AUTH_SOCK", "XDG_CONFIG_HOME"])
46
+
47
+ // The files Bun loads, in increasing precedence: .env, .env.<NODE_ENV> (default
48
+ // development), then .env.local (skipped under test).
49
+ function loadedFiles(cwd: string): string[] {
50
+ const mode = process.env.NODE_ENV || "development"
51
+ const names = [".env", `.env.${mode}`]
52
+ if (mode !== "test") names.push(".env.local")
53
+ return names.map((n) => join(cwd, n)).filter((p) => existsSync(p))
54
+ }
55
+
56
+ // Each key with the value the file gives it, or null where that value can't be
57
+ // read with certainty (variable expansion, escapes, multi-line quotes). The value
58
+ // only matters for telling a .env key from a shell one; see scrubCwdDotenv.
59
+ export function dotenvEntries(text: string): Map<string, string | null> {
60
+ const out = new Map<string, string | null>()
61
+ for (const m of text.matchAll(/^[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t]*=(.*)$/gm)) {
62
+ out.set(m[1]!, dotenvValue(m[2]!.trim()))
63
+ }
64
+ return out
65
+ }
66
+
67
+ function dotenvValue(raw: string): string | null {
68
+ const q = raw[0]
69
+ if (q === "'" || q === "`" || q === '"') {
70
+ const end = raw.indexOf(q, 1)
71
+ if (end < 0) return null
72
+ const inner = raw.slice(1, end)
73
+ return q === '"' && /[$\\]/.test(inner) ? null : inner
74
+ }
75
+ const value = raw.replace(/\s+#.*$/, "").trim()
76
+ return value.includes("$") ? null : value
77
+ }
78
+
79
+ // Returns the keys removed, for tests and debugging.
80
+ export function scrubCwdDotenv(cwd = process.cwd()): string[] {
81
+ // Later files win, as they do in Bun.
82
+ const entries = new Map<string, string | null>()
83
+ for (const file of loadedFiles(cwd)) {
84
+ let text: string
85
+ try {
86
+ text = readFileSync(file, "utf8")
87
+ } catch {
88
+ continue
89
+ }
90
+ for (const [key, value] of dotenvEntries(text)) entries.set(key, value)
91
+ }
92
+ const ownDir = isProjectDir(cwd)
93
+ const removed: string[] = []
94
+ for (const [key, value] of entries) {
95
+ if (OWN.test(key) || (ownDir && PROVIDER.test(key)) || ESSENTIAL.has(key) || !(key in process.env)) continue
96
+ // Bun never overrides a variable the shell already set, so a value that differs
97
+ // from the file's came from the shell — keep it. When the file's value can't be
98
+ // read with certainty, assume it's the file's and drop it.
99
+ if (value !== null && process.env[key] !== value) continue
100
+ delete process.env[key]
101
+ removed.push(key)
102
+ }
103
+ return removed
104
+ }
105
+
106
+ scrubCwdDotenv()
@@ -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
 
@@ -114,7 +115,7 @@ export async function runProcess(
114
115
  ): Promise<{ code: number; stderr: string; stdout: string }> {
115
116
  let proc: ReturnType<typeof Bun.spawn>
116
117
  try {
117
- proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe", stdin: "ignore", cwd, env })
118
+ proc = spawn(cmd, { stdout: "pipe", stderr: "pipe", stdin: "ignore", cwd, env })
118
119
  } catch (err) {
119
120
  return { code: -1, stderr: `Failed to launch ${cmd[0]}: ${(err as Error).message}`, stdout: "" }
120
121
  }
Binary file
@@ -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 = Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "ignore", stdin: "ignore" })
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() }
@@ -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 = Bun.spawn(["security", ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe" })
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/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
- Bun.spawn([cmd, ...args], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
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 = Bun.spawn(cmd, { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
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
- Bun.spawn(["osascript", ...args], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
76
+ spawn(["osascript", ...args], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
75
77
  } else if (platform === "win32") {
76
- Bun.spawn(["cmd", "/c", "start", "cmd", "/k", cmd], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
78
+ spawn(["cmd", "/c", "start", "cmd", "/k", cmd], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
77
79
  } else {
78
- Bun.spawn(["x-terminal-emulator", "-e", cmd], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
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
- Bun.spawn(["open", "-a", resolveTerminalApp(terminalApp), dir], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
95
+ spawn(["open", "-a", resolveTerminalApp(terminalApp), dir], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
94
96
  } else if (platform === "win32") {
95
- Bun.spawn(["cmd", "/c", "start", "cmd", "/k", `cd /d ${dir}`], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
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
- Bun.spawn(["x-terminal-emulator", "--working-directory", dir], { stdout: "ignore", stderr: "ignore", stdin: "ignore" })
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 = Bun.spawn(["ssh", ...SSH_OPTS, target, buildRemoteScript(site.domain, site.public_folder)], {
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",
@@ -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 = Bun.spawn(["ssh", ...SSH_OPTS, ...portOpt, target, "sudo -S -p '' bash -s"], {
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 = Bun.spawn(
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
  )
@@ -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 = Bun.spawn(["ssh", ...SSH_OPTS, target, REBOOT_SCRIPT], { stdout: "pipe", stderr: "pipe", stdin: "ignore" })
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 = Bun.spawn(["ssh", ...SSH_OPTS, target, REMOTE_SCRIPT], {
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 = Bun.spawn(
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 = Bun.spawn(["ssh", ...SSH_OPTS, ...portOpt, target, remoteCmd], {
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 = Bun.spawn(["ssh-add", "-L"], { stdout: "pipe", stderr: "ignore", stdin: "ignore" })
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()
@@ -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
 
@@ -63,7 +64,7 @@ export async function execSshCommand(
63
64
  const start = Date.now()
64
65
  let proc: ReturnType<typeof Bun.spawn>
65
66
  try {
66
- proc = Bun.spawn(["ssh", ...SSH_OPTS, ...portOpt, sshTarget, command], {
67
+ proc = spawn(["ssh", ...SSH_OPTS, ...portOpt, sshTarget, command], {
67
68
  stdout: "pipe",
68
69
  stderr: "pipe",
69
70
  stdin: "ignore",
@@ -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 = Bun.spawn(["ssh", ...SSH_OPTS, ...sshPort(server.ssh_port ?? null), target, remote], {
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",