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
@@ -0,0 +1,299 @@
1
+ // The SpinupTUI folder overlay (Modules tab, `f`): where your jobs and reports
2
+ // live, and sharing that folder across machines — `spinuptui home sync` with
3
+ // the confirm-before-firing pattern. Pick how to share it, give a name or URL,
4
+ // see the plan, press y. Nothing runs git until then.
5
+
6
+ import { useEffect, useState } from "react"
7
+ import { useKeyboard } from "@opentui/react"
8
+ import { theme } from "../../lib/theme.ts"
9
+ import { truncate } from "../../lib/format.ts"
10
+ import { openUrl } from "../../lib/open.ts"
11
+ import { Panel, Spinner, Centered } from "../components.tsx"
12
+ import { StatusBar } from "../StatusBar.tsx"
13
+ import { useStore } from "../store.tsx"
14
+ import { applySync, describeShare, ensureHome, ghReady, homeStatus, planSync, tilde, type ApplyResult, type HomeStatus, type SyncOptions, type SyncPlan } from "../../lib/home.ts"
15
+
16
+ type Phase = "loading" | "pick" | "input" | "planning" | "confirm" | "blocked" | "applying" | "done" | "error"
17
+
18
+ interface Choice {
19
+ key: keyof SyncOptions
20
+ label: string
21
+ detail: string
22
+ prompt: string
23
+ placeholder: string
24
+ initial: string
25
+ }
26
+
27
+ const INPUT_STYLE = { backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }
28
+
29
+ export function HomeSync() {
30
+ const { setHomeSyncOpen, setInputMode } = useStore()
31
+ const [phase, setPhase] = useState<Phase>("loading")
32
+ const [status, setStatus] = useState<HomeStatus | null>(null)
33
+ const [gh, setGh] = useState<string | null>(null) // why gh can't be used; null = it can
34
+ const [selected, setSelected] = useState(0)
35
+ const [value, setValue] = useState("")
36
+ const [plan, setPlan] = useState<SyncPlan | null>(null)
37
+ const [result, setResult] = useState<ApplyResult | null>(null)
38
+
39
+ const choices: Choice[] = [
40
+ {
41
+ key: "github",
42
+ label: "Create a private GitHub repository for it",
43
+ detail: gh ? `${gh} Or make one yourself and use the next option.` : "Uses the GitHub CLI you're signed in to.",
44
+ prompt: "Repository name (or owner/name)",
45
+ placeholder: "spinuptui-home",
46
+ initial: "spinuptui-home",
47
+ },
48
+ {
49
+ key: "remote",
50
+ label: "Use an empty private repository I've made",
51
+ detail: "Any git host: GitHub, GitLab, Gitea, your own server.",
52
+ prompt: "Repository URL",
53
+ placeholder: "git@github.com:you/spinuptui-home.git",
54
+ initial: "",
55
+ },
56
+ {
57
+ key: "clone",
58
+ label: "Set this machine up from another machine's folder",
59
+ detail: "For a second machine: clones the repository you shared from the first.",
60
+ prompt: "Repository URL",
61
+ placeholder: "git@github.com:you/spinuptui-home.git",
62
+ initial: "",
63
+ },
64
+ ]
65
+ const choice = choices[selected]!
66
+
67
+ const close = () => setHomeSyncOpen(false)
68
+
69
+ const makePlan = (opts: SyncOptions) => {
70
+ setPhase("planning")
71
+ void planSync(opts).then((p) => {
72
+ setPlan(p)
73
+ setPhase(p.blockers.length ? "blocked" : "confirm")
74
+ })
75
+ }
76
+
77
+ useEffect(() => {
78
+ void Promise.all([homeStatus(), ghReady()]).then(([s, why]) => {
79
+ setStatus(s)
80
+ setGh(why)
81
+ // No usable gh: start on the option that works without it.
82
+ if (why) setSelected(1)
83
+ // Already shared: the plan is "push what's pending", or nothing.
84
+ if (s.git && s.remote) makePlan({})
85
+ else setPhase("pick")
86
+ })
87
+ // Once, on open.
88
+ // eslint-disable-next-line react-hooks/exhaustive-deps
89
+ }, [])
90
+
91
+ // The input owns the keyboard while it's showing.
92
+ useEffect(() => {
93
+ if (phase !== "input") return
94
+ setInputMode(true)
95
+ return () => setInputMode(false)
96
+ }, [phase, setInputMode])
97
+
98
+ const apply = () => {
99
+ if (!plan) return
100
+ setPhase("applying")
101
+ void applySync(plan).then(async (r) => {
102
+ if (r.ok) ensureHome()
103
+ setResult(r)
104
+ setStatus(await homeStatus())
105
+ setPhase(r.ok ? "done" : "error")
106
+ })
107
+ }
108
+
109
+ useKeyboard((key) => {
110
+ const name = key.name ?? ""
111
+ if (phase === "input") {
112
+ if (name === "escape") setPhase("pick")
113
+ return
114
+ }
115
+ if (name === "escape" || name === "q") return close()
116
+ if (name === "o" && status?.exists) return openUrl(status.path)
117
+ if (phase === "pick") {
118
+ if (name === "up" || name === "k") return setSelected((i) => Math.max(0, i - 1))
119
+ if (name === "down" || name === "j") return setSelected((i) => Math.min(choices.length - 1, i + 1))
120
+ if (name === "return") {
121
+ if (choice.key === "github" && gh) return
122
+ setValue(choice.initial)
123
+ return setPhase("input")
124
+ }
125
+ }
126
+ if (phase === "confirm" && name === "y" && plan && plan.steps.length) return apply()
127
+ if ((phase === "blocked" || phase === "error") && name === "r" && !(status?.git && status.remote)) return setPhase("pick")
128
+ })
129
+
130
+ return (
131
+ <box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 210 }}>
132
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
133
+ <text content="▤ Your SpinupTUI folder " fg={theme.brand} style={{ flexShrink: 0 }} />
134
+ <text content={status ? tilde(status.path) : ""} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
135
+ </box>
136
+ <Centered>{body()}</Centered>
137
+ <StatusBar hints={hints()} showGlobal={false} />
138
+ </box>
139
+ )
140
+
141
+ function header() {
142
+ if (!status) return null
143
+ return (
144
+ <>
145
+ <text content="Your Scheduler jobs and reports live here, and later other modules' work too." fg={theme.text} />
146
+ <text
147
+ content={`${status.exists ? "" : "Created the first time a module writes to it. "}Its README.md explains each part.`}
148
+ fg={theme.textDim}
149
+ />
150
+ <box style={{ height: 1 }} />
151
+ <box style={{ flexDirection: "row" }}>
152
+ <text content="Now: " fg={theme.textDim} style={{ flexShrink: 0 }} />
153
+ <text content={truncate(describeShare(status), 66)} fg={status.git && status.remote ? theme.good : theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
154
+ </box>
155
+ {status.station && (
156
+ <text content={`Its jobs run on ${status.station === status.thisMachine ? "this machine" : status.station}.`} fg={theme.textDim} wrapMode="none" />
157
+ )}
158
+ <box style={{ height: 1 }} />
159
+ </>
160
+ )
161
+ }
162
+
163
+ function body() {
164
+ if (phase === "loading" || phase === "planning" || phase === "applying") {
165
+ return (
166
+ <box style={{ flexDirection: "row" }}>
167
+ <Spinner />
168
+ <text content={phase === "applying" ? " Sharing the folder…" : phase === "planning" ? " Checking the repository…" : " Looking at the folder…"} fg={theme.textDim} wrapMode="none" />
169
+ </box>
170
+ )
171
+ }
172
+ if (phase === "pick") {
173
+ return (
174
+ <Panel title=" Share it across machines " active>
175
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
176
+ {header()}
177
+ <text content="Shared through a private git repository, you can queue jobs from any machine" fg={theme.text} />
178
+ <text content="and read the reports anywhere. Scheduler pulls it before each run and pushes" fg={theme.text} />
179
+ <text content="the reports after." fg={theme.text} />
180
+ <box style={{ height: 1 }} />
181
+ {choices.map((c, i) => {
182
+ const off = c.key === "github" && !!gh
183
+ const sel = i === selected
184
+ return (
185
+ <box key={c.key} style={{ flexDirection: "column" }}>
186
+ <text content={`${sel ? "▸ " : " "}${c.label}`} fg={off ? theme.textFaint : sel ? theme.accent : theme.text} wrapMode="none" />
187
+ {sel && <text content={` ${c.detail}`} fg={off ? theme.warn : theme.textDim} />}
188
+ </box>
189
+ )
190
+ })}
191
+ </box>
192
+ </Panel>
193
+ )
194
+ }
195
+ if (phase === "input") {
196
+ return (
197
+ <Panel title={` ${choice.label} `} active>
198
+ <box style={{ flexDirection: "column", width: 72, paddingTop: 1, paddingBottom: 1 }}>
199
+ <text content={choice.prompt} fg={theme.accent} />
200
+ <input
201
+ focused
202
+ value={value}
203
+ placeholder={choice.placeholder}
204
+ onInput={(v: string) => setValue(v)}
205
+ onSubmit={() => value.trim() && makePlan({ [choice.key]: value.trim() })}
206
+ style={INPUT_STYLE}
207
+ />
208
+ <box style={{ height: 1 }} />
209
+ <text content={choice.key === "clone" ? "Nothing changes until you've seen the plan." : "Keep it private: reports name your sites and clients. Nothing changes until you've seen the plan."} fg={theme.textFaint} />
210
+ </box>
211
+ </Panel>
212
+ )
213
+ }
214
+ if (phase === "blocked" && plan) {
215
+ return (
216
+ <Panel title=" Can't share it yet " active>
217
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
218
+ {plan.blockers.map((b) => (
219
+ <box key={b.reason + b.message} style={{ flexDirection: "column" }}>
220
+ <text content={`✕ ${b.message}`} fg={theme.bad} />
221
+ {b.remedy && <text content={` ${b.remedy}`} fg={theme.textDim} />}
222
+ <box style={{ height: 1 }} />
223
+ </box>
224
+ ))}
225
+ </box>
226
+ </Panel>
227
+ )
228
+ }
229
+ if (phase === "confirm" && plan) {
230
+ if (!plan.steps.length) {
231
+ return (
232
+ <Panel title=" Shared " active>
233
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
234
+ {header()}
235
+ <text content="✓ Shared and up to date. Nothing to do." fg={theme.good} />
236
+ <box style={{ height: 1 }} />
237
+ <text content={`On another machine: spinuptui home sync --clone ${status?.remote ?? "<url>"} --plan`} fg={theme.textFaint} />
238
+ {plan.notes.map((n) => (
239
+ <text key={n} content={n} fg={theme.warn} />
240
+ ))}
241
+ </box>
242
+ </Panel>
243
+ )
244
+ }
245
+ return (
246
+ <Panel title=" The plan " active>
247
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
248
+ {header()}
249
+ {plan.steps.map((s, i) => (
250
+ <text key={s.label} content={`${i + 1}. ${truncate(s.label, 70)}`} fg={theme.text} wrapMode="none" />
251
+ ))}
252
+ <box style={{ height: 1 }} />
253
+ {plan.notes.map((n) => (
254
+ <text key={n} content={n} fg={theme.textFaint} />
255
+ ))}
256
+ <box style={{ height: 1 }} />
257
+ <text content="Press y to do it · Esc to cancel" fg={theme.text} wrapMode="none" />
258
+ </box>
259
+ </Panel>
260
+ )
261
+ }
262
+ if (phase === "done" && result) {
263
+ return (
264
+ <Panel title=" Shared " active>
265
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
266
+ {result.done.map((d) => (
267
+ <text key={d} content={`✓ ${truncate(d, 72)}`} fg={theme.good} wrapMode="none" />
268
+ ))}
269
+ <box style={{ height: 1 }} />
270
+ {status && <text content={`${tilde(status.path)} is ${truncate(describeShare(status), 56)}.`} fg={theme.text} />}
271
+ <box style={{ height: 1 }} />
272
+ <text content="Esc to close" fg={theme.textFaint} />
273
+ </box>
274
+ </Panel>
275
+ )
276
+ }
277
+ return (
278
+ <Panel title=" Stopped " active>
279
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
280
+ {result?.done.map((d) => (
281
+ <text key={d} content={`✓ ${truncate(d, 72)}`} fg={theme.good} wrapMode="none" />
282
+ ))}
283
+ <text content={`✕ ${result?.failed?.step ?? "Something went wrong"}: ${result?.failed?.message ?? ""}`} fg={theme.bad} />
284
+ <box style={{ height: 1 }} />
285
+ <text content="The steps above it are done. Fix this and run it again: it picks up where it stopped." fg={theme.textDim} />
286
+ </box>
287
+ </Panel>
288
+ )
289
+ }
290
+
291
+ function hints() {
292
+ const open = status?.exists ? [{ key: "o", label: "open folder" }] : []
293
+ if (phase === "pick") return [{ key: "↑↓", label: "choose" }, { key: "⏎", label: "next" }, ...open, { key: "esc", label: "close" }]
294
+ if (phase === "input") return [{ key: "⏎", label: "see the plan" }, { key: "esc", label: "back" }]
295
+ if (phase === "confirm" && plan?.steps.length) return [{ key: "y", label: "do it" }, ...open, { key: "esc", label: "cancel" }]
296
+ if (phase === "blocked" || phase === "error") return [...(status?.git && status.remote ? [] : [{ key: "r", label: "back" }]), ...open, { key: "esc", label: "close" }]
297
+ return [...open, { key: "esc", label: "close" }]
298
+ }
299
+ }
@@ -16,6 +16,8 @@ import { MODULES, UPCOMING_MODULES } from "../../modules/index.ts"
16
16
  import type { ModuleId, ModuleStatus } from "../../modules/contract.ts"
17
17
  import { beaconFromConfig, beaconSend, testMessage } from "../../lib/beacon.ts"
18
18
  import { loadConfig } from "../../config.ts"
19
+ import { upcomingRuns } from "../../modules/scheduler.ts"
20
+ import { describeShare, homeStatus, tilde, type HomeStatus } from "../../lib/home.ts"
19
21
 
20
22
  interface Card {
21
23
  id: ModuleId
@@ -42,16 +44,16 @@ function stateLabel(card: Card, status: ModuleStatus | undefined): { dot: string
42
44
 
43
45
  export function Modules({ rows }: { rows: number }) {
44
46
  const store = useStore()
45
- const { route, inputMode, overlayOpen, beaconSetupOpen, setBeaconSetupOpen } = store
47
+ const { route, inputMode, overlayOpen, beaconSetupOpen, setBeaconSetupOpen, schedulerInstallOpen, setSchedulerInstallOpen, homeSyncOpen, setHomeSyncOpen, heartbeatSetupOpen, setHeartbeatSetupOpen } = store
46
48
  const [selected, setSelected] = useState(0)
47
49
  const [statuses, setStatuses] = useState<Map<ModuleId, ModuleStatus>>(new Map())
48
50
  const [testing, setTesting] = useState(false)
49
51
 
50
- // Recompute on open and whenever the setup overlay closes (it may have
51
- // changed the config). status() is cheap and never sends anything.
52
+ // Recompute on open and whenever a setup overlay closes (it may have changed
53
+ // the config). status() is cheap and never sends anything.
52
54
  const [tick, setTick] = useState(0)
53
55
  useEffect(() => {
54
- if (beaconSetupOpen) return
56
+ if (beaconSetupOpen || schedulerInstallOpen || homeSyncOpen || heartbeatSetupOpen) return
55
57
  let live = true
56
58
  void Promise.all(MODULES.map((m) => m.status())).then((list) => {
57
59
  if (live) setStatuses(new Map(list.map((s) => [s.id, s])))
@@ -59,7 +61,7 @@ export function Modules({ rows }: { rows: number }) {
59
61
  return () => {
60
62
  live = false
61
63
  }
62
- }, [beaconSetupOpen, tick])
64
+ }, [beaconSetupOpen, schedulerInstallOpen, homeSyncOpen, heartbeatSetupOpen, tick])
63
65
 
64
66
  const isActive = route === "modules" && !inputMode && !overlayOpen
65
67
  const card = CARDS[selected]!
@@ -88,6 +90,13 @@ export function Modules({ rows }: { rows: number }) {
88
90
  case "return":
89
91
  case "s":
90
92
  if (card.id === "beacon") return setBeaconSetupOpen(true)
93
+ if (card.id === "scheduler") return setSchedulerInstallOpen(true)
94
+ return
95
+ case "f":
96
+ if (card.id === "scheduler") return setHomeSyncOpen(true)
97
+ return
98
+ case "h":
99
+ if (card.id === "scheduler") return setHeartbeatSetupOpen(true)
91
100
  return
92
101
  case "t":
93
102
  if (card.id === "beacon" && status && status.state !== "not_configured") return sendTest()
@@ -140,6 +149,13 @@ export function Modules({ rows }: { rows: number }) {
140
149
  hints={[
141
150
  { key: "↑↓/jk", label: "select" },
142
151
  ...(card.id === "beacon" ? [{ key: "⏎", label: beaconReady ? "reconfigure" : "set up" }] : []),
152
+ ...(card.id === "scheduler"
153
+ ? [
154
+ { key: "⏎", label: status && status.state !== "not_configured" ? "reinstall" : "install" },
155
+ { key: "f", label: "your folder" },
156
+ { key: "h", label: "heartbeat" },
157
+ ]
158
+ : []),
143
159
  ...(beaconReady ? [{ key: "t", label: testing ? "sending…" : "test page" }] : []),
144
160
  ]}
145
161
  />
@@ -191,6 +207,54 @@ function ModuleDetail({ card, status }: { card: Card; status: ModuleStatus | und
191
207
  {card.id === "beacon" && status.state !== "not_configured" && (
192
208
  <text content="⏎ reconfigure the pager · t send a test page · CLI: spinuptui beacon status" fg={theme.textFaint} />
193
209
  )}
210
+ {card.id === "scheduler" && <SchedulerDetail installed={status.state !== "not_configured"} refreshKey={status} />}
194
211
  </box>
195
212
  )
196
213
  }
214
+
215
+ function SchedulerDetail({ installed, refreshKey }: { installed: boolean; refreshKey: unknown }) {
216
+ const [runs] = useState(() => upcomingRuns())
217
+ // Level 0 of the SpinupTUI folder: always say where the work lands.
218
+ const [home, setHome] = useState<HomeStatus | null>(null)
219
+ const heartbeatSet = !!loadConfig().scheduler?.heartbeat
220
+ useEffect(() => {
221
+ let live = true
222
+ void homeStatus().then((h) => live && setHome(h))
223
+ return () => {
224
+ live = false
225
+ }
226
+ }, [refreshKey])
227
+ return (
228
+ <>
229
+ <box style={{ height: 1 }} />
230
+ {!installed && (
231
+ <>
232
+ <text
233
+ content="Scheduler runs follow-ups on this machine while you're away: queue a check for tomorrow at 8 and the result arrives on your pager. It's also the clock the other modules run on, so turning it on is what makes this machine a station."
234
+ fg={theme.textDim}
235
+ />
236
+ <box style={{ height: 1 }} />
237
+ <text content="Press ⏎ to see exactly what it installs, then confirm." fg={theme.accent} />
238
+ <box style={{ height: 1 }} />
239
+ </>
240
+ )}
241
+ <text content={runs.length ? "Next up:" : "No follow-ups queued."} fg={theme.textDim} />
242
+ {runs.map((r) => (
243
+ <box key={`${r.source}/${r.id}`} style={{ flexDirection: "row" }}>
244
+ <text content={` ${r.next.padEnd(17)} `} fg={theme.text} wrapMode="none" style={{ flexShrink: 0 }} />
245
+ <text content={`${r.source}/${r.id}`} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
246
+ </box>
247
+ ))}
248
+ <box style={{ height: 1 }} />
249
+ {home && <text content={`Jobs and reports: ${tilde(home.path)}`} fg={theme.textDim} wrapMode="none" />}
250
+ {home && <text content={` ${describeShare(home)}`} fg={home.git && home.remote ? theme.good : theme.textDim} wrapMode="none" />}
251
+ <text content={home?.git && home.remote ? "f your folder" : "f share it across machines, so you can queue jobs from anywhere"} fg={theme.accent} wrapMode="none" />
252
+ <text content={heartbeatSet ? "h station heartbeat" : "h get alerted from elsewhere if this machine stops (station heartbeat)"} fg={theme.accent} wrapMode="none" />
253
+ <box style={{ height: 1 }} />
254
+ <text
255
+ content={`${installed ? "⏎ reinstall (repairs the unit) · " : ""}CLI: spinuptui scheduler add | list | status`}
256
+ fg={theme.textFaint}
257
+ />
258
+ </>
259
+ )
260
+ }
@@ -0,0 +1,172 @@
1
+ // Scheduler's install overlay (Modules tab, ⏎ on Scheduler): shows exactly what
2
+ // `spinuptui scheduler install --plan` would write and run, and applies it on
3
+ // `y` — the confirm-before-firing pattern. Applying ends with the proving tick
4
+ // (a tick that really ran under launchd), so "done" here means the station's
5
+ // clock is running, not just that a file was written.
6
+
7
+ import { useEffect, useState } from "react"
8
+ import { useKeyboard } from "@opentui/react"
9
+ import { theme } from "../../lib/theme.ts"
10
+ import { truncate } from "../../lib/format.ts"
11
+ import { Panel, Spinner, Centered } from "../components.tsx"
12
+ import { StatusBar } from "../StatusBar.tsx"
13
+ import { useStore } from "../store.tsx"
14
+ import { applyInstall, binHasScheduler, NO_SCHEDULER, planInstall, type ApplyResult, type PlanResult } from "../../lib/schedulerInstall.ts"
15
+
16
+ type Phase = "confirm" | "applying" | "done" | "error"
17
+
18
+ export function SchedulerInstall() {
19
+ const { setSchedulerInstallOpen } = useStore()
20
+ const [plan, setPlan] = useState<PlanResult>(() => planInstall())
21
+ // Same refusal as `install --plan`: don't offer a plan whose binary has no
22
+ // Scheduler (it would launch the dashboard under launchd).
23
+ useEffect(() => {
24
+ if (!plan.ok) return
25
+ void binHasScheduler(plan).then((has) => {
26
+ if (!has) {
27
+ setPlan(NO_SCHEDULER)
28
+ setPhase("error")
29
+ }
30
+ })
31
+ // Checked once, for the plan computed on open.
32
+ // eslint-disable-next-line react-hooks/exhaustive-deps
33
+ }, [])
34
+ const [phase, setPhase] = useState<Phase>(plan.ok ? "confirm" : "error")
35
+ const [stage, setStage] = useState("")
36
+ const [result, setResult] = useState<ApplyResult | null>(null)
37
+ const [live, setLive] = useState(true)
38
+ useEffect(() => () => setLive(false), [])
39
+
40
+ const close = () => setSchedulerInstallOpen(false)
41
+
42
+ const apply = () => {
43
+ if (!plan.ok) return
44
+ setPhase("applying")
45
+ void applyInstall(plan, (s) => live && setStage(s)).then((r) => {
46
+ setResult(r)
47
+ setPhase(r.ok ? "done" : "error")
48
+ })
49
+ }
50
+
51
+ useKeyboard((key) => {
52
+ const name = key.name ?? ""
53
+ if (name === "escape" || name === "q") return close()
54
+ if (phase === "confirm" && name === "y" && plan.ok && plan.platform === "darwin") return apply()
55
+ if (phase === "error" && name === "r" && plan.ok) return setPhase("confirm")
56
+ })
57
+
58
+ return (
59
+ <box
60
+ style={{
61
+ position: "absolute",
62
+ top: 0,
63
+ left: 0,
64
+ width: "100%",
65
+ height: "100%",
66
+ flexDirection: "column",
67
+ backgroundColor: theme.bg,
68
+ zIndex: 210,
69
+ }}
70
+ >
71
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
72
+ <text content="⏱ Scheduler " fg={theme.brand} style={{ flexShrink: 0 }} />
73
+ <text content="install the station's clock" fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
74
+ </box>
75
+ <Centered>{body()}</Centered>
76
+ <StatusBar hints={hints()} showGlobal={false} />
77
+ </box>
78
+ )
79
+
80
+ function body() {
81
+ if (phase === "applying") {
82
+ return (
83
+ <box style={{ flexDirection: "column", alignItems: "center" }}>
84
+ <box style={{ flexDirection: "row" }}>
85
+ <Spinner />
86
+ <text content={` ${stage || "Installing…"}`} fg={theme.textDim} wrapMode="none" />
87
+ </box>
88
+ <box style={{ height: 1 }} />
89
+ <text content="Install finishes with a real tick from launchd (up to a minute)." fg={theme.textFaint} wrapMode="none" />
90
+ </box>
91
+ )
92
+ }
93
+ if (phase === "done" && result?.ok) {
94
+ return (
95
+ <Panel title=" Installed " active>
96
+ <box style={{ flexDirection: "column", width: 70, paddingTop: 1, paddingBottom: 1 }}>
97
+ <text content={`✓ ${result.label} is ticking: its first run under launchd was at ${new Date(result.tickAt).toLocaleTimeString()}.`} fg={theme.good} />
98
+ <box style={{ height: 1 }} />
99
+ <text content="Queue a follow-up from any session:" fg={theme.textDim} />
100
+ <text content={' spinuptui scheduler add <id> --kind spinuptui --when "YYYY-MM-DD 08:00" \\'} fg={theme.accent} wrapMode="none" />
101
+ <text content={' --expires YYYY-MM-DD --created-by "…" -- incidents example.com'} fg={theme.accent} wrapMode="none" />
102
+ <box style={{ height: 1 }} />
103
+ {result.notes.map((n) => (
104
+ <text key={n} content={`Note: ${n}`} fg={theme.textFaint} />
105
+ ))}
106
+ <text content="Esc to close" fg={theme.textFaint} />
107
+ </box>
108
+ </Panel>
109
+ )
110
+ }
111
+ if (phase === "error" || !plan.ok) {
112
+ const r = !plan.ok ? plan : result && !result.ok ? result : null
113
+ return (
114
+ <Panel title=" Can't install " active>
115
+ <box style={{ flexDirection: "column", width: 72, paddingTop: 1, paddingBottom: 1 }}>
116
+ <text content={`✕ ${r?.message ?? "Something went wrong."}`} fg={theme.bad} />
117
+ {r?.remedy && (
118
+ <>
119
+ <box style={{ height: 1 }} />
120
+ <text content={r.remedy} fg={theme.textDim} />
121
+ </>
122
+ )}
123
+ <box style={{ height: 1 }} />
124
+ <text content={plan.ok ? "Press r to see the plan again · Esc to close" : "Esc to close"} fg={theme.textFaint} />
125
+ </box>
126
+ </Panel>
127
+ )
128
+ }
129
+ const darwin = plan.platform === "darwin"
130
+ return (
131
+ <Panel title=" Install Scheduler's dispatcher " active>
132
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
133
+ <text content="Runs at login and every 5 minutes, unattended:" fg={theme.text} />
134
+ <text content={` ${truncate(plan.programArguments.join(" "), 72)}`} fg={theme.accent} wrapMode="none" />
135
+ <box style={{ height: 1 }} />
136
+ <text content={`Unit: ${truncate(plan.unitPath, 68)}`} fg={theme.textDim} wrapMode="none" />
137
+ <text content={`PATH: ${truncate(plan.env.PATH ?? "", 68)}`} fg={theme.textDim} wrapMode="none" />
138
+ <box style={{ height: 1 }} />
139
+ {plan.commands.map((c) => (
140
+ <text key={c} content={`· ${truncate(c, 72)}`} fg={theme.textDim} wrapMode="none" />
141
+ ))}
142
+ <box style={{ height: 1 }} />
143
+ {plan.notes.map((n) => (
144
+ <text key={n} content={`Note: ${n}`} fg={theme.textFaint} />
145
+ ))}
146
+ <box style={{ height: 1 }} />
147
+ <text
148
+ content={darwin ? "Press y to install · Esc to cancel" : "Installing on Linux isn't supported yet · Esc to close"}
149
+ fg={darwin ? theme.text : theme.warn}
150
+ wrapMode="none"
151
+ />
152
+ </box>
153
+ </Panel>
154
+ )
155
+ }
156
+
157
+ function hints() {
158
+ if (phase === "confirm" && plan.ok && plan.platform === "darwin") {
159
+ return [
160
+ { key: "y", label: "install" },
161
+ { key: "esc", label: "cancel" },
162
+ ]
163
+ }
164
+ if (phase === "error" && plan.ok) {
165
+ return [
166
+ { key: "r", label: "back to plan" },
167
+ { key: "esc", label: "close" },
168
+ ]
169
+ }
170
+ return [{ key: "esc", label: "close" }]
171
+ }
172
+ }
@@ -36,7 +36,7 @@ function score(haystack: string, q: string): number | null {
36
36
 
37
37
  export function Search({ rows }: { rows: number }) {
38
38
  const store = useStore()
39
- const { servers, sites, serverById, sitesForServer, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setServerActionsServer, accountSlug, localLinks, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setDbBackupSite, dbBackups, setDbSyncSite, dbSyncs, localSync, setMediaFallbackSite, beginClone, setSudoConnectServer, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setWpInventorySite, setGrantKeySite, runProbe, setEnableLocalSyncSite, setVanityServer, vanityJob } = store
39
+ const { servers, sites, serverById, sitesForServer, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setServerActionsServer, openInSpinupWP, localLinks, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setDbBackupSite, dbBackups, setDbSyncSite, dbSyncs, localSync, setMediaFallbackSite, beginClone, setSudoConnectServer, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setWpInventorySite, setGrantKeySite, runProbe, setEnableLocalSyncSite, setVanityServer, vanityJob } = store
40
40
  const [query, setQuery] = useState("")
41
41
  const [selected, setSelected] = useState(0)
42
42
  // "query" = typing/filtering (input focused); "actions" = input blurred so the
@@ -101,9 +101,8 @@ export function Search({ rows }: { rows: number }) {
101
101
  openUrl((s.https?.enabled ? "https://" : "http://") + s.domain)
102
102
  flashMsg(`Opening ${s.domain}…`)
103
103
  }
104
- const openInSpinup = (url: string) => {
105
- openUrl(url)
106
- flashMsg(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
104
+ const openInSpinup = (build: (slug: string) => string) => {
105
+ if (openInSpinupWP(build)) flashMsg("Opening in SpinupWP…")
107
106
  }
108
107
 
109
108
  useKeyboard((key) => {
@@ -141,8 +140,8 @@ export function Search({ rows }: { rows: number }) {
141
140
  if (current?.kind === "site") openSite(current.site)
142
141
  return
143
142
  case "w":
144
- if (current?.kind === "site") openInSpinup(siteWebUrl(current.site.id, accountSlug))
145
- else if (current?.kind === "server") openInSpinup(serverWebUrl(current.server.id, accountSlug))
143
+ if (current?.kind === "site") openInSpinup((slug) => siteWebUrl(current.site.id, slug))
144
+ else if (current?.kind === "server") openInSpinup((slug) => serverWebUrl(current.server.id, slug))
146
145
  return
147
146
  case "u":
148
147
  if (current?.kind === "site") setPhpUpgradeSite(current.site)
@@ -19,7 +19,6 @@ import { Panel, Spinner, Centered } from "../components.tsx"
19
19
  import { StatusBar } from "../StatusBar.tsx"
20
20
  import { useStore } from "../store.tsx"
21
21
  import { moveSelection } from "../List.tsx"
22
- import { openUrl } from "../../lib/open.ts"
23
22
  import { serverWebUrl } from "../../lib/spinupweb.ts"
24
23
  import { toast } from "../toast.ts"
25
24
  import type { RebootInfo } from "../../lib/ssh.ts"
@@ -67,7 +66,7 @@ export function ServerActions() {
67
66
  rebootInfoLoading,
68
67
  rebootInfoErrors,
69
68
  loadRebootInfo,
70
- accountSlug,
69
+ openInSpinupWP,
71
70
  refresh,
72
71
  } = store
73
72
 
@@ -161,8 +160,11 @@ export function ServerActions() {
161
160
  return
162
161
  case "w":
163
162
  if (upgradeRequired && server) {
164
- openUrl(serverWebUrl(server.id, accountSlug))
165
- toast.success(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
163
+ const id = server.id
164
+ if (openInSpinupWP((slug) => serverWebUrl(id, slug))) toast.success("Opening in SpinupWP…")
165
+ // No slug yet: the prompt takes over — close this overlay so its
166
+ // keys don't fire underneath.
167
+ else close()
166
168
  }
167
169
  return
168
170
  }