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,551 @@
1
+ // Thin Uptime Kuma client over its socket.io management API (Kuma exposes no REST
2
+ // for management — only the push/badge/status-page endpoints). Event names and
3
+ // payloads verified against the Kuma 2.x source; everything used here exists in
4
+ // 1.21+ as well. Connections are short-lived: connect → login → work → disconnect.
5
+ //
6
+ // Auth: password login returns a JWT; we hand it back so callers can persist it
7
+ // (config.json, 0600) and reconnect via loginByToken — the JWT stays valid until
8
+ // the Kuma password changes, and it's what lets a 2FA-protected account work
9
+ // beyond its first (token-prompted) login.
10
+
11
+ import { io, type Socket } from "socket.io-client"
12
+ import type { KumaMonitorRef } from "../config.ts"
13
+
14
+ // The push cron sends 1-min load ×this as an INTEGER (some Kuma builds validate
15
+ // `ping` as an int and silently store null for floats — verified live). Every
16
+ // encoder (the cron line) and decoder (the store's status poll) shares this one
17
+ // constant so the unit contract can't drift.
18
+ export const LOAD_PUSH_SCALE = 100
19
+
20
+ export interface KumaCreds {
21
+ url: string
22
+ username: string
23
+ password: string
24
+ jwt?: string // from a prior successful login; preferred when present
25
+ }
26
+
27
+ // A monitor row as Kuma broadcasts it (monitorList / getMonitor). Only the fields
28
+ // we read are typed; the rest ride along untyped.
29
+ export interface KumaMonitor {
30
+ id: number
31
+ name: string
32
+ type: string
33
+ url?: string
34
+ active?: boolean | number
35
+ pushToken?: string
36
+ [k: string]: unknown
37
+ }
38
+
39
+ export interface KumaBeat {
40
+ status: number // 0 down, 1 up, 2 pending, 3 maintenance
41
+ time: string
42
+ msg?: string
43
+ ping?: number | null
44
+ }
45
+
46
+ // A notification provider as configured in Kuma (Settings → Notifications) —
47
+ // e.g. a Telegram bot. Kuma keeps the display name inside the config JSON.
48
+ export interface KumaNotificationInfo {
49
+ id: number
50
+ name: string
51
+ active: boolean
52
+ isDefault: boolean // Kuma auto-attaches these to monitors its UI creates (we mirror that in addMonitor)
53
+ }
54
+
55
+ interface Ack {
56
+ ok: boolean
57
+ msg?: string
58
+ monitorID?: number
59
+ maintenanceID?: number
60
+ token?: string
61
+ tokenRequired?: boolean
62
+ monitor?: KumaMonitor // getMonitor's payload
63
+ data?: unknown
64
+ }
65
+
66
+ const ACK_TIMEOUT_MS = 15_000
67
+ const CONNECT_TIMEOUT_MS = 10_000
68
+
69
+ // 32 chars, [A-Za-z0-9] — same shape the Kuma UI generates for push monitors.
70
+ // (Kuma stores whatever the client sends; the token is chosen client-side.)
71
+ export function genPushToken(): string {
72
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
73
+ const bytes = crypto.getRandomValues(new Uint8Array(32))
74
+ return [...bytes].map((b) => chars[b % chars.length]).join("")
75
+ }
76
+
77
+ export function pushUrl(baseUrl: string, token: string): string {
78
+ return `${baseUrl.replace(/\/+$/, "")}/api/push/${token}`
79
+ }
80
+
81
+ export class KumaError extends Error {}
82
+
83
+ export class KumaClient {
84
+ private socket: Socket
85
+ version: string | null = null
86
+ // Kuma pushes these as events (not ack payloads) right after login and on change.
87
+ private monitorList: Record<string, KumaMonitor> | null = null
88
+ private notificationList: { id: number; isDefault?: boolean; active?: boolean; config?: string }[] = []
89
+ // notificationList is pushed in the post-login burst (there's no request event
90
+ // for it) — track arrival so getNotifications can tell "none configured" apart
91
+ // from "hasn't landed yet".
92
+ private notificationsArrived = false
93
+
94
+ // Kuma pushes each monitor's recent beats + uptime percentages as events right
95
+ // after login (it's how its own dashboard populates) — capture them so one
96
+ // short-lived connection yields a full status snapshot with no extra queries.
97
+ private heartbeats = new Map<number, KumaBeat[]>()
98
+ private uptimes = new Map<string, number>() // "<monitorId>:<period>" → percent
99
+
100
+ private constructor(socket: Socket) {
101
+ this.socket = socket
102
+ socket.on("info", (info: { version?: string }) => {
103
+ this.version = info?.version ?? null
104
+ })
105
+ socket.on("monitorList", (list: Record<string, KumaMonitor>) => {
106
+ this.monitorList = list
107
+ })
108
+ socket.on("notificationList", (list: typeof this.notificationList) => {
109
+ this.notificationList = list ?? []
110
+ this.notificationsArrived = true
111
+ })
112
+ socket.on("heartbeatList", (monitorID: number | string, beats: KumaBeat[], overwrite?: boolean) => {
113
+ const id = Number(monitorID)
114
+ const prev = overwrite ? [] : (this.heartbeats.get(id) ?? [])
115
+ // Cap the retained history: the UI reads the last ~40 beats, and an
116
+ // uncapped append would grow without bound on a long-lived connection.
117
+ this.heartbeats.set(id, [...prev, ...(beats ?? [])].slice(-100))
118
+ })
119
+ socket.on("uptime", (monitorID: number | string, period: number | string, percent: number) => {
120
+ this.uptimes.set(`${Number(monitorID)}:${period}`, percent)
121
+ })
122
+ }
123
+
124
+ beatsFor(monitorId: number): KumaBeat[] {
125
+ return this.heartbeats.get(monitorId) ?? []
126
+ }
127
+
128
+ uptimeFor(monitorId: number, period: number): number | null {
129
+ return this.uptimes.get(`${monitorId}:${period}`) ?? null
130
+ }
131
+
132
+ // The post-login event burst (one heartbeatList per monitor) arrives over ~a
133
+ // second. Wait until we've heard from `expected` monitors (not just one — a
134
+ // single early arrival is a partial snapshot) or the deadline passes.
135
+ async waitForSnapshot(expected: number, ms = 1500): Promise<void> {
136
+ const deadline = Date.now() + ms
137
+ while (this.heartbeats.size < Math.max(1, expected) && Date.now() < deadline) {
138
+ await new Promise((r) => setTimeout(r, 100))
139
+ }
140
+ }
141
+
142
+ // `info` (with the version) can land after the login ack; wait briefly so the
143
+ // connect flow can report a real version instead of racing it.
144
+ async waitForVersion(ms = 1200): Promise<void> {
145
+ if (this.version) return
146
+ await new Promise<void>((resolve) => {
147
+ const t = setTimeout(resolve, ms)
148
+ this.socket.once("info", () => {
149
+ clearTimeout(t)
150
+ resolve()
151
+ })
152
+ })
153
+ }
154
+
155
+ static async connect(url: string): Promise<KumaClient> {
156
+ const base = url.replace(/\/+$/, "")
157
+ const socket = io(base, { transports: ["websocket"], reconnection: false, timeout: CONNECT_TIMEOUT_MS })
158
+ const client = new KumaClient(socket)
159
+ await new Promise<void>((resolve, reject) => {
160
+ const t = setTimeout(() => {
161
+ socket.disconnect()
162
+ reject(new KumaError(`Timed out reaching ${base} — is that the right URL?`))
163
+ }, CONNECT_TIMEOUT_MS)
164
+ socket.once("connect", () => {
165
+ clearTimeout(t)
166
+ resolve()
167
+ })
168
+ socket.once("connect_error", (e: Error) => {
169
+ clearTimeout(t)
170
+ socket.disconnect()
171
+ reject(new KumaError(`Couldn't reach Uptime Kuma at ${base}: ${e.message}`))
172
+ })
173
+ })
174
+ return client
175
+ }
176
+
177
+ disconnect(): void {
178
+ this.socket.disconnect()
179
+ }
180
+
181
+ private emitAck(event: string, ...args: unknown[]): Promise<Ack> {
182
+ return new Promise((resolve, reject) => {
183
+ const t = setTimeout(() => reject(new KumaError(`Kuma didn't answer "${event}" in time.`)), ACK_TIMEOUT_MS)
184
+ this.socket.emit(event, ...args, (res: Ack) => {
185
+ clearTimeout(t)
186
+ resolve(res ?? { ok: false, msg: "Empty response" })
187
+ })
188
+ })
189
+ }
190
+
191
+ private async must(event: string, ...args: unknown[]): Promise<Ack> {
192
+ const res = await this.emitAck(event, ...args)
193
+ if (!res.ok) throw new KumaError(res.msg || `Kuma rejected "${event}".`)
194
+ return res
195
+ }
196
+
197
+ // Login, preferring a saved JWT. Returns the JWT to persist on success (the
198
+ // password path mints one; the JWT path echoes it back). `tokenRequired` means
199
+ // the account has 2FA — retry with a current TOTP in `twofa`.
200
+ async login(creds: KumaCreds, twofa?: string): Promise<{ ok: true; jwt: string } | { ok: false; error: string; tokenRequired?: boolean }> {
201
+ if (creds.jwt) {
202
+ const byToken = await this.emitAck("loginByToken", creds.jwt)
203
+ if (byToken.ok) return { ok: true, jwt: creds.jwt }
204
+ // Stale JWT (password changed / server reset) — fall through to password.
205
+ }
206
+ const res = await this.emitAck("login", { username: creds.username, password: creds.password, token: twofa ?? "" })
207
+ if (res.ok && res.token) return { ok: true, jwt: res.token }
208
+ if (res.tokenRequired) return { ok: false, error: "This account has 2FA — enter a current code to log in.", tokenRequired: true }
209
+ return { ok: false, error: res.msg || "Uptime Kuma rejected the login." }
210
+ }
211
+
212
+ // The monitor list arrives as a broadcast event (login triggers one); asking via
213
+ // getMonitorList re-triggers it. Await whichever lands first.
214
+ async getMonitors(): Promise<KumaMonitor[]> {
215
+ if (!this.monitorList) {
216
+ const wait = new Promise<void>((resolve, reject) => {
217
+ const t = setTimeout(() => reject(new KumaError("Kuma didn't send the monitor list in time.")), ACK_TIMEOUT_MS)
218
+ this.socket.once("monitorList", () => {
219
+ clearTimeout(t)
220
+ resolve()
221
+ })
222
+ })
223
+ // If the ack below rejects first, `wait` is never awaited — pre-attach a
224
+ // no-op handler so its own later timeout can't become an unhandled
225
+ // rejection (the `await wait` path still observes the real error).
226
+ wait.catch(() => {})
227
+ await this.emitAck("getMonitorList")
228
+ if (!this.monitorList) await wait
229
+ }
230
+ return Object.values(this.monitorList ?? {})
231
+ }
232
+
233
+ // The notification providers configured in Kuma, with display names parsed out
234
+ // of the config JSON. Waits briefly for the post-login burst so an immediate
235
+ // call after login doesn't misread "not arrived yet" as "none configured".
236
+ async getNotifications(ms = 2000): Promise<KumaNotificationInfo[]> {
237
+ const deadline = Date.now() + ms
238
+ while (!this.notificationsArrived && Date.now() < deadline) {
239
+ await new Promise((r) => setTimeout(r, 100))
240
+ }
241
+ return this.notificationList.map((n) => {
242
+ let name = `notification #${n.id}`
243
+ let isDefault = !!n.isDefault
244
+ try {
245
+ const cfg = n.config ? (JSON.parse(n.config) as { name?: string; isDefault?: boolean }) : {}
246
+ if (cfg.name) name = cfg.name
247
+ if (cfg.isDefault) isDefault = true
248
+ } catch {
249
+ // Unparseable config — keep the fallback name.
250
+ }
251
+ return { id: n.id, name, active: n.active !== false, isDefault }
252
+ })
253
+ }
254
+
255
+ // Notifications flagged "Default enabled" in Kuma — the UI auto-attaches these
256
+ // to new monitors, so we do the same for monitors we create.
257
+ private defaultNotificationIds(): Record<string, boolean> {
258
+ const ids: Record<string, boolean> = {}
259
+ for (const n of this.notificationList) {
260
+ try {
261
+ const cfg = n.config ? (JSON.parse(n.config) as { isDefault?: boolean }) : {}
262
+ if (n.isDefault || cfg.isDefault) ids[String(n.id)] = true
263
+ } catch {
264
+ // Unparseable config — skip.
265
+ }
266
+ }
267
+ return ids
268
+ }
269
+
270
+ // Kuma 2.x monitors carry a `conditions` array its UI always sends; 1.x has no
271
+ // such column and the insert fails if we send one. Try the 2.x shape first and
272
+ // fall back, so one client speaks to either generation.
273
+ async addMonitor(monitor: Record<string, unknown>): Promise<number> {
274
+ const payload = { notificationIDList: this.defaultNotificationIds(), conditions: [], ...monitor }
275
+ let res = await this.emitAck("add", payload)
276
+ if (!res.ok && /conditions/i.test(res.msg ?? "")) {
277
+ const { conditions: _legacy, ...withoutConditions } = payload
278
+ res = await this.emitAck("add", withoutConditions)
279
+ }
280
+ if (!res.ok) throw new KumaError(res.msg || 'Kuma rejected "add".')
281
+ if (typeof res.monitorID !== "number") throw new KumaError("Kuma didn't return the new monitor's id.")
282
+ return res.monitorID
283
+ }
284
+
285
+ // The full monitor row (getMonitor returns more fields than the monitorList
286
+ // broadcast) — the shape editMonitor expects back, so rotate flows round-trip it.
287
+ async getMonitor(id: number): Promise<KumaMonitor> {
288
+ const res = await this.must("getMonitor", id)
289
+ if (!res.monitor) throw new KumaError("Kuma didn't return the monitor.")
290
+ return res.monitor
291
+ }
292
+
293
+ // Save changes to an existing monitor IN PLACE — same id, so heartbeat history,
294
+ // uptime stats and notification wiring all survive. Callers pass a full monitor
295
+ // object from getMonitor (Kuma's own edit dialog does the same round-trip).
296
+ async editMonitor(monitor: Record<string, unknown>): Promise<void> {
297
+ await this.must("editMonitor", monitor)
298
+ }
299
+
300
+ async pauseMonitor(id: number): Promise<void> {
301
+ await this.must("pauseMonitor", id)
302
+ }
303
+
304
+ async resumeMonitor(id: number): Promise<void> {
305
+ await this.must("resumeMonitor", id)
306
+ }
307
+
308
+ async deleteMonitor(id: number): Promise<void> {
309
+ await this.must("deleteMonitor", id)
310
+ }
311
+
312
+ async getMonitorBeats(id: number, periodHours: number): Promise<KumaBeat[]> {
313
+ const res = await this.must("getMonitorBeats", id, periodHours)
314
+ return (res.data as KumaBeat[]) ?? []
315
+ }
316
+
317
+ // Manual-strategy maintenance: active immediately, until deleted/paused.
318
+ async addManualMaintenance(title: string, description: string): Promise<number> {
319
+ const res = await this.must("addMaintenance", {
320
+ title,
321
+ description,
322
+ strategy: "manual",
323
+ active: true,
324
+ intervalDay: 1,
325
+ dateRange: [null, null],
326
+ timeRange: [
327
+ { hours: 0, minutes: 0 },
328
+ { hours: 0, minutes: 0 },
329
+ ],
330
+ weekdays: [],
331
+ daysOfMonth: [],
332
+ })
333
+ if (typeof res.maintenanceID !== "number") throw new KumaError("Kuma didn't return the maintenance id.")
334
+ return res.maintenanceID
335
+ }
336
+
337
+ async setMaintenanceMonitors(maintenanceID: number, monitorIds: number[]): Promise<void> {
338
+ await this.must(
339
+ "addMonitorMaintenance",
340
+ maintenanceID,
341
+ monitorIds.map((id) => ({ id })),
342
+ )
343
+ }
344
+
345
+ async deleteMaintenance(maintenanceID: number): Promise<void> {
346
+ await this.must("deleteMaintenance", maintenanceID)
347
+ }
348
+ }
349
+
350
+ // Standard payloads for the two monitors Spinup creates per vanity site. Kuma's
351
+ // `add` handler tolerates missing columns, but we send what its own UI sends so
352
+ // the rows are indistinguishable from hand-made ones.
353
+ export function healthMonitorPayload(name: string, url: string): Record<string, unknown> {
354
+ return {
355
+ type: "http",
356
+ name,
357
+ url,
358
+ method: "GET",
359
+ interval: 60,
360
+ retryInterval: 60,
361
+ resendInterval: 0,
362
+ maxretries: 2, // ride out a brief load spike before alerting
363
+ timeout: 48,
364
+ accepted_statuscodes: ["200-299"],
365
+ expiryNotification: true, // LE-renewal canary for the whole server
366
+ ignoreTls: false,
367
+ upsideDown: false,
368
+ maxredirects: 10,
369
+ }
370
+ }
371
+
372
+ export function loadPushMonitorPayload(name: string, pushToken: string): Record<string, unknown> {
373
+ return {
374
+ type: "push",
375
+ name,
376
+ pushToken,
377
+ description: "1-min load average ×100 (integer), pushed by SpinupTUI's heartbeat cron. 164 = load 1.64.",
378
+ // The cron beats once a minute; alert after ~2 missed beats.
379
+ interval: 60,
380
+ retryInterval: 60,
381
+ maxretries: 2,
382
+ upsideDown: false,
383
+ // Kuma validates status-code arrays on every monitor type, push included.
384
+ accepted_statuscodes: ["200-299"],
385
+ }
386
+ }
387
+
388
+ // The server-wide Redis sentinel: the same heartbeat cron runs `redis-cli ping`
389
+ // once a minute and pushes status=up/down here. One per SERVER (Redis is one
390
+ // daemon per box), not per site — the per-site silent-fallback story is the
391
+ // Phase-3b health endpoint's job.
392
+ export function redisPushMonitorPayload(name: string, pushToken: string): Record<string, unknown> {
393
+ return {
394
+ type: "push",
395
+ name,
396
+ pushToken,
397
+ description: "Server-wide Redis sentinel: SpinupTUI's heartbeat cron pings Redis every minute and reports up/down. Silence = the server (not Redis) is the problem.",
398
+ interval: 60,
399
+ retryInterval: 60,
400
+ maxretries: 2,
401
+ upsideDown: false,
402
+ accepted_statuscodes: ["200-299"],
403
+ }
404
+ }
405
+
406
+ // The front-page fingerprint monitor (site monitoring Phase 1): a `keyword`
407
+ // monitor asserting the front page still carries the template-identity string
408
+ // calibration derived (lib/siteFingerprint.ts). Catches "the page cache is
409
+ // serving the wrong template" — a failure plain up/down monitors sleep through
410
+ // (the real incidents answered 200 throughout). `interval` is the user-chosen
411
+ // check window; the fetch is served straight from nginx's page cache, so even
412
+ // the tightest window costs the site nothing.
413
+ export function fingerprintMonitorPayload(name: string, url: string, keyword: string, interval: number): Record<string, unknown> {
414
+ return {
415
+ type: "keyword",
416
+ name,
417
+ url,
418
+ keyword,
419
+ method: "GET",
420
+ interval,
421
+ retryInterval: 60, // once suspect, confirm quickly regardless of the window
422
+ resendInterval: 0,
423
+ maxretries: 2, // two confirming failures before alerting — ride out one flaky fetch
424
+ timeout: 48,
425
+ accepted_statuscodes: ["200-299"],
426
+ expiryNotification: false, // the plain health monitor already owns cert expiry
427
+ ignoreTls: false,
428
+ upsideDown: false,
429
+ maxredirects: 10,
430
+ }
431
+ }
432
+
433
+ // Adopt-or-EDIT-or-create the fingerprint monitor for a domain. Unlike
434
+ // registerMonitors (adopt-or-create only), an existing row is edited in place:
435
+ // recalibrating after a redesign updates keyword/interval while the monitor id,
436
+ // heartbeat history and notification wiring all survive (same philosophy as
437
+ // rotatePushToken).
438
+ export async function registerFingerprintMonitor(
439
+ kuma: KumaClient,
440
+ domain: string,
441
+ opts: { proto: "http" | "https"; keyword: string; interval: number; knownId?: number | null },
442
+ ): Promise<number> {
443
+ const name = `${domain} front page`
444
+ const url = `${opts.proto}://${domain}/`
445
+ const monitors = await kuma.getMonitors()
446
+ const byId = new Map(monitors.map((m) => [m.id, m]))
447
+ const existingId = (opts.knownId != null && byId.has(opts.knownId) ? opts.knownId : undefined) ?? monitors.find((m) => m.name === name)?.id
448
+ if (existingId != null) {
449
+ const full = await kuma.getMonitor(existingId)
450
+ await kuma.editMonitor({ ...full, type: "keyword", url, keyword: opts.keyword, interval: opts.interval })
451
+ return existingId
452
+ }
453
+ return kuma.addMonitor(fingerprintMonitorPayload(name, url, opts.keyword, opts.interval))
454
+ }
455
+
456
+ // Adopt-or-create the monitors for one domain — THE single implementation shared
457
+ // by the vanity wizard's monitor step and the `m` overlay's add/repair, so the
458
+ // two paths cannot drift. Rules:
459
+ // - A recorded id is trusted only if the monitor still EXISTS in Kuma (deleted
460
+ // over there ⇒ re-create, don't silently no-op).
461
+ // - Same-named monitors are adopted rather than duplicated; an adopted push
462
+ // monitor keeps its own token so the cron feeds the right one.
463
+ // - The two creates are independent acks — run them in parallel.
464
+ export async function registerMonitors(
465
+ kuma: KumaClient,
466
+ domain: string,
467
+ opts: { proto: "http" | "https"; healthzPath: string; wantPush: boolean; wantRedis?: boolean; known?: KumaMonitorRef | null; pushToken?: string | null },
468
+ ): Promise<{ healthId: number; pushId?: number; pushToken?: string; redisId?: number; redisToken?: string }> {
469
+ const monitors = await kuma.getMonitors()
470
+ const byId = new Map(monitors.map((m) => [m.id, m]))
471
+ const byName = new Map(monitors.map((m) => [m.name, m]))
472
+ const known = opts.known ?? {}
473
+ const live = (id?: number) => (id != null && byId.has(id) ? id : undefined)
474
+
475
+ let pushToken = opts.pushToken ?? known.pushToken ?? genPushToken()
476
+ const existingPush = opts.wantPush && live(known.pushId) == null ? byName.get(`${domain} load`) : undefined
477
+ if (existingPush?.pushToken) pushToken = existingPush.pushToken
478
+
479
+ // The Redis sentinel gets its OWN token — the cron feeds two distinct push
480
+ // URLs so the monitors alert independently.
481
+ let redisToken = known.redisToken ?? genPushToken()
482
+ const existingRedis = opts.wantRedis && live(known.redisId) == null ? byName.get(`${domain} redis`) : undefined
483
+ if (existingRedis?.pushToken) redisToken = existingRedis.pushToken
484
+
485
+ const [healthId, pushId, redisId] = await Promise.all([
486
+ (async () => live(known.healthId) ?? byName.get(domain)?.id ?? (await kuma.addMonitor(healthMonitorPayload(domain, `${opts.proto}://${domain}${opts.healthzPath}`))))(),
487
+ (async () => {
488
+ if (!opts.wantPush) return undefined
489
+ return live(known.pushId) ?? existingPush?.id ?? (await kuma.addMonitor(loadPushMonitorPayload(`${domain} load`, pushToken)))
490
+ })(),
491
+ (async () => {
492
+ if (!opts.wantRedis) return undefined
493
+ return live(known.redisId) ?? existingRedis?.id ?? (await kuma.addMonitor(redisPushMonitorPayload(`${domain} redis`, redisToken)))
494
+ })(),
495
+ ])
496
+ return {
497
+ healthId,
498
+ pushId,
499
+ pushToken: opts.wantPush ? pushToken : undefined,
500
+ redisId,
501
+ redisToken: opts.wantRedis ? redisToken : undefined,
502
+ }
503
+ }
504
+
505
+ // Attach/detach one notification provider on a set of monitors, editing each
506
+ // row in place (getMonitor → editMonitor, Kuma's own edit-dialog round-trip) so
507
+ // history, tags and the rest of the notification wiring survive untouched.
508
+ export async function setMonitorNotifications(kuma: KumaClient, monitorIds: number[], providerId: number, on: boolean): Promise<void> {
509
+ for (const id of monitorIds) {
510
+ const full = await kuma.getMonitor(id)
511
+ const list = { ...((full.notificationIDList as Record<string, boolean> | undefined) ?? {}), [String(providerId)]: on }
512
+ await kuma.editMonitor({ ...full, notificationIDList: list })
513
+ }
514
+ }
515
+
516
+ // Swap a push monitor's token in place — the old push URL stops accepting beats
517
+ // the moment this lands, but the monitor row (and its history) is untouched.
518
+ export async function rotatePushToken(kuma: KumaClient, pushId: number, newToken: string): Promise<void> {
519
+ const mon = await kuma.getMonitor(pushId)
520
+ await kuma.editMonitor({ ...mon, pushToken: newToken })
521
+ }
522
+
523
+ // Repoint any monitor whose URL embeds the old health key (the JSON-query recipe
524
+ // in docs/uptime-kuma.md bakes it into the query string) at the new key, so a
525
+ // key rotation doesn't silently kill hand-made threshold monitors. Returns how
526
+ // many were updated. Keys shorter than 8 chars are refused — too much risk of
527
+ // matching an unrelated URL fragment.
528
+ export async function retargetHealthKeyMonitors(kuma: KumaClient, oldKey: string, newKey: string): Promise<number> {
529
+ if (oldKey.length < 8) return 0
530
+ const monitors = await kuma.getMonitors()
531
+ const hits = monitors.filter((m) => typeof m.url === "string" && m.url.includes(oldKey))
532
+ for (const m of hits) {
533
+ const full = await kuma.getMonitor(m.id)
534
+ await kuma.editMonitor({ ...full, url: String(full.url).split(oldKey).join(newKey) })
535
+ }
536
+ return hits.length
537
+ }
538
+
539
+ // Connect → login → run → always disconnect. Returns the (possibly refreshed) JWT
540
+ // alongside the result so callers can persist it.
541
+ export async function withKuma<T>(creds: KumaCreds, fn: (kuma: KumaClient) => Promise<T>): Promise<{ result: T; jwt: string; version: string | null }> {
542
+ const kuma = await KumaClient.connect(creds.url)
543
+ try {
544
+ const login = await kuma.login(creds)
545
+ if (!login.ok) throw new KumaError(login.error)
546
+ const result = await fn(kuma)
547
+ return { result, jwt: login.jwt, version: kuma.version }
548
+ } finally {
549
+ kuma.disconnect()
550
+ }
551
+ }