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.
- package/CHANGELOG.md +943 -0
- package/LICENSE +21 -0
- package/README.md +856 -0
- package/bin/spinuptui.js +13 -0
- package/package.json +55 -0
- package/src/api/client.ts +354 -0
- package/src/api/types.ts +206 -0
- package/src/config.ts +309 -0
- package/src/dev/devMode.ts +7 -0
- package/src/dev/fixtures.ts +238 -0
- package/src/dev/mockClient.ts +214 -0
- package/src/index.tsx +68 -0
- package/src/lib/appUpdate.ts +206 -0
- package/src/lib/awsSigV4.ts +133 -0
- package/src/lib/cloneDomains.ts +112 -0
- package/src/lib/cloneLog.ts +85 -0
- package/src/lib/dbBackup.ts +198 -0
- package/src/lib/dbSync.ts +253 -0
- package/src/lib/discover.ts +165 -0
- package/src/lib/dns.ts +144 -0
- package/src/lib/dnsCache.ts +94 -0
- package/src/lib/dnsQuery.ts +180 -0
- package/src/lib/dnsRecords.ts +411 -0
- package/src/lib/format.ts +106 -0
- package/src/lib/gitDeployKey.ts +0 -0
- package/src/lib/gitStatus.ts +37 -0
- package/src/lib/jobs.ts +32 -0
- package/src/lib/keychain.ts +49 -0
- package/src/lib/local.ts +116 -0
- package/src/lib/mediaFallback.ts +288 -0
- package/src/lib/open.ts +103 -0
- package/src/lib/phpEol.ts +151 -0
- package/src/lib/probe.ts +183 -0
- package/src/lib/providers.ts +284 -0
- package/src/lib/providersCache.ts +105 -0
- package/src/lib/releaseNotes.ts +67 -0
- package/src/lib/serverClone.ts +782 -0
- package/src/lib/serverCreate.ts +161 -0
- package/src/lib/siteDoctor.ts +260 -0
- package/src/lib/siteFingerprint.ts +108 -0
- package/src/lib/spinupweb.ts +33 -0
- package/src/lib/ssh.ts +669 -0
- package/src/lib/stack.ts +78 -0
- package/src/lib/stackCache.ts +113 -0
- package/src/lib/theme.ts +54 -0
- package/src/lib/ubuntuEol.ts +124 -0
- package/src/lib/uptimeKuma.ts +551 -0
- package/src/lib/vanitySite.ts +297 -0
- package/src/lib/wpInventory.ts +153 -0
- package/src/ui/App.tsx +265 -0
- package/src/ui/Details.tsx +403 -0
- package/src/ui/Explain.tsx +158 -0
- package/src/ui/Header.tsx +117 -0
- package/src/ui/Help.tsx +296 -0
- package/src/ui/List.tsx +95 -0
- package/src/ui/Onboarding.tsx +93 -0
- package/src/ui/Splash.tsx +51 -0
- package/src/ui/StatusBar.tsx +60 -0
- package/src/ui/components.tsx +392 -0
- package/src/ui/store.tsx +4253 -0
- package/src/ui/views/Browser.tsx +365 -0
- package/src/ui/views/CloneWizard.tsx +932 -0
- package/src/ui/views/Dashboard.tsx +149 -0
- package/src/ui/views/DbBackup.tsx +228 -0
- package/src/ui/views/DbSync.tsx +307 -0
- package/src/ui/views/Discover.tsx +271 -0
- package/src/ui/views/DnsInventory.tsx +580 -0
- package/src/ui/views/DnsRecords.tsx +562 -0
- package/src/ui/views/Events.tsx +98 -0
- package/src/ui/views/Forgotten.tsx +165 -0
- package/src/ui/views/GrantKey.tsx +504 -0
- package/src/ui/views/Health.tsx +322 -0
- package/src/ui/views/HttpsToggle.tsx +195 -0
- package/src/ui/views/KumaSite.tsx +571 -0
- package/src/ui/views/LocalLink.tsx +220 -0
- package/src/ui/views/MediaFallback.tsx +180 -0
- package/src/ui/views/NewServer.tsx +750 -0
- package/src/ui/views/PhpUpgrade.tsx +301 -0
- package/src/ui/views/ProviderConnect.tsx +527 -0
- package/src/ui/views/PurgeCache.tsx +186 -0
- package/src/ui/views/ReleaseNotes.tsx +84 -0
- package/src/ui/views/Search.tsx +448 -0
- package/src/ui/views/ServerActions.tsx +328 -0
- package/src/ui/views/Stacks.tsx +371 -0
- package/src/ui/views/SudoConnect.tsx +358 -0
- package/src/ui/views/VanityNewSite.tsx +407 -0
- package/src/ui/views/WpInventory.tsx +218 -0
- 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
|
+
}
|