spinuptui 1.3.0-alpha.4 → 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 (44) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +8 -2
  3. package/package.json +1 -1
  4. package/src/config.ts +29 -0
  5. package/src/index.tsx +8 -0
  6. package/src/lib/beacon.test.ts +14 -0
  7. package/src/lib/beacon.ts +4 -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 +30 -1
  11. package/src/lib/beaconListen.ts +40 -17
  12. package/src/lib/beaconListenInstall.ts +13 -4
  13. package/src/lib/cliBeacon.ts +25 -1
  14. package/src/lib/cliIncidents.ts +13 -6
  15. package/src/lib/cliSentinel.ts +451 -0
  16. package/src/lib/heartbeat.ts +5 -0
  17. package/src/lib/schedule.ts +4 -1
  18. package/src/lib/scheduler.test.ts +88 -1
  19. package/src/lib/schedulerJobs.ts +4 -2
  20. package/src/lib/schedulerRun.ts +61 -15
  21. package/src/lib/schedulerTick.ts +35 -13
  22. package/src/lib/sentinelAttach.test.ts +45 -0
  23. package/src/lib/sentinelAttach.ts +98 -0
  24. package/src/lib/sentinelDigest.test.ts +82 -0
  25. package/src/lib/sentinelDigest.ts +261 -0
  26. package/src/lib/sentinelExpect.test.ts +82 -0
  27. package/src/lib/sentinelExpect.ts +197 -0
  28. package/src/lib/sentinelIncidents.test.ts +145 -0
  29. package/src/lib/sentinelIncidents.ts +194 -0
  30. package/src/lib/sentinelPrompt.ts +89 -0
  31. package/src/lib/sentinelReply.ts +36 -0
  32. package/src/lib/sentinelState.ts +246 -0
  33. package/src/lib/sentinelTick.test.ts +214 -0
  34. package/src/lib/sentinelTick.ts +265 -0
  35. package/src/lib/sentinelWorker.test.ts +170 -0
  36. package/src/lib/sentinelWorker.ts +291 -0
  37. package/src/modules/beacon.ts +3 -0
  38. package/src/modules/index.ts +2 -7
  39. package/src/modules/scheduler.ts +31 -8
  40. package/src/modules/sentinel.ts +90 -0
  41. package/src/ui/App.tsx +4 -0
  42. package/src/ui/store.tsx +5 -0
  43. package/src/ui/views/Modules.tsx +56 -3
  44. package/src/ui/views/SentinelSetup.tsx +171 -0
package/CHANGELOG.md CHANGED
@@ -11,6 +11,24 @@ version; such changes are called out here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.3.0-alpha.5] - 2026-10-30
15
+
16
+ A pre-release, published on npm's `next` tag (`bun add -g spinuptui@next --no-cache`). `@latest` and Homebrew are on 1.2.1.
17
+
18
+ ### Added
19
+ - **Sentinel: know why, not just that it's down.** The third module checks Uptime Kuma every 5 minutes, groups what went down by server, pages you, and writes it up: the evidence on its own, or a full diagnosis if you bring an agent CLI. The agent reads servers only through `ssh-exec` and can't post anything; Sentinel saves its report and does the posting. A repeat within 6 hours skips the diagnosis and offers **Diagnose anyway**. A lapsed agent login delays work instead of losing it. Set it up from the Modules tab, or `spinuptui sentinel setup`. See [docs/sentinel.md](docs/sentinel.md).
20
+ - **A morning digest that only speaks up about what's new.** Sentinel's 08:00 message covers station health, the last 24 hours, server updates and your expectation checks: a crawler that stopped arriving, a cache that stopped hitting, a box that latches under load. A flag is New on the day it trips, then Ongoing with its age, and the digest is silent unless something needs you.
21
+ - **`spinuptui sentinel attach`** adopts an existing Kuma's monitors on a fresh install, without changing anything in Kuma.
22
+ - **Jobs can say they're blocked.** A job that ran but couldn't do its work now reports `blocked` instead of `ok`: an agent with `STATUS: blocked`, a script by exiting 75. Failed and blocked pages, and `/jobs`, get a **Run again** button. It re-runs the job exactly as written and never adds permissions.
23
+ - **Team boards.** `spinuptui beacon add basecamp <name> --project <id>` connects a Basecamp message board for your own team's record. It can't be paged, and it's where Sentinel posts reports.
24
+
25
+ ### Changed
26
+ - **`spinuptui incidents` output** gains `monitorId` on each event and a `failedMonitors` list, so a caller can tell "no events" from "couldn't read that monitor".
27
+
28
+ ### Fixed
29
+ - **A long job no longer makes Kuma report a healthy station as dead.** The heartbeat was only sent when a tick finished, so a job past the 15-minute window looked like an outage. A tick that finds another still running now pings too.
30
+ - **Beacon's listener no longer reads as down right after it starts.** Its first poll takes up to 50 seconds, so `beacon listener status` showed ✗ after an install, and a Scheduler tick in that window could have paged about a listener that was fine.
31
+
14
32
  ## [1.3.0-alpha.4] - 2026-10-30
15
33
 
16
34
  A pre-release, published on npm's `next` tag (`bun add -g spinuptui@next --no-cache`). `@latest` and Homebrew are on 1.2.1.
@@ -1545,7 +1563,8 @@ Initial tagged release.
1545
1563
  ### Notes
1546
1564
  - Read-only release: works with a SpinupWP **Read Only** API token.
1547
1565
 
1548
- [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.4...HEAD
1566
+ [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.5...HEAD
1567
+ [1.3.0-alpha.5]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.4...v1.3.0-alpha.5
1549
1568
  [1.3.0-alpha.4]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.3...v1.3.0-alpha.4
1550
1569
  [1.3.0-alpha.3]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.2...v1.3.0-alpha.3
1551
1570
  [1.2.1]: https://github.com/mwender/spinupwp-tui/compare/v1.2.0...v1.2.1
package/README.md CHANGED
@@ -91,6 +91,11 @@
91
91
  any machine. One LaunchAgent ticks every 5 minutes and is the clock the other
92
92
  modules run on, and a station heartbeat (`h`) pings your Uptime Kuma each
93
93
  tick, so a station that stops is noticed from elsewhere. → [docs/scheduler.md](docs/scheduler.md)
94
+ Then **Sentinel**: watches the fleet, works out what actually broke, and
95
+ writes it up. A page tells you why, not just that something's down, and a
96
+ morning digest covers what monitoring can't see, like a crawler that stopped
97
+ arriving or a cache that stopped hitting. Bring your own agent for diagnosis,
98
+ or use it without one. → [docs/sentinel.md](docs/sentinel.md)
94
99
  - **Non-interactive CLI subcommands for external tooling** — `spinuptui ssh <domain>`
95
100
  resolves a domain to a live SSH target with a live connectivity probe;
96
101
  `spinuptui ssh-exec <domain> -- <command>` runs a command over SSH, but only
@@ -385,8 +390,9 @@ These can be set in `config.json` or via an environment variable:
385
390
  | `w` | Open the selected server/site in the SpinupWP web app |
386
391
  | `/` | Jump to global search |
387
392
  | `A` | Accounts — switch, add, rename, or remove SpinupWP accounts (from anywhere) |
388
- | `⏎` / `s` | Set up the selected module — Beacon opens its guided Telegram setup, Scheduler its install plan (Modules tab) |
389
- | `t` | Send a test page through Beacon (Modules tab, once Beacon is set up) |
393
+ | `⏎` / `s` | Set up the selected module — Beacon opens its guided Telegram setup, Scheduler its install plan, Sentinel its setup (Modules tab) |
394
+ | `t` | Send a test page through Beacon, or preview Sentinel's digest (Modules tab) |
395
+ | `p` | Pause Sentinel for 2 hours, or resume it (Modules tab, Sentinel) |
390
396
  | `l` | Beacon's listener: install it so the pager answers `/jobs`, see whether it's running, or remove it (Modules tab, Beacon) |
391
397
  | `f` | Your SpinupTUI folder: where jobs and reports live, and sharing it across machines (Modules tab, Scheduler) |
392
398
  | `h` | Station heartbeat: set up the Uptime Kuma push monitor, see the last ping, test or remove it (Modules tab, Scheduler) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "1.3.0-alpha.4",
3
+ "version": "1.3.0-alpha.5",
4
4
  "description": "SpinupTUI — a terminal dashboard for browsing, monitoring, and managing your SpinupWP servers and sites.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.ts CHANGED
@@ -112,6 +112,7 @@ export interface AppConfig {
112
112
  // Scheduler's sources and installed dispatcher (lib/scheduler*.ts,
113
113
  // docs/2026-10-20_scheduler-spec.md). Machine-level: a station runs one clock.
114
114
  scheduler: SchedulerStored | null
115
+ sentinel: SentinelStored | null
115
116
  // Where modules put the user's work (Scheduler's jobs and reports…); null =
116
117
  // the default, ~/SpinupTUI. SPINUPTUI_HOME overrides. See modules/contract.ts.
117
118
  homeDir: string | null
@@ -127,14 +128,40 @@ export interface BeaconTelegramChannel {
127
128
  // are additive.
128
129
  export type BeaconChannel = BeaconTelegramChannel
129
130
 
131
+ // A Basecamp project's message board, for the team's own record. Not a channel:
132
+ // it's never paged, and only a named post reaches it. `audience` exists so a
133
+ // client-facing board can't be reached by accident: only "team" is accepted
134
+ // until the client channel's gate is built.
135
+ export interface BeaconBoard {
136
+ type: "basecamp"
137
+ projectId: string
138
+ mention?: string // the person @-mentioned on a new post
139
+ audience: "team"
140
+ }
141
+
130
142
  export interface BeaconStored {
131
143
  defaultChannel?: string
132
144
  channels?: Record<string, BeaconChannel>
145
+ boards?: Record<string, BeaconBoard>
133
146
  // Written by `beacon listener install --apply`, cleared by uninstall: the
134
147
  // resident process that answers the operator (lib/beaconListen.ts).
135
148
  listener?: { label: string; unitPath: string; bin: string; channel: string; installedAt: string }
136
149
  }
137
150
 
151
+ // Sentinel's settings (docs/2026-10-30_sentinel-spec.md). Machine-level, and
152
+ // written only by setup: everything it does while running lives in its state
153
+ // directory.
154
+ export interface SentinelStored {
155
+ enabled?: boolean
156
+ mode?: "live" | "shadow"
157
+ channel?: string | null // Beacon channel for pages; default Beacon's default
158
+ reportTo?: string | null // a Beacon board's name
159
+ workspace?: string
160
+ recurrenceHours?: number
161
+ maxDiagnosesPerDay?: number
162
+ diagnosis?: { agent?: "claude" | "codex" | null; model?: string | null; timeoutMin?: number; allow?: string | null }
163
+ }
164
+
138
165
  export interface SchedulerStored {
139
166
  // Named job sources beyond the built-in `local` one: directories holding
140
167
  // jobs/ and runs/, pulled and pushed when they are git repositories.
@@ -249,6 +276,7 @@ export interface StoredConfig {
249
276
  lastSeenVersion?: string
250
277
  beacon?: BeaconStored
251
278
  scheduler?: SchedulerStored
279
+ sentinel?: SentinelStored
252
280
  homeDir?: string
253
281
  }
254
282
 
@@ -537,6 +565,7 @@ export function loadConfig(): AppConfig {
537
565
  lastSeenVersion: stored.lastSeenVersion?.trim() || null,
538
566
  beacon: stored.beacon ?? null,
539
567
  scheduler: stored.scheduler ?? null,
568
+ sentinel: stored.sentinel ?? null,
540
569
  homeDir: stored.homeDir?.trim() || null,
541
570
  }
542
571
  }
package/src/index.tsx CHANGED
@@ -115,6 +115,9 @@ Usage:
115
115
  Queue a follow-up for this station to run later and report
116
116
  on through Beacon (\`spinuptui scheduler\` for the rest)
117
117
  spinuptui scheduler list | status | install --plan|--apply
118
+ spinuptui sentinel setup | attach | status | list | pause | expect
119
+ Watch the fleet, work out what broke, write it up
120
+ (runs as Scheduler jobs; see docs/sentinel.md)
118
121
  spinuptui home Where the SpinupTUI folder (your jobs and reports) is
119
122
  spinuptui home sync --plan|--apply --github <name> | --remote <url> | --clone <url>
120
123
  Share that folder across machines through a private git
@@ -328,6 +331,11 @@ if (command === "scheduler") {
328
331
  process.exit(await runScheduler(args.slice(args.indexOf("scheduler") + 1)))
329
332
  }
330
333
 
334
+ if (command === "sentinel") {
335
+ const { runSentinel } = await import("./lib/cliSentinel.ts")
336
+ process.exit(await runSentinel(args.slice(args.indexOf("sentinel") + 1)))
337
+ }
338
+
331
339
  // `spinuptui login` forces the onboarding wizard even when a token already exists,
332
340
  // so the token can be (re)saved to the config file for global use.
333
341
  const { startDashboard } = await import("./ui/main.tsx")
@@ -138,3 +138,17 @@ describe("beaconSend", () => {
138
138
  expect(f.calls.length).toBe(2)
139
139
  })
140
140
  })
141
+
142
+ describe("buttons on a page", () => {
143
+ test("go on the last part only", async () => {
144
+ const bodies: URLSearchParams[] = []
145
+ const beacon: ResolvedBeacon = { channels: [{ name: "pager", channel: { type: "telegram", botToken: "1:a", chatId: "2" }, env: false }], defaultChannel: "pager" }
146
+ const fetchImpl = (async (_u: string, init?: RequestInit) => (bodies.push(init!.body as URLSearchParams), Response.json({ ok: true, result: {} }))) as never
147
+ const long = Array.from({ length: 300 }, (_, i) => `line ${i} ${"x".repeat(40)}`).join("\n")
148
+ const r = await beaconSend(beacon, long, { replyMarkup: '{"inline_keyboard":[]}', fetchImpl, log: false })
149
+ expect(r.ok).toBe(true)
150
+ expect(bodies.length).toBeGreaterThan(1)
151
+ expect(bodies.slice(0, -1).some((b) => b.has("reply_markup"))).toBe(false)
152
+ expect(bodies[bodies.length - 1]!.get("reply_markup")).toBe('{"inline_keyboard":[]}')
153
+ })
154
+ })
package/src/lib/beacon.ts CHANGED
@@ -227,6 +227,8 @@ export type SendResult =
227
227
  export interface SendOptions {
228
228
  to?: string | null
229
229
  silent?: boolean
230
+ // Telegram's reply_markup for the last part. Build it with pageMarkup().
231
+ replyMarkup?: string
230
232
  fetchImpl?: Fetch
231
233
  wait?: (ms: number) => Promise<unknown>
232
234
  log?: boolean
@@ -271,7 +273,7 @@ export async function beaconSend(beacon: ResolvedBeacon, text: string, opts: Sen
271
273
  const wait = opts.wait ?? sleep
272
274
  let sent = 0
273
275
  let failure: string | undefined
274
- for (const part of parts) {
276
+ for (const [i, part] of parts.entries()) {
275
277
  const r = await telegramWithRetry(
276
278
  channel.botToken,
277
279
  "sendMessage",
@@ -280,6 +282,7 @@ export async function beaconSend(beacon: ResolvedBeacon, text: string, opts: Sen
280
282
  text: part,
281
283
  disable_web_page_preview: "true",
282
284
  ...(opts.silent ? { disable_notification: "true" } : {}),
285
+ ...(opts.replyMarkup && i === parts.length - 1 ? { reply_markup: opts.replyMarkup } : {}),
283
286
  },
284
287
  fetchImpl,
285
288
  wait,
@@ -0,0 +1,69 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import type { BeaconBoard } from "../config.ts"
3
+ import { markdownToBasecampHtml, postToBoard, splitTitle, type BasecampRun } from "./beaconBoard.ts"
4
+
5
+ const board: BeaconBoard = { type: "basecamp", projectId: "123", mention: "Pat Doe", audience: "team" }
6
+
7
+ function fakeCli(post: { code: number; out: string }): { run: BasecampRun; calls: string[][] } {
8
+ const calls: string[][] = []
9
+ const run: BasecampRun = async (args) => {
10
+ calls.push(args)
11
+ if (args[0] === "api" && args[1] === "get") return { code: 0, out: JSON.stringify({ ok: true, data: { dock: [{ id: 9, name: "message_board" }] } }) }
12
+ if (args[0] === "people") return { code: 0, out: JSON.stringify({ ok: true, data: [{ name: "Pat Doe", attachable_sgid: "sg" }] }) }
13
+ return post
14
+ }
15
+ return { run, calls }
16
+ }
17
+
18
+ describe("markdownToBasecampHtml", () => {
19
+ test("renders what a report uses", () => {
20
+ const html = markdownToBasecampHtml("## Root cause\n\nThe **cron** ran `wp` twice.\n\n- [x] done\n- [ ] todo\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\n```\nx < y\n```\n")
21
+ expect(html).toContain("<h2>Root cause</h2>")
22
+ expect(html).toContain("<p>The <strong>cron</strong> ran <code>wp</code> twice.</p>")
23
+ expect(html).toContain("<li>☑ done</li>")
24
+ expect(html).toContain("<li>☐ todo</li>")
25
+ expect(html).toContain("<th>A</th>")
26
+ expect(html).toContain("<td>2</td>")
27
+ expect(html).toContain("<pre><code>x &lt; y</code></pre>")
28
+ })
29
+
30
+ test("escapes anything a log line could carry, and joins soft-wrapped text", () => {
31
+ expect(markdownToBasecampHtml("a <script>alert(1)</script>\nb")).toBe("<p>a &lt;script&gt;alert(1)&lt;/script&gt; b</p>")
32
+ expect(markdownToBasecampHtml("<img src=x onerror=y>")).toBe("<p>&lt;img src=x onerror=y&gt;</p>")
33
+ })
34
+
35
+ test("keeps real links and flattens local ones", () => {
36
+ expect(markdownToBasecampHtml("[a](https://example.com) [b](2026-10-30-x.md)")).toBe('<p><a href="https://example.com">a</a> b</p>')
37
+ })
38
+ })
39
+
40
+ describe("postToBoard", () => {
41
+ const report = "# 10/30/2026 (13:34) - example.com - health\n\n## Symptom\n\nDown."
42
+
43
+ test("uses the H1 as the title and returns the message", async () => {
44
+ const { run, calls } = fakeCli({ code: 0, out: JSON.stringify({ ok: true, data: { id: 42, app_url: "https://bc/42" } }) })
45
+ expect(await postToBoard(board, report, run)).toEqual({ ok: true, id: "42", url: "https://bc/42" })
46
+ const post = calls.find((c) => c[1] === "post")!
47
+ expect(post[2]).toBe("buckets/123/message_boards/9/messages.json")
48
+ const payload = JSON.parse(post[4]!) as { subject: string; content: string }
49
+ expect(payload.subject).toBe("10/30/2026 (13:34) - example.com - health")
50
+ expect(payload.content).toContain('sgid="sg"')
51
+ expect(payload.content).not.toContain("<h1")
52
+ })
53
+
54
+ test("refuses a report with no title, and anything but a team board", async () => {
55
+ const { run } = fakeCli({ code: 0, out: "{}" })
56
+ expect(await postToBoard(board, "no heading", run)).toMatchObject({ ok: false, reason: "bad_report" })
57
+ expect(await postToBoard({ ...board, audience: "client" as never }, report, run)).toMatchObject({ ok: false })
58
+ })
59
+
60
+ test("reports a refused post", async () => {
61
+ const { run } = fakeCli({ code: 1, out: "nope" })
62
+ expect(await postToBoard(board, report, run)).toMatchObject({ ok: false, reason: "api" })
63
+ })
64
+
65
+ test("splitTitle", () => {
66
+ expect(splitTitle("# T\nbody")).toEqual({ title: "T", body: "body" })
67
+ expect(splitTitle("body").title).toBe("")
68
+ })
69
+ })
@@ -0,0 +1,197 @@
1
+ // Beacon's boards: a Basecamp project's message board, for the team's own record
2
+ // (docs/2026-10-30_sentinel-spec.md, "Basecamp"). A board isn't a pager channel.
3
+ // It carries reports for the team that owns the fleet, and nothing here can reach
4
+ // a client: `audience` only accepts "team" until that gate is built.
5
+ //
6
+ // The HTML is made here rather than by the CLI's converter, whose output depends
7
+ // on the installed version: one version posted a whole report as literal ## and
8
+ // **. The post goes through `basecamp api`, so its login stays the CLI's business.
9
+ // Basecamp has no idempotency key, so a caller must record a post before and
10
+ // after making it (see the worker) rather than retry blindly.
11
+
12
+ import type { BeaconBoard } from "../config.ts"
13
+ import { jobEnv } from "./schedulerRun.ts"
14
+ import { spawn } from "./spawn.ts"
15
+
16
+ // ---- Markdown → Basecamp HTML ------------------------------------------------------
17
+
18
+ const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
19
+ const escAttr = (s: string) => esc(s).replace(/"/g, "&quot;")
20
+
21
+ const HEADING = /^(#{1,6})\s+(.*)$/
22
+ const LIST_ITEM = /^\s*(?:([-*+])|(\d+)\.)\s+(.*)$/
23
+ const TASK_MARK = /^\[([ xX])\]\s+/
24
+ const TABLE_SEP = /^\s*\|[\s:|-]+\|\s*$/
25
+
26
+ function inline(text: string): string {
27
+ const spans: string[] = []
28
+ let out = text.replace(/`([^`]+)`/g, (_, code: string) => {
29
+ spans.push(`<code>${esc(code)}</code>`)
30
+ return `\x00${spans.length - 1}\x00`
31
+ })
32
+ out = esc(out)
33
+ // A relative link points at a local file that means nothing to a board reader.
34
+ out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label: string, target: string) =>
35
+ /^(https?:\/\/|mailto:)/.test(target) ? `<a href="${escAttr(target)}">${label}</a>` : label,
36
+ )
37
+ out = out.replace(/\*\*(.+?)\*\*/gs, "<strong>$1</strong>")
38
+ out = out.replace(/(?<![\w*])\*(?!\s)([^*]+?)(?<!\s)\*(?![\w*])/g, "<em>$1</em>")
39
+ // eslint-disable-next-line no-control-regex
40
+ return out.replace(/\x00(\d+)\x00/g, (_, n: string) => spans[Number(n)]!)
41
+ }
42
+
43
+ function splitRow(line: string): string[] {
44
+ return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim())
45
+ }
46
+
47
+ // Headings, paragraphs, lists with task items, GFM tables, fenced code,
48
+ // blockquotes, rules, bold, italic, code and links: what a report uses.
49
+ export function markdownToBasecampHtml(md: string): string {
50
+ const lines = md.replace(/<!--[\s\S]*?-->/g, "").split("\n")
51
+ const out: string[] = []
52
+ let i = 0
53
+ while (i < lines.length) {
54
+ const line = lines[i]!
55
+ if (!line.trim()) {
56
+ i++
57
+ continue
58
+ }
59
+ if (line.trimStart().startsWith("```")) {
60
+ const code: string[] = []
61
+ i++
62
+ while (i < lines.length && !lines[i]!.trimStart().startsWith("```")) code.push(lines[i++]!)
63
+ i++
64
+ out.push(`<pre><code>${esc(code.join("\n"))}</code></pre>`)
65
+ continue
66
+ }
67
+ if (/^\s*-{3,}\s*$/.test(line)) {
68
+ out.push("<hr>")
69
+ i++
70
+ continue
71
+ }
72
+ const heading = HEADING.exec(line)
73
+ if (heading) {
74
+ const level = Math.min(Math.max(heading[1]!.length, 2), 4) // never an h1 in a post
75
+ out.push(`<h${level}>${inline(heading[2]!.trim())}</h${level}>`)
76
+ i++
77
+ continue
78
+ }
79
+ if (line.trim().startsWith("|") && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1]!)) {
80
+ const rows = [splitRow(line)]
81
+ i += 2
82
+ while (i < lines.length && lines[i]!.trim().startsWith("|")) rows.push(splitRow(lines[i++]!))
83
+ const body = rows.map((cells, n) => `<tr>${cells.map((c) => `<${n ? "td" : "th"}>${inline(c)}</${n ? "td" : "th"}>`).join("")}</tr>`)
84
+ out.push(`<table>\n${body.join("\n")}\n</table>`)
85
+ continue
86
+ }
87
+ if (line.trimStart().startsWith("> ")) {
88
+ const quote: string[] = []
89
+ while (i < lines.length && lines[i]!.trimStart().startsWith(">")) quote.push(lines[i++]!.trimStart().slice(1).trim())
90
+ out.push(`<blockquote><p>${inline(quote.join(" "))}</p></blockquote>`)
91
+ continue
92
+ }
93
+ const first = LIST_ITEM.exec(line)
94
+ if (first) {
95
+ const ordered = first[2] !== undefined
96
+ const items: string[] = []
97
+ while (i < lines.length && lines[i]!.trim()) {
98
+ const m = LIST_ITEM.exec(lines[i]!)
99
+ if (m && (m[2] !== undefined) === ordered) {
100
+ const text = m[3]!
101
+ const task = TASK_MARK.exec(text)
102
+ items.push(task ? `${task[1]!.toLowerCase() === "x" ? "☑" : "☐"} ${text.slice(task[0].length)}` : text)
103
+ } else {
104
+ items[items.length - 1] += ` ${lines[i]!.trim()}`
105
+ }
106
+ i++
107
+ }
108
+ const tag = ordered ? "ol" : "ul"
109
+ out.push(`<${tag}>\n${items.map((t) => `<li>${inline(t)}</li>`).join("\n")}\n</${tag}>`)
110
+ continue
111
+ }
112
+ const para: string[] = []
113
+ while (i < lines.length && lines[i]!.trim()) {
114
+ const next = lines[i]!
115
+ if (para.length && (HEADING.test(next) || LIST_ITEM.test(next) || /^\s*(```|> |\|)/.test(next))) break
116
+ para.push(next.trim())
117
+ i++
118
+ }
119
+ out.push(`<p>${inline(para.join(" "))}</p>`)
120
+ }
121
+ return out.join("\n")
122
+ }
123
+
124
+ // A report's first line, its H1, is the post's title.
125
+ export function splitTitle(md: string): { title: string; body: string } {
126
+ const m = /^\s*#\s+(.*?)\s*(\n|$)/.exec(md)
127
+ return m ? { title: m[1]!, body: md.slice(m[0].length) } : { title: "", body: md }
128
+ }
129
+
130
+ // ---- the post ----------------------------------------------------------------------
131
+
132
+ export type BasecampRun = (args: string[]) => Promise<{ code: number; out: string }>
133
+
134
+ const realRun: BasecampRun = async (args) => {
135
+ const p = spawn(["basecamp", ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe", env: jobEnv() })
136
+ const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()])
137
+ const code = await p.exited
138
+ return { code, out: code === 0 ? out : `${out}${err}`.trim() }
139
+ }
140
+
141
+ export type BoardPost = { ok: true; id: string; url: string } | { ok: false; reason: "cli_missing" | "auth" | "bad_report" | "api"; message: string }
142
+
143
+ interface CliEnvelope<T> {
144
+ ok?: boolean
145
+ data?: T
146
+ }
147
+
148
+ async function cliJson<T>(run: BasecampRun, args: string[]): Promise<T | null> {
149
+ const r = await run([...args, "--json"])
150
+ if (r.code !== 0) return null
151
+ try {
152
+ const parsed = JSON.parse(r.out) as CliEnvelope<T>
153
+ return parsed.ok === false ? null : (parsed.data ?? null)
154
+ } catch {
155
+ return null
156
+ }
157
+ }
158
+
159
+ // Is the CLI logged in? Only meaningful from the real login session (the
160
+ // dispatcher): a sandboxed shell can't read the Keychain, so a "no" there proves
161
+ // nothing. A digest runs under the dispatcher, which is why it can ask.
162
+ export async function boardLogin(run: BasecampRun = realRun): Promise<{ ok: boolean; detail: string }> {
163
+ try {
164
+ const me = await cliJson<{ identity?: { first_name?: string; last_name?: string } }>(run, ["me"])
165
+ if (!me) return { ok: false, detail: "not logged in: run `basecamp auth login` in a real terminal" }
166
+ return { ok: true, detail: [me.identity?.first_name, me.identity?.last_name].filter(Boolean).join(" ") || "logged in" }
167
+ } catch {
168
+ return { ok: false, detail: "the basecamp CLI isn't on the PATH" }
169
+ }
170
+ }
171
+
172
+ export async function postToBoard(board: BeaconBoard, report: string, run: BasecampRun = realRun): Promise<BoardPost> {
173
+ if (board.audience !== "team") return { ok: false, reason: "api", message: "Only team boards are supported." }
174
+ const { title, body } = splitTitle(report)
175
+ if (!title) return { ok: false, reason: "bad_report", message: "The report has no H1 to use as the post's title." }
176
+ try {
177
+ const project = await cliJson<{ dock?: { id: number; name: string }[] }>(run, ["api", "get", `buckets/${board.projectId}.json`])
178
+ const boardId = project?.dock?.find((d) => d.name === "message_board")?.id
179
+ if (!boardId) return { ok: false, reason: "auth", message: `Couldn't read project ${board.projectId}'s message board. Is \`basecamp\` logged in?` }
180
+ let content = markdownToBasecampHtml(body)
181
+ if (board.mention) {
182
+ // A mention's sgid is signed and expires, so it's resolved every time.
183
+ const people = await cliJson<{ name: string; attachable_sgid: string }[]>(run, ["people", "pingable"])
184
+ const sgid = people?.find((p) => p.name === board.mention)?.attachable_sgid
185
+ if (sgid) content = `<p><bc-attachment sgid="${escAttr(sgid)}" content-type="application/vnd.basecamp.mention"></bc-attachment> — new incident diagnosed and documented.</p>\n${content}`
186
+ }
187
+ const r = await run(["api", "post", `buckets/${board.projectId}/message_boards/${boardId}/messages.json`, "-d", JSON.stringify({ subject: title, status: "active", content }), "--json"])
188
+ if (r.code === 0) {
189
+ const d = (JSON.parse(r.out) as CliEnvelope<{ id: number; app_url: string }>).data
190
+ if (d?.id) return { ok: true, id: String(d.id), url: d.app_url }
191
+ }
192
+ return { ok: false, reason: "api", message: `Basecamp refused the post: ${r.out.slice(0, 300)}` }
193
+ } catch (err) {
194
+ const e = err as NodeJS.ErrnoException
195
+ return e.code === "ENOENT" ? { ok: false, reason: "cli_missing", message: "The basecamp CLI isn't on the PATH." } : { ok: false, reason: "api", message: e.message }
196
+ }
197
+ }
@@ -1,8 +1,9 @@
1
1
  import { beforeEach, describe, expect, test } from "bun:test"
2
- import { mkdtempSync } from "node:fs"
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
3
3
  import { tmpdir } from "node:os"
4
4
  import { join } from "node:path"
5
5
  import { handleUpdate, listen, readListenerState, routesFrom, sendReply, storeButton, takeButton, type TgUpdate } from "./beaconListen.ts"
6
+ import { listenerHealth } from "./beaconListenInstall.ts"
6
7
  import type { BeaconReply } from "../modules/contract.ts"
7
8
 
8
9
  const TOKEN = "123456789:AAEabcdefghijklmnopqrstuvwxyz0123456"
@@ -159,3 +160,31 @@ describe("listen", () => {
159
160
  await first
160
161
  })
161
162
  })
163
+
164
+ describe("listenerHealth", () => {
165
+ const install = (state: Record<string, string>) => {
166
+ const dir = join(process.env.XDG_CONFIG_HOME!, "spinupwp-tui")
167
+ mkdirSync(join(dir, "modules", "beacon"), { recursive: true })
168
+ const unitPath = join(dir, "unit.plist")
169
+ writeFileSync(unitPath, "")
170
+ writeFileSync(join(dir, "config.json"), JSON.stringify({ beacon: { listener: { label: "t", unitPath, bin: "x", channel: "pager", installedAt: "2026-10-30T12:00:00Z" } } }))
171
+ writeFileSync(join(dir, "modules", "beacon", "listener.json"), JSON.stringify(state))
172
+ }
173
+ const now = Date.parse("2026-10-30T12:00:00Z")
174
+
175
+ test("a listener whose first poll is still out is alive, until that takes too long", () => {
176
+ install({ startedAt: "2026-10-30T11:59:40Z" })
177
+ expect(listenerHealth(now)?.ok).toBe(true)
178
+ install({ startedAt: "2026-10-30T11:50:00Z" })
179
+ expect(listenerHealth(now)?.ok).toBe(false)
180
+ })
181
+
182
+ test("stale polls and polls that keep failing are down", () => {
183
+ install({ startedAt: "2026-10-29T00:00:00Z", lastPollAt: "2026-10-30T11:59:30Z" })
184
+ expect(listenerHealth(now)?.ok).toBe(true)
185
+ install({ startedAt: "2026-10-29T00:00:00Z", lastPollAt: "2026-10-30T11:50:00Z" })
186
+ expect(listenerHealth(now)?.ok).toBe(false)
187
+ install({ startedAt: "2026-10-29T00:00:00Z", lastPollAt: "2026-10-30T11:59:30Z", failingSince: "2026-10-30T11:45:00Z", lastError: "Conflict" })
188
+ expect(listenerHealth(now)?.detail).toContain("Conflict")
189
+ })
190
+ })
@@ -21,9 +21,9 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSy
21
21
  import { randomBytes } from "node:crypto"
22
22
  import { dirname, join } from "node:path"
23
23
  import type { BeaconChannel } from "../config.ts"
24
- import { moduleStateDir, type BeaconActionDecl, type BeaconCommandDecl, type BeaconReply, type ModuleDefinition } from "../modules/contract.ts"
24
+ import { moduleStateDir, type BeaconActionDecl, type BeaconButton, type BeaconCommandDecl, type BeaconReply, type ModuleDefinition } from "../modules/contract.ts"
25
25
  import { htmlToPlain, splitMessage, telegramCall, type Fetch } from "./beacon.ts"
26
- import { tryLock } from "./flock.ts"
26
+ import { tryLock, type HeldLock } from "./flock.ts"
27
27
 
28
28
  export const POLL_SECONDS = 50
29
29
  // A /command older than this when it arrives (sent while the listener was down)
@@ -37,6 +37,7 @@ export const listenerStatePath = () => join(listenerDir(), "listener.json")
37
37
  export const listenerLockPath = () => join(listenerDir(), "listener.lock")
38
38
  export const listenerLogPath = () => join(listenerDir(), "listener.log")
39
39
  const buttonsPath = () => join(listenerDir(), "buttons.json")
40
+ const buttonsLockPath = () => join(listenerDir(), "buttons.lock")
40
41
 
41
42
  export interface ListenerState {
42
43
  channel?: string
@@ -112,23 +113,47 @@ interface StoredButton {
112
113
  createdAt: string
113
114
  }
114
115
 
116
+ // The tick and Sentinel's worker store buttons while the listener takes them, so
117
+ // each read-modify-write holds this. It's held for a millisecond, and a button
118
+ // is worth more than the lock: past the wait, go ahead without it.
119
+ function withButtons<T>(fn: () => T): T {
120
+ let lock: HeldLock | null = null
121
+ for (let i = 0; i < 100 && !lock; i++) {
122
+ lock = tryLock(buttonsLockPath())
123
+ if (!lock) Bun.sleepSync(10)
124
+ }
125
+ try {
126
+ return fn()
127
+ } finally {
128
+ lock?.release()
129
+ }
130
+ }
131
+
115
132
  export function storeButton(action: string, payload: Record<string, string>, now = new Date()): string {
116
- const all = readJson<Record<string, StoredButton>>(buttonsPath(), {})
117
- for (const [k, b] of Object.entries(all)) if (now.getTime() - Date.parse(b.createdAt) > BUTTON_TTL_MS) delete all[k]
118
- const token = randomBytes(6).toString("base64url")
119
- all[token] = { action, payload, createdAt: now.toISOString() }
120
- writeJson(buttonsPath(), all)
121
- return token
133
+ return withButtons(() => {
134
+ const all = readJson<Record<string, StoredButton>>(buttonsPath(), {})
135
+ for (const [k, b] of Object.entries(all)) if (now.getTime() - Date.parse(b.createdAt) > BUTTON_TTL_MS) delete all[k]
136
+ const token = randomBytes(6).toString("base64url")
137
+ all[token] = { action, payload, createdAt: now.toISOString() }
138
+ writeJson(buttonsPath(), all)
139
+ return token
140
+ })
141
+ }
142
+
143
+ export function buttonMarkup(buttons: BeaconButton[][], now = new Date()): string {
144
+ return JSON.stringify({ inline_keyboard: buttons.map((row) => row.map((b) => ({ text: b.text, callback_data: `b:${storeButton(b.action, b.payload, now)}` }))) })
122
145
  }
123
146
 
124
147
  // The button for `token`, removed so it can't be used twice.
125
148
  export function takeButton(token: string): StoredButton | null {
126
- const all = readJson<Record<string, StoredButton>>(buttonsPath(), {})
127
- const b = all[token]
128
- if (!b) return null
129
- delete all[token]
130
- writeJson(buttonsPath(), all)
131
- return b
149
+ return withButtons(() => {
150
+ const all = readJson<Record<string, StoredButton>>(buttonsPath(), {})
151
+ const b = all[token]
152
+ if (!b) return null
153
+ delete all[token]
154
+ writeJson(buttonsPath(), all)
155
+ return b
156
+ })
132
157
  }
133
158
 
134
159
  // ---- Telegram -----------------------------------------------------------------
@@ -168,9 +193,7 @@ export async function sendReply(ctx: ListenContext, reply: BeaconReply): Promise
168
193
  for (let i = 0; i < parts.length; i++) {
169
194
  const params: Record<string, string> = { chat_id: ctx.channel.chatId, text: parts[i]!, disable_web_page_preview: "true" }
170
195
  if (i === parts.length - 1 && reply.buttons?.length) {
171
- const now = ctx.now?.() ?? new Date()
172
- const keyboard = reply.buttons.map((row) => row.map((b) => ({ text: b.text, callback_data: `b:${storeButton(b.action, b.payload, now)}` })))
173
- params.reply_markup = JSON.stringify({ inline_keyboard: keyboard })
196
+ params.reply_markup = buttonMarkup(reply.buttons, ctx.now?.() ?? new Date())
174
197
  }
175
198
  if (reply.html) params.parse_mode = "HTML"
176
199
  let r = await telegramCall(ctx.channel.botToken, "sendMessage", params, ctx.fetchImpl)
@@ -9,8 +9,9 @@ import { homedir } from "node:os"
9
9
  import { dirname, join } from "node:path"
10
10
  import { loadConfig, saveConfig } from "../config.ts"
11
11
  import { beaconFromConfig, beaconSend, pickChannel } from "./beacon.ts"
12
- import { channelProblem, listenerDir, listenerLogPath, readListenerState } from "./beaconListen.ts"
12
+ import { buttonMarkup, channelProblem, listenerDir, listenerLogPath, readListenerState } from "./beaconListen.ts"
13
13
  import { helpIncludes, launchctl, resolveRunner } from "./schedulerInstall.ts"
14
+ import type { BeaconButton } from "../modules/contract.ts"
14
15
 
15
16
  export const DEFAULT_LISTENER_LABEL = "com.spinuptui.beacon-listener"
16
17
  // Three missed polls, with room for a restart.
@@ -208,6 +209,12 @@ export function listenerInstalled(): boolean {
208
209
  }
209
210
 
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
+
211
218
  export function listenerHealth(now = Date.now()): { ok: boolean; detail: string } | null {
212
219
  if (!listenerInstalled()) return null
213
220
  try {
@@ -217,14 +224,16 @@ export function listenerHealth(now = Date.now()): { ok: boolean; detail: string
217
224
  // Not paused.
218
225
  }
219
226
  const st = readListenerState()
220
- const last = st.lastPollAt ? Date.parse(st.lastPollAt) : NaN
221
- if (!Number.isFinite(last) || now - last > LISTENER_STALE_MS) {
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) {
222
231
  return { ok: false, detail: `hasn't polled Telegram since ${st.lastPollAt ?? "it was installed"}; see ${listenerLogPath()}` }
223
232
  }
224
233
  if (st.failingSince && now - Date.parse(st.failingSince) > LISTENER_FAILING_MS) {
225
234
  return { ok: false, detail: `polls failing since ${st.failingSince}: ${st.lastError ?? "unknown error"}` }
226
235
  }
227
- return { ok: true, detail: `listening on ${st.channel ?? "?"} (pid ${st.pid ?? "?"}, ${st.version ?? "?"}), last poll ${st.lastPollAt}` }
236
+ return { ok: true, detail: `listening on ${st.channel ?? "?"} (pid ${st.pid ?? "?"}, ${st.version ?? "?"}), ${st.lastPollAt ? `last poll ${st.lastPollAt}` : `started ${st.startedAt}`}` }
228
237
  }
229
238
 
230
239
  // Telegram hands a bot's updates to one reader at a time, so Beacon setup's chat