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,165 @@
|
|
|
1
|
+
// "Needs a local copy" report — Phase 4a (the forgotten report).
|
|
2
|
+
//
|
|
3
|
+
// The inverse of discovery (`S`, which finds copies that exist on disk): this
|
|
4
|
+
// lists WordPress/Bedrock sites you have NO usable local copy for — either never
|
|
5
|
+
// linked, or linked to a path that's gone missing. It's the direct answer to the
|
|
6
|
+
// original pain ("I forget which managed sites have a local copy and miss
|
|
7
|
+
// composer updates"). Read-only; from a row you jump straight into linking it.
|
|
8
|
+
//
|
|
9
|
+
// Defaults to the ACTIONABLE set (sites with pending updates, plus missing-path
|
|
10
|
+
// links) so it's a short to-do list rather than a dump of every unlinked site;
|
|
11
|
+
// `a` toggles the full list. Opened with `f` (Stacks). Drift (git ahead/behind,
|
|
12
|
+
// PHP mismatch) is a later pass.
|
|
13
|
+
|
|
14
|
+
import { useMemo, useState } from "react"
|
|
15
|
+
import { useKeyboard, useTerminalDimensions } from "@opentui/react"
|
|
16
|
+
import { theme, statusColor, statusDot } from "../../lib/theme.ts"
|
|
17
|
+
import { truncate } from "../../lib/format.ts"
|
|
18
|
+
import { effectiveStack, stackColor, stackTag, type Stack } from "../../lib/stack.ts"
|
|
19
|
+
import { resolveLocalLink } from "../../lib/local.ts"
|
|
20
|
+
import { Panel } from "../components.tsx"
|
|
21
|
+
import { List, moveSelection } from "../List.tsx"
|
|
22
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
23
|
+
import { useStore } from "../store.tsx"
|
|
24
|
+
import type { Site } from "../../api/types.ts"
|
|
25
|
+
|
|
26
|
+
interface Entry {
|
|
27
|
+
site: Site
|
|
28
|
+
updates: number
|
|
29
|
+
missing: boolean // linked, but the path is gone
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Stack filter options the report can cycle through (← / →). null = all stacks.
|
|
33
|
+
const STACK_FILTERS: (Stack | null)[] = [null, "Standard WP", "Bedrock", "Non-WP"]
|
|
34
|
+
|
|
35
|
+
export function Forgotten() {
|
|
36
|
+
const { sites, localLinks, probes, setForgottenOpen, setLocalLinkSite, forgottenStack, setLinkReturnToForgotten } = useStore()
|
|
37
|
+
const { height } = useTerminalDimensions()
|
|
38
|
+
// Default shows every site in the filter without a local copy; this trims to
|
|
39
|
+
// the "needs attention" subset (pending updates or a missing path).
|
|
40
|
+
const [needsAttentionOnly, setNeedsAttentionOnly] = useState(false)
|
|
41
|
+
const [index, setIndex] = useState(0)
|
|
42
|
+
// Stack filter is adjustable in-report (← / →), seeded from the Stacks group
|
|
43
|
+
// that was selected when opened (Non-WP / no WP group → "All").
|
|
44
|
+
const [stackFilter, setStackFilter] = useState<Stack | null>(forgottenStack)
|
|
45
|
+
|
|
46
|
+
// WP/Bedrock sites without a usable local copy.
|
|
47
|
+
const all = useMemo<Entry[]>(() => {
|
|
48
|
+
const out: Entry[] = []
|
|
49
|
+
for (const s of sites) {
|
|
50
|
+
const stack = effectiveStack(s, probes.get(s.id)?.result.kind)
|
|
51
|
+
if (stackFilter && stack !== stackFilter) continue // active stack filter (adjustable)
|
|
52
|
+
const link = localLinks.get(s.id)
|
|
53
|
+
let missing = false
|
|
54
|
+
if (link) {
|
|
55
|
+
if (resolveLocalLink(link).exists) continue // has a copy → not forgotten
|
|
56
|
+
missing = true
|
|
57
|
+
}
|
|
58
|
+
const updates = (s.wp_plugin_updates || 0) + (s.wp_theme_updates || 0) + (s.wp_core_update ? 1 : 0)
|
|
59
|
+
out.push({ site: s, updates, missing })
|
|
60
|
+
}
|
|
61
|
+
// Alphabetical by domain (consistent with the discovery list).
|
|
62
|
+
out.sort((a, b) => a.site.domain.localeCompare(b.site.domain))
|
|
63
|
+
return out
|
|
64
|
+
}, [sites, localLinks, probes, stackFilter])
|
|
65
|
+
|
|
66
|
+
const actionable = useMemo(() => all.filter((e) => e.updates > 0 || e.missing), [all])
|
|
67
|
+
const shown = needsAttentionOnly ? actionable : all
|
|
68
|
+
const safeIndex = Math.min(index, Math.max(0, shown.length - 1))
|
|
69
|
+
|
|
70
|
+
const close = () => setForgottenOpen(false)
|
|
71
|
+
|
|
72
|
+
useKeyboard((key) => {
|
|
73
|
+
const raw = key.name ?? ""
|
|
74
|
+
const name = key.shift && raw.length === 1 ? raw.toUpperCase() : raw
|
|
75
|
+
switch (name) {
|
|
76
|
+
case "escape":
|
|
77
|
+
case "q":
|
|
78
|
+
return close()
|
|
79
|
+
case "up":
|
|
80
|
+
case "k":
|
|
81
|
+
return setIndex((i) => moveSelection(i, -1, shown.length))
|
|
82
|
+
case "down":
|
|
83
|
+
case "j":
|
|
84
|
+
return setIndex((i) => moveSelection(i, 1, shown.length))
|
|
85
|
+
case "left":
|
|
86
|
+
case "right": {
|
|
87
|
+
const dir = name === "right" ? 1 : -1
|
|
88
|
+
const i = STACK_FILTERS.findIndex((s) => s === stackFilter)
|
|
89
|
+
setStackFilter(STACK_FILTERS[(i + dir + STACK_FILTERS.length) % STACK_FILTERS.length])
|
|
90
|
+
return setIndex(0)
|
|
91
|
+
}
|
|
92
|
+
case "a":
|
|
93
|
+
setNeedsAttentionOnly((v) => !v)
|
|
94
|
+
return setIndex(0)
|
|
95
|
+
case "L":
|
|
96
|
+
case "return": {
|
|
97
|
+
const e = shown[safeIndex]
|
|
98
|
+
if (e) {
|
|
99
|
+
close()
|
|
100
|
+
setLinkReturnToForgotten(true) // come back here when the link overlay closes
|
|
101
|
+
setLocalLinkSite(e.site) // jump straight into linking it
|
|
102
|
+
}
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const listRows = Math.max(3, height - 5)
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 215 }}>
|
|
112
|
+
<box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
|
|
113
|
+
<text content={`📦 Needs a local copy · ${stackFilter ?? "All"} `} fg={theme.brand} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
114
|
+
<text content={needsAttentionOnly ? `${actionable.length} need attention` : `${all.length} without a local copy`} fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
115
|
+
<box style={{ flexGrow: 1 }} />
|
|
116
|
+
</box>
|
|
117
|
+
|
|
118
|
+
<box style={{ flexGrow: 1, flexDirection: "column", padding: 1 }}>
|
|
119
|
+
{shown.length === 0 ? (
|
|
120
|
+
<text
|
|
121
|
+
content={
|
|
122
|
+
all.length === 0
|
|
123
|
+
? "✓ Every site here has a local copy."
|
|
124
|
+
: "✓ Nothing needs attention — press a to show all without a local copy."
|
|
125
|
+
}
|
|
126
|
+
fg={theme.good}
|
|
127
|
+
wrapMode="none"
|
|
128
|
+
/>
|
|
129
|
+
) : (
|
|
130
|
+
<List
|
|
131
|
+
items={shown}
|
|
132
|
+
selectedIndex={safeIndex}
|
|
133
|
+
viewportRows={listRows}
|
|
134
|
+
focused
|
|
135
|
+
keyFor={(e) => e.site.id}
|
|
136
|
+
emptyText="—"
|
|
137
|
+
renderRow={(e, selected) => {
|
|
138
|
+
const stack = effectiveStack(e.site, probes.get(e.site.id)?.result.kind)
|
|
139
|
+
return (
|
|
140
|
+
<>
|
|
141
|
+
<text content={statusDot(e.site.status) + " "} fg={statusColor(e.site.status)} style={{ flexShrink: 0 }} />
|
|
142
|
+
<text content={truncate(e.site.domain, 44)} fg={selected ? theme.text : theme.textDim} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
|
|
143
|
+
{e.missing && <text content="missing copy " fg={selected ? theme.text : theme.bad} style={{ flexShrink: 0 }} />}
|
|
144
|
+
{e.updates > 0 && <text content={`↑${e.updates} `} fg={selected ? theme.text : theme.warn} style={{ flexShrink: 0 }} />}
|
|
145
|
+
<text content={stackTag(stack) + " "} fg={stackColor(stack, selected)} style={{ flexShrink: 0 }} />
|
|
146
|
+
</>
|
|
147
|
+
)
|
|
148
|
+
}}
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
</box>
|
|
152
|
+
|
|
153
|
+
<StatusBar
|
|
154
|
+
hints={[
|
|
155
|
+
{ key: "↑↓/jk", label: "select" },
|
|
156
|
+
{ key: "←→", label: "stack" },
|
|
157
|
+
{ key: "L/⏎", label: "link this site" },
|
|
158
|
+
{ key: "a", label: needsAttentionOnly ? "show all" : "needs attention only" },
|
|
159
|
+
{ key: "esc", label: "close" },
|
|
160
|
+
]}
|
|
161
|
+
showGlobal={false}
|
|
162
|
+
/>
|
|
163
|
+
</box>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
// Grant SSH key overlay — a privileged write-over-SSH on a sudo-connected server.
|
|
2
|
+
//
|
|
3
|
+
// Opened with `K` on a selected site (Browser). Pick which key(s) to deploy — your
|
|
4
|
+
// PERSONAL key(s) (so you can SSH/SFTP as yourself) and/or Spinup's dedicated
|
|
5
|
+
// `spinup-tui` MACHINE key (for unattended automation) — choose the scope (just
|
|
6
|
+
// this site, or every site on the server), confirm, and grant via the server's
|
|
7
|
+
// SUDO session. Requires sudo to be connected first (press `S` on the server). The
|
|
8
|
+
// grant runs in the store (`startGrantKey`), so closing the modal doesn't abandon it.
|
|
9
|
+
// See docs/2026-06-26_sudo-ssh-key-provisioning-spec.md.
|
|
10
|
+
|
|
11
|
+
import { useEffect, useState } from "react"
|
|
12
|
+
import { useKeyboard } from "@opentui/react"
|
|
13
|
+
import { theme } from "../../lib/theme.ts"
|
|
14
|
+
import { truncate } from "../../lib/format.ts"
|
|
15
|
+
import { Panel, Centered, Spinner } from "../components.tsx"
|
|
16
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
17
|
+
import { useStore, isKeyGrantInFlight } from "../store.tsx"
|
|
18
|
+
import { ensureSpinupKey, listPersonalKeys, buildGrantScript, buildRevokeScript, keyBody, type GrantableKey } from "../../lib/ssh.ts"
|
|
19
|
+
import { moveSelection } from "../List.tsx"
|
|
20
|
+
import type { Site } from "../../api/types.ts"
|
|
21
|
+
|
|
22
|
+
type Phase = "disconnected" | "loading" | "pick" | "scope" | "confirm" | "running" | "done" | "error"
|
|
23
|
+
|
|
24
|
+
export function GrantKey() {
|
|
25
|
+
const store = useStore()
|
|
26
|
+
const {
|
|
27
|
+
grantKeySite: site,
|
|
28
|
+
setGrantKeySite,
|
|
29
|
+
serverById,
|
|
30
|
+
sitesForServer,
|
|
31
|
+
keyGrants,
|
|
32
|
+
startGrantKey,
|
|
33
|
+
startRevokeKey,
|
|
34
|
+
clearGrantKey,
|
|
35
|
+
preferredGrantKeys,
|
|
36
|
+
setPreferredGrantKeys,
|
|
37
|
+
grantedKeys,
|
|
38
|
+
isSudoConnected,
|
|
39
|
+
sudoUserFor,
|
|
40
|
+
setSudoConnectServer,
|
|
41
|
+
} = store
|
|
42
|
+
|
|
43
|
+
const server = serverById(site?.server_id)
|
|
44
|
+
const connected = server ? isSudoConnected(server.id) : false
|
|
45
|
+
const sudoUser = server ? sudoUserFor(server.id) : undefined
|
|
46
|
+
|
|
47
|
+
// Sites on this server that can receive a key (have a site_user). The anchor
|
|
48
|
+
// site is always a valid single target.
|
|
49
|
+
const serverSites = server ? sitesForServer(server.id).filter((s) => s.site_user) : []
|
|
50
|
+
const canScope = serverSites.length > 1
|
|
51
|
+
|
|
52
|
+
const [phase, setPhase] = useState<Phase>(() => {
|
|
53
|
+
if (site && keyGrants.has(site.id)) return "running"
|
|
54
|
+
return connected ? "loading" : "disconnected"
|
|
55
|
+
})
|
|
56
|
+
const [keys, setKeys] = useState<GrantableKey[]>([])
|
|
57
|
+
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
58
|
+
const [index, setIndex] = useState(0)
|
|
59
|
+
const [loadError, setLoadError] = useState<string | null>(null)
|
|
60
|
+
const [scope, setScope] = useState<"site" | "server">("site")
|
|
61
|
+
const [scopeIndex, setScopeIndex] = useState(0)
|
|
62
|
+
// "grant" adds the selected keys; "revoke" removes them. Toggled on the pick screen.
|
|
63
|
+
const [mode, setMode] = useState<"grant" | "revoke">("grant")
|
|
64
|
+
|
|
65
|
+
// Resolve the grantable keys once (machine key + discovered personal keys).
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (phase !== "loading") return
|
|
68
|
+
let cancelled = false
|
|
69
|
+
void Promise.all([ensureSpinupKey(), listPersonalKeys()])
|
|
70
|
+
.then(([machine, personal]) => {
|
|
71
|
+
if (cancelled) return
|
|
72
|
+
const machineKey: GrantableKey = { id: keyBody(machine.pub), kind: "machine", label: machine.comment, line: machine.pub, source: "spinup" }
|
|
73
|
+
const list = [...personal.filter((p) => p.id !== machineKey.id), machineKey]
|
|
74
|
+
setKeys(list)
|
|
75
|
+
const remembered = list.filter((k) => preferredGrantKeys.includes(k.id)).map((k) => k.id)
|
|
76
|
+
setSelected(new Set(remembered.length > 0 ? remembered : [personal[0]?.id ?? machineKey.id]))
|
|
77
|
+
setPhase("pick")
|
|
78
|
+
})
|
|
79
|
+
.catch((err) => {
|
|
80
|
+
if (!cancelled) {
|
|
81
|
+
setKeys([])
|
|
82
|
+
setLoadError((err as Error).message)
|
|
83
|
+
setPhase("error")
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
return () => {
|
|
87
|
+
cancelled = true
|
|
88
|
+
}
|
|
89
|
+
}, [phase])
|
|
90
|
+
|
|
91
|
+
const selectedKeys = keys.filter((k) => selected.has(k.id))
|
|
92
|
+
const targetSites: Site[] = scope === "server" ? serverSites : site ? [site] : []
|
|
93
|
+
// Keys Spinup has recorded as granted on the anchor site (for revoke defaults +
|
|
94
|
+
// the "on site" annotation).
|
|
95
|
+
const grantedForSite = site ? grantedKeys.get(site.id) ?? new Set<string>() : new Set<string>()
|
|
96
|
+
|
|
97
|
+
// Switch grant/revoke, re-defaulting the selection: grant → remembered keys;
|
|
98
|
+
// revoke → the keys recorded as granted on this site.
|
|
99
|
+
const switchMode = (m: "grant" | "revoke") => {
|
|
100
|
+
setMode(m)
|
|
101
|
+
if (m === "revoke") {
|
|
102
|
+
setSelected(new Set(keys.filter((k) => grantedForSite.has(k.id)).map((k) => k.id)))
|
|
103
|
+
} else {
|
|
104
|
+
const remembered = keys.filter((k) => preferredGrantKeys.includes(k.id)).map((k) => k.id)
|
|
105
|
+
setSelected(new Set(remembered.length ? remembered : keys[0] ? [keys[0].id] : []))
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Batch progress over the target sites.
|
|
110
|
+
const targetProgress = targetSites.map((s) => keyGrants.get(s.id))
|
|
111
|
+
const doneCount = targetProgress.filter((p) => p?.status === "done").length
|
|
112
|
+
const failedSites = targetSites.filter((s) => keyGrants.get(s.id)?.status === "error")
|
|
113
|
+
const settledCount = doneCount + failedSites.length
|
|
114
|
+
const allSettled = targetSites.length > 0 && settledCount === targetSites.length
|
|
115
|
+
|
|
116
|
+
const dp: Phase = phase !== "running" ? phase : allSettled ? "done" : "running"
|
|
117
|
+
|
|
118
|
+
const close = () => {
|
|
119
|
+
// Drop settled markers for the targets so they don't linger after close.
|
|
120
|
+
if (phase === "running" && allSettled) for (const s of targetSites) clearGrantKey(s.id)
|
|
121
|
+
setGrantKeySite(null)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const fire = (sites: Site[]) => {
|
|
125
|
+
if (sites.length > 0 && selectedKeys.length > 0) {
|
|
126
|
+
const lines = selectedKeys.map((k) => k.line)
|
|
127
|
+
if (mode === "grant") {
|
|
128
|
+
startGrantKey(sites, lines)
|
|
129
|
+
setPreferredGrantKeys(selectedKeys.map((k) => k.id)) // remember the choice
|
|
130
|
+
} else {
|
|
131
|
+
startRevokeKey(sites, lines)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
setPhase("running")
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
useKeyboard((key) => {
|
|
138
|
+
const name = key.name ?? ""
|
|
139
|
+
if (name === "escape" || name === "q") return close()
|
|
140
|
+
|
|
141
|
+
if (dp === "disconnected") {
|
|
142
|
+
if (name === "s" && server) {
|
|
143
|
+
setGrantKeySite(null)
|
|
144
|
+
setSudoConnectServer(server)
|
|
145
|
+
}
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (dp === "pick") {
|
|
150
|
+
switch (name) {
|
|
151
|
+
case "up":
|
|
152
|
+
case "k":
|
|
153
|
+
return setIndex((i) => moveSelection(i, -1, keys.length))
|
|
154
|
+
case "down":
|
|
155
|
+
case "j":
|
|
156
|
+
return setIndex((i) => moveSelection(i, 1, keys.length))
|
|
157
|
+
case "space": {
|
|
158
|
+
const k = keys[index]
|
|
159
|
+
if (!k) return
|
|
160
|
+
return setSelected((prev) => {
|
|
161
|
+
const next = new Set(prev)
|
|
162
|
+
if (next.has(k.id)) next.delete(k.id)
|
|
163
|
+
else next.add(k.id)
|
|
164
|
+
return next
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
case "a":
|
|
168
|
+
return switchMode("grant")
|
|
169
|
+
case "r":
|
|
170
|
+
return switchMode("revoke")
|
|
171
|
+
case "return":
|
|
172
|
+
case "right":
|
|
173
|
+
case "l":
|
|
174
|
+
if (selectedKeys.length === 0) return
|
|
175
|
+
return setPhase(canScope ? "scope" : "confirm")
|
|
176
|
+
}
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (dp === "scope") {
|
|
181
|
+
switch (name) {
|
|
182
|
+
case "up":
|
|
183
|
+
case "k":
|
|
184
|
+
setScopeIndex(0)
|
|
185
|
+
return setScope("site")
|
|
186
|
+
case "down":
|
|
187
|
+
case "j":
|
|
188
|
+
setScopeIndex(1)
|
|
189
|
+
return setScope("server")
|
|
190
|
+
case "return":
|
|
191
|
+
case "right":
|
|
192
|
+
case "l":
|
|
193
|
+
return setPhase("confirm")
|
|
194
|
+
case "left":
|
|
195
|
+
case "h":
|
|
196
|
+
return setPhase("pick")
|
|
197
|
+
}
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (dp === "confirm") {
|
|
202
|
+
if (name === "y") return fire(targetSites)
|
|
203
|
+
if (name === "left" || name === "h") return setPhase(canScope ? "scope" : "pick")
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (dp === "done") {
|
|
208
|
+
if (name === "r" && failedSites.length > 0) return fire(failedSites)
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (dp === "error") {
|
|
213
|
+
if (name === "r") {
|
|
214
|
+
setLoadError(null)
|
|
215
|
+
return setPhase("loading")
|
|
216
|
+
}
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
if (!site) return null
|
|
222
|
+
|
|
223
|
+
const siteUser = site.site_user ?? "(no site user)"
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<box
|
|
227
|
+
style={{
|
|
228
|
+
position: "absolute",
|
|
229
|
+
top: 0,
|
|
230
|
+
left: 0,
|
|
231
|
+
width: "100%",
|
|
232
|
+
height: "100%",
|
|
233
|
+
flexDirection: "column",
|
|
234
|
+
backgroundColor: theme.bg,
|
|
235
|
+
zIndex: 210,
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
238
|
+
{/* Title bar */}
|
|
239
|
+
<box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
|
|
240
|
+
<text content={`🔑 ${mode === "revoke" ? "Remove" : "Grant"} SSH key `} fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
241
|
+
<text content={truncate(site.domain, 40)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
242
|
+
<box style={{ flexGrow: 1 }} />
|
|
243
|
+
<text content={connected ? "● " : "○ "} fg={connected ? theme.good : theme.textFaint} style={{ flexShrink: 0 }} />
|
|
244
|
+
<text content={server?.name ?? "—"} fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
245
|
+
</box>
|
|
246
|
+
|
|
247
|
+
<Centered>{renderBody()}</Centered>
|
|
248
|
+
|
|
249
|
+
<StatusBar hints={hints()} showGlobal={false} />
|
|
250
|
+
</box>
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
function renderBody() {
|
|
254
|
+
if (dp === "disconnected") {
|
|
255
|
+
return (
|
|
256
|
+
<Panel title=" Sudo not connected " active>
|
|
257
|
+
<box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
|
|
258
|
+
<box style={{ flexDirection: "row" }}>
|
|
259
|
+
<text content="○ Sudo isn't connected on " fg={theme.warn} wrapMode="none" />
|
|
260
|
+
<text content={server?.name ?? "this server"} fg={theme.accent} wrapMode="none" />
|
|
261
|
+
</box>
|
|
262
|
+
<box style={{ height: 1 }} />
|
|
263
|
+
<text content="Why: each site runs as its own Linux user, and its SSH" fg={theme.textDim} wrapMode="none" />
|
|
264
|
+
<text content="keys live in that user's authorized_keys. Writing another" fg={theme.textDim} wrapMode="none" />
|
|
265
|
+
<text content="user's file needs root — so SpinupTUI logs in as the server's" fg={theme.textDim} wrapMode="none" />
|
|
266
|
+
<text content="sudo user. (The SpinupWP API can't manage SSH keys.)" fg={theme.textDim} wrapMode="none" />
|
|
267
|
+
<box style={{ height: 1 }} />
|
|
268
|
+
<text content="Press s to connect sudo now (or S on the server), then" fg={theme.textFaint} wrapMode="none" />
|
|
269
|
+
<text content="press K on the site again." fg={theme.textFaint} wrapMode="none" />
|
|
270
|
+
</box>
|
|
271
|
+
</Panel>
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (dp === "loading") {
|
|
276
|
+
return (
|
|
277
|
+
<box style={{ flexDirection: "row" }}>
|
|
278
|
+
<Spinner />
|
|
279
|
+
<text content=" Finding your SSH keys…" fg={theme.textDim} />
|
|
280
|
+
</box>
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (dp === "pick") {
|
|
285
|
+
const revoke = mode === "revoke"
|
|
286
|
+
return (
|
|
287
|
+
<Panel title={revoke ? " Choose keys to remove " : " Choose keys to grant "} active>
|
|
288
|
+
<box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
|
|
289
|
+
<box style={{ flexDirection: "row" }}>
|
|
290
|
+
<text content={revoke ? "✕ Remove mode — pick key(s) to remove" : "+ Grant mode — pick key(s) to grant"} fg={revoke ? theme.bad : theme.good} wrapMode="none" />
|
|
291
|
+
<box style={{ flexGrow: 1 }} />
|
|
292
|
+
<text content="a/r switch" fg={theme.textFaint} wrapMode="none" />
|
|
293
|
+
</box>
|
|
294
|
+
<box style={{ height: 1 }} />
|
|
295
|
+
{keys.map((k, i) => {
|
|
296
|
+
const sel = i === index
|
|
297
|
+
const checked = selected.has(k.id)
|
|
298
|
+
const onSite = grantedForSite.has(k.id)
|
|
299
|
+
const tag = k.kind === "machine" ? "machine" : "personal"
|
|
300
|
+
const tagFg = sel ? theme.text : k.kind === "machine" ? theme.brand : theme.accent
|
|
301
|
+
const boxFg = revoke ? theme.bad : theme.good
|
|
302
|
+
return (
|
|
303
|
+
<box key={k.id} style={{ flexDirection: "row", height: 1, backgroundColor: sel ? theme.selectedBg : undefined }}>
|
|
304
|
+
<text content={(sel ? "❯ " : " ") + (checked ? "[x] " : "[ ] ")} fg={checked ? boxFg : theme.textDim} style={{ flexShrink: 0 }} />
|
|
305
|
+
<text content={truncate(k.label || k.source, 38)} fg={sel ? theme.text : theme.textDim} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
|
|
306
|
+
<text content={onSite ? "✓on site " : " "} fg={sel ? theme.text : theme.good} style={{ flexShrink: 0 }} wrapMode="none" />
|
|
307
|
+
<text content={tag} fg={tagFg} style={{ flexShrink: 0 }} />
|
|
308
|
+
</box>
|
|
309
|
+
)
|
|
310
|
+
})}
|
|
311
|
+
<box style={{ height: 1 }} />
|
|
312
|
+
{revoke ? (
|
|
313
|
+
<>
|
|
314
|
+
<text content="Removes the checked key(s) from authorized_keys. Other keys" fg={theme.textFaint} wrapMode="none" />
|
|
315
|
+
<text content="(incl. SpinupWP-managed ones) are left untouched." fg={theme.textFaint} wrapMode="none" />
|
|
316
|
+
</>
|
|
317
|
+
) : (
|
|
318
|
+
<>
|
|
319
|
+
<text content="Personal keys log you in as yourself; the machine key is" fg={theme.textFaint} wrapMode="none" />
|
|
320
|
+
<text content="SpinupTUI's own identity for unattended automation." fg={theme.textFaint} wrapMode="none" />
|
|
321
|
+
</>
|
|
322
|
+
)}
|
|
323
|
+
</box>
|
|
324
|
+
</Panel>
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (dp === "scope") {
|
|
329
|
+
const opts = [
|
|
330
|
+
{ label: "Just this site", sub: site!.domain },
|
|
331
|
+
{ label: `All ${serverSites.length} sites on this server`, sub: server?.name ?? "" },
|
|
332
|
+
]
|
|
333
|
+
return (
|
|
334
|
+
<Panel title={mode === "revoke" ? " Remove from " : " Grant to "} active>
|
|
335
|
+
<box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
|
|
336
|
+
{opts.map((o, i) => {
|
|
337
|
+
const sel = i === scopeIndex
|
|
338
|
+
return (
|
|
339
|
+
<box key={i} style={{ flexDirection: "row", height: 1, backgroundColor: sel ? theme.selectedBg : undefined }}>
|
|
340
|
+
<text content={(sel ? "❯ " : " ") + (sel ? "(•) " : "( ) ")} fg={sel ? theme.good : theme.textDim} style={{ flexShrink: 0 }} />
|
|
341
|
+
<text content={o.label} fg={sel ? theme.text : theme.textDim} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
|
|
342
|
+
<text content={truncate(o.sub, 26) + " "} fg={sel ? theme.text : theme.textFaint} style={{ flexShrink: 0 }} wrapMode="none" />
|
|
343
|
+
</box>
|
|
344
|
+
)
|
|
345
|
+
})}
|
|
346
|
+
<box style={{ height: 1 }} />
|
|
347
|
+
<text content="All sites: the same idempotent append runs on each — keys" fg={theme.textFaint} wrapMode="none" />
|
|
348
|
+
<text content="already present are skipped." fg={theme.textFaint} wrapMode="none" />
|
|
349
|
+
</box>
|
|
350
|
+
</Panel>
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (dp === "confirm") {
|
|
355
|
+
const revoke = mode === "revoke"
|
|
356
|
+
const n = targetSites.length
|
|
357
|
+
const single = n === 1
|
|
358
|
+
const displayLines = selectedKeys.map((k) => `…${k.kind === "machine" ? "spinup-tui" : "ed25519"}… ${k.label}`)
|
|
359
|
+
const script = single ? (revoke ? buildRevokeScript(siteUser, targetSites[0].domain, displayLines) : buildGrantScript(siteUser, targetSites[0].domain, displayLines)) : null
|
|
360
|
+
return (
|
|
361
|
+
<Panel title={revoke ? " Confirm — remove keys " : " Confirm — privileged write "} active>
|
|
362
|
+
<box style={{ flexDirection: "column", width: 72, paddingTop: 1, paddingBottom: 1 }}>
|
|
363
|
+
<box style={{ flexDirection: "row" }}>
|
|
364
|
+
<text content={`${revoke ? "Remove" : "Add"} ${selectedKeys.length} key${selectedKeys.length === 1 ? "" : "s"} ${revoke ? "from" : "to"} `} fg={theme.text} wrapMode="none" />
|
|
365
|
+
<text content={single ? `${siteUser}@${targetSites[0].domain}` : `${n} sites on ${server?.name ?? ""}`} fg={theme.accent} wrapMode="none" />
|
|
366
|
+
</box>
|
|
367
|
+
<box style={{ flexDirection: "row" }}>
|
|
368
|
+
<text content="via sudo as " fg={theme.textDim} wrapMode="none" />
|
|
369
|
+
<text content={sudoUser ?? "—"} fg={theme.good} wrapMode="none" />
|
|
370
|
+
</box>
|
|
371
|
+
{selectedKeys.map((k) => (
|
|
372
|
+
<box key={k.id} style={{ flexDirection: "row" }}>
|
|
373
|
+
<text content=" • " fg={theme.textFaint} wrapMode="none" />
|
|
374
|
+
<text content={truncate(k.label || k.source, 44)} fg={theme.textDim} wrapMode="none" />
|
|
375
|
+
<text content={` (${k.kind})`} fg={k.kind === "machine" ? theme.brand : theme.accent} wrapMode="none" />
|
|
376
|
+
</box>
|
|
377
|
+
))}
|
|
378
|
+
<box style={{ height: 1 }} />
|
|
379
|
+
{single ? (
|
|
380
|
+
<>
|
|
381
|
+
<text content={`Runs on the server (idempotent — re-running is a no-op):`} fg={theme.textFaint} wrapMode="none" />
|
|
382
|
+
<box style={{ flexDirection: "column", backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1 }}>
|
|
383
|
+
{script!.split("\n").map((line, i) => (
|
|
384
|
+
<text key={i} content={line} fg={theme.textDim} wrapMode="none" />
|
|
385
|
+
))}
|
|
386
|
+
</box>
|
|
387
|
+
</>
|
|
388
|
+
) : (
|
|
389
|
+
<text content={`The same idempotent ${revoke ? "removal" : "append"} runs on each of the ${n} sites.`} fg={theme.textFaint} wrapMode="none" />
|
|
390
|
+
)}
|
|
391
|
+
<box style={{ height: 1 }} />
|
|
392
|
+
<text content={revoke ? "This removes SSH access for those keys. Press y to continue." : "This is SpinupTUI's most powerful write. Press y to continue."} fg={theme.warn} wrapMode="none" />
|
|
393
|
+
<text content="y confirm · ← back · Esc cancel" fg={theme.textFaint} wrapMode="none" />
|
|
394
|
+
</box>
|
|
395
|
+
</Panel>
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (dp === "running") {
|
|
400
|
+
const revoke = mode === "revoke"
|
|
401
|
+
const n = targetSites.length
|
|
402
|
+
return (
|
|
403
|
+
<Panel title={revoke ? " Removing " : " Granting "} active>
|
|
404
|
+
<box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
|
|
405
|
+
<box style={{ flexDirection: "row" }}>
|
|
406
|
+
<Spinner />
|
|
407
|
+
<text content={` ${revoke ? "Removing from" : "Granting to"} ${n} site${n === 1 ? "" : "s"} — ${settledCount}/${n} done`} fg={theme.textDim} wrapMode="none" />
|
|
408
|
+
</box>
|
|
409
|
+
{failedSites.length > 0 && (
|
|
410
|
+
<text content={`${doneCount} ok · ${failedSites.length} failed so far`} fg={theme.textFaint} wrapMode="none" />
|
|
411
|
+
)}
|
|
412
|
+
<box style={{ height: 1 }} />
|
|
413
|
+
<text content="You can press Esc — it keeps running in the background." fg={theme.textFaint} wrapMode="none" />
|
|
414
|
+
</box>
|
|
415
|
+
</Panel>
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (dp === "done") {
|
|
420
|
+
const n = targetSites.length
|
|
421
|
+
return (
|
|
422
|
+
<Panel title={failedSites.length > 0 ? " Done — with errors " : " Done "} active>
|
|
423
|
+
<box style={{ flexDirection: "column", width: 70, paddingTop: 1, paddingBottom: 1 }}>
|
|
424
|
+
<box style={{ flexDirection: "row" }}>
|
|
425
|
+
<text content={failedSites.length > 0 ? "⚠ " : "✓ "} fg={failedSites.length > 0 ? theme.warn : theme.good} />
|
|
426
|
+
<text content={`${mode === "revoke" ? "Removed from" : "Granted on"} ${doneCount} of ${n} site${n === 1 ? "" : "s"}`} fg={theme.text} wrapMode="none" />
|
|
427
|
+
</box>
|
|
428
|
+
{failedSites.length > 0 && (
|
|
429
|
+
<>
|
|
430
|
+
<box style={{ height: 1 }} />
|
|
431
|
+
<text content="Failed:" fg={theme.bad} wrapMode="none" />
|
|
432
|
+
{failedSites.slice(0, 6).map((s) => (
|
|
433
|
+
<box key={s.id} style={{ flexDirection: "row" }}>
|
|
434
|
+
<text content={` ✕ ${truncate(s.domain, 30)} `} fg={theme.bad} wrapMode="none" />
|
|
435
|
+
<text content={truncate(keyGrants.get(s.id)?.error ?? "", 30)} fg={theme.textFaint} wrapMode="none" />
|
|
436
|
+
</box>
|
|
437
|
+
))}
|
|
438
|
+
{failedSites.length > 6 && <text content={` …${failedSites.length - 6} more`} fg={theme.textFaint} wrapMode="none" />}
|
|
439
|
+
</>
|
|
440
|
+
)}
|
|
441
|
+
<box style={{ height: 1 }} />
|
|
442
|
+
<text content={failedSites.length > 0 ? "r retry the failed · Esc close" : "Esc to close"} fg={theme.textFaint} wrapMode="none" />
|
|
443
|
+
</box>
|
|
444
|
+
</Panel>
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// error (loading keys failed)
|
|
449
|
+
return (
|
|
450
|
+
<Panel title=" Grant failed " active>
|
|
451
|
+
<box style={{ flexDirection: "column", width: 72, paddingTop: 1, paddingBottom: 1 }}>
|
|
452
|
+
<text content={`✕ ${loadError ?? "Something went wrong."}`} fg={theme.bad} wrapMode="none" />
|
|
453
|
+
<box style={{ height: 1 }} />
|
|
454
|
+
<text content="r retry · Esc close" fg={theme.textFaint} wrapMode="none" />
|
|
455
|
+
</box>
|
|
456
|
+
</Panel>
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function hints() {
|
|
461
|
+
switch (dp) {
|
|
462
|
+
case "disconnected":
|
|
463
|
+
return [
|
|
464
|
+
{ key: "s", label: "connect sudo" },
|
|
465
|
+
{ key: "esc", label: "close" },
|
|
466
|
+
]
|
|
467
|
+
case "pick":
|
|
468
|
+
return [
|
|
469
|
+
{ key: "↑↓", label: "key" },
|
|
470
|
+
{ key: "space", label: "toggle" },
|
|
471
|
+
{ key: "a/r", label: mode === "revoke" ? "remove mode" : "grant mode" },
|
|
472
|
+
{ key: "⏎", label: canScope ? "scope" : "next" },
|
|
473
|
+
{ key: "esc", label: "cancel" },
|
|
474
|
+
]
|
|
475
|
+
case "scope":
|
|
476
|
+
return [
|
|
477
|
+
{ key: "↑↓", label: "scope" },
|
|
478
|
+
{ key: "⏎", label: "next" },
|
|
479
|
+
{ key: "←", label: "back" },
|
|
480
|
+
{ key: "esc", label: "cancel" },
|
|
481
|
+
]
|
|
482
|
+
case "confirm":
|
|
483
|
+
return [
|
|
484
|
+
{ key: "y", label: "confirm" },
|
|
485
|
+
{ key: "←", label: "back" },
|
|
486
|
+
{ key: "esc", label: "cancel" },
|
|
487
|
+
]
|
|
488
|
+
case "done":
|
|
489
|
+
return failedSites.length > 0
|
|
490
|
+
? [
|
|
491
|
+
{ key: "r", label: "retry failed" },
|
|
492
|
+
{ key: "esc", label: "close" },
|
|
493
|
+
]
|
|
494
|
+
: [{ key: "esc", label: "close" }]
|
|
495
|
+
case "error":
|
|
496
|
+
return [
|
|
497
|
+
{ key: "r", label: "retry" },
|
|
498
|
+
{ key: "esc", label: "close" },
|
|
499
|
+
]
|
|
500
|
+
default:
|
|
501
|
+
return [{ key: "esc", label: "close" }]
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|