feat: implement tm list with new refactored structure

This commit is contained in:
Ralph Khreish
2025-08-26 21:10:28 +02:00
parent 3eb88feff1
commit fb44c58a23
13 changed files with 228 additions and 211 deletions

36
.vscode/settings.json vendored
View File

@@ -1,15 +1,27 @@
{
"json.schemas": [
{
"fileMatch": ["src/prompts/*.json"],
"url": "./src/prompts/schemas/prompt-template.schema.json"
}
],
"files.associations": {
"src/prompts/*.json": "json"
},
"json.schemas": [
{
"fileMatch": ["src/prompts/*.json"],
"url": "./src/prompts/schemas/prompt-template.schema.json"
}
],
"files.associations": {
"src/prompts/*.json": "json"
},
"json.format.enable": true,
"json.validate.enable": true,
"typescript.tsdk": "node_modules/typescript/lib"
"json.format.enable": true,
"json.validate.enable": true,
"typescript.tsdk": "node_modules/typescript/lib",
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
}
}

View File

@@ -24,7 +24,7 @@ export interface ListCommandOptions {
status?: string;
tag?: string;
withSubtasks?: boolean;
format?: string;
format?: OutputFormat;
silent?: boolean;
project?: string;
}
@@ -73,6 +73,7 @@ export class ListTasksCommand extends Command {
* Execute the list command
*/
private async executeCommand(options: ListCommandOptions): Promise<void> {
console.log('executeCommand', options);
try {
// Validate options
if (!this.validateOptions(options)) {
@@ -138,8 +139,7 @@ export class ListTasksCommand extends Command {
*/
private async initializeCore(projectRoot: string): Promise<void> {
if (!this.tmCore) {
this.tmCore = createTaskMasterCore(projectRoot);
await this.tmCore.initialize();
this.tmCore = await createTaskMasterCore({ projectPath: projectRoot });
}
}

View File

@@ -266,7 +266,7 @@ export function createTaskTable(
if (showSubtasks && task.subtasks && task.subtasks.length > 0) {
task.subtasks.forEach((subtask) => {
const subRow: string[] = [
chalk.gray(` └─ ${task.id}.${subtask.id}`),
chalk.gray(` └─ ${subtask.id}`),
chalk.gray(truncate(subtask.title, 36)),
getStatusWithColor(subtask.status),
chalk.gray(subtask.priority || 'medium')

View File

@@ -12,7 +12,10 @@
"workspaces": ["apps/*", "packages/*", "."],
"scripts": {
"build": "npm run build:packages && tsup",
"dev": "tsup --watch --onSuccess 'echo Build complete'",
"dev": "npm run build:packages && (npm run dev:packages & tsup --watch --onSuccess 'echo Build complete')",
"dev:packages": "(cd packages/tm-core && npm run dev) & (cd apps/cli && npm run dev) & wait",
"dev:core": "cd packages/tm-core && npm run dev",
"dev:cli": "cd apps/cli && npm run dev",
"build:packages": "npm run build:core && npm run build:cli",
"build:core": "cd packages/tm-core && npm run build",
"build:cli": "cd apps/cli && npm run build",

View File

@@ -8,39 +8,10 @@
"types": "./dist/index.d.ts",
"exports": {
".": {
"development": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./types": {
"types": "./dist/types/index.d.ts",
"import": "./dist/types/index.js",
"require": "./dist/types/index.cjs"
},
"./providers": {
"types": "./dist/providers/index.d.ts",
"import": "./dist/providers/index.js",
"require": "./dist/providers/index.cjs"
},
"./storage": {
"types": "./dist/storage/index.d.ts",
"import": "./dist/storage/index.js",
"require": "./dist/storage/index.cjs"
},
"./parser": {
"types": "./dist/parser/index.d.ts",
"import": "./dist/parser/index.js",
"require": "./dist/parser/index.cjs"
},
"./utils": {
"types": "./dist/utils/index.d.ts",
"import": "./dist/utils/index.js",
"require": "./dist/utils/index.cjs"
},
"./errors": {
"types": "./dist/errors/index.d.ts",
"import": "./dist/errors/index.js",
"require": "./dist/errors/index.cjs"
}
},
"scripts": {

View File

@@ -25,65 +25,80 @@ describe('ConfigManager', () => {
beforeEach(async () => {
vi.clearAllMocks();
// Clear environment variables
Object.keys(process.env).forEach(key => {
Object.keys(process.env).forEach((key) => {
if (key.startsWith('TASKMASTER_')) {
delete process.env[key];
}
});
// Setup default mock behaviors
vi.mocked(ConfigLoader).mockImplementation(() => ({
getDefaultConfig: vi.fn().mockReturnValue({
models: { main: 'default-model', fallback: 'fallback-model' },
storage: { type: 'file' },
version: '1.0.0'
}),
loadLocalConfig: vi.fn().mockResolvedValue(null),
loadGlobalConfig: vi.fn().mockResolvedValue(null),
hasLocalConfig: vi.fn().mockResolvedValue(false),
hasGlobalConfig: vi.fn().mockResolvedValue(false)
} as any));
vi.mocked(ConfigLoader).mockImplementation(
() =>
({
getDefaultConfig: vi.fn().mockReturnValue({
models: { main: 'default-model', fallback: 'fallback-model' },
storage: { type: 'file' },
version: '1.0.0'
}),
loadLocalConfig: vi.fn().mockResolvedValue(null),
loadGlobalConfig: vi.fn().mockResolvedValue(null),
hasLocalConfig: vi.fn().mockResolvedValue(false),
hasGlobalConfig: vi.fn().mockResolvedValue(false)
}) as any
);
vi.mocked(ConfigMerger).mockImplementation(() => ({
addSource: vi.fn(),
clearSources: vi.fn(),
merge: vi.fn().mockReturnValue({
models: { main: 'merged-model', fallback: 'fallback-model' },
storage: { type: 'file' }
}),
getSources: vi.fn().mockReturnValue([]),
hasSource: vi.fn().mockReturnValue(false),
removeSource: vi.fn().mockReturnValue(false)
} as any));
vi.mocked(ConfigMerger).mockImplementation(
() =>
({
addSource: vi.fn(),
clearSources: vi.fn(),
merge: vi.fn().mockReturnValue({
models: { main: 'merged-model', fallback: 'fallback-model' },
storage: { type: 'file' }
}),
getSources: vi.fn().mockReturnValue([]),
hasSource: vi.fn().mockReturnValue(false),
removeSource: vi.fn().mockReturnValue(false)
}) as any
);
vi.mocked(RuntimeStateManager).mockImplementation(() => ({
loadState: vi.fn().mockResolvedValue({ activeTag: 'master' }),
saveState: vi.fn().mockResolvedValue(undefined),
getActiveTag: vi.fn().mockReturnValue('master'),
setActiveTag: vi.fn().mockResolvedValue(undefined),
getState: vi.fn().mockReturnValue({ activeTag: 'master' }),
updateMetadata: vi.fn().mockResolvedValue(undefined),
clearState: vi.fn().mockResolvedValue(undefined)
} as any));
vi.mocked(RuntimeStateManager).mockImplementation(
() =>
({
loadState: vi.fn().mockResolvedValue({ activeTag: 'master' }),
saveState: vi.fn().mockResolvedValue(undefined),
getActiveTag: vi.fn().mockReturnValue('master'),
setActiveTag: vi.fn().mockResolvedValue(undefined),
getState: vi.fn().mockReturnValue({ activeTag: 'master' }),
updateMetadata: vi.fn().mockResolvedValue(undefined),
clearState: vi.fn().mockResolvedValue(undefined)
}) as any
);
vi.mocked(ConfigPersistence).mockImplementation(() => ({
saveConfig: vi.fn().mockResolvedValue(undefined),
configExists: vi.fn().mockResolvedValue(false),
deleteConfig: vi.fn().mockResolvedValue(undefined),
getBackups: vi.fn().mockResolvedValue([]),
restoreFromBackup: vi.fn().mockResolvedValue(undefined)
} as any));
vi.mocked(ConfigPersistence).mockImplementation(
() =>
({
saveConfig: vi.fn().mockResolvedValue(undefined),
configExists: vi.fn().mockResolvedValue(false),
deleteConfig: vi.fn().mockResolvedValue(undefined),
getBackups: vi.fn().mockResolvedValue([]),
restoreFromBackup: vi.fn().mockResolvedValue(undefined)
}) as any
);
vi.mocked(EnvironmentConfigProvider).mockImplementation(() => ({
loadConfig: vi.fn().mockReturnValue({}),
getRuntimeState: vi.fn().mockReturnValue({}),
hasEnvVar: vi.fn().mockReturnValue(false),
getAllTaskmasterEnvVars: vi.fn().mockReturnValue({}),
addMapping: vi.fn(),
getMappings: vi.fn().mockReturnValue([])
} as any));
vi.mocked(EnvironmentConfigProvider).mockImplementation(
() =>
({
loadConfig: vi.fn().mockReturnValue({}),
getRuntimeState: vi.fn().mockReturnValue({}),
hasEnvVar: vi.fn().mockReturnValue(false),
getAllTaskmasterEnvVars: vi.fn().mockReturnValue({}),
addMapping: vi.fn(),
getMappings: vi.fn().mockReturnValue([])
}) as any
);
// Since constructor is private, we need to use the factory method
// But for testing, we'll create a test instance using create()
@@ -167,20 +182,23 @@ describe('ConfigManager', () => {
it('should return API storage configuration when configured', async () => {
// Create a new instance with API storage config
vi.mocked(ConfigMerger).mockImplementationOnce(() => ({
addSource: vi.fn(),
clearSources: vi.fn(),
merge: vi.fn().mockReturnValue({
storage: {
type: 'api',
apiEndpoint: 'https://api.example.com',
apiAccessToken: 'token123'
}
}),
getSources: vi.fn().mockReturnValue([]),
hasSource: vi.fn().mockReturnValue(false),
removeSource: vi.fn().mockReturnValue(false)
} as any));
vi.mocked(ConfigMerger).mockImplementationOnce(
() =>
({
addSource: vi.fn(),
clearSources: vi.fn(),
merge: vi.fn().mockReturnValue({
storage: {
type: 'api',
apiEndpoint: 'https://api.example.com',
apiAccessToken: 'token123'
}
}),
getSources: vi.fn().mockReturnValue([]),
hasSource: vi.fn().mockReturnValue(false),
removeSource: vi.fn().mockReturnValue(false)
}) as any
);
const apiManager = await ConfigManager.create(testProjectRoot);
@@ -271,7 +289,9 @@ describe('ConfigManager', () => {
// Manager is already initialized in the main beforeEach
it('should update configuration and save', async () => {
const updates = { models: { main: 'new-model', fallback: 'fallback-model' } };
const updates = {
models: { main: 'new-model', fallback: 'fallback-model' }
};
await manager.updateConfig(updates);
const persistence = (manager as any).persistence;
@@ -302,10 +322,10 @@ describe('ConfigManager', () => {
await manager.saveConfig();
const persistence = (manager as any).persistence;
expect(persistence.saveConfig).toHaveBeenCalledWith(
expect.any(Object),
{ createBackup: true, atomic: true }
);
expect(persistence.saveConfig).toHaveBeenCalledWith(expect.any(Object), {
createBackup: true,
atomic: true
});
});
});
@@ -347,9 +367,11 @@ describe('ConfigManager', () => {
const unsubscribe = manager.watch(callback);
expect(warnSpy).toHaveBeenCalledWith('Configuration watching not yet implemented');
expect(warnSpy).toHaveBeenCalledWith(
'Configuration watching not yet implemented'
);
expect(unsubscribe).toBeInstanceOf(Function);
// Calling unsubscribe should not throw
expect(() => unsubscribe()).not.toThrow();
@@ -364,7 +386,9 @@ describe('ConfigManager', () => {
loader.loadLocalConfig.mockRejectedValue(new Error('File error'));
// Creating a new manager should not throw even if service fails
await expect(ConfigManager.create(testProjectRoot)).resolves.not.toThrow();
await expect(
ConfigManager.create(testProjectRoot)
).resolves.not.toThrow();
});
});
});
});

View File

@@ -41,7 +41,7 @@ export class ConfigManager {
/**
* Create and initialize a new ConfigManager instance
* This is the ONLY way to create a ConfigManager
*
*
* @param projectRoot - The root directory of the project
* @returns Fully initialized ConfigManager instance
*/

View File

@@ -34,18 +34,25 @@ export class TaskEntity implements Task {
assignee?: string;
complexity?: Task['complexity'];
constructor(data: Task) {
constructor(data: Task | (Omit<Task, 'id'> & { id: number | string })) {
this.validate(data);
this.id = data.id;
// Always convert ID to string
this.id = String(data.id);
this.title = data.title;
this.description = data.description;
this.status = data.status;
this.priority = data.priority;
this.dependencies = data.dependencies || [];
// Ensure dependency IDs are also strings
this.dependencies = (data.dependencies || []).map((dep) => String(dep));
this.details = data.details;
this.testStrategy = data.testStrategy;
this.subtasks = data.subtasks || [];
// Normalize subtask IDs to strings
this.subtasks = (data.subtasks || []).map((subtask) => ({
...subtask,
id: Number(subtask.id), // Keep subtask IDs as numbers per interface
parentId: String(subtask.parentId)
}));
// Optional properties
this.createdAt = data.createdAt;
@@ -60,10 +67,16 @@ export class TaskEntity implements Task {
/**
* Validate task data
*/
private validate(data: Partial<Task>): void {
if (!data.id || typeof data.id !== 'string') {
private validate(
data: Partial<Task> | Partial<Omit<Task, 'id'> & { id: number | string }>
): void {
if (
data.id === undefined ||
data.id === null ||
(typeof data.id !== 'string' && typeof data.id !== 'number')
) {
throw new TaskMasterError(
'Task ID is required and must be a string',
'Task ID is required and must be a string or number',
ERROR_CODES.VALIDATION_ERROR
);
}

View File

@@ -9,19 +9,19 @@ export {
createTaskMasterCore,
type TaskMasterCoreOptions,
type ListTasksResult
} from './task-master-core.js';
} from './task-master-core';
// Re-export types
export type * from './types/index';
export type * from './types';
// Re-export interfaces (types only to avoid conflicts)
export type * from './interfaces/index';
export type * from './interfaces';
// Re-export constants
export * from './constants/index';
export * from './constants';
// Re-export providers
export * from './providers/index';
export * from './providers';
// Re-export storage (selectively to avoid conflicts)
export {
@@ -29,21 +29,17 @@ export {
ApiStorage,
StorageFactory,
type ApiStorageConfig
} from './storage/index';
export { PlaceholderStorage, type StorageAdapter } from './storage/index';
} from './storage';
export { PlaceholderStorage, type StorageAdapter } from './storage';
// Re-export parser
export * from './parser/index';
export * from './parser';
// Re-export utilities
export * from './utils/index';
export * from './utils';
// Re-export errors
export * from './errors/index';
export * from './errors';
// Re-export entities
export { TaskEntity } from './entities/task.entity.js';
// Package metadata
export const version = '1.0.0';
export const name = '@task-master/tm-core';
export { TaskEntity } from './entities/task.entity';

View File

@@ -60,9 +60,6 @@ export class TaskService {
async initialize(): Promise<void> {
if (this.initialized) return;
// Ensure config manager is initialized
await this.configManager.initialize();
// Create storage based on configuration
const storageConfig = this.configManager.getStorageConfig();
const projectRoot = this.configManager.getProjectRoot();
@@ -83,7 +80,7 @@ export class TaskService {
* This is the main method that retrieves tasks from storage and applies filters
*/
async getTaskList(options: GetTaskListOptions = {}): Promise<TaskListResult> {
await this.ensureInitialized();
console.log('getTaskList', options);
// Determine which tag to use
const activeTag = this.configManager.getActiveTag();
@@ -113,13 +110,15 @@ export class TaskService {
}));
}
console.log('tasks', tasks);
return {
tasks,
total: rawTasks.length,
filtered: filteredEntities.length,
tag: options.tag, // Only include tag if explicitly provided
storageType: this.configManager.getStorageConfig().type
} as TaskListResult;
};
} catch (error) {
throw new TaskMasterError(
'Failed to get task list',
@@ -336,15 +335,6 @@ export class TaskService {
});
}
/**
* Ensure service is initialized
*/
private async ensureInitialized(): Promise<void> {
if (!this.initialized) {
await this.initialize();
}
}
/**
* Get current storage type
*/

View File

@@ -127,6 +127,18 @@ export class FileStorage implements IStorage {
// Ensure directory exists
await this.ensureDirectoryExists();
// Normalize task IDs to strings (force string IDs everywhere)
const normalizedTasks = tasks.map(task => ({
...task,
id: String(task.id), // Force ID to string
dependencies: task.dependencies?.map(dep => String(dep)) || [],
subtasks: task.subtasks?.map(subtask => ({
...subtask,
id: String(subtask.id),
parentId: String(subtask.parentId)
})) || []
}));
// Check if we need to use legacy format
let dataToWrite: any;
@@ -140,12 +152,12 @@ export class FileStorage implements IStorage {
) {
dataToWrite = {
[resolvedTag]: {
tasks,
tasks: normalizedTasks,
metadata: {
version: '1.0.0',
lastModified: new Date().toISOString(),
taskCount: tasks.length,
completedCount: tasks.filter((t) => t.status === 'done').length,
taskCount: normalizedTasks.length,
completedCount: normalizedTasks.filter((t) => t.status === 'done').length,
tags: [resolvedTag]
}
}
@@ -153,12 +165,12 @@ export class FileStorage implements IStorage {
} else {
// Use standard format for new files
dataToWrite = {
tasks,
tasks: normalizedTasks,
metadata: {
version: '1.0.0',
lastModified: new Date().toISOString(),
taskCount: tasks.length,
completedCount: tasks.filter((t) => t.status === 'done').length,
taskCount: normalizedTasks.length,
completedCount: normalizedTasks.filter((t) => t.status === 'done').length,
tags: tag ? [tag] : []
}
};
@@ -166,12 +178,12 @@ export class FileStorage implements IStorage {
} catch (error: any) {
// File doesn't exist, use standard format
dataToWrite = {
tasks,
tasks: normalizedTasks,
metadata: {
version: '1.0.0',
lastModified: new Date().toISOString(),
taskCount: tasks.length,
completedCount: tasks.filter((t) => t.status === 'done').length,
taskCount: normalizedTasks.length,
completedCount: normalizedTasks.filter((t) => t.status === 'done').length,
tags: tag ? [tag] : []
}
};

View File

@@ -33,9 +33,35 @@ export type { GetTaskListOptions } from './services/task-service.js';
export class TaskMasterCore {
private configManager: ConfigManager;
private taskService: TaskService;
private initialized = false;
constructor(options: TaskMasterCoreOptions) {
/**
* Create and initialize a new TaskMasterCore instance
* This is the ONLY way to create a TaskMasterCore
*
* @param options - Configuration options for TaskMasterCore
* @returns Fully initialized TaskMasterCore instance
*/
static async create(options: TaskMasterCoreOptions): Promise<TaskMasterCore> {
const instance = new TaskMasterCore();
await instance.initialize(options);
return instance;
}
/**
* Private constructor - use TaskMasterCore.create() instead
* This ensures the TaskMasterCore is always properly initialized
*/
private constructor() {
// Services will be initialized in the initialize() method
this.configManager = null as any;
this.taskService = null as any;
}
/**
* Initialize by loading services
* Private - only called by the factory method
*/
private async initialize(options: TaskMasterCoreOptions): Promise<void> {
if (!options.projectPath) {
throw new TaskMasterError(
'Project path is required',
@@ -43,28 +69,18 @@ export class TaskMasterCore {
);
}
// Create config manager
this.configManager = new ConfigManager(options.projectPath);
// Create task service
this.taskService = new TaskService(this.configManager);
// Apply any provided configuration
if (options.configuration) {
// This will be applied after initialization
}
}
/**
* Initialize the TaskMasterCore instance
*/
async initialize(): Promise<void> {
if (this.initialized) return;
try {
await this.configManager.initialize();
// Create config manager using factory method
this.configManager = await ConfigManager.create(options.projectPath);
// Apply configuration overrides if provided
if (options.configuration) {
await this.configManager.updateConfig(options.configuration);
}
// Create task service
this.taskService = new TaskService(this.configManager);
await this.taskService.initialize();
this.initialized = true;
} catch (error) {
throw new TaskMasterError(
'Failed to initialize TaskMasterCore',
@@ -75,15 +91,6 @@ export class TaskMasterCore {
}
}
/**
* Ensure the instance is initialized
*/
private async ensureInitialized(): Promise<void> {
if (!this.initialized) {
await this.initialize();
}
}
/**
* Get list of tasks with optional filtering
* @deprecated Use getTaskList() instead
@@ -100,7 +107,6 @@ export class TaskMasterCore {
* Get list of tasks with optional filtering
*/
async getTaskList(options?: GetTaskListOptions): Promise<ListTasksResult> {
await this.ensureInitialized();
return this.taskService.getTaskList(options);
}
@@ -108,7 +114,6 @@ export class TaskMasterCore {
* Get a specific task by ID
*/
async getTask(taskId: string, tag?: string): Promise<Task | null> {
await this.ensureInitialized();
return this.taskService.getTask(taskId, tag);
}
@@ -119,7 +124,6 @@ export class TaskMasterCore {
status: TaskStatus | TaskStatus[],
tag?: string
): Promise<Task[]> {
await this.ensureInitialized();
return this.taskService.getTasksByStatus(status, tag);
}
@@ -132,7 +136,6 @@ export class TaskMasterCore {
withSubtasks: number;
blocked: number;
}> {
await this.ensureInitialized();
const stats = await this.taskService.getTaskStats(tag);
// Remove storageType from the return to maintain backward compatibility
const { storageType, ...restStats } = stats;
@@ -143,7 +146,6 @@ export class TaskMasterCore {
* Get next available task
*/
async getNextTask(tag?: string): Promise<Task | null> {
await this.ensureInitialized();
return this.taskService.getNextTask(tag);
}
@@ -173,21 +175,14 @@ export class TaskMasterCore {
*/
async close(): Promise<void> {
// TaskService handles storage cleanup internally
this.initialized = false;
}
}
/**
* Factory function to create TaskMasterCore instance
*/
export function createTaskMasterCore(
projectPath: string,
options?: {
configuration?: Partial<IConfiguration>;
}
): TaskMasterCore {
return new TaskMasterCore({
projectPath,
configuration: options?.configuration
});
export async function createTaskMasterCore(
options: TaskMasterCoreOptions
): Promise<TaskMasterCore> {
return TaskMasterCore.create(options);
}

View File

@@ -11,6 +11,7 @@ export default defineConfig({
sourcemap: true,
clean: true,
shims: true,
dts: true,
bundle: true, // Bundle everything into one file
outDir: 'dist',
// Handle TypeScript imports transparently