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.
- package/CHANGELOG.md +20 -1
- package/README.md +10 -5
- package/package.json +1 -1
- package/src/config.ts +3 -0
- package/src/index.tsx +9 -0
- package/src/lib/cliHome.ts +107 -0
- package/src/lib/cliScheduler.ts +99 -3
- package/src/lib/heartbeat.test.ts +86 -0
- package/src/lib/heartbeat.ts +245 -0
- package/src/lib/home.test.ts +102 -0
- package/src/lib/home.ts +442 -0
- package/src/lib/schedulerJobs.ts +2 -0
- package/src/lib/schedulerRun.ts +3 -2
- package/src/lib/schedulerTick.ts +17 -2
- package/src/lib/spinupweb.test.ts +22 -0
- package/src/lib/spinupweb.ts +11 -0
- package/src/lib/stackCache.test.ts +43 -0
- package/src/lib/stackCache.ts +35 -1
- package/src/modules/scheduler.ts +13 -0
- package/src/ui/App.tsx +15 -1
- package/src/ui/Details.tsx +7 -1
- package/src/ui/Onboarding.tsx +71 -3
- package/src/ui/store.tsx +81 -3
- package/src/ui/views/AccountSlugPrompt.tsx +102 -0
- package/src/ui/views/Browser.tsx +5 -5
- package/src/ui/views/HeartbeatSetup.tsx +284 -0
- package/src/ui/views/HomeSync.tsx +299 -0
- package/src/ui/views/Modules.tsx +34 -6
- package/src/ui/views/Search.tsx +5 -6
- package/src/ui/views/ServerActions.tsx +6 -4
- package/src/ui/views/Stacks.tsx +22 -9
package/src/lib/home.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/schedulerJobs.ts
CHANGED
|
@@ -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")
|
package/src/lib/schedulerRun.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
148
|
+
prepareWorkDir(runs)
|
|
148
149
|
const reportRel = `runs/${id}/${stamp(now)}.md`
|
|
149
150
|
|
|
150
151
|
const started = new Date()
|
package/src/lib/schedulerTick.ts
CHANGED
|
@@ -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))
|
|
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
|
+
})
|
package/src/lib/spinupweb.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/lib/stackCache.ts
CHANGED
|
@@ -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
|
|
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)
|