simple-task-master
Version:
A simple command-line task management tool
405 lines • 18.5 kB
JavaScript
;
/**
* 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