spinuptui 1.3.0-alpha.2 → 1.3.0-alpha.3

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.
@@ -0,0 +1,442 @@
1
+ // The SpinupTUI folder (~/SpinupTUI by default): where modules keep the user's
2
+ // work. Three levels, from docs/2026-10-27_spinuptui-home-spec.md:
3
+ //
4
+ // 0. It just works: created on first use, and the path is shown wherever
5
+ // something lands in it.
6
+ // 1. It explains itself: a README.md written when the folder is created, saying
7
+ // what each part is, who writes it, and how to move or share it.
8
+ // 2. Sharing it across machines is an opt-in action (`spinuptui home sync`, and
9
+ // the Modules tab), shown as a plan and confirmed: git init, a .gitignore, a
10
+ // first commit, a private remote, a push. Or, on a second machine, a clone.
11
+ // Nothing here ever runs git or creates a remote on its own.
12
+
13
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from "node:fs"
14
+ import { homedir } from "node:os"
15
+ import { join, relative, sep } from "node:path"
16
+ import { configDir, loadConfig } from "../config.ts"
17
+ import { spinuptuiHome } from "../modules/contract.ts"
18
+ import { tryLock } from "./flock.ts"
19
+ import { spawn } from "./spawn.ts"
20
+ import { jobEnv } from "./schedulerRun.ts"
21
+ import { gitRootOf, LOCAL_SOURCE, schedulerStateDir } from "./schedulerJobs.ts"
22
+ import { readClaim, stationName } from "./schedulerGit.ts"
23
+
24
+ export const tilde = (p: string) => {
25
+ const h = homedir()
26
+ return p === h ? "~" : p.startsWith(h + sep) ? "~" + p.slice(h.length) : p
27
+ }
28
+
29
+ // ---- level 1: the README ------------------------------------------------------------
30
+
31
+ export function readmeText(): string {
32
+ return `# SpinupTUI
33
+
34
+ This folder holds what SpinupTUI's modules make for you: the things you read, keep or share. SpinupTUI created it the first time it needed it, and you can open, back up or search it like any other folder.
35
+
36
+ ## What's here
37
+
38
+ - \`scheduler/jobs/\` holds your queued follow-ups, one Markdown file each. They're yours: add them with \`spinuptui scheduler add\`, or write and edit them by hand and check them with \`spinuptui scheduler validate\`.
39
+ - \`scheduler/runs/\` holds a report for every run, plus each job's \`state.json\`. The machine that runs the jobs writes these. Read them freely, but don't edit them.
40
+ - \`scheduler/.scheduler/station\` names the machine that runs these jobs, once this folder is shared. Change it with \`spinuptui scheduler source claim\` or \`release\`, not by hand.
41
+
42
+ Other modules add a folder of their own here when you turn them on.
43
+
44
+ ## What's not here
45
+
46
+ Settings, tokens and passwords stay in SpinupTUI's config directory (\`${tilde(configDir())}\`), along with locks and logs. Nothing in this folder is a secret by design, but reports can name your sites and clients, so treat it as private.
47
+
48
+ ## Using it from more than one machine
49
+
50
+ \`spinuptui home sync --plan\` shows how to turn this folder into a git repository with a private remote. Then you can queue jobs from a laptop and read the reports anywhere. On the other machine, \`spinuptui home sync --clone <url> --plan\` sets up the same folder there. \`spinuptui home\` shows where things stand.
51
+
52
+ ## Moving it
53
+
54
+ Set \`homeDir\` in SpinupTUI's \`config.json\`, or the \`SPINUPTUI_HOME\` environment variable. Once it's a git repository, keep it out of iCloud, Dropbox and similar sync folders: two programs syncing the same \`.git\` folder can corrupt it.
55
+ `
56
+ }
57
+
58
+ const GITIGNORE = `# SpinupTUI folder: written by \`spinuptui home sync\`
59
+ .DS_Store
60
+ *.tmp
61
+ .state/
62
+ `
63
+
64
+ // Creates the folder and its README if either is missing. Never overwrites the
65
+ // README, which is the user's to edit once it exists. Returns true if it wrote it.
66
+ export function ensureHome(home = spinuptuiHome()): boolean {
67
+ mkdirSync(home, { recursive: true })
68
+ const readme = join(home, "README.md")
69
+ if (existsSync(readme)) return false
70
+ writeFileSync(readme, readmeText())
71
+ return true
72
+ }
73
+
74
+ const inside = (child: string, parent: string) => {
75
+ const r = relative(parent, child)
76
+ return r === "" || (!r.startsWith("..") && !r.startsWith(sep))
77
+ }
78
+
79
+ // mkdir -p for a module writing its work, plus the README when the directory is
80
+ // inside the SpinupTUI folder: whichever module writes first explains the folder.
81
+ export function prepareWorkDir(dir: string): void {
82
+ const home = spinuptuiHome()
83
+ if (inside(dir, home)) ensureHome(home)
84
+ mkdirSync(dir, { recursive: true })
85
+ }
86
+
87
+ // ---- where things stand -----------------------------------------------------------
88
+
89
+ // A sync service that also writes inside the folder. Two syncers on one .git
90
+ // produce conflicted copies and can corrupt the index.
91
+ export function cloudSyncedBy(path: string): string | null {
92
+ let p = path
93
+ try {
94
+ p = realpathSync(path)
95
+ } catch {
96
+ // Not created yet: judge the configured path.
97
+ }
98
+ const h = homedir()
99
+ if (p.includes("/Library/Mobile Documents/")) return "iCloud Drive"
100
+ const cs = p.match(/\/Library\/CloudStorage\/([^/]+)/)
101
+ if (cs) return cs[1]!.split("-")[0]!.replace("GoogleDrive", "Google Drive")
102
+ if (/\/(Dropbox|Dropbox \([^)]*\))(\/|$)/.test(p)) return "Dropbox"
103
+ const other = p.match(/\/(OneDrive|Google Drive|pCloud Drive)(\/|$)/)
104
+ if (other) return other[1]!
105
+ // macOS "Desktop & Documents Folders" in iCloud, when it's turned on.
106
+ for (const d of ["Desktop", "Documents"]) {
107
+ if (inside(p, join(h, d)) && existsSync(join(h, "Library", "Mobile Documents", "com~apple~CloudDocs", d))) return `iCloud Drive (${d})`
108
+ }
109
+ return null
110
+ }
111
+
112
+ export async function run(cmd: string[], cwd?: string): Promise<{ code: number; out: string }> {
113
+ try {
114
+ const p = spawn(cmd, { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe", env: { ...jobEnv(), GIT_TERMINAL_PROMPT: "0" } })
115
+ const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()])
116
+ return { code: await p.exited, out: (out + err).trim() }
117
+ } catch (e) {
118
+ return { code: 127, out: e instanceof Error ? e.message : String(e) }
119
+ }
120
+ }
121
+
122
+ const git = (dir: string, ...args: string[]) => run(["git", "-C", dir, ...args])
123
+
124
+ export interface HomeStatus {
125
+ path: string
126
+ exists: boolean
127
+ readme: boolean
128
+ syncedBy: string | null
129
+ // The folder is its own repository.
130
+ git: boolean
131
+ // Some other repository contains it (a project checkout, say).
132
+ insideRepo: string | null
133
+ remote: string | null
134
+ branch: string | null
135
+ // Commits not yet pushed / not yet pulled; null when unknown (no upstream).
136
+ ahead: number | null
137
+ behind: number | null
138
+ station: string | null
139
+ thisMachine: string
140
+ }
141
+
142
+ export async function homeStatus(home = spinuptuiHome()): Promise<HomeStatus> {
143
+ const exists = existsSync(home)
144
+ // gitRootOf walks up from a path that needn't exist yet, so a folder still to
145
+ // be created inside a project checkout is caught too.
146
+ const root = gitRootOf(home)
147
+ const own = root === home
148
+ const s: HomeStatus = {
149
+ path: home,
150
+ exists,
151
+ readme: existsSync(join(home, "README.md")),
152
+ syncedBy: cloudSyncedBy(home),
153
+ git: own,
154
+ insideRepo: root && !own ? root : null,
155
+ remote: null,
156
+ branch: null,
157
+ ahead: null,
158
+ behind: null,
159
+ station: own ? readClaim({ name: LOCAL_SOURCE, path: join(home, "scheduler"), gitRoot: home, git: true, queueOnly: false }) : null,
160
+ thisMachine: stationName(),
161
+ }
162
+ if (!own) return s
163
+ const r = await git(home, "remote", "get-url", "origin")
164
+ if (r.code === 0) s.remote = r.out
165
+ const b = await git(home, "symbolic-ref", "--short", "-q", "HEAD")
166
+ if (b.code === 0) s.branch = b.out
167
+ const c = await git(home, "rev-list", "--left-right", "--count", "HEAD...@{u}")
168
+ if (c.code === 0) {
169
+ const [a, bh] = c.out.split(/\s+/).map(Number)
170
+ s.ahead = a ?? null
171
+ s.behind = bh ?? null
172
+ }
173
+ return s
174
+ }
175
+
176
+ export function describeShare(s: HomeStatus): string {
177
+ if (s.insideRepo) return `inside another repository (${tilde(s.insideRepo)})`
178
+ if (!s.git) return "on this machine only"
179
+ if (!s.remote) return "a git repository with no remote yet"
180
+ const pending =
181
+ s.ahead === null ? ", not pushed yet" : s.ahead || s.behind ? `, ${[s.ahead ? `${s.ahead} to push` : "", s.behind ? `${s.behind} to pull` : ""].filter(Boolean).join(", ")}` : ", up to date"
182
+ return `shared via ${s.remote}${pending}`
183
+ }
184
+
185
+ // ---- level 2: the sync plan -------------------------------------------------------------
186
+
187
+ export interface SyncOptions {
188
+ // An existing, empty repository to push to.
189
+ remote?: string
190
+ // Create a private GitHub repository with `gh` (owner/name or name).
191
+ github?: string
192
+ // Second machine: clone an existing SpinupTUI folder here.
193
+ clone?: string
194
+ }
195
+
196
+ export interface PlanStep {
197
+ label: string
198
+ apply: () => Promise<string | null> // null = done, else why it failed
199
+ }
200
+
201
+ export interface SyncPlan {
202
+ mode: "share" | "clone" | "repair" | "nothing"
203
+ home: string
204
+ steps: PlanStep[]
205
+ notes: string[]
206
+ // Any of these and the plan can't be applied.
207
+ blockers: { reason: string; message: string; remedy?: string }[]
208
+ }
209
+
210
+ const PRIVATE_NOTE =
211
+ "Use a private repository. Nothing here is a secret by design, but reports and write-ups name your sites and clients."
212
+
213
+ const ghHost = (url: string) => /^(https:\/\/|git@|ssh:\/\/git@)github\.com[/:]/.test(url)
214
+
215
+ export async function ghReady(): Promise<string | null> {
216
+ const v = await run(["gh", "--version"])
217
+ if (v.code !== 0) return "The GitHub CLI (gh) isn't installed."
218
+ const a = await run(["gh", "auth", "status"])
219
+ if (a.code !== 0) return "The GitHub CLI isn't signed in (gh auth login)."
220
+ return null
221
+ }
222
+
223
+ // What `gh` says about a GitHub URL's visibility, when it can tell.
224
+ async function githubVisibility(url: string): Promise<string | null> {
225
+ if (!ghHost(url) || (await ghReady())) return null
226
+ const v = await run(["gh", "repo", "view", url, "--json", "visibility", "-q", ".visibility"])
227
+ return v.code === 0 ? v.out.trim().toUpperCase() : null
228
+ }
229
+
230
+ // Only what SpinupTUI itself put there: a folder holding nothing else can be
231
+ // replaced by a clone.
232
+ function onlyOurFiles(home: string): boolean {
233
+ if (!existsSync(home)) return true
234
+ for (const name of readdirSync(home)) {
235
+ if (name === ".DS_Store") continue
236
+ if (name === "README.md" && readFileSync(join(home, name), "utf8") === readmeText()) continue
237
+ return false
238
+ }
239
+ return true
240
+ }
241
+
242
+ export async function planSync(opts: SyncOptions, home = spinuptuiHome()): Promise<SyncPlan> {
243
+ const plan: SyncPlan = { mode: "share", home, steps: [], notes: [], blockers: [] }
244
+ const block = (reason: string, message: string, remedy?: string) => plan.blockers.push({ reason, message, ...(remedy ? { remedy } : {}) })
245
+ const given = [opts.remote, opts.github, opts.clone].filter(Boolean).length
246
+ if (given > 1) block("usage", "Give one of --remote, --github or --clone.")
247
+
248
+ if ((await run(["git", "--version"])).code !== 0) block("no_git", "git isn't installed on this machine.", "Install git, then run this again.")
249
+ const synced = cloudSyncedBy(home)
250
+ if (synced) {
251
+ block(
252
+ "cloud_synced",
253
+ `${tilde(home)} is inside ${synced}. Two programs syncing the same .git folder can corrupt it.`,
254
+ "Move the SpinupTUI folder out of it first (homeDir in config.json, or SPINUPTUI_HOME), then run this again.",
255
+ )
256
+ }
257
+ const st = await homeStatus(home)
258
+ if (st.insideRepo) {
259
+ block("inside_repo", `${tilde(home)} is inside another git repository (${tilde(st.insideRepo)}).`, "Point the SpinupTUI folder somewhere of its own (homeDir in config.json, or SPINUPTUI_HOME).")
260
+ }
261
+
262
+ // ---- second machine ----
263
+ if (opts.clone) {
264
+ plan.mode = "clone"
265
+ const url = opts.clone
266
+ if (st.git) block("already_repo", `${tilde(home)} is already a git repository${st.remote ? ` (origin ${st.remote})` : ""}.`, "Nothing to clone: `spinuptui home` shows its state.")
267
+ else if (!onlyOurFiles(home)) {
268
+ block(
269
+ "not_empty",
270
+ `${tilde(home)} already has work in it, and a clone would need an empty folder.`,
271
+ "To combine them, move this folder's contents aside, clone, then copy back the jobs you want to keep.",
272
+ )
273
+ }
274
+ const ls = await run(["git", "ls-remote", "--heads", url])
275
+ if (ls.code !== 0) block("unreachable", `Couldn't reach ${url}: ${ls.out.split("\n")[0]}`, "Check the URL and that this machine's git credentials can read it.")
276
+ else if (!ls.out) block("empty_remote", `${url} is empty, so there's nothing to clone.`, "Run `spinuptui home sync` on the machine that has the folder first.")
277
+ if (plan.blockers.length) return plan
278
+ if (existsSync(home)) {
279
+ plan.steps.push({
280
+ label: `Remove the SpinupTUI folder's starter README (the clone brings its own)`,
281
+ apply: async () => {
282
+ for (const n of ["README.md", ".DS_Store"]) if (existsSync(join(home, n))) unlinkSync(join(home, n))
283
+ return null
284
+ },
285
+ })
286
+ }
287
+ plan.steps.push({
288
+ label: `Clone ${url} into ${tilde(home)}`,
289
+ apply: async () => {
290
+ const r = await run(["git", "clone", "--quiet", url, home])
291
+ return r.code === 0 ? null : `git clone failed: ${r.out}`
292
+ },
293
+ })
294
+ plan.notes.push(cloneNote())
295
+ return plan
296
+ }
297
+
298
+ // ---- this machine has the folder ----
299
+ if (st.git && st.remote) {
300
+ if (opts.remote && opts.remote !== st.remote) block("has_remote", `${tilde(home)} already pushes to ${st.remote}.`, `Change it with: git -C ${tilde(home)} remote set-url origin ${opts.remote}`)
301
+ if (opts.github) block("has_remote", `${tilde(home)} already pushes to ${st.remote}.`)
302
+ if (plan.blockers.length) return plan
303
+ if (st.ahead === null) {
304
+ plan.mode = "repair"
305
+ plan.steps.push(pushStep(home, "Push to origin and track it"))
306
+ } else if (st.ahead > 0) {
307
+ plan.mode = "repair"
308
+ plan.steps.push(pushStep(home, `Push ${st.ahead} unpushed commit${st.ahead === 1 ? "" : "s"}`))
309
+ } else plan.mode = "nothing"
310
+ const vis = await githubVisibility(st.remote)
311
+ if (vis === "PUBLIC") plan.notes.push(`Warning: ${st.remote} is PUBLIC on GitHub. ${PRIVATE_NOTE}`)
312
+ return plan
313
+ }
314
+
315
+ if (!opts.remote && !opts.github) {
316
+ block(
317
+ "need_remote",
318
+ "Say where to keep it: an empty private repository you've made (--remote <url>), or one to create on GitHub (--github <name>, needs the GitHub CLI).",
319
+ "e.g. spinuptui home sync --github spinuptui-home --plan",
320
+ )
321
+ return plan
322
+ }
323
+ if (opts.github) {
324
+ const why = await ghReady()
325
+ if (why) block("no_gh", why, "Or make an empty private repository anywhere and pass --remote <url>.")
326
+ }
327
+ if (opts.remote) {
328
+ const ls = await run(["git", "ls-remote", "--heads", opts.remote])
329
+ if (ls.code !== 0) block("unreachable", `Couldn't reach ${opts.remote}: ${ls.out.split("\n")[0]}`, "Check the URL and that this machine's git credentials can write to it.")
330
+ else if (ls.out) {
331
+ block(
332
+ "remote_not_empty",
333
+ `${opts.remote} already has commits.`,
334
+ "To join a SpinupTUI folder another machine already shares, use --clone on a machine whose folder is empty. Otherwise make a new, empty repository.",
335
+ )
336
+ }
337
+ const vis = await githubVisibility(opts.remote)
338
+ if (vis === "PUBLIC") block("public_repo", `${opts.remote} is public on GitHub. ${PRIVATE_NOTE}`, "Make it private (gh repo edit --visibility private) or use another repository.")
339
+ }
340
+ if (plan.blockers.length) return plan
341
+
342
+ if (!existsSync(home) || !existsSync(join(home, "README.md"))) {
343
+ plan.steps.push({ label: `Write ${tilde(join(home, "README.md"))}, which explains the folder`, apply: async () => (ensureHome(home), null) })
344
+ }
345
+ if (!st.git) {
346
+ plan.steps.push({
347
+ label: `Make ${tilde(home)} a git repository (git init)`,
348
+ apply: async () => {
349
+ mkdirSync(home, { recursive: true })
350
+ const r = await git(home, "init", "--quiet", "-b", "main")
351
+ return r.code === 0 ? null : `git init failed: ${r.out}`
352
+ },
353
+ })
354
+ }
355
+ if (!existsSync(join(home, ".gitignore"))) {
356
+ plan.steps.push({
357
+ label: "Add a .gitignore (macOS clutter, temp files, local locks)",
358
+ apply: async () => (writeFileSync(join(home, ".gitignore"), GITIGNORE), null),
359
+ })
360
+ }
361
+ const hasHead = st.git && (await git(home, "rev-parse", "--verify", "-q", "HEAD")).code === 0
362
+ if (!hasHead) {
363
+ plan.steps.push({
364
+ label: "Commit what's there now",
365
+ apply: async () => {
366
+ await git(home, "add", "-A")
367
+ const r = await git(home, "commit", "--quiet", "-m", "SpinupTUI folder: first commit")
368
+ return r.code === 0 ? null : `git commit failed: ${r.out}`
369
+ },
370
+ })
371
+ }
372
+ if (opts.github) {
373
+ plan.steps.push({
374
+ label: `Create the private GitHub repository ${opts.github} and make it origin`,
375
+ apply: async () => {
376
+ const r = await run(["gh", "repo", "create", opts.github!, "--private", "--source", home, "--remote", "origin"])
377
+ return r.code === 0 ? null : `gh repo create failed: ${r.out}`
378
+ },
379
+ })
380
+ } else {
381
+ plan.steps.push({
382
+ label: `Set origin to ${opts.remote}`,
383
+ apply: async () => {
384
+ const r = await git(home, "remote", "add", "origin", opts.remote!)
385
+ return r.code === 0 ? null : `git remote add failed: ${r.out}`
386
+ },
387
+ })
388
+ }
389
+ plan.steps.push(pushStep(home, "Push it"))
390
+ plan.notes.push(PRIVATE_NOTE)
391
+ plan.notes.push(shareNote())
392
+ return plan
393
+ }
394
+
395
+ function pushStep(home: string, label: string): PlanStep {
396
+ return {
397
+ label,
398
+ apply: async () => {
399
+ const r = await git(home, "push", "--quiet", "-u", "origin", "HEAD")
400
+ return r.code === 0 ? null : `git push failed: ${r.out}`
401
+ },
402
+ }
403
+ }
404
+
405
+ const dispatcherHere = () => !!loadConfig().scheduler?.dispatcher
406
+
407
+ function shareNote(): string {
408
+ return dispatcherHere()
409
+ ? "Scheduler then pulls the folder before each run and pushes the reports, and this machine claims its jobs on the next tick. On another machine: spinuptui home sync --clone <url> --plan"
410
+ : "Scheduler then pulls the folder before each run and pushes the reports. On another machine: spinuptui home sync --clone <url> --plan"
411
+ }
412
+
413
+ function cloneNote(): string {
414
+ return dispatcherHere()
415
+ ? "This machine has a Scheduler dispatcher. If another station already runs these jobs, this one only queues to them; if none does, the first dispatcher to tick claims them."
416
+ : "This machine has no Scheduler dispatcher, so it only queues jobs: `spinuptui scheduler add` commits and pushes them for the station to run."
417
+ }
418
+
419
+ export interface ApplyResult {
420
+ ok: boolean
421
+ done: string[]
422
+ failed?: { step: string; message: string }
423
+ }
424
+
425
+ // Runs the plan's steps in order, stopping at the first failure. Holds the
426
+ // Scheduler tick lock so a tick can't pull, commit or push mid-way.
427
+ export async function applySync(plan: SyncPlan): Promise<ApplyResult> {
428
+ mkdirSync(schedulerStateDir(), { recursive: true })
429
+ const lock = tryLock(join(schedulerStateDir(), "tick.lock"))
430
+ if (!lock) return { ok: false, done: [], failed: { step: "wait for Scheduler", message: "A Scheduler tick is running. Try again in a minute." } }
431
+ const done: string[] = []
432
+ try {
433
+ for (const s of plan.steps) {
434
+ const err = await s.apply()
435
+ if (err) return { ok: false, done, failed: { step: s.label, message: err } }
436
+ done.push(s.label)
437
+ }
438
+ return { ok: true, done }
439
+ } finally {
440
+ lock.release()
441
+ }
442
+ }
@@ -235,6 +235,8 @@ export interface TickRecord {
235
235
  errors: string[]
236
236
  // Git sources another station runs (or released here): expected, not errors.
237
237
  notRunHere?: string[]
238
+ // The station heartbeat's ping at the end of this tick, when one is set.
239
+ heartbeat?: { ok: boolean; status: "up" | "down"; error?: string }
238
240
  }
239
241
 
240
242
  export const lastTickPath = () => join(schedulerStateDir(), "last-tick.json")
@@ -6,12 +6,13 @@
6
6
  // rather than counted as a run) carry over from the git-backed job runner; see
7
7
  // docs/2026-10-20_scheduler-spec.md.
8
8
 
9
- import { existsSync, mkdirSync, writeFileSync } from "node:fs"
9
+ import { existsSync, writeFileSync } from "node:fs"
10
10
  import { hostname, homedir } from "node:os"
11
11
  import { join } from "node:path"
12
12
  import { loadConfig } from "../config.ts"
13
13
  import { beaconFromConfig, beaconSend } from "./beacon.ts"
14
14
  import { spawn } from "./spawn.ts"
15
+ import { prepareWorkDir } from "./home.ts"
15
16
  import { fmt } from "./schedule.ts"
16
17
  import { agentOf, DEFAULT_TIMEOUT_MIN, expandHome, loadState, runsDir, saveState, splitArgs, type Job, type Source } from "./schedulerJobs.ts"
17
18
 
@@ -144,7 +145,7 @@ export async function runJob(source: Source, job: Job, occurrence: Date, now = n
144
145
  const workdir = expandHome(h.workdir || source.path)
145
146
  const timeoutMs = Number(h.timeout || DEFAULT_TIMEOUT_MIN) * 60_000
146
147
  const runs = join(runsDir(source), id)
147
- mkdirSync(runs, { recursive: true })
148
+ prepareWorkDir(runs)
148
149
  const reportRel = `runs/${id}/${stamp(now)}.md`
149
150
 
150
151
  const started = new Date()
@@ -23,6 +23,7 @@ import {
23
23
  lateGraceMs,
24
24
  listSources,
25
25
  loadJobs,
26
+ LOCAL_SOURCE,
26
27
  loadState,
27
28
  saveState,
28
29
  schedulerStateDir,
@@ -35,6 +36,7 @@ import {
35
36
  } from "./schedulerJobs.ts"
36
37
  import { jobEnv, notify, runJob, runProcess, selfCommand } from "./schedulerRun.ts"
37
38
  import { commitAndPush, decideClaim, gitPull, readClaim, stationName } from "./schedulerGit.ts"
39
+ import { pagerProblem, sendHeartbeat, tickHeartbeat } from "./heartbeat.ts"
38
40
 
39
41
  const LOG_MAX_BYTES = 1_000_000
40
42
  const MODULE_JOB_TIMEOUT_MS = 10 * 60_000
@@ -142,7 +144,12 @@ async function tickSource(
142
144
  ): Promise<{ ran: TickRun[]; errors: string[]; notRunHere?: string }> {
143
145
  const ran: TickRun[] = []
144
146
  const errors: string[] = []
145
- if (!existsSync(source.path)) return { ran, errors: [`source ${source.name}: ${source.path} does not exist`] }
147
+ if (!existsSync(source.path)) {
148
+ // ~/SpinupTUI/scheduler appears on first use, so a missing local folder
149
+ // just means nothing has been queued there yet.
150
+ if (source.name === LOCAL_SOURCE) return { ran, errors }
151
+ return { ran, errors: [`source ${source.name}: ${source.path} does not exist`] }
152
+ }
146
153
  const lock = tryLock(sourceLockPath(source))
147
154
  if (!lock) return { ran, errors: [`source ${source.name}: busy (another dispatcher holds its lock); skipped this tick`] }
148
155
  try {
@@ -283,8 +290,16 @@ export async function tick(opts: TickOptions): Promise<TickResult> {
283
290
  ...(notRunHere.length ? { notRunHere } : {}),
284
291
  }
285
292
  // Only the dispatcher's ticks count as "the station is alive": a manual
286
- // `run` or `tick --here` must not mask a dispatcher that has stopped.
293
+ // `run` or `tick --here` must not mask a dispatcher that has stopped. The
294
+ // same goes for the heartbeat, which tells Kuma the clock is running.
287
295
  if (opts.via === "dispatcher") {
296
+ const hb = loadConfig().scheduler?.heartbeat
297
+ if (hb) {
298
+ const t = tickHeartbeat(ran.length, errors, pagerProblem())
299
+ const r = await sendHeartbeat(hb.url, t.status, t.msg, record.durationMs)
300
+ record.heartbeat = { ok: r.ok, status: r.status, ...(r.error ? { error: r.error } : {}) }
301
+ if (!r.ok) record.errors.push(`heartbeat: Kuma didn't take the ping (${r.error})`)
302
+ }
288
303
  writeFileSync(`${lastTickPath()}.tmp`, JSON.stringify(record, null, 2) + "\n")
289
304
  renameSync(`${lastTickPath()}.tmp`, lastTickPath())
290
305
  }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { parseAccountSlug, siteWebUrl } from "./spinupweb.ts"
3
+
4
+ describe("parseAccountSlug", () => {
5
+ test("a bare slug", () => {
6
+ expect(parseAccountSlug(" acme-agency ")).toBe("acme-agency")
7
+ })
8
+ test("a whole dashboard URL", () => {
9
+ expect(parseAccountSlug("https://spinupwp.app/acme-agency/sites/123#git")).toBe("acme-agency")
10
+ expect(parseAccountSlug("spinupwp.app/acme-agency")).toBe("acme-agency")
11
+ expect(parseAccountSlug("https://SpinupWP.app/acme-agency/?tab=servers")).toBe("acme-agency")
12
+ })
13
+ test("anything that isn't a slug", () => {
14
+ expect(parseAccountSlug("")).toBeNull()
15
+ expect(parseAccountSlug("https://spinupwp.app/")).toBeNull()
16
+ expect(parseAccountSlug("acme agency")).toBeNull()
17
+ expect(parseAccountSlug("../etc")).toBeNull()
18
+ })
19
+ test("feeds the deep link", () => {
20
+ expect(siteWebUrl(42, parseAccountSlug("https://spinupwp.app/acme-agency/sites"))).toBe("https://spinupwp.app/acme-agency/sites/42")
21
+ })
22
+ })
@@ -12,6 +12,17 @@
12
12
 
13
13
  const WEB_BASE = "https://spinupwp.app"
14
14
 
15
+ // Read an account slug from what a user types: the slug itself, or any dashboard
16
+ // URL pasted whole ("https://spinupwp.app/acme-agency/sites/123" → "acme-agency").
17
+ // Null when it doesn't look like a slug.
18
+ export function parseAccountSlug(input: string): string | null {
19
+ let s = input.trim()
20
+ const at = s.toLowerCase().indexOf("spinupwp.app/")
21
+ if (at >= 0) s = s.slice(at + "spinupwp.app/".length)
22
+ s = s.replace(/^\/+/, "").split(/[/?#]/)[0] ?? ""
23
+ return /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(s) ? s : null
24
+ }
25
+
15
26
  export function serverWebUrl(id: number, accountSlug: string | null): string {
16
27
  return accountSlug ? `${WEB_BASE}/${accountSlug}/servers/${id}` : WEB_BASE
17
28
  }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { isRecheckDue, isVersionOld, VERSION_MAX_AGE_MS, type CachedProbe } from "./stackCache.ts"
3
+
4
+ const NOW = 1_800_000_000_000
5
+
6
+ function entry(version: string | null, ageMs: number): CachedProbe {
7
+ return {
8
+ result: { kind: version ? "wordpress" : "whmcs", app: "x", version, label: "x" },
9
+ detectedAt: NOW - ageMs,
10
+ signature: "true:",
11
+ }
12
+ }
13
+
14
+ describe("isVersionOld", () => {
15
+ test("a fresh version reading isn't old", () => {
16
+ expect(isVersionOld(entry("7.1.3", 60_000), NOW)).toBe(false)
17
+ })
18
+ test("a version reading past the max age is old", () => {
19
+ expect(isVersionOld(entry("7.0", VERSION_MAX_AGE_MS + 1), NOW)).toBe(true)
20
+ })
21
+ test("an identity without a version never goes old", () => {
22
+ expect(isVersionOld(entry(null, 365 * VERSION_MAX_AGE_MS), NOW)).toBe(false)
23
+ })
24
+ test("no entry isn't old", () => {
25
+ expect(isVersionOld(undefined, NOW)).toBe(false)
26
+ })
27
+ })
28
+
29
+ describe("isRecheckDue", () => {
30
+ const old = entry("7.0", 3 * VERSION_MAX_AGE_MS)
31
+ test("an old version with no attempt yet is due", () => {
32
+ expect(isRecheckDue(old, NOW)).toBe(true)
33
+ })
34
+ test("an attempt inside the window (even a failed one) holds it off", () => {
35
+ expect(isRecheckDue({ ...old, recheckedAt: NOW - 60_000 }, NOW)).toBe(false)
36
+ })
37
+ test("an attempt from a previous window doesn't", () => {
38
+ expect(isRecheckDue({ ...old, recheckedAt: NOW - VERSION_MAX_AGE_MS - 1 }, NOW)).toBe(true)
39
+ })
40
+ test("a fresh reading is never due", () => {
41
+ expect(isRecheckDue(entry("7.1.3", 60_000), NOW)).toBe(false)
42
+ })
43
+ })
@@ -5,7 +5,8 @@
5
5
  // - set() is called after a successful `d` probe; it upserts the entry and
6
6
  // writes the whole file back immediately (write-through), so a probe
7
7
  // survives even if the TUI is killed with q / Ctrl+C.
8
- // Probing itself is always on-demand and per-site — this cache exists to avoid
8
+ // Probing is on-demand and per-site, with one exception: a WP version older than
9
+ // VERSION_MAX_AGE_MS is re-probed in the background. This cache exists to avoid
9
10
  // repeating the slow SSH round-trips across launches, not to save RAM. It's a
10
11
  // deliberately small, reusable pattern for other expensive derived data later.
11
12
 
@@ -21,6 +22,10 @@ export interface CachedProbe {
21
22
  result: ProbeResult
22
23
  detectedAt: number // epoch ms
23
24
  signature: string // invalidation key (see siteSignature)
25
+ // Last automatic re-check of an old version (epoch ms), kept even when it
26
+ // failed, so the once-per-window limit holds across launches. A successful
27
+ // probe replaces the whole entry, which clears it.
28
+ recheckedAt?: number
24
29
  }
25
30
 
26
31
  interface CacheFile {
@@ -32,6 +37,25 @@ export function stackCachePath(dir = profileDataDir()): string {
32
37
  return join(dir, "stack-cache.json")
33
38
  }
34
39
 
40
+ // A WordPress version is a fact about the server that changes without us: auto-
41
+ // updates, wp-admin and deploys all move it, and none of them touch the site's
42
+ // shape below. So a reading that carries a version is re-checked once it's older
43
+ // than this (in the background, when the site list loads), and its age is shown.
44
+ export const VERSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
45
+
46
+ // True when the entry carries a WP version read longer ago than the max age.
47
+ // Identities without a version (WHMCS, Laravel, static) don't drift this way.
48
+ export function isVersionOld(entry: CachedProbe | undefined, now = Date.now()): boolean {
49
+ return entry?.result.version != null && now - entry.detectedAt > VERSION_MAX_AGE_MS
50
+ }
51
+
52
+ // True when an old version is due an automatic re-check: none was attempted in
53
+ // the current window. Unattended SSH that fails must not repeat on every launch.
54
+ export function isRecheckDue(entry: CachedProbe | undefined, now = Date.now()): boolean {
55
+ if (!entry || !isVersionOld(entry, now)) return false
56
+ return entry.recheckedAt == null || now - entry.recheckedAt > VERSION_MAX_AGE_MS
57
+ }
58
+
35
59
  // A cached probe is considered stale when the site's relevant shape changes.
36
60
  // Keying on is_wordpress + public_folder catches the cases that would alter the
37
61
  // probe's target paths or expected result.
@@ -83,6 +107,16 @@ export class StackCache {
83
107
  await this.persist()
84
108
  }
85
109
 
110
+ // Record an automatic re-check attempt on existing entries, in one write.
111
+ async noteRecheck(siteIds: number[]): Promise<void> {
112
+ const now = Date.now()
113
+ for (const id of siteIds) {
114
+ const e = this.entries.get(id)
115
+ if (e) this.entries.set(id, { ...e, recheckedAt: now })
116
+ }
117
+ await this.persist()
118
+ }
119
+
86
120
  // A copy of all entries (for rendering aggregates without exposing the map).
87
121
  snapshot(): Map<number, CachedProbe> {
88
122
  return new Map(this.entries)