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
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.
|
|
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.
|
|
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
|
|
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
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")
|
package/src/lib/beacon.test.ts
CHANGED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function htmlToPlain(html: string): string {
|
|
114
|
+
return html.replace(/<[^>]*>/g, "").replace(/</g, "<").replace(/>/g, ">").replace(/&/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 < 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 <script>alert(1)</script> b</p>")
|
|
32
|
+
expect(markdownToBasecampHtml("<img src=x onerror=y>")).toBe("<p><img src=x onerror=y></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, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
19
|
+
const escAttr = (s: string) => esc(s).replace(/"/g, """)
|
|
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
|
+
}
|