spinuptui 1.3.0-alpha.1 → 1.3.0-alpha.3
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 +32 -1
- package/README.md +13 -3
- package/package.json +1 -1
- package/src/config.ts +25 -0
- package/src/index.tsx +26 -38
- package/src/lib/cliHome.ts +107 -0
- package/src/lib/cliScheduler.ts +630 -0
- package/src/lib/flock.ts +51 -0
- package/src/lib/heartbeat.test.ts +86 -0
- package/src/lib/heartbeat.ts +245 -0
- package/src/lib/home.test.ts +102 -0
- package/src/lib/home.ts +442 -0
- package/src/lib/schedule.ts +156 -0
- package/src/lib/scheduler.test.ts +353 -0
- package/src/lib/schedulerGit.ts +119 -0
- package/src/lib/schedulerInstall.ts +325 -0
- package/src/lib/schedulerJobs.ts +290 -0
- package/src/lib/schedulerRun.ts +247 -0
- package/src/lib/schedulerTick.ts +322 -0
- package/src/lib/spinupweb.test.ts +22 -0
- package/src/lib/spinupweb.ts +11 -0
- package/src/lib/stackCache.test.ts +43 -0
- package/src/lib/stackCache.ts +35 -1
- package/src/modules/beacon.ts +2 -1
- package/src/modules/contract.ts +22 -1
- package/src/modules/index.ts +2 -7
- package/src/modules/scheduler.ts +159 -0
- package/src/ui/App.tsx +19 -1
- package/src/ui/Details.tsx +7 -1
- package/src/ui/Onboarding.tsx +71 -3
- package/src/ui/main.tsx +42 -0
- package/src/ui/store.tsx +87 -3
- package/src/ui/views/AccountSlugPrompt.tsx +102 -0
- package/src/ui/views/Browser.tsx +5 -5
- package/src/ui/views/HeartbeatSetup.tsx +284 -0
- package/src/ui/views/HomeSync.tsx +299 -0
- package/src/ui/views/Modules.tsx +69 -5
- package/src/ui/views/SchedulerInstall.tsx +172 -0
- package/src/ui/views/Search.tsx +5 -6
- package/src/ui/views/ServerActions.tsx +6 -4
- package/src/ui/views/Stacks.tsx +22 -9
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import type { AppConfig } from "../config.ts"
|
|
3
|
+
import { chooseNotifications, pagerProblem, parsePushUrl, pingUrl, sendHeartbeat, stationMonitorPayload, tickHeartbeat } from "./heartbeat.ts"
|
|
4
|
+
|
|
5
|
+
const PUSH = "https://kuma.example.com/api/push/AbC123?status=up&msg=OK&ping="
|
|
6
|
+
|
|
7
|
+
describe("push URLs", () => {
|
|
8
|
+
test("accepts Kuma's push URL with or without its query", () => {
|
|
9
|
+
expect(parsePushUrl(PUSH)?.toString()).toBe("https://kuma.example.com/api/push/AbC123")
|
|
10
|
+
expect(parsePushUrl("https://kuma.example.com/api/push/AbC123")).not.toBeNull()
|
|
11
|
+
expect(parsePushUrl("https://kuma.example.com/dashboard/12")).toBeNull()
|
|
12
|
+
expect(parsePushUrl("ftp://kuma.example.com/api/push/x")).toBeNull()
|
|
13
|
+
expect(parsePushUrl("not a url")).toBeNull()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test("each ping sets status, msg and ping", () => {
|
|
17
|
+
const u = new URL(pingUrl(PUSH, "down", "pager broken", 1234.6))
|
|
18
|
+
expect(u.searchParams.get("status")).toBe("down")
|
|
19
|
+
expect(u.searchParams.get("msg")).toBe("pager broken")
|
|
20
|
+
expect(u.searchParams.get("ping")).toBe("1235")
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe("what a tick reports", () => {
|
|
25
|
+
test("up, with what ran and the first problem", () => {
|
|
26
|
+
expect(tickHeartbeat(0, [], null)).toEqual({ status: "up", msg: "tick ok, nothing due" })
|
|
27
|
+
expect(tickHeartbeat(2, ["git push failed"], null)).toEqual({ status: "up", msg: "tick ok, 2 jobs ran; 1 problem: git push failed" })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test("down when the pager is broken", () => {
|
|
31
|
+
expect(tickHeartbeat(1, [], "Beacon's last page failed (HTTP 401)").status).toBe("down")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("pagerProblem: no channel, or the last page failed", () => {
|
|
35
|
+
const none = { beacon: null } as unknown as AppConfig
|
|
36
|
+
const one = { beacon: { defaultChannel: "pager", channels: { pager: { type: "telegram", botToken: "t", chatId: "1" } } } } as unknown as AppConfig
|
|
37
|
+
const entry = (ok: boolean) => ({ at: "", channel: "pager", ok, parts: 1, chars: 1, preview: "", ...(ok ? {} : { error: "HTTP 401" }) })
|
|
38
|
+
expect(pagerProblem(none, null)).toMatch(/isn't set up/)
|
|
39
|
+
expect(pagerProblem(one, null)).toBeNull()
|
|
40
|
+
expect(pagerProblem(one, entry(true))).toBeNull()
|
|
41
|
+
expect(pagerProblem(one, entry(false))).toMatch(/HTTP 401/)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe("sendHeartbeat against a fake Kuma", () => {
|
|
46
|
+
test("ok, Kuma's refusal, and an unreachable host", async () => {
|
|
47
|
+
const seen: URL[] = []
|
|
48
|
+
const server = Bun.serve({
|
|
49
|
+
port: 0,
|
|
50
|
+
fetch(req) {
|
|
51
|
+
const u = new URL(req.url)
|
|
52
|
+
seen.push(u)
|
|
53
|
+
return Response.json(u.pathname.endsWith("/good") ? { ok: true } : { ok: false, msg: "Monitor not found or not active." })
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
try {
|
|
57
|
+
const base = `http://127.0.0.1:${server.port}/api/push`
|
|
58
|
+
expect(await sendHeartbeat(`${base}/good`, "up", "tick ok", 10)).toEqual({ ok: true, status: "up", msg: "tick ok" })
|
|
59
|
+
expect(seen[0]!.searchParams.get("status")).toBe("up")
|
|
60
|
+
const bad = await sendHeartbeat(`${base}/gone`, "up", "tick ok")
|
|
61
|
+
expect(bad.ok).toBe(false)
|
|
62
|
+
expect(bad.error).toBe("Monitor not found or not active.")
|
|
63
|
+
} finally {
|
|
64
|
+
server.stop(true)
|
|
65
|
+
}
|
|
66
|
+
expect((await sendHeartbeat("http://127.0.0.1:1/api/push/x", "up", "m")).ok).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe("station monitor setup", () => {
|
|
71
|
+
const n = (id: number, name: string, isDefault = false) => ({ id, name, active: true, isDefault })
|
|
72
|
+
|
|
73
|
+
test("who it alerts", () => {
|
|
74
|
+
expect(chooseNotifications([n(1, "Pager")]).chosen.map((x) => x.id)).toEqual([1])
|
|
75
|
+
expect(chooseNotifications([n(1, "Email"), n(2, "Telegram", true)]).chosen.map((x) => x.id)).toEqual([2])
|
|
76
|
+
expect(chooseNotifications([n(1, "Email"), n(2, "Telegram")]).needChoice).toMatch(/--notify/)
|
|
77
|
+
expect(chooseNotifications([n(1, "Email"), n(2, "Telegram")], "telegram").chosen.map((x) => x.id)).toEqual([2])
|
|
78
|
+
expect(chooseNotifications([n(1, "Email")], "2").needChoice).toMatch(/No active Kuma notification/)
|
|
79
|
+
expect(chooseNotifications([]).needChoice).toMatch(/no active notifications/)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test("a push monitor that waits three ticks", () => {
|
|
83
|
+
const p = stationMonitorPayload("SpinupTUI station: x", "tok")
|
|
84
|
+
expect(p).toMatchObject({ type: "push", pushToken: "tok", interval: 900, maxretries: 0 })
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// The station heartbeat: the end of every dispatcher tick pings an Uptime Kuma
|
|
2
|
+
// push monitor that runs somewhere else.
|
|
3
|
+
//
|
|
4
|
+
// It covers what nothing on the station can report about itself. If the machine
|
|
5
|
+
// is off, the global spinuptui is broken, or the LaunchAgent is gone, the pings
|
|
6
|
+
// stop and Kuma pages through its own notifications. And when the pager itself
|
|
7
|
+
// is broken (Beacon's last page failed, or Beacon isn't set up) the ping says
|
|
8
|
+
// "down", so that failure reaches you without going through Beacon.
|
|
9
|
+
//
|
|
10
|
+
// The heartbeat belongs to the station, not to a module; it lives in Scheduler,
|
|
11
|
+
// the station's clock, until station setup exists. See
|
|
12
|
+
// docs/2026-10-20_scheduler-spec.md, "Station heartbeat".
|
|
13
|
+
|
|
14
|
+
import { loadConfig, saveConfig, type AppConfig, type UptimeKumaConn } from "../config.ts"
|
|
15
|
+
import { beaconFromConfig, lastSendLogEntry } from "./beacon.ts"
|
|
16
|
+
import { genPushToken, pushUrl, withKuma, type KumaNotificationInfo } from "./uptimeKuma.ts"
|
|
17
|
+
|
|
18
|
+
export const HEARTBEAT_TIMEOUT_MS = 10_000
|
|
19
|
+
|
|
20
|
+
export interface HeartbeatResult {
|
|
21
|
+
ok: boolean // Kuma accepted the ping
|
|
22
|
+
status: "up" | "down"
|
|
23
|
+
msg: string
|
|
24
|
+
error?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A Kuma push URL: http(s)://host/api/push/<token>, with or without the query
|
|
28
|
+
// string Kuma shows (status, msg and ping are set on each ping).
|
|
29
|
+
export function parsePushUrl(input: string): URL | null {
|
|
30
|
+
try {
|
|
31
|
+
const u = new URL(input.trim())
|
|
32
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") return null
|
|
33
|
+
if (!/\/api\/push\/[^/]+\/?$/.test(u.pathname)) return null
|
|
34
|
+
u.search = ""
|
|
35
|
+
u.hash = ""
|
|
36
|
+
return u
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function pingUrl(base: string, status: "up" | "down", msg: string, pingMs?: number): string {
|
|
43
|
+
const u = parsePushUrl(base)
|
|
44
|
+
if (!u) throw new Error("not a Kuma push URL")
|
|
45
|
+
u.searchParams.set("status", status)
|
|
46
|
+
u.searchParams.set("msg", msg.slice(0, 250))
|
|
47
|
+
u.searchParams.set("ping", pingMs === undefined ? "" : String(Math.round(pingMs)))
|
|
48
|
+
return u.toString()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Can this station page anyone? Not if Beacon has no channel, or its last
|
|
52
|
+
// page failed (that clears itself with the next page that gets through).
|
|
53
|
+
export function pagerProblem(cfg: AppConfig = loadConfig(), last = lastSendLogEntry()): string | null {
|
|
54
|
+
if (beaconFromConfig(cfg).channels.length === 0) return "Beacon isn't set up, so nothing can page you"
|
|
55
|
+
if (last && !last.ok) return `Beacon's last page failed (${(last.error ?? "unknown error").slice(0, 120)})`
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function sendHeartbeat(
|
|
60
|
+
base: string,
|
|
61
|
+
status: "up" | "down",
|
|
62
|
+
msg: string,
|
|
63
|
+
pingMs?: number,
|
|
64
|
+
fetchImpl: typeof fetch = fetch,
|
|
65
|
+
): Promise<HeartbeatResult> {
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetchImpl(pingUrl(base, status, msg, pingMs), { signal: AbortSignal.timeout(HEARTBEAT_TIMEOUT_MS) })
|
|
68
|
+
const body = (await res.json().catch(() => null)) as { ok?: boolean; msg?: string } | null
|
|
69
|
+
if (res.ok && body?.ok) return { ok: true, status, msg }
|
|
70
|
+
return { ok: false, status, msg, error: body?.msg || `HTTP ${res.status}` }
|
|
71
|
+
} catch (e) {
|
|
72
|
+
return { ok: false, status, msg, error: e instanceof Error ? e.message : String(e) }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// What one tick tells Kuma.
|
|
77
|
+
export function tickHeartbeat(ranCount: number, errors: string[], pager: string | null): { status: "up" | "down"; msg: string } {
|
|
78
|
+
if (pager) return { status: "down", msg: `Station is up, but ${pager}` }
|
|
79
|
+
const what = ranCount ? `${ranCount} job${ranCount === 1 ? "" : "s"} ran` : "nothing due"
|
|
80
|
+
return { status: "up", msg: errors.length ? `tick ok, ${what}; ${errors.length} problem${errors.length === 1 ? "" : "s"}: ${errors[0]}` : `tick ok, ${what}` }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---- setup through SpinupTUI's own Kuma login --------------------------------------
|
|
84
|
+
//
|
|
85
|
+
// With a Kuma login saved (the one site monitoring uses), SpinupTUI creates the
|
|
86
|
+
// station's push monitor itself: one per station, found again by name, so
|
|
87
|
+
// re-running repairs rather than duplicates. Without one, `heartbeat set <url>`
|
|
88
|
+
// takes a monitor made by hand.
|
|
89
|
+
|
|
90
|
+
// Three missed 5-minute ticks, the same threshold `scheduler status` uses.
|
|
91
|
+
export const STATION_INTERVAL_S = 900
|
|
92
|
+
|
|
93
|
+
export const stationMonitorName = (host: string) => `SpinupTUI station: ${host}`
|
|
94
|
+
|
|
95
|
+
export function stationMonitorPayload(name: string, pushToken: string): Record<string, unknown> {
|
|
96
|
+
return {
|
|
97
|
+
type: "push",
|
|
98
|
+
name,
|
|
99
|
+
pushToken,
|
|
100
|
+
description: "Pinged by SpinupTUI's Scheduler at the end of every tick. Down = the station stopped, or it can't page you (see the message).",
|
|
101
|
+
interval: STATION_INTERVAL_S,
|
|
102
|
+
retryInterval: 60,
|
|
103
|
+
maxretries: 0,
|
|
104
|
+
upsideDown: false,
|
|
105
|
+
accepted_statuscodes: ["200-299"],
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface StationMonitorPlan {
|
|
110
|
+
kumaUrl: string
|
|
111
|
+
name: string
|
|
112
|
+
existing: { id: number; url: string } | null
|
|
113
|
+
// Every active notification in this Kuma, and the ones the monitor will use.
|
|
114
|
+
available: KumaNotificationInfo[]
|
|
115
|
+
chosen: KumaNotificationInfo[]
|
|
116
|
+
// Why none could be chosen (ask for --notify), when that's the case.
|
|
117
|
+
needChoice: string | null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Who the station monitor alerts: the one asked for by name or id; else
|
|
121
|
+
// Kuma's default notifications; else the only one there is. Several and no
|
|
122
|
+
// default means asking: alerting the wrong channel (or none) defeats the point.
|
|
123
|
+
export function chooseNotifications(available: KumaNotificationInfo[], want?: string): { chosen: KumaNotificationInfo[]; needChoice: string | null } {
|
|
124
|
+
const list = available.map((n) => `"${n.name}"`).join(", ")
|
|
125
|
+
if (want) {
|
|
126
|
+
const hit = available.find((n) => n.name.toLowerCase() === want.toLowerCase() || String(n.id) === want)
|
|
127
|
+
return hit ? { chosen: [hit], needChoice: null } : { chosen: [], needChoice: `No active Kuma notification called "${want}". Choose one of: ${list}` }
|
|
128
|
+
}
|
|
129
|
+
const defaults = available.filter((n) => n.isDefault)
|
|
130
|
+
if (defaults.length) return { chosen: defaults, needChoice: null }
|
|
131
|
+
if (available.length === 1) return { chosen: available, needChoice: null }
|
|
132
|
+
if (!available.length) return { chosen: [], needChoice: "This Kuma has no active notifications, so the monitor would alert no one. Add one in Kuma (Settings → Notifications) first." }
|
|
133
|
+
return { chosen: [], needChoice: `This Kuma has several notifications and no default. Choose who the station alerts with --notify <name>: ${list}` }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function planStationMonitor(conn: UptimeKumaConn, host: string, want?: string): Promise<{ plan: StationMonitorPlan; jwt: string }> {
|
|
137
|
+
const name = stationMonitorName(host)
|
|
138
|
+
const { result, jwt } = await withKuma(conn, async (kuma) => {
|
|
139
|
+
const found = (await kuma.getMonitors()).find((m) => m.name === name && m.type === "push" && m.pushToken)
|
|
140
|
+
const available = (await kuma.getNotifications()).filter((n) => n.active)
|
|
141
|
+
return { found, available }
|
|
142
|
+
})
|
|
143
|
+
return {
|
|
144
|
+
plan: {
|
|
145
|
+
kumaUrl: conn.url,
|
|
146
|
+
name,
|
|
147
|
+
existing: result.found ? { id: result.found.id, url: pushUrl(conn.url, result.found.pushToken!) } : null,
|
|
148
|
+
available: result.available,
|
|
149
|
+
...chooseNotifications(result.available, want),
|
|
150
|
+
},
|
|
151
|
+
jwt,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function createStationMonitor(conn: UptimeKumaConn, host: string, notificationIds: number[]): Promise<{ id: number; url: string; jwt: string }> {
|
|
156
|
+
const token = genPushToken()
|
|
157
|
+
const notificationIDList = Object.fromEntries(notificationIds.map((id) => [String(id), true]))
|
|
158
|
+
const { result, jwt } = await withKuma(conn, (kuma) => kuma.addMonitor({ ...stationMonitorPayload(stationMonitorName(host), token), notificationIDList }))
|
|
159
|
+
return { id: result, url: pushUrl(conn.url, token), jwt }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---- the shared flow (CLI `heartbeat setup`/`set`, and the Modules tab) -------------
|
|
163
|
+
|
|
164
|
+
export type Outcome<T> = ({ ok: true } & T) | { ok: false; message: string; remedy?: string }
|
|
165
|
+
|
|
166
|
+
// Pings `url` with this station's real state and, if Kuma takes it, saves it.
|
|
167
|
+
export async function connectHeartbeat(url: string, host: string, label = "heartbeat connected"): Promise<Outcome<{ status: "up" | "down" }>> {
|
|
168
|
+
if (!parsePushUrl(url)) {
|
|
169
|
+
return { ok: false, message: "That isn't an Uptime Kuma push URL (it looks like https://<kuma>/api/push/<token>…).", remedy: "In Kuma: Add New Monitor → Monitor Type: Push, then copy its Push URL." }
|
|
170
|
+
}
|
|
171
|
+
const cfg = loadConfig()
|
|
172
|
+
const t = tickHeartbeat(0, [], pagerProblem(cfg))
|
|
173
|
+
const r = await sendHeartbeat(url, t.status, `${host}: ${label} (${t.msg})`)
|
|
174
|
+
if (!r.ok) return { ok: false, message: `Kuma didn't take the ping: ${r.error}`, remedy: "Check the URL, that the monitor is active, and that this machine can reach Kuma." }
|
|
175
|
+
await saveConfig({ scheduler: { ...(cfg.scheduler ?? {}), heartbeat: { url: parsePushUrl(url)!.toString(), setAt: new Date().toISOString() } } })
|
|
176
|
+
return { ok: true, status: t.status }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function removeHeartbeat(): Promise<void> {
|
|
180
|
+
const { heartbeat: _gone, ...rest } = loadConfig().scheduler ?? {}
|
|
181
|
+
await saveConfig({ scheduler: rest })
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function saveKumaJwt(conn: UptimeKumaConn, jwt: string): Promise<void> {
|
|
185
|
+
if (!conn.env && jwt && jwt !== conn.jwt) await saveConfig({ uptimeKuma: { url: conn.url, username: conn.username, password: conn.password, jwt } })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const NO_KUMA = {
|
|
189
|
+
message: "SpinupTUI has no Uptime Kuma login to set it up with.",
|
|
190
|
+
remedy: "Connect Kuma in the app (site monitoring, M), or make a Push monitor by hand (interval 900s) and give SpinupTUI its push URL.",
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function loadStationPlan(host: string, want?: string): Promise<Outcome<{ plan: StationMonitorPlan }>> {
|
|
194
|
+
const conn = loadConfig().uptimeKuma
|
|
195
|
+
if (!conn) return { ok: false, ...NO_KUMA }
|
|
196
|
+
try {
|
|
197
|
+
const r = await planStationMonitor(conn, host, want)
|
|
198
|
+
await saveKumaJwt(conn, r.jwt)
|
|
199
|
+
return { ok: true, plan: r.plan }
|
|
200
|
+
} catch (e) {
|
|
201
|
+
const msg = (e as Error).message
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
message: `Couldn't read Uptime Kuma at ${conn.url}: ${msg}`,
|
|
205
|
+
...(/2FA|token/i.test(msg) ? { remedy: "Its saved session has expired. Log in again from the app (M on any site, with your 2FA code), then try again; it reuses that session." } : {}),
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function stationPlanText(plan: StationMonitorPlan): { steps: string[]; notes: string[] } {
|
|
211
|
+
const who = plan.chosen.map((n) => n.name)
|
|
212
|
+
return {
|
|
213
|
+
steps: [
|
|
214
|
+
plan.existing ? `Use the existing Kuma monitor "${plan.name}" (#${plan.existing.id})` : `Create a push monitor "${plan.name}" in ${plan.kumaUrl}: down after ${STATION_INTERVAL_S / 60} min without a ping`,
|
|
215
|
+
"Ping it once, then save it: every dispatcher tick pings it from then on",
|
|
216
|
+
],
|
|
217
|
+
notes: [
|
|
218
|
+
plan.existing ? "The existing monitor keeps its own notification settings." : `When it goes down, Kuma alerts: ${who.join(", ")}. That doesn't go through Beacon, which is the point.`,
|
|
219
|
+
"Kuma must run on another machine: a monitor on this station would stop with it.",
|
|
220
|
+
],
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Creates the monitor if the plan needs one, then connects. Re-running after a
|
|
225
|
+
// failed ping finds the monitor by name and reuses it.
|
|
226
|
+
export async function applyStationPlan(plan: StationMonitorPlan, host: string): Promise<Outcome<{ created: number | null; monitorId: number; status: "up" | "down" }>> {
|
|
227
|
+
const conn = loadConfig().uptimeKuma
|
|
228
|
+
if (!conn) return { ok: false, ...NO_KUMA }
|
|
229
|
+
let url = plan.existing?.url
|
|
230
|
+
let created: number | null = null
|
|
231
|
+
if (!url) {
|
|
232
|
+
if (plan.needChoice) return { ok: false, message: plan.needChoice }
|
|
233
|
+
try {
|
|
234
|
+
const r = await createStationMonitor(conn, host, plan.chosen.map((n) => n.id))
|
|
235
|
+
url = r.url
|
|
236
|
+
created = r.id
|
|
237
|
+
await saveKumaJwt(conn, r.jwt)
|
|
238
|
+
} catch (e) {
|
|
239
|
+
return { ok: false, message: `Kuma didn't create the monitor: ${(e as Error).message}` }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const r = await connectHeartbeat(url, host)
|
|
243
|
+
if (!r.ok) return { ok: false, message: `${created ? `Created monitor #${created}, but ` : ""}${r.message}`, remedy: "Check this machine can reach Kuma, then try again: it reuses the monitor." }
|
|
244
|
+
return { ok: true, created, monitorId: created ?? plan.existing!.id, status: r.status }
|
|
245
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, test } from "bun:test"
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { applySync, cloudSyncedBy, ensureHome, homeStatus, planSync, prepareWorkDir, readmeText, run } from "./home.ts"
|
|
6
|
+
|
|
7
|
+
// Isolated config and folder: nothing here touches the real ones.
|
|
8
|
+
const scratch = mkdtempSync(join(tmpdir(), "spinuptui-home-"))
|
|
9
|
+
process.env.XDG_CONFIG_HOME = join(scratch, "config")
|
|
10
|
+
process.env.SPINUPTUI_HOME = join(scratch, "SpinupTUI")
|
|
11
|
+
const home = process.env.SPINUPTUI_HOME
|
|
12
|
+
const bare = join(scratch, "remote.git")
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
await run(["git", "init", "--quiet", "--bare", "-b", "main", bare])
|
|
16
|
+
process.env.GIT_AUTHOR_NAME = process.env.GIT_COMMITTER_NAME = "Test"
|
|
17
|
+
process.env.GIT_AUTHOR_EMAIL = process.env.GIT_COMMITTER_EMAIL = "test@example.com"
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe("level 1: the README", () => {
|
|
21
|
+
test("written once, on first use, never overwritten", () => {
|
|
22
|
+
prepareWorkDir(join(home, "scheduler", "jobs"))
|
|
23
|
+
expect(readFileSync(join(home, "README.md"), "utf8")).toBe(readmeText())
|
|
24
|
+
writeFileSync(join(home, "README.md"), "mine")
|
|
25
|
+
expect(ensureHome(home)).toBe(false)
|
|
26
|
+
expect(readFileSync(join(home, "README.md"), "utf8")).toBe("mine")
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("a directory outside the folder gets no README", () => {
|
|
30
|
+
const other = join(scratch, "elsewhere", "jobs")
|
|
31
|
+
prepareWorkDir(other)
|
|
32
|
+
expect(existsSync(join(scratch, "elsewhere", "README.md"))).toBe(false)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe("cloudSyncedBy", () => {
|
|
37
|
+
test("recognises sync folders", () => {
|
|
38
|
+
expect(cloudSyncedBy("/Users/x/Library/Mobile Documents/com~apple~CloudDocs/SpinupTUI")).toBe("iCloud Drive")
|
|
39
|
+
expect(cloudSyncedBy("/Users/x/Library/CloudStorage/Dropbox/SpinupTUI")).toBe("Dropbox")
|
|
40
|
+
expect(cloudSyncedBy("/Users/x/Library/CloudStorage/GoogleDrive-a@b.c/My Drive/S")).toBe("Google Drive")
|
|
41
|
+
expect(cloudSyncedBy("/Users/x/Dropbox (Personal)/SpinupTUI")).toBe("Dropbox")
|
|
42
|
+
expect(cloudSyncedBy("/Users/x/SpinupTUI")).toBe(null)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe("level 2: home sync", () => {
|
|
47
|
+
test("asks where to keep it", async () => {
|
|
48
|
+
const plan = await planSync({}, home)
|
|
49
|
+
expect(plan.blockers.map((b) => b.reason)).toEqual(["need_remote"])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test("shares the folder to an empty remote, then has nothing to do", async () => {
|
|
53
|
+
mkdirSync(join(home, "scheduler", "runs", "x"), { recursive: true })
|
|
54
|
+
writeFileSync(join(home, "scheduler", "runs", "x", "state.json"), "{}\n")
|
|
55
|
+
const plan = await planSync({ remote: bare }, home)
|
|
56
|
+
expect(plan.blockers).toEqual([])
|
|
57
|
+
expect(plan.mode).toBe("share")
|
|
58
|
+
const r = await applySync(plan)
|
|
59
|
+
expect(r.failed).toBeUndefined()
|
|
60
|
+
const s = await homeStatus(home)
|
|
61
|
+
expect(s.git).toBe(true)
|
|
62
|
+
expect(s.remote).toBe(bare)
|
|
63
|
+
expect(s.ahead).toBe(0)
|
|
64
|
+
expect(readFileSync(join(home, ".gitignore"), "utf8")).toContain(".DS_Store")
|
|
65
|
+
const files = (await run(["git", "-C", bare, "ls-tree", "-r", "--name-only", "main"])).out.split("\n")
|
|
66
|
+
expect(files).toContain("scheduler/runs/x/state.json")
|
|
67
|
+
expect((await planSync({}, home)).mode).toBe("nothing")
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test("refuses a remote that already has commits", async () => {
|
|
71
|
+
const other = join(scratch, "other")
|
|
72
|
+
const plan = await planSync({ remote: bare }, other)
|
|
73
|
+
expect(plan.blockers.map((b) => b.reason)).toContain("remote_not_empty")
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("a second machine clones it", async () => {
|
|
77
|
+
const laptop = join(scratch, "laptop", "SpinupTUI")
|
|
78
|
+
ensureHome(laptop) // the starter README a module already wrote there
|
|
79
|
+
const plan = await planSync({ clone: bare }, laptop)
|
|
80
|
+
expect(plan.blockers).toEqual([])
|
|
81
|
+
expect((await applySync(plan)).ok).toBe(true)
|
|
82
|
+
expect(existsSync(join(laptop, "scheduler", "runs", "x", "state.json"))).toBe(true)
|
|
83
|
+
expect((await homeStatus(laptop)).remote).toBe(bare)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test("won't clone over work already there", async () => {
|
|
87
|
+
const busy = join(scratch, "busy", "SpinupTUI")
|
|
88
|
+
mkdirSync(join(busy, "scheduler", "jobs"), { recursive: true })
|
|
89
|
+
writeFileSync(join(busy, "scheduler", "jobs", "a.md"), "x")
|
|
90
|
+
const plan = await planSync({ clone: bare }, busy)
|
|
91
|
+
expect(plan.blockers.map((b) => b.reason)).toEqual(["not_empty"])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test("won't nest inside another repository", async () => {
|
|
95
|
+
const outer = join(scratch, "project")
|
|
96
|
+
await run(["git", "init", "--quiet", outer])
|
|
97
|
+
// Before the folder exists, too.
|
|
98
|
+
expect((await planSync({ remote: bare }, join(outer, "SpinupTUI"))).blockers.map((b) => b.reason)).toContain("inside_repo")
|
|
99
|
+
mkdirSync(join(outer, "SpinupTUI"), { recursive: true })
|
|
100
|
+
expect((await planSync({ remote: bare }, join(outer, "SpinupTUI"))).blockers.map((b) => b.reason)).toContain("inside_repo")
|
|
101
|
+
})
|
|
102
|
+
})
|