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,143 +0,0 @@
1
- // Guided Beacon setup, shared by `spinuptui beacon setup` and the Modules
2
- // screen: check a bot token, find the chat to page by waiting for the user to
3
- // message the bot, send a test, save.
4
- //
5
- // Creating the bot itself can't be automated — Telegram only issues bots through
6
- // a chat with @BotFather — so that step is instructions. Finding the chat id is
7
- // the part people get stuck on, and it can be: once the user messages the bot,
8
- // Telegram's getUpdates hands us the chat. Stale updates (anything already
9
- // waiting when setup starts) are skipped so an old conversation can't be picked
10
- // by mistake. Reading updates acknowledges them on Telegram's side; Beacon never
11
- // uses updates for anything else, so nothing is lost.
12
-
13
- import { loadConfig, saveConfig, type BeaconStored, type BeaconTelegramChannel } from "../config.ts"
14
- import { beaconSend, telegramCall, telegramGetMe, type Fetch, type TelegramIdentity } from "./beacon.ts"
15
-
16
- export const BOTFATHER_URL = "https://t.me/BotFather"
17
- export const BOTFATHER_STEPS = [
18
- "Open @BotFather in Telegram (t.me/BotFather) and send /newbot.",
19
- "Pick a display name, then a username ending in “bot”.",
20
- "BotFather replies with a token like 123456789:AAE… — copy it.",
21
- ]
22
-
23
- // A loose shape check so a mis-paste fails fast with a clear message instead of
24
- // a round trip to Telegram.
25
- export function looksLikeBotToken(token: string): boolean {
26
- return /^\d{5,}:[A-Za-z0-9_-]{30,}$/.test(token.trim())
27
- }
28
-
29
- export async function verifyBotToken(token: string, fetchImpl?: Fetch): Promise<TelegramIdentity> {
30
- if (!looksLikeBotToken(token)) return { ok: false, error: "That doesn't look like a bot token (expected digits, a colon, then a long code)." }
31
- return telegramGetMe(token.trim(), fetchImpl)
32
- }
33
-
34
- export function botLink(username: string): string {
35
- return `https://t.me/${username}`
36
- }
37
-
38
- export interface FoundChat {
39
- chatId: string
40
- kind: "private" | "group" | "supergroup" | "channel"
41
- label: string // "Jane Doe (@jane)" / "Ops alerts"
42
- }
43
-
44
- export type DiscoverResult = { ok: true; chat: FoundChat } | { ok: false; reason: "timeout" | "cancelled" | "conflict" | "error"; message: string }
45
-
46
- interface TgChat {
47
- id: number
48
- type: FoundChat["kind"]
49
- title?: string
50
- first_name?: string
51
- last_name?: string
52
- username?: string
53
- }
54
- interface TgUpdate {
55
- update_id: number
56
- message?: { chat: TgChat }
57
- channel_post?: { chat: TgChat }
58
- my_chat_member?: { chat: TgChat }
59
- }
60
-
61
- function describeChat(c: TgChat): FoundChat {
62
- const name = c.title || [c.first_name, c.last_name].filter(Boolean).join(" ") || String(c.id)
63
- return { chatId: String(c.id), kind: c.type, label: c.username ? `${name} (@${c.username})` : name }
64
- }
65
-
66
- function conflictMessage(error: string | undefined): string {
67
- return /webhook/i.test(error ?? "")
68
- ? "This bot has a webhook set, so Telegram won't hand its messages to Beacon. Use a bot made just for alerts, or remove the webhook."
69
- : "Something else is reading this bot's messages right now. Stop it, or use a bot made just for alerts."
70
- }
71
-
72
- // Wait for the next message to the bot (or the bot being added to a group or
73
- // channel) and return that chat. Long-polls in 25-second rounds until
74
- // `timeoutMs` or the signal aborts.
75
- export async function discoverChat(
76
- token: string,
77
- opts: { timeoutMs?: number; signal?: AbortSignal; fetchImpl?: Fetch } = {},
78
- ): Promise<DiscoverResult> {
79
- const deadline = Date.now() + (opts.timeoutMs ?? 5 * 60_000)
80
- const call = (params: Record<string, string>, timeoutMs: number) =>
81
- telegramCall(token, "getUpdates", params, opts.fetchImpl, { timeoutMs, signal: opts.signal })
82
-
83
- // Skip whatever is already queued: only a message sent from now on counts.
84
- const first = await call({ timeout: "0" }, 15_000)
85
- if (!first.ok) {
86
- if (first.error === "cancelled") return { ok: false, reason: "cancelled", message: "Cancelled." }
87
- if (first.status === 409) return { ok: false, reason: "conflict", message: conflictMessage(first.error) }
88
- return { ok: false, reason: "error", message: `Couldn't read the bot's messages: ${first.error}` }
89
- }
90
- const stale = (first.result as TgUpdate[] | undefined) ?? []
91
- let offset = stale.length ? Math.max(...stale.map((u) => u.update_id)) + 1 : 0
92
-
93
- while (Date.now() < deadline) {
94
- const wait = Math.max(1, Math.min(25, Math.floor((deadline - Date.now()) / 1000)))
95
- const r = await call({ offset: String(offset), timeout: String(wait), allowed_updates: JSON.stringify(["message", "channel_post", "my_chat_member"]) }, (wait + 10) * 1000)
96
- if (!r.ok) {
97
- if (r.error === "cancelled") return { ok: false, reason: "cancelled", message: "Cancelled." }
98
- if (r.status === 409) return { ok: false, reason: "conflict", message: conflictMessage(r.error) }
99
- // A dropped long-poll is routine; pause and keep listening.
100
- await new Promise((res) => setTimeout(res, 2000))
101
- continue
102
- }
103
- for (const u of (r.result as TgUpdate[] | undefined) ?? []) {
104
- offset = Math.max(offset, u.update_id + 1)
105
- const chat = u.message?.chat ?? u.channel_post?.chat ?? u.my_chat_member?.chat
106
- if (chat) {
107
- // Acknowledge it so a re-run doesn't see the same message as stale-but-new.
108
- await call({ offset: String(offset), timeout: "0" }, 15_000)
109
- return { ok: true, chat: describeChat(chat) }
110
- }
111
- }
112
- }
113
- return { ok: false, reason: "timeout", message: "No message reached the bot in time." }
114
- }
115
-
116
- export function channelExists(name: string): boolean {
117
- return Boolean(loadConfig().beacon?.channels?.[name])
118
- }
119
-
120
- // The name a guided setup configures: the stored default if there is one (so
121
- // re-running setup repairs the pager rather than adding a second one), else "pager".
122
- export function setupChannelName(): string {
123
- const stored = loadConfig().beacon
124
- const def = stored?.defaultChannel
125
- return def && stored?.channels?.[def] ? def : "pager"
126
- }
127
-
128
- export async function saveBeaconChannel(name: string, channel: BeaconTelegramChannel, makeDefault: boolean): Promise<{ isDefault: boolean }> {
129
- const stored: BeaconStored = loadConfig().beacon ?? {}
130
- const channels = { ...(stored.channels ?? {}), [name]: channel }
131
- const isDefault = makeDefault || !stored.defaultChannel || stored.defaultChannel === name
132
- await saveConfig({ beacon: { ...stored, channels, ...(isDefault ? { defaultChannel: name } : {}) } })
133
- return { isDefault }
134
- }
135
-
136
- // Send the "connected" message on a channel that isn't saved yet. Credentials
137
- // that can't deliver are never stored, so callers save only after this succeeds.
138
- export async function sendConnectedMessage(name: string, channel: BeaconTelegramChannel, fetchImpl?: Fetch) {
139
- return beaconSend({ channels: [{ name, channel, env: false }], defaultChannel: name }, `Beacon connected: channel "${name}" will page this chat.`, {
140
- log: false,
141
- fetchImpl,
142
- })
143
- }
@@ -1,290 +0,0 @@
1
- // `spinuptui beacon …` and `spinuptui modules` — the CLI over lib/beacon.ts and
2
- // the module registry.
3
- //
4
- // Exit codes for `beacon send` are a contract callers branch on: 0 delivered;
5
- // 1 usage error or not configured (nothing was attempted, so a caller may fall
6
- // back to its own sender); 2 delivery failed after retries (Beacon tried — a
7
- // caller must not resend through another path, or a partial multi-part send
8
- // would be duplicated).
9
-
10
- import { readFileSync } from "node:fs"
11
- import { loadConfig, saveConfig, type BeaconStored } from "../config.ts"
12
- import { beaconFromConfig, beaconSend, maskChatId, maskToken, parseEnvFile, pickChannel, telegramGetMe, testMessage } from "./beacon.ts"
13
- import {
14
- BOTFATHER_STEPS,
15
- botLink,
16
- channelExists,
17
- discoverChat,
18
- saveBeaconChannel,
19
- sendConnectedMessage,
20
- setupChannelName,
21
- verifyBotToken,
22
- } from "./beaconSetup.ts"
23
- import { confirm, PromptCancelled, readLine } from "./prompt.ts"
24
- import { beaconModule } from "../modules/beacon.ts"
25
- import { allModuleStatuses, UPCOMING_MODULES } from "../modules/index.ts"
26
- import type { ModuleStatus } from "../modules/contract.ts"
27
-
28
- const VALUE_FLAGS = new Set(["--to", "--env-file"])
29
-
30
- function parse(list: string[]): { positionals: string[]; flags: Map<string, string>; rest: string | null } {
31
- const dash = list.indexOf("--")
32
- const head = dash === -1 ? list : list.slice(0, dash)
33
- const positionals: string[] = []
34
- const flags = new Map<string, string>()
35
- for (let i = 0; i < head.length; i++) {
36
- const a = head[i]!
37
- if (a.startsWith("-") && a !== "-") {
38
- if (VALUE_FLAGS.has(a)) {
39
- flags.set(a, head[i + 1] ?? "")
40
- i++
41
- } else flags.set(a, "")
42
- continue
43
- }
44
- positionals.push(a)
45
- }
46
- return { positionals, flags, rest: dash === -1 ? null : list.slice(dash + 1).join(" ") }
47
- }
48
-
49
- function fail(json: boolean, reason: string, message: string, remedy?: string): number {
50
- if (json) console.log(JSON.stringify({ ok: false, reason, message, ...(remedy ? { remedy } : {}) }))
51
- else {
52
- console.error(message)
53
- if (remedy) console.error(remedy)
54
- }
55
- return 1
56
- }
57
-
58
- const USAGE = `Usage:
59
- spinuptui beacon setup [<name>] (guided, in a terminal)
60
- spinuptui beacon send [--to <channel>] [--silent] [--json] [<message> | -]
61
- spinuptui beacon add telegram <name> [--env-file <path>] [--default] [--no-test]
62
- spinuptui beacon remove <name>
63
- spinuptui beacon test [--to <channel>] [--json]
64
- spinuptui beacon status [--json]`
65
-
66
- function badValue(flags: Map<string, string>, name: string): boolean {
67
- if (!flags.has(name)) return false
68
- const v = flags.get(name)!
69
- return !v || v.startsWith("-")
70
- }
71
-
72
- export async function runBeacon(argv: string[]): Promise<number> {
73
- const { positionals, flags, rest } = parse(argv)
74
- const json = flags.has("--json")
75
- const sub = positionals[0]
76
- for (const f of VALUE_FLAGS) if (badValue(flags, f)) return fail(json, "usage", `${f} needs a value.\n${USAGE}`)
77
-
78
- if (sub === "setup") return setup(positionals.slice(1))
79
- if (sub === "send") return send(positionals.slice(1), flags, rest, json)
80
- if (sub === "test") return test(flags, json)
81
- if (sub === "add") return add(positionals.slice(1), flags, json)
82
- if (sub === "remove") return remove(positionals.slice(1), json)
83
- if (sub === "status") return status(json)
84
- return fail(json, "usage", USAGE)
85
- }
86
-
87
- async function send(pos: string[], flags: Map<string, string>, rest: string | null, json: boolean): Promise<number> {
88
- // The message: everything after `--`, else the positionals, else stdin when it
89
- // is `-` or absent and stdin isn't a terminal.
90
- let text: string
91
- if (rest !== null) text = rest
92
- else if (pos.length > 0 && !(pos.length === 1 && pos[0] === "-")) text = pos.join(" ")
93
- else if (pos[0] === "-" || !process.stdin.isTTY) text = await Bun.stdin.text()
94
- else return fail(json, "usage", `No message: pass it as an argument or pipe it on stdin.\n${USAGE}`)
95
- text = text.replace(/\s+$/, "")
96
-
97
- const result = await beaconSend(beaconFromConfig(loadConfig()), text, {
98
- to: flags.get("--to") ?? null,
99
- silent: flags.has("--silent"),
100
- })
101
- if (json) console.log(JSON.stringify(result))
102
- else if (!result.ok) {
103
- console.error(result.message)
104
- if (result.remedy) console.error(result.remedy)
105
- }
106
- if (result.ok) return 0
107
- return result.reason === "delivery_failed" ? 2 : 1
108
- }
109
-
110
- async function test(flags: Map<string, string>, json: boolean): Promise<number> {
111
- const result = await beaconSend(beaconFromConfig(loadConfig()), testMessage(), { to: flags.get("--to") ?? null })
112
- if (json) console.log(JSON.stringify(result))
113
- else if (result.ok) console.log(`Test message sent via ${result.channel}.`)
114
- else {
115
- console.error(result.message)
116
- if (result.remedy) console.error(result.remedy)
117
- }
118
- if (result.ok) return 0
119
- return result.reason === "delivery_failed" ? 2 : 1
120
- }
121
-
122
- const NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
123
-
124
- async function add(pos: string[], flags: Map<string, string>, json: boolean): Promise<number> {
125
- const [type, name] = pos
126
- if (type !== "telegram" || !name) return fail(json, "usage", USAGE)
127
- if (!NAME_RE.test(name)) return fail(json, "usage", `Channel names are lowercase letters, digits and dashes (got "${name}").`)
128
- if (name === "env") return fail(json, "usage", `"env" is reserved for the channel defined by SPINUPTUI_BEACON_* environment variables.`)
129
-
130
- // Secrets come from a file or the environment, never argv (where `ps` shows them).
131
- let source: Record<string, string | undefined> = process.env
132
- const envFile = flags.get("--env-file")
133
- if (envFile) {
134
- try {
135
- source = parseEnvFile(readFileSync(envFile, "utf8"))
136
- } catch (err) {
137
- return fail(json, "env_file_unreadable", `Couldn't read ${envFile}: ${(err as Error).message}`)
138
- }
139
- }
140
- const botToken = source.TELEGRAM_BOT_TOKEN?.trim()
141
- const chatId = source.TELEGRAM_CHAT_ID?.trim()
142
- if (!botToken || !chatId) {
143
- return fail(
144
- json,
145
- "missing_credentials",
146
- `TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID must both be set ${envFile ? `in ${envFile}` : "in the environment"}.`,
147
- "Put them in a KEY=VALUE file and pass --env-file <path>.",
148
- )
149
- }
150
-
151
- // Credentials that can't deliver are never stored.
152
- const me = await telegramGetMe(botToken)
153
- if (!me.ok) {
154
- if (json) console.log(JSON.stringify({ ok: false, reason: "token_rejected", message: `Telegram rejected the bot token: ${me.error}` }))
155
- else console.error(`Telegram rejected the bot token: ${me.error}`)
156
- return 2
157
- }
158
- const channel = { type: "telegram" as const, botToken, chatId }
159
- if (!flags.has("--no-test")) {
160
- const r = await sendConnectedMessage(name, channel)
161
- if (!r.ok) {
162
- if (json) console.log(JSON.stringify({ ...r, message: `Not saved. ${r.message}` }))
163
- else console.error(`Not saved. ${r.message}`)
164
- return 2
165
- }
166
- }
167
-
168
- const previousDefault = loadConfig().beacon?.defaultChannel
169
- const { isDefault } = await saveBeaconChannel(name, channel, flags.has("--default"))
170
- const summary = {
171
- ok: true,
172
- channel: name,
173
- bot: me.username ? `@${me.username}` : maskToken(botToken),
174
- chat: maskChatId(chatId),
175
- default: isDefault ? name : previousDefault,
176
- tested: !flags.has("--no-test"),
177
- }
178
- if (json) console.log(JSON.stringify(summary))
179
- else console.log(`Saved channel "${name}" (${summary.bot} → chat ${summary.chat})${isDefault ? ", now the default" : ""}.`)
180
- return 0
181
- }
182
-
183
- // The guided path for someone without a bot yet. Interactive only: scripts and
184
- // provisioning use `add --env-file`.
185
- async function setup(pos: string[]): Promise<number> {
186
- if (!process.stdin.isTTY || !process.stderr.isTTY) {
187
- return fail(false, "needs_terminal", "`beacon setup` is interactive and needs a terminal.", "For scripts, use `spinuptui beacon add telegram <name> --env-file <path>`.")
188
- }
189
- const name = pos[0] ?? setupChannelName()
190
- if (!NAME_RE.test(name) || name === "env") return fail(false, "usage", `"${name}" can't be a channel name (lowercase letters, digits and dashes; "env" is reserved).`)
191
- const say = (line = "") => process.stderr.write(line + "\n")
192
- try {
193
- say("Beacon setup: page yourself on Telegram.")
194
- say()
195
- if (channelExists(name) && !(await confirm(`A channel named "${name}" already exists. Replace it?`, false))) return 1
196
-
197
- say("1. Create a bot (skip if you already have one):")
198
- for (const step of BOTFATHER_STEPS) say(` · ${step}`)
199
- say()
200
- let token = ""
201
- let username = ""
202
- for (let attempt = 0; ; attempt++) {
203
- token = (await readLine("2. Paste the bot token (hidden): ", { hidden: true })).trim()
204
- const me = await verifyBotToken(token)
205
- if (me.ok) {
206
- username = me.username ?? ""
207
- say(` ✓ Bot ${username ? `@${username}` : "found"}`)
208
- break
209
- }
210
- say(` ✗ ${me.error}`)
211
- if (attempt >= 2) return 2
212
- }
213
- say()
214
- for (;;) {
215
- say(`3. In Telegram, send any message to ${username ? `@${username} (${botLink(username)})` : "your bot"}.`)
216
- say(" To page a group instead, add the bot to the group and send /start there.")
217
- say(" Waiting up to 5 minutes… (Ctrl-C to cancel)")
218
- const found = await discoverChat(token)
219
- if (!found.ok) {
220
- say(` ✗ ${found.message}`)
221
- if (found.reason === "timeout" && (await confirm(" Keep waiting?", true))) continue
222
- return found.reason === "cancelled" ? 1 : 2
223
- }
224
- say(` ✓ Found: ${found.chat.label} (${found.chat.kind} chat)`)
225
- if (!(await confirm(" Page this chat?", true))) continue
226
- const channel = { type: "telegram" as const, botToken: token, chatId: found.chat.chatId }
227
- const sent = await sendConnectedMessage(name, channel)
228
- if (!sent.ok) {
229
- say(` ✗ Couldn't send the test message: ${sent.message}. Nothing was saved.`)
230
- return 2
231
- }
232
- const { isDefault } = await saveBeaconChannel(name, channel, false)
233
- say()
234
- say(`✓ Saved channel "${name}"${isDefault ? " (the default)" : ""}. A test message is on its way to that chat.`)
235
- say(` Try it: spinuptui beacon send "hello from $(hostname)"`)
236
- return 0
237
- }
238
- } catch (err) {
239
- if (err instanceof PromptCancelled) {
240
- say("Cancelled; nothing was saved.")
241
- return 1
242
- }
243
- throw err
244
- }
245
- }
246
-
247
- async function remove(pos: string[], json: boolean): Promise<number> {
248
- const name = pos[0]
249
- if (!name) return fail(json, "usage", USAGE)
250
- const stored: BeaconStored = loadConfig().beacon ?? {}
251
- if (!stored.channels?.[name]) {
252
- const resolved = pickChannel(beaconFromConfig(loadConfig()), name)
253
- if (!("ok" in resolved) && resolved.env) {
254
- return fail(json, "read_only", `"${name}" comes from SPINUPTUI_BEACON_* environment variables; unset them to remove it.`)
255
- }
256
- return fail(json, "unknown_channel", `No stored Beacon channel named "${name}".`)
257
- }
258
- const { [name]: _gone, ...channels } = stored.channels
259
- const next: BeaconStored = { ...stored, channels }
260
- if (next.defaultChannel === name) delete next.defaultChannel
261
- await saveConfig({ beacon: next })
262
- if (json) console.log(JSON.stringify({ ok: true, removed: name }))
263
- else console.log(`Removed channel "${name}".`)
264
- return 0
265
- }
266
-
267
- function printStatus(s: ModuleStatus & { name?: string }): void {
268
- console.log(`${s.name ?? s.id}: ${s.state} — ${s.summary}`)
269
- for (const c of s.checks) console.log(` ${c.ok ? "✓" : "✗"} ${c.name}: ${c.detail}`)
270
- if (s.remedy) console.log(` → ${s.remedy}`)
271
- }
272
-
273
- async function status(json: boolean): Promise<number> {
274
- const s = await beaconModule.status()
275
- if (json) console.log(JSON.stringify({ ok: true, ...s }))
276
- else printStatus({ ...s, name: beaconModule.name })
277
- return 0
278
- }
279
-
280
- export async function runModules(argv: string[]): Promise<number> {
281
- const json = argv.includes("--json")
282
- const statuses = await allModuleStatuses()
283
- const upcoming = UPCOMING_MODULES.map(({ id, name, summary }) => ({ id, name, about: summary }))
284
- if (json) console.log(JSON.stringify({ ok: true, modules: statuses, upcoming }))
285
- else {
286
- for (const s of statuses) printStatus(s)
287
- console.log(`Coming soon: ${upcoming.map((u) => u.name).join(", ")}`)
288
- }
289
- return 0
290
- }
package/src/lib/prompt.ts DELETED
@@ -1,47 +0,0 @@
1
- // Minimal line prompts for interactive CLI subcommands (not the TUI). Prompts go
2
- // to stderr so stdout stays clean. `hidden` reads in raw mode and echoes a dot
3
- // per character, for secrets: they never appear on screen or in argv.
4
-
5
- export class PromptCancelled extends Error {}
6
-
7
- export function readLine(question: string, opts: { hidden?: boolean } = {}): Promise<string> {
8
- const stdin = process.stdin
9
- process.stderr.write(question)
10
- return new Promise((resolve, reject) => {
11
- let buf = ""
12
- const raw = Boolean(stdin.isTTY)
13
- if (raw) stdin.setRawMode(true)
14
- stdin.resume()
15
- const done = (err: Error | null) => {
16
- stdin.off("data", onData)
17
- if (raw) stdin.setRawMode(false)
18
- stdin.pause()
19
- process.stderr.write("\n")
20
- if (err) reject(err)
21
- else resolve(buf)
22
- }
23
- const onData = (chunk: Buffer) => {
24
- for (const ch of chunk.toString("utf8")) {
25
- if (ch === "\u0003" || ch === "\u0004") return done(new PromptCancelled("cancelled"))
26
- if (ch === "\r" || ch === "\n") return done(null)
27
- if (ch === "\u007f" || ch === "\b") {
28
- if (buf.length) {
29
- buf = buf.slice(0, -1)
30
- if (raw) process.stderr.write("\b \b")
31
- }
32
- continue
33
- }
34
- if (ch < " ") continue
35
- buf += ch
36
- if (raw) process.stderr.write(opts.hidden ? "•" : ch)
37
- }
38
- }
39
- stdin.on("data", onData)
40
- })
41
- }
42
-
43
- export async function confirm(question: string, defaultYes: boolean): Promise<boolean> {
44
- const answer = (await readLine(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase()
45
- if (!answer) return defaultYes
46
- return answer === "y" || answer === "yes"
47
- }
@@ -1,51 +0,0 @@
1
- // Beacon's module declaration: the first implementation of the contract.
2
-
3
- import { loadConfig } from "../config.ts"
4
- import { beaconFromConfig, lastSendLogEntry, maskChatId, maskToken } from "../lib/beacon.ts"
5
- import { moduleStateDir, type ModuleCheck, type ModuleDefinition, type ModuleStatus } from "./contract.ts"
6
-
7
- export const beaconModule: ModuleDefinition = {
8
- id: "beacon",
9
- name: "Beacon",
10
- summary: "Tells you when something needs you: one pager for the fleet and every module.",
11
- dependsOn: [],
12
- configKey: "beacon",
13
- stateDir: () => moduleStateDir("beacon"),
14
- jobs: [],
15
- async status(): Promise<ModuleStatus> {
16
- const beacon = beaconFromConfig(loadConfig())
17
- if (beacon.channels.length === 0) {
18
- return {
19
- id: "beacon",
20
- state: "not_configured",
21
- summary: "No channels configured",
22
- checks: [],
23
- remedy: "spinuptui beacon add telegram <name> --env-file <path>",
24
- }
25
- }
26
- const checks: ModuleCheck[] = beacon.channels.map((c) => ({
27
- name: `channel ${c.name}`,
28
- ok: true,
29
- detail: `telegram bot ${maskToken(c.channel.botToken)} → chat ${maskChatId(c.channel.chatId)}${c.env ? " (from environment)" : ""}${c.name === beacon.defaultChannel ? ", default" : ""}`,
30
- }))
31
- checks.push({
32
- name: "default channel",
33
- ok: beacon.defaultChannel !== null,
34
- detail: beacon.defaultChannel ?? "none — senders must pass --to",
35
- })
36
- const last = lastSendLogEntry()
37
- checks.push({
38
- name: "last send",
39
- ok: last?.ok ?? true,
40
- detail: last ? `${last.ok ? "delivered" : `failed (${last.error})`} via ${last.channel} at ${last.at}` : "nothing sent yet",
41
- })
42
- const failing = checks.find((c) => !c.ok)
43
- return {
44
- id: "beacon",
45
- state: failing ? "degraded" : "ok",
46
- summary: failing ? `${failing.name}: ${failing.detail}` : `${beacon.channels.length} channel${beacon.channels.length === 1 ? "" : "s"}, default ${beacon.defaultChannel}`,
47
- checks,
48
- ...(failing ? { remedy: "spinuptui beacon test" } : {}),
49
- }
50
- },
51
- }
@@ -1,61 +0,0 @@
1
- // The module contract (docs/2026-10-16_beacon-and-module-contract-spec.md).
2
- //
3
- // A module is a feature that keeps working after the TUI is closed. Each one
4
- // describes itself with a ModuleDefinition so `spinuptui modules`, the future
5
- // Modules screen and `station setup` are views over the same data rather than
6
- // knowing every module by hand.
7
- //
8
- // Rules the shape encodes:
9
- // - Config is a machine-level top-level key in config.json named after the
10
- // module, written only by deliberate setup commands.
11
- // - Anything a module writes while running goes in its state directory, never
12
- // config.json (whose writes are neither atomic nor locked).
13
- // - Recurring work is declared in `jobs` for Scheduler to run; a module never
14
- // installs an OS scheduler unit of its own.
15
- // - status() is computed on demand, is cheap, and never sends anything.
16
-
17
- import { join } from "node:path"
18
- import { configDir } from "../config.ts"
19
-
20
- export type ModuleId = "beacon" | "scheduler" | "sentinel" | "samurai"
21
-
22
- export interface ScheduledJobDecl {
23
- id: string // e.g. "sentinel.tick"
24
- every?: string // e.g. "5m"
25
- at?: string // e.g. "08:00"
26
- // A spinuptui subcommand's arguments, never a path into a checkout.
27
- command: string[]
28
- }
29
-
30
- export interface ModuleCheck {
31
- name: string
32
- ok: boolean
33
- detail: string
34
- }
35
-
36
- export interface ModuleStatus {
37
- id: ModuleId
38
- state: "not_configured" | "ok" | "degraded"
39
- summary: string
40
- checks: ModuleCheck[]
41
- remedy?: string
42
- }
43
-
44
- export interface ModuleDefinition {
45
- id: ModuleId
46
- name: string
47
- // Outcome copy, shared with the landing page's module card.
48
- summary: string
49
- dependsOn: ModuleId[]
50
- configKey: string
51
- stateDir(): string
52
- jobs: ScheduledJobDecl[]
53
- status(): Promise<ModuleStatus>
54
- }
55
-
56
- export function moduleStateDir(id: ModuleId): string {
57
- return join(configDir(), "modules", id)
58
- }
59
-
60
- // A module that hasn't shipped yet: just enough to draw its card.
61
- export type UpcomingModule = Pick<ModuleDefinition, "id" | "name" | "summary" | "dependsOn">
@@ -1,34 +0,0 @@
1
- // Every module SpinupTUI ships, in dependency order, plus the ones still to come
2
- // (shown as "coming soon" on the Modules screen and in `spinuptui modules`).
3
-
4
- import type { ModuleDefinition, UpcomingModule } from "./contract.ts"
5
- import { beaconModule } from "./beacon.ts"
6
-
7
- export const MODULES: ModuleDefinition[] = [beaconModule]
8
-
9
- // Outcome copy only — the same lines feed the landing page. An entry moves to
10
- // MODULES (and out of here) when the module ships.
11
- export const UPCOMING_MODULES: UpcomingModule[] = [
12
- {
13
- id: "scheduler",
14
- name: "Scheduler",
15
- summary: "Follow-ups that run themselves: the fix you shipped today gets checked tomorrow without you remembering.",
16
- dependsOn: ["beacon"],
17
- },
18
- {
19
- id: "sentinel",
20
- name: "Sentinel",
21
- summary: "Watches the fleet, works out what actually broke, and writes it up.",
22
- dependsOn: ["beacon", "scheduler"],
23
- },
24
- {
25
- id: "samurai",
26
- name: "Samurai",
27
- summary: "Defends your sites at the nginx layer, proactively and when Sentinel spots an attack, with your approval.",
28
- dependsOn: ["beacon", "scheduler", "sentinel"],
29
- },
30
- ]
31
-
32
- export async function allModuleStatuses() {
33
- return Promise.all(MODULES.map(async (m) => ({ name: m.name, about: m.summary, dependsOn: m.dependsOn, ...(await m.status()) })))
34
- }