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! :)

Implement session management with OS keychain integration

- Save/load/delete session data from keychain
- Use @napi-rs/keyring for cross-platform support
- Track current session metadata
- Error handling for keychain operations
- Comprehensive unit tests with mocked keychain
- Fix AtpSessionData type compliance (add required 'active' field)

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

markbennett.ca ccebce98 10e77742

verified
+301 -12
+1 -5
.claude/settings.json
··· 1 1 { 2 2 "permissions": { 3 - "allow": [ 4 - "Bash(npm run test:*)", 5 - "Bash(npm run build:*)", 6 - "Bash(npm test:*)" 7 - ] 3 + "allow": ["Bash(npm run test:*)", "Bash(npm run build:*)", "Bash(npm test:*)"] 8 4 } 9 5 }
+1 -7
package.json
··· 26 26 "type": "git", 27 27 "url": "git@tangled.org:markbennett.ca/tangled-cli" 28 28 }, 29 - "keywords": [ 30 - "git", 31 - "tangled", 32 - "pds", 33 - "atproto", 34 - "cli" 35 - ], 29 + "keywords": ["git", "tangled", "pds", "atproto", "cli"], 36 30 "author": "Mark Bennett", 37 31 "license": "MIT", 38 32 "dependencies": {
+96
src/lib/session.ts
··· 1 + import type { AtpSessionData } from '@atproto/api'; 2 + import { AsyncEntry } from '@napi-rs/keyring'; 3 + 4 + const SERVICE_NAME = 'tangled-cli'; 5 + 6 + export interface SessionMetadata { 7 + handle: string; 8 + did: string; 9 + pds: string; 10 + lastUsed: string; // ISO timestamp 11 + } 12 + 13 + /** 14 + * Store session data in OS keychain 15 + * @param sessionData - Session data from AtpAgent 16 + */ 17 + export async function saveSession(sessionData: AtpSessionData): Promise<void> { 18 + try { 19 + const accountId = sessionData.did || sessionData.handle; 20 + if (!accountId) { 21 + throw new Error('Session data must include DID or handle'); 22 + } 23 + 24 + const serialized = JSON.stringify(sessionData); 25 + const entry = new AsyncEntry(SERVICE_NAME, accountId); 26 + await entry.setPassword(serialized); 27 + } catch (error) { 28 + throw new Error( 29 + `Failed to save session to keychain: ${error instanceof Error ? error.message : 'Unknown error'}` 30 + ); 31 + } 32 + } 33 + 34 + /** 35 + * Retrieve session data from OS keychain 36 + * @param accountId - User's DID or handle 37 + */ 38 + export async function loadSession(accountId: string): Promise<AtpSessionData | null> { 39 + try { 40 + const entry = new AsyncEntry(SERVICE_NAME, accountId); 41 + const serialized = await entry.getPassword(); 42 + if (!serialized) { 43 + return null; 44 + } 45 + return JSON.parse(serialized) as AtpSessionData; 46 + } catch (error) { 47 + throw new Error( 48 + `Failed to load session from keychain: ${error instanceof Error ? error.message : 'Unknown error'}` 49 + ); 50 + } 51 + } 52 + 53 + /** 54 + * Delete session from OS keychain 55 + * @param accountId - User's DID or handle 56 + */ 57 + export async function deleteSession(accountId: string): Promise<boolean> { 58 + try { 59 + const entry = new AsyncEntry(SERVICE_NAME, accountId); 60 + return await entry.deleteCredential(); 61 + } catch (error) { 62 + throw new Error( 63 + `Failed to delete session from keychain: ${error instanceof Error ? error.message : 'Unknown error'}` 64 + ); 65 + } 66 + } 67 + 68 + /** 69 + * Store metadata about current session for CLI to track active user 70 + * Uses a special "current" account in keychain 71 + */ 72 + export async function saveCurrentSessionMetadata(metadata: SessionMetadata): Promise<void> { 73 + const serialized = JSON.stringify(metadata); 74 + const entry = new AsyncEntry(SERVICE_NAME, 'current-session-metadata'); 75 + await entry.setPassword(serialized); 76 + } 77 + 78 + /** 79 + * Get metadata about current active session 80 + */ 81 + 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; 86 + } 87 + return JSON.parse(serialized) as SessionMetadata; 88 + } 89 + 90 + /** 91 + * Clear current session metadata 92 + */ 93 + export async function clearCurrentSessionMetadata(): Promise<void> { 94 + const entry = new AsyncEntry(SERVICE_NAME, 'current-session-metadata'); 95 + await entry.deleteCredential(); 96 + }
+203
tests/lib/session.test.ts
··· 1 + import type { AtpSessionData } from '@atproto/api'; 2 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 3 + import { 4 + type SessionMetadata, 5 + clearCurrentSessionMetadata, 6 + deleteSession, 7 + getCurrentSessionMetadata, 8 + loadSession, 9 + saveCurrentSessionMetadata, 10 + saveSession, 11 + } from '../../src/lib/session.js'; 12 + 13 + // Mock @napi-rs/keyring 14 + vi.mock('@napi-rs/keyring', () => { 15 + const mockStorage = new Map<string, string>(); 16 + 17 + return { 18 + AsyncEntry: vi.fn().mockImplementation((service: string, account: string) => { 19 + const key = `${service}:${account}`; 20 + 21 + return { 22 + setPassword: vi.fn().mockImplementation(async (password: string) => { 23 + mockStorage.set(key, password); 24 + }), 25 + getPassword: vi.fn().mockImplementation(async () => { 26 + return mockStorage.get(key) || null; 27 + }), 28 + deleteCredential: vi.fn().mockImplementation(async () => { 29 + return mockStorage.delete(key); 30 + }), 31 + }; 32 + }), 33 + // Export the storage for test access 34 + __mockStorage: mockStorage, 35 + }; 36 + }); 37 + 38 + describe('Session Management', () => { 39 + beforeEach(async () => { 40 + // Clear mock storage before each test 41 + // biome-ignore lint/suspicious/noExplicitAny: accessing mock-specific property 42 + const keyring = (await import('@napi-rs/keyring')) as any; 43 + if (keyring.__mockStorage instanceof Map) { 44 + keyring.__mockStorage.clear(); 45 + } 46 + vi.clearAllMocks(); 47 + }); 48 + 49 + afterEach(async () => { 50 + // Clean up after each test 51 + // biome-ignore lint/suspicious/noExplicitAny: accessing mock-specific property 52 + const keyring = (await import('@napi-rs/keyring')) as any; 53 + if (keyring.__mockStorage instanceof Map) { 54 + keyring.__mockStorage.clear(); 55 + } 56 + }); 57 + 58 + describe('saveSession', () => { 59 + it('should save session to keychain using DID', async () => { 60 + const sessionData: AtpSessionData = { 61 + did: 'did:plc:test123', 62 + handle: 'user.bsky.social', 63 + email: 'user@example.com', 64 + emailConfirmed: true, 65 + active: true, 66 + accessJwt: 'token123', 67 + refreshJwt: 'refresh123', 68 + }; 69 + 70 + await saveSession(sessionData); 71 + 72 + // Verify session was stored 73 + const loaded = await loadSession('did:plc:test123'); 74 + expect(loaded).toEqual(sessionData); 75 + }); 76 + 77 + it('should save and retrieve session with all required fields', async () => { 78 + const sessionData: AtpSessionData = { 79 + did: 'did:plc:test456', 80 + handle: 'user.bsky.social', 81 + email: 'user@example.com', 82 + emailConfirmed: true, 83 + active: true, 84 + accessJwt: 'token123', 85 + refreshJwt: 'refresh123', 86 + }; 87 + 88 + await saveSession(sessionData); 89 + 90 + // Verify session was stored (keyed by DID) 91 + const loaded = await loadSession('did:plc:test456'); 92 + expect(loaded).toEqual(sessionData); 93 + }); 94 + 95 + it('should throw error if session has no DID or handle', async () => { 96 + const sessionData = { 97 + email: 'user@example.com', 98 + accessJwt: 'token123', 99 + refreshJwt: 'refresh123', 100 + } as AtpSessionData; 101 + 102 + await expect(saveSession(sessionData)).rejects.toThrow( 103 + 'Session data must include DID or handle' 104 + ); 105 + }); 106 + }); 107 + 108 + describe('loadSession', () => { 109 + it('should load session from keychain', async () => { 110 + const sessionData: AtpSessionData = { 111 + did: 'did:plc:test123', 112 + handle: 'user.bsky.social', 113 + active: true, 114 + accessJwt: 'token123', 115 + refreshJwt: 'refresh123', 116 + }; 117 + 118 + await saveSession(sessionData); 119 + const result = await loadSession('did:plc:test123'); 120 + 121 + expect(result).toEqual(sessionData); 122 + }); 123 + 124 + it('should return null when session not found', async () => { 125 + const result = await loadSession('did:plc:notfound'); 126 + expect(result).toBeNull(); 127 + }); 128 + }); 129 + 130 + describe('deleteSession', () => { 131 + it('should delete session from keychain', async () => { 132 + const sessionData: AtpSessionData = { 133 + did: 'did:plc:test123', 134 + handle: 'user.bsky.social', 135 + active: true, 136 + accessJwt: 'token123', 137 + refreshJwt: 'refresh123', 138 + }; 139 + 140 + await saveSession(sessionData); 141 + 142 + // Verify session exists 143 + let loaded = await loadSession('did:plc:test123'); 144 + expect(loaded).toEqual(sessionData); 145 + 146 + // Delete session 147 + const deleted = await deleteSession('did:plc:test123'); 148 + expect(deleted).toBe(true); 149 + 150 + // Verify session no longer exists 151 + loaded = await loadSession('did:plc:test123'); 152 + expect(loaded).toBeNull(); 153 + }); 154 + 155 + it('should return false when deleting non-existent session', async () => { 156 + const deleted = await deleteSession('did:plc:notfound'); 157 + expect(deleted).toBe(false); 158 + }); 159 + }); 160 + 161 + describe('session metadata', () => { 162 + it('should save and load current session metadata', async () => { 163 + const metadata: SessionMetadata = { 164 + handle: 'user.bsky.social', 165 + did: 'did:plc:test123', 166 + pds: 'https://bsky.social', 167 + lastUsed: new Date().toISOString(), 168 + }; 169 + 170 + await saveCurrentSessionMetadata(metadata); 171 + const result = await getCurrentSessionMetadata(); 172 + 173 + expect(result).toEqual(metadata); 174 + }); 175 + 176 + it('should return null when no metadata exists', async () => { 177 + const result = await getCurrentSessionMetadata(); 178 + expect(result).toBeNull(); 179 + }); 180 + 181 + it('should clear current session metadata', async () => { 182 + const metadata: SessionMetadata = { 183 + handle: 'user.bsky.social', 184 + did: 'did:plc:test123', 185 + pds: 'https://bsky.social', 186 + lastUsed: new Date().toISOString(), 187 + }; 188 + 189 + await saveCurrentSessionMetadata(metadata); 190 + 191 + // Verify metadata exists 192 + let result = await getCurrentSessionMetadata(); 193 + expect(result).toEqual(metadata); 194 + 195 + // Clear metadata 196 + await clearCurrentSessionMetadata(); 197 + 198 + // Verify metadata no longer exists 199 + result = await getCurrentSessionMetadata(); 200 + expect(result).toBeNull(); 201 + }); 202 + }); 203 + });