spinuptui 1.3.0-alpha.3 → 1.3.0-alpha.5

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 (50) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +12 -3
  3. package/package.json +1 -1
  4. package/src/config.ts +32 -0
  5. package/src/index.tsx +11 -0
  6. package/src/lib/beacon.test.ts +14 -0
  7. package/src/lib/beacon.ts +14 -1
  8. package/src/lib/beaconBoard.test.ts +69 -0
  9. package/src/lib/beaconBoard.ts +197 -0
  10. package/src/lib/beaconListen.test.ts +190 -0
  11. package/src/lib/beaconListen.ts +355 -0
  12. package/src/lib/beaconListenInstall.ts +251 -0
  13. package/src/lib/cliBeacon.ts +115 -3
  14. package/src/lib/cliIncidents.ts +13 -6
  15. package/src/lib/cliScheduler.ts +30 -1
  16. package/src/lib/cliSentinel.ts +451 -0
  17. package/src/lib/heartbeat.ts +5 -0
  18. package/src/lib/schedule.ts +4 -1
  19. package/src/lib/scheduler.test.ts +189 -1
  20. package/src/lib/schedulerExtend.ts +105 -0
  21. package/src/lib/schedulerInstall.ts +36 -15
  22. package/src/lib/schedulerJobs.ts +4 -2
  23. package/src/lib/schedulerRun.ts +61 -15
  24. package/src/lib/schedulerTick.ts +35 -13
  25. package/src/lib/sentinelAttach.test.ts +45 -0
  26. package/src/lib/sentinelAttach.ts +98 -0
  27. package/src/lib/sentinelDigest.test.ts +82 -0
  28. package/src/lib/sentinelDigest.ts +261 -0
  29. package/src/lib/sentinelExpect.test.ts +82 -0
  30. package/src/lib/sentinelExpect.ts +197 -0
  31. package/src/lib/sentinelIncidents.test.ts +145 -0
  32. package/src/lib/sentinelIncidents.ts +194 -0
  33. package/src/lib/sentinelPrompt.ts +89 -0
  34. package/src/lib/sentinelReply.ts +36 -0
  35. package/src/lib/sentinelState.ts +246 -0
  36. package/src/lib/sentinelTick.test.ts +214 -0
  37. package/src/lib/sentinelTick.ts +265 -0
  38. package/src/lib/sentinelWorker.test.ts +170 -0
  39. package/src/lib/sentinelWorker.ts +291 -0
  40. package/src/modules/beacon.ts +9 -1
  41. package/src/modules/contract.ts +34 -0
  42. package/src/modules/index.ts +2 -7
  43. package/src/modules/scheduler.ts +147 -6
  44. package/src/modules/sentinel.ts +90 -0
  45. package/src/ui/App.tsx +8 -0
  46. package/src/ui/store.tsx +11 -0
  47. package/src/ui/views/BeaconSetup.tsx +11 -7
  48. package/src/ui/views/ListenerSetup.tsx +205 -0
  49. package/src/ui/views/Modules.tsx +70 -5
  50. package/src/ui/views/SentinelSetup.tsx +171 -0
@@ -0,0 +1,251 @@
1
+ // Installing Beacon's listener: a resident LaunchAgent (KeepAlive) running
2
+ // `spinuptui beacon listen`, so macOS restarts it if it dies. It's the station's
3
+ // one unit besides Scheduler's dispatcher; it does no scheduled work, it only
4
+ // waits for the operator. Scheduler's tick runs `beacon listener check` (a
5
+ // Beacon module job) and pages when the listener stops answering.
6
+
7
+ import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"
8
+ import { homedir } from "node:os"
9
+ import { dirname, join } from "node:path"
10
+ import { loadConfig, saveConfig } from "../config.ts"
11
+ import { beaconFromConfig, beaconSend, pickChannel } from "./beacon.ts"
12
+ import { buttonMarkup, channelProblem, listenerDir, listenerLogPath, readListenerState } from "./beaconListen.ts"
13
+ import { helpIncludes, launchctl, resolveRunner } from "./schedulerInstall.ts"
14
+ import type { BeaconButton } from "../modules/contract.ts"
15
+
16
+ export const DEFAULT_LISTENER_LABEL = "com.spinuptui.beacon-listener"
17
+ // Three missed polls, with room for a restart.
18
+ export const LISTENER_STALE_MS = 5 * 60_000
19
+ // A poll that keeps failing this long (Telegram unreachable, another reader of
20
+ // the bot) counts as down too; one blip doesn't.
21
+ export const LISTENER_FAILING_MS = 10 * 60_000
22
+
23
+ // Set while Beacon setup has the listener stopped, so the alive check doesn't
24
+ // page about a pause the operator asked for.
25
+ const pausedPath = () => join(listenerDir(), "listener.paused")
26
+ const PAUSE_MAX_MS = 30 * 60_000
27
+
28
+ export function listenerLabel(): string {
29
+ return process.env.SPINUPTUI_LISTENER_LABEL?.trim() || DEFAULT_LISTENER_LABEL
30
+ }
31
+
32
+ const domain = () => `gui/${process.getuid?.() ?? 501}`
33
+ const xml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
34
+
35
+ export function renderListenerPlist(p: { label: string; programArguments: string[]; env: Record<string, string>; logPath: string }): string {
36
+ const args = p.programArguments.map((a) => ` <string>${xml(a)}</string>`).join("\n")
37
+ const env = Object.entries(p.env)
38
+ .map(([k, v]) => ` <key>${xml(k)}</key><string>${xml(v)}</string>`)
39
+ .join("\n")
40
+ return `<?xml version="1.0" encoding="UTF-8"?>
41
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
42
+ <!--
43
+ SpinupTUI Beacon's listener: \`spinuptui beacon listen\`, kept running so the
44
+ pager can answer you (/jobs and button taps). Written by
45
+ \`spinuptui beacon listener install --apply\`; remove it with
46
+ \`spinuptui beacon listener uninstall --apply\` rather than by hand.
47
+ -->
48
+ <plist version="1.0">
49
+ <dict>
50
+ <key>Label</key><string>${xml(p.label)}</string>
51
+ <key>ProgramArguments</key>
52
+ <array>
53
+ ${args}
54
+ </array>
55
+ <key>RunAtLoad</key><true/>
56
+ <key>KeepAlive</key><true/>
57
+ <key>ThrottleInterval</key><integer>30</integer>
58
+ <key>StandardOutPath</key><string>${xml(p.logPath)}</string>
59
+ <key>StandardErrorPath</key><string>${xml(p.logPath)}</string>
60
+ <key>EnvironmentVariables</key>
61
+ <dict>
62
+ ${env}
63
+ </dict>
64
+ </dict>
65
+ </plist>
66
+ `
67
+ }
68
+
69
+ export interface ListenerPlan {
70
+ label: string
71
+ unitPath: string
72
+ bin: string
73
+ channel: string
74
+ programArguments: string[]
75
+ env: Record<string, string>
76
+ unitText: string
77
+ commands: string[]
78
+ notes: string[]
79
+ }
80
+
81
+ export type ListenerPlanResult = ({ ok: true } & ListenerPlan) | { ok: false; reason: string; message: string; remedy?: string }
82
+
83
+ export function planListenerInstall(opts: { bin?: string | null; to?: string | null } = {}): ListenerPlanResult {
84
+ if (process.platform !== "darwin") {
85
+ return { ok: false, reason: "unsupported_platform", message: "The listener installs as a macOS LaunchAgent; Linux isn't supported yet." }
86
+ }
87
+ const picked = pickChannel(beaconFromConfig(loadConfig()), opts.to)
88
+ if ("ok" in picked) return { ok: false, reason: picked.reason, message: picked.message, remedy: picked.remedy ?? "Set up Beacon first: spinuptui beacon setup" }
89
+ if (picked.env) {
90
+ return {
91
+ ok: false,
92
+ reason: "env_channel",
93
+ message: "The default channel comes from SPINUPTUI_BEACON_* in this shell, which the LaunchAgent won't have.",
94
+ remedy: "Save the channel with `spinuptui beacon setup` (or `beacon add`) and install again.",
95
+ }
96
+ }
97
+ const problem = channelProblem(picked.channel)
98
+ if (problem) return { ok: false, reason: "group_chat", message: problem, remedy: "Point a channel at your private chat with the bot (spinuptui beacon setup), then install with --to <that channel>." }
99
+ const runner = resolveRunner(opts)
100
+ if (!runner.ok) return runner
101
+ const label = listenerLabel()
102
+ const programArguments = [...runner.prefix, "beacon", "listen", "--to", picked.name]
103
+ const env = { ...runner.env, SPINUPTUI_BEACON_LISTENER: "1" }
104
+ const unitPath = join(homedir(), "Library/LaunchAgents", `${label}.plist`)
105
+ const notes = [
106
+ `It answers only your private chat with the bot on channel "${picked.name}", and ignores everyone else.`,
107
+ "It long-polls Telegram (about one request a minute when idle) and restarts itself after you upgrade SpinupTUI.",
108
+ "Scheduler's tick checks it's alive and pages you if it isn't, so install Scheduler too.",
109
+ ]
110
+ if (runner.checkout) notes.push(`--bin points into a checkout (${runner.checkout}); fine for testing, never for a station.`)
111
+ return {
112
+ ok: true,
113
+ label,
114
+ unitPath,
115
+ bin: runner.bin,
116
+ channel: picked.name,
117
+ programArguments,
118
+ env,
119
+ unitText: renderListenerPlist({ label, programArguments, env, logPath: join(listenerDir(), "launchd.log") }),
120
+ commands: [`launchctl bootout ${domain()}/${label} (if loaded)`, `launchctl bootstrap ${domain()} ${unitPath}`, "wait for the listener to start (up to 60 s)", "send a message saying it's listening"],
121
+ notes,
122
+ }
123
+ }
124
+
125
+ export type ListenerApplyResult =
126
+ | { ok: true; label: string; unitPath: string; channel: string; startedAt: string; notes: string[] }
127
+ | { ok: false; reason: string; message: string; remedy?: string; unitPath?: string }
128
+
129
+ // Asked through --help, as for Scheduler's dispatcher: a release without the
130
+ // listener treats `beacon listen` as an unknown subcommand.
131
+ export function binHasListener(plan: Pick<ListenerPlan, "programArguments" | "env">): Promise<boolean> {
132
+ return helpIncludes(plan.programArguments.slice(0, -4), plan.env.PATH ?? "", "spinuptui beacon listen")
133
+ }
134
+
135
+ export const NO_LISTENER = {
136
+ ok: false as const,
137
+ reason: "spinuptui_too_old",
138
+ message: "The `spinuptui` the LaunchAgent would run doesn't have Beacon's listener yet.",
139
+ remedy: "Install a release that has it first: bun add -g spinuptui@next --no-cache",
140
+ }
141
+
142
+ export async function applyListenerInstall(plan: ListenerPlan, onStage: (s: string) => void = () => {}): Promise<ListenerApplyResult> {
143
+ onStage("checking that this spinuptui has the listener")
144
+ if (!(await binHasListener(plan))) return NO_LISTENER
145
+ mkdirSync(dirname(plan.unitPath), { recursive: true })
146
+ mkdirSync(listenerDir(), { recursive: true })
147
+ onStage(`writing ${plan.unitPath}`)
148
+ writeFileSync(plan.unitPath, plan.unitText, { mode: 0o644 })
149
+ await launchctl("bootout", `${domain()}/${plan.label}`)
150
+ const since = Date.now()
151
+ onStage("loading it with launchctl")
152
+ const boot = await launchctl("bootstrap", domain(), plan.unitPath)
153
+ if (boot.code !== 0) return { ok: false, reason: "launchctl_failed", message: `launchctl bootstrap failed: ${boot.out}`, unitPath: plan.unitPath }
154
+ const beacon = loadConfig().beacon ?? {}
155
+ await saveConfig({ beacon: { ...beacon, listener: { label: plan.label, unitPath: plan.unitPath, bin: plan.bin, channel: plan.channel, installedAt: new Date().toISOString() } } })
156
+
157
+ onStage("waiting for the listener to start")
158
+ for (let waited = 0; waited < 60_000; waited += 1000) {
159
+ const st = readListenerState()
160
+ if (st.startedAt && Date.parse(st.startedAt) >= since) {
161
+ const sent = await beaconSend(beaconFromConfig(loadConfig()), "Beacon is listening on this station. Send /help to see what it answers, or /jobs for the scheduled jobs.", { to: plan.channel })
162
+ const notes = sent.ok ? plan.notes : [...plan.notes, `It's running, but the hello message failed: ${sent.message}`]
163
+ return { ok: true, label: plan.label, unitPath: plan.unitPath, channel: plan.channel, startedAt: st.startedAt, notes }
164
+ }
165
+ await Bun.sleep(1000)
166
+ }
167
+ return {
168
+ ok: false,
169
+ reason: "not_started",
170
+ message: "The listener is installed, but it didn't start within 60 seconds.",
171
+ remedy: `Check ${join(listenerDir(), "launchd.log")} and \`launchctl print ${domain()}/${plan.label}\`.`,
172
+ unitPath: plan.unitPath,
173
+ }
174
+ }
175
+
176
+ export interface ListenerUninstallPlan {
177
+ label: string
178
+ unitPath: string
179
+ installed: boolean
180
+ commands: string[]
181
+ }
182
+
183
+ export function planListenerUninstall(): ListenerUninstallPlan {
184
+ const l = loadConfig().beacon?.listener
185
+ const label = l?.label ?? listenerLabel()
186
+ const unitPath = l?.unitPath ?? join(homedir(), "Library/LaunchAgents", `${label}.plist`)
187
+ return {
188
+ label,
189
+ unitPath,
190
+ installed: !!l || existsSync(unitPath),
191
+ commands: [`launchctl bootout ${domain()}/${label}`, `delete ${unitPath}`, "clear beacon.listener in config.json (channels are kept)"],
192
+ }
193
+ }
194
+
195
+ export async function applyListenerUninstall(plan: ListenerUninstallPlan): Promise<{ ok: true; removed: string }> {
196
+ if (process.platform === "darwin") await launchctl("bootout", `${domain()}/${plan.label}`)
197
+ if (existsSync(plan.unitPath)) unlinkSync(plan.unitPath)
198
+ const b = loadConfig().beacon
199
+ if (b?.listener) {
200
+ const { listener: _gone, ...rest } = b
201
+ await saveConfig({ beacon: rest })
202
+ }
203
+ return { ok: true, removed: plan.unitPath }
204
+ }
205
+
206
+ export function listenerInstalled(): boolean {
207
+ const l = loadConfig().beacon?.listener
208
+ return !!l && existsSync(l.unitPath)
209
+ }
210
+
211
+ // Is the installed listener answering? null when none is installed.
212
+ // Inline buttons for a page. Only the listener can receive a tap, so with none
213
+ // installed a page goes out without them.
214
+ export function pageMarkup(buttons: BeaconButton[][]): string | undefined {
215
+ return listenerInstalled() ? buttonMarkup(buttons) : undefined
216
+ }
217
+
218
+ export function listenerHealth(now = Date.now()): { ok: boolean; detail: string } | null {
219
+ if (!listenerInstalled()) return null
220
+ try {
221
+ const at = Date.parse(readFileSync(pausedPath(), "utf8").trim())
222
+ if (now - at < PAUSE_MAX_MS) return { ok: true, detail: `paused by Beacon setup since ${new Date(at).toISOString()}` }
223
+ } catch {
224
+ // Not paused.
225
+ }
226
+ const st = readListenerState()
227
+ // A listener that just started has a first poll still out (up to 50 seconds),
228
+ // so its start counts as a sign of life too.
229
+ const last = Math.max(...[st.lastPollAt, st.startedAt].map((t) => (t ? Date.parse(t) : 0)))
230
+ if (now - last > LISTENER_STALE_MS) {
231
+ return { ok: false, detail: `hasn't polled Telegram since ${st.lastPollAt ?? "it was installed"}; see ${listenerLogPath()}` }
232
+ }
233
+ if (st.failingSince && now - Date.parse(st.failingSince) > LISTENER_FAILING_MS) {
234
+ return { ok: false, detail: `polls failing since ${st.failingSince}: ${st.lastError ?? "unknown error"}` }
235
+ }
236
+ return { ok: true, detail: `listening on ${st.channel ?? "?"} (pid ${st.pid ?? "?"}, ${st.version ?? "?"}), ${st.lastPollAt ? `last poll ${st.lastPollAt}` : `started ${st.startedAt}`}` }
237
+ }
238
+
239
+ // Telegram hands a bot's updates to one reader at a time, so Beacon setup's chat
240
+ // discovery stops the listener while it waits and starts it again after.
241
+ export async function pauseListener(): Promise<() => Promise<void>> {
242
+ const l = loadConfig().beacon?.listener
243
+ if (process.platform !== "darwin" || !l || !existsSync(l.unitPath)) return async () => {}
244
+ mkdirSync(listenerDir(), { recursive: true })
245
+ writeFileSync(pausedPath(), new Date().toISOString() + "\n")
246
+ await launchctl("bootout", `${domain()}/${l.label}`)
247
+ return async () => {
248
+ await launchctl("bootstrap", domain(), l.unitPath)
249
+ rmSync(pausedPath(), { force: true })
250
+ }
251
+ }
@@ -20,12 +20,25 @@ import {
20
20
  setupChannelName,
21
21
  verifyBotToken,
22
22
  } from "./beaconSetup.ts"
23
+ import { boardLogin } from "./beaconBoard.ts"
23
24
  import { confirm, PromptCancelled, readLine } from "./prompt.ts"
25
+ import { listen, routesFrom } from "./beaconListen.ts"
26
+ import {
27
+ applyListenerInstall,
28
+ applyListenerUninstall,
29
+ binHasListener,
30
+ listenerHealth,
31
+ NO_LISTENER,
32
+ pauseListener,
33
+ planListenerInstall,
34
+ planListenerUninstall,
35
+ } from "./beaconListenInstall.ts"
36
+ import { MODULES } from "../modules/index.ts"
24
37
  import { beaconModule } from "../modules/beacon.ts"
25
38
  import { allModuleStatuses, UPCOMING_MODULES } from "../modules/index.ts"
26
39
  import type { ModuleStatus } from "../modules/contract.ts"
27
40
 
28
- const VALUE_FLAGS = new Set(["--to", "--env-file"])
41
+ const VALUE_FLAGS = new Set(["--to", "--env-file", "--bin", "--project", "--mention"])
29
42
 
30
43
  function parse(list: string[]): { positionals: string[]; flags: Map<string, string>; rest: string | null } {
31
44
  const dash = list.indexOf("--")
@@ -59,9 +72,14 @@ const USAGE = `Usage:
59
72
  spinuptui beacon setup [<name>] (guided, in a terminal)
60
73
  spinuptui beacon send [--to <channel>] [--silent] [--json] [<message> | -]
61
74
  spinuptui beacon add telegram <name> [--env-file <path>] [--default] [--no-test]
75
+ spinuptui beacon add basecamp <name> --project <id> [--mention "<person>"]
76
+ a team message board: the \`basecamp\` CLI must be logged in
62
77
  spinuptui beacon remove <name>
63
78
  spinuptui beacon test [--to <channel>] [--json]
64
- spinuptui beacon status [--json]`
79
+ spinuptui beacon status [--json]
80
+ spinuptui beacon listen [--to <channel>] answer the operator: /jobs, button taps (foreground)
81
+ spinuptui beacon listener install | uninstall --plan | --apply [--to <channel>] [--bin <path>]
82
+ spinuptui beacon listener status | check is it running? (check exits 1 when it isn't)`
65
83
 
66
84
  function badValue(flags: Map<string, string>, name: string): boolean {
67
85
  if (!flags.has(name)) return false
@@ -81,6 +99,8 @@ export async function runBeacon(argv: string[]): Promise<number> {
81
99
  if (sub === "add") return add(positionals.slice(1), flags, json)
82
100
  if (sub === "remove") return remove(positionals.slice(1), json)
83
101
  if (sub === "status") return status(json)
102
+ if (sub === "listen") return listenCmd(flags)
103
+ if (sub === "listener") return listener(positionals.slice(1), flags, json)
84
104
  return fail(json, "usage", USAGE)
85
105
  }
86
106
 
@@ -121,8 +141,22 @@ async function test(flags: Map<string, string>, json: boolean): Promise<number>
121
141
 
122
142
  const NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
123
143
 
144
+ async function addBoard(name: string, flags: Map<string, string>, json: boolean): Promise<number> {
145
+ const projectId = flags.get("--project") ?? ""
146
+ if (!/^\d+$/.test(projectId)) return fail(json, "usage", `--project needs the Basecamp project's numeric id.\n${USAGE}`)
147
+ const login = await boardLogin()
148
+ if (!login.ok) return fail(json, "basecamp_login", `Can't reach Basecamp: ${login.detail}.`)
149
+ const mention = flags.get("--mention")
150
+ const stored: BeaconStored = loadConfig().beacon ?? {}
151
+ await saveConfig({ beacon: { ...stored, boards: { ...(stored.boards ?? {}), [name]: { type: "basecamp", projectId, audience: "team", ...(mention ? { mention } : {}) } } } })
152
+ if (json) console.log(JSON.stringify({ ok: true, board: name, projectId }))
153
+ else console.log(`Added team board "${name}" (Basecamp project ${projectId}), posting as ${login.detail}.`)
154
+ return 0
155
+ }
156
+
124
157
  async function add(pos: string[], flags: Map<string, string>, json: boolean): Promise<number> {
125
158
  const [type, name] = pos
159
+ if (type === "basecamp" && name && NAME_RE.test(name)) return addBoard(name, flags, json)
126
160
  if (type !== "telegram" || !name) return fail(json, "usage", USAGE)
127
161
  if (!NAME_RE.test(name)) return fail(json, "usage", `Channel names are lowercase letters, digits and dashes (got "${name}").`)
128
162
  if (name === "env") return fail(json, "usage", `"env" is reserved for the channel defined by SPINUPTUI_BEACON_* environment variables.`)
@@ -215,7 +249,9 @@ async function setup(pos: string[]): Promise<number> {
215
249
  say(`3. In Telegram, send any message to ${username ? `@${username} (${botLink(username)})` : "your bot"}.`)
216
250
  say(" To page a group instead, add the bot to the group and send /start there.")
217
251
  say(" Waiting up to 5 minutes… (Ctrl-C to cancel)")
218
- const found = await discoverChat(token)
252
+ // Telegram gives a bot's messages to one reader: stop the listener meanwhile.
253
+ const resume = await pauseListener()
254
+ const found = await discoverChat(token).finally(resume)
219
255
  if (!found.ok) {
220
256
  say(` ✗ ${found.message}`)
221
257
  if (found.reason === "timeout" && (await confirm(" Keep waiting?", true))) continue
@@ -248,6 +284,13 @@ async function remove(pos: string[], json: boolean): Promise<number> {
248
284
  const name = pos[0]
249
285
  if (!name) return fail(json, "usage", USAGE)
250
286
  const stored: BeaconStored = loadConfig().beacon ?? {}
287
+ if (stored.boards?.[name]) {
288
+ const { [name]: _board, ...boards } = stored.boards
289
+ await saveConfig({ beacon: { ...stored, boards } })
290
+ if (json) console.log(JSON.stringify({ ok: true, removed: name }))
291
+ else console.log(`Removed board "${name}".`)
292
+ return 0
293
+ }
251
294
  if (!stored.channels?.[name]) {
252
295
  const resolved = pickChannel(beaconFromConfig(loadConfig()), name)
253
296
  if (!("ok" in resolved) && resolved.env) {
@@ -288,3 +331,72 @@ export async function runModules(argv: string[]): Promise<number> {
288
331
  }
289
332
  return 0
290
333
  }
334
+
335
+ // ---- the listener ----------------------------------------------------------------
336
+
337
+ async function listenCmd(flags: Map<string, string>): Promise<number> {
338
+ const picked = pickChannel(beaconFromConfig(loadConfig()), flags.get("--to") ?? null)
339
+ if ("ok" in picked) return fail(false, picked.reason, picked.message, picked.remedy)
340
+ const ctrl = new AbortController()
341
+ for (const sig of ["SIGTERM", "SIGINT"] as const) process.on(sig, () => ctrl.abort())
342
+ if (process.env.SPINUPTUI_BEACON_LISTENER !== "1") process.stderr.write(`Listening on "${picked.name}" for /commands and button taps (Ctrl-C to stop)…\n`)
343
+ const r = await listen({ channel: picked.channel, channelName: picked.name, routes: routesFrom(MODULES), signal: ctrl.signal })
344
+ if (!r.ok) return fail(false, r.reason, r.message)
345
+ return 0
346
+ }
347
+
348
+ async function listener(pos: string[], flags: Map<string, string>, json: boolean): Promise<number> {
349
+ const sub = pos[0]
350
+ if (sub === "status" || sub === "check") {
351
+ const h = listenerHealth()
352
+ if (json) console.log(JSON.stringify({ ok: h?.ok ?? true, installed: h !== null, ...(h ? { detail: h.detail } : {}) }))
353
+ else if (!h) console.log("Beacon's listener isn't installed on this machine.")
354
+ else console.log(`${h.ok ? "✓" : "✗"} Beacon's listener ${h.detail}`)
355
+ if (sub === "check" && h && !h.ok && !json) console.log("Reinstall it with `spinuptui beacon listener install --apply`, or from the Modules tab.")
356
+ return sub === "check" && h && !h.ok ? 1 : 0
357
+ }
358
+ const apply = flags.has("--apply")
359
+ if ((sub !== "install" && sub !== "uninstall") || apply === flags.has("--plan")) {
360
+ return fail(json, "usage", "Usage: spinuptui beacon listener install | uninstall --plan | --apply, or listener status | check")
361
+ }
362
+ if (sub === "uninstall") {
363
+ const plan = planListenerUninstall()
364
+ if (!apply) {
365
+ if (json) console.log(JSON.stringify({ ok: true, ...plan }))
366
+ else {
367
+ if (!plan.installed) console.log("Beacon's listener isn't installed; nothing to remove.")
368
+ for (const c of plan.commands) console.log(` · ${c}`)
369
+ }
370
+ return 0
371
+ }
372
+ const r = await applyListenerUninstall(plan)
373
+ if (json) console.log(JSON.stringify(r))
374
+ else console.log(`Removed ${plan.label}. The pager still sends; it just won't answer.`)
375
+ return 0
376
+ }
377
+ const plan = planListenerInstall({ bin: flags.get("--bin") ?? null, to: flags.get("--to") ?? null })
378
+ if (!plan.ok) return fail(json, plan.reason, plan.message, plan.remedy)
379
+ // Refuse at --plan too, so a plan that can't work is never shown as one.
380
+ if (!(await binHasListener(plan))) return fail(json, NO_LISTENER.reason, NO_LISTENER.message, NO_LISTENER.remedy)
381
+ if (!apply) {
382
+ if (json) console.log(JSON.stringify(plan))
383
+ else {
384
+ console.log(`Would install Beacon's listener (${plan.label}), kept running by launchd:\n`)
385
+ console.log(` ${plan.programArguments.join(" ")}\n`)
386
+ console.log(`Unit: ${plan.unitPath}\n`)
387
+ console.log("Steps:")
388
+ for (const c of plan.commands) console.log(` · ${c}`)
389
+ for (const n of plan.notes) console.log(`Note: ${n}`)
390
+ console.log(`\nRun with --apply to install it.`)
391
+ }
392
+ return 0
393
+ }
394
+ const r = await applyListenerInstall(plan, (line) => process.stderr.write(`→ ${line}\n`))
395
+ if (!r.ok) return fail(json, r.reason, r.message, r.remedy)
396
+ if (json) console.log(JSON.stringify(r))
397
+ else {
398
+ console.log(`Installed ${r.label}; it's listening on "${r.channel}". Send /jobs to the bot to try it.`)
399
+ for (const n of r.notes) console.log(`Note: ${n}`)
400
+ }
401
+ return 0
402
+ }
@@ -18,10 +18,14 @@ export interface IncidentEvent {
18
18
  status: IncidentStatus
19
19
  time: string
20
20
  message: string
21
+ monitorId: number
21
22
  }
22
23
 
23
24
  export type IncidentsResult =
24
- | { ok: true; windowHours: number; events: IncidentEvent[] }
25
+ // failedMonitors: monitors whose history couldn't be read this time, so a
26
+ // caller doesn't mistake their silence for "no events". kumaAlerted: with
27
+ // `wiring`, the monitors Kuma itself sends a notification for.
28
+ | { ok: true; windowHours: number; events: IncidentEvent[]; failedMonitors: string[]; kumaAlerted?: number[] }
25
29
  | { ok: false; reason: "kuma_not_configured" | "no_monitors_registered" | "kuma_error"; message: string }
26
30
 
27
31
  const STATUS_LABEL: Record<number, IncidentStatus> = { 0: "down", 1: "up", 2: "pending", 3: "maintenance" }
@@ -50,7 +54,7 @@ interface Target {
50
54
 
51
55
  export async function resolveIncidents(
52
56
  cfg: AppConfig,
53
- opts: { domain?: string; hours: number },
57
+ opts: { domain?: string; hours: number; wiring?: boolean },
54
58
  ): Promise<IncidentsResult> {
55
59
  if (!cfg.uptimeKuma) {
56
60
  return {
@@ -83,11 +87,13 @@ export async function resolveIncidents(
83
87
 
84
88
  try {
85
89
  const { result } = await withKuma(cfg.uptimeKuma, async (kuma) => {
90
+ const failedMonitors: string[] = []
86
91
  const perTarget = await Promise.all(
87
92
  targets.map(async (t) => {
88
93
  try {
89
94
  return { t, beats: await kuma.getMonitorBeats(t.id, opts.hours) }
90
95
  } catch {
96
+ failedMonitors.push(`${t.domain} ${t.kind}`)
91
97
  return { t, beats: [] as KumaBeat[] }
92
98
  }
93
99
  }),
@@ -115,17 +121,18 @@ export async function resolveIncidents(
115
121
  const looksLikeDomainList = domainList.every((d) => /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/.test(d))
116
122
  if (t.kind === "fatal" && status === "down" && looksLikeDomainList) {
117
123
  for (const domain of domainList) {
118
- events.push({ domain, kind: t.kind, status, time: b.time, message })
124
+ events.push({ domain, kind: t.kind, status, time: b.time, message, monitorId: t.id })
119
125
  }
120
126
  continue
121
127
  }
122
- events.push({ domain: t.domain, kind: t.kind, status, time: b.time, message })
128
+ events.push({ domain: t.domain, kind: t.kind, status, time: b.time, message, monitorId: t.id })
123
129
  }
124
130
  }
125
131
  events.sort((a, b) => a.time.localeCompare(b.time))
126
- return events
132
+ const kumaAlerted = opts.wiring ? (await kuma.getMonitors()).filter((m) => Object.keys((m.notificationIDList as object | null) ?? {}).length > 0).map((m) => m.id) : undefined
133
+ return { events, failedMonitors, kumaAlerted }
127
134
  })
128
- return { ok: true, windowHours: opts.hours, events: result }
135
+ return { ok: true, windowHours: opts.hours, ...result }
129
136
  } catch (err) {
130
137
  const message = err instanceof KumaError ? err.message : `Unexpected error: ${(err as Error).message}`
131
138
  return { ok: false, reason: "kuma_error", message }
@@ -7,7 +7,7 @@ import { existsSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSy
7
7
  import { join, relative, resolve } from "node:path"
8
8
  import { loadConfig, saveConfig } from "../config.ts"
9
9
  import { schedulerModule } from "../modules/scheduler.ts"
10
- import { fmt, nextRunText } from "./schedule.ts"
10
+ import { fmt, nextRunText, parseDate } from "./schedule.ts"
11
11
  import {
12
12
  defaultSourceName,
13
13
  expandHome,
@@ -29,6 +29,7 @@ import {
29
29
  } from "./schedulerJobs.ts"
30
30
  import { applyInstall, applyUninstall, binHasScheduler, NO_SCHEDULER, planInstall, planUninstall } from "./schedulerInstall.ts"
31
31
  import { tick } from "./schedulerTick.ts"
32
+ import { extendJob, PERIOD_RE } from "./schedulerExtend.ts"
32
33
  import { prepareWorkDir, tilde } from "./home.ts"
33
34
  import { applyStationPlan, connectHeartbeat, loadStationPlan, pagerProblem, removeHeartbeat, sendHeartbeat, stationPlanText, tickHeartbeat } from "./heartbeat.ts"
34
35
  import { git, gitPull, readClaim, removeClaim, stationName, writeClaim } from "./schedulerGit.ts"
@@ -53,6 +54,7 @@ const VALUE_FLAGS = new Set([
53
54
  "--prompt-file",
54
55
  "--bin",
55
56
  "--late",
57
+ "--by",
56
58
  ])
57
59
 
58
60
  // Header keys `add` writes from flags, in file order.
@@ -76,6 +78,9 @@ const USAGE = `Usage:
76
78
  spinuptui scheduler add <id> --kind ${KINDS.join("|")} (--when "YYYY-MM-DD HH:MM" | --when now | --every <schedule>)
77
79
  --expires YYYY-MM-DD --created-by <who> [--source <name>] [options]
78
80
  [--body-file <path> | -- <body>] (else stdin)
81
+ spinuptui scheduler extend <id> (--expires YYYY-MM-DD | --by 30d|2w|6m|1y) [--source <name>]
82
+ move its expiry later; --by counts from the current expiry
83
+ (or from today, if it has already expired)
79
84
  spinuptui scheduler cancel <id> [--source <name>]
80
85
  spinuptui scheduler run <id> [--source <name>] run it now, on this machine
81
86
  spinuptui scheduler validate [--source <name>]
@@ -156,6 +161,8 @@ export async function runScheduler(argv: string[]): Promise<number> {
156
161
  return show(pos, p)
157
162
  case "add":
158
163
  return add(pos, p, json)
164
+ case "extend":
165
+ return extend(pos, p, json)
159
166
  case "cancel":
160
167
  return cancel(pos, p, json)
161
168
  case "run":
@@ -321,6 +328,28 @@ async function add(pos: string[], p: Parsed, json: boolean): Promise<number> {
321
328
  return 0
322
329
  }
323
330
 
331
+ async function extend(pos: string[], p: Parsed, json: boolean): Promise<number> {
332
+ const id = pos[0]
333
+ const date = p.flags.get("--expires")
334
+ const by = p.flags.get("--by")
335
+ if (!id || !!date === !!by) return fail(json, "usage", `Give a job id and exactly one of --expires or --by.\n${USAGE}`)
336
+ if (by && !PERIOD_RE.test(by.toLowerCase())) return fail(json, "usage", `--by takes a period like 30d, 2w, 6m or 1y, got "${by}".`)
337
+ if (date) {
338
+ try {
339
+ parseDate(date)
340
+ } catch (err) {
341
+ return fail(json, "usage", (err as Error).message.replace(/^expires:/, "--expires"))
342
+ }
343
+ }
344
+ const s = sourceOrFail(p, json)
345
+ if (typeof s === "number") return s
346
+ const r = await extendJob(s, id, { date, by }, { push: !p.flags.has("--no-push") })
347
+ if (!r.ok) return fail(json, r.reason, r.message, r.remedy)
348
+ if (json) console.log(JSON.stringify(r))
349
+ else console.log(`Extended ${s.name}/${id}: expires ${r.to} (was ${r.from})${r.pushed ? ", pushed" : ""}.`)
350
+ return 0
351
+ }
352
+
324
353
  async function cancel(pos: string[], p: Parsed, json: boolean): Promise<number> {
325
354
  const id = pos[0]
326
355
  if (!id) return fail(json, "usage", USAGE)