diff --git a/.claude/tasks/cli-auth-functional-implementation.md b/.claude/tasks/cli-auth-functional-implementation.md new file mode 100644 index 000000000..ed35ca7c9 --- /dev/null +++ b/.claude/tasks/cli-auth-functional-implementation.md @@ -0,0 +1,1036 @@ +# CLI Authentication Implementation Plan - Functional & Factory Patterns + +## Implementation Status + +### Overview +This plan outlines the implementation of authentication functionality for the TypeScript CLI using strict functional programming patterns and factory functions. The implementation will port the existing Rust authentication logic while adhering to functional composition principles and avoiding classes entirely. + +## Architecture Analysis + +### Existing Rust Implementation Analysis + +From analyzing the Rust code at `/apps/cli-deprecated/cli/src/commands/auth.rs` and related utilities, the key features are: + +1. **Credential Management**: + - Stores credentials in `~/.buster/credentials` + - Supports YAML serialization + - Environment variable overrides (BUSTER_HOST, BUSTER_API_KEY) + +2. **CLI Arguments**: + - `--host`, `--api-key`, `--no-save`, `--clear`, `--local`, `--cloud` + - Environment variables: BUSTER_HOST, BUSTER_API_KEY + - Interactive prompts for missing credentials + +3. **Validation Flow**: + - Loads cached credentials or defaults + - Applies environment variable overrides + - Prompts for missing credentials interactively + - Validates credentials against server API + - Saves credentials (unless --no-save flag) + +4. **Host Configuration**: + - Local: `http://localhost:3001` + - Cloud: `https://api.buster.so` (default) + - Custom via --host flag + +## Functional Architecture Design + +### Core Functional Principles + +1. **Pure Functions**: All core logic implemented as pure functions with no side effects +2. **Factory Pattern**: Use factory functions to create configured instances +3. **Composition**: Build complex functionality by composing simple functions +4. **Immutability**: All data structures immutable by default +5. **Error Handling**: Use Result types or functional error handling patterns +6. **Dependency Injection**: Pass dependencies as function parameters +7. **Higher-Order Functions**: Use HOFs for middleware and configuration + +### System Architecture Diagram + +```mermaid +graph TD + A[CLI Entry Point] --> B[Auth Command Factory] + B --> C[Credential Manager Factory] + B --> D[API Client Factory] + B --> E[Prompt Manager Factory] + + C --> F[Load Credentials] + C --> G[Save Credentials] + C --> H[Validate Credentials] + + D --> I[HTTP Client] + D --> J[Request Validator] + D --> K[Response Parser] + + E --> L[Interactive Prompts] + E --> M[Input Validation] + + F --> N[File System Reader] + G --> O[File System Writer] + H --> P[Server Validator] + + Q[Environment Manager] --> R[Env Variable Reader] + Q --> S[Config Merger] + + T[Error Handler] --> U[Result Types] + T --> V[Error Formatters] +``` + +## Implementation Plan + +### Phase 1: Core Functional Infrastructure + +#### Ticket 1.1: Result Types and Error Handling (Functional) +**Location**: `/apps/cli/src/utils/result.ts` + +```typescript +// Functional Result type implementation +export type Result = + | { success: true; data: T } + | { success: false; error: E }; + +export const Ok = (data: T): Result => ({ success: true, data }); +export const Err = (error: E): Result => ({ success: false, error }); + +// Functional combinators +export const map = ( + fn: (value: T) => U +) => (result: Result): Result => + result.success ? Ok(fn(result.data)) : result; + +export const flatMap = ( + fn: (value: T) => Result +) => (result: Result): Result => + result.success ? fn(result.data) : result; + +export const mapError = ( + fn: (error: E) => F +) => (result: Result): Result => + result.success ? result : Err(fn(result.error)); + +// Async Result utilities +export const asyncMap = ( + fn: (value: T) => Promise +) => async (result: Result): Promise> => + result.success ? Ok(await fn(result.data)) : result; + +export const asyncFlatMap = ( + fn: (value: T) => Promise> +) => async (result: Result): Promise> => + result.success ? await fn(result.data) : result; +``` + +#### Ticket 1.2: Functional Configuration Management +**Location**: `/apps/cli/src/utils/config-factory.ts` + +```typescript +// Immutable configuration types +export interface BusterCredentials { + readonly url: string; + readonly apiKey: string; +} + +export interface AuthConfig { + readonly host?: string; + readonly apiKey?: string; + readonly noSave: boolean; + readonly clear: boolean; + readonly local: boolean; + readonly cloud: boolean; +} + +// Factory function for creating configuration +export const createAuthConfig = (args: Partial): AuthConfig => ({ + host: args.host, + apiKey: args.apiKey, + noSave: args.noSave ?? false, + clear: args.clear ?? false, + local: args.local ?? false, + cloud: args.cloud ?? false, +}); + +// Pure functions for configuration resolution +export const resolveHost = (config: AuthConfig): string => { + if (config.local) return 'http://localhost:3001'; + if (config.cloud) return 'https://api.buster.so'; + if (config.host) return config.host; + return 'https://api.buster.so'; // default +}; + +export const mergeCredentials = ( + cached: Partial, + env: Partial, + args: Partial +): BusterCredentials => ({ + url: args.url ?? env.url ?? cached.url ?? 'https://api.buster.so', + apiKey: args.apiKey ?? env.apiKey ?? cached.apiKey ?? '', +}); + +// Configuration validation +export const validateAuthConfig = (config: AuthConfig): Result => { + const conflictingFlags = [config.local, config.cloud, !!config.host].filter(Boolean); + if (conflictingFlags.length > 1) { + return Err('Cannot specify multiple host options (--local, --cloud, --host)'); + } + return Ok(config); +}; +``` + +#### Ticket 1.3: Functional File System Operations +**Location**: `/apps/cli/src/utils/fs-factory.ts` + +```typescript +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import * as yaml from 'js-yaml'; +import { z } from 'zod'; + +// Pure functions for path resolution +export const getBusterDirectory = (): string => join(homedir(), '.buster'); +export const getCredentialsPath = (): string => join(getBusterDirectory(), 'credentials.yml'); + +// Factory for file system operations +export interface FileSystemOperations { + readonly readCredentials: () => Promise>; + readonly writeCredentials: (creds: BusterCredentials) => Promise>; + readonly deleteCredentials: () => Promise>; + readonly ensureDirectory: (path: string) => Promise>; +} + +export const createFileSystemOperations = (): FileSystemOperations => ({ + readCredentials: async () => { + try { + const credentialsPath = getCredentialsPath(); + const content = await fs.readFile(credentialsPath, 'utf-8'); + const parsed = yaml.load(content) as unknown; + + const result = CredentialsSchema.safeParse(parsed); + if (!result.success) { + return Err(`Invalid credentials format: ${result.error.message}`); + } + + return Ok(result.data); + } catch (error) { + return Err(`Failed to read credentials: ${error}`); + } + }, + + writeCredentials: async (credentials) => { + try { + const busterDir = getBusterDirectory(); + const credentialsPath = getCredentialsPath(); + + // Ensure directory exists + await fs.mkdir(busterDir, { recursive: true }); + + const yamlContent = yaml.dump(credentials); + await fs.writeFile(credentialsPath, yamlContent, 'utf-8'); + + return Ok(void 0); + } catch (error) { + return Err(`Failed to write credentials: ${error}`); + } + }, + + deleteCredentials: async () => { + try { + const credentialsPath = getCredentialsPath(); + await fs.unlink(credentialsPath); + return Ok(void 0); + } catch (error) { + if ((error as any).code === 'ENOENT') { + return Ok(void 0); // File doesn't exist, that's fine + } + return Err(`Failed to delete credentials: ${error}`); + } + }, + + ensureDirectory: async (path) => { + try { + await fs.mkdir(path, { recursive: true }); + return Ok(void 0); + } catch (error) { + return Err(`Failed to create directory: ${error}`); + } + }, +}); + +// Zod schema for credentials +const CredentialsSchema = z.object({ + url: z.string(), + apiKey: z.string(), +}); +``` + +#### Ticket 1.4: Functional API Client with Factory Pattern +**Location**: `/apps/cli/src/utils/api-factory.ts` + +```typescript +// API Client factory with functional composition +export interface ApiClientOperations { + readonly validateApiKey: (host: string, apiKey: string) => Promise>; + readonly makeRequest: ( + endpoint: string, + options: RequestOptions + ) => Promise>; +} + +interface RequestOptions { + readonly method: 'GET' | 'POST' | 'PUT' | 'DELETE'; + readonly body?: unknown; + readonly headers?: Record; +} + +// Higher-order function for creating authenticated requests +const withAuth = (apiKey: string) => (options: RequestOptions): RequestOptions => ({ + ...options, + headers: { + ...options.headers, + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'User-Agent': 'buster-cli', + }, +}); + +// Functional HTTP client +const createHttpClient = () => ({ + request: async ( + url: string, + options: RequestOptions + ): Promise> => { + try { + const response = await fetch(url, { + method: options.method, + headers: options.headers, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + if (!response.ok) { + const errorText = await response.text(); + return Err(`HTTP ${response.status}: ${errorText}`); + } + + const data = await response.json() as T; + return Ok(data); + } catch (error) { + return Err(`Request failed: ${error}`); + } + }, +}); + +// Factory function for API client +export const createApiClient = (): ApiClientOperations => { + const httpClient = createHttpClient(); + + return { + validateApiKey: async (host, apiKey) => { + const endpoint = `${host}/api/v1/api_keys/validate`; + const requestOptions = withAuth(apiKey)({ + method: 'POST', + body: { api_key: apiKey }, + }); + + const result = await httpClient.request<{ valid: boolean }>(endpoint, requestOptions); + + return map((response: { valid: boolean }) => response.valid)(result); + }, + + makeRequest: async (endpoint: string, options: RequestOptions) => { + return httpClient.request(endpoint, options); + }, + }; +}; +``` + +### Phase 2: Functional Authentication Logic + +#### Ticket 2.1: Environment Variable Management (Functional) +**Location**: `/apps/cli/src/utils/env-factory.ts` + +```typescript +// Pure function for reading environment variables +export interface EnvironmentVariables { + readonly BUSTER_HOST?: string; + readonly BUSTER_API_KEY?: string; +} + +export const readEnvironmentVariables = (): EnvironmentVariables => ({ + BUSTER_HOST: process.env.BUSTER_HOST, + BUSTER_API_KEY: process.env.BUSTER_API_KEY, +}); + +// Pure function for creating credentials from environment +export const credentialsFromEnvironment = (env: EnvironmentVariables): Partial => ({ + url: env.BUSTER_HOST, + apiKey: env.BUSTER_API_KEY, +}); + +// Environment manager factory +export interface EnvironmentManager { + readonly getEnvironmentCredentials: () => Partial; + readonly hasRequiredVariables: () => boolean; +} + +export const createEnvironmentManager = (): EnvironmentManager => { + const env = readEnvironmentVariables(); + + return { + getEnvironmentCredentials: () => credentialsFromEnvironment(env), + hasRequiredVariables: () => !!env.BUSTER_API_KEY, + }; +}; +``` + +#### Ticket 2.2: Functional Input Prompting with Ink +**Location**: `/apps/cli/src/utils/prompt-factory.ts` + +```typescript +import React from 'react'; +import { render } from 'ink'; +import TextInput from 'ink-text-input'; +import { Box, Text } from 'ink'; + +// Functional prompt types +export interface PromptOperations { + readonly promptForApiKey: (currentKey?: string) => Promise>; + readonly promptForHost: (currentHost?: string) => Promise>; + readonly promptForConfirmation: (message: string) => Promise>; +} + +// Pure prompt configuration functions +export const createPromptConfig = (message: string, defaultValue?: string, isPassword = false) => ({ + message, + defaultValue, + isPassword, +}); + +// Functional Ink component for text input +const PromptComponent: React.FC<{ + message: string; + defaultValue?: string; + isPassword?: boolean; + onSubmit: (value: string) => void; +}> = ({ message, defaultValue, isPassword, onSubmit }) => { + const [value, setValue] = React.useState(defaultValue || ''); + + return ( + + {message} + + + ); +}; + +// Factory for prompt operations +export const createPromptOperations = (): PromptOperations => ({ + promptForApiKey: (currentKey) => { + return new Promise((resolve) => { + const obfuscatedKey = currentKey + ? `${currentKey.slice(0, 4)}...` + : '[Not Set]'; + + const message = `Enter your API key (current: ${obfuscatedKey}):`; + + const { unmount } = render( + { + unmount(); + if (value.trim()) { + resolve(Ok(value.trim())); + } else if (currentKey) { + resolve(Ok(currentKey)); // Keep existing key + } else { + resolve(Err('API key is required')); + } + }} + /> + ); + }); + }, + + promptForHost: (currentHost) => { + return new Promise((resolve) => { + const message = `Enter the URL of your Buster API (current: ${currentHost || 'https://api.buster.so'}):`; + + const { unmount } = render( + { + unmount(); + resolve(Ok(value.trim() || currentHost || 'https://api.buster.so')); + }} + /> + ); + }); + }, + + promptForConfirmation: (message) => { + return new Promise((resolve) => { + const { unmount } = render( + { + unmount(); + const confirmed = value.toLowerCase().startsWith('y'); + resolve(Ok(confirmed)); + }} + /> + ); + }); + }, +}); +``` + +#### Ticket 2.3: Functional Credential Management +**Location**: `/apps/cli/src/utils/credential-factory.ts` + +```typescript +// Credential management with functional composition +export interface CredentialManager { + readonly loadCredentials: () => Promise>; + readonly saveCredentials: (credentials: BusterCredentials) => Promise>; + readonly deleteCredentials: () => Promise>; + readonly validateCredentials: (credentials: BusterCredentials) => Promise>; + readonly resolveCredentials: (config: AuthConfig) => Promise>; +} + +// Higher-order function for credential resolution pipeline +const createCredentialPipeline = ( + fsOps: FileSystemOperations, + envManager: EnvironmentManager, + promptOps: PromptOperations, + apiClient: ApiClientOperations +) => { + + // Pure function for merging credential sources + const mergeCredentialSources = ( + cached: Partial, + env: Partial, + args: Partial + ): Partial => ({ + url: args.url || env.url || cached.url, + apiKey: args.apiKey || env.apiKey || cached.apiKey, + }); + + // Pure function for checking if credentials are complete + const areCredentialsComplete = (creds: Partial): creds is BusterCredentials => + !!(creds.url && creds.apiKey); + + return { + loadFromSources: async (config: AuthConfig): Promise, string>> => { + const cachedResult = await fsOps.readCredentials(); + const cached = cachedResult.success ? cachedResult.data : {}; + + const env = envManager.getEnvironmentCredentials(); + const args = { + url: resolveHost(config), + apiKey: config.apiKey, + }; + + const merged = mergeCredentialSources(cached, env, args); + return Ok(merged); + }, + + promptForMissing: async (partial: Partial): Promise> => { + let credentials = { ...partial } as Partial; + + // Prompt for host if missing + if (!credentials.url) { + const hostResult = await promptOps.promptForHost(); + if (!hostResult.success) return hostResult; + credentials.url = hostResult.data; + } + + // Prompt for API key if missing + if (!credentials.apiKey) { + const keyResult = await promptOps.promptForApiKey(); + if (!keyResult.success) return keyResult; + credentials.apiKey = keyResult.data; + } + + if (areCredentialsComplete(credentials)) { + return Ok(credentials); + } + + return Err('Failed to obtain complete credentials'); + }, + }; +}; + +// Factory function for credential manager +export const createCredentialManager = ( + fsOps: FileSystemOperations, + envManager: EnvironmentManager, + promptOps: PromptOperations, + apiClient: ApiClientOperations +): CredentialManager => { + + const pipeline = createCredentialPipeline(fsOps, envManager, promptOps, apiClient); + + return { + loadCredentials: async () => { + return fsOps.readCredentials(); + }, + + saveCredentials: async (credentials) => { + return fsOps.writeCredentials(credentials); + }, + + deleteCredentials: async () => { + return fsOps.deleteCredentials(); + }, + + validateCredentials: async (credentials) => { + return apiClient.validateApiKey(credentials.url, credentials.apiKey); + }, + + resolveCredentials: async (config) => { + // Load from all sources + const partialResult = await pipeline.loadFromSources(config); + if (!partialResult.success) return partialResult; + + const partial = partialResult.data; + + // Check if we need to prompt for confirmation to overwrite + if (partial.apiKey && !config.apiKey) { + const confirmResult = await promptOps.promptForConfirmation( + 'Existing credentials found. Do you want to overwrite them?' + ); + if (!confirmResult.success) return Err('Failed to get confirmation'); + if (!confirmResult.data) return Err('Authentication cancelled'); + } + + // Prompt for any missing credentials + const completeResult = await pipeline.promptForMissing(partial); + if (!completeResult.success) return completeResult; + + return completeResult; + }, + }; +}; +``` + +### Phase 3: Functional Auth Command Implementation + +#### Ticket 3.1: Main Auth Command with Functional Composition +**Location**: `/apps/cli/src/commands/auth/index.ts` + +```typescript +// Main auth command using functional composition +export interface AuthDependencies { + readonly credentialManager: CredentialManager; + readonly promptOperations: PromptOperations; + readonly apiClient: ApiClientOperations; +} + +// Pure function for auth command logic +const authCommandLogic = (deps: AuthDependencies) => async (config: AuthConfig): Promise> => { + const { credentialManager, promptOperations, apiClient } = deps; + + // Handle --clear flag + if (config.clear) { + const deleteResult = await credentialManager.deleteCredentials(); + if (!deleteResult.success) return deleteResult; + + console.log('✅ Saved credentials cleared successfully.'); + return Ok(void 0); + } + + // Resolve credentials from all sources + const credentialsResult = await credentialManager.resolveCredentials(config); + if (!credentialsResult.success) return credentialsResult; + + const credentials = credentialsResult.data; + + // Validate credentials + const validationResult = await credentialManager.validateCredentials(credentials); + if (!validationResult.success) return validationResult; + + if (!validationResult.data) { + return Err('Invalid API key'); + } + + // Save credentials unless --no-save flag is set + if (!config.noSave) { + const saveResult = await credentialManager.saveCredentials(credentials); + if (!saveResult.success) return saveResult; + } + + console.log('✅ You\'ve successfully connected to Buster!'); + console.log(` Host: ${credentials.url}`); + console.log(` API Key: ${'*'.repeat(Math.max(0, credentials.apiKey.length - 6))}${credentials.apiKey.slice(-6)}`); + + if (!config.noSave) { + console.log('\n✅ Credentials saved successfully!'); + } else { + console.log('\n⚠️ Note: Credentials were not saved due to --no-save flag'); + } + + return Ok(void 0); +}; + +// Factory function for creating auth command +export const createAuthCommand = () => { + // Create all dependencies using factories + const fsOps = createFileSystemOperations(); + const envManager = createEnvironmentManager(); + const promptOps = createPromptOperations(); + const apiClient = createApiClient(); + + const credentialManager = createCredentialManager(fsOps, envManager, promptOps, apiClient); + + const dependencies: AuthDependencies = { + credentialManager, + promptOperations: promptOps, + apiClient, + }; + + // Return the configured auth command function + return authCommandLogic(dependencies); +}; + +// Command function that integrates with Commander +export const authCommand = async (options: AuthConfig): Promise => { + const authFn = createAuthCommand(); + const result = await authFn(options); + + if (!result.success) { + console.error(`❌ Authentication failed: ${result.error}`); + process.exit(1); + } +}; +``` + +#### Ticket 3.2: Functional Authentication Checker +**Location**: `/apps/cli/src/utils/auth-checker.ts` + +```typescript +// Functional authentication checker for use by other commands +export interface AuthChecker { + readonly checkAuthentication: () => Promise>; + readonly requireAuthentication: () => Promise; +} + +// Pure function for auth checking logic +const createAuthCheckLogic = ( + fsOps: FileSystemOperations, + envManager: EnvironmentManager, + apiClient: ApiClientOperations +) => async (): Promise> => { + + // Try to load cached credentials + const cachedResult = await fsOps.readCredentials(); + let credentials = cachedResult.success ? cachedResult.data : { url: '', apiKey: '' }; + + // Override with environment variables + const envCreds = envManager.getEnvironmentCredentials(); + credentials = { + url: envCreds.url || credentials.url || 'https://api.buster.so', + apiKey: envCreds.apiKey || credentials.apiKey, + }; + + // Check if we have an API key + if (!credentials.apiKey) { + return Err('Authentication required. Please run `buster auth` or set BUSTER_API_KEY.'); + } + + // Validate credentials + const validationResult = await apiClient.validateApiKey(credentials.url, credentials.apiKey); + if (!validationResult.success) { + return Err(`Authentication failed: ${validationResult.error}. Please run \`buster auth\` to configure credentials.`); + } + + if (!validationResult.data) { + return Err('Invalid API key. Please run `buster auth` to configure credentials.'); + } + + return Ok(credentials); +}; + +// Factory for auth checker +export const createAuthChecker = (): AuthChecker => { + const fsOps = createFileSystemOperations(); + const envManager = createEnvironmentManager(); + const apiClient = createApiClient(); + + const checkLogic = createAuthCheckLogic(fsOps, envManager, apiClient); + + return { + checkAuthentication: checkLogic, + + requireAuthentication: async () => { + const result = await checkLogic(); + if (!result.success) { + console.error(`❌ ${result.error}`); + process.exit(1); + } + return result.data; + }, + }; +}; +``` + +### Phase 4: Functional Testing Strategy + +#### Ticket 4.1: Functional Unit Tests +**Location**: `/apps/cli/src/commands/auth/auth.test.ts` + +```typescript +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Ok, Err } from '../../utils/result.js'; +import { createAuthCommand } from './index.js'; + +describe('Auth Command - Functional Tests', () => { + // Mock factories for testing + const createMockDependencies = () => ({ + credentialManager: { + loadCredentials: vi.fn(), + saveCredentials: vi.fn(), + deleteCredentials: vi.fn(), + validateCredentials: vi.fn(), + resolveCredentials: vi.fn(), + }, + promptOperations: { + promptForApiKey: vi.fn(), + promptForHost: vi.fn(), + promptForConfirmation: vi.fn(), + }, + apiClient: { + validateApiKey: vi.fn(), + makeRequest: vi.fn(), + }, + }); + + it('should handle clear flag correctly', async () => { + const mockDeps = createMockDependencies(); + mockDeps.credentialManager.deleteCredentials.mockResolvedValue(Ok(void 0)); + + const authCommand = createAuthCommand(); + const result = await authCommand({ clear: true, noSave: false, local: false, cloud: false }); + + expect(result.success).toBe(true); + expect(mockDeps.credentialManager.deleteCredentials).toHaveBeenCalledOnce(); + }); + + it('should validate credentials successfully', async () => { + const mockDeps = createMockDependencies(); + const mockCredentials = { url: 'https://api.buster.so', apiKey: 'test-key' }; + + mockDeps.credentialManager.resolveCredentials.mockResolvedValue(Ok(mockCredentials)); + mockDeps.credentialManager.validateCredentials.mockResolvedValue(Ok(true)); + mockDeps.credentialManager.saveCredentials.mockResolvedValue(Ok(void 0)); + + const authCommand = createAuthCommand(); + const result = await authCommand({ noSave: false, clear: false, local: false, cloud: false }); + + expect(result.success).toBe(true); + expect(mockDeps.credentialManager.validateCredentials).toHaveBeenCalledWith(mockCredentials); + expect(mockDeps.credentialManager.saveCredentials).toHaveBeenCalledWith(mockCredentials); + }); + + it('should handle validation failures', async () => { + const mockDeps = createMockDependencies(); + const mockCredentials = { url: 'https://api.buster.so', apiKey: 'invalid-key' }; + + mockDeps.credentialManager.resolveCredentials.mockResolvedValue(Ok(mockCredentials)); + mockDeps.credentialManager.validateCredentials.mockResolvedValue(Ok(false)); + + const authCommand = createAuthCommand(); + const result = await authCommand({ noSave: false, clear: false, local: false, cloud: false }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid API key'); + }); +}); + +// Pure function tests +describe('Configuration Functions', () => { + it('should resolve host correctly', () => { + expect(resolveHost({ local: true, cloud: false, host: undefined })).toBe('http://localhost:3001'); + expect(resolveHost({ local: false, cloud: true, host: undefined })).toBe('https://api.buster.so'); + expect(resolveHost({ local: false, cloud: false, host: 'https://custom.host' })).toBe('https://custom.host'); + expect(resolveHost({ local: false, cloud: false, host: undefined })).toBe('https://api.buster.so'); + }); + + it('should merge credentials correctly', () => { + const cached = { url: 'cached-url', apiKey: 'cached-key' }; + const env = { url: 'env-url' }; + const args = { apiKey: 'args-key' }; + + const result = mergeCredentials(cached, env, args); + expect(result).toEqual({ + url: 'env-url', // env overrides cached + apiKey: 'args-key', // args overrides everything + }); + }); +}); +``` + +#### Ticket 4.2: Integration Tests +**Location**: `/apps/cli/src/commands/auth/auth.int.test.ts` + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createAuthCommand } from './index.js'; + +describe('Auth Command - Integration Tests', () => { + let tempDir: string; + let originalHome: string | undefined; + + beforeEach(async () => { + // Create temporary directory for testing + tempDir = await fs.mkdtemp(join(tmpdir(), 'buster-auth-test-')); + originalHome = process.env.HOME; + process.env.HOME = tempDir; + }); + + afterEach(async () => { + // Cleanup + await fs.rm(tempDir, { recursive: true, force: true }); + if (originalHome) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + }); + + it('should create credentials file', async () => { + const authCommand = createAuthCommand(); + + // Mock the API validation to succeed + vi.mock('../../utils/api-factory.js', () => ({ + createApiClient: () => ({ + validateApiKey: vi.fn().mockResolvedValue({ success: true, data: true }), + }), + })); + + const config = { + apiKey: 'test-api-key', + host: 'https://test.buster.so', + noSave: false, + clear: false, + local: false, + cloud: false, + }; + + const result = await authCommand(config); + expect(result.success).toBe(true); + + // Check that credentials file was created + const credentialsPath = join(tempDir, '.buster', 'credentials.yml'); + const exists = await fs.access(credentialsPath).then(() => true).catch(() => false); + expect(exists).toBe(true); + }); + + it('should read environment variables correctly', async () => { + process.env.BUSTER_API_KEY = 'env-api-key'; + process.env.BUSTER_HOST = 'https://env.buster.so'; + + const authCommand = createAuthCommand(); + + // The command should use environment variables + const config = { noSave: true, clear: false, local: false, cloud: false }; + const result = await authCommand(config); + + expect(result.success).toBe(true); + // Verify that environment variables were used + }); +}); +``` + +## Implementation Benefits + +### Functional Programming Benefits + +1. **Testability**: Pure functions are easy to test in isolation +2. **Composability**: Functions can be combined to create complex behavior +3. **Predictability**: No side effects make code predictable and debuggable +4. **Reusability**: Pure functions can be reused across different contexts +5. **Error Handling**: Functional error handling with Result types is explicit and safe + +### Factory Pattern Benefits + +1. **Dependency Injection**: Dependencies are passed as function parameters +2. **Configuration**: Factory functions can be configured with different parameters +3. **Testing**: Easy to create mock implementations for testing +4. **Flexibility**: Different implementations can be swapped by changing the factory +5. **No Classes**: Avoids the complexity of class-based OOP + +### Architecture Benefits + +1. **Clear Separation**: Each factory handles a specific concern +2. **Immutability**: All data structures are immutable by default +3. **Type Safety**: Full TypeScript type safety throughout +4. **Error Handling**: Consistent error handling with Result types +5. **Modularity**: Each component can be developed and tested independently + +## Integration with Existing CLI + +### Command Registration +```typescript +// In main.ts +import { Command } from 'commander'; +import { authCommand } from './commands/auth/index.js'; + +const program = new Command(); + +program + .command('auth') + .description('Authenticate with Buster API') + .option('--host ', 'Buster API host URL') + .option('--api-key ', 'Your Buster API key') + .option('--no-save', 'Don\'t save credentials to disk') + .option('--clear', 'Clear saved credentials') + .option('--local', 'Use local buster instance') + .option('--cloud', 'Use cloud buster instance') + .action(authCommand); +``` + +### Usage by Other Commands +```typescript +// In other commands +import { createAuthChecker } from '../../utils/auth-checker.js'; + +export const deployCommand = async () => { + const authChecker = createAuthChecker(); + const credentials = await authChecker.requireAuthentication(); + + // Use credentials for API calls + console.log(`Deploying to ${credentials.url}...`); +}; +``` + +## Security Considerations + +1. **Credential Storage**: Credentials are stored in YAML format (matching Rust implementation) +2. **File Permissions**: Ensure ~/.buster directory has appropriate permissions +3. **Memory Management**: Clear sensitive data from memory when possible +4. **Input Validation**: All inputs are validated using Zod schemas +5. **Error Messages**: Avoid leaking sensitive information in error messages + +## Success Criteria + +1. **Functional Parity**: All Rust auth functionality replicated +2. **No Classes**: Entire implementation uses functions and factories +3. **Type Safety**: Full TypeScript type safety with Zod validation +4. **Testability**: Comprehensive unit and integration tests +5. **Error Handling**: Robust error handling with Result types +6. **User Experience**: Rich Ink UI for interactive prompts +7. **Security**: Secure credential storage and handling +8. **Performance**: Fast startup and execution times + +This implementation plan provides a complete functional architecture for authentication while strictly adhering to functional programming principles and factory patterns. The design is modular, testable, and maintains compatibility with the existing Rust implementation. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index e6672e14a..5a8583c6a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,17 +60,72 @@ This is a pnpm-based monorepo using Turborepo with the following structure: - **Run quality checks while working**: Periodically run `turbo build:dry-run` and `turbo lint` to catch issues early - **Your primary job is the functional implementation**: Focus on accomplishing the task; the QA engineer specializes in comprehensive testing +## Unit Testing Philosophy + +### Importance of Unit Tests +- **Unit tests are critical** - Every function should be testable in isolation +- **Small, focused tests** - Each test should verify one specific behavior +- **Test file naming**: Unit tests use `.test.ts` extension, integration tests use `.int.test.ts` +- **Default to unit tests** - Always write unit tests unless specifically asked for integration tests +- **100% testability goal** - All code should be written to be testable in small chunks + +### Writing Testable Code +- **Pure functions first** - Functions should have clear inputs and outputs +- **Dependency injection** - Pass dependencies as parameters instead of importing directly +- **Avoid side effects** - Separate business logic from I/O operations +- **Small functions** - If a function is hard to test, it's probably doing too much +- **Mockable boundaries** - Design interfaces that can be easily mocked in tests + +### Test Structure +```typescript +// Example of testable function design +// Bad: Hard to test +async function sendNotification(userId: string) { + const user = await db.getUser(userId); // Direct database dependency + const message = createMessage(user); + await emailService.send(message); // Direct service dependency +} + +// Good: Easily testable +async function sendNotification( + userId: string, + getUser: (id: string) => Promise, + sendEmail: (message: Message) => Promise +) { + const user = await getUser(userId); + const message = createMessage(user); + await sendEmail(message); +} + +// Unit test example (*.test.ts) +describe('sendNotification', () => { + it('should send email with correct message', async () => { + const mockUser = { id: '123', name: 'Test User' }; + const mockGetUser = vi.fn().mockResolvedValue(mockUser); + const mockSendEmail = vi.fn().mockResolvedValue(undefined); + + await sendNotification('123', mockGetUser, mockSendEmail); + + expect(mockGetUser).toHaveBeenCalledWith('123'); + expect(mockSendEmail).toHaveBeenCalledWith( + expect.objectContaining({ to: mockUser.email }) + ); + }); +}); +``` + ## Development Workflow When writing code, follow this workflow to ensure code quality: ### 1. Write Modular, Testable Functions -- Create small, focused functions with single responsibilities -- Design functions to be easily testable with clear inputs/outputs -- Use dependency injection for external dependencies +- **Create small, focused functions** with single responsibilities that can be unit tested effectively +- **Design for testability** - Every function should have clear inputs/outputs and be testable in isolation +- **Use dependency injection** - Pass dependencies as parameters to enable easy mocking in unit tests - **IMPORTANT: Write functional, composable code - avoid classes** -- All features should be composed of testable functions -- Follow existing patterns in the codebase +- **All features must be composed of testable functions** - If you can't write a unit test for it, refactor it +- **Follow existing patterns** in the codebase for consistency +- **Remember**: Unit tests use `.test.ts`, integration tests use `.int.test.ts` ### 2. Build Features by Composing Functions - Combine modular functions to create complete features @@ -287,10 +342,15 @@ export async function getWorkspaceSettingsHandler( - For update operations, we should almost always perform an upsert unless otherwise specified ## Test Running Guidelines +- **Always default to unit tests** unless specifically asked for integration tests +- **Test file naming conventions**: + - Unit tests: `*.test.ts` (e.g., `user.test.ts`) + - Integration tests: `*.int.test.ts` (e.g., `user.int.test.ts`) - When running tests, use the following Turbo commands: - - `turbo test:unit` for unit tests - - `turbo test:integration` for integration tests + - `turbo test:unit` for unit tests (run these frequently during development) + - `turbo test:integration` for integration tests (run sparingly, only when needed) - `turbo test` for running all tests +- **Unit tests should be fast** - If a test takes more than a few milliseconds, it might be an integration test ## Pre-Completion Workflow - Run `turbo build:dry-run lint` during development to ensure type safety and code quality @@ -298,6 +358,123 @@ export async function getWorkspaceSettingsHandler( - Update task status and documentation in `.claude/tasks/` files - Coordinate with the monorepo-task-planner for any requirement changes +## Ink CLI Component Development + +### Overview +Ink is a React-based framework for building command-line interfaces. We use Ink v6.1.0 (latest stable) which requires Node.js 20+ and React 19. + +### Core Concepts +- **React for CLIs**: Build CLI output using React components +- **Flexbox Layout**: Uses Yoga layout engine for terminal layouts +- **Component-based**: All UI is built with components like `` and `` + +### Component Structure +- **All text must be wrapped in `` components** +- **`` is the fundamental container** - works like a flex container +- **Use flexbox properties** for layout (flexDirection, justifyContent, alignItems) +- **Style through props** - color, backgroundColor, bold, italic, etc. + +### Key APIs and Hooks +- `render()` - Display components in the terminal +- `useState()` - Manage component state +- `useEffect()` - Handle side effects and lifecycle +- `useInput()` - Capture and handle user keyboard input +- `useFocus()` - Manage focus between components +- `useStdout()` - Direct access to stdout stream +- `useStderr()` - Direct access to stderr stream + +### Component Best Practices +```typescript +import React, { useState, useEffect } from 'react'; +import { render, Box, Text } from 'ink'; + +// Example: Progress indicator component +const ProgressIndicator = ({ total, current }: { total: number; current: number }) => { + const percentage = Math.round((current / total) * 100); + + return ( + + Progress: {percentage}% + + {`[${'='.repeat(percentage / 2).padEnd(50, ' ')}]`} + + + ); +}; + +// Example: Interactive menu component +const Menu = ({ items }: { items: string[] }) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + useInput((input, key) => { + if (key.upArrow && selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1); + } + if (key.downArrow && selectedIndex < items.length - 1) { + setSelectedIndex(selectedIndex + 1); + } + }); + + return ( + + {items.map((item, index) => ( + + {index === selectedIndex ? '> ' : ' '}{item} + + ))} + + ); +}; +``` + +### Styling Guidelines +- **Text formatting**: Use props like `bold`, `italic`, `underline`, `strikethrough` +- **Colors**: Use `color` prop with color names or hex values +- **Background**: Use `backgroundColor` prop on Box components (v6.1.0+) +- **Dimensions**: Control with `width`, `height`, `minWidth`, `minHeight` +- **Spacing**: Use `margin`, `padding`, `gap` props +- **Text wrapping**: Use `wrap` prop on Text components + +### TypeScript Support +```typescript +import type { FC } from 'react'; +import { Box, Text } from 'ink'; + +interface StatusMessageProps { + status: 'success' | 'error' | 'warning'; + message: string; +} + +const StatusMessage: FC = ({ status, message }) => { + const colors = { + success: 'green', + error: 'red', + warning: 'yellow' + }; + + return ( + + + {status.toUpperCase()}: + + {message} + + ); +}; +``` + +### Testing Ink Components +- Use `ink-testing-library` for testing +- Test component output and interactions +- Mock terminal dimensions for consistent tests + +### Common Patterns +1. **Loading states**: Use spinners from `ink-spinner` +2. **Forms**: Use `ink-text-input` for text input +3. **Tables**: Use `ink-table` for tabular data +4. **Links**: Use `ink-link` for clickable URLs +5. **Progress**: Use `ink-progress-bar` for progress indicators + ## Local Development ### Local Development Details