diff --git a/src/commands/auth.ts b/src/commands/auth.ts new file mode 100644 index 0000000..4d2da00 --- /dev/null +++ b/src/commands/auth.ts @@ -0,0 +1,100 @@ +import { Command } from 'commander'; +import { createApiClient } from '../lib/api-client.js'; +import { getCurrentSessionMetadata } from '../lib/session.js'; +import { promptForLogin } from '../utils/prompts.js'; + +/** + * Create the auth command with login and logout subcommands + */ +export function createAuthCommand(): Command { + const auth = new Command('auth'); + auth.description('Manage authentication with AT Protocol'); + + // Login command + auth + .command('login') + .description('Login to your AT Protocol account') + .action(async () => { + try { + const client = createApiClient(); + + // Check if already logged in + const existingSession = await getCurrentSessionMetadata(); + if (existingSession) { + console.log(`Already logged in as @${existingSession.handle}`); + console.log('Run "tangled auth logout" first to switch accounts'); + process.exit(0); + } + + // Prompt for credentials + const { identifier, password } = await promptForLogin(); + + // Attempt login + console.log('\nAuthenticating...'); + const session = await client.login(identifier, password); + + console.log(`\n✓ Successfully logged in as @${session.handle}`); + console.log(` DID: ${session.did}`); + } catch (error) { + console.error( + `\n✗ Login failed: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + process.exit(1); + } + }); + + // Logout command + auth + .command('logout') + .description('Logout and clear stored credentials') + .action(async () => { + try { + const client = createApiClient(); + + // Check if logged in + const session = await getCurrentSessionMetadata(); + if (!session) { + console.log('Not currently logged in'); + process.exit(0); + } + + // Perform logout + await client.logout(); + + console.log(`✓ Logged out @${session.handle}`); + } catch (error) { + console.error( + `✗ Logout failed: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + process.exit(1); + } + }); + + // Status command + auth + .command('status') + .description('Check authentication status') + .action(async () => { + try { + const session = await getCurrentSessionMetadata(); + + if (session) { + console.log('✓ Authenticated'); + console.log(` Handle: @${session.handle}`); + console.log(` DID: ${session.did}`); + console.log(` PDS: ${session.pds}`); + console.log(` Last used: ${new Date(session.lastUsed).toLocaleString()}`); + } else { + console.log('✗ Not authenticated'); + console.log('Run "tangled auth login" to authenticate'); + } + } catch (error) { + console.error( + `✗ Failed to check status: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + process.exit(1); + } + }); + + return auth; +} diff --git a/src/index.ts b/src/index.ts index 3682c61..3d02da3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Command } from 'commander'; +import { createAuthCommand } from './commands/auth.js'; // Get package.json for version const __filename = fileURLToPath(import.meta.url); @@ -16,13 +17,7 @@ program .description('A CLI for Tangled.org - AT Protocol-based Git hosting') .version(packageJson.version, '-v, --version', 'Output the current version'); -// Future command registrations will go here -// Example: -// program -// .command('auth') -// .description('Authenticate with Tangled.org') -// .action(() => { -// console.log('Auth command coming soon!'); -// }); +// Register commands +program.addCommand(createAuthCommand()); program.parse(process.argv); diff --git a/tests/commands/auth.test.ts b/tests/commands/auth.test.ts new file mode 100644 index 0000000..8778dff --- /dev/null +++ b/tests/commands/auth.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createAuthCommand } from '../../src/commands/auth.js'; +import * as apiClientModule from '../../src/lib/api-client.js'; +import * as sessionModule from '../../src/lib/session.js'; +import * as promptsModule from '../../src/utils/prompts.js'; +import { mockSessionData, mockSessionMetadata } from '../helpers/mock-data.js'; + +// Mock modules +vi.mock('../../src/lib/api-client.js'); +vi.mock('../../src/lib/session.js'); +vi.mock('../../src/utils/prompts.js'); + +describe('Auth Commands', () => { + let mockClient: { + login: ReturnType; + logout: ReturnType; + }; + let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; + let processExitSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + // Mock API client + mockClient = { + login: vi.fn(), + logout: vi.fn(), + }; + vi.mocked(apiClientModule.createApiClient).mockReturnValue(mockClient as never); + + // Mock console methods + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; + + // Mock process.exit to throw to stop execution (mimicking real behavior) + processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }) as never; + }); + + describe('login command', () => { + it('should login successfully with valid credentials', async () => { + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); + vi.mocked(promptsModule.promptForLogin).mockResolvedValue({ + identifier: 'user.bsky.social', + password: 'test-password', + }); + mockClient.login.mockResolvedValue(mockSessionData); + + const auth = createAuthCommand(); + await auth.parseAsync(['node', 'test', 'login']); + + expect(promptsModule.promptForLogin).toHaveBeenCalled(); + expect(mockClient.login).toHaveBeenCalledWith('user.bsky.social', 'test-password'); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Successfully logged in')); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining(`@${mockSessionData.handle}`) + ); + }); + + it('should prevent login when already authenticated', async () => { + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); + + const auth = createAuthCommand(); + await expect(auth.parseAsync(['node', 'test', 'login'])).rejects.toThrow('process.exit'); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Already logged in')); + expect(promptsModule.promptForLogin).not.toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalled(); + }); + + it('should handle login errors gracefully', async () => { + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); + vi.mocked(promptsModule.promptForLogin).mockResolvedValue({ + identifier: 'user.bsky.social', + password: 'wrong-password', + }); + mockClient.login.mockRejectedValue(new Error('Invalid credentials')); + + const auth = createAuthCommand(); + await expect(auth.parseAsync(['node', 'test', 'login'])).rejects.toThrow('process.exit(1)'); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Login failed')); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid credentials')); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('logout command', () => { + it('should logout successfully when authenticated', async () => { + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); + mockClient.logout.mockResolvedValue(undefined); + + const auth = createAuthCommand(); + await auth.parseAsync(['node', 'test', 'logout']); + + expect(mockClient.logout).toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Logged out')); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining(`@${mockSessionMetadata.handle}`) + ); + }); + + it('should handle logout when not authenticated', async () => { + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); + + const auth = createAuthCommand(); + await expect(auth.parseAsync(['node', 'test', 'logout'])).rejects.toThrow('process.exit'); + + expect(mockClient.logout).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith('Not currently logged in'); + expect(processExitSpy).toHaveBeenCalled(); + }); + + it('should handle logout errors gracefully', async () => { + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); + mockClient.logout.mockRejectedValue(new Error('Logout failed')); + + const auth = createAuthCommand(); + await expect(auth.parseAsync(['node', 'test', 'logout'])).rejects.toThrow('process.exit(1)'); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Logout failed')); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('status command', () => { + it('should show authenticated status with session details', async () => { + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); + + const auth = createAuthCommand(); + await auth.parseAsync(['node', 'test', 'status']); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Authenticated')); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining(`@${mockSessionMetadata.handle}`) + ); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining(mockSessionMetadata.did)); + }); + + it('should show not authenticated status', async () => { + vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(null); + + const auth = createAuthCommand(); + await auth.parseAsync(['node', 'test', 'status']); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Not authenticated')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('tangled auth login')); + }); + + it('should handle status check errors gracefully', async () => { + vi.mocked(sessionModule.getCurrentSessionMetadata).mockRejectedValue( + new Error('Failed to read session') + ); + + const auth = createAuthCommand(); + await expect(auth.parseAsync(['node', 'test', 'status'])).rejects.toThrow('process.exit(1)'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to check status') + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + }); +});