spinuptui 0.22.0 → 0.22.2
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 +22 -1
- package/package.json +1 -1
- package/src/lib/keychain.ts +13 -4
- package/src/ui/Details.tsx +1 -1
- package/src/ui/components.tsx +1 -0
- package/src/ui/store.tsx +16 -11
- package/src/ui/views/Browser.tsx +10 -5
- package/src/ui/views/SudoConnect.tsx +17 -3
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,25 @@ versions; such changes are called out here.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [0.22.2] - 2026-07-13
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- **A failed "Remember in macOS Keychain" save no longer fails silently.** If the
|
|
18
|
+
Keychain write fails (e.g. a locked login keychain — the underlying `security`
|
|
19
|
+
command needs it unlocked first), the sudo connection itself still succeeds,
|
|
20
|
+
but the "Sudo connected" panel now shows the real reason as a warning instead
|
|
21
|
+
of silently reverting to "kept in memory only, never saved."
|
|
22
|
+
|
|
23
|
+
## [0.22.1] - 2026-07-13
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- **`K` (grant/revoke SSH key) is discoverable again.** The v0.22.0 Control-strip
|
|
27
|
+
rebuild dropped it from both Site Control and Server Control's action lists —
|
|
28
|
+
the keybinding itself kept working, but you had to already know it existed.
|
|
29
|
+
It's back in Site Control's Remote group and Server Control's Access group,
|
|
30
|
+
and now also works from the Servers pane (falling back to the server's first
|
|
31
|
+
key-eligible site), matching how `S`/`h` already work from either pane.
|
|
32
|
+
|
|
14
33
|
## [0.22.0] - 2026-07-13
|
|
15
34
|
|
|
16
35
|
### Added
|
|
@@ -1166,7 +1185,9 @@ Initial tagged release.
|
|
|
1166
1185
|
### Notes
|
|
1167
1186
|
- Read-only release: works with a SpinupWP **Read Only** API token.
|
|
1168
1187
|
|
|
1169
|
-
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.22.
|
|
1188
|
+
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.22.2...HEAD
|
|
1189
|
+
[0.22.2]: https://github.com/mwender/spinupwp-tui/compare/v0.22.1...v0.22.2
|
|
1190
|
+
[0.22.1]: https://github.com/mwender/spinupwp-tui/compare/v0.22.0...v0.22.1
|
|
1170
1191
|
[0.22.0]: https://github.com/mwender/spinupwp-tui/compare/v0.21.2...v0.22.0
|
|
1171
1192
|
[0.21.2]: https://github.com/mwender/spinupwp-tui/compare/v0.21.1...v0.21.2
|
|
1172
1193
|
[0.21.1]: https://github.com/mwender/spinupwp-tui/compare/v0.21.0...v0.21.1
|
package/package.json
CHANGED
package/src/lib/keychain.ts
CHANGED
|
@@ -27,11 +27,20 @@ async function security(args: string[]): Promise<{ ok: boolean; stdout: string;
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
export interface KeychainWriteResult {
|
|
31
|
+
ok: boolean
|
|
32
|
+
// `security`'s own error line (e.g. "User interaction is not allowed." when the
|
|
33
|
+
// login keychain is locked) — set whenever ok is false and on macOS, so the
|
|
34
|
+
// caller can show the real reason instead of a silent no-op.
|
|
35
|
+
error?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Store (or update) a server's sudo password. ok:false off macOS / on failure.
|
|
39
|
+
export async function setSudoPassword(serverId: number, password: string): Promise<KeychainWriteResult> {
|
|
40
|
+
if (!keychainAvailable()) return { ok: false }
|
|
33
41
|
const r = await security(["add-generic-password", "-U", "-s", SERVICE, "-a", account(serverId), "-w", password])
|
|
34
|
-
|
|
42
|
+
if (r.ok) return { ok: true }
|
|
43
|
+
return { ok: false, error: r.stderr.trim().replace(/^security:\s*/, "") || "the Keychain write failed" }
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
// Retrieve a server's sudo password, or null if absent / denied / off macOS. The first
|
package/src/ui/Details.tsx
CHANGED
|
@@ -106,7 +106,7 @@ function serverControlGroups(
|
|
|
106
106
|
// Labels stay terse and avoid repeating their group's title — see siteGroups().
|
|
107
107
|
export const SERVER_CONTROL: ActionGroup[] = [
|
|
108
108
|
{ title: "Clone", items: [["C", "Clone →"]] },
|
|
109
|
-
{ title: "Access", items: [["S", "Sudo"]] },
|
|
109
|
+
{ title: "Access", items: [["S", "Sudo"], ["K", "Grant SSH key"]] },
|
|
110
110
|
{
|
|
111
111
|
title: "Manage",
|
|
112
112
|
items: [
|
package/src/ui/components.tsx
CHANGED
|
@@ -436,6 +436,7 @@ export function ControlPanel({ heading, groups, layout = "list" }: { heading: st
|
|
|
436
436
|
export function siteGroups(isWordpress: boolean, localSync: boolean, isVanity: boolean): ActionGroup[] {
|
|
437
437
|
const remote: [string, string][] = [
|
|
438
438
|
["s", "SSH"],
|
|
439
|
+
["K", "Grant SSH key"],
|
|
439
440
|
["n", "DNS"],
|
|
440
441
|
]
|
|
441
442
|
if (isWordpress) {
|
package/src/ui/store.tsx
CHANGED
|
@@ -649,8 +649,8 @@ interface StoreValue extends DataState {
|
|
|
649
649
|
sudoConnectServer: Server | null
|
|
650
650
|
setSudoConnectServer: (s: Server | null) => void
|
|
651
651
|
isSudoConnected: (serverId: number) => boolean
|
|
652
|
-
connectSudo: (server: Server, user: string, password: string, remember?: boolean) => Promise<{ ok: true } | { ok: false; error: string }>
|
|
653
|
-
connectSudoFromKeychain: (server: Server) => Promise<{ ok: true } | { ok: false; error: string }>
|
|
652
|
+
connectSudo: (server: Server, user: string, password: string, remember?: boolean) => Promise<{ ok: true; keychainError?: string } | { ok: false; error: string }>
|
|
653
|
+
connectSudoFromKeychain: (server: Server) => Promise<{ ok: true; keychainError?: string } | { ok: false; error: string }>
|
|
654
654
|
disconnectSudo: (serverId: number) => void
|
|
655
655
|
sudoSavedFor: (serverId: number) => boolean // a sudo password is saved in the Keychain
|
|
656
656
|
forgetSudoKeychain: (serverId: number) => Promise<void>
|
|
@@ -1783,17 +1783,21 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1783
1783
|
// (opt-in, macOS) the password into the Keychain. `remember` toggles Keychain
|
|
1784
1784
|
// storage — unchecking it on a server that had one saved removes it.
|
|
1785
1785
|
const finishConnect = useCallback(
|
|
1786
|
-
async (server: Server, sudoUser: string, password: string, remember: boolean) => {
|
|
1786
|
+
async (server: Server, sudoUser: string, password: string, remember: boolean): Promise<{ keychainError?: string }> => {
|
|
1787
1787
|
sudoPwRef.current.set(server.id, password)
|
|
1788
1788
|
setSudoConnected((prev) => new Set(prev).add(server.id))
|
|
1789
1789
|
const users = new Map(sudoUsers).set(server.id, sudoUser)
|
|
1790
1790
|
setSudoUsers(users)
|
|
1791
1791
|
let saved = sudoSaved
|
|
1792
|
+
let keychainError: string | undefined
|
|
1792
1793
|
if (keychainAvailable()) {
|
|
1793
1794
|
if (remember && !sudoSaved.has(server.id)) {
|
|
1794
|
-
|
|
1795
|
+
const r = await setSudoPassword(server.id, password)
|
|
1796
|
+
if (r.ok) saved = new Set(sudoSaved).add(server.id)
|
|
1797
|
+
else keychainError = r.error
|
|
1795
1798
|
} else if (remember && sudoSaved.has(server.id)) {
|
|
1796
|
-
await setSudoPassword(server.id, password) // refresh in case the password changed
|
|
1799
|
+
const r = await setSudoPassword(server.id, password) // refresh in case the password changed
|
|
1800
|
+
if (!r.ok) keychainError = r.error
|
|
1797
1801
|
} else if (!remember && sudoSaved.has(server.id)) {
|
|
1798
1802
|
await deleteSudoPassword(server.id)
|
|
1799
1803
|
saved = new Set(sudoSaved)
|
|
@@ -1802,6 +1806,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1802
1806
|
if (saved !== sudoSaved) setSudoSaved(saved)
|
|
1803
1807
|
}
|
|
1804
1808
|
persistSudoMeta(users, saved)
|
|
1809
|
+
return { keychainError }
|
|
1805
1810
|
},
|
|
1806
1811
|
[sudoUsers, sudoSaved, persistSudoMeta],
|
|
1807
1812
|
)
|
|
@@ -1810,13 +1815,13 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1810
1815
|
// connected, hold the password in memory, persist the username, and optionally save
|
|
1811
1816
|
// the password to the Keychain. Returns the verify result so the overlay shows pass/fail.
|
|
1812
1817
|
const connectSudo = useCallback(
|
|
1813
|
-
async (server: Server, user: string, password: string, remember = false): Promise<{ ok: true } | { ok: false; error: string }> => {
|
|
1818
|
+
async (server: Server, user: string, password: string, remember = false): Promise<{ ok: true; keychainError?: string } | { ok: false; error: string }> => {
|
|
1814
1819
|
const sudoUser = user.trim()
|
|
1815
1820
|
if (!sudoUser) return { ok: false, error: "Enter a sudo username." }
|
|
1816
1821
|
const res = await verifySudo(server, { sudoUser, sudoPassword: password })
|
|
1817
1822
|
if (!res.ok) return res
|
|
1818
|
-
await finishConnect(server, sudoUser, password, remember)
|
|
1819
|
-
return { ok: true }
|
|
1823
|
+
const { keychainError } = await finishConnect(server, sudoUser, password, remember)
|
|
1824
|
+
return { ok: true, keychainError }
|
|
1820
1825
|
},
|
|
1821
1826
|
[finishConnect],
|
|
1822
1827
|
)
|
|
@@ -1824,15 +1829,15 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1824
1829
|
// Auto-connect from the Keychain (the `S` overlay calls this on open when a password
|
|
1825
1830
|
// is saved). Retrieve → verify → connect, all without prompting for the password.
|
|
1826
1831
|
const connectSudoFromKeychain = useCallback(
|
|
1827
|
-
async (server: Server): Promise<{ ok: true } | { ok: false; error: string }> => {
|
|
1832
|
+
async (server: Server): Promise<{ ok: true; keychainError?: string } | { ok: false; error: string }> => {
|
|
1828
1833
|
const sudoUser = sudoUsers.get(server.id)
|
|
1829
1834
|
if (!sudoUser) return { ok: false, error: "No saved sudo user for this server." }
|
|
1830
1835
|
const password = await getSudoPassword(server.id)
|
|
1831
1836
|
if (password == null) return { ok: false, error: "Couldn't read the saved password from the Keychain." }
|
|
1832
1837
|
const res = await verifySudo(server, { sudoUser, sudoPassword: password })
|
|
1833
1838
|
if (!res.ok) return res
|
|
1834
|
-
await finishConnect(server, sudoUser, password, true)
|
|
1835
|
-
return { ok: true }
|
|
1839
|
+
const { keychainError } = await finishConnect(server, sudoUser, password, true)
|
|
1840
|
+
return { ok: true, keychainError }
|
|
1836
1841
|
},
|
|
1837
1842
|
[sudoUsers, finishConnect],
|
|
1838
1843
|
)
|
package/src/ui/views/Browser.tsx
CHANGED
|
@@ -128,12 +128,17 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
128
128
|
}
|
|
129
129
|
return
|
|
130
130
|
}
|
|
131
|
-
case "K":
|
|
132
|
-
// Grant Spinup's machine key to
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
131
|
+
case "K": {
|
|
132
|
+
// Grant Spinup's machine key to a site over SSH (privileged write via the
|
|
133
|
+
// server's sudo user — the API can't do this). Capital K (like L/V/N)
|
|
134
|
+
// since lowercase k is the vim "move up" binding. Site-anchored (the
|
|
135
|
+
// overlay itself picks the scope: just this site, or every site on the
|
|
136
|
+
// server) but works from either pane like S/h — the Servers pane falls
|
|
137
|
+
// back to the server's first key-eligible site as the anchor.
|
|
138
|
+
const anchor = focus === "sites" ? sites[siteIndex] : server ? sitesForServer(server.id).find((s) => s.site_user) : undefined
|
|
139
|
+
if (anchor) setGrantKeySite(anchor)
|
|
136
140
|
return
|
|
141
|
+
}
|
|
137
142
|
case "L":
|
|
138
143
|
// Link / view the selected site's local working copy (Phase 1).
|
|
139
144
|
if (focus === "sites" && sites[siteIndex]) setLocalLinkSite(sites[siteIndex])
|
|
@@ -46,6 +46,9 @@ export function SudoConnect() {
|
|
|
46
46
|
const [pwInput, setPwInput] = useState("")
|
|
47
47
|
const [remember, setRemember] = useState(saved) // keep the box checked for already-saved servers
|
|
48
48
|
const [error, setError] = useState<string | null>(null)
|
|
49
|
+
// Sudo itself connected fine, but "Remember in Keychain" silently failed (e.g. a
|
|
50
|
+
// locked login keychain) — distinct from `error`, which blocks the connection.
|
|
51
|
+
const [keychainWarning, setKeychainWarning] = useState<string | null>(null)
|
|
49
52
|
|
|
50
53
|
// Auto-unlock from the Keychain once, when we open on a saved server.
|
|
51
54
|
const unlockTried = useRef(false)
|
|
@@ -53,8 +56,10 @@ export function SudoConnect() {
|
|
|
53
56
|
if (unlockTried.current || phase !== "unlocking" || !server) return
|
|
54
57
|
unlockTried.current = true
|
|
55
58
|
void connectSudoFromKeychain(server).then((res) => {
|
|
56
|
-
if (res.ok)
|
|
57
|
-
|
|
59
|
+
if (res.ok) {
|
|
60
|
+
setKeychainWarning(res.keychainError ?? null)
|
|
61
|
+
setPhase("connected")
|
|
62
|
+
} else {
|
|
58
63
|
setError(`Keychain unlock failed: ${res.error}`)
|
|
59
64
|
setUserInput(savedUser ?? "")
|
|
60
65
|
setField("password")
|
|
@@ -84,9 +89,11 @@ export function SudoConnect() {
|
|
|
84
89
|
setPwInput("") // don't keep the secret in component state past the attempt
|
|
85
90
|
if (res.ok) {
|
|
86
91
|
setError(null)
|
|
92
|
+
setKeychainWarning(res.keychainError ?? null)
|
|
87
93
|
setPhase("connected")
|
|
88
94
|
} else {
|
|
89
95
|
setError(res.error)
|
|
96
|
+
setKeychainWarning(null)
|
|
90
97
|
setPhase("error")
|
|
91
98
|
}
|
|
92
99
|
})
|
|
@@ -99,6 +106,7 @@ export function SudoConnect() {
|
|
|
99
106
|
if (phase === "connected") {
|
|
100
107
|
if ((name === "x" || name === "d") && server) {
|
|
101
108
|
disconnectSudo(server.id)
|
|
109
|
+
setKeychainWarning(null)
|
|
102
110
|
// Saved in the Keychain → drop the session but offer a no-password
|
|
103
111
|
// reconnect instead of making them re-type a password we already hold.
|
|
104
112
|
if (saved && keychainAvailable) return setPhase("reconnect")
|
|
@@ -211,7 +219,13 @@ export function SudoConnect() {
|
|
|
211
219
|
<text content="Privileged actions on this server run for the rest of this" fg={theme.textDim} wrapMode="none" />
|
|
212
220
|
<text content="session — press K on a site to grant SpinupTUI's SSH key." fg={theme.textDim} wrapMode="none" />
|
|
213
221
|
<box style={{ height: 1 }} />
|
|
214
|
-
{
|
|
222
|
+
{keychainWarning ? (
|
|
223
|
+
<>
|
|
224
|
+
<text content={`⚠ Couldn't save to the Keychain: ${keychainWarning}`} fg={theme.warn} wrapMode="none" />
|
|
225
|
+
<text content="Connected for this session only — check whether your login" fg={theme.textFaint} wrapMode="none" />
|
|
226
|
+
<text content="keychain is locked, then reconnect." fg={theme.textFaint} wrapMode="none" />
|
|
227
|
+
</>
|
|
228
|
+
) : saved ? (
|
|
215
229
|
<>
|
|
216
230
|
<text content="Saved in the macOS Keychain — sudo unlocks automatically" fg={theme.textFaint} wrapMode="none" />
|
|
217
231
|
<text content="next time you open this server. Press f to forget it." fg={theme.textFaint} wrapMode="none" />
|