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

- add typescript
- add npm workspaces
This commit is contained in:
Ralph Khreish
2025-09-01 21:44:43 +02:00
committed by GitHub
parent df26c65632
commit 19ec52181d
163 changed files with 21196 additions and 4648 deletions

View 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();
});
});
});

View 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
}
}

View 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';

View File

@@ -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);
});
});
});

View 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;
}
}
}

View File

@@ -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);
});
});
});

View 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;
}
}

View File

@@ -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();
});
});
});

View File

@@ -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
);
}
}
}

View File

@@ -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);
});
});
});

View File

@@ -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];
}
}

View 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';

View File

@@ -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'
);
});
});
});

View File

@@ -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
};
}
}

View 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;

View 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));
}
}

View 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';
}
}

View 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);
}
}

View 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';

View 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');
}
}
}

View 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;

View 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';

View 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();
}
}

View 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;
}
}

View 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>;
}

View 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';

View 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';

View 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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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);
}

View 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;

View 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;

View 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];
}

View 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));
}