diff --git a/apps/cli/src/commands/set-status.command.ts b/apps/cli/src/commands/set-status.command.ts new file mode 100644 index 00000000..d6ae39c7 --- /dev/null +++ b/apps/cli/src/commands/set-status.command.ts @@ -0,0 +1,318 @@ +/** + * @fileoverview SetStatusCommand using Commander's native class pattern + * Extends Commander.Command for better integration with the framework + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import boxen from 'boxen'; +import { + createTaskMasterCore, + type TaskMasterCore, + type TaskStatus +} from '@tm/core'; +import type { StorageType } from '@tm/core/types'; + +/** + * Valid task status values for validation + */ +const VALID_TASK_STATUSES: TaskStatus[] = [ + 'pending', + 'in-progress', + 'done', + 'deferred', + 'cancelled', + 'blocked', + 'review' +]; + +/** + * Options interface for the set-status command + */ +export interface SetStatusCommandOptions { + id?: string; + status?: TaskStatus; + format?: 'text' | 'json'; + silent?: boolean; + project?: string; +} + +/** + * Result type from set-status command + */ +export interface SetStatusResult { + success: boolean; + updatedTasks: Array<{ + taskId: string; + oldStatus: TaskStatus; + newStatus: TaskStatus; + }>; + storageType: Exclude; +} + +/** + * SetStatusCommand extending Commander's Command class + * This is a thin presentation layer over @tm/core + */ +export class SetStatusCommand extends Command { + private tmCore?: TaskMasterCore; + private lastResult?: SetStatusResult; + + constructor(name?: string) { + super(name || 'set-status'); + + // Configure the command + this.description('Update the status of one or more tasks') + .requiredOption( + '-i, --id ', + 'Task ID(s) to update (comma-separated for multiple, supports subtasks like 5.2)' + ) + .requiredOption( + '-s, --status ', + `New status (${VALID_TASK_STATUSES.join(', ')})` + ) + .option('-f, --format ', 'Output format (text, json)', 'text') + .option('--silent', 'Suppress output (useful for programmatic usage)') + .option('-p, --project ', 'Project root directory', process.cwd()) + .action(async (options: SetStatusCommandOptions) => { + await this.executeCommand(options); + }); + } + + /** + * Execute the set-status command + */ + private async executeCommand( + options: SetStatusCommandOptions + ): Promise { + try { + // Validate required options + if (!options.id) { + console.error(chalk.red('Error: Task ID is required. Use -i or --id')); + process.exit(1); + } + + if (!options.status) { + console.error( + chalk.red('Error: Status is required. Use -s or --status') + ); + process.exit(1); + } + + // Validate status + if (!VALID_TASK_STATUSES.includes(options.status)) { + console.error( + chalk.red( + `Error: Invalid status "${options.status}". Valid options: ${VALID_TASK_STATUSES.join(', ')}` + ) + ); + process.exit(1); + } + + // Initialize TaskMaster core + this.tmCore = await createTaskMasterCore({ + projectPath: options.project || process.cwd() + }); + + // Parse task IDs (handle comma-separated values) + const taskIds = options.id.split(',').map((id) => id.trim()); + + // Update each task + const updatedTasks: Array<{ + taskId: string; + oldStatus: TaskStatus; + newStatus: TaskStatus; + }> = []; + + for (const taskId of taskIds) { + try { + const result = await this.tmCore.updateTaskStatus( + taskId, + options.status + ); + updatedTasks.push({ + taskId: result.taskId, + 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}`) + ); + } + if (options.format === 'json') { + console.log( + JSON.stringify({ + success: false, + error: errorMessage, + taskId, + timestamp: new Date().toISOString() + }) + ); + } + process.exit(1); + } + } + + // Store result for potential reuse + this.lastResult = { + success: true, + updatedTasks, + storageType: this.tmCore.getStorageType() as Exclude< + StorageType, + 'auto' + > + }; + + // 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}`)); + } + + if (options.format === 'json') { + console.log(JSON.stringify({ success: false, error: errorMessage })); + } + + process.exit(1); + } finally { + // Clean up resources + if (this.tmCore) { + await this.tmCore.close(); + } + } + } + + /** + * Display results based on format + */ + private displayResults( + result: SetStatusResult, + options: SetStatusCommandOptions + ): void { + const format = options.format || 'text'; + + switch (format) { + case 'json': + console.log(JSON.stringify(result, null, 2)); + break; + + case 'text': + default: + if (!options.silent) { + this.displayTextResults(result); + } + break; + } + } + + /** + * Display results in text format + */ + private displayTextResults(result: SetStatusResult): void { + if (result.updatedTasks.length === 1) { + // Single task update + const update = result.updatedTasks[0]; + console.log( + boxen( + chalk.white.bold(`✅ Successfully updated task ${update.taskId}`) + + '\n\n' + + `${chalk.blue('From:')} ${this.getStatusDisplay(update.oldStatus)}\n` + + `${chalk.blue('To:')} ${this.getStatusDisplay(update.newStatus)}`, + { + padding: 1, + borderColor: 'green', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); + } else { + // Multiple task updates + console.log( + boxen( + chalk.white.bold( + `✅ Successfully updated ${result.updatedTasks.length} tasks` + ) + + '\n\n' + + result.updatedTasks + .map( + (update) => + `${chalk.cyan(update.taskId)}: ${this.getStatusDisplay(update.oldStatus)} → ${this.getStatusDisplay(update.newStatus)}` + ) + .join('\n'), + { + padding: 1, + borderColor: 'green', + borderStyle: 'round', + margin: { top: 1 } + } + ) + ); + } + + // Show storage info + console.log(chalk.gray(`\nUsing ${result.storageType} storage`)); + } + + /** + * Get colored status display + */ + private getStatusDisplay(status: TaskStatus): string { + const statusColors: Record string> = { + pending: chalk.yellow, + 'in-progress': chalk.blue, + done: chalk.green, + deferred: chalk.gray, + cancelled: chalk.red, + blocked: chalk.red, + review: chalk.magenta, + completed: chalk.green + }; + + const colorFn = statusColors[status] || chalk.white; + return colorFn(status); + } + + /** + * Get the last command result (useful for testing or chaining) + */ + getLastResult(): SetStatusResult | undefined { + return this.lastResult; + } + + /** + * 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 setStatusCommand = new SetStatusCommand(); + program.addCommand(setStatusCommand); + return setStatusCommand; + } + + /** + * Alternative registration that returns the command for chaining + * Can also configure the command name if needed + */ + static register(program: Command, name?: string): SetStatusCommand { + const setStatusCommand = new SetStatusCommand(name); + program.addCommand(setStatusCommand); + return setStatusCommand; + } +} + +/** + * Factory function to create and configure the set-status command + */ +export function createSetStatusCommand(): SetStatusCommand { + return new SetStatusCommand(); +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 1aa10751..48abcabc 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -8,6 +8,7 @@ export { ListTasksCommand } from './commands/list.command.js'; export { ShowCommand } from './commands/show.command.js'; export { AuthCommand } from './commands/auth.command.js'; export { ContextCommand } from './commands/context.command.js'; +export { SetStatusCommand } from './commands/set-status.command.js'; // UI utilities (for other commands to use) export * as ui from './utils/ui.js'; diff --git a/apps/extension/package.json b/apps/extension/package.json index 9975072a..6a25c722 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -3,7 +3,7 @@ "private": true, "displayName": "TaskMaster", "description": "A visual Kanban board interface for TaskMaster projects in VS Code", - "version": "0.25.0-rc.0", + "version": "0.24.2", "publisher": "Hamster", "icon": "assets/icon.png", "engines": { diff --git a/package-lock.json b/package-lock.json index 362d06c6..c1f90a25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -365,7 +365,7 @@ } }, "apps/extension": { - "version": "0.25.0-rc.0", + "version": "0.24.2", "dependencies": { "task-master-ai": "*" }, diff --git a/package.json b/package.json index 4ce189f4..d7bd55e5 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "workspaces": ["apps/*", "packages/*", "."], "scripts": { "build": "npm run build:build-config && cross-env NODE_ENV=production tsdown", - "dev": "tsdown --watch='packages/*/src/**/*' --watch='apps/cli/src/**/*' --watch='bin/**/*' --watch='mcp-server/**/*'", + "dev": "tsdown --watch", "turbo:dev": "turbo dev", "turbo:build": "turbo build", "turbo:typecheck": "turbo typecheck", diff --git a/packages/tm-core/src/interfaces/storage.interface.ts b/packages/tm-core/src/interfaces/storage.interface.ts index d10427d8..4fee9893 100644 --- a/packages/tm-core/src/interfaces/storage.interface.ts +++ b/packages/tm-core/src/interfaces/storage.interface.ts @@ -17,6 +17,14 @@ export interface IStorage { */ loadTasks(tag?: string): Promise; + /** + * Load a single task by ID + * @param taskId - ID of the task to load + * @param tag - Optional tag context for the task + * @returns Promise that resolves to the task or null if not found + */ + loadTask(taskId: string, tag?: string): Promise; + /** * Save tasks to storage, replacing existing tasks * @param tasks - Array of tasks to save @@ -175,6 +183,7 @@ export abstract class BaseStorage implements IStorage { // Abstract methods that must be implemented by concrete classes abstract loadTasks(tag?: string): Promise; + abstract loadTask(taskId: string, tag?: string): Promise; abstract saveTasks(tasks: Task[], tag?: string): Promise; abstract appendTasks(tasks: Task[], tag?: string): Promise; abstract updateTask( diff --git a/packages/tm-core/src/mappers/TaskMapper.ts b/packages/tm-core/src/mappers/TaskMapper.ts index f1b9be2b..89f5a8eb 100644 --- a/packages/tm-core/src/mappers/TaskMapper.ts +++ b/packages/tm-core/src/mappers/TaskMapper.ts @@ -127,7 +127,7 @@ export class TaskMapper { /** * Maps database status to internal status */ - private static mapStatus( + static mapStatus( status: Database['public']['Enums']['task_status'] ): Task['status'] { switch (status) { diff --git a/packages/tm-core/src/repositories/supabase-task-repository.ts b/packages/tm-core/src/repositories/supabase-task-repository.ts index c448722b..48689496 100644 --- a/packages/tm-core/src/repositories/supabase-task-repository.ts +++ b/packages/tm-core/src/repositories/supabase-task-repository.ts @@ -3,6 +3,30 @@ import { Task } from '../types/index.js'; import { Database } from '../types/database.types.js'; import { TaskMapper } from '../mappers/TaskMapper.js'; import { AuthManager } from '../auth/auth-manager.js'; +import { z } from 'zod'; + +// Zod schema for task status validation +const TaskStatusSchema = z.enum([ + 'pending', + 'in-progress', + 'done', + 'review', + 'deferred', + 'cancelled', + 'blocked' +]); + +// Zod schema for task updates +const TaskUpdateSchema = z + .object({ + title: z.string().min(1).optional(), + description: z.string().optional(), + status: TaskStatusSchema.optional(), + priority: z.enum(['low', 'medium', 'high', 'critical']).optional(), + details: z.string().optional(), + testStrategy: z.string().optional() + }) + .partial(); export class SupabaseTaskRepository { constructor(private supabase: SupabaseClient) {} @@ -60,12 +84,22 @@ export class SupabaseTaskRepository { return TaskMapper.mapDatabaseTasksToTasks(tasks, depsData || []); } - async getTask(accountId: string, taskId: string): Promise { + async getTask(_projectId: string, taskId: string): Promise { + // Get the current context to determine briefId (projectId not used in Supabase context) + const authManager = AuthManager.getInstance(); + const context = authManager.getContext(); + + if (!context || !context.briefId) { + throw new Error( + 'No brief selected. Please select a brief first using: tm context brief' + ); + } + const { data, error } = await this.supabase .from('tasks') .select('*') - .eq('account_id', accountId) - .ilike('display_id', taskId) + .eq('brief_id', context.briefId) + .eq('display_id', taskId.toUpperCase()) .single(); if (error) { @@ -107,4 +141,85 @@ export class SupabaseTaskRepository { dependenciesByTaskId ); } + + async updateTask( + projectId: string, + taskId: string, + updates: Partial + ): Promise { + + // Get the current context to determine briefId + const authManager = AuthManager.getInstance(); + const context = authManager.getContext(); + + if (!context || !context.briefId) { + throw new Error( + 'No brief selected. Please select a brief first using: tm context brief' + ); + } + + // Validate updates using Zod schema + try { + TaskUpdateSchema.parse(updates); + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessages = error.errors + .map((err) => `${err.path.join('.')}: ${err.message}`) + .join(', '); + throw new Error(`Invalid task update data: ${errorMessages}`); + } + throw error; + } + + // Convert Task fields to database fields - only include fields that actually exist in the database + const dbUpdates: any = {}; + + if (updates.title !== undefined) dbUpdates.title = updates.title; + if (updates.description !== undefined) + dbUpdates.description = updates.description; + if (updates.status !== undefined) + dbUpdates.status = this.mapStatusToDatabase(updates.status); + if (updates.priority !== undefined) dbUpdates.priority = updates.priority; + // Skip fields that don't exist in database schema: details, testStrategy, etc. + + // Update the task + const { error } = await this.supabase + .from('tasks') + .update(dbUpdates) + .eq('brief_id', context.briefId) + .eq('display_id', taskId.toUpperCase()); + + if (error) { + throw new Error(`Failed to update task: ${error.message}`); + } + + // Return the updated task by fetching it + const updatedTask = await this.getTask(projectId, taskId); + if (!updatedTask) { + throw new Error(`Failed to retrieve updated task ${taskId}`); + } + + return updatedTask; + } + + /** + * Maps internal status to database status + */ + private mapStatusToDatabase( + status: string + ): Database['public']['Enums']['task_status'] { + switch (status) { + case 'pending': + return 'todo'; + case 'in-progress': + case 'in_progress': // Accept both formats + return 'in_progress'; + case 'done': + return 'done'; + default: + throw new Error( + `Invalid task status: ${status}. Valid statuses are: pending, in-progress, done` + ); + } + } } diff --git a/packages/tm-core/src/services/task-service.ts b/packages/tm-core/src/services/task-service.ts index 8e5b974e..38160b64 100644 --- a/packages/tm-core/src/services/task-service.ts +++ b/packages/tm-core/src/services/task-service.ts @@ -360,4 +360,74 @@ export class TaskService { async setActiveTag(tag: string): Promise { await this.configManager.setActiveTag(tag); } + + /** + * Update task status + */ + async updateTaskStatus( + taskId: string | number, + newStatus: TaskStatus, + tag?: string + ): Promise<{ + success: boolean; + oldStatus: TaskStatus; + newStatus: TaskStatus; + taskId: string; + }> { + + // Ensure we have storage + if (!this.storage) { + throw new TaskMasterError( + 'Storage not initialized', + ERROR_CODES.STORAGE_ERROR + ); + } + + // Use provided tag or get active tag + const activeTag = tag || this.getActiveTag(); + + const taskIdStr = String(taskId); + + // TODO: For now, assume it's a regular task and just try to update directly + // In the future, we can add subtask support if needed + if (taskIdStr.includes('.')) { + throw new TaskMasterError( + 'Subtask status updates not yet supported in API storage', + ERROR_CODES.NOT_IMPLEMENTED + ); + } + + // Get the current task to get old status (simple, direct approach) + let currentTask: Task | null; + try { + // Try to get the task directly + currentTask = await this.storage.loadTask(taskIdStr, activeTag); + } catch (error) { + throw new TaskMasterError( + `Failed to load task ${taskIdStr}`, + ERROR_CODES.TASK_NOT_FOUND, + { taskId: taskIdStr }, + error as Error + ); + } + + if (!currentTask) { + throw new TaskMasterError( + `Task ${taskIdStr} not found`, + ERROR_CODES.TASK_NOT_FOUND + ); + } + + const oldStatus = currentTask.status; + + // Simple, direct update - just change the status + await this.storage.updateTask(taskIdStr, { status: newStatus }, activeTag); + + return { + success: true, + oldStatus, + newStatus, + taskId: taskIdStr + }; + } } diff --git a/packages/tm-core/src/storage/api-storage.ts b/packages/tm-core/src/storage/api-storage.ts index e23eb0ca..a6f74ba8 100644 --- a/packages/tm-core/src/storage/api-storage.ts +++ b/packages/tm-core/src/storage/api-storage.ts @@ -223,14 +223,6 @@ export class ApiStorage implements IStorage { await this.ensureInitialized(); try { - if (tag) { - // Check if task is in tag - const tagData = this.tagsCache.get(tag); - if (!tagData || !tagData.tasks.includes(taskId)) { - return null; - } - } - return await this.retryOperation(() => this.repository.getTask(this.projectId, taskId) ); @@ -477,6 +469,7 @@ export class ApiStorage implements IStorage { updates: Partial, tag?: string ): Promise { + await this.ensureInitialized(); try { diff --git a/packages/tm-core/src/storage/file-storage/file-storage.ts b/packages/tm-core/src/storage/file-storage/file-storage.ts index bcef5488..66074a43 100644 --- a/packages/tm-core/src/storage/file-storage/file-storage.ts +++ b/packages/tm-core/src/storage/file-storage/file-storage.ts @@ -102,6 +102,14 @@ export class FileStorage implements IStorage { } } + /** + * Load a single task by ID from the tasks.json file + */ + async loadTask(taskId: string, tag?: string): Promise { + const tasks = await this.loadTasks(tag); + return tasks.find(task => task.id === taskId) || null; + } + /** * Save tasks for a specific tag in the single tasks.json file */ diff --git a/packages/tm-core/src/task-master-core.ts b/packages/tm-core/src/task-master-core.ts index 93b662ff..2db8cc26 100644 --- a/packages/tm-core/src/task-master-core.ts +++ b/packages/tm-core/src/task-master-core.ts @@ -175,6 +175,22 @@ export class TaskMasterCore { await this.configManager.setActiveTag(tag); } + /** + * Update task status + */ + async updateTaskStatus( + taskId: string | number, + newStatus: TaskStatus, + tag?: string + ): Promise<{ + success: boolean; + oldStatus: TaskStatus; + newStatus: TaskStatus; + taskId: string; + }> { + return this.taskService.updateTaskStatus(taskId, newStatus, tag); + } + /** * Close and cleanup resources */ diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index 325c9c72..1024b0e2 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -20,14 +20,14 @@ import { ListTasksCommand, ShowCommand, AuthCommand, - ContextCommand + ContextCommand, + SetStatusCommand } from '@tm/cli'; import { parsePRD, updateTasks, generateTaskFiles, - setTaskStatus, listTasks, expandTask, expandAllTasks, @@ -1684,63 +1684,9 @@ function registerCommands(programInstance) { }); }); - // set-status command - programInstance - .command('set-status') - .alias('mark') - .alias('set') - .description('Set the status of a task') - .option( - '-i, --id ', - 'Task ID (can be comma-separated for multiple tasks)' - ) - .option( - '-s, --status ', - `New status (one of: ${TASK_STATUS_OPTIONS.join(', ')})` - ) - .option( - '-f, --file ', - 'Path to the tasks file', - TASKMASTER_TASKS_FILE - ) - .option('--tag ', 'Specify tag context for task operations') - .action(async (options) => { - // Initialize TaskMaster - const taskMaster = initTaskMaster({ - tasksPath: options.file || true, - tag: options.tag - }); - - const taskId = options.id; - const status = options.status; - - if (!taskId || !status) { - console.error(chalk.red('Error: Both --id and --status are required')); - process.exit(1); - } - - if (!isValidTaskStatus(status)) { - console.error( - chalk.red( - `Error: Invalid status value: ${status}. Use one of: ${TASK_STATUS_OPTIONS.join(', ')}` - ) - ); - - process.exit(1); - } - const tag = taskMaster.getCurrentTag(); - - displayCurrentTagIndicator(tag); - - console.log( - chalk.blue(`Setting status of task(s) ${taskId} to: ${status}`) - ); - - await setTaskStatus(taskMaster.getTasksPath(), taskId, status, { - projectRoot: taskMaster.getProjectRoot(), - tag - }); - }); + // Register the set-status command from @tm/cli + // Handles task status updates with proper error handling and validation + SetStatusCommand.registerOn(programInstance); // NEW: Register the new list command from @tm/cli // This command handles all its own configuration and logic