Skip to main content
Glama
config.ts•8.69 kB
import fs from 'fs-extra'; import { readFile, writeFile, rename } from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; import { z } from 'zod'; import lockfile from 'proper-lockfile'; import { log } from '../utils/logger.js'; import { ToyboxConfig, ToyboxConfigSchema, ToyboxRepoConfig, CONFIG_FILE_NAME, CONFIG_VERSION, createDefaultConfig } from '../types/config.js'; /** * Service for managing TOYBOX configuration * Implements safe file operations with atomic writes and locking */ export class ConfigService { private configPath: string; private lockRetries = 5; private lockStale = 5000; // 5 seconds constructor(configPath?: string) { this.configPath = configPath || path.join(os.homedir(), CONFIG_FILE_NAME); } /** * Read configuration from disk * Creates default config if file doesn't exist */ async read(): Promise<ToyboxConfig> { try { // Check if config file exists const exists = await fs.pathExists(this.configPath); if (!exists) { // Create default config const defaultConfig = createDefaultConfig(); await this.write(defaultConfig); return defaultConfig; } // Read and parse config const configData = await readFile(this.configPath, 'utf-8'); const parsedConfig = JSON.parse(configData); // Validate with Zod schema const validatedConfig = ToyboxConfigSchema.parse(parsedConfig); // Migrate if needed const migratedConfig = await this.migrate(validatedConfig); return migratedConfig; } catch (error) { if (error instanceof z.ZodError) { log.error('Invalid configuration format', { errors: error.errors }); // Return default config on validation error const defaultConfig = createDefaultConfig(); await this.write(defaultConfig); return defaultConfig; } throw new Error(`Failed to read configuration: ${error instanceof Error ? error.message : String(error)}`); } } /** * Write configuration to disk with atomic operation * Uses write-rename pattern to prevent corruption */ async write(config: ToyboxConfig): Promise<void> { // Update lastUpdated timestamp config.lastUpdated = new Date().toISOString(); // Validate config before writing const validatedConfig = ToyboxConfigSchema.parse(config); // Create temp file path const tempPath = `${this.configPath}.tmp`; try { // Ensure directory exists await fs.ensureDir(path.dirname(this.configPath)); // Write to temp file await writeFile( tempPath, JSON.stringify(validatedConfig, null, 2), 'utf-8' ); // Atomic rename await rename(tempPath, this.configPath); } catch (error) { // Clean up temp file if it exists await fs.remove(tempPath).catch(() => {}); throw new Error(`Failed to write configuration: ${error instanceof Error ? error.message : String(error)}`); } } /** * Update configuration with locking to prevent concurrent modifications */ async update(updater: (config: ToyboxConfig) => ToyboxConfig | Promise<ToyboxConfig>): Promise<ToyboxConfig> { let release: (() => Promise<void>) | null = null; try { // Acquire lock with retries release = await lockfile.lock(this.configPath, { retries: this.lockRetries, stale: this.lockStale, }); // Read current config const currentConfig = await this.read(); // Apply update const updatedConfig = await updater(currentConfig); // Write updated config await this.write(updatedConfig); return updatedConfig; } catch (error) { if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { // File doesn't exist yet, create it const defaultConfig = createDefaultConfig(); const updatedConfig = await updater(defaultConfig); await this.write(updatedConfig); return updatedConfig; } throw new Error(`Failed to update configuration: ${error instanceof Error ? error.message : String(error)}`); } finally { // Always release lock if (release) { await release(); } } } /** * Get active repository configuration */ async getActiveRepository(): Promise<ToyboxRepoConfig | null> { const config = await this.read(); if (!config.activeRepository) { // Try to find a repository marked as active const activeRepo = config.repositories.find(r => r.isActive); return activeRepo || null; } return config.repositories.find(r => r.name === config.activeRepository) || null; } /** * Set active repository */ async setActiveRepository(repoName: string): Promise<void> { await this.update(config => { // Ensure repository exists const repo = config.repositories.find(r => r.name === repoName); if (!repo) { throw new Error(`Repository '${repoName}' not found in configuration`); } // Update active flags config.repositories.forEach(r => { r.isActive = r.name === repoName; }); config.activeRepository = repoName; return config; }); } /** * Add or update repository configuration */ async upsertRepository(repo: ToyboxRepoConfig): Promise<void> { await this.update(config => { const existingIndex = config.repositories.findIndex(r => r.name === repo.name); if (existingIndex >= 0) { // Update existing config.repositories[existingIndex] = { ...config.repositories[existingIndex], ...repo, lastUsedAt: new Date().toISOString(), }; } else { // Add new config.repositories.push({ ...repo, createdAt: repo.createdAt || new Date().toISOString(), lastUsedAt: new Date().toISOString(), }); } // If this is the first repository, make it active if (config.repositories.length === 1) { config.activeRepository = repo.name; config.repositories[0].isActive = true; } return config; }); } /** * Remove repository configuration */ async removeRepository(repoName: string): Promise<void> { await this.update(config => { config.repositories = config.repositories.filter(r => r.name !== repoName); // Clear active repository if it was removed if (config.activeRepository === repoName) { config.activeRepository = undefined; // Make the first remaining repository active if (config.repositories.length > 0) { config.repositories[0].isActive = true; config.activeRepository = config.repositories[0].name; } } return config; }); } /** * Get all repositories */ async getRepositories(): Promise<ToyboxRepoConfig[]> { const config = await this.read(); return config.repositories; } /** * Update repository last used timestamp */ async touchRepository(repoName: string): Promise<void> { await this.update(config => { const repo = config.repositories.find(r => r.name === repoName); if (repo) { repo.lastUsedAt = new Date().toISOString(); } return config; }); } /** * Update repository configuration */ async updateRepository(repoName: string, updates: Partial<ToyboxRepoConfig>): Promise<void> { await this.update(config => { const repo = config.repositories.find(r => r.name === repoName); if (repo) { Object.assign(repo, updates); repo.lastUsedAt = new Date().toISOString(); } return config; }); } /** * Migrate configuration to latest version */ private async migrate(config: ToyboxConfig): Promise<ToyboxConfig> { // Currently at version 1.0.0, no migrations needed // Future migrations would go here if (config.version !== CONFIG_VERSION) { // Example migration pattern: // if (config.version === '0.9.0') { // config = migrateFrom090To100(config); // } config.version = CONFIG_VERSION; } return config; } /** * Check if configuration exists */ async exists(): Promise<boolean> { return await fs.pathExists(this.configPath); } /** * Get configuration file path */ getConfigPath(): string { return this.configPath; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/isnbh0/toybox-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server