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

Configure Feed

Select the types of activity you want to include in your feed.

fix: report auth state based on session validation

Instead of trusting cached metadata, it actually ensures the session is valid,
and distinguishes between lack of auth and an expired session.

Co-authored-by: Claude (claude-opus-4-8) <noreply@anthropic.com>

Ken-ichi Ueda (Jun 3, 2026, 11:53 AM -0700) 05904f7c a1e29151

+101 -10
+21 -4
src/commands/auth.ts
··· 1 1 import { Command } from 'commander'; 2 2 import { createApiClient } from '../lib/api-client.js'; 3 - import { getCurrentSessionMetadata } from '../lib/session.js'; 3 + import { getCurrentSessionMetadata, KeychainAccessError } from '../lib/session.js'; 4 4 import { promptForLogin } from '../utils/prompts.js'; 5 5 6 6 /** ··· 78 78 try { 79 79 const session = await getCurrentSessionMetadata(); 80 80 81 - if (session) { 81 + if (!session) { 82 + console.log('✗ Not authenticated'); 83 + console.log('Run "tangled auth login" to authenticate'); 84 + return; 85 + } 86 + 87 + // Metadata only proves a login happened at some point. Validate the 88 + // stored session actually works (resumeSession refreshes the token) so 89 + // we don't report "Authenticated" for an expired session that every 90 + // other command would reject. 91 + const client = createApiClient(); 92 + const valid = await client.resumeSession(); 93 + 94 + if (valid) { 82 95 console.log('✓ Authenticated'); 83 96 console.log(` Handle: @${session.handle}`); 84 97 console.log(` DID: ${session.did}`); 85 98 console.log(` PDS: ${session.pds}`); 86 99 console.log(` Last used: ${new Date(session.lastUsed).toLocaleString()}`); 87 100 } else { 88 - console.log('✗ Not authenticated'); 89 - console.log('Run "tangled auth login" to authenticate'); 101 + console.log('✗ Session expired'); 102 + console.log('Run "tangled auth login" to re-authenticate'); 90 103 } 91 104 } catch (error) { 105 + if (error instanceof KeychainAccessError) { 106 + console.error('✗ Cannot access keychain. Please unlock your keychain and try again.'); 107 + process.exit(1); 108 + } 92 109 console.error( 93 110 `✗ Failed to check status: ${error instanceof Error ? error.message : 'Unknown error'}` 94 111 );
+11 -2
src/utils/auth-helpers.ts
··· 1 1 import { execSync } from 'node:child_process'; 2 2 import type { TangledApiClient } from '../lib/api-client.js'; 3 - import { KeychainAccessError } from '../lib/session.js'; 3 + import { getCurrentSessionMetadata, KeychainAccessError } from '../lib/session.js'; 4 4 5 5 /** 6 6 * Validate that the client is authenticated and has an active session ··· 43 43 try { 44 44 const authenticated = await client.resumeSession(); 45 45 if (!authenticated) { 46 - console.error('✗ Not authenticated. Run "tangled auth login" first.'); 46 + // Distinguish "never logged in" from "session expired" so the message is 47 + // actionable. resumeSession only clears metadata when stored credentials 48 + // are missing entirely, so surviving metadata means the credentials were 49 + // present but could not be refreshed (an expired session). 50 + const metadata = await getCurrentSessionMetadata(); 51 + if (metadata) { 52 + console.error('✗ Session expired. Run "tangled auth login" to re-authenticate.'); 53 + } else { 54 + console.error('✗ Not authenticated. Run "tangled auth login" first.'); 55 + } 47 56 process.exit(1); 48 57 } 49 58 } catch (error) {
+39 -3
tests/commands/auth.test.ts
··· 2 2 import { createAuthCommand } from '../../src/commands/auth.js'; 3 3 import * as apiClientModule from '../../src/lib/api-client.js'; 4 4 import * as sessionModule from '../../src/lib/session.js'; 5 + import { KeychainAccessError } from '../../src/lib/session.js'; 5 6 import * as promptsModule from '../../src/utils/prompts.js'; 6 7 import { mockSessionData, mockSessionMetadata } from '../helpers/mock-data.js'; 7 8 8 9 // Mock modules 9 10 vi.mock('../../src/lib/api-client.js'); 10 - vi.mock('../../src/lib/session.js'); 11 + vi.mock('../../src/lib/session.js', async (importOriginal) => { 12 + const actual = await importOriginal<typeof import('../../src/lib/session.js')>(); 13 + return { 14 + ...actual, 15 + getCurrentSessionMetadata: vi.fn(), 16 + }; 17 + }); 11 18 vi.mock('../../src/utils/prompts.js'); 12 19 13 20 describe('Auth Commands', () => { 14 21 let mockClient: { 15 22 login: ReturnType<typeof vi.fn>; 16 23 logout: ReturnType<typeof vi.fn>; 24 + resumeSession: ReturnType<typeof vi.fn>; 17 25 }; 18 26 let consoleLogSpy: ReturnType<typeof vi.fn>; 19 27 let consoleErrorSpy: ReturnType<typeof vi.fn>; ··· 26 34 mockClient = { 27 35 login: vi.fn(), 28 36 logout: vi.fn(), 37 + resumeSession: vi.fn(), 29 38 }; 30 39 vi.mocked(apiClientModule.createApiClient).mockReturnValue(mockClient as never); 31 40 ··· 126 135 }); 127 136 128 137 describe('status command', () => { 129 - it('should show authenticated status with session details', async () => { 138 + it('should show authenticated status when the session validates', async () => { 130 139 vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); 140 + mockClient.resumeSession.mockResolvedValue(true); 131 141 132 142 const auth = createAuthCommand(); 133 143 await auth.parseAsync(['node', 'test', 'status']); 134 144 145 + expect(mockClient.resumeSession).toHaveBeenCalled(); 135 146 expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Authenticated')); 136 147 expect(consoleLogSpy).toHaveBeenCalledWith( 137 148 expect.stringContaining(`@${mockSessionMetadata.handle}`) ··· 139 150 expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining(mockSessionMetadata.did)); 140 151 }); 141 152 142 - it('should show not authenticated status', async () => { 153 + it('should show session expired when metadata exists but resume fails', async () => { 154 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); 155 + mockClient.resumeSession.mockResolvedValue(false); 156 + 157 + const auth = createAuthCommand(); 158 + await auth.parseAsync(['node', 'test', 'status']); 159 + 160 + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Session expired')); 161 + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('tangled auth login')); 162 + // Must not falsely claim the user is authenticated. 163 + expect(consoleLogSpy).not.toHaveBeenCalledWith(expect.stringContaining('✓ Authenticated')); 164 + }); 165 + 166 + it('should show not authenticated status when no metadata exists', async () => { 143 167 vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); 144 168 145 169 const auth = createAuthCommand(); 146 170 await auth.parseAsync(['node', 'test', 'status']); 147 171 172 + expect(mockClient.resumeSession).not.toHaveBeenCalled(); 148 173 expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Not authenticated')); 149 174 expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('tangled auth login')); 175 + }); 176 + 177 + it('should report a locked keychain distinctly', async () => { 178 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); 179 + mockClient.resumeSession.mockRejectedValue(new KeychainAccessError('locked')); 180 + 181 + const auth = createAuthCommand(); 182 + await expect(auth.parseAsync(['node', 'test', 'status'])).rejects.toThrow('process.exit(1)'); 183 + 184 + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('keychain')); 185 + expect(processExitSpy).toHaveBeenCalledWith(1); 150 186 }); 151 187 152 188 it('should handle status check errors gracefully', async () => {
+30 -1
tests/utils/auth-helpers.test.ts
··· 1 1 import { execSync } from 'node:child_process'; 2 2 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 3 3 import type { TangledApiClient } from '../../src/lib/api-client.js'; 4 + import * as sessionModule from '../../src/lib/session.js'; 4 5 import { KeychainAccessError } from '../../src/lib/session.js'; 5 6 import { ensureAuthenticated, requireAuth } from '../../src/utils/auth-helpers.js'; 6 7 7 8 vi.mock('node:child_process', () => ({ 8 9 execSync: vi.fn(), 9 10 })); 11 + 12 + vi.mock('../../src/lib/session.js', async (importOriginal) => { 13 + const actual = await importOriginal<typeof import('../../src/lib/session.js')>(); 14 + return { 15 + ...actual, 16 + getCurrentSessionMetadata: vi.fn(), 17 + }; 18 + }); 10 19 11 20 // Mock API client factory 12 21 const createMockClient = ( ··· 56 65 }); 57 66 mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); 58 67 vi.mocked(execSync).mockReset(); 68 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); 59 69 }); 60 70 61 71 afterEach(() => { ··· 72 82 expect(mockExit).not.toHaveBeenCalled(); 73 83 }); 74 84 75 - it('should exit with error when not authenticated', async () => { 85 + it('should exit with "not authenticated" when resume fails and no metadata exists', async () => { 76 86 const mockClient = { 77 87 resumeSession: vi.fn().mockResolvedValue(false), 78 88 } as unknown as TangledApiClient; 89 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); 79 90 80 91 await expect(ensureAuthenticated(mockClient)).rejects.toThrow('process.exit called'); 81 92 expect(mockConsoleError).toHaveBeenCalledWith( 82 93 '✗ Not authenticated. Run "tangled auth login" first.' 94 + ); 95 + expect(mockExit).toHaveBeenCalledWith(1); 96 + }); 97 + 98 + it('should exit with "session expired" when resume fails but metadata exists', async () => { 99 + const mockClient = { 100 + resumeSession: vi.fn().mockResolvedValue(false), 101 + } as unknown as TangledApiClient; 102 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue({ 103 + handle: 'user.bsky.social', 104 + did: 'did:plc:test123', 105 + pds: 'https://bsky.social', 106 + lastUsed: '2024-01-01T00:00:00.000Z', 107 + }); 108 + 109 + await expect(ensureAuthenticated(mockClient)).rejects.toThrow('process.exit called'); 110 + expect(mockConsoleError).toHaveBeenCalledWith( 111 + '✗ Session expired. Run "tangled auth login" to re-authenticate.' 83 112 ); 84 113 expect(mockExit).toHaveBeenCalledWith(1); 85 114 });