diff --git a/apps/cli/src/commands/auth.command.ts b/apps/cli/src/commands/auth.command.ts index e2878dd8..7c2e03c3 100644 --- a/apps/cli/src/commands/auth.command.ts +++ b/apps/cli/src/commands/auth.command.ts @@ -15,6 +15,7 @@ import { } from '@tm/core/auth'; import * as ui from '../utils/ui.js'; import { ContextCommand } from './context.command.js'; +import { displayError } from '../utils/error-handler.js'; /** * Result type from auth command @@ -117,8 +118,7 @@ export class AuthCommand extends Command { process.exit(0); }, 100); } catch (error: any) { - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -134,8 +134,7 @@ export class AuthCommand extends Command { process.exit(1); } } catch (error: any) { - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -147,8 +146,7 @@ export class AuthCommand extends Command { const result = this.displayStatus(); this.setLastResult(result); } catch (error: any) { - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -164,8 +162,7 @@ export class AuthCommand extends Command { process.exit(1); } } catch (error: any) { - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -390,7 +387,7 @@ export class AuthCommand extends Command { message: 'Authentication successful' }; } catch (error) { - this.handleAuthError(error as AuthenticationError); + displayError(error, { skipExit: true }); return { success: false, @@ -453,51 +450,6 @@ export class AuthCommand extends Command { } } - /** - * 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 */ diff --git a/apps/cli/src/commands/context.command.ts b/apps/cli/src/commands/context.command.ts index dc2f0fc7..0d933799 100644 --- a/apps/cli/src/commands/context.command.ts +++ b/apps/cli/src/commands/context.command.ts @@ -8,12 +8,9 @@ import chalk from 'chalk'; import inquirer from 'inquirer'; import search from '@inquirer/search'; import ora, { Ora } from 'ora'; -import { - AuthManager, - AuthenticationError, - type UserContext -} from '@tm/core/auth'; +import { AuthManager, type UserContext } from '@tm/core/auth'; import * as ui from '../utils/ui.js'; +import { displayError } from '../utils/error-handler.js'; /** * Result type from context command @@ -119,8 +116,7 @@ export class ContextCommand extends Command { const result = this.displayContext(); this.setLastResult(result); } catch (error: any) { - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -216,8 +212,7 @@ export class ContextCommand extends Command { process.exit(1); } } catch (error: any) { - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -258,6 +253,7 @@ export class ContextCommand extends Command { this.authManager.updateContext({ orgId: selectedOrg.id, orgName: selectedOrg.name, + orgSlug: selectedOrg.slug, // Clear brief when changing org briefId: undefined, briefName: undefined @@ -304,8 +300,7 @@ export class ContextCommand extends Command { process.exit(1); } } catch (error: any) { - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -429,8 +424,7 @@ export class ContextCommand extends Command { process.exit(1); } } catch (error: any) { - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -476,8 +470,7 @@ export class ContextCommand extends Command { process.exit(1); } } catch (error: any) { - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -513,11 +506,13 @@ export class ContextCommand extends Command { process.exit(1); } - // Fetch org to get a friendly name (optional) + // Fetch org to get a friendly name and slug (optional) let orgName: string | undefined; + let orgSlug: string | undefined; try { const org = await this.authManager.getOrganization(brief.accountId); orgName = org?.name; + orgSlug = org?.slug; } catch { // Non-fatal if org lookup fails } @@ -528,6 +523,7 @@ export class ContextCommand extends Command { this.authManager.updateContext({ orgId: brief.accountId, orgName, + orgSlug, briefId: brief.id, briefName }); @@ -549,8 +545,7 @@ export class ContextCommand extends Command { try { if (spinner?.isSpinning) spinner.stop(); } catch {} - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -679,26 +674,6 @@ export class ContextCommand extends Command { } } - /** - * Handle errors - */ - private handleError(error: any): void { - if (error instanceof AuthenticationError) { - console.error(chalk.red(`\nāœ— ${error.message}`)); - - if (error.code === 'NOT_AUTHENTICATED') { - ui.displayWarning('Please authenticate first: tm auth login'); - } - } else { - const msg = error?.message ?? String(error); - console.error(chalk.red(`Error: ${msg}`)); - - if (error.stack && process.env.DEBUG) { - console.error(chalk.gray(error.stack)); - } - } - } - /** * Set the last result for programmatic access */ diff --git a/apps/cli/src/commands/export.command.ts b/apps/cli/src/commands/export.command.ts index 25b55a93..2b45a835 100644 --- a/apps/cli/src/commands/export.command.ts +++ b/apps/cli/src/commands/export.command.ts @@ -7,13 +7,10 @@ import { Command } from 'commander'; import chalk from 'chalk'; import inquirer from 'inquirer'; import ora, { Ora } from 'ora'; -import { - AuthManager, - AuthenticationError, - type UserContext -} from '@tm/core/auth'; +import { AuthManager, type UserContext } from '@tm/core/auth'; import { TaskMasterCore, type ExportResult } from '@tm/core'; import * as ui from '../utils/ui.js'; +import { displayError } from '../utils/error-handler.js'; /** * Result type from export command @@ -197,8 +194,7 @@ export class ExportCommand extends Command { }; } catch (error: any) { if (spinner?.isSpinning) spinner.fail('Export failed'); - this.handleError(error); - process.exit(1); + displayError(error); } } @@ -334,26 +330,6 @@ export class ExportCommand extends Command { return confirmed; } - /** - * Handle errors - */ - private handleError(error: any): void { - if (error instanceof AuthenticationError) { - console.error(chalk.red(`\nāœ— ${error.message}`)); - - if (error.code === 'NOT_AUTHENTICATED') { - ui.displayWarning('Please authenticate first: tm auth login'); - } - } else { - const msg = error?.message ?? String(error); - console.error(chalk.red(`Error: ${msg}`)); - - if (error.stack && process.env.DEBUG) { - console.error(chalk.gray(error.stack)); - } - } - } - /** * Get the last export result (useful for testing) */ diff --git a/apps/cli/src/commands/list.command.ts b/apps/cli/src/commands/list.command.ts index 38e4184b..e6bafcc0 100644 --- a/apps/cli/src/commands/list.command.ts +++ b/apps/cli/src/commands/list.command.ts @@ -17,8 +17,9 @@ 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 { displayCommandHeader } from '../utils/display-helpers.js'; import { - displayHeader, displayDashboards, calculateTaskStatistics, calculateSubtaskStatistics, @@ -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); } } @@ -257,15 +251,12 @@ export class ListTasksCommand extends Command { * Display in text format with tables */ private displayText(data: ListTasksResult, withSubtasks?: boolean): void { - const { tasks, tag } = data; + const { tasks, tag, storageType } = data; - // Get file path for display - const filePath = this.tmCore ? `.taskmaster/tasks/tasks.json` : undefined; - - // Display header without banner (banner already shown by main CLI) - displayHeader({ + // Display header using utility function + displayCommandHeader(this.tmCore, { tag: tag || 'master', - filePath: filePath + storageType }); // No tasks message diff --git a/apps/cli/src/commands/next.command.ts b/apps/cli/src/commands/next.command.ts index 5c037177..50b37169 100644 --- a/apps/cli/src/commands/next.command.ts +++ b/apps/cli/src/commands/next.command.ts @@ -9,8 +9,9 @@ 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'; +import { displayCommandHeader } from '../utils/display-helpers.js'; /** * Options interface for the next command @@ -58,6 +59,7 @@ export class NextCommand extends Command { * Execute the next command */ private async executeCommand(options: NextCommandOptions): Promise { + let hasError = false; try { // Validate options (throws on invalid options) this.validateOptions(options); @@ -76,16 +78,17 @@ 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'); + hasError = true; + displayError(error, { skipExit: true }); } finally { // Always clean up resources, even on error await this.cleanup(); } + + // Exit after cleanup completes + if (hasError) { + process.exit(1); + } } /** @@ -170,9 +173,10 @@ export class NextCommand extends Command { * Display in text format */ private displayText(result: NextTaskResult): void { - // Display header with tag (no file path for next command) - displayHeader({ - tag: result.tag || 'master' + // Display header with storage info + displayCommandHeader(this.tmCore, { + tag: result.tag || 'master', + storageType: result.storageType }); if (!result.found || !result.task) { @@ -191,7 +195,6 @@ export class NextCommand extends Command { } ) ); - console.log(`\n${chalk.gray('Storage: ' + result.storageType)}`); console.log( `\n${chalk.dim('Tip: Try')} ${chalk.cyan('task-master list --status pending')} ${chalk.dim('to see all pending tasks')}` ); @@ -208,8 +211,6 @@ export class NextCommand extends Command { headerColor: 'green', showSuggestedActions: true }); - - console.log(`\n${chalk.gray('Storage: ' + result.storageType)}`); } /** diff --git a/apps/cli/src/commands/set-status.command.ts b/apps/cli/src/commands/set-status.command.ts index 25dc40bd..9e08b1cd 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 @@ -85,6 +86,7 @@ export class SetStatusCommand extends Command { private async executeCommand( options: SetStatusCommandOptions ): Promise { + let hasError = false; try { // Validate required options if (!options.id) { @@ -135,16 +137,15 @@ 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) { + hasError = true; 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 +154,13 @@ export class SetStatusCommand extends Command { timestamp: new Date().toISOString() }) ); + } else if (!options.silent) { + // Show which task failed with context + console.error(chalk.red(`\nFailed to update task ${taskId}:`)); + displayError(error, { skipExit: true }); } - process.exit(1); + // Don't exit here - let finally block clean up first + break; } } @@ -170,25 +176,26 @@ 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) { + hasError = true; if (options.format === 'json') { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error occurred'; console.log(JSON.stringify({ success: false, error: errorMessage })); + } else if (!options.silent) { + displayError(error, { skipExit: true }); } - - process.exit(1); } finally { // Clean up resources if (this.tmCore) { await this.tmCore.close(); } } + + // Exit after cleanup completes + if (hasError) { + process.exit(1); + } } /** diff --git a/apps/cli/src/commands/show.command.ts b/apps/cli/src/commands/show.command.ts index f41cb786..121c0819 100644 --- a/apps/cli/src/commands/show.command.ts +++ b/apps/cli/src/commands/show.command.ts @@ -9,7 +9,9 @@ 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'; +import { displayCommandHeader } from '../utils/display-helpers.js'; /** * Options interface for the show command @@ -112,14 +114,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); } } @@ -257,6 +252,15 @@ export class ShowCommand extends Command { return; } + // Display header with storage info + const activeTag = this.tmCore?.getActiveTag() || 'master'; + displayCommandHeader(this.tmCore, { + tag: activeTag, + storageType: result.storageType + }); + + console.log(); // Add spacing + // Use the global task details display function displayTaskDetails(result.task, { statusFilter: options.status, @@ -271,8 +275,12 @@ export class ShowCommand extends Command { result: ShowMultipleTasksResult, _options: ShowCommandOptions ): void { - // Header - ui.displayBanner(`Tasks (${result.tasks.length} found)`); + // Display header with storage info + const activeTag = this.tmCore?.getActiveTag() || 'master'; + displayCommandHeader(this.tmCore, { + tag: activeTag, + storageType: result.storageType + }); if (result.notFound.length > 0) { console.log(chalk.yellow(`\n⚠ Not found: ${result.notFound.join(', ')}`)); @@ -291,8 +299,6 @@ export class ShowCommand extends Command { showDependencies: true }) ); - - console.log(`\n${chalk.gray('Storage: ' + result.storageType)}`); } /** 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/ui/components/header.component.ts b/apps/cli/src/ui/components/header.component.ts index e47337c2..9f3e9984 100644 --- a/apps/cli/src/ui/components/header.component.ts +++ b/apps/cli/src/ui/components/header.component.ts @@ -5,6 +5,16 @@ import chalk from 'chalk'; +/** + * Brief information for API storage + */ +export interface BriefInfo { + briefId: string; + briefName: string; + orgSlug?: string; + webAppUrl?: string; +} + /** * Header configuration options */ @@ -12,16 +22,44 @@ export interface HeaderOptions { title?: string; tag?: string; filePath?: string; + storageType?: 'api' | 'file'; + briefInfo?: BriefInfo; } /** * Display the Task Master header with project info */ export function displayHeader(options: HeaderOptions = {}): void { - const { filePath, tag } = options; + const { filePath, tag, storageType, briefInfo } = options; - // Display tag and file path info - if (tag) { + // Display different header based on storage type + if (storageType === 'api' && briefInfo) { + // API storage: Show brief information + const briefDisplay = `šŸ· Brief: ${chalk.cyan(briefInfo.briefName)} ${chalk.gray(`(${briefInfo.briefId})`)}`; + console.log(briefDisplay); + + // Construct and display the brief URL or ID + if (briefInfo.webAppUrl && briefInfo.orgSlug) { + const briefUrl = `${briefInfo.webAppUrl}/home/${briefInfo.orgSlug}/briefs/${briefInfo.briefId}/plan`; + console.log(`Listing tasks from: ${chalk.dim(briefUrl)}`); + } else if (briefInfo.webAppUrl) { + // Show web app URL and brief ID if org slug is missing + console.log( + `Listing tasks from: ${chalk.dim(`${briefInfo.webAppUrl} (Brief: ${briefInfo.briefId})`)}` + ); + console.log( + chalk.yellow( + `šŸ’” Tip: Run ${chalk.cyan('tm context select')} to set your organization and see the full URL` + ) + ); + } else { + // Fallback: just show the brief ID if we can't get web app URL + console.log( + `Listing tasks from: ${chalk.dim(`API (Brief ID: ${briefInfo.briefId})`)}` + ); + } + } else if (tag) { + // File storage: Show tag information let tagInfo = ''; if (tag && tag !== 'master') { diff --git a/apps/cli/src/utils/display-helpers.ts b/apps/cli/src/utils/display-helpers.ts new file mode 100644 index 00000000..acbce710 --- /dev/null +++ b/apps/cli/src/utils/display-helpers.ts @@ -0,0 +1,75 @@ +/** + * @fileoverview Display helper utilities for commands + * Provides DRY utilities for displaying headers and other command output + */ + +import type { TaskMasterCore } from '@tm/core'; +import type { StorageType } from '@tm/core/types'; +import { displayHeader, type BriefInfo } from '../ui/index.js'; + +/** + * Get web app base URL from environment + */ +function getWebAppUrl(): string | undefined { + const baseDomain = + process.env.TM_BASE_DOMAIN || process.env.TM_PUBLIC_BASE_DOMAIN; + + if (!baseDomain) { + return undefined; + } + + // If it already includes protocol, use as-is + if (baseDomain.startsWith('http://') || baseDomain.startsWith('https://')) { + return baseDomain; + } + + // Otherwise, add protocol based on domain + if (baseDomain.includes('localhost') || baseDomain.includes('127.0.0.1')) { + return `http://${baseDomain}`; + } + + return `https://${baseDomain}`; +} + +/** + * Display the command header with appropriate storage information + * Handles both API and file storage displays + */ +export function displayCommandHeader( + tmCore: TaskMasterCore | undefined, + options: { + tag?: string; + storageType: Exclude; + } +): void { + const { tag, storageType } = options; + + // Get brief info if using API storage + let briefInfo: BriefInfo | undefined; + if (storageType === 'api' && tmCore) { + const storageInfo = tmCore.getStorageDisplayInfo(); + if (storageInfo) { + // Construct full brief info with web app URL + briefInfo = { + ...storageInfo, + webAppUrl: getWebAppUrl() + }; + } + } + + // Get file path for display (only for file storage) + // Note: The file structure is fixed for file storage and won't change. + // This is a display-only relative path, not used for actual file operations. + const filePath = + storageType === 'file' && tmCore + ? `.taskmaster/tasks/tasks.json` + : undefined; + + // Display header + displayHeader({ + tag: tag || 'master', + filePath: filePath, + storageType: storageType === 'api' ? 'api' : 'file', + briefInfo: briefInfo + }); +} 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/auth/types.ts b/packages/tm-core/src/auth/types.ts index f6108f31..4a6b8c76 100644 --- a/packages/tm-core/src/auth/types.ts +++ b/packages/tm-core/src/auth/types.ts @@ -16,6 +16,7 @@ export interface AuthCredentials { export interface UserContext { orgId?: string; orgName?: string; + orgSlug?: string; briefId?: string; briefName?: string; updatedAt: string; 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..a30289bf 100644 --- a/packages/tm-core/src/services/task-service.ts +++ b/packages/tm-core/src/services/task-service.ts @@ -161,6 +161,16 @@ 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 +196,14 @@ 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 +540,14 @@ 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..0798e38e 100644 --- a/packages/tm-core/src/storage/api-storage.ts +++ b/packages/tm-core/src/storage/api-storage.ts @@ -37,6 +37,13 @@ export interface ApiStorageConfig { maxRetries?: number; } +/** + * Auth context with a guaranteed briefId + */ +type ContextWithBrief = NonNullable< + ReturnType +> & { briefId: string }; + /** * ApiStorage implementation using repository pattern * Provides flexibility to swap between different backend implementations @@ -126,7 +133,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) { @@ -158,15 +165,7 @@ export class ApiStorage implements IStorage { await this.ensureInitialized(); try { - const authManager = AuthManager.getInstance(); - const context = await 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 ' - ); - } + const context = this.ensureBriefSelected('loadTasks'); // Load tasks from the current brief context with filters pushed to repository const tasks = await this.retryOperation(() => @@ -181,12 +180,11 @@ export class ApiStorage implements IStorage { return tasks; } catch (error) { - throw new TaskMasterError( - 'Failed to load tasks from API', - ERROR_CODES.STORAGE_ERROR, - { operation: 'loadTasks', tag, context: 'brief-based loading' }, - error as Error - ); + this.wrapError(error, 'Failed to load tasks from API', { + operation: 'loadTasks', + tag, + context: 'brief-based loading' + }); } } @@ -237,16 +235,17 @@ export class ApiStorage implements IStorage { await this.ensureInitialized(); try { + this.ensureBriefSelected('loadTask'); + return await this.retryOperation(() => this.repository.getTask(this.projectId, taskId) ); } catch (error) { - throw new TaskMasterError( - 'Failed to load task from API', - ERROR_CODES.STORAGE_ERROR, - { operation: 'loadTask', taskId, tag }, - error as Error - ); + this.wrapError(error, 'Failed to load task from API', { + operation: 'loadTask', + taskId, + tag + }); } } @@ -325,7 +324,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 +509,8 @@ export class ApiStorage implements IStorage { await this.ensureInitialized(); try { + this.ensureBriefSelected('updateTaskStatus'); + const existingTask = await this.retryOperation(() => this.repository.getTask(this.projectId, taskId) ); @@ -546,12 +547,12 @@ export class ApiStorage implements IStorage { taskId }; } catch (error) { - throw new TaskMasterError( - 'Failed to update task status via API', - ERROR_CODES.STORAGE_ERROR, - { operation: 'updateTaskStatus', taskId, newStatus, tag }, - error as Error - ); + this.wrapError(error, 'Failed to update task status via API', { + operation: 'updateTaskStatus', + taskId, + newStatus, + tag + }); } } @@ -769,6 +770,29 @@ export class ApiStorage implements IStorage { } } + /** + * Ensure a brief is selected in the current context + * @returns The current auth context with a valid briefId + */ + private ensureBriefSelected(operation: string): ContextWithBrief { + const authManager = AuthManager.getInstance(); + const context = authManager.getContext(); + + if (!context?.briefId) { + throw new TaskMasterError( + 'No brief selected', + ERROR_CODES.NO_BRIEF_SELECTED, + { + operation, + userMessage: + 'No brief selected. Please select a brief first using: tm context brief or tm context brief ' + } + ); + } + + return context as ContextWithBrief; + } + /** * Retry an operation with exponential backoff */ @@ -787,4 +811,28 @@ export class ApiStorage implements IStorage { throw error; } } + + /** + * Wrap an error unless it's already a NO_BRIEF_SELECTED error + */ + private wrapError( + error: unknown, + message: string, + context: Record + ): never { + // 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( + message, + ERROR_CODES.STORAGE_ERROR, + context, + error as Error + ); + } } diff --git a/packages/tm-core/src/task-master-core.ts b/packages/tm-core/src/task-master-core.ts index 8b0d07ee..6c958825 100644 --- a/packages/tm-core/src/task-master-core.ts +++ b/packages/tm-core/src/task-master-core.ts @@ -201,6 +201,44 @@ export class TaskMasterCore { return this.taskService.getStorageType(); } + /** + * Get storage configuration + */ + getStorageConfig() { + return this.configManager.getStorageConfig(); + } + + /** + * Get storage display information for headers + * Returns context info for API storage, null for file storage + */ + getStorageDisplayInfo(): { + briefId: string; + briefName: string; + orgSlug?: string; + } | null { + // Only return info if using API storage + const storageType = this.getStorageType(); + if (storageType !== 'api') { + return null; + } + + // Get credentials from auth manager + const authManager = AuthManager.getInstance(); + const credentials = authManager.getCredentials(); + const selectedContext = credentials?.selectedContext; + + if (!selectedContext?.briefId || !selectedContext?.briefName) { + return null; + } + + return { + briefId: selectedContext.briefId, + briefName: selectedContext.briefName, + orgSlug: selectedContext.orgSlug + }; + } + /** * Get current active tag */ 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);