task-master-sync
Version:
A bidirectional synchronization tool between TaskMaster AI and Monday.com with automatic item recreation
524 lines (449 loc) • 18.6 kB
JavaScript
/**
* TaskMaster-Monday Sync CLI Module
* Core CLI functionality separated for testing
*/
const path = require('path');
const fs = require('fs');
const chalk = require('chalk');
const ora = require('ora');
const { createPushSync } = require('../sync/pushSyncLogic');
const { createPullSync } = require('../sync/pullSyncLogic');
const { Logger } = require('../utils/logger');
const { spawn } = require('child_process');
// Default paths
const DEFAULT_TASKS_PATH = 'tasks/tasks.json';
const DEFAULT_SYNC_CONFIG_PATH = 'sync-config.json';
const DEFAULT_SYNC_STATE_PATH = '.taskmaster_sync_state.json';
/**
* Load and validate the sync configuration
* @param {string} configPath - Path to the sync config file
* @returns {Object} - The sync configuration
*/
function loadConfig(configPath) {
try {
// Check if the config file exists
if (!fs.existsSync(configPath)) {
console.error(chalk.red(`Error: Config file not found at ${configPath}`));
console.log(chalk.yellow(`Create a sync-config.json file based on sync-config.example.json`));
process.exit(1);
}
// Load the config file
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
// Validate required fields
if (!config.monday_api_key && !process.env.MONDAY_API_KEY) {
console.error(chalk.red('Error: Monday.com API key is required in config file or MONDAY_API_KEY environment variable'));
process.exit(1);
}
if (!config.monday_board_id) {
console.error(chalk.red('Error: Monday.com board ID is required in config file'));
process.exit(1);
}
if (!config.monday_group_ids || !Array.isArray(config.monday_group_ids) || config.monday_group_ids.length === 0) {
console.error(chalk.red('Error: Monday.com group IDs array is required in config file'));
process.exit(1);
}
return config;
} catch (error) {
console.error(chalk.red(`Error loading config: ${error.message}`));
process.exit(1);
}
}
/**
* Format sync results for console output
* @param {Object} results - Sync results
* @returns {string} - Formatted results
*/
function formatSyncResults(results) {
let output = '\n';
// Created items
if (results.created.length > 0) {
output += chalk.green(`✓ Created ${results.created.length} items:\n`);
results.created.forEach(item => {
output += chalk.green(` - Task ${item.taskId} → Monday.com item ${item.mondayItemId}\n`);
});
output += '\n';
}
// Recreated items
if (results.recreated && results.recreated.length > 0) {
output += chalk.cyan(`✓ Recreated ${results.recreated.length} deleted Monday.com items:\n`);
results.recreated.forEach(item => {
output += chalk.cyan(` - Task ${item.taskId} → New Monday.com item ${item.newMondayItemId} (replaced ${item.oldMondayItemId})\n`);
});
output += '\n';
}
// Updated items
if (results.updated.length > 0) {
output += chalk.blue(`✓ Updated ${results.updated.length} items:\n`);
results.updated.forEach(item => {
output += chalk.blue(` - Task ${item.taskId} → Monday.com item ${item.mondayItemId}\n`);
});
output += '\n';
}
// Errors
if (results.errors.length > 0) {
output += chalk.red(`✗ Encountered ${results.errors.length} errors:\n`);
results.errors.forEach(error => {
output += chalk.red(` - Task ${error.taskId}: ${error.error}\n`);
});
output += '\n';
}
// Add the deleted items section before the summary
if (results.deleted && results.deleted.length > 0) {
const deletedSection = chalk.yellow(`✓ Deleted ${results.deleted.length} orphaned items:\n`);
const deletedItems = results.deleted.map(item =>
chalk.yellow(` - Monday.com item ${item.mondayItemId} (was mapped to task ${item.taskId})\n`)
).join('');
output += deletedSection + deletedItems + '\n';
}
// Add summary line
output += chalk.bold(`Summary: ${results.created.length} created, ${results.updated.length} updated, ${results.errors.length} errors\n`);
if (results.dryRun) {
output += chalk.yellow('\nThis was a dry run. No changes were made to Monday.com.\n');
}
return output;
}
/**
* Run the task-master generate command to ensure task files are up to date
* @param {boolean} verbose - Whether to show verbose output
* @returns {Promise<boolean>} - Whether the command succeeded
*/
async function runTaskMasterGenerate(verbose = false) {
// eslint-disable-next-line no-unused-vars
return new Promise((resolve, reject) => {
console.log(chalk.blue('Running task-master generate to ensure task files are up to date...'));
const spinner = ora({
text: 'Generating task files...',
color: 'blue'
}).start();
const generateProcess = spawn('task-master', ['generate'], {
stdio: verbose ? 'inherit' : 'pipe'
});
let errorOutput = '';
if (!verbose && generateProcess.stderr) {
generateProcess.stderr.on('data', (data) => {
errorOutput += data.toString();
});
}
generateProcess.on('close', (code) => {
if (code === 0) {
spinner.succeed('Task files generated successfully');
resolve(true);
} else {
spinner.fail('Failed to generate task files');
if (errorOutput) {
console.error(chalk.red(errorOutput));
}
// Don't reject, just log the error and continue with sync
console.error(chalk.yellow(`Warning: task-master generate exited with code ${code}`));
console.log(chalk.yellow('Continuing with sync anyway...'));
resolve(false);
}
});
generateProcess.on('error', (error) => {
spinner.fail('Failed to run task-master generate');
console.error(chalk.red(`Error: ${error.message}`));
console.log(chalk.yellow('Continuing with sync anyway...'));
resolve(false);
});
});
}
/**
* Run the push sync process
* @param {Object} options - CLI options
* @returns {Promise<Object>} - Sync results
*/
async function runPushSync(options) {
try {
// Set verbose mode
const verboseMode = options.verbose || false;
if (verboseMode) {
Logger.level = 'debug';
console.log(chalk.blue('Verbose mode enabled'));
}
// First, run task-master generate to ensure task files are up to date
// Only if not in dry run mode
const skipGenerate = options.skipGenerate || false;
if (!options.dryRun && !skipGenerate) {
await runTaskMasterGenerate(verboseMode);
} else if (skipGenerate) {
console.log(chalk.yellow('Skipping task-master generate (--skip-generate flag set)'));
}
// Resolve file paths
const configPath = path.resolve(process.cwd(), options.config || DEFAULT_SYNC_CONFIG_PATH);
const tasksPath = path.resolve(process.cwd(), options.tasks || DEFAULT_TASKS_PATH);
const statePath = path.resolve(process.cwd(), options.state || DEFAULT_SYNC_STATE_PATH);
const dryRun = options.dryRun || false;
const deleteOrphaned = options.deleteOrphaned !== undefined ? options.deleteOrphaned : true;
// Load the config
const config = loadConfig(configPath);
// Check if tasks file exists
if (!fs.existsSync(tasksPath)) {
console.error(chalk.red(`Error: Tasks file not found at ${tasksPath}`));
process.exit(1);
}
// Create push sync instance
console.log(chalk.dim(`API Key in config: ${config.monday_api_key ? 'Present' : 'Not found'}`));
console.log(chalk.dim(`API Key in env: ${process.env.MONDAY_API_KEY ? 'Present' : 'Not found'}`));
// Ensure API key is in the config object
const configWithApiKey = {
...config,
// Ensure API key is available in all the formats the client might expect
monday_api_key: config.monday_api_key || process.env.MONDAY_API_KEY,
apiToken: config.monday_api_key || process.env.MONDAY_API_KEY
};
console.log(chalk.dim(`Push sync config: ${JSON.stringify({
monday_api_key: configWithApiKey.monday_api_key ? 'Present' : 'Not found',
apiToken: configWithApiKey.apiToken ? 'Present' : 'Not found',
mondayBoardId: configWithApiKey.monday_board_id,
deleteOrphaned: deleteOrphaned
})}`));
const pushSyncOptions = {
mondayApiKey: configWithApiKey.monday_api_key || configWithApiKey.apiToken,
tasksFilePath: tasksPath,
syncFilePath: statePath,
dryRun: dryRun,
deleteOrphaned: deleteOrphaned,
mondayBoardId: config.monday_board_id,
mondayGroupIds: config.monday_group_ids,
columnMappings: config.column_mappings
};
console.log(chalk.dim(`Push sync options: ${JSON.stringify({
mondayApiKey: pushSyncOptions.mondayApiKey ? 'Present' : 'Not found',
dryRun: pushSyncOptions.dryRun,
deleteOrphaned: pushSyncOptions.deleteOrphaned,
mondayBoardId: pushSyncOptions.mondayBoardId,
mondayGroupIds: pushSyncOptions.mondayGroupIds
})}`));
// Create push sync with the updated options
const pushSync = createPushSync(pushSyncOptions);
// Debug log
console.log(chalk.dim(`Using API key: ${config.monday_api_key ? 'From config' : process.env.MONDAY_API_KEY ? 'From env' : 'Not found'}`));
// Show start message
console.log(chalk.bold(`\nStarting push sync${dryRun ? ' (DRY RUN)' : ''} of TaskMaster tasks to Monday.com\n`));
console.log(chalk.dim(`Using config: ${configPath}`));
console.log(chalk.dim(`Tasks file: ${tasksPath}`));
console.log(chalk.dim(`Sync state: ${statePath}`));
console.log(chalk.dim(`Board ID: ${config.monday_board_id}`));
console.log(chalk.dim(`Group IDs: ${config.monday_group_ids.join(', ')}`));
if (!deleteOrphaned) {
console.log(chalk.yellow('Note: Orphaned Monday.com items will not be deleted'));
}
// Create a spinner
const spinner = ora({
text: 'Pushing tasks to Monday.com...',
color: 'yellow'
}).start();
// Execute the push sync
const results = await pushSync.pushSync(tasksPath, { dryRun: dryRun, deleteOrphaned: deleteOrphaned });
// Show results
spinner.succeed('Push sync completed');
// Format and display the results
const output = formatSyncResults(results);
console.log(output);
return results;
} catch (error) {
console.error(chalk.red(`\nError during push sync: ${error.message}`));
if (options.verbose && error.stack) {
console.error(chalk.red(error.stack));
}
process.exit(1);
}
}
/**
* Show the current configuration
* @param {Object} options - CLI options
*/
function showConfig(options) {
try {
const configPath = path.resolve(process.cwd(), options.config || DEFAULT_SYNC_CONFIG_PATH);
const config = loadConfig(configPath);
console.log(chalk.bold('\nCurrent Sync Configuration:\n'));
console.log(chalk.dim(`Config file: ${configPath}`));
console.log(`Monday.com Board ID: ${chalk.cyan(config.monday_board_id)}`);
console.log(`Monday.com Group IDs: ${chalk.cyan(config.monday_group_ids.join(', '))}`);
console.log(`Monday.com API Key: ${chalk.cyan('***' + (config.monday_api_key || process.env.MONDAY_API_KEY).slice(-4))}`);
if (config.developer_id) {
console.log(`Developer ID: ${chalk.cyan(config.developer_id)}`);
}
} catch (error) {
console.error(chalk.red(`\nError displaying config: ${error.message}`));
process.exit(1);
}
}
/**
* Format pull sync results for console output
* @param {Object} results - Pull sync results
* @returns {string} - Formatted results
*/
function formatPullResults(results) {
let output = '\n';
// New tasks
if (results.newTasks > 0) {
output += chalk.green(`✓ Found ${results.newTasks} new tasks from Monday.com:\n`);
results.newItems.forEach(task => {
output += chalk.green(` - Monday.com item → Task ${task.id}: ${task.title}\n`);
});
output += '\n';
}
// Updated tasks
if (results.updatedTasks > 0) {
output += chalk.blue(`✓ Found ${results.updatedTasks} tasks with updates from Monday.com:\n`);
results.updatedItems.forEach(task => {
output += chalk.blue(` - Monday.com item → Task ${task.id}: ${task.title}\n`);
});
output += '\n';
}
// Recreated tasks
if (results.recreated > 0) {
output += chalk.cyan(`✓ Recreated ${results.recreated} tasks that exist in Monday.com but were missing locally:\n`);
results.recreatedItems.forEach(task => {
output += chalk.cyan(` - Monday.com item → Task ${task.id}: ${task.title}\n`);
});
output += '\n';
}
// Orphaned tasks
if (results.orphanedTasks > 0) {
output += chalk.yellow(`✓ Found ${results.orphanedTasks} orphaned tasks (Monday.com items deleted):\n`);
if (results.orphanedTaskIds && results.orphanedTaskIds.length > 0) {
results.orphanedTaskIds.forEach(taskId => {
output += chalk.yellow(` - Task ${taskId} (Monday.com item deleted)\n`);
});
}
output += '\n';
}
// Conflicts
if (results.conflicts > 0) {
output += chalk.yellow(`⚠ Found ${results.conflicts} potential conflicts:\n`);
results.conflictItems.forEach(conflict => {
output += chalk.yellow(` - Task ${conflict.mondayTask.id}: Local changes conflict with Monday.com changes\n`);
});
output += '\n';
}
// Summary
output += chalk.bold(`Summary: ${results.newTasks} new, ${results.updatedTasks} updated, ${results.orphanedTasks} orphaned, ${results.conflicts} conflicts\n`);
if (results.dryRun) {
output += chalk.yellow('\nThis was a dry run. No changes were made to tasks.json.\n');
}
return output;
}
/**
* Run the pull sync process
* @param {Object} options - CLI options
* @returns {Promise<Object>} - Sync results
*/
async function runPullSync(options) {
try {
// Set verbose mode
const verboseMode = options.verbose || false;
if (verboseMode) {
Logger.level = 'debug';
console.log(chalk.blue('Verbose mode enabled'));
}
// Resolve file paths
const configPath = path.resolve(process.cwd(), options.config || DEFAULT_SYNC_CONFIG_PATH);
const tasksPath = path.resolve(process.cwd(), options.tasks || DEFAULT_TASKS_PATH);
const statePath = path.resolve(process.cwd(), options.state || DEFAULT_SYNC_STATE_PATH);
const dryRun = options.dryRun || false;
const forceOverwrite = options.force || false;
const skipConflicts = options.skipConflicts || false;
const specificTaskId = options.task || null;
const regenerateTaskFiles = options.regenerate !== undefined ? options.regenerate : true;
const removeOrphaned = options.removeOrphaned !== undefined ? options.removeOrphaned : true;
const recreateMissingTasks = options.recreateMissingTasks !== undefined ? options.recreateMissingTasks : true;
// Load the config
const config = loadConfig(configPath);
// Handle group override if provided
let mondayGroupIds = config.monday_group_ids;
if (options.group) {
mondayGroupIds = [options.group];
console.log(chalk.blue(`Overriding configured groups with: ${options.group}`));
}
// Ensure API key is in the config object
const configWithApiKey = {
...config,
// Ensure API key is available in all the formats the client might expect
monday_api_key: config.monday_api_key || process.env.MONDAY_API_KEY,
apiToken: config.monday_api_key || process.env.MONDAY_API_KEY
};
// Create pull sync
const pullSync = createPullSync(configWithApiKey, {
tasksFilePath: tasksPath,
syncFilePath: statePath,
dryRun: dryRun,
mondayBoardId: config.monday_board_id,
mondayGroupIds: mondayGroupIds,
mondayApiKey: configWithApiKey.monday_api_key || configWithApiKey.apiToken,
removeOrphaned: removeOrphaned,
columnMappings: config.column_mappings
});
// Show start message
console.log(chalk.bold(`\nStarting pull sync${dryRun ? ' (DRY RUN)' : ''} of Monday.com items to TaskMaster\n`));
console.log(chalk.dim(`Using config: ${configPath}`));
console.log(chalk.dim(`Tasks file: ${tasksPath}`));
console.log(chalk.dim(`Sync state: ${statePath}`));
console.log(chalk.dim(`Board ID: ${config.monday_board_id}`));
console.log(chalk.dim(`Group IDs: ${mondayGroupIds.join(', ')}`));
if (forceOverwrite) {
console.log(chalk.yellow('Force mode enabled: local changes will be overwritten'));
}
if (skipConflicts) {
console.log(chalk.yellow('Skip conflicts mode enabled: tasks with local changes will be skipped'));
}
if (specificTaskId) {
console.log(chalk.yellow(`Specific task mode: pulling only task ID ${specificTaskId}`));
}
if (!regenerateTaskFiles) {
console.log(chalk.yellow('Task files will not be regenerated'));
}
if (!removeOrphaned) {
console.log(chalk.yellow('Orphaned local tasks will not be removed'));
}
if (!recreateMissingTasks) {
console.log(chalk.yellow('Missing tasks will not be automatically recreated'));
}
// Create a spinner
const spinner = ora({
text: 'Pulling items from Monday.com...',
color: 'yellow'
}).start();
// Execute the pull sync
const results = await pullSync.pullSync({
dryRun,
forceOverwrite,
skipConflicts,
specificTaskId,
regenerateTaskFiles,
removeOrphaned,
recreateMissingTasks
});
// Show results
spinner.succeed('Pull sync completed');
console.log(formatPullResults(results));
// Run task-master generate after pull if not in dry run mode and regenerateTaskFiles is true
if (!dryRun && regenerateTaskFiles && !options.skipGenerate) {
await runTaskMasterGenerate(verboseMode);
} else if (!dryRun && regenerateTaskFiles && options.skipGenerate) {
console.log(chalk.yellow('Skipping task-master generate (--skip-generate flag set)'));
}
return results;
} catch (error) {
console.error(chalk.red(`\nError during pull sync: ${error.message}`));
if (options.verbose && error.stack) {
console.error(chalk.red(error.stack));
}
process.exit(1);
}
}
// Export functions for testing and for use by the CLI entry point
module.exports = {
loadConfig,
formatSyncResults,
formatPullResults,
runPushSync,
runPullSync,
showConfig,
runTaskMasterGenerate,
DEFAULT_TASKS_PATH,
DEFAULT_SYNC_CONFIG_PATH,
DEFAULT_SYNC_STATE_PATH
};