mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
feat: reintroduce task-master generate command for task file generation (#1446)
This commit is contained in:
11
.changeset/pretty-suits-marry.md
Normal file
11
.changeset/pretty-suits-marry.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
"task-master-ai": minor
|
||||
---
|
||||
|
||||
Bring back `task-master generate` as a command and mcp tool (after popular demand)
|
||||
|
||||
- Generated files are now `.md` instead of `.txt`
|
||||
- They also follow the markdownlint format making them look like more standard md files
|
||||
- added parameters to generate allowing you to generate with the `--tag` flag
|
||||
- If I am on an active tag and want to generate files from another tag, I can with the tag parameter
|
||||
- See `task-master generate --help` for more information.
|
||||
@@ -10,6 +10,7 @@ import { AutopilotCommand } from './commands/autopilot/index.js';
|
||||
import { BriefsCommand } from './commands/briefs.command.js';
|
||||
import { ContextCommand } from './commands/context.command.js';
|
||||
import { ExportCommand } from './commands/export.command.js';
|
||||
import { GenerateCommand } from './commands/generate.command.js';
|
||||
// Import all commands
|
||||
import { ListTasksCommand } from './commands/list.command.js';
|
||||
import { NextCommand } from './commands/next.command.js';
|
||||
@@ -105,6 +106,12 @@ export class CommandRegistry {
|
||||
description: 'Manage briefs (Hamster only)',
|
||||
commandClass: BriefsCommand as any,
|
||||
category: 'task'
|
||||
},
|
||||
{
|
||||
name: 'generate',
|
||||
description: 'Generate individual task files from tasks.json',
|
||||
commandClass: GenerateCommand as any,
|
||||
category: 'utility'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
251
apps/cli/src/commands/generate.command.ts
Normal file
251
apps/cli/src/commands/generate.command.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* @fileoverview Generate command for generating individual task files from tasks.json
|
||||
* This is a thin presentation layer over @tm/core
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import {
|
||||
type GenerateTaskFilesResult,
|
||||
type TmCore,
|
||||
createTmCore
|
||||
} from '@tm/core';
|
||||
import boxen from 'boxen';
|
||||
import chalk from 'chalk';
|
||||
import { Command } from 'commander';
|
||||
import { displayCommandHeader } from '../utils/display-helpers.js';
|
||||
import { displayError } from '../utils/error-handler.js';
|
||||
import { getProjectRoot } from '../utils/project-root.js';
|
||||
|
||||
/**
|
||||
* Options interface for the generate command
|
||||
*/
|
||||
export interface GenerateCommandOptions {
|
||||
tag?: string;
|
||||
output?: string;
|
||||
project?: string;
|
||||
format?: 'text' | 'json';
|
||||
}
|
||||
|
||||
/**
|
||||
* GenerateCommand extending Commander's Command class
|
||||
* Generates individual task files from tasks.json
|
||||
*/
|
||||
export class GenerateCommand extends Command {
|
||||
private tmCore?: TmCore;
|
||||
private lastResult?: GenerateTaskFilesResult;
|
||||
|
||||
constructor(name?: string) {
|
||||
super(name || 'generate');
|
||||
|
||||
// Configure the command
|
||||
this.description('Generate individual task files from tasks.json')
|
||||
.option('-t, --tag <tag>', 'Tag context for task operations')
|
||||
.option(
|
||||
'-o, --output <dir>',
|
||||
'Output directory for generated files (defaults to .taskmaster/tasks)'
|
||||
)
|
||||
.option(
|
||||
'-p, --project <path>',
|
||||
'Project root directory (auto-detected if not provided)'
|
||||
)
|
||||
.option('-f, --format <format>', 'Output format (text, json)', 'text')
|
||||
.action(async (options: GenerateCommandOptions) => {
|
||||
await this.executeCommand(options);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the generate command
|
||||
*/
|
||||
private async executeCommand(options: GenerateCommandOptions): Promise<void> {
|
||||
let hasError = false;
|
||||
try {
|
||||
// Validate options
|
||||
this.validateOptions(options);
|
||||
|
||||
// Initialize tm-core
|
||||
const projectRoot = getProjectRoot(options.project);
|
||||
await this.initializeCore(projectRoot);
|
||||
|
||||
// Generate task files
|
||||
const result = await this.generateFiles(projectRoot, options);
|
||||
|
||||
// Store result for programmatic access
|
||||
this.lastResult = result;
|
||||
|
||||
// Display results
|
||||
this.displayResults(result, options);
|
||||
} catch (error: any) {
|
||||
hasError = true;
|
||||
displayError(error, { skipExit: true });
|
||||
} finally {
|
||||
await this.cleanup();
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate command options
|
||||
*/
|
||||
private validateOptions(options: GenerateCommandOptions): void {
|
||||
if (options.format && !['text', 'json'].includes(options.format)) {
|
||||
throw new Error(
|
||||
`Invalid format: ${options.format}. Valid formats are: text, json`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize TmCore
|
||||
*/
|
||||
private async initializeCore(projectRoot: string): Promise<void> {
|
||||
if (!this.tmCore) {
|
||||
const resolved = path.resolve(projectRoot);
|
||||
this.tmCore = await createTmCore({ projectPath: resolved });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate task files using tm-core
|
||||
*/
|
||||
private async generateFiles(
|
||||
projectRoot: string,
|
||||
options: GenerateCommandOptions
|
||||
): Promise<GenerateTaskFilesResult> {
|
||||
if (!this.tmCore) {
|
||||
throw new Error('TmCore not initialized');
|
||||
}
|
||||
|
||||
// Resolve output directory
|
||||
const outputDir = options.output
|
||||
? path.resolve(projectRoot, options.output)
|
||||
: undefined;
|
||||
|
||||
// Call tm-core to generate task files
|
||||
return this.tmCore.tasks.generateTaskFiles({
|
||||
tag: options.tag,
|
||||
outputDir
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Display results based on format
|
||||
*/
|
||||
private displayResults(
|
||||
result: GenerateTaskFilesResult,
|
||||
options: GenerateCommandOptions
|
||||
): void {
|
||||
const format = options.format || 'text';
|
||||
|
||||
switch (format) {
|
||||
case 'json':
|
||||
this.displayJson(result);
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
default:
|
||||
this.displayText(result, options);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display in JSON format
|
||||
*/
|
||||
private displayJson(result: GenerateTaskFilesResult): void {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display in text format
|
||||
*/
|
||||
private displayText(
|
||||
result: GenerateTaskFilesResult,
|
||||
options: GenerateCommandOptions
|
||||
): void {
|
||||
// Display header with storage info
|
||||
if (this.tmCore) {
|
||||
const storageType = this.tmCore.tasks.getStorageType();
|
||||
const activeTag = options.tag || this.tmCore.config.getActiveTag();
|
||||
|
||||
displayCommandHeader(this.tmCore, {
|
||||
tag: activeTag,
|
||||
storageType
|
||||
});
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
// Error occurred
|
||||
console.log(
|
||||
boxen(chalk.red(`Error: ${result.error || 'Unknown error'}`), {
|
||||
padding: 1,
|
||||
borderStyle: 'round',
|
||||
borderColor: 'red',
|
||||
title: '❌ GENERATION FAILED',
|
||||
titleAlignment: 'center'
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.count === 0) {
|
||||
// No tasks to generate
|
||||
console.log(
|
||||
boxen(chalk.yellow('No tasks found to generate files for.'), {
|
||||
padding: 1,
|
||||
borderStyle: 'round',
|
||||
borderColor: 'yellow',
|
||||
title: '⚠️ NO TASKS',
|
||||
titleAlignment: 'center'
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Success message
|
||||
let message = `${chalk.green('✓')} Generated ${chalk.cyan(result.count)} task file(s)`;
|
||||
message += `\n\n${chalk.dim('Output directory:')} ${result.directory}`;
|
||||
|
||||
if (result.orphanedFilesRemoved > 0) {
|
||||
message += `\n${chalk.dim('Orphaned files removed:')} ${result.orphanedFilesRemoved}`;
|
||||
}
|
||||
|
||||
console.log(
|
||||
boxen(message, {
|
||||
padding: 1,
|
||||
borderStyle: 'round',
|
||||
borderColor: 'green',
|
||||
title: '📄 TASK FILES GENERATED',
|
||||
titleAlignment: 'center'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last result (for programmatic usage)
|
||||
*/
|
||||
getLastResult(): GenerateTaskFilesResult | undefined {
|
||||
return this.lastResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
if (this.tmCore) {
|
||||
this.tmCore = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this command on an existing program
|
||||
*/
|
||||
static register(program: Command, name?: string): GenerateCommand {
|
||||
const generateCommand = new GenerateCommand(name);
|
||||
program.addCommand(generateCommand);
|
||||
return generateCommand;
|
||||
}
|
||||
}
|
||||
224
apps/cli/tests/integration/commands/generate.command.test.ts
Normal file
224
apps/cli/tests/integration/commands/generate.command.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* @fileoverview Integration tests for 'task-master generate' command
|
||||
*
|
||||
* Tests CLI-specific behavior: argument parsing, output formatting, exit codes.
|
||||
* Core file generation logic is tested in tm-core's task-file-generator.service.spec.ts.
|
||||
*
|
||||
* @integration
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createTask, createTasksFile } from '@tm/core/testing';
|
||||
import { getCliBinPath } from '../../helpers/test-utils';
|
||||
|
||||
// Capture initial working directory at module load time
|
||||
const initialCwd = process.cwd();
|
||||
|
||||
describe('generate command', () => {
|
||||
let testDir: string;
|
||||
let tasksPath: string;
|
||||
let binPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-generate-test-'));
|
||||
process.chdir(testDir);
|
||||
process.env.TASKMASTER_SKIP_AUTO_UPDATE = '1';
|
||||
|
||||
binPath = getCliBinPath();
|
||||
|
||||
execSync(`node "${binPath}" init --yes`, {
|
||||
stdio: 'pipe',
|
||||
env: { ...process.env, TASKMASTER_SKIP_AUTO_UPDATE: '1' }
|
||||
});
|
||||
|
||||
tasksPath = path.join(testDir, '.taskmaster', 'tasks', 'tasks.json');
|
||||
|
||||
// Use fixture to create initial empty tasks file
|
||||
const initialTasks = createTasksFile();
|
||||
fs.writeFileSync(tasksPath, JSON.stringify(initialTasks, null, 2));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
// Restore to the original working directory captured at module load
|
||||
process.chdir(initialCwd);
|
||||
} catch {
|
||||
// Fallback to home directory if initial directory no longer exists
|
||||
process.chdir(os.homedir());
|
||||
}
|
||||
|
||||
if (testDir && fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
delete process.env.TASKMASTER_SKIP_AUTO_UPDATE;
|
||||
});
|
||||
|
||||
const writeTasks = (tasksData: any) => {
|
||||
fs.writeFileSync(tasksPath, JSON.stringify(tasksData, null, 2));
|
||||
};
|
||||
|
||||
const runGenerate = (args = ''): { output: string; exitCode: number } => {
|
||||
try {
|
||||
const output = execSync(`node "${binPath}" generate ${args}`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
env: { ...process.env, TASKMASTER_SKIP_AUTO_UPDATE: '1' }
|
||||
});
|
||||
return { output, exitCode: 0 };
|
||||
} catch (error: any) {
|
||||
return {
|
||||
output: error.stderr?.toString() || error.stdout?.toString() || '',
|
||||
exitCode: error.status || 1
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ========== CLI-specific tests ==========
|
||||
|
||||
it('should exit with code 0 on success', () => {
|
||||
const testData = createTasksFile({
|
||||
tasks: [createTask({ id: 1, title: 'Test Task', status: 'pending' })]
|
||||
});
|
||||
writeTasks(testData);
|
||||
|
||||
const { exitCode } = runGenerate();
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('should display user-friendly message when no tasks exist', () => {
|
||||
const { output, exitCode } = runGenerate();
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.toLowerCase()).toContain('no tasks');
|
||||
});
|
||||
|
||||
it('should display task count in success message', () => {
|
||||
const testData = createTasksFile({
|
||||
tasks: [
|
||||
createTask({ id: 1, title: 'Task 1', status: 'pending' }),
|
||||
createTask({ id: 2, title: 'Task 2', status: 'pending' }),
|
||||
createTask({ id: 3, title: 'Task 3', status: 'pending' })
|
||||
]
|
||||
});
|
||||
writeTasks(testData);
|
||||
|
||||
const { output, exitCode } = runGenerate();
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output).toContain('3');
|
||||
});
|
||||
|
||||
it('should mention orphaned files in output when cleaned up', () => {
|
||||
// Create tasks and generate files
|
||||
const testData = createTasksFile({
|
||||
tasks: [
|
||||
createTask({ id: 1, title: 'Task 1', status: 'pending' }),
|
||||
createTask({ id: 2, title: 'Task 2', status: 'pending' }),
|
||||
createTask({ id: 3, title: 'Task 3', status: 'pending' })
|
||||
]
|
||||
});
|
||||
writeTasks(testData);
|
||||
runGenerate();
|
||||
|
||||
// Remove task 3 and regenerate
|
||||
const reducedData = createTasksFile({
|
||||
tasks: [
|
||||
createTask({ id: 1, title: 'Task 1', status: 'pending' }),
|
||||
createTask({ id: 2, title: 'Task 2', status: 'pending' })
|
||||
]
|
||||
});
|
||||
writeTasks(reducedData);
|
||||
|
||||
const { output, exitCode } = runGenerate();
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.toLowerCase()).toContain('orphan');
|
||||
});
|
||||
|
||||
it('should support --format json flag', () => {
|
||||
const testData = createTasksFile({
|
||||
tasks: [createTask({ id: 1, title: 'Test Task', status: 'pending' })]
|
||||
});
|
||||
writeTasks(testData);
|
||||
|
||||
const { output, exitCode } = runGenerate('--format json');
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// JSON output should be parseable and contain expected fields
|
||||
// Note: output may have leading/trailing whitespace or newlines
|
||||
const jsonMatch = output.match(/\{[\s\S]*\}/);
|
||||
expect(jsonMatch).not.toBeNull();
|
||||
|
||||
const parsed = JSON.parse(jsonMatch![0]);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.count).toBe(1);
|
||||
});
|
||||
|
||||
it('should support --output flag for custom directory', () => {
|
||||
const testData = createTasksFile({
|
||||
tasks: [createTask({ id: 1, title: 'Test Task', status: 'pending' })]
|
||||
});
|
||||
writeTasks(testData);
|
||||
|
||||
const customDir = path.join(testDir, 'custom-tasks');
|
||||
|
||||
const { exitCode } = runGenerate(`--output "${customDir}"`);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
// Verify file was created in custom directory
|
||||
expect(fs.existsSync(path.join(customDir, 'task_001.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should support --tag flag', () => {
|
||||
// Create tasks under a custom tag (not master)
|
||||
const testData = {
|
||||
master: { tasks: [], metadata: {} },
|
||||
'feature-branch': {
|
||||
tasks: [createTask({ id: 1, title: 'Test Task', status: 'pending' })],
|
||||
metadata: {}
|
||||
}
|
||||
};
|
||||
writeTasks(testData);
|
||||
|
||||
const { exitCode } = runGenerate('--tag feature-branch');
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Should create tag-specific file
|
||||
const outputDir = path.join(testDir, '.taskmaster', 'tasks');
|
||||
expect(
|
||||
fs.existsSync(path.join(outputDir, 'task_001_feature-branch.md'))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should show output directory in success message', () => {
|
||||
const testData = createTasksFile({
|
||||
tasks: [createTask({ id: 1, title: 'Test Task', status: 'pending' })]
|
||||
});
|
||||
writeTasks(testData);
|
||||
|
||||
const { output, exitCode } = runGenerate();
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output).toContain('.taskmaster/tasks');
|
||||
});
|
||||
|
||||
it('should exit with non-zero code for invalid --format value', () => {
|
||||
const testData = createTasksFile({
|
||||
tasks: [createTask({ id: 1, title: 'Test Task', status: 'pending' })]
|
||||
});
|
||||
writeTasks(testData);
|
||||
|
||||
const { output, exitCode } = runGenerate('--format invalid');
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
expect(output.toLowerCase()).toMatch(/invalid|error|format/);
|
||||
});
|
||||
});
|
||||
@@ -11,11 +11,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createSubtask,
|
||||
createTask,
|
||||
createTasksFile
|
||||
} from '../../fixtures/task-fixtures';
|
||||
import { createSubtask, createTask, createTasksFile } from '@tm/core/testing';
|
||||
import { getCliBinPath } from '../../helpers/test-utils';
|
||||
|
||||
// Capture initial working directory at module load time
|
||||
|
||||
@@ -11,7 +11,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createTask, createTasksFile } from '../../fixtures/task-fixtures';
|
||||
import { createTask, createTasksFile } from '@tm/core/testing';
|
||||
import { getCliBinPath } from '../../helpers/test-utils';
|
||||
|
||||
// Capture initial working directory at module load time
|
||||
|
||||
@@ -12,7 +12,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createTask, createTasksFile } from '../../fixtures/task-fixtures';
|
||||
import { createTask, createTasksFile } from '@tm/core/testing';
|
||||
import { getCliBinPath } from '../../helpers/test-utils';
|
||||
|
||||
// Capture initial working directory at module load time
|
||||
|
||||
@@ -11,11 +11,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createTask,
|
||||
createTasksFile,
|
||||
createSubtask
|
||||
} from '../../fixtures/task-fixtures';
|
||||
import { createTask, createTasksFile, createSubtask } from '@tm/core/testing';
|
||||
import { getCliBinPath } from '../../helpers/test-utils';
|
||||
|
||||
// Capture initial working directory at module load time
|
||||
|
||||
106
apps/mcp/src/tools/tasks/generate.tool.ts
Normal file
106
apps/mcp/src/tools/tasks/generate.tool.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @fileoverview generate MCP tool
|
||||
* Generates individual task files from tasks.json
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { handleApiResult, withToolContext } from '../../shared/utils.js';
|
||||
import type { ToolContext } from '../../shared/types.js';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
|
||||
const GenerateSchema = z.object({
|
||||
output: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Output directory for generated files (default: same directory as tasks file)'
|
||||
),
|
||||
projectRoot: z
|
||||
.string()
|
||||
.describe('The directory of the project. Must be an absolute path.'),
|
||||
tag: z.string().optional().describe('Tag context to operate on')
|
||||
});
|
||||
|
||||
type GenerateArgs = z.infer<typeof GenerateSchema>;
|
||||
|
||||
/**
|
||||
* Register the generate tool with the MCP server
|
||||
*/
|
||||
export function registerGenerateTool(server: FastMCP) {
|
||||
server.addTool({
|
||||
name: 'generate',
|
||||
description:
|
||||
'Generates individual task files in tasks/ directory based on tasks.json. Only works with local file storage.',
|
||||
parameters: GenerateSchema,
|
||||
execute: withToolContext(
|
||||
'generate',
|
||||
async (args: GenerateArgs, { log, tmCore }: ToolContext) => {
|
||||
const { projectRoot, tag, output } = args;
|
||||
|
||||
try {
|
||||
log.info(`Generating task files with args: ${JSON.stringify(args)}`);
|
||||
|
||||
// Resolve output directory
|
||||
const outputDir = output
|
||||
? path.resolve(projectRoot, output)
|
||||
: undefined;
|
||||
|
||||
// Call tm-core to generate task files
|
||||
const result = await tmCore.tasks.generateTaskFiles({
|
||||
tag,
|
||||
outputDir
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
log.info(
|
||||
`Successfully generated ${result.count} task files in ${result.directory}`
|
||||
);
|
||||
if (result.orphanedFilesRemoved > 0) {
|
||||
log.info(
|
||||
`Removed ${result.orphanedFilesRemoved} orphaned task files`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log.error(
|
||||
`Failed to generate task files: ${result.error || 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
|
||||
return handleApiResult({
|
||||
result: {
|
||||
success: result.success,
|
||||
data: result.success
|
||||
? {
|
||||
message: `Successfully generated ${result.count} task file(s)`,
|
||||
count: result.count,
|
||||
directory: result.directory,
|
||||
orphanedFilesRemoved: result.orphanedFilesRemoved
|
||||
}
|
||||
: undefined,
|
||||
error: result.success ? undefined : { message: result.error || 'Unknown error' }
|
||||
},
|
||||
log,
|
||||
projectRoot,
|
||||
tag
|
||||
});
|
||||
} catch (error: any) {
|
||||
log.error(`Error in generate tool: ${error.message}`);
|
||||
if (error.stack) {
|
||||
log.debug(error.stack);
|
||||
}
|
||||
return handleApiResult({
|
||||
result: {
|
||||
success: false,
|
||||
error: {
|
||||
message: `Failed to generate task files: ${error.message}`
|
||||
}
|
||||
},
|
||||
log,
|
||||
projectRoot
|
||||
});
|
||||
}
|
||||
}
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -5,3 +5,4 @@
|
||||
|
||||
export { registerGetTasksTool } from './get-tasks.tool.js';
|
||||
export { registerGetTaskTool } from './get-task.tool.js';
|
||||
export { registerGenerateTool } from './generate.tool.js';
|
||||
|
||||
1
apps/mcp/tests/fixtures/task-fixtures.ts
vendored
1
apps/mcp/tests/fixtures/task-fixtures.ts
vendored
@@ -1 +0,0 @@
|
||||
../../../cli/tests/fixtures/task-fixtures.ts
|
||||
199
apps/mcp/tests/integration/tools/generate.tool.test.ts
Normal file
199
apps/mcp/tests/integration/tools/generate.tool.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* @fileoverview Integration tests for generate MCP tool
|
||||
*
|
||||
* Tests MCP-specific behavior: tool response format, parameter handling.
|
||||
* Core file generation logic is tested in tm-core's task-file-generator.service.spec.ts.
|
||||
*
|
||||
* @integration
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createTask, createTasksFile } from '@tm/core/testing';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('generate MCP tool', () => {
|
||||
let testDir: string;
|
||||
let tasksPath: string;
|
||||
let cliPath: string;
|
||||
let mcpServerPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-mcp-generate-'));
|
||||
process.chdir(testDir);
|
||||
|
||||
cliPath = path.resolve(__dirname, '../../../../../dist/task-master.js');
|
||||
mcpServerPath = path.resolve(
|
||||
__dirname,
|
||||
'../../../../../dist/mcp-server.js'
|
||||
);
|
||||
|
||||
// Initialize Task Master in test directory
|
||||
execSync(`node "${cliPath}" init --yes`, {
|
||||
stdio: 'pipe',
|
||||
env: { ...process.env, TASKMASTER_SKIP_AUTO_UPDATE: '1' }
|
||||
});
|
||||
|
||||
tasksPath = path.join(testDir, '.taskmaster', 'tasks', 'tasks.json');
|
||||
|
||||
// Create initial empty tasks file using fixtures
|
||||
const initialTasks = createTasksFile();
|
||||
fs.writeFileSync(tasksPath, JSON.stringify(initialTasks, null, 2));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Change back to original directory and cleanup
|
||||
try {
|
||||
const originalDir = path.resolve(__dirname, '../../../../..');
|
||||
process.chdir(originalDir);
|
||||
} catch {
|
||||
process.chdir(os.homedir());
|
||||
}
|
||||
|
||||
if (testDir && fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const writeTasks = (tasksData: any) => {
|
||||
fs.writeFileSync(tasksPath, JSON.stringify(tasksData, null, 2));
|
||||
};
|
||||
|
||||
/**
|
||||
* Call an MCP tool using the inspector CLI
|
||||
* The inspector returns MCP protocol format: { content: [{ type: "text", text: "<json>" }] }
|
||||
*/
|
||||
const callMCPTool = (toolName: string, args: Record<string, any>): any => {
|
||||
const toolArgs = Object.entries(args)
|
||||
.map(([key, value]) => `--tool-arg ${key}=${value}`)
|
||||
.join(' ');
|
||||
|
||||
const output = execSync(
|
||||
`npx @modelcontextprotocol/inspector --cli node "${mcpServerPath}" --method tools/call --tool-name ${toolName} ${toolArgs}`,
|
||||
{ encoding: 'utf-8', stdio: 'pipe' }
|
||||
);
|
||||
|
||||
// Parse the MCP protocol response: { content: [{ type: "text", text: "<json>" }] }
|
||||
const mcpResponse = JSON.parse(output);
|
||||
const resultText = mcpResponse.content[0].text;
|
||||
return JSON.parse(resultText);
|
||||
};
|
||||
|
||||
// ========== MCP-specific tests ==========
|
||||
|
||||
it('should return MCP response with data object on success', () => {
|
||||
const testData = createTasksFile({
|
||||
tasks: [createTask({ id: 1, title: 'Test Task', status: 'pending' })]
|
||||
});
|
||||
writeTasks(testData);
|
||||
|
||||
const response = callMCPTool('generate', { projectRoot: testDir });
|
||||
|
||||
// Verify MCP response structure
|
||||
expect(response).toHaveProperty('data');
|
||||
expect(response.data).toHaveProperty('count');
|
||||
expect(response.data).toHaveProperty('directory');
|
||||
expect(response.data).toHaveProperty('message');
|
||||
}, 15000);
|
||||
|
||||
it('should include tag in response', () => {
|
||||
const testData = createTasksFile({
|
||||
tasks: [createTask({ id: 1, title: 'Test Task', status: 'pending' })]
|
||||
});
|
||||
writeTasks(testData);
|
||||
|
||||
const response = callMCPTool('generate', { projectRoot: testDir });
|
||||
|
||||
expect(response.tag).toBe('master');
|
||||
}, 15000);
|
||||
|
||||
it('should return count of generated files', () => {
|
||||
const testData = createTasksFile({
|
||||
tasks: [
|
||||
createTask({ id: 1, title: 'Task 1', status: 'pending' }),
|
||||
createTask({ id: 2, title: 'Task 2', status: 'pending' }),
|
||||
createTask({ id: 3, title: 'Task 3', status: 'pending' })
|
||||
]
|
||||
});
|
||||
writeTasks(testData);
|
||||
|
||||
const response = callMCPTool('generate', { projectRoot: testDir });
|
||||
|
||||
expect(response.data.count).toBe(3);
|
||||
}, 15000);
|
||||
|
||||
it('should return zero count when no tasks exist', () => {
|
||||
const response = callMCPTool('generate', { projectRoot: testDir });
|
||||
|
||||
expect(response.data.count).toBe(0);
|
||||
}, 15000);
|
||||
|
||||
it('should return orphanedFilesRemoved count', () => {
|
||||
// Create tasks and generate files
|
||||
const testData = createTasksFile({
|
||||
tasks: [
|
||||
createTask({ id: 1, title: 'Task 1', status: 'pending' }),
|
||||
createTask({ id: 2, title: 'Task 2', status: 'pending' }),
|
||||
createTask({ id: 3, title: 'Task 3', status: 'pending' })
|
||||
]
|
||||
});
|
||||
writeTasks(testData);
|
||||
callMCPTool('generate', { projectRoot: testDir });
|
||||
|
||||
// Remove task 3 and regenerate
|
||||
const reducedData = createTasksFile({
|
||||
tasks: [
|
||||
createTask({ id: 1, title: 'Task 1', status: 'pending' }),
|
||||
createTask({ id: 2, title: 'Task 2', status: 'pending' })
|
||||
]
|
||||
});
|
||||
writeTasks(reducedData);
|
||||
|
||||
const response = callMCPTool('generate', { projectRoot: testDir });
|
||||
|
||||
expect(response.data.orphanedFilesRemoved).toBe(1);
|
||||
}, 15000);
|
||||
|
||||
it('should accept output parameter for custom directory', () => {
|
||||
const testData = createTasksFile({
|
||||
tasks: [createTask({ id: 1, title: 'Test Task', status: 'pending' })]
|
||||
});
|
||||
writeTasks(testData);
|
||||
|
||||
const customDir = path.join(testDir, 'custom-tasks');
|
||||
|
||||
const response = callMCPTool('generate', {
|
||||
projectRoot: testDir,
|
||||
output: customDir
|
||||
});
|
||||
|
||||
expect(response.data.directory).toBe(customDir);
|
||||
}, 15000);
|
||||
|
||||
it('should accept tag parameter', () => {
|
||||
// Create tasks under a custom tag (not master)
|
||||
const testData = {
|
||||
master: { tasks: [], metadata: {} },
|
||||
'feature-branch': {
|
||||
tasks: [createTask({ id: 1, title: 'Test Task', status: 'pending' })],
|
||||
metadata: {}
|
||||
}
|
||||
};
|
||||
writeTasks(testData);
|
||||
|
||||
const response = callMCPTool('generate', {
|
||||
projectRoot: testDir,
|
||||
tag: 'feature-branch'
|
||||
});
|
||||
|
||||
expect(response.data.count).toBe(1);
|
||||
|
||||
// Verify tag-specific file was created
|
||||
const outputDir = path.join(testDir, '.taskmaster', 'tasks');
|
||||
expect(
|
||||
fs.existsSync(path.join(outputDir, 'task_001_feature-branch.md'))
|
||||
).toBe(true);
|
||||
}, 15000);
|
||||
});
|
||||
@@ -12,7 +12,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createTask, createTasksFile } from '../../fixtures/task-fixtures';
|
||||
import { createTask, createTasksFile } from '@tm/core/testing';
|
||||
|
||||
describe('get_tasks MCP tool', () => {
|
||||
let testDir: string;
|
||||
|
||||
@@ -48,11 +48,12 @@ import {
|
||||
registerAutopilotFinalizeTool,
|
||||
registerAutopilotAbortTool,
|
||||
registerGetTasksTool,
|
||||
registerGetTaskTool
|
||||
registerGetTaskTool,
|
||||
registerGenerateTool
|
||||
} from '@tm/mcp';
|
||||
|
||||
/**
|
||||
* Comprehensive tool registry mapping all 44 tool names to their registration functions
|
||||
* Comprehensive tool registry mapping tool names to their registration functions
|
||||
* Used for dynamic tool registration and validation
|
||||
*/
|
||||
export const toolRegistry = {
|
||||
@@ -98,7 +99,8 @@ export const toolRegistry = {
|
||||
autopilot_complete: registerAutopilotCompleteTool,
|
||||
autopilot_commit: registerAutopilotCommitTool,
|
||||
autopilot_finalize: registerAutopilotFinalizeTool,
|
||||
autopilot_abort: registerAutopilotAbortTool
|
||||
autopilot_abort: registerAutopilotAbortTool,
|
||||
generate: registerGenerateTool
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"main": "./dist/index.js",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./testing": "./src/testing/index.ts",
|
||||
"./auth": "./src/auth/index.ts",
|
||||
"./storage": "./src/storage/index.ts",
|
||||
"./config": "./src/config/index.ts",
|
||||
|
||||
@@ -146,6 +146,22 @@ export { CommitMessageGenerator } from './modules/git/services/commit-message-ge
|
||||
// Tasks - Advanced
|
||||
export { PreflightChecker } from './modules/tasks/services/preflight-checker.service.js';
|
||||
export { TaskLoaderService } from './modules/tasks/services/task-loader.service.js';
|
||||
export {
|
||||
TaskFileGeneratorService,
|
||||
type GenerateTaskFilesOptions,
|
||||
type GenerateTaskFilesResult
|
||||
} from './modules/tasks/services/task-file-generator.service.js';
|
||||
|
||||
// Integration - Advanced
|
||||
export { ExportService } from './modules/integration/services/export.service.js';
|
||||
|
||||
// ========== Testing Utilities ==========
|
||||
|
||||
// Test fixtures for integration tests
|
||||
export {
|
||||
createTask,
|
||||
createSubtask,
|
||||
createTasksFile,
|
||||
TaskScenarios,
|
||||
type TasksFile
|
||||
} from './testing/index.js';
|
||||
|
||||
@@ -12,7 +12,8 @@ export const LOCAL_ONLY_COMMANDS = [
|
||||
'validate-dependencies',
|
||||
'fix-dependencies',
|
||||
'clear-subtasks',
|
||||
'models'
|
||||
'models',
|
||||
'generate'
|
||||
] as const;
|
||||
|
||||
export type LocalOnlyCommand = (typeof LOCAL_ONLY_COMMANDS)[number];
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for TaskFileGeneratorService
|
||||
* Tests task file generation from storage
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TaskFileGeneratorService } from './task-file-generator.service.js';
|
||||
import type { IStorage } from '../../../common/interfaces/storage.interface.js';
|
||||
import type { Task } from '../../../common/types/index.js';
|
||||
import type { ConfigManager } from '../../config/managers/config-manager.js';
|
||||
|
||||
// Mock storage implementation for testing
|
||||
function createMockStorage(tasks: Task[] = []): IStorage {
|
||||
return {
|
||||
loadTasks: vi.fn().mockResolvedValue(tasks),
|
||||
loadTask: vi.fn(),
|
||||
saveTasks: vi.fn(),
|
||||
appendTasks: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
updateTaskWithPrompt: vi.fn(),
|
||||
expandTaskWithPrompt: vi.fn(),
|
||||
updateTaskStatus: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
exists: vi.fn().mockResolvedValue(true),
|
||||
loadMetadata: vi.fn(),
|
||||
saveMetadata: vi.fn(),
|
||||
getAllTags: vi.fn().mockResolvedValue(['master']),
|
||||
createTag: vi.fn(),
|
||||
deleteTag: vi.fn(),
|
||||
renameTag: vi.fn(),
|
||||
copyTag: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
close: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
getStorageType: vi.fn().mockReturnValue('file'),
|
||||
getCurrentBriefName: vi.fn().mockReturnValue(null),
|
||||
getTagsWithStats: vi.fn()
|
||||
};
|
||||
}
|
||||
|
||||
function createSampleTasks(): Task[] {
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
title: 'First Task',
|
||||
description: 'Description for first task',
|
||||
status: 'pending',
|
||||
priority: 'high',
|
||||
dependencies: [],
|
||||
details: 'Implementation details for task 1',
|
||||
testStrategy: 'Unit tests for task 1',
|
||||
subtasks: []
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Second Task',
|
||||
description: 'Description for second task',
|
||||
status: 'in-progress',
|
||||
priority: 'medium',
|
||||
dependencies: ['1'],
|
||||
details: 'Implementation details for task 2',
|
||||
testStrategy: 'Integration tests for task 2',
|
||||
subtasks: [
|
||||
{
|
||||
id: 1,
|
||||
parentId: '2',
|
||||
title: 'Subtask 1',
|
||||
description: 'First subtask description',
|
||||
status: 'done',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
details: 'Subtask details',
|
||||
testStrategy: ''
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
parentId: '2',
|
||||
title: 'Subtask 2',
|
||||
description: 'Second subtask description',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: ['1'],
|
||||
details: '',
|
||||
testStrategy: ''
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Third Task',
|
||||
description: 'Description for third task',
|
||||
status: 'done',
|
||||
priority: 'low',
|
||||
dependencies: ['1', '2'],
|
||||
details: '',
|
||||
testStrategy: '',
|
||||
subtasks: []
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// Mock ConfigManager for testing
|
||||
function createMockConfigManager(activeTag = 'master'): ConfigManager {
|
||||
return {
|
||||
getActiveTag: vi.fn().mockReturnValue(activeTag),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/tmp/test-project')
|
||||
} as unknown as ConfigManager;
|
||||
}
|
||||
|
||||
describe('TaskFileGeneratorService', () => {
|
||||
let tempDir: string;
|
||||
let service: TaskFileGeneratorService;
|
||||
let mockStorage: IStorage;
|
||||
let mockConfigManager: ConfigManager;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a temporary directory for test outputs
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-test-'));
|
||||
mockStorage = createMockStorage(createSampleTasks());
|
||||
mockConfigManager = createMockConfigManager();
|
||||
service = new TaskFileGeneratorService(mockStorage, tempDir, mockConfigManager);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('generateTaskFiles', () => {
|
||||
it('should generate task files for all tasks', async () => {
|
||||
const result = await service.generateTaskFiles();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.count).toBe(3);
|
||||
expect(result.directory).toContain('.taskmaster/tasks');
|
||||
|
||||
// Verify files were created
|
||||
const outputDir = result.directory;
|
||||
expect(fs.existsSync(path.join(outputDir, 'task_001.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(outputDir, 'task_002.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(outputDir, 'task_003.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate task files with correct content', async () => {
|
||||
const result = await service.generateTaskFiles();
|
||||
const outputDir = result.directory;
|
||||
|
||||
// Read and verify content of first task file
|
||||
const task1Content = fs.readFileSync(
|
||||
path.join(outputDir, 'task_001.md'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
expect(task1Content).toContain('# Task ID: 1');
|
||||
expect(task1Content).toContain('**Title:** First Task');
|
||||
expect(task1Content).toContain('**Status:** pending');
|
||||
expect(task1Content).toContain('**Priority:** high');
|
||||
expect(task1Content).toContain('**Description:** Description for first task');
|
||||
expect(task1Content).toContain('**Details:**');
|
||||
expect(task1Content).toContain('Implementation details for task 1');
|
||||
expect(task1Content).toContain('**Test Strategy:**');
|
||||
expect(task1Content).toContain('Unit tests for task 1');
|
||||
});
|
||||
|
||||
it('should include subtasks in task files', async () => {
|
||||
const result = await service.generateTaskFiles();
|
||||
const outputDir = result.directory;
|
||||
|
||||
// Read task 2 which has subtasks
|
||||
const task2Content = fs.readFileSync(
|
||||
path.join(outputDir, 'task_002.md'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
expect(task2Content).toContain('## Subtasks');
|
||||
expect(task2Content).toContain('### 2.1. Subtask 1');
|
||||
expect(task2Content).toContain('**Status:** done');
|
||||
expect(task2Content).toContain('### 2.2. Subtask 2');
|
||||
expect(task2Content).toContain('**Status:** pending');
|
||||
expect(task2Content).toContain('**Dependencies:** 2.1');
|
||||
});
|
||||
|
||||
it('should format dependencies with status symbols', async () => {
|
||||
const result = await service.generateTaskFiles();
|
||||
const outputDir = result.directory;
|
||||
|
||||
// Task 3 depends on task 1 (pending) and task 2 (in-progress)
|
||||
const task3Content = fs.readFileSync(
|
||||
path.join(outputDir, 'task_003.md'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Check that dependencies include status symbols
|
||||
expect(task3Content).toContain('**Dependencies:**');
|
||||
expect(task3Content).toMatch(/1.*,.*2/); // Both dependencies listed
|
||||
});
|
||||
|
||||
it('should use tag-specific filenames for non-master tags', async () => {
|
||||
const result = await service.generateTaskFiles({ tag: 'feature-branch' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const outputDir = result.directory;
|
||||
expect(
|
||||
fs.existsSync(path.join(outputDir, 'task_001_feature-branch.md'))
|
||||
).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(outputDir, 'task_002_feature-branch.md'))
|
||||
).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(outputDir, 'task_003_feature-branch.md'))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should use custom output directory when provided', async () => {
|
||||
const customDir = path.join(tempDir, 'custom-output');
|
||||
|
||||
const result = await service.generateTaskFiles({ outputDir: customDir });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.directory).toBe(customDir);
|
||||
expect(fs.existsSync(path.join(customDir, 'task_001.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty task list', async () => {
|
||||
const emptyStorage = createMockStorage([]);
|
||||
const emptyService = new TaskFileGeneratorService(emptyStorage, tempDir, mockConfigManager);
|
||||
|
||||
const result = await emptyService.generateTaskFiles();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.count).toBe(0);
|
||||
});
|
||||
|
||||
it('should clean up orphaned task files', async () => {
|
||||
// First generate files for 3 tasks
|
||||
await service.generateTaskFiles();
|
||||
|
||||
// Now mock storage to return only 2 tasks (task 3 removed)
|
||||
const reducedTasks = createSampleTasks().slice(0, 2);
|
||||
const reducedStorage = createMockStorage(reducedTasks);
|
||||
const reducedService = new TaskFileGeneratorService(
|
||||
reducedStorage,
|
||||
tempDir,
|
||||
mockConfigManager
|
||||
);
|
||||
|
||||
const result = await reducedService.generateTaskFiles();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.count).toBe(2);
|
||||
expect(result.orphanedFilesRemoved).toBe(1);
|
||||
|
||||
// Verify task_003.md was removed
|
||||
const outputDir = result.directory;
|
||||
expect(fs.existsSync(path.join(outputDir, 'task_001.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(outputDir, 'task_002.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(outputDir, 'task_003.md'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle storage errors gracefully', async () => {
|
||||
const errorStorage = createMockStorage();
|
||||
(errorStorage.loadTasks as any).mockRejectedValue(
|
||||
new Error('Storage error')
|
||||
);
|
||||
const errorService = new TaskFileGeneratorService(errorStorage, tempDir, mockConfigManager);
|
||||
|
||||
const result = await errorService.generateTaskFiles();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Storage error');
|
||||
});
|
||||
|
||||
it('should use active tag from config when tag is not provided', async () => {
|
||||
// Create service with non-master active tag
|
||||
const featureConfigManager = createMockConfigManager('feature-x');
|
||||
const featureService = new TaskFileGeneratorService(
|
||||
mockStorage,
|
||||
tempDir,
|
||||
featureConfigManager
|
||||
);
|
||||
|
||||
const result = await featureService.generateTaskFiles();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify files were created with the active tag suffix
|
||||
const outputDir = result.directory;
|
||||
expect(
|
||||
fs.existsSync(path.join(outputDir, 'task_001_feature-x.md'))
|
||||
).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(outputDir, 'task_002_feature-x.md'))
|
||||
).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(outputDir, 'task_003_feature-x.md'))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should override active tag when tag is explicitly provided', async () => {
|
||||
// Create service with master active tag
|
||||
const masterConfigManager = createMockConfigManager('master');
|
||||
const masterService = new TaskFileGeneratorService(
|
||||
mockStorage,
|
||||
tempDir,
|
||||
masterConfigManager
|
||||
);
|
||||
|
||||
// Explicitly provide a different tag
|
||||
const result = await masterService.generateTaskFiles({ tag: 'override-tag' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify files were created with the override tag, not active tag
|
||||
const outputDir = result.directory;
|
||||
expect(
|
||||
fs.existsSync(path.join(outputDir, 'task_001_override-tag.md'))
|
||||
).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(outputDir, 'task_002_override-tag.md'))
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* @fileoverview Task file generator service
|
||||
* Generates individual markdown task files from tasks.json
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { Task, Subtask, TaskStatus } from '../../../common/types/index.js';
|
||||
import type { IStorage } from '../../../common/interfaces/storage.interface.js';
|
||||
import type { ConfigManager } from '../../config/managers/config-manager.js';
|
||||
|
||||
/**
|
||||
* Options for generating task files
|
||||
*/
|
||||
export interface GenerateTaskFilesOptions {
|
||||
/** Tag context for the tasks */
|
||||
tag?: string;
|
||||
/** Output directory for generated files (defaults to tasks directory) */
|
||||
outputDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of task file generation
|
||||
*/
|
||||
export interface GenerateTaskFilesResult {
|
||||
success: boolean;
|
||||
/** Number of task files successfully generated */
|
||||
count: number;
|
||||
/** Output directory where files were written */
|
||||
directory: string;
|
||||
/** Number of orphaned files cleaned up */
|
||||
orphanedFilesRemoved: number;
|
||||
/** Error message if generation failed completely */
|
||||
error?: string;
|
||||
/** Individual file write errors (task ID -> error message) */
|
||||
fileErrors?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for generating individual task markdown files from tasks.json
|
||||
*/
|
||||
export class TaskFileGeneratorService {
|
||||
constructor(
|
||||
private storage: IStorage,
|
||||
private projectPath: string,
|
||||
private configManager: ConfigManager
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generate individual task files from storage
|
||||
* - Reads tasks from storage
|
||||
* - Cleans up orphaned task files
|
||||
* - Writes individual markdown files for each task
|
||||
*/
|
||||
async generateTaskFiles(
|
||||
options: GenerateTaskFilesOptions = {}
|
||||
): Promise<GenerateTaskFilesResult> {
|
||||
const tag = options.tag || this.configManager.getActiveTag();
|
||||
const outputDir =
|
||||
options.outputDir ||
|
||||
path.join(this.projectPath, '.taskmaster', 'tasks');
|
||||
|
||||
try {
|
||||
// Ensure output directory exists
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
|
||||
// Load tasks from storage
|
||||
const tasks = await this.storage.loadTasks(tag);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
count: 0,
|
||||
directory: outputDir,
|
||||
orphanedFilesRemoved: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Clean up orphaned task files
|
||||
const orphanedCount = await this.cleanupOrphanedFiles(
|
||||
outputDir,
|
||||
tasks,
|
||||
tag
|
||||
);
|
||||
|
||||
// Generate task files in parallel with individual error handling
|
||||
// This allows partial success - some files can be written even if others fail
|
||||
const fileErrors: Record<string, string> = {};
|
||||
const results = await Promise.allSettled(
|
||||
tasks.map(async (task) => {
|
||||
const content = this.formatTaskContent(task, tasks);
|
||||
const fileName = this.getTaskFileName(task.id, tag);
|
||||
const filePath = path.join(outputDir, fileName);
|
||||
await fs.writeFile(filePath, content, 'utf-8');
|
||||
return task.id;
|
||||
})
|
||||
);
|
||||
|
||||
// Count successes and collect errors
|
||||
let successCount = 0;
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i];
|
||||
if (result.status === 'fulfilled') {
|
||||
successCount++;
|
||||
} else {
|
||||
const taskId = String(tasks[i].id);
|
||||
fileErrors[taskId] = result.reason?.message || 'Unknown error';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: Object.keys(fileErrors).length === 0,
|
||||
count: successCount,
|
||||
directory: outputDir,
|
||||
orphanedFilesRemoved: orphanedCount,
|
||||
...(Object.keys(fileErrors).length > 0 && { fileErrors })
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
count: 0,
|
||||
directory: outputDir,
|
||||
orphanedFilesRemoved: 0,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the filename for a task file
|
||||
* Master tag: task_001.md
|
||||
* Other tags: task_001_tagname.md
|
||||
*/
|
||||
private getTaskFileName(taskId: string | number, tag: string): string {
|
||||
const paddedId = String(taskId).padStart(3, '0');
|
||||
return tag === 'master'
|
||||
? `task_${paddedId}.md`
|
||||
: `task_${paddedId}_${tag}.md`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special regex characters in a string
|
||||
*/
|
||||
private escapeRegExp(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up orphaned task files (files for tasks that no longer exist)
|
||||
* @returns Number of files removed
|
||||
*/
|
||||
private async cleanupOrphanedFiles(
|
||||
outputDir: string,
|
||||
tasks: Task[],
|
||||
tag: string
|
||||
): Promise<number> {
|
||||
let removedCount = 0;
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(outputDir);
|
||||
const validTaskIds = tasks.map((task) => String(task.id));
|
||||
|
||||
// Tag-aware file patterns
|
||||
const masterFilePattern = /^task_(\d+)\.md$/;
|
||||
const taggedFilePattern = new RegExp(`^task_(\\d+)_${this.escapeRegExp(tag)}\\.md$`);
|
||||
|
||||
// Collect files to remove
|
||||
const filesToRemove: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
let match = null;
|
||||
let fileTaskId: string | null = null;
|
||||
|
||||
// Check if file belongs to current tag
|
||||
if (tag === 'master') {
|
||||
match = file.match(masterFilePattern);
|
||||
if (match) {
|
||||
fileTaskId = match[1];
|
||||
}
|
||||
} else {
|
||||
match = file.match(taggedFilePattern);
|
||||
if (match) {
|
||||
fileTaskId = match[1];
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a task file for the current tag and the task no longer exists
|
||||
if (fileTaskId !== null) {
|
||||
// Convert to integer for comparison (removes leading zeros)
|
||||
const normalizedId = String(parseInt(fileTaskId, 10));
|
||||
if (!validTaskIds.includes(normalizedId)) {
|
||||
filesToRemove.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove files in parallel
|
||||
await Promise.all(
|
||||
filesToRemove.map(async (file) => {
|
||||
const filePath = path.join(outputDir, file);
|
||||
await fs.unlink(filePath);
|
||||
})
|
||||
);
|
||||
removedCount = filesToRemove.length;
|
||||
} catch (error) {
|
||||
// Ignore errors during cleanup - non-critical operation
|
||||
}
|
||||
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a task into markdown content for the task file
|
||||
* Uses single H1 title with metadata as key-value pairs
|
||||
*/
|
||||
private formatTaskContent(task: Task, allTasks: Task[]): string {
|
||||
// Single H1 title with task ID
|
||||
let content = `# Task ID: ${task.id}\n\n`;
|
||||
|
||||
// Metadata as key-value pairs (using bold for keys)
|
||||
content += `**Title:** ${task.title}\n\n`;
|
||||
content += `**Status:** ${task.status || 'pending'}\n\n`;
|
||||
|
||||
// Format dependencies with status
|
||||
if (task.dependencies && task.dependencies.length > 0) {
|
||||
const depsWithStatus = this.formatDependenciesWithStatus(
|
||||
task.dependencies,
|
||||
allTasks
|
||||
);
|
||||
content += `**Dependencies:** ${depsWithStatus}\n\n`;
|
||||
} else {
|
||||
content += '**Dependencies:** None\n\n';
|
||||
}
|
||||
|
||||
content += `**Priority:** ${task.priority || 'medium'}\n\n`;
|
||||
content += `**Description:** ${task.description || ''}\n\n`;
|
||||
|
||||
// Details section
|
||||
content += '**Details:**\n\n';
|
||||
content += `${task.details || 'No details provided.'}\n\n`;
|
||||
|
||||
// Test Strategy section
|
||||
content += '**Test Strategy:**\n\n';
|
||||
content += `${task.testStrategy || 'No test strategy provided.'}\n`;
|
||||
|
||||
// Add subtasks if present
|
||||
if (task.subtasks && task.subtasks.length > 0) {
|
||||
content += '\n## Subtasks\n';
|
||||
for (const subtask of task.subtasks) {
|
||||
content += this.formatSubtaskContent(subtask, task);
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize multiple blank lines to single blank lines
|
||||
return content.replace(/\n{3,}/g, '\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a subtask into markdown content
|
||||
*/
|
||||
private formatSubtaskContent(subtask: Subtask, parentTask: Task): string {
|
||||
// H3 for subtask title since H2 is used for sections
|
||||
let content = `\n### ${parentTask.id}.${subtask.id}. ${subtask.title}\n\n`;
|
||||
|
||||
// Metadata using bold labels
|
||||
content += `**Status:** ${subtask.status || 'pending'} \n`;
|
||||
|
||||
// Format subtask dependencies
|
||||
if (subtask.dependencies && subtask.dependencies.length > 0) {
|
||||
const subtaskDeps = subtask.dependencies
|
||||
.map((depId) => {
|
||||
const depStr = String(depId);
|
||||
// Check if it's already a full ID (contains a dot) or is a simple number reference
|
||||
// Simple numbers (e.g., 1, '1') are internal subtask refs and need parent prefix
|
||||
// Full IDs (e.g., '1.2', '3') with dots are already complete
|
||||
if (depStr.includes('.')) {
|
||||
return depStr; // Already a full subtask ID like "2.1"
|
||||
}
|
||||
// Simple number - prefix with parent task ID
|
||||
return `${parentTask.id}.${depStr}`;
|
||||
})
|
||||
.join(', ');
|
||||
content += `**Dependencies:** ${subtaskDeps} \n`;
|
||||
} else {
|
||||
content += '**Dependencies:** None \n';
|
||||
}
|
||||
|
||||
content += '\n';
|
||||
|
||||
if (subtask.description) {
|
||||
content += `${subtask.description}\n`;
|
||||
}
|
||||
|
||||
if (subtask.details) {
|
||||
content += `\n**Details:**\n\n${subtask.details}\n`;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format dependencies with their current status
|
||||
*/
|
||||
private formatDependenciesWithStatus(
|
||||
dependencies: (string | number)[],
|
||||
allTasks: Task[]
|
||||
): string {
|
||||
return dependencies
|
||||
.map((depId) => {
|
||||
const depTask = allTasks.find(
|
||||
(t) => String(t.id) === String(depId)
|
||||
);
|
||||
if (depTask) {
|
||||
const statusSymbol = this.getStatusSymbol(depTask.status);
|
||||
return `${depId}${statusSymbol}`;
|
||||
}
|
||||
return String(depId);
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status symbol for display
|
||||
*/
|
||||
private getStatusSymbol(status: TaskStatus | undefined): string {
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return ' ✓';
|
||||
case 'in-progress':
|
||||
return ' ⧖';
|
||||
case 'blocked':
|
||||
return ' ⛔';
|
||||
case 'cancelled':
|
||||
return ' ✗';
|
||||
case 'deferred':
|
||||
return ' ⏸';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -792,4 +792,15 @@ export class TaskService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close and cleanup resources
|
||||
* Releases file locks and other storage resources
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this.storage) {
|
||||
await this.storage.close();
|
||||
}
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ import { TaskExecutionService } from './services/task-execution-service.js';
|
||||
import { TaskLoaderService } from './services/task-loader.service.js';
|
||||
import { PreflightChecker } from './services/preflight-checker.service.js';
|
||||
import { TagService } from './services/tag.service.js';
|
||||
import {
|
||||
TaskFileGeneratorService,
|
||||
type GenerateTaskFilesOptions,
|
||||
type GenerateTaskFilesResult
|
||||
} from './services/task-file-generator.service.js';
|
||||
import type {
|
||||
CreateTagOptions,
|
||||
DeleteTagOptions,
|
||||
@@ -42,12 +47,17 @@ export class TasksDomain {
|
||||
private preflightChecker: PreflightChecker;
|
||||
private briefsDomain: BriefsDomain;
|
||||
private tagService!: TagService;
|
||||
private taskFileGenerator!: TaskFileGeneratorService;
|
||||
private projectRoot: string;
|
||||
private configManager: ConfigManager;
|
||||
|
||||
constructor(configManager: ConfigManager, _authDomain?: AuthDomain) {
|
||||
this.projectRoot = configManager.getProjectRoot();
|
||||
this.configManager = configManager;
|
||||
this.taskService = new TaskService(configManager);
|
||||
this.executionService = new TaskExecutionService(this.taskService);
|
||||
this.loaderService = new TaskLoaderService(this.taskService);
|
||||
this.preflightChecker = new PreflightChecker(configManager.getProjectRoot());
|
||||
this.preflightChecker = new PreflightChecker(this.projectRoot);
|
||||
this.briefsDomain = new BriefsDomain();
|
||||
}
|
||||
|
||||
@@ -56,6 +66,13 @@ export class TasksDomain {
|
||||
|
||||
// TagService needs storage - get it from TaskService AFTER initialization
|
||||
this.tagService = new TagService(this.taskService.getStorage());
|
||||
|
||||
// TaskFileGeneratorService needs storage and config for active tag
|
||||
this.taskFileGenerator = new TaskFileGeneratorService(
|
||||
this.taskService.getStorage(),
|
||||
this.projectRoot,
|
||||
this.configManager
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Task Retrieval ==========
|
||||
@@ -371,4 +388,43 @@ export class TasksDomain {
|
||||
getStorageType(): 'file' | 'api' {
|
||||
return this.taskService.getStorageType();
|
||||
}
|
||||
|
||||
// ========== Task File Generation ==========
|
||||
|
||||
/**
|
||||
* Generate individual task markdown files from tasks.json
|
||||
* This writes .md files for each task in the tasks directory.
|
||||
*
|
||||
* Note: Only applicable for file storage. API storage throws an error.
|
||||
*
|
||||
* @param options - Generation options (tag, outputDir)
|
||||
* @returns Result with count of generated files and cleanup info
|
||||
*/
|
||||
async generateTaskFiles(
|
||||
options?: GenerateTaskFilesOptions
|
||||
): Promise<GenerateTaskFilesResult> {
|
||||
// Only file storage supports task file generation
|
||||
if (this.getStorageType() === 'api') {
|
||||
return {
|
||||
success: false,
|
||||
count: 0,
|
||||
directory: '',
|
||||
orphanedFilesRemoved: 0,
|
||||
error:
|
||||
'Task file generation is only available for local file storage. API storage manages tasks in the cloud.'
|
||||
};
|
||||
}
|
||||
|
||||
return this.taskFileGenerator.generateTaskFiles(options);
|
||||
}
|
||||
|
||||
// ========== Cleanup ==========
|
||||
|
||||
/**
|
||||
* Close and cleanup resources
|
||||
* Releases file locks and other storage resources
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
await this.taskService.close();
|
||||
}
|
||||
}
|
||||
|
||||
22
packages/tm-core/src/testing/index.ts
Normal file
22
packages/tm-core/src/testing/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @fileoverview Testing utilities for @tm/core
|
||||
*
|
||||
* This module provides test fixtures and utilities for testing applications
|
||||
* that use @tm/core. Import from '@tm/core/testing' or '@tm/core'.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { createTask, createTasksFile, TaskScenarios } from '@tm/core/testing';
|
||||
*
|
||||
* const task = createTask({ id: 1, title: 'Test Task' });
|
||||
* const tasksFile = TaskScenarios.linearDependencyChain();
|
||||
* ```
|
||||
*/
|
||||
|
||||
export {
|
||||
createTask,
|
||||
createSubtask,
|
||||
createTasksFile,
|
||||
TaskScenarios,
|
||||
type TasksFile
|
||||
} from './task-fixtures.js';
|
||||
@@ -9,7 +9,7 @@
|
||||
*
|
||||
* USAGE:
|
||||
* ```ts
|
||||
* import { createTask, createTasksFile } from '../fixtures/task-fixtures';
|
||||
* import { createTask, createTasksFile } from '@tm/core/testing';
|
||||
*
|
||||
* // Create a single task with defaults
|
||||
* const task = createTask({ id: 1, title: 'My Task', status: 'pending' });
|
||||
@@ -24,7 +24,7 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { Task, Subtask, TaskMetadata } from '@tm/core';
|
||||
import type { Task, Subtask, TaskMetadata } from '../common/types/index.js';
|
||||
|
||||
/**
|
||||
* File structure for tasks.json
|
||||
@@ -203,6 +203,17 @@ export class TmCore {
|
||||
get projectPath(): string {
|
||||
return this._projectPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close and cleanup resources
|
||||
* Releases file locks and other storage resources
|
||||
* Should be called when done using TmCore, especially in tests
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this._tasks) {
|
||||
await this._tasks.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
export const EXPECTED_TOOL_COUNTS = {
|
||||
core: 7,
|
||||
standard: 14,
|
||||
total: 43
|
||||
total: 44
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user