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.
- package/CHANGELOG.md +32 -1
- package/README.md +12 -3
- package/package.json +1 -1
- package/src/config.ts +32 -0
- package/src/index.tsx +11 -0
- package/src/lib/beacon.test.ts +14 -0
- package/src/lib/beacon.ts +14 -1
- package/src/lib/beaconBoard.test.ts +69 -0
- package/src/lib/beaconBoard.ts +197 -0
- package/src/lib/beaconListen.test.ts +190 -0
- package/src/lib/beaconListen.ts +355 -0
- package/src/lib/beaconListenInstall.ts +251 -0
- package/src/lib/cliBeacon.ts +115 -3
- package/src/lib/cliIncidents.ts +13 -6
- package/src/lib/cliScheduler.ts +30 -1
- package/src/lib/cliSentinel.ts +451 -0
- package/src/lib/heartbeat.ts +5 -0
- package/src/lib/schedule.ts +4 -1
- package/src/lib/scheduler.test.ts +189 -1
- package/src/lib/schedulerExtend.ts +105 -0
- package/src/lib/schedulerInstall.ts +36 -15
- package/src/lib/schedulerJobs.ts +4 -2
- package/src/lib/schedulerRun.ts +61 -15
- package/src/lib/schedulerTick.ts +35 -13
- package/src/lib/sentinelAttach.test.ts +45 -0
- package/src/lib/sentinelAttach.ts +98 -0
- package/src/lib/sentinelDigest.test.ts +82 -0
- package/src/lib/sentinelDigest.ts +261 -0
- package/src/lib/sentinelExpect.test.ts +82 -0
- package/src/lib/sentinelExpect.ts +197 -0
- package/src/lib/sentinelIncidents.test.ts +145 -0
- package/src/lib/sentinelIncidents.ts +194 -0
- package/src/lib/sentinelPrompt.ts +89 -0
- package/src/lib/sentinelReply.ts +36 -0
- package/src/lib/sentinelState.ts +246 -0
- package/src/lib/sentinelTick.test.ts +214 -0
- package/src/lib/sentinelTick.ts +265 -0
- package/src/lib/sentinelWorker.test.ts +170 -0
- package/src/lib/sentinelWorker.ts +291 -0
- package/src/modules/beacon.ts +9 -1
- package/src/modules/contract.ts +34 -0
- package/src/modules/index.ts +2 -7
- package/src/modules/scheduler.ts +147 -6
- package/src/modules/sentinel.ts +90 -0
- package/src/ui/App.tsx +8 -0
- package/src/ui/store.tsx +11 -0
- package/src/ui/views/BeaconSetup.tsx +11 -7
- package/src/ui/views/ListenerSetup.tsx +205 -0
- package/src/ui/views/Modules.tsx +70 -5
- 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, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
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
|
+
}
|
package/src/lib/cliBeacon.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/lib/cliIncidents.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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 }
|
package/src/lib/cliScheduler.ts
CHANGED
|
@@ -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)
|