WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)

Fix #17: detect locked keychain and preserve credentials on access failure

- Export KeychainAccessError from session.ts; thrown when getPassword() fails
(platform error like locked keychain), not when an entry is missing (undefined)
- resumeSession() now rethrows KeychainAccessError without clearing metadata,
so temporarily locked keychains no longer wipe stored credentials
- Add ensureAuthenticated(client) to auth-helpers.ts: on KeychainAccessError,
attempts to unlock the keychain via `security unlock-keychain` (macOS only),
retries once, then falls back to a clear error message with manual instructions
- Replace 7 repeated inline auth-check blocks in issue.ts with ensureAuthenticated()
- Add/update tests for KeychainAccessError propagation and ensureAuthenticated behavior

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

authored by markbennett.ca

Claude Sonnet 4.5 and committed by tangled.org 265ca911 168b5a33

+238 -58
+8 -29
src/commands/issue.ts
··· 16 16 } from '../lib/issues-api.js'; 17 17 import type { IssueData } from '../lib/issues-api.js'; 18 18 import { buildRepoAtUri } from '../utils/at-uri.js'; 19 - import { requireAuth } from '../utils/auth-helpers.js'; 19 + import { ensureAuthenticated, requireAuth } from '../utils/auth-helpers.js'; 20 20 import { readBodyInput } from '../utils/body-input.js'; 21 21 import { formatDate, formatIssueState, outputJson } from '../utils/formatting.js'; 22 22 import { validateIssueBody, validateIssueTitle } from '../utils/validation.js'; ··· 112 112 try { 113 113 // 1. Validate auth 114 114 const client = createApiClient(); 115 - if (!(await client.resumeSession())) { 116 - console.error('✗ Not authenticated. Run "tangled auth login" first.'); 117 - process.exit(1); 118 - } 115 + await ensureAuthenticated(client); 119 116 120 117 // 2. Get repo context 121 118 const context = await getCurrentRepoContext(); ··· 188 185 189 186 // 2. Validate auth 190 187 const client = createApiClient(); 191 - if (!(await client.resumeSession())) { 192 - console.error('✗ Not authenticated. Run "tangled auth login" first.'); 193 - process.exit(1); 194 - } 188 + await ensureAuthenticated(client); 195 189 196 190 // 3. Get repo context 197 191 const context = await getCurrentRepoContext(); ··· 271 265 try { 272 266 // 1. Validate auth 273 267 const client = createApiClient(); 274 - if (!(await client.resumeSession())) { 275 - console.error('✗ Not authenticated. Run "tangled auth login" first.'); 276 - process.exit(1); 277 - } 268 + await ensureAuthenticated(client); 278 269 279 270 // 2. Get repo context 280 271 const context = await getCurrentRepoContext(); ··· 331 322 try { 332 323 // 1. Validate auth 333 324 const client = createApiClient(); 334 - if (!(await client.resumeSession())) { 335 - console.error('✗ Not authenticated. Run "tangled auth login" first.'); 336 - process.exit(1); 337 - } 325 + await ensureAuthenticated(client); 338 326 339 327 // 2. Get repo context 340 328 const context = await getCurrentRepoContext(); ··· 391 379 .action(async (issueId: string, options: { force?: boolean; json?: string | true }) => { 392 380 // 1. Validate auth 393 381 const client = createApiClient(); 394 - if (!(await client.resumeSession())) { 395 - console.error('✗ Not authenticated. Run "tangled auth login" first.'); 396 - process.exit(1); 397 - } 382 + await ensureAuthenticated(client); 398 383 399 384 // 2. Get repo context 400 385 const context = await getCurrentRepoContext(); ··· 487 472 try { 488 473 // 1. Validate auth 489 474 const client = createApiClient(); 490 - if (!(await client.resumeSession())) { 491 - console.error('✗ Not authenticated. Run "tangled auth login" first.'); 492 - process.exit(1); 493 - } 475 + await ensureAuthenticated(client); 494 476 495 477 // 2. Get repo context 496 478 const context = await getCurrentRepoContext(); ··· 574 556 try { 575 557 // 1. Validate auth 576 558 const client = createApiClient(); 577 - if (!(await client.resumeSession())) { 578 - console.error('✗ Not authenticated. Run "tangled auth login" first.'); 579 - process.exit(1); 580 - } 559 + await ensureAuthenticated(client); 581 560 582 561 // 2. Get repo context 583 562 const context = await getCurrentRepoContext();
+6 -1
src/lib/api-client.ts
··· 1 1 import { AtpAgent } from '@atproto/api'; 2 2 import type { AtpSessionData } from '@atproto/api'; 3 3 import { 4 + KeychainAccessError, 4 5 clearCurrentSessionMetadata, 5 6 deleteSession, 6 7 getCurrentSessionMetadata, ··· 106 107 107 108 return true; 108 109 } catch (error) { 109 - // If resume fails, clear invalid session 110 + if (error instanceof KeychainAccessError) { 111 + // Don't clear credentials — keychain may just be temporarily locked 112 + throw error; 113 + } 114 + // Session data invalid or agent resume failed — clear stale state 110 115 await clearCurrentSessionMetadata(); 111 116 return false; 112 117 }
+20 -7
src/lib/session.ts
··· 3 3 4 4 const SERVICE_NAME = 'tangled-cli'; 5 5 6 + export class KeychainAccessError extends Error { 7 + constructor(message: string) { 8 + super(message); 9 + this.name = 'KeychainAccessError'; 10 + } 11 + } 12 + 6 13 export interface SessionMetadata { 7 14 handle: string; 8 15 did: string; ··· 44 51 } 45 52 return JSON.parse(serialized) as AtpSessionData; 46 53 } catch (error) { 47 - throw new Error( 48 - `Failed to load session from keychain: ${error instanceof Error ? error.message : 'Unknown error'}` 54 + throw new KeychainAccessError( 55 + `Cannot access keychain: ${error instanceof Error ? error.message : 'Unknown error'}` 49 56 ); 50 57 } 51 58 } ··· 79 86 * Get metadata about current active session 80 87 */ 81 88 export async function getCurrentSessionMetadata(): Promise<SessionMetadata | null> { 82 - const entry = new AsyncEntry(SERVICE_NAME, 'current-session-metadata'); 83 - const serialized = await entry.getPassword(); 84 - if (!serialized) { 85 - return null; 89 + try { 90 + const entry = new AsyncEntry(SERVICE_NAME, 'current-session-metadata'); 91 + const serialized = await entry.getPassword(); 92 + if (!serialized) { 93 + return null; 94 + } 95 + return JSON.parse(serialized) as SessionMetadata; 96 + } catch (error) { 97 + throw new KeychainAccessError( 98 + `Cannot access keychain: ${error instanceof Error ? error.message : 'Unknown error'}` 99 + ); 86 100 } 87 - return JSON.parse(serialized) as SessionMetadata; 88 101 } 89 102 90 103 /**
+45 -1
src/utils/auth-helpers.ts
··· 1 + import { execSync } from 'node:child_process'; 1 2 import type { TangledApiClient } from '../lib/api-client.js'; 3 + import { KeychainAccessError } from '../lib/session.js'; 2 4 3 5 /** 4 6 * Validate that the client is authenticated and has an active session ··· 9 11 did: string; 10 12 handle: string; 11 13 }> { 12 - if (!(await client.isAuthenticated())) { 14 + if (!client.isAuthenticated()) { 13 15 throw new Error('Must be authenticated. Run "tangled auth login" first.'); 14 16 } 15 17 ··· 20 22 21 23 return session; 22 24 } 25 + 26 + function tryUnlockKeychain(): boolean { 27 + if (process.platform !== 'darwin') return false; 28 + try { 29 + execSync('security unlock-keychain', { stdio: 'inherit' }); 30 + return true; 31 + } catch { 32 + return false; 33 + } 34 + } 35 + 36 + /** 37 + * Resume session and ensure the client is authenticated. 38 + * On macOS, if the keychain is locked, attempts to unlock it interactively 39 + * via `security unlock-keychain` before falling back to an error message. 40 + * Exits the process with a clear error message if authentication fails. 41 + */ 42 + export async function ensureAuthenticated(client: TangledApiClient): Promise<void> { 43 + try { 44 + const authenticated = await client.resumeSession(); 45 + if (!authenticated) { 46 + console.error('✗ Not authenticated. Run "tangled auth login" first.'); 47 + process.exit(1); 48 + } 49 + } catch (error) { 50 + if (error instanceof KeychainAccessError) { 51 + const unlocked = tryUnlockKeychain(); 52 + if (unlocked) { 53 + try { 54 + const retried = await client.resumeSession(); 55 + if (retried) return; 56 + } catch { 57 + // fall through to error message 58 + } 59 + } 60 + console.error('✗ Cannot access keychain. Please unlock your Mac keychain and try again.'); 61 + console.error(' You can unlock it manually with: security unlock-keychain'); 62 + process.exit(1); 63 + } 64 + throw error; 65 + } 66 + }
+28 -7
tests/commands/issue.test.ts
··· 163 163 164 164 describe('authentication required', () => { 165 165 it('should fail when not authenticated', async () => { 166 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 166 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 167 + console.error('✗ Not authenticated. Run "tangled auth login" first.'); 168 + process.exit(1); 169 + }); 167 170 168 171 const command = createIssueCommand(); 169 172 ··· 419 422 420 423 describe('authentication required', () => { 421 424 it('should fail when not authenticated', async () => { 422 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 425 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 426 + console.error('✗ Not authenticated. Run "tangled auth login" first.'); 427 + process.exit(1); 428 + }); 423 429 424 430 const command = createIssueCommand(); 425 431 ··· 689 695 }); 690 696 691 697 it('should fail when not authenticated', async () => { 692 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 698 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 699 + console.error('✗ Not authenticated. Run "tangled auth login" first.'); 700 + process.exit(1); 701 + }); 693 702 694 703 const command = createIssueCommand(); 695 704 await expect(command.parseAsync(['node', 'test', 'view', '1'])).rejects.toThrow( ··· 886 895 }); 887 896 888 897 it('should fail when not authenticated', async () => { 889 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 898 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 899 + console.error('✗ Not authenticated. Run "tangled auth login" first.'); 900 + process.exit(1); 901 + }); 890 902 891 903 const command = createIssueCommand(); 892 904 await expect( ··· 1028 1040 }); 1029 1041 1030 1042 it('should fail when not authenticated', async () => { 1031 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 1043 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 1044 + console.error('✗ Not authenticated. Run "tangled auth login" first.'); 1045 + process.exit(1); 1046 + }); 1032 1047 1033 1048 const command = createIssueCommand(); 1034 1049 await expect(command.parseAsync(['node', 'test', 'close', '1'])).rejects.toThrow( ··· 1142 1157 }); 1143 1158 1144 1159 it('should fail when not authenticated', async () => { 1145 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 1160 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 1161 + console.error('✗ Not authenticated. Run "tangled auth login" first.'); 1162 + process.exit(1); 1163 + }); 1146 1164 1147 1165 const command = createIssueCommand(); 1148 1166 await expect(command.parseAsync(['node', 'test', 'reopen', '1'])).rejects.toThrow( ··· 1294 1312 }); 1295 1313 1296 1314 it('should fail when not authenticated', async () => { 1297 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 1315 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 1316 + console.error('✗ Not authenticated. Run "tangled auth login" first.'); 1317 + process.exit(1); 1318 + }); 1298 1319 1299 1320 const command = createIssueCommand(); 1300 1321 await expect(command.parseAsync(['node', 'test', 'delete', '1', '--force'])).rejects.toThrow(
+23 -9
tests/lib/api-client.test.ts
··· 1 1 import type { AtpSessionData } from '@atproto/api'; 2 2 import { beforeEach, describe, expect, it, vi } from 'vitest'; 3 3 import { TangledApiClient } from '../../src/lib/api-client.js'; 4 + import { KeychainAccessError } from '../../src/lib/session.js'; 4 5 import * as sessionModule from '../../src/lib/session.js'; 5 6 import { mockSessionData, mockSessionMetadata } from '../helpers/mock-data.js'; 6 7 ··· 30 31 }; 31 32 }); 32 33 33 - // Mock session management 34 - vi.mock('../../src/lib/session.js', () => ({ 35 - saveSession: vi.fn(), 36 - loadSession: vi.fn(), 37 - deleteSession: vi.fn(), 38 - saveCurrentSessionMetadata: vi.fn(), 39 - getCurrentSessionMetadata: vi.fn(), 40 - clearCurrentSessionMetadata: vi.fn(), 41 - })); 34 + // Mock session management (use importOriginal to preserve KeychainAccessError class) 35 + vi.mock('../../src/lib/session.js', async (importOriginal) => { 36 + const actual = await importOriginal<typeof import('../../src/lib/session.js')>(); 37 + return { 38 + ...actual, 39 + saveSession: vi.fn(), 40 + loadSession: vi.fn(), 41 + deleteSession: vi.fn(), 42 + saveCurrentSessionMetadata: vi.fn(), 43 + getCurrentSessionMetadata: vi.fn(), 44 + clearCurrentSessionMetadata: vi.fn(), 45 + }; 46 + }); 42 47 43 48 describe('TangledApiClient', () => { 44 49 let client: TangledApiClient; ··· 149 154 150 155 expect(resumed).toBe(false); 151 156 expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).toHaveBeenCalled(); 157 + }); 158 + 159 + it('should rethrow KeychainAccessError without clearing metadata', async () => { 160 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockRejectedValueOnce( 161 + new KeychainAccessError('Cannot access keychain: locked') 162 + ); 163 + 164 + await expect(client.resumeSession()).rejects.toThrow(KeychainAccessError); 165 + expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).not.toHaveBeenCalled(); 152 166 }); 153 167 }); 154 168
+1 -1
tests/lib/issues-api.test.ts
··· 30 30 }; 31 31 32 32 return { 33 - isAuthenticated: vi.fn(async () => authenticated), 33 + isAuthenticated: vi.fn(() => authenticated), 34 34 getSession: vi.fn(() => 35 35 authenticated ? { did: 'did:plc:test123', handle: 'test.bsky.social' } : null 36 36 ),
+107 -3
tests/utils/auth-helpers.test.ts
··· 1 - import { describe, expect, it, vi } from 'vitest'; 1 + import { execSync } from 'node:child_process'; 2 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 3 import type { TangledApiClient } from '../../src/lib/api-client.js'; 3 - import { requireAuth } from '../../src/utils/auth-helpers.js'; 4 + import { KeychainAccessError } from '../../src/lib/session.js'; 5 + import { ensureAuthenticated, requireAuth } from '../../src/utils/auth-helpers.js'; 6 + 7 + vi.mock('node:child_process', () => ({ 8 + execSync: vi.fn(), 9 + })); 4 10 5 11 // Mock API client factory 6 12 const createMockClient = ( ··· 8 14 session: { did: string; handle: string } | null 9 15 ): TangledApiClient => { 10 16 return { 11 - isAuthenticated: vi.fn(async () => authenticated), 17 + isAuthenticated: vi.fn(() => authenticated), 12 18 getSession: vi.fn(() => session), 13 19 } as unknown as TangledApiClient; 14 20 }; ··· 37 43 await expect(requireAuth(mockClient)).rejects.toThrow('No active session found'); 38 44 }); 39 45 }); 46 + 47 + describe('ensureAuthenticated', () => { 48 + // biome-ignore lint/suspicious/noExplicitAny: spy instance types vary by platform signature 49 + let mockExit: any; 50 + // biome-ignore lint/suspicious/noExplicitAny: spy instance types vary by platform signature 51 + let mockConsoleError: any; 52 + 53 + beforeEach(() => { 54 + mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { 55 + throw new Error('process.exit called'); 56 + }); 57 + mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); 58 + vi.mocked(execSync).mockReset(); 59 + }); 60 + 61 + afterEach(() => { 62 + mockExit.mockRestore(); 63 + mockConsoleError.mockRestore(); 64 + }); 65 + 66 + it('should return normally when resumeSession succeeds', async () => { 67 + const mockClient = { 68 + resumeSession: vi.fn().mockResolvedValue(true), 69 + } as unknown as TangledApiClient; 70 + 71 + await expect(ensureAuthenticated(mockClient)).resolves.toBeUndefined(); 72 + expect(mockExit).not.toHaveBeenCalled(); 73 + }); 74 + 75 + it('should exit with error when not authenticated', async () => { 76 + const mockClient = { 77 + resumeSession: vi.fn().mockResolvedValue(false), 78 + } as unknown as TangledApiClient; 79 + 80 + await expect(ensureAuthenticated(mockClient)).rejects.toThrow('process.exit called'); 81 + expect(mockConsoleError).toHaveBeenCalledWith( 82 + '✗ Not authenticated. Run "tangled auth login" first.' 83 + ); 84 + expect(mockExit).toHaveBeenCalledWith(1); 85 + }); 86 + 87 + it('should unlock keychain and retry when KeychainAccessError is thrown', async () => { 88 + const mockClient = { 89 + resumeSession: vi 90 + .fn() 91 + .mockRejectedValueOnce(new KeychainAccessError('locked')) 92 + .mockResolvedValueOnce(true), 93 + } as unknown as TangledApiClient; 94 + 95 + vi.mocked(execSync).mockReturnValue(Buffer.from('')); 96 + 97 + await expect(ensureAuthenticated(mockClient)).resolves.toBeUndefined(); 98 + expect(execSync).toHaveBeenCalledWith('security unlock-keychain', { stdio: 'inherit' }); 99 + expect(mockExit).not.toHaveBeenCalled(); 100 + }); 101 + 102 + it('should exit with keychain error when unlock fails', async () => { 103 + const mockClient = { 104 + resumeSession: vi.fn().mockRejectedValue(new KeychainAccessError('locked')), 105 + } as unknown as TangledApiClient; 106 + 107 + vi.mocked(execSync).mockImplementation(() => { 108 + throw new Error('unlock failed'); 109 + }); 110 + 111 + await expect(ensureAuthenticated(mockClient)).rejects.toThrow('process.exit called'); 112 + expect(mockConsoleError).toHaveBeenCalledWith( 113 + '✗ Cannot access keychain. Please unlock your Mac keychain and try again.' 114 + ); 115 + expect(mockExit).toHaveBeenCalledWith(1); 116 + }); 117 + 118 + it('should exit with keychain error when unlock succeeds but retry fails', async () => { 119 + const mockClient = { 120 + resumeSession: vi 121 + .fn() 122 + .mockRejectedValueOnce(new KeychainAccessError('locked')) 123 + .mockRejectedValueOnce(new KeychainAccessError('still locked')), 124 + } as unknown as TangledApiClient; 125 + 126 + vi.mocked(execSync).mockReturnValue(Buffer.from('')); 127 + 128 + await expect(ensureAuthenticated(mockClient)).rejects.toThrow('process.exit called'); 129 + expect(mockConsoleError).toHaveBeenCalledWith( 130 + '✗ Cannot access keychain. Please unlock your Mac keychain and try again.' 131 + ); 132 + expect(mockExit).toHaveBeenCalledWith(1); 133 + }); 134 + 135 + it('should rethrow unexpected errors', async () => { 136 + const mockClient = { 137 + resumeSession: vi.fn().mockRejectedValue(new Error('unexpected network error')), 138 + } as unknown as TangledApiClient; 139 + 140 + await expect(ensureAuthenticated(mockClient)).rejects.toThrow('unexpected network error'); 141 + expect(mockExit).not.toHaveBeenCalled(); 142 + }); 143 + });