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
@@ -5,7 +5,8 @@
5
5
  // - set() is called after a successful `d` probe; it upserts the entry and
6
6
  // writes the whole file back immediately (write-through), so a probe
7
7
  // survives even if the TUI is killed with q / Ctrl+C.
8
- // Probing itself is always on-demand and per-site — this cache exists to avoid
8
+ // Probing is on-demand and per-site, with one exception: a WP version older than
9
+ // VERSION_MAX_AGE_MS is re-probed in the background. This cache exists to avoid
9
10
  // repeating the slow SSH round-trips across launches, not to save RAM. It's a
10
11
  // deliberately small, reusable pattern for other expensive derived data later.
11
12
 
@@ -21,6 +22,10 @@ export interface CachedProbe {
21
22
  result: ProbeResult
22
23
  detectedAt: number // epoch ms
23
24
  signature: string // invalidation key (see siteSignature)
25
+ // Last automatic re-check of an old version (epoch ms), kept even when it
26
+ // failed, so the once-per-window limit holds across launches. A successful
27
+ // probe replaces the whole entry, which clears it.
28
+ recheckedAt?: number
24
29
  }
25
30
 
26
31
  interface CacheFile {
@@ -32,6 +37,25 @@ export function stackCachePath(dir = profileDataDir()): string {
32
37
  return join(dir, "stack-cache.json")
33
38
  }
34
39
 
40
+ // A WordPress version is a fact about the server that changes without us: auto-
41
+ // updates, wp-admin and deploys all move it, and none of them touch the site's
42
+ // shape below. So a reading that carries a version is re-checked once it's older
43
+ // than this (in the background, when the site list loads), and its age is shown.
44
+ export const VERSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
45
+
46
+ // True when the entry carries a WP version read longer ago than the max age.
47
+ // Identities without a version (WHMCS, Laravel, static) don't drift this way.
48
+ export function isVersionOld(entry: CachedProbe | undefined, now = Date.now()): boolean {
49
+ return entry?.result.version != null && now - entry.detectedAt > VERSION_MAX_AGE_MS
50
+ }
51
+
52
+ // True when an old version is due an automatic re-check: none was attempted in
53
+ // the current window. Unattended SSH that fails must not repeat on every launch.
54
+ export function isRecheckDue(entry: CachedProbe | undefined, now = Date.now()): boolean {
55
+ if (!entry || !isVersionOld(entry, now)) return false
56
+ return entry.recheckedAt == null || now - entry.recheckedAt > VERSION_MAX_AGE_MS
57
+ }
58
+
35
59
  // A cached probe is considered stale when the site's relevant shape changes.
36
60
  // Keying on is_wordpress + public_folder catches the cases that would alter the
37
61
  // probe's target paths or expected result.
@@ -83,6 +107,16 @@ export class StackCache {
83
107
  await this.persist()
84
108
  }
85
109
 
110
+ // Record an automatic re-check attempt on existing entries, in one write.
111
+ async noteRecheck(siteIds: number[]): Promise<void> {
112
+ const now = Date.now()
113
+ for (const id of siteIds) {
114
+ const e = this.entries.get(id)
115
+ if (e) this.entries.set(id, { ...e, recheckedAt: now })
116
+ }
117
+ await this.persist()
118
+ }
119
+
86
120
  // A copy of all entries (for rendering aggregates without exposing the map).
87
121
  snapshot(): Map<number, CachedProbe> {
88
122
  return new Map(this.entries)
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { loadConfig } from "../config.ts"
4
4
  import { beaconFromConfig, lastSendLogEntry, maskChatId, maskToken } from "../lib/beacon.ts"
5
- import { moduleStateDir, type ModuleCheck, type ModuleDefinition, type ModuleStatus } from "./contract.ts"
5
+ import { moduleStateDir, moduleWorkDir, type ModuleCheck, type ModuleDefinition, type ModuleStatus } from "./contract.ts"
6
6
 
7
7
  export const beaconModule: ModuleDefinition = {
8
8
  id: "beacon",
@@ -11,6 +11,7 @@ export const beaconModule: ModuleDefinition = {
11
11
  dependsOn: [],
12
12
  configKey: "beacon",
13
13
  stateDir: () => moduleStateDir("beacon"),
14
+ workDir: () => moduleWorkDir("beacon"),
14
15
  jobs: [],
15
16
  async status(): Promise<ModuleStatus> {
16
17
  const beacon = beaconFromConfig(loadConfig())
@@ -10,12 +10,16 @@
10
10
  // module, written only by deliberate setup commands.
11
11
  // - Anything a module writes while running goes in its state directory, never
12
12
  // config.json (whose writes are neither atomic nor locked).
13
+ // - The user's work (jobs, reports, write-ups — anything a person reads, keeps
14
+ // or shares) goes in its work directory, ~/SpinupTUI/<id>/, not next to the
15
+ // tokens in the hidden config directory.
13
16
  // - Recurring work is declared in `jobs` for Scheduler to run; a module never
14
17
  // installs an OS scheduler unit of its own.
15
18
  // - status() is computed on demand, is cheap, and never sends anything.
16
19
 
20
+ import { homedir } from "node:os"
17
21
  import { join } from "node:path"
18
- import { configDir } from "../config.ts"
22
+ import { configDir, loadConfig } from "../config.ts"
19
23
 
20
24
  export type ModuleId = "beacon" | "scheduler" | "sentinel" | "samurai"
21
25
 
@@ -48,7 +52,10 @@ export interface ModuleDefinition {
48
52
  summary: string
49
53
  dependsOn: ModuleId[]
50
54
  configKey: string
55
+ // Machine state: locks, logs, high-water marks.
51
56
  stateDir(): string
57
+ // The user's work. Created on first use, never at install.
58
+ workDir(): string
52
59
  jobs: ScheduledJobDecl[]
53
60
  status(): Promise<ModuleStatus>
54
61
  }
@@ -57,5 +64,19 @@ export function moduleStateDir(id: ModuleId): string {
57
64
  return join(configDir(), "modules", id)
58
65
  }
59
66
 
67
+ const expandHome = (p: string) => (p === "~" ? homedir() : p.startsWith("~/") ? join(homedir(), p.slice(2)) : p)
68
+
69
+ // ~/SpinupTUI by default: visible and easy to back up or turn into a git
70
+ // repository. Deliberately not ~/Documents, which iCloud may sync (a second
71
+ // syncer writing inside a .git corrupts it).
72
+ export function spinuptuiHome(): string {
73
+ const p = process.env.SPINUPTUI_HOME?.trim() || loadConfig().homeDir || join(homedir(), "SpinupTUI")
74
+ return expandHome(p)
75
+ }
76
+
77
+ export function moduleWorkDir(id: ModuleId): string {
78
+ return join(spinuptuiHome(), id)
79
+ }
80
+
60
81
  // A module that hasn't shipped yet: just enough to draw its card.
61
82
  export type UpcomingModule = Pick<ModuleDefinition, "id" | "name" | "summary" | "dependsOn">
@@ -3,18 +3,13 @@
3
3
 
4
4
  import type { ModuleDefinition, UpcomingModule } from "./contract.ts"
5
5
  import { beaconModule } from "./beacon.ts"
6
+ import { schedulerModule } from "./scheduler.ts"
6
7
 
7
- export const MODULES: ModuleDefinition[] = [beaconModule]
8
+ export const MODULES: ModuleDefinition[] = [beaconModule, schedulerModule]
8
9
 
9
10
  // Outcome copy only — the same lines feed the landing page. An entry moves to
10
11
  // MODULES (and out of here) when the module ships.
11
12
  export const UPCOMING_MODULES: UpcomingModule[] = [
12
- {
13
- id: "scheduler",
14
- name: "Scheduler",
15
- summary: "Follow-ups that run themselves: the fix you shipped today gets checked tomorrow without you remembering.",
16
- dependsOn: ["beacon"],
17
- },
18
13
  {
19
14
  id: "sentinel",
20
15
  name: "Sentinel",
@@ -0,0 +1,159 @@
1
+ // Scheduler's module declaration: the station's clock. Its status is the
2
+ // one-question answer to "is the station alive?" — how long ago the dispatcher
3
+ // last ticked.
4
+
5
+ import { existsSync } from "node:fs"
6
+ import { join } from "node:path"
7
+ import { tryLock } from "../lib/flock.ts"
8
+ import { loadConfig } from "../config.ts"
9
+ import { beaconFromConfig } from "../lib/beacon.ts"
10
+ import { nextRunText, parseWhen } from "../lib/schedule.ts"
11
+ import { listSources, loadJobs, loadState, readLastTick, type Source } from "../lib/schedulerJobs.ts"
12
+ import { readClaim, stationName } from "../lib/schedulerGit.ts"
13
+ import { moduleStateDir, moduleWorkDir, type ModuleCheck, type ModuleDefinition, type ModuleStatus } from "./contract.ts"
14
+
15
+ // Three missed ticks.
16
+ export const TICK_STALE_MS = 15 * 60_000
17
+ const RECENT_FAILURE_MS = 24 * 60 * 60_000
18
+
19
+ function claimText(s: Source): string {
20
+ const holder = readClaim(s)
21
+ if (s.queueOnly) return holder ? `run by ${holder}; released here, this machine only queues` : "released here; this machine only queues"
22
+ if (!holder) return "unclaimed: the next dispatcher tick here claims it"
23
+ return holder === stationName() ? "run by this station" : `run by ${holder}; this machine only queues`
24
+ }
25
+
26
+ function tickRunning(): boolean {
27
+ try {
28
+ const lock = tryLock(join(moduleStateDir("scheduler"), "tick.lock"))
29
+ if (!lock) return true
30
+ lock.release()
31
+ } catch {
32
+ // No lock support: assume nothing is running.
33
+ }
34
+ return false
35
+ }
36
+
37
+ function age(ms: number): string {
38
+ const m = Math.round(ms / 60_000)
39
+ if (m < 1) return "just now"
40
+ if (m < 120) return `${m} min ago`
41
+ const h = Math.round(m / 60)
42
+ return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`
43
+ }
44
+
45
+ export const schedulerModule: ModuleDefinition = {
46
+ id: "scheduler",
47
+ name: "Scheduler",
48
+ summary: "Follow-ups that run themselves: the fix you shipped today gets checked tomorrow without you remembering.",
49
+ dependsOn: ["beacon"],
50
+ configKey: "scheduler",
51
+ stateDir: () => moduleStateDir("scheduler"),
52
+ workDir: () => moduleWorkDir("scheduler"),
53
+ jobs: [],
54
+ async status(): Promise<ModuleStatus> {
55
+ const cfg = loadConfig()
56
+ const d = cfg.scheduler?.dispatcher
57
+ const checks: ModuleCheck[] = []
58
+ const installed = !!d && existsSync(d.unitPath)
59
+ checks.push({
60
+ name: "dispatcher",
61
+ ok: installed,
62
+ detail: installed ? `${d!.label}, every 5 min` : d ? `unit file missing (${d.unitPath})` : "not installed",
63
+ })
64
+ const last = readLastTick()
65
+ const lastAge = last ? Date.now() - Date.parse(last.at) : null
66
+ const fresh = lastAge !== null && lastAge < TICK_STALE_MS
67
+ // A tick still holding its lock is alive: a long agent job can take 30
68
+ // minutes, and the station isn't dead while it runs.
69
+ const running = !fresh && tickRunning()
70
+ checks.push({
71
+ name: "last tick",
72
+ ok: !installed || fresh || running,
73
+ detail:
74
+ (last ? `${age(lastAge!)} (${last.via})${last.errors.length ? `, ${last.errors.length} problem${last.errors.length === 1 ? "" : "s"}: ${last.errors[0]}` : ""}` : "never") +
75
+ (running ? "; a tick is running now" : ""),
76
+ })
77
+ const now = new Date()
78
+ let active = 0
79
+ const failed: string[] = []
80
+ for (const s of listSources(cfg)) {
81
+ const exists = existsSync(s.path)
82
+ // The local folder appears on first use, so its absence is normal.
83
+ if (s.name !== "local" || exists) checks.push({ name: `source ${s.name}`, ok: exists, detail: exists ? `${s.path}${s.git ? ` (git, ${claimText(s)})` : ""}` : `${s.path} does not exist` })
84
+ if (!exists) continue
85
+ for (const j of loadJobs(s)) {
86
+ if (j.problems.length) {
87
+ failed.push(`${s.name}/${j.name} invalid`)
88
+ continue
89
+ }
90
+ const st = loadState(s, j.header.id!)
91
+ let next = "?"
92
+ try {
93
+ next = nextRunText(j.header, st, now)
94
+ } catch {
95
+ // validateJob already accepted it; leave "?".
96
+ }
97
+ if (next !== "expired" && next !== "done") active++
98
+ // A failure was paged when it happened; it stays news for a day, not
99
+ // until the job expires.
100
+ const recent = st.last_run ? now.getTime() - parseWhen(st.last_run).getTime() < RECENT_FAILURE_MS : st.last_status === "auth"
101
+ if (st.last_status && st.last_status !== "ok" && next !== "expired" && recent) failed.push(`${s.name}/${j.header.id} ${st.last_status}`)
102
+ }
103
+ }
104
+ checks.push({ name: "jobs", ok: failed.length === 0, detail: `${active} active${failed.length ? `; not ok in the last day: ${failed.slice(0, 3).join(", ")}` : ""}` })
105
+ const beacon = beaconFromConfig(cfg)
106
+ checks.push({ name: "digests", ok: beacon.channels.length > 0, detail: beacon.channels.length ? `via Beacon (${beacon.defaultChannel ?? "no default"})` : "Beacon isn't set up, so runs aren't reported" })
107
+ const hb = cfg.scheduler?.heartbeat
108
+ const lastHb = last?.heartbeat
109
+ checks.push({
110
+ name: "heartbeat",
111
+ ok: !hb || !lastHb || lastHb.ok,
112
+ detail: !hb
113
+ ? "not set: nothing outside this machine notices if it stops (spinuptui scheduler heartbeat set <kuma-push-url>)"
114
+ : !lastHb
115
+ ? `${new URL(hb.url).host}: first ping on the next dispatcher tick`
116
+ : lastHb.ok
117
+ ? `pinging ${new URL(hb.url).host} every tick (last: ${lastHb.status})`
118
+ : `last ping to ${new URL(hb.url).host} failed: ${lastHb.error}`,
119
+ })
120
+
121
+ if (!installed && !d) {
122
+ return {
123
+ id: "scheduler",
124
+ state: "not_configured",
125
+ summary: "Dispatcher not installed",
126
+ checks,
127
+ remedy: "spinuptui scheduler install --plan",
128
+ }
129
+ }
130
+ const failing = checks.find((c) => !c.ok)
131
+ return {
132
+ id: "scheduler",
133
+ state: failing ? "degraded" : "ok",
134
+ summary: failing ? `${failing.name}: ${failing.detail}` : `ticking (${checks[1]!.detail.split(" (")[0]}), ${active} active job${active === 1 ? "" : "s"}`,
135
+ checks,
136
+ ...(failing ? { remedy: failing.name === "dispatcher" || failing.name === "last tick" ? "spinuptui scheduler install --apply" : "spinuptui scheduler list" } : {}),
137
+ }
138
+ },
139
+ }
140
+
141
+ // The next few follow-ups due, soonest first, for the Modules screen.
142
+ export function upcomingRuns(limit = 5): { source: string; id: string; next: string }[] {
143
+ const now = new Date()
144
+ const out: { source: string; id: string; next: string }[] = []
145
+ for (const s of listSources(loadConfig())) {
146
+ if (!existsSync(s.path)) continue
147
+ for (const j of loadJobs(s)) {
148
+ if (j.problems.length) continue
149
+ try {
150
+ const next = nextRunText(j.header, loadState(s, j.header.id!), now)
151
+ if (next !== "expired" && next !== "done") out.push({ source: s.name, id: j.header.id!, next })
152
+ } catch {
153
+ // Skip a job whose schedule can't be read.
154
+ }
155
+ }
156
+ }
157
+ // Fixed times sort before "every N min", which has no single next time.
158
+ return out.sort((a, b) => (a.next.startsWith("every") ? 1 : 0) - (b.next.startsWith("every") ? 1 : 0) || a.next.localeCompare(b.next)).slice(0, limit)
159
+ }
package/src/ui/App.tsx CHANGED
@@ -19,6 +19,7 @@ import { SiteUpdate } from "./views/SiteUpdate.tsx"
19
19
  import { BulkWpUpdate } from "./views/BulkWpUpdate.tsx"
20
20
  import { HttpsToggle } from "./views/HttpsToggle.tsx"
21
21
  import { PurgeCache } from "./views/PurgeCache.tsx"
22
+ import { AccountSlugPrompt } from "./views/AccountSlugPrompt.tsx"
22
23
  import { SiteOffline } from "./views/SiteOffline.tsx"
23
24
  import { ReleaseNotes } from "./views/ReleaseNotes.tsx"
24
25
  import { GrantKey } from "./views/GrantKey.tsx"
@@ -43,6 +44,9 @@ import { KumaSite } from "./views/KumaSite.tsx"
43
44
  import { Accounts } from "./views/Accounts.tsx"
44
45
  import { Modules } from "./views/Modules.tsx"
45
46
  import { BeaconSetup } from "./views/BeaconSetup.tsx"
47
+ import { SchedulerInstall } from "./views/SchedulerInstall.tsx"
48
+ import { HomeSync } from "./views/HomeSync.tsx"
49
+ import { HeartbeatSetup } from "./views/HeartbeatSetup.tsx"
46
50
 
47
51
  const MIN_SPLASH_MS = 1200
48
52
 
@@ -92,7 +96,11 @@ export function App() {
92
96
  store.connectZoneTarget !== null ||
93
97
  store.dnsRecordsTarget !== null ||
94
98
  store.kumaSite !== null ||
95
- store.beaconSetupOpen
99
+ store.beaconSetupOpen ||
100
+ store.schedulerInstallOpen ||
101
+ store.homeSyncOpen ||
102
+ store.heartbeatSetupOpen ||
103
+ store.slugPrompt !== null
96
104
  useEffect(() => {
97
105
  store.setOverlayOpen(overlayActive)
98
106
  }, [overlayActive, store])
@@ -130,6 +138,9 @@ export function App() {
130
138
  // The Accounts overlay owns the keyboard while open.
131
139
  if (showAccounts) return
132
140
 
141
+ // The account-slug prompt (from `w`) owns the keyboard while open.
142
+ if (store.slugPrompt) return
143
+
133
144
  // The release-notes overlay owns the keyboard while open (any key dismisses).
134
145
  if (store.releaseNotesInfo) return
135
146
 
@@ -210,6 +221,9 @@ export function App() {
210
221
 
211
222
  // Beacon's guided setup (Modules tab) owns the keyboard while open.
212
223
  if (store.beaconSetupOpen) return
224
+ if (store.schedulerInstallOpen) return
225
+ if (store.homeSyncOpen) return
226
+ if (store.heartbeatSetupOpen) return
213
227
 
214
228
  // A (Shift+a): the Accounts overlay — the header advertises it from every tab.
215
229
  if (key.shift && key.name === "a") return setShowAccounts(true)
@@ -299,7 +313,11 @@ export function App() {
299
313
  {store.dnsRecordsTarget && <DnsRecords />}
300
314
  {store.kumaSite && <KumaSite />}
301
315
  {store.beaconSetupOpen && <BeaconSetup />}
316
+ {store.schedulerInstallOpen && <SchedulerInstall />}
317
+ {store.homeSyncOpen && <HomeSync />}
318
+ {store.heartbeatSetupOpen && <HeartbeatSetup />}
302
319
  {showAccounts && <Accounts onClose={() => setShowAccounts(false)} />}
320
+ {store.slugPrompt && <AccountSlugPrompt />}
303
321
  <ToastStack />
304
322
  </box>
305
323
  )
@@ -180,7 +180,13 @@ export function SiteDetail({ site, serverName }: { site: Site; serverName: strin
180
180
  <Field label="Stack" value={stack} valueColor={stackColor(stack)} />
181
181
  <Field
182
182
  label="Detected"
183
- value={probing ? "identifying…" : probe ? probe.result.label + (isProbeStale(site) ? " (stale)" : "") : "not identified · d"}
183
+ value={
184
+ probing
185
+ ? "identifying…"
186
+ : probe
187
+ ? `${probe.result.label}${isProbeStale(site) ? " (stale)" : ""} · ${timeAgo(new Date(probe.detectedAt).toISOString())}`
188
+ : "not identified · d"
189
+ }
184
190
  valueColor={probing ? theme.textDim : probe ? probeKindColor(probe.result.kind) : theme.textFaint}
185
191
  />
186
192
  <Field label="Type" value={isWordPress ? "WordPress" : "Generic"} valueColor={isWordPress ? theme.brand : theme.textDim} />
@@ -2,20 +2,25 @@
2
2
  //
3
3
  // Shown when no token is found in the environment or config file. Collects an
4
4
  // API token, validates it against the live API, persists it to ~/.config, then
5
- // hands control back to the app.
5
+ // asks (optionally) for the account slug that `w` deep links need — the API
6
+ // can't tell us — and hands control back to the app.
6
7
 
7
8
  import { useState } from "react"
8
9
  import { theme } from "../lib/theme.ts"
9
10
  import { Spinner, SecretInput } from "./components.tsx"
10
11
  import { SpinupWPClient } from "../api/client.ts"
11
12
  import { DEFAULT_BASE_URL, saveConfig, configPath } from "../config.ts"
13
+ import { parseAccountSlug } from "../lib/spinupweb.ts"
12
14
 
13
- type Phase = "input" | "validating" | "error"
15
+ type Phase = "input" | "validating" | "error" | "slug"
16
+
17
+ const INPUT_STYLE = { backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }
14
18
 
15
19
  export function Onboarding({ onComplete }: { onComplete: () => void }) {
16
20
  const [value, setValue] = useState("")
17
21
  const [phase, setPhase] = useState<Phase>("input")
18
22
  const [error, setError] = useState<string | null>(null)
23
+ const [slug, setSlug] = useState("")
19
24
 
20
25
  async function submit(token: string) {
21
26
  const trimmed = token.trim()
@@ -26,13 +31,76 @@ export function Onboarding({ onComplete }: { onComplete: () => void }) {
26
31
  const result = await client.validateToken()
27
32
  if (result.ok) {
28
33
  await saveConfig({ token: trimmed, baseUrl: DEFAULT_BASE_URL })
29
- onComplete()
34
+ setPhase("slug")
30
35
  } else {
31
36
  setError(result.reason)
32
37
  setPhase("error")
33
38
  }
34
39
  }
35
40
 
41
+ // Optional: empty skips (an existing slug, e.g. on `spinuptui login`, is kept).
42
+ async function submitSlug() {
43
+ if (!slug.trim()) return onComplete()
44
+ const parsed = parseAccountSlug(slug)
45
+ if (!parsed) {
46
+ setError("That doesn't look like a slug — paste the part after spinupwp.app/, or the whole URL.")
47
+ return
48
+ }
49
+ await saveConfig({ accountSlug: parsed })
50
+ onComplete()
51
+ }
52
+
53
+ if (phase === "slug") {
54
+ return (
55
+ <box
56
+ style={{
57
+ width: "100%",
58
+ height: "100%",
59
+ flexDirection: "column",
60
+ justifyContent: "center",
61
+ alignItems: "center",
62
+ backgroundColor: theme.bg,
63
+ }}
64
+ >
65
+ <ascii-font text="SPINUPTUI" font="block" color={[theme.brand, theme.accent]} />
66
+ <box style={{ height: 1 }} />
67
+ <box
68
+ title=" Almost done "
69
+ titleColor={theme.brand}
70
+ border
71
+ borderColor={theme.border}
72
+ style={{ flexDirection: "column", width: 70, paddingLeft: 2, paddingRight: 2, paddingTop: 1, paddingBottom: 1 }}
73
+ >
74
+ <text content="✓ Token works." fg={theme.good} />
75
+ <box style={{ height: 1 }} />
76
+ <text content="One more (optional): your SpinupWP account slug, the part" fg={theme.text} wrapMode="none" />
77
+ <text content="after spinupwp.app/ in your dashboard's address. With it, w" fg={theme.text} wrapMode="none" />
78
+ <text content="opens a site or server straight in SpinupWP." fg={theme.text} wrapMode="none" />
79
+ <text content="Pasting any dashboard URL works too." fg={theme.textDim} wrapMode="none" />
80
+ <box style={{ height: 1 }} />
81
+ <text content="Account slug" fg={theme.accent} />
82
+ <input
83
+ focused
84
+ value={slug}
85
+ placeholder="acme-agency"
86
+ onInput={(v: string) => {
87
+ setSlug(v)
88
+ setError(null)
89
+ }}
90
+ onSubmit={() => void submitSlug()}
91
+ style={INPUT_STYLE}
92
+ />
93
+ <box style={{ height: 1 }} />
94
+ {error ? (
95
+ <text content={"✕ " + error} fg={theme.bad} wrapMode="none" />
96
+ ) : (
97
+ <text content="Press Enter to finish. Leave it empty to skip; w will ask later." fg={theme.textFaint} wrapMode="none" />
98
+ )}
99
+ </box>
100
+ </box>
101
+ )
102
+ }
103
+
36
104
  return (
37
105
  <box
38
106
  style={{
@@ -0,0 +1,42 @@
1
+ // The dashboard: boots the OpenTUI renderer and mounts either the first-run
2
+ // onboarding wizard (no token yet) or the main app wrapped in the data store.
3
+ // Loaded by src/index.tsx with a dynamic import, only once no headless command
4
+ // has claimed the arguments.
5
+
6
+ import { useCallback, useMemo, useState } from "react"
7
+ import { createCliRenderer } from "@opentui/core"
8
+ import { createRoot } from "@opentui/react"
9
+ import { hasToken, pinActiveProfile, setActiveProfile } from "../config.ts"
10
+ import { StoreProvider } from "./store.tsx"
11
+ import { App } from "./App.tsx"
12
+ import { AccountsContext, type AccountsApi } from "./accounts.tsx"
13
+ import { resetWpCoreJobs } from "./wpCoreJobs.tsx"
14
+ import { Onboarding } from "./Onboarding.tsx"
15
+ import { isDevMode } from "../dev/devMode.ts"
16
+
17
+ export async function startDashboard({ forceLogin }: { forceLogin: boolean }): Promise<void> {
18
+ function Root() {
19
+ const [configured, setConfigured] = useState(isDevMode() ? true : forceLogin ? false : hasToken())
20
+ // The store is keyed by account: switching remounts it (see ui/accounts.tsx).
21
+ const [account, setAccount] = useState(() => pinActiveProfile())
22
+ const switchAccount = useCallback(async (id: string) => {
23
+ await setActiveProfile(id)
24
+ resetWpCoreJobs()
25
+ setAccount(id)
26
+ }, [])
27
+ const accounts = useMemo<AccountsApi>(() => ({ switchAccount }), [switchAccount])
28
+ if (!configured) {
29
+ return <Onboarding onComplete={() => setConfigured(true)} />
30
+ }
31
+ return (
32
+ <AccountsContext.Provider value={accounts}>
33
+ <StoreProvider key={account}>
34
+ <App />
35
+ </StoreProvider>
36
+ </AccountsContext.Provider>
37
+ )
38
+ }
39
+
40
+ const renderer = await createCliRenderer()
41
+ createRoot(renderer).render(<Root />)
42
+ }