430 lines
12 KiB
JavaScript
Executable File
430 lines
12 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Mosaic Tasks CLI
|
|
*
|
|
* Command-line interface for Mosaic Stack's Tasks API
|
|
* Supports create, list, get, update, and delete operations
|
|
*/
|
|
|
|
const https = require('https');
|
|
const http = require('http');
|
|
const { URL } = require('url');
|
|
|
|
// Configuration from environment
|
|
const MOSAIC_API_URL = process.env.MOSAIC_API_URL || 'http://localhost:3001';
|
|
const MOSAIC_WORKSPACE_ID = process.env.MOSAIC_WORKSPACE_ID;
|
|
const MOSAIC_API_TOKEN = process.env.MOSAIC_API_TOKEN;
|
|
|
|
// Valid enum values
|
|
const TASK_STATUSES = ['NOT_STARTED', 'IN_PROGRESS', 'PAUSED', 'COMPLETED', 'ARCHIVED'];
|
|
const TASK_PRIORITIES = ['LOW', 'MEDIUM', 'HIGH'];
|
|
|
|
/**
|
|
* Make HTTP request to Mosaic API
|
|
*/
|
|
function apiRequest(method, path, body = null) {
|
|
return new Promise((resolve, reject) => {
|
|
if (!MOSAIC_API_TOKEN) {
|
|
reject(new Error('MOSAIC_API_TOKEN environment variable is required'));
|
|
return;
|
|
}
|
|
if (!MOSAIC_WORKSPACE_ID) {
|
|
reject(new Error('MOSAIC_WORKSPACE_ID environment variable is required'));
|
|
return;
|
|
}
|
|
|
|
const url = new URL(path, MOSAIC_API_URL);
|
|
const isHttps = url.protocol === 'https:';
|
|
const client = isHttps ? https : http;
|
|
|
|
const options = {
|
|
method,
|
|
hostname: url.hostname,
|
|
port: url.port || (isHttps ? 443 : 80),
|
|
path: url.pathname + url.search,
|
|
headers: {
|
|
'Authorization': `Bearer ${MOSAIC_API_TOKEN}`,
|
|
'X-Workspace-ID': MOSAIC_WORKSPACE_ID,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
};
|
|
|
|
const req = client.request(options, (res) => {
|
|
let data = '';
|
|
|
|
res.on('data', (chunk) => {
|
|
data += chunk;
|
|
});
|
|
|
|
res.on('end', () => {
|
|
try {
|
|
const parsed = data ? JSON.parse(data) : null;
|
|
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
resolve(parsed);
|
|
} else {
|
|
const error = new Error(
|
|
parsed?.message || `HTTP ${res.statusCode}: ${res.statusMessage}`
|
|
);
|
|
error.statusCode = res.statusCode;
|
|
error.response = parsed;
|
|
reject(error);
|
|
}
|
|
} catch (err) {
|
|
reject(new Error(`Failed to parse response: ${err.message}`));
|
|
}
|
|
});
|
|
});
|
|
|
|
req.on('error', (err) => {
|
|
reject(new Error(`Request failed: ${err.message}`));
|
|
});
|
|
|
|
if (body) {
|
|
req.write(JSON.stringify(body));
|
|
}
|
|
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Parse command-line arguments
|
|
*/
|
|
function parseArgs(argv) {
|
|
const args = {};
|
|
const positional = [];
|
|
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
|
|
if (arg.startsWith('--')) {
|
|
const key = arg.slice(2);
|
|
const value = argv[i + 1];
|
|
|
|
if (value && !value.startsWith('--')) {
|
|
args[key] = value;
|
|
i++; // Skip next arg since we consumed it
|
|
} else {
|
|
args[key] = true; // Flag without value
|
|
}
|
|
} else {
|
|
positional.push(arg);
|
|
}
|
|
}
|
|
|
|
return { args, positional };
|
|
}
|
|
|
|
/**
|
|
* Build query string from filters
|
|
*/
|
|
function buildQueryString(filters) {
|
|
const params = new URLSearchParams();
|
|
|
|
for (const [key, value] of Object.entries(filters)) {
|
|
if (value !== undefined && value !== null) {
|
|
params.append(key, value);
|
|
}
|
|
}
|
|
|
|
const qs = params.toString();
|
|
return qs ? `?${qs}` : '';
|
|
}
|
|
|
|
/**
|
|
* Create a new task
|
|
*/
|
|
async function createTask(args) {
|
|
const body = {
|
|
title: args.title,
|
|
};
|
|
|
|
if (args.description) body.description = args.description;
|
|
if (args.status) {
|
|
if (!TASK_STATUSES.includes(args.status)) {
|
|
throw new Error(`Invalid status. Must be one of: ${TASK_STATUSES.join(', ')}`);
|
|
}
|
|
body.status = args.status;
|
|
}
|
|
if (args.priority) {
|
|
if (!TASK_PRIORITIES.includes(args.priority)) {
|
|
throw new Error(`Invalid priority. Must be one of: ${TASK_PRIORITIES.join(', ')}`);
|
|
}
|
|
body.priority = args.priority;
|
|
}
|
|
if (args.due) body.dueDate = args.due;
|
|
if (args['assignee-id']) body.assigneeId = args['assignee-id'];
|
|
if (args['project-id']) body.projectId = args['project-id'];
|
|
if (args['parent-id']) body.parentId = args['parent-id'];
|
|
if (args['sort-order']) body.sortOrder = parseInt(args['sort-order'], 10);
|
|
|
|
if (!body.title) {
|
|
throw new Error('--title is required');
|
|
}
|
|
|
|
const result = await apiRequest('POST', '/api/tasks', body);
|
|
console.log(JSON.stringify(result, null, 2));
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* List tasks with optional filters
|
|
*/
|
|
async function listTasks(args) {
|
|
const filters = {};
|
|
|
|
if (args.status) {
|
|
if (!TASK_STATUSES.includes(args.status)) {
|
|
throw new Error(`Invalid status. Must be one of: ${TASK_STATUSES.join(', ')}`);
|
|
}
|
|
filters.status = args.status;
|
|
}
|
|
if (args.priority) {
|
|
if (!TASK_PRIORITIES.includes(args.priority)) {
|
|
throw new Error(`Invalid priority. Must be one of: ${TASK_PRIORITIES.join(', ')}`);
|
|
}
|
|
filters.priority = args.priority;
|
|
}
|
|
if (args['assignee-id']) filters.assigneeId = args['assignee-id'];
|
|
if (args['project-id']) filters.projectId = args['project-id'];
|
|
if (args['parent-id']) filters.parentId = args['parent-id'];
|
|
if (args.page) filters.page = args.page;
|
|
if (args.limit) filters.limit = args.limit;
|
|
|
|
// Handle overdue filter
|
|
if (args.overdue) {
|
|
filters.dueDateTo = new Date().toISOString();
|
|
// Exclude completed and archived
|
|
if (!filters.status) {
|
|
console.error('Note: Overdue filter shows tasks with due date < now, excluding COMPLETED and ARCHIVED');
|
|
}
|
|
}
|
|
|
|
if (args['due-from']) filters.dueDateFrom = args['due-from'];
|
|
if (args['due-to']) filters.dueDateTo = args['due-to'];
|
|
|
|
const query = buildQueryString(filters);
|
|
const result = await apiRequest('GET', `/api/tasks${query}`);
|
|
|
|
// Filter out completed/archived for overdue query
|
|
if (args.overdue && result.data) {
|
|
result.data = result.data.filter(
|
|
task => task.status !== 'COMPLETED' && task.status !== 'ARCHIVED'
|
|
);
|
|
if (result.pagination) {
|
|
result.pagination.total = result.data.length;
|
|
}
|
|
}
|
|
|
|
console.log(JSON.stringify(result, null, 2));
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get a single task by ID
|
|
*/
|
|
async function getTask(taskId) {
|
|
if (!taskId) {
|
|
throw new Error('Task ID is required');
|
|
}
|
|
|
|
const result = await apiRequest('GET', `/api/tasks/${taskId}`);
|
|
console.log(JSON.stringify(result, null, 2));
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Update a task
|
|
*/
|
|
async function updateTask(taskId, args) {
|
|
if (!taskId) {
|
|
throw new Error('Task ID is required');
|
|
}
|
|
|
|
const body = {};
|
|
|
|
if (args.title) body.title = args.title;
|
|
if (args.description !== undefined) body.description = args.description;
|
|
if (args.status) {
|
|
if (!TASK_STATUSES.includes(args.status)) {
|
|
throw new Error(`Invalid status. Must be one of: ${TASK_STATUSES.join(', ')}`);
|
|
}
|
|
body.status = args.status;
|
|
}
|
|
if (args.priority) {
|
|
if (!TASK_PRIORITIES.includes(args.priority)) {
|
|
throw new Error(`Invalid priority. Must be one of: ${TASK_PRIORITIES.join(', ')}`);
|
|
}
|
|
body.priority = args.priority;
|
|
}
|
|
if (args.due !== undefined) body.dueDate = args.due || null;
|
|
if (args['assignee-id'] !== undefined) body.assigneeId = args['assignee-id'] || null;
|
|
if (args['project-id'] !== undefined) body.projectId = args['project-id'] || null;
|
|
if (args['parent-id'] !== undefined) body.parentId = args['parent-id'] || null;
|
|
if (args['sort-order']) body.sortOrder = parseInt(args['sort-order'], 10);
|
|
|
|
if (Object.keys(body).length === 0) {
|
|
throw new Error('At least one field to update is required');
|
|
}
|
|
|
|
const result = await apiRequest('PATCH', `/api/tasks/${taskId}`, body);
|
|
console.log(JSON.stringify(result, null, 2));
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Delete a task
|
|
*/
|
|
async function deleteTask(taskId) {
|
|
if (!taskId) {
|
|
throw new Error('Task ID is required');
|
|
}
|
|
|
|
const result = await apiRequest('DELETE', `/api/tasks/${taskId}`);
|
|
console.log(JSON.stringify(result || { success: true }, null, 2));
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Show usage help
|
|
*/
|
|
function showHelp() {
|
|
console.log(`
|
|
Mosaic Tasks CLI
|
|
|
|
Usage:
|
|
node tasks.cjs <command> [options]
|
|
|
|
Commands:
|
|
create Create a new task
|
|
list List tasks with optional filters
|
|
get <id> Get a specific task by ID
|
|
update <id> Update a task
|
|
delete <id> Delete a task
|
|
help Show this help message
|
|
|
|
Create Options:
|
|
--title <text> Task title (required)
|
|
--description <text> Task description
|
|
--status <status> Status: ${TASK_STATUSES.join(', ')}
|
|
--priority <priority> Priority: ${TASK_PRIORITIES.join(', ')}
|
|
--due <iso-date> Due date (ISO 8601 format)
|
|
--assignee-id <uuid> Assign to user ID
|
|
--project-id <uuid> Link to project ID
|
|
--parent-id <uuid> Parent task ID (for subtasks)
|
|
--sort-order <number> Sort order (integer)
|
|
|
|
List Options:
|
|
--status <status> Filter by status
|
|
--priority <priority> Filter by priority
|
|
--assignee-id <uuid> Filter by assignee
|
|
--project-id <uuid> Filter by project
|
|
--parent-id <uuid> Filter by parent task
|
|
--due-from <iso-date> Filter tasks due after this date
|
|
--due-to <iso-date> Filter tasks due before this date
|
|
--overdue Show only overdue tasks
|
|
--page <number> Page number (default: 1)
|
|
--limit <number> Results per page (default: 50, max: 100)
|
|
|
|
Update Options:
|
|
--title <text> Update title
|
|
--description <text> Update description
|
|
--status <status> Update status
|
|
--priority <priority> Update priority
|
|
--due <iso-date> Update due date (empty string to clear)
|
|
--assignee-id <uuid> Update assignee (empty string to clear)
|
|
--project-id <uuid> Update project (empty string to clear)
|
|
--parent-id <uuid> Update parent (empty string to clear)
|
|
--sort-order <number> Update sort order
|
|
|
|
Environment Variables:
|
|
MOSAIC_API_URL API base URL (default: http://localhost:3001)
|
|
MOSAIC_WORKSPACE_ID Workspace ID (required)
|
|
MOSAIC_API_TOKEN Authentication token (required)
|
|
|
|
Examples:
|
|
# Create a task
|
|
node tasks.cjs create --title "Review PR #123" --priority HIGH
|
|
|
|
# List high priority tasks
|
|
node tasks.cjs list --priority HIGH
|
|
|
|
# List overdue tasks
|
|
node tasks.cjs list --overdue
|
|
|
|
# Get task details
|
|
node tasks.cjs get abc-123-def-456
|
|
|
|
# Mark task as completed
|
|
node tasks.cjs update abc-123-def-456 --status COMPLETED
|
|
|
|
# Delete a task
|
|
node tasks.cjs delete abc-123-def-456
|
|
`);
|
|
}
|
|
|
|
/**
|
|
* Main entry point
|
|
*/
|
|
async function main() {
|
|
const { args, positional } = parseArgs(process.argv.slice(2));
|
|
const command = positional[0];
|
|
|
|
try {
|
|
switch (command) {
|
|
case 'create':
|
|
await createTask(args);
|
|
break;
|
|
|
|
case 'list':
|
|
await listTasks(args);
|
|
break;
|
|
|
|
case 'get':
|
|
await getTask(positional[1]);
|
|
break;
|
|
|
|
case 'update':
|
|
await updateTask(positional[1], args);
|
|
break;
|
|
|
|
case 'delete':
|
|
await deleteTask(positional[1]);
|
|
break;
|
|
|
|
case 'help':
|
|
case undefined:
|
|
showHelp();
|
|
break;
|
|
|
|
default:
|
|
console.error(`Unknown command: ${command}`);
|
|
console.error('Run "node tasks.cjs help" for usage information');
|
|
process.exit(1);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error:', error.message);
|
|
|
|
if (error.statusCode === 401) {
|
|
console.error('\nAuthentication failed. Check your MOSAIC_API_TOKEN.');
|
|
} else if (error.statusCode === 403) {
|
|
console.error('\nPermission denied. You may need higher workspace permissions.');
|
|
} else if (error.statusCode === 404) {
|
|
console.error('\nTask not found. Check the task ID.');
|
|
} else if (error.response) {
|
|
console.error('\nAPI Response:', JSON.stringify(error.response, null, 2));
|
|
}
|
|
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Run if executed directly
|
|
if (require.main === module) {
|
|
main();
|
|
}
|
|
|
|
module.exports = { createTask, listTasks, getTask, updateTask, deleteTask };
|