spinuptui 0.19.0

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 (88) hide show
  1. package/CHANGELOG.md +943 -0
  2. package/LICENSE +21 -0
  3. package/README.md +856 -0
  4. package/bin/spinuptui.js +13 -0
  5. package/package.json +55 -0
  6. package/src/api/client.ts +354 -0
  7. package/src/api/types.ts +206 -0
  8. package/src/config.ts +309 -0
  9. package/src/dev/devMode.ts +7 -0
  10. package/src/dev/fixtures.ts +238 -0
  11. package/src/dev/mockClient.ts +214 -0
  12. package/src/index.tsx +68 -0
  13. package/src/lib/appUpdate.ts +206 -0
  14. package/src/lib/awsSigV4.ts +133 -0
  15. package/src/lib/cloneDomains.ts +112 -0
  16. package/src/lib/cloneLog.ts +85 -0
  17. package/src/lib/dbBackup.ts +198 -0
  18. package/src/lib/dbSync.ts +253 -0
  19. package/src/lib/discover.ts +165 -0
  20. package/src/lib/dns.ts +144 -0
  21. package/src/lib/dnsCache.ts +94 -0
  22. package/src/lib/dnsQuery.ts +180 -0
  23. package/src/lib/dnsRecords.ts +411 -0
  24. package/src/lib/format.ts +106 -0
  25. package/src/lib/gitDeployKey.ts +0 -0
  26. package/src/lib/gitStatus.ts +37 -0
  27. package/src/lib/jobs.ts +32 -0
  28. package/src/lib/keychain.ts +49 -0
  29. package/src/lib/local.ts +116 -0
  30. package/src/lib/mediaFallback.ts +288 -0
  31. package/src/lib/open.ts +103 -0
  32. package/src/lib/phpEol.ts +151 -0
  33. package/src/lib/probe.ts +183 -0
  34. package/src/lib/providers.ts +284 -0
  35. package/src/lib/providersCache.ts +105 -0
  36. package/src/lib/releaseNotes.ts +67 -0
  37. package/src/lib/serverClone.ts +782 -0
  38. package/src/lib/serverCreate.ts +161 -0
  39. package/src/lib/siteDoctor.ts +260 -0
  40. package/src/lib/siteFingerprint.ts +108 -0
  41. package/src/lib/spinupweb.ts +33 -0
  42. package/src/lib/ssh.ts +669 -0
  43. package/src/lib/stack.ts +78 -0
  44. package/src/lib/stackCache.ts +113 -0
  45. package/src/lib/theme.ts +54 -0
  46. package/src/lib/ubuntuEol.ts +124 -0
  47. package/src/lib/uptimeKuma.ts +551 -0
  48. package/src/lib/vanitySite.ts +297 -0
  49. package/src/lib/wpInventory.ts +153 -0
  50. package/src/ui/App.tsx +265 -0
  51. package/src/ui/Details.tsx +403 -0
  52. package/src/ui/Explain.tsx +158 -0
  53. package/src/ui/Header.tsx +117 -0
  54. package/src/ui/Help.tsx +296 -0
  55. package/src/ui/List.tsx +95 -0
  56. package/src/ui/Onboarding.tsx +93 -0
  57. package/src/ui/Splash.tsx +51 -0
  58. package/src/ui/StatusBar.tsx +60 -0
  59. package/src/ui/components.tsx +392 -0
  60. package/src/ui/store.tsx +4253 -0
  61. package/src/ui/views/Browser.tsx +365 -0
  62. package/src/ui/views/CloneWizard.tsx +932 -0
  63. package/src/ui/views/Dashboard.tsx +149 -0
  64. package/src/ui/views/DbBackup.tsx +228 -0
  65. package/src/ui/views/DbSync.tsx +307 -0
  66. package/src/ui/views/Discover.tsx +271 -0
  67. package/src/ui/views/DnsInventory.tsx +580 -0
  68. package/src/ui/views/DnsRecords.tsx +562 -0
  69. package/src/ui/views/Events.tsx +98 -0
  70. package/src/ui/views/Forgotten.tsx +165 -0
  71. package/src/ui/views/GrantKey.tsx +504 -0
  72. package/src/ui/views/Health.tsx +322 -0
  73. package/src/ui/views/HttpsToggle.tsx +195 -0
  74. package/src/ui/views/KumaSite.tsx +571 -0
  75. package/src/ui/views/LocalLink.tsx +220 -0
  76. package/src/ui/views/MediaFallback.tsx +180 -0
  77. package/src/ui/views/NewServer.tsx +750 -0
  78. package/src/ui/views/PhpUpgrade.tsx +301 -0
  79. package/src/ui/views/ProviderConnect.tsx +527 -0
  80. package/src/ui/views/PurgeCache.tsx +186 -0
  81. package/src/ui/views/ReleaseNotes.tsx +84 -0
  82. package/src/ui/views/Search.tsx +448 -0
  83. package/src/ui/views/ServerActions.tsx +328 -0
  84. package/src/ui/views/Stacks.tsx +371 -0
  85. package/src/ui/views/SudoConnect.tsx +358 -0
  86. package/src/ui/views/VanityNewSite.tsx +407 -0
  87. package/src/ui/views/WpInventory.tsx +218 -0
  88. package/src/version.ts +8 -0
@@ -0,0 +1,158 @@
1
+ // "Explain this screen" overlay — contextual, in-context guidance (Pass 2).
2
+ //
3
+ // Distinct from the `?` keybinding reference: this is scoped to the CURRENT view
4
+ // and speaks in outcomes — what each pane is for and what each action actually
5
+ // accomplishes *here*. Opened with `i` from any view. Because it's per-route,
6
+ // every new (often novel) workflow ships with its own plain-language explanation
7
+ // rather than relying on the user to translate a flat key-list back to the screen.
8
+
9
+ import { theme } from "../lib/theme.ts"
10
+ import type { Route } from "./store.tsx"
11
+
12
+ interface Guide {
13
+ title: string
14
+ summary: string
15
+ panes?: [string, string][]
16
+ actions: [string, string][]
17
+ }
18
+
19
+ const GUIDES: Record<Route, Guide> = {
20
+ dashboard: {
21
+ title: "Dashboard",
22
+ summary: "A high-level read on your whole account — fleet health, what needs attention, and recent activity.",
23
+ panes: [
24
+ ["Stat cards", "Totals: servers, sites, fleet disk, pending WordPress updates"],
25
+ ["Disk usage", "Servers ranked by how full their disk is"],
26
+ ["Needs attention", "Servers/sites with problems (offline, reboot or upgrade due)"],
27
+ ["Recent activity", "The latest deploys and operations"],
28
+ ],
29
+ actions: [
30
+ ["1–5", "Switch tabs"],
31
+ ["r", "Refresh from the API"],
32
+ ],
33
+ },
34
+ servers: {
35
+ title: "Servers",
36
+ summary: "Browse every server and drill into the sites it hosts.",
37
+ panes: [
38
+ ["Servers", "All your servers"],
39
+ ["Sites", "Sites on the selected server"],
40
+ ["Details", "Everything about the highlighted server or site"],
41
+ ],
42
+ actions: [
43
+ ["→ / ⏎", "Drill in (server → its sites)"],
44
+ ["h", "Server health over SSH (CPU / memory / disk)"],
45
+ ["a", "Server actions: reboot or restart a service"],
46
+ ["d", "Identify the app running on a site (SSH)"],
47
+ ["u", "Change a site's PHP version"],
48
+ ["o / w", "Open the site · open it in the SpinupWP web app"],
49
+ ["s", "Open a terminal and SSH into the site"],
50
+ ["t / v / L", "Local copy: open a terminal · open the local URL · link or edit"],
51
+ ],
52
+ },
53
+ stacks: {
54
+ title: "Stacks",
55
+ summary: "Your fleet grouped by app type (Standard WP / Bedrock / Non-WP), with a fleet-wide PHP version breakdown.",
56
+ panes: [
57
+ ["Groups", "App-type buckets; Non-WP expands into WHMCS / Laravel / …"],
58
+ ["Sites", "The sites in the selected group"],
59
+ ["PHP versions", "How many sites run each PHP version (EOL flagged)"],
60
+ ],
61
+ actions: [
62
+ ["d", "Identify the app on a site (SSH)"],
63
+ ["D", "Identify every app in the selected group"],
64
+ ["S", "Find local copies on your disk and link them"],
65
+ ["f", "Report sites with no local copy (filter by stack with ←/→)"],
66
+ ["u", "Change a site's PHP version"],
67
+ ["t / v / L", "Local copy: terminal · local URL · link or edit"],
68
+ ["o / w", "Open the site · open it in SpinupWP"],
69
+ ["s", "Open a terminal and SSH into the site"],
70
+ ],
71
+ },
72
+ search: {
73
+ title: "Search",
74
+ summary: "Jump to any server or site across your whole account by typing its name, domain, or IP.",
75
+ panes: [
76
+ ["Results", "Matches, narrowed as you type"],
77
+ ["Details / Actions", "Info on the selection, or its action menu when focused"],
78
+ ],
79
+ actions: [
80
+ ["type", "Filter results"],
81
+ ["Tab / →", "Hand focus from the box to the result's actions"],
82
+ ["o / w / u", "Open · open in SpinupWP · change PHP"],
83
+ ["s", "SSH into the site (opens a terminal)"],
84
+ ["t / v / L", "Local copy: terminal · local URL · link or edit"],
85
+ ["d / p / m", "DB backup · pull prod→local · production media fallback"],
86
+ ["a / h", "Server actions · server health"],
87
+ ],
88
+ },
89
+ events: {
90
+ title: "Events",
91
+ summary: "A live feed of deploys, reboots, and operations across your account.",
92
+ actions: [
93
+ ["1–5", "Switch tabs"],
94
+ ["r", "Refresh"],
95
+ ],
96
+ },
97
+ }
98
+
99
+ export function ExplainOverlay({ route }: { route: Route }) {
100
+ const g = GUIDES[route]
101
+ return (
102
+ <box
103
+ style={{
104
+ position: "absolute",
105
+ top: 0,
106
+ left: 0,
107
+ width: "100%",
108
+ height: "100%",
109
+ justifyContent: "center",
110
+ alignItems: "center",
111
+ zIndex: 120,
112
+ }}
113
+ >
114
+ <box
115
+ title={` ${g.title} — what's on this screen `}
116
+ titleColor={theme.brand}
117
+ bottomTitle=" press i or Esc to close "
118
+ bottomTitleAlignment="center"
119
+ border
120
+ borderColor={theme.borderActive}
121
+ backgroundColor={theme.bgPanel}
122
+ style={{ flexDirection: "column", width: 72, paddingLeft: 2, paddingRight: 2, paddingTop: 1, paddingBottom: 1 }}
123
+ >
124
+ <text content={g.summary} fg={theme.text} />
125
+ <box style={{ height: 1 }} />
126
+
127
+ {g.panes && (
128
+ <box style={{ flexDirection: "column" }}>
129
+ <text content="On screen" fg={theme.accent} attributes={1} />
130
+ {g.panes.map(([label, desc]) => (
131
+ <box key={label} style={{ flexDirection: "row" }}>
132
+ <text content={label.padEnd(16)} fg={theme.brand} wrapMode="none" style={{ flexShrink: 0 }} />
133
+ <text content={desc} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
134
+ </box>
135
+ ))}
136
+ <box style={{ height: 1 }} />
137
+ </box>
138
+ )}
139
+
140
+ <text content="What you can do" fg={theme.accent} attributes={1} />
141
+ {g.actions.map(([k, desc]) => (
142
+ <box key={k} style={{ flexDirection: "row" }}>
143
+ <text content={k.padEnd(11)} fg={theme.brand} wrapMode="none" style={{ flexShrink: 0 }} />
144
+ <text content={desc} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
145
+ </box>
146
+ ))}
147
+ <box style={{ height: 1 }} />
148
+
149
+ <box style={{ flexDirection: "row" }}>
150
+ <text content="◆ " fg={theme.good} style={{ flexShrink: 0 }} />
151
+ <text content="linked locally " fg={theme.textDim} style={{ flexShrink: 0 }} />
152
+ <text content="↑N " fg={theme.warn} style={{ flexShrink: 0 }} />
153
+ <text content="pending WordPress updates" fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
154
+ </box>
155
+ </box>
156
+ </box>
157
+ )
158
+ }
@@ -0,0 +1,117 @@
1
+ // Top navigation bar: brand mark, tab strip, and live account summary.
2
+
3
+ import { theme } from "../lib/theme.ts"
4
+ import { useStore, isNewServerInFlight, isVanityInFlight, isCloneInFlight, type Route } from "./store.tsx"
5
+ import { timeAgo, truncate } from "../lib/format.ts"
6
+ import { Spinner } from "./components.tsx"
7
+ import { APP_NAME, APP_VERSION } from "../version.ts"
8
+ import { isDevMode } from "../dev/devMode.ts"
9
+
10
+ const TABS: { route: Route; key: string; label: string }[] = [
11
+ { route: "dashboard", key: "1", label: "Dashboard" },
12
+ { route: "servers", key: "2", label: "Servers" },
13
+ { route: "stacks", key: "3", label: "Stacks" },
14
+ { route: "search", key: "4", label: "Search" },
15
+ { route: "events", key: "5", label: "Events" },
16
+ ]
17
+
18
+ // A one-line "what is this view, and what can I do here" subtitle per tab —
19
+ // the cheapest in-context teaching surface. Phrased in outcomes, naming the few
20
+ // actions a user would reach for. Shown directly under the nav strip.
21
+ const SUBTITLES: Record<Route, string> = {
22
+ dashboard: "Your account at a glance — fleet health, what needs attention, recent activity",
23
+ servers: "Browse servers and their sites · h server health · u change PHP · L link a local copy",
24
+ stacks: "Your fleet grouped by app type · d identify an app (SSH) · S find local copies on your disk",
25
+ search: "Jump to any server or site by name · Tab hands focus to the result's actions",
26
+ events: "Recent deploys, reboots, and operations across your account",
27
+ }
28
+
29
+ export function Header() {
30
+ const { route, servers, sites, loading, lastUpdated, updateInfo, newServerJob, vanityJob, cloneJob, cloneServer, kumaStatus } = useStore()
31
+ const kumaDown = [...kumaStatus.values()].filter((s) => s.up === false).length
32
+ const updateReady = updateInfo?.updateAvailable ?? false
33
+ const building = isNewServerInFlight(newServerJob)
34
+ // The sshkey step is the one that waits on the USER (add your key, confirm in
35
+ // the overlay) — a spinner there reads as "working" when nothing is running,
36
+ // so it gets a distinct waiting badge that says what it wants and where.
37
+ const vanityWaiting = vanityJob?.step === "sshkey"
38
+ const connecting = isVanityInFlight(vanityJob) && !vanityWaiting
39
+ const vanityStuck = vanityJob != null && vanityJob.step === "error"
40
+ // Show a clone badge only when it's running AND backgrounded (wizard closed) — the
41
+ // open wizard covers the screen, so no badge is needed there.
42
+ const cloning = isCloneInFlight(cloneJob) && cloneServer == null
43
+
44
+ return (
45
+ <box style={{ flexDirection: "column", flexShrink: 0 }}>
46
+ <box
47
+ style={{
48
+ flexDirection: "row",
49
+ height: 1,
50
+ backgroundColor: theme.bgAlt,
51
+ paddingLeft: 1,
52
+ paddingRight: 1,
53
+ alignItems: "center",
54
+ }}
55
+ >
56
+ <text content={`◆ ${APP_NAME}`} fg={theme.brand} style={{ flexShrink: 0 }} />
57
+ <text content={` v${APP_VERSION}`} fg={theme.textFaint} style={{ flexShrink: 0 }} />
58
+ {updateReady && <text content={` ✦ v${updateInfo!.latest}`} fg={theme.update} attributes={1} style={{ flexShrink: 0 }} />}
59
+ {isDevMode() && <text content=" DEV MODE " fg={theme.bg} bg={theme.purple} attributes={1} style={{ flexShrink: 0 }} />}
60
+ <box style={{ width: 2, flexShrink: 0 }} />
61
+ {TABS.map((tab) => {
62
+ const active = tab.route === route
63
+ return (
64
+ <box key={tab.route} style={{ flexDirection: "row", flexShrink: 0 }}>
65
+ <text content={` ${tab.key} `} fg={active ? theme.bg : theme.textFaint} bg={active ? theme.brand : undefined} />
66
+ <text
67
+ content={`${tab.label} `}
68
+ fg={active ? theme.text : theme.textDim}
69
+ bg={active ? theme.bgPanel : undefined}
70
+ />
71
+ </box>
72
+ )
73
+ })}
74
+ <box style={{ flexGrow: 1 }} />
75
+ {/* A server provision runs for ~10 min in the background; surface it from
76
+ any tab so it isn't invisible once the New Server overlay is closed
77
+ (press c on the Servers tab to reopen the live tracker). */}
78
+ {building && (
79
+ <box style={{ flexDirection: "row", flexShrink: 0, alignItems: "center" }}>
80
+ <Spinner interval={120} color={theme.warn} />
81
+ <text content={` Provisioning ${truncate(newServerJob!.hostname, 22)} `} fg={theme.warn} wrapMode="none" />
82
+ </box>
83
+ )}
84
+ {/* Vanity build runs in the background too — surface it so it's reachable
85
+ after the site is created (press V on its server to reopen). A stuck
86
+ (errored) build is flagged too so it isn't lost. */}
87
+ {connecting && (
88
+ <box style={{ flexDirection: "row", flexShrink: 0, alignItems: "center" }}>
89
+ <Spinner interval={120} color={theme.warn} />
90
+ <text content={` Connecting ${truncate(vanityJob!.hostname, 22)} — press V `} fg={theme.warn} wrapMode="none" />
91
+ </box>
92
+ )}
93
+ {vanityWaiting && (
94
+ <text content={` ○ ${truncate(vanityJob!.hostname, 20)} needs your SSH key — press V `} fg={theme.warn} style={{ flexShrink: 0 }} wrapMode="none" />
95
+ )}
96
+ {vanityStuck && <text content={` ⚠ ${truncate(vanityJob!.hostname, 20)} — press V `} fg={theme.bad} style={{ flexShrink: 0 }} wrapMode="none" />}
97
+ {/* A backgrounded clone keeps running in the store — surface it so it's
98
+ reachable (press C on the source server to reopen the live roster). */}
99
+ {cloning && (
100
+ <box style={{ flexDirection: "row", flexShrink: 0, alignItems: "center" }}>
101
+ <Spinner interval={120} color={theme.warn} />
102
+ <text content={` Cloning ${truncate(cloneJob!.sourceServerName, 20)} — press C `} fg={theme.warn} wrapMode="none" />
103
+ </box>
104
+ )}
105
+ {/* Uptime Kuma: only bad news earns header space — silence when all up. */}
106
+ {kumaDown > 0 && <text content={` ▼ ${kumaDown} monitor${kumaDown === 1 ? "" : "s"} down `} fg={theme.bad} style={{ flexShrink: 0 }} wrapMode="none" />}
107
+ {loading && <Spinner interval={100} />}
108
+ <text content={` ${servers.length} servers · ${sites.length} sites `} fg={theme.textDim} style={{ flexShrink: 0 }} />
109
+ <text content={lastUpdated ? `updated ${timeAgo(lastUpdated.toISOString())}` : "loading…"} fg={theme.textFaint} style={{ flexShrink: 0 }} />
110
+ </box>
111
+ <box style={{ flexDirection: "row", height: 1, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
112
+ <text content="💡 " fg={theme.accent} style={{ flexShrink: 0 }} />
113
+ <text content={SUBTITLES[route]} fg={theme.textDim} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
114
+ </box>
115
+ </box>
116
+ )
117
+ }
@@ -0,0 +1,296 @@
1
+ // Help overlay: an "About SpinupTUI" column plus the keybindings, laid out in
2
+ // responsive columns so it stays short on wide terminals and stacks on narrow
3
+ // ones. Rendered on top via zIndex. Opened/closed with `?`.
4
+
5
+ import { useState, type ReactNode } from "react"
6
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react"
7
+ import { theme } from "../lib/theme.ts"
8
+ import { APP_NAME, APP_VERSION, REPO_URL } from "../version.ts"
9
+ import { Sparkle, Spinner } from "./components.tsx"
10
+ import { useStore } from "./store.tsx"
11
+ import { runSelfUpdate, installChannel, PACKAGE_UPDATE_CMD, type UpdateInfo, type SelfUpdateResult } from "../lib/appUpdate.ts"
12
+
13
+ type Section = { title: string; keys: [string, string][] }
14
+
15
+ const GLOBAL: Section = {
16
+ title: "Global",
17
+ keys: [
18
+ ["1 … 5", "Switch tabs"],
19
+ ["r", "Refresh from the API"],
20
+ ["/", "Global search"],
21
+ ["i", "Explain the current screen"],
22
+ ["?", "This help"],
23
+ ["q · Ctrl+C", "Quit · force quit"],
24
+ ],
25
+ }
26
+
27
+ const NAV: Section = {
28
+ title: "Navigate & act",
29
+ keys: [
30
+ ["↑/↓ j/k", "Move selection"],
31
+ ["Enter / →", "Drill in"],
32
+ ["← / Esc", "Back / collapse"],
33
+ ["Tab", "Switch column focus"],
34
+ ["g / G", "Jump to top / bottom"],
35
+ ["o", "Open the site's URL"],
36
+ ["s", "SSH into the site"],
37
+ ["h", "Server health (SSH)"],
38
+ ["w", "Open in SpinupWP web app"],
39
+ ["a", "Server actions (reboot/restart)"],
40
+ ["c", "Create a new server"],
41
+ ["C", "Clone this server to a new one"],
42
+ ["S", "Connect sudo on the server"],
43
+ ["u", "Change PHP version"],
44
+ ["H", "Enable/disable HTTPS"],
45
+ ["P", "Purge page + object cache"],
46
+ ["K", "Grant SpinupTUI's SSH key (sudo)"],
47
+ ["d", "Identify a site's app (SSH)"],
48
+ ],
49
+ }
50
+
51
+ const LOCAL: Section = {
52
+ title: "Local working copy",
53
+ keys: [
54
+ ["L", "Link / edit local copy"],
55
+ ["t", "Terminal at the local copy"],
56
+ ["v", "Open the local URL"],
57
+ ["S", "Scan & batch-link (Stacks)"],
58
+ ["f", "Sites needing a local copy"],
59
+ ],
60
+ }
61
+
62
+ const DBMEDIA: Section = {
63
+ title: "Database & media (local)",
64
+ keys: [
65
+ ["d", "Download prod DB backup (Search)"],
66
+ ["p", "Pull prod DB → local (opt-in)"],
67
+ ["m", "Production media fallback"],
68
+ ],
69
+ }
70
+
71
+ const DNS: Section = {
72
+ title: "DNS hosts",
73
+ keys: [
74
+ ["n / N", "Site / server DNS inventory"],
75
+ ["Enter", "Edit a record's TTL"],
76
+ ["c", "Connect provider / open console"],
77
+ ["✓ ↗ ○ ·", "editable · web · needs key · ?"],
78
+ ],
79
+ }
80
+
81
+ const MARKERS: Section = {
82
+ title: "Row markers",
83
+ keys: [
84
+ ["◆", "Local copy linked"],
85
+ ["↑N", "Pending WP updates"],
86
+ ["⇣ / ⬇", "Sync / backup running"],
87
+ ],
88
+ }
89
+
90
+ const SEARCHTAB: Section = {
91
+ title: "Search tab",
92
+ keys: [
93
+ ["Tab / →", "Box → result actions"],
94
+ ["← / Esc", "Back to the box"],
95
+ ],
96
+ }
97
+
98
+ // Column groupings: two balanced columns on wide screens, one column otherwise.
99
+ const TWO_COLS: Section[][] = [
100
+ [GLOBAL, NAV, SEARCHTAB],
101
+ [LOCAL, DBMEDIA, DNS, MARKERS],
102
+ ]
103
+ const ONE_COL: Section[][] = [[GLOBAL, NAV, LOCAL, DBMEDIA, DNS, MARKERS, SEARCHTAB]]
104
+
105
+ const ABOUT_W = 34
106
+ const SC_W_MIN = 34 // never narrower than this; below it, fall back to one column
107
+
108
+ function KeyRow({ k, desc }: { k: string; desc: string }) {
109
+ return (
110
+ <box style={{ flexDirection: "row" }}>
111
+ <box style={{ width: 13, flexShrink: 0 }}>
112
+ <text content={k} fg={theme.brand} wrapMode="none" />
113
+ </box>
114
+ <text content={desc} fg={theme.text} style={{ flexGrow: 1, flexShrink: 1 }} />
115
+ </box>
116
+ )
117
+ }
118
+
119
+ function SectionBlock({ section }: { section: Section }) {
120
+ return (
121
+ <box style={{ flexDirection: "column" }}>
122
+ <text content={section.title} fg={theme.accent} attributes={1} />
123
+ {section.keys.map(([k, desc]) => (
124
+ <KeyRow key={k} k={k} desc={desc} />
125
+ ))}
126
+ <box style={{ height: 1 }} />
127
+ </box>
128
+ )
129
+ }
130
+
131
+ function ShortcutColumn({ sections, width }: { sections: Section[]; width: number }) {
132
+ return (
133
+ <box style={{ flexDirection: "column", width, flexShrink: 0 }}>
134
+ {sections.map((s) => (
135
+ <SectionBlock key={s.title} section={s} />
136
+ ))}
137
+ </box>
138
+ )
139
+ }
140
+
141
+ type SelfUpdateState = "idle" | "running" | SelfUpdateResult
142
+
143
+ function AboutColumn({
144
+ width,
145
+ updateInfo,
146
+ selfUpdate,
147
+ }: {
148
+ width: number
149
+ updateInfo: UpdateInfo | null
150
+ selfUpdate: SelfUpdateState
151
+ }) {
152
+ const line = (content: string, fg: string = theme.textDim): ReactNode => (
153
+ <text content={content} fg={fg} wrapMode="none" />
154
+ )
155
+ return (
156
+ <box style={{ flexDirection: "column", width, flexShrink: 0 }}>
157
+ <box style={{ flexDirection: "row" }}>
158
+ <text content={`◆ ${APP_NAME}`} fg={theme.brand} attributes={1} />
159
+ <text content={` v${APP_VERSION}`} fg={theme.text} />
160
+ </box>
161
+ <box style={{ flexDirection: "row" }}>
162
+ <text content="n " fg={theme.brand} />
163
+ <text content={`what's new in v${APP_VERSION}`} fg={theme.textFaint} wrapMode="none" />
164
+ </box>
165
+ <box style={{ height: 1 }} />
166
+ {line("A terminal control center for")}
167
+ {line("your SpinupWP account.")}
168
+ <box style={{ height: 1 }} />
169
+ <text content="UPDATING" fg={theme.accent} attributes={1} />
170
+ {updateInfo?.updateAvailable ? (
171
+ <box style={{ flexDirection: "row" }}>
172
+ <Sparkle color={theme.update} />
173
+ <text content={` v${updateInfo.latest} available`} fg={theme.update} attributes={1} wrapMode="none" />
174
+ </box>
175
+ ) : updateInfo ? (
176
+ line("You're on the latest.", theme.textFaint)
177
+ ) : null}
178
+ {updateInfo?.updateAvailable && selfUpdate === "idle" && installChannel() === "git" ? (
179
+ <box style={{ flexDirection: "row" }}>
180
+ <text content="u " fg={theme.brand} attributes={1} />
181
+ <text content="update now (git pull)" fg={theme.textDim} wrapMode="none" />
182
+ </box>
183
+ ) : null}
184
+ {selfUpdate === "running" ? (
185
+ <box style={{ flexDirection: "row" }}>
186
+ <Spinner color={theme.brand} interval={120} />
187
+ <text content=" pulling…" fg={theme.textDim} wrapMode="none" />
188
+ </box>
189
+ ) : null}
190
+ {selfUpdate !== "idle" && selfUpdate !== "running" ? (
191
+ <text content={selfUpdate.message} fg={selfUpdate.ok ? theme.good : theme.bad} />
192
+ ) : null}
193
+ {installChannel() === "package" ? (
194
+ <>
195
+ {line("Manual:")}
196
+ {line(PACKAGE_UPDATE_CMD, theme.text)}
197
+ </>
198
+ ) : (
199
+ <>
200
+ {line("Manual: git pull in your")}
201
+ {line("checkout — spinuptui picks")}
202
+ {line("it up immediately.")}
203
+ </>
204
+ )}
205
+ <box style={{ height: 1 }} />
206
+ <box style={{ flexDirection: "row" }}>
207
+ <text content="check " fg={theme.textFaint} />
208
+ <text content="spinuptui --version" fg={theme.text} wrapMode="none" />
209
+ </box>
210
+ <box style={{ height: 1 }} />
211
+ {line(REPO_URL.replace(/^https:\/\//, ""), theme.textFaint)}
212
+ {line("Built with OpenTUI", theme.textFaint)}
213
+ </box>
214
+ )
215
+ }
216
+
217
+ export function HelpOverlay({ onClose }: { onClose: () => void }) {
218
+ const { width } = useTerminalDimensions()
219
+ const { updateInfo, showReleaseNotes } = useStore()
220
+ const aboutLeft = width >= 96
221
+ const twoShortcutCols = width >= 118
222
+
223
+ const [selfUpdate, setSelfUpdate] = useState<SelfUpdateState>("idle")
224
+ useKeyboard((key) => {
225
+ if (key.name === "n") {
226
+ // Close Help first — ReleaseNotes owns its own keyboard once open, and
227
+ // leaving Help mounted underneath would double-handle every keypress.
228
+ onClose()
229
+ showReleaseNotes()
230
+ return
231
+ }
232
+ if (key.name !== "u") return
233
+ if (installChannel() !== "git") return
234
+ if (!updateInfo?.updateAvailable || selfUpdate === "running") return
235
+ setSelfUpdate("running")
236
+ void runSelfUpdate().then(setSelfUpdate)
237
+ })
238
+
239
+ // Fill the terminal up to a cap, then size columns to the content area (box
240
+ // width − 2 border − 4 padding). Computing widths from the real space keeps it
241
+ // from clipping on narrow terminals and from sprawling on ultra-wide ones.
242
+ const GAP = 3
243
+ const boxWidth = Math.min(width - 4, 152)
244
+ const content = boxWidth - 6
245
+
246
+ const groups = twoShortcutCols ? TWO_COLS : ONE_COL
247
+ const cols = groups.length
248
+ const aboutWidth = aboutLeft ? ABOUT_W : content
249
+ const scArea = aboutLeft ? content - ABOUT_W - GAP : content
250
+ const scWidth = Math.max(SC_W_MIN, Math.floor((scArea - (cols - 1) * GAP) / cols))
251
+
252
+ const shortcutCols = groups.map((sections, i) => (
253
+ <box key={i} style={{ marginLeft: aboutLeft || i > 0 ? GAP : 0 }}>
254
+ <ShortcutColumn sections={sections} width={scWidth} />
255
+ </box>
256
+ ))
257
+
258
+ return (
259
+ <box
260
+ style={{
261
+ position: "absolute",
262
+ top: 0,
263
+ left: 0,
264
+ width: "100%",
265
+ height: "100%",
266
+ justifyContent: "center",
267
+ alignItems: "center",
268
+ zIndex: 100,
269
+ }}
270
+ >
271
+ <box
272
+ title={` ${APP_NAME} — Help `}
273
+ titleColor={theme.brand}
274
+ bottomTitle=" press ? or Esc to close "
275
+ bottomTitleAlignment="center"
276
+ border
277
+ borderColor={theme.borderActive}
278
+ backgroundColor={theme.bgPanel}
279
+ style={{ flexDirection: "column", width: boxWidth, paddingLeft: 2, paddingRight: 2, paddingTop: 1, paddingBottom: 1 }}
280
+ >
281
+ {aboutLeft ? (
282
+ <box style={{ flexDirection: "row", alignItems: "flex-start" }}>
283
+ <AboutColumn width={aboutWidth} updateInfo={updateInfo} selfUpdate={selfUpdate} />
284
+ {shortcutCols}
285
+ </box>
286
+ ) : (
287
+ <box style={{ flexDirection: "column" }}>
288
+ <AboutColumn width={aboutWidth} updateInfo={updateInfo} selfUpdate={selfUpdate} />
289
+ <box style={{ height: 1 }} />
290
+ {shortcutCols}
291
+ </box>
292
+ )}
293
+ </box>
294
+ </box>
295
+ )
296
+ }
@@ -0,0 +1,95 @@
1
+ // A generic, keyboard-navigable, windowed list.
2
+ //
3
+ // The parent owns `selectedIndex` (so it can react to selection changes); this
4
+ // component owns only the scroll offset, which it keeps in sync so the selected
5
+ // row stays visible. Rendering each row is delegated to `renderRow`, giving each
6
+ // view full control over columns and colors.
7
+
8
+ import { useEffect, useState, type ReactNode } from "react"
9
+ import { theme } from "../lib/theme.ts"
10
+
11
+ export interface ListProps<T> {
12
+ items: T[]
13
+ selectedIndex: number
14
+ // Number of rows available to render (the viewport height in rows).
15
+ viewportRows: number
16
+ renderRow: (item: T, selected: boolean, index: number) => ReactNode
17
+ // Whether this list currently has focus (affects the selection highlight color).
18
+ focused?: boolean
19
+ emptyText?: string
20
+ // Optional fixed key extractor; defaults to index.
21
+ keyFor?: (item: T, index: number) => string | number
22
+ }
23
+
24
+ export function List<T>({
25
+ items,
26
+ selectedIndex,
27
+ viewportRows,
28
+ renderRow,
29
+ focused = true,
30
+ emptyText = "Nothing here.",
31
+ keyFor,
32
+ }: ListProps<T>) {
33
+ const [offset, setOffset] = useState(0)
34
+ const rows = Math.max(1, viewportRows)
35
+
36
+ // Keep the selected row within the visible window [offset, offset+rows).
37
+ useEffect(() => {
38
+ setOffset((cur) => {
39
+ const maxOffset = Math.max(0, items.length - rows)
40
+ let next = cur
41
+ if (selectedIndex < cur) next = selectedIndex
42
+ else if (selectedIndex >= cur + rows) next = selectedIndex - rows + 1
43
+ return Math.min(Math.max(0, next), maxOffset)
44
+ })
45
+ }, [selectedIndex, rows, items.length])
46
+
47
+ if (items.length === 0) {
48
+ return (
49
+ <box style={{ flexGrow: 1, justifyContent: "center", alignItems: "center" }}>
50
+ <text content={emptyText} fg={theme.textFaint} />
51
+ </box>
52
+ )
53
+ }
54
+
55
+ const start = Math.min(offset, Math.max(0, items.length - rows))
56
+ const visible = items.slice(start, start + rows)
57
+ const hasAbove = start > 0
58
+ const hasBelow = start + rows < items.length
59
+
60
+ return (
61
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
62
+ {visible.map((item, i) => {
63
+ const index = start + i
64
+ const selected = index === selectedIndex
65
+ return (
66
+ <box
67
+ key={keyFor ? keyFor(item, index) : index}
68
+ style={{
69
+ flexDirection: "row",
70
+ height: 1,
71
+ backgroundColor: selected ? (focused ? theme.selectedBg : theme.bgAlt) : undefined,
72
+ }}
73
+ >
74
+ {renderRow(item, selected, index)}
75
+ </box>
76
+ )
77
+ })}
78
+ {/* Scroll affordance: show counts when the list overflows the viewport. */}
79
+ {(hasAbove || hasBelow) && (
80
+ <box style={{ flexDirection: "row", justifyContent: "flex-end", height: 1 }}>
81
+ <text
82
+ content={`${hasAbove ? "↑" : " "}${hasBelow ? "↓" : " "} ${selectedIndex + 1}/${items.length}`}
83
+ fg={theme.textFaint}
84
+ />
85
+ </box>
86
+ )}
87
+ </box>
88
+ )
89
+ }
90
+
91
+ // Shared helper for list views: clamp/move a selection index with wrap-around.
92
+ export function moveSelection(current: number, delta: number, length: number): number {
93
+ if (length === 0) return 0
94
+ return (current + delta + length) % length
95
+ }