diff --git a/apps/cli/src/commands/auth.command.ts b/apps/cli/src/commands/auth.command.ts new file mode 100644 index 00000000..fd9adeac --- /dev/null +++ b/apps/cli/src/commands/auth.command.ts @@ -0,0 +1,532 @@ +/** + * @fileoverview Auth command using Commander's native class pattern + * Extends Commander.Command for better integration with the framework + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import inquirer from 'inquirer'; +import ora from 'ora'; +import { exec } from 'child_process'; +import { + AuthManager, + AuthenticationError, + type AuthCredentials +} from '@tm/core'; +import * as ui from '../utils/ui.js'; + +/** + * Result type from auth command + */ +export interface AuthResult { + success: boolean; + action: 'login' | 'logout' | 'status' | 'refresh'; + credentials?: AuthCredentials; + message?: string; +} + +/** + * AuthCommand extending Commander's Command class + * This is a thin presentation layer over @tm/core's AuthManager + */ +export class AuthCommand extends Command { + private authManager: AuthManager; + private lastResult?: AuthResult; + + constructor(name?: string) { + super(name || 'auth'); + + // Initialize auth manager + this.authManager = AuthManager.getInstance(); + + // Configure the command with subcommands + this.description('Manage authentication with tryhamster.com'); + + // Add subcommands + this.addLoginCommand(); + this.addLogoutCommand(); + this.addStatusCommand(); + this.addRefreshCommand(); + + // Default action shows help + this.action(() => { + this.help(); + }); + } + + /** + * Add login subcommand + */ + private addLoginCommand(): void { + this.command('login') + .description('Authenticate with tryhamster.com') + .action(async () => { + await this.executeLogin(); + }); + } + + /** + * Add logout subcommand + */ + private addLogoutCommand(): void { + this.command('logout') + .description('Logout and clear credentials') + .action(async () => { + await this.executeLogout(); + }); + } + + /** + * Add status subcommand + */ + private addStatusCommand(): void { + this.command('status') + .description('Display authentication status') + .action(async () => { + await this.executeStatus(); + }); + } + + /** + * Add refresh subcommand + */ + private addRefreshCommand(): void { + this.command('refresh') + .description('Refresh authentication token') + .action(async () => { + await this.executeRefresh(); + }); + } + + /** + * Execute login command + */ + private async executeLogin(): Promise { + try { + const result = await this.performInteractiveAuth(); + this.setLastResult(result); + + if (!result.success) { + process.exit(1); + } + } catch (error: any) { + this.handleError(error); + process.exit(1); + } + } + + /** + * Execute logout command + */ + private async executeLogout(): Promise { + try { + const result = this.performLogout(); + this.setLastResult(result); + + if (!result.success) { + process.exit(1); + } + } catch (error: any) { + this.handleError(error); + process.exit(1); + } + } + + /** + * Execute status command + */ + private async executeStatus(): Promise { + try { + const result = this.displayStatus(); + this.setLastResult(result); + } catch (error: any) { + this.handleError(error); + process.exit(1); + } + } + + /** + * Execute refresh command + */ + private async executeRefresh(): Promise { + try { + const result = await this.refreshToken(); + this.setLastResult(result); + + if (!result.success) { + process.exit(1); + } + } catch (error: any) { + this.handleError(error); + process.exit(1); + } + } + + /** + * Display authentication status + */ + private displayStatus(): AuthResult { + const credentials = this.authManager.getCredentials(); + + console.log(chalk.cyan('\nšŸ” Authentication Status\n')); + + if (credentials) { + console.log(chalk.green('āœ“ Authenticated')); + console.log(chalk.gray(` Email: ${credentials.email || 'N/A'}`)); + console.log(chalk.gray(` User ID: ${credentials.userId}`)); + console.log( + chalk.gray(` Token Type: ${credentials.tokenType || 'standard'}`) + ); + + if (credentials.expiresAt) { + const expiresAt = new Date(credentials.expiresAt); + const now = new Date(); + const hoursRemaining = Math.floor( + (expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60) + ); + + if (hoursRemaining > 0) { + console.log( + chalk.gray( + ` Expires: ${expiresAt.toLocaleString()} (${hoursRemaining} hours remaining)` + ) + ); + } else { + console.log( + chalk.yellow(` Token expired at: ${expiresAt.toLocaleString()}`) + ); + } + } else { + console.log(chalk.gray(' Expires: Never (API key)')); + } + + console.log( + chalk.gray(` Saved: ${new Date(credentials.savedAt).toLocaleString()}`) + ); + + return { + success: true, + action: 'status', + credentials, + message: 'Authenticated' + }; + } else { + console.log(chalk.yellow('āœ— Not authenticated')); + console.log(chalk.gray('\n Run "task-master auth login" to authenticate')); + + return { + success: false, + action: 'status', + message: 'Not authenticated' + }; + } + } + + /** + * Perform logout + */ + private performLogout(): AuthResult { + try { + this.authManager.logout(); + ui.displaySuccess('Successfully logged out'); + + return { + success: true, + action: 'logout', + message: 'Successfully logged out' + }; + } catch (error) { + const message = `Failed to logout: ${(error as Error).message}`; + ui.displayError(message); + + return { + success: false, + action: 'logout', + message + }; + } + } + + /** + * Refresh authentication token + */ + private async refreshToken(): Promise { + const spinner = ora('Refreshing authentication token...').start(); + + try { + const credentials = await this.authManager.refreshToken(); + spinner.succeed('Token refreshed successfully'); + + console.log( + chalk.gray( + ` New expiration: ${credentials.expiresAt ? new Date(credentials.expiresAt).toLocaleString() : 'Never'}` + ) + ); + + return { + success: true, + action: 'refresh', + credentials, + message: 'Token refreshed successfully' + }; + } catch (error) { + spinner.fail('Failed to refresh token'); + + if ((error as AuthenticationError).code === 'NO_REFRESH_TOKEN') { + ui.displayWarning( + 'No refresh token available. Please re-authenticate.' + ); + } else { + ui.displayError(`Refresh failed: ${(error as Error).message}`); + } + + return { + success: false, + action: 'refresh', + message: `Failed to refresh: ${(error as Error).message}` + }; + } + } + + /** + * Perform interactive authentication + */ + private async performInteractiveAuth(): Promise { + ui.displayBanner('Task Master Authentication'); + + // Check if already authenticated + if (this.authManager.isAuthenticated()) { + const { continueAuth } = await inquirer.prompt([ + { + type: 'confirm', + name: 'continueAuth', + message: + 'You are already authenticated. Do you want to re-authenticate?', + default: false + } + ]); + + if (!continueAuth) { + const credentials = this.authManager.getCredentials(); + ui.displaySuccess('Using existing authentication'); + + if (credentials) { + console.log(chalk.gray(` Email: ${credentials.email || 'N/A'}`)); + console.log(chalk.gray(` User ID: ${credentials.userId}`)); + } + + return { + success: true, + action: 'login', + credentials: credentials || undefined, + message: 'Using existing authentication' + }; + } + } + + try { + // Direct browser authentication - no menu needed + const credentials = await this.authenticateWithBrowser(); + + ui.displaySuccess('Authentication successful!'); + console.log( + chalk.gray(` Logged in as: ${credentials.email || credentials.userId}`) + ); + + return { + success: true, + action: 'login', + credentials, + message: 'Authentication successful' + }; + } catch (error) { + this.handleAuthError(error as AuthenticationError); + + return { + success: false, + action: 'login', + message: `Authentication failed: ${(error as Error).message}` + }; + } + } + + /** + * Authenticate with browser using OAuth 2.0 with PKCE + */ + private async authenticateWithBrowser(): Promise { + const spinner = ora('Starting authentication server...').start(); + + try { + // Start OAuth flow with PKCE (this starts the local server) + const authPromise = this.authManager.startOAuthFlow(); + + // Wait a moment for server to start + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Get the authorization URL + const authUrl = this.authManager.getAuthorizationUrl(); + + if (!authUrl) { + throw new AuthenticationError( + 'Failed to generate authorization URL', + 'URL_GENERATION_FAILED' + ); + } + + spinner.stop(); + + // Display authentication instructions + console.log(chalk.blue.bold('\nšŸ” Browser Authentication\n')); + console.log(chalk.white(' Opening your browser to authenticate...')); + console.log(chalk.gray(" If the browser doesn't open, visit:")); + console.log(chalk.cyan.underline(` ${authUrl}\n`)); + + // Open browser + this.openBrowser(authUrl); + + // Wait for authentication with spinner + const authSpinner = ora({ + text: 'Waiting for authentication...', + spinner: 'dots' + }).start(); + + try { + const credentials = await authPromise; + authSpinner.succeed('Authentication successful!'); + return credentials; + } catch (error) { + authSpinner.fail('Authentication failed'); + throw error; + } + } catch (error) { + if (spinner.isSpinning) { + spinner.fail('Authentication failed'); + } + throw error; + } + } + + /** + * Open browser with the given URL + */ + private openBrowser(url: string): void { + const platform = process.platform; + let command: string; + + if (platform === 'darwin') { + command = `open "${url}"`; + } else if (platform === 'win32') { + command = `start "${url}"`; + } else { + command = `xdg-open "${url}"`; + } + + exec(command, (error) => { + if (error) { + // Silently fail - user can still manually open the URL + console.log(chalk.gray('\n (Could not automatically open browser)')); + } + }); + } + + /** + * Handle authentication errors + */ + private handleAuthError(error: AuthenticationError): void { + console.error(chalk.red(`\nāœ— ${error.message}`)); + + switch (error.code) { + case 'NETWORK_ERROR': + ui.displayWarning( + 'Please check your internet connection and try again.' + ); + break; + case 'INVALID_CREDENTIALS': + ui.displayWarning('Please check your credentials and try again.'); + break; + case 'AUTH_EXPIRED': + ui.displayWarning( + 'Your session has expired. Please authenticate again.' + ); + break; + default: + if (process.env.DEBUG) { + console.error(chalk.gray(error.stack || '')); + } + } + } + + /** + * Handle general errors + */ + private handleError(error: any): void { + if (error instanceof AuthenticationError) { + this.handleAuthError(error); + } else { + const msg = error?.getSanitizedDetails?.() ?? { + message: error?.message ?? String(error) + }; + console.error(chalk.red(`Error: ${msg.message || 'Unexpected error'}`)); + + if (error.stack && process.env.DEBUG) { + console.error(chalk.gray(error.stack)); + } + } + } + + /** + * Set the last result for programmatic access + */ + private setLastResult(result: AuthResult): void { + this.lastResult = result; + } + + /** + * Get the last result (for programmatic usage) + */ + getLastResult(): AuthResult | undefined { + return this.lastResult; + } + + /** + * Get current authentication status (for programmatic usage) + */ + isAuthenticated(): boolean { + return this.authManager.isAuthenticated(); + } + + /** + * Get current credentials (for programmatic usage) + */ + getCredentials(): AuthCredentials | null { + return this.authManager.getCredentials(); + } + + /** + * Clean up resources + */ + async cleanup(): Promise { + // No resources to clean up for auth command + // But keeping method for consistency with other commands + } + + /** + * Static method to register this command on an existing program + * This is for gradual migration - allows commands.js to use this + */ + static registerOn(program: Command): Command { + const authCommand = new AuthCommand(); + program.addCommand(authCommand); + return authCommand; + } + + /** + * Alternative registration that returns the command for chaining + * Can also configure the command name if needed + */ + static register(program: Command, name?: string): AuthCommand { + const authCommand = new AuthCommand(name); + program.addCommand(authCommand); + return authCommand; + } +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 9e096a5e..10fe1c6a 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -5,6 +5,7 @@ // Commands export { ListTasksCommand } from './commands/list.command.js'; +export { AuthCommand } from './commands/auth.command.js'; // UI utilities (for other commands to use) export * as ui from './utils/ui.js'; diff --git a/packages/tm-core/src/auth/auth-manager.ts b/packages/tm-core/src/auth/auth-manager.ts new file mode 100644 index 00000000..cfee18cf --- /dev/null +++ b/packages/tm-core/src/auth/auth-manager.ts @@ -0,0 +1,626 @@ +/** + * Authentication manager for tryhamster.com + */ + +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import https from 'https'; +import http from 'http'; +import { URL } from 'url'; +import crypto from 'crypto'; + +// Auth configuration +const AUTH_CONFIG_DIR = path.join(os.homedir(), '.taskmaster'); +const AUTH_CONFIG_FILE = path.join(AUTH_CONFIG_DIR, 'auth.json'); +// const API_BASE_URL = process.env.HAMSTER_API_URL || 'https://tryhamster.com/api'; +const API_BASE_URL = process.env.HAMSTER_API_URL || 'https://localhost:8080'; + +export interface AuthCredentials { + token: string; + refreshToken?: string; + userId: string; + email?: string; + expiresAt?: string; + tokenType?: 'standard' | 'api_key'; + savedAt: string; +} + +export interface AuthOptions { + email?: string; + password?: string; + apiKey?: string; +} + +export interface AuthResponse { + token: string; + refreshToken?: string; + userId: string; + email?: string; + expiresAt?: string; +} + +/** + * Authentication error class + */ +export class AuthenticationError extends Error { + constructor( + message: string, + public code: string + ) { + super(message); + this.name = 'AuthenticationError'; + } +} + +/** + * Authentication manager class + */ +export class AuthManager { + private static instance: AuthManager; + + private constructor() {} + + /** + * Get singleton instance + */ + static getInstance(): AuthManager { + if (!AuthManager.instance) { + AuthManager.instance = new AuthManager(); + } + return AuthManager.instance; + } + + /** + * Get stored authentication credentials + */ + getCredentials(): AuthCredentials | null { + try { + // Check for environment variable override (useful for CI/CD) + // Similar to SUPABASE_ACCESS_TOKEN pattern + if (process.env.TASKMASTER_ACCESS_TOKEN) { + return { + token: process.env.TASKMASTER_ACCESS_TOKEN, + userId: process.env.TASKMASTER_USER_ID || 'env-user', + email: process.env.TASKMASTER_EMAIL, + tokenType: 'api_key', + savedAt: new Date().toISOString() + }; + } + + if (!fs.existsSync(AUTH_CONFIG_FILE)) { + return null; + } + + const authData = JSON.parse( + fs.readFileSync(AUTH_CONFIG_FILE, 'utf-8') + ) as AuthCredentials; + + // Check if token is expired + if (authData.expiresAt && new Date(authData.expiresAt) < new Date()) { + console.warn('Authentication token has expired'); + return null; + } + + return authData; + } catch (error) { + console.error( + `Failed to read auth credentials: ${(error as Error).message}` + ); + return null; + } + } + + /** + * Save authentication credentials + */ + private saveCredentials(authData: AuthCredentials): void { + try { + // Ensure directory exists + if (!fs.existsSync(AUTH_CONFIG_DIR)) { + fs.mkdirSync(AUTH_CONFIG_DIR, { recursive: true }); + } + + // Add timestamp + authData.savedAt = new Date().toISOString(); + + // Save credentials + fs.writeFileSync(AUTH_CONFIG_FILE, JSON.stringify(authData, null, 2)); + + // Set file permissions to read/write for owner only + fs.chmodSync(AUTH_CONFIG_FILE, 0o600); + } catch (error) { + throw new AuthenticationError( + `Failed to save auth credentials: ${(error as Error).message}`, + 'SAVE_FAILED' + ); + } + } + + /** + * Make an API request + */ + private makeApiRequest(endpoint: string, options: any = {}): Promise { + return new Promise((resolve, reject) => { + const url = new URL(endpoint, API_BASE_URL); + + const requestOptions = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname + url.search, + method: options.method || 'GET', + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }; + + const protocol = url.protocol === 'https:' ? https : http; + + const req = protocol.request(requestOptions, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + try { + const parsedData = JSON.parse(data); + + if ( + res.statusCode && + res.statusCode >= 200 && + res.statusCode < 300 + ) { + resolve(parsedData); + } else { + reject( + new AuthenticationError( + parsedData.message || + `API request failed with status ${res.statusCode}`, + parsedData.code || 'API_ERROR' + ) + ); + } + } catch (error) { + reject( + new AuthenticationError( + `Failed to parse API response: ${(error as Error).message}`, + 'PARSE_ERROR' + ) + ); + } + }); + }); + + req.on('error', (error) => { + reject( + new AuthenticationError( + `Network error: ${error.message}`, + 'NETWORK_ERROR' + ) + ); + }); + + if (options.body) { + req.write(JSON.stringify(options.body)); + } + + req.end(); + }); + } + + /** + * Generate PKCE parameters for OAuth flow + */ + private generatePKCEParams(): { + codeVerifier: string; + codeChallenge: string; + state: string; + } { + // Generate code verifier (43-128 characters) + const codeVerifier = crypto.randomBytes(32).toString('base64url'); + + // Generate code challenge using SHA256 + const codeChallenge = crypto + .createHash('sha256') + .update(codeVerifier) + .digest('base64url'); + + // Generate state for CSRF protection + const state = crypto.randomBytes(16).toString('base64url'); + + return { codeVerifier, codeChallenge, state }; + } + + /** + * Get a random available port + */ + private async getRandomPort(): Promise { + return new Promise((resolve) => { + const server = http.createServer(); + server.listen(0, '127.0.0.1', () => { + const port = (server.address() as any).port; + server.close(() => resolve(port)); + }); + }); + } + + /** + * Start OAuth 2.0 Authorization Code Flow with PKCE + */ + async startOAuthFlow(): Promise { + const { codeVerifier, codeChallenge, state } = this.generatePKCEParams(); + const port = await this.getRandomPort(); + const redirectUri = `http://127.0.0.1:${port}/callback`; + + return new Promise((resolve, reject) => { + let serverClosed = false; + + // Create local HTTP server for OAuth callback + const server = http.createServer(async (req, res) => { + const url = new URL(req.url!, `http://127.0.0.1:${port}`); + + if (url.pathname === '/callback') { + const code = url.searchParams.get('code'); + const returnedState = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + const errorDescription = url.searchParams.get('error_description'); + + // Send response to browser + res.writeHead(200, { 'Content-Type': 'text/html' }); + + if (error) { + res.end(` + + + + Authentication Failed + + + +
+

āŒ Authentication Failed

+

${errorDescription || error}

+

You can close this window and try again.

+
+ + + `); + + if (!serverClosed) { + serverClosed = true; + server.close(); + reject( + new AuthenticationError( + errorDescription || error || 'Authentication failed', + 'OAUTH_ERROR' + ) + ); + } + return; + } + + // Verify state parameter + if (returnedState !== state) { + res.end(` + + + + Security Error + + + +
+

āš ļø Security Error

+

Invalid state parameter. Please try again.

+
+ + + `); + + if (!serverClosed) { + serverClosed = true; + server.close(); + reject( + new AuthenticationError( + 'Invalid state parameter', + 'INVALID_STATE' + ) + ); + } + return; + } + + if (code) { + res.end(` + + + + Authentication Successful + + + +
+ + + + +

Authentication Successful!

+

You can close this window and return to your terminal.

+

Task Master CLI

+
+ + + `); + + try { + // Exchange authorization code for tokens + const tokens = await this.exchangeCodeForTokens( + code, + codeVerifier, + redirectUri + ); + + if (!serverClosed) { + serverClosed = true; + server.close(); + resolve(tokens); + } + } catch (error) { + if (!serverClosed) { + serverClosed = true; + server.close(); + reject(error); + } + } + } + } else { + // Handle other paths (favicon, etc.) + res.writeHead(404); + res.end(); + } + }); + + // Start server on localhost only + server.listen(port, '127.0.0.1', () => { + // Build authorization URL + const authUrl = new URL(`${API_BASE_URL.replace('/api', '')}/auth/cli`); + authUrl.searchParams.append('client_id', 'task-master-cli'); + authUrl.searchParams.append('redirect_uri', redirectUri); + authUrl.searchParams.append('response_type', 'code'); + authUrl.searchParams.append('code_challenge', codeChallenge); + authUrl.searchParams.append('code_challenge_method', 'S256'); + authUrl.searchParams.append('state', state); + authUrl.searchParams.append('scope', 'offline_access'); // Request refresh token + + // Store auth URL for browser opening + (this as any).authorizationUrl = authUrl.toString(); + }); + + // Set timeout for authentication + setTimeout( + () => { + if (!serverClosed) { + serverClosed = true; + server.close(); + reject( + new AuthenticationError('Authentication timeout', 'AUTH_TIMEOUT') + ); + } + }, + 5 * 60 * 1000 + ); // 5 minute timeout + }); + } + + /** + * Exchange authorization code for tokens using PKCE + */ + private async exchangeCodeForTokens( + code: string, + codeVerifier: string, + redirectUri: string + ): Promise { + try { + const response = (await this.makeApiRequest('/auth/token', { + method: 'POST', + body: { + grant_type: 'authorization_code', + client_id: 'task-master-cli', + code, + code_verifier: codeVerifier, + redirect_uri: redirectUri + } + })) as AuthResponse; + + // Save authentication data + const authData: AuthCredentials = { + token: response.token, + refreshToken: response.refreshToken, + userId: response.userId, + email: response.email, + expiresAt: response.expiresAt, + tokenType: 'standard', + savedAt: new Date().toISOString() + }; + + this.saveCredentials(authData); + return authData; + } catch (error) { + throw new AuthenticationError( + `Failed to exchange code for tokens: ${(error as Error).message}`, + 'TOKEN_EXCHANGE_FAILED' + ); + } + } + + /** + * Get the authorization URL (for browser opening) + */ + getAuthorizationUrl(): string | null { + return (this as any).authorizationUrl || null; + } + + /** + * Authenticate with email and password + */ + async authenticateWithCredentials( + email: string, + password: string + ): Promise { + try { + const response = (await this.makeApiRequest('/auth/login', { + method: 'POST', + body: { email, password } + })) as AuthResponse; + + // Save authentication data + const authData: AuthCredentials = { + token: response.token, + refreshToken: response.refreshToken, + userId: response.userId, + email: email, + expiresAt: response.expiresAt, + tokenType: 'standard', + savedAt: new Date().toISOString() + }; + + this.saveCredentials(authData); + + return authData; + } catch (error) { + throw error; + } + } + + /** + * Authenticate with API key + */ + async authenticateWithApiKey(apiKey: string): Promise { + try { + const response = (await this.makeApiRequest('/auth/validate', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}` + } + })) as AuthResponse; + + // Save authentication data + const authData: AuthCredentials = { + token: apiKey, + tokenType: 'api_key', + userId: response.userId, + email: response.email, + expiresAt: undefined, // API keys don't expire + savedAt: new Date().toISOString() + }; + + this.saveCredentials(authData); + + return authData; + } catch (error) { + throw error; + } + } + + /** + * Refresh authentication token + */ + async refreshToken(): Promise { + const authData = this.getCredentials(); + + if (!authData || !authData.refreshToken) { + throw new AuthenticationError( + 'No refresh token available', + 'NO_REFRESH_TOKEN' + ); + } + + try { + const response = (await this.makeApiRequest('/auth/refresh', { + method: 'POST', + body: { + refreshToken: authData.refreshToken + } + })) as AuthResponse; + + // Update authentication data + const newAuthData: AuthCredentials = { + ...authData, + token: response.token, + expiresAt: response.expiresAt, + savedAt: new Date().toISOString() + }; + + this.saveCredentials(newAuthData); + + return newAuthData; + } catch (error) { + throw error; + } + } + + /** + * Logout and clear credentials + */ + logout(): void { + try { + if (fs.existsSync(AUTH_CONFIG_FILE)) { + fs.unlinkSync(AUTH_CONFIG_FILE); + } + } catch (error) { + throw new AuthenticationError( + `Failed to logout: ${(error as Error).message}`, + 'LOGOUT_FAILED' + ); + } + } + + /** + * Check if authenticated + */ + isAuthenticated(): boolean { + // Fast check for environment variable + if (process.env.TASKMASTER_ACCESS_TOKEN) { + return true; + } + + const authData = this.getCredentials(); + return authData !== null; + } + + /** + * Get authorization headers + */ + getAuthHeaders(): Record { + const authData = this.getCredentials(); + + if (!authData) { + throw new AuthenticationError( + 'Not authenticated. Please authenticate first.', + 'NOT_AUTHENTICATED' + ); + } + + return { + Authorization: `Bearer ${authData.token}` + }; + } +} diff --git a/packages/tm-core/src/index.ts b/packages/tm-core/src/index.ts index 6a83c683..e5bf6cc5 100644 --- a/packages/tm-core/src/index.ts +++ b/packages/tm-core/src/index.ts @@ -43,3 +43,12 @@ export * from './errors'; // Re-export entities export { TaskEntity } from './entities/task.entity'; + +// Re-export authentication +export { + AuthManager, + AuthenticationError, + type AuthCredentials, + type AuthOptions, + type AuthResponse +} from './auth/auth-manager'; diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index b684f44b..82085b5a 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -15,8 +15,8 @@ import search from '@inquirer/search'; import ora from 'ora'; // Import ora import { log, readJSON } from './utils.js'; -// Import new ListTasksCommand from @tm/cli -import { ListTasksCommand } from '@tm/cli'; +// Import new commands from @tm/cli +import { ListTasksCommand, AuthCommand } from '@tm/cli'; import { parsePRD, @@ -1740,6 +1740,11 @@ function registerCommands(programInstance) { // NEW: Register the new list command from @tm/cli // This command handles all its own configuration and logic ListTasksCommand.registerOn(programInstance); + + // Register the auth command from @tm/cli + // Handles authentication with tryhamster.com + AuthCommand.registerOn(programInstance); + // expand command programInstance .command('expand') diff --git a/tests/integration/profiles/roo-files-inclusion.test.js b/tests/integration/profiles/roo-files-inclusion.test.js index b795479e..77451241 100644 --- a/tests/integration/profiles/roo-files-inclusion.test.js +++ b/tests/integration/profiles/roo-files-inclusion.test.js @@ -103,10 +103,14 @@ describe('Roo Files Inclusion in Package', () => { test('source Roo files exist in public/assets directory', () => { // Verify that the source files for Roo integration exist expect( - fs.existsSync(path.join(process.cwd(), 'public', 'assets', 'roocode', '.roo')) + fs.existsSync( + path.join(process.cwd(), 'public', 'assets', 'roocode', '.roo') + ) ).toBe(true); expect( - fs.existsSync(path.join(process.cwd(), 'public', 'assets', 'roocode', '.roomodes')) + fs.existsSync( + path.join(process.cwd(), 'public', 'assets', 'roocode', '.roomodes') + ) ).toBe(true); }); }); diff --git a/tests/integration/profiles/rules-files-inclusion.test.js b/tests/integration/profiles/rules-files-inclusion.test.js index 00b65bed..c8cdbe03 100644 --- a/tests/integration/profiles/rules-files-inclusion.test.js +++ b/tests/integration/profiles/rules-files-inclusion.test.js @@ -89,10 +89,14 @@ describe('Rules Files Inclusion in Package', () => { test('source Roo files exist in public/assets directory', () => { // Verify that the source files for Roo integration exist expect( - fs.existsSync(path.join(process.cwd(), 'public', 'assets', 'roocode', '.roo')) + fs.existsSync( + path.join(process.cwd(), 'public', 'assets', 'roocode', '.roo') + ) ).toBe(true); expect( - fs.existsSync(path.join(process.cwd(), 'public', 'assets', 'roocode', '.roomodes')) + fs.existsSync( + path.join(process.cwd(), 'public', 'assets', 'roocode', '.roomodes') + ) ).toBe(true); }); }); diff --git a/tests/unit/prompt-manager.test.js b/tests/unit/prompt-manager.test.js index 8abe1c41..f135f7d0 100644 --- a/tests/unit/prompt-manager.test.js +++ b/tests/unit/prompt-manager.test.js @@ -62,11 +62,11 @@ describe('PromptManager', () => { describe('loadPrompt', () => { it('should load and render a prompt from actual files', () => { // Test with an actual prompt that exists - const result = promptManager.loadPrompt('research', { + const result = promptManager.loadPrompt('research', { query: 'test query', projectContext: 'test context' }); - + expect(result.systemPrompt).toBeDefined(); expect(result.userPrompt).toBeDefined(); expect(result.userPrompt).toContain('test query'); @@ -87,7 +87,7 @@ describe('PromptManager', () => { }); const result = promptManager.loadPrompt('test-prompt', { name: 'John' }); - + expect(result.userPrompt).toBe('Hello John, your age is '); }); @@ -100,13 +100,13 @@ describe('PromptManager', () => { it('should use cache for repeated calls', () => { // First call with a real prompt const result1 = promptManager.loadPrompt('research', { query: 'test' }); - + // Mark the result to verify cache is used result1._cached = true; - + // Second call with same parameters should return cached result const result2 = promptManager.loadPrompt('research', { query: 'test' }); - + expect(result2._cached).toBe(true); expect(result1).toBe(result2); // Same object reference }); @@ -127,7 +127,7 @@ describe('PromptManager', () => { const result = promptManager.loadPrompt('array-prompt', { items: ['one', 'two', 'three'] }); - + // The actual implementation doesn't handle {{this}} properly, check what it does produce expect(result.userPrompt).toContain('Item:'); }); @@ -145,10 +145,14 @@ describe('PromptManager', () => { } }); - const withData = promptManager.loadPrompt('conditional-prompt', { hasData: true }); + const withData = promptManager.loadPrompt('conditional-prompt', { + hasData: true + }); expect(withData.userPrompt).toBe('Data exists'); - const withoutData = promptManager.loadPrompt('conditional-prompt', { hasData: false }); + const withoutData = promptManager.loadPrompt('conditional-prompt', { + hasData: false + }); expect(withoutData.userPrompt).toBe('No data'); }); }); @@ -162,7 +166,7 @@ describe('PromptManager', () => { age: 30 } }; - + const result = promptManager.renderTemplate(template, variables); expect(result).toBe('User: John, Age: 30'); }); @@ -172,7 +176,7 @@ describe('PromptManager', () => { const variables = { special: '<>&"\'' }; - + const result = promptManager.renderTemplate(template, variables); expect(result).toBe('Special: <>&"\''); }); @@ -183,8 +187,8 @@ describe('PromptManager', () => { const prompts = promptManager.listPrompts(); expect(prompts).toBeInstanceOf(Array); expect(prompts.length).toBeGreaterThan(0); - - const ids = prompts.map(p => p.id); + + const ids = prompts.map((p) => p.id); expect(ids).toContain('analyze-complexity'); expect(ids).toContain('expand-task'); expect(ids).toContain('add-task'); @@ -192,7 +196,6 @@ describe('PromptManager', () => { }); }); - describe('validateTemplate', () => { it('should validate a correct template', () => { const result = promptManager.validateTemplate('research'); @@ -202,7 +205,7 @@ describe('PromptManager', () => { it('should reject invalid template', () => { const result = promptManager.validateTemplate('non-existent'); expect(result.valid).toBe(false); - expect(result.error).toContain("not found"); + expect(result.error).toContain('not found'); }); }); -}); \ No newline at end of file +});