feat: create tm-core and apps/cli (#1093)
- add typescript - add npm workspaces
This commit is contained in:
724
packages/tm-core/src/storage/api-storage.ts
Normal file
724
packages/tm-core/src/storage/api-storage.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
170
packages/tm-core/src/storage/file-storage/file-operations.ts
Normal file
170
packages/tm-core/src/storage/file-storage/file-operations.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
384
packages/tm-core/src/storage/file-storage/file-storage.ts
Normal file
384
packages/tm-core/src/storage/file-storage/file-storage.ts
Normal 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;
|
||||
248
packages/tm-core/src/storage/file-storage/format-handler.ts
Normal file
248
packages/tm-core/src/storage/file-storage/format-handler.ts
Normal 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]
|
||||
};
|
||||
}
|
||||
}
|
||||
14
packages/tm-core/src/storage/file-storage/index.ts
Normal file
14
packages/tm-core/src/storage/file-storage/index.ts
Normal 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';
|
||||
42
packages/tm-core/src/storage/file-storage/path-resolver.ts
Normal file
42
packages/tm-core/src/storage/file-storage/path-resolver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
46
packages/tm-core/src/storage/index.ts
Normal file
46
packages/tm-core/src/storage/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
170
packages/tm-core/src/storage/storage-factory.ts
Normal file
170
packages/tm-core/src/storage/storage-factory.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user