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.
Files changed (41) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +13 -3
  3. package/package.json +1 -1
  4. package/src/config.ts +25 -0
  5. package/src/index.tsx +26 -38
  6. package/src/lib/cliHome.ts +107 -0
  7. package/src/lib/cliScheduler.ts +630 -0
  8. package/src/lib/flock.ts +51 -0
  9. package/src/lib/heartbeat.test.ts +86 -0
  10. package/src/lib/heartbeat.ts +245 -0
  11. package/src/lib/home.test.ts +102 -0
  12. package/src/lib/home.ts +442 -0
  13. package/src/lib/schedule.ts +156 -0
  14. package/src/lib/scheduler.test.ts +353 -0
  15. package/src/lib/schedulerGit.ts +119 -0
  16. package/src/lib/schedulerInstall.ts +325 -0
  17. package/src/lib/schedulerJobs.ts +290 -0
  18. package/src/lib/schedulerRun.ts +247 -0
  19. package/src/lib/schedulerTick.ts +322 -0
  20. package/src/lib/spinupweb.test.ts +22 -0
  21. package/src/lib/spinupweb.ts +11 -0
  22. package/src/lib/stackCache.test.ts +43 -0
  23. package/src/lib/stackCache.ts +35 -1
  24. package/src/modules/beacon.ts +2 -1
  25. package/src/modules/contract.ts +22 -1
  26. package/src/modules/index.ts +2 -7
  27. package/src/modules/scheduler.ts +159 -0
  28. package/src/ui/App.tsx +19 -1
  29. package/src/ui/Details.tsx +7 -1
  30. package/src/ui/Onboarding.tsx +71 -3
  31. package/src/ui/main.tsx +42 -0
  32. package/src/ui/store.tsx +87 -3
  33. package/src/ui/views/AccountSlugPrompt.tsx +102 -0
  34. package/src/ui/views/Browser.tsx +5 -5
  35. package/src/ui/views/HeartbeatSetup.tsx +284 -0
  36. package/src/ui/views/HomeSync.tsx +299 -0
  37. package/src/ui/views/Modules.tsx +69 -5
  38. package/src/ui/views/SchedulerInstall.tsx +172 -0
  39. package/src/ui/views/Search.tsx +5 -6
  40. package/src/ui/views/ServerActions.tsx +6 -4
  41. package/src/ui/views/Stacks.tsx +22 -9
package/CHANGELOG.md CHANGED
@@ -11,6 +11,34 @@ version; such changes are called out here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.3.0-alpha.3] - 2026-10-27
15
+
16
+ A pre-release, published on npm's `next` tag (`bun add -g spinuptui@next --no-cache`). `@latest` and Homebrew are on 1.2.1.
17
+
18
+ ### Added
19
+ - **Station heartbeat: find out when the station itself stops.** If the machine running Scheduler is off, the installed `spinuptui` is broken, or its LaunchAgent is gone, nothing on that machine can page you. Now every dispatcher tick pings an Uptime Kuma push monitor that you run somewhere else (a small VPS is easiest), and when the pings stop, Kuma alerts you through its own notifications. If SpinupTUI has your Kuma login (from site monitoring), `spinuptui scheduler heartbeat setup --plan|--apply` creates the monitor for you; otherwise make a push monitor by hand and run `spinuptui scheduler heartbeat set '<push URL>'`. Both ping once before saving. In the app, `h` on the Modules tab's Scheduler card walks through the same setup and later shows the last ping, sends a test ping, or removes it. The ping reports **down** when the station is running but can't page you, because Beacon isn't set up or its last page failed, so a broken pager reaches you too. `scheduler status` and the Modules tab show the last ping. See [docs/scheduler.md](docs/scheduler.md#know-when-the-station-itself-stops).
20
+ - **Your SpinupTUI folder explains itself, and can be shared across machines in one step.** `~/SpinupTUI/`, where Scheduler keeps your jobs and reports, now gets a `README.md` when it's created, covering what each part is, which parts are yours to edit, and how to move or share it. `spinuptui home` shows where it is and whether it's shared. `spinuptui home sync --plan|--apply` shares it through a private git repository: it creates one on GitHub (`--github <name>`, using the GitHub CLI) or pushes to an empty one you made (`--remote <url>`). Then you can queue jobs from any machine and read the reports anywhere. On a second machine, `--clone <url>` sets up the same folder. It won't share a folder inside iCloud, Dropbox or another sync service, push to a repository that already has commits, or use a public GitHub repository. The Modules tab shows where jobs and reports land, and `f` on Scheduler walks through the same steps. Queuing a job now prints the full path of the job file and where its reports will go.
21
+
22
+ ### Fixed
23
+ - **Scheduler no longer reports a problem on every tick before you've queued anything locally.** `~/SpinupTUI/scheduler/` is created on first use, so its absence is normal; `scheduler status` used to list "source local … does not exist" as a problem from the last tick.
24
+
25
+ ## [1.2.1] - 2026-10-27
26
+
27
+ ### Fixed
28
+ - **WordPress versions no longer go stale.** The version shown for a site was read once, when the site was first identified, and never again, so sites updated outside SpinupTUI (auto-updates, wp-admin, deploys) kept showing the old version, and `r` didn't help. Versions older than a day are now re-checked in the background when the site list loads, one site at a time per server, stopping at a server's first failure so unattended SSH can't trip fail2ban. A reading that couldn't be refreshed shows its age in the Stacks list, and the Details pane always says when it was taken.
29
+ - **`w` opens the site or server you picked, not the SpinupWP dashboard.** Deep links need the account slug, which SpinupWP's API doesn't provide and first-run setup never asked for, so unless you had added `accountSlug` to `config.json` by hand, `w` quietly fell back to the dashboard. Now `w` asks for the slug the first time it needs it (pasting any dashboard URL works), saves it to the current account and opens the right page. First-run setup also asks for it, as an optional step after the token.
30
+
31
+ ## [1.3.0-alpha.2] - 2026-10-26
32
+
33
+ A pre-release, published on npm's `next` tag (`bun add -g spinuptui@next --no-cache`). `@latest` and Homebrew stay on 1.2.0.
34
+
35
+ ### Added
36
+ - **Scheduler: follow-ups that run themselves.** Queue a job for later (`spinuptui scheduler add verify-410s --kind spinuptui --when "2026-10-21 08:00" --expires 2026-10-28 --created-by "…" -- incidents example.com`) and this machine runs it at that time, writes a report, and sends you the result through Beacon. Jobs can be a SpinupTUI command, a shell script, or a prompt for an agent CLI (Claude Code or Codex, read-only by default). They can be one-offs or recurring (`every 30m`, `weekdays 08:00`, `mon,thu 08:00`), and every job carries an expiry date so a forgotten one stops on its own. Jobs and reports live in `~/SpinupTUI/scheduler/`, a visible folder of your own. Make `~/SpinupTUI/` a git repository and Scheduler pulls it before each run and commits and pushes the reports, so you can queue a job from any machine with a commit. Only one machine runs a repository's jobs: the first station to tick it claims it, so a second machine with a dispatcher can't run them twice. `scheduler source claim` and `release` move them between machines. A job can say what happens when the station reaches it late (`--late run`, `skip`, or a grace window like `2h`). A skipped job is paged, not dropped silently. `scheduler list`, `show`, `run`, `cancel` and `validate` round it out. See [docs/scheduler.md](docs/scheduler.md).
37
+ - **`spinuptui scheduler install --plan|--apply`** sets up the one LaunchAgent everything runs on: a tick at login and every five minutes, with the PATH a bare launchd environment lacks written into it. `--apply` doesn't report success until a tick has actually run under launchd. `uninstall` removes it and keeps your jobs and reports. Scheduler shows up live on the Modules tab (`6`), where `⏎` shows the same plan and installs it on confirm, and in `spinuptui modules`, which reports how long ago the last tick ran. Modules will declare their own recurring work for Scheduler to run, so a station needs only this one unit. The design is in [docs/2026-10-20_scheduler-spec.md](docs/2026-10-20_scheduler-spec.md).
38
+
39
+ ### Changed
40
+ - **Headless commands start about three times faster.** `beacon send`, `incidents`, `ssh-exec`, `scheduler` and the rest no longer load the dashboard's renderer before looking at their arguments, which matters for something started every five minutes.
41
+
14
42
  ## [1.3.0-alpha.1] - 2026-10-21
15
43
 
16
44
  A pre-release, published on npm's `next` tag (`bun add -g spinuptui@next --no-cache`). `@latest` and Homebrew stay on 1.2.0. It continues `1.2.0-alpha.1` under a new number, because stable 1.2.0 shipped without the modules: it has Beacon and the Modules tab (see [1.2.0-alpha.1]) plus everything in [1.2.0]. The stable 1.3.0 will roll these notes up with the next modules.
@@ -1506,7 +1534,10 @@ Initial tagged release.
1506
1534
  ### Notes
1507
1535
  - Read-only release: works with a SpinupWP **Read Only** API token.
1508
1536
 
1509
- [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.1...HEAD
1537
+ [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.3...HEAD
1538
+ [1.3.0-alpha.3]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.2...v1.3.0-alpha.3
1539
+ [1.2.1]: https://github.com/mwender/spinupwp-tui/compare/v1.2.0...v1.2.1
1540
+ [1.3.0-alpha.2]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.1...v1.3.0-alpha.2
1510
1541
  [1.3.0-alpha.1]: https://github.com/mwender/spinupwp-tui/compare/v1.2.0...v1.3.0-alpha.1
1511
1542
  [1.2.0]: https://github.com/mwender/spinupwp-tui/compare/v1.1.2...v1.2.0
1512
1543
  [1.2.0-alpha.1]: https://github.com/mwender/spinupwp-tui/compare/v1.1.2...v1.2.0-alpha.1
package/README.md CHANGED
@@ -82,6 +82,13 @@
82
82
  pages you on Telegram from any cron job, watcher or agent, with retries, long
83
83
  messages split instead of dropped, and a send log. A guided setup creates the
84
84
  bot connection for you. → [docs/beacon.md](docs/beacon.md)
85
+ Then **Scheduler**: follow-ups that run themselves. Queue "check this
86
+ tomorrow at 8" from any session, and the result arrives through Beacon. Jobs
87
+ and reports live in `~/SpinupTUI/scheduler/`; `spinuptui home sync` (or `f`)
88
+ shares that folder through a private git repository so you can queue from
89
+ any machine. One LaunchAgent ticks every 5 minutes and is the clock the other
90
+ modules run on, and a station heartbeat (`h`) pings your Uptime Kuma each
91
+ tick, so a station that stops is noticed from elsewhere. → [docs/scheduler.md](docs/scheduler.md)
85
92
  - **Non-interactive CLI subcommands for external tooling** — `spinuptui ssh <domain>`
86
93
  resolves a domain to a live SSH target with a live connectivity probe;
87
94
  `spinuptui ssh-exec <domain> -- <command>` runs a command over SSH, but only
@@ -310,8 +317,9 @@ These can be set in `config.json` or via an environment variable:
310
317
  - **`accountSlug`** / `SPINUPWP_ACCOUNT_SLUG` — your SpinupWP account/team slug
311
318
  (the first path segment in a SpinupWP URL, e.g. `acme-agency` in
312
319
  `https://spinupwp.app/acme-agency/servers/12345`). The API
313
- doesn't expose it, so set it to enable the `w` deep links into the web app.
314
- Without it, `w` opens the SpinupWP dashboard root.
320
+ doesn't expose it, so it's needed for the `w` deep links into the web app.
321
+ You don't have to set it by hand: first-run setup asks for it (optional), and
322
+ `w` asks the first time it's needed, then saves it to the current account.
315
323
  - **`sshUser`** / `SPINUPWP_SSH_USER` — override the SSH user for the health view
316
324
  and stack probes (see "Server health" below).
317
325
  - **`localSync`** / `SPINUPWP_LOCAL_SYNC` — opt-in for the **Pull production →
@@ -375,8 +383,10 @@ These can be set in `config.json` or via an environment variable:
375
383
  | `w` | Open the selected server/site in the SpinupWP web app |
376
384
  | `/` | Jump to global search |
377
385
  | `A` | Accounts — switch, add, rename, or remove SpinupWP accounts (from anywhere) |
378
- | `⏎` / `s` | Set up the selected module — Beacon opens its guided Telegram setup (Modules tab) |
386
+ | `⏎` / `s` | Set up the selected module — Beacon opens its guided Telegram setup, Scheduler its install plan (Modules tab) |
379
387
  | `t` | Send a test page through Beacon (Modules tab, once Beacon is set up) |
388
+ | `f` | Your SpinupTUI folder: where jobs and reports live, and sharing it across machines (Modules tab, Scheduler) |
389
+ | `h` | Station heartbeat: set up the Uptime Kuma push monitor, see the last ping, test or remove it (Modules tab, Scheduler) |
380
390
  | `r` | Refresh data from the API |
381
391
  | `i` | Explain the current screen (what each pane and key does) |
382
392
  | `?` | Toggle the help overlay |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "1.3.0-alpha.1",
3
+ "version": "1.3.0-alpha.3",
4
4
  "description": "SpinupTUI — a terminal dashboard for browsing, monitoring, and managing your SpinupWP servers and sites.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.ts CHANGED
@@ -109,6 +109,12 @@ export interface AppConfig {
109
109
  // it relies on the file's 600 mode. The env channel (SPINUPTUI_BEACON_*) is
110
110
  // resolved in lib/beacon.ts, not here, so this is only what's stored.
111
111
  beacon: BeaconStored | null
112
+ // Scheduler's sources and installed dispatcher (lib/scheduler*.ts,
113
+ // docs/2026-10-20_scheduler-spec.md). Machine-level: a station runs one clock.
114
+ scheduler: SchedulerStored | null
115
+ // Where modules put the user's work (Scheduler's jobs and reports…); null =
116
+ // the default, ~/SpinupTUI. SPINUPTUI_HOME overrides. See modules/contract.ts.
117
+ homeDir: string | null
112
118
  }
113
119
 
114
120
  export interface BeaconTelegramChannel {
@@ -126,6 +132,21 @@ export interface BeaconStored {
126
132
  channels?: Record<string, BeaconChannel>
127
133
  }
128
134
 
135
+ export interface SchedulerStored {
136
+ // Named job sources beyond the built-in `local` one: directories holding
137
+ // jobs/ and runs/, pulled and pushed when they are git repositories.
138
+ sources?: Record<string, { path: string }>
139
+ defaultSource?: string
140
+ // Git sources this machine only queues to: it never claims or runs them
141
+ // (set by `scheduler source release`, cleared by `claim`).
142
+ queueOnly?: string[]
143
+ // Written by `scheduler install --apply`, cleared by uninstall.
144
+ dispatcher?: { label: string; unitPath: string; bin: string; installedAt: string }
145
+ // The station heartbeat: an Uptime Kuma push monitor, pinged at the end of
146
+ // every dispatcher tick (lib/heartbeat.ts). Set by `scheduler heartbeat set`.
147
+ heartbeat?: { url: string; setAt: string }
148
+ }
149
+
129
150
  export interface ServerProviderRef {
130
151
  id: number
131
152
  databaseProviderId?: number
@@ -224,6 +245,8 @@ export interface StoredConfig {
224
245
  kumaMonitors?: Record<string, KumaMonitorRef>
225
246
  lastSeenVersion?: string
226
247
  beacon?: BeaconStored
248
+ scheduler?: SchedulerStored
249
+ homeDir?: string
227
250
  }
228
251
 
229
252
  // ---- Accounts (profiles) -------------------------------------------------------
@@ -510,6 +533,8 @@ export function loadConfig(): AppConfig {
510
533
  kumaMonitors: stored.kumaMonitors ?? {},
511
534
  lastSeenVersion: stored.lastSeenVersion?.trim() || null,
512
535
  beacon: stored.beacon ?? null,
536
+ scheduler: stored.scheduler ?? null,
537
+ homeDir: stored.homeDir?.trim() || null,
513
538
  }
514
539
  }
515
540
 
package/src/index.tsx CHANGED
@@ -1,22 +1,15 @@
1
1
  #!/usr/bin/env bun
2
- // Entry point. Handles a few non-interactive CLI subcommands, then boots the
3
- // OpenTUI renderer and mounts either the first-run onboarding wizard (no token
4
- // yet) or the main app wrapped in the data store.
2
+ // Entry point. Handles the non-interactive CLI subcommands, then hands off to
3
+ // the dashboard (ui/main.tsx).
5
4
 
6
5
  // First, before any config read or spawn: keep a site checkout's .env from
7
6
  // leaking into child processes (see the module).
8
7
  import "./lib/cwdDotenv.ts"
9
- import { useCallback, useMemo, useState } from "react"
10
- import { createCliRenderer } from "@opentui/core"
11
- import { createRoot } from "@opentui/react"
8
+ // Nothing UI-related is imported statically: headless commands (the Scheduler
9
+ // tick every 5 minutes, beacon send, incidents…) never load the renderer or
10
+ // React. The dashboard is a dynamic import at the bottom.
12
11
  import pkg from "../package.json" with { type: "json" }
13
- import { hasToken, configPath, loadConfig, pinActiveProfile, setActiveProfile, listProfiles } from "./config.ts"
14
- import { StoreProvider } from "./ui/store.tsx"
15
- import { App } from "./ui/App.tsx"
16
- import { AccountsContext, type AccountsApi } from "./ui/accounts.tsx"
17
- import { resetWpCoreJobs } from "./ui/wpCoreJobs.tsx"
18
- import { Onboarding } from "./ui/Onboarding.tsx"
19
- import { isDevMode } from "./dev/devMode.ts"
12
+ import { configPath, loadConfig, listProfiles } from "./config.ts"
20
13
  import { SpinupWPClient } from "./api/client.ts"
21
14
  import { resolveSshAccess } from "./lib/cliSsh.ts"
22
15
  import { resolveIncidents } from "./lib/cliIncidents.ts"
@@ -25,6 +18,7 @@ import { execSshCommand } from "./lib/sshExec.ts"
25
18
  import { runPullFiles, runPullDb } from "./lib/cliPull.ts"
26
19
  import { runBeacon, runModules } from "./lib/cliBeacon.ts"
27
20
  import { runOffline, runOnline } from "./lib/cliOffline.ts"
21
+ import { runScheduler } from "./lib/cliScheduler.ts"
28
22
 
29
23
  const args = process.argv.slice(2)
30
24
  const positionals = args.filter((a) => !a.startsWith("-"))
@@ -113,6 +107,15 @@ Usage:
113
107
  Add a channel from a file holding TELEGRAM_BOT_TOKEN and
114
108
  TELEGRAM_CHAT_ID; sends a test message before saving.
115
109
  spinuptui beacon test | status | remove <name>
110
+ spinuptui scheduler add <id> --kind spinuptui|script|agent --when <time> | --every <schedule>
111
+ --expires <date> --created-by <who> -- <body>
112
+ Queue a follow-up for this station to run later and report
113
+ on through Beacon (\`spinuptui scheduler\` for the rest)
114
+ spinuptui scheduler list | status | install --plan|--apply
115
+ spinuptui home Where the SpinupTUI folder (your jobs and reports) is
116
+ spinuptui home sync --plan|--apply --github <name> | --remote <url> | --clone <url>
117
+ Share that folder across machines through a private git
118
+ repository, or set it up from one on a second machine
116
119
  spinuptui modules List SpinupTUI's modules and their status (--json)
117
120
  spinuptui --version Print the version
118
121
  spinuptui --help Show this help
@@ -313,31 +316,16 @@ if (command === "modules") {
313
316
  process.exit(await runModules(args.slice(1)))
314
317
  }
315
318
 
316
- // `spinuptui login` forces the onboarding wizard even when a token already exists,
317
- // so the token can be (re)saved to the config file for global use.
318
- const forceLogin = command === "login"
319
+ if (command === "home") {
320
+ const { runHome } = await import("./lib/cliHome.ts")
321
+ process.exit(await runHome(args.slice(args.indexOf("home") + 1)))
322
+ }
319
323
 
320
- function Root() {
321
- const [configured, setConfigured] = useState(isDevMode() ? true : forceLogin ? false : hasToken())
322
- // The store is keyed by account: switching remounts it (see ui/accounts.tsx).
323
- const [account, setAccount] = useState(() => pinActiveProfile())
324
- const switchAccount = useCallback(async (id: string) => {
325
- await setActiveProfile(id)
326
- resetWpCoreJobs()
327
- setAccount(id)
328
- }, [])
329
- const accounts = useMemo<AccountsApi>(() => ({ switchAccount }), [switchAccount])
330
- if (!configured) {
331
- return <Onboarding onComplete={() => setConfigured(true)} />
332
- }
333
- return (
334
- <AccountsContext.Provider value={accounts}>
335
- <StoreProvider key={account}>
336
- <App />
337
- </StoreProvider>
338
- </AccountsContext.Provider>
339
- )
324
+ if (command === "scheduler") {
325
+ process.exit(await runScheduler(args.slice(args.indexOf("scheduler") + 1)))
340
326
  }
341
327
 
342
- const renderer = await createCliRenderer()
343
- createRoot(renderer).render(<Root />)
328
+ // `spinuptui login` forces the onboarding wizard even when a token already exists,
329
+ // so the token can be (re)saved to the config file for global use.
330
+ const { startDashboard } = await import("./ui/main.tsx")
331
+ await startDashboard({ forceLogin: command === "login" })
@@ -0,0 +1,107 @@
1
+ // `spinuptui home`: where the SpinupTUI folder is and whether it's shared, and
2
+ // `home sync`, which shares it across machines (lib/home.ts). Headless like the
3
+ // module commands: --json prints one object on stdout.
4
+
5
+ import { applySync, describeShare, ensureHome, homeStatus, planSync, tilde, type SyncOptions } from "./home.ts"
6
+
7
+ const USAGE = `Usage:
8
+ spinuptui home [--json] where the SpinupTUI folder is, and whether it's shared
9
+ spinuptui home sync --plan | --apply [--json]
10
+ --github <name> create a private GitHub repository for it (needs gh)
11
+ --remote <url> or push it to an empty private repository you made
12
+ --clone <url> on another machine: set the same folder up here
13
+
14
+ The SpinupTUI folder (~/SpinupTUI) holds your Scheduler jobs and reports and,
15
+ later, other modules' work. Shared through a private git repository, you can
16
+ queue jobs from any machine and read the reports anywhere.`
17
+
18
+ const VALUE_FLAGS = new Set(["--github", "--remote", "--clone"])
19
+
20
+ function fail(json: boolean, reason: string, message: string, remedy?: string): number {
21
+ if (json) console.log(JSON.stringify({ ok: false, reason, message, ...(remedy ? { remedy } : {}) }))
22
+ else {
23
+ console.error(message)
24
+ if (remedy) console.error(remedy)
25
+ }
26
+ return 1
27
+ }
28
+
29
+ export async function runHome(argv: string[]): Promise<number> {
30
+ const flags = new Map<string, string>()
31
+ const pos: string[] = []
32
+ for (let i = 0; i < argv.length; i++) {
33
+ const a = argv[i]!
34
+ if (VALUE_FLAGS.has(a)) flags.set(a, argv[++i] ?? "")
35
+ else if (a.startsWith("-")) flags.set(a, "")
36
+ else pos.push(a)
37
+ }
38
+ const json = flags.has("--json")
39
+ if (flags.has("--help") || flags.has("-h")) {
40
+ console.log(USAGE)
41
+ return 0
42
+ }
43
+ if (pos.length === 0) return status(json)
44
+ if (pos[0] === "sync") return sync(flags, json)
45
+ return fail(json, "usage", USAGE)
46
+ }
47
+
48
+ async function status(json: boolean): Promise<number> {
49
+ const s = await homeStatus()
50
+ if (json) {
51
+ console.log(JSON.stringify({ ok: true, ...s }))
52
+ return 0
53
+ }
54
+ console.log(`SpinupTUI folder: ${tilde(s.path)}${s.exists ? "" : " (created the first time a module writes to it)"}`)
55
+ console.log(` ${s.git && s.remote ? "✓" : "·"} ${describeShare(s)}`)
56
+ if (s.station) console.log(` · its jobs run on ${s.station === s.thisMachine ? "this machine" : s.station}`)
57
+ if (s.syncedBy) console.log(` ✗ inside ${s.syncedBy}: ${s.git ? "two programs syncing the same .git can corrupt it. Move it (homeDir in config.json)." : "fine on its own, but move it before sharing it with git."}`)
58
+ if (!s.git && !s.insideRepo) console.log(`\nTo queue jobs from other machines: spinuptui home sync --plan`)
59
+ return 0
60
+ }
61
+
62
+ async function sync(flags: Map<string, string>, json: boolean): Promise<number> {
63
+ const apply = flags.has("--apply")
64
+ if (apply === flags.has("--plan")) return fail(json, "usage", "Pass --plan to see what would happen, or --apply to do it.\n\n" + USAGE)
65
+ const opts: SyncOptions = {}
66
+ for (const [flag, key] of [
67
+ ["--github", "github"],
68
+ ["--remote", "remote"],
69
+ ["--clone", "clone"],
70
+ ] as const) {
71
+ if (!flags.has(flag)) continue
72
+ if (!flags.get(flag)) return fail(json, "usage", `${flag} needs a value.`)
73
+ opts[key] = flags.get(flag)
74
+ }
75
+ const plan = await planSync(opts)
76
+ if (plan.blockers.length) {
77
+ const b = plan.blockers[0]!
78
+ if (json) console.log(JSON.stringify({ ok: false, reason: b.reason, message: b.message, ...(b.remedy ? { remedy: b.remedy } : {}), blockers: plan.blockers }))
79
+ else for (const x of plan.blockers) fail(false, x.reason, x.message, x.remedy)
80
+ return 1
81
+ }
82
+ const steps = plan.steps.map((s) => s.label)
83
+ if (!apply) {
84
+ if (json) console.log(JSON.stringify({ ok: true, applied: false, mode: plan.mode, home: plan.home, steps, notes: plan.notes }))
85
+ else if (!steps.length) console.log(`${tilde(plan.home)} is already shared and up to date. Nothing to do.`)
86
+ else {
87
+ console.log(`Plan for ${tilde(plan.home)}:`)
88
+ steps.forEach((s, i) => console.log(` ${i + 1}. ${s}`))
89
+ for (const n of plan.notes) console.log(`\n${n}`)
90
+ console.log(`\nRun with --apply to do it.`)
91
+ }
92
+ return 0
93
+ }
94
+ const r = await applySync(plan)
95
+ if (json) console.log(JSON.stringify({ ok: r.ok, applied: true, mode: plan.mode, home: plan.home, done: r.done, ...(r.failed ? { failed: r.failed } : {}) }))
96
+ else {
97
+ for (const d of r.done) console.log(`✓ ${d}`)
98
+ if (r.failed) console.error(`✗ ${r.failed.step}: ${r.failed.message}\nSteps above it are done; fix this and run it again, and it picks up where it stopped.`)
99
+ else if (steps.length) {
100
+ // A clone brings the other machine's README; make sure there is one.
101
+ ensureHome()
102
+ const s = await homeStatus()
103
+ console.log(`\n${tilde(s.path)} is ${describeShare(s)}.`)
104
+ } else console.log(`${tilde(plan.home)} is already shared and up to date. Nothing to do.`)
105
+ }
106
+ return r.ok ? 0 : 1
107
+ }