UNPKG

simple-task-master

Version:
405 lines 18.5 kB
"use strict"; /** * Update task command */ Object.defineProperty(exports, "__esModule", { value: true }); exports.updateCommand = void 0; const commander_1 = require("commander"); const task_manager_1 = require("../lib/task-manager"); const output_1 = require("../lib/output"); const errors_1 = require("../lib/errors"); const markdown_sections_1 = require("../lib/markdown-sections"); const utils_1 = require("../lib/utils"); /** * Validate field name */ function validateFieldName(key) { // Check for newlines and carriage returns if (key.includes('\n') || key.includes('\r')) { throw new errors_1.ValidationError('Field names cannot contain newlines'); } // Check for leading/trailing whitespace if (key.trim() !== key) { throw new errors_1.ValidationError('Field names cannot have leading/trailing whitespace'); } // Check for control characters // (ASCII 0-31 except tab, newline, carriage return which are handled above) for (let i = 0; i < key.length; i++) { const charCode = key.charCodeAt(i); if (charCode < 32 && charCode !== 9) { // Allow tab (9), block other control chars throw new errors_1.ValidationError('Field names cannot contain control characters'); } } // All other field names are allowed } /** * Parse a key=value assignment */ function parseAssignment(assignment) { // Check for += operation (add to array) if (assignment.includes('+=')) { const index = assignment.indexOf('+='); const key = assignment.substring(0, index); const value = assignment.substring(index + 2); if (!key) { throw new errors_1.ValidationError(`Invalid += assignment format: ${assignment}`); } validateFieldName(key); return { key: key, value: value.trim(), operation: 'add' }; } // Check for -= operation (remove from array) if (assignment.includes('-=')) { const index = assignment.indexOf('-='); const key = assignment.substring(0, index); const value = assignment.substring(index + 2); if (!key) { throw new errors_1.ValidationError(`Invalid -= assignment format: ${assignment}`); } validateFieldName(key); return { key: key, value: value.trim(), operation: 'remove' }; } // Default = operation (set value) if (assignment.includes('=')) { const index = assignment.indexOf('='); const key = assignment.substring(0, index); const value = assignment.substring(index + 1); if (!key) { throw new errors_1.ValidationError(`Invalid = assignment format: ${assignment}`); } validateFieldName(key); return { key: key, value: value.trim(), operation: 'set' }; } throw new errors_1.ValidationError(`Invalid assignment format: ${assignment}. Expected key=value, key+=value, or key-=value`); } /** * Parse value based on field type */ function parseValue(key, value) { switch (key) { case 'title': case 'content': case 'description': case 'details': case 'validation': if (value.length === 0) { throw new errors_1.ValidationError(`${key} cannot be empty`); } return value; case 'status': { const validStatuses = ['pending', 'in-progress', 'done']; if (!validStatuses.includes(value)) { throw new errors_1.ValidationError(`Invalid status: ${value}. Must be one of: ${validStatuses.join(', ')}`); } return value; } case 'tags': return value .split(',') .map((tag) => { const trimmed = tag.trim(); if (trimmed.length === 0) { throw new errors_1.ValidationError('Tag cannot be empty'); } if (trimmed.length > 50) { throw new errors_1.ValidationError(`Tag "${trimmed}" exceeds maximum length of 50 characters`); } return trimmed; }) .filter((tag) => tag.length > 0); case 'dependencies': return value.split(',').map((dep) => { const id = parseInt(dep.trim(), 10); if (isNaN(id) || id <= 0) { throw new errors_1.ValidationError(`Invalid dependency ID: ${dep.trim()}`); } return id; }); default: // For arbitrary field names, treat as string values // Allow empty values for custom fields (unlike core fields) return value; } } /** * Update a task */ async function updateTask(idStr, assignments, options) { try { const taskManager = await task_manager_1.TaskManager.create(); // Parse task ID const id = parseInt(idStr, 10); if (isNaN(id) || id <= 0) { throw new errors_1.ValidationError(`Invalid task ID: ${idStr}`); } // Check if no changes are specified and handle editor fallback const hasChanges = assignments.length > 0 || options.title || options.description || options.details || options.validation || options.status || options.tags || options.deps; if (!hasChanges) { // Check if editor is enabled (default true, disabled with --no-editor) if (options.editor !== false) { try { // Launch editor with current task content as initial content const currentTask = await taskManager.get(id); const editorPrompt = `# Edit Task ${id}: ${currentTask.title}\n\n${currentTask.content || ''}`; const editedContent = await (0, utils_1.launchEditor)(editorPrompt); // If user didn't change anything, exit without error if (editedContent === editorPrompt) { (0, output_1.printError)('No changes made'); process.exit(2); } // Extract the content (remove the title comment) const lines = editedContent.split('\n'); const contentStartIndex = lines.findIndex((line) => line.trim() !== '' && !line.startsWith('#')); const newContent = contentStartIndex >= 0 ? lines.slice(contentStartIndex).join('\n').trim() : ''; // Update the task with the new content await taskManager.update(id, { content: newContent }); (0, output_1.printSuccess)(`Updated task ${id}`); return; } catch (error) { if (error instanceof Error) { (0, output_1.printError)(`Editor failed: ${error.message}`); } else { (0, output_1.printError)('Failed to launch editor'); } process.exit(2); } } else { // Editor is disabled, exit with error (0, output_1.printError)('No changes specified'); process.exit(2); return; // Ensure we don't continue execution } } // Get current task const currentTask = await taskManager.get(id); // Build update input const updates = {}; // Process option-based updates if (options.title !== undefined) { updates.title = options.title; } // Handle body section updates with stdin support let updatedContent = currentTask.content || ''; let contentModified = false; // Process description option (supports stdin with "-") if (options.description !== undefined) { try { const descContent = await (0, utils_1.readInput)(options.description, false, '', 30000); if (descContent !== undefined) { // Validate that description is not empty after trimming if (descContent.trim().length === 0) { throw new errors_1.ValidationError('description cannot be empty'); } updatedContent = (0, markdown_sections_1.updateBodySection)(updatedContent, 'description', descContent); contentModified = true; } } catch (error) { if (error instanceof Error) { throw new errors_1.ValidationError(`Failed to read description input: ${error.message}`); } throw new errors_1.ValidationError('Failed to read description input'); } } // Process details option (supports stdin with "-") if (options.details !== undefined) { try { const detailsContent = await (0, utils_1.readInput)(options.details, false, '', 30000); if (detailsContent !== undefined) { updatedContent = (0, markdown_sections_1.updateBodySection)(updatedContent, 'details', detailsContent); contentModified = true; } } catch (error) { if (error instanceof Error) { throw new errors_1.ValidationError(`Failed to read details input: ${error.message}`); } throw new errors_1.ValidationError('Failed to read details input'); } } // Process validation option (supports stdin with "-") if (options.validation !== undefined) { try { const validationContent = await (0, utils_1.readInput)(options.validation, false, '', 30000); if (validationContent !== undefined) { updatedContent = (0, markdown_sections_1.updateBodySection)(updatedContent, 'validation', validationContent); contentModified = true; } } catch (error) { if (error instanceof Error) { throw new errors_1.ValidationError(`Failed to read validation input: ${error.message}`); } throw new errors_1.ValidationError('Failed to read validation input'); } } if (contentModified) { updates.content = updatedContent; } if (options.status !== undefined) { if (!['pending', 'in-progress', 'done'].includes(options.status)) { throw new errors_1.ValidationError('Status must be one of: pending, in-progress, done'); } updates.status = options.status; } if (options.tags !== undefined) { updates.tags = options.tags .split(',') .map((tag) => tag.trim()) .filter((tag) => tag.length > 0); } if (options.deps !== undefined) { updates.dependencies = options.deps.split(',').map((dep) => { const depId = parseInt(dep.trim(), 10); if (isNaN(depId) || depId <= 0) { throw new errors_1.ValidationError(`Invalid dependency ID: ${dep.trim()}`); } return depId; }); } // Process assignment-based updates for (const assignment of assignments) { const { key, value, operation } = parseAssignment(assignment); if (operation === 'set') { const parsedValue = parseValue(key, value); // Handle body section fields if (key === 'description' || key === 'details' || key === 'validation') { if (!contentModified) { updatedContent = currentTask.content || ''; contentModified = true; } updatedContent = (0, markdown_sections_1.updateBodySection)(updatedContent, key, parsedValue); updates.content = updatedContent; } else { // Map field aliases to actual field names let fieldName = key; if (key === 'content') { fieldName = 'content'; } updates[fieldName] = parsedValue; } } else if (operation === 'add') { // Add to array fields if (key === 'tags') { const newTags = value .split(',') .map((tag) => tag.trim()) .filter((tag) => tag.length > 0); const existingTags = updates.tags || currentTask.tags || []; updates.tags = [...new Set([...existingTags, ...newTags])]; // Remove duplicates } else if (key === 'dependencies') { const newDeps = value.split(',').map((dep) => { const depId = parseInt(dep.trim(), 10); if (isNaN(depId) || depId <= 0) { throw new errors_1.ValidationError(`Invalid dependency ID: ${dep.trim()}`); } return depId; }); const existingDeps = updates.dependencies || currentTask.dependencies || []; updates.dependencies = [...new Set([...existingDeps, ...newDeps])]; // Remove duplicates } else { throw new errors_1.ValidationError(`Cannot add to field '${key}'. The += operation is only supported for array fields (tags, dependencies). For custom fields, use = to set the value.`); } } else if (operation === 'remove') { // Remove from array fields if (key === 'tags') { const tagsToRemove = value.split(',').map((tag) => tag.trim()); const existingTags = updates.tags || currentTask.tags || []; updates.tags = existingTags.filter((tag) => !tagsToRemove.includes(tag)); } else if (key === 'dependencies') { const depsToRemove = value.split(',').map((dep) => { const depId = parseInt(dep.trim(), 10); if (isNaN(depId) || depId <= 0) { throw new errors_1.ValidationError(`Invalid dependency ID: ${dep.trim()}`); } return depId; }); const existingDeps = updates.dependencies || currentTask.dependencies || []; updates.dependencies = existingDeps.filter((dep) => !depsToRemove.includes(dep)); } else { throw new errors_1.ValidationError(`Cannot remove from field '${key}'. The -= operation is only supported for array fields (tags, dependencies). For custom fields, use = to set or clear the value.`); } } } // Validate dependency cycles if (updates.dependencies) { await validateDependencies(taskManager, id, updates.dependencies); } // Apply updates await taskManager.update(id, updates); (0, output_1.printSuccess)(`Updated task ${id}`); } catch (error) { // Don't handle errors that come from process.exit calls (for testing) if (error instanceof Error && error.message.startsWith('Process.exit(')) { throw error; } if (error instanceof errors_1.ValidationError || error instanceof errors_1.FileSystemError || error instanceof errors_1.ConfigurationError || error instanceof errors_1.NotFoundError || error instanceof Error) { (0, output_1.printError)(error.message); process.exit(error instanceof errors_1.NotFoundError ? 3 : 1); } throw error; } } /** * Validate dependencies to prevent cycles */ async function validateDependencies(taskManager, taskId, dependencies) { // Check for self-dependency if (dependencies.includes(taskId)) { throw new errors_1.ValidationError(`Task cannot depend on itself (ID: ${taskId})`); } // Check if all dependency tasks exist for (const depId of dependencies) { try { await taskManager.get(depId); } catch (error) { if (error instanceof errors_1.NotFoundError) { throw new errors_1.ValidationError(`Dependency task ${depId} does not exist`); } throw error; } } // TODO: Implement cycle detection for complex dependency chains // This would require traversing the dependency graph to detect cycles } /** * Create the update command */ exports.updateCommand = new commander_1.Command('update') .description('Update a task with flexible options for metadata, content sections, and editor integration') .argument('<id>', 'Task ID') .argument('[assignments...]', 'Field assignments (field=value, field+=value, field-=value). Any field name is allowed, including custom metadata fields for external tool integration') .option('-t, --title <title>', 'Update task title') .option('-d, --description <text>', 'Update description: why & what - problem context, solution overview, and acceptance criteria (use - for stdin)') .option('--details <text>', 'Update details: how - implementation approach, technical design, and architecture notes (use - for stdin)') .option('--validation <text>', 'Update validation: testing strategy, verification steps, and quality checks (use - for stdin)') .option('-s, --status <status>', 'Update task status (pending, in-progress, done)') .option('--tags <tags>', 'Set task tags (comma-separated)') .option('--deps <dependencies>', 'Set task dependencies (comma-separated IDs)') .option('--no-editor', 'Disable editor fallback when no changes are specified') .action(async (id, assignments, options) => { await updateTask(id, assignments, options); }); //# sourceMappingURL=update.js.map