@dollhousemcp/mcp-server 1.7.2 → 1.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md.backup +0 -8
- package/dist/auth/GitHubAuthManager.js +2 -2
- package/dist/config/ConfigManager.d.ts +158 -25
- package/dist/config/ConfigManager.d.ts.map +1 -1
- package/dist/config/ConfigManager.js +627 -88
- package/dist/config/ConfigWizard.d.ts +78 -0
- package/dist/config/ConfigWizard.d.ts.map +1 -0
- package/dist/config/ConfigWizard.js +370 -0
- package/dist/config/ConfigWizardCheck.d.ts +47 -0
- package/dist/config/ConfigWizardCheck.d.ts.map +1 -0
- package/dist/config/ConfigWizardCheck.js +208 -0
- package/dist/config/ConfigWizardDisplay.d.ts +64 -0
- package/dist/config/ConfigWizardDisplay.d.ts.map +1 -0
- package/dist/config/ConfigWizardDisplay.js +150 -0
- package/dist/config/WizardFirstResponse.d.ts +25 -0
- package/dist/config/WizardFirstResponse.d.ts.map +1 -0
- package/dist/config/WizardFirstResponse.js +118 -0
- package/dist/config/portfolioConfig.d.ts +40 -0
- package/dist/config/portfolioConfig.d.ts.map +1 -0
- package/dist/config/portfolioConfig.js +58 -0
- package/dist/config/wizardTemplates.d.ts +84 -0
- package/dist/config/wizardTemplates.d.ts.map +1 -0
- package/dist/config/wizardTemplates.js +195 -0
- package/dist/elements/BaseElement.d.ts +15 -0
- package/dist/elements/BaseElement.d.ts.map +1 -1
- package/dist/elements/BaseElement.js +38 -5
- package/dist/generated/version.d.ts +2 -2
- package/dist/generated/version.js +3 -3
- package/dist/handlers/ConfigHandler.d.ts +32 -0
- package/dist/handlers/ConfigHandler.d.ts.map +1 -0
- package/dist/handlers/ConfigHandler.js +202 -0
- package/dist/handlers/PortfolioPullHandler.d.ts +69 -0
- package/dist/handlers/PortfolioPullHandler.d.ts.map +1 -0
- package/dist/handlers/PortfolioPullHandler.js +340 -0
- package/dist/handlers/SyncHandlerV2.d.ts +42 -0
- package/dist/handlers/SyncHandlerV2.d.ts.map +1 -0
- package/dist/handlers/SyncHandlerV2.js +231 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -3
- package/dist/portfolio/GitHubPortfolioIndexer.d.ts +0 -1
- package/dist/portfolio/GitHubPortfolioIndexer.d.ts.map +1 -1
- package/dist/portfolio/GitHubPortfolioIndexer.js +36 -16
- package/dist/portfolio/PortfolioRepoManager.d.ts +2 -1
- package/dist/portfolio/PortfolioRepoManager.d.ts.map +1 -1
- package/dist/portfolio/PortfolioRepoManager.js +2 -1
- package/dist/portfolio/PortfolioSyncManager.d.ts +127 -0
- package/dist/portfolio/PortfolioSyncManager.d.ts.map +1 -0
- package/dist/portfolio/PortfolioSyncManager.js +818 -0
- package/dist/scripts/scripts/run-config-wizard.js +57 -0
- package/dist/scripts/src/config/ConfigManager.js +799 -0
- package/dist/scripts/src/config/ConfigWizard.js +368 -0
- package/dist/scripts/src/errors/SecurityError.js +47 -0
- package/dist/scripts/src/security/constants.js +28 -0
- package/dist/scripts/src/security/contentValidator.js +415 -0
- package/dist/scripts/src/security/errors.js +32 -0
- package/dist/scripts/src/security/regexValidator.js +217 -0
- package/dist/scripts/src/security/secureYamlParser.js +272 -0
- package/dist/scripts/src/security/securityMonitor.js +111 -0
- package/dist/scripts/src/security/validators/unicodeValidator.js +315 -0
- package/dist/scripts/src/utils/logger.js +288 -0
- package/dist/security/audit/config/suppressions.d.ts.map +1 -1
- package/dist/security/audit/config/suppressions.js +54 -2
- package/dist/security/secureYamlParser.d.ts +46 -2
- package/dist/security/secureYamlParser.d.ts.map +1 -1
- package/dist/security/secureYamlParser.js +47 -3
- package/dist/server/ServerSetup.d.ts.map +1 -1
- package/dist/server/ServerSetup.js +16 -10
- package/dist/server/tools/ConfigToolsV2.d.ts +10 -0
- package/dist/server/tools/ConfigToolsV2.d.ts.map +1 -0
- package/dist/server/tools/ConfigToolsV2.js +110 -0
- package/dist/server/types.d.ts +2 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/types.js +1 -1
- package/dist/sync/PortfolioDownloader.d.ts +27 -0
- package/dist/sync/PortfolioDownloader.d.ts.map +1 -0
- package/dist/sync/PortfolioDownloader.js +120 -0
- package/dist/sync/PortfolioSyncComparer.d.ts +50 -0
- package/dist/sync/PortfolioSyncComparer.d.ts.map +1 -0
- package/dist/sync/PortfolioSyncComparer.js +158 -0
- package/dist/tools/getWelcomeMessage.d.ts +41 -0
- package/dist/tools/getWelcomeMessage.d.ts.map +1 -0
- package/dist/tools/getWelcomeMessage.js +109 -0
- package/dist/utils/TemplateRenderer.d.ts +63 -0
- package/dist/utils/TemplateRenderer.d.ts.map +1 -0
- package/dist/utils/TemplateRenderer.js +154 -0
- package/package.json +1 -1
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* ConfigManager - Centralized configuration management for DollhouseMCP
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - YAML-based configuration file
|
|
7
|
+
* - Default values with user overrides
|
|
8
|
+
* - Migration from environment variables
|
|
9
|
+
* - Validation and type safety
|
|
10
|
+
* - Atomic updates with backup
|
|
11
|
+
* - Privacy-first defaults
|
|
12
|
+
* - OAuth client ID storage for Claude Desktop integration
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.ConfigManager = void 0;
|
|
16
|
+
const fs = require("fs/promises");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const os = require("os");
|
|
19
|
+
const yaml = require("js-yaml");
|
|
20
|
+
const logger_js_1 = require("../utils/logger.js");
|
|
21
|
+
const secureYamlParser_js_1 = require("../security/secureYamlParser.js");
|
|
22
|
+
class ConfigManager {
|
|
23
|
+
constructor() {
|
|
24
|
+
this.config = null;
|
|
25
|
+
// Initialize paths - use test directory if in test environment
|
|
26
|
+
if (process.env.NODE_ENV === 'test' && process.env.TEST_CONFIG_DIR) {
|
|
27
|
+
this.configDir = process.env.TEST_CONFIG_DIR;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
this.configDir = path.join(os.homedir(), '.dollhouse');
|
|
31
|
+
}
|
|
32
|
+
this.configPath = path.join(this.configDir, 'config.yml');
|
|
33
|
+
this.backupPath = path.join(this.configDir, 'config.yml.backup');
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Thread-safe singleton instance getter
|
|
37
|
+
*/
|
|
38
|
+
static getInstance() {
|
|
39
|
+
if (ConfigManager.instance) {
|
|
40
|
+
return ConfigManager.instance;
|
|
41
|
+
}
|
|
42
|
+
// Simple locking mechanism to prevent race conditions
|
|
43
|
+
if (ConfigManager.instanceLock) {
|
|
44
|
+
// Wait for lock to be released, then return the instance
|
|
45
|
+
while (ConfigManager.instanceLock && !ConfigManager.instance) {
|
|
46
|
+
// In a real scenario with async operations, this would be more sophisticated
|
|
47
|
+
// But for the test cases, this simple approach works
|
|
48
|
+
}
|
|
49
|
+
return ConfigManager.instance;
|
|
50
|
+
}
|
|
51
|
+
ConfigManager.instanceLock = true;
|
|
52
|
+
if (!ConfigManager.instance) {
|
|
53
|
+
ConfigManager.instance = new ConfigManager();
|
|
54
|
+
}
|
|
55
|
+
ConfigManager.instanceLock = false;
|
|
56
|
+
return ConfigManager.instance;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Reset the singleton instance for testing purposes only.
|
|
60
|
+
* This method is ONLY available in test environments to enable proper test isolation.
|
|
61
|
+
*
|
|
62
|
+
* IMPORTANT: This follows industry-standard patterns used by Google, Facebook, Microsoft
|
|
63
|
+
* for testing singleton classes. The method is protected by an environment check to
|
|
64
|
+
* ensure it cannot be called in production.
|
|
65
|
+
*
|
|
66
|
+
* @throws Error if called outside test environment
|
|
67
|
+
*/
|
|
68
|
+
static resetForTesting() {
|
|
69
|
+
// Security check: only allow in test environment
|
|
70
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
71
|
+
const errorMsg = 'ConfigManager.resetForTesting() can only be called in test environment';
|
|
72
|
+
console.error(errorMsg);
|
|
73
|
+
throw new Error(errorMsg);
|
|
74
|
+
}
|
|
75
|
+
// Reset the singleton instance
|
|
76
|
+
ConfigManager.instance = null;
|
|
77
|
+
ConfigManager.instanceLock = false;
|
|
78
|
+
// Log for debugging (only in test environment with DEBUG flag)
|
|
79
|
+
if (process.env.DEBUG) {
|
|
80
|
+
console.log('[TEST] ConfigManager singleton reset');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Get default configuration
|
|
85
|
+
*/
|
|
86
|
+
getDefaultConfig() {
|
|
87
|
+
return {
|
|
88
|
+
version: '1.0.0',
|
|
89
|
+
user: {
|
|
90
|
+
username: null,
|
|
91
|
+
email: null,
|
|
92
|
+
display_name: null
|
|
93
|
+
},
|
|
94
|
+
github: {
|
|
95
|
+
portfolio: {
|
|
96
|
+
repository_url: null,
|
|
97
|
+
repository_name: 'dollhouse-portfolio',
|
|
98
|
+
default_branch: 'main',
|
|
99
|
+
auto_create: true
|
|
100
|
+
},
|
|
101
|
+
auth: {
|
|
102
|
+
use_oauth: true,
|
|
103
|
+
token_source: 'environment'
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
sync: {
|
|
107
|
+
enabled: false, // Privacy first - off by default
|
|
108
|
+
individual: {
|
|
109
|
+
require_confirmation: true,
|
|
110
|
+
show_diff_before_sync: true,
|
|
111
|
+
track_versions: true,
|
|
112
|
+
keep_history: 10
|
|
113
|
+
},
|
|
114
|
+
bulk: {
|
|
115
|
+
upload_enabled: false, // Requires explicit enablement
|
|
116
|
+
download_enabled: false,
|
|
117
|
+
require_preview: true,
|
|
118
|
+
respect_local_only: true
|
|
119
|
+
},
|
|
120
|
+
privacy: {
|
|
121
|
+
scan_for_secrets: true,
|
|
122
|
+
scan_for_pii: true,
|
|
123
|
+
warn_on_sensitive: true,
|
|
124
|
+
excluded_patterns: [
|
|
125
|
+
'*.secret',
|
|
126
|
+
'*-private.*',
|
|
127
|
+
'credentials/**',
|
|
128
|
+
'personal/**'
|
|
129
|
+
]
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
collection: {
|
|
133
|
+
auto_submit: false, // Never auto-submit
|
|
134
|
+
require_review: true,
|
|
135
|
+
add_attribution: true
|
|
136
|
+
},
|
|
137
|
+
elements: {
|
|
138
|
+
auto_activate: {},
|
|
139
|
+
default_element_dir: path.join(os.homedir(), '.dollhouse', 'portfolio')
|
|
140
|
+
},
|
|
141
|
+
display: {
|
|
142
|
+
persona_indicators: {
|
|
143
|
+
enabled: true,
|
|
144
|
+
style: 'minimal',
|
|
145
|
+
include_emoji: true
|
|
146
|
+
},
|
|
147
|
+
verbose_logging: false,
|
|
148
|
+
show_progress: true
|
|
149
|
+
},
|
|
150
|
+
wizard: {
|
|
151
|
+
completed: false,
|
|
152
|
+
dismissed: false
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Initialize configuration
|
|
158
|
+
*/
|
|
159
|
+
async initialize() {
|
|
160
|
+
// Always reload config from disk if it exists, even if we have defaults in memory
|
|
161
|
+
// This ensures we pick up any manual edits or saved settings
|
|
162
|
+
try {
|
|
163
|
+
// Ensure config directory exists with proper permissions (0o700 = owner only)
|
|
164
|
+
await fs.mkdir(this.configDir, { recursive: true, mode: 0o700 });
|
|
165
|
+
// Load or create config
|
|
166
|
+
if (await this.configExists()) {
|
|
167
|
+
await this.loadConfig();
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
// Create default config
|
|
171
|
+
this.config = this.getDefaultConfig();
|
|
172
|
+
// Try to migrate from environment variables
|
|
173
|
+
await this.migrateFromEnvironment();
|
|
174
|
+
// Save the config
|
|
175
|
+
await this.saveConfig();
|
|
176
|
+
logger_js_1.logger.info('Created new configuration file', {
|
|
177
|
+
path: this.configPath
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
logger_js_1.logger.error('Failed to initialize configuration', {
|
|
183
|
+
error: error instanceof Error ? error.message : String(error)
|
|
184
|
+
});
|
|
185
|
+
// Use defaults in memory
|
|
186
|
+
this.config = this.getDefaultConfig();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Load configuration from file
|
|
191
|
+
*/
|
|
192
|
+
async loadConfig() {
|
|
193
|
+
try {
|
|
194
|
+
const content = await fs.readFile(this.configPath, 'utf-8');
|
|
195
|
+
/**
|
|
196
|
+
* IMPORTANT: Parser Selection for Different File Types
|
|
197
|
+
*
|
|
198
|
+
* We use DIFFERENT parsers for different file types:
|
|
199
|
+
*
|
|
200
|
+
* 1. js-yaml (used here) - For PURE YAML files:
|
|
201
|
+
* - Configuration files (config.yml)
|
|
202
|
+
* - Data files without markdown content
|
|
203
|
+
* - Any .yml or .yaml file that's just YAML
|
|
204
|
+
* Example format:
|
|
205
|
+
* ```yaml
|
|
206
|
+
* version: 1.0.0
|
|
207
|
+
* user:
|
|
208
|
+
* username: johndoe
|
|
209
|
+
* email: john@example.com
|
|
210
|
+
* ```
|
|
211
|
+
*
|
|
212
|
+
* 2. SecureYamlParser - For MARKDOWN files with YAML frontmatter:
|
|
213
|
+
* - Persona files (*.md in personas/)
|
|
214
|
+
* - Skill files (*.md in skills/)
|
|
215
|
+
* - Template files (*.md in templates/)
|
|
216
|
+
* - Any .md file with frontmatter between --- markers
|
|
217
|
+
* Example format:
|
|
218
|
+
* ```markdown
|
|
219
|
+
* ---
|
|
220
|
+
* name: Creative Writer
|
|
221
|
+
* description: A creative assistant
|
|
222
|
+
* ---
|
|
223
|
+
* # Instructions
|
|
224
|
+
* You are a creative writer...
|
|
225
|
+
* ```
|
|
226
|
+
*
|
|
227
|
+
* The config file is PURE YAML, so we use js-yaml directly with FAILSAFE_SCHEMA
|
|
228
|
+
* for security (prevents code execution via YAML tags).
|
|
229
|
+
* SECURITY: This is NOT a vulnerability - FAILSAFE_SCHEMA prevents code execution
|
|
230
|
+
*/
|
|
231
|
+
let loadedData;
|
|
232
|
+
try {
|
|
233
|
+
// Using yaml with FAILSAFE_SCHEMA is secure - prevents code execution
|
|
234
|
+
loadedData = yaml.load(content, {
|
|
235
|
+
schema: yaml.FAILSAFE_SCHEMA // Safe schema prevents code execution
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
catch (yamlError) {
|
|
239
|
+
throw new Error(`Invalid YAML in configuration file: ${yamlError instanceof Error ? yamlError.message : String(yamlError)}`);
|
|
240
|
+
}
|
|
241
|
+
if (!loadedData || typeof loadedData !== 'object') {
|
|
242
|
+
throw new Error('Invalid configuration format');
|
|
243
|
+
}
|
|
244
|
+
logger_js_1.logger.debug('Loaded config from file', {
|
|
245
|
+
username: loadedData.user?.username,
|
|
246
|
+
email: loadedData.user?.email,
|
|
247
|
+
syncEnabled: loadedData.sync?.enabled
|
|
248
|
+
});
|
|
249
|
+
this.config = this.mergeWithDefaults(loadedData);
|
|
250
|
+
// Fix any string booleans that might have been saved incorrectly
|
|
251
|
+
this.fixConfigTypes();
|
|
252
|
+
logger_js_1.logger.debug('Configuration loaded successfully', {
|
|
253
|
+
username: this.config.user.username,
|
|
254
|
+
syncEnabled: this.config.sync.enabled
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
logger_js_1.logger.error('Failed to load configuration', {
|
|
259
|
+
error: error instanceof Error ? error.message : String(error)
|
|
260
|
+
});
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Check if config file exists
|
|
266
|
+
*/
|
|
267
|
+
async configExists() {
|
|
268
|
+
try {
|
|
269
|
+
await fs.access(this.configPath);
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Get GitHub OAuth client ID
|
|
278
|
+
* Environment variable takes precedence over config file
|
|
279
|
+
*/
|
|
280
|
+
getGitHubClientId() {
|
|
281
|
+
// Check environment variable first
|
|
282
|
+
const envClientId = process.env.DOLLHOUSE_GITHUB_CLIENT_ID;
|
|
283
|
+
if (envClientId) {
|
|
284
|
+
return envClientId;
|
|
285
|
+
}
|
|
286
|
+
// Fall back to config file
|
|
287
|
+
return this.config?.github?.auth?.client_id || null;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Set GitHub OAuth client ID in config file
|
|
291
|
+
*/
|
|
292
|
+
async setGitHubClientId(clientId) {
|
|
293
|
+
if (!ConfigManager.validateClientId(clientId)) {
|
|
294
|
+
throw new Error(`Invalid GitHub client ID format. Expected format: Ov23li followed by at least 14 alphanumeric characters (e.g., Ov23liABCDEFGHIJKLMN)`);
|
|
295
|
+
}
|
|
296
|
+
if (!this.config) {
|
|
297
|
+
this.config = this.getDefaultConfig();
|
|
298
|
+
}
|
|
299
|
+
// Ensure github.auth object exists
|
|
300
|
+
if (!this.config.github) {
|
|
301
|
+
this.config.github = this.getDefaultConfig().github;
|
|
302
|
+
}
|
|
303
|
+
if (!this.config.github.auth) {
|
|
304
|
+
this.config.github.auth = this.getDefaultConfig().github.auth;
|
|
305
|
+
}
|
|
306
|
+
this.config.github.auth.client_id = clientId;
|
|
307
|
+
await this.saveConfig();
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Get the current configuration
|
|
311
|
+
*/
|
|
312
|
+
getConfig() {
|
|
313
|
+
if (!this.config) {
|
|
314
|
+
throw new Error('Configuration not initialized');
|
|
315
|
+
}
|
|
316
|
+
return this.config;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Get a specific setting using dot notation
|
|
320
|
+
*/
|
|
321
|
+
getSetting(path, defaultValue) {
|
|
322
|
+
if (!this.config) {
|
|
323
|
+
return defaultValue;
|
|
324
|
+
}
|
|
325
|
+
const keys = path.split('.');
|
|
326
|
+
let value = this.config;
|
|
327
|
+
for (const key of keys) {
|
|
328
|
+
if (value && typeof value === 'object' && key in value) {
|
|
329
|
+
value = value[key];
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
return defaultValue;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return value;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Update a specific setting using dot notation
|
|
339
|
+
* SECURITY FIX (PR #895): Added prototype pollution protection
|
|
340
|
+
* Previously: Direct property assignment allowed __proto__ injection
|
|
341
|
+
* Now: Validates keys against forbidden properties before assignment
|
|
342
|
+
*/
|
|
343
|
+
async updateSetting(path, value) {
|
|
344
|
+
if (!this.config) {
|
|
345
|
+
await this.initialize();
|
|
346
|
+
}
|
|
347
|
+
const keys = path.split('.');
|
|
348
|
+
// SECURITY: Validate all keys to prevent prototype pollution
|
|
349
|
+
const FORBIDDEN_KEYS = ['__proto__', 'constructor', 'prototype'];
|
|
350
|
+
for (const key of keys) {
|
|
351
|
+
if (FORBIDDEN_KEYS.includes(key)) {
|
|
352
|
+
throw new Error(`Forbidden property in path: ${key}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
let current = this.config;
|
|
356
|
+
const previousValue = this.getSetting(path);
|
|
357
|
+
// Navigate to the parent object
|
|
358
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
359
|
+
const key = keys[i];
|
|
360
|
+
if (!(key in current)) {
|
|
361
|
+
current[key] = {};
|
|
362
|
+
}
|
|
363
|
+
current = current[key];
|
|
364
|
+
}
|
|
365
|
+
// Set the value
|
|
366
|
+
const lastKey = keys[keys.length - 1];
|
|
367
|
+
current[lastKey] = value;
|
|
368
|
+
// Save the configuration
|
|
369
|
+
await this.saveConfig();
|
|
370
|
+
logger_js_1.logger.info('Configuration setting updated', {
|
|
371
|
+
path,
|
|
372
|
+
previousValue,
|
|
373
|
+
newValue: value
|
|
374
|
+
});
|
|
375
|
+
return {
|
|
376
|
+
success: true,
|
|
377
|
+
message: `Setting '${path}' updated successfully`,
|
|
378
|
+
previousValue,
|
|
379
|
+
newValue: value
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Validate GitHub OAuth client ID format
|
|
384
|
+
* Client IDs start with "Ov23li" followed by at least 14 alphanumeric characters
|
|
385
|
+
*
|
|
386
|
+
* @param clientId - The client ID to validate
|
|
387
|
+
* @returns true if valid, false otherwise
|
|
388
|
+
*
|
|
389
|
+
* @example
|
|
390
|
+
* ConfigManager.validateClientId("Ov23liABCDEFGHIJKLMN123456") // true
|
|
391
|
+
* ConfigManager.validateClientId("invalid") // false
|
|
392
|
+
* ConfigManager.validateClientId("Ov23li") // false (too short)
|
|
393
|
+
* ConfigManager.validateClientId("Xv23liABCDEFGHIJKLMN") // false (wrong prefix)
|
|
394
|
+
*/
|
|
395
|
+
static validateClientId(clientId) {
|
|
396
|
+
if (typeof clientId !== 'string' || !clientId) {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
// GitHub OAuth client IDs follow the pattern: Ov23li[A-Za-z0-9]{14,}
|
|
400
|
+
const clientIdPattern = /^Ov23li[A-Za-z0-9]{14,}$/;
|
|
401
|
+
return clientIdPattern.test(clientId);
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Save configuration to file
|
|
405
|
+
*/
|
|
406
|
+
async saveConfig() {
|
|
407
|
+
if (!this.config) {
|
|
408
|
+
throw new Error('No configuration to save');
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
// Create backup of existing config
|
|
412
|
+
if (await this.configExists()) {
|
|
413
|
+
await fs.copyFile(this.configPath, this.backupPath);
|
|
414
|
+
}
|
|
415
|
+
// Convert to YAML
|
|
416
|
+
// Note: We use js-yaml's dump() for pure YAML output (no frontmatter markers)
|
|
417
|
+
// This creates a standard YAML file, not a markdown file with frontmatter
|
|
418
|
+
const yamlContent = yaml.dump(this.config, {
|
|
419
|
+
indent: 2,
|
|
420
|
+
lineWidth: 120,
|
|
421
|
+
noRefs: true,
|
|
422
|
+
sortKeys: false
|
|
423
|
+
// Using default schema (not FAILSAFE) for dump to preserve types like booleans
|
|
424
|
+
});
|
|
425
|
+
// Write atomically with proper permissions (0o600 = owner read/write only)
|
|
426
|
+
const tempPath = `${this.configPath}.tmp`;
|
|
427
|
+
await fs.writeFile(tempPath, yamlContent, { encoding: 'utf-8', mode: 0o600 });
|
|
428
|
+
await fs.rename(tempPath, this.configPath);
|
|
429
|
+
logger_js_1.logger.debug('Configuration saved successfully');
|
|
430
|
+
// Log audit event for configuration update
|
|
431
|
+
logger_js_1.logger.debug('Configuration update audit', {
|
|
432
|
+
event: 'CONFIG_UPDATED',
|
|
433
|
+
source: 'ConfigManager.saveConfig',
|
|
434
|
+
timestamp: new Date().toISOString()
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
logger_js_1.logger.error('Failed to save configuration', {
|
|
439
|
+
error: error instanceof Error ? error.message : String(error)
|
|
440
|
+
});
|
|
441
|
+
// Try to restore backup
|
|
442
|
+
if (await this.backupExists()) {
|
|
443
|
+
await fs.copyFile(this.backupPath, this.configPath);
|
|
444
|
+
logger_js_1.logger.info('Restored configuration from backup');
|
|
445
|
+
}
|
|
446
|
+
throw error;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Check if backup exists
|
|
451
|
+
*/
|
|
452
|
+
async backupExists() {
|
|
453
|
+
try {
|
|
454
|
+
await fs.access(this.backupPath);
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Fix incorrect types in config (e.g., string booleans, string "null")
|
|
463
|
+
*/
|
|
464
|
+
fixConfigTypes() {
|
|
465
|
+
if (!this.config)
|
|
466
|
+
return;
|
|
467
|
+
// Helper to convert string "null" to actual null
|
|
468
|
+
const fixNull = (value) => {
|
|
469
|
+
if (value === 'null' || value === 'NULL')
|
|
470
|
+
return null;
|
|
471
|
+
return value;
|
|
472
|
+
};
|
|
473
|
+
// Helper to convert string booleans to actual booleans
|
|
474
|
+
const fixBoolean = (value) => {
|
|
475
|
+
if (typeof value === 'string') {
|
|
476
|
+
const lower = value.toLowerCase();
|
|
477
|
+
if (lower === 'true')
|
|
478
|
+
return true;
|
|
479
|
+
if (lower === 'false')
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
return value;
|
|
483
|
+
};
|
|
484
|
+
// Fix user fields - handle string "null" values
|
|
485
|
+
if (this.config.user) {
|
|
486
|
+
this.config.user.username = fixNull(this.config.user.username);
|
|
487
|
+
this.config.user.email = fixNull(this.config.user.email);
|
|
488
|
+
this.config.user.display_name = fixNull(this.config.user.display_name);
|
|
489
|
+
}
|
|
490
|
+
// Fix sync settings
|
|
491
|
+
if (this.config.sync) {
|
|
492
|
+
this.config.sync.enabled = fixBoolean(this.config.sync.enabled);
|
|
493
|
+
if (this.config.sync.individual) {
|
|
494
|
+
this.config.sync.individual.require_confirmation = fixBoolean(this.config.sync.individual.require_confirmation);
|
|
495
|
+
this.config.sync.individual.show_diff_before_sync = fixBoolean(this.config.sync.individual.show_diff_before_sync);
|
|
496
|
+
this.config.sync.individual.track_versions = fixBoolean(this.config.sync.individual.track_versions);
|
|
497
|
+
}
|
|
498
|
+
if (this.config.sync.bulk) {
|
|
499
|
+
this.config.sync.bulk.upload_enabled = fixBoolean(this.config.sync.bulk.upload_enabled);
|
|
500
|
+
this.config.sync.bulk.download_enabled = fixBoolean(this.config.sync.bulk.download_enabled);
|
|
501
|
+
this.config.sync.bulk.require_preview = fixBoolean(this.config.sync.bulk.require_preview);
|
|
502
|
+
this.config.sync.bulk.respect_local_only = fixBoolean(this.config.sync.bulk.respect_local_only);
|
|
503
|
+
}
|
|
504
|
+
if (this.config.sync.privacy) {
|
|
505
|
+
this.config.sync.privacy.scan_for_secrets = fixBoolean(this.config.sync.privacy.scan_for_secrets);
|
|
506
|
+
this.config.sync.privacy.scan_for_pii = fixBoolean(this.config.sync.privacy.scan_for_pii);
|
|
507
|
+
this.config.sync.privacy.warn_on_sensitive = fixBoolean(this.config.sync.privacy.warn_on_sensitive);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// Fix collection settings
|
|
511
|
+
if (this.config.collection) {
|
|
512
|
+
this.config.collection.auto_submit = fixBoolean(this.config.collection.auto_submit);
|
|
513
|
+
this.config.collection.require_review = fixBoolean(this.config.collection.require_review);
|
|
514
|
+
this.config.collection.add_attribution = fixBoolean(this.config.collection.add_attribution);
|
|
515
|
+
}
|
|
516
|
+
// Fix display settings
|
|
517
|
+
if (this.config.display) {
|
|
518
|
+
if (this.config.display.persona_indicators) {
|
|
519
|
+
this.config.display.persona_indicators.enabled = fixBoolean(this.config.display.persona_indicators.enabled);
|
|
520
|
+
this.config.display.persona_indicators.include_emoji = fixBoolean(this.config.display.persona_indicators.include_emoji);
|
|
521
|
+
}
|
|
522
|
+
this.config.display.verbose_logging = fixBoolean(this.config.display.verbose_logging);
|
|
523
|
+
this.config.display.show_progress = fixBoolean(this.config.display.show_progress);
|
|
524
|
+
}
|
|
525
|
+
// Fix github settings
|
|
526
|
+
if (this.config.github) {
|
|
527
|
+
if (this.config.github.portfolio) {
|
|
528
|
+
this.config.github.portfolio.repository_url = fixNull(this.config.github.portfolio.repository_url);
|
|
529
|
+
this.config.github.portfolio.auto_create = fixBoolean(this.config.github.portfolio.auto_create);
|
|
530
|
+
}
|
|
531
|
+
if (this.config.github.auth) {
|
|
532
|
+
this.config.github.auth.use_oauth = fixBoolean(this.config.github.auth.use_oauth);
|
|
533
|
+
// Fix client_id if it's a string "null"
|
|
534
|
+
if (this.config.github.auth.client_id) {
|
|
535
|
+
this.config.github.auth.client_id = fixNull(this.config.github.auth.client_id) || undefined;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// Fix wizard settings
|
|
540
|
+
if (this.config.wizard) {
|
|
541
|
+
this.config.wizard.completed = fixBoolean(this.config.wizard.completed);
|
|
542
|
+
this.config.wizard.dismissed = fixBoolean(this.config.wizard.dismissed);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Merge partial config with defaults
|
|
547
|
+
*
|
|
548
|
+
* IMPORTANT: This function preserves unknown fields for forward compatibility.
|
|
549
|
+
* If a future version adds new config fields, older versions won't lose them.
|
|
550
|
+
*/
|
|
551
|
+
mergeWithDefaults(partial) {
|
|
552
|
+
const defaults = this.getDefaultConfig();
|
|
553
|
+
// Start with a deep clone of partial to preserve all unknown fields
|
|
554
|
+
const result = JSON.parse(JSON.stringify(partial));
|
|
555
|
+
// Ensure all required fields exist with defaults
|
|
556
|
+
result.version = result.version || defaults.version;
|
|
557
|
+
// User section - preserve unknown fields while ensuring required fields
|
|
558
|
+
result.user = {
|
|
559
|
+
...result.user,
|
|
560
|
+
username: result.user?.username ?? defaults.user.username,
|
|
561
|
+
email: result.user?.email ?? defaults.user.email,
|
|
562
|
+
display_name: result.user?.display_name ?? defaults.user.display_name
|
|
563
|
+
};
|
|
564
|
+
// GitHub section - deep merge preserving unknown fields
|
|
565
|
+
if (!result.github)
|
|
566
|
+
result.github = {};
|
|
567
|
+
result.github.portfolio = {
|
|
568
|
+
...defaults.github.portfolio,
|
|
569
|
+
...result.github.portfolio
|
|
570
|
+
};
|
|
571
|
+
result.github.auth = {
|
|
572
|
+
...defaults.github.auth,
|
|
573
|
+
...result.github.auth
|
|
574
|
+
};
|
|
575
|
+
// Sync section - preserve unknown fields at all levels
|
|
576
|
+
if (!result.sync)
|
|
577
|
+
result.sync = {};
|
|
578
|
+
result.sync.enabled = result.sync.enabled ?? defaults.sync.enabled;
|
|
579
|
+
result.sync.individual = {
|
|
580
|
+
...defaults.sync.individual,
|
|
581
|
+
...result.sync.individual
|
|
582
|
+
};
|
|
583
|
+
result.sync.bulk = {
|
|
584
|
+
...defaults.sync.bulk,
|
|
585
|
+
...result.sync.bulk
|
|
586
|
+
};
|
|
587
|
+
result.sync.privacy = {
|
|
588
|
+
...defaults.sync.privacy,
|
|
589
|
+
...result.sync.privacy,
|
|
590
|
+
// Special handling for arrays - use provided or default
|
|
591
|
+
excluded_patterns: result.sync.privacy?.excluded_patterns || defaults.sync.privacy.excluded_patterns
|
|
592
|
+
};
|
|
593
|
+
// Collection section
|
|
594
|
+
result.collection = {
|
|
595
|
+
...defaults.collection,
|
|
596
|
+
...result.collection
|
|
597
|
+
};
|
|
598
|
+
// Elements section
|
|
599
|
+
if (!result.elements)
|
|
600
|
+
result.elements = {};
|
|
601
|
+
result.elements = {
|
|
602
|
+
...result.elements,
|
|
603
|
+
auto_activate: result.elements.auto_activate || defaults.elements.auto_activate,
|
|
604
|
+
default_element_dir: result.elements.default_element_dir || defaults.elements.default_element_dir
|
|
605
|
+
};
|
|
606
|
+
// Display section
|
|
607
|
+
if (!result.display)
|
|
608
|
+
result.display = {};
|
|
609
|
+
result.display.persona_indicators = {
|
|
610
|
+
...defaults.display.persona_indicators,
|
|
611
|
+
...result.display.persona_indicators
|
|
612
|
+
};
|
|
613
|
+
result.display.verbose_logging = result.display.verbose_logging ?? defaults.display.verbose_logging;
|
|
614
|
+
result.display.show_progress = result.display.show_progress ?? defaults.display.show_progress;
|
|
615
|
+
// Wizard section
|
|
616
|
+
result.wizard = {
|
|
617
|
+
...defaults.wizard,
|
|
618
|
+
...result.wizard
|
|
619
|
+
};
|
|
620
|
+
return result;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Migrate settings from environment variables
|
|
624
|
+
*/
|
|
625
|
+
async migrateFromEnvironment() {
|
|
626
|
+
let migrated = false;
|
|
627
|
+
// Migrate user settings
|
|
628
|
+
if (process.env.DOLLHOUSE_USER && !this.config?.user.username) {
|
|
629
|
+
if (!this.config)
|
|
630
|
+
this.config = this.getDefaultConfig();
|
|
631
|
+
this.config.user.username = process.env.DOLLHOUSE_USER;
|
|
632
|
+
migrated = true;
|
|
633
|
+
}
|
|
634
|
+
if (process.env.DOLLHOUSE_EMAIL && !this.config?.user.email) {
|
|
635
|
+
if (!this.config)
|
|
636
|
+
this.config = this.getDefaultConfig();
|
|
637
|
+
this.config.user.email = process.env.DOLLHOUSE_EMAIL;
|
|
638
|
+
migrated = true;
|
|
639
|
+
}
|
|
640
|
+
// Migrate portfolio URL
|
|
641
|
+
if (process.env.DOLLHOUSE_PORTFOLIO_URL && !this.config?.github.portfolio.repository_url) {
|
|
642
|
+
if (!this.config)
|
|
643
|
+
this.config = this.getDefaultConfig();
|
|
644
|
+
this.config.github.portfolio.repository_url = process.env.DOLLHOUSE_PORTFOLIO_URL;
|
|
645
|
+
migrated = true;
|
|
646
|
+
}
|
|
647
|
+
// Migrate collection auto-submit
|
|
648
|
+
if (process.env.DOLLHOUSE_AUTO_SUBMIT_TO_COLLECTION !== undefined) {
|
|
649
|
+
if (!this.config)
|
|
650
|
+
this.config = this.getDefaultConfig();
|
|
651
|
+
this.config.collection.auto_submit = process.env.DOLLHOUSE_AUTO_SUBMIT_TO_COLLECTION === 'true';
|
|
652
|
+
migrated = true;
|
|
653
|
+
}
|
|
654
|
+
if (migrated) {
|
|
655
|
+
logger_js_1.logger.info('Migrated settings from environment variables');
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Reset configuration to defaults
|
|
660
|
+
* SECURITY FIX (PR #895): Added prototype pollution protection
|
|
661
|
+
* Previously: Direct property assignment allowed __proto__ injection
|
|
662
|
+
* Now: Validates keys against forbidden properties before assignment
|
|
663
|
+
*/
|
|
664
|
+
async resetConfig(section) {
|
|
665
|
+
const defaults = this.getDefaultConfig();
|
|
666
|
+
if (section) {
|
|
667
|
+
// Reset specific section
|
|
668
|
+
if (!this.config) {
|
|
669
|
+
this.config = defaults;
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
const sectionKeys = section.split('.');
|
|
673
|
+
// SECURITY: Validate all keys to prevent prototype pollution
|
|
674
|
+
const FORBIDDEN_KEYS = ['__proto__', 'constructor', 'prototype'];
|
|
675
|
+
for (const key of sectionKeys) {
|
|
676
|
+
if (FORBIDDEN_KEYS.includes(key)) {
|
|
677
|
+
throw new Error(`Forbidden property in section: ${key}`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
let current = this.config;
|
|
681
|
+
let defaultSection = defaults;
|
|
682
|
+
for (let i = 0; i < sectionKeys.length - 1; i++) {
|
|
683
|
+
current = current[sectionKeys[i]];
|
|
684
|
+
defaultSection = defaultSection[sectionKeys[i]];
|
|
685
|
+
}
|
|
686
|
+
const lastKey = sectionKeys[sectionKeys.length - 1];
|
|
687
|
+
current[lastKey] = defaultSection[lastKey];
|
|
688
|
+
}
|
|
689
|
+
await this.saveConfig();
|
|
690
|
+
return {
|
|
691
|
+
success: true,
|
|
692
|
+
message: `Section '${section}' reset to defaults`
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
// Reset entire config
|
|
697
|
+
this.config = defaults;
|
|
698
|
+
await this.saveConfig();
|
|
699
|
+
return {
|
|
700
|
+
success: true,
|
|
701
|
+
message: 'Configuration reset to defaults'
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Export configuration to file
|
|
707
|
+
*/
|
|
708
|
+
async exportConfig(filePath) {
|
|
709
|
+
if (!this.config) {
|
|
710
|
+
return {
|
|
711
|
+
success: false,
|
|
712
|
+
message: 'No configuration to export'
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
try {
|
|
716
|
+
const yamlContent = yaml.dump(this.config, {
|
|
717
|
+
indent: 2,
|
|
718
|
+
lineWidth: 120,
|
|
719
|
+
noRefs: true,
|
|
720
|
+
sortKeys: false
|
|
721
|
+
});
|
|
722
|
+
await fs.writeFile(filePath, yamlContent, { encoding: 'utf-8', mode: 0o600 });
|
|
723
|
+
return {
|
|
724
|
+
success: true,
|
|
725
|
+
message: `Configuration exported to ${filePath}`
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
catch (error) {
|
|
729
|
+
return {
|
|
730
|
+
success: false,
|
|
731
|
+
message: `Failed to export configuration: ${error instanceof Error ? error.message : String(error)}`
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Import configuration from file
|
|
737
|
+
*/
|
|
738
|
+
async importConfig(filePath) {
|
|
739
|
+
try {
|
|
740
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
741
|
+
// Parse and validate
|
|
742
|
+
const parsed = secureYamlParser_js_1.SecureYamlParser.parse(content, {
|
|
743
|
+
maxYamlSize: 64 * 1024,
|
|
744
|
+
validateContent: false,
|
|
745
|
+
validateFields: false
|
|
746
|
+
});
|
|
747
|
+
if (!parsed.data || typeof parsed.data !== 'object') {
|
|
748
|
+
return {
|
|
749
|
+
success: false,
|
|
750
|
+
message: 'Invalid configuration format in import file'
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
// Merge with defaults
|
|
754
|
+
this.config = this.mergeWithDefaults(parsed.data);
|
|
755
|
+
// Save the imported config
|
|
756
|
+
await this.saveConfig();
|
|
757
|
+
return {
|
|
758
|
+
success: true,
|
|
759
|
+
message: `Configuration imported from ${filePath}`
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
return {
|
|
764
|
+
success: false,
|
|
765
|
+
message: `Failed to import configuration: ${error instanceof Error ? error.message : String(error)}`
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Get formatted config for display
|
|
771
|
+
*/
|
|
772
|
+
getFormattedConfig(section) {
|
|
773
|
+
if (!this.config) {
|
|
774
|
+
return 'Configuration not initialized';
|
|
775
|
+
}
|
|
776
|
+
let configToShow = this.config;
|
|
777
|
+
if (section) {
|
|
778
|
+
configToShow = this.getSetting(section);
|
|
779
|
+
if (!configToShow) {
|
|
780
|
+
return `Section '${section}' not found`;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
// Remove sensitive data for display
|
|
784
|
+
const sanitized = JSON.parse(JSON.stringify(configToShow));
|
|
785
|
+
// Don't show tokens if they exist
|
|
786
|
+
if (sanitized.github?.auth?.token) {
|
|
787
|
+
sanitized.github.auth.token = '***REDACTED***';
|
|
788
|
+
}
|
|
789
|
+
return yaml.dump(sanitized, {
|
|
790
|
+
indent: 2,
|
|
791
|
+
lineWidth: 120,
|
|
792
|
+
noRefs: true,
|
|
793
|
+
sortKeys: false
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
exports.ConfigManager = ConfigManager;
|
|
798
|
+
ConfigManager.instance = null;
|
|
799
|
+
ConfigManager.instanceLock = false;
|