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

Configure Feed

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

Implement auth commands (login, logout, status)

- Add tangled auth login command with credential prompts
- Add tangled auth logout command with session cleanup
- Add tangled auth status command to check authentication state
- Prevent duplicate logins when already authenticated
- Wire up auth commands to main CLI (src/index.ts)
- Add 9 comprehensive tests for all auth command scenarios
- Mock process.exit to properly test early returns

All 56 tests passing.

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

+269 -8
+100
src/commands/auth.ts
··· 1 + import { Command } from 'commander'; 2 + import { createApiClient } from '../lib/api-client.js'; 3 + import { getCurrentSessionMetadata } from '../lib/session.js'; 4 + import { promptForLogin } from '../utils/prompts.js'; 5 + 6 + /** 7 + * Create the auth command with login and logout subcommands 8 + */ 9 + export function createAuthCommand(): Command { 10 + const auth = new Command('auth'); 11 + auth.description('Manage authentication with AT Protocol'); 12 + 13 + // Login command 14 + auth 15 + .command('login') 16 + .description('Login to your AT Protocol account') 17 + .action(async () => { 18 + try { 19 + const client = createApiClient(); 20 + 21 + // Check if already logged in 22 + const existingSession = await getCurrentSessionMetadata(); 23 + if (existingSession) { 24 + console.log(`Already logged in as @${existingSession.handle}`); 25 + console.log('Run "tangled auth logout" first to switch accounts'); 26 + process.exit(0); 27 + } 28 + 29 + // Prompt for credentials 30 + const { identifier, password } = await promptForLogin(); 31 + 32 + // Attempt login 33 + console.log('\nAuthenticating...'); 34 + const session = await client.login(identifier, password); 35 + 36 + console.log(`\n✓ Successfully logged in as @${session.handle}`); 37 + console.log(` DID: ${session.did}`); 38 + } catch (error) { 39 + console.error( 40 + `\n✗ Login failed: ${error instanceof Error ? error.message : 'Unknown error'}` 41 + ); 42 + process.exit(1); 43 + } 44 + }); 45 + 46 + // Logout command 47 + auth 48 + .command('logout') 49 + .description('Logout and clear stored credentials') 50 + .action(async () => { 51 + try { 52 + const client = createApiClient(); 53 + 54 + // Check if logged in 55 + const session = await getCurrentSessionMetadata(); 56 + if (!session) { 57 + console.log('Not currently logged in'); 58 + process.exit(0); 59 + } 60 + 61 + // Perform logout 62 + await client.logout(); 63 + 64 + console.log(`✓ Logged out @${session.handle}`); 65 + } catch (error) { 66 + console.error( 67 + `✗ Logout failed: ${error instanceof Error ? error.message : 'Unknown error'}` 68 + ); 69 + process.exit(1); 70 + } 71 + }); 72 + 73 + // Status command 74 + auth 75 + .command('status') 76 + .description('Check authentication status') 77 + .action(async () => { 78 + try { 79 + const session = await getCurrentSessionMetadata(); 80 + 81 + if (session) { 82 + console.log('✓ Authenticated'); 83 + console.log(` Handle: @${session.handle}`); 84 + console.log(` DID: ${session.did}`); 85 + console.log(` PDS: ${session.pds}`); 86 + console.log(` Last used: ${new Date(session.lastUsed).toLocaleString()}`); 87 + } else { 88 + console.log('✗ Not authenticated'); 89 + console.log('Run "tangled auth login" to authenticate'); 90 + } 91 + } catch (error) { 92 + console.error( 93 + `✗ Failed to check status: ${error instanceof Error ? error.message : 'Unknown error'}` 94 + ); 95 + process.exit(1); 96 + } 97 + }); 98 + 99 + return auth; 100 + }
+3 -8
src/index.ts
··· 3 3 import { dirname, join } from 'node:path'; 4 4 import { fileURLToPath } from 'node:url'; 5 5 import { Command } from 'commander'; 6 + import { createAuthCommand } from './commands/auth.js'; 6 7 7 8 // Get package.json for version 8 9 const __filename = fileURLToPath(import.meta.url); ··· 16 17 .description('A CLI for Tangled.org - AT Protocol-based Git hosting') 17 18 .version(packageJson.version, '-v, --version', 'Output the current version'); 18 19 19 - // Future command registrations will go here 20 - // Example: 21 - // program 22 - // .command('auth') 23 - // .description('Authenticate with Tangled.org') 24 - // .action(() => { 25 - // console.log('Auth command coming soon!'); 26 - // }); 20 + // Register commands 21 + program.addCommand(createAuthCommand()); 27 22 28 23 program.parse(process.argv);
+166
tests/commands/auth.test.ts
··· 1 + import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 + import { createAuthCommand } from '../../src/commands/auth.js'; 3 + import * as apiClientModule from '../../src/lib/api-client.js'; 4 + import * as sessionModule from '../../src/lib/session.js'; 5 + import * as promptsModule from '../../src/utils/prompts.js'; 6 + import { mockSessionData, mockSessionMetadata } from '../helpers/mock-data.js'; 7 + 8 + // Mock modules 9 + vi.mock('../../src/lib/api-client.js'); 10 + vi.mock('../../src/lib/session.js'); 11 + vi.mock('../../src/utils/prompts.js'); 12 + 13 + describe('Auth Commands', () => { 14 + let mockClient: { 15 + login: ReturnType<typeof vi.fn>; 16 + logout: ReturnType<typeof vi.fn>; 17 + }; 18 + let consoleLogSpy: ReturnType<typeof vi.fn>; 19 + let consoleErrorSpy: ReturnType<typeof vi.fn>; 20 + let processExitSpy: ReturnType<typeof vi.fn>; 21 + 22 + beforeEach(() => { 23 + vi.clearAllMocks(); 24 + 25 + // Mock API client 26 + mockClient = { 27 + login: vi.fn(), 28 + logout: vi.fn(), 29 + }; 30 + vi.mocked(apiClientModule.createApiClient).mockReturnValue(mockClient as never); 31 + 32 + // Mock console methods 33 + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 34 + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 35 + 36 + // Mock process.exit to throw to stop execution (mimicking real behavior) 37 + processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { 38 + throw new Error(`process.exit(${code})`); 39 + }) as never; 40 + }); 41 + 42 + describe('login command', () => { 43 + it('should login successfully with valid credentials', async () => { 44 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); 45 + vi.mocked(promptsModule.promptForLogin).mockResolvedValue({ 46 + identifier: 'user.bsky.social', 47 + password: 'test-password', 48 + }); 49 + mockClient.login.mockResolvedValue(mockSessionData); 50 + 51 + const auth = createAuthCommand(); 52 + await auth.parseAsync(['node', 'test', 'login']); 53 + 54 + expect(promptsModule.promptForLogin).toHaveBeenCalled(); 55 + expect(mockClient.login).toHaveBeenCalledWith('user.bsky.social', 'test-password'); 56 + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Successfully logged in')); 57 + expect(consoleLogSpy).toHaveBeenCalledWith( 58 + expect.stringContaining(`@${mockSessionData.handle}`) 59 + ); 60 + }); 61 + 62 + it('should prevent login when already authenticated', async () => { 63 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); 64 + 65 + const auth = createAuthCommand(); 66 + await expect(auth.parseAsync(['node', 'test', 'login'])).rejects.toThrow('process.exit'); 67 + 68 + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Already logged in')); 69 + expect(promptsModule.promptForLogin).not.toHaveBeenCalled(); 70 + expect(processExitSpy).toHaveBeenCalled(); 71 + }); 72 + 73 + it('should handle login errors gracefully', async () => { 74 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); 75 + vi.mocked(promptsModule.promptForLogin).mockResolvedValue({ 76 + identifier: 'user.bsky.social', 77 + password: 'wrong-password', 78 + }); 79 + mockClient.login.mockRejectedValue(new Error('Invalid credentials')); 80 + 81 + const auth = createAuthCommand(); 82 + await expect(auth.parseAsync(['node', 'test', 'login'])).rejects.toThrow('process.exit(1)'); 83 + 84 + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Login failed')); 85 + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid credentials')); 86 + expect(processExitSpy).toHaveBeenCalledWith(1); 87 + }); 88 + }); 89 + 90 + describe('logout command', () => { 91 + it('should logout successfully when authenticated', async () => { 92 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); 93 + mockClient.logout.mockResolvedValue(undefined); 94 + 95 + const auth = createAuthCommand(); 96 + await auth.parseAsync(['node', 'test', 'logout']); 97 + 98 + expect(mockClient.logout).toHaveBeenCalled(); 99 + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Logged out')); 100 + expect(consoleLogSpy).toHaveBeenCalledWith( 101 + expect.stringContaining(`@${mockSessionMetadata.handle}`) 102 + ); 103 + }); 104 + 105 + it('should handle logout when not authenticated', async () => { 106 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); 107 + 108 + const auth = createAuthCommand(); 109 + await expect(auth.parseAsync(['node', 'test', 'logout'])).rejects.toThrow('process.exit'); 110 + 111 + expect(mockClient.logout).not.toHaveBeenCalled(); 112 + expect(consoleLogSpy).toHaveBeenCalledWith('Not currently logged in'); 113 + expect(processExitSpy).toHaveBeenCalled(); 114 + }); 115 + 116 + it('should handle logout errors gracefully', async () => { 117 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); 118 + mockClient.logout.mockRejectedValue(new Error('Logout failed')); 119 + 120 + const auth = createAuthCommand(); 121 + await expect(auth.parseAsync(['node', 'test', 'logout'])).rejects.toThrow('process.exit(1)'); 122 + 123 + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Logout failed')); 124 + expect(processExitSpy).toHaveBeenCalledWith(1); 125 + }); 126 + }); 127 + 128 + describe('status command', () => { 129 + it('should show authenticated status with session details', async () => { 130 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); 131 + 132 + const auth = createAuthCommand(); 133 + await auth.parseAsync(['node', 'test', 'status']); 134 + 135 + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Authenticated')); 136 + expect(consoleLogSpy).toHaveBeenCalledWith( 137 + expect.stringContaining(`@${mockSessionMetadata.handle}`) 138 + ); 139 + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining(mockSessionMetadata.did)); 140 + }); 141 + 142 + it('should show not authenticated status', async () => { 143 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); 144 + 145 + const auth = createAuthCommand(); 146 + await auth.parseAsync(['node', 'test', 'status']); 147 + 148 + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Not authenticated')); 149 + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('tangled auth login')); 150 + }); 151 + 152 + it('should handle status check errors gracefully', async () => { 153 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockRejectedValue( 154 + new Error('Failed to read session') 155 + ); 156 + 157 + const auth = createAuthCommand(); 158 + await expect(auth.parseAsync(['node', 'test', 'status'])).rejects.toThrow('process.exit(1)'); 159 + 160 + expect(consoleErrorSpy).toHaveBeenCalledWith( 161 + expect.stringContaining('Failed to check status') 162 + ); 163 + expect(processExitSpy).toHaveBeenCalledWith(1); 164 + }); 165 + }); 166 + });