feat: create tm-core and apps/cli (#1093)

- add typescript
- add npm workspaces
This commit is contained in:
Ralph Khreish
2025-09-01 21:44:43 +02:00
parent a7ad4c8e92
commit 0f3ab00f26
162 changed files with 22235 additions and 706 deletions

View File

@@ -0,0 +1,724 @@
/**
* @fileoverview API-based storage implementation for Hamster integration
* This provides storage via REST API instead of local file system
*/
import type {
IStorage,
StorageStats
} from '../interfaces/storage.interface.js';
import type { Task, TaskMetadata } from '../types/index.js';
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
/**
* API storage configuration
*/
export interface ApiStorageConfig {
/** API endpoint base URL */
endpoint: string;
/** Access token for authentication */
accessToken: string;
/** Optional project ID */
projectId?: string;
/** Request timeout in milliseconds */
timeout?: number;
/** Enable request retries */
enableRetry?: boolean;
/** Maximum retry attempts */
maxRetries?: number;
}
/**
* API response wrapper
*/
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
/**
* ApiStorage implementation for Hamster integration
* Fetches and stores tasks via REST API
*/
export class ApiStorage implements IStorage {
private readonly config: Required<ApiStorageConfig>;
private initialized = false;
constructor(config: ApiStorageConfig) {
this.validateConfig(config);
this.config = {
endpoint: config.endpoint.replace(/\/$/, ''), // Remove trailing slash
accessToken: config.accessToken,
projectId: config.projectId || 'default',
timeout: config.timeout || 30000,
enableRetry: config.enableRetry ?? true,
maxRetries: config.maxRetries || 3
};
}
/**
* Validate API storage configuration
*/
private validateConfig(config: ApiStorageConfig): void {
if (!config.endpoint) {
throw new TaskMasterError(
'API endpoint is required for API storage',
ERROR_CODES.MISSING_CONFIGURATION
);
}
if (!config.accessToken) {
throw new TaskMasterError(
'Access token is required for API storage',
ERROR_CODES.MISSING_CONFIGURATION
);
}
// Validate endpoint URL format
try {
new URL(config.endpoint);
} catch {
throw new TaskMasterError(
'Invalid API endpoint URL',
ERROR_CODES.INVALID_INPUT,
{ endpoint: config.endpoint }
);
}
}
/**
* Initialize the API storage
*/
async initialize(): Promise<void> {
if (this.initialized) return;
try {
// Verify API connectivity
await this.verifyConnection();
this.initialized = true;
} catch (error) {
throw new TaskMasterError(
'Failed to initialize API storage',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'initialize' },
error as Error
);
}
}
/**
* Verify API connection
*/
private async verifyConnection(): Promise<void> {
const response = await this.makeRequest<{ status: string }>('/health');
if (!response.success) {
throw new Error(`API health check failed: ${response.error}`);
}
}
/**
* Load tasks from API
*/
async loadTasks(tag?: string): Promise<Task[]> {
await this.ensureInitialized();
try {
const endpoint = tag
? `/projects/${this.config.projectId}/tasks?tag=${encodeURIComponent(tag)}`
: `/projects/${this.config.projectId}/tasks`;
const response = await this.makeRequest<{ tasks: Task[] }>(endpoint);
if (!response.success) {
throw new Error(response.error || 'Failed to load tasks');
}
return response.data?.tasks || [];
} catch (error) {
throw new TaskMasterError(
'Failed to load tasks from API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'loadTasks', tag },
error as Error
);
}
}
/**
* Save tasks to API
*/
async saveTasks(tasks: Task[], tag?: string): Promise<void> {
await this.ensureInitialized();
try {
const endpoint = tag
? `/projects/${this.config.projectId}/tasks?tag=${encodeURIComponent(tag)}`
: `/projects/${this.config.projectId}/tasks`;
const response = await this.makeRequest(endpoint, 'PUT', { tasks });
if (!response.success) {
throw new Error(response.error || 'Failed to save tasks');
}
} catch (error) {
throw new TaskMasterError(
'Failed to save tasks to API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'saveTasks', tag, taskCount: tasks.length },
error as Error
);
}
}
/**
* Load a single task by ID
*/
async loadTask(taskId: string, tag?: string): Promise<Task | null> {
await this.ensureInitialized();
try {
const endpoint = tag
? `/projects/${this.config.projectId}/tasks/${taskId}?tag=${encodeURIComponent(tag)}`
: `/projects/${this.config.projectId}/tasks/${taskId}`;
const response = await this.makeRequest<{ task: Task }>(endpoint);
if (!response.success) {
if (response.error?.includes('not found')) {
return null;
}
throw new Error(response.error || 'Failed to load task');
}
return response.data?.task || null;
} catch (error) {
throw new TaskMasterError(
'Failed to load task from API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'loadTask', taskId, tag },
error as Error
);
}
}
/**
* Save a single task
*/
async saveTask(task: Task, tag?: string): Promise<void> {
await this.ensureInitialized();
try {
const endpoint = tag
? `/projects/${this.config.projectId}/tasks/${task.id}?tag=${encodeURIComponent(tag)}`
: `/projects/${this.config.projectId}/tasks/${task.id}`;
const response = await this.makeRequest(endpoint, 'PUT', { task });
if (!response.success) {
throw new Error(response.error || 'Failed to save task');
}
} catch (error) {
throw new TaskMasterError(
'Failed to save task to API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'saveTask', taskId: task.id, tag },
error as Error
);
}
}
/**
* Delete a task
*/
async deleteTask(taskId: string, tag?: string): Promise<void> {
await this.ensureInitialized();
try {
const endpoint = tag
? `/projects/${this.config.projectId}/tasks/${taskId}?tag=${encodeURIComponent(tag)}`
: `/projects/${this.config.projectId}/tasks/${taskId}`;
const response = await this.makeRequest(endpoint, 'DELETE');
if (!response.success) {
throw new Error(response.error || 'Failed to delete task');
}
} catch (error) {
throw new TaskMasterError(
'Failed to delete task from API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'deleteTask', taskId, tag },
error as Error
);
}
}
/**
* List available tags
*/
async listTags(): Promise<string[]> {
await this.ensureInitialized();
try {
const response = await this.makeRequest<{ tags: string[] }>(
`/projects/${this.config.projectId}/tags`
);
if (!response.success) {
throw new Error(response.error || 'Failed to list tags');
}
return response.data?.tags || [];
} catch (error) {
throw new TaskMasterError(
'Failed to list tags from API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'listTags' },
error as Error
);
}
}
/**
* Load metadata
*/
async loadMetadata(tag?: string): Promise<TaskMetadata | null> {
await this.ensureInitialized();
try {
const endpoint = tag
? `/projects/${this.config.projectId}/metadata?tag=${encodeURIComponent(tag)}`
: `/projects/${this.config.projectId}/metadata`;
const response = await this.makeRequest<{ metadata: TaskMetadata }>(
endpoint
);
if (!response.success) {
return null;
}
return response.data?.metadata || null;
} catch (error) {
throw new TaskMasterError(
'Failed to load metadata from API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'loadMetadata', tag },
error as Error
);
}
}
/**
* Save metadata
*/
async saveMetadata(metadata: TaskMetadata, tag?: string): Promise<void> {
await this.ensureInitialized();
try {
const endpoint = tag
? `/projects/${this.config.projectId}/metadata?tag=${encodeURIComponent(tag)}`
: `/projects/${this.config.projectId}/metadata`;
const response = await this.makeRequest(endpoint, 'PUT', { metadata });
if (!response.success) {
throw new Error(response.error || 'Failed to save metadata');
}
} catch (error) {
throw new TaskMasterError(
'Failed to save metadata to API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'saveMetadata', tag },
error as Error
);
}
}
/**
* Check if storage exists
*/
async exists(): Promise<boolean> {
try {
await this.initialize();
return true;
} catch {
return false;
}
}
/**
* Append tasks to existing storage
*/
async appendTasks(tasks: Task[], tag?: string): Promise<void> {
await this.ensureInitialized();
try {
// First load existing tasks
const existingTasks = await this.loadTasks(tag);
// Append new tasks
const allTasks = [...existingTasks, ...tasks];
// Save all tasks
await this.saveTasks(allTasks, tag);
} catch (error) {
throw new TaskMasterError(
'Failed to append tasks to API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'appendTasks', tag, taskCount: tasks.length },
error as Error
);
}
}
/**
* Update a specific task
*/
async updateTask(
taskId: string,
updates: Partial<Task>,
tag?: string
): Promise<void> {
await this.ensureInitialized();
try {
// Load the task
const task = await this.loadTask(taskId, tag);
if (!task) {
throw new Error(`Task ${taskId} not found`);
}
// Merge updates
const updatedTask = { ...task, ...updates, id: taskId };
// Save updated task
await this.saveTask(updatedTask, tag);
} catch (error) {
throw new TaskMasterError(
'Failed to update task via API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'updateTask', taskId, tag },
error as Error
);
}
}
/**
* Get all available tags
*/
async getAllTags(): Promise<string[]> {
return this.listTags();
}
/**
* Delete all tasks for a tag
*/
async deleteTag(tag: string): Promise<void> {
await this.ensureInitialized();
try {
const response = await this.makeRequest(
`/projects/${this.config.projectId}/tags/${encodeURIComponent(tag)}`,
'DELETE'
);
if (!response.success) {
throw new Error(response.error || 'Failed to delete tag');
}
} catch (error) {
throw new TaskMasterError(
'Failed to delete tag via API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'deleteTag', tag },
error as Error
);
}
}
/**
* Rename a tag
*/
async renameTag(oldTag: string, newTag: string): Promise<void> {
await this.ensureInitialized();
try {
const response = await this.makeRequest(
`/projects/${this.config.projectId}/tags/${encodeURIComponent(oldTag)}/rename`,
'POST',
{ newTag }
);
if (!response.success) {
throw new Error(response.error || 'Failed to rename tag');
}
} catch (error) {
throw new TaskMasterError(
'Failed to rename tag via API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'renameTag', oldTag, newTag },
error as Error
);
}
}
/**
* Copy a tag
*/
async copyTag(sourceTag: string, targetTag: string): Promise<void> {
await this.ensureInitialized();
try {
const response = await this.makeRequest(
`/projects/${this.config.projectId}/tags/${encodeURIComponent(sourceTag)}/copy`,
'POST',
{ targetTag }
);
if (!response.success) {
throw new Error(response.error || 'Failed to copy tag');
}
} catch (error) {
throw new TaskMasterError(
'Failed to copy tag via API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'copyTag', sourceTag, targetTag },
error as Error
);
}
}
/**
* Get storage statistics
*/
async getStats(): Promise<StorageStats> {
await this.ensureInitialized();
try {
const response = await this.makeRequest<{
stats: StorageStats;
}>(`/projects/${this.config.projectId}/stats`);
if (!response.success) {
throw new Error(response.error || 'Failed to get stats');
}
// Return stats or default values
return (
response.data?.stats || {
totalTasks: 0,
totalTags: 0,
storageSize: 0,
lastModified: new Date().toISOString(),
tagStats: []
}
);
} catch (error) {
throw new TaskMasterError(
'Failed to get stats from API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'getStats' },
error as Error
);
}
}
/**
* Create backup
*/
async backup(): Promise<string> {
await this.ensureInitialized();
try {
const response = await this.makeRequest<{ backupId: string }>(
`/projects/${this.config.projectId}/backup`,
'POST'
);
if (!response.success) {
throw new Error(response.error || 'Failed to create backup');
}
return response.data?.backupId || 'unknown';
} catch (error) {
throw new TaskMasterError(
'Failed to create backup via API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'backup' },
error as Error
);
}
}
/**
* Restore from backup
*/
async restore(backupPath: string): Promise<void> {
await this.ensureInitialized();
try {
const response = await this.makeRequest(
`/projects/${this.config.projectId}/restore`,
'POST',
{ backupId: backupPath }
);
if (!response.success) {
throw new Error(response.error || 'Failed to restore backup');
}
} catch (error) {
throw new TaskMasterError(
'Failed to restore backup via API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'restore', backupPath },
error as Error
);
}
}
/**
* Clear all data
*/
async clear(): Promise<void> {
await this.ensureInitialized();
try {
const response = await this.makeRequest(
`/projects/${this.config.projectId}/clear`,
'POST'
);
if (!response.success) {
throw new Error(response.error || 'Failed to clear data');
}
} catch (error) {
throw new TaskMasterError(
'Failed to clear data via API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'clear' },
error as Error
);
}
}
/**
* Close connection
*/
async close(): Promise<void> {
this.initialized = false;
}
/**
* Ensure storage is initialized
*/
private async ensureInitialized(): Promise<void> {
if (!this.initialized) {
await this.initialize();
}
}
/**
* Make HTTP request to API
*/
private async makeRequest<T>(
path: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
body?: unknown
): Promise<ApiResponse<T>> {
const url = `${this.config.endpoint}${path}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
try {
const options: RequestInit = {
method,
headers: {
Authorization: `Bearer ${this.config.accessToken}`,
'Content-Type': 'application/json',
Accept: 'application/json'
},
signal: controller.signal
};
if (body && (method === 'POST' || method === 'PUT')) {
options.body = JSON.stringify(body);
}
let lastError: Error | null = null;
let attempt = 0;
while (attempt < this.config.maxRetries) {
attempt++;
try {
const response = await fetch(url, options);
const data = await response.json();
if (response.ok) {
return { success: true, data: data as T };
}
// Handle specific error codes
if (response.status === 401) {
return {
success: false,
error: 'Authentication failed - check access token'
};
}
if (response.status === 404) {
return {
success: false,
error: 'Resource not found'
};
}
if (response.status === 429) {
// Rate limited - retry with backoff
if (this.config.enableRetry && attempt < this.config.maxRetries) {
await this.delay(Math.pow(2, attempt) * 1000);
continue;
}
}
const errorData = data as any;
return {
success: false,
error:
errorData.error ||
errorData.message ||
`HTTP ${response.status}: ${response.statusText}`
};
} catch (error) {
lastError = error as Error;
// Retry on network errors
if (this.config.enableRetry && attempt < this.config.maxRetries) {
await this.delay(Math.pow(2, attempt) * 1000);
continue;
}
}
}
// All retries exhausted
return {
success: false,
error: lastError?.message || 'Request failed after retries'
};
} finally {
clearTimeout(timeoutId);
}
}
/**
* Delay helper for retries
*/
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,170 @@
/**
* @fileoverview File operations with atomic writes and locking
*/
import { promises as fs } from 'node:fs';
import type { FileStorageData } from './format-handler.js';
/**
* Handles atomic file operations with locking mechanism
*/
export class FileOperations {
private fileLocks: Map<string, Promise<void>> = new Map();
/**
* Read and parse JSON file
*/
async readJson(filePath: string): Promise<any> {
try {
const content = await fs.readFile(filePath, 'utf-8');
return JSON.parse(content);
} catch (error: any) {
if (error.code === 'ENOENT') {
throw error; // Re-throw ENOENT for caller to handle
}
if (error instanceof SyntaxError) {
throw new Error(`Invalid JSON in file ${filePath}: ${error.message}`);
}
throw new Error(`Failed to read file ${filePath}: ${error.message}`);
}
}
/**
* Write JSON file with atomic operation and locking
*/
async writeJson(
filePath: string,
data: FileStorageData | any
): Promise<void> {
// Use file locking to prevent concurrent writes
const lockKey = filePath;
const existingLock = this.fileLocks.get(lockKey);
if (existingLock) {
await existingLock;
}
const lockPromise = this.performAtomicWrite(filePath, data);
this.fileLocks.set(lockKey, lockPromise);
try {
await lockPromise;
} finally {
this.fileLocks.delete(lockKey);
}
}
/**
* Perform atomic write operation using temporary file
*/
private async performAtomicWrite(filePath: string, data: any): Promise<void> {
const tempPath = `${filePath}.tmp`;
try {
// Write to temp file first
const content = JSON.stringify(data, null, 2);
await fs.writeFile(tempPath, content, 'utf-8');
// Atomic rename
await fs.rename(tempPath, filePath);
} catch (error: any) {
// Clean up temp file if it exists
try {
await fs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
throw new Error(`Failed to write file ${filePath}: ${error.message}`);
}
}
/**
* Check if file exists
*/
async exists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
/**
* Get file stats
*/
async getStats(filePath: string) {
return fs.stat(filePath);
}
/**
* Read directory contents
*/
async readDir(dirPath: string): Promise<string[]> {
return fs.readdir(dirPath);
}
/**
* Create directory recursively
*/
async ensureDir(dirPath: string): Promise<void> {
try {
await fs.mkdir(dirPath, { recursive: true });
} catch (error: any) {
throw new Error(
`Failed to create directory ${dirPath}: ${error.message}`
);
}
}
/**
* Delete file
*/
async deleteFile(filePath: string): Promise<void> {
try {
await fs.unlink(filePath);
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw new Error(`Failed to delete file ${filePath}: ${error.message}`);
}
}
}
/**
* Rename/move file
*/
async moveFile(oldPath: string, newPath: string): Promise<void> {
try {
await fs.rename(oldPath, newPath);
} catch (error: any) {
throw new Error(
`Failed to move file from ${oldPath} to ${newPath}: ${error.message}`
);
}
}
/**
* Copy file
*/
async copyFile(srcPath: string, destPath: string): Promise<void> {
try {
await fs.copyFile(srcPath, destPath);
} catch (error: any) {
throw new Error(
`Failed to copy file from ${srcPath} to ${destPath}: ${error.message}`
);
}
}
/**
* Clean up all pending file operations
*/
async cleanup(): Promise<void> {
const locks = Array.from(this.fileLocks.values());
if (locks.length > 0) {
await Promise.all(locks);
}
this.fileLocks.clear();
}
}

View File

@@ -0,0 +1,384 @@
/**
* @fileoverview Refactored file-based storage implementation for Task Master
*/
import type { Task, TaskMetadata } from '../../types/index.js';
import type {
IStorage,
StorageStats
} from '../../interfaces/storage.interface.js';
import { FormatHandler } from './format-handler.js';
import { FileOperations } from './file-operations.js';
import { PathResolver } from './path-resolver.js';
/**
* File-based storage implementation using a single tasks.json file with separated concerns
*/
export class FileStorage implements IStorage {
private formatHandler: FormatHandler;
private fileOps: FileOperations;
private pathResolver: PathResolver;
constructor(projectPath: string) {
this.formatHandler = new FormatHandler();
this.fileOps = new FileOperations();
this.pathResolver = new PathResolver(projectPath);
}
/**
* Initialize storage by creating necessary directories
*/
async initialize(): Promise<void> {
await this.fileOps.ensureDir(this.pathResolver.getTasksDir());
}
/**
* Close storage and cleanup resources
*/
async close(): Promise<void> {
await this.fileOps.cleanup();
}
/**
* Get statistics about the storage
*/
async getStats(): Promise<StorageStats> {
const filePath = this.pathResolver.getTasksPath();
try {
const stats = await this.fileOps.getStats(filePath);
const data = await this.fileOps.readJson(filePath);
const tags = this.formatHandler.extractTags(data);
let totalTasks = 0;
const tagStats = tags.map((tag) => {
const tasks = this.formatHandler.extractTasks(data, tag);
const taskCount = tasks.length;
totalTasks += taskCount;
return {
tag,
taskCount,
lastModified: stats.mtime.toISOString()
};
});
return {
totalTasks,
totalTags: tags.length,
lastModified: stats.mtime.toISOString(),
storageSize: 0, // Could calculate actual file sizes if needed
tagStats
};
} catch (error: any) {
if (error.code === 'ENOENT') {
return {
totalTasks: 0,
totalTags: 0,
lastModified: new Date().toISOString(),
storageSize: 0,
tagStats: []
};
}
throw new Error(`Failed to get storage stats: ${error.message}`);
}
}
/**
* Load tasks from the single tasks.json file for a specific tag
*/
async loadTasks(tag?: string): Promise<Task[]> {
const filePath = this.pathResolver.getTasksPath();
const resolvedTag = tag || 'master';
try {
const rawData = await this.fileOps.readJson(filePath);
return this.formatHandler.extractTasks(rawData, resolvedTag);
} catch (error: any) {
if (error.code === 'ENOENT') {
return []; // File doesn't exist, return empty array
}
throw new Error(`Failed to load tasks: ${error.message}`);
}
}
/**
* Save tasks for a specific tag in the single tasks.json file
*/
async saveTasks(tasks: Task[], tag?: string): Promise<void> {
const filePath = this.pathResolver.getTasksPath();
const resolvedTag = tag || 'master';
// Ensure directory exists
await this.fileOps.ensureDir(this.pathResolver.getTasksDir());
// Get existing data from the file
let existingData: any = {};
try {
existingData = await this.fileOps.readJson(filePath);
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw new Error(`Failed to read existing tasks: ${error.message}`);
}
// File doesn't exist, start with empty data
}
// Create metadata for this tag
const metadata: TaskMetadata = {
version: '1.0.0',
lastModified: new Date().toISOString(),
taskCount: tasks.length,
completedCount: tasks.filter((t) => t.status === 'done').length,
tags: [resolvedTag]
};
// Normalize tasks
const normalizedTasks = this.normalizeTaskIds(tasks);
// Update the specific tag in the existing data structure
if (
this.formatHandler.detectFormat(existingData) === 'legacy' ||
Object.keys(existingData).some(
(key) => key !== 'tasks' && key !== 'metadata'
)
) {
// Legacy format - update/add the tag
existingData[resolvedTag] = {
tasks: normalizedTasks,
metadata
};
} else if (resolvedTag === 'master') {
// Standard format for master tag
existingData = {
tasks: normalizedTasks,
metadata
};
} else {
// Convert to legacy format when adding non-master tags
const masterTasks = existingData.tasks || [];
const masterMetadata = existingData.metadata || metadata;
existingData = {
master: {
tasks: masterTasks,
metadata: masterMetadata
},
[resolvedTag]: {
tasks: normalizedTasks,
metadata
}
};
}
// Write the updated file
await this.fileOps.writeJson(filePath, existingData);
}
/**
* Normalize task IDs - keep Task IDs as strings, Subtask IDs as numbers
*/
private normalizeTaskIds(tasks: Task[]): Task[] {
return tasks.map((task) => ({
...task,
id: String(task.id), // Task IDs are strings
dependencies: task.dependencies?.map((dep) => String(dep)) || [],
subtasks:
task.subtasks?.map((subtask) => ({
...subtask,
id: Number(subtask.id), // Subtask IDs are numbers
parentId: String(subtask.parentId) // Parent ID is string (Task ID)
})) || []
}));
}
/**
* Check if the tasks file exists
*/
async exists(_tag?: string): Promise<boolean> {
const filePath = this.pathResolver.getTasksPath();
return this.fileOps.exists(filePath);
}
/**
* Get all available tags from the single tasks.json file
*/
async getAllTags(): Promise<string[]> {
try {
const filePath = this.pathResolver.getTasksPath();
const data = await this.fileOps.readJson(filePath);
return this.formatHandler.extractTags(data);
} catch (error: any) {
if (error.code === 'ENOENT') {
return []; // File doesn't exist
}
throw new Error(`Failed to get tags: ${error.message}`);
}
}
/**
* Load metadata from the single tasks.json file for a specific tag
*/
async loadMetadata(tag?: string): Promise<TaskMetadata | null> {
const filePath = this.pathResolver.getTasksPath();
const resolvedTag = tag || 'master';
try {
const rawData = await this.fileOps.readJson(filePath);
return this.formatHandler.extractMetadata(rawData, resolvedTag);
} catch (error: any) {
if (error.code === 'ENOENT') {
return null;
}
throw new Error(`Failed to load metadata: ${error.message}`);
}
}
/**
* Save metadata (stored with tasks)
*/
async saveMetadata(_metadata: TaskMetadata, tag?: string): Promise<void> {
const tasks = await this.loadTasks(tag);
await this.saveTasks(tasks, tag);
}
/**
* Append tasks to existing storage
*/
async appendTasks(tasks: Task[], tag?: string): Promise<void> {
const existingTasks = await this.loadTasks(tag);
const allTasks = [...existingTasks, ...tasks];
await this.saveTasks(allTasks, tag);
}
/**
* Update a specific task
*/
async updateTask(
taskId: string,
updates: Partial<Task>,
tag?: string
): Promise<void> {
const tasks = await this.loadTasks(tag);
const taskIndex = tasks.findIndex((t) => t.id === taskId.toString());
if (taskIndex === -1) {
throw new Error(`Task ${taskId} not found`);
}
tasks[taskIndex] = {
...tasks[taskIndex],
...updates,
id: taskId.toString()
};
await this.saveTasks(tasks, tag);
}
/**
* Delete a task
*/
async deleteTask(taskId: string, tag?: string): Promise<void> {
const tasks = await this.loadTasks(tag);
const filteredTasks = tasks.filter((t) => t.id !== taskId);
if (filteredTasks.length === tasks.length) {
throw new Error(`Task ${taskId} not found`);
}
await this.saveTasks(filteredTasks, tag);
}
/**
* Delete a tag from the single tasks.json file
*/
async deleteTag(tag: string): Promise<void> {
const filePath = this.pathResolver.getTasksPath();
try {
const existingData = await this.fileOps.readJson(filePath);
if (this.formatHandler.detectFormat(existingData) === 'legacy') {
// Legacy format - remove the tag key
if (tag in existingData) {
delete existingData[tag];
await this.fileOps.writeJson(filePath, existingData);
} else {
throw new Error(`Tag ${tag} not found`);
}
} else if (tag === 'master') {
// Standard format - delete the entire file for master tag
await this.fileOps.deleteFile(filePath);
} else {
throw new Error(`Tag ${tag} not found in standard format`);
}
} catch (error: any) {
if (error.code === 'ENOENT') {
throw new Error(`Tag ${tag} not found - file doesn't exist`);
}
throw error;
}
}
/**
* Rename a tag within the single tasks.json file
*/
async renameTag(oldTag: string, newTag: string): Promise<void> {
const filePath = this.pathResolver.getTasksPath();
try {
const existingData = await this.fileOps.readJson(filePath);
if (this.formatHandler.detectFormat(existingData) === 'legacy') {
// Legacy format - rename the tag key
if (oldTag in existingData) {
existingData[newTag] = existingData[oldTag];
delete existingData[oldTag];
// Update metadata tags array
if (existingData[newTag].metadata) {
existingData[newTag].metadata.tags = [newTag];
}
await this.fileOps.writeJson(filePath, existingData);
} else {
throw new Error(`Tag ${oldTag} not found`);
}
} else if (oldTag === 'master') {
// Convert standard format to legacy when renaming master
const masterTasks = existingData.tasks || [];
const masterMetadata = existingData.metadata || {};
const newData = {
[newTag]: {
tasks: masterTasks,
metadata: { ...masterMetadata, tags: [newTag] }
}
};
await this.fileOps.writeJson(filePath, newData);
} else {
throw new Error(`Tag ${oldTag} not found in standard format`);
}
} catch (error: any) {
if (error.code === 'ENOENT') {
throw new Error(`Tag ${oldTag} not found - file doesn't exist`);
}
throw error;
}
}
/**
* Copy a tag within the single tasks.json file
*/
async copyTag(sourceTag: string, targetTag: string): Promise<void> {
const tasks = await this.loadTasks(sourceTag);
if (tasks.length === 0) {
throw new Error(`Source tag ${sourceTag} not found or has no tasks`);
}
await this.saveTasks(tasks, targetTag);
}
}
// Export as default for convenience
export default FileStorage;

View File

@@ -0,0 +1,248 @@
/**
* @fileoverview Format handler for task storage files
*/
import type { Task, TaskMetadata } from '../../types/index.js';
export interface FileStorageData {
tasks: Task[];
metadata: TaskMetadata;
}
export type FileFormat = 'legacy' | 'standard';
/**
* Handles format detection and conversion between legacy and standard task file formats
*/
export class FormatHandler {
/**
* Detect the format of the raw data
*/
detectFormat(data: any): FileFormat {
if (!data || typeof data !== 'object') {
return 'standard';
}
const keys = Object.keys(data);
// Check if this uses the legacy format with tag keys
// Legacy format has keys that are not 'tasks' or 'metadata'
const hasLegacyFormat = keys.some(
(key) => key !== 'tasks' && key !== 'metadata'
);
return hasLegacyFormat ? 'legacy' : 'standard';
}
/**
* Extract tasks from data for a specific tag
*/
extractTasks(data: any, tag: string): Task[] {
if (!data) {
return [];
}
const format = this.detectFormat(data);
if (format === 'legacy') {
return this.extractTasksFromLegacy(data, tag);
}
return this.extractTasksFromStandard(data);
}
/**
* Extract tasks from legacy format
*/
private extractTasksFromLegacy(data: any, tag: string): Task[] {
// First check if the requested tag exists
if (tag in data) {
const tagData = data[tag];
return tagData?.tasks || [];
}
// If we're looking for 'master' tag but it doesn't exist, try the first available tag
const availableKeys = Object.keys(data).filter(
(key) => key !== 'tasks' && key !== 'metadata'
);
if (tag === 'master' && availableKeys.length > 0) {
const firstTag = availableKeys[0];
const tagData = data[firstTag];
return tagData?.tasks || [];
}
return [];
}
/**
* Extract tasks from standard format
*/
private extractTasksFromStandard(data: any): Task[] {
return data?.tasks || [];
}
/**
* Extract metadata from data for a specific tag
*/
extractMetadata(data: any, tag: string): TaskMetadata | null {
if (!data) {
return null;
}
const format = this.detectFormat(data);
if (format === 'legacy') {
return this.extractMetadataFromLegacy(data, tag);
}
return this.extractMetadataFromStandard(data);
}
/**
* Extract metadata from legacy format
*/
private extractMetadataFromLegacy(
data: any,
tag: string
): TaskMetadata | null {
if (tag in data) {
const tagData = data[tag];
// Generate metadata if not present in legacy format
if (!tagData?.metadata && tagData?.tasks) {
return this.generateMetadataFromTasks(tagData.tasks, tag);
}
return tagData?.metadata || null;
}
// If we're looking for 'master' tag but it doesn't exist, try the first available tag
const availableKeys = Object.keys(data).filter(
(key) => key !== 'tasks' && key !== 'metadata'
);
if (tag === 'master' && availableKeys.length > 0) {
const firstTag = availableKeys[0];
const tagData = data[firstTag];
if (!tagData?.metadata && tagData?.tasks) {
return this.generateMetadataFromTasks(tagData.tasks, firstTag);
}
return tagData?.metadata || null;
}
return null;
}
/**
* Extract metadata from standard format
*/
private extractMetadataFromStandard(data: any): TaskMetadata | null {
return data?.metadata || null;
}
/**
* Extract all available tags from the single tasks.json file
*/
extractTags(data: any): string[] {
if (!data) {
return [];
}
const format = this.detectFormat(data);
if (format === 'legacy') {
// Return all tag keys from legacy format
const keys = Object.keys(data);
return keys.filter((key) => key !== 'tasks' && key !== 'metadata');
}
// Standard format - just has 'master' tag
return ['master'];
}
/**
* Convert tasks and metadata to the appropriate format for saving
*/
convertToSaveFormat(
tasks: Task[],
metadata: TaskMetadata,
existingData: any,
tag: string
): any {
const resolvedTag = tag || 'master';
// Normalize task IDs to strings
const normalizedTasks = this.normalizeTasks(tasks);
// Check if existing file uses legacy format
if (existingData && this.detectFormat(existingData) === 'legacy') {
return this.convertToLegacyFormat(normalizedTasks, metadata, resolvedTag);
}
// Use standard format for new files
return this.convertToStandardFormat(normalizedTasks, metadata, tag);
}
/**
* Convert to legacy format
*/
private convertToLegacyFormat(
tasks: Task[],
metadata: TaskMetadata,
tag: string
): any {
return {
[tag]: {
tasks,
metadata: {
...metadata,
tags: [tag]
}
}
};
}
/**
* Convert to standard format
*/
private convertToStandardFormat(
tasks: Task[],
metadata: TaskMetadata,
tag?: string
): FileStorageData {
return {
tasks,
metadata: {
...metadata,
tags: tag ? [tag] : []
}
};
}
/**
* Normalize task IDs - keep Task IDs as strings, Subtask IDs as numbers
*/
private normalizeTasks(tasks: Task[]): Task[] {
return tasks.map((task) => ({
...task,
id: String(task.id), // Task IDs are strings
dependencies: task.dependencies?.map((dep) => String(dep)) || [],
subtasks:
task.subtasks?.map((subtask) => ({
...subtask,
id: Number(subtask.id), // Subtask IDs are numbers
parentId: String(subtask.parentId) // Parent ID is string (Task ID)
})) || []
}));
}
/**
* Generate metadata from tasks when not present
*/
private generateMetadataFromTasks(tasks: Task[], tag: string): TaskMetadata {
return {
version: '1.0.0',
lastModified: new Date().toISOString(),
taskCount: tasks.length,
completedCount: tasks.filter((t: any) => t.status === 'done').length,
tags: [tag]
};
}
}

View File

@@ -0,0 +1,14 @@
/**
* @fileoverview Exports for file storage components
*/
export {
FormatHandler,
type FileStorageData,
type FileFormat
} from './format-handler.js';
export { FileOperations } from './file-operations.js';
export { PathResolver } from './path-resolver.js';
// Main FileStorage class - primary export
export { FileStorage as default, FileStorage } from './file-storage.js';

View File

@@ -0,0 +1,42 @@
/**
* @fileoverview Path resolution utilities for single tasks.json file
*/
import path from 'node:path';
/**
* Handles path resolution for the single tasks.json file storage
*/
export class PathResolver {
private readonly basePath: string;
private readonly tasksDir: string;
private readonly tasksFilePath: string;
constructor(projectPath: string) {
this.basePath = path.join(projectPath, '.taskmaster');
this.tasksDir = path.join(this.basePath, 'tasks');
this.tasksFilePath = path.join(this.tasksDir, 'tasks.json');
}
/**
* Get the base storage directory path
*/
getBasePath(): string {
return this.basePath;
}
/**
* Get the tasks directory path
*/
getTasksDir(): string {
return this.tasksDir;
}
/**
* Get the path to the single tasks.json file
* All tags are stored in this one file
*/
getTasksPath(): string {
return this.tasksFilePath;
}
}

View File

@@ -0,0 +1,46 @@
/**
* @fileoverview Storage layer for the tm-core package
* This file exports all storage-related classes and interfaces
*/
// Export storage implementations
export { FileStorage } from './file-storage/index.js';
export { ApiStorage, type ApiStorageConfig } from './api-storage.js';
export { StorageFactory } from './storage-factory.js';
// Export storage interface and types
export type {
IStorage,
StorageStats
} from '../interfaces/storage.interface.js';
// Placeholder exports - these will be implemented in later tasks
export interface StorageAdapter {
read(path: string): Promise<string | null>;
write(path: string, data: string): Promise<void>;
exists(path: string): Promise<boolean>;
delete(path: string): Promise<void>;
}
/**
* @deprecated This is a placeholder class that will be properly implemented in later tasks
*/
export class PlaceholderStorage implements StorageAdapter {
private data = new Map<string, string>();
async read(path: string): Promise<string | null> {
return this.data.get(path) || null;
}
async write(path: string, data: string): Promise<void> {
this.data.set(path, data);
}
async exists(path: string): Promise<boolean> {
return this.data.has(path);
}
async delete(path: string): Promise<void> {
this.data.delete(path);
}
}

View File

@@ -0,0 +1,170 @@
/**
* @fileoverview Storage factory for creating appropriate storage implementations
*/
import type { IStorage } from '../interfaces/storage.interface.js';
import type { IConfiguration } from '../interfaces/configuration.interface.js';
import { FileStorage } from './file-storage';
import { ApiStorage } from './api-storage.js';
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
/**
* Factory for creating storage implementations based on configuration
*/
export class StorageFactory {
/**
* Create a storage implementation based on configuration
* @param config - Configuration object
* @param projectPath - Project root path (for file storage)
* @returns Storage implementation
*/
static create(
config: Partial<IConfiguration>,
projectPath: string
): IStorage {
const storageType = config.storage?.type || 'file';
switch (storageType) {
case 'file':
return StorageFactory.createFileStorage(projectPath, config);
case 'api':
return StorageFactory.createApiStorage(config);
default:
throw new TaskMasterError(
`Unknown storage type: ${storageType}`,
ERROR_CODES.INVALID_INPUT,
{ storageType }
);
}
}
/**
* Create file storage implementation
*/
private static createFileStorage(
projectPath: string,
config: Partial<IConfiguration>
): FileStorage {
const basePath = config.storage?.basePath || projectPath;
return new FileStorage(basePath);
}
/**
* Create API storage implementation
*/
private static createApiStorage(config: Partial<IConfiguration>): ApiStorage {
const { apiEndpoint, apiAccessToken } = config.storage || {};
if (!apiEndpoint) {
throw new TaskMasterError(
'API endpoint is required for API storage',
ERROR_CODES.MISSING_CONFIGURATION,
{ storageType: 'api' }
);
}
if (!apiAccessToken) {
throw new TaskMasterError(
'API access token is required for API storage',
ERROR_CODES.MISSING_CONFIGURATION,
{ storageType: 'api' }
);
}
return new ApiStorage({
endpoint: apiEndpoint,
accessToken: apiAccessToken,
projectId: config.projectPath,
timeout: config.retry?.requestTimeout,
enableRetry: config.retry?.retryOnNetworkError,
maxRetries: config.retry?.retryAttempts
});
}
/**
* Detect optimal storage type based on available configuration
*/
static detectOptimalStorage(config: Partial<IConfiguration>): 'file' | 'api' {
// If API credentials are provided, prefer API storage (Hamster)
if (config.storage?.apiEndpoint && config.storage?.apiAccessToken) {
return 'api';
}
// Default to file storage
return 'file';
}
/**
* Validate storage configuration
*/
static validateStorageConfig(config: Partial<IConfiguration>): {
isValid: boolean;
errors: string[];
} {
const errors: string[] = [];
const storageType = config.storage?.type;
if (!storageType) {
errors.push('Storage type is not specified');
return { isValid: false, errors };
}
switch (storageType) {
case 'api':
if (!config.storage?.apiEndpoint) {
errors.push('API endpoint is required for API storage');
}
if (!config.storage?.apiAccessToken) {
errors.push('API access token is required for API storage');
}
break;
case 'file':
// File storage doesn't require additional config
break;
default:
errors.push(`Unknown storage type: ${storageType}`);
}
return {
isValid: errors.length === 0,
errors
};
}
/**
* Check if Hamster (API storage) is available
*/
static isHamsterAvailable(config: Partial<IConfiguration>): boolean {
return !!(config.storage?.apiEndpoint && config.storage?.apiAccessToken);
}
/**
* Create a storage implementation with fallback
* Tries API storage first, falls back to file storage
*/
static async createWithFallback(
config: Partial<IConfiguration>,
projectPath: string
): Promise<IStorage> {
// Try API storage if configured
if (StorageFactory.isHamsterAvailable(config)) {
try {
const apiStorage = StorageFactory.createApiStorage(config);
await apiStorage.initialize();
return apiStorage;
} catch (error) {
console.warn(
'Failed to initialize API storage, falling back to file storage:',
error
);
}
}
// Fallback to file storage
return StorageFactory.createFileStorage(projectPath, config);
}
}