spinuptui 0.26.0 → 0.27.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 +22 -1
- package/README.md +3 -3
- package/package.json +3 -2
- package/src/api/client.ts +38 -2
- package/src/index.tsx +3 -0
- package/src/lib/appUpdate.ts +2 -1
- package/src/lib/cliPull.ts +11 -6
- package/src/lib/cliSsh.ts +2 -1
- package/src/lib/cwdDotenv.ts +106 -0
- package/src/lib/dbBackup.ts +2 -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/open.ts +10 -8
- package/src/lib/probe.ts +18 -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 +2 -1
- package/src/lib/wpCore.ts +273 -0
- package/src/lib/wpInventory.ts +2 -1
- package/src/ui/App.tsx +8 -2
- package/src/ui/Explain.tsx +5 -4
- package/src/ui/Header.tsx +1 -1
- package/src/ui/Help.tsx +1 -1
- package/src/ui/ToastStack.tsx +2 -2
- package/src/ui/components.tsx +1 -1
- package/src/ui/store.tsx +24 -1
- package/src/ui/toast.ts +16 -10
- package/src/ui/views/Browser.tsx +2 -0
- package/src/ui/views/BulkWpUpdate.tsx +396 -0
- package/src/ui/views/PhpUpgrade.tsx +5 -2
- package/src/ui/views/Search.tsx +2 -0
- package/src/ui/views/SiteUpdate.tsx +124 -0
- package/src/ui/views/Stacks.tsx +83 -10
- package/src/ui/views/WpCoreUpdate.tsx +322 -0
- package/src/ui/wpCoreJobs.tsx +235 -0
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,25 @@ versions; such changes are called out here.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [0.27.0] - 2026-09-23
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **Update WordPress core from `u`.** `u` is now **Update**, a menu with two choices: PHP version, the flow you already know, and WordPress core. The WordPress option checks the site over SSH with wp-cli and lists the available versions, with minor (security) releases picked first. After you confirm, it runs `wp core update` and then the database upgrade. The update keeps running if you close the overlay, and a toast reports when it's done. Bedrock and Radicle sites are included; for those, the confirm screen names the version `composer.lock` pins, and warns that the next `composer install` will put that version back until you run `composer update`.
|
|
18
|
+
- **Stacks groups WordPress sites by core version.** Under Standard WP, Bedrock and Radicle, one indented row per WordPress version, newest first, shows how many sites run it (for example `└ WP 7.0.2 51`). The newest version in the fleet is highlighted green. Select a row to list its sites, which makes it easy to find the ones still waiting for a security release. `D` on a version row re-probes just those sites. Sites with no known version are grouped under `no version`, and the left pane now scrolls when the list is taller than the terminal.
|
|
19
|
+
- **Update WordPress on a whole version group.** In Stacks, `u` on a version row (`└ WP 7.0.2`) checks every site in that group live over SSH, then shows a plan you can edit. The default target is the newest release in each site's own line (the security hotfix), and `m` switches to the newest release overall. `space` leaves a site out, and Bedrock and Radicle sites are marked `⚙` because of the `composer.lock` caveat. After you confirm, a canary site updates alone first, and if it fails nothing else is touched. The rest follow up to 4 at a time, one per server, stopping after 3 failures; `x` stops starting new sites. It keeps running if you close the overlay, and a single toast reports the result.
|
|
20
|
+
- **Update progress shows on the site's row.** While a WordPress core update runs, the Stacks row replaces the version with a spinner and the target version (`⠹ → WP 7.1.2`). Servers and Search show a compact `WP` marker. If the update fails, the row keeps a `⬆!` marker until you open the result, and the toast that reports the failure is red instead of green.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **The WordPress version on a site's row now follows the Update check.** Stacks and Servers show the version from the last `d` probe, so it went stale as soon as core changed. Opening **Update → WordPress core** reads the live version over SSH, and that value, along with the result of any update, now replaces the stored one.
|
|
24
|
+
- **SpinupWP API errors now say why.** A rejected write used to show only "SpinupWP API error (HTTP 400)." The message now includes the reason from the API's response, such as which field it rejected.
|
|
25
|
+
|
|
26
|
+
## [0.26.1] - 2026-09-18
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- **`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).
|
|
30
|
+
- **`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.
|
|
31
|
+
- **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.
|
|
32
|
+
|
|
14
33
|
## [0.26.0] - 2026-08-26
|
|
15
34
|
|
|
16
35
|
### Added
|
|
@@ -1408,7 +1427,9 @@ Initial tagged release.
|
|
|
1408
1427
|
### Notes
|
|
1409
1428
|
- Read-only release: works with a SpinupWP **Read Only** API token.
|
|
1410
1429
|
|
|
1411
|
-
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.
|
|
1430
|
+
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.27.0...HEAD
|
|
1431
|
+
[0.27.0]: https://github.com/mwender/spinupwp-tui/compare/v0.26.1...v0.27.0
|
|
1432
|
+
[0.26.1]: https://github.com/mwender/spinupwp-tui/compare/v0.26.0...v0.26.1
|
|
1412
1433
|
[0.26.0]: https://github.com/mwender/spinupwp-tui/compare/v0.25.0...v0.26.0
|
|
1413
1434
|
[0.25.0]: https://github.com/mwender/spinupwp-tui/compare/v0.24.4...v0.25.0
|
|
1414
1435
|
[0.24.4]: https://github.com/mwender/spinupwp-tui/compare/v0.24.3...v0.24.4
|
package/README.md
CHANGED
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
pull a production DB into a linked local copy. → [docs/database-backup-sync.md](docs/database-backup-sync.md)
|
|
54
54
|
- **Production media fallback** (`m`, Servers / Search tabs) — serve missing-locally images
|
|
55
55
|
from production after a DB pull. → [docs/production-media-fallback.md](docs/production-media-fallback.md)
|
|
56
|
-
- **
|
|
57
|
-
|
|
56
|
+
- **Update a site** (`u`) — change its PHP version, or update WordPress core over
|
|
57
|
+
SSH (Standard WP, Bedrock and Radicle). → [docs/php-upgrade.md](docs/php-upgrade.md)
|
|
58
58
|
- **Enable / disable HTTPS** (`H`) — toggle a site's certificate.
|
|
59
59
|
- **Purge cache** (`P`) — clear page + object cache together.
|
|
60
60
|
- **Server actions** (`a`) — reboot or restart a service, with the real reason a
|
|
@@ -320,7 +320,7 @@ These can be set in `config.json` or via an environment variable:
|
|
|
320
320
|
| `D` | Identify every site in the selected stack (Stacks tab) |
|
|
321
321
|
| `S` | Auto-discover & batch-link local copies (Stacks tab) |
|
|
322
322
|
| `f` | Report sites with no usable local copy (Stacks tab only — `f` identifies a site's stack in Servers/Search, above) |
|
|
323
|
-
| `u` |
|
|
323
|
+
| `u` | Update a site: PHP version (needs a Read/Write token) or WordPress core (over SSH) (Servers / Stacks / Search) |
|
|
324
324
|
| `H` | Enable / disable HTTPS on a site (Servers / Stacks / Search; needs a Read/Write token) |
|
|
325
325
|
| `P` | Purge a site's page cache + object cache (Servers / Stacks / Search; needs a Read/Write token) |
|
|
326
326
|
| `M` | Site/server monitoring — a two-pane browser of this site's Uptime Kuma monitors (capital, since lowercase `m` is media fallback in Servers/Search). Inside: `↑`/`↓` selects a monitor, `a` registers/recalibrates/repairs it (Front page and Cache bypass open a check-window picker), `x` removes Front page or Cache bypass, `o` opens the selected monitor in Kuma, `d` runs the site doctor, `n` shows/edits alert wiring, and vanity sites add `R`/`r` for page refresh/secret rotation (Servers / Stacks / Search) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spinuptui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
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/api/client.ts
CHANGED
|
@@ -31,6 +31,42 @@ export class ApiError extends Error {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// Pull the human-readable reason out of a SpinupWP error body. The API answers
|
|
35
|
+
// validation failures Laravel-style — {"message": "...", "errors": {field: [..]}}
|
|
36
|
+
// — and a bare "HTTP 400" hides exactly the part the user needs (which field,
|
|
37
|
+
// and why). Returns "" when the body has nothing usable, so callers can fall
|
|
38
|
+
// back to the status-only message.
|
|
39
|
+
export function apiErrorDetail(body: string | undefined): string {
|
|
40
|
+
const text = (body ?? "").trim()
|
|
41
|
+
if (!text) return ""
|
|
42
|
+
let json: unknown
|
|
43
|
+
try {
|
|
44
|
+
json = JSON.parse(text)
|
|
45
|
+
} catch {
|
|
46
|
+
// Not JSON (an HTML error page, say) — a short plain-text body is still worth
|
|
47
|
+
// showing; anything long is noise.
|
|
48
|
+
return text.length <= 200 && !text.startsWith("<") ? text : ""
|
|
49
|
+
}
|
|
50
|
+
if (!json || typeof json !== "object") return ""
|
|
51
|
+
const j = json as { message?: unknown; error?: unknown; errors?: unknown }
|
|
52
|
+
const parts: string[] = []
|
|
53
|
+
const message = typeof j.message === "string" ? j.message : typeof j.error === "string" ? j.error : ""
|
|
54
|
+
if (j.errors && typeof j.errors === "object") {
|
|
55
|
+
for (const v of Object.values(j.errors as Record<string, unknown>)) {
|
|
56
|
+
for (const m of Array.isArray(v) ? v : [v]) if (typeof m === "string" && !parts.includes(m)) parts.push(m)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Laravel's top-level message usually just repeats the first field error
|
|
60
|
+
// ("The php version is invalid. (and 1 more error)") — prefer the field list.
|
|
61
|
+
if (parts.length) return parts.join(" ")
|
|
62
|
+
return message
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function httpErrorMessage(status: number, body: string): string {
|
|
66
|
+
const detail = apiErrorDetail(body)
|
|
67
|
+
return detail ? `SpinupWP API error (HTTP ${status}): ${detail}` : `SpinupWP API error (HTTP ${status}).`
|
|
68
|
+
}
|
|
69
|
+
|
|
34
70
|
export interface ClientOptions {
|
|
35
71
|
token: string
|
|
36
72
|
baseUrl: string
|
|
@@ -152,7 +188,7 @@ export class SpinupWPClient implements SpinupWPClientLike {
|
|
|
152
188
|
}
|
|
153
189
|
if (!res.ok) {
|
|
154
190
|
const body = await res.text().catch(() => "")
|
|
155
|
-
throw new ApiError(
|
|
191
|
+
throw new ApiError(httpErrorMessage(res.status, body), res.status, body)
|
|
156
192
|
}
|
|
157
193
|
|
|
158
194
|
return (await res.json()) as T
|
|
@@ -189,7 +225,7 @@ export class SpinupWPClient implements SpinupWPClientLike {
|
|
|
189
225
|
}
|
|
190
226
|
if (!res.ok) {
|
|
191
227
|
const errBody = await res.text().catch(() => "")
|
|
192
|
-
throw new ApiError(
|
|
228
|
+
throw new ApiError(httpErrorMessage(res.status, errBody), res.status, errBody)
|
|
193
229
|
}
|
|
194
230
|
|
|
195
231
|
// Some write endpoints (e.g. PHP upgrade) return a bare body, others 204.
|
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"
|
package/src/lib/appUpdate.ts
CHANGED
|
@@ -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 =
|
|
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
|
}
|
package/src/lib/cliPull.ts
CHANGED
|
@@ -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
|
-
//
|
|
355
|
-
// wp-config.php still
|
|
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
|
-
?
|
|
364
|
-
:
|
|
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 =
|
|
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()
|
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
|
|
|
@@ -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 =
|
|
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
|
}
|
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/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",
|
|
@@ -166,6 +167,22 @@ function parseSections(out: string): Record<string, string> {
|
|
|
166
167
|
return flat
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
// The same probe result with a newly learned WordPress core version, relabeled the
|
|
171
|
+
// way classify() labels it. Returns null for kinds that don't carry a WP version
|
|
172
|
+
// (a WHMCS/Laravel/static site's cached identity must never be rewritten).
|
|
173
|
+
export function withWpVersion(r: ProbeResult, version: string): ProbeResult | null {
|
|
174
|
+
switch (r.kind) {
|
|
175
|
+
case "wordpress":
|
|
176
|
+
return { ...r, version, label: `WordPress ${version}` }
|
|
177
|
+
case "bedrock":
|
|
178
|
+
return { ...r, version, label: `Bedrock · WP ${version}` }
|
|
179
|
+
case "radicle":
|
|
180
|
+
return { ...r, version, label: `Radicle · WP ${version}` }
|
|
181
|
+
default:
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
169
186
|
// Turn raw signals into a named stack. Order matters: name the distinctive apps
|
|
170
187
|
// (WHMCS, Bedrock, Laravel) before falling back to a generic WordPress version.
|
|
171
188
|
export function classify(sig: Record<string, string>): ProbeResult {
|
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
|
|
|
@@ -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 =
|
|
67
|
+
proc = spawn(["ssh", ...SSH_OPTS, ...portOpt, sshTarget, command], {
|
|
67
68
|
stdout: "pipe",
|
|
68
69
|
stderr: "pipe",
|
|
69
70
|
stdin: "ignore",
|