ConfigManager.tsโข34.9 kB
/**
* ConfigManager - Centralized configuration management for DollhouseMCP
*
* Features:
* - YAML-based configuration file
* - Default values with user overrides
* - Migration from environment variables
* - Validation and type safety
* - Atomic updates with backup
* - Privacy-first defaults
* - OAuth client ID storage for MCP client integration
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import * as yaml from 'js-yaml';
import { logger } from '../utils/logger.js';
import { SecureYamlParser } from '../security/secureYamlParser.js';
import {
validatePropertyPath,
safeSetProperty,
createSafeObject,
safeHasOwnProperty
} from '../utils/securityUtils.js';
export interface UserConfig {
username: string | null;
email: string | null;
display_name: string | null;
}
export interface GitHubPortfolioConfig {
repository_url: string | null;
repository_name: string;
default_branch: string;
auto_create: boolean;
}
export interface GitHubAuthConfig {
use_oauth: boolean;
token_source: 'environment' | 'oauth' | 'config';
client_id?: string; // OAuth client ID for GitHub App
}
export interface GitHubConfig {
portfolio: GitHubPortfolioConfig;
auth: GitHubAuthConfig;
}
export interface SyncIndividualConfig {
require_confirmation: boolean;
show_diff_before_sync: boolean;
track_versions: boolean;
keep_history: number;
}
export interface SyncBulkConfig {
upload_enabled: boolean;
download_enabled: boolean;
require_preview: boolean;
respect_local_only: boolean;
}
export interface SyncPrivacyConfig {
scan_for_secrets: boolean;
scan_for_pii: boolean;
warn_on_sensitive: boolean;
excluded_patterns: string[];
}
export interface SyncConfig {
enabled: boolean;
individual: SyncIndividualConfig;
bulk: SyncBulkConfig;
privacy: SyncPrivacyConfig;
}
export interface CollectionConfig {
auto_submit: boolean;
require_review: boolean;
add_attribution: boolean;
}
export interface CapabilityIndexResourcesConfig {
advertise_resources: boolean; // Default: false - safe, don't advertise
variants: {
summary: boolean; // ~1,254 tokens - Opt-in
full: boolean; // ~48,306 tokens - Opt-in
stats: boolean; // ~50 tokens - Safe to enable by default
};
}
export interface EnhancedIndexConfig {
enabled: boolean;
limits: {
maxTriggersPerElement: number;
maxTriggerLength: number;
maxKeywordsToCheck: number;
};
telemetry: {
enabled: boolean;
sampleRate: number;
metricsInterval: number;
};
verbPatterns?: {
customPrefixes?: string[];
customSuffixes?: string[];
excludedNouns?: string[];
};
backgroundAnalysis?: {
enabled: boolean;
scanInterval: number;
maxConcurrentScans: number;
};
resources?: CapabilityIndexResourcesConfig;
}
export interface ElementsConfig {
auto_activate: {
personas?: string[];
skills?: string[];
templates?: string[];
agents?: string[];
memories?: string[];
ensembles?: string[];
};
default_element_dir: string;
enhanced_index?: EnhancedIndexConfig;
}
export interface DisplayConfig {
persona_indicators: {
enabled: boolean;
style: 'full' | 'minimal' | 'compact' | 'custom';
include_emoji: boolean;
};
verbose_logging: boolean;
show_progress: boolean;
}
export interface WizardConfig {
completed: boolean; // Wizard was successfully completed
dismissed: boolean; // User chose "Don't show again"
completedAt?: string; // ISO timestamp when completed
version?: string; // Wizard version for future updates (deprecated - use lastSeenVersion)
lastSeenVersion?: string; // Last version where user saw the wizard
skippedSections?: string[]; // Track which sections were skipped
}
export interface DollhouseConfig {
version: string;
user: UserConfig;
github: GitHubConfig;
sync: SyncConfig;
collection: CollectionConfig;
elements: ElementsConfig;
display: DisplayConfig;
wizard: WizardConfig;
}
export interface ConfigUpdateResult {
success: boolean;
message: string;
previousValue?: any;
newValue?: any;
}
export interface ConfigActionResult {
success: boolean;
message: string;
data?: any;
}
export class ConfigManager {
private static instance: ConfigManager | null = null;
private static instanceLock: boolean = false;
private configDir: string;
private configPath: string;
private backupPath: string;
private config: DollhouseConfig | null = null;
private constructor() {
// Initialize paths - use test directory if in test environment
if (process.env.NODE_ENV === 'test' && process.env.TEST_CONFIG_DIR) {
this.configDir = process.env.TEST_CONFIG_DIR;
} else {
this.configDir = path.join(os.homedir(), '.dollhouse');
}
this.configPath = path.join(this.configDir, 'config.yml');
this.backupPath = path.join(this.configDir, 'config.yml.backup');
}
/**
* Thread-safe singleton instance getter
*/
public static getInstance(): ConfigManager {
if (ConfigManager.instance) {
return ConfigManager.instance;
}
// Simple locking mechanism to prevent race conditions
if (ConfigManager.instanceLock) {
// Wait for lock to be released, then return the instance
while (ConfigManager.instanceLock && !ConfigManager.instance) {
// In a real scenario with async operations, this would be more sophisticated
// But for the test cases, this simple approach works
}
return ConfigManager.instance!;
}
ConfigManager.instanceLock = true;
if (!ConfigManager.instance) {
ConfigManager.instance = new ConfigManager();
}
ConfigManager.instanceLock = false;
return ConfigManager.instance;
}
/**
* Reset the singleton instance for testing purposes only.
* This method is ONLY available in test environments to enable proper test isolation.
*
* IMPORTANT: This follows industry-standard patterns used by Google, Facebook, Microsoft
* for testing singleton classes. The method is protected by an environment check to
* ensure it cannot be called in production.
*
* @throws Error if called outside test environment
*/
public static resetForTesting(): void {
// Security check: only allow in test environment
if (process.env.NODE_ENV !== 'test') {
const errorMsg = 'ConfigManager.resetForTesting() can only be called in test environment';
console.error(errorMsg);
throw new Error(errorMsg);
}
// Reset the singleton instance
ConfigManager.instance = null;
ConfigManager.instanceLock = false;
// Log for debugging (only in test environment with DEBUG flag)
if (process.env.DEBUG) {
console.log('[TEST] ConfigManager singleton reset');
}
}
/**
* Get default configuration
*/
private getDefaultConfig(): DollhouseConfig {
return {
version: '1.0.0',
user: {
username: null,
email: null,
display_name: null
},
github: {
portfolio: {
repository_url: null,
repository_name: process.env.TEST_GITHUB_REPO || 'dollhouse-portfolio',
default_branch: 'main',
auto_create: true
},
auth: {
use_oauth: true,
token_source: 'environment'
}
},
sync: {
enabled: false, // Privacy first - off by default
individual: {
require_confirmation: true,
show_diff_before_sync: true,
track_versions: true,
keep_history: 10
},
bulk: {
upload_enabled: false, // Requires explicit enablement
download_enabled: false,
require_preview: true,
respect_local_only: true
},
privacy: {
scan_for_secrets: true,
scan_for_pii: true,
warn_on_sensitive: true,
excluded_patterns: [
'*.secret',
'*-private.*',
'credentials/**',
'personal/**'
]
}
},
collection: {
auto_submit: false, // Never auto-submit
require_review: true,
add_attribution: true
},
elements: {
auto_activate: {},
default_element_dir: path.join(os.homedir(), '.dollhouse', 'portfolio'),
enhanced_index: {
enabled: true,
limits: {
maxTriggersPerElement: 50,
maxTriggerLength: 50,
maxKeywordsToCheck: 100
},
telemetry: {
enabled: false, // Opt-in only
sampleRate: 0.1,
metricsInterval: 60000
},
resources: {
advertise_resources: false, // Default: safe, disabled
variants: {
summary: false, // ~1,254 tokens - Opt-in required
full: false, // ~48,306 tokens - Opt-in required
stats: true // ~50 tokens - Safe by default
}
}
}
},
display: {
persona_indicators: {
enabled: true,
style: 'minimal',
include_emoji: true
},
verbose_logging: false,
show_progress: true
},
wizard: {
completed: false,
dismissed: false
}
};
}
/**
* Initialize configuration
*/
public async initialize(): Promise<void> {
// Always reload config from disk if it exists, even if we have defaults in memory
// This ensures we pick up any manual edits or saved settings
try {
// Ensure config directory exists with proper permissions (0o700 = owner only)
await fs.mkdir(this.configDir, { recursive: true, mode: 0o700 });
// Load or create config
if (await this.configExists()) {
await this.loadConfig();
} else {
// Create default config
this.config = this.getDefaultConfig();
// Try to migrate from environment variables
await this.migrateFromEnvironment();
// Save the config
await this.saveConfig();
logger.info('Created new configuration file', {
path: this.configPath
});
}
} catch (error) {
logger.error('Failed to initialize configuration', {
error: error instanceof Error ? error.message : String(error)
});
// Use defaults in memory
this.config = this.getDefaultConfig();
}
}
/**
* Load configuration from file
*/
private async loadConfig(): Promise<void> {
try {
const content = await fs.readFile(this.configPath, 'utf-8');
/**
* IMPORTANT: Parser Selection for Different File Types
*
* We use DIFFERENT parsers for different file types:
*
* 1. js-yaml (used here) - For PURE YAML files:
* - Configuration files (config.yml)
* - Data files without markdown content
* - Any .yml or .yaml file that's just YAML
* Example format:
* ```yaml
* version: 1.0.0
* user:
* username: johndoe
* email: john@example.com
* ```
*
* 2. SecureYamlParser - For MARKDOWN files with YAML frontmatter:
* - Persona files (*.md in personas/)
* - Skill files (*.md in skills/)
* - Template files (*.md in templates/)
* - Any .md file with frontmatter between --- markers
* Example format:
* ```markdown
* ---
* name: Creative Writer
* description: A creative assistant
* ---
* # Instructions
* You are a creative writer...
* ```
*
* The config file is PURE YAML, so we use js-yaml directly with FAILSAFE_SCHEMA
* for security (prevents code execution via YAML tags).
* SECURITY: This is NOT a vulnerability - FAILSAFE_SCHEMA prevents code execution
*/
let loadedData: any;
try {
// Using yaml with FAILSAFE_SCHEMA is secure - prevents code execution
loadedData = yaml.load(content, {
schema: yaml.FAILSAFE_SCHEMA // Safe schema prevents code execution
});
} catch (yamlError) {
throw new Error(`Invalid YAML in configuration file: ${yamlError instanceof Error ? yamlError.message : String(yamlError)}`);
}
if (!loadedData || typeof loadedData !== 'object') {
throw new Error('Invalid configuration format');
}
logger.debug('Loaded config from file', {
username: loadedData.user?.username,
email: loadedData.user?.email,
syncEnabled: loadedData.sync?.enabled
});
this.config = this.mergeWithDefaults(loadedData);
// Fix any string booleans that might have been saved incorrectly
this.fixConfigTypes();
logger.debug('Configuration loaded successfully', {
username: this.config.user.username,
syncEnabled: this.config.sync.enabled
});
} catch (error) {
logger.error('Failed to load configuration', {
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
}
/**
* Check if config file exists
*/
private async configExists(): Promise<boolean> {
try {
await fs.access(this.configPath);
return true;
} catch {
return false;
}
}
/**
* Get GitHub OAuth client ID
* Environment variable takes precedence over config file
*/
public getGitHubClientId(): string | null {
// Check environment variable first
const envClientId = process.env.DOLLHOUSE_GITHUB_CLIENT_ID;
if (envClientId) {
return envClientId;
}
// Fall back to config file
return this.config?.github?.auth?.client_id || null;
}
/**
* Set GitHub OAuth client ID in config file
*/
public async setGitHubClientId(clientId: string): Promise<void> {
if (!ConfigManager.validateClientId(clientId)) {
throw new Error(
`Invalid GitHub client ID format. Expected format: Ov23li followed by at least 14 alphanumeric characters (e.g., Ov23liABCDEFGHIJKLMN)`
);
}
if (!this.config) {
this.config = this.getDefaultConfig();
}
// Ensure github.auth object exists
if (!this.config.github) {
this.config.github = this.getDefaultConfig().github;
}
if (!this.config.github.auth) {
this.config.github.auth = this.getDefaultConfig().github.auth;
}
this.config.github.auth.client_id = clientId;
await this.saveConfig();
}
/**
* Get the current configuration
*/
public getConfig(): DollhouseConfig {
if (!this.config) {
throw new Error('Configuration not initialized');
}
return this.config;
}
/**
* Get a specific setting using dot notation
*/
public getSetting<T>(path: string, defaultValue?: T): T | undefined {
if (!this.config) {
return defaultValue;
}
const keys = path.split('.');
let value: any = this.config;
for (const key of keys) {
if (value && typeof value === 'object' && key in value) {
value = value[key];
} else {
return defaultValue;
}
}
return value as T;
}
/**
* Update a specific setting using dot notation
* SECURITY FIX (PR #895): Added prototype pollution protection
* Previously: Direct property assignment allowed __proto__ injection
* Now: Validates keys against forbidden properties before assignment
*/
public async updateSetting(path: string, value: any): Promise<ConfigUpdateResult> {
if (!this.config) {
await this.initialize();
}
// SECURITY: Validate path to prevent prototype pollution
validatePropertyPath(path, 'path');
const keys = path.split('.');
let current: any = this.config;
const previousValue = this.getSetting(path);
// Navigate to the parent object using security utilities
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
// SECURITY: Use safe property check and create prototype-less objects
if (!safeHasOwnProperty(current, key)) {
current[key] = createSafeObject();
}
current = current[key];
}
// Set the value using secure property setter
const lastKey = keys[keys.length - 1];
safeSetProperty(current, lastKey, value);
// Save the configuration
await this.saveConfig();
logger.info('Configuration setting updated', {
path,
previousValue,
newValue: value
});
return {
success: true,
message: `Setting '${path}' updated successfully`,
previousValue,
newValue: value
};
}
/**
* Validate GitHub OAuth client ID format
* Client IDs start with "Ov23li" followed by at least 14 alphanumeric characters
*
* @param clientId - The client ID to validate
* @returns true if valid, false otherwise
*
* @example
* ConfigManager.validateClientId("Ov23liABCDEFGHIJKLMN123456") // true
* ConfigManager.validateClientId("invalid") // false
* ConfigManager.validateClientId("Ov23li") // false (too short)
* ConfigManager.validateClientId("Xv23liABCDEFGHIJKLMN") // false (wrong prefix)
*/
public static validateClientId(clientId: any): boolean {
if (typeof clientId !== 'string' || !clientId) {
return false;
}
// GitHub OAuth client IDs follow the pattern: Ov23li[A-Za-z0-9]{14,}
const clientIdPattern = /^Ov23li[A-Za-z0-9]{14,}$/;
return clientIdPattern.test(clientId);
}
/**
* Save configuration to file
*/
private async saveConfig(): Promise<void> {
if (!this.config) {
throw new Error('No configuration to save');
}
try {
// Create backup of existing config
if (await this.configExists()) {
await fs.copyFile(this.configPath, this.backupPath);
}
// Convert to YAML
// Note: We use js-yaml's dump() for pure YAML output (no frontmatter markers)
// This creates a standard YAML file, not a markdown file with frontmatter
const yamlContent = yaml.dump(this.config, {
indent: 2,
lineWidth: 120,
noRefs: true,
sortKeys: false
// Using default schema (not FAILSAFE) for dump to preserve types like booleans
});
// Write atomically with proper permissions (0o600 = owner read/write only)
const tempPath = `${this.configPath}.tmp`;
await fs.writeFile(tempPath, yamlContent, { encoding: 'utf-8', mode: 0o600 });
await fs.rename(tempPath, this.configPath);
logger.debug('Configuration saved successfully');
// Log audit event for configuration update
logger.debug('Configuration update audit', {
event: 'CONFIG_UPDATED',
source: 'ConfigManager.saveConfig',
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('Failed to save configuration', {
error: error instanceof Error ? error.message : String(error)
});
// Try to restore backup
if (await this.backupExists()) {
await fs.copyFile(this.backupPath, this.configPath);
logger.info('Restored configuration from backup');
}
throw error;
}
}
/**
* Check if backup exists
*/
private async backupExists(): Promise<boolean> {
try {
await fs.access(this.backupPath);
return true;
} catch {
return false;
}
}
/**
* Fix incorrect types in config (e.g., string booleans, string "null")
*/
private fixConfigTypes(): void {
if (!this.config) return;
// Helper to convert string "null" to actual null
const fixNull = (value: any): any => {
if (value === 'null' || value === 'NULL') return null;
return value;
};
// Helper to convert string booleans to actual booleans
const fixBoolean = (value: any): any => {
if (typeof value === 'string') {
const lower = value.toLowerCase();
if (lower === 'true') return true;
if (lower === 'false') return false;
}
return value;
};
// Fix user fields - handle string "null" values
if (this.config.user) {
this.config.user.username = fixNull(this.config.user.username);
this.config.user.email = fixNull(this.config.user.email);
this.config.user.display_name = fixNull(this.config.user.display_name);
}
// Fix sync settings
if (this.config.sync) {
this.config.sync.enabled = fixBoolean(this.config.sync.enabled);
if (this.config.sync.individual) {
this.config.sync.individual.require_confirmation = fixBoolean(this.config.sync.individual.require_confirmation);
this.config.sync.individual.show_diff_before_sync = fixBoolean(this.config.sync.individual.show_diff_before_sync);
this.config.sync.individual.track_versions = fixBoolean(this.config.sync.individual.track_versions);
}
if (this.config.sync.bulk) {
this.config.sync.bulk.upload_enabled = fixBoolean(this.config.sync.bulk.upload_enabled);
this.config.sync.bulk.download_enabled = fixBoolean(this.config.sync.bulk.download_enabled);
this.config.sync.bulk.require_preview = fixBoolean(this.config.sync.bulk.require_preview);
this.config.sync.bulk.respect_local_only = fixBoolean(this.config.sync.bulk.respect_local_only);
}
if (this.config.sync.privacy) {
this.config.sync.privacy.scan_for_secrets = fixBoolean(this.config.sync.privacy.scan_for_secrets);
this.config.sync.privacy.scan_for_pii = fixBoolean(this.config.sync.privacy.scan_for_pii);
this.config.sync.privacy.warn_on_sensitive = fixBoolean(this.config.sync.privacy.warn_on_sensitive);
}
}
// Fix collection settings
if (this.config.collection) {
this.config.collection.auto_submit = fixBoolean(this.config.collection.auto_submit);
this.config.collection.require_review = fixBoolean(this.config.collection.require_review);
this.config.collection.add_attribution = fixBoolean(this.config.collection.add_attribution);
}
// Fix display settings
if (this.config.display) {
if (this.config.display.persona_indicators) {
this.config.display.persona_indicators.enabled = fixBoolean(this.config.display.persona_indicators.enabled);
this.config.display.persona_indicators.include_emoji = fixBoolean(this.config.display.persona_indicators.include_emoji);
}
this.config.display.verbose_logging = fixBoolean(this.config.display.verbose_logging);
this.config.display.show_progress = fixBoolean(this.config.display.show_progress);
}
// Fix github settings
if (this.config.github) {
if (this.config.github.portfolio) {
this.config.github.portfolio.repository_url = fixNull(this.config.github.portfolio.repository_url);
this.config.github.portfolio.auto_create = fixBoolean(this.config.github.portfolio.auto_create);
}
if (this.config.github.auth) {
this.config.github.auth.use_oauth = fixBoolean(this.config.github.auth.use_oauth);
// Fix client_id if it's a string "null"
if (this.config.github.auth.client_id) {
this.config.github.auth.client_id = fixNull(this.config.github.auth.client_id) || undefined;
}
}
}
// Fix wizard settings
if (this.config.wizard) {
this.config.wizard.completed = fixBoolean(this.config.wizard.completed);
this.config.wizard.dismissed = fixBoolean(this.config.wizard.dismissed);
}
// Fix capability index resources settings
if (this.config.elements?.enhanced_index?.resources) {
const resources = this.config.elements.enhanced_index.resources;
resources.advertise_resources = fixBoolean(resources.advertise_resources);
if (resources.variants) {
resources.variants.summary = fixBoolean(resources.variants.summary);
resources.variants.full = fixBoolean(resources.variants.full);
resources.variants.stats = fixBoolean(resources.variants.stats);
}
}
}
/**
* Merge partial config with defaults
*
* FIX: Reduced cognitive complexity by extracting helper methods
* Previously: Cognitive complexity of 17 (exceeded max of 15)
* Now: Split into focused helper methods for each config section
*
* IMPORTANT: This function preserves unknown fields for forward compatibility.
* If a future version adds new config fields, older versions won't lose them.
*/
private mergeWithDefaults(partial: Partial<DollhouseConfig>): DollhouseConfig {
const defaults = this.getDefaultConfig();
// Start with a deep clone of partial to preserve all unknown fields
const result: any = JSON.parse(JSON.stringify(partial));
// Ensure all required fields exist with defaults
result.version = result.version || defaults.version;
// Merge each section using helper methods
result.user = this.mergeUserConfig(result.user, defaults.user);
result.github = this.mergeGitHubConfig(result.github, defaults.github);
result.sync = this.mergeSyncConfig(result.sync, defaults.sync);
result.collection = { ...defaults.collection, ...result.collection };
result.elements = this.mergeElementsConfig(result.elements, defaults.elements);
result.display = this.mergeDisplayConfig(result.display, defaults.display);
result.wizard = { ...defaults.wizard, ...result.wizard };
return result as DollhouseConfig;
}
/**
* Merge user configuration section
*/
private mergeUserConfig(userConfig: any, defaults: UserConfig): UserConfig {
return {
...userConfig,
username: userConfig?.username ?? defaults.username,
email: userConfig?.email ?? defaults.email,
display_name: userConfig?.display_name ?? defaults.display_name
};
}
/**
* Merge GitHub configuration section
*/
private mergeGitHubConfig(githubConfig: any, defaults: GitHubConfig): GitHubConfig {
const result = githubConfig || {};
result.portfolio = {
...defaults.portfolio,
...result.portfolio
};
result.auth = {
...defaults.auth,
...result.auth
};
return result;
}
/**
* Merge sync configuration section
*/
private mergeSyncConfig(syncConfig: any, defaults: SyncConfig): SyncConfig {
const result = syncConfig || {};
result.enabled = result.enabled ?? defaults.enabled;
result.individual = { ...defaults.individual, ...result.individual };
result.bulk = { ...defaults.bulk, ...result.bulk };
result.privacy = {
...defaults.privacy,
...result.privacy,
excluded_patterns: result.privacy?.excluded_patterns || defaults.privacy.excluded_patterns
};
return result;
}
/**
* Merge elements configuration section
*/
private mergeElementsConfig(elementsConfig: any, defaults: ElementsConfig): ElementsConfig {
const result = elementsConfig || {};
result.auto_activate = result.auto_activate || defaults.auto_activate;
result.default_element_dir = result.default_element_dir || defaults.default_element_dir;
result.enhanced_index = this.mergeEnhancedIndexConfig(result.enhanced_index, defaults.enhanced_index);
return result;
}
/**
* Merge enhanced index configuration
*/
private mergeEnhancedIndexConfig(userIndex: any, defaultIndex: EnhancedIndexConfig | undefined): EnhancedIndexConfig | undefined {
// FIX: Inverted negated condition for better readability
// Previously: if (userIndex || defaultIndex)
// Now: Early return for clear logic flow
if (!userIndex && !defaultIndex) {
return undefined;
}
if (!userIndex) {
return defaultIndex;
}
if (!defaultIndex) {
return userIndex;
}
const result: any = {
...defaultIndex,
...userIndex,
limits: { ...defaultIndex.limits, ...userIndex.limits },
telemetry: { ...defaultIndex.telemetry, ...userIndex.telemetry },
resources: userIndex.resources ? {
...defaultIndex.resources,
...userIndex.resources,
variants: {
...defaultIndex.resources?.variants,
...userIndex.resources?.variants
}
} : defaultIndex.resources
};
// Preserve optional fields if they exist
if (userIndex.verbPatterns) {
result.verbPatterns = { ...defaultIndex.verbPatterns, ...userIndex.verbPatterns };
}
if (userIndex.backgroundAnalysis) {
result.backgroundAnalysis = { ...defaultIndex.backgroundAnalysis, ...userIndex.backgroundAnalysis };
}
return result;
}
/**
* Merge display configuration section
*/
private mergeDisplayConfig(displayConfig: any, defaults: DisplayConfig): DisplayConfig {
const result = displayConfig || {};
result.persona_indicators = {
...defaults.persona_indicators,
...result.persona_indicators
};
result.verbose_logging = result.verbose_logging ?? defaults.verbose_logging;
result.show_progress = result.show_progress ?? defaults.show_progress;
return result;
}
/**
* Migrate settings from environment variables
*/
private async migrateFromEnvironment(): Promise<void> {
let migrated = false;
// Migrate user settings
if (process.env.DOLLHOUSE_USER && !this.config?.user.username) {
if (!this.config) this.config = this.getDefaultConfig();
this.config.user.username = process.env.DOLLHOUSE_USER;
migrated = true;
}
if (process.env.DOLLHOUSE_EMAIL && !this.config?.user.email) {
if (!this.config) this.config = this.getDefaultConfig();
this.config.user.email = process.env.DOLLHOUSE_EMAIL;
migrated = true;
}
// Migrate portfolio URL
if (process.env.DOLLHOUSE_PORTFOLIO_URL && !this.config?.github.portfolio.repository_url) {
if (!this.config) this.config = this.getDefaultConfig();
this.config.github.portfolio.repository_url = process.env.DOLLHOUSE_PORTFOLIO_URL;
migrated = true;
}
// Migrate collection auto-submit
if (process.env.DOLLHOUSE_AUTO_SUBMIT_TO_COLLECTION !== undefined) {
if (!this.config) this.config = this.getDefaultConfig();
this.config.collection.auto_submit = process.env.DOLLHOUSE_AUTO_SUBMIT_TO_COLLECTION === 'true';
migrated = true;
}
if (migrated) {
logger.info('Migrated settings from environment variables');
}
}
/**
* Reset configuration to defaults
* SECURITY FIX (PR #895): Added prototype pollution protection
* Previously: Direct property assignment allowed __proto__ injection
* Now: Validates keys against forbidden properties before assignment
*/
public async resetConfig(section?: string): Promise<ConfigActionResult> {
const defaults = this.getDefaultConfig();
if (section) {
// Reset specific section
if (!this.config) {
this.config = defaults;
} else {
// SECURITY: Validate section path to prevent prototype pollution
validatePropertyPath(section, 'section');
const sectionKeys = section.split('.');
let current: any = this.config;
let defaultSection: any = defaults;
for (let i = 0; i < sectionKeys.length - 1; i++) {
current = current[sectionKeys[i]];
defaultSection = defaultSection[sectionKeys[i]];
}
const lastKey = sectionKeys[sectionKeys.length - 1];
// SECURITY: Use secure property setter to avoid prototype chain pollution
safeSetProperty(current, lastKey, defaultSection[lastKey]);
}
await this.saveConfig();
return {
success: true,
message: `Section '${section}' reset to defaults`
};
} else {
// Reset entire config
this.config = defaults;
await this.saveConfig();
return {
success: true,
message: 'Configuration reset to defaults'
};
}
}
/**
* Export configuration to file
*/
public async exportConfig(filePath: string): Promise<ConfigActionResult> {
if (!this.config) {
return {
success: false,
message: 'No configuration to export'
};
}
try {
const yamlContent = yaml.dump(this.config, {
indent: 2,
lineWidth: 120,
noRefs: true,
sortKeys: false
});
await fs.writeFile(filePath, yamlContent, { encoding: 'utf-8', mode: 0o600 });
return {
success: true,
message: `Configuration exported to ${filePath}`
};
} catch (error) {
return {
success: false,
message: `Failed to export configuration: ${error instanceof Error ? error.message : String(error)}`
};
}
}
/**
* Import configuration from file
*/
public async importConfig(filePath: string): Promise<ConfigActionResult> {
try {
const content = await fs.readFile(filePath, 'utf-8');
// Parse and validate
const parsed = SecureYamlParser.parse(content, {
maxYamlSize: 64 * 1024,
validateContent: false,
validateFields: false
});
if (!parsed.data || typeof parsed.data !== 'object') {
return {
success: false,
message: 'Invalid configuration format in import file'
};
}
// Merge with defaults
this.config = this.mergeWithDefaults(parsed.data as Partial<DollhouseConfig>);
// Save the imported config
await this.saveConfig();
return {
success: true,
message: `Configuration imported from ${filePath}`
};
} catch (error) {
return {
success: false,
message: `Failed to import configuration: ${error instanceof Error ? error.message : String(error)}`
};
}
}
/**
* Get formatted config for display
*/
public getFormattedConfig(section?: string): string {
if (!this.config) {
return 'Configuration not initialized';
}
let configToShow: any = this.config;
if (section) {
configToShow = this.getSetting(section);
if (!configToShow) {
return `Section '${section}' not found`;
}
}
// Remove sensitive data for display
const sanitized = JSON.parse(JSON.stringify(configToShow));
// Don't show tokens if they exist
if (sanitized.github?.auth?.token) {
sanitized.github.auth.token = '***REDACTED***';
}
return yaml.dump(sanitized, {
indent: 2,
lineWidth: 120,
noRefs: true,
sortKeys: false
});
}
}