spinuptui 1.3.0-alpha.1 → 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 +32 -1
- package/README.md +13 -3
- package/package.json +1 -1
- package/src/config.ts +25 -0
- package/src/index.tsx +26 -38
- package/src/lib/cliHome.ts +107 -0
- package/src/lib/cliScheduler.ts +630 -0
- package/src/lib/flock.ts +51 -0
- 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/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 +290 -0
- package/src/lib/schedulerRun.ts +247 -0
- package/src/lib/schedulerTick.ts +322 -0
- 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/beacon.ts +2 -1
- package/src/modules/contract.ts +22 -1
- package/src/modules/index.ts +2 -7
- package/src/modules/scheduler.ts +159 -0
- package/src/ui/App.tsx +19 -1
- package/src/ui/Details.tsx +7 -1
- package/src/ui/Onboarding.tsx +71 -3
- package/src/ui/main.tsx +42 -0
- package/src/ui/store.tsx +87 -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 +69 -5
- package/src/ui/views/SchedulerInstall.tsx +172 -0
- 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
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Scheduler's clock rules: parsing `when:` / `every:` / `expires:` and deciding
|
|
2
|
+
// which slot (if any) is due. A straight port of the git-backed job runner's
|
|
3
|
+
// rules, deliberately identical — both dispatchers read the same state.json
|
|
4
|
+
// during a cutover, so they must reach the same decision from it (see
|
|
5
|
+
// docs/2026-10-20_scheduler-spec.md, "Cutover").
|
|
6
|
+
//
|
|
7
|
+
// All times are the station's local time, written "YYYY-MM-DD HH:MM".
|
|
8
|
+
|
|
9
|
+
export const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const
|
|
10
|
+
|
|
11
|
+
export type Schedule = { type: "interval"; minutes: number } | { type: "calendar"; days: Set<number>; hour: number; minute: number }
|
|
12
|
+
|
|
13
|
+
// Monday = 0, matching the prior art (Python's weekday()).
|
|
14
|
+
export function weekday(d: Date): number {
|
|
15
|
+
return (d.getDay() + 6) % 7
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const pad = (n: number) => String(n).padStart(2, "0")
|
|
19
|
+
|
|
20
|
+
export function fmt(d: Date | null | undefined): string {
|
|
21
|
+
if (!d) return ""
|
|
22
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function fmtDate(d: Date): string {
|
|
26
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseWhen(value: string): Date {
|
|
30
|
+
const m = value.trim().match(/^(\d{4})-(\d{2})-(\d{2}) (\d{1,2}):(\d{2})$/)
|
|
31
|
+
const d = m ? new Date(+m[1]!, +m[2]! - 1, +m[3]!, +m[4]!, +m[5]!) : null
|
|
32
|
+
if (!d || d.getMonth() !== +m![2]! - 1 || d.getDate() !== +m![3]! || +m![4]! > 23 || +m![5]! > 59) {
|
|
33
|
+
throw new Error(`when:/created: must look like 2026-10-16 08:00 (station local time), got "${value}"`)
|
|
34
|
+
}
|
|
35
|
+
return d
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A date-only value (expires:), as that day's local midnight.
|
|
39
|
+
export function parseDate(value: string): Date {
|
|
40
|
+
const m = value.trim().match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
|
41
|
+
const d = m ? new Date(+m[1]!, +m[2]! - 1, +m[3]!) : null
|
|
42
|
+
if (!d || d.getMonth() !== +m![2]! - 1 || d.getDate() !== +m![3]!) {
|
|
43
|
+
throw new Error(`expires: must look like 2026-10-20, got "${value}"`)
|
|
44
|
+
}
|
|
45
|
+
return d
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseEvery(value: string): Schedule {
|
|
49
|
+
const v = value.trim().toLowerCase()
|
|
50
|
+
let m = v.match(/^(\d+)\s*([mh])$/)
|
|
51
|
+
if (m) {
|
|
52
|
+
const minutes = +m[1]! * (m[2] === "h" ? 60 : 1)
|
|
53
|
+
if (minutes < 5) throw new Error("every: intervals are 5m or longer")
|
|
54
|
+
return { type: "interval", minutes }
|
|
55
|
+
}
|
|
56
|
+
m = v.match(/^([a-z,]+)\s+(\d{1,2}):(\d{2})$/)
|
|
57
|
+
if (!m) throw new Error(`every: is like '30m', '6h', 'daily 08:00', 'weekdays 08:00' or 'mon,thu 08:00', got "${value}"`)
|
|
58
|
+
const hour = +m[2]!
|
|
59
|
+
const minute = +m[3]!
|
|
60
|
+
if (hour > 23 || minute > 59) throw new Error(`every: time out of range in "${value}"`)
|
|
61
|
+
let days: Set<number>
|
|
62
|
+
if (m[1] === "daily") days = new Set([0, 1, 2, 3, 4, 5, 6])
|
|
63
|
+
else if (m[1] === "weekdays") days = new Set([0, 1, 2, 3, 4])
|
|
64
|
+
else {
|
|
65
|
+
days = new Set()
|
|
66
|
+
for (const d of m[1]!.split(",")) {
|
|
67
|
+
const i = (DAYS as readonly string[]).indexOf(d)
|
|
68
|
+
if (i === -1) throw new Error(`every: unknown day "${d}" (use ${DAYS.join(",")})`)
|
|
69
|
+
days.add(i)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { type: "calendar", days, hour, minute }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A module job's `every: "10m"` / `at: "08:00"` as a Schedule.
|
|
76
|
+
export function declSchedule(decl: { every?: string; at?: string }): Schedule {
|
|
77
|
+
if (decl.every) return parseEvery(decl.every)
|
|
78
|
+
if (decl.at) return parseEvery(`daily ${decl.at}`)
|
|
79
|
+
throw new Error("a scheduled job needs every: or at:")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ScheduleState {
|
|
83
|
+
last_run?: string
|
|
84
|
+
last_scheduled?: string
|
|
85
|
+
retry_after?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface DueInput {
|
|
89
|
+
when?: string
|
|
90
|
+
every?: string
|
|
91
|
+
schedule?: Schedule
|
|
92
|
+
expires?: string
|
|
93
|
+
created?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function minuteOf(d: Date): Date {
|
|
97
|
+
const out = new Date(d)
|
|
98
|
+
out.setSeconds(0, 0)
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function dayStart(d: Date): number {
|
|
103
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The scheduled time this run is for, or null when nothing is due.
|
|
107
|
+
export function dueOccurrence(job: DueInput, state: ScheduleState, now: Date): Date | null {
|
|
108
|
+
if (job.expires && dayStart(now) > parseDate(job.expires).getTime()) return null
|
|
109
|
+
if (state.retry_after && now < parseWhen(state.retry_after)) return null
|
|
110
|
+
const lastSched = state.last_scheduled ? parseWhen(state.last_scheduled) : null
|
|
111
|
+
|
|
112
|
+
if (job.when) {
|
|
113
|
+
const when = parseWhen(job.when)
|
|
114
|
+
if (now >= when && lastSched?.getTime() !== when.getTime()) return when
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const sched = job.schedule ?? parseEvery(job.every!)
|
|
119
|
+
const created = job.created ? parseWhen(job.created) : null
|
|
120
|
+
if (sched.type === "interval") {
|
|
121
|
+
const lastRun = state.last_run ? parseWhen(state.last_run) : null
|
|
122
|
+
const start = lastRun ?? created
|
|
123
|
+
if (!start || now.getTime() >= start.getTime() + sched.minutes * 60_000) return minuteOf(now)
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Calendar: the latest occurrence at or before now. Missed ones collapse into
|
|
128
|
+
// a single catch-up run instead of a backlog.
|
|
129
|
+
for (let back = 0; back < 8; back++) {
|
|
130
|
+
const day = new Date(now.getFullYear(), now.getMonth(), now.getDate() - back)
|
|
131
|
+
const occ = new Date(day.getFullYear(), day.getMonth(), day.getDate(), sched.hour, sched.minute)
|
|
132
|
+
if (sched.days.has(weekday(day)) && occ <= now) {
|
|
133
|
+
if (created && occ < created) return null
|
|
134
|
+
if (!lastSched || occ > lastSched) return occ
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// What `list` shows in its NEXT column.
|
|
142
|
+
export function nextRunText(job: DueInput, state: ScheduleState, now: Date): string {
|
|
143
|
+
if (job.expires && dayStart(now) > parseDate(job.expires).getTime()) return "expired"
|
|
144
|
+
if (job.when) {
|
|
145
|
+
const when = parseWhen(job.when)
|
|
146
|
+
return state.last_scheduled === fmt(when) ? "done" : fmt(when)
|
|
147
|
+
}
|
|
148
|
+
const sched = job.schedule ?? parseEvery(job.every!)
|
|
149
|
+
if (sched.type === "interval") return `every ${sched.minutes} min`
|
|
150
|
+
for (let ahead = 0; ahead < 8; ahead++) {
|
|
151
|
+
const day = new Date(now.getFullYear(), now.getMonth(), now.getDate() + ahead)
|
|
152
|
+
const occ = new Date(day.getFullYear(), day.getMonth(), day.getDate(), sched.hour, sched.minute)
|
|
153
|
+
if (sched.days.has(weekday(day)) && occ > now) return fmt(occ)
|
|
154
|
+
}
|
|
155
|
+
return "?"
|
|
156
|
+
}
|