spinuptui 0.22.1 → 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,15 @@ 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
+
14
23
  ## [0.22.1] - 2026-07-13
15
24
 
16
25
  ### Fixed
@@ -1176,7 +1185,8 @@ Initial tagged release.
1176
1185
  ### Notes
1177
1186
  - Read-only release: works with a SpinupWP **Read Only** API token.
1178
1187
 
1179
- [Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.22.1...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
1180
1190
  [0.22.1]: https://github.com/mwender/spinupwp-tui/compare/v0.22.0...v0.22.1
1181
1191
  [0.22.0]: https://github.com/mwender/spinupwp-tui/compare/v0.21.2...v0.22.0
1182
1192
  [0.21.2]: https://github.com/mwender/spinupwp-tui/compare/v0.21.1...v0.21.2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spinuptui",
3
- "version": "0.22.1",
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
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
  )
@@ -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" />