|
| 1 | +/** |
| 2 | + * Boss Ghost MCP — JSON config file loader. |
| 3 | + * |
| 4 | + * Loads configuration from ~/.boss-ghost/config.json, merges with CLI args |
| 5 | + * (CLI takes precedence), and exposes a singleton via getConfig(). |
| 6 | + */ |
| 7 | + |
| 8 | +import fs from 'node:fs'; |
| 9 | +import path from 'node:path'; |
| 10 | +import os from 'node:os'; |
| 11 | + |
| 12 | +import {logger} from '../logger.js'; |
| 13 | +import type {BossGhostConfig} from './types.js'; |
| 14 | + |
| 15 | +const CONFIG_DIR = path.join(os.homedir(), '.boss-ghost'); |
| 16 | +const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json'); |
| 17 | + |
| 18 | +const EMPTY_CONFIG: BossGhostConfig = {}; |
| 19 | + |
| 20 | +let currentConfig: BossGhostConfig = EMPTY_CONFIG; |
| 21 | +let configLoaded = false; |
| 22 | + |
| 23 | +/** |
| 24 | + * Validate that a parsed JSON value looks like a BossGhostConfig. |
| 25 | + * Performs basic structural checks without a schema library. |
| 26 | + */ |
| 27 | +function validateConfig(value: unknown): BossGhostConfig { |
| 28 | + if (value === null || typeof value !== 'object' || Array.isArray(value)) { |
| 29 | + throw new Error('Config must be a JSON object'); |
| 30 | + } |
| 31 | + |
| 32 | + const obj = value as Record<string, unknown>; |
| 33 | + |
| 34 | + if (obj.defaultProfile !== undefined && typeof obj.defaultProfile !== 'string') { |
| 35 | + throw new Error('defaultProfile must be a string'); |
| 36 | + } |
| 37 | + |
| 38 | + if (obj.profiles !== undefined) { |
| 39 | + if (typeof obj.profiles !== 'object' || obj.profiles === null || Array.isArray(obj.profiles)) { |
| 40 | + throw new Error('profiles must be an object'); |
| 41 | + } |
| 42 | + } |
| 43 | + |
| 44 | + if (obj.security !== undefined) { |
| 45 | + if (typeof obj.security !== 'object' || obj.security === null || Array.isArray(obj.security)) { |
| 46 | + throw new Error('security must be an object'); |
| 47 | + } |
| 48 | + } |
| 49 | + |
| 50 | + if (obj.ghostMode !== undefined) { |
| 51 | + if (typeof obj.ghostMode !== 'object' || obj.ghostMode === null || Array.isArray(obj.ghostMode)) { |
| 52 | + throw new Error('ghostMode must be an object'); |
| 53 | + } |
| 54 | + const gm = obj.ghostMode as Record<string, unknown>; |
| 55 | + if (gm.stealthLevel !== undefined) { |
| 56 | + const allowed = ['maximum', 'high', 'medium', 'low']; |
| 57 | + if (!allowed.includes(gm.stealthLevel as string)) { |
| 58 | + throw new Error(`ghostMode.stealthLevel must be one of: ${allowed.join(', ')}`); |
| 59 | + } |
| 60 | + } |
| 61 | + } |
| 62 | + |
| 63 | + if (obj.providers !== undefined) { |
| 64 | + if (typeof obj.providers !== 'object' || obj.providers === null || Array.isArray(obj.providers)) { |
| 65 | + throw new Error('providers must be an object'); |
| 66 | + } |
| 67 | + } |
| 68 | + |
| 69 | + return value as BossGhostConfig; |
| 70 | +} |
| 71 | + |
| 72 | +/** |
| 73 | + * Load configuration from ~/.boss-ghost/config.json. |
| 74 | + * Returns empty defaults if the file does not exist. |
| 75 | + * Throws on malformed JSON or validation errors. |
| 76 | + */ |
| 77 | +function loadConfigFromDisk(): BossGhostConfig { |
| 78 | + if (!fs.existsSync(CONFIG_PATH)) { |
| 79 | + logger('No config file found at %s — using defaults', CONFIG_PATH); |
| 80 | + return {...EMPTY_CONFIG}; |
| 81 | + } |
| 82 | + |
| 83 | + try { |
| 84 | + const raw = fs.readFileSync(CONFIG_PATH, 'utf-8'); |
| 85 | + const parsed: unknown = JSON.parse(raw); |
| 86 | + const config = validateConfig(parsed); |
| 87 | + logger('Loaded config from %s', CONFIG_PATH); |
| 88 | + return config; |
| 89 | + } catch (err) { |
| 90 | + const message = err instanceof Error ? err.message : String(err); |
| 91 | + logger('Failed to load config from %s: %s', CONFIG_PATH, message); |
| 92 | + throw new Error(`Invalid boss-ghost config at ${CONFIG_PATH}: ${message}`); |
| 93 | + } |
| 94 | +} |
| 95 | + |
| 96 | +/** |
| 97 | + * Deep-merge source into target. Arrays are replaced, not concatenated. |
| 98 | + * CLI overrides (source) take precedence over config file values (target). |
| 99 | + */ |
| 100 | +function deepMerge<T extends Record<string, unknown>>(target: T, source: Record<string, unknown>): T { |
| 101 | + const result = {...target} as Record<string, unknown>; |
| 102 | + |
| 103 | + for (const key of Object.keys(source)) { |
| 104 | + const srcVal = source[key]; |
| 105 | + const tgtVal = result[key]; |
| 106 | + |
| 107 | + if (srcVal === undefined) { |
| 108 | + continue; |
| 109 | + } |
| 110 | + |
| 111 | + if ( |
| 112 | + srcVal !== null && |
| 113 | + typeof srcVal === 'object' && |
| 114 | + !Array.isArray(srcVal) && |
| 115 | + tgtVal !== null && |
| 116 | + typeof tgtVal === 'object' && |
| 117 | + !Array.isArray(tgtVal) |
| 118 | + ) { |
| 119 | + result[key] = deepMerge( |
| 120 | + tgtVal as Record<string, unknown>, |
| 121 | + srcVal as Record<string, unknown>, |
| 122 | + ); |
| 123 | + } else { |
| 124 | + result[key] = srcVal; |
| 125 | + } |
| 126 | + } |
| 127 | + |
| 128 | + return result as T; |
| 129 | +} |
| 130 | + |
| 131 | +/** |
| 132 | + * Return the current configuration singleton. |
| 133 | + * Automatically loads from disk on first call. |
| 134 | + */ |
| 135 | +export function getConfig(): BossGhostConfig { |
| 136 | + if (!configLoaded) { |
| 137 | + currentConfig = loadConfigFromDisk(); |
| 138 | + configLoaded = true; |
| 139 | + } |
| 140 | + return currentConfig; |
| 141 | +} |
| 142 | + |
| 143 | +/** |
| 144 | + * Reload configuration from disk, discarding any in-memory state. |
| 145 | + * Useful for hot-reload scenarios. |
| 146 | + */ |
| 147 | +export function reloadConfig(): BossGhostConfig { |
| 148 | + configLoaded = false; |
| 149 | + currentConfig = EMPTY_CONFIG; |
| 150 | + return getConfig(); |
| 151 | +} |
| 152 | + |
| 153 | +/** |
| 154 | + * Merge CLI argument overrides on top of the file-based config. |
| 155 | + * Call this after parsing CLI args to ensure CLI takes precedence. |
| 156 | + */ |
| 157 | +export function mergeCliOverrides(cliOverrides: Record<string, unknown>): BossGhostConfig { |
| 158 | + const base = getConfig(); |
| 159 | + currentConfig = deepMerge(base as Record<string, unknown>, cliOverrides) as BossGhostConfig; |
| 160 | + logger('Merged CLI overrides into config'); |
| 161 | + return currentConfig; |
| 162 | +} |
| 163 | + |
| 164 | +/** |
| 165 | + * Save the current configuration to ~/.boss-ghost/config.json. |
| 166 | + * Auto-creates the ~/.boss-ghost/ directory if it doesn't exist. |
| 167 | + */ |
| 168 | +export function saveConfig(config?: BossGhostConfig): void { |
| 169 | + const toSave = config ?? currentConfig; |
| 170 | + |
| 171 | + if (!fs.existsSync(CONFIG_DIR)) { |
| 172 | + fs.mkdirSync(CONFIG_DIR, {recursive: true}); |
| 173 | + logger('Created config directory: %s', CONFIG_DIR); |
| 174 | + } |
| 175 | + |
| 176 | + fs.writeFileSync(CONFIG_PATH, JSON.stringify(toSave, null, 2) + '\n', 'utf-8'); |
| 177 | + logger('Saved config to %s', CONFIG_PATH); |
| 178 | + |
| 179 | + // Update in-memory state to match what was saved |
| 180 | + currentConfig = toSave; |
| 181 | + configLoaded = true; |
| 182 | +} |
| 183 | + |
| 184 | +/** |
| 185 | + * Return the resolved path to the config file. |
| 186 | + */ |
| 187 | +export function getConfigPath(): string { |
| 188 | + return CONFIG_PATH; |
| 189 | +} |
0 commit comments