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 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.0...HEAD
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "0.22.0",
3
+ "version": "0.22.2",
4
4
  "description": "SpinupTUI — a terminal dashboard for browsing, monitoring, and managing your SpinupWP servers and sites.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,11 +27,20 @@ async function security(args: string[]): Promise<{ ok: boolean; stdout: string;
27
27
  }
28
28
  }
29
29
 
30
- // Store (or update) a server's sudo password. Returns false off macOS / on failure.
31
- export async function setSudoPassword(serverId: number, password: string): Promise<boolean> {
32
- if (!keychainAvailable()) return false
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
- return r.ok
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
@@ -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: [
@@ -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
- if (await setSudoPassword(server.id, password)) saved = new Set(sudoSaved).add(server.id)
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
  )
@@ -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 the selected site over SSH (privileged
133
- // write via the server's sudo user — the API can't do this). Capital K
134
- // (like L/V/N) since lowercase k is the vim "move up" binding.
135
- if (focus === "sites" && sites[siteIndex]) setGrantKeySite(sites[siteIndex])
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) setPhase("connected")
57
- else {
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
- {saved ? (
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" />