spinuptui 1.1.2 → 1.2.0-alpha.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.
- package/CHANGELOG.md +14 -1
- package/README.md +8 -1
- package/package.json +1 -1
- package/src/config.ts +23 -0
- package/src/index.tsx +20 -0
- package/src/lib/appUpdate.test.ts +26 -0
- package/src/lib/appUpdate.ts +33 -7
- package/src/lib/beacon.test.ts +140 -0
- package/src/lib/beacon.ts +349 -0
- package/src/lib/beaconSetup.test.ts +65 -0
- package/src/lib/beaconSetup.ts +143 -0
- package/src/lib/cliBeacon.ts +290 -0
- package/src/lib/prompt.ts +47 -0
- package/src/modules/beacon.ts +51 -0
- package/src/modules/contract.ts +61 -0
- package/src/modules/index.ts +34 -0
- package/src/ui/App.tsx +11 -1
- package/src/ui/Explain.tsx +15 -2
- package/src/ui/Header.tsx +2 -0
- package/src/ui/Help.tsx +1 -1
- package/src/ui/store.tsx +7 -1
- package/src/ui/views/BeaconSetup.tsx +268 -0
- package/src/ui/views/Modules.tsx +196 -0
|
@@ -0,0 +1,290 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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">
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
}
|
package/src/ui/App.tsx
CHANGED
|
@@ -40,6 +40,8 @@ import { ProviderConnect } from "./views/ProviderConnect.tsx"
|
|
|
40
40
|
import { DnsRecords } from "./views/DnsRecords.tsx"
|
|
41
41
|
import { KumaSite } from "./views/KumaSite.tsx"
|
|
42
42
|
import { Accounts } from "./views/Accounts.tsx"
|
|
43
|
+
import { Modules } from "./views/Modules.tsx"
|
|
44
|
+
import { BeaconSetup } from "./views/BeaconSetup.tsx"
|
|
43
45
|
|
|
44
46
|
const MIN_SPLASH_MS = 1200
|
|
45
47
|
|
|
@@ -87,7 +89,8 @@ export function App() {
|
|
|
87
89
|
store.dnsInventoryServer !== null ||
|
|
88
90
|
store.connectZoneTarget !== null ||
|
|
89
91
|
store.dnsRecordsTarget !== null ||
|
|
90
|
-
store.kumaSite !== null
|
|
92
|
+
store.kumaSite !== null ||
|
|
93
|
+
store.beaconSetupOpen
|
|
91
94
|
useEffect(() => {
|
|
92
95
|
store.setOverlayOpen(overlayActive)
|
|
93
96
|
}, [overlayActive, store])
|
|
@@ -200,6 +203,9 @@ export function App() {
|
|
|
200
203
|
// The site-monitoring (Uptime Kuma) overlay owns the keyboard while open.
|
|
201
204
|
if (store.kumaSite) return
|
|
202
205
|
|
|
206
|
+
// Beacon's guided setup (Modules tab) owns the keyboard while open.
|
|
207
|
+
if (store.beaconSetupOpen) return
|
|
208
|
+
|
|
203
209
|
// A (Shift+a): the Accounts overlay — the header advertises it from every tab.
|
|
204
210
|
if (key.shift && key.name === "a") return setShowAccounts(true)
|
|
205
211
|
|
|
@@ -216,6 +222,8 @@ export function App() {
|
|
|
216
222
|
return store.setRoute("search")
|
|
217
223
|
case "5":
|
|
218
224
|
return store.setRoute("events")
|
|
225
|
+
case "6":
|
|
226
|
+
return store.setRoute("modules")
|
|
219
227
|
case "r":
|
|
220
228
|
return void store.refresh()
|
|
221
229
|
case "?":
|
|
@@ -255,6 +263,7 @@ export function App() {
|
|
|
255
263
|
{store.route === "stacks" && <Stacks rows={contentRows} />}
|
|
256
264
|
{store.route === "search" && <Search rows={contentRows} />}
|
|
257
265
|
{store.route === "events" && <Events rows={contentRows} />}
|
|
266
|
+
{store.route === "modules" && <Modules rows={contentRows} />}
|
|
258
267
|
</box>
|
|
259
268
|
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
|
|
260
269
|
{showExplain && <ExplainOverlay route={store.route} />}
|
|
@@ -283,6 +292,7 @@ export function App() {
|
|
|
283
292
|
{store.connectZoneTarget && <ProviderConnect />}
|
|
284
293
|
{store.dnsRecordsTarget && <DnsRecords />}
|
|
285
294
|
{store.kumaSite && <KumaSite />}
|
|
295
|
+
{store.beaconSetupOpen && <BeaconSetup />}
|
|
286
296
|
{showAccounts && <Accounts onClose={() => setShowAccounts(false)} />}
|
|
287
297
|
<ToastStack />
|
|
288
298
|
</box>
|
package/src/ui/Explain.tsx
CHANGED
|
@@ -27,7 +27,7 @@ const GUIDES: Record<Route, Guide> = {
|
|
|
27
27
|
["Recent activity", "The latest deploys and operations"],
|
|
28
28
|
],
|
|
29
29
|
actions: [
|
|
30
|
-
["1–
|
|
30
|
+
["1–6", "Switch tabs"],
|
|
31
31
|
["r", "Refresh from the API"],
|
|
32
32
|
],
|
|
33
33
|
},
|
|
@@ -91,10 +91,23 @@ const GUIDES: Record<Route, Guide> = {
|
|
|
91
91
|
title: "Events",
|
|
92
92
|
summary: "A live feed of deploys, reboots, and operations across your account.",
|
|
93
93
|
actions: [
|
|
94
|
-
["1–
|
|
94
|
+
["1–6", "Switch tabs"],
|
|
95
95
|
["r", "Refresh"],
|
|
96
96
|
],
|
|
97
97
|
},
|
|
98
|
+
modules: {
|
|
99
|
+
title: "Modules",
|
|
100
|
+
summary: "Modules are the parts of SpinupTUI that keep working after you close it: paging you, running follow-ups, watching the fleet. Each card says what it does and whether it's set up on this machine.",
|
|
101
|
+
panes: [
|
|
102
|
+
["Modules", "Every module, with its status here: ● set up · ○ not set up · coming soon"],
|
|
103
|
+
["Detail", "What the selected module does, its health checks, and what to do next"],
|
|
104
|
+
],
|
|
105
|
+
actions: [
|
|
106
|
+
["⏎ / s", "Set up the selected module (Beacon: connect a Telegram bot)"],
|
|
107
|
+
["t", "Send a test page through Beacon"],
|
|
108
|
+
["1–6", "Switch tabs"],
|
|
109
|
+
],
|
|
110
|
+
},
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
export function ExplainOverlay({ route }: { route: Route }) {
|
package/src/ui/Header.tsx
CHANGED
|
@@ -15,6 +15,7 @@ const TABS: { route: Route; key: string; label: string }[] = [
|
|
|
15
15
|
{ route: "stacks", key: "3", label: "Stacks" },
|
|
16
16
|
{ route: "search", key: "4", label: "Search" },
|
|
17
17
|
{ route: "events", key: "5", label: "Events" },
|
|
18
|
+
{ route: "modules", key: "6", label: "Modules" },
|
|
18
19
|
]
|
|
19
20
|
|
|
20
21
|
// A one-line "what is this view, and what can I do here" subtitle per tab —
|
|
@@ -26,6 +27,7 @@ const SUBTITLES: Record<Route, string> = {
|
|
|
26
27
|
stacks: "Your fleet grouped by app type · d identify an app (SSH) · S find local copies on your disk",
|
|
27
28
|
search: "Jump to any server or site by name · Tab hands focus to the result's actions",
|
|
28
29
|
events: "Recent deploys, reboots, and operations across your account",
|
|
30
|
+
modules: "What keeps working after you close SpinupTUI · ⏎ set up the selected module",
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
// SpinupWP allows 60 API requests a minute. Show what's left of this minute so
|
package/src/ui/Help.tsx
CHANGED
|
@@ -15,7 +15,7 @@ type Section = { title: string; keys: [string, string][] }
|
|
|
15
15
|
const GLOBAL: Section = {
|
|
16
16
|
title: "Global",
|
|
17
17
|
keys: [
|
|
18
|
-
["1 …
|
|
18
|
+
["1 … 6", "Switch tabs"],
|
|
19
19
|
["r", "Refresh from the API"],
|
|
20
20
|
["/", "Global search"],
|
|
21
21
|
["A", "Switch SpinupWP accounts"],
|
package/src/ui/store.tsx
CHANGED
|
@@ -60,7 +60,7 @@ import { planDbSync, runDbSync, isDbSyncInFlight, type DbSyncProgress, type Sync
|
|
|
60
60
|
import { planLocalSetup, runLocalSetup, isLocalSetupInFlight, type LocalSetupProgress, type LocalSetupPlanResult } from "../lib/localSetup.ts"
|
|
61
61
|
import { planMediaFallback, type MediaFallbackResult } from "../lib/mediaFallback.ts"
|
|
62
62
|
|
|
63
|
-
export type Route = "dashboard" | "servers" | "stacks" | "search" | "events"
|
|
63
|
+
export type Route = "dashboard" | "servers" | "stacks" | "search" | "events" | "modules"
|
|
64
64
|
|
|
65
65
|
// Progress of a PHP-version upgrade, tracked in the store so it survives the
|
|
66
66
|
// modal being closed. `status` mirrors the SpinupWP event status
|
|
@@ -742,6 +742,9 @@ interface StoreValue extends DataState {
|
|
|
742
742
|
// Whether the local-copy discovery overlay is open.
|
|
743
743
|
discoverOpen: boolean
|
|
744
744
|
setDiscoverOpen: (v: boolean) => void
|
|
745
|
+
// Beacon's guided setup overlay, opened from the Modules tab.
|
|
746
|
+
beaconSetupOpen: boolean
|
|
747
|
+
setBeaconSetupOpen: (v: boolean) => void
|
|
745
748
|
// Whether the "needs a local copy" (forgotten) report overlay is open, and an
|
|
746
749
|
// optional stack filter (set from the selected Stacks group when opened).
|
|
747
750
|
forgottenOpen: boolean
|
|
@@ -1110,6 +1113,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1110
1113
|
const [mediaFallbackSite, setMediaFallbackSite] = useState<Site | null>(null)
|
|
1111
1114
|
const [localRoots, setLocalRoots] = useState<string[]>(() => [...cfgRef.current.localRoots])
|
|
1112
1115
|
const [discoverOpen, setDiscoverOpen] = useState(false)
|
|
1116
|
+
const [beaconSetupOpen, setBeaconSetupOpen] = useState(false)
|
|
1113
1117
|
const [forgottenOpen, setForgottenOpen] = useState(false)
|
|
1114
1118
|
const [forgottenStack, setForgottenStack] = useState<Stack | null>(null)
|
|
1115
1119
|
const [linkReturnToForgotten, setLinkReturnToForgotten] = useState(false)
|
|
@@ -4528,6 +4532,8 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
4528
4532
|
addLocalRoot,
|
|
4529
4533
|
discoverOpen,
|
|
4530
4534
|
setDiscoverOpen,
|
|
4535
|
+
beaconSetupOpen,
|
|
4536
|
+
setBeaconSetupOpen,
|
|
4531
4537
|
forgottenOpen,
|
|
4532
4538
|
setForgottenOpen,
|
|
4533
4539
|
forgottenStack,
|