spinuptui 1.2.0 → 1.3.0-alpha.2

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 CHANGED
@@ -11,11 +11,38 @@ version; such changes are called out here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.3.0-alpha.2] - 2026-10-26
15
+
16
+ A pre-release, published on npm's `next` tag (`bun add -g spinuptui@next --no-cache`). `@latest` and Homebrew stay on 1.2.0.
17
+
18
+ ### Added
19
+ - **Scheduler: follow-ups that run themselves.** Queue a job for later (`spinuptui scheduler add verify-410s --kind spinuptui --when "2026-10-21 08:00" --expires 2026-10-28 --created-by "…" -- incidents example.com`) and this machine runs it at that time, writes a report, and sends you the result through Beacon. Jobs can be a SpinupTUI command, a shell script, or a prompt for an agent CLI (Claude Code or Codex, read-only by default). They can be one-offs or recurring (`every 30m`, `weekdays 08:00`, `mon,thu 08:00`), and every job carries an expiry date so a forgotten one stops on its own. Jobs and reports live in `~/SpinupTUI/scheduler/`, a visible folder of your own. Make `~/SpinupTUI/` a git repository and Scheduler pulls it before each run and commits and pushes the reports, so you can queue a job from any machine with a commit. Only one machine runs a repository's jobs: the first station to tick it claims it, so a second machine with a dispatcher can't run them twice. `scheduler source claim` and `release` move them between machines. A job can say what happens when the station reaches it late (`--late run`, `skip`, or a grace window like `2h`). A skipped job is paged, not dropped silently. `scheduler list`, `show`, `run`, `cancel` and `validate` round it out. See [docs/scheduler.md](docs/scheduler.md).
20
+ - **`spinuptui scheduler install --plan|--apply`** sets up the one LaunchAgent everything runs on: a tick at login and every five minutes, with the PATH a bare launchd environment lacks written into it. `--apply` doesn't report success until a tick has actually run under launchd. `uninstall` removes it and keeps your jobs and reports. Scheduler shows up live on the Modules tab (`6`), where `⏎` shows the same plan and installs it on confirm, and in `spinuptui modules`, which reports how long ago the last tick ran. Modules will declare their own recurring work for Scheduler to run, so a station needs only this one unit. The design is in [docs/2026-10-20_scheduler-spec.md](docs/2026-10-20_scheduler-spec.md).
21
+
22
+ ### Changed
23
+ - **Headless commands start about three times faster.** `beacon send`, `incidents`, `ssh-exec`, `scheduler` and the rest no longer load the dashboard's renderer before looking at their arguments, which matters for something started every five minutes.
24
+
25
+ ## [1.3.0-alpha.1] - 2026-10-21
26
+
27
+ A pre-release, published on npm's `next` tag (`bun add -g spinuptui@next --no-cache`). `@latest` and Homebrew stay on 1.2.0. It continues `1.2.0-alpha.1` under a new number, because stable 1.2.0 shipped without the modules: it has Beacon and the Modules tab (see [1.2.0-alpha.1]) plus everything in [1.2.0]. The stable 1.3.0 will roll these notes up with the next modules.
28
+
14
29
  ## [1.2.0] - 2026-10-21
15
30
 
16
31
  ### Added
17
32
  - **Take a server's sites offline, and bring them back.** For a vulnerability you can't patch yet: `spinuptui offline <server>… --yes` (or **Take all sites offline** in the `a` menu) disables every site on the server through SpinupWP, so nginx stops answering for them and no request reaches PHP. Files, databases and certificates are untouched. `spinuptui online <server>… --yes` re-enables exactly the sites it took down, never one disabled for another reason, and a re-run finishes anything that failed. Without `--yes` both print the plan. One site at a time works too: `O` on a site, or `--site <domain>` on either command; the confirmation says how many other sites on its server stay up, since PHP is shared. Uptime Kuma monitors for those sites sit in a maintenance window until they're back, and offline servers are flagged `⏻ off` in the Servers list and on the Overview. See [docs/taking-sites-offline.md](docs/taking-sites-offline.md).
18
33
 
34
+ ## [1.2.0-alpha.1] - 2026-10-17
35
+
36
+ A pre-release, published on npm's `next` tag (`bun add -g spinuptui@next --no-cache`). `@latest` and Homebrew stay on 1.1.2. The modules' stable release is 1.3.0, which will roll these notes up with the next modules; 1.2.0 shipped without them.
37
+
38
+ ### Added
39
+ - **Beacon: `spinuptui beacon send` pages you on Telegram.** One command any script, cron job or agent can use instead of its own `curl` to the Telegram API. Messages go out as plain text with link previews off; anything over Telegram's 4096-character limit is split into numbered parts instead of being rejected; sends are retried (honoring Telegram's rate-limit hints) and logged. Exit codes tell a caller what happened: `0` delivered, `1` not configured (nothing attempted), `2` delivery failed. No Telegram bot yet? `spinuptui beacon setup` walks you through making one with @BotFather, checks the token, and finds your chat by itself once you message the bot. No digging for a chat ID. It saves nothing until a test message has arrived. Scripts can use `spinuptui beacon add telegram <name> --env-file <path>` instead. `beacon test`, `beacon status` and `beacon remove` round it out. See [docs/beacon.md](docs/beacon.md).
40
+ - **A Modules tab (`6`).** Everything SpinupTUI can keep doing after you close it, with its status on this machine and set up from the same place: select Beacon and press `⏎` for the guided Telegram setup, or `t` for a test page. Modules still on the way (Scheduler, Sentinel, Samurai) are listed as coming soon.
41
+ - **`spinuptui modules`** lists SpinupTUI's modules (the features that keep working after the TUI is closed) and their status. Beacon is the first; the module contract is in [docs/2026-10-16_beacon-and-module-contract-spec.md](docs/2026-10-16_beacon-and-module-contract-spec.md).
42
+
43
+ ### Fixed
44
+ - **The update hint understands pre-releases.** Someone running a pre-release (say `1.2.0-alpha.1`) is now told when the matching stable release ships; before, the two counted as the same version.
45
+
19
46
  ## [1.1.2] - 2026-10-15
20
47
 
21
48
  ### Fixed
@@ -1490,8 +1517,11 @@ Initial tagged release.
1490
1517
  ### Notes
1491
1518
  - Read-only release: works with a SpinupWP **Read Only** API token.
1492
1519
 
1493
- [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.2.0...HEAD
1520
+ [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.2...HEAD
1521
+ [1.3.0-alpha.2]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.1...v1.3.0-alpha.2
1522
+ [1.3.0-alpha.1]: https://github.com/mwender/spinupwp-tui/compare/v1.2.0...v1.3.0-alpha.1
1494
1523
  [1.2.0]: https://github.com/mwender/spinupwp-tui/compare/v1.1.2...v1.2.0
1524
+ [1.2.0-alpha.1]: https://github.com/mwender/spinupwp-tui/compare/v1.1.2...v1.2.0-alpha.1
1495
1525
  [1.1.2]: https://github.com/mwender/spinupwp-tui/compare/v1.1.1...v1.1.2
1496
1526
  [1.1.1]: https://github.com/mwender/spinupwp-tui/compare/v1.1.0...v1.1.1
1497
1527
  [1.1.0]: https://github.com/mwender/spinupwp-tui/compare/v1.0.0...v1.1.0
package/README.md CHANGED
@@ -77,6 +77,16 @@
77
77
  register/recalibrate/remove it — plus a cache/outage doctor and alert
78
78
  wiring. → [docs/site-monitoring.md](docs/site-monitoring.md) ·
79
79
  [docs/uptime-kuma.md](docs/uptime-kuma.md)
80
+ - **Modules** (`6`, pre-release: `bun add -g spinuptui@next --no-cache`) — the parts of SpinupTUI that keep working after you close it,
81
+ each set up from the same screen. First up, **Beacon**: `spinuptui beacon send`
82
+ pages you on Telegram from any cron job, watcher or agent, with retries, long
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)
85
+ Then **Scheduler**: follow-ups that run themselves. Queue "check this
86
+ tomorrow at 8" from any session, and the result arrives through Beacon. Jobs
87
+ and reports live in `~/SpinupTUI/scheduler/`; make that folder a git
88
+ repository to queue from any machine. One LaunchAgent ticks every
89
+ 5 minutes and is the clock the other modules run on. → [docs/scheduler.md](docs/scheduler.md)
80
90
  - **Non-interactive CLI subcommands for external tooling** — `spinuptui ssh <domain>`
81
91
  resolves a domain to a live SSH target with a live connectivity probe;
82
92
  `spinuptui ssh-exec <domain> -- <command>` runs a command over SSH, but only
@@ -334,7 +344,7 @@ These can be set in `config.json` or via an environment variable:
334
344
 
335
345
  | Key | Action |
336
346
  | --- | --- |
337
- | `1`…`5` | Switch tabs: Dashboard · Servers · Stacks · Search · Events |
347
+ | `1`…`6` | Switch tabs: Dashboard · Servers · Stacks · Search · Events · Modules |
338
348
  | `↑`/`↓` or `j`/`k` | Move selection |
339
349
  | `Enter` / `→` | Drill in (server → its sites) |
340
350
  | `←` / `Esc` | Go back / collapse |
@@ -370,6 +380,8 @@ These can be set in `config.json` or via an environment variable:
370
380
  | `w` | Open the selected server/site in the SpinupWP web app |
371
381
  | `/` | Jump to global search |
372
382
  | `A` | Accounts — switch, add, rename, or remove SpinupWP accounts (from anywhere) |
383
+ | `⏎` / `s` | Set up the selected module — Beacon opens its guided Telegram setup, Scheduler its install plan (Modules tab) |
384
+ | `t` | Send a test page through Beacon (Modules tab, once Beacon is set up) |
373
385
  | `r` | Refresh data from the API |
374
386
  | `i` | Explain the current screen (what each pane and key does) |
375
387
  | `?` | Toggle the help overlay |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "1.2.0",
3
+ "version": "1.3.0-alpha.2",
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
@@ -103,6 +103,45 @@ export interface AppConfig {
103
103
  // running version with no notes shown — nothing meaningful to announce). A
104
104
  // mismatch against the running version is what triggers the overlay.
105
105
  lastSeenVersion: string | null
106
+ // Beacon's notification channels (see lib/beacon.ts and
107
+ // docs/2026-10-16_beacon-and-module-contract-spec.md). Machine-level like the
108
+ // Kuma login: one operator's pager serves every account. Holds bot tokens, so
109
+ // it relies on the file's 600 mode. The env channel (SPINUPTUI_BEACON_*) is
110
+ // resolved in lib/beacon.ts, not here, so this is only what's stored.
111
+ beacon: BeaconStored | null
112
+ // Scheduler's sources and installed dispatcher (lib/scheduler*.ts,
113
+ // docs/2026-10-20_scheduler-spec.md). Machine-level: a station runs one clock.
114
+ scheduler: SchedulerStored | null
115
+ // Where modules put the user's work (Scheduler's jobs and reports…); null =
116
+ // the default, ~/SpinupTUI. SPINUPTUI_HOME overrides. See modules/contract.ts.
117
+ homeDir: string | null
118
+ }
119
+
120
+ export interface BeaconTelegramChannel {
121
+ type: "telegram"
122
+ botToken: string
123
+ chatId: string
124
+ }
125
+
126
+ // A union so later transports (Slack, ntfy, a client-facing Basecamp channel)
127
+ // are additive.
128
+ export type BeaconChannel = BeaconTelegramChannel
129
+
130
+ export interface BeaconStored {
131
+ defaultChannel?: string
132
+ channels?: Record<string, BeaconChannel>
133
+ }
134
+
135
+ export interface SchedulerStored {
136
+ // Named job sources beyond the built-in `local` one: directories holding
137
+ // jobs/ and runs/, pulled and pushed when they are git repositories.
138
+ sources?: Record<string, { path: string }>
139
+ defaultSource?: string
140
+ // Git sources this machine only queues to: it never claims or runs them
141
+ // (set by `scheduler source release`, cleared by `claim`).
142
+ queueOnly?: string[]
143
+ // Written by `scheduler install --apply`, cleared by uninstall.
144
+ dispatcher?: { label: string; unitPath: string; bin: string; installedAt: string }
106
145
  }
107
146
 
108
147
  export interface ServerProviderRef {
@@ -202,6 +241,9 @@ export interface StoredConfig {
202
241
  uptimeKuma?: UptimeKumaConn
203
242
  kumaMonitors?: Record<string, KumaMonitorRef>
204
243
  lastSeenVersion?: string
244
+ beacon?: BeaconStored
245
+ scheduler?: SchedulerStored
246
+ homeDir?: string
205
247
  }
206
248
 
207
249
  // ---- Accounts (profiles) -------------------------------------------------------
@@ -487,6 +529,9 @@ export function loadConfig(): AppConfig {
487
529
  })(),
488
530
  kumaMonitors: stored.kumaMonitors ?? {},
489
531
  lastSeenVersion: stored.lastSeenVersion?.trim() || null,
532
+ beacon: stored.beacon ?? null,
533
+ scheduler: stored.scheduler ?? null,
534
+ homeDir: stored.homeDir?.trim() || null,
490
535
  }
491
536
  }
492
537
 
package/src/index.tsx CHANGED
@@ -1,29 +1,24 @@
1
1
  #!/usr/bin/env bun
2
- // Entry point. Handles a few non-interactive CLI subcommands, then boots the
3
- // OpenTUI renderer and mounts either the first-run onboarding wizard (no token
4
- // yet) or the main app wrapped in the data store.
2
+ // Entry point. Handles the non-interactive CLI subcommands, then hands off to
3
+ // the dashboard (ui/main.tsx).
5
4
 
6
5
  // First, before any config read or spawn: keep a site checkout's .env from
7
6
  // leaking into child processes (see the module).
8
7
  import "./lib/cwdDotenv.ts"
9
- import { useCallback, useMemo, useState } from "react"
10
- import { createCliRenderer } from "@opentui/core"
11
- import { createRoot } from "@opentui/react"
8
+ // Nothing UI-related is imported statically: headless commands (the Scheduler
9
+ // tick every 5 minutes, beacon send, incidents…) never load the renderer or
10
+ // React. The dashboard is a dynamic import at the bottom.
12
11
  import pkg from "../package.json" with { type: "json" }
13
- import { hasToken, configPath, loadConfig, pinActiveProfile, setActiveProfile, listProfiles } from "./config.ts"
14
- import { StoreProvider } from "./ui/store.tsx"
15
- import { App } from "./ui/App.tsx"
16
- import { AccountsContext, type AccountsApi } from "./ui/accounts.tsx"
17
- import { resetWpCoreJobs } from "./ui/wpCoreJobs.tsx"
18
- import { Onboarding } from "./ui/Onboarding.tsx"
19
- import { isDevMode } from "./dev/devMode.ts"
12
+ import { configPath, loadConfig, listProfiles } from "./config.ts"
20
13
  import { SpinupWPClient } from "./api/client.ts"
21
14
  import { resolveSshAccess } from "./lib/cliSsh.ts"
22
15
  import { resolveIncidents } from "./lib/cliIncidents.ts"
23
16
  import { resolveSiblings } from "./lib/cliSiblings.ts"
24
17
  import { execSshCommand } from "./lib/sshExec.ts"
25
18
  import { runPullFiles, runPullDb } from "./lib/cliPull.ts"
19
+ import { runBeacon, runModules } from "./lib/cliBeacon.ts"
26
20
  import { runOffline, runOnline } from "./lib/cliOffline.ts"
21
+ import { runScheduler } from "./lib/cliScheduler.ts"
27
22
 
28
23
  const args = process.argv.slice(2)
29
24
  const positionals = args.filter((a) => !a.startsWith("-"))
@@ -102,6 +97,22 @@ Usage:
102
97
  spinuptui online <server>… | --all | --site <domain> --yes
103
98
  Bring back exactly the sites \`offline\` took down.
104
99
  Servers are matched by exact name, IP or id.
100
+ spinuptui beacon setup Guided Telegram setup: make a bot, find your chat,
101
+ send a test (interactive; also in the app's Modules tab, 6)
102
+ spinuptui beacon send [--to <channel>] [--silent] [<message> | -]
103
+ Page yourself through Beacon (Telegram). Reads the
104
+ message from stdin when it's \`-\` or absent. Exit 0 sent,
105
+ 1 not configured / usage, 2 delivery failed.
106
+ spinuptui beacon add telegram <name> --env-file <path> [--default]
107
+ Add a channel from a file holding TELEGRAM_BOT_TOKEN and
108
+ TELEGRAM_CHAT_ID; sends a test message before saving.
109
+ spinuptui beacon test | status | remove <name>
110
+ spinuptui scheduler add <id> --kind spinuptui|script|agent --when <time> | --every <schedule>
111
+ --expires <date> --created-by <who> -- <body>
112
+ Queue a follow-up for this station to run later and report
113
+ on through Beacon (\`spinuptui scheduler\` for the rest)
114
+ spinuptui scheduler list | status | install --plan|--apply
115
+ spinuptui modules List SpinupTUI's modules and their status (--json)
105
116
  spinuptui --version Print the version
106
117
  spinuptui --help Show this help
107
118
 
@@ -293,31 +304,19 @@ if (command === "online") {
293
304
  process.exit(await runOnline(args.slice(args.indexOf("online") + 1)))
294
305
  }
295
306
 
296
- // `spinuptui login` forces the onboarding wizard even when a token already exists,
297
- // so the token can be (re)saved to the config file for global use.
298
- const forceLogin = command === "login"
307
+ if (command === "beacon") {
308
+ process.exit(await runBeacon(args.slice(args.indexOf("beacon") + 1)))
309
+ }
299
310
 
300
- function Root() {
301
- const [configured, setConfigured] = useState(isDevMode() ? true : forceLogin ? false : hasToken())
302
- // The store is keyed by account: switching remounts it (see ui/accounts.tsx).
303
- const [account, setAccount] = useState(() => pinActiveProfile())
304
- const switchAccount = useCallback(async (id: string) => {
305
- await setActiveProfile(id)
306
- resetWpCoreJobs()
307
- setAccount(id)
308
- }, [])
309
- const accounts = useMemo<AccountsApi>(() => ({ switchAccount }), [switchAccount])
310
- if (!configured) {
311
- return <Onboarding onComplete={() => setConfigured(true)} />
312
- }
313
- return (
314
- <AccountsContext.Provider value={accounts}>
315
- <StoreProvider key={account}>
316
- <App />
317
- </StoreProvider>
318
- </AccountsContext.Provider>
319
- )
311
+ if (command === "modules") {
312
+ process.exit(await runModules(args.slice(1)))
320
313
  }
321
314
 
322
- const renderer = await createCliRenderer()
323
- createRoot(renderer).render(<Root />)
315
+ if (command === "scheduler") {
316
+ process.exit(await runScheduler(args.slice(args.indexOf("scheduler") + 1)))
317
+ }
318
+
319
+ // `spinuptui login` forces the onboarding wizard even when a token already exists,
320
+ // so the token can be (re)saved to the config file for global use.
321
+ const { startDashboard } = await import("./ui/main.tsx")
322
+ await startDashboard({ forceLogin: command === "login" })
@@ -0,0 +1,26 @@
1
+ import { expect, test } from "bun:test"
2
+ import { isNewer } from "./appUpdate.ts"
3
+
4
+ test("plain versions", () => {
5
+ expect(isNewer("1.2.0", "1.1.2")).toBe(true)
6
+ expect(isNewer("1.1.2", "1.2.0")).toBe(false)
7
+ expect(isNewer("1.10.0", "1.9.9")).toBe(true)
8
+ expect(isNewer("1.2.0", "1.2.0")).toBe(false)
9
+ })
10
+
11
+ test("a release outranks its pre-releases", () => {
12
+ expect(isNewer("1.2.0", "1.2.0-alpha.1")).toBe(true)
13
+ expect(isNewer("1.2.0-alpha.1", "1.2.0")).toBe(false)
14
+ expect(isNewer("1.2.0-alpha.1", "1.1.2")).toBe(true)
15
+ })
16
+
17
+ test("pre-release ordering", () => {
18
+ expect(isNewer("1.2.0-alpha.2", "1.2.0-alpha.1")).toBe(true)
19
+ expect(isNewer("1.2.0-alpha.10", "1.2.0-alpha.9")).toBe(true)
20
+ expect(isNewer("1.2.0-beta.1", "1.2.0-alpha.5")).toBe(true)
21
+ expect(isNewer("1.2.0-alpha.1", "1.2.0-alpha")).toBe(true)
22
+ })
23
+
24
+ test("unparseable never nags", () => {
25
+ expect(isNewer("garbage", "1.0.0")).toBe(false)
26
+ })
@@ -35,21 +35,47 @@ function cachePath(): string {
35
35
  return join(configDir(), "update-check.json")
36
36
  }
37
37
 
38
- // Parse "v1.2.3" / "1.2.3" → [1,2,3]; null when it doesn't look like a version.
39
- function parseVersion(s: string): [number, number, number] | null {
40
- const m = s.trim().replace(/^v/i, "").match(/^(\d+)\.(\d+)\.(\d+)/)
41
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
38
+ // Parse "v1.2.3" / "1.2.3" / "1.2.3-alpha.1" → numbers + pre-release tag; null
39
+ // when it doesn't look like a version.
40
+ function parseVersion(s: string): { nums: [number, number, number]; pre: string | null } | null {
41
+ const m = s.trim().replace(/^v/i, "").match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/)
42
+ return m ? { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null } : null
43
+ }
44
+
45
+ // SemVer pre-release precedence: dot-separated identifiers compared left to
46
+ // right, numeric ones numerically and below alphanumeric ones; a shorter set of
47
+ // otherwise-equal identifiers sorts first.
48
+ function comparePre(a: string, b: string): number {
49
+ const x = a.split(".")
50
+ const y = b.split(".")
51
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
52
+ if (x[i] === undefined) return -1
53
+ if (y[i] === undefined) return 1
54
+ const nx = /^\d+$/.test(x[i]!)
55
+ const ny = /^\d+$/.test(y[i]!)
56
+ if (nx && ny) {
57
+ const d = Number(x[i]) - Number(y[i])
58
+ if (d) return d
59
+ } else if (nx !== ny) return nx ? -1 : 1
60
+ else if (x[i] !== y[i]) return x[i]! < y[i]! ? -1 : 1
61
+ }
62
+ return 0
42
63
  }
43
64
 
44
65
  // Strictly-newer comparison (latest > current). Unparseable → false (never nag).
45
- function isNewer(latest: string, current: string): boolean {
66
+ // A release outranks its own pre-releases, so someone on 1.2.0-alpha.1 is told
67
+ // when 1.2.0 ships.
68
+ export function isNewer(latest: string, current: string): boolean {
46
69
  const a = parseVersion(latest)
47
70
  const b = parseVersion(current)
48
71
  if (!a || !b) return false
49
72
  for (let i = 0; i < 3; i++) {
50
- if (a[i] !== b[i]) return a[i] > b[i]
73
+ if (a.nums[i] !== b.nums[i]) return a.nums[i]! > b.nums[i]!
51
74
  }
52
- return false
75
+ if (a.pre === b.pre) return false
76
+ if (a.pre === null) return true
77
+ if (b.pre === null) return false
78
+ return comparePre(a.pre, b.pre) > 0
53
79
  }
54
80
 
55
81
  function readCache(): CacheFile | null {
@@ -0,0 +1,140 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { beaconSend, MAX_PARTS, maskToken, parseEnvFile, resolveBeacon, splitMessage, TELEGRAM_LIMIT, type ResolvedBeacon } from "./beacon.ts"
3
+
4
+ const pager: ResolvedBeacon = {
5
+ channels: [{ name: "pager", channel: { type: "telegram", botToken: "123:SECRET", chatId: "-1001" }, env: false }],
6
+ defaultChannel: "pager",
7
+ }
8
+
9
+ function fakeFetch(responses: Array<{ status: number; body: unknown } | Error>) {
10
+ const calls: Array<{ url: string; params: URLSearchParams }> = []
11
+ const impl = async (url: string, init?: RequestInit) => {
12
+ calls.push({ url, params: init?.body as URLSearchParams })
13
+ const next = responses.shift() ?? { status: 200, body: { ok: true, result: {} } }
14
+ if (next instanceof Error) throw next
15
+ return new Response(JSON.stringify(next.body), { status: next.status })
16
+ }
17
+ return { impl, calls }
18
+ }
19
+
20
+ const noWait = async () => {}
21
+
22
+ describe("resolveBeacon", () => {
23
+ test("a lone stored channel is the default", () => {
24
+ const r = resolveBeacon({ channels: { pager: { type: "telegram", botToken: "1:a", chatId: "2" } } }, {})
25
+ expect(r.defaultChannel).toBe("pager")
26
+ })
27
+
28
+ test("env channel is added and becomes default unless one is chosen", () => {
29
+ const env = { SPINUPTUI_BEACON_TELEGRAM_TOKEN: "9:z", SPINUPTUI_BEACON_TELEGRAM_CHAT_ID: "5" }
30
+ const stored = { channels: { pager: { type: "telegram" as const, botToken: "1:a", chatId: "2" } } }
31
+ expect(resolveBeacon(stored, env).defaultChannel).toBe("env")
32
+ expect(resolveBeacon({ ...stored, defaultChannel: "pager" }, env).defaultChannel).toBe("pager")
33
+ expect(resolveBeacon(stored, env).channels.find((c) => c.name === "env")?.env).toBe(true)
34
+ })
35
+
36
+ test("two stored channels and no choice means no default", () => {
37
+ const ch = { type: "telegram" as const, botToken: "1:a", chatId: "2" }
38
+ expect(resolveBeacon({ channels: { a: ch, b: ch } }, {}).defaultChannel).toBeNull()
39
+ })
40
+ })
41
+
42
+ describe("splitMessage", () => {
43
+ test("short text is untouched", () => {
44
+ expect(splitMessage("hello")).toEqual(["hello"])
45
+ })
46
+
47
+ test("long text splits on lines, numbered, each under the limit", () => {
48
+ const text = Array.from({ length: 300 }, (_, i) => `line ${i} ${"x".repeat(40)}`).join("\n")
49
+ const parts = splitMessage(text)
50
+ expect(parts.length).toBeGreaterThan(1)
51
+ parts.forEach((p, i) => {
52
+ expect(p.length).toBeLessThanOrEqual(TELEGRAM_LIMIT)
53
+ expect(p.startsWith(`(${i + 1}/${parts.length}) `)).toBe(true)
54
+ })
55
+ // Nothing lost or reordered.
56
+ expect(parts.map((p) => p.replace(/^\(\d+\/\d+\) /, "")).join("\n")).toBe(text)
57
+ })
58
+
59
+ test("a single huge line is hard-split", () => {
60
+ const parts = splitMessage("y".repeat(9000))
61
+ expect(parts.length).toBe(3)
62
+ parts.forEach((p) => expect(p.length).toBeLessThanOrEqual(TELEGRAM_LIMIT))
63
+ })
64
+
65
+ test("beyond the part cap the last part is truncated with a marker", () => {
66
+ const parts = splitMessage("z".repeat(4000 * 8))
67
+ expect(parts.length).toBe(MAX_PARTS)
68
+ expect(parts[MAX_PARTS - 1]).toContain("[truncated:")
69
+ parts.forEach((p) => expect(p.length).toBeLessThanOrEqual(TELEGRAM_LIMIT))
70
+ })
71
+ })
72
+
73
+ describe("parseEnvFile", () => {
74
+ test("handles export, quotes and comments", () => {
75
+ const env = parseEnvFile(`# creds\nexport TELEGRAM_BOT_TOKEN="1:abc"\nTELEGRAM_CHAT_ID='-100' \nOTHER=x # note\n`)
76
+ expect(env).toEqual({ TELEGRAM_BOT_TOKEN: "1:abc", TELEGRAM_CHAT_ID: "-100", OTHER: "x" })
77
+ })
78
+ })
79
+
80
+ test("maskToken keeps only the bot id", () => {
81
+ expect(maskToken("123456:AAE-secret")).toBe("123456:…")
82
+ })
83
+
84
+ describe("beaconSend", () => {
85
+ test("sends plain text with previews off", async () => {
86
+ const f = fakeFetch([])
87
+ const r = await beaconSend(pager, "site down", { fetchImpl: f.impl, wait: noWait, log: false })
88
+ expect(r).toEqual({ ok: true, channel: "pager", parts: 1, chars: 9 })
89
+ expect(f.calls[0]!.params.get("disable_web_page_preview")).toBe("true")
90
+ expect(f.calls[0]!.params.has("parse_mode")).toBe(false)
91
+ expect(f.calls[0]!.params.has("disable_notification")).toBe(false)
92
+ })
93
+
94
+ test("--silent sets disable_notification", async () => {
95
+ const f = fakeFetch([])
96
+ await beaconSend(pager, "digest", { silent: true, fetchImpl: f.impl, wait: noWait, log: false })
97
+ expect(f.calls[0]!.params.get("disable_notification")).toBe("true")
98
+ })
99
+
100
+ test("retries a 429 and a network error, then succeeds", async () => {
101
+ const f = fakeFetch([{ status: 429, body: { ok: false, parameters: { retry_after: 1 } } }, new Error("socket hang up")])
102
+ const r = await beaconSend(pager, "x", { fetchImpl: f.impl, wait: noWait, log: false })
103
+ expect(r.ok).toBe(true)
104
+ expect(f.calls.length).toBe(3)
105
+ })
106
+
107
+ test("a 400 is not retried and is a delivery failure", async () => {
108
+ const f = fakeFetch([{ status: 400, body: { ok: false, description: "Bad Request: chat not found" } }])
109
+ const r = await beaconSend(pager, "x", { fetchImpl: f.impl, wait: noWait, log: false })
110
+ expect(f.calls.length).toBe(1)
111
+ expect(r.ok).toBe(false)
112
+ if (!r.ok) {
113
+ expect(r.reason).toBe("delivery_failed")
114
+ expect(r.message).toContain("chat not found")
115
+ }
116
+ })
117
+
118
+ test("network errors never leak the token", async () => {
119
+ const f = fakeFetch([new Error("fail https://api.telegram.org/bot123:SECRET/sendMessage"), new Error("x"), new Error("x")])
120
+ const r = await beaconSend(pager, "x", { fetchImpl: f.impl, wait: noWait, log: false })
121
+ expect(r.ok).toBe(false)
122
+ if (!r.ok) expect(r.message).not.toContain("SECRET")
123
+ })
124
+
125
+ test("not configured and empty messages attempt nothing", async () => {
126
+ const f = fakeFetch([])
127
+ const none = await beaconSend({ channels: [], defaultChannel: null }, "x", { fetchImpl: f.impl, log: false })
128
+ const empty = await beaconSend(pager, " ", { fetchImpl: f.impl, log: false })
129
+ expect(!none.ok && none.reason).toBe("not_configured")
130
+ expect(!empty.ok && empty.reason).toBe("empty_message")
131
+ expect(f.calls.length).toBe(0)
132
+ })
133
+
134
+ test("a multi-part send stops at the first failed part", async () => {
135
+ const f = fakeFetch([{ status: 200, body: { ok: true } }, { status: 403, body: { ok: false, description: "Forbidden" } }])
136
+ const r = await beaconSend(pager, "q".repeat(9000), { fetchImpl: f.impl, wait: noWait, log: false })
137
+ expect(!r.ok && r.partsSent).toBe(1)
138
+ expect(f.calls.length).toBe(2)
139
+ })
140
+ })