diff --git a/apps/cli/src/commands/list.command.ts b/apps/cli/src/commands/list.command.ts index 38e4184b..63065fd3 100644 --- a/apps/cli/src/commands/list.command.ts +++ b/apps/cli/src/commands/list.command.ts @@ -17,6 +17,7 @@ import { } from '@tm/core'; import type { StorageType } from '@tm/core/types'; import * as ui from '../utils/ui.js'; +import { displayError } from '../utils/error-handler.js'; import { displayHeader, displayDashboards, @@ -106,14 +107,7 @@ export class ListTasksCommand extends Command { this.displayResults(result, options); } } catch (error: any) { - 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)); - } - process.exit(1); + displayError(error); } } diff --git a/apps/cli/src/commands/next.command.ts b/apps/cli/src/commands/next.command.ts index 5c037177..631a6528 100644 --- a/apps/cli/src/commands/next.command.ts +++ b/apps/cli/src/commands/next.command.ts @@ -9,6 +9,7 @@ import chalk from 'chalk'; import boxen from 'boxen'; import { createTaskMasterCore, type Task, type TaskMasterCore } from '@tm/core'; import type { StorageType } from '@tm/core/types'; +import { displayError } from '../utils/error-handler.js'; import { displayTaskDetails } from '../ui/components/task-detail.component.js'; import { displayHeader } from '../ui/index.js'; @@ -76,12 +77,7 @@ export class NextCommand extends Command { this.displayResults(result, options); } } catch (error: any) { - const msg = error?.getSanitizedDetails?.() ?? { - message: error?.message ?? String(error) - }; - - // Allow error to propagate for library compatibility - throw new Error(msg.message || 'Unexpected error in next command'); + displayError(error); } finally { // Always clean up resources, even on error await this.cleanup(); diff --git a/apps/cli/src/commands/set-status.command.ts b/apps/cli/src/commands/set-status.command.ts index 25dc40bd..b0a591e8 100644 --- a/apps/cli/src/commands/set-status.command.ts +++ b/apps/cli/src/commands/set-status.command.ts @@ -12,6 +12,7 @@ import { type TaskStatus } from '@tm/core'; import type { StorageType } from '@tm/core/types'; +import { displayError } from '../utils/error-handler.js'; /** * Valid task status values for validation @@ -135,16 +136,14 @@ export class SetStatusCommand extends Command { oldStatus: result.oldStatus, newStatus: result.newStatus }); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (!options.silent) { - console.error( - chalk.red(`Failed to update task ${taskId}: ${errorMessage}`) - ); - } + } catch (error: any) { if (options.format === 'json') { + const errorMessage = error?.getSanitizedDetails + ? error.getSanitizedDetails().message + : error instanceof Error + ? error.message + : String(error); + console.log( JSON.stringify({ success: false, @@ -153,8 +152,14 @@ export class SetStatusCommand extends Command { timestamp: new Date().toISOString() }) ); + process.exit(1); + } else if (!options.silent) { + // Show which task failed with context + console.error(chalk.red(`\nFailed to update task ${taskId}:`)); + displayError(error); + } else { + process.exit(1); } - process.exit(1); } } @@ -170,19 +175,17 @@ export class SetStatusCommand extends Command { // Display results this.displayResults(this.lastResult, options); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error occurred'; - - if (!options.silent) { - console.error(chalk.red(`Error: ${errorMessage}`)); - } - + } catch (error: any) { if (options.format === 'json') { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error occurred'; console.log(JSON.stringify({ success: false, error: errorMessage })); + process.exit(1); + } else if (!options.silent) { + displayError(error); + } else { + process.exit(1); } - - process.exit(1); } finally { // Clean up resources if (this.tmCore) { diff --git a/apps/cli/src/commands/show.command.ts b/apps/cli/src/commands/show.command.ts index f41cb786..9b58a98c 100644 --- a/apps/cli/src/commands/show.command.ts +++ b/apps/cli/src/commands/show.command.ts @@ -9,6 +9,7 @@ import boxen from 'boxen'; import { createTaskMasterCore, type Task, type TaskMasterCore } from '@tm/core'; import type { StorageType } from '@tm/core/types'; import * as ui from '../utils/ui.js'; +import { displayError } from '../utils/error-handler.js'; import { displayTaskDetails } from '../ui/components/task-detail.component.js'; /** @@ -112,14 +113,7 @@ export class ShowCommand extends Command { this.displayResults(result, options); } } catch (error: any) { - 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)); - } - process.exit(1); + displayError(error); } } diff --git a/apps/cli/src/commands/start.command.ts b/apps/cli/src/commands/start.command.ts index ec82c2e5..dbeb17a7 100644 --- a/apps/cli/src/commands/start.command.ts +++ b/apps/cli/src/commands/start.command.ts @@ -16,6 +16,7 @@ import { } from '@tm/core'; import { displayTaskDetails } from '../ui/components/task-detail.component.js'; import * as ui from '../utils/ui.js'; +import { displayError } from '../utils/error-handler.js'; /** * CLI-specific options interface for the start command @@ -160,8 +161,7 @@ export class StartCommand extends Command { if (spinner) { spinner.fail('Operation failed'); } - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -452,22 +452,6 @@ export class StartCommand extends Command { console.log(`\n${chalk.gray('Storage: ' + result.storageType)}`); } - /** - * Handle general errors - */ - private handleError(error: any): void { - const msg = error?.getSanitizedDetails?.() ?? { - message: error?.message ?? String(error) - }; - console.error(chalk.red(`Error: ${msg.message || 'Unexpected error'}`)); - - // Show stack trace in development mode or when DEBUG is set - const isDevelopment = process.env.NODE_ENV !== 'production'; - if ((isDevelopment || process.env.DEBUG) && error.stack) { - console.error(chalk.gray(error.stack)); - } - } - /** * Set the last result for programmatic access */ diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index cd010fb1..42f16dc9 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -24,6 +24,9 @@ export { // UI utilities (for other commands to use) export * as ui from './utils/ui.js'; +// Error handling utilities +export { displayError, isDebugMode } from './utils/error-handler.js'; + // Auto-update utilities export { checkForUpdate, diff --git a/apps/cli/src/utils/error-handler.ts b/apps/cli/src/utils/error-handler.ts new file mode 100644 index 00000000..900ed796 --- /dev/null +++ b/apps/cli/src/utils/error-handler.ts @@ -0,0 +1,60 @@ +/** + * @fileoverview Centralized error handling utilities for CLI + * Provides consistent error formatting and debug mode detection + */ + +import chalk from 'chalk'; + +/** + * Check if debug mode is enabled via environment variable + * Only returns true when DEBUG is explicitly set to 'true' or '1' + * + * @returns True if debug mode is enabled + */ +export function isDebugMode(): boolean { + return process.env.DEBUG === 'true' || process.env.DEBUG === '1'; +} + +/** + * Display an error to the user with optional stack trace in debug mode + * Handles both TaskMasterError instances and regular errors + * + * @param error - The error to display + * @param options - Display options + */ +export function displayError( + error: any, + options: { + /** Skip exit, useful when caller wants to handle exit */ + skipExit?: boolean; + /** Force show stack trace regardless of debug mode */ + forceStack?: boolean; + } = {} +): void { + // Check if it's a TaskMasterError with sanitized details + if (error?.getSanitizedDetails) { + const sanitized = error.getSanitizedDetails(); + console.error(chalk.red(`\n${sanitized.message}`)); + + // Show stack trace in debug mode or if forced + if ((isDebugMode() || options.forceStack) && error.stack) { + console.error(chalk.gray('\nStack trace:')); + console.error(chalk.gray(error.stack)); + } + } else { + // For other errors, show the message + const message = error?.message ?? String(error); + console.error(chalk.red(`\nError: ${message}`)); + + // Show stack trace in debug mode or if forced + if ((isDebugMode() || options.forceStack) && error?.stack) { + console.error(chalk.gray('\nStack trace:')); + console.error(chalk.gray(error.stack)); + } + } + + // Exit if not skipped + if (!options.skipExit) { + process.exit(1); + } +} diff --git a/packages/tm-core/src/errors/task-master-error.ts b/packages/tm-core/src/errors/task-master-error.ts index 545e6353..d5c8bb5f 100644 --- a/packages/tm-core/src/errors/task-master-error.ts +++ b/packages/tm-core/src/errors/task-master-error.ts @@ -52,7 +52,10 @@ export const ERROR_CODES = { INVALID_INPUT: 'INVALID_INPUT', NOT_IMPLEMENTED: 'NOT_IMPLEMENTED', UNKNOWN_ERROR: 'UNKNOWN_ERROR', - NOT_FOUND: 'NOT_FOUND' + NOT_FOUND: 'NOT_FOUND', + + // Context errors + NO_BRIEF_SELECTED: 'NO_BRIEF_SELECTED' } as const; export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; diff --git a/packages/tm-core/src/services/task-service.ts b/packages/tm-core/src/services/task-service.ts index 60fb7d3c..7f119e03 100644 --- a/packages/tm-core/src/services/task-service.ts +++ b/packages/tm-core/src/services/task-service.ts @@ -161,6 +161,13 @@ export class TaskService { storageType: this.getStorageType() }; } catch (error) { + // If it's a user-facing error (like NO_BRIEF_SELECTED), don't log it as an internal error + if (error instanceof TaskMasterError && error.is(ERROR_CODES.NO_BRIEF_SELECTED)) { + // Just re-throw user-facing errors without wrapping + throw error; + } + + // Log internal errors this.logger.error('Failed to get task list', error); throw new TaskMasterError( 'Failed to get task list', @@ -186,6 +193,11 @@ export class TaskService { // Delegate to storage layer which handles the specific logic for tasks vs subtasks return await this.storage.loadTask(String(taskId), activeTag); } catch (error) { + // If it's a user-facing error (like NO_BRIEF_SELECTED), don't wrap it + if (error instanceof TaskMasterError && error.is(ERROR_CODES.NO_BRIEF_SELECTED)) { + throw error; + } + throw new TaskMasterError( `Failed to get task ${taskId}`, ERROR_CODES.STORAGE_ERROR, @@ -522,6 +534,11 @@ export class TaskService { activeTag ); } catch (error) { + // If it's a user-facing error (like NO_BRIEF_SELECTED), don't wrap it + if (error instanceof TaskMasterError && error.is(ERROR_CODES.NO_BRIEF_SELECTED)) { + throw error; + } + throw new TaskMasterError( `Failed to update task status for ${taskIdStr}`, ERROR_CODES.STORAGE_ERROR, diff --git a/packages/tm-core/src/storage/api-storage.ts b/packages/tm-core/src/storage/api-storage.ts index 51784c9e..d7abf663 100644 --- a/packages/tm-core/src/storage/api-storage.ts +++ b/packages/tm-core/src/storage/api-storage.ts @@ -126,7 +126,7 @@ export class ApiStorage implements IStorage { private async loadTagsIntoCache(): Promise { try { const authManager = AuthManager.getInstance(); - const context = await authManager.getContext(); + const context = authManager.getContext(); // If we have a selected brief, create a virtual "tag" for it if (context?.briefId) { @@ -159,12 +159,18 @@ export class ApiStorage implements IStorage { try { const authManager = AuthManager.getInstance(); - const context = await authManager.getContext(); + const context = authManager.getContext(); // If no brief is selected in context, throw an error if (!context?.briefId) { - throw new Error( - 'No brief selected. Please select a brief first using: tm context brief ' + throw new TaskMasterError( + 'No brief selected', + ERROR_CODES.NO_BRIEF_SELECTED, + { + operation: 'loadTasks', + userMessage: + 'No brief selected. Please select a brief first using: tm context brief or tm context brief ' + } ); } @@ -181,6 +187,14 @@ export class ApiStorage implements IStorage { return tasks; } catch (error) { + // If it's already a NO_BRIEF_SELECTED error, don't wrap it + if ( + error instanceof TaskMasterError && + error.is(ERROR_CODES.NO_BRIEF_SELECTED) + ) { + throw error; + } + throw new TaskMasterError( 'Failed to load tasks from API', ERROR_CODES.STORAGE_ERROR, @@ -237,10 +251,34 @@ export class ApiStorage implements IStorage { await this.ensureInitialized(); try { + const authManager = AuthManager.getInstance(); + const context = authManager.getContext(); + + // If no brief is selected in context, throw an error + if (!context?.briefId) { + throw new TaskMasterError( + 'No brief selected', + ERROR_CODES.NO_BRIEF_SELECTED, + { + operation: 'loadTask', + userMessage: + 'No brief selected. Please select a brief first using: tm context brief or tm context brief ' + } + ); + } + return await this.retryOperation(() => this.repository.getTask(this.projectId, taskId) ); } catch (error) { + // If it's already a NO_BRIEF_SELECTED error, don't wrap it + if ( + error instanceof TaskMasterError && + error.is(ERROR_CODES.NO_BRIEF_SELECTED) + ) { + throw error; + } + throw new TaskMasterError( 'Failed to load task from API', ERROR_CODES.STORAGE_ERROR, @@ -325,7 +363,7 @@ export class ApiStorage implements IStorage { try { const authManager = AuthManager.getInstance(); - const context = await authManager.getContext(); + const context = authManager.getContext(); // In our API-based system, we only have one "tag" at a time - the current brief if (context?.briefId) { @@ -510,6 +548,22 @@ export class ApiStorage implements IStorage { await this.ensureInitialized(); try { + const authManager = AuthManager.getInstance(); + const context = authManager.getContext(); + + // If no brief is selected in context, throw an error + if (!context?.briefId) { + throw new TaskMasterError( + 'No brief selected', + ERROR_CODES.NO_BRIEF_SELECTED, + { + operation: 'updateTaskStatus', + userMessage: + 'No brief selected. Please select a brief first using: tm context brief or tm context brief ' + } + ); + } + const existingTask = await this.retryOperation(() => this.repository.getTask(this.projectId, taskId) ); @@ -546,6 +600,14 @@ export class ApiStorage implements IStorage { taskId }; } catch (error) { + // If it's already a NO_BRIEF_SELECTED error, don't wrap it + if ( + error instanceof TaskMasterError && + error.is(ERROR_CODES.NO_BRIEF_SELECTED) + ) { + throw error; + } + throw new TaskMasterError( 'Failed to update task status via API', ERROR_CODES.STORAGE_ERROR, diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index f3032bd5..13361b7b 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -19,7 +19,8 @@ import { registerAllCommands, checkForUpdate, performAutoUpdate, - displayUpgradeNotification + displayUpgradeNotification, + displayError } from '@tm/cli'; import { @@ -5156,10 +5157,7 @@ async function runCLI(argv = process.argv) { ); } else { // Generic error handling for other errors - console.error(chalk.red(`Error: ${error.message}`)); - if (getDebugFlag()) { - console.error(error); - } + displayError(error); } process.exit(1);