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/stackCache.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// - set() is called after a successful `d` probe; it upserts the entry and
|
|
6
6
|
// writes the whole file back immediately (write-through), so a probe
|
|
7
7
|
// survives even if the TUI is killed with q / Ctrl+C.
|
|
8
|
-
// Probing
|
|
8
|
+
// Probing is on-demand and per-site, with one exception: a WP version older than
|
|
9
|
+
// VERSION_MAX_AGE_MS is re-probed in the background. This cache exists to avoid
|
|
9
10
|
// repeating the slow SSH round-trips across launches, not to save RAM. It's a
|
|
10
11
|
// deliberately small, reusable pattern for other expensive derived data later.
|
|
11
12
|
|
|
@@ -21,6 +22,10 @@ export interface CachedProbe {
|
|
|
21
22
|
result: ProbeResult
|
|
22
23
|
detectedAt: number // epoch ms
|
|
23
24
|
signature: string // invalidation key (see siteSignature)
|
|
25
|
+
// Last automatic re-check of an old version (epoch ms), kept even when it
|
|
26
|
+
// failed, so the once-per-window limit holds across launches. A successful
|
|
27
|
+
// probe replaces the whole entry, which clears it.
|
|
28
|
+
recheckedAt?: number
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
interface CacheFile {
|
|
@@ -32,6 +37,25 @@ export function stackCachePath(dir = profileDataDir()): string {
|
|
|
32
37
|
return join(dir, "stack-cache.json")
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
// A WordPress version is a fact about the server that changes without us: auto-
|
|
41
|
+
// updates, wp-admin and deploys all move it, and none of them touch the site's
|
|
42
|
+
// shape below. So a reading that carries a version is re-checked once it's older
|
|
43
|
+
// than this (in the background, when the site list loads), and its age is shown.
|
|
44
|
+
export const VERSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
|
45
|
+
|
|
46
|
+
// True when the entry carries a WP version read longer ago than the max age.
|
|
47
|
+
// Identities without a version (WHMCS, Laravel, static) don't drift this way.
|
|
48
|
+
export function isVersionOld(entry: CachedProbe | undefined, now = Date.now()): boolean {
|
|
49
|
+
return entry?.result.version != null && now - entry.detectedAt > VERSION_MAX_AGE_MS
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// True when an old version is due an automatic re-check: none was attempted in
|
|
53
|
+
// the current window. Unattended SSH that fails must not repeat on every launch.
|
|
54
|
+
export function isRecheckDue(entry: CachedProbe | undefined, now = Date.now()): boolean {
|
|
55
|
+
if (!entry || !isVersionOld(entry, now)) return false
|
|
56
|
+
return entry.recheckedAt == null || now - entry.recheckedAt > VERSION_MAX_AGE_MS
|
|
57
|
+
}
|
|
58
|
+
|
|
35
59
|
// A cached probe is considered stale when the site's relevant shape changes.
|
|
36
60
|
// Keying on is_wordpress + public_folder catches the cases that would alter the
|
|
37
61
|
// probe's target paths or expected result.
|
|
@@ -83,6 +107,16 @@ export class StackCache {
|
|
|
83
107
|
await this.persist()
|
|
84
108
|
}
|
|
85
109
|
|
|
110
|
+
// Record an automatic re-check attempt on existing entries, in one write.
|
|
111
|
+
async noteRecheck(siteIds: number[]): Promise<void> {
|
|
112
|
+
const now = Date.now()
|
|
113
|
+
for (const id of siteIds) {
|
|
114
|
+
const e = this.entries.get(id)
|
|
115
|
+
if (e) this.entries.set(id, { ...e, recheckedAt: now })
|
|
116
|
+
}
|
|
117
|
+
await this.persist()
|
|
118
|
+
}
|
|
119
|
+
|
|
86
120
|
// A copy of all entries (for rendering aggregates without exposing the map).
|
|
87
121
|
snapshot(): Map<number, CachedProbe> {
|
|
88
122
|
return new Map(this.entries)
|
package/src/modules/beacon.ts
CHANGED
|
@@ -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())
|
package/src/modules/contract.ts
CHANGED
|
@@ -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">
|
package/src/modules/index.ts
CHANGED
|
@@ -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
|
)
|
package/src/ui/Details.tsx
CHANGED
|
@@ -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={
|
|
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} />
|
package/src/ui/Onboarding.tsx
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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={{
|
package/src/ui/main.tsx
ADDED
|
@@ -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
|
+
}
|