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
package/CHANGELOG.md CHANGED
@@ -11,6 +11,35 @@ 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
+
32
+ ## [1.3.0-alpha.4] - 2026-10-30
33
+
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.
35
+
36
+ ### Added
37
+ - **Keep a job running longer without editing its file.** `spinuptui scheduler extend <id> --by 1y` moves a job's expiry a year later (`--by` takes days, weeks, months or years: `30d`, `2w`, `6m`, `1y`), or `--expires YYYY-MM-DD` sets a date. `--by` counts from the current expiry, or from today if the job has already expired. Only the `expires:` line changes, and in a git source the change is committed and pushed like `add` and `cancel`. It refuses a date that isn't later; to stop a job sooner, cancel it.
38
+ - **The pager answers back: send `/jobs` to your Beacon bot.** Beacon's new listener lets you message the station from Telegram. `/jobs` replies with the station's scheduled jobs, soonest expiry first, and flags any that expire within 14 days. Recurring jobs about to expire get an **Extend 1 year** button, which commits and pushes the change like `scheduler extend`. Turn it on with `l` on the Modules tab's Beacon card, or `spinuptui beacon listener install --plan|--apply`. It runs as a LaunchAgent that launchd keeps alive and restarts itself after an upgrade. Scheduler's tick pages you if it stops polling. It answers only your own private chat with the bot, ignores everyone else without replying, acts on each message once, and makes every button work once. Beacon setup stops it while it looks for your chat, because Telegram allows only one reader of a bot's messages. See [docs/beacon.md](docs/beacon.md#let-the-pager-answer-you).
39
+
40
+ ### Fixed
41
+ - **`scheduler status` points to the right heartbeat setup.** With a saved Uptime Kuma login, the "heartbeat: not set" line now suggests `h` on the Scheduler card or `scheduler heartbeat setup`, which create the monitor for you, instead of the manual `heartbeat set <url>`.
42
+
14
43
  ## [1.3.0-alpha.3] - 2026-10-27
15
44
 
16
45
  A pre-release, published on npm's `next` tag (`bun add -g spinuptui@next --no-cache`). `@latest` and Homebrew are on 1.2.1.
@@ -1534,7 +1563,9 @@ Initial tagged release.
1534
1563
  ### Notes
1535
1564
  - Read-only release: works with a SpinupWP **Read Only** API token.
1536
1565
 
1537
- [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.3...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
1568
+ [1.3.0-alpha.4]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.3...v1.3.0-alpha.4
1538
1569
  [1.3.0-alpha.3]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.2...v1.3.0-alpha.3
1539
1570
  [1.2.1]: https://github.com/mwender/spinupwp-tui/compare/v1.2.0...v1.2.1
1540
1571
  [1.3.0-alpha.2]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.1...v1.3.0-alpha.2
package/README.md CHANGED
@@ -81,7 +81,9 @@
81
81
  each set up from the same screen. First up, **Beacon**: `spinuptui beacon send`
82
82
  pages you on Telegram from any cron job, watcher or agent, with retries, long
83
83
  messages split instead of dropped, and a send log. A guided setup creates the
84
- bot connection for you. → [docs/beacon.md](docs/beacon.md)
84
+ bot connection for you. The pager also answers back: with the listener on
85
+ (`l`), send `/jobs` to your bot to see the station's jobs and extend the ones
86
+ expiring soon with a tap. → [docs/beacon.md](docs/beacon.md)
85
87
  Then **Scheduler**: follow-ups that run themselves. Queue "check this
86
88
  tomorrow at 8" from any session, and the result arrives through Beacon. Jobs
87
89
  and reports live in `~/SpinupTUI/scheduler/`; `spinuptui home sync` (or `f`)
@@ -89,6 +91,11 @@
89
91
  any machine. One LaunchAgent ticks every 5 minutes and is the clock the other
90
92
  modules run on, and a station heartbeat (`h`) pings your Uptime Kuma each
91
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)
92
99
  - **Non-interactive CLI subcommands for external tooling** — `spinuptui ssh <domain>`
93
100
  resolves a domain to a live SSH target with a live connectivity probe;
94
101
  `spinuptui ssh-exec <domain> -- <command>` runs a command over SSH, but only
@@ -383,8 +390,10 @@ These can be set in `config.json` or via an environment variable:
383
390
  | `w` | Open the selected server/site in the SpinupWP web app |
384
391
  | `/` | Jump to global search |
385
392
  | `A` | Accounts — switch, add, rename, or remove SpinupWP accounts (from anywhere) |
386
- | `⏎` / `s` | Set up the selected module — Beacon opens its guided Telegram setup, Scheduler its install plan (Modules tab) |
387
- | `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) |
396
+ | `l` | Beacon's listener: install it so the pager answers `/jobs`, see whether it's running, or remove it (Modules tab, Beacon) |
388
397
  | `f` | Your SpinupTUI folder: where jobs and reports live, and sharing it across machines (Modules tab, Scheduler) |
389
398
  | `h` | Station heartbeat: set up the Uptime Kuma push monitor, see the last ping, test or remove it (Modules tab, Scheduler) |
390
399
  | `r` | Refresh data from the API |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "1.3.0-alpha.3",
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,9 +128,38 @@ 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>
146
+ // Written by `beacon listener install --apply`, cleared by uninstall: the
147
+ // resident process that answers the operator (lib/beaconListen.ts).
148
+ listener?: { label: string; unitPath: string; bin: string; channel: string; installedAt: string }
149
+ }
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 }
133
163
  }
134
164
 
135
165
  export interface SchedulerStored {
@@ -246,6 +276,7 @@ export interface StoredConfig {
246
276
  lastSeenVersion?: string
247
277
  beacon?: BeaconStored
248
278
  scheduler?: SchedulerStored
279
+ sentinel?: SentinelStored
249
280
  homeDir?: string
250
281
  }
251
282
 
@@ -534,6 +565,7 @@ export function loadConfig(): AppConfig {
534
565
  lastSeenVersion: stored.lastSeenVersion?.trim() || null,
535
566
  beacon: stored.beacon ?? null,
536
567
  scheduler: stored.scheduler ?? null,
568
+ sentinel: stored.sentinel ?? null,
537
569
  homeDir: stored.homeDir?.trim() || null,
538
570
  }
539
571
  }
package/src/index.tsx CHANGED
@@ -107,11 +107,17 @@ Usage:
107
107
  Add a channel from a file holding TELEGRAM_BOT_TOKEN and
108
108
  TELEGRAM_CHAT_ID; sends a test message before saving.
109
109
  spinuptui beacon test | status | remove <name>
110
+ spinuptui beacon listen | listener install --plan|--apply
111
+ Let the pager answer: /jobs lists this station's scheduled
112
+ jobs, with a button to extend any that expire soon
110
113
  spinuptui scheduler add <id> --kind spinuptui|script|agent --when <time> | --every <schedule>
111
114
  --expires <date> --created-by <who> -- <body>
112
115
  Queue a follow-up for this station to run later and report
113
116
  on through Beacon (\`spinuptui scheduler\` for the rest)
114
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)
115
121
  spinuptui home Where the SpinupTUI folder (your jobs and reports) is
116
122
  spinuptui home sync --plan|--apply --github <name> | --remote <url> | --clone <url>
117
123
  Share that folder across machines through a private git
@@ -325,6 +331,11 @@ if (command === "scheduler") {
325
331
  process.exit(await runScheduler(args.slice(args.indexOf("scheduler") + 1)))
326
332
  }
327
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
+
328
339
  // `spinuptui login` forces the onboarding wizard even when a token already exists,
329
340
  // so the token can be (re)saved to the config file for global use.
330
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
@@ -104,6 +104,16 @@ export function splitMessage(text: string, budget = PART_BUDGET, maxParts = MAX_
104
104
  return parts.map((p, i) => `(${i + 1}/${parts.length}) ${p}`)
105
105
  }
106
106
 
107
+ // For replies sent as Telegram HTML, where only these three are special. Pages
108
+ // stay plain text, so a markup mistake can never cost an alert.
109
+ export function escapeHtml(text: string): string {
110
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
111
+ }
112
+
113
+ export function htmlToPlain(html: string): string {
114
+ return html.replace(/<[^>]*>/g, "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&")
115
+ }
116
+
107
117
  // Parse a KEY=VALUE env file the way the existing shell senders write them:
108
118
  // `export ` prefixes, quotes and # comments allowed.
109
119
  export function parseEnvFile(text: string): Record<string, string> {
@@ -217,6 +227,8 @@ export type SendResult =
217
227
  export interface SendOptions {
218
228
  to?: string | null
219
229
  silent?: boolean
230
+ // Telegram's reply_markup for the last part. Build it with pageMarkup().
231
+ replyMarkup?: string
220
232
  fetchImpl?: Fetch
221
233
  wait?: (ms: number) => Promise<unknown>
222
234
  log?: boolean
@@ -261,7 +273,7 @@ export async function beaconSend(beacon: ResolvedBeacon, text: string, opts: Sen
261
273
  const wait = opts.wait ?? sleep
262
274
  let sent = 0
263
275
  let failure: string | undefined
264
- for (const part of parts) {
276
+ for (const [i, part] of parts.entries()) {
265
277
  const r = await telegramWithRetry(
266
278
  channel.botToken,
267
279
  "sendMessage",
@@ -270,6 +282,7 @@ export async function beaconSend(beacon: ResolvedBeacon, text: string, opts: Sen
270
282
  text: part,
271
283
  disable_web_page_preview: "true",
272
284
  ...(opts.silent ? { disable_notification: "true" } : {}),
285
+ ...(opts.replyMarkup && i === parts.length - 1 ? { reply_markup: opts.replyMarkup } : {}),
273
286
  },
274
287
  fetchImpl,
275
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
+ }