feat: create tm-core and apps/cli (#1093)
- add typescript - add npm workspaces
This commit is contained in:
83
packages/tm-core/.gitignore
vendored
Normal file
83
packages/tm-core/.gitignore
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
*.pnp
|
||||
.pnp.js
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Coverage reports
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Dependency directories
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
70
packages/tm-core/CHANGELOG.md
Normal file
70
packages/tm-core/CHANGELOG.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the @task-master/tm-core package will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Initial package structure and configuration
|
||||
- TypeScript support with strict mode
|
||||
- Dual ESM/CJS build system with tsup
|
||||
- Jest testing framework with TypeScript support
|
||||
- ESLint and Prettier for code quality
|
||||
- Modular architecture with barrel exports
|
||||
- Placeholder implementations for all modules
|
||||
- Comprehensive documentation and README
|
||||
|
||||
### Development Infrastructure
|
||||
- tsup configuration for dual format builds
|
||||
- Jest configuration with ESM support
|
||||
- ESLint configuration with TypeScript rules
|
||||
- Prettier configuration for consistent formatting
|
||||
- Complete package.json with all required fields
|
||||
- TypeScript configuration with strict settings
|
||||
- .gitignore for development files
|
||||
|
||||
### Package Structure
|
||||
- `src/types/` - TypeScript type definitions (placeholder)
|
||||
- `src/providers/` - AI provider implementations (placeholder)
|
||||
- `src/storage/` - Storage layer abstractions (placeholder)
|
||||
- `src/parser/` - Task parsing utilities (placeholder)
|
||||
- `src/utils/` - Common utility functions (placeholder)
|
||||
- `src/errors/` - Custom error classes (placeholder)
|
||||
- `tests/` - Test directories and setup
|
||||
|
||||
## [1.0.0] - TBD
|
||||
|
||||
### Planned Features
|
||||
- Complete TypeScript type system
|
||||
- AI provider implementations
|
||||
- Storage adapters
|
||||
- Task parsing capabilities
|
||||
- Comprehensive utility functions
|
||||
- Custom error handling
|
||||
- Full test coverage
|
||||
- Complete documentation
|
||||
|
||||
---
|
||||
|
||||
## Release Notes
|
||||
|
||||
### Version 1.0.0 (Coming Soon)
|
||||
This will be the first stable release of tm-core with complete implementations of all modules. Currently, all modules contain placeholder implementations to establish the package structure and enable development of dependent packages.
|
||||
|
||||
### Development Status
|
||||
- ✅ Package structure and configuration
|
||||
- ✅ Build and test infrastructure
|
||||
- ✅ Development tooling setup
|
||||
- 🚧 TypeScript types implementation (Task 116)
|
||||
- 🚧 AI provider system (Task 117)
|
||||
- 🚧 Storage layer (Task 118)
|
||||
- 🚧 Task parser (Task 119)
|
||||
- 🚧 Utility functions (Task 120)
|
||||
- 🚧 Error handling (Task 121)
|
||||
- 🚧 Configuration system (Task 122)
|
||||
- 🚧 Testing infrastructure (Task 123)
|
||||
- 🚧 Documentation (Task 124)
|
||||
- 🚧 Package finalization (Task 125)
|
||||
194
packages/tm-core/POC-STATUS.md
Normal file
194
packages/tm-core/POC-STATUS.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# GetTaskList POC Status
|
||||
|
||||
## ✅ What We've Accomplished
|
||||
|
||||
We've successfully implemented a complete end-to-end proof of concept for the `getTaskList` functionality with improved separation of concerns:
|
||||
|
||||
### 1. Clean Architecture Layers with Proper Separation
|
||||
|
||||
#### Configuration Layer (ConfigManager)
|
||||
- Single source of truth for configuration
|
||||
- Manages active tag and storage settings
|
||||
- Handles config.json persistence
|
||||
- Determines storage type (file vs API)
|
||||
|
||||
#### Service Layer (TaskService)
|
||||
- Core business logic and operations
|
||||
- `getTaskList()` method that coordinates between ConfigManager and Storage
|
||||
- Handles all filtering and task processing
|
||||
- Manages storage lifecycle
|
||||
|
||||
#### Facade Layer (TaskMasterCore)
|
||||
- Simplified API for consumers
|
||||
- Delegates to TaskService for operations
|
||||
- Backwards compatible `listTasks()` method
|
||||
- New `getTaskList()` method (preferred naming)
|
||||
|
||||
#### Domain Layer (Entities)
|
||||
- `TaskEntity` with business logic
|
||||
- Validation and status transitions
|
||||
- Dependency checking (`canComplete()`)
|
||||
|
||||
#### Infrastructure Layer (Storage)
|
||||
- `IStorage` interface for abstraction
|
||||
- `FileStorage` for local files (handles 'master' tag correctly)
|
||||
- `ApiStorage` for Hamster integration
|
||||
- `StorageFactory` for automatic selection
|
||||
- **NO business logic** - only persistence
|
||||
|
||||
### 2. Storage Abstraction Benefits
|
||||
|
||||
```typescript
|
||||
// Same API works with different backends
|
||||
const fileCore = createTaskMasterCore(path, {
|
||||
storage: { type: 'file' }
|
||||
});
|
||||
|
||||
const apiCore = createTaskMasterCore(path, {
|
||||
storage: {
|
||||
type: 'api',
|
||||
apiEndpoint: 'https://hamster.ai',
|
||||
apiAccessToken: 'xxx'
|
||||
}
|
||||
});
|
||||
|
||||
// Identical usage
|
||||
const result = await core.listTasks({
|
||||
filter: { status: 'pending' }
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Type Safety Throughout
|
||||
|
||||
- Full TypeScript implementation
|
||||
- Comprehensive interfaces
|
||||
- Type-safe filters and options
|
||||
- Proper error types
|
||||
|
||||
### 4. Testing Coverage
|
||||
|
||||
- 50 tests passing
|
||||
- Unit tests for core components
|
||||
- Integration tests for listTasks
|
||||
- Mock implementations for testing
|
||||
|
||||
## 📊 Architecture Validation
|
||||
|
||||
### ✅ Separation of Concerns
|
||||
- **CLI** handles UI/formatting only
|
||||
- **tm-core** handles business logic
|
||||
- **Storage** handles persistence
|
||||
- Each layer is independently testable
|
||||
|
||||
### ✅ Extensibility
|
||||
- Easy to add new storage types (database, S3, etc.)
|
||||
- New filters can be added to `TaskFilter`
|
||||
- AI providers follow same pattern (BaseProvider)
|
||||
|
||||
### ✅ Error Handling
|
||||
- Consistent `TaskMasterError` with codes
|
||||
- Context preservation
|
||||
- User-friendly messages
|
||||
|
||||
### ✅ Performance Considerations
|
||||
- File locking for concurrent access
|
||||
- Atomic writes with temp files
|
||||
- Retry logic with exponential backoff
|
||||
- Request timeout handling
|
||||
|
||||
## 🔄 Integration Path
|
||||
|
||||
### Current CLI Structure
|
||||
```javascript
|
||||
// scripts/modules/task-manager/list-tasks.js
|
||||
listTasks(tasksPath, statusFilter, reportPath, withSubtasks, outputFormat, context)
|
||||
// Directly reads files, handles all logic
|
||||
```
|
||||
|
||||
### New Integration Structure
|
||||
```javascript
|
||||
// Using tm-core with proper separation of concerns
|
||||
const tmCore = createTaskMasterCore(projectPath, config);
|
||||
const result = await tmCore.getTaskList(options);
|
||||
// CLI only handles formatting result for display
|
||||
|
||||
// Under the hood:
|
||||
// 1. ConfigManager determines active tag and storage type
|
||||
// 2. TaskService uses storage to fetch tasks for the tag
|
||||
// 3. TaskService applies business logic and filters
|
||||
// 4. Storage only handles reading/writing - no business logic
|
||||
```
|
||||
|
||||
## 📈 Metrics
|
||||
|
||||
### Code Quality
|
||||
- **Clean Code**: Methods under 40 lines ✅
|
||||
- **Single Responsibility**: Each class has one purpose ✅
|
||||
- **DRY**: No code duplication ✅
|
||||
- **Type Coverage**: 100% TypeScript ✅
|
||||
|
||||
### Test Coverage
|
||||
- **Unit Tests**: BaseProvider, TaskEntity ✅
|
||||
- **Integration Tests**: Full listTasks flow ✅
|
||||
- **Storage Tests**: File and API operations ✅
|
||||
|
||||
## 🎯 POC Success Criteria
|
||||
|
||||
| Criteria | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| Clean architecture | ✅ | Clear layer separation |
|
||||
| Storage abstraction | ✅ | File + API storage working |
|
||||
| Type safety | ✅ | Full TypeScript |
|
||||
| Error handling | ✅ | Comprehensive error system |
|
||||
| Testing | ✅ | 50 tests passing |
|
||||
| Performance | ✅ | Optimized with caching, batching |
|
||||
| Documentation | ✅ | Architecture docs created |
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
### Immediate (Complete ListTasks Integration)
|
||||
1. Create npm script to test integration example
|
||||
2. Add mock Hamster API for testing
|
||||
3. Create migration guide for CLI
|
||||
|
||||
### Phase 1 Remaining Work
|
||||
Based on this POC success, implement remaining operations:
|
||||
- `addTask()` - Add new tasks
|
||||
- `updateTask()` - Update existing tasks
|
||||
- `deleteTask()` - Remove tasks
|
||||
- `expandTask()` - Break into subtasks
|
||||
- Tag management operations
|
||||
|
||||
### Phase 2 (AI Integration)
|
||||
- Complete AI provider implementations
|
||||
- Task generation from PRD
|
||||
- Task complexity analysis
|
||||
- Auto-expansion of tasks
|
||||
|
||||
## 💡 Lessons Learned
|
||||
|
||||
### What Worked Well
|
||||
1. **Separation of Concerns** - ConfigManager, TaskService, and Storage have clear responsibilities
|
||||
2. **Storage Factory Pattern** - Clean abstraction for multiple backends
|
||||
3. **Entity Pattern** - Business logic encapsulation
|
||||
4. **Template Method Pattern** - BaseProvider for AI providers
|
||||
5. **Comprehensive Error Handling** - TaskMasterError with context
|
||||
|
||||
### Improvements Made
|
||||
1. Migrated from Jest to Vitest (faster)
|
||||
2. Replaced ESLint/Prettier with Biome (unified tooling)
|
||||
3. Fixed conflicting interface definitions
|
||||
4. Added proper TypeScript exports
|
||||
5. **Better Architecture** - Separated configuration, business logic, and persistence
|
||||
6. **Proper Tag Handling** - 'master' tag maps correctly to tasks.json
|
||||
7. **Clean Storage Layer** - Removed business logic from storage
|
||||
|
||||
## ✨ Conclusion
|
||||
|
||||
The ListTasks POC successfully validates our architecture. The structure is:
|
||||
- **Clean and maintainable**
|
||||
- **Properly abstracted**
|
||||
- **Well-tested**
|
||||
- **Ready for extension**
|
||||
|
||||
We can confidently proceed with implementing the remaining functionality following this same pattern.
|
||||
226
packages/tm-core/README.md
Normal file
226
packages/tm-core/README.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# @task-master/tm-core
|
||||
|
||||
Core library for Task Master AI - providing task management and orchestration capabilities with TypeScript support.
|
||||
|
||||
## Overview
|
||||
|
||||
`tm-core` is the foundational library that powers Task Master AI's task management system. It provides a comprehensive set of tools for creating, managing, and orchestrating tasks with AI integration.
|
||||
|
||||
## Features
|
||||
|
||||
- **TypeScript-first**: Built with full TypeScript support and strict type checking
|
||||
- **Dual Format**: Supports both ESM and CommonJS with automatic format detection
|
||||
- **Modular Architecture**: Clean separation of concerns with dedicated modules for different functionality
|
||||
- **AI Provider Integration**: Pluggable AI provider system for task generation and management
|
||||
- **Flexible Storage**: Abstracted storage layer supporting different persistence strategies
|
||||
- **Task Parsing**: Advanced parsing capabilities for various task definition formats
|
||||
- **Error Handling**: Comprehensive error system with specific error types
|
||||
- **Testing**: Complete test coverage with Jest and TypeScript support
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @task-master/tm-core
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { generateTaskId, PlaceholderTask } from '@task-master/tm-core';
|
||||
|
||||
// Generate a unique task ID
|
||||
const taskId = generateTaskId();
|
||||
|
||||
// Create a task (coming soon - full implementation)
|
||||
const task: PlaceholderTask = {
|
||||
id: taskId,
|
||||
title: 'My Task',
|
||||
status: 'pending',
|
||||
priority: 'medium'
|
||||
};
|
||||
```
|
||||
|
||||
### Modular Imports
|
||||
|
||||
You can import specific modules to reduce bundle size:
|
||||
|
||||
```typescript
|
||||
// Import types only
|
||||
import type { TaskId, TaskStatus } from '@task-master/tm-core/types';
|
||||
|
||||
// Import utilities
|
||||
import { generateTaskId, formatDate } from '@task-master/tm-core/utils';
|
||||
|
||||
// Import providers (AI providers coming soon)
|
||||
// import { AIProvider } from '@task-master/tm-core/providers';
|
||||
|
||||
// Import storage
|
||||
import { PlaceholderStorage } from '@task-master/tm-core/storage';
|
||||
|
||||
// Import parsers
|
||||
import { PlaceholderParser } from '@task-master/tm-core/parser';
|
||||
|
||||
// Import errors
|
||||
import { TmCoreError, TaskNotFoundError } from '@task-master/tm-core/errors';
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The library is organized into several key modules:
|
||||
|
||||
- **types/**: TypeScript type definitions and interfaces
|
||||
- **providers/**: AI provider implementations for task generation
|
||||
- **storage/**: Storage adapters for different persistence strategies
|
||||
- **parser/**: Task parsing utilities for various formats
|
||||
- **utils/**: Common utility functions and helpers
|
||||
- **errors/**: Custom error classes and error handling
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 18.0.0
|
||||
- npm or yarn
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Build the library
|
||||
npm run build
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
|
||||
# Run tests with coverage
|
||||
npm run test:coverage
|
||||
|
||||
# Lint code
|
||||
npm run lint
|
||||
|
||||
# Format code
|
||||
npm run format
|
||||
```
|
||||
|
||||
### Scripts
|
||||
|
||||
- `build`: Build the library for both ESM and CJS formats
|
||||
- `build:watch`: Build in watch mode for development
|
||||
- `test`: Run the test suite
|
||||
- `test:watch`: Run tests in watch mode
|
||||
- `test:coverage`: Run tests with coverage reporting
|
||||
- `lint`: Lint TypeScript files
|
||||
- `lint:fix`: Lint and auto-fix issues
|
||||
- `format`: Format code with Prettier
|
||||
- `format:check`: Check code formatting
|
||||
- `typecheck`: Type-check without emitting files
|
||||
- `clean`: Clean build artifacts
|
||||
- `dev`: Development mode with watch
|
||||
|
||||
## ESM and CommonJS Support
|
||||
|
||||
This package supports both ESM and CommonJS formats automatically:
|
||||
|
||||
```javascript
|
||||
// ESM
|
||||
import { generateTaskId } from '@task-master/tm-core';
|
||||
|
||||
// CommonJS
|
||||
const { generateTaskId } = require('@task-master/tm-core');
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
This is the initial package structure. The following features are planned for implementation:
|
||||
|
||||
### Task 116: TypeScript Types
|
||||
- [ ] Complete type definitions for tasks, projects, and configurations
|
||||
- [ ] Zod schema validation
|
||||
- [ ] Generic type utilities
|
||||
|
||||
### Task 117: AI Provider System
|
||||
- [ ] Base provider interface
|
||||
- [ ] Anthropic Claude integration
|
||||
- [ ] OpenAI integration
|
||||
- [ ] Perplexity integration
|
||||
- [ ] Provider factory and registry
|
||||
|
||||
### Task 118: Storage Layer
|
||||
- [ ] File system storage adapter
|
||||
- [ ] Memory storage adapter
|
||||
- [ ] Storage interface and factory
|
||||
|
||||
### Task 119: Task Parser
|
||||
- [ ] PRD parser implementation
|
||||
- [ ] Markdown parser
|
||||
- [ ] JSON task format parser
|
||||
- [ ] Validation utilities
|
||||
|
||||
### Task 120: Utility Functions
|
||||
- [ ] Task ID generation
|
||||
- [ ] Date formatting
|
||||
- [ ] Validation helpers
|
||||
- [ ] File system utilities
|
||||
|
||||
### Task 121: Error Handling
|
||||
- [ ] Task-specific errors
|
||||
- [ ] Storage errors
|
||||
- [ ] Provider errors
|
||||
- [ ] Validation errors
|
||||
|
||||
### Task 122: Configuration System
|
||||
- [ ] Configuration schema
|
||||
- [ ] Default configurations
|
||||
- [ ] Environment variable support
|
||||
|
||||
### Task 123: Testing Infrastructure
|
||||
- [ ] Unit test coverage
|
||||
- [ ] Integration tests
|
||||
- [ ] Mock utilities
|
||||
|
||||
### Task 124: Documentation
|
||||
- [ ] API documentation
|
||||
- [ ] Usage examples
|
||||
- [ ] Migration guides
|
||||
|
||||
### Task 125: Package Finalization
|
||||
- [ ] Final testing and validation
|
||||
- [ ] Release preparation
|
||||
- [ ] CI/CD integration
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### ✅ Task 115: Initialize tm-core Package Structure (COMPLETED)
|
||||
- [x] Create tm-core directory structure and base configuration files
|
||||
- [x] Configure build and test infrastructure
|
||||
- [x] Create barrel export files for all directories
|
||||
- [x] Add development tooling and documentation
|
||||
- [x] Validate package structure and prepare for development
|
||||
|
||||
### 🚧 Remaining Implementation Tasks
|
||||
- [ ] **Task 116**: TypeScript Types - Complete type definitions for tasks, projects, and configurations
|
||||
- [ ] **Task 117**: AI Provider System - Base provider interface and integrations
|
||||
- [ ] **Task 118**: Storage Layer - File system and memory storage adapters
|
||||
- [ ] **Task 119**: Task Parser - PRD, Markdown, and JSON parsers
|
||||
- [ ] **Task 120**: Utility Functions - Task ID generation, validation helpers
|
||||
- [ ] **Task 121**: Error Handling - Task-specific and validation errors
|
||||
- [ ] **Task 122**: Configuration System - Schema and environment support
|
||||
- [ ] **Task 123**: Testing Infrastructure - Complete unit and integration tests
|
||||
- [ ] **Task 124**: Documentation - API docs and usage examples
|
||||
- [ ] **Task 125**: Package Finalization - Release preparation and CI/CD
|
||||
|
||||
## Contributing
|
||||
|
||||
This package is part of the Task Master AI project. Please refer to the main project's contributing guidelines.
|
||||
|
||||
## License
|
||||
|
||||
MIT - See the main project's LICENSE file for details.
|
||||
|
||||
## Support
|
||||
|
||||
For questions and support, please refer to the main Task Master AI documentation.
|
||||
161
packages/tm-core/docs/listTasks-architecture.md
Normal file
161
packages/tm-core/docs/listTasks-architecture.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# ListTasks Architecture - End-to-End POC
|
||||
|
||||
## Current Implementation Structure
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ CLI Layer │
|
||||
│ scripts/modules/task-manager/list-tasks.js │
|
||||
│ - Complex UI rendering (tables, progress bars) │
|
||||
│ - Multiple output formats (json, text, markdown, compact) │
|
||||
│ - Status filtering and statistics │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ Currently reads directly
|
||||
│ from files (needs integration)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ tm-core Package │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ TaskMasterCore (Facade) │ │
|
||||
│ │ src/task-master-core.ts │ │
|
||||
│ │ │ │
|
||||
│ │ - listTasks(options) │ │
|
||||
│ │ • tag filtering │ │
|
||||
│ │ • status filtering │ │
|
||||
│ │ • include/exclude subtasks │ │
|
||||
│ │ - getTask(id) │ │
|
||||
│ │ - getTasksByStatus(status) │ │
|
||||
│ │ - getTaskStats() │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Storage Layer (IStorage) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────┐ ┌──────────────┐ │ │
|
||||
│ │ │ FileStorage │ │ ApiStorage │ │ │
|
||||
│ │ │ │ │ (Hamster) │ │ │
|
||||
│ │ └──────────────┘ └──────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ StorageFactory.create() selects based on config │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Domain Layer (Entities) │ │
|
||||
│ │ │ │
|
||||
│ │ TaskEntity │ │
|
||||
│ │ - Business logic │ │
|
||||
│ │ - Validation │ │
|
||||
│ │ - Status transitions │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## ListTasks Data Flow
|
||||
|
||||
### 1. CLI Request
|
||||
```javascript
|
||||
// Current CLI (needs update to use tm-core)
|
||||
listTasks(tasksPath, statusFilter, reportPath, withSubtasks, outputFormat, context)
|
||||
```
|
||||
|
||||
### 2. TaskMasterCore Processing
|
||||
```typescript
|
||||
// Our new implementation
|
||||
const tmCore = createTaskMasterCore(projectPath, {
|
||||
storage: {
|
||||
type: 'api', // or 'file'
|
||||
apiEndpoint: 'https://hamster.ai/api',
|
||||
apiAccessToken: 'xxx'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await tmCore.listTasks({
|
||||
tag: 'feature-branch',
|
||||
filter: {
|
||||
status: ['pending', 'in-progress'],
|
||||
priority: 'high',
|
||||
search: 'authentication'
|
||||
},
|
||||
includeSubtasks: true
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Storage Selection
|
||||
```typescript
|
||||
// StorageFactory automatically selects storage
|
||||
const storage = StorageFactory.create(config, projectPath);
|
||||
// Returns either FileStorage or ApiStorage based on config
|
||||
```
|
||||
|
||||
### 4. Data Loading
|
||||
```typescript
|
||||
// FileStorage
|
||||
- Reads from .taskmaster/tasks/tasks.json (or tag-specific file)
|
||||
- Local file system operations
|
||||
|
||||
// ApiStorage (Hamster)
|
||||
- Makes HTTP requests to Hamster API
|
||||
- Uses access token from config
|
||||
- Handles retries and rate limiting
|
||||
```
|
||||
|
||||
### 5. Entity Processing
|
||||
```typescript
|
||||
// Convert raw data to TaskEntity for business logic
|
||||
const taskEntities = TaskEntity.fromArray(rawTasks);
|
||||
|
||||
// Apply filters
|
||||
const filtered = applyFilters(taskEntities, filter);
|
||||
|
||||
// Convert back to plain objects
|
||||
const tasks = filtered.map(entity => entity.toJSON());
|
||||
```
|
||||
|
||||
### 6. Response Structure
|
||||
```typescript
|
||||
interface ListTasksResult {
|
||||
tasks: Task[]; // Filtered tasks
|
||||
total: number; // Total task count
|
||||
filtered: number; // Filtered task count
|
||||
tag?: string; // Tag context if applicable
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Points Needed
|
||||
|
||||
### 1. CLI Integration
|
||||
- [ ] Update `scripts/modules/task-manager/list-tasks.js` to use tm-core
|
||||
- [ ] Map CLI options to TaskMasterCore options
|
||||
- [ ] Handle output formatting in CLI layer
|
||||
|
||||
### 2. Configuration Loading
|
||||
- [ ] Load `.taskmaster/config.json` for storage settings
|
||||
- [ ] Support environment variables for API tokens
|
||||
- [ ] Handle storage type selection
|
||||
|
||||
### 3. Testing Requirements
|
||||
- [x] Unit tests for TaskEntity
|
||||
- [x] Unit tests for BaseProvider
|
||||
- [x] Integration tests for listTasks with FileStorage
|
||||
- [ ] Integration tests for listTasks with ApiStorage (mock API)
|
||||
- [ ] E2E tests with real Hamster API (optional)
|
||||
|
||||
## Benefits of This Architecture
|
||||
|
||||
1. **Storage Abstraction**: Switch between file and API storage without changing business logic
|
||||
2. **Clean Separation**: UI (CLI) separate from business logic (tm-core)
|
||||
3. **Testability**: Each layer can be tested independently
|
||||
4. **Extensibility**: Easy to add new storage types (database, cloud, etc.)
|
||||
5. **Type Safety**: Full TypeScript support throughout
|
||||
6. **Error Handling**: Consistent error handling with TaskMasterError
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Create a simple CLI wrapper that uses tm-core
|
||||
2. Test with file storage (existing functionality)
|
||||
3. Test with mock API storage
|
||||
4. Integrate with actual Hamster API when available
|
||||
5. Migrate other commands (addTask, updateTask, etc.) following same pattern
|
||||
7021
packages/tm-core/package-lock.json
generated
Normal file
7021
packages/tm-core/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
packages/tm-core/package.json
Normal file
47
packages/tm-core/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@tm/core",
|
||||
"version": "1.0.0",
|
||||
"description": "Core library for Task Master - TypeScript task management system",
|
||||
"type": "module",
|
||||
"types": "./dist/index.d.ts",
|
||||
"main": "./dist/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "biome check --write",
|
||||
"lint:check": "biome check",
|
||||
"lint:fix": "biome check --fix --unsafe",
|
||||
"format": "biome format --write",
|
||||
"format:check": "biome format",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@types/node": "^20.11.30",
|
||||
"@vitest/coverage-v8": "^2.0.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.3",
|
||||
"vitest": "^2.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||
"keywords": ["task-management", "typescript", "ai", "prd", "parser"],
|
||||
"author": "Task Master AI",
|
||||
"license": "MIT"
|
||||
}
|
||||
394
packages/tm-core/src/config/config-manager.spec.ts
Normal file
394
packages/tm-core/src/config/config-manager.spec.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* @fileoverview Integration tests for ConfigManager
|
||||
* Tests the orchestration of all configuration services
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { ConfigManager } from './config-manager.js';
|
||||
import { ConfigLoader } from './services/config-loader.service.js';
|
||||
import { ConfigMerger } from './services/config-merger.service.js';
|
||||
import { RuntimeStateManager } from './services/runtime-state-manager.service.js';
|
||||
import { ConfigPersistence } from './services/config-persistence.service.js';
|
||||
import { EnvironmentConfigProvider } from './services/environment-config-provider.service.js';
|
||||
|
||||
// Mock all services
|
||||
vi.mock('./services/config-loader.service.js');
|
||||
vi.mock('./services/config-merger.service.js');
|
||||
vi.mock('./services/runtime-state-manager.service.js');
|
||||
vi.mock('./services/config-persistence.service.js');
|
||||
vi.mock('./services/environment-config-provider.service.js');
|
||||
|
||||
describe('ConfigManager', () => {
|
||||
let manager: ConfigManager;
|
||||
const testProjectRoot = '/test/project';
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Clear environment variables
|
||||
Object.keys(process.env).forEach((key) => {
|
||||
if (key.startsWith('TASKMASTER_')) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Setup default mock behaviors
|
||||
vi.mocked(ConfigLoader).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
getDefaultConfig: vi.fn().mockReturnValue({
|
||||
models: { main: 'default-model', fallback: 'fallback-model' },
|
||||
storage: { type: 'file' },
|
||||
version: '1.0.0'
|
||||
}),
|
||||
loadLocalConfig: vi.fn().mockResolvedValue(null),
|
||||
loadGlobalConfig: vi.fn().mockResolvedValue(null),
|
||||
hasLocalConfig: vi.fn().mockResolvedValue(false),
|
||||
hasGlobalConfig: vi.fn().mockResolvedValue(false)
|
||||
}) as any
|
||||
);
|
||||
|
||||
vi.mocked(ConfigMerger).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
addSource: vi.fn(),
|
||||
clearSources: vi.fn(),
|
||||
merge: vi.fn().mockReturnValue({
|
||||
models: { main: 'merged-model', fallback: 'fallback-model' },
|
||||
storage: { type: 'file' }
|
||||
}),
|
||||
getSources: vi.fn().mockReturnValue([]),
|
||||
hasSource: vi.fn().mockReturnValue(false),
|
||||
removeSource: vi.fn().mockReturnValue(false)
|
||||
}) as any
|
||||
);
|
||||
|
||||
vi.mocked(RuntimeStateManager).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
loadState: vi.fn().mockResolvedValue({ activeTag: 'master' }),
|
||||
saveState: vi.fn().mockResolvedValue(undefined),
|
||||
getActiveTag: vi.fn().mockReturnValue('master'),
|
||||
setActiveTag: vi.fn().mockResolvedValue(undefined),
|
||||
getState: vi.fn().mockReturnValue({ activeTag: 'master' }),
|
||||
updateMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
clearState: vi.fn().mockResolvedValue(undefined)
|
||||
}) as any
|
||||
);
|
||||
|
||||
vi.mocked(ConfigPersistence).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
saveConfig: vi.fn().mockResolvedValue(undefined),
|
||||
configExists: vi.fn().mockResolvedValue(false),
|
||||
deleteConfig: vi.fn().mockResolvedValue(undefined),
|
||||
getBackups: vi.fn().mockResolvedValue([]),
|
||||
restoreFromBackup: vi.fn().mockResolvedValue(undefined)
|
||||
}) as any
|
||||
);
|
||||
|
||||
vi.mocked(EnvironmentConfigProvider).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
loadConfig: vi.fn().mockReturnValue({}),
|
||||
getRuntimeState: vi.fn().mockReturnValue({}),
|
||||
hasEnvVar: vi.fn().mockReturnValue(false),
|
||||
getAllTaskmasterEnvVars: vi.fn().mockReturnValue({}),
|
||||
addMapping: vi.fn(),
|
||||
getMappings: vi.fn().mockReturnValue([])
|
||||
}) as any
|
||||
);
|
||||
|
||||
// Since constructor is private, we need to use the factory method
|
||||
// But for testing, we'll create a test instance using create()
|
||||
manager = await ConfigManager.create(testProjectRoot);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
describe('creation', () => {
|
||||
it('should initialize all services when created', () => {
|
||||
// Services should have been initialized during beforeEach
|
||||
expect(ConfigLoader).toHaveBeenCalledWith(testProjectRoot);
|
||||
expect(ConfigMerger).toHaveBeenCalled();
|
||||
expect(RuntimeStateManager).toHaveBeenCalledWith(testProjectRoot);
|
||||
expect(ConfigPersistence).toHaveBeenCalledWith(testProjectRoot);
|
||||
expect(EnvironmentConfigProvider).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create (factory method)', () => {
|
||||
it('should create and initialize manager', async () => {
|
||||
const createdManager = await ConfigManager.create(testProjectRoot);
|
||||
|
||||
expect(createdManager).toBeInstanceOf(ConfigManager);
|
||||
expect(createdManager.getConfig()).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialization (via create)', () => {
|
||||
it('should load and merge all configuration sources', () => {
|
||||
// Manager was created in beforeEach, so initialization already happened
|
||||
const loader = (manager as any).loader;
|
||||
const merger = (manager as any).merger;
|
||||
const stateManager = (manager as any).stateManager;
|
||||
const envProvider = (manager as any).envProvider;
|
||||
|
||||
// Verify loading sequence
|
||||
expect(merger.clearSources).toHaveBeenCalled();
|
||||
expect(loader.getDefaultConfig).toHaveBeenCalled();
|
||||
expect(loader.loadGlobalConfig).toHaveBeenCalled();
|
||||
expect(loader.loadLocalConfig).toHaveBeenCalled();
|
||||
expect(envProvider.loadConfig).toHaveBeenCalled();
|
||||
expect(merger.merge).toHaveBeenCalled();
|
||||
expect(stateManager.loadState).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add sources with correct precedence during creation', () => {
|
||||
const merger = (manager as any).merger;
|
||||
|
||||
// Check that sources were added with correct precedence
|
||||
expect(merger.addSource).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'defaults',
|
||||
precedence: 0
|
||||
})
|
||||
);
|
||||
|
||||
// Note: local and env sources may not be added if they don't exist
|
||||
// The mock setup determines what gets called
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration access', () => {
|
||||
// Manager is already initialized in the main beforeEach
|
||||
|
||||
it('should return merged configuration', () => {
|
||||
const config = manager.getConfig();
|
||||
expect(config).toEqual({
|
||||
models: { main: 'merged-model', fallback: 'fallback-model' },
|
||||
storage: { type: 'file' }
|
||||
});
|
||||
});
|
||||
|
||||
it('should return storage configuration', () => {
|
||||
const storage = manager.getStorageConfig();
|
||||
expect(storage).toEqual({ type: 'file' });
|
||||
});
|
||||
|
||||
it('should return API storage configuration when configured', async () => {
|
||||
// Create a new instance with API storage config
|
||||
vi.mocked(ConfigMerger).mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
addSource: vi.fn(),
|
||||
clearSources: vi.fn(),
|
||||
merge: vi.fn().mockReturnValue({
|
||||
storage: {
|
||||
type: 'api',
|
||||
apiEndpoint: 'https://api.example.com',
|
||||
apiAccessToken: 'token123'
|
||||
}
|
||||
}),
|
||||
getSources: vi.fn().mockReturnValue([]),
|
||||
hasSource: vi.fn().mockReturnValue(false),
|
||||
removeSource: vi.fn().mockReturnValue(false)
|
||||
}) as any
|
||||
);
|
||||
|
||||
const apiManager = await ConfigManager.create(testProjectRoot);
|
||||
|
||||
const storage = apiManager.getStorageConfig();
|
||||
expect(storage).toEqual({
|
||||
type: 'api',
|
||||
apiEndpoint: 'https://api.example.com',
|
||||
apiAccessToken: 'token123'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return model configuration', () => {
|
||||
const models = manager.getModelConfig();
|
||||
expect(models).toEqual({
|
||||
main: 'merged-model',
|
||||
fallback: 'fallback-model'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return default models when not configured', () => {
|
||||
// Update the mock for current instance
|
||||
const merger = (manager as any).merger;
|
||||
merger.merge.mockReturnValue({});
|
||||
// Force re-merge
|
||||
(manager as any).config = merger.merge();
|
||||
|
||||
const models = manager.getModelConfig();
|
||||
expect(models).toEqual({
|
||||
main: 'claude-3-5-sonnet-20241022',
|
||||
fallback: 'gpt-4o-mini'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return response language', () => {
|
||||
const language = manager.getResponseLanguage();
|
||||
expect(language).toBe('English');
|
||||
});
|
||||
|
||||
it('should return custom response language', () => {
|
||||
// Update config for current instance
|
||||
(manager as any).config = {
|
||||
custom: { responseLanguage: 'Spanish' }
|
||||
};
|
||||
|
||||
const language = manager.getResponseLanguage();
|
||||
expect(language).toBe('Spanish');
|
||||
});
|
||||
|
||||
it('should return project root', () => {
|
||||
expect(manager.getProjectRoot()).toBe(testProjectRoot);
|
||||
});
|
||||
|
||||
it('should check if using API storage', () => {
|
||||
expect(manager.isUsingApiStorage()).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect API storage', () => {
|
||||
// Update config for current instance
|
||||
(manager as any).config = {
|
||||
storage: {
|
||||
type: 'api',
|
||||
apiEndpoint: 'https://api.example.com',
|
||||
apiAccessToken: 'token'
|
||||
}
|
||||
};
|
||||
|
||||
expect(manager.isUsingApiStorage()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime state', () => {
|
||||
// Manager is already initialized in the main beforeEach
|
||||
|
||||
it('should get active tag from state manager', () => {
|
||||
const tag = manager.getActiveTag();
|
||||
expect(tag).toBe('master');
|
||||
});
|
||||
|
||||
it('should set active tag through state manager', async () => {
|
||||
await manager.setActiveTag('feature-branch');
|
||||
|
||||
const stateManager = (manager as any).stateManager;
|
||||
expect(stateManager.setActiveTag).toHaveBeenCalledWith('feature-branch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration updates', () => {
|
||||
// Manager is already initialized in the main beforeEach
|
||||
|
||||
it('should update configuration and save', async () => {
|
||||
const updates = {
|
||||
models: { main: 'new-model', fallback: 'fallback-model' }
|
||||
};
|
||||
await manager.updateConfig(updates);
|
||||
|
||||
const persistence = (manager as any).persistence;
|
||||
expect(persistence.saveConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should re-initialize after update to maintain precedence', async () => {
|
||||
const merger = (manager as any).merger;
|
||||
merger.clearSources.mockClear();
|
||||
|
||||
await manager.updateConfig({ custom: { test: 'value' } });
|
||||
|
||||
expect(merger.clearSources).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set response language', async () => {
|
||||
await manager.setResponseLanguage('French');
|
||||
|
||||
const persistence = (manager as any).persistence;
|
||||
expect(persistence.saveConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
custom: { responseLanguage: 'French' }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should save configuration with options', async () => {
|
||||
await manager.saveConfig();
|
||||
|
||||
const persistence = (manager as any).persistence;
|
||||
expect(persistence.saveConfig).toHaveBeenCalledWith(expect.any(Object), {
|
||||
createBackup: true,
|
||||
atomic: true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('utilities', () => {
|
||||
// Manager is already initialized in the main beforeEach
|
||||
|
||||
it('should reset configuration to defaults', async () => {
|
||||
await manager.reset();
|
||||
|
||||
const persistence = (manager as any).persistence;
|
||||
const stateManager = (manager as any).stateManager;
|
||||
|
||||
expect(persistence.deleteConfig).toHaveBeenCalled();
|
||||
expect(stateManager.clearState).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should re-initialize after reset', async () => {
|
||||
const merger = (manager as any).merger;
|
||||
merger.clearSources.mockClear();
|
||||
|
||||
await manager.reset();
|
||||
|
||||
expect(merger.clearSources).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should get configuration sources for debugging', () => {
|
||||
const merger = (manager as any).merger;
|
||||
const mockSources = [{ name: 'test', config: {}, precedence: 1 }];
|
||||
merger.getSources.mockReturnValue(mockSources);
|
||||
|
||||
const sources = manager.getConfigSources();
|
||||
|
||||
expect(sources).toEqual(mockSources);
|
||||
});
|
||||
|
||||
it('should return no-op function for watch (not implemented)', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const callback = vi.fn();
|
||||
|
||||
const unsubscribe = manager.watch(callback);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Configuration watching not yet implemented'
|
||||
);
|
||||
expect(unsubscribe).toBeInstanceOf(Function);
|
||||
|
||||
// Calling unsubscribe should not throw
|
||||
expect(() => unsubscribe()).not.toThrow();
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle missing services gracefully', async () => {
|
||||
// Even if a service fails, manager should still work
|
||||
const loader = (manager as any).loader;
|
||||
loader.loadLocalConfig.mockRejectedValue(new Error('File error'));
|
||||
|
||||
// Creating a new manager should not throw even if service fails
|
||||
await expect(
|
||||
ConfigManager.create(testProjectRoot)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
280
packages/tm-core/src/config/config-manager.ts
Normal file
280
packages/tm-core/src/config/config-manager.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* @fileoverview Configuration Manager
|
||||
* Orchestrates configuration services following clean architecture principles
|
||||
*
|
||||
* This ConfigManager delegates responsibilities to specialized services for better
|
||||
* maintainability, testability, and separation of concerns.
|
||||
*/
|
||||
|
||||
import type { PartialConfiguration } from '../interfaces/configuration.interface.js';
|
||||
import { ConfigLoader } from './services/config-loader.service.js';
|
||||
import {
|
||||
ConfigMerger,
|
||||
CONFIG_PRECEDENCE
|
||||
} from './services/config-merger.service.js';
|
||||
import { RuntimeStateManager } from './services/runtime-state-manager.service.js';
|
||||
import { ConfigPersistence } from './services/config-persistence.service.js';
|
||||
import { EnvironmentConfigProvider } from './services/environment-config-provider.service.js';
|
||||
|
||||
/**
|
||||
* ConfigManager orchestrates all configuration services
|
||||
*
|
||||
* This class delegates responsibilities to specialized services:
|
||||
* - ConfigLoader: Loads configuration from files
|
||||
* - ConfigMerger: Merges configurations with precedence
|
||||
* - RuntimeStateManager: Manages runtime state
|
||||
* - ConfigPersistence: Handles file persistence
|
||||
* - EnvironmentConfigProvider: Extracts env var configuration
|
||||
*/
|
||||
export class ConfigManager {
|
||||
private projectRoot: string;
|
||||
private config: PartialConfiguration = {};
|
||||
private initialized = false;
|
||||
|
||||
// Services
|
||||
private loader: ConfigLoader;
|
||||
private merger: ConfigMerger;
|
||||
private stateManager: RuntimeStateManager;
|
||||
private persistence: ConfigPersistence;
|
||||
private envProvider: EnvironmentConfigProvider;
|
||||
|
||||
/**
|
||||
* Create and initialize a new ConfigManager instance
|
||||
* This is the ONLY way to create a ConfigManager
|
||||
*
|
||||
* @param projectRoot - The root directory of the project
|
||||
* @returns Fully initialized ConfigManager instance
|
||||
*/
|
||||
static async create(projectRoot: string): Promise<ConfigManager> {
|
||||
const manager = new ConfigManager(projectRoot);
|
||||
await manager.initialize();
|
||||
return manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Private constructor - use ConfigManager.create() instead
|
||||
* This ensures the ConfigManager is always properly initialized
|
||||
*/
|
||||
private constructor(projectRoot: string) {
|
||||
this.projectRoot = projectRoot;
|
||||
|
||||
// Initialize services
|
||||
this.loader = new ConfigLoader(projectRoot);
|
||||
this.merger = new ConfigMerger();
|
||||
this.stateManager = new RuntimeStateManager(projectRoot);
|
||||
this.persistence = new ConfigPersistence(projectRoot);
|
||||
this.envProvider = new EnvironmentConfigProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize by loading configuration from all sources
|
||||
* Private - only called by the factory method
|
||||
*/
|
||||
private async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
// Clear any existing configuration sources
|
||||
this.merger.clearSources();
|
||||
|
||||
// 1. Load default configuration (lowest precedence)
|
||||
this.merger.addSource({
|
||||
name: 'defaults',
|
||||
config: this.loader.getDefaultConfig(),
|
||||
precedence: CONFIG_PRECEDENCE.DEFAULTS
|
||||
});
|
||||
|
||||
// 2. Load global configuration (if exists)
|
||||
const globalConfig = await this.loader.loadGlobalConfig();
|
||||
if (globalConfig) {
|
||||
this.merger.addSource({
|
||||
name: 'global',
|
||||
config: globalConfig,
|
||||
precedence: CONFIG_PRECEDENCE.GLOBAL
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Load local project configuration
|
||||
const localConfig = await this.loader.loadLocalConfig();
|
||||
if (localConfig) {
|
||||
this.merger.addSource({
|
||||
name: 'local',
|
||||
config: localConfig,
|
||||
precedence: CONFIG_PRECEDENCE.LOCAL
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Load environment variables (highest precedence)
|
||||
const envConfig = this.envProvider.loadConfig();
|
||||
if (Object.keys(envConfig).length > 0) {
|
||||
this.merger.addSource({
|
||||
name: 'environment',
|
||||
config: envConfig,
|
||||
precedence: CONFIG_PRECEDENCE.ENVIRONMENT
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Merge all configurations
|
||||
this.config = this.merger.merge();
|
||||
|
||||
// 6. Load runtime state
|
||||
await this.stateManager.loadState();
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
// ==================== Configuration Access ====================
|
||||
|
||||
/**
|
||||
* Get full configuration
|
||||
*/
|
||||
getConfig(): PartialConfiguration {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage configuration
|
||||
*/
|
||||
getStorageConfig(): {
|
||||
type: 'file' | 'api';
|
||||
apiEndpoint?: string;
|
||||
apiAccessToken?: string;
|
||||
} {
|
||||
const storage = this.config.storage;
|
||||
|
||||
if (
|
||||
storage?.type === 'api' &&
|
||||
storage.apiEndpoint &&
|
||||
storage.apiAccessToken
|
||||
) {
|
||||
return {
|
||||
type: 'api',
|
||||
apiEndpoint: storage.apiEndpoint,
|
||||
apiAccessToken: storage.apiAccessToken
|
||||
};
|
||||
}
|
||||
|
||||
return { type: 'file' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model configuration
|
||||
*/
|
||||
getModelConfig() {
|
||||
return (
|
||||
this.config.models || {
|
||||
main: 'claude-3-5-sonnet-20241022',
|
||||
fallback: 'gpt-4o-mini'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get response language setting
|
||||
*/
|
||||
getResponseLanguage(): string {
|
||||
const customConfig = this.config.custom as any;
|
||||
return customConfig?.responseLanguage || 'English';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project root path
|
||||
*/
|
||||
getProjectRoot(): string {
|
||||
return this.projectRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if using API storage
|
||||
*/
|
||||
isUsingApiStorage(): boolean {
|
||||
return this.getStorageConfig().type === 'api';
|
||||
}
|
||||
|
||||
// ==================== Runtime State ====================
|
||||
|
||||
/**
|
||||
* Get the currently active tag
|
||||
*/
|
||||
getActiveTag(): string {
|
||||
return this.stateManager.getCurrentTag();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active tag
|
||||
*/
|
||||
async setActiveTag(tag: string): Promise<void> {
|
||||
await this.stateManager.setCurrentTag(tag);
|
||||
}
|
||||
|
||||
// ==================== Configuration Updates ====================
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
async updateConfig(updates: PartialConfiguration): Promise<void> {
|
||||
// Merge updates into current config
|
||||
Object.assign(this.config, updates);
|
||||
|
||||
// Save to persistence
|
||||
await this.persistence.saveConfig(this.config);
|
||||
|
||||
// Re-initialize to respect precedence
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set response language
|
||||
*/
|
||||
async setResponseLanguage(language: string): Promise<void> {
|
||||
if (!this.config.custom) {
|
||||
this.config.custom = {};
|
||||
}
|
||||
(this.config.custom as any).responseLanguage = language;
|
||||
await this.persistence.saveConfig(this.config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current configuration
|
||||
*/
|
||||
async saveConfig(): Promise<void> {
|
||||
await this.persistence.saveConfig(this.config, {
|
||||
createBackup: true,
|
||||
atomic: true
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Utilities ====================
|
||||
|
||||
/**
|
||||
* Reset configuration to defaults
|
||||
*/
|
||||
async reset(): Promise<void> {
|
||||
// Clear configuration file
|
||||
await this.persistence.deleteConfig();
|
||||
|
||||
// Clear runtime state
|
||||
await this.stateManager.clearState();
|
||||
|
||||
// Reset internal state
|
||||
this.initialized = false;
|
||||
this.config = {};
|
||||
|
||||
// Re-initialize with defaults
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration sources for debugging
|
||||
*/
|
||||
getConfigSources() {
|
||||
return this.merger.getSources();
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for configuration changes (placeholder for future)
|
||||
*/
|
||||
watch(_callback: (config: PartialConfiguration) => void): () => void {
|
||||
console.warn('Configuration watching not yet implemented');
|
||||
return () => {}; // Return no-op unsubscribe function
|
||||
}
|
||||
}
|
||||
43
packages/tm-core/src/config/index.ts
Normal file
43
packages/tm-core/src/config/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @fileoverview Configuration module exports
|
||||
* Exports the main ConfigManager and all configuration services
|
||||
*/
|
||||
|
||||
// Export the main ConfigManager
|
||||
export { ConfigManager } from './config-manager.js';
|
||||
|
||||
// Export all configuration services for advanced usage
|
||||
export {
|
||||
ConfigLoader,
|
||||
ConfigMerger,
|
||||
CONFIG_PRECEDENCE,
|
||||
RuntimeStateManager,
|
||||
ConfigPersistence,
|
||||
EnvironmentConfigProvider,
|
||||
type ConfigSource,
|
||||
type RuntimeState,
|
||||
type PersistenceOptions
|
||||
} from './services/index.js';
|
||||
|
||||
// Re-export configuration interfaces
|
||||
export type {
|
||||
IConfiguration,
|
||||
PartialConfiguration,
|
||||
ModelConfig,
|
||||
ProviderConfig,
|
||||
TaskSettings,
|
||||
TagSettings,
|
||||
StorageSettings,
|
||||
RetrySettings,
|
||||
LoggingSettings,
|
||||
SecuritySettings,
|
||||
ConfigValidationResult,
|
||||
EnvironmentConfig,
|
||||
ConfigSchema,
|
||||
ConfigProperty,
|
||||
IConfigurationFactory,
|
||||
IConfigurationManager
|
||||
} from '../interfaces/configuration.interface.js';
|
||||
|
||||
// Re-export default values
|
||||
export { DEFAULT_CONFIG_VALUES } from '../interfaces/configuration.interface.js';
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for ConfigLoader service
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { ConfigLoader } from './config-loader.service.js';
|
||||
import { DEFAULT_CONFIG_VALUES } from '../../interfaces/configuration.interface.js';
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
promises: {
|
||||
readFile: vi.fn(),
|
||||
access: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
describe('ConfigLoader', () => {
|
||||
let configLoader: ConfigLoader;
|
||||
const testProjectRoot = '/test/project';
|
||||
|
||||
beforeEach(() => {
|
||||
configLoader = new ConfigLoader(testProjectRoot);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getDefaultConfig', () => {
|
||||
it('should return default configuration values', () => {
|
||||
const config = configLoader.getDefaultConfig();
|
||||
|
||||
expect(config.models).toEqual({
|
||||
main: DEFAULT_CONFIG_VALUES.MODELS.MAIN,
|
||||
fallback: DEFAULT_CONFIG_VALUES.MODELS.FALLBACK
|
||||
});
|
||||
|
||||
expect(config.storage).toEqual({
|
||||
type: DEFAULT_CONFIG_VALUES.STORAGE.TYPE,
|
||||
encoding: DEFAULT_CONFIG_VALUES.STORAGE.ENCODING,
|
||||
enableBackup: false,
|
||||
maxBackups: DEFAULT_CONFIG_VALUES.STORAGE.MAX_BACKUPS,
|
||||
enableCompression: false,
|
||||
atomicOperations: true
|
||||
});
|
||||
|
||||
expect(config.version).toBe(DEFAULT_CONFIG_VALUES.VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadLocalConfig', () => {
|
||||
it('should load and parse local configuration file', async () => {
|
||||
const mockConfig = {
|
||||
models: { main: 'test-model' },
|
||||
storage: { type: 'api' as const }
|
||||
};
|
||||
|
||||
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockConfig));
|
||||
|
||||
const result = await configLoader.loadLocalConfig();
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalledWith(
|
||||
'/test/project/.taskmaster/config.json',
|
||||
'utf-8'
|
||||
);
|
||||
expect(result).toEqual(mockConfig);
|
||||
});
|
||||
|
||||
it('should return null when config file does not exist', async () => {
|
||||
const error = new Error('File not found') as any;
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
|
||||
const result = await configLoader.loadLocalConfig();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw TaskMasterError for other file errors', async () => {
|
||||
const error = new Error('Permission denied');
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
|
||||
await expect(configLoader.loadLocalConfig()).rejects.toThrow(
|
||||
'Failed to load local configuration'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid JSON', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('invalid json');
|
||||
|
||||
await expect(configLoader.loadLocalConfig()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadGlobalConfig', () => {
|
||||
it('should return null (not implemented yet)', async () => {
|
||||
const result = await configLoader.loadGlobalConfig();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasLocalConfig', () => {
|
||||
it('should return true when local config exists', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
|
||||
const result = await configLoader.hasLocalConfig();
|
||||
|
||||
expect(fs.access).toHaveBeenCalledWith(
|
||||
'/test/project/.taskmaster/config.json'
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when local config does not exist', async () => {
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error('Not found'));
|
||||
|
||||
const result = await configLoader.hasLocalConfig();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasGlobalConfig', () => {
|
||||
it('should check global config path', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
|
||||
const result = await configLoader.hasGlobalConfig();
|
||||
|
||||
expect(fs.access).toHaveBeenCalledWith(
|
||||
expect.stringContaining('.taskmaster/config.json')
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when global config does not exist', async () => {
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error('Not found'));
|
||||
|
||||
const result = await configLoader.hasGlobalConfig();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
124
packages/tm-core/src/config/services/config-loader.service.ts
Normal file
124
packages/tm-core/src/config/services/config-loader.service.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @fileoverview Configuration Loader Service
|
||||
* Responsible for loading configuration from various file sources
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { PartialConfiguration } from '../../interfaces/configuration.interface.js';
|
||||
import { DEFAULT_CONFIG_VALUES } from '../../interfaces/configuration.interface.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
TaskMasterError
|
||||
} from '../../errors/task-master-error.js';
|
||||
|
||||
/**
|
||||
* ConfigLoader handles loading configuration from files
|
||||
* Single responsibility: File-based configuration loading
|
||||
*/
|
||||
export class ConfigLoader {
|
||||
private localConfigPath: string;
|
||||
private globalConfigPath: string;
|
||||
|
||||
constructor(projectRoot: string) {
|
||||
this.localConfigPath = path.join(projectRoot, '.taskmaster', 'config.json');
|
||||
this.globalConfigPath = path.join(
|
||||
process.env.HOME || '',
|
||||
'.taskmaster',
|
||||
'config.json'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration values
|
||||
*/
|
||||
getDefaultConfig(): PartialConfiguration {
|
||||
return {
|
||||
models: {
|
||||
main: DEFAULT_CONFIG_VALUES.MODELS.MAIN,
|
||||
fallback: DEFAULT_CONFIG_VALUES.MODELS.FALLBACK
|
||||
},
|
||||
storage: {
|
||||
type: DEFAULT_CONFIG_VALUES.STORAGE.TYPE,
|
||||
encoding: DEFAULT_CONFIG_VALUES.STORAGE.ENCODING,
|
||||
enableBackup: false,
|
||||
maxBackups: DEFAULT_CONFIG_VALUES.STORAGE.MAX_BACKUPS,
|
||||
enableCompression: false,
|
||||
atomicOperations: true
|
||||
},
|
||||
version: DEFAULT_CONFIG_VALUES.VERSION
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load local project configuration
|
||||
*/
|
||||
async loadLocalConfig(): Promise<PartialConfiguration | null> {
|
||||
try {
|
||||
const configData = await fs.readFile(this.localConfigPath, 'utf-8');
|
||||
return JSON.parse(configData);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// File doesn't exist, return null
|
||||
console.debug('No local config.json found, using defaults');
|
||||
return null;
|
||||
}
|
||||
throw new TaskMasterError(
|
||||
'Failed to load local configuration',
|
||||
ERROR_CODES.CONFIG_ERROR,
|
||||
{ configPath: this.localConfigPath },
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load global user configuration
|
||||
* @future-implementation Full implementation pending
|
||||
*/
|
||||
async loadGlobalConfig(): Promise<PartialConfiguration | null> {
|
||||
// TODO: Implement in future PR
|
||||
// For now, return null to indicate no global config
|
||||
return null;
|
||||
|
||||
// Future implementation:
|
||||
// try {
|
||||
// const configData = await fs.readFile(this.globalConfigPath, 'utf-8');
|
||||
// return JSON.parse(configData);
|
||||
// } catch (error: any) {
|
||||
// if (error.code === 'ENOENT') {
|
||||
// return null;
|
||||
// }
|
||||
// throw new TaskMasterError(
|
||||
// 'Failed to load global configuration',
|
||||
// ERROR_CODES.CONFIG_ERROR,
|
||||
// { configPath: this.globalConfigPath },
|
||||
// error
|
||||
// );
|
||||
// }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if local config exists
|
||||
*/
|
||||
async hasLocalConfig(): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(this.localConfigPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if global config exists
|
||||
*/
|
||||
async hasGlobalConfig(): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(this.globalConfigPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for ConfigMerger service
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ConfigMerger, CONFIG_PRECEDENCE } from './config-merger.service.js';
|
||||
|
||||
describe('ConfigMerger', () => {
|
||||
let merger: ConfigMerger;
|
||||
|
||||
beforeEach(() => {
|
||||
merger = new ConfigMerger();
|
||||
});
|
||||
|
||||
describe('addSource', () => {
|
||||
it('should add configuration source', () => {
|
||||
const source = {
|
||||
name: 'test',
|
||||
config: { test: true },
|
||||
precedence: 1
|
||||
};
|
||||
|
||||
merger.addSource(source);
|
||||
const sources = merger.getSources();
|
||||
|
||||
expect(sources).toHaveLength(1);
|
||||
expect(sources[0]).toEqual(source);
|
||||
});
|
||||
|
||||
it('should add multiple sources', () => {
|
||||
merger.addSource({ name: 'source1', config: {}, precedence: 1 });
|
||||
merger.addSource({ name: 'source2', config: {}, precedence: 2 });
|
||||
|
||||
expect(merger.getSources()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearSources', () => {
|
||||
it('should remove all configuration sources', () => {
|
||||
merger.addSource({ name: 'test', config: {}, precedence: 1 });
|
||||
merger.clearSources();
|
||||
|
||||
expect(merger.getSources()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('merge', () => {
|
||||
it('should merge configurations based on precedence', () => {
|
||||
merger.addSource({
|
||||
name: 'low',
|
||||
config: { a: 1, b: 2 },
|
||||
precedence: 1
|
||||
});
|
||||
|
||||
merger.addSource({
|
||||
name: 'high',
|
||||
config: { a: 3, c: 4 },
|
||||
precedence: 2
|
||||
});
|
||||
|
||||
const result = merger.merge();
|
||||
|
||||
expect(result).toEqual({
|
||||
a: 3, // High precedence wins
|
||||
b: 2, // Only in low
|
||||
c: 4 // Only in high
|
||||
});
|
||||
});
|
||||
|
||||
it('should deep merge nested objects', () => {
|
||||
merger.addSource({
|
||||
name: 'base',
|
||||
config: {
|
||||
models: { main: 'model1', fallback: 'model2' },
|
||||
storage: { type: 'file' as const }
|
||||
},
|
||||
precedence: 1
|
||||
});
|
||||
|
||||
merger.addSource({
|
||||
name: 'override',
|
||||
config: {
|
||||
models: { main: 'model3' },
|
||||
storage: { encoding: 'utf8' as const }
|
||||
},
|
||||
precedence: 2
|
||||
});
|
||||
|
||||
const result = merger.merge();
|
||||
|
||||
expect(result).toEqual({
|
||||
models: {
|
||||
main: 'model3', // Overridden
|
||||
fallback: 'model2' // Preserved
|
||||
},
|
||||
storage: {
|
||||
type: 'file', // Preserved
|
||||
encoding: 'utf8' // Added
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle arrays by replacement', () => {
|
||||
merger.addSource({
|
||||
name: 'base',
|
||||
config: { items: [1, 2, 3] },
|
||||
precedence: 1
|
||||
});
|
||||
|
||||
merger.addSource({
|
||||
name: 'override',
|
||||
config: { items: [4, 5] },
|
||||
precedence: 2
|
||||
});
|
||||
|
||||
const result = merger.merge();
|
||||
|
||||
expect(result.items).toEqual([4, 5]); // Arrays are replaced, not merged
|
||||
});
|
||||
|
||||
it('should ignore null and undefined values', () => {
|
||||
merger.addSource({
|
||||
name: 'base',
|
||||
config: { a: 1, b: 2 },
|
||||
precedence: 1
|
||||
});
|
||||
|
||||
merger.addSource({
|
||||
name: 'override',
|
||||
config: { a: null, b: undefined, c: 3 } as any,
|
||||
precedence: 2
|
||||
});
|
||||
|
||||
const result = merger.merge();
|
||||
|
||||
expect(result).toEqual({
|
||||
a: 1, // null ignored
|
||||
b: 2, // undefined ignored
|
||||
c: 3 // new value added
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty object when no sources', () => {
|
||||
const result = merger.merge();
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should use CONFIG_PRECEDENCE constants correctly', () => {
|
||||
merger.addSource({
|
||||
name: 'defaults',
|
||||
config: { level: 'default' },
|
||||
precedence: CONFIG_PRECEDENCE.DEFAULTS
|
||||
});
|
||||
|
||||
merger.addSource({
|
||||
name: 'local',
|
||||
config: { level: 'local' },
|
||||
precedence: CONFIG_PRECEDENCE.LOCAL
|
||||
});
|
||||
|
||||
merger.addSource({
|
||||
name: 'environment',
|
||||
config: { level: 'env' },
|
||||
precedence: CONFIG_PRECEDENCE.ENVIRONMENT
|
||||
});
|
||||
|
||||
const result = merger.merge();
|
||||
|
||||
expect(result.level).toBe('env'); // Highest precedence wins
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSources', () => {
|
||||
it('should return sources sorted by precedence (highest first)', () => {
|
||||
merger.addSource({ name: 'low', config: {}, precedence: 1 });
|
||||
merger.addSource({ name: 'high', config: {}, precedence: 3 });
|
||||
merger.addSource({ name: 'medium', config: {}, precedence: 2 });
|
||||
|
||||
const sources = merger.getSources();
|
||||
|
||||
expect(sources[0].name).toBe('high');
|
||||
expect(sources[1].name).toBe('medium');
|
||||
expect(sources[2].name).toBe('low');
|
||||
});
|
||||
|
||||
it('should return a copy of sources array', () => {
|
||||
merger.addSource({ name: 'test', config: {}, precedence: 1 });
|
||||
|
||||
const sources1 = merger.getSources();
|
||||
const sources2 = merger.getSources();
|
||||
|
||||
expect(sources1).not.toBe(sources2); // Different array instances
|
||||
expect(sources1).toEqual(sources2); // Same content
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasSource', () => {
|
||||
it('should return true when source exists', () => {
|
||||
merger.addSource({ name: 'test', config: {}, precedence: 1 });
|
||||
|
||||
expect(merger.hasSource('test')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when source does not exist', () => {
|
||||
expect(merger.hasSource('nonexistent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeSource', () => {
|
||||
it('should remove source by name and return true', () => {
|
||||
merger.addSource({ name: 'test', config: {}, precedence: 1 });
|
||||
merger.addSource({ name: 'keep', config: {}, precedence: 2 });
|
||||
|
||||
const removed = merger.removeSource('test');
|
||||
|
||||
expect(removed).toBe(true);
|
||||
expect(merger.hasSource('test')).toBe(false);
|
||||
expect(merger.hasSource('keep')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when source does not exist', () => {
|
||||
const removed = merger.removeSource('nonexistent');
|
||||
|
||||
expect(removed).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle removing all sources', () => {
|
||||
merger.addSource({ name: 'test1', config: {}, precedence: 1 });
|
||||
merger.addSource({ name: 'test2', config: {}, precedence: 2 });
|
||||
|
||||
merger.removeSource('test1');
|
||||
merger.removeSource('test2');
|
||||
|
||||
expect(merger.getSources()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
118
packages/tm-core/src/config/services/config-merger.service.ts
Normal file
118
packages/tm-core/src/config/services/config-merger.service.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @fileoverview Configuration Merger Service
|
||||
* Responsible for merging configurations from multiple sources with precedence
|
||||
*/
|
||||
|
||||
import type { PartialConfiguration } from '../../interfaces/configuration.interface.js';
|
||||
|
||||
/**
|
||||
* Configuration source with precedence
|
||||
*/
|
||||
export interface ConfigSource {
|
||||
/** Source name for debugging */
|
||||
name: string;
|
||||
/** Configuration data from this source */
|
||||
config: PartialConfiguration;
|
||||
/** Precedence level (higher = more important) */
|
||||
precedence: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration precedence levels (higher number = higher priority)
|
||||
*/
|
||||
export const CONFIG_PRECEDENCE = {
|
||||
DEFAULTS: 0,
|
||||
GLOBAL: 1, // Reserved for future implementation
|
||||
LOCAL: 2,
|
||||
ENVIRONMENT: 3
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* ConfigMerger handles merging configurations with precedence rules
|
||||
* Single responsibility: Configuration merging logic
|
||||
*/
|
||||
export class ConfigMerger {
|
||||
private configSources: ConfigSource[] = [];
|
||||
|
||||
/**
|
||||
* Add a configuration source
|
||||
*/
|
||||
addSource(source: ConfigSource): void {
|
||||
this.configSources.push(source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all configuration sources
|
||||
*/
|
||||
clearSources(): void {
|
||||
this.configSources = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all configuration sources based on precedence
|
||||
*/
|
||||
merge(): PartialConfiguration {
|
||||
// Sort sources by precedence (lowest first)
|
||||
const sortedSources = [...this.configSources].sort(
|
||||
(a, b) => a.precedence - b.precedence
|
||||
);
|
||||
|
||||
// Merge from lowest to highest precedence
|
||||
let merged: PartialConfiguration = {};
|
||||
for (const source of sortedSources) {
|
||||
merged = this.deepMerge(merged, source.config);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge two configuration objects
|
||||
* Higher precedence values override lower ones
|
||||
*/
|
||||
private deepMerge(target: any, source: any): any {
|
||||
if (!source) return target;
|
||||
if (!target) return source;
|
||||
|
||||
const result = { ...target };
|
||||
|
||||
for (const key in source) {
|
||||
if (source[key] === null || source[key] === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||
result[key] = this.deepMerge(result[key] || {}, source[key]);
|
||||
} else {
|
||||
result[key] = source[key];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration sources for debugging
|
||||
*/
|
||||
getSources(): ConfigSource[] {
|
||||
return [...this.configSources].sort((a, b) => b.precedence - a.precedence);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a source exists
|
||||
*/
|
||||
hasSource(name: string): boolean {
|
||||
return this.configSources.some((source) => source.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a source by name
|
||||
*/
|
||||
removeSource(name: string): boolean {
|
||||
const initialLength = this.configSources.length;
|
||||
this.configSources = this.configSources.filter(
|
||||
(source) => source.name !== name
|
||||
);
|
||||
return this.configSources.length < initialLength;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for ConfigPersistence service
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { ConfigPersistence } from './config-persistence.service.js';
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
promises: {
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
access: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
rename: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
describe('ConfigPersistence', () => {
|
||||
let persistence: ConfigPersistence;
|
||||
const testProjectRoot = '/test/project';
|
||||
|
||||
beforeEach(() => {
|
||||
persistence = new ConfigPersistence(testProjectRoot);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('saveConfig', () => {
|
||||
const mockConfig = {
|
||||
models: { main: 'test-model' },
|
||||
storage: { type: 'file' as const }
|
||||
};
|
||||
|
||||
it('should save configuration to file', async () => {
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
await persistence.saveConfig(mockConfig);
|
||||
|
||||
expect(fs.mkdir).toHaveBeenCalledWith('/test/project/.taskmaster', {
|
||||
recursive: true
|
||||
});
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
'/test/project/.taskmaster/config.json',
|
||||
JSON.stringify(mockConfig, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should use atomic write when specified', async () => {
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.rename).mockResolvedValue(undefined);
|
||||
|
||||
await persistence.saveConfig(mockConfig, { atomic: true });
|
||||
|
||||
// Should write to temp file first
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
'/test/project/.taskmaster/config.json.tmp',
|
||||
JSON.stringify(mockConfig, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Then rename to final location
|
||||
expect(fs.rename).toHaveBeenCalledWith(
|
||||
'/test/project/.taskmaster/config.json.tmp',
|
||||
'/test/project/.taskmaster/config.json'
|
||||
);
|
||||
});
|
||||
|
||||
it('should create backup when requested', async () => {
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined); // Config exists
|
||||
vi.mocked(fs.readFile).mockResolvedValue('{"old": "config"}');
|
||||
vi.mocked(fs.readdir).mockResolvedValue([]);
|
||||
|
||||
await persistence.saveConfig(mockConfig, { createBackup: true });
|
||||
|
||||
// Should create backup directory
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(
|
||||
'/test/project/.taskmaster/backups',
|
||||
{ recursive: true }
|
||||
);
|
||||
|
||||
// Should read existing config for backup
|
||||
expect(fs.readFile).toHaveBeenCalledWith(
|
||||
'/test/project/.taskmaster/config.json',
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Should write backup file
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/test/project/.taskmaster/backups/config-'),
|
||||
'{"old": "config"}',
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not create backup if config does not exist', async () => {
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error('Not found'));
|
||||
|
||||
await persistence.saveConfig(mockConfig, { createBackup: true });
|
||||
|
||||
// Should not read or create backup
|
||||
expect(fs.readFile).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).toHaveBeenCalledTimes(1); // Only the main config
|
||||
});
|
||||
|
||||
it('should throw TaskMasterError on save failure', async () => {
|
||||
vi.mocked(fs.mkdir).mockRejectedValue(new Error('Disk full'));
|
||||
|
||||
await expect(persistence.saveConfig(mockConfig)).rejects.toThrow(
|
||||
'Failed to save configuration'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('configExists', () => {
|
||||
it('should return true when config exists', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
|
||||
const exists = await persistence.configExists();
|
||||
|
||||
expect(fs.access).toHaveBeenCalledWith(
|
||||
'/test/project/.taskmaster/config.json'
|
||||
);
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when config does not exist', async () => {
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error('Not found'));
|
||||
|
||||
const exists = await persistence.configExists();
|
||||
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteConfig', () => {
|
||||
it('should delete configuration file', async () => {
|
||||
vi.mocked(fs.unlink).mockResolvedValue(undefined);
|
||||
|
||||
await persistence.deleteConfig();
|
||||
|
||||
expect(fs.unlink).toHaveBeenCalledWith(
|
||||
'/test/project/.taskmaster/config.json'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw when file does not exist', async () => {
|
||||
const error = new Error('File not found') as any;
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.unlink).mockRejectedValue(error);
|
||||
|
||||
await expect(persistence.deleteConfig()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw TaskMasterError for other errors', async () => {
|
||||
vi.mocked(fs.unlink).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
await expect(persistence.deleteConfig()).rejects.toThrow(
|
||||
'Failed to delete configuration'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBackups', () => {
|
||||
it('should return list of backup files sorted newest first', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
'config-2024-01-01T10-00-00-000Z.json',
|
||||
'config-2024-01-02T10-00-00-000Z.json',
|
||||
'config-2024-01-03T10-00-00-000Z.json',
|
||||
'other-file.txt'
|
||||
] as any);
|
||||
|
||||
const backups = await persistence.getBackups();
|
||||
|
||||
expect(fs.readdir).toHaveBeenCalledWith(
|
||||
'/test/project/.taskmaster/backups'
|
||||
);
|
||||
|
||||
expect(backups).toEqual([
|
||||
'config-2024-01-03T10-00-00-000Z.json',
|
||||
'config-2024-01-02T10-00-00-000Z.json',
|
||||
'config-2024-01-01T10-00-00-000Z.json'
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return empty array when backup directory does not exist', async () => {
|
||||
vi.mocked(fs.readdir).mockRejectedValue(new Error('Not found'));
|
||||
|
||||
const backups = await persistence.getBackups();
|
||||
|
||||
expect(backups).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter out non-backup files', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
'config-2024-01-01T10-00-00-000Z.json',
|
||||
'README.md',
|
||||
'.DS_Store',
|
||||
'config.json',
|
||||
'config-backup.json' // Wrong format
|
||||
] as any);
|
||||
|
||||
const backups = await persistence.getBackups();
|
||||
|
||||
expect(backups).toEqual(['config-2024-01-01T10-00-00-000Z.json']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreFromBackup', () => {
|
||||
const backupFile = 'config-2024-01-01T10-00-00-000Z.json';
|
||||
const backupContent = '{"restored": "config"}';
|
||||
|
||||
it('should restore configuration from backup', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(backupContent);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
await persistence.restoreFromBackup(backupFile);
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalledWith(
|
||||
`/test/project/.taskmaster/backups/${backupFile}`,
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
'/test/project/.taskmaster/config.json',
|
||||
backupContent,
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw TaskMasterError when backup file not found', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('File not found'));
|
||||
|
||||
await expect(
|
||||
persistence.restoreFromBackup('nonexistent.json')
|
||||
).rejects.toThrow('Failed to restore from backup');
|
||||
});
|
||||
|
||||
it('should throw TaskMasterError on write failure', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(backupContent);
|
||||
vi.mocked(fs.writeFile).mockRejectedValue(new Error('Disk full'));
|
||||
|
||||
await expect(persistence.restoreFromBackup(backupFile)).rejects.toThrow(
|
||||
'Failed to restore from backup'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backup management', () => {
|
||||
it('should clean old backups when limit exceeded', async () => {
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readFile).mockResolvedValue('{"old": "config"}');
|
||||
vi.mocked(fs.unlink).mockResolvedValue(undefined);
|
||||
|
||||
// Mock 7 existing backups
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
'config-2024-01-01T10-00-00-000Z.json',
|
||||
'config-2024-01-02T10-00-00-000Z.json',
|
||||
'config-2024-01-03T10-00-00-000Z.json',
|
||||
'config-2024-01-04T10-00-00-000Z.json',
|
||||
'config-2024-01-05T10-00-00-000Z.json',
|
||||
'config-2024-01-06T10-00-00-000Z.json',
|
||||
'config-2024-01-07T10-00-00-000Z.json'
|
||||
] as any);
|
||||
|
||||
await persistence.saveConfig({}, { createBackup: true });
|
||||
|
||||
// Should delete oldest backups (keeping 5)
|
||||
expect(fs.unlink).toHaveBeenCalledWith(
|
||||
'/test/project/.taskmaster/backups/config-2024-01-01T10-00-00-000Z.json'
|
||||
);
|
||||
expect(fs.unlink).toHaveBeenCalledWith(
|
||||
'/test/project/.taskmaster/backups/config-2024-01-02T10-00-00-000Z.json'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle backup cleanup errors gracefully', async () => {
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readFile).mockResolvedValue('{"old": "config"}');
|
||||
vi.mocked(fs.readdir).mockResolvedValue(['config-old.json'] as any);
|
||||
vi.mocked(fs.unlink).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
// Mock console.warn to verify it's called
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
// Should not throw even if cleanup fails
|
||||
await expect(
|
||||
persistence.saveConfig({}, { createBackup: true })
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Failed to clean old backups:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* @fileoverview Configuration Persistence Service
|
||||
* Handles saving and backup of configuration files
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { PartialConfiguration } from '../../interfaces/configuration.interface.js';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
TaskMasterError
|
||||
} from '../../errors/task-master-error.js';
|
||||
|
||||
/**
|
||||
* Persistence options
|
||||
*/
|
||||
export interface PersistenceOptions {
|
||||
/** Enable backup before saving */
|
||||
createBackup?: boolean;
|
||||
/** Maximum number of backups to keep */
|
||||
maxBackups?: number;
|
||||
/** Use atomic write operations */
|
||||
atomic?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfigPersistence handles all configuration file I/O operations
|
||||
* Single responsibility: Configuration persistence
|
||||
*/
|
||||
export class ConfigPersistence {
|
||||
private localConfigPath: string;
|
||||
private backupDir: string;
|
||||
|
||||
constructor(projectRoot: string) {
|
||||
this.localConfigPath = path.join(projectRoot, '.taskmaster', 'config.json');
|
||||
this.backupDir = path.join(projectRoot, '.taskmaster', 'backups');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save configuration to file
|
||||
*/
|
||||
async saveConfig(
|
||||
config: PartialConfiguration,
|
||||
options: PersistenceOptions = {}
|
||||
): Promise<void> {
|
||||
const { createBackup = false, atomic = true } = options;
|
||||
|
||||
try {
|
||||
// Create backup if requested
|
||||
if (createBackup && (await this.configExists())) {
|
||||
await this.createBackup();
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
const configDir = path.dirname(this.localConfigPath);
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
const jsonContent = JSON.stringify(config, null, 2);
|
||||
|
||||
if (atomic) {
|
||||
// Atomic write: write to temp file then rename
|
||||
const tempPath = `${this.localConfigPath}.tmp`;
|
||||
await fs.writeFile(tempPath, jsonContent, 'utf-8');
|
||||
await fs.rename(tempPath, this.localConfigPath);
|
||||
} else {
|
||||
// Direct write
|
||||
await fs.writeFile(this.localConfigPath, jsonContent, 'utf-8');
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to save configuration',
|
||||
ERROR_CODES.CONFIG_ERROR,
|
||||
{ configPath: this.localConfigPath },
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup of the current configuration
|
||||
*/
|
||||
private async createBackup(): Promise<string> {
|
||||
try {
|
||||
await fs.mkdir(this.backupDir, { recursive: true });
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupPath = path.join(this.backupDir, `config-${timestamp}.json`);
|
||||
|
||||
const configContent = await fs.readFile(this.localConfigPath, 'utf-8');
|
||||
await fs.writeFile(backupPath, configContent, 'utf-8');
|
||||
|
||||
// Clean old backups
|
||||
await this.cleanOldBackups();
|
||||
|
||||
return backupPath;
|
||||
} catch (error) {
|
||||
console.warn('Failed to create backup:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean old backup files
|
||||
*/
|
||||
private async cleanOldBackups(maxBackups = 5): Promise<void> {
|
||||
try {
|
||||
const files = await fs.readdir(this.backupDir);
|
||||
const backupFiles = files
|
||||
.filter((f) => f.startsWith('config-') && f.endsWith('.json'))
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
// Remove old backups
|
||||
const toDelete = backupFiles.slice(maxBackups);
|
||||
for (const file of toDelete) {
|
||||
await fs.unlink(path.join(this.backupDir, file));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to clean old backups:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if config file exists
|
||||
*/
|
||||
async configExists(): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(this.localConfigPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete configuration file
|
||||
*/
|
||||
async deleteConfig(): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(this.localConfigPath);
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw new TaskMasterError(
|
||||
'Failed to delete configuration',
|
||||
ERROR_CODES.CONFIG_ERROR,
|
||||
{ configPath: this.localConfigPath },
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available backups
|
||||
*/
|
||||
async getBackups(): Promise<string[]> {
|
||||
try {
|
||||
const files = await fs.readdir(this.backupDir);
|
||||
return files
|
||||
.filter((f) => f.startsWith('config-') && f.endsWith('.json'))
|
||||
.sort()
|
||||
.reverse();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from a backup
|
||||
*/
|
||||
async restoreFromBackup(backupFile: string): Promise<void> {
|
||||
const backupPath = path.join(this.backupDir, backupFile);
|
||||
|
||||
try {
|
||||
const backupContent = await fs.readFile(backupPath, 'utf-8');
|
||||
await fs.writeFile(this.localConfigPath, backupContent, 'utf-8');
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to restore from backup',
|
||||
ERROR_CODES.CONFIG_ERROR,
|
||||
{ backupPath },
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for EnvironmentConfigProvider service
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { EnvironmentConfigProvider } from './environment-config-provider.service.js';
|
||||
|
||||
describe('EnvironmentConfigProvider', () => {
|
||||
let provider: EnvironmentConfigProvider;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear all TASKMASTER_ env vars
|
||||
Object.keys(process.env).forEach((key) => {
|
||||
if (key.startsWith('TASKMASTER_')) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
provider = new EnvironmentConfigProvider();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
describe('loadConfig', () => {
|
||||
it('should load configuration from environment variables', () => {
|
||||
process.env.TASKMASTER_STORAGE_TYPE = 'api';
|
||||
process.env.TASKMASTER_API_ENDPOINT = 'https://api.example.com';
|
||||
process.env.TASKMASTER_MODEL_MAIN = 'gpt-4';
|
||||
|
||||
const config = provider.loadConfig();
|
||||
|
||||
expect(config).toEqual({
|
||||
storage: {
|
||||
type: 'api',
|
||||
apiEndpoint: 'https://api.example.com'
|
||||
},
|
||||
models: {
|
||||
main: 'gpt-4'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty object when no env vars are set', () => {
|
||||
const config = provider.loadConfig();
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
|
||||
it('should skip runtime state variables', () => {
|
||||
process.env.TASKMASTER_TAG = 'feature-branch';
|
||||
process.env.TASKMASTER_MODEL_MAIN = 'claude-3';
|
||||
|
||||
const config = provider.loadConfig();
|
||||
|
||||
expect(config).toEqual({
|
||||
models: { main: 'claude-3' }
|
||||
});
|
||||
expect(config).not.toHaveProperty('activeTag');
|
||||
});
|
||||
|
||||
it('should validate storage type values', () => {
|
||||
// Mock console.warn to check validation
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
process.env.TASKMASTER_STORAGE_TYPE = 'invalid';
|
||||
|
||||
const config = provider.loadConfig();
|
||||
|
||||
expect(config).toEqual({});
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Invalid value for TASKMASTER_STORAGE_TYPE: invalid'
|
||||
);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should accept valid storage type values', () => {
|
||||
process.env.TASKMASTER_STORAGE_TYPE = 'file';
|
||||
let config = provider.loadConfig();
|
||||
expect(config.storage?.type).toBe('file');
|
||||
|
||||
process.env.TASKMASTER_STORAGE_TYPE = 'api';
|
||||
provider = new EnvironmentConfigProvider(); // Reset provider
|
||||
config = provider.loadConfig();
|
||||
expect(config.storage?.type).toBe('api');
|
||||
});
|
||||
|
||||
it('should handle nested configuration paths', () => {
|
||||
process.env.TASKMASTER_MODEL_MAIN = 'model1';
|
||||
process.env.TASKMASTER_MODEL_RESEARCH = 'model2';
|
||||
process.env.TASKMASTER_MODEL_FALLBACK = 'model3';
|
||||
|
||||
const config = provider.loadConfig();
|
||||
|
||||
expect(config).toEqual({
|
||||
models: {
|
||||
main: 'model1',
|
||||
research: 'model2',
|
||||
fallback: 'model3'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle custom response language', () => {
|
||||
process.env.TASKMASTER_RESPONSE_LANGUAGE = 'Spanish';
|
||||
|
||||
const config = provider.loadConfig();
|
||||
|
||||
expect(config).toEqual({
|
||||
custom: {
|
||||
responseLanguage: 'Spanish'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore empty string values', () => {
|
||||
process.env.TASKMASTER_MODEL_MAIN = '';
|
||||
process.env.TASKMASTER_MODEL_FALLBACK = 'fallback-model';
|
||||
|
||||
const config = provider.loadConfig();
|
||||
|
||||
expect(config).toEqual({
|
||||
models: {
|
||||
fallback: 'fallback-model'
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRuntimeState', () => {
|
||||
it('should extract runtime state variables', () => {
|
||||
process.env.TASKMASTER_TAG = 'develop';
|
||||
process.env.TASKMASTER_MODEL_MAIN = 'model'; // Should not be included
|
||||
|
||||
const state = provider.getRuntimeState();
|
||||
|
||||
expect(state).toEqual({
|
||||
activeTag: 'develop'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty object when no runtime state vars', () => {
|
||||
process.env.TASKMASTER_MODEL_MAIN = 'model';
|
||||
|
||||
const state = provider.getRuntimeState();
|
||||
|
||||
expect(state).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasEnvVar', () => {
|
||||
it('should return true when env var exists', () => {
|
||||
process.env.TASKMASTER_MODEL_MAIN = 'test';
|
||||
|
||||
expect(provider.hasEnvVar('TASKMASTER_MODEL_MAIN')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when env var does not exist', () => {
|
||||
expect(provider.hasEnvVar('TASKMASTER_NONEXISTENT')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined values', () => {
|
||||
process.env.TASKMASTER_TEST = undefined as any;
|
||||
|
||||
expect(provider.hasEnvVar('TASKMASTER_TEST')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllTaskmasterEnvVars', () => {
|
||||
it('should return all TASKMASTER_ prefixed variables', () => {
|
||||
process.env.TASKMASTER_VAR1 = 'value1';
|
||||
process.env.TASKMASTER_VAR2 = 'value2';
|
||||
process.env.OTHER_VAR = 'other';
|
||||
process.env.TASK_MASTER = 'wrong-prefix';
|
||||
|
||||
const vars = provider.getAllTaskmasterEnvVars();
|
||||
|
||||
expect(vars).toEqual({
|
||||
TASKMASTER_VAR1: 'value1',
|
||||
TASKMASTER_VAR2: 'value2'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty object when no TASKMASTER_ vars', () => {
|
||||
process.env.OTHER_VAR = 'value';
|
||||
|
||||
const vars = provider.getAllTaskmasterEnvVars();
|
||||
|
||||
expect(vars).toEqual({});
|
||||
});
|
||||
|
||||
it('should filter out undefined values', () => {
|
||||
process.env.TASKMASTER_DEFINED = 'value';
|
||||
process.env.TASKMASTER_UNDEFINED = undefined as any;
|
||||
|
||||
const vars = provider.getAllTaskmasterEnvVars();
|
||||
|
||||
expect(vars).toEqual({
|
||||
TASKMASTER_DEFINED: 'value'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom mappings', () => {
|
||||
it('should use custom mappings when provided', () => {
|
||||
const customMappings = [{ env: 'CUSTOM_VAR', path: ['custom', 'value'] }];
|
||||
|
||||
const customProvider = new EnvironmentConfigProvider(customMappings);
|
||||
process.env.CUSTOM_VAR = 'test-value';
|
||||
|
||||
const config = customProvider.loadConfig();
|
||||
|
||||
expect(config).toEqual({
|
||||
custom: {
|
||||
value: 'test-value'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should add new mapping with addMapping', () => {
|
||||
process.env.NEW_MAPPING = 'new-value';
|
||||
|
||||
provider.addMapping({
|
||||
env: 'NEW_MAPPING',
|
||||
path: ['new', 'mapping']
|
||||
});
|
||||
|
||||
const config = provider.loadConfig();
|
||||
|
||||
expect(config).toHaveProperty('new.mapping', 'new-value');
|
||||
});
|
||||
|
||||
it('should return current mappings with getMappings', () => {
|
||||
const mappings = provider.getMappings();
|
||||
|
||||
expect(mappings).toBeInstanceOf(Array);
|
||||
expect(mappings.length).toBeGreaterThan(0);
|
||||
|
||||
// Check for some expected mappings
|
||||
const envNames = mappings.map((m) => m.env);
|
||||
expect(envNames).toContain('TASKMASTER_STORAGE_TYPE');
|
||||
expect(envNames).toContain('TASKMASTER_MODEL_MAIN');
|
||||
expect(envNames).toContain('TASKMASTER_TAG');
|
||||
});
|
||||
|
||||
it('should return copy of mappings array', () => {
|
||||
const mappings1 = provider.getMappings();
|
||||
const mappings2 = provider.getMappings();
|
||||
|
||||
expect(mappings1).not.toBe(mappings2); // Different instances
|
||||
expect(mappings1).toEqual(mappings2); // Same content
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('should validate values when validator is provided', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
process.env.TASKMASTER_STORAGE_TYPE = 'database'; // Invalid
|
||||
|
||||
const config = provider.loadConfig();
|
||||
|
||||
expect(config).toEqual({});
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Invalid value for TASKMASTER_STORAGE_TYPE: database'
|
||||
);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should accept values that pass validation', () => {
|
||||
process.env.TASKMASTER_STORAGE_TYPE = 'file';
|
||||
|
||||
const config = provider.loadConfig();
|
||||
|
||||
expect(config.storage?.type).toBe('file');
|
||||
});
|
||||
|
||||
it('should work with custom validators', () => {
|
||||
const customProvider = new EnvironmentConfigProvider([
|
||||
{
|
||||
env: 'CUSTOM_NUMBER',
|
||||
path: ['custom', 'number'],
|
||||
validate: (v) => !isNaN(Number(v))
|
||||
}
|
||||
]);
|
||||
|
||||
process.env.CUSTOM_NUMBER = '123';
|
||||
let config = customProvider.loadConfig();
|
||||
expect(config.custom?.number).toBe('123');
|
||||
|
||||
process.env.CUSTOM_NUMBER = 'not-a-number';
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
customProvider = new EnvironmentConfigProvider([
|
||||
{
|
||||
env: 'CUSTOM_NUMBER',
|
||||
path: ['custom', 'number'],
|
||||
validate: (v) => !isNaN(Number(v))
|
||||
}
|
||||
]);
|
||||
config = customProvider.loadConfig();
|
||||
expect(config).toEqual({});
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle special characters in values', () => {
|
||||
process.env.TASKMASTER_API_ENDPOINT =
|
||||
'https://api.example.com/v1?key=abc&token=xyz';
|
||||
process.env.TASKMASTER_API_TOKEN = 'Bearer abc123!@#$%^&*()';
|
||||
|
||||
const config = provider.loadConfig();
|
||||
|
||||
expect(config.storage?.apiEndpoint).toBe(
|
||||
'https://api.example.com/v1?key=abc&token=xyz'
|
||||
);
|
||||
expect(config.storage?.apiAccessToken).toBe('Bearer abc123!@#$%^&*()');
|
||||
});
|
||||
|
||||
it('should handle whitespace in values', () => {
|
||||
process.env.TASKMASTER_MODEL_MAIN = ' claude-3 ';
|
||||
|
||||
const config = provider.loadConfig();
|
||||
|
||||
// Note: We're not trimming, preserving the value as-is
|
||||
expect(config.models?.main).toBe(' claude-3 ');
|
||||
});
|
||||
|
||||
it('should handle very long values', () => {
|
||||
const longValue = 'a'.repeat(10000);
|
||||
process.env.TASKMASTER_API_TOKEN = longValue;
|
||||
|
||||
const config = provider.loadConfig();
|
||||
|
||||
expect(config.storage?.apiAccessToken).toBe(longValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @fileoverview Environment Configuration Provider
|
||||
* Extracts configuration from environment variables
|
||||
*/
|
||||
|
||||
import type { PartialConfiguration } from '../../interfaces/configuration.interface.js';
|
||||
|
||||
/**
|
||||
* Environment variable mapping definition
|
||||
*/
|
||||
interface EnvMapping {
|
||||
/** Environment variable name */
|
||||
env: string;
|
||||
/** Path in configuration object */
|
||||
path: readonly string[];
|
||||
/** Optional validator function */
|
||||
validate?: (value: string) => boolean;
|
||||
/** Whether this is runtime state (not configuration) */
|
||||
isRuntimeState?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* EnvironmentConfigProvider extracts configuration from environment variables
|
||||
* Single responsibility: Environment variable configuration extraction
|
||||
*/
|
||||
export class EnvironmentConfigProvider {
|
||||
/**
|
||||
* Default environment variable mappings
|
||||
*/
|
||||
private static readonly DEFAULT_MAPPINGS: EnvMapping[] = [
|
||||
{
|
||||
env: 'TASKMASTER_STORAGE_TYPE',
|
||||
path: ['storage', 'type'],
|
||||
validate: (v: string) => ['file', 'api'].includes(v)
|
||||
},
|
||||
{ env: 'TASKMASTER_API_ENDPOINT', path: ['storage', 'apiEndpoint'] },
|
||||
{ env: 'TASKMASTER_API_TOKEN', path: ['storage', 'apiAccessToken'] },
|
||||
{ env: 'TASKMASTER_MODEL_MAIN', path: ['models', 'main'] },
|
||||
{ env: 'TASKMASTER_MODEL_RESEARCH', path: ['models', 'research'] },
|
||||
{ env: 'TASKMASTER_MODEL_FALLBACK', path: ['models', 'fallback'] },
|
||||
{
|
||||
env: 'TASKMASTER_RESPONSE_LANGUAGE',
|
||||
path: ['custom', 'responseLanguage']
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Runtime state mappings (separate from configuration)
|
||||
*/
|
||||
private static readonly RUNTIME_STATE_MAPPINGS: EnvMapping[] = [
|
||||
{ env: 'TASKMASTER_TAG', path: ['activeTag'], isRuntimeState: true }
|
||||
];
|
||||
|
||||
private mappings: EnvMapping[];
|
||||
|
||||
constructor(customMappings?: EnvMapping[]) {
|
||||
this.mappings = customMappings || [
|
||||
...EnvironmentConfigProvider.DEFAULT_MAPPINGS,
|
||||
...EnvironmentConfigProvider.RUNTIME_STATE_MAPPINGS
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from environment variables
|
||||
*/
|
||||
loadConfig(): PartialConfiguration {
|
||||
const config: PartialConfiguration = {};
|
||||
|
||||
for (const mapping of this.mappings) {
|
||||
// Skip runtime state variables
|
||||
if (mapping.isRuntimeState) continue;
|
||||
|
||||
const value = process.env[mapping.env];
|
||||
if (!value) continue;
|
||||
|
||||
// Validate value if validator is provided
|
||||
if (mapping.validate && !mapping.validate(value)) {
|
||||
console.warn(`Invalid value for ${mapping.env}: ${value}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Set the value in the config object
|
||||
this.setNestedProperty(config, mapping.path, value);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get runtime state from environment variables
|
||||
*/
|
||||
getRuntimeState(): Record<string, string> {
|
||||
const state: Record<string, string> = {};
|
||||
|
||||
for (const mapping of this.mappings) {
|
||||
if (!mapping.isRuntimeState) continue;
|
||||
|
||||
const value = process.env[mapping.env];
|
||||
if (value) {
|
||||
const key = mapping.path[mapping.path.length - 1];
|
||||
state[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to set a nested property in an object
|
||||
*/
|
||||
private setNestedProperty(
|
||||
obj: any,
|
||||
path: readonly string[],
|
||||
value: any
|
||||
): void {
|
||||
const lastKey = path[path.length - 1];
|
||||
const keys = path.slice(0, -1);
|
||||
|
||||
let current = obj;
|
||||
for (const key of keys) {
|
||||
if (!current[key]) {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
|
||||
current[lastKey] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an environment variable is set
|
||||
*/
|
||||
hasEnvVar(envName: string): boolean {
|
||||
return envName in process.env && process.env[envName] !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all environment variables that match our prefix
|
||||
*/
|
||||
getAllTaskmasterEnvVars(): Record<string, string> {
|
||||
const vars: Record<string, string> = {};
|
||||
const prefix = 'TASKMASTER_';
|
||||
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (key.startsWith(prefix) && value !== undefined) {
|
||||
vars[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom mapping
|
||||
*/
|
||||
addMapping(mapping: EnvMapping): void {
|
||||
this.mappings.push(mapping);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current mappings
|
||||
*/
|
||||
getMappings(): EnvMapping[] {
|
||||
return [...this.mappings];
|
||||
}
|
||||
}
|
||||
20
packages/tm-core/src/config/services/index.ts
Normal file
20
packages/tm-core/src/config/services/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @fileoverview Configuration services exports
|
||||
* Export all configuration-related services
|
||||
*/
|
||||
|
||||
export { ConfigLoader } from './config-loader.service.js';
|
||||
export {
|
||||
ConfigMerger,
|
||||
CONFIG_PRECEDENCE,
|
||||
type ConfigSource
|
||||
} from './config-merger.service.js';
|
||||
export {
|
||||
RuntimeStateManager,
|
||||
type RuntimeState
|
||||
} from './runtime-state-manager.service.js';
|
||||
export {
|
||||
ConfigPersistence,
|
||||
type PersistenceOptions
|
||||
} from './config-persistence.service.js';
|
||||
export { EnvironmentConfigProvider } from './environment-config-provider.service.js';
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for RuntimeStateManager service
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { RuntimeStateManager } from './runtime-state-manager.service.js';
|
||||
import { DEFAULT_CONFIG_VALUES } from '../../interfaces/configuration.interface.js';
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
promises: {
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
unlink: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
describe('RuntimeStateManager', () => {
|
||||
let stateManager: RuntimeStateManager;
|
||||
const testProjectRoot = '/test/project';
|
||||
|
||||
beforeEach(() => {
|
||||
stateManager = new RuntimeStateManager(testProjectRoot);
|
||||
vi.clearAllMocks();
|
||||
// Clear environment variables
|
||||
delete process.env.TASKMASTER_TAG;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.TASKMASTER_TAG;
|
||||
});
|
||||
|
||||
describe('loadState', () => {
|
||||
it('should load state from file', async () => {
|
||||
const mockState = {
|
||||
activeTag: 'feature-branch',
|
||||
lastUpdated: '2024-01-01T00:00:00.000Z',
|
||||
metadata: { test: 'data' }
|
||||
};
|
||||
|
||||
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockState));
|
||||
|
||||
const state = await stateManager.loadState();
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalledWith(
|
||||
'/test/project/.taskmaster/state.json',
|
||||
'utf-8'
|
||||
);
|
||||
expect(state.activeTag).toBe('feature-branch');
|
||||
expect(state.metadata).toEqual({ test: 'data' });
|
||||
});
|
||||
|
||||
it('should override with environment variable if set', async () => {
|
||||
const mockState = { activeTag: 'file-tag' };
|
||||
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockState));
|
||||
|
||||
process.env.TASKMASTER_TAG = 'env-tag';
|
||||
|
||||
const state = await stateManager.loadState();
|
||||
|
||||
expect(state.activeTag).toBe('env-tag');
|
||||
});
|
||||
|
||||
it('should use default state when file does not exist', async () => {
|
||||
const error = new Error('File not found') as any;
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
|
||||
const state = await stateManager.loadState();
|
||||
|
||||
expect(state.activeTag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG);
|
||||
});
|
||||
|
||||
it('should use environment variable when file does not exist', async () => {
|
||||
const error = new Error('File not found') as any;
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
|
||||
process.env.TASKMASTER_TAG = 'env-tag';
|
||||
|
||||
const state = await stateManager.loadState();
|
||||
|
||||
expect(state.activeTag).toBe('env-tag');
|
||||
});
|
||||
|
||||
it('should handle file read errors gracefully', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const state = await stateManager.loadState();
|
||||
|
||||
expect(state.activeTag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG);
|
||||
});
|
||||
|
||||
it('should handle invalid JSON gracefully', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('invalid json');
|
||||
|
||||
// Mock console.warn to avoid noise in tests
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const state = await stateManager.loadState();
|
||||
|
||||
expect(state.activeTag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveState', () => {
|
||||
it('should save state to file with timestamp', async () => {
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
// Set a specific state
|
||||
await stateManager.setActiveTag('test-tag');
|
||||
|
||||
// Verify mkdir was called
|
||||
expect(fs.mkdir).toHaveBeenCalledWith('/test/project/.taskmaster', {
|
||||
recursive: true
|
||||
});
|
||||
|
||||
// Verify writeFile was called with correct data
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
'/test/project/.taskmaster/state.json',
|
||||
expect.stringContaining('"activeTag":"test-tag"'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Verify timestamp is included
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('"lastUpdated"'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw TaskMasterError on save failure', async () => {
|
||||
vi.mocked(fs.mkdir).mockRejectedValue(new Error('Disk full'));
|
||||
|
||||
await expect(stateManager.saveState()).rejects.toThrow(
|
||||
'Failed to save runtime state'
|
||||
);
|
||||
});
|
||||
|
||||
it('should format JSON with proper indentation', async () => {
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
await stateManager.saveState();
|
||||
|
||||
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
|
||||
const jsonContent = writeCall[1] as string;
|
||||
|
||||
// Check for 2-space indentation
|
||||
expect(jsonContent).toMatch(/\n /);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActiveTag', () => {
|
||||
it('should return current active tag', () => {
|
||||
const tag = stateManager.getActiveTag();
|
||||
expect(tag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG);
|
||||
});
|
||||
|
||||
it('should return updated tag after setActiveTag', async () => {
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
await stateManager.setActiveTag('new-tag');
|
||||
|
||||
expect(stateManager.getActiveTag()).toBe('new-tag');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setActiveTag', () => {
|
||||
it('should update active tag and save state', async () => {
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
await stateManager.setActiveTag('feature-xyz');
|
||||
|
||||
expect(stateManager.getActiveTag()).toBe('feature-xyz');
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getState', () => {
|
||||
it('should return copy of current state', () => {
|
||||
const state1 = stateManager.getState();
|
||||
const state2 = stateManager.getState();
|
||||
|
||||
expect(state1).not.toBe(state2); // Different instances
|
||||
expect(state1).toEqual(state2); // Same content
|
||||
expect(state1.activeTag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMetadata', () => {
|
||||
it('should update metadata and save state', async () => {
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
await stateManager.updateMetadata({ key1: 'value1' });
|
||||
|
||||
const state = stateManager.getState();
|
||||
expect(state.metadata).toEqual({ key1: 'value1' });
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should merge metadata with existing values', async () => {
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
await stateManager.updateMetadata({ key1: 'value1' });
|
||||
await stateManager.updateMetadata({ key2: 'value2' });
|
||||
|
||||
const state = stateManager.getState();
|
||||
expect(state.metadata).toEqual({
|
||||
key1: 'value1',
|
||||
key2: 'value2'
|
||||
});
|
||||
});
|
||||
|
||||
it('should override existing metadata values', async () => {
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
await stateManager.updateMetadata({ key1: 'value1' });
|
||||
await stateManager.updateMetadata({ key1: 'value2' });
|
||||
|
||||
const state = stateManager.getState();
|
||||
expect(state.metadata).toEqual({ key1: 'value2' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearState', () => {
|
||||
it('should delete state file and reset to defaults', async () => {
|
||||
vi.mocked(fs.unlink).mockResolvedValue(undefined);
|
||||
|
||||
await stateManager.clearState();
|
||||
|
||||
expect(fs.unlink).toHaveBeenCalledWith(
|
||||
'/test/project/.taskmaster/state.json'
|
||||
);
|
||||
expect(stateManager.getActiveTag()).toBe(
|
||||
DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG
|
||||
);
|
||||
expect(stateManager.getState().metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should ignore ENOENT errors when file does not exist', async () => {
|
||||
const error = new Error('File not found') as any;
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.unlink).mockRejectedValue(error);
|
||||
|
||||
await expect(stateManager.clearState()).resolves.not.toThrow();
|
||||
expect(stateManager.getActiveTag()).toBe(
|
||||
DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw other errors', async () => {
|
||||
vi.mocked(fs.unlink).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
await expect(stateManager.clearState()).rejects.toThrow(
|
||||
'Permission denied'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* @fileoverview Runtime State Manager Service
|
||||
* Manages runtime state separate from configuration
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
TaskMasterError
|
||||
} from '../../errors/task-master-error.js';
|
||||
import { DEFAULT_CONFIG_VALUES } from '../../interfaces/configuration.interface.js';
|
||||
|
||||
/**
|
||||
* Runtime state data structure
|
||||
*/
|
||||
export interface RuntimeState {
|
||||
/** Currently active tag */
|
||||
currentTag: string;
|
||||
/** Last updated timestamp */
|
||||
lastUpdated?: string;
|
||||
/** Additional metadata */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* RuntimeStateManager handles runtime state persistence
|
||||
* Single responsibility: Runtime state management (separate from config)
|
||||
*/
|
||||
export class RuntimeStateManager {
|
||||
private stateFilePath: string;
|
||||
private currentState: RuntimeState;
|
||||
|
||||
constructor(projectRoot: string) {
|
||||
this.stateFilePath = path.join(projectRoot, '.taskmaster', 'state.json');
|
||||
this.currentState = {
|
||||
currentTag: DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load runtime state from disk
|
||||
*/
|
||||
async loadState(): Promise<RuntimeState> {
|
||||
try {
|
||||
const stateData = await fs.readFile(this.stateFilePath, 'utf-8');
|
||||
const rawState = JSON.parse(stateData);
|
||||
|
||||
// Map legacy field names to current interface
|
||||
const state: RuntimeState = {
|
||||
currentTag:
|
||||
rawState.currentTag ||
|
||||
rawState.activeTag ||
|
||||
DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG,
|
||||
lastUpdated: rawState.lastUpdated,
|
||||
metadata: rawState.metadata
|
||||
};
|
||||
|
||||
// Apply environment variable override for current tag
|
||||
if (process.env.TASKMASTER_TAG) {
|
||||
state.currentTag = process.env.TASKMASTER_TAG;
|
||||
}
|
||||
|
||||
this.currentState = state;
|
||||
return state;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// State file doesn't exist, use defaults
|
||||
console.debug('No state.json found, using default state');
|
||||
|
||||
// Check environment variable
|
||||
if (process.env.TASKMASTER_TAG) {
|
||||
this.currentState.currentTag = process.env.TASKMASTER_TAG;
|
||||
}
|
||||
|
||||
return this.currentState;
|
||||
}
|
||||
|
||||
console.warn('Failed to load state file:', error.message);
|
||||
return this.currentState;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save runtime state to disk
|
||||
*/
|
||||
async saveState(): Promise<void> {
|
||||
const stateDir = path.dirname(this.stateFilePath);
|
||||
|
||||
try {
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
|
||||
const stateToSave = {
|
||||
...this.currentState,
|
||||
lastUpdated: new Date().toISOString()
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
this.stateFilePath,
|
||||
JSON.stringify(stateToSave, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to save runtime state',
|
||||
ERROR_CODES.CONFIG_ERROR,
|
||||
{ statePath: this.stateFilePath },
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active tag
|
||||
*/
|
||||
getCurrentTag(): string {
|
||||
return this.currentState.currentTag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current tag
|
||||
*/
|
||||
async setCurrentTag(tag: string): Promise<void> {
|
||||
this.currentState.currentTag = tag;
|
||||
await this.saveState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state
|
||||
*/
|
||||
getState(): RuntimeState {
|
||||
return { ...this.currentState };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update metadata
|
||||
*/
|
||||
async updateMetadata(metadata: Record<string, unknown>): Promise<void> {
|
||||
this.currentState.metadata = {
|
||||
...this.currentState.metadata,
|
||||
...metadata
|
||||
};
|
||||
await this.saveState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear state file
|
||||
*/
|
||||
async clearState(): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(this.stateFilePath);
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
this.currentState = {
|
||||
currentTag: DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG
|
||||
};
|
||||
}
|
||||
}
|
||||
75
packages/tm-core/src/constants/index.ts
Normal file
75
packages/tm-core/src/constants/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @fileoverview Constants for Task Master Core
|
||||
* Single source of truth for all constant values
|
||||
*/
|
||||
|
||||
import type {
|
||||
TaskStatus,
|
||||
TaskPriority,
|
||||
TaskComplexity
|
||||
} from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Valid task status values
|
||||
*/
|
||||
export const TASK_STATUSES: readonly TaskStatus[] = [
|
||||
'pending',
|
||||
'in-progress',
|
||||
'done',
|
||||
'deferred',
|
||||
'cancelled',
|
||||
'blocked',
|
||||
'review'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Valid task priority values
|
||||
*/
|
||||
export const TASK_PRIORITIES: readonly TaskPriority[] = [
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'critical'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Valid task complexity values
|
||||
*/
|
||||
export const TASK_COMPLEXITIES: readonly TaskComplexity[] = [
|
||||
'simple',
|
||||
'moderate',
|
||||
'complex',
|
||||
'very-complex'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Valid output formats for task display
|
||||
*/
|
||||
export const OUTPUT_FORMATS = ['text', 'json', 'compact'] as const;
|
||||
export type OutputFormat = (typeof OUTPUT_FORMATS)[number];
|
||||
|
||||
/**
|
||||
* Status icons for display
|
||||
*/
|
||||
export const STATUS_ICONS: Record<TaskStatus, string> = {
|
||||
done: '✓',
|
||||
'in-progress': '►',
|
||||
blocked: '⭕',
|
||||
pending: '○',
|
||||
deferred: '⏸',
|
||||
cancelled: '✗',
|
||||
review: '👁'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Status colors for display (using chalk color names)
|
||||
*/
|
||||
export const STATUS_COLORS: Record<TaskStatus, string> = {
|
||||
pending: 'yellow',
|
||||
'in-progress': 'blue',
|
||||
done: 'green',
|
||||
deferred: 'gray',
|
||||
cancelled: 'red',
|
||||
blocked: 'magenta',
|
||||
review: 'cyan'
|
||||
} as const;
|
||||
266
packages/tm-core/src/entities/task.entity.ts
Normal file
266
packages/tm-core/src/entities/task.entity.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* @fileoverview Task entity with business rules and domain logic
|
||||
*/
|
||||
|
||||
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
|
||||
import type {
|
||||
Subtask,
|
||||
Task,
|
||||
TaskPriority,
|
||||
TaskStatus
|
||||
} from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Task entity representing a task with business logic
|
||||
* Encapsulates validation and state management rules
|
||||
*/
|
||||
export class TaskEntity implements Task {
|
||||
readonly id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
dependencies: string[];
|
||||
details: string;
|
||||
testStrategy: string;
|
||||
subtasks: Subtask[];
|
||||
|
||||
// Optional properties
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
effort?: number;
|
||||
actualEffort?: number;
|
||||
tags?: string[];
|
||||
assignee?: string;
|
||||
complexity?: Task['complexity'];
|
||||
|
||||
constructor(data: Task | (Omit<Task, 'id'> & { id: number | string })) {
|
||||
this.validate(data);
|
||||
|
||||
// Always convert ID to string
|
||||
this.id = String(data.id);
|
||||
this.title = data.title;
|
||||
this.description = data.description;
|
||||
this.status = data.status;
|
||||
this.priority = data.priority;
|
||||
// Ensure dependency IDs are also strings
|
||||
this.dependencies = (data.dependencies || []).map((dep) => String(dep));
|
||||
this.details = data.details;
|
||||
this.testStrategy = data.testStrategy;
|
||||
// Normalize subtask IDs to strings
|
||||
this.subtasks = (data.subtasks || []).map((subtask) => ({
|
||||
...subtask,
|
||||
id: Number(subtask.id), // Keep subtask IDs as numbers per interface
|
||||
parentId: String(subtask.parentId)
|
||||
}));
|
||||
|
||||
// Optional properties
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
this.effort = data.effort;
|
||||
this.actualEffort = data.actualEffort;
|
||||
this.tags = data.tags;
|
||||
this.assignee = data.assignee;
|
||||
this.complexity = data.complexity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate task data
|
||||
*/
|
||||
private validate(
|
||||
data: Partial<Task> | Partial<Omit<Task, 'id'> & { id: number | string }>
|
||||
): void {
|
||||
if (
|
||||
data.id === undefined ||
|
||||
data.id === null ||
|
||||
(typeof data.id !== 'string' && typeof data.id !== 'number')
|
||||
) {
|
||||
throw new TaskMasterError(
|
||||
'Task ID is required and must be a string or number',
|
||||
ERROR_CODES.VALIDATION_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.title || data.title.trim().length === 0) {
|
||||
throw new TaskMasterError(
|
||||
'Task title is required',
|
||||
ERROR_CODES.VALIDATION_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.description || data.description.trim().length === 0) {
|
||||
throw new TaskMasterError(
|
||||
'Task description is required',
|
||||
ERROR_CODES.VALIDATION_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.isValidStatus(data.status)) {
|
||||
throw new TaskMasterError(
|
||||
`Invalid task status: ${data.status}`,
|
||||
ERROR_CODES.VALIDATION_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.isValidPriority(data.priority)) {
|
||||
throw new TaskMasterError(
|
||||
`Invalid task priority: ${data.priority}`,
|
||||
ERROR_CODES.VALIDATION_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if status is valid
|
||||
*/
|
||||
private isValidStatus(status: any): status is TaskStatus {
|
||||
return [
|
||||
'pending',
|
||||
'in-progress',
|
||||
'done',
|
||||
'deferred',
|
||||
'cancelled',
|
||||
'blocked',
|
||||
'review'
|
||||
].includes(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if priority is valid
|
||||
*/
|
||||
private isValidPriority(priority: any): priority is TaskPriority {
|
||||
return ['low', 'medium', 'high', 'critical'].includes(priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if task can be marked as complete
|
||||
*/
|
||||
canComplete(): boolean {
|
||||
// Cannot complete if status is already done or cancelled
|
||||
if (this.status === 'done' || this.status === 'cancelled') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cannot complete if blocked
|
||||
if (this.status === 'blocked') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if all subtasks are complete
|
||||
const allSubtasksComplete = this.subtasks.every(
|
||||
(subtask) => subtask.status === 'done' || subtask.status === 'cancelled'
|
||||
);
|
||||
|
||||
return allSubtasksComplete;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark task as complete
|
||||
*/
|
||||
markAsComplete(): void {
|
||||
if (!this.canComplete()) {
|
||||
throw new TaskMasterError(
|
||||
'Task cannot be marked as complete',
|
||||
ERROR_CODES.TASK_STATUS_ERROR,
|
||||
{
|
||||
taskId: this.id,
|
||||
currentStatus: this.status,
|
||||
hasIncompleteSubtasks: this.subtasks.some(
|
||||
(s) => s.status !== 'done' && s.status !== 'cancelled'
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.status = 'done';
|
||||
this.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if task has dependencies
|
||||
*/
|
||||
hasDependencies(): boolean {
|
||||
return this.dependencies.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if task has subtasks
|
||||
*/
|
||||
hasSubtasks(): boolean {
|
||||
return this.subtasks.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a subtask
|
||||
*/
|
||||
addSubtask(subtask: Omit<Subtask, 'id' | 'parentId'>): void {
|
||||
const nextId = this.subtasks.length + 1;
|
||||
this.subtasks.push({
|
||||
...subtask,
|
||||
id: nextId,
|
||||
parentId: this.id
|
||||
});
|
||||
this.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task status
|
||||
*/
|
||||
updateStatus(newStatus: TaskStatus): void {
|
||||
if (!this.isValidStatus(newStatus)) {
|
||||
throw new TaskMasterError(
|
||||
`Invalid status: ${newStatus}`,
|
||||
ERROR_CODES.VALIDATION_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
// Business rule: Cannot move from done to pending
|
||||
if (this.status === 'done' && newStatus === 'pending') {
|
||||
throw new TaskMasterError(
|
||||
'Cannot move completed task back to pending',
|
||||
ERROR_CODES.TASK_STATUS_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
this.status = newStatus;
|
||||
this.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert entity to plain object
|
||||
*/
|
||||
toJSON(): Task {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
status: this.status,
|
||||
priority: this.priority,
|
||||
dependencies: this.dependencies,
|
||||
details: this.details,
|
||||
testStrategy: this.testStrategy,
|
||||
subtasks: this.subtasks,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
effort: this.effort,
|
||||
actualEffort: this.actualEffort,
|
||||
tags: this.tags,
|
||||
assignee: this.assignee,
|
||||
complexity: this.complexity
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create TaskEntity from plain object
|
||||
*/
|
||||
static fromObject(data: Task): TaskEntity {
|
||||
return new TaskEntity(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple TaskEntities from array
|
||||
*/
|
||||
static fromArray(data: Task[]): TaskEntity[] {
|
||||
return data.map((task) => new TaskEntity(task));
|
||||
}
|
||||
}
|
||||
68
packages/tm-core/src/errors/index.ts
Normal file
68
packages/tm-core/src/errors/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @fileoverview Custom error classes for the tm-core package
|
||||
* This file exports all custom error types and error handling utilities
|
||||
*/
|
||||
|
||||
// Export the main TaskMasterError class
|
||||
export {
|
||||
TaskMasterError,
|
||||
ERROR_CODES,
|
||||
type ErrorCode,
|
||||
type ErrorContext,
|
||||
type SerializableError
|
||||
} from './task-master-error.js';
|
||||
|
||||
// Error implementations will be defined here
|
||||
// export * from './task-errors.js';
|
||||
// export * from './storage-errors.js';
|
||||
// export * from './provider-errors.js';
|
||||
// export * from './validation-errors.js';
|
||||
|
||||
// Placeholder exports - these will be implemented in later tasks
|
||||
|
||||
/**
|
||||
* Base error class for all tm-core errors
|
||||
* @deprecated This is a placeholder class that will be properly implemented in later tasks
|
||||
*/
|
||||
export class TmCoreError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TmCoreError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a task is not found
|
||||
* @deprecated This is a placeholder class that will be properly implemented in later tasks
|
||||
*/
|
||||
export class TaskNotFoundError extends TmCoreError {
|
||||
constructor(taskId: string) {
|
||||
super(`Task not found: ${taskId}`, 'TASK_NOT_FOUND');
|
||||
this.name = 'TaskNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when validation fails
|
||||
* @deprecated This is a placeholder class that will be properly implemented in later tasks
|
||||
*/
|
||||
export class ValidationError extends TmCoreError {
|
||||
constructor(message: string) {
|
||||
super(message, 'VALIDATION_ERROR');
|
||||
this.name = 'ValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when storage operations fail
|
||||
* @deprecated This is a placeholder class that will be properly implemented in later tasks
|
||||
*/
|
||||
export class StorageError extends TmCoreError {
|
||||
constructor(message: string) {
|
||||
super(message, 'STORAGE_ERROR');
|
||||
this.name = 'StorageError';
|
||||
}
|
||||
}
|
||||
328
packages/tm-core/src/errors/task-master-error.ts
Normal file
328
packages/tm-core/src/errors/task-master-error.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* @fileoverview Base error class for Task Master operations
|
||||
* Provides comprehensive error handling with metadata, context, and serialization support
|
||||
*/
|
||||
|
||||
/**
|
||||
* Error codes used throughout the Task Master system
|
||||
*/
|
||||
export const ERROR_CODES = {
|
||||
// File system errors
|
||||
FILE_NOT_FOUND: 'FILE_NOT_FOUND',
|
||||
FILE_READ_ERROR: 'FILE_READ_ERROR',
|
||||
FILE_WRITE_ERROR: 'FILE_WRITE_ERROR',
|
||||
|
||||
// Parsing errors
|
||||
PARSE_ERROR: 'PARSE_ERROR',
|
||||
JSON_PARSE_ERROR: 'JSON_PARSE_ERROR',
|
||||
YAML_PARSE_ERROR: 'YAML_PARSE_ERROR',
|
||||
|
||||
// Validation errors
|
||||
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
||||
SCHEMA_VALIDATION_ERROR: 'SCHEMA_VALIDATION_ERROR',
|
||||
TYPE_VALIDATION_ERROR: 'TYPE_VALIDATION_ERROR',
|
||||
|
||||
// API and network errors
|
||||
API_ERROR: 'API_ERROR',
|
||||
NETWORK_ERROR: 'NETWORK_ERROR',
|
||||
AUTHENTICATION_ERROR: 'AUTHENTICATION_ERROR',
|
||||
AUTHORIZATION_ERROR: 'AUTHORIZATION_ERROR',
|
||||
|
||||
// Task management errors
|
||||
TASK_NOT_FOUND: 'TASK_NOT_FOUND',
|
||||
TASK_DEPENDENCY_ERROR: 'TASK_DEPENDENCY_ERROR',
|
||||
TASK_STATUS_ERROR: 'TASK_STATUS_ERROR',
|
||||
|
||||
// Storage errors
|
||||
STORAGE_ERROR: 'STORAGE_ERROR',
|
||||
DATABASE_ERROR: 'DATABASE_ERROR',
|
||||
|
||||
// Configuration errors
|
||||
CONFIG_ERROR: 'CONFIG_ERROR',
|
||||
MISSING_CONFIGURATION: 'MISSING_CONFIGURATION',
|
||||
INVALID_CONFIGURATION: 'INVALID_CONFIGURATION',
|
||||
|
||||
// Provider errors
|
||||
PROVIDER_ERROR: 'PROVIDER_ERROR',
|
||||
PROVIDER_NOT_FOUND: 'PROVIDER_NOT_FOUND',
|
||||
PROVIDER_INITIALIZATION_ERROR: 'PROVIDER_INITIALIZATION_ERROR',
|
||||
|
||||
// Generic errors
|
||||
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
||||
INVALID_INPUT: 'INVALID_INPUT',
|
||||
NOT_IMPLEMENTED: 'NOT_IMPLEMENTED',
|
||||
UNKNOWN_ERROR: 'UNKNOWN_ERROR'
|
||||
} as const;
|
||||
|
||||
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
||||
|
||||
/**
|
||||
* Error context interface for additional error metadata
|
||||
*/
|
||||
export interface ErrorContext {
|
||||
/** Additional details about the error */
|
||||
details?: any;
|
||||
/** Error timestamp */
|
||||
timestamp?: Date;
|
||||
/** Operation that failed */
|
||||
operation?: string;
|
||||
/** Resource identifier related to the error */
|
||||
resource?: string;
|
||||
/** Stack of operations leading to the error */
|
||||
operationStack?: string[];
|
||||
/** User-safe message for display */
|
||||
userMessage?: string;
|
||||
/** Internal error identifier for debugging */
|
||||
errorId?: string;
|
||||
/** Additional metadata */
|
||||
metadata?: Record<string, any>;
|
||||
/** Allow additional properties for flexibility */
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable error representation
|
||||
*/
|
||||
export interface SerializableError {
|
||||
name: string;
|
||||
message: string;
|
||||
code: string;
|
||||
context: ErrorContext;
|
||||
stack?: string;
|
||||
cause?: SerializableError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base error class for all Task Master operations
|
||||
*
|
||||
* Provides comprehensive error handling with:
|
||||
* - Error codes for programmatic handling
|
||||
* - Rich context and metadata support
|
||||
* - Error chaining with cause property
|
||||
* - Serialization for logging and transport
|
||||
* - Sanitization for user-facing messages
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* try {
|
||||
* // Some operation that might fail
|
||||
* throw new TaskMasterError(
|
||||
* 'Failed to parse task file',
|
||||
* ERROR_CODES.PARSE_ERROR,
|
||||
* {
|
||||
* details: { filename: 'tasks.json', line: 42 },
|
||||
* operation: 'parseTaskFile',
|
||||
* userMessage: 'There was an error reading your task file'
|
||||
* }
|
||||
* );
|
||||
* } catch (error) {
|
||||
* console.error(error.toJSON());
|
||||
* throw new TaskMasterError(
|
||||
* 'Operation failed',
|
||||
* ERROR_CODES.INTERNAL_ERROR,
|
||||
* { operation: 'processTask' },
|
||||
* error
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class TaskMasterError extends Error {
|
||||
/** Error code for programmatic handling */
|
||||
public readonly code: string;
|
||||
|
||||
/** Rich context and metadata */
|
||||
public readonly context: ErrorContext;
|
||||
|
||||
/** Original error that caused this error (for error chaining) */
|
||||
public readonly cause?: Error;
|
||||
|
||||
/** Timestamp when error was created */
|
||||
public readonly timestamp: Date;
|
||||
|
||||
/**
|
||||
* Create a new TaskMasterError
|
||||
*
|
||||
* @param message - Human-readable error message
|
||||
* @param code - Error code from ERROR_CODES
|
||||
* @param context - Additional error context and metadata
|
||||
* @param cause - Original error that caused this error (for chaining)
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
code: string = ERROR_CODES.UNKNOWN_ERROR,
|
||||
context: ErrorContext = {},
|
||||
cause?: Error
|
||||
) {
|
||||
super(message);
|
||||
|
||||
// Set error name
|
||||
this.name = 'TaskMasterError';
|
||||
|
||||
// Set properties
|
||||
this.code = code;
|
||||
this.cause = cause;
|
||||
this.timestamp = new Date();
|
||||
|
||||
// Merge context with defaults
|
||||
this.context = {
|
||||
timestamp: this.timestamp,
|
||||
...context
|
||||
};
|
||||
|
||||
// Fix prototype chain for proper instanceof checks
|
||||
Object.setPrototypeOf(this, TaskMasterError.prototype);
|
||||
|
||||
// Maintain proper stack trace
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, TaskMasterError);
|
||||
}
|
||||
|
||||
// If we have a cause error, append its stack trace
|
||||
if (cause?.stack) {
|
||||
this.stack = `${this.stack}\nCaused by: ${cause.stack}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly error message
|
||||
* Falls back to the main message if no user message is provided
|
||||
*/
|
||||
public getUserMessage(): string {
|
||||
return this.context.userMessage || this.message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sanitized error details safe for user display
|
||||
* Removes sensitive information and internal details
|
||||
*/
|
||||
public getSanitizedDetails(): Record<string, any> {
|
||||
const { details, resource, operation } = this.context;
|
||||
|
||||
return {
|
||||
code: this.code,
|
||||
message: this.getUserMessage(),
|
||||
...(resource && { resource }),
|
||||
...(operation && { operation }),
|
||||
...(details &&
|
||||
typeof details === 'object' &&
|
||||
!this.containsSensitiveInfo(details) && { details })
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error details contain potentially sensitive information
|
||||
*/
|
||||
private containsSensitiveInfo(obj: any): boolean {
|
||||
if (typeof obj !== 'object' || obj === null) return false;
|
||||
|
||||
const sensitiveKeys = [
|
||||
'password',
|
||||
'token',
|
||||
'key',
|
||||
'secret',
|
||||
'auth',
|
||||
'credential'
|
||||
];
|
||||
const objString = JSON.stringify(obj).toLowerCase();
|
||||
|
||||
return sensitiveKeys.some((key) => objString.includes(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert error to JSON for serialization
|
||||
* Includes all error information for logging and debugging
|
||||
*/
|
||||
public toJSON(): SerializableError {
|
||||
const result: SerializableError = {
|
||||
name: this.name,
|
||||
message: this.message,
|
||||
code: this.code,
|
||||
context: this.context,
|
||||
stack: this.stack
|
||||
};
|
||||
|
||||
// Include serialized cause if present
|
||||
if (this.cause) {
|
||||
if (this.cause instanceof TaskMasterError) {
|
||||
result.cause = this.cause.toJSON();
|
||||
} else {
|
||||
result.cause = {
|
||||
name: this.cause.name,
|
||||
message: this.cause.message,
|
||||
code: ERROR_CODES.UNKNOWN_ERROR,
|
||||
context: {},
|
||||
stack: this.cause.stack
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert error to string representation
|
||||
* Provides formatted output for logging and debugging
|
||||
*/
|
||||
public toString(): string {
|
||||
let result = `${this.name}[${this.code}]: ${this.message}`;
|
||||
|
||||
if (this.context.operation) {
|
||||
result += ` (operation: ${this.context.operation})`;
|
||||
}
|
||||
|
||||
if (this.context.resource) {
|
||||
result += ` (resource: ${this.context.resource})`;
|
||||
}
|
||||
|
||||
if (this.cause) {
|
||||
result += `\nCaused by: ${this.cause.toString()}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this error is of a specific code
|
||||
*/
|
||||
public is(code: string): boolean {
|
||||
return this.code === code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this error or any error in its cause chain is of a specific code
|
||||
*/
|
||||
public hasCode(code: string): boolean {
|
||||
if (this.is(code)) return true;
|
||||
|
||||
if (this.cause instanceof TaskMasterError) {
|
||||
return this.cause.hasCode(code);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new error with additional context
|
||||
*/
|
||||
public withContext(
|
||||
additionalContext: Partial<ErrorContext>
|
||||
): TaskMasterError {
|
||||
return new TaskMasterError(
|
||||
this.message,
|
||||
this.code,
|
||||
{ ...this.context, ...additionalContext },
|
||||
this.cause
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new error wrapping this one as the cause
|
||||
*/
|
||||
public wrap(
|
||||
message: string,
|
||||
code: string = ERROR_CODES.INTERNAL_ERROR,
|
||||
context: ErrorContext = {}
|
||||
): TaskMasterError {
|
||||
return new TaskMasterError(message, code, context, this);
|
||||
}
|
||||
}
|
||||
45
packages/tm-core/src/index.ts
Normal file
45
packages/tm-core/src/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @fileoverview Main entry point for the tm-core package
|
||||
* This file exports all public APIs from the core Task Master library
|
||||
*/
|
||||
|
||||
// Export main facade
|
||||
export {
|
||||
TaskMasterCore,
|
||||
createTaskMasterCore,
|
||||
type TaskMasterCoreOptions,
|
||||
type ListTasksResult
|
||||
} from './task-master-core';
|
||||
|
||||
// Re-export types
|
||||
export type * from './types';
|
||||
|
||||
// Re-export interfaces (types only to avoid conflicts)
|
||||
export type * from './interfaces';
|
||||
|
||||
// Re-export constants
|
||||
export * from './constants';
|
||||
|
||||
// Re-export providers
|
||||
export * from './providers';
|
||||
|
||||
// Re-export storage (selectively to avoid conflicts)
|
||||
export {
|
||||
FileStorage,
|
||||
ApiStorage,
|
||||
StorageFactory,
|
||||
type ApiStorageConfig
|
||||
} from './storage';
|
||||
export { PlaceholderStorage, type StorageAdapter } from './storage';
|
||||
|
||||
// Re-export parser
|
||||
export * from './parser';
|
||||
|
||||
// Re-export utilities
|
||||
export * from './utils';
|
||||
|
||||
// Re-export errors
|
||||
export * from './errors';
|
||||
|
||||
// Re-export entities
|
||||
export { TaskEntity } from './entities/task.entity';
|
||||
423
packages/tm-core/src/interfaces/ai-provider.interface.ts
Normal file
423
packages/tm-core/src/interfaces/ai-provider.interface.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* @fileoverview AI Provider interface definitions for the tm-core package
|
||||
* This file defines the contract for all AI provider implementations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Options for AI completion requests
|
||||
*/
|
||||
export interface AIOptions {
|
||||
/** Temperature for response randomness (0.0 to 1.0) */
|
||||
temperature?: number;
|
||||
/** Maximum number of tokens to generate */
|
||||
maxTokens?: number;
|
||||
/** Whether to use streaming responses */
|
||||
stream?: boolean;
|
||||
/** Top-p sampling parameter (0.0 to 1.0) */
|
||||
topP?: number;
|
||||
/** Frequency penalty to reduce repetition (-2.0 to 2.0) */
|
||||
frequencyPenalty?: number;
|
||||
/** Presence penalty to encourage new topics (-2.0 to 2.0) */
|
||||
presencePenalty?: number;
|
||||
/** Stop sequences to halt generation */
|
||||
stop?: string | string[];
|
||||
/** Custom system prompt override */
|
||||
systemPrompt?: string;
|
||||
/** Request timeout in milliseconds */
|
||||
timeout?: number;
|
||||
/** Number of retry attempts on failure */
|
||||
retries?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from AI completion request
|
||||
*/
|
||||
export interface AIResponse {
|
||||
/** Generated text content */
|
||||
content: string;
|
||||
/** Token count for the request */
|
||||
inputTokens: number;
|
||||
/** Token count for the response */
|
||||
outputTokens: number;
|
||||
/** Total tokens used */
|
||||
totalTokens: number;
|
||||
/** Cost in USD (if available) */
|
||||
cost?: number;
|
||||
/** Model used for generation */
|
||||
model: string;
|
||||
/** Provider name */
|
||||
provider: string;
|
||||
/** Response timestamp */
|
||||
timestamp: string;
|
||||
/** Request duration in milliseconds */
|
||||
duration: number;
|
||||
/** Whether the response was cached */
|
||||
cached?: boolean;
|
||||
/** Finish reason (completed, length, stop, etc.) */
|
||||
finishReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI model information
|
||||
*/
|
||||
export interface AIModel {
|
||||
/** Model identifier */
|
||||
id: string;
|
||||
/** Human-readable model name */
|
||||
name: string;
|
||||
/** Model description */
|
||||
description?: string;
|
||||
/** Maximum context length in tokens */
|
||||
contextLength: number;
|
||||
/** Input cost per 1K tokens in USD */
|
||||
inputCostPer1K?: number;
|
||||
/** Output cost per 1K tokens in USD */
|
||||
outputCostPer1K?: number;
|
||||
/** Whether the model supports function calling */
|
||||
supportsFunctions?: boolean;
|
||||
/** Whether the model supports vision/image inputs */
|
||||
supportsVision?: boolean;
|
||||
/** Whether the model supports streaming */
|
||||
supportsStreaming?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider capabilities and metadata
|
||||
*/
|
||||
export interface ProviderInfo {
|
||||
/** Provider name */
|
||||
name: string;
|
||||
/** Provider display name */
|
||||
displayName: string;
|
||||
/** Provider description */
|
||||
description?: string;
|
||||
/** Base API URL */
|
||||
baseUrl?: string;
|
||||
/** Available models */
|
||||
models: AIModel[];
|
||||
/** Default model ID */
|
||||
defaultModel: string;
|
||||
/** Whether the provider requires an API key */
|
||||
requiresApiKey: boolean;
|
||||
/** Supported features */
|
||||
features: {
|
||||
streaming?: boolean;
|
||||
functions?: boolean;
|
||||
vision?: boolean;
|
||||
embeddings?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for AI provider implementations
|
||||
* All AI providers must implement this interface
|
||||
*/
|
||||
export interface IAIProvider {
|
||||
/**
|
||||
* Generate a text completion from a prompt
|
||||
* @param prompt - Input prompt text
|
||||
* @param options - Optional generation parameters
|
||||
* @returns Promise that resolves to AI response
|
||||
*/
|
||||
generateCompletion(prompt: string, options?: AIOptions): Promise<AIResponse>;
|
||||
|
||||
/**
|
||||
* Generate a streaming completion (if supported)
|
||||
* @param prompt - Input prompt text
|
||||
* @param options - Optional generation parameters
|
||||
* @returns AsyncIterator of response chunks
|
||||
*/
|
||||
generateStreamingCompletion(
|
||||
prompt: string,
|
||||
options?: AIOptions
|
||||
): AsyncIterator<Partial<AIResponse>>;
|
||||
|
||||
/**
|
||||
* Calculate token count for given text
|
||||
* @param text - Text to count tokens for
|
||||
* @param model - Optional model to use for counting
|
||||
* @returns Number of tokens
|
||||
*/
|
||||
calculateTokens(text: string, model?: string): number;
|
||||
|
||||
/**
|
||||
* Get the provider name
|
||||
* @returns Provider name string
|
||||
*/
|
||||
getName(): string;
|
||||
|
||||
/**
|
||||
* Get current model being used
|
||||
* @returns Current model ID
|
||||
*/
|
||||
getModel(): string;
|
||||
|
||||
/**
|
||||
* Set the model to use for requests
|
||||
* @param model - Model ID to use
|
||||
*/
|
||||
setModel(model: string): void;
|
||||
|
||||
/**
|
||||
* Get the default model for this provider
|
||||
* @returns Default model ID
|
||||
*/
|
||||
getDefaultModel(): string;
|
||||
|
||||
/**
|
||||
* Check if the provider is available and configured
|
||||
* @returns Promise that resolves to availability status
|
||||
*/
|
||||
isAvailable(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Get provider information and capabilities
|
||||
* @returns Provider information object
|
||||
*/
|
||||
getProviderInfo(): ProviderInfo;
|
||||
|
||||
/**
|
||||
* Get available models for this provider
|
||||
* @returns Array of available models
|
||||
*/
|
||||
getAvailableModels(): AIModel[];
|
||||
|
||||
/**
|
||||
* Validate API key or credentials
|
||||
* @returns Promise that resolves to validation status
|
||||
*/
|
||||
validateCredentials(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Get usage statistics if available
|
||||
* @returns Promise that resolves to usage stats or null
|
||||
*/
|
||||
getUsageStats(): Promise<ProviderUsageStats | null>;
|
||||
|
||||
/**
|
||||
* Initialize the provider (set up connections, validate config, etc.)
|
||||
* @returns Promise that resolves when initialization is complete
|
||||
*/
|
||||
initialize(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clean up and close provider connections
|
||||
* @returns Promise that resolves when cleanup is complete
|
||||
*/
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage statistics for a provider
|
||||
*/
|
||||
export interface ProviderUsageStats {
|
||||
/** Total requests made */
|
||||
totalRequests: number;
|
||||
/** Total tokens consumed */
|
||||
totalTokens: number;
|
||||
/** Total cost in USD */
|
||||
totalCost: number;
|
||||
/** Requests today */
|
||||
requestsToday: number;
|
||||
/** Tokens used today */
|
||||
tokensToday: number;
|
||||
/** Cost today */
|
||||
costToday: number;
|
||||
/** Average response time in milliseconds */
|
||||
averageResponseTime: number;
|
||||
/** Success rate (0.0 to 1.0) */
|
||||
successRate: number;
|
||||
/** Last request timestamp */
|
||||
lastRequestAt?: string;
|
||||
/** Rate limit information if available */
|
||||
rateLimits?: {
|
||||
requestsPerMinute: number;
|
||||
tokensPerMinute: number;
|
||||
requestsRemaining: number;
|
||||
tokensRemaining: number;
|
||||
resetTime: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for AI provider instances
|
||||
*/
|
||||
export interface AIProviderConfig {
|
||||
/** API key for the provider */
|
||||
apiKey: string;
|
||||
/** Base URL override */
|
||||
baseUrl?: string;
|
||||
/** Default model to use */
|
||||
model?: string;
|
||||
/** Default generation options */
|
||||
defaultOptions?: AIOptions;
|
||||
/** Request timeout in milliseconds */
|
||||
timeout?: number;
|
||||
/** Maximum retry attempts */
|
||||
maxRetries?: number;
|
||||
/** Custom headers to include in requests */
|
||||
headers?: Record<string, string>;
|
||||
/** Enable request/response logging */
|
||||
enableLogging?: boolean;
|
||||
/** Enable usage tracking */
|
||||
enableUsageTracking?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base class for AI provider implementations
|
||||
* Provides common functionality and enforces the interface
|
||||
*/
|
||||
export abstract class BaseAIProvider implements IAIProvider {
|
||||
protected config: AIProviderConfig;
|
||||
protected currentModel: string;
|
||||
protected usageStats: ProviderUsageStats | null = null;
|
||||
|
||||
constructor(config: AIProviderConfig) {
|
||||
this.config = config;
|
||||
this.currentModel = config.model || this.getDefaultModel();
|
||||
|
||||
if (config.enableUsageTracking) {
|
||||
this.initializeUsageTracking();
|
||||
}
|
||||
}
|
||||
|
||||
// Abstract methods that must be implemented by concrete classes
|
||||
abstract generateCompletion(
|
||||
prompt: string,
|
||||
options?: AIOptions
|
||||
): Promise<AIResponse>;
|
||||
abstract generateStreamingCompletion(
|
||||
prompt: string,
|
||||
options?: AIOptions
|
||||
): AsyncIterator<Partial<AIResponse>>;
|
||||
abstract calculateTokens(text: string, model?: string): number;
|
||||
abstract getName(): string;
|
||||
abstract getDefaultModel(): string;
|
||||
abstract isAvailable(): Promise<boolean>;
|
||||
abstract getProviderInfo(): ProviderInfo;
|
||||
abstract validateCredentials(): Promise<boolean>;
|
||||
abstract initialize(): Promise<void>;
|
||||
abstract close(): Promise<void>;
|
||||
|
||||
// Implemented methods with common functionality
|
||||
getModel(): string {
|
||||
return this.currentModel;
|
||||
}
|
||||
|
||||
setModel(model: string): void {
|
||||
const availableModels = this.getAvailableModels();
|
||||
const modelExists = availableModels.some((m) => m.id === model);
|
||||
|
||||
if (!modelExists) {
|
||||
throw new Error(
|
||||
`Model "${model}" is not available for provider "${this.getName()}"`
|
||||
);
|
||||
}
|
||||
|
||||
this.currentModel = model;
|
||||
}
|
||||
|
||||
getAvailableModels(): AIModel[] {
|
||||
return this.getProviderInfo().models;
|
||||
}
|
||||
|
||||
async getUsageStats(): Promise<ProviderUsageStats | null> {
|
||||
return this.usageStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize usage tracking
|
||||
*/
|
||||
protected initializeUsageTracking(): void {
|
||||
this.usageStats = {
|
||||
totalRequests: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
requestsToday: 0,
|
||||
tokensToday: 0,
|
||||
costToday: 0,
|
||||
averageResponseTime: 0,
|
||||
successRate: 1.0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update usage statistics after a request
|
||||
* @param response - AI response to record
|
||||
* @param duration - Request duration in milliseconds
|
||||
* @param success - Whether the request was successful
|
||||
*/
|
||||
protected updateUsageStats(
|
||||
response: AIResponse,
|
||||
duration: number,
|
||||
success: boolean
|
||||
): void {
|
||||
if (!this.usageStats) return;
|
||||
|
||||
this.usageStats.totalRequests++;
|
||||
this.usageStats.totalTokens += response.totalTokens;
|
||||
|
||||
if (response.cost) {
|
||||
this.usageStats.totalCost += response.cost;
|
||||
}
|
||||
|
||||
// Update daily stats (simplified - would need proper date tracking)
|
||||
this.usageStats.requestsToday++;
|
||||
this.usageStats.tokensToday += response.totalTokens;
|
||||
|
||||
if (response.cost) {
|
||||
this.usageStats.costToday += response.cost;
|
||||
}
|
||||
|
||||
// Update average response time
|
||||
const totalTime =
|
||||
this.usageStats.averageResponseTime * (this.usageStats.totalRequests - 1);
|
||||
this.usageStats.averageResponseTime =
|
||||
(totalTime + duration) / this.usageStats.totalRequests;
|
||||
|
||||
// Update success rate
|
||||
const successCount = Math.floor(
|
||||
this.usageStats.successRate * (this.usageStats.totalRequests - 1)
|
||||
);
|
||||
const newSuccessCount = successCount + (success ? 1 : 0);
|
||||
this.usageStats.successRate =
|
||||
newSuccessCount / this.usageStats.totalRequests;
|
||||
|
||||
this.usageStats.lastRequestAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge user options with default options
|
||||
* @param userOptions - User-provided options
|
||||
* @returns Merged options object
|
||||
*/
|
||||
protected mergeOptions(userOptions?: AIOptions): AIOptions {
|
||||
return {
|
||||
temperature: 0.7,
|
||||
maxTokens: 2000,
|
||||
stream: false,
|
||||
topP: 1.0,
|
||||
frequencyPenalty: 0.0,
|
||||
presencePenalty: 0.0,
|
||||
timeout: 30000,
|
||||
retries: 3,
|
||||
...this.config.defaultOptions,
|
||||
...userOptions
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate prompt input
|
||||
* @param prompt - Prompt to validate
|
||||
* @throws Error if prompt is invalid
|
||||
*/
|
||||
protected validatePrompt(prompt: string): void {
|
||||
if (!prompt || typeof prompt !== 'string') {
|
||||
throw new Error('Prompt must be a non-empty string');
|
||||
}
|
||||
|
||||
if (prompt.trim().length === 0) {
|
||||
throw new Error('Prompt cannot be empty or only whitespace');
|
||||
}
|
||||
}
|
||||
}
|
||||
413
packages/tm-core/src/interfaces/configuration.interface.ts
Normal file
413
packages/tm-core/src/interfaces/configuration.interface.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* @fileoverview Configuration interface definitions for the tm-core package
|
||||
* This file defines the contract for configuration management
|
||||
*/
|
||||
|
||||
import type { TaskComplexity, TaskPriority } from '../types/index';
|
||||
|
||||
/**
|
||||
* Model configuration for different AI roles
|
||||
*/
|
||||
export interface ModelConfig {
|
||||
/** Primary model for task generation and updates */
|
||||
main: string;
|
||||
/** Research model for enhanced task analysis (optional) */
|
||||
research?: string;
|
||||
/** Fallback model when primary fails */
|
||||
fallback: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI provider configuration
|
||||
*/
|
||||
export interface ProviderConfig {
|
||||
/** Provider name (e.g., 'anthropic', 'openai', 'perplexity') */
|
||||
name: string;
|
||||
/** API key for the provider */
|
||||
apiKey?: string;
|
||||
/** Base URL override */
|
||||
baseUrl?: string;
|
||||
/** Custom configuration options */
|
||||
options?: Record<string, unknown>;
|
||||
/** Whether this provider is enabled */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task generation and management settings
|
||||
*/
|
||||
export interface TaskSettings {
|
||||
/** Default priority for new tasks */
|
||||
defaultPriority: TaskPriority;
|
||||
/** Default complexity for analysis */
|
||||
defaultComplexity: TaskComplexity;
|
||||
/** Maximum number of subtasks per task */
|
||||
maxSubtasks: number;
|
||||
/** Maximum number of concurrent tasks */
|
||||
maxConcurrentTasks: number;
|
||||
/** Enable automatic task ID generation */
|
||||
autoGenerateIds: boolean;
|
||||
/** Task ID prefix (e.g., 'TASK-', 'TM-') */
|
||||
taskIdPrefix?: string;
|
||||
/** Enable task dependency validation */
|
||||
validateDependencies: boolean;
|
||||
/** Enable automatic timestamps */
|
||||
enableTimestamps: boolean;
|
||||
/** Enable effort tracking */
|
||||
enableEffortTracking: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag and context management settings
|
||||
*/
|
||||
export interface TagSettings {
|
||||
/** Enable tag-based task organization */
|
||||
enableTags: boolean;
|
||||
/** Default tag for new tasks */
|
||||
defaultTag: string;
|
||||
/** Maximum number of tags per task */
|
||||
maxTagsPerTask: number;
|
||||
/** Enable automatic tag creation from Git branches */
|
||||
autoCreateFromBranch: boolean;
|
||||
/** Tag naming convention (kebab-case, camelCase, snake_case) */
|
||||
tagNamingConvention: 'kebab-case' | 'camelCase' | 'snake_case';
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage and persistence settings
|
||||
*/
|
||||
export interface StorageSettings {
|
||||
/** Storage backend type */
|
||||
type: 'file' | 'api';
|
||||
/** Base path for file storage */
|
||||
basePath?: string;
|
||||
/** API endpoint for API storage (Hamster integration) */
|
||||
apiEndpoint?: string;
|
||||
/** Access token for API authentication */
|
||||
apiAccessToken?: string;
|
||||
/** Enable automatic backups */
|
||||
enableBackup: boolean;
|
||||
/** Maximum number of backups to retain */
|
||||
maxBackups: number;
|
||||
/** Enable compression for storage */
|
||||
enableCompression: boolean;
|
||||
/** File encoding for text files */
|
||||
encoding: BufferEncoding;
|
||||
/** Enable atomic file operations */
|
||||
atomicOperations: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry and resilience settings
|
||||
*/
|
||||
export interface RetrySettings {
|
||||
/** Number of retry attempts for failed operations */
|
||||
retryAttempts: number;
|
||||
/** Base delay between retries in milliseconds */
|
||||
retryDelay: number;
|
||||
/** Maximum delay between retries in milliseconds */
|
||||
maxRetryDelay: number;
|
||||
/** Exponential backoff multiplier */
|
||||
backoffMultiplier: number;
|
||||
/** Request timeout in milliseconds */
|
||||
requestTimeout: number;
|
||||
/** Enable retry for network errors */
|
||||
retryOnNetworkError: boolean;
|
||||
/** Enable retry for rate limit errors */
|
||||
retryOnRateLimit: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logging and debugging settings
|
||||
*/
|
||||
export interface LoggingSettings {
|
||||
/** Enable logging */
|
||||
enabled: boolean;
|
||||
/** Log level (error, warn, info, debug) */
|
||||
level: 'error' | 'warn' | 'info' | 'debug';
|
||||
/** Log file path (optional) */
|
||||
filePath?: string;
|
||||
/** Enable request/response logging */
|
||||
logRequests: boolean;
|
||||
/** Enable performance metrics logging */
|
||||
logPerformance: boolean;
|
||||
/** Enable error stack traces */
|
||||
logStackTraces: boolean;
|
||||
/** Maximum log file size in MB */
|
||||
maxFileSize: number;
|
||||
/** Maximum number of log files to retain */
|
||||
maxFiles: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Security and validation settings
|
||||
*/
|
||||
export interface SecuritySettings {
|
||||
/** Enable API key validation */
|
||||
validateApiKeys: boolean;
|
||||
/** Enable request rate limiting */
|
||||
enableRateLimit: boolean;
|
||||
/** Maximum requests per minute */
|
||||
maxRequestsPerMinute: number;
|
||||
/** Enable input sanitization */
|
||||
sanitizeInputs: boolean;
|
||||
/** Maximum prompt length in characters */
|
||||
maxPromptLength: number;
|
||||
/** Allowed file extensions for imports */
|
||||
allowedFileExtensions: string[];
|
||||
/** Enable CORS protection */
|
||||
enableCors: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main configuration interface for Task Master core
|
||||
*/
|
||||
export interface IConfiguration {
|
||||
/** Project root path */
|
||||
projectPath: string;
|
||||
|
||||
/** Current AI provider name */
|
||||
aiProvider: string;
|
||||
|
||||
/** API keys for different providers */
|
||||
apiKeys: Record<string, string>;
|
||||
|
||||
/** Model configuration for different roles */
|
||||
models: ModelConfig;
|
||||
|
||||
/** Provider configurations */
|
||||
providers: Record<string, ProviderConfig>;
|
||||
|
||||
/** Task management settings */
|
||||
tasks: TaskSettings;
|
||||
|
||||
/** Tag and context settings */
|
||||
tags: TagSettings;
|
||||
|
||||
/** Storage configuration */
|
||||
storage: StorageSettings;
|
||||
|
||||
/** Retry and resilience settings */
|
||||
retry: RetrySettings;
|
||||
|
||||
/** Logging configuration */
|
||||
logging: LoggingSettings;
|
||||
|
||||
/** Security settings */
|
||||
security: SecuritySettings;
|
||||
|
||||
/** Custom user-defined settings */
|
||||
custom?: Record<string, unknown>;
|
||||
|
||||
/** Configuration version for migration purposes */
|
||||
version: string;
|
||||
|
||||
/** Last updated timestamp */
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial configuration for updates (all fields optional)
|
||||
*/
|
||||
export type PartialConfiguration = Partial<IConfiguration>;
|
||||
|
||||
/**
|
||||
* Configuration validation result
|
||||
*/
|
||||
export interface ConfigValidationResult {
|
||||
/** Whether the configuration is valid */
|
||||
isValid: boolean;
|
||||
/** Array of error messages */
|
||||
errors: string[];
|
||||
/** Array of warning messages */
|
||||
warnings: string[];
|
||||
/** Suggested fixes */
|
||||
suggestions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment variable configuration mapping
|
||||
*/
|
||||
export interface EnvironmentConfig {
|
||||
/** Mapping of environment variables to config paths */
|
||||
variables: Record<string, string>;
|
||||
/** Prefix for environment variables */
|
||||
prefix: string;
|
||||
/** Whether to override existing config with env vars */
|
||||
override: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration schema definition for validation
|
||||
*/
|
||||
export interface ConfigSchema {
|
||||
/** Schema for the main configuration */
|
||||
properties: Record<string, ConfigProperty>;
|
||||
/** Required properties */
|
||||
required: string[];
|
||||
/** Additional properties allowed */
|
||||
additionalProperties: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration property schema
|
||||
*/
|
||||
export interface ConfigProperty {
|
||||
/** Property type */
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||
/** Property description */
|
||||
description?: string;
|
||||
/** Default value */
|
||||
default?: unknown;
|
||||
/** Allowed values for enums */
|
||||
enum?: unknown[];
|
||||
/** Minimum value (for numbers) */
|
||||
minimum?: number;
|
||||
/** Maximum value (for numbers) */
|
||||
maximum?: number;
|
||||
/** Pattern for string validation */
|
||||
pattern?: string;
|
||||
/** Nested properties (for objects) */
|
||||
properties?: Record<string, ConfigProperty>;
|
||||
/** Array item type (for arrays) */
|
||||
items?: ConfigProperty;
|
||||
/** Whether the property is required */
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration factory
|
||||
*/
|
||||
export interface IConfigurationFactory {
|
||||
/**
|
||||
* Create a default configuration
|
||||
* @param projectPath - Project root path
|
||||
* @returns Default configuration object
|
||||
*/
|
||||
createDefault(projectPath: string): IConfiguration;
|
||||
|
||||
/**
|
||||
* Merge configurations with precedence
|
||||
* @param base - Base configuration
|
||||
* @param override - Override configuration
|
||||
* @returns Merged configuration
|
||||
*/
|
||||
merge(base: IConfiguration, override: PartialConfiguration): IConfiguration;
|
||||
|
||||
/**
|
||||
* Validate configuration against schema
|
||||
* @param config - Configuration to validate
|
||||
* @returns Validation result
|
||||
*/
|
||||
validate(config: IConfiguration): ConfigValidationResult;
|
||||
|
||||
/**
|
||||
* Load configuration from environment variables
|
||||
* @param envConfig - Environment variable mapping
|
||||
* @returns Partial configuration from environment
|
||||
*/
|
||||
loadFromEnvironment(envConfig: EnvironmentConfig): PartialConfiguration;
|
||||
|
||||
/**
|
||||
* Get configuration schema
|
||||
* @returns Configuration schema definition
|
||||
*/
|
||||
getSchema(): ConfigSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration manager interface
|
||||
*/
|
||||
export interface IConfigurationManager {
|
||||
/**
|
||||
* Load configuration from file or create default
|
||||
* @param configPath - Path to configuration file
|
||||
* @returns Promise that resolves to configuration
|
||||
*/
|
||||
load(configPath?: string): Promise<IConfiguration>;
|
||||
|
||||
/**
|
||||
* Save configuration to file
|
||||
* @param config - Configuration to save
|
||||
* @param configPath - Optional path override
|
||||
* @returns Promise that resolves when save is complete
|
||||
*/
|
||||
save(config: IConfiguration, configPath?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Update configuration with partial changes
|
||||
* @param updates - Partial configuration updates
|
||||
* @returns Promise that resolves to updated configuration
|
||||
*/
|
||||
update(updates: PartialConfiguration): Promise<IConfiguration>;
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
* @returns Current configuration object
|
||||
*/
|
||||
getConfig(): IConfiguration;
|
||||
|
||||
/**
|
||||
* Watch for configuration changes
|
||||
* @param callback - Function to call when config changes
|
||||
* @returns Function to stop watching
|
||||
*/
|
||||
watch(callback: (config: IConfiguration) => void): () => void;
|
||||
|
||||
/**
|
||||
* Validate current configuration
|
||||
* @returns Validation result
|
||||
*/
|
||||
validate(): ConfigValidationResult;
|
||||
|
||||
/**
|
||||
* Reset configuration to defaults
|
||||
* @returns Promise that resolves when reset is complete
|
||||
*/
|
||||
reset(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constants for default configuration values
|
||||
*/
|
||||
export const DEFAULT_CONFIG_VALUES = {
|
||||
MODELS: {
|
||||
MAIN: 'claude-3-5-sonnet-20241022',
|
||||
FALLBACK: 'gpt-4o-mini'
|
||||
},
|
||||
TASKS: {
|
||||
DEFAULT_PRIORITY: 'medium' as TaskPriority,
|
||||
DEFAULT_COMPLEXITY: 'moderate' as TaskComplexity,
|
||||
MAX_SUBTASKS: 20,
|
||||
MAX_CONCURRENT: 5,
|
||||
TASK_ID_PREFIX: 'TASK-'
|
||||
},
|
||||
TAGS: {
|
||||
DEFAULT_TAG: 'master',
|
||||
MAX_TAGS_PER_TASK: 10,
|
||||
NAMING_CONVENTION: 'kebab-case' as const
|
||||
},
|
||||
STORAGE: {
|
||||
TYPE: 'file' as const,
|
||||
ENCODING: 'utf8' as BufferEncoding,
|
||||
MAX_BACKUPS: 5
|
||||
},
|
||||
RETRY: {
|
||||
ATTEMPTS: 3,
|
||||
DELAY: 1000,
|
||||
MAX_DELAY: 30000,
|
||||
BACKOFF_MULTIPLIER: 2,
|
||||
TIMEOUT: 30000
|
||||
},
|
||||
LOGGING: {
|
||||
LEVEL: 'info' as const,
|
||||
MAX_FILE_SIZE: 10,
|
||||
MAX_FILES: 5
|
||||
},
|
||||
SECURITY: {
|
||||
MAX_REQUESTS_PER_MINUTE: 60,
|
||||
MAX_PROMPT_LENGTH: 100000,
|
||||
ALLOWED_EXTENSIONS: ['.txt', '.md', '.json']
|
||||
},
|
||||
VERSION: '1.0.0'
|
||||
} as const;
|
||||
16
packages/tm-core/src/interfaces/index.ts
Normal file
16
packages/tm-core/src/interfaces/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* @fileoverview Interface definitions index for the tm-core package
|
||||
* This file exports all interface definitions from their respective modules
|
||||
*/
|
||||
|
||||
// Storage interfaces
|
||||
export type * from './storage.interface';
|
||||
export * from './storage.interface';
|
||||
|
||||
// AI Provider interfaces
|
||||
export type * from './ai-provider.interface';
|
||||
export * from './ai-provider.interface';
|
||||
|
||||
// Configuration interfaces
|
||||
export type * from './configuration.interface';
|
||||
export * from './configuration.interface';
|
||||
238
packages/tm-core/src/interfaces/storage.interface.ts
Normal file
238
packages/tm-core/src/interfaces/storage.interface.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* @fileoverview Storage interface definitions for the tm-core package
|
||||
* This file defines the contract for all storage implementations
|
||||
*/
|
||||
|
||||
import type { Task, TaskMetadata } from '../types/index';
|
||||
|
||||
/**
|
||||
* Interface for storage operations on tasks
|
||||
* All storage implementations must implement this interface
|
||||
*/
|
||||
export interface IStorage {
|
||||
/**
|
||||
* Load all tasks from storage, optionally filtered by tag
|
||||
* @param tag - Optional tag to filter tasks by
|
||||
* @returns Promise that resolves to an array of tasks
|
||||
*/
|
||||
loadTasks(tag?: string): Promise<Task[]>;
|
||||
|
||||
/**
|
||||
* Save tasks to storage, replacing existing tasks
|
||||
* @param tasks - Array of tasks to save
|
||||
* @param tag - Optional tag context for the tasks
|
||||
* @returns Promise that resolves when save is complete
|
||||
*/
|
||||
saveTasks(tasks: Task[], tag?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Append new tasks to existing storage without replacing
|
||||
* @param tasks - Array of tasks to append
|
||||
* @param tag - Optional tag context for the tasks
|
||||
* @returns Promise that resolves when append is complete
|
||||
*/
|
||||
appendTasks(tasks: Task[], tag?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Update a specific task by ID
|
||||
* @param taskId - ID of the task to update
|
||||
* @param updates - Partial task object with fields to update
|
||||
* @param tag - Optional tag context for the task
|
||||
* @returns Promise that resolves when update is complete
|
||||
*/
|
||||
updateTask(
|
||||
taskId: string,
|
||||
updates: Partial<Task>,
|
||||
tag?: string
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete a task by ID
|
||||
* @param taskId - ID of the task to delete
|
||||
* @param tag - Optional tag context for the task
|
||||
* @returns Promise that resolves when deletion is complete
|
||||
*/
|
||||
deleteTask(taskId: string, tag?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if tasks exist in storage for the given tag
|
||||
* @param tag - Optional tag to check existence for
|
||||
* @returns Promise that resolves to boolean indicating existence
|
||||
*/
|
||||
exists(tag?: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Load metadata about the task collection
|
||||
* @param tag - Optional tag to get metadata for
|
||||
* @returns Promise that resolves to task metadata
|
||||
*/
|
||||
loadMetadata(tag?: string): Promise<TaskMetadata | null>;
|
||||
|
||||
/**
|
||||
* Save metadata about the task collection
|
||||
* @param metadata - Metadata object to save
|
||||
* @param tag - Optional tag context for the metadata
|
||||
* @returns Promise that resolves when save is complete
|
||||
*/
|
||||
saveMetadata(metadata: TaskMetadata, tag?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get all available tags in storage
|
||||
* @returns Promise that resolves to array of available tags
|
||||
*/
|
||||
getAllTags(): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Delete all tasks and metadata for a specific tag
|
||||
* @param tag - Tag to delete
|
||||
* @returns Promise that resolves when deletion is complete
|
||||
*/
|
||||
deleteTag(tag: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Rename a tag (move all tasks from old tag to new tag)
|
||||
* @param oldTag - Current tag name
|
||||
* @param newTag - New tag name
|
||||
* @returns Promise that resolves when rename is complete
|
||||
*/
|
||||
renameTag(oldTag: string, newTag: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Copy all tasks from one tag to another
|
||||
* @param sourceTag - Source tag to copy from
|
||||
* @param targetTag - Target tag to copy to
|
||||
* @returns Promise that resolves when copy is complete
|
||||
*/
|
||||
copyTag(sourceTag: string, targetTag: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Initialize storage (create necessary directories, files, etc.)
|
||||
* @returns Promise that resolves when initialization is complete
|
||||
*/
|
||||
initialize(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clean up and close storage connections
|
||||
* @returns Promise that resolves when cleanup is complete
|
||||
*/
|
||||
close(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get storage statistics (file sizes, task counts, etc.)
|
||||
* @returns Promise that resolves to storage statistics
|
||||
*/
|
||||
getStats(): Promise<StorageStats>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage statistics interface
|
||||
*/
|
||||
export interface StorageStats {
|
||||
/** Total number of tasks across all tags */
|
||||
totalTasks: number;
|
||||
/** Total number of tags */
|
||||
totalTags: number;
|
||||
/** Storage size in bytes */
|
||||
storageSize: number;
|
||||
/** Last modified timestamp */
|
||||
lastModified: string;
|
||||
/** Available tags with task counts */
|
||||
tagStats: Array<{
|
||||
tag: string;
|
||||
taskCount: number;
|
||||
lastModified: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for storage implementations
|
||||
*/
|
||||
export interface StorageConfig {
|
||||
/** Base path for storage */
|
||||
basePath: string;
|
||||
/** Enable backup creation */
|
||||
enableBackup?: boolean;
|
||||
/** Maximum number of backups to keep */
|
||||
maxBackups?: number;
|
||||
/** Enable compression for storage */
|
||||
enableCompression?: boolean;
|
||||
/** File encoding (default: utf8) */
|
||||
encoding?: BufferEncoding;
|
||||
/** Enable atomic writes */
|
||||
atomicWrites?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base abstract class for storage implementations
|
||||
* Provides common functionality and enforces the interface
|
||||
*/
|
||||
export abstract class BaseStorage implements IStorage {
|
||||
protected config: StorageConfig;
|
||||
|
||||
constructor(config: StorageConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
// Abstract methods that must be implemented by concrete classes
|
||||
abstract loadTasks(tag?: string): Promise<Task[]>;
|
||||
abstract saveTasks(tasks: Task[], tag?: string): Promise<void>;
|
||||
abstract appendTasks(tasks: Task[], tag?: string): Promise<void>;
|
||||
abstract updateTask(
|
||||
taskId: string,
|
||||
updates: Partial<Task>,
|
||||
tag?: string
|
||||
): Promise<void>;
|
||||
abstract deleteTask(taskId: string, tag?: string): Promise<void>;
|
||||
abstract exists(tag?: string): Promise<boolean>;
|
||||
abstract loadMetadata(tag?: string): Promise<TaskMetadata | null>;
|
||||
abstract saveMetadata(metadata: TaskMetadata, tag?: string): Promise<void>;
|
||||
abstract getAllTags(): Promise<string[]>;
|
||||
abstract deleteTag(tag: string): Promise<void>;
|
||||
abstract renameTag(oldTag: string, newTag: string): Promise<void>;
|
||||
abstract copyTag(sourceTag: string, targetTag: string): Promise<void>;
|
||||
abstract initialize(): Promise<void>;
|
||||
abstract close(): Promise<void>;
|
||||
abstract getStats(): Promise<StorageStats>;
|
||||
|
||||
/**
|
||||
* Utility method to generate backup filename
|
||||
* @param originalPath - Original file path
|
||||
* @returns Backup file path with timestamp
|
||||
*/
|
||||
protected generateBackupPath(originalPath: string): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const parts = originalPath.split('.');
|
||||
const extension = parts.pop();
|
||||
const baseName = parts.join('.');
|
||||
return `${baseName}.backup.${timestamp}.${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to validate task data before storage operations
|
||||
* @param task - Task to validate
|
||||
* @throws Error if task is invalid
|
||||
*/
|
||||
protected validateTask(task: Task): void {
|
||||
if (!task.id) {
|
||||
throw new Error('Task ID is required');
|
||||
}
|
||||
if (!task.title) {
|
||||
throw new Error('Task title is required');
|
||||
}
|
||||
if (!task.description) {
|
||||
throw new Error('Task description is required');
|
||||
}
|
||||
if (!task.status) {
|
||||
throw new Error('Task status is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to sanitize tag names for file system safety
|
||||
* @param tag - Tag name to sanitize
|
||||
* @returns Sanitized tag name
|
||||
*/
|
||||
protected sanitizeTag(tag: string): string {
|
||||
return tag.replace(/[^a-zA-Z0-9-_]/g, '-').toLowerCase();
|
||||
}
|
||||
}
|
||||
39
packages/tm-core/src/parser/index.ts
Normal file
39
packages/tm-core/src/parser/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @fileoverview Task parsing functionality for the tm-core package
|
||||
* This file exports all parsing-related classes and functions
|
||||
*/
|
||||
|
||||
import type { PlaceholderTask } from '../types/index';
|
||||
|
||||
// Parser implementations will be defined here
|
||||
// export * from './prd-parser.js';
|
||||
// export * from './task-parser.js';
|
||||
// export * from './markdown-parser.js';
|
||||
|
||||
// Placeholder exports - these will be implemented in later tasks
|
||||
export interface TaskParser {
|
||||
parse(content: string): Promise<PlaceholderTask[]>;
|
||||
validate(content: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated This is a placeholder class that will be properly implemented in later tasks
|
||||
*/
|
||||
export class PlaceholderParser implements TaskParser {
|
||||
async parse(content: string): Promise<PlaceholderTask[]> {
|
||||
// Simple placeholder parsing logic
|
||||
const lines = content
|
||||
.split('\n')
|
||||
.filter((line) => line.trim().startsWith('-'));
|
||||
return lines.map((line, index) => ({
|
||||
id: `task-${index + 1}`,
|
||||
title: line.trim().replace(/^-\s*/, ''),
|
||||
status: 'pending' as const,
|
||||
priority: 'medium' as const
|
||||
}));
|
||||
}
|
||||
|
||||
async validate(content: string): Promise<boolean> {
|
||||
return content.trim().length > 0;
|
||||
}
|
||||
}
|
||||
444
packages/tm-core/src/providers/ai/base-provider.ts
Normal file
444
packages/tm-core/src/providers/ai/base-provider.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* @fileoverview Abstract base provider with Template Method pattern for AI providers
|
||||
* Provides common functionality, error handling, and retry logic
|
||||
*/
|
||||
|
||||
import {
|
||||
ERROR_CODES,
|
||||
TaskMasterError
|
||||
} from '../../errors/task-master-error.js';
|
||||
import type {
|
||||
AIOptions,
|
||||
AIResponse,
|
||||
IAIProvider
|
||||
} from '../../interfaces/ai-provider.interface.js';
|
||||
|
||||
// Constants for retry logic
|
||||
const DEFAULT_MAX_RETRIES = 3;
|
||||
const BASE_RETRY_DELAY_MS = 1000;
|
||||
const MAX_RETRY_DELAY_MS = 32000;
|
||||
const BACKOFF_MULTIPLIER = 2;
|
||||
const JITTER_FACTOR = 0.1;
|
||||
|
||||
// Constants for validation
|
||||
const MIN_PROMPT_LENGTH = 1;
|
||||
const MAX_PROMPT_LENGTH = 100000;
|
||||
const MIN_TEMPERATURE = 0;
|
||||
const MAX_TEMPERATURE = 2;
|
||||
const MIN_MAX_TOKENS = 1;
|
||||
const MAX_MAX_TOKENS = 100000;
|
||||
|
||||
/**
|
||||
* Configuration for BaseProvider
|
||||
*/
|
||||
export interface BaseProviderConfig {
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal completion result structure
|
||||
*/
|
||||
export interface CompletionResult {
|
||||
content: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
finishReason?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation result for input validation
|
||||
*/
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepared request after preprocessing
|
||||
*/
|
||||
interface PreparedRequest {
|
||||
prompt: string;
|
||||
options: AIOptions;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base provider implementing Template Method pattern
|
||||
* Provides common error handling, retry logic, and validation
|
||||
*/
|
||||
export abstract class BaseProvider implements IAIProvider {
|
||||
protected readonly apiKey: string;
|
||||
protected model: string;
|
||||
|
||||
constructor(config: BaseProviderConfig) {
|
||||
if (!config.apiKey) {
|
||||
throw new TaskMasterError(
|
||||
'API key is required',
|
||||
ERROR_CODES.AUTHENTICATION_ERROR
|
||||
);
|
||||
}
|
||||
this.apiKey = config.apiKey;
|
||||
this.model = config.model || this.getDefaultModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Template method for generating completions
|
||||
* Handles validation, retries, and error handling
|
||||
*/
|
||||
async generateCompletion(
|
||||
prompt: string,
|
||||
options?: AIOptions
|
||||
): Promise<AIResponse> {
|
||||
// Validate input
|
||||
const validation = this.validateInput(prompt, options);
|
||||
if (!validation.valid) {
|
||||
throw new TaskMasterError(
|
||||
validation.error || 'Invalid input',
|
||||
ERROR_CODES.VALIDATION_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare request
|
||||
const prepared = this.prepareRequest(prompt, options);
|
||||
|
||||
// Execute with retry logic
|
||||
let lastError: Error | undefined;
|
||||
const maxRetries = this.getMaxRetries();
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const result = await this.generateCompletionInternal(
|
||||
prepared.prompt,
|
||||
prepared.options
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
return this.handleResponse(result, duration, prepared);
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
if (!this.shouldRetry(error, attempt)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const delay = this.calculateBackoffDelay(attempt);
|
||||
await this.sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
// All retries failed
|
||||
this.handleError(lastError || new Error('Unknown error'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate input prompt and options
|
||||
*/
|
||||
protected validateInput(
|
||||
prompt: string,
|
||||
options?: AIOptions
|
||||
): ValidationResult {
|
||||
// Validate prompt
|
||||
if (!prompt || typeof prompt !== 'string') {
|
||||
return { valid: false, error: 'Prompt must be a non-empty string' };
|
||||
}
|
||||
|
||||
const trimmedPrompt = prompt.trim();
|
||||
if (trimmedPrompt.length < MIN_PROMPT_LENGTH) {
|
||||
return { valid: false, error: 'Prompt cannot be empty' };
|
||||
}
|
||||
|
||||
if (trimmedPrompt.length > MAX_PROMPT_LENGTH) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Prompt exceeds maximum length of ${MAX_PROMPT_LENGTH} characters`
|
||||
};
|
||||
}
|
||||
|
||||
// Validate options if provided
|
||||
if (options) {
|
||||
const optionValidation = this.validateOptions(options);
|
||||
if (!optionValidation.valid) {
|
||||
return optionValidation;
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate completion options
|
||||
*/
|
||||
protected validateOptions(options: AIOptions): ValidationResult {
|
||||
if (options.temperature !== undefined) {
|
||||
if (
|
||||
options.temperature < MIN_TEMPERATURE ||
|
||||
options.temperature > MAX_TEMPERATURE
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Temperature must be between ${MIN_TEMPERATURE} and ${MAX_TEMPERATURE}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (options.maxTokens !== undefined) {
|
||||
if (
|
||||
options.maxTokens < MIN_MAX_TOKENS ||
|
||||
options.maxTokens > MAX_MAX_TOKENS
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Max tokens must be between ${MIN_MAX_TOKENS} and ${MAX_MAX_TOKENS}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (options.topP !== undefined) {
|
||||
if (options.topP < 0 || options.topP > 1) {
|
||||
return { valid: false, error: 'Top-p must be between 0 and 1' };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare request for processing
|
||||
*/
|
||||
protected prepareRequest(
|
||||
prompt: string,
|
||||
options?: AIOptions
|
||||
): PreparedRequest {
|
||||
const defaultOptions = this.getDefaultOptions();
|
||||
const mergedOptions = { ...defaultOptions, ...options };
|
||||
|
||||
return {
|
||||
prompt: prompt.trim(),
|
||||
options: mergedOptions,
|
||||
metadata: {
|
||||
provider: this.getName(),
|
||||
model: this.model,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process and format the response
|
||||
*/
|
||||
protected handleResponse(
|
||||
result: CompletionResult,
|
||||
duration: number,
|
||||
request: PreparedRequest
|
||||
): AIResponse {
|
||||
const inputTokens =
|
||||
result.inputTokens || this.calculateTokens(request.prompt);
|
||||
const outputTokens =
|
||||
result.outputTokens || this.calculateTokens(result.content);
|
||||
|
||||
return {
|
||||
content: result.content,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens: inputTokens + outputTokens,
|
||||
model: result.model || this.model,
|
||||
provider: this.getName(),
|
||||
timestamp: request.metadata.timestamp,
|
||||
duration,
|
||||
finishReason: result.finishReason
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle errors with proper wrapping
|
||||
*/
|
||||
protected handleError(error: unknown): never {
|
||||
if (error instanceof TaskMasterError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorCode = this.getErrorCode(error);
|
||||
|
||||
throw new TaskMasterError(
|
||||
`${this.getName()} provider error: ${errorMessage}`,
|
||||
errorCode,
|
||||
{
|
||||
operation: 'generateCompletion',
|
||||
resource: this.getName(),
|
||||
details:
|
||||
error instanceof Error
|
||||
? {
|
||||
name: error.name,
|
||||
stack: error.stack,
|
||||
model: this.model
|
||||
}
|
||||
: { error: String(error), model: this.model }
|
||||
},
|
||||
error instanceof Error ? error : undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if request should be retried
|
||||
*/
|
||||
protected shouldRetry(error: unknown, attempt: number): boolean {
|
||||
if (attempt >= this.getMaxRetries()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.isRetryableError(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is retryable
|
||||
*/
|
||||
protected isRetryableError(error: unknown): boolean {
|
||||
if (this.isRateLimitError(error)) return true;
|
||||
if (this.isTimeoutError(error)) return true;
|
||||
if (this.isNetworkError(error)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a rate limit error
|
||||
*/
|
||||
protected isRateLimitError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
message.includes('rate limit') ||
|
||||
message.includes('too many requests') ||
|
||||
message.includes('429')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a timeout error
|
||||
*/
|
||||
protected isTimeoutError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
message.includes('timeout') ||
|
||||
message.includes('timed out') ||
|
||||
message.includes('econnreset')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a network error
|
||||
*/
|
||||
protected isNetworkError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
message.includes('network') ||
|
||||
message.includes('enotfound') ||
|
||||
message.includes('econnrefused')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate exponential backoff delay with jitter
|
||||
*/
|
||||
protected calculateBackoffDelay(attempt: number): number {
|
||||
const exponentialDelay =
|
||||
BASE_RETRY_DELAY_MS * BACKOFF_MULTIPLIER ** (attempt - 1);
|
||||
const clampedDelay = Math.min(exponentialDelay, MAX_RETRY_DELAY_MS);
|
||||
|
||||
// Add jitter to prevent thundering herd
|
||||
const jitter = clampedDelay * JITTER_FACTOR * (Math.random() - 0.5) * 2;
|
||||
|
||||
return Math.round(clampedDelay + jitter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error code from error
|
||||
*/
|
||||
protected getErrorCode(error: unknown): string {
|
||||
if (this.isRateLimitError(error)) return ERROR_CODES.API_ERROR;
|
||||
if (this.isTimeoutError(error)) return ERROR_CODES.NETWORK_ERROR;
|
||||
if (this.isNetworkError(error)) return ERROR_CODES.NETWORK_ERROR;
|
||||
|
||||
if (error instanceof Error && error.message.includes('401')) {
|
||||
return ERROR_CODES.AUTHENTICATION_ERROR;
|
||||
}
|
||||
|
||||
return ERROR_CODES.PROVIDER_ERROR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep utility for delays
|
||||
*/
|
||||
protected sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default options for completions
|
||||
*/
|
||||
protected getDefaultOptions(): AIOptions {
|
||||
return {
|
||||
temperature: 0.7,
|
||||
maxTokens: 2000,
|
||||
topP: 1.0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum retry attempts
|
||||
*/
|
||||
protected getMaxRetries(): number {
|
||||
return DEFAULT_MAX_RETRIES;
|
||||
}
|
||||
|
||||
// Public interface methods
|
||||
getModel(): string {
|
||||
return this.model;
|
||||
}
|
||||
|
||||
setModel(model: string): void {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
// Abstract methods that must be implemented by concrete providers
|
||||
protected abstract generateCompletionInternal(
|
||||
prompt: string,
|
||||
options?: AIOptions
|
||||
): Promise<CompletionResult>;
|
||||
|
||||
abstract calculateTokens(text: string, model?: string): number;
|
||||
abstract getName(): string;
|
||||
abstract getDefaultModel(): string;
|
||||
|
||||
// IAIProvider methods that must be implemented
|
||||
abstract generateStreamingCompletion(
|
||||
prompt: string,
|
||||
options?: AIOptions
|
||||
): AsyncIterator<Partial<AIResponse>>;
|
||||
abstract isAvailable(): Promise<boolean>;
|
||||
abstract getProviderInfo(): import(
|
||||
'../../interfaces/ai-provider.interface.js'
|
||||
).ProviderInfo;
|
||||
abstract getAvailableModels(): import(
|
||||
'../../interfaces/ai-provider.interface.js'
|
||||
).AIModel[];
|
||||
abstract validateCredentials(): Promise<boolean>;
|
||||
abstract getUsageStats(): Promise<
|
||||
| import('../../interfaces/ai-provider.interface.js').ProviderUsageStats
|
||||
| null
|
||||
>;
|
||||
abstract initialize(): Promise<void>;
|
||||
abstract close(): Promise<void>;
|
||||
}
|
||||
14
packages/tm-core/src/providers/ai/index.ts
Normal file
14
packages/tm-core/src/providers/ai/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @fileoverview Barrel export for AI provider modules
|
||||
*/
|
||||
|
||||
export { BaseProvider } from './base-provider.js';
|
||||
export type { BaseProviderConfig, CompletionResult } from './base-provider.js';
|
||||
|
||||
// Export provider factory when implemented
|
||||
// export { ProviderFactory } from './provider-factory.js';
|
||||
|
||||
// Export concrete providers when implemented
|
||||
// export { AnthropicProvider } from './adapters/anthropic-provider.js';
|
||||
// export { OpenAIProvider } from './adapters/openai-provider.js';
|
||||
// export { GoogleProvider } from './adapters/google-provider.js';
|
||||
9
packages/tm-core/src/providers/index.ts
Normal file
9
packages/tm-core/src/providers/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @fileoverview Barrel export for provider modules
|
||||
*/
|
||||
|
||||
// Export all from AI module
|
||||
export * from './ai/index.js';
|
||||
|
||||
// Storage providers will be exported here when implemented
|
||||
// export * from './storage/index.js';
|
||||
354
packages/tm-core/src/services/task-service.ts
Normal file
354
packages/tm-core/src/services/task-service.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* @fileoverview Task Service
|
||||
* Core service for task operations - handles business logic between storage and API
|
||||
*/
|
||||
|
||||
import type { Task, TaskFilter, TaskStatus } from '../types/index.js';
|
||||
import type { IStorage } from '../interfaces/storage.interface.js';
|
||||
import { ConfigManager } from '../config/config-manager.js';
|
||||
import { StorageFactory } from '../storage/storage-factory.js';
|
||||
import { TaskEntity } from '../entities/task.entity.js';
|
||||
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
|
||||
|
||||
/**
|
||||
* Result returned by getTaskList
|
||||
*/
|
||||
export interface TaskListResult {
|
||||
/** The filtered list of tasks */
|
||||
tasks: Task[];
|
||||
/** Total number of tasks before filtering */
|
||||
total: number;
|
||||
/** Number of tasks after filtering */
|
||||
filtered: number;
|
||||
/** The tag these tasks belong to (only present if explicitly provided) */
|
||||
tag?: string;
|
||||
/** Storage type being used */
|
||||
storageType: 'file' | 'api';
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for getTaskList
|
||||
*/
|
||||
export interface GetTaskListOptions {
|
||||
/** Optional tag override (uses active tag from config if not provided) */
|
||||
tag?: string;
|
||||
/** Filter criteria */
|
||||
filter?: TaskFilter;
|
||||
/** Include subtasks in response */
|
||||
includeSubtasks?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskService handles all task-related operations
|
||||
* This is where business logic lives - it coordinates between ConfigManager and Storage
|
||||
*/
|
||||
export class TaskService {
|
||||
private configManager: ConfigManager;
|
||||
private storage: IStorage;
|
||||
private initialized = false;
|
||||
|
||||
constructor(configManager: ConfigManager) {
|
||||
this.configManager = configManager;
|
||||
|
||||
// Storage will be created during initialization
|
||||
this.storage = null as any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the service
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
// Create storage based on configuration
|
||||
const storageConfig = this.configManager.getStorageConfig();
|
||||
const projectRoot = this.configManager.getProjectRoot();
|
||||
|
||||
this.storage = StorageFactory.create(
|
||||
{ storage: storageConfig } as any,
|
||||
projectRoot
|
||||
);
|
||||
|
||||
// Initialize storage
|
||||
await this.storage.initialize();
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of tasks
|
||||
* This is the main method that retrieves tasks from storage and applies filters
|
||||
*/
|
||||
async getTaskList(options: GetTaskListOptions = {}): Promise<TaskListResult> {
|
||||
// Determine which tag to use
|
||||
const activeTag = this.configManager.getActiveTag();
|
||||
const tag = options.tag || activeTag;
|
||||
|
||||
try {
|
||||
// Load raw tasks from storage - storage only knows about tags
|
||||
const rawTasks = await this.storage.loadTasks(tag);
|
||||
|
||||
// Convert to TaskEntity for business logic operations
|
||||
const taskEntities = TaskEntity.fromArray(rawTasks);
|
||||
|
||||
// Apply filters if provided
|
||||
let filteredEntities = taskEntities;
|
||||
if (options.filter) {
|
||||
filteredEntities = this.applyFilters(taskEntities, options.filter);
|
||||
}
|
||||
|
||||
// Convert back to plain objects
|
||||
let tasks = filteredEntities.map((entity) => entity.toJSON());
|
||||
|
||||
// Handle subtasks option
|
||||
if (options.includeSubtasks === false) {
|
||||
tasks = tasks.map((task) => ({
|
||||
...task,
|
||||
subtasks: []
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
tasks,
|
||||
total: rawTasks.length,
|
||||
filtered: filteredEntities.length,
|
||||
tag: options.tag, // Only include tag if explicitly provided
|
||||
storageType: this.configManager.getStorageConfig().type
|
||||
};
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to get task list',
|
||||
ERROR_CODES.INTERNAL_ERROR,
|
||||
{
|
||||
operation: 'getTaskList',
|
||||
tag,
|
||||
hasFilter: !!options.filter
|
||||
},
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single task by ID
|
||||
*/
|
||||
async getTask(taskId: string, tag?: string): Promise<Task | null> {
|
||||
const result = await this.getTaskList({
|
||||
tag,
|
||||
includeSubtasks: true
|
||||
});
|
||||
|
||||
return result.tasks.find((t) => t.id === taskId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tasks filtered by status
|
||||
*/
|
||||
async getTasksByStatus(
|
||||
status: TaskStatus | TaskStatus[],
|
||||
tag?: string
|
||||
): Promise<Task[]> {
|
||||
const statuses = Array.isArray(status) ? status : [status];
|
||||
|
||||
const result = await this.getTaskList({
|
||||
tag,
|
||||
filter: { status: statuses }
|
||||
});
|
||||
|
||||
return result.tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about tasks
|
||||
*/
|
||||
async getTaskStats(tag?: string): Promise<{
|
||||
total: number;
|
||||
byStatus: Record<TaskStatus, number>;
|
||||
withSubtasks: number;
|
||||
blocked: number;
|
||||
storageType: 'file' | 'api';
|
||||
}> {
|
||||
const result = await this.getTaskList({
|
||||
tag,
|
||||
includeSubtasks: true
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: result.total,
|
||||
byStatus: {} as Record<TaskStatus, number>,
|
||||
withSubtasks: 0,
|
||||
blocked: 0,
|
||||
storageType: result.storageType
|
||||
};
|
||||
|
||||
// Initialize all statuses
|
||||
const allStatuses: TaskStatus[] = [
|
||||
'pending',
|
||||
'in-progress',
|
||||
'done',
|
||||
'deferred',
|
||||
'cancelled',
|
||||
'blocked',
|
||||
'review'
|
||||
];
|
||||
|
||||
allStatuses.forEach((status) => {
|
||||
stats.byStatus[status] = 0;
|
||||
});
|
||||
|
||||
// Count tasks
|
||||
result.tasks.forEach((task) => {
|
||||
stats.byStatus[task.status]++;
|
||||
|
||||
if (task.subtasks && task.subtasks.length > 0) {
|
||||
stats.withSubtasks++;
|
||||
}
|
||||
|
||||
if (task.status === 'blocked') {
|
||||
stats.blocked++;
|
||||
}
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next available task to work on
|
||||
*/
|
||||
async getNextTask(tag?: string): Promise<Task | null> {
|
||||
const result = await this.getTaskList({
|
||||
tag,
|
||||
filter: {
|
||||
status: ['pending', 'in-progress']
|
||||
}
|
||||
});
|
||||
|
||||
// Find tasks with no dependencies or all dependencies satisfied
|
||||
const completedIds = new Set(
|
||||
result.tasks.filter((t) => t.status === 'done').map((t) => t.id)
|
||||
);
|
||||
|
||||
const availableTasks = result.tasks.filter((task) => {
|
||||
if (task.status === 'done' || task.status === 'blocked') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!task.dependencies || task.dependencies.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return task.dependencies.every((depId) =>
|
||||
completedIds.has(depId.toString())
|
||||
);
|
||||
});
|
||||
|
||||
// Sort by priority
|
||||
availableTasks.sort((a, b) => {
|
||||
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
const aPriority = priorityOrder[a.priority || 'medium'];
|
||||
const bPriority = priorityOrder[b.priority || 'medium'];
|
||||
return aPriority - bPriority;
|
||||
});
|
||||
|
||||
return availableTasks[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters to task entities
|
||||
*/
|
||||
private applyFilters(tasks: TaskEntity[], filter: TaskFilter): TaskEntity[] {
|
||||
return tasks.filter((task) => {
|
||||
// Status filter
|
||||
if (filter.status) {
|
||||
const statuses = Array.isArray(filter.status)
|
||||
? filter.status
|
||||
: [filter.status];
|
||||
if (!statuses.includes(task.status)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority filter
|
||||
if (filter.priority) {
|
||||
const priorities = Array.isArray(filter.priority)
|
||||
? filter.priority
|
||||
: [filter.priority];
|
||||
if (!priorities.includes(task.priority)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if (filter.tags && filter.tags.length > 0) {
|
||||
if (
|
||||
!task.tags ||
|
||||
!filter.tags.some((tag) => task.tags?.includes(tag))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Assignee filter
|
||||
if (filter.assignee) {
|
||||
if (task.assignee !== filter.assignee) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Complexity filter
|
||||
if (filter.complexity) {
|
||||
const complexities = Array.isArray(filter.complexity)
|
||||
? filter.complexity
|
||||
: [filter.complexity];
|
||||
if (!task.complexity || !complexities.includes(task.complexity)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (filter.search) {
|
||||
const searchLower = filter.search.toLowerCase();
|
||||
const inTitle = task.title.toLowerCase().includes(searchLower);
|
||||
const inDescription = task.description
|
||||
.toLowerCase()
|
||||
.includes(searchLower);
|
||||
const inDetails = task.details.toLowerCase().includes(searchLower);
|
||||
|
||||
if (!inTitle && !inDescription && !inDetails) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Has subtasks filter
|
||||
if (filter.hasSubtasks !== undefined) {
|
||||
const hasSubtasks = task.subtasks.length > 0;
|
||||
if (hasSubtasks !== filter.hasSubtasks) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current storage type
|
||||
*/
|
||||
getStorageType(): 'file' | 'api' {
|
||||
return this.configManager.getStorageConfig().type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current active tag
|
||||
*/
|
||||
getActiveTag(): string {
|
||||
return this.configManager.getActiveTag();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active tag
|
||||
*/
|
||||
async setActiveTag(tag: string): Promise<void> {
|
||||
await this.configManager.setActiveTag(tag);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
188
packages/tm-core/src/task-master-core.ts
Normal file
188
packages/tm-core/src/task-master-core.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @fileoverview TaskMasterCore facade - main entry point for tm-core functionality
|
||||
*/
|
||||
|
||||
import { ConfigManager } from './config/config-manager.js';
|
||||
import {
|
||||
TaskService,
|
||||
type TaskListResult as ListTasksResult,
|
||||
type GetTaskListOptions
|
||||
} from './services/task-service.js';
|
||||
import { ERROR_CODES, TaskMasterError } from './errors/task-master-error.js';
|
||||
import type { IConfiguration } from './interfaces/configuration.interface.js';
|
||||
import type { Task, TaskStatus, TaskFilter } from './types/index.js';
|
||||
|
||||
/**
|
||||
* Options for creating TaskMasterCore instance
|
||||
*/
|
||||
export interface TaskMasterCoreOptions {
|
||||
projectPath: string;
|
||||
configuration?: Partial<IConfiguration>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-export result types from TaskService
|
||||
*/
|
||||
export type { TaskListResult as ListTasksResult } from './services/task-service.js';
|
||||
export type { GetTaskListOptions } from './services/task-service.js';
|
||||
|
||||
/**
|
||||
* TaskMasterCore facade class
|
||||
* Provides simplified API for all tm-core operations
|
||||
*/
|
||||
export class TaskMasterCore {
|
||||
private configManager: ConfigManager;
|
||||
private taskService: TaskService;
|
||||
|
||||
/**
|
||||
* Create and initialize a new TaskMasterCore instance
|
||||
* This is the ONLY way to create a TaskMasterCore
|
||||
*
|
||||
* @param options - Configuration options for TaskMasterCore
|
||||
* @returns Fully initialized TaskMasterCore instance
|
||||
*/
|
||||
static async create(options: TaskMasterCoreOptions): Promise<TaskMasterCore> {
|
||||
const instance = new TaskMasterCore();
|
||||
await instance.initialize(options);
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Private constructor - use TaskMasterCore.create() instead
|
||||
* This ensures the TaskMasterCore is always properly initialized
|
||||
*/
|
||||
private constructor() {
|
||||
// Services will be initialized in the initialize() method
|
||||
this.configManager = null as any;
|
||||
this.taskService = null as any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize by loading services
|
||||
* Private - only called by the factory method
|
||||
*/
|
||||
private async initialize(options: TaskMasterCoreOptions): Promise<void> {
|
||||
if (!options.projectPath) {
|
||||
throw new TaskMasterError(
|
||||
'Project path is required',
|
||||
ERROR_CODES.MISSING_CONFIGURATION
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Create config manager using factory method
|
||||
this.configManager = await ConfigManager.create(options.projectPath);
|
||||
|
||||
// Apply configuration overrides if provided
|
||||
if (options.configuration) {
|
||||
await this.configManager.updateConfig(options.configuration);
|
||||
}
|
||||
|
||||
// Create task service
|
||||
this.taskService = new TaskService(this.configManager);
|
||||
await this.taskService.initialize();
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to initialize TaskMasterCore',
|
||||
ERROR_CODES.INTERNAL_ERROR,
|
||||
{ operation: 'initialize' },
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of tasks with optional filtering
|
||||
* @deprecated Use getTaskList() instead
|
||||
*/
|
||||
async listTasks(options?: {
|
||||
tag?: string;
|
||||
filter?: TaskFilter;
|
||||
includeSubtasks?: boolean;
|
||||
}): Promise<ListTasksResult> {
|
||||
return this.getTaskList(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of tasks with optional filtering
|
||||
*/
|
||||
async getTaskList(options?: GetTaskListOptions): Promise<ListTasksResult> {
|
||||
return this.taskService.getTaskList(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific task by ID
|
||||
*/
|
||||
async getTask(taskId: string, tag?: string): Promise<Task | null> {
|
||||
return this.taskService.getTask(taskId, tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tasks by status
|
||||
*/
|
||||
async getTasksByStatus(
|
||||
status: TaskStatus | TaskStatus[],
|
||||
tag?: string
|
||||
): Promise<Task[]> {
|
||||
return this.taskService.getTasksByStatus(status, tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task statistics
|
||||
*/
|
||||
async getTaskStats(tag?: string): Promise<{
|
||||
total: number;
|
||||
byStatus: Record<TaskStatus, number>;
|
||||
withSubtasks: number;
|
||||
blocked: number;
|
||||
}> {
|
||||
const stats = await this.taskService.getTaskStats(tag);
|
||||
// Remove storageType from the return to maintain backward compatibility
|
||||
const { storageType, ...restStats } = stats;
|
||||
return restStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next available task
|
||||
*/
|
||||
async getNextTask(tag?: string): Promise<Task | null> {
|
||||
return this.taskService.getNextTask(tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current storage type
|
||||
*/
|
||||
getStorageType(): 'file' | 'api' {
|
||||
return this.taskService.getStorageType();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current active tag
|
||||
*/
|
||||
getActiveTag(): string {
|
||||
return this.configManager.getActiveTag();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active tag
|
||||
*/
|
||||
async setActiveTag(tag: string): Promise<void> {
|
||||
await this.configManager.setActiveTag(tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close and cleanup resources
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
// TaskService handles storage cleanup internally
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create TaskMasterCore instance
|
||||
*/
|
||||
export async function createTaskMasterCore(
|
||||
options: TaskMasterCoreOptions
|
||||
): Promise<TaskMasterCore> {
|
||||
return TaskMasterCore.create(options);
|
||||
}
|
||||
238
packages/tm-core/src/types/index.ts
Normal file
238
packages/tm-core/src/types/index.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Core type definitions for Task Master
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Type Literals
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Task status values
|
||||
*/
|
||||
export type TaskStatus =
|
||||
| 'pending'
|
||||
| 'in-progress'
|
||||
| 'done'
|
||||
| 'deferred'
|
||||
| 'cancelled'
|
||||
| 'blocked'
|
||||
| 'review';
|
||||
|
||||
/**
|
||||
* Task priority levels
|
||||
*/
|
||||
export type TaskPriority = 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
/**
|
||||
* Task complexity levels
|
||||
*/
|
||||
export type TaskComplexity = 'simple' | 'moderate' | 'complex' | 'very-complex';
|
||||
|
||||
// ============================================================================
|
||||
// Core Interfaces
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Placeholder task interface for temporary/minimal task objects
|
||||
*/
|
||||
export interface PlaceholderTask {
|
||||
id: string;
|
||||
title: string;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base task interface
|
||||
*/
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
dependencies: string[];
|
||||
details: string;
|
||||
testStrategy: string;
|
||||
subtasks: Subtask[];
|
||||
|
||||
// Optional enhanced properties
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
effort?: number;
|
||||
actualEffort?: number;
|
||||
tags?: string[];
|
||||
assignee?: string;
|
||||
complexity?: TaskComplexity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtask interface extending Task with numeric ID
|
||||
*/
|
||||
export interface Subtask extends Omit<Task, 'id' | 'subtasks'> {
|
||||
id: number;
|
||||
parentId: string;
|
||||
subtasks?: never; // Subtasks cannot have their own subtasks
|
||||
}
|
||||
|
||||
/**
|
||||
* Task metadata for tracking overall project state
|
||||
*/
|
||||
export interface TaskMetadata {
|
||||
version: string;
|
||||
lastModified: string;
|
||||
taskCount: number;
|
||||
completedCount: number;
|
||||
projectName?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Task collection with metadata
|
||||
*/
|
||||
export interface TaskCollection {
|
||||
tasks: Task[];
|
||||
metadata: TaskMetadata;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Type for creating a new task (without generated fields)
|
||||
*/
|
||||
export type CreateTask = Omit<
|
||||
Task,
|
||||
'id' | 'createdAt' | 'updatedAt' | 'subtasks'
|
||||
> & {
|
||||
subtasks?: Omit<Subtask, 'id' | 'parentId' | 'createdAt' | 'updatedAt'>[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Type for updating a task (all fields optional except ID)
|
||||
*/
|
||||
export type UpdateTask = Partial<Omit<Task, 'id'>> & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Type for task filters
|
||||
*/
|
||||
export interface TaskFilter {
|
||||
status?: TaskStatus | TaskStatus[];
|
||||
priority?: TaskPriority | TaskPriority[];
|
||||
tags?: string[];
|
||||
hasSubtasks?: boolean;
|
||||
search?: string;
|
||||
assignee?: string;
|
||||
complexity?: TaskComplexity | TaskComplexity[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for sort options
|
||||
*/
|
||||
export interface TaskSortOptions {
|
||||
field: keyof Task;
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Type Guards
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a valid TaskStatus
|
||||
*/
|
||||
export function isTaskStatus(value: unknown): value is TaskStatus {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
[
|
||||
'pending',
|
||||
'in-progress',
|
||||
'done',
|
||||
'deferred',
|
||||
'cancelled',
|
||||
'blocked',
|
||||
'review'
|
||||
].includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a valid TaskPriority
|
||||
*/
|
||||
export function isTaskPriority(value: unknown): value is TaskPriority {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
['low', 'medium', 'high', 'critical'].includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a valid TaskComplexity
|
||||
*/
|
||||
export function isTaskComplexity(value: unknown): value is TaskComplexity {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
['simple', 'moderate', 'complex', 'very-complex'].includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an object is a Task
|
||||
*/
|
||||
export function isTask(obj: unknown): obj is Task {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
const task = obj as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
typeof task.id === 'string' &&
|
||||
typeof task.title === 'string' &&
|
||||
typeof task.description === 'string' &&
|
||||
isTaskStatus(task.status) &&
|
||||
isTaskPriority(task.priority) &&
|
||||
Array.isArray(task.dependencies) &&
|
||||
typeof task.details === 'string' &&
|
||||
typeof task.testStrategy === 'string' &&
|
||||
Array.isArray(task.subtasks)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an object is a Subtask
|
||||
*/
|
||||
export function isSubtask(obj: unknown): obj is Subtask {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
const subtask = obj as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
typeof subtask.id === 'number' &&
|
||||
typeof subtask.parentId === 'string' &&
|
||||
typeof subtask.title === 'string' &&
|
||||
typeof subtask.description === 'string' &&
|
||||
isTaskStatus(subtask.status) &&
|
||||
isTaskPriority(subtask.priority) &&
|
||||
!('subtasks' in subtask)
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Deprecated Types (for backwards compatibility)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @deprecated Use TaskStatus instead
|
||||
*/
|
||||
export type Status = TaskStatus;
|
||||
|
||||
/**
|
||||
* @deprecated Use TaskPriority instead
|
||||
*/
|
||||
export type Priority = TaskPriority;
|
||||
|
||||
/**
|
||||
* @deprecated Use TaskComplexity instead
|
||||
*/
|
||||
export type Complexity = TaskComplexity;
|
||||
9
packages/tm-core/src/types/legacy.ts
Normal file
9
packages/tm-core/src/types/legacy.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @fileoverview Legacy type definitions for backwards compatibility
|
||||
* These types are deprecated and will be removed in future versions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @deprecated Use string directly instead. This will be removed in a future version.
|
||||
*/
|
||||
export type TaskId = string;
|
||||
142
packages/tm-core/src/utils/id-generator.ts
Normal file
142
packages/tm-core/src/utils/id-generator.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @fileoverview ID generation utilities for Task Master
|
||||
* Provides functions to generate unique identifiers for tasks and subtasks
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Generates a unique task ID using the format: TASK-{timestamp}-{random}
|
||||
*
|
||||
* @returns A unique task ID string
|
||||
* @example
|
||||
* ```typescript
|
||||
* const taskId = generateTaskId();
|
||||
* // Returns something like: "TASK-1704067200000-A7B3"
|
||||
* ```
|
||||
*/
|
||||
export function generateTaskId(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = generateRandomString(4);
|
||||
return `TASK-${timestamp}-${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a subtask ID using the format: {parentId}.{sequential}
|
||||
*
|
||||
* @param parentId - The ID of the parent task
|
||||
* @param existingSubtasks - Array of existing subtask IDs to determine the next sequential number
|
||||
* @returns A unique subtask ID string
|
||||
* @example
|
||||
* ```typescript
|
||||
* const subtaskId = generateSubtaskId("TASK-123-A7B3", ["TASK-123-A7B3.1"]);
|
||||
* // Returns: "TASK-123-A7B3.2"
|
||||
* ```
|
||||
*/
|
||||
export function generateSubtaskId(
|
||||
parentId: string,
|
||||
existingSubtasks: string[] = []
|
||||
): string {
|
||||
// Find existing subtasks for this parent
|
||||
const parentSubtasks = existingSubtasks.filter((id) =>
|
||||
id.startsWith(`${parentId}.`)
|
||||
);
|
||||
|
||||
// Extract sequential numbers and find the highest
|
||||
const sequentialNumbers = parentSubtasks
|
||||
.map((id) => {
|
||||
const parts = id.split('.');
|
||||
const lastPart = parts[parts.length - 1];
|
||||
return Number.parseInt(lastPart, 10);
|
||||
})
|
||||
.filter((num) => !Number.isNaN(num))
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
// Determine the next sequential number
|
||||
const nextSequential =
|
||||
sequentialNumbers.length > 0 ? Math.max(...sequentialNumbers) + 1 : 1;
|
||||
|
||||
return `${parentId}.${nextSequential}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random alphanumeric string of specified length
|
||||
* Uses crypto.randomBytes for cryptographically secure randomness
|
||||
*
|
||||
* @param length - The desired length of the random string
|
||||
* @returns A random alphanumeric string
|
||||
* @internal
|
||||
*/
|
||||
function generateRandomString(length: number): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const bytes = randomBytes(length);
|
||||
let result = '';
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars[bytes[i] % chars.length];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a task ID format
|
||||
*
|
||||
* @param id - The ID to validate
|
||||
* @returns True if the ID matches the expected task ID format
|
||||
* @example
|
||||
* ```typescript
|
||||
* isValidTaskId("TASK-1704067200000-A7B3"); // true
|
||||
* isValidTaskId("invalid-id"); // false
|
||||
* ```
|
||||
*/
|
||||
export function isValidTaskId(id: string): boolean {
|
||||
const taskIdRegex = /^TASK-\d{13}-[A-Z0-9]{4}$/;
|
||||
return taskIdRegex.test(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a subtask ID format
|
||||
*
|
||||
* @param id - The ID to validate
|
||||
* @returns True if the ID matches the expected subtask ID format
|
||||
* @example
|
||||
* ```typescript
|
||||
* isValidSubtaskId("TASK-1704067200000-A7B3.1"); // true
|
||||
* isValidSubtaskId("TASK-1704067200000-A7B3.1.2"); // true (nested subtask)
|
||||
* isValidSubtaskId("invalid.id"); // false
|
||||
* ```
|
||||
*/
|
||||
export function isValidSubtaskId(id: string): boolean {
|
||||
const parts = id.split('.');
|
||||
if (parts.length < 2) return false;
|
||||
|
||||
// First part should be a valid task ID
|
||||
const taskIdPart = parts[0];
|
||||
if (!isValidTaskId(taskIdPart)) return false;
|
||||
|
||||
// Remaining parts should be positive integers
|
||||
const sequentialParts = parts.slice(1);
|
||||
return sequentialParts.every((part) => {
|
||||
const num = Number.parseInt(part, 10);
|
||||
return !Number.isNaN(num) && num > 0 && part === num.toString();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the parent task ID from a subtask ID
|
||||
*
|
||||
* @param subtaskId - The subtask ID
|
||||
* @returns The parent task ID, or null if the input is not a valid subtask ID
|
||||
* @example
|
||||
* ```typescript
|
||||
* getParentTaskId("TASK-1704067200000-A7B3.1.2"); // "TASK-1704067200000-A7B3"
|
||||
* getParentTaskId("TASK-1704067200000-A7B3"); // null (not a subtask)
|
||||
* ```
|
||||
*/
|
||||
export function getParentTaskId(subtaskId: string): string | null {
|
||||
if (!isValidSubtaskId(subtaskId)) return null;
|
||||
|
||||
const parts = subtaskId.split('.');
|
||||
return parts[0];
|
||||
}
|
||||
44
packages/tm-core/src/utils/index.ts
Normal file
44
packages/tm-core/src/utils/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @fileoverview Utility functions for the tm-core package
|
||||
* This file exports all utility functions and helper classes
|
||||
*/
|
||||
|
||||
// Utility implementations will be defined here
|
||||
// export * from './validation.js';
|
||||
// export * from './formatting.js';
|
||||
// export * from './file-utils.js';
|
||||
// export * from './async-utils.js';
|
||||
|
||||
// Placeholder exports - these will be implemented in later tasks
|
||||
|
||||
/**
|
||||
* Generates a unique ID for tasks
|
||||
* @deprecated This is a placeholder function that will be properly implemented in later tasks
|
||||
*/
|
||||
export function generateTaskId(): string {
|
||||
return `task-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a task ID format
|
||||
* @deprecated This is a placeholder function that will be properly implemented in later tasks
|
||||
*/
|
||||
export function isValidTaskId(id: string): boolean {
|
||||
return typeof id === 'string' && id.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date for task timestamps
|
||||
* @deprecated This is a placeholder function that will be properly implemented in later tasks
|
||||
*/
|
||||
export function formatDate(date: Date = new Date()): string {
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clones an object
|
||||
* @deprecated This is a placeholder function that will be properly implemented in later tasks
|
||||
*/
|
||||
export function deepClone<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
422
packages/tm-core/tests/integration/list-tasks.test.ts
Normal file
422
packages/tm-core/tests/integration/list-tasks.test.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* @fileoverview End-to-end integration test for listTasks functionality
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
type Task,
|
||||
type TaskMasterCore,
|
||||
type TaskStatus,
|
||||
createTaskMasterCore
|
||||
} from '../../src/index';
|
||||
|
||||
describe('TaskMasterCore - listTasks E2E', () => {
|
||||
let tmpDir: string;
|
||||
let tmCore: TaskMasterCore;
|
||||
|
||||
// Sample tasks data
|
||||
const sampleTasks: Task[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Setup project',
|
||||
description: 'Initialize the project structure',
|
||||
status: 'done',
|
||||
priority: 'high',
|
||||
dependencies: [],
|
||||
details: 'Create all necessary directories and config files',
|
||||
testStrategy: 'Manual verification',
|
||||
subtasks: [
|
||||
{
|
||||
id: 1,
|
||||
parentId: '1',
|
||||
title: 'Create directories',
|
||||
description: 'Create project directories',
|
||||
status: 'done',
|
||||
priority: 'high',
|
||||
dependencies: [],
|
||||
details: 'Create src, tests, docs directories',
|
||||
testStrategy: 'Check directories exist'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
parentId: '1',
|
||||
title: 'Initialize package.json',
|
||||
description: 'Create package.json file',
|
||||
status: 'done',
|
||||
priority: 'high',
|
||||
dependencies: [],
|
||||
details: 'Run npm init',
|
||||
testStrategy: 'Verify package.json exists'
|
||||
}
|
||||
],
|
||||
tags: ['setup', 'infrastructure']
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Implement core features',
|
||||
description: 'Build the main functionality',
|
||||
status: 'in-progress',
|
||||
priority: 'high',
|
||||
dependencies: ['1'],
|
||||
details: 'Implement all core business logic',
|
||||
testStrategy: 'Unit tests for all features',
|
||||
subtasks: [],
|
||||
tags: ['feature', 'core'],
|
||||
assignee: 'developer1'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Write documentation',
|
||||
description: 'Create user and developer docs',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: ['2'],
|
||||
details: 'Write comprehensive documentation',
|
||||
testStrategy: 'Review by team',
|
||||
subtasks: [],
|
||||
tags: ['documentation'],
|
||||
complexity: 'simple'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Performance optimization',
|
||||
description: 'Optimize for speed and efficiency',
|
||||
status: 'blocked',
|
||||
priority: 'low',
|
||||
dependencies: ['2'],
|
||||
details: 'Profile and optimize bottlenecks',
|
||||
testStrategy: 'Performance benchmarks',
|
||||
subtasks: [],
|
||||
assignee: 'developer2',
|
||||
complexity: 'complex'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Security audit',
|
||||
description: 'Review security vulnerabilities',
|
||||
status: 'deferred',
|
||||
priority: 'critical',
|
||||
dependencies: [],
|
||||
details: 'Complete security assessment',
|
||||
testStrategy: 'Security scanning tools',
|
||||
subtasks: [],
|
||||
tags: ['security', 'audit']
|
||||
}
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temp directory for testing
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tm-core-test-'));
|
||||
|
||||
// Create .taskmaster/tasks directory
|
||||
const tasksDir = path.join(tmpDir, '.taskmaster', 'tasks');
|
||||
await fs.mkdir(tasksDir, { recursive: true });
|
||||
|
||||
// Write sample tasks.json
|
||||
const tasksFile = path.join(tasksDir, 'tasks.json');
|
||||
const tasksData = {
|
||||
tasks: sampleTasks,
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
lastModified: new Date().toISOString(),
|
||||
taskCount: sampleTasks.length,
|
||||
completedCount: 1
|
||||
}
|
||||
};
|
||||
await fs.writeFile(tasksFile, JSON.stringify(tasksData, null, 2));
|
||||
|
||||
// Create TaskMasterCore instance
|
||||
tmCore = createTaskMasterCore(tmpDir);
|
||||
await tmCore.initialize();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Cleanup
|
||||
if (tmCore) {
|
||||
await tmCore.close();
|
||||
}
|
||||
|
||||
// Remove temp directory
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Basic listing', () => {
|
||||
it('should list all tasks', async () => {
|
||||
const result = await tmCore.listTasks();
|
||||
|
||||
expect(result.tasks).toHaveLength(5);
|
||||
expect(result.total).toBe(5);
|
||||
expect(result.filtered).toBe(5);
|
||||
expect(result.tag).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include subtasks by default', async () => {
|
||||
const result = await tmCore.listTasks();
|
||||
const setupTask = result.tasks.find((t) => t.id === '1');
|
||||
|
||||
expect(setupTask?.subtasks).toHaveLength(2);
|
||||
expect(setupTask?.subtasks[0].title).toBe('Create directories');
|
||||
});
|
||||
|
||||
it('should exclude subtasks when requested', async () => {
|
||||
const result = await tmCore.listTasks({ includeSubtasks: false });
|
||||
const setupTask = result.tasks.find((t) => t.id === '1');
|
||||
|
||||
expect(setupTask?.subtasks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filtering', () => {
|
||||
it('should filter by status', async () => {
|
||||
const result = await tmCore.listTasks({
|
||||
filter: { status: 'done' }
|
||||
});
|
||||
|
||||
expect(result.filtered).toBe(1);
|
||||
expect(result.tasks[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('should filter by multiple statuses', async () => {
|
||||
const result = await tmCore.listTasks({
|
||||
filter: { status: ['done', 'in-progress'] }
|
||||
});
|
||||
|
||||
expect(result.filtered).toBe(2);
|
||||
const ids = result.tasks.map((t) => t.id);
|
||||
expect(ids).toContain('1');
|
||||
expect(ids).toContain('2');
|
||||
});
|
||||
|
||||
it('should filter by priority', async () => {
|
||||
const result = await tmCore.listTasks({
|
||||
filter: { priority: 'high' }
|
||||
});
|
||||
|
||||
expect(result.filtered).toBe(2);
|
||||
});
|
||||
|
||||
it('should filter by tags', async () => {
|
||||
const result = await tmCore.listTasks({
|
||||
filter: { tags: ['setup'] }
|
||||
});
|
||||
|
||||
expect(result.filtered).toBe(1);
|
||||
expect(result.tasks[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('should filter by assignee', async () => {
|
||||
const result = await tmCore.listTasks({
|
||||
filter: { assignee: 'developer1' }
|
||||
});
|
||||
|
||||
expect(result.filtered).toBe(1);
|
||||
expect(result.tasks[0].id).toBe('2');
|
||||
});
|
||||
|
||||
it('should filter by complexity', async () => {
|
||||
const result = await tmCore.listTasks({
|
||||
filter: { complexity: 'complex' }
|
||||
});
|
||||
|
||||
expect(result.filtered).toBe(1);
|
||||
expect(result.tasks[0].id).toBe('4');
|
||||
});
|
||||
|
||||
it('should filter by search term', async () => {
|
||||
const result = await tmCore.listTasks({
|
||||
filter: { search: 'documentation' }
|
||||
});
|
||||
|
||||
expect(result.filtered).toBe(1);
|
||||
expect(result.tasks[0].id).toBe('3');
|
||||
});
|
||||
|
||||
it('should filter by hasSubtasks', async () => {
|
||||
const withSubtasks = await tmCore.listTasks({
|
||||
filter: { hasSubtasks: true }
|
||||
});
|
||||
|
||||
expect(withSubtasks.filtered).toBe(1);
|
||||
expect(withSubtasks.tasks[0].id).toBe('1');
|
||||
|
||||
const withoutSubtasks = await tmCore.listTasks({
|
||||
filter: { hasSubtasks: false }
|
||||
});
|
||||
|
||||
expect(withoutSubtasks.filtered).toBe(4);
|
||||
});
|
||||
|
||||
it('should handle combined filters', async () => {
|
||||
const result = await tmCore.listTasks({
|
||||
filter: {
|
||||
priority: ['high', 'critical'],
|
||||
status: ['pending', 'deferred']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.filtered).toBe(1);
|
||||
expect(result.tasks[0].id).toBe('5'); // Critical priority, deferred status
|
||||
});
|
||||
});
|
||||
|
||||
describe('Helper methods', () => {
|
||||
it('should get task by ID', async () => {
|
||||
const task = await tmCore.getTask('2');
|
||||
|
||||
expect(task).not.toBeNull();
|
||||
expect(task?.title).toBe('Implement core features');
|
||||
});
|
||||
|
||||
it('should return null for non-existent task', async () => {
|
||||
const task = await tmCore.getTask('999');
|
||||
|
||||
expect(task).toBeNull();
|
||||
});
|
||||
|
||||
it('should get tasks by status', async () => {
|
||||
const pendingTasks = await tmCore.getTasksByStatus('pending');
|
||||
|
||||
expect(pendingTasks).toHaveLength(1);
|
||||
expect(pendingTasks[0].id).toBe('3');
|
||||
|
||||
const multipleTasks = await tmCore.getTasksByStatus(['done', 'blocked']);
|
||||
|
||||
expect(multipleTasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should get task statistics', async () => {
|
||||
const stats = await tmCore.getTaskStats();
|
||||
|
||||
expect(stats.total).toBe(5);
|
||||
expect(stats.byStatus.done).toBe(1);
|
||||
expect(stats.byStatus['in-progress']).toBe(1);
|
||||
expect(stats.byStatus.pending).toBe(1);
|
||||
expect(stats.byStatus.blocked).toBe(1);
|
||||
expect(stats.byStatus.deferred).toBe(1);
|
||||
expect(stats.byStatus.cancelled).toBe(0);
|
||||
expect(stats.byStatus.review).toBe(0);
|
||||
expect(stats.withSubtasks).toBe(1);
|
||||
expect(stats.blocked).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should handle missing tasks file gracefully', async () => {
|
||||
// Create new instance with empty directory
|
||||
const emptyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tm-empty-'));
|
||||
const emptyCore = createTaskMasterCore(emptyDir);
|
||||
|
||||
try {
|
||||
const result = await emptyCore.listTasks();
|
||||
|
||||
expect(result.tasks).toHaveLength(0);
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.filtered).toBe(0);
|
||||
} finally {
|
||||
await emptyCore.close();
|
||||
await fs.rm(emptyDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate task entities', async () => {
|
||||
// Write invalid task data
|
||||
const invalidDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'tm-invalid-')
|
||||
);
|
||||
const tasksDir = path.join(invalidDir, '.taskmaster', 'tasks');
|
||||
await fs.mkdir(tasksDir, { recursive: true });
|
||||
|
||||
const invalidData = {
|
||||
tasks: [
|
||||
{
|
||||
id: '', // Invalid: empty ID
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
status: 'done',
|
||||
priority: 'high',
|
||||
dependencies: [],
|
||||
details: 'Test',
|
||||
testStrategy: 'Test',
|
||||
subtasks: []
|
||||
}
|
||||
],
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
lastModified: new Date().toISOString(),
|
||||
taskCount: 1,
|
||||
completedCount: 0
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(tasksDir, 'tasks.json'),
|
||||
JSON.stringify(invalidData)
|
||||
);
|
||||
|
||||
const invalidCore = createTaskMasterCore(invalidDir);
|
||||
|
||||
try {
|
||||
await expect(invalidCore.listTasks()).rejects.toThrow();
|
||||
} finally {
|
||||
await invalidCore.close();
|
||||
await fs.rm(invalidDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tags support', () => {
|
||||
beforeEach(async () => {
|
||||
// Create tasks for a different tag
|
||||
const taggedTasks = [
|
||||
{
|
||||
id: 'tag-1',
|
||||
title: 'Tagged task',
|
||||
description: 'Task with tag',
|
||||
status: 'pending' as TaskStatus,
|
||||
priority: 'medium' as const,
|
||||
dependencies: [],
|
||||
details: 'Tagged task details',
|
||||
testStrategy: 'Test',
|
||||
subtasks: []
|
||||
}
|
||||
];
|
||||
|
||||
const tagFile = path.join(
|
||||
tmpDir,
|
||||
'.taskmaster',
|
||||
'tasks',
|
||||
'feature-branch.json'
|
||||
);
|
||||
await fs.writeFile(
|
||||
tagFile,
|
||||
JSON.stringify({
|
||||
tasks: taggedTasks,
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
lastModified: new Date().toISOString(),
|
||||
taskCount: 1,
|
||||
completedCount: 0
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should list tasks for specific tag', async () => {
|
||||
const result = await tmCore.listTasks({ tag: 'feature-branch' });
|
||||
|
||||
expect(result.tasks).toHaveLength(1);
|
||||
expect(result.tasks[0].id).toBe('tag-1');
|
||||
expect(result.tag).toBe('feature-branch');
|
||||
});
|
||||
|
||||
it('should list default tasks when no tag specified', async () => {
|
||||
const result = await tmCore.listTasks();
|
||||
|
||||
expect(result.tasks).toHaveLength(5);
|
||||
expect(result.tasks[0].id).toBe('1');
|
||||
});
|
||||
});
|
||||
});
|
||||
210
packages/tm-core/tests/mocks/mock-provider.ts
Normal file
210
packages/tm-core/tests/mocks/mock-provider.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* @fileoverview Mock provider for testing BaseProvider functionality
|
||||
*/
|
||||
|
||||
import type {
|
||||
AIModel,
|
||||
AIOptions,
|
||||
AIResponse,
|
||||
ProviderInfo,
|
||||
ProviderUsageStats
|
||||
} from '../../src/interfaces/ai-provider.interface';
|
||||
import {
|
||||
BaseProvider,
|
||||
type BaseProviderConfig,
|
||||
type CompletionResult
|
||||
} from '../../src/providers/ai/base-provider';
|
||||
|
||||
/**
|
||||
* Configuration for MockProvider behavior
|
||||
*/
|
||||
export interface MockProviderOptions extends BaseProviderConfig {
|
||||
shouldFail?: boolean;
|
||||
failAfterAttempts?: number;
|
||||
simulateRateLimit?: boolean;
|
||||
simulateTimeout?: boolean;
|
||||
responseDelay?: number;
|
||||
tokenMultiplier?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock provider for testing BaseProvider functionality
|
||||
*/
|
||||
export class MockProvider extends BaseProvider {
|
||||
private attemptCount = 0;
|
||||
private readonly options: MockProviderOptions;
|
||||
|
||||
constructor(options: MockProviderOptions) {
|
||||
super(options);
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate completion generation with configurable behavior
|
||||
*/
|
||||
protected async generateCompletionInternal(
|
||||
prompt: string,
|
||||
_options?: AIOptions
|
||||
): Promise<CompletionResult> {
|
||||
this.attemptCount++;
|
||||
|
||||
// Simulate delay if configured
|
||||
if (this.options.responseDelay) {
|
||||
await this.sleep(this.options.responseDelay);
|
||||
}
|
||||
|
||||
// Simulate failures based on configuration
|
||||
if (this.options.shouldFail) {
|
||||
throw new Error('Mock provider error');
|
||||
}
|
||||
|
||||
if (
|
||||
this.options.failAfterAttempts &&
|
||||
this.attemptCount <= this.options.failAfterAttempts
|
||||
) {
|
||||
if (this.options.simulateRateLimit) {
|
||||
throw new Error('Rate limit exceeded - too many requests (429)');
|
||||
}
|
||||
if (this.options.simulateTimeout) {
|
||||
throw new Error('Request timeout - ECONNRESET');
|
||||
}
|
||||
throw new Error('Temporary failure');
|
||||
}
|
||||
|
||||
// Return successful mock response
|
||||
return {
|
||||
content: `Mock response to: ${prompt}`,
|
||||
inputTokens: this.calculateTokens(prompt),
|
||||
outputTokens: this.calculateTokens(`Mock response to: ${prompt}`),
|
||||
finishReason: 'complete',
|
||||
model: this.model
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple token calculation for testing
|
||||
*/
|
||||
calculateTokens(text: string, _model?: string): number {
|
||||
const multiplier = this.options.tokenMultiplier || 1;
|
||||
// Rough approximation: 1 token per 4 characters
|
||||
return Math.ceil((text.length / 4) * multiplier);
|
||||
}
|
||||
|
||||
getName(): string {
|
||||
return 'mock';
|
||||
}
|
||||
|
||||
getDefaultModel(): string {
|
||||
return 'mock-model-v1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of attempts made
|
||||
*/
|
||||
getAttemptCount(): number {
|
||||
return this.attemptCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset attempt counter
|
||||
*/
|
||||
resetAttempts(): void {
|
||||
this.attemptCount = 0;
|
||||
}
|
||||
|
||||
// Implement remaining abstract methods
|
||||
async generateStreamingCompletion(
|
||||
prompt: string,
|
||||
_options?: AIOptions
|
||||
): AsyncIterator<Partial<AIResponse>> {
|
||||
// Simple mock implementation
|
||||
const response: Partial<AIResponse> = {
|
||||
content: `Mock streaming response to: ${prompt}`,
|
||||
provider: this.getName(),
|
||||
model: this.model
|
||||
};
|
||||
|
||||
return {
|
||||
async next() {
|
||||
return { value: response, done: true };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return !this.options.shouldFail;
|
||||
}
|
||||
|
||||
getProviderInfo(): ProviderInfo {
|
||||
return {
|
||||
name: 'mock',
|
||||
displayName: 'Mock Provider',
|
||||
description: 'Mock provider for testing',
|
||||
models: this.getAvailableModels(),
|
||||
defaultModel: this.getDefaultModel(),
|
||||
requiresApiKey: true,
|
||||
features: {
|
||||
streaming: true,
|
||||
functions: false,
|
||||
vision: false,
|
||||
embeddings: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getAvailableModels(): AIModel[] {
|
||||
return [
|
||||
{
|
||||
id: 'mock-model-v1',
|
||||
name: 'Mock Model v1',
|
||||
description: 'First mock model',
|
||||
contextLength: 4096,
|
||||
inputCostPer1K: 0.001,
|
||||
outputCostPer1K: 0.002,
|
||||
supportsStreaming: true
|
||||
},
|
||||
{
|
||||
id: 'mock-model-v2',
|
||||
name: 'Mock Model v2',
|
||||
description: 'Second mock model',
|
||||
contextLength: 8192,
|
||||
inputCostPer1K: 0.002,
|
||||
outputCostPer1K: 0.004,
|
||||
supportsStreaming: true
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
async validateCredentials(): Promise<boolean> {
|
||||
return this.apiKey === 'valid-key';
|
||||
}
|
||||
|
||||
async getUsageStats(): Promise<ProviderUsageStats | null> {
|
||||
return {
|
||||
totalRequests: this.attemptCount,
|
||||
totalTokens: 1000,
|
||||
totalCost: 0.01,
|
||||
requestsToday: this.attemptCount,
|
||||
tokensToday: 1000,
|
||||
costToday: 0.01,
|
||||
averageResponseTime: 100,
|
||||
successRate: 0.9,
|
||||
lastRequestAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// No-op for mock
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// No-op for mock
|
||||
}
|
||||
|
||||
// Override retry configuration for testing
|
||||
protected getMaxRetries(): number {
|
||||
return this.options.failAfterAttempts
|
||||
? this.options.failAfterAttempts + 1
|
||||
: 3;
|
||||
}
|
||||
}
|
||||
21
packages/tm-core/tests/setup.ts
Normal file
21
packages/tm-core/tests/setup.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @fileoverview Vitest test setup file
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, vi } from 'vitest';
|
||||
|
||||
// Setup any global test configuration here
|
||||
// For example, increase timeout for slow CI environments
|
||||
if (process.env.CI) {
|
||||
// Vitest timeout is configured in vitest.config.ts
|
||||
}
|
||||
|
||||
// Suppress console errors during tests unless explicitly testing them
|
||||
const originalError = console.error;
|
||||
beforeAll(() => {
|
||||
console.error = vi.fn();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
console.error = originalError;
|
||||
});
|
||||
265
packages/tm-core/tests/unit/base-provider.test.ts
Normal file
265
packages/tm-core/tests/unit/base-provider.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for BaseProvider abstract class
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ERROR_CODES,
|
||||
TaskMasterError
|
||||
} from '../../src/errors/task-master-error';
|
||||
import { MockProvider } from '../mocks/mock-provider';
|
||||
|
||||
describe('BaseProvider', () => {
|
||||
describe('constructor', () => {
|
||||
it('should require an API key', () => {
|
||||
expect(() => {
|
||||
new MockProvider({ apiKey: '' });
|
||||
}).toThrow(TaskMasterError);
|
||||
});
|
||||
|
||||
it('should initialize with provided API key and model', () => {
|
||||
const provider = new MockProvider({
|
||||
apiKey: 'test-key',
|
||||
model: 'mock-model-v2'
|
||||
});
|
||||
|
||||
expect(provider.getModel()).toBe('mock-model-v2');
|
||||
});
|
||||
|
||||
it('should use default model if not provided', () => {
|
||||
const provider = new MockProvider({ apiKey: 'test-key' });
|
||||
expect(provider.getModel()).toBe('mock-model-v1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCompletion', () => {
|
||||
let provider: MockProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new MockProvider({ apiKey: 'test-key' });
|
||||
});
|
||||
|
||||
it('should successfully generate a completion', async () => {
|
||||
const response = await provider.generateCompletion('Test prompt');
|
||||
|
||||
expect(response).toMatchObject({
|
||||
content: 'Mock response to: Test prompt',
|
||||
provider: 'mock',
|
||||
model: 'mock-model-v1',
|
||||
inputTokens: expect.any(Number),
|
||||
outputTokens: expect.any(Number),
|
||||
totalTokens: expect.any(Number),
|
||||
duration: expect.any(Number),
|
||||
timestamp: expect.any(String)
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate empty prompts', async () => {
|
||||
await expect(provider.generateCompletion('')).rejects.toThrow(
|
||||
'Prompt must be a non-empty string'
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate prompt type', async () => {
|
||||
await expect(provider.generateCompletion(null as any)).rejects.toThrow(
|
||||
'Prompt must be a non-empty string'
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate temperature range', async () => {
|
||||
await expect(
|
||||
provider.generateCompletion('Test', { temperature: 3 })
|
||||
).rejects.toThrow('Temperature must be between 0 and 2');
|
||||
});
|
||||
|
||||
it('should validate maxTokens range', async () => {
|
||||
await expect(
|
||||
provider.generateCompletion('Test', { maxTokens: 0 })
|
||||
).rejects.toThrow('Max tokens must be between 1 and 100000');
|
||||
});
|
||||
|
||||
it('should validate topP range', async () => {
|
||||
await expect(
|
||||
provider.generateCompletion('Test', { topP: 1.5 })
|
||||
).rejects.toThrow('Top-p must be between 0 and 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('retry logic', () => {
|
||||
it('should retry on rate limit errors', async () => {
|
||||
const provider = new MockProvider({
|
||||
apiKey: 'test-key',
|
||||
failAfterAttempts: 2,
|
||||
simulateRateLimit: true,
|
||||
responseDelay: 10
|
||||
});
|
||||
|
||||
const response = await provider.generateCompletion('Test prompt');
|
||||
|
||||
expect(response.content).toBe('Mock response to: Test prompt');
|
||||
expect(provider.getAttemptCount()).toBe(3); // 2 failures + 1 success
|
||||
});
|
||||
|
||||
it('should retry on timeout errors', async () => {
|
||||
const provider = new MockProvider({
|
||||
apiKey: 'test-key',
|
||||
failAfterAttempts: 1,
|
||||
simulateTimeout: true
|
||||
});
|
||||
|
||||
const response = await provider.generateCompletion('Test prompt');
|
||||
|
||||
expect(response.content).toBe('Mock response to: Test prompt');
|
||||
expect(provider.getAttemptCount()).toBe(2); // 1 failure + 1 success
|
||||
});
|
||||
|
||||
it('should fail after max retries', async () => {
|
||||
const provider = new MockProvider({
|
||||
apiKey: 'test-key',
|
||||
shouldFail: true
|
||||
});
|
||||
|
||||
await expect(provider.generateCompletion('Test prompt')).rejects.toThrow(
|
||||
'mock provider error'
|
||||
);
|
||||
});
|
||||
|
||||
it('should calculate exponential backoff delays', () => {
|
||||
const provider = new MockProvider({ apiKey: 'test-key' });
|
||||
|
||||
// Access protected method through type assertion
|
||||
const calculateDelay = (provider as any).calculateBackoffDelay.bind(
|
||||
provider
|
||||
);
|
||||
|
||||
const delay1 = calculateDelay(1);
|
||||
const delay2 = calculateDelay(2);
|
||||
const delay3 = calculateDelay(3);
|
||||
|
||||
// Check exponential growth (with jitter, so use ranges)
|
||||
expect(delay1).toBeGreaterThanOrEqual(900);
|
||||
expect(delay1).toBeLessThanOrEqual(1100);
|
||||
|
||||
expect(delay2).toBeGreaterThanOrEqual(1800);
|
||||
expect(delay2).toBeLessThanOrEqual(2200);
|
||||
|
||||
expect(delay3).toBeGreaterThanOrEqual(3600);
|
||||
expect(delay3).toBeLessThanOrEqual(4400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should wrap provider errors properly', async () => {
|
||||
const provider = new MockProvider({
|
||||
apiKey: 'test-key',
|
||||
shouldFail: true
|
||||
});
|
||||
|
||||
try {
|
||||
await provider.generateCompletion('Test prompt');
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(TaskMasterError);
|
||||
const tmError = error as TaskMasterError;
|
||||
expect(tmError.code).toBe(ERROR_CODES.PROVIDER_ERROR);
|
||||
expect(tmError.context.operation).toBe('generateCompletion');
|
||||
expect(tmError.context.resource).toBe('mock');
|
||||
}
|
||||
});
|
||||
|
||||
it('should identify rate limit errors correctly', () => {
|
||||
const provider = new MockProvider({ apiKey: 'test-key' });
|
||||
const isRateLimitError = (provider as any).isRateLimitError.bind(
|
||||
provider
|
||||
);
|
||||
|
||||
expect(isRateLimitError(new Error('Rate limit exceeded'))).toBe(true);
|
||||
expect(isRateLimitError(new Error('Too many requests'))).toBe(true);
|
||||
expect(isRateLimitError(new Error('Status: 429'))).toBe(true);
|
||||
expect(isRateLimitError(new Error('Some other error'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should identify timeout errors correctly', () => {
|
||||
const provider = new MockProvider({ apiKey: 'test-key' });
|
||||
const isTimeoutError = (provider as any).isTimeoutError.bind(provider);
|
||||
|
||||
expect(isTimeoutError(new Error('Request timeout'))).toBe(true);
|
||||
expect(isTimeoutError(new Error('Operation timed out'))).toBe(true);
|
||||
expect(isTimeoutError(new Error('ECONNRESET'))).toBe(true);
|
||||
expect(isTimeoutError(new Error('Some other error'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should identify network errors correctly', () => {
|
||||
const provider = new MockProvider({ apiKey: 'test-key' });
|
||||
const isNetworkError = (provider as any).isNetworkError.bind(provider);
|
||||
|
||||
expect(isNetworkError(new Error('Network error'))).toBe(true);
|
||||
expect(isNetworkError(new Error('ENOTFOUND'))).toBe(true);
|
||||
expect(isNetworkError(new Error('ECONNREFUSED'))).toBe(true);
|
||||
expect(isNetworkError(new Error('Some other error'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('model management', () => {
|
||||
it('should get and set model', () => {
|
||||
const provider = new MockProvider({ apiKey: 'test-key' });
|
||||
|
||||
expect(provider.getModel()).toBe('mock-model-v1');
|
||||
|
||||
provider.setModel('mock-model-v2');
|
||||
expect(provider.getModel()).toBe('mock-model-v2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider information', () => {
|
||||
it('should return provider info', () => {
|
||||
const provider = new MockProvider({ apiKey: 'test-key' });
|
||||
const info = provider.getProviderInfo();
|
||||
|
||||
expect(info.name).toBe('mock');
|
||||
expect(info.displayName).toBe('Mock Provider');
|
||||
expect(info.requiresApiKey).toBe(true);
|
||||
expect(info.models).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return available models', () => {
|
||||
const provider = new MockProvider({ apiKey: 'test-key' });
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
expect(models).toHaveLength(2);
|
||||
expect(models[0].id).toBe('mock-model-v1');
|
||||
expect(models[1].id).toBe('mock-model-v2');
|
||||
});
|
||||
|
||||
it('should validate credentials', async () => {
|
||||
const validProvider = new MockProvider({ apiKey: 'valid-key' });
|
||||
const invalidProvider = new MockProvider({ apiKey: 'invalid-key' });
|
||||
|
||||
expect(await validProvider.validateCredentials()).toBe(true);
|
||||
expect(await invalidProvider.validateCredentials()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('template method pattern', () => {
|
||||
it('should follow the template method flow', async () => {
|
||||
const provider = new MockProvider({
|
||||
apiKey: 'test-key',
|
||||
responseDelay: 50
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
const response = await provider.generateCompletion('Test prompt', {
|
||||
temperature: 0.5,
|
||||
maxTokens: 100
|
||||
});
|
||||
const endTime = Date.now();
|
||||
|
||||
// Verify the response was processed through the template
|
||||
expect(response.content).toBeDefined();
|
||||
expect(response.duration).toBeGreaterThanOrEqual(50);
|
||||
expect(response.duration).toBeLessThanOrEqual(endTime - startTime + 10);
|
||||
expect(response.timestamp).toBeDefined();
|
||||
expect(response.provider).toBe('mock');
|
||||
});
|
||||
});
|
||||
});
|
||||
139
packages/tm-core/tests/unit/smoke.test.ts
Normal file
139
packages/tm-core/tests/unit/smoke.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Smoke tests to verify basic package functionality and imports
|
||||
*/
|
||||
|
||||
import {
|
||||
PlaceholderParser,
|
||||
PlaceholderStorage,
|
||||
StorageError,
|
||||
TaskNotFoundError,
|
||||
TmCoreError,
|
||||
ValidationError,
|
||||
formatDate,
|
||||
generateTaskId,
|
||||
isValidTaskId,
|
||||
name,
|
||||
version
|
||||
} from '@tm/core';
|
||||
|
||||
import type {
|
||||
PlaceholderTask,
|
||||
TaskId,
|
||||
TaskPriority,
|
||||
TaskStatus
|
||||
} from '@tm/core';
|
||||
|
||||
describe('tm-core smoke tests', () => {
|
||||
describe('package metadata', () => {
|
||||
it('should export correct package name and version', () => {
|
||||
expect(name).toBe('@task-master/tm-core');
|
||||
expect(version).toBe('1.0.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('utility functions', () => {
|
||||
it('should generate valid task IDs', () => {
|
||||
const id1 = generateTaskId();
|
||||
const id2 = generateTaskId();
|
||||
|
||||
expect(typeof id1).toBe('string');
|
||||
expect(typeof id2).toBe('string');
|
||||
expect(id1).not.toBe(id2); // Should be unique
|
||||
expect(isValidTaskId(id1)).toBe(true);
|
||||
expect(isValidTaskId('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should format dates', () => {
|
||||
const date = new Date('2023-01-01T00:00:00.000Z');
|
||||
const formatted = formatDate(date);
|
||||
expect(formatted).toBe('2023-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeholder storage', () => {
|
||||
it('should perform basic storage operations', async () => {
|
||||
const storage = new PlaceholderStorage();
|
||||
const testPath = 'test/path';
|
||||
const testData = 'test data';
|
||||
|
||||
// Initially should not exist
|
||||
expect(await storage.exists(testPath)).toBe(false);
|
||||
expect(await storage.read(testPath)).toBe(null);
|
||||
|
||||
// Write and verify
|
||||
await storage.write(testPath, testData);
|
||||
expect(await storage.exists(testPath)).toBe(true);
|
||||
expect(await storage.read(testPath)).toBe(testData);
|
||||
|
||||
// Delete and verify
|
||||
await storage.delete(testPath);
|
||||
expect(await storage.exists(testPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeholder parser', () => {
|
||||
it('should parse simple task lists', async () => {
|
||||
const parser = new PlaceholderParser();
|
||||
const content = `
|
||||
- Task 1
|
||||
- Task 2
|
||||
- Task 3
|
||||
`;
|
||||
|
||||
const isValid = await parser.validate(content);
|
||||
expect(isValid).toBe(true);
|
||||
|
||||
const tasks = await parser.parse(content);
|
||||
expect(tasks).toHaveLength(3);
|
||||
expect(tasks[0]?.title).toBe('Task 1');
|
||||
expect(tasks[1]?.title).toBe('Task 2');
|
||||
expect(tasks[2]?.title).toBe('Task 3');
|
||||
|
||||
tasks.forEach((task) => {
|
||||
expect(task.status).toBe('pending');
|
||||
expect(task.priority).toBe('medium');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error classes', () => {
|
||||
it('should create and throw custom errors', () => {
|
||||
const baseError = new TmCoreError('Base error');
|
||||
expect(baseError.name).toBe('TmCoreError');
|
||||
expect(baseError.message).toBe('Base error');
|
||||
|
||||
const taskNotFound = new TaskNotFoundError('task-123');
|
||||
expect(taskNotFound.name).toBe('TaskNotFoundError');
|
||||
expect(taskNotFound.code).toBe('TASK_NOT_FOUND');
|
||||
expect(taskNotFound.message).toContain('task-123');
|
||||
|
||||
const validationError = new ValidationError('Invalid data');
|
||||
expect(validationError.name).toBe('ValidationError');
|
||||
expect(validationError.code).toBe('VALIDATION_ERROR');
|
||||
|
||||
const storageError = new StorageError('Storage failed');
|
||||
expect(storageError.name).toBe('StorageError');
|
||||
expect(storageError.code).toBe('STORAGE_ERROR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('type definitions', () => {
|
||||
it('should have correct types available', () => {
|
||||
// These are compile-time checks that verify types exist
|
||||
const taskId: TaskId = 'test-id';
|
||||
const status: TaskStatus = 'pending';
|
||||
const priority: TaskPriority = 'high';
|
||||
|
||||
const task: PlaceholderTask = {
|
||||
id: taskId,
|
||||
title: 'Test Task',
|
||||
status: status,
|
||||
priority: priority
|
||||
};
|
||||
|
||||
expect(task.id).toBe('test-id');
|
||||
expect(task.status).toBe('pending');
|
||||
expect(task.priority).toBe('high');
|
||||
});
|
||||
});
|
||||
});
|
||||
41
packages/tm-core/tsconfig.json
Normal file
41
packages/tm-core/tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/types": ["./src/types"],
|
||||
"@/providers": ["./src/providers"],
|
||||
"@/storage": ["./src/storage"],
|
||||
"@/parser": ["./src/parser"],
|
||||
"@/utils": ["./src/utils"],
|
||||
"@/errors": ["./src/errors"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "tests", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
27
packages/tm-core/tsup.config.ts
Normal file
27
packages/tm-core/tsup.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
index: 'src/index.ts',
|
||||
'types/index': 'src/types/index.ts',
|
||||
'providers/index': 'src/providers/index.ts',
|
||||
'storage/index': 'src/storage/index.ts',
|
||||
'parser/index': 'src/parser/index.ts',
|
||||
'utils/index': 'src/utils/index.ts',
|
||||
'errors/index': 'src/errors/index.ts'
|
||||
},
|
||||
format: ['cjs', 'esm'],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
splitting: false,
|
||||
treeshake: true,
|
||||
minify: false,
|
||||
target: 'es2022',
|
||||
tsconfig: './tsconfig.json',
|
||||
outDir: 'dist',
|
||||
external: ['zod'],
|
||||
esbuildOptions(options) {
|
||||
options.conditions = ['module'];
|
||||
}
|
||||
});
|
||||
57
packages/tm-core/vitest.config.ts
Normal file
57
packages/tm-core/vitest.config.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// __dirname in ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: [
|
||||
'tests/**/*.test.ts',
|
||||
'tests/**/*.spec.ts',
|
||||
'tests/{unit,integration,e2e}/**/*.{test,spec}.ts',
|
||||
'src/**/*.test.ts',
|
||||
'src/**/*.spec.ts'
|
||||
],
|
||||
exclude: ['node_modules', 'dist', '.git', '.cache'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
exclude: [
|
||||
'node_modules',
|
||||
'dist',
|
||||
'tests',
|
||||
'**/*.test.ts',
|
||||
'**/*.spec.ts',
|
||||
'**/*.d.ts',
|
||||
'src/index.ts'
|
||||
],
|
||||
thresholds: {
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80
|
||||
}
|
||||
},
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
testTimeout: 10000,
|
||||
clearMocks: true,
|
||||
restoreMocks: true,
|
||||
mockReset: true
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@/types': path.resolve(__dirname, './src/types'),
|
||||
'@/providers': path.resolve(__dirname, './src/providers'),
|
||||
'@/storage': path.resolve(__dirname, './src/storage'),
|
||||
'@/parser': path.resolve(__dirname, './src/parser'),
|
||||
'@/utils': path.resolve(__dirname, './src/utils'),
|
||||
'@/errors': path.resolve(__dirname, './src/errors')
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user