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,534 @@
|
|
|
1
|
+
// `spinuptui scheduler …` — the CLI over Scheduler (lib/schedule*.ts,
|
|
2
|
+
// lib/scheduler*.ts). Headless like every module command: --json prints one
|
|
3
|
+
// result object on stdout, progress goes to stderr, failures use the
|
|
4
|
+
// { ok: false, reason, message, remedy? } shape and exit 1.
|
|
5
|
+
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"
|
|
7
|
+
import { join, relative, resolve } from "node:path"
|
|
8
|
+
import { loadConfig, saveConfig } from "../config.ts"
|
|
9
|
+
import { schedulerModule } from "../modules/scheduler.ts"
|
|
10
|
+
import { fmt, nextRunText } from "./schedule.ts"
|
|
11
|
+
import {
|
|
12
|
+
defaultSourceName,
|
|
13
|
+
expandHome,
|
|
14
|
+
findSource,
|
|
15
|
+
gitRootOf,
|
|
16
|
+
ID_RE,
|
|
17
|
+
jobsDir,
|
|
18
|
+
KINDS,
|
|
19
|
+
listSources,
|
|
20
|
+
loadJobs,
|
|
21
|
+
loadState,
|
|
22
|
+
LOCAL_SOURCE,
|
|
23
|
+
parseJobText,
|
|
24
|
+
renderJob,
|
|
25
|
+
validateJob,
|
|
26
|
+
type Source,
|
|
27
|
+
} from "./schedulerJobs.ts"
|
|
28
|
+
import { applyInstall, applyUninstall, binHasScheduler, NO_SCHEDULER, planInstall, planUninstall } from "./schedulerInstall.ts"
|
|
29
|
+
import { tick } from "./schedulerTick.ts"
|
|
30
|
+
import { git, gitPull, readClaim, removeClaim, stationName, writeClaim } from "./schedulerGit.ts"
|
|
31
|
+
|
|
32
|
+
const VALUE_FLAGS = new Set([
|
|
33
|
+
"--source",
|
|
34
|
+
"--kind",
|
|
35
|
+
"--when",
|
|
36
|
+
"--every",
|
|
37
|
+
"--expires",
|
|
38
|
+
"--created-by",
|
|
39
|
+
"--workdir",
|
|
40
|
+
"--timeout",
|
|
41
|
+
"--notify",
|
|
42
|
+
"--to",
|
|
43
|
+
"--agent",
|
|
44
|
+
"--allow",
|
|
45
|
+
"--add-dirs",
|
|
46
|
+
"--model",
|
|
47
|
+
"--sandbox",
|
|
48
|
+
"--body-file",
|
|
49
|
+
"--prompt-file",
|
|
50
|
+
"--bin",
|
|
51
|
+
"--late",
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
// Header keys `add` writes from flags, in file order.
|
|
55
|
+
const OPTIONAL_KEYS: [string, string][] = [
|
|
56
|
+
["late", "--late"],
|
|
57
|
+
["workdir", "--workdir"],
|
|
58
|
+
["timeout", "--timeout"],
|
|
59
|
+
["notify", "--notify"],
|
|
60
|
+
["to", "--to"],
|
|
61
|
+
["agent", "--agent"],
|
|
62
|
+
["allow", "--allow"],
|
|
63
|
+
["add_dirs", "--add-dirs"],
|
|
64
|
+
["model", "--model"],
|
|
65
|
+
["sandbox", "--sandbox"],
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
const USAGE = `Usage:
|
|
69
|
+
spinuptui scheduler status [--json]
|
|
70
|
+
spinuptui scheduler list [--source <name>] [--json]
|
|
71
|
+
spinuptui scheduler show <id> [--source <name>]
|
|
72
|
+
spinuptui scheduler add <id> --kind ${KINDS.join("|")} (--when "YYYY-MM-DD HH:MM" | --when now | --every <schedule>)
|
|
73
|
+
--expires YYYY-MM-DD --created-by <who> [--source <name>] [options]
|
|
74
|
+
[--body-file <path> | -- <body>] (else stdin)
|
|
75
|
+
spinuptui scheduler cancel <id> [--source <name>]
|
|
76
|
+
spinuptui scheduler run <id> [--source <name>] run it now, on this machine
|
|
77
|
+
spinuptui scheduler validate [--source <name>]
|
|
78
|
+
spinuptui scheduler source add <name> <path> [--default] | source remove <name>
|
|
79
|
+
spinuptui scheduler source claim <name> run this git source from this station
|
|
80
|
+
spinuptui scheduler source release <name> stop running it here; another station takes it
|
|
81
|
+
spinuptui scheduler tick [--here]
|
|
82
|
+
spinuptui scheduler install | uninstall --plan | --apply [--bin <path>]
|
|
83
|
+
|
|
84
|
+
Schedules: --every 30m | 6h | "daily 08:00" | "weekdays 08:00" | "mon,thu 08:00" (station local time).
|
|
85
|
+
Add options: --workdir <dir> --timeout <min> --notify beacon|none --to <channel>
|
|
86
|
+
agent jobs: --agent claude|codex --allow <tools> --add-dirs <dirs> --model <m> --sandbox <mode>
|
|
87
|
+
The body is a spinuptui command line (kind spinuptui), a bash script (script), or a prompt
|
|
88
|
+
(agent). Write it for a reader with no context, and keep secrets out of what it prints:
|
|
89
|
+
reports go in ~/SpinupTUI/scheduler/runs/ (or the source's runs/), and a git source
|
|
90
|
+
commits them.
|
|
91
|
+
|
|
92
|
+
Late slots: --late run (default) | skip (only within 15 min of the slot) | 2h (a grace window).`
|
|
93
|
+
|
|
94
|
+
interface Parsed {
|
|
95
|
+
positionals: string[]
|
|
96
|
+
flags: Map<string, string>
|
|
97
|
+
rest: string[] | null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parse(list: string[]): Parsed {
|
|
101
|
+
const dash = list.indexOf("--")
|
|
102
|
+
const head = dash === -1 ? list : list.slice(0, dash)
|
|
103
|
+
const positionals: string[] = []
|
|
104
|
+
const flags = new Map<string, string>()
|
|
105
|
+
for (let i = 0; i < head.length; i++) {
|
|
106
|
+
const a = head[i]!
|
|
107
|
+
if (a.startsWith("-") && a !== "-") {
|
|
108
|
+
if (VALUE_FLAGS.has(a)) {
|
|
109
|
+
flags.set(a, head[i + 1] ?? "")
|
|
110
|
+
i++
|
|
111
|
+
} else flags.set(a, "")
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
positionals.push(a)
|
|
115
|
+
}
|
|
116
|
+
return { positionals, flags, rest: dash === -1 ? null : list.slice(dash + 1) }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function fail(json: boolean, reason: string, message: string, remedy?: string): number {
|
|
120
|
+
if (json) console.log(JSON.stringify({ ok: false, reason, message, ...(remedy ? { remedy } : {}) }))
|
|
121
|
+
else {
|
|
122
|
+
console.error(message)
|
|
123
|
+
if (remedy) console.error(remedy)
|
|
124
|
+
}
|
|
125
|
+
return 1
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const say = (line: string) => process.stderr.write(`→ ${line}\n`)
|
|
129
|
+
|
|
130
|
+
// Re-quote argv so splitArgs() gives the same argv back: `add … -- ssh-exec x -- 'curl -sI …'`
|
|
131
|
+
// arrives with the shell's quotes already removed.
|
|
132
|
+
export function quoteArg(a: string): string {
|
|
133
|
+
return /^[\w@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function runScheduler(argv: string[]): Promise<number> {
|
|
137
|
+
const p = parse(argv)
|
|
138
|
+
const json = p.flags.has("--json")
|
|
139
|
+
for (const f of VALUE_FLAGS) {
|
|
140
|
+
if (p.flags.has(f) && (!p.flags.get(f) || p.flags.get(f)!.startsWith("--"))) return fail(json, "usage", `${f} needs a value.\n${USAGE}`)
|
|
141
|
+
}
|
|
142
|
+
const [sub, ...pos] = p.positionals
|
|
143
|
+
switch (sub) {
|
|
144
|
+
case "status":
|
|
145
|
+
return status(json)
|
|
146
|
+
case "list":
|
|
147
|
+
return list(p, json)
|
|
148
|
+
case "show":
|
|
149
|
+
return show(pos, p)
|
|
150
|
+
case "add":
|
|
151
|
+
return add(pos, p, json)
|
|
152
|
+
case "cancel":
|
|
153
|
+
return cancel(pos, p, json)
|
|
154
|
+
case "run":
|
|
155
|
+
return run(pos, p, json)
|
|
156
|
+
case "validate":
|
|
157
|
+
return validate(p, json)
|
|
158
|
+
case "source":
|
|
159
|
+
return source(pos, p, json)
|
|
160
|
+
case "tick":
|
|
161
|
+
return doTick(p, json)
|
|
162
|
+
case "install":
|
|
163
|
+
return install(p, json)
|
|
164
|
+
case "uninstall":
|
|
165
|
+
return uninstall(p, json)
|
|
166
|
+
}
|
|
167
|
+
return fail(json, "usage", USAGE)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function sourceOrFail(p: Parsed, json: boolean): Source | number {
|
|
171
|
+
const name = p.flags.get("--source") ?? null
|
|
172
|
+
const s = findSource(loadConfig(), name)
|
|
173
|
+
if (s) return s
|
|
174
|
+
const names = listSources(loadConfig()).map((x) => x.name)
|
|
175
|
+
return fail(json, "unknown_source", `No Scheduler source named "${name ?? defaultSourceName(loadConfig())}" (have: ${names.join(", ")}).`, "Add one with `spinuptui scheduler source add <name> <path>`.")
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function status(json: boolean): Promise<number> {
|
|
179
|
+
const s = await schedulerModule.status()
|
|
180
|
+
if (json) console.log(JSON.stringify({ ok: true, ...s }))
|
|
181
|
+
else {
|
|
182
|
+
console.log(`Scheduler: ${s.state} — ${s.summary}`)
|
|
183
|
+
for (const c of s.checks) console.log(` ${c.ok ? "✓" : "✗"} ${c.name}: ${c.detail}`)
|
|
184
|
+
if (s.remedy) console.log(` → ${s.remedy}`)
|
|
185
|
+
}
|
|
186
|
+
return 0
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function list(p: Parsed, json: boolean): Promise<number> {
|
|
190
|
+
const cfg = loadConfig()
|
|
191
|
+
const only = p.flags.get("--source")
|
|
192
|
+
const sources = listSources(cfg).filter((s) => !only || s.name === only)
|
|
193
|
+
if (only && sources.length === 0) return fail(json, "unknown_source", `No Scheduler source named "${only}".`)
|
|
194
|
+
const now = new Date()
|
|
195
|
+
const jobs: Record<string, unknown>[] = []
|
|
196
|
+
for (const s of sources) {
|
|
197
|
+
if (!existsSync(s.path)) continue
|
|
198
|
+
if (s.git && !p.flags.has("--no-pull")) {
|
|
199
|
+
const e = await gitPull(s.path)
|
|
200
|
+
if (e) process.stderr.write(`Warning: ${s.name}: ${e}\n`)
|
|
201
|
+
}
|
|
202
|
+
for (const j of loadJobs(s)) {
|
|
203
|
+
const h = j.header
|
|
204
|
+
if (j.problems.length) {
|
|
205
|
+
jobs.push({ source: s.name, id: h.id ?? j.name, invalid: j.problems })
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
const st = loadState(s, h.id!)
|
|
209
|
+
jobs.push({
|
|
210
|
+
source: s.name,
|
|
211
|
+
id: h.id,
|
|
212
|
+
kind: h.kind === "agent" ? `agent/${h.agent || "claude"}` : h.kind,
|
|
213
|
+
schedule: h.when ?? `every ${h.every}`,
|
|
214
|
+
expires: h.expires,
|
|
215
|
+
runs: st.runs ?? 0,
|
|
216
|
+
last: st.last_status ?? "-",
|
|
217
|
+
next: nextRunText(h, st, now),
|
|
218
|
+
...(st.last_report ? { lastReport: st.last_report } : {}),
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (json) {
|
|
223
|
+
console.log(JSON.stringify({ ok: true, jobs }))
|
|
224
|
+
return 0
|
|
225
|
+
}
|
|
226
|
+
if (jobs.length === 0) {
|
|
227
|
+
console.log(`No jobs. Queue one with \`spinuptui scheduler add\` (see --help).`)
|
|
228
|
+
return 0
|
|
229
|
+
}
|
|
230
|
+
const rows = [["SOURCE", "ID", "KIND", "SCHEDULE", "EXPIRES", "RUNS", "LAST", "NEXT"]]
|
|
231
|
+
for (const j of jobs) {
|
|
232
|
+
if (j.invalid) rows.push([String(j.source), String(j.id), "?", `INVALID: ${(j.invalid as string[]).join("; ")}`.slice(0, 70), "", "", "", ""])
|
|
233
|
+
else rows.push([j.source, j.id, j.kind, j.schedule, j.expires, j.runs, j.last, j.next].map(String))
|
|
234
|
+
}
|
|
235
|
+
const widths = rows[0]!.map((_, i) => Math.max(...rows.map((r) => r[i]!.length)))
|
|
236
|
+
for (const r of rows) console.log(r.map((c, i) => c.padEnd(widths[i]!)).join(" ").trimEnd())
|
|
237
|
+
return 0
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function show(pos: string[], p: Parsed): Promise<number> {
|
|
241
|
+
const id = pos[0]
|
|
242
|
+
if (!id) return fail(false, "usage", USAGE)
|
|
243
|
+
const s = sourceOrFail(p, false)
|
|
244
|
+
if (typeof s === "number") return s
|
|
245
|
+
if (s.git && !p.flags.has("--no-pull")) await gitPull(s.path)
|
|
246
|
+
const path = join(jobsDir(s), `${id}.md`)
|
|
247
|
+
console.log(existsSync(path) ? readFileSync(path, "utf8") : `(no jobs/${id}.md in ${s.name} — cancelled or never added)`)
|
|
248
|
+
const st = loadState(s, id)
|
|
249
|
+
if (st.last_report && existsSync(join(s.path, st.last_report))) {
|
|
250
|
+
console.log(`=== latest report: ${s.name}/${st.last_report}\n`)
|
|
251
|
+
console.log(readFileSync(join(s.path, st.last_report), "utf8"))
|
|
252
|
+
}
|
|
253
|
+
return 0
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function readBody(p: Parsed): Promise<string | null> {
|
|
257
|
+
const file = p.flags.get("--body-file") ?? p.flags.get("--prompt-file")
|
|
258
|
+
if (file) return readFileSync(expandHome(file), "utf8")
|
|
259
|
+
// A spinuptui body is an argv, so keep each argument's boundaries; a script
|
|
260
|
+
// or prompt is text, usually passed as one quoted argument.
|
|
261
|
+
if (p.rest !== null) return p.flags.get("--kind") === "spinuptui" ? p.rest.map(quoteArg).join(" ") : p.rest.join(" ")
|
|
262
|
+
if (!process.stdin.isTTY) return Bun.stdin.text()
|
|
263
|
+
return null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function add(pos: string[], p: Parsed, json: boolean): Promise<number> {
|
|
267
|
+
const id = pos[0]
|
|
268
|
+
const f = p.flags
|
|
269
|
+
if (!id) return fail(json, "usage", USAGE)
|
|
270
|
+
if (!ID_RE.test(id)) return fail(json, "usage", "Job ids are lowercase letters, digits and hyphens (2–80 characters).")
|
|
271
|
+
for (const req of ["--kind", "--expires", "--created-by"]) if (!f.has(req)) return fail(json, "usage", `${req} is required.\n${USAGE}`)
|
|
272
|
+
if (f.has("--when") === f.has("--every")) return fail(json, "usage", "Give exactly one of --when or --every.")
|
|
273
|
+
const s = sourceOrFail(p, json)
|
|
274
|
+
if (typeof s === "number") return s
|
|
275
|
+
const body = await readBody(p)
|
|
276
|
+
if (body === null || !body.trim()) return fail(json, "usage", "No body: pass --body-file <path>, `-- <command…>`, or pipe it on stdin.")
|
|
277
|
+
|
|
278
|
+
if (s.git && !f.has("--no-push")) {
|
|
279
|
+
const e = await gitPull(s.path)
|
|
280
|
+
if (e) return fail(json, "git_failed", `${s.name}: ${e}`)
|
|
281
|
+
}
|
|
282
|
+
const path = join(jobsDir(s), `${id}.md`)
|
|
283
|
+
if (existsSync(path) && !f.has("--replace")) return fail(json, "exists", `${s.name}/jobs/${id}.md already exists.`, "Pass --replace, or pick another id.")
|
|
284
|
+
const now = new Date()
|
|
285
|
+
const header: [string, string][] = [
|
|
286
|
+
["id", id],
|
|
287
|
+
["kind", f.get("--kind")!],
|
|
288
|
+
]
|
|
289
|
+
if (f.has("--when")) header.push(["when", f.get("--when") === "now" ? fmt(now) : f.get("--when")!])
|
|
290
|
+
else header.push(["every", f.get("--every")!])
|
|
291
|
+
header.push(["expires", f.get("--expires")!], ["created", fmt(now)], ["created_by", f.get("--created-by")!])
|
|
292
|
+
for (const [key, flag] of OPTIONAL_KEYS) if (f.get(flag)) header.push([key, f.get(flag)!])
|
|
293
|
+
const text = renderJob(header, body)
|
|
294
|
+
const parsed = parseJobText(text)
|
|
295
|
+
const problems = validateJob(parsed.header, parsed.body, `${id}.md`)
|
|
296
|
+
if (problems.length) return fail(json, "invalid_job", `Not added: ${problems.join("; ")}`)
|
|
297
|
+
|
|
298
|
+
mkdirSync(jobsDir(s), { recursive: true })
|
|
299
|
+
writeFileSync(`${path}.tmp`, text)
|
|
300
|
+
renameSync(`${path}.tmp`, path)
|
|
301
|
+
let pushed: boolean | null = null
|
|
302
|
+
if (s.git && !f.has("--no-push")) {
|
|
303
|
+
await git(s.path, "add", "--", path)
|
|
304
|
+
await git(s.path, "commit", "--quiet", "-m", `job: ${f.has("--replace") ? "replace" : "add"} ${id} (${f.get("--created-by")})`)
|
|
305
|
+
const r = await git(s.path, "push", "--quiet")
|
|
306
|
+
if (r.code !== 0) return fail(json, "push_failed", `Committed ${s.name}/jobs/${id}.md, but the push failed, so the station won't see it until it's pushed:\n${r.out}`)
|
|
307
|
+
pushed = true
|
|
308
|
+
}
|
|
309
|
+
const result = { ok: true, source: s.name, id, path, pushed }
|
|
310
|
+
if (json) console.log(JSON.stringify(result))
|
|
311
|
+
else console.log(`Queued ${s.name}/jobs/${id}.md${pushed ? " and pushed it" : ""}. The station runs it within 5 minutes of it falling due.`)
|
|
312
|
+
return 0
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function cancel(pos: string[], p: Parsed, json: boolean): Promise<number> {
|
|
316
|
+
const id = pos[0]
|
|
317
|
+
if (!id) return fail(json, "usage", USAGE)
|
|
318
|
+
const s = sourceOrFail(p, json)
|
|
319
|
+
if (typeof s === "number") return s
|
|
320
|
+
if (s.git) await gitPull(s.path)
|
|
321
|
+
const path = join(jobsDir(s), `${id}.md`)
|
|
322
|
+
if (!existsSync(path)) return fail(json, "unknown_job", `No ${s.name}/jobs/${id}.md.`)
|
|
323
|
+
let pushed: boolean | null = null
|
|
324
|
+
if (s.git) {
|
|
325
|
+
await git(s.path, "rm", "--quiet", "--", path)
|
|
326
|
+
await git(s.path, "commit", "--quiet", "-m", `job: cancel ${id}`)
|
|
327
|
+
pushed = (await git(s.path, "push", "--quiet")).code === 0
|
|
328
|
+
} else unlinkSync(path)
|
|
329
|
+
if (json) console.log(JSON.stringify({ ok: true, source: s.name, id, pushed }))
|
|
330
|
+
else console.log(`Cancelled ${s.name}/${id}${pushed === false ? " (the push failed; push it by hand)" : ""}. Its reports stay in runs/.`)
|
|
331
|
+
return 0
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function run(pos: string[], p: Parsed, json: boolean): Promise<number> {
|
|
335
|
+
const id = pos[0]
|
|
336
|
+
if (!id) return fail(json, "usage", USAGE)
|
|
337
|
+
const s = sourceOrFail(p, json)
|
|
338
|
+
if (typeof s === "number") return s
|
|
339
|
+
if (!loadJobs(s).some((j) => j.header.id === id)) return fail(json, "unknown_job", `No job "${id}" in source ${s.name}.`)
|
|
340
|
+
const r = await tick({ via: "manual", only: { source: s.name, id } })
|
|
341
|
+
if (json) console.log(JSON.stringify(r))
|
|
342
|
+
else if ("skipped" in r) console.error(r.message)
|
|
343
|
+
else {
|
|
344
|
+
for (const x of r.ran) console.log(`${x.source}/${x.id}: ${x.status}${x.report ? ` — ${x.report}` : ""}${x.notified && x.notified !== "sent" ? ` (not paged: ${x.notified})` : ""}`)
|
|
345
|
+
for (const e of r.errors) console.error(`Warning: ${e}`)
|
|
346
|
+
}
|
|
347
|
+
if ("skipped" in r) return 1
|
|
348
|
+
return r.ran.some((x) => x.status !== "ok") ? 1 : 0
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function validate(p: Parsed, json: boolean): Promise<number> {
|
|
352
|
+
const only = p.flags.get("--source")
|
|
353
|
+
const bad: { source: string; file: string; problems: string[] }[] = []
|
|
354
|
+
for (const s of listSources(loadConfig())) {
|
|
355
|
+
if ((only && s.name !== only) || !existsSync(s.path)) continue
|
|
356
|
+
for (const j of loadJobs(s)) if (j.problems.length) bad.push({ source: s.name, file: j.name, problems: j.problems })
|
|
357
|
+
}
|
|
358
|
+
if (json) console.log(JSON.stringify({ ok: bad.length === 0, invalid: bad }))
|
|
359
|
+
else {
|
|
360
|
+
for (const b of bad) console.log(`${b.source}/jobs/${b.file}: ${b.problems.join("; ")}`)
|
|
361
|
+
console.log(bad.length ? `${bad.length} invalid job file(s)` : "all jobs valid")
|
|
362
|
+
}
|
|
363
|
+
return bad.length ? 1 : 0
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
|
367
|
+
|
|
368
|
+
async function source(pos: string[], p: Parsed, json: boolean): Promise<number> {
|
|
369
|
+
const [action, name, path] = pos
|
|
370
|
+
const cfg = loadConfig()
|
|
371
|
+
const stored = cfg.scheduler ?? {}
|
|
372
|
+
if (action === "add") {
|
|
373
|
+
if (!name || !path) return fail(json, "usage", "Usage: spinuptui scheduler source add <name> <path> [--default]")
|
|
374
|
+
if (!NAME_RE.test(name) || name === LOCAL_SOURCE) return fail(json, "usage", `"${name}" can't be a source name (lowercase letters, digits and dashes; "local" is built in).`)
|
|
375
|
+
const abs = resolve(expandHome(path))
|
|
376
|
+
if (!existsSync(abs) || !statSync(abs).isDirectory()) return fail(json, "not_a_directory", `${abs} is not a directory.`)
|
|
377
|
+
const root = gitRootOf(abs)
|
|
378
|
+
// The source's lock lives in .state/; keep it out of the repository without
|
|
379
|
+
// committing anything to it (info/exclude is local to this clone).
|
|
380
|
+
if (root && existsSync(join(root, ".git", "info"))) {
|
|
381
|
+
const rel = relative(root, abs)
|
|
382
|
+
const pattern = rel ? `/${rel}/.state/` : "/.state/"
|
|
383
|
+
const exclude = join(root, ".git", "info", "exclude")
|
|
384
|
+
const current = existsSync(exclude) ? readFileSync(exclude, "utf8") : ""
|
|
385
|
+
if (!current.split("\n").some((l) => l.trim() === pattern || (!rel && l.trim() === ".state/"))) {
|
|
386
|
+
writeFileSync(exclude, current + (current && !current.endsWith("\n") ? "\n" : "") + `# SpinupTUI Scheduler's lock\n${pattern}\n`)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
await saveConfig({
|
|
390
|
+
scheduler: { ...stored, sources: { ...(stored.sources ?? {}), [name]: { path: abs } }, ...(p.flags.has("--default") ? { defaultSource: name } : {}) },
|
|
391
|
+
})
|
|
392
|
+
const src = findSource(loadConfig(), name)!
|
|
393
|
+
const jobs = existsSync(join(abs, "jobs")) ? loadJobs(src).length : 0
|
|
394
|
+
const holder = src.git ? readClaim(src) : null
|
|
395
|
+
const git = src.git
|
|
396
|
+
if (json) console.log(JSON.stringify({ ok: true, source: name, path: abs, git, jobs, station: holder, default: p.flags.has("--default") }))
|
|
397
|
+
else {
|
|
398
|
+
console.log(`Added source "${name}" (${abs}${git ? ", git: pulled before each tick, runs/ pushed after" : ""}) with ${jobs} job file(s)${p.flags.has("--default") ? "; it's now the default" : ""}.`)
|
|
399
|
+
if (holder && holder !== stationName()) console.log(`It's run by ${holder}, so this machine will only queue to it. To move it here: spinuptui scheduler source claim ${name}`)
|
|
400
|
+
}
|
|
401
|
+
return 0
|
|
402
|
+
}
|
|
403
|
+
if (action === "remove") {
|
|
404
|
+
if (!name || !stored.sources?.[name]) return fail(json, "unknown_source", `No Scheduler source named "${name ?? ""}".`)
|
|
405
|
+
const { [name]: _gone, ...sources } = stored.sources
|
|
406
|
+
const next = { ...stored, sources, queueOnly: (stored.queueOnly ?? []).filter((n) => n !== name) }
|
|
407
|
+
if (next.defaultSource === name) delete next.defaultSource
|
|
408
|
+
await saveConfig({ scheduler: next })
|
|
409
|
+
if (json) console.log(JSON.stringify({ ok: true, removed: name }))
|
|
410
|
+
else console.log(`Removed source "${name}". Its directory and files are untouched.`)
|
|
411
|
+
return 0
|
|
412
|
+
}
|
|
413
|
+
if (action === "claim" || action === "release") {
|
|
414
|
+
if (!name) return fail(json, "usage", `Usage: spinuptui scheduler source ${action} <name>`)
|
|
415
|
+
const src = findSource(cfg, name)
|
|
416
|
+
if (!src) return fail(json, "unknown_source", `No Scheduler source named "${name}".`)
|
|
417
|
+
if (!src.git) return fail(json, "not_git", `${name} isn't in a git repository, so only this machine can see it; there's nothing to ${action}.`)
|
|
418
|
+
const e = await gitPull(src.path)
|
|
419
|
+
if (e) return fail(json, "git_failed", `${name}: ${e}`)
|
|
420
|
+
const host = stationName()
|
|
421
|
+
const holder = readClaim(src)
|
|
422
|
+
const queueOnly = new Set(stored.queueOnly ?? [])
|
|
423
|
+
if (action === "claim") {
|
|
424
|
+
if (holder !== host) {
|
|
425
|
+
const r = await writeClaim(src, host, `station: ${host} runs ${name}${holder ? ` (was ${holder})` : ""}`)
|
|
426
|
+
if (r) return fail(json, "claim_failed", `Couldn't claim ${name}: ${r}`, "Check the repository's remote, then try again.")
|
|
427
|
+
}
|
|
428
|
+
queueOnly.delete(name)
|
|
429
|
+
await saveConfig({ scheduler: { ...stored, queueOnly: [...queueOnly] } })
|
|
430
|
+
const result = { ok: true, source: name, station: host, previous: holder }
|
|
431
|
+
if (json) console.log(JSON.stringify(result))
|
|
432
|
+
else
|
|
433
|
+
console.log(
|
|
434
|
+
holder === host
|
|
435
|
+
? `${name} is already run by this station (${host}).`
|
|
436
|
+
: `${host} now runs ${name}.${holder ? ` ${holder} stops on its next pull.` : ""}${loadConfig().scheduler?.dispatcher ? "" : " This machine has no dispatcher yet: spinuptui scheduler install --plan"}`,
|
|
437
|
+
)
|
|
438
|
+
return 0
|
|
439
|
+
}
|
|
440
|
+
// release
|
|
441
|
+
if (holder && holder !== host && !p.flags.has("--force")) {
|
|
442
|
+
return fail(
|
|
443
|
+
json,
|
|
444
|
+
"not_holder",
|
|
445
|
+
`${name} is run by ${holder}, not this station (${host}), so there's nothing to release here.`,
|
|
446
|
+
`Release it on ${holder}, or move it here with: spinuptui scheduler source claim ${name}`,
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
if (holder === host) {
|
|
450
|
+
const r = await removeClaim(src, `station: ${host} releases ${name}`)
|
|
451
|
+
if (r) return fail(json, "release_failed", `Couldn't release ${name}: ${r}`)
|
|
452
|
+
}
|
|
453
|
+
queueOnly.add(name)
|
|
454
|
+
await saveConfig({ scheduler: { ...stored, queueOnly: [...queueOnly] } })
|
|
455
|
+
if (json) console.log(JSON.stringify({ ok: true, source: name, released: holder === host, queueOnly: true }))
|
|
456
|
+
else console.log(`Released ${name}: this machine only queues to it now. The next other station to tick it takes it over. To take it back: spinuptui scheduler source claim ${name}`)
|
|
457
|
+
return 0
|
|
458
|
+
}
|
|
459
|
+
return fail(json, "usage", "Usage: spinuptui scheduler source add <name> <path> [--default] | remove <name> | claim <name> | release <name>")
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function doTick(p: Parsed, json: boolean): Promise<number> {
|
|
463
|
+
const dispatcher = process.env.SPINUPTUI_SCHEDULER_DISPATCHER === "1"
|
|
464
|
+
if (!dispatcher && !p.flags.has("--here")) {
|
|
465
|
+
return fail(
|
|
466
|
+
json,
|
|
467
|
+
"not_dispatcher",
|
|
468
|
+
"`scheduler tick` runs due jobs on THIS machine. The installed dispatcher sets SPINUPTUI_SCHEDULER_DISPATCHER=1; pass --here to run one anyway.",
|
|
469
|
+
)
|
|
470
|
+
}
|
|
471
|
+
const r = await tick({ via: dispatcher ? "dispatcher" : "manual" })
|
|
472
|
+
// Under launchd stdout is launchd.log, kept for crashes: the tick's record is
|
|
473
|
+
// already in last-tick.json and tick.log.
|
|
474
|
+
if (dispatcher && !json) return 0
|
|
475
|
+
if (json) console.log(JSON.stringify(r))
|
|
476
|
+
else if ("skipped" in r) console.error(r.message)
|
|
477
|
+
else {
|
|
478
|
+
if (r.ran.length === 0) console.log("Nothing due.")
|
|
479
|
+
for (const x of r.ran) console.log(`${x.source}/${x.id}: ${x.status}${x.report ? ` — ${x.report}` : ""}`)
|
|
480
|
+
for (const e of r.errors) console.error(`Warning: ${e}`)
|
|
481
|
+
}
|
|
482
|
+
return 0
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function install(p: Parsed, json: boolean): Promise<number> {
|
|
486
|
+
const apply = p.flags.has("--apply")
|
|
487
|
+
if (apply === p.flags.has("--plan")) return fail(json, "usage", "Pass --plan to see what would be installed, or --apply to install it.")
|
|
488
|
+
const plan = planInstall({ bin: p.flags.get("--bin") ?? null })
|
|
489
|
+
if (!plan.ok) return fail(json, plan.reason, plan.message, plan.remedy)
|
|
490
|
+
// Refuse at --plan too, so a plan that can't work is never shown as one.
|
|
491
|
+
if (!(await binHasScheduler(plan))) return fail(json, NO_SCHEDULER.reason, NO_SCHEDULER.message, NO_SCHEDULER.remedy)
|
|
492
|
+
if (!apply) {
|
|
493
|
+
if (json) console.log(JSON.stringify(plan))
|
|
494
|
+
else {
|
|
495
|
+
console.log(`Would install Scheduler's dispatcher (${plan.label}), running every 5 minutes and at login:\n`)
|
|
496
|
+
console.log(` ${plan.programArguments.join(" ")}\n`)
|
|
497
|
+
console.log(`Unit: ${plan.unitPath}`)
|
|
498
|
+
for (const u of plan.extraUnits ?? []) console.log(`Unit: ${u.path}`)
|
|
499
|
+
console.log(`PATH: ${plan.env.PATH}\n`)
|
|
500
|
+
console.log("Steps:")
|
|
501
|
+
for (const c of plan.commands) console.log(` · ${c}`)
|
|
502
|
+
for (const n of plan.notes) console.log(`Note: ${n}`)
|
|
503
|
+
console.log(`\nRun with --apply to install it.`)
|
|
504
|
+
}
|
|
505
|
+
return 0
|
|
506
|
+
}
|
|
507
|
+
const r = await applyInstall(plan, say)
|
|
508
|
+
if (!r.ok) return fail(json, r.reason, r.message, r.remedy)
|
|
509
|
+
if (json) console.log(JSON.stringify(r))
|
|
510
|
+
else {
|
|
511
|
+
console.log(`Installed ${r.label}. Its first tick ran under launchd at ${new Date(r.tickAt).toLocaleTimeString()}.`)
|
|
512
|
+
for (const n of r.notes) console.log(`Note: ${n}`)
|
|
513
|
+
}
|
|
514
|
+
return 0
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function uninstall(p: Parsed, json: boolean): Promise<number> {
|
|
518
|
+
const apply = p.flags.has("--apply")
|
|
519
|
+
if (apply === p.flags.has("--plan")) return fail(json, "usage", "Pass --plan to see what would be removed, or --apply to remove it.")
|
|
520
|
+
const plan = planUninstall()
|
|
521
|
+
if (!apply) {
|
|
522
|
+
if (json) console.log(JSON.stringify({ ok: true, ...plan }))
|
|
523
|
+
else if (!plan.installed) console.log("Scheduler's dispatcher isn't installed; nothing to remove.")
|
|
524
|
+
else {
|
|
525
|
+
console.log(`Would remove Scheduler's dispatcher (${plan.label}):`)
|
|
526
|
+
for (const c of plan.commands) console.log(` · ${c}`)
|
|
527
|
+
}
|
|
528
|
+
return 0
|
|
529
|
+
}
|
|
530
|
+
const r = await applyUninstall(plan)
|
|
531
|
+
if (json) console.log(JSON.stringify(r))
|
|
532
|
+
else console.log(`Removed ${plan.label}. Jobs and reports are still in place; \`install --apply\` brings the dispatcher back.`)
|
|
533
|
+
return 0
|
|
534
|
+
}
|
package/src/lib/flock.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Advisory file locks via flock(2), for Scheduler's tick and per-source locks.
|
|
2
|
+
//
|
|
3
|
+
// flock rather than a pid file because it is the lock other dispatchers already
|
|
4
|
+
// take on a shared job source (Python's fcntl.flock is the same syscall), so a
|
|
5
|
+
// Scheduler tick and another runner's dispatch serialize on the same file during
|
|
6
|
+
// a cutover. The kernel drops the lock when the process exits, so a crashed tick
|
|
7
|
+
// can't leave a stale lock behind.
|
|
8
|
+
|
|
9
|
+
import { dlopen, FFIType } from "bun:ffi"
|
|
10
|
+
import { closeSync, mkdirSync, openSync } from "node:fs"
|
|
11
|
+
import { dirname } from "node:path"
|
|
12
|
+
|
|
13
|
+
const LOCK_EX = 2
|
|
14
|
+
const LOCK_NB = 4
|
|
15
|
+
const LOCK_UN = 8
|
|
16
|
+
|
|
17
|
+
function open() {
|
|
18
|
+
const path = process.platform === "darwin" ? "/usr/lib/libSystem.B.dylib" : "libc.so.6"
|
|
19
|
+
return dlopen(path, { flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 } })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let libc: ReturnType<typeof open> | null = null
|
|
23
|
+
|
|
24
|
+
function lib() {
|
|
25
|
+
libc ??= open()
|
|
26
|
+
return libc
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface HeldLock {
|
|
30
|
+
release(): void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Takes an exclusive lock without waiting. Returns null when another process
|
|
34
|
+
// holds it.
|
|
35
|
+
export function tryLock(path: string): HeldLock | null {
|
|
36
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
37
|
+
const fd = openSync(path, "a")
|
|
38
|
+
if (lib().symbols.flock(fd, LOCK_EX | LOCK_NB) !== 0) {
|
|
39
|
+
closeSync(fd)
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
let held = true
|
|
43
|
+
return {
|
|
44
|
+
release() {
|
|
45
|
+
if (!held) return
|
|
46
|
+
held = false
|
|
47
|
+
lib().symbols.flock(fd, LOCK_UN)
|
|
48
|
+
closeSync(fd)
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -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
|
+
}
|