spinuptui 0.19.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +943 -0
  2. package/LICENSE +21 -0
  3. package/README.md +856 -0
  4. package/bin/spinuptui.js +13 -0
  5. package/package.json +55 -0
  6. package/src/api/client.ts +354 -0
  7. package/src/api/types.ts +206 -0
  8. package/src/config.ts +309 -0
  9. package/src/dev/devMode.ts +7 -0
  10. package/src/dev/fixtures.ts +238 -0
  11. package/src/dev/mockClient.ts +214 -0
  12. package/src/index.tsx +68 -0
  13. package/src/lib/appUpdate.ts +206 -0
  14. package/src/lib/awsSigV4.ts +133 -0
  15. package/src/lib/cloneDomains.ts +112 -0
  16. package/src/lib/cloneLog.ts +85 -0
  17. package/src/lib/dbBackup.ts +198 -0
  18. package/src/lib/dbSync.ts +253 -0
  19. package/src/lib/discover.ts +165 -0
  20. package/src/lib/dns.ts +144 -0
  21. package/src/lib/dnsCache.ts +94 -0
  22. package/src/lib/dnsQuery.ts +180 -0
  23. package/src/lib/dnsRecords.ts +411 -0
  24. package/src/lib/format.ts +106 -0
  25. package/src/lib/gitDeployKey.ts +0 -0
  26. package/src/lib/gitStatus.ts +37 -0
  27. package/src/lib/jobs.ts +32 -0
  28. package/src/lib/keychain.ts +49 -0
  29. package/src/lib/local.ts +116 -0
  30. package/src/lib/mediaFallback.ts +288 -0
  31. package/src/lib/open.ts +103 -0
  32. package/src/lib/phpEol.ts +151 -0
  33. package/src/lib/probe.ts +183 -0
  34. package/src/lib/providers.ts +284 -0
  35. package/src/lib/providersCache.ts +105 -0
  36. package/src/lib/releaseNotes.ts +67 -0
  37. package/src/lib/serverClone.ts +782 -0
  38. package/src/lib/serverCreate.ts +161 -0
  39. package/src/lib/siteDoctor.ts +260 -0
  40. package/src/lib/siteFingerprint.ts +108 -0
  41. package/src/lib/spinupweb.ts +33 -0
  42. package/src/lib/ssh.ts +669 -0
  43. package/src/lib/stack.ts +78 -0
  44. package/src/lib/stackCache.ts +113 -0
  45. package/src/lib/theme.ts +54 -0
  46. package/src/lib/ubuntuEol.ts +124 -0
  47. package/src/lib/uptimeKuma.ts +551 -0
  48. package/src/lib/vanitySite.ts +297 -0
  49. package/src/lib/wpInventory.ts +153 -0
  50. package/src/ui/App.tsx +265 -0
  51. package/src/ui/Details.tsx +403 -0
  52. package/src/ui/Explain.tsx +158 -0
  53. package/src/ui/Header.tsx +117 -0
  54. package/src/ui/Help.tsx +296 -0
  55. package/src/ui/List.tsx +95 -0
  56. package/src/ui/Onboarding.tsx +93 -0
  57. package/src/ui/Splash.tsx +51 -0
  58. package/src/ui/StatusBar.tsx +60 -0
  59. package/src/ui/components.tsx +392 -0
  60. package/src/ui/store.tsx +4253 -0
  61. package/src/ui/views/Browser.tsx +365 -0
  62. package/src/ui/views/CloneWizard.tsx +932 -0
  63. package/src/ui/views/Dashboard.tsx +149 -0
  64. package/src/ui/views/DbBackup.tsx +228 -0
  65. package/src/ui/views/DbSync.tsx +307 -0
  66. package/src/ui/views/Discover.tsx +271 -0
  67. package/src/ui/views/DnsInventory.tsx +580 -0
  68. package/src/ui/views/DnsRecords.tsx +562 -0
  69. package/src/ui/views/Events.tsx +98 -0
  70. package/src/ui/views/Forgotten.tsx +165 -0
  71. package/src/ui/views/GrantKey.tsx +504 -0
  72. package/src/ui/views/Health.tsx +322 -0
  73. package/src/ui/views/HttpsToggle.tsx +195 -0
  74. package/src/ui/views/KumaSite.tsx +571 -0
  75. package/src/ui/views/LocalLink.tsx +220 -0
  76. package/src/ui/views/MediaFallback.tsx +180 -0
  77. package/src/ui/views/NewServer.tsx +750 -0
  78. package/src/ui/views/PhpUpgrade.tsx +301 -0
  79. package/src/ui/views/ProviderConnect.tsx +527 -0
  80. package/src/ui/views/PurgeCache.tsx +186 -0
  81. package/src/ui/views/ReleaseNotes.tsx +84 -0
  82. package/src/ui/views/Search.tsx +448 -0
  83. package/src/ui/views/ServerActions.tsx +328 -0
  84. package/src/ui/views/Stacks.tsx +371 -0
  85. package/src/ui/views/SudoConnect.tsx +358 -0
  86. package/src/ui/views/VanityNewSite.tsx +407 -0
  87. package/src/ui/views/WpInventory.tsx +218 -0
  88. package/src/version.ts +8 -0
@@ -0,0 +1,297 @@
1
+ // Vanity-site helpers: the embedded placeholder index.php, a site_user derivation,
2
+ // and the SSH push that seeds the page into a fresh site's docroot.
3
+ //
4
+ // The page is EMBEDDED here (not read from docs/vanity-site/index.php) so it ships
5
+ // with the installed binary — same location-independence lesson as config.json.
6
+ // docs/vanity-site/index.php is the design reference; keep the two in sync.
7
+
8
+ import { resolve4 } from "node:dns/promises"
9
+ import { SSH_OPTS, sshPort, runProcess, meaningfulError, remoteDocRoot } from "./dbBackup.ts"
10
+ import { pushUrl, LOAD_PUSH_SCALE } from "./uptimeKuma.ts"
11
+
12
+ // The vanity convention: the site living at the server's own hostname. Every
13
+ // feature that special-cases vanity sites (full monitoring, page re-seed) keys
14
+ // off this ONE predicate so they can't diverge.
15
+ export function isVanityPair(siteDomain: string, serverName: string): boolean {
16
+ return siteDomain === serverName
17
+ }
18
+
19
+ // Does the hostname now resolve to the server IP? Used to gate Let's Encrypt on the
20
+ // new A record having propagated. Uses the system resolver; a fresh subdomain has no
21
+ // prior cache, so this reflects the new record quickly once it's live.
22
+ export async function aRecordResolves(host: string, ip: string): Promise<boolean> {
23
+ try {
24
+ return (await resolve4(host)).includes(ip)
25
+ } catch {
26
+ return false
27
+ }
28
+ }
29
+
30
+ // Brand-neutral, auto light/dark, reads its hostname from the request. The
31
+ // <load>/<uptime> tags are machine-readable (WHMCS uptime checker parses them).
32
+ // Machine modes for monitoring tools (Uptime Kuma etc. — see docs/uptime-kuma.md):
33
+ // ?healthz → 200 "ok" / 503 "unhealthy: …" (plain text, no key)
34
+ // ?format=json[&key=] → metrics JSON; key required when one is baked in at
35
+ // seed time via the __SPINUP_HEALTH_KEY__ placeholder.
36
+ export const VANITY_INDEX_PHP = `<?php
37
+ // --- Server load (1-min average) -------------------------------------------
38
+ $load = @file_get_contents("/proc/loadavg");
39
+ $load = $load ? explode(' ', $load)[0] : '';
40
+ if (!$load && function_exists('exec')) {
41
+ $reguptime = trim(@exec("uptime"));
42
+ if ($reguptime && preg_match("/, *(\\d) (users?), .*: (.*), (.*), (.*)/", $reguptime, $m)) $load = $m[3];
43
+ }
44
+
45
+ // --- Uptime ----------------------------------------------------------------
46
+ $uptime_text = @file_get_contents("/proc/uptime");
47
+ $uptime = $uptime_text ? substr($uptime_text, 0, strpos($uptime_text, " ")) : '';
48
+ if (!$uptime && function_exists('shell_exec')) $uptime = @shell_exec("cut -d. -f1 /proc/uptime");
49
+ $days = floor($uptime / 60 / 60 / 24);
50
+ $hours = str_pad($uptime / 60 / 60 % 24, 2, "0", STR_PAD_LEFT);
51
+ $mins = str_pad($uptime / 60 % 60, 2, "0", STR_PAD_LEFT);
52
+ $secs = str_pad($uptime % 60, 2, "0", STR_PAD_LEFT);
53
+
54
+ // Reusable on any server: derive the name from the request host, not a literal.
55
+ $host = $_SERVER['HTTP_HOST'] ?? gethostname();
56
+
57
+ // --- Machine-readable modes (?healthz, ?format=json) ------------------------
58
+ // Substituted at seed time; empty means the JSON mode needs no key.
59
+ $health_key = '__SPINUP_HEALTH_KEY__';
60
+ if (isset($_GET['healthz']) || (($_GET['format'] ?? '') === 'json')) {
61
+ header('Cache-Control: no-store');
62
+ $cores = 1;
63
+ $cpuinfo = @file_get_contents('/proc/cpuinfo');
64
+ if ($cpuinfo && preg_match_all('/^processor\\s*:/m', $cpuinfo, $m)) $cores = max(1, count($m[0]));
65
+ // SpinupWP's FPM pool disables disk_*/exec (fatal even with @) but leaves
66
+ // shell_exec — guard with function_exists and fall back to df.
67
+ $disk_free_pct = null;
68
+ if (function_exists('disk_free_space') && function_exists('disk_total_space')) {
69
+ $dt = @disk_total_space(__DIR__);
70
+ $df = @disk_free_space(__DIR__);
71
+ if ($dt && $df !== false) $disk_free_pct = round($df / $dt * 100, 1);
72
+ } elseif (function_exists('shell_exec')) {
73
+ $df_out = @shell_exec('df -Pk ' . escapeshellarg(__DIR__) . ' 2>/dev/null');
74
+ if ($df_out && preg_match('/\\n\\S+\\s+(\\d+)\\s+\\d+\\s+(\\d+)\\s/', $df_out, $dm) && (int)$dm[1] > 0) {
75
+ $disk_free_pct = round((int)$dm[2] / (int)$dm[1] * 100, 1);
76
+ }
77
+ }
78
+ $mem_available_pct = null;
79
+ $meminfo = @file_get_contents('/proc/meminfo');
80
+ if ($meminfo && preg_match('/^MemTotal:\\s+(\\d+)/m', $meminfo, $mt)
81
+ && preg_match('/^MemAvailable:\\s+(\\d+)/m', $meminfo, $ma)
82
+ && (int)$mt[1] > 0) {
83
+ $mem_available_pct = round((int)$ma[1] / (int)$mt[1] * 100, 1);
84
+ }
85
+ // Unhealthy = CPU pressure (1-min load per core > 2) or low disk (< 10% free).
86
+ $reasons = [];
87
+ if ($load !== '' && ((float)$load / $cores) > 2) $reasons[] = 'load';
88
+ if ($disk_free_pct !== null && $disk_free_pct < 10) $reasons[] = 'disk';
89
+
90
+ // ?healthz: binary state only — safe without a key. A plain HTTP monitor
91
+ // (200-299 = up) gets threshold alerting for free via the 503.
92
+ if (isset($_GET['healthz'])) {
93
+ http_response_code($reasons ? 503 : 200);
94
+ header('Content-Type: text/plain; charset=utf-8');
95
+ echo $reasons ? 'unhealthy: ' . implode(' ', $reasons) : 'ok';
96
+ exit;
97
+ }
98
+ if ($health_key !== '' && !hash_equals($health_key, (string)($_GET['key'] ?? ''))) {
99
+ http_response_code(403);
100
+ header('Content-Type: application/json');
101
+ echo '{"error":"forbidden"}';
102
+ exit;
103
+ }
104
+ header('Content-Type: application/json');
105
+ echo json_encode([
106
+ 'host' => $host,
107
+ 'status' => $reasons ? 'unhealthy' : 'ok',
108
+ 'load_1m' => $load === '' ? null : (float)$load,
109
+ 'cores' => $cores,
110
+ 'uptime_s' => (int)$uptime,
111
+ 'disk_free_pct' => $disk_free_pct,
112
+ 'mem_available_pct' => $mem_available_pct,
113
+ 'php_version' => PHP_VERSION,
114
+ 'time' => gmdate('c'),
115
+ ]);
116
+ exit;
117
+ }
118
+ ?>
119
+ <!DOCTYPE html>
120
+ <html lang="en">
121
+ <head>
122
+ <meta charset="UTF-8">
123
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
124
+ <meta name="robots" content="noindex, nofollow">
125
+ <title><?= htmlspecialchars($host) ?></title>
126
+ <style>
127
+ :root {
128
+ --bg: #fafafa; --card: #ffffff; --line: #ececec;
129
+ --ink: #1b1b1f; --muted: #6b7280; --faint: #9ca3af;
130
+ --ok: #16a34a; --accent: #111827;
131
+ }
132
+ @media (prefers-color-scheme: dark) {
133
+ :root {
134
+ --bg: #0b0d10; --card: #14171c; --line: #232830;
135
+ --ink: #e8eaed; --muted: #9aa3af; --faint: #6b7280;
136
+ --ok: #34d399; --accent: #e8eaed;
137
+ }
138
+ }
139
+ * { box-sizing: border-box; }
140
+ body, html { height: 100%; margin: 0; }
141
+ body {
142
+ display: flex; align-items: center; justify-content: center;
143
+ background: var(--bg); color: var(--ink);
144
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
145
+ -webkit-font-smoothing: antialiased;
146
+ }
147
+ .card {
148
+ width: min(92vw, 440px);
149
+ padding: 40px 44px;
150
+ background: var(--card);
151
+ border: 1px solid var(--line);
152
+ border-radius: 14px;
153
+ }
154
+ /* Swap in a logo by setting a background-image here, or replace the dot. */
155
+ .status {
156
+ display: inline-flex; align-items: center; gap: 8px;
157
+ font-size: 12px; letter-spacing: .08em; text-transform: uppercase;
158
+ color: var(--muted);
159
+ }
160
+ .dot {
161
+ width: 9px; height: 9px; border-radius: 50%; background: var(--ok);
162
+ box-shadow: 0 0 0 0 var(--ok); animation: pulse 2.4s infinite;
163
+ }
164
+ @keyframes pulse {
165
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ok) 55%, transparent); }
166
+ 70% { box-shadow: 0 0 0 10px transparent; }
167
+ 100% { box-shadow: 0 0 0 0 transparent; }
168
+ }
169
+ @media (prefers-reduced-motion: reduce) { .dot { animation: none; } }
170
+ h1 {
171
+ margin: 18px 0 4px; font-size: 22px; font-weight: 600;
172
+ letter-spacing: -.01em; word-break: break-all;
173
+ }
174
+ .sub { margin: 0 0 28px; color: var(--muted); font-size: 14px; }
175
+ .metrics {
176
+ display: flex; gap: 14px; padding-top: 22px;
177
+ border-top: 1px solid var(--line);
178
+ }
179
+ .metric { flex: 1; }
180
+ .metric .k { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--faint); }
181
+ load, uptime {
182
+ display: block; margin-top: 5px;
183
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
184
+ font-size: 15px; color: var(--ink);
185
+ }
186
+ </style>
187
+ </head>
188
+ <body>
189
+ <main class="card">
190
+ <span class="status"><span class="dot"></span>Online</span>
191
+ <h1><?= htmlspecialchars($host) ?></h1>
192
+ <p class="sub">This server is up and running.</p>
193
+ <div class="metrics">
194
+ <div class="metric">
195
+ <div class="k">Uptime</div>
196
+ <?php echo "<uptime>{$days}d {$hours}:{$mins}:{$secs}</uptime>"; ?>
197
+ </div>
198
+ <div class="metric">
199
+ <div class="k">Load (1m)</div>
200
+ <?php echo "<load>" . htmlspecialchars($load) . "</load>"; ?>
201
+ </div>
202
+ </div>
203
+ </main>
204
+ </body>
205
+ </html>
206
+ `
207
+
208
+ // Derive a sane Linux site_user from a hostname. Assumes the apex is the last two
209
+ // labels (fine for .com etc.; user can edit the default). Letters/digits only,
210
+ // starts with a letter, capped at 32 chars (Linux username limit).
211
+ export function deriveSiteUser(hostname: string): string {
212
+ const labels = hostname.toLowerCase().trim().replace(/\.$/, "").split(".")
213
+ const sub = labels.length > 2 ? labels.slice(0, labels.length - 2) : [labels[0] ?? ""]
214
+ let u = sub.join("").replace(/[^a-z0-9]/g, "")
215
+ if (!u) u = "site"
216
+ if (!/^[a-z]/.test(u)) u = `s${u}`
217
+ return u.slice(0, 32)
218
+ }
219
+
220
+ export interface VanitySeedTarget {
221
+ host: string // server IP / hostname for SSH
222
+ user: string // site_user
223
+ port: number | null
224
+ domain: string // the site domain (for the docroot path)
225
+ publicFolder: string | null
226
+ // Baked into the page's ?format=json mode (letters/digits only — it lands inside
227
+ // a single-quoted PHP string). Empty/absent ⇒ the JSON mode needs no key.
228
+ healthKey?: string | null
229
+ }
230
+
231
+ // Push the embedded index.php into the site's docroot over SSH. Idempotent — safe
232
+ // to re-run on resume (it overwrites). base64-pipe avoids any quoting headaches.
233
+ export async function seedVanityIndex(t: VanitySeedTarget): Promise<{ ok: boolean; error?: string }> {
234
+ const docroot = remoteDocRoot(t.domain, t.publicFolder)
235
+ const php = VANITY_INDEX_PHP.replace("__SPINUP_HEALTH_KEY__", t.healthKey ?? "")
236
+ const b64 = Buffer.from(php, "utf8").toString("base64")
237
+ const remote = `mkdir -p '${docroot}' && printf '%s' '${b64}' | base64 -d > '${docroot}/index.php'`
238
+ const r = await runProcess(["ssh", ...SSH_OPTS, ...sshPort(t.port), `${t.user}@${t.host}`, remote], 60_000)
239
+ if (r.code !== 0) return { ok: false, error: meaningfulError(r.stderr, "Couldn't write index.php over SSH (is your key on the server yet?).") }
240
+ return { ok: true }
241
+ }
242
+
243
+ export interface PushCronTarget {
244
+ host: string // server IP / hostname for SSH
245
+ user: string // site_user (the cron lands in this user's crontab — no sudo needed)
246
+ port: number | null
247
+ kumaUrl: string // Uptime Kuma base URL
248
+ pushToken: string
249
+ // When set, a second marker-managed cron line feeds the server's Redis
250
+ // sentinel monitor (`{server} redis`): redis-cli ping → status=up/down.
251
+ // Unlike the load heartbeat (dead-man's switch), this line ACTIVELY reports
252
+ // down — Redis being dead while the server is fine is exactly the state it
253
+ // exists to catch. Server dead ⇒ both monitors go silent anyway.
254
+ redisToken?: string | null
255
+ }
256
+
257
+ const PUSH_CRON_MARKER = "spinup-kuma-push"
258
+ const REDIS_CRON_MARKER = "spinup-kuma-redis"
259
+
260
+ // Is Redis answerable from this box's shell? Run BEFORE creating the sentinel
261
+ // monitor: a server without redis-cli (or with auth in the way) would otherwise
262
+ // get a monitor that's red forever. Fail loudly at setup time instead.
263
+ export async function probeServerRedis(t: { host: string; user: string; port: number | null }): Promise<{ ok: boolean; error?: string }> {
264
+ const r = await runProcess(["ssh", ...SSH_OPTS, ...sshPort(t.port), `${t.user}@${t.host}`, `[ "$(redis-cli ping 2>/dev/null)" = "PONG" ]`], 30_000)
265
+ if (r.code === 0) return { ok: true }
266
+ return { ok: false, error: "redis-cli didn't answer PONG on the server — Redis sentinel skipped." }
267
+ }
268
+
269
+ // Install (or refresh) the once-a-minute heartbeat cron that feeds the Kuma push
270
+ // monitor: status=up plus the 1-min load in `ping`, which Kuma graphs. The beat
271
+ // stopping is the signal (dead-man's switch) — so the cron itself never reports
272
+ // "down"; it just goes quiet when the server/cron/egress dies.
273
+ //
274
+ // Load is sent ×LOAD_PUSH_SCALE as an INTEGER (1.64 → 164): some Kuma builds
275
+ // validate `ping` as an int and silently store null for floats (verified live).
276
+ // The store's Kuma poll divides by the same constant.
277
+ //
278
+ // Idempotent: a marker comment identifies our line, and the install strips any
279
+ // previous marked line before appending. NO `%` anywhere in the line — cron
280
+ // treats a bare % as newline (which also rules out awk printf here).
281
+ export async function seedVanityPushCron(t: PushCronTarget): Promise<{ ok: boolean; error?: string }> {
282
+ const push = pushUrl(t.kumaUrl, t.pushToken)
283
+ const lines = [`* * * * * curl -fsS --max-time 20 '${push}?status=up&msg=ok&ping='$(awk '{print int($1*${LOAD_PUSH_SCALE})}' /proc/loadavg) >/dev/null 2>&1 # ${PUSH_CRON_MARKER}`]
284
+ if (t.redisToken) {
285
+ const redisPush = pushUrl(t.kumaUrl, t.redisToken)
286
+ lines.push(`* * * * * s=down; [ "$(redis-cli ping 2>/dev/null)" = "PONG" ] && s=up; curl -fsS --max-time 20 "${redisPush}?status=$s&msg=redis-$s&ping=" >/dev/null 2>&1 # ${REDIS_CRON_MARKER}`)
287
+ }
288
+ // The lines contain quotes of both kinds once cron expands them, so ship them
289
+ // base64'd (same trick as the index.php seed) instead of fighting nesting.
290
+ // Both markers are always stripped first, so seeding WITHOUT a redisToken also
291
+ // removes a previously installed sentinel line (idempotent uninstall).
292
+ const b64 = Buffer.from(lines.join("\n") + "\n", "utf8").toString("base64")
293
+ const remote = `( crontab -l 2>/dev/null | grep -v -e '${PUSH_CRON_MARKER}' -e '${REDIS_CRON_MARKER}' ; printf '%s' '${b64}' | base64 -d ) | crontab -`
294
+ const r = await runProcess(["ssh", ...SSH_OPTS, ...sshPort(t.port), `${t.user}@${t.host}`, remote], 60_000)
295
+ if (r.code !== 0) return { ok: false, error: meaningfulError(r.stderr, "Couldn't install the heartbeat cron over SSH.") }
296
+ return { ok: true }
297
+ }
@@ -0,0 +1,153 @@
1
+ // A site's installed plugins & themes over SSH — the `wp plugin list` / `wp theme
2
+ // list` detail the API never exposes (it only counts pending updates). Strictly
3
+ // read-only: we shell out to the local `ssh` client as the site user (the same
4
+ // non-interactive, key-based auth the health view uses) and run wp-cli in the
5
+ // site's real WordPress directory.
6
+ //
7
+ // The WP dir is DETECTED, never assumed: `public_folder` is a setting, not a fact
8
+ // (see CLAUDE.md "WordPress layout rules"), so we reuse the clone wizard's
9
+ // wp-settings.php probe to locate core on both /public/ and root-webroot sites.
10
+
11
+ import type { Server, Site } from "../api/types.ts"
12
+ import { SSH_OPTS, sshPort } from "./dbBackup.ts"
13
+ import { detectWpDirScript } from "./serverClone.ts"
14
+
15
+ // One plugin or theme, mirroring `wp {plugin,theme} list` columns.
16
+ export interface WpItem {
17
+ name: string
18
+ status: string // active | inactive | must-use | dropin | parent | active-network
19
+ version: string
20
+ update: string // available | none | unavailable
21
+ updateVersion: string | null // the version an update would install, when available
22
+ }
23
+
24
+ export interface WpInventory {
25
+ plugins: WpItem[]
26
+ themes: WpItem[]
27
+ }
28
+
29
+ export type WpInventoryResult =
30
+ | { ok: true; target: string; wpDir: string; inventory: WpInventory }
31
+ | { ok: false; target: string; error: string }
32
+
33
+ // wp-cli's JSON fields (snake_case) → our WpItem.
34
+ interface RawItem {
35
+ name?: string
36
+ status?: string
37
+ version?: string
38
+ update?: string | boolean // wp emits `false` for must-use/dropin items
39
+ update_version?: string
40
+ }
41
+
42
+ function normalize(raw: RawItem[]): WpItem[] {
43
+ return raw.map((r) => ({
44
+ name: r.name ?? "(unknown)",
45
+ status: r.status ?? "",
46
+ version: r.version ?? "",
47
+ update: typeof r.update === "string" ? r.update : "none",
48
+ updateVersion: r.update_version && r.update_version !== "" ? r.update_version : null,
49
+ }))
50
+ }
51
+
52
+ // Split the batched remote output into its ===LABEL sections.
53
+ function splitSections(out: string): Record<string, string[]> {
54
+ const sections: Record<string, string[]> = {}
55
+ let current = ""
56
+ for (const rawLine of out.split("\n")) {
57
+ const line = rawLine.replace(/\r$/, "")
58
+ const m = line.match(/^===([A-Z]+)$/)
59
+ if (m) {
60
+ current = m[1]
61
+ sections[current] = []
62
+ } else if (current) {
63
+ sections[current].push(line)
64
+ }
65
+ }
66
+ return sections
67
+ }
68
+
69
+ // wp --format=json emits a single-line JSON array; tolerate blank/garbage by
70
+ // returning [] rather than throwing (a fresh site can legitimately have 0 themes).
71
+ function parseJsonArray(lines: string[] | undefined): RawItem[] {
72
+ const text = (lines ?? []).join("").trim()
73
+ if (!text) return []
74
+ try {
75
+ const parsed = JSON.parse(text)
76
+ return Array.isArray(parsed) ? parsed : []
77
+ } catch {
78
+ return []
79
+ }
80
+ }
81
+
82
+ export async function fetchWpInventory(
83
+ server: Server,
84
+ site: Site,
85
+ sshUser: string | null,
86
+ ): Promise<WpInventoryResult> {
87
+ const ip = server.ip_address
88
+ const user = site.site_user ?? sshUser
89
+ if (!ip) return { ok: false, target: "(no IP)", error: "Server has no IP address." }
90
+ if (!user) return { ok: false, target: ip, error: "Site has no site user — can't SSH in to read wp-cli." }
91
+ const target = `${user}@${ip}`
92
+ // We deliberately do NOT gate on site.is_wordpress: the API flag is unreliable
93
+ // (it's false for many git/Bedrock sites and for Standard-WP sites SpinupWP
94
+ // misclassified — e.g. a /public/ install imported as "Generic"). The remote
95
+ // wp-settings.php probe below is authoritative for both /public/ and Bedrock
96
+ // (web/wp) layouts, and returns a clean "no core found" error otherwise.
97
+ const root = `/sites/${site.domain}/files`
98
+ const fields = "name,status,version,update,update_version"
99
+ // Detect the WP dir, bail cleanly if there's no core, then list both from it.
100
+ const remote = [
101
+ detectWpDirScript(root, site.public_folder ?? undefined),
102
+ `if [ -z "$W" ]; then echo ===NOTWP; echo ===END; exit 0; fi`,
103
+ `WP=$(command -v wp 2>/dev/null || echo /usr/local/bin/wp)`,
104
+ `echo ===WPDIR; printf '%s\\n' "$W"`,
105
+ // The trailing `echo` after each wp command is load-bearing: `wp --format=json`
106
+ // emits NO trailing newline, so without it the next ===MARKER glues onto the end
107
+ // of the JSON line — the marker never parses and JSON.parse chokes on it.
108
+ `echo ===PLUGINS; "$WP" --path="$W" plugin list --fields=${fields} --format=json 2>/dev/null || true; echo`,
109
+ `echo ===THEMES; "$WP" --path="$W" theme list --fields=${fields} --format=json 2>/dev/null || true; echo`,
110
+ `echo ===END`,
111
+ ].join("\n")
112
+
113
+ let proc: ReturnType<typeof Bun.spawn>
114
+ try {
115
+ proc = Bun.spawn(["ssh", ...SSH_OPTS, ...sshPort(server.ssh_port ?? null), target, remote], {
116
+ stdout: "pipe",
117
+ stderr: "pipe",
118
+ stdin: "ignore",
119
+ })
120
+ } catch (err) {
121
+ return { ok: false, target, error: `Failed to launch ssh: ${(err as Error).message}` }
122
+ }
123
+ const timer = setTimeout(() => {
124
+ try {
125
+ proc.kill()
126
+ } catch {
127
+ /* already gone */
128
+ }
129
+ }, 45_000)
130
+ const code = await proc.exited
131
+ clearTimeout(timer)
132
+ const stdout = await new Response(proc.stdout as ReadableStream<Uint8Array>).text()
133
+ const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text()
134
+
135
+ if (code !== 0 && !stdout.includes("===END")) {
136
+ const reason = stderr.trim().split("\n").slice(-2).join(" ") || `ssh exited with code ${code}`
137
+ return { ok: false, target, error: reason }
138
+ }
139
+
140
+ const s = splitSections(stdout)
141
+ if ("NOTWP" in s) {
142
+ return { ok: false, target, error: `No WordPress core found under ${root} — is this a WordPress site?` }
143
+ }
144
+ const wpDir = (s.WPDIR?.[0] || root).trim()
145
+ const plugins = normalize(parseJsonArray(s.PLUGINS))
146
+ const themes = normalize(parseJsonArray(s.THEMES))
147
+ return { ok: true, target, wpDir, inventory: { plugins, themes } }
148
+ }
149
+
150
+ // Count of items with an update available — for section-header badges.
151
+ export function updateCount(items: WpItem[]): number {
152
+ return items.filter((i) => i.update === "available").length
153
+ }