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.
- package/CHANGELOG.md +943 -0
- package/LICENSE +21 -0
- package/README.md +856 -0
- package/bin/spinuptui.js +13 -0
- package/package.json +55 -0
- package/src/api/client.ts +354 -0
- package/src/api/types.ts +206 -0
- package/src/config.ts +309 -0
- package/src/dev/devMode.ts +7 -0
- package/src/dev/fixtures.ts +238 -0
- package/src/dev/mockClient.ts +214 -0
- package/src/index.tsx +68 -0
- package/src/lib/appUpdate.ts +206 -0
- package/src/lib/awsSigV4.ts +133 -0
- package/src/lib/cloneDomains.ts +112 -0
- package/src/lib/cloneLog.ts +85 -0
- package/src/lib/dbBackup.ts +198 -0
- package/src/lib/dbSync.ts +253 -0
- package/src/lib/discover.ts +165 -0
- package/src/lib/dns.ts +144 -0
- package/src/lib/dnsCache.ts +94 -0
- package/src/lib/dnsQuery.ts +180 -0
- package/src/lib/dnsRecords.ts +411 -0
- package/src/lib/format.ts +106 -0
- package/src/lib/gitDeployKey.ts +0 -0
- package/src/lib/gitStatus.ts +37 -0
- package/src/lib/jobs.ts +32 -0
- package/src/lib/keychain.ts +49 -0
- package/src/lib/local.ts +116 -0
- package/src/lib/mediaFallback.ts +288 -0
- package/src/lib/open.ts +103 -0
- package/src/lib/phpEol.ts +151 -0
- package/src/lib/probe.ts +183 -0
- package/src/lib/providers.ts +284 -0
- package/src/lib/providersCache.ts +105 -0
- package/src/lib/releaseNotes.ts +67 -0
- package/src/lib/serverClone.ts +782 -0
- package/src/lib/serverCreate.ts +161 -0
- package/src/lib/siteDoctor.ts +260 -0
- package/src/lib/siteFingerprint.ts +108 -0
- package/src/lib/spinupweb.ts +33 -0
- package/src/lib/ssh.ts +669 -0
- package/src/lib/stack.ts +78 -0
- package/src/lib/stackCache.ts +113 -0
- package/src/lib/theme.ts +54 -0
- package/src/lib/ubuntuEol.ts +124 -0
- package/src/lib/uptimeKuma.ts +551 -0
- package/src/lib/vanitySite.ts +297 -0
- package/src/lib/wpInventory.ts +153 -0
- package/src/ui/App.tsx +265 -0
- package/src/ui/Details.tsx +403 -0
- package/src/ui/Explain.tsx +158 -0
- package/src/ui/Header.tsx +117 -0
- package/src/ui/Help.tsx +296 -0
- package/src/ui/List.tsx +95 -0
- package/src/ui/Onboarding.tsx +93 -0
- package/src/ui/Splash.tsx +51 -0
- package/src/ui/StatusBar.tsx +60 -0
- package/src/ui/components.tsx +392 -0
- package/src/ui/store.tsx +4253 -0
- package/src/ui/views/Browser.tsx +365 -0
- package/src/ui/views/CloneWizard.tsx +932 -0
- package/src/ui/views/Dashboard.tsx +149 -0
- package/src/ui/views/DbBackup.tsx +228 -0
- package/src/ui/views/DbSync.tsx +307 -0
- package/src/ui/views/Discover.tsx +271 -0
- package/src/ui/views/DnsInventory.tsx +580 -0
- package/src/ui/views/DnsRecords.tsx +562 -0
- package/src/ui/views/Events.tsx +98 -0
- package/src/ui/views/Forgotten.tsx +165 -0
- package/src/ui/views/GrantKey.tsx +504 -0
- package/src/ui/views/Health.tsx +322 -0
- package/src/ui/views/HttpsToggle.tsx +195 -0
- package/src/ui/views/KumaSite.tsx +571 -0
- package/src/ui/views/LocalLink.tsx +220 -0
- package/src/ui/views/MediaFallback.tsx +180 -0
- package/src/ui/views/NewServer.tsx +750 -0
- package/src/ui/views/PhpUpgrade.tsx +301 -0
- package/src/ui/views/ProviderConnect.tsx +527 -0
- package/src/ui/views/PurgeCache.tsx +186 -0
- package/src/ui/views/ReleaseNotes.tsx +84 -0
- package/src/ui/views/Search.tsx +448 -0
- package/src/ui/views/ServerActions.tsx +328 -0
- package/src/ui/views/Stacks.tsx +371 -0
- package/src/ui/views/SudoConnect.tsx +358 -0
- package/src/ui/views/VanityNewSite.tsx +407 -0
- package/src/ui/views/WpInventory.tsx +218 -0
- 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
|
+
}
|