spinuptui 1.2.0-alpha.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +10 -10
  2. package/README.md +5 -10
  3. package/package.json +1 -1
  4. package/src/api/client.ts +15 -0
  5. package/src/api/types.ts +3 -0
  6. package/src/config.ts +0 -23
  7. package/src/dev/mockClient.ts +30 -7
  8. package/src/index.tsx +17 -16
  9. package/src/lib/appUpdate.ts +7 -33
  10. package/src/lib/cliOffline.ts +418 -0
  11. package/src/lib/offline.test.ts +181 -0
  12. package/src/lib/offline.ts +422 -0
  13. package/src/lib/spinupweb.test.ts +22 -0
  14. package/src/lib/spinupweb.ts +11 -0
  15. package/src/lib/stackCache.test.ts +43 -0
  16. package/src/lib/stackCache.ts +35 -1
  17. package/src/ui/App.tsx +12 -10
  18. package/src/ui/Details.tsx +8 -2
  19. package/src/ui/Explain.tsx +2 -15
  20. package/src/ui/Header.tsx +0 -2
  21. package/src/ui/Help.tsx +3 -2
  22. package/src/ui/Onboarding.tsx +71 -3
  23. package/src/ui/components.tsx +2 -1
  24. package/src/ui/offlineRun.tsx +82 -0
  25. package/src/ui/store.tsx +76 -10
  26. package/src/ui/views/AccountSlugPrompt.tsx +102 -0
  27. package/src/ui/views/Browser.tsx +13 -5
  28. package/src/ui/views/Dashboard.tsx +3 -1
  29. package/src/ui/views/Search.tsx +8 -6
  30. package/src/ui/views/ServerActions.tsx +108 -11
  31. package/src/ui/views/SiteOffline.tsx +171 -0
  32. package/src/ui/views/Stacks.tsx +26 -9
  33. package/src/lib/appUpdate.test.ts +0 -26
  34. package/src/lib/beacon.test.ts +0 -140
  35. package/src/lib/beacon.ts +0 -349
  36. package/src/lib/beaconSetup.test.ts +0 -65
  37. package/src/lib/beaconSetup.ts +0 -143
  38. package/src/lib/cliBeacon.ts +0 -290
  39. package/src/lib/prompt.ts +0 -47
  40. package/src/modules/beacon.ts +0 -51
  41. package/src/modules/contract.ts +0 -61
  42. package/src/modules/index.ts +0 -34
  43. package/src/ui/views/BeaconSetup.tsx +0 -268
  44. package/src/ui/views/Modules.tsx +0 -196
@@ -1,140 +0,0 @@
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
- })
package/src/lib/beacon.ts DELETED
@@ -1,349 +0,0 @@
1
- // Beacon: one notification layer for SpinupTUI and its modules
2
- // (docs/2026-10-16_beacon-and-module-contract-spec.md). This round is the
3
- // operator channel only — a Telegram pager.
4
- //
5
- // Messages go out as plain text (never parse_mode: an underscore in a domain
6
- // must not break a page), with link previews off. Anything over Telegram's
7
- // 4096-character limit is split on line boundaries instead of being dropped.
8
- // Every send is appended to the module's send.log, which is how a caller's
9
- // cutover is verified. Nothing here writes config.json.
10
-
11
- import { appendFile, mkdir, rename, stat } from "node:fs/promises"
12
- import { existsSync, readFileSync } from "node:fs"
13
- import { hostname } from "node:os"
14
- import { join } from "node:path"
15
- import type { AppConfig, BeaconChannel, BeaconStored } from "../config.ts"
16
- import { moduleStateDir } from "../modules/contract.ts"
17
-
18
- export const TELEGRAM_LIMIT = 4096
19
- // Room left under the limit for the "(1/3) " prefix.
20
- const PART_BUDGET = 4000
21
- export const MAX_PARTS = 5
22
- const ATTEMPTS = 3
23
- const TIMEOUT_MS = 15_000
24
- const LOG_MAX_BYTES = 1_000_000
25
- const TELEGRAM_API = "https://api.telegram.org"
26
-
27
- export interface ResolvedChannel {
28
- name: string
29
- channel: BeaconChannel
30
- env: boolean // from SPINUPTUI_BEACON_* — read-only
31
- }
32
-
33
- export interface ResolvedBeacon {
34
- channels: ResolvedChannel[]
35
- defaultChannel: string | null
36
- }
37
-
38
- // Stored channels plus the read-only env channel. The env channel is the default
39
- // only when no stored channel has been made the default; a lone channel is the
40
- // default automatically.
41
- export function resolveBeacon(stored: BeaconStored | null, env: Record<string, string | undefined> = process.env): ResolvedBeacon {
42
- const channels: ResolvedChannel[] = Object.entries(stored?.channels ?? {}).map(([name, channel]) => ({ name, channel, env: false }))
43
- const token = env.SPINUPTUI_BEACON_TELEGRAM_TOKEN?.trim()
44
- const chatId = env.SPINUPTUI_BEACON_TELEGRAM_CHAT_ID?.trim()
45
- if (token && chatId) channels.push({ name: "env", channel: { type: "telegram", botToken: token, chatId }, env: true })
46
- const want = stored?.defaultChannel
47
- let defaultChannel: string | null = null
48
- if (want && channels.some((c) => c.name === want)) defaultChannel = want
49
- else if (channels.some((c) => c.name === "env")) defaultChannel = "env"
50
- else if (channels.length === 1) defaultChannel = channels[0]!.name
51
- return { channels, defaultChannel }
52
- }
53
-
54
- export function beaconFromConfig(cfg: AppConfig): ResolvedBeacon {
55
- return resolveBeacon(cfg.beacon)
56
- }
57
-
58
- // "123456789:AAE…" → "123456789:…" — the bot id identifies it, the rest is secret.
59
- export function maskToken(token: string): string {
60
- const i = token.indexOf(":")
61
- return i > 0 ? `${token.slice(0, i)}:…` : "…"
62
- }
63
-
64
- export function maskChatId(chatId: string): string {
65
- return chatId.length <= 4 ? "…" : `…${chatId.slice(-4)}`
66
- }
67
-
68
- // Split text into parts that fit Telegram's limit, on line boundaries where
69
- // possible, numbering them when there's more than one. Beyond MAX_PARTS the last
70
- // part is cut short with a marker saying how much was left out.
71
- export function splitMessage(text: string, budget = PART_BUDGET, maxParts = MAX_PARTS): string[] {
72
- if (text.length <= TELEGRAM_LIMIT) return [text]
73
- const chunks: string[] = []
74
- let current = ""
75
- const push = () => {
76
- if (current) chunks.push(current)
77
- current = ""
78
- }
79
- for (const line of text.split("\n")) {
80
- let rest = line
81
- // A single line longer than the budget is hard-split.
82
- while (rest.length > budget) {
83
- push()
84
- chunks.push(rest.slice(0, budget))
85
- rest = rest.slice(budget)
86
- }
87
- const next = current ? `${current}\n${rest}` : rest
88
- if (next.length > budget) {
89
- push()
90
- current = rest
91
- } else {
92
- current = next
93
- }
94
- }
95
- push()
96
- let parts = chunks
97
- if (parts.length > maxParts) {
98
- const dropped = parts.slice(maxParts).reduce((n, p) => n + p.length + 1, 0)
99
- const marker = `\n… [truncated: ${dropped} more characters]`
100
- parts = parts.slice(0, maxParts)
101
- const last = parts[maxParts - 1]!
102
- parts[maxParts - 1] = last.slice(0, budget - marker.length) + marker
103
- }
104
- return parts.map((p, i) => `(${i + 1}/${parts.length}) ${p}`)
105
- }
106
-
107
- // Parse a KEY=VALUE env file the way the existing shell senders write them:
108
- // `export ` prefixes, quotes and # comments allowed.
109
- export function parseEnvFile(text: string): Record<string, string> {
110
- const out: Record<string, string> = {}
111
- for (const raw of text.split("\n")) {
112
- const line = raw.trim()
113
- if (!line || line.startsWith("#")) continue
114
- const m = line.replace(/^export\s+/, "").match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/)
115
- if (!m) continue
116
- let v = m[2]!.trim()
117
- if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
118
- else v = v.replace(/\s+#.*$/, "")
119
- out[m[1]!] = v
120
- }
121
- return out
122
- }
123
-
124
- // ---- Telegram transport -------------------------------------------------------
125
-
126
- export interface TelegramCallResult {
127
- ok: boolean
128
- status: number // HTTP status, 0 for a network error/timeout
129
- error?: string
130
- retryAfter?: number
131
- result?: unknown
132
- }
133
-
134
- export type Fetch = (input: string, init?: RequestInit) => Promise<Response>
135
-
136
- export async function telegramCall(
137
- token: string,
138
- method: string,
139
- params: Record<string, string>,
140
- fetchImpl: Fetch = fetch,
141
- opts: { timeoutMs?: number; signal?: AbortSignal } = {},
142
- ): Promise<TelegramCallResult> {
143
- try {
144
- const timeout = AbortSignal.timeout(opts.timeoutMs ?? TIMEOUT_MS)
145
- const res = await fetchImpl(`${TELEGRAM_API}/bot${token}/${method}`, {
146
- method: "POST",
147
- body: new URLSearchParams(params),
148
- signal: opts.signal ? AbortSignal.any([timeout, opts.signal]) : timeout,
149
- })
150
- const body = (await res.json().catch(() => null)) as { ok?: boolean; description?: string; parameters?: { retry_after?: number }; result?: unknown } | null
151
- if (res.ok && body?.ok) return { ok: true, status: res.status, result: body.result }
152
- return {
153
- ok: false,
154
- status: res.status,
155
- error: body?.description || `HTTP ${res.status}`,
156
- retryAfter: body?.parameters?.retry_after,
157
- }
158
- } catch (err) {
159
- // Never echo the URL: it carries the token.
160
- const e = err as Error
161
- if (opts.signal?.aborted) return { ok: false, status: 0, error: "cancelled" }
162
- return { ok: false, status: 0, error: e.name === "TimeoutError" ? "timed out" : e.message.replaceAll(token, "…") }
163
- }
164
- }
165
-
166
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
167
-
168
- // Three attempts: honor retry_after on 429, back off on 5xx/network errors, and
169
- // give up at once on any other 4xx (a bad token or chat id won't fix itself).
170
- async function telegramWithRetry(
171
- token: string,
172
- method: string,
173
- params: Record<string, string>,
174
- fetchImpl: Fetch,
175
- wait: (ms: number) => Promise<unknown>,
176
- ): Promise<TelegramCallResult> {
177
- let last: TelegramCallResult = { ok: false, status: 0, error: "not attempted" }
178
- for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
179
- last = await telegramCall(token, method, params, fetchImpl)
180
- if (last.ok) return last
181
- const retryable = last.status === 0 || last.status === 429 || last.status >= 500
182
- if (!retryable || attempt === ATTEMPTS) break
183
- await wait(last.status === 429 ? Math.min(last.retryAfter ?? 5, 30) * 1000 : 2000 * attempt)
184
- }
185
- return last
186
- }
187
-
188
- export interface TelegramIdentity {
189
- ok: boolean
190
- username?: string
191
- error?: string
192
- }
193
-
194
- export async function telegramGetMe(token: string, fetchImpl: Fetch = fetch): Promise<TelegramIdentity> {
195
- const r = await telegramCall(token, "getMe", {}, fetchImpl)
196
- if (!r.ok) return { ok: false, error: r.error }
197
- return { ok: true, username: (r.result as { username?: string } | undefined)?.username }
198
- }
199
-
200
- // ---- send ---------------------------------------------------------------------
201
-
202
- export type SendResult =
203
- | { ok: true; channel: string; parts: number; chars: number }
204
- | {
205
- ok: false
206
- // not_configured / unknown_channel / empty_message: nothing was attempted
207
- // (exit 1). delivery_failed: Beacon tried and Telegram refused or was
208
- // unreachable (exit 2) — callers must not retry through another path.
209
- reason: "not_configured" | "unknown_channel" | "empty_message" | "delivery_failed"
210
- message: string
211
- remedy?: string
212
- channel?: string
213
- partsSent?: number
214
- parts?: number
215
- }
216
-
217
- export interface SendOptions {
218
- to?: string | null
219
- silent?: boolean
220
- fetchImpl?: Fetch
221
- wait?: (ms: number) => Promise<unknown>
222
- log?: boolean
223
- }
224
-
225
- export function pickChannel(beacon: ResolvedBeacon, to: string | null | undefined): ResolvedChannel | Extract<SendResult, { ok: false }> {
226
- if (beacon.channels.length === 0) {
227
- return {
228
- ok: false,
229
- reason: "not_configured",
230
- message: "Beacon has no channels configured.",
231
- remedy: "Add one with `spinuptui beacon add telegram <name> --env-file <path>`.",
232
- }
233
- }
234
- const name = to || beacon.defaultChannel
235
- if (!name) {
236
- return {
237
- ok: false,
238
- reason: "unknown_channel",
239
- message: `No default channel; pick one with --to (${beacon.channels.map((c) => c.name).join(", ")}).`,
240
- remedy: "Or make one the default with `spinuptui beacon add … --default`.",
241
- }
242
- }
243
- const found = beacon.channels.find((c) => c.name === name)
244
- if (!found) {
245
- return {
246
- ok: false,
247
- reason: "unknown_channel",
248
- message: `No Beacon channel named "${name}" (have: ${beacon.channels.map((c) => c.name).join(", ")}).`,
249
- }
250
- }
251
- return found
252
- }
253
-
254
- export async function beaconSend(beacon: ResolvedBeacon, text: string, opts: SendOptions = {}): Promise<SendResult> {
255
- if (!text.trim()) return { ok: false, reason: "empty_message", message: "Nothing to send: the message is empty." }
256
- const picked = pickChannel(beacon, opts.to)
257
- if ("ok" in picked) return picked
258
- const { name, channel } = picked
259
- const parts = splitMessage(text)
260
- const fetchImpl = opts.fetchImpl ?? fetch
261
- const wait = opts.wait ?? sleep
262
- let sent = 0
263
- let failure: string | undefined
264
- for (const part of parts) {
265
- const r = await telegramWithRetry(
266
- channel.botToken,
267
- "sendMessage",
268
- {
269
- chat_id: channel.chatId,
270
- text: part,
271
- disable_web_page_preview: "true",
272
- ...(opts.silent ? { disable_notification: "true" } : {}),
273
- },
274
- fetchImpl,
275
- wait,
276
- )
277
- if (!r.ok) {
278
- failure = r.error
279
- break
280
- }
281
- sent++
282
- }
283
- const result: SendResult =
284
- failure === undefined
285
- ? { ok: true, channel: name, parts: parts.length, chars: text.length }
286
- : {
287
- ok: false,
288
- reason: "delivery_failed",
289
- message: `Telegram refused or was unreachable: ${failure}`,
290
- channel: name,
291
- partsSent: sent,
292
- parts: parts.length,
293
- }
294
- if (opts.log !== false) await appendSendLog(name, text, result)
295
- return result
296
- }
297
-
298
- // ---- send log -----------------------------------------------------------------
299
-
300
- export function sendLogPath(): string {
301
- return join(moduleStateDir("beacon"), "send.log")
302
- }
303
-
304
- export interface SendLogEntry {
305
- at: string
306
- channel: string
307
- ok: boolean
308
- parts: number
309
- chars: number
310
- error?: string
311
- preview: string
312
- }
313
-
314
- async function appendSendLog(channel: string, text: string, result: SendResult): Promise<void> {
315
- const entry: SendLogEntry = {
316
- at: new Date().toISOString(),
317
- channel,
318
- ok: result.ok,
319
- parts: result.ok ? result.parts : (result.parts ?? 0),
320
- chars: text.length,
321
- ...(result.ok ? {} : { error: result.message }),
322
- preview: text.replace(/\s+/g, " ").trim().slice(0, 80),
323
- }
324
- try {
325
- const dir = moduleStateDir("beacon")
326
- await mkdir(dir, { recursive: true })
327
- const path = sendLogPath()
328
- const size = await stat(path).then((s) => s.size).catch(() => 0)
329
- if (size > LOG_MAX_BYTES) await rename(path, `${path}.1`)
330
- await appendFile(path, JSON.stringify(entry) + "\n", { mode: 0o600 })
331
- } catch {
332
- // Logging must never turn a delivered page into a failure.
333
- }
334
- }
335
-
336
- export function lastSendLogEntry(): SendLogEntry | null {
337
- try {
338
- const path = sendLogPath()
339
- if (!existsSync(path)) return null
340
- const lines = readFileSync(path, "utf8").trimEnd().split("\n")
341
- return JSON.parse(lines[lines.length - 1]!) as SendLogEntry
342
- } catch {
343
- return null
344
- }
345
- }
346
-
347
- export function testMessage(): string {
348
- return `Beacon test from ${hostname()} at ${new Date().toLocaleString()}. If you can read this, SpinupTUI can page you here.`
349
- }
@@ -1,65 +0,0 @@
1
- import { describe, expect, test } from "bun:test"
2
- import { discoverChat, looksLikeBotToken } from "./beaconSetup.ts"
3
-
4
- const TOKEN = "123456789:AAEabcdefghijklmnopqrstuvwxyz0123456"
5
-
6
- function fakeUpdates(rounds: Array<{ status?: number; body: unknown }>) {
7
- const calls: URLSearchParams[] = []
8
- const impl = async (_url: string, init?: RequestInit) => {
9
- calls.push(init?.body as URLSearchParams)
10
- const next = rounds.shift() ?? { body: { ok: true, result: [] } }
11
- return new Response(JSON.stringify(next.body), { status: next.status ?? 200 })
12
- }
13
- return { impl, calls }
14
- }
15
-
16
- const chat = (id: number, extra: Record<string, unknown> = {}) => ({ id, type: "private", first_name: "Jane", ...extra })
17
-
18
- describe("discoverChat", () => {
19
- test("skips messages already waiting and returns the next chat", async () => {
20
- const f = fakeUpdates([
21
- { body: { ok: true, result: [{ update_id: 7, message: { chat: chat(111) } }] } }, // stale
22
- { body: { ok: true, result: [] } }, // a quiet long-poll round
23
- { body: { ok: true, result: [{ update_id: 8, message: { chat: chat(222, { username: "jane" }) } }] } },
24
- ])
25
- const r = await discoverChat(TOKEN, { fetchImpl: f.impl, timeoutMs: 60_000 })
26
- expect(r).toEqual({ ok: true, chat: { chatId: "222", kind: "private", label: "Jane (@jane)" } })
27
- // The first listening round starts after the stale update.
28
- expect(f.calls[1]!.get("offset")).toBe("8")
29
- // The found update is acknowledged.
30
- expect(f.calls[3]!.get("offset")).toBe("9")
31
- })
32
-
33
- test("a group chat is labelled by its title", async () => {
34
- const f = fakeUpdates([
35
- { body: { ok: true, result: [] } },
36
- { body: { ok: true, result: [{ update_id: 1, my_chat_member: { chat: { id: -100, type: "supergroup", title: "Ops alerts" } } }] } },
37
- ])
38
- const r = await discoverChat(TOKEN, { fetchImpl: f.impl, timeoutMs: 60_000 })
39
- expect(r.ok && r.chat).toEqual({ chatId: "-100", kind: "supergroup", label: "Ops alerts" })
40
- })
41
-
42
- test("a webhook conflict is explained", async () => {
43
- const f = fakeUpdates([{ status: 409, body: { ok: false, description: "Conflict: can't use getUpdates method while webhook is active" } }])
44
- const r = await discoverChat(TOKEN, { fetchImpl: f.impl })
45
- expect(!r.ok && r.reason).toBe("conflict")
46
- expect(!r.ok && r.message).toContain("webhook")
47
- })
48
-
49
- test("an aborted signal cancels", async () => {
50
- const ctrl = new AbortController()
51
- ctrl.abort()
52
- const impl = async (_u: string, init?: RequestInit) => {
53
- init?.signal?.throwIfAborted()
54
- return new Response("{}")
55
- }
56
- const r = await discoverChat(TOKEN, { fetchImpl: impl, signal: ctrl.signal })
57
- expect(!r.ok && r.reason).toBe("cancelled")
58
- })
59
- })
60
-
61
- test("looksLikeBotToken", () => {
62
- expect(looksLikeBotToken(TOKEN)).toBe(true)
63
- expect(looksLikeBotToken("not-a-token")).toBe(false)
64
- expect(looksLikeBotToken("123:short")).toBe(false)
65
- })