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,271 @@
1
+ // Local working-copy discovery overlay β€” Phase 2.
2
+ //
3
+ // Opened with `S` (Stacks). Scans the configured roots for local copies and
4
+ // matches them to sites (git remote β†’ WP_HOME β†’ folder name). Flow:
5
+ // β€’ needRoot β€” no scan roots configured yet: a one-field prompt to add one
6
+ // (inline, so the opt-in scan is self-sufficient β€” no JSON editing).
7
+ // β€’ scanning β€” running the (read-only) scan.
8
+ // β€’ review β€” batch list of proposed links; toggle which to accept, then apply.
9
+ // β€’ empty β€” nothing new found; add another root or close.
10
+ // Accepting a proposal calls the same store.linkSite used by the manual `L` flow;
11
+ // a WP_HOME discovered during the scan prefills the link's local URL.
12
+
13
+ import { useEffect, useState } from "react"
14
+ import { useKeyboard } from "@opentui/react"
15
+ import { theme } from "../../lib/theme.ts"
16
+ import { truncate } from "../../lib/format.ts"
17
+ import { Panel, Spinner, Centered } from "../components.tsx"
18
+ import { StatusBar } from "../StatusBar.tsx"
19
+ import { useStore } from "../store.tsx"
20
+ import { moveSelection } from "../List.tsx"
21
+ import { scanRoots, type Proposal, type Confidence } from "../../lib/discover.ts"
22
+
23
+ type Phase = "needRoot" | "scanning" | "review" | "empty" | "done"
24
+
25
+ function confColor(c: Confidence): string {
26
+ return c === "high" ? theme.good : c === "medium" ? theme.accent : theme.warn
27
+ }
28
+
29
+ export function Discover() {
30
+ const store = useStore()
31
+ const { sites, localLinks, localRoots, addLocalRoot, linkSite, setDiscoverOpen, setInputMode } = store
32
+
33
+ const [phase, setPhase] = useState<Phase>(() => (localRoots.length === 0 ? "needRoot" : "scanning"))
34
+ const [rootInput, setRootInput] = useState("")
35
+ const [proposals, setProposals] = useState<Proposal[]>([])
36
+ const [scannedDirs, setScannedDirs] = useState(0)
37
+ const [accepted, setAccepted] = useState<Set<number>>(new Set())
38
+ const [index, setIndex] = useState(0)
39
+ const [linkedCount, setLinkedCount] = useState(0)
40
+
41
+ // Run a read-only scan against an explicit root list (avoids state-timing races
42
+ // when a root was just added), then route to review / empty.
43
+ const runScan = (roots: string[]) => {
44
+ setPhase("scanning")
45
+ void scanRoots(roots, sites, new Set(localLinks.keys())).then((res) => {
46
+ setProposals(res.proposals)
47
+ setScannedDirs(res.scannedDirs)
48
+ setAccepted(new Set(res.proposals.map((_, i) => i))) // default: accept all
49
+ setIndex(0)
50
+ setPhase(res.proposals.length > 0 ? "review" : "empty")
51
+ })
52
+ }
53
+
54
+ // Kick the initial scan once (if roots already exist).
55
+ useEffect(() => {
56
+ if (localRoots.length > 0) runScan(localRoots)
57
+ // eslint-disable-next-line react-hooks/exhaustive-deps
58
+ }, [])
59
+
60
+ // The prompt owns the keyboard while adding a root.
61
+ useEffect(() => {
62
+ setInputMode(phase === "needRoot")
63
+ return () => setInputMode(false)
64
+ }, [phase, setInputMode])
65
+
66
+ const close = () => {
67
+ setInputMode(false)
68
+ setDiscoverOpen(false)
69
+ }
70
+
71
+ const submitRoot = () => {
72
+ // Accept several comma-separated folders at once.
73
+ const dirs = rootInput.split(",").map((d) => d.trim()).filter(Boolean)
74
+ if (dirs.length === 0) return
75
+ for (const d of dirs) addLocalRoot(d)
76
+ setRootInput("")
77
+ // Scan the union (dedup) so newly-added roots take effect immediately.
78
+ runScan([...new Set([...localRoots, ...dirs])])
79
+ }
80
+
81
+ const apply = () => {
82
+ let n = 0
83
+ for (let i = 0; i < proposals.length; i++) {
84
+ if (!accepted.has(i)) continue
85
+ const p = proposals[i]
86
+ linkSite(p.site.id, { domain: p.site.domain, path: p.path, localUrl: p.localUrl ?? "" })
87
+ n++
88
+ }
89
+ setLinkedCount(n)
90
+ setPhase("done")
91
+ }
92
+
93
+ useKeyboard((key) => {
94
+ const name = key.name ?? ""
95
+
96
+ if (phase === "needRoot") {
97
+ if (name === "escape") return close()
98
+ return // the input handles typing + Enter
99
+ }
100
+ if (name === "escape" || name === "q") return close()
101
+
102
+ if (phase === "review") {
103
+ switch (name) {
104
+ case "up":
105
+ case "k":
106
+ return setIndex((i) => moveSelection(i, -1, proposals.length))
107
+ case "down":
108
+ case "j":
109
+ return setIndex((i) => moveSelection(i, 1, proposals.length))
110
+ case "space":
111
+ return setAccepted((prev) => {
112
+ const next = new Set(prev)
113
+ next.has(index) ? next.delete(index) : next.add(index)
114
+ return next
115
+ })
116
+ case "a":
117
+ return setAccepted((prev) =>
118
+ prev.size === proposals.length ? new Set() : new Set(proposals.map((_, i) => i)),
119
+ )
120
+ case "return":
121
+ case "y":
122
+ return apply()
123
+ }
124
+ return
125
+ }
126
+
127
+ if (phase === "empty") {
128
+ if (name === "+" || name === "=") {
129
+ setRootInput("")
130
+ setPhase("needRoot")
131
+ }
132
+ return
133
+ }
134
+ })
135
+
136
+ return (
137
+ <box
138
+ style={{
139
+ position: "absolute",
140
+ top: 0,
141
+ left: 0,
142
+ width: "100%",
143
+ height: "100%",
144
+ flexDirection: "column",
145
+ backgroundColor: theme.bg,
146
+ zIndex: 220,
147
+ }}
148
+ >
149
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
150
+ <text content="πŸ” Discover local copies " fg={theme.brand} style={{ flexShrink: 0 }} />
151
+ <box style={{ flexGrow: 1 }} />
152
+ <text content={localRoots.length > 0 ? `${localRoots.length} scan root${localRoots.length === 1 ? "" : "s"}` : "no roots yet"} fg={theme.textFaint} style={{ flexShrink: 0 }} />
153
+ </box>
154
+
155
+ {phase === "review" ? renderReview() : <Centered>{renderCentered()}</Centered>}
156
+
157
+ <StatusBar hints={hints()} showGlobal={false} />
158
+ </box>
159
+ )
160
+
161
+ function renderCentered() {
162
+ if (phase === "needRoot") {
163
+ return (
164
+ <Panel title=" Add a directory to scan " active>
165
+ <box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
166
+ <text content="Where do you keep your local working copies?" fg={theme.text} wrapMode="none" />
167
+ <text content="The parent folder(s) β€” their subdirectories get matched to sites." fg={theme.textDim} wrapMode="none" />
168
+ <text content="Separate multiple folders with commas." fg={theme.textDim} wrapMode="none" />
169
+ <box style={{ height: 1 }} />
170
+ <input
171
+ focused
172
+ value={rootInput}
173
+ placeholder="~/code, ~/sites/bedrock"
174
+ onInput={setRootInput}
175
+ onSubmit={submitRoot}
176
+ style={{ backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }}
177
+ />
178
+ <box style={{ height: 1 }} />
179
+ <text content="Enter to scan Β· Esc to cancel" fg={theme.textFaint} />
180
+ </box>
181
+ </Panel>
182
+ )
183
+ }
184
+ if (phase === "scanning") {
185
+ return (
186
+ <box style={{ flexDirection: "row" }}>
187
+ <Spinner />
188
+ <text content=" Scanning for local copies…" fg={theme.textDim} />
189
+ </box>
190
+ )
191
+ }
192
+ if (phase === "empty") {
193
+ return (
194
+ <Panel title=" Nothing new found " active>
195
+ <box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
196
+ <text content={`Scanned ${scannedDirs} director${scannedDirs === 1 ? "y" : "ies"} β€” no new matches.`} fg={theme.text} wrapMode="none" />
197
+ <text content="Already-linked sites are skipped." fg={theme.textDim} wrapMode="none" />
198
+ <box style={{ height: 1 }} />
199
+ <text content="+ add another scan directory Β· Esc to close" fg={theme.textFaint} wrapMode="none" />
200
+ </box>
201
+ </Panel>
202
+ )
203
+ }
204
+ // done
205
+ return (
206
+ <Panel title=" Done " active>
207
+ <box style={{ flexDirection: "column", width: 56, paddingTop: 1, paddingBottom: 1 }}>
208
+ <box style={{ flexDirection: "row" }}>
209
+ <text content="βœ“ " fg={theme.good} />
210
+ <text content={`Linked ${linkedCount} local cop${linkedCount === 1 ? "y" : "ies"}.`} fg={theme.text} wrapMode="none" />
211
+ </box>
212
+ <box style={{ height: 1 }} />
213
+ <text content="Esc to close" fg={theme.textFaint} />
214
+ </box>
215
+ </Panel>
216
+ )
217
+ }
218
+
219
+ function renderReview() {
220
+ return (
221
+ <box style={{ flexGrow: 1, flexDirection: "column", padding: 1 }}>
222
+ <text
223
+ content={`Found ${proposals.length} local cop${proposals.length === 1 ? "y" : "ies"} in ${scannedDirs} director${scannedDirs === 1 ? "y" : "ies"}. Space toggles, a all, Enter links the checked.`}
224
+ fg={theme.textDim}
225
+ wrapMode="none"
226
+ />
227
+ <box style={{ height: 1 }} />
228
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
229
+ {proposals.map((p, i) => {
230
+ const sel = i === index
231
+ const on = accepted.has(i)
232
+ return (
233
+ <box key={p.site.id} style={{ flexDirection: "row", height: 1, backgroundColor: sel ? theme.selectedBg : undefined }}>
234
+ <text content={on ? "[βœ“] " : "[ ] "} fg={sel ? theme.text : on ? theme.good : theme.textFaint} style={{ flexShrink: 0 }} />
235
+ <text content={truncate(p.site.domain, 34)} fg={sel ? theme.text : theme.text} wrapMode="none" style={{ flexShrink: 0 }} />
236
+ <text content=" ← " fg={theme.textFaint} style={{ flexShrink: 0 }} />
237
+ <text content={p.path} fg={sel ? theme.text : theme.textDim} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
238
+ <text content={` ${p.reason}`} fg={sel ? theme.text : confColor(p.confidence)} wrapMode="none" style={{ flexShrink: 0 }} />
239
+ </box>
240
+ )
241
+ })}
242
+ </box>
243
+ </box>
244
+ )
245
+ }
246
+
247
+ function hints() {
248
+ switch (phase) {
249
+ case "needRoot":
250
+ return [
251
+ { key: "⏎", label: "scan" },
252
+ { key: "esc", label: "cancel" },
253
+ ]
254
+ case "review":
255
+ return [
256
+ { key: "↑↓/jk", label: "move" },
257
+ { key: "space", label: "toggle" },
258
+ { key: "a", label: "all/none" },
259
+ { key: "⏎/y", label: "link checked" },
260
+ { key: "esc", label: "cancel" },
261
+ ]
262
+ case "empty":
263
+ return [
264
+ { key: "+", label: "add root" },
265
+ { key: "esc", label: "close" },
266
+ ]
267
+ default:
268
+ return [{ key: "esc", label: "close" }]
269
+ }
270
+ }
271
+ }