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! :)
at feature/issue-4-pr-create-list-view 115 lines 3.4 kB view raw
1import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; 2import { homedir } from 'node:os'; 3import { join } from 'node:path'; 4import type { AtpSessionData } from '@atproto/api'; 5import { AsyncEntry } from '@napi-rs/keyring'; 6 7const SERVICE_NAME = 'tangled-cli'; 8const SESSION_METADATA_PATH = join(homedir(), '.config', 'tangled', 'session.json'); 9 10export class KeychainAccessError extends Error { 11 constructor(message: string) { 12 super(message); 13 this.name = 'KeychainAccessError'; 14 } 15} 16 17export interface SessionMetadata { 18 handle: string; 19 did: string; 20 pds: string; 21 lastUsed: string; // ISO timestamp 22} 23 24/** 25 * Store session data in OS keychain 26 * @param sessionData - Session data from AtpAgent 27 */ 28export async function saveSession(sessionData: AtpSessionData): Promise<void> { 29 try { 30 const accountId = sessionData.did || sessionData.handle; 31 if (!accountId) { 32 throw new Error('Session data must include DID or handle'); 33 } 34 35 const serialized = JSON.stringify(sessionData); 36 const entry = new AsyncEntry(SERVICE_NAME, accountId); 37 await entry.setPassword(serialized); 38 } catch (error) { 39 throw new Error( 40 `Failed to save session to keychain: ${error instanceof Error ? error.message : 'Unknown error'}` 41 ); 42 } 43} 44 45/** 46 * Retrieve session data from OS keychain 47 * @param accountId - User's DID or handle 48 */ 49export async function loadSession(accountId: string): Promise<AtpSessionData | null> { 50 try { 51 const entry = new AsyncEntry(SERVICE_NAME, accountId); 52 const serialized = await entry.getPassword(); 53 if (!serialized) { 54 return null; 55 } 56 return JSON.parse(serialized) as AtpSessionData; 57 } catch (error) { 58 throw new KeychainAccessError( 59 `Cannot access keychain: ${error instanceof Error ? error.message : 'Unknown error'}` 60 ); 61 } 62} 63 64/** 65 * Delete session from OS keychain 66 * @param accountId - User's DID or handle 67 */ 68export async function deleteSession(accountId: string): Promise<boolean> { 69 try { 70 const entry = new AsyncEntry(SERVICE_NAME, accountId); 71 return await entry.deleteCredential(); 72 } catch (error) { 73 throw new Error( 74 `Failed to delete session from keychain: ${error instanceof Error ? error.message : 'Unknown error'}` 75 ); 76 } 77} 78 79/** 80 * Store metadata about current session for CLI to track active user. 81 * Written to a plain file — metadata is not secret and must be readable 82 * even when the keychain is locked (e.g. after sleep/wake). 83 */ 84export async function saveCurrentSessionMetadata(metadata: SessionMetadata): Promise<void> { 85 await mkdir(join(homedir(), '.config', 'tangled'), { recursive: true }); 86 await writeFile(SESSION_METADATA_PATH, JSON.stringify(metadata, null, 2), 'utf-8'); 87} 88 89/** 90 * Get metadata about current active session 91 */ 92export async function getCurrentSessionMetadata(): Promise<SessionMetadata | null> { 93 try { 94 const content = await readFile(SESSION_METADATA_PATH, 'utf-8'); 95 return JSON.parse(content) as SessionMetadata; 96 } catch (error) { 97 if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 98 return null; 99 } 100 throw error; 101 } 102} 103 104/** 105 * Clear current session metadata 106 */ 107export async function clearCurrentSessionMetadata(): Promise<void> { 108 try { 109 await unlink(SESSION_METADATA_PATH); 110 } catch (error) { 111 if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { 112 throw error; 113 } 114 } 115}