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 interactive prompts for authentication

- Add promptForIdentifier() with validation
- Add promptForPassword() with masking
- Add promptForLogin() combining both prompts
- Use @inquirer/prompts for user-friendly CLI interaction
- Add 5 tests covering all prompt functions and validation

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

markbennett.ca faf1ee7f 94afdcaf

verified
+162
+56
src/utils/prompts.ts
··· 1 + import { input, password } from '@inquirer/prompts'; 2 + import { safeValidateIdentifier } from './validation.js'; 3 + 4 + /** 5 + * Prompt for user's AT Protocol identifier (handle or DID) 6 + */ 7 + export async function promptForIdentifier(): Promise<string> { 8 + return await input({ 9 + message: 'Enter your AT Protocol identifier (handle or DID):', 10 + validate: (value: string) => { 11 + if (!value.trim()) { 12 + return 'Identifier cannot be empty'; 13 + } 14 + 15 + const result = safeValidateIdentifier(value.trim()); 16 + if (!result.success) { 17 + return result.error; 18 + } 19 + 20 + return true; 21 + }, 22 + }); 23 + } 24 + 25 + /** 26 + * Prompt for app password 27 + */ 28 + export async function promptForPassword(): Promise<string> { 29 + return await password({ 30 + message: 'Enter your app password:', 31 + mask: '*', 32 + validate: (value: string) => { 33 + if (!value) { 34 + return 'Password cannot be empty'; 35 + } 36 + return true; 37 + }, 38 + }); 39 + } 40 + 41 + /** 42 + * Prompt for login credentials 43 + * Returns identifier and password 44 + */ 45 + export async function promptForLogin(): Promise<{ 46 + identifier: string; 47 + password: string; 48 + }> { 49 + const identifier = await promptForIdentifier(); 50 + const passwordValue = await promptForPassword(); 51 + 52 + return { 53 + identifier, 54 + password: passwordValue, 55 + }; 56 + }
+106
tests/utils/prompts.test.ts
··· 1 + import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 + import { promptForIdentifier, promptForLogin, promptForPassword } from '../../src/utils/prompts.js'; 3 + 4 + // Mock @inquirer/prompts 5 + vi.mock('@inquirer/prompts', () => ({ 6 + input: vi.fn(), 7 + password: vi.fn(), 8 + })); 9 + 10 + describe('Prompts', () => { 11 + let mockInput: ReturnType<typeof vi.fn>; 12 + let mockPassword: ReturnType<typeof vi.fn>; 13 + 14 + beforeEach(async () => { 15 + const inquirer = await import('@inquirer/prompts'); 16 + mockInput = vi.mocked(inquirer.input); 17 + mockPassword = vi.mocked(inquirer.password); 18 + vi.clearAllMocks(); 19 + }); 20 + 21 + describe('promptForIdentifier', () => { 22 + it('should prompt for identifier and return valid handle', async () => { 23 + mockInput.mockResolvedValue('user.bsky.social'); 24 + 25 + const result = await promptForIdentifier(); 26 + 27 + expect(result).toBe('user.bsky.social'); 28 + expect(mockInput).toHaveBeenCalledWith({ 29 + message: 'Enter your AT Protocol identifier (handle or DID):', 30 + validate: expect.any(Function), 31 + }); 32 + }); 33 + 34 + it('should validate identifier format', async () => { 35 + mockInput.mockResolvedValue('user.bsky.social'); 36 + 37 + await promptForIdentifier(); 38 + 39 + const validateFn = mockInput.mock.calls[0]?.[0]?.validate; 40 + expect(validateFn).toBeDefined(); 41 + 42 + if (validateFn) { 43 + // Valid handle 44 + expect(validateFn('user.bsky.social')).toBe(true); 45 + 46 + // Valid DID 47 + expect(validateFn('did:plc:test123')).toBe(true); 48 + 49 + // Empty 50 + expect(validateFn('')).toBe('Identifier cannot be empty'); 51 + expect(validateFn(' ')).toBe('Identifier cannot be empty'); 52 + 53 + // Invalid format 54 + expect(validateFn('invalid')).toContain('Invalid'); 55 + } 56 + }); 57 + }); 58 + 59 + describe('promptForPassword', () => { 60 + it('should prompt for password with masking', async () => { 61 + mockPassword.mockResolvedValue('test-password'); 62 + 63 + const result = await promptForPassword(); 64 + 65 + expect(result).toBe('test-password'); 66 + expect(mockPassword).toHaveBeenCalledWith({ 67 + message: 'Enter your app password:', 68 + mask: '*', 69 + validate: expect.any(Function), 70 + }); 71 + }); 72 + 73 + it('should validate password is not empty', async () => { 74 + mockPassword.mockResolvedValue('test-password'); 75 + 76 + await promptForPassword(); 77 + 78 + const validateFn = mockPassword.mock.calls[0]?.[0]?.validate; 79 + expect(validateFn).toBeDefined(); 80 + 81 + if (validateFn) { 82 + // Valid password 83 + expect(validateFn('password123')).toBe(true); 84 + 85 + // Empty 86 + expect(validateFn('')).toBe('Password cannot be empty'); 87 + } 88 + }); 89 + }); 90 + 91 + describe('promptForLogin', () => { 92 + it('should prompt for both identifier and password', async () => { 93 + mockInput.mockResolvedValue('user.bsky.social'); 94 + mockPassword.mockResolvedValue('test-password'); 95 + 96 + const result = await promptForLogin(); 97 + 98 + expect(result).toEqual({ 99 + identifier: 'user.bsky.social', 100 + password: 'test-password', 101 + }); 102 + expect(mockInput).toHaveBeenCalledOnce(); 103 + expect(mockPassword).toHaveBeenCalledOnce(); 104 + }); 105 + }); 106 + });