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 +31 -1
- package/README.md +13 -1
- package/package.json +1 -1
- package/src/config.ts +45 -0
- package/src/index.tsx +37 -38
- 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/cliScheduler.ts +534 -0
- package/src/lib/flock.ts +51 -0
- package/src/lib/prompt.ts +47 -0
- package/src/lib/schedule.ts +156 -0
- package/src/lib/scheduler.test.ts +353 -0
- package/src/lib/schedulerGit.ts +119 -0
- package/src/lib/schedulerInstall.ts +325 -0
- package/src/lib/schedulerJobs.ts +288 -0
- package/src/lib/schedulerRun.ts +246 -0
- package/src/lib/schedulerTick.ts +307 -0
- package/src/modules/beacon.ts +52 -0
- package/src/modules/contract.ts +82 -0
- package/src/modules/index.ts +29 -0
- package/src/modules/scheduler.ts +146 -0
- package/src/ui/App.tsx +15 -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/main.tsx +42 -0
- package/src/ui/store.tsx +13 -1
- package/src/ui/views/BeaconSetup.tsx +268 -0
- package/src/ui/views/Modules.tsx +232 -0
- package/src/ui/views/SchedulerInstall.tsx +172 -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
|
+
}
|