/** * Config mutation handlers — write operations for .planning/config.json. * * Ported from get-shit-done/bin/lib/config.cjs. * Provides config-set (with key validation and value coercion), * config-set-model-profile, config-new-project, and config-ensure-section. * * @example * ```typescript * import { configSet, configNewProject } from './config-mutation.js'; * * await configSet(['model_profile', 'quality'], '/project'); * // { data: { updated: true, key: 'model_profile', value: 'quality', previousValue: 'balanced' } } * * await configNewProject([], '/project'); * // { data: { created: true, path: '.planning/config.json' } } * ``` */ import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { GSDError, ErrorClassification } from '../errors.js'; import { VALID_PROFILES, getAgentToModelMapForProfile } from './config-query.js'; import { VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS, DYNAMIC_KEY_PATTERNS } from './config-schema.js'; import { CONFIG_DEFAULTS } from '../configuration/index.js'; import { planningPaths } from './helpers.js'; import { acquireStateLock, releaseStateLock } from './state-mutation.js'; import { maskIfSecret } from './secrets.js'; import type { QueryHandler } from './utils.js'; /** * Write config JSON atomically via temp file + rename to prevent * partial writes on process interruption. */ async function atomicWriteConfig(configPath: string, config: Record): Promise { const tmpPath = configPath + '.tmp.' + process.pid; const content = JSON.stringify(config, null, 2) + '\n'; try { await writeFile(tmpPath, content, 'utf-8'); await rename(tmpPath, configPath); } catch { // D5: Rename-failure fallback — clean up temp, fall back to direct write try { await unlink(tmpPath); } catch { /* already gone */ } await writeFile(configPath, content, 'utf-8'); } } // ─── VALID_CONFIG_KEYS ──────────────────────────────────────────────────── // Imported from ./config-schema.js — single source of truth, kept in sync // with get-shit-done/bin/lib/config-schema.cjs by a CI parity test (#2653). // ─── CONFIG_KEY_SUGGESTIONS (D9 — match CJS config.cjs:57-67) ──────────── /** * Curated typo correction map for known config key mistakes. * Checked before the general LCP fallback for more precise suggestions. */ const CONFIG_KEY_SUGGESTIONS: Record = { 'workflow.nyquist_validation_enabled': 'workflow.nyquist_validation', 'agents.nyquist_validation_enabled': 'workflow.nyquist_validation', 'nyquist.validation_enabled': 'workflow.nyquist_validation', 'hooks.research_questions': 'workflow.research_before_questions', 'workflow.research_questions': 'workflow.research_before_questions', 'workflow.codereview': 'workflow.code_review', 'workflow.review_command': 'workflow.code_review_command', 'workflow.review': 'workflow.code_review', 'workflow.code_review_level': 'workflow.code_review_depth', 'workflow.review_depth': 'workflow.code_review_depth', 'review.model': 'review.models.', 'sub_repos': 'planning.sub_repos', 'plan_checker': 'workflow.plan_check', }; const SHIP_PR_BODY_SECTION_KEYS = new Set(['heading', 'enabled', 'source', 'fallback', 'template']); const SHIP_PR_BODY_TEMPLATE_TOKENS = new Set([ 'phase_number', 'phase_name', 'phase_dir', 'base_branch', 'padded_phase', ]); const SHIP_PR_BODY_SOURCE_RE = /^(ROADMAP|PLAN|SUMMARY|VERIFICATION|STATE|REQUIREMENTS|CONTEXT)\.md\s+##\s+[^\r\n#][^\r\n]*$/; function validateShipPrBodySections(value: unknown): void { if (!Array.isArray(value)) { throw new GSDError( 'Invalid ship.pr_body_sections value. Expected a JSON array of section objects.', ErrorClassification.Validation, ); } value.forEach((section, index) => { const prefix = `Invalid ship.pr_body_sections[${index}]`; if (!section || typeof section !== 'object' || Array.isArray(section)) { throw new GSDError(`${prefix}. Expected an object.`, ErrorClassification.Validation); } const record = section as Record; const unknownKeys = Object.keys(record).filter((key) => !SHIP_PR_BODY_SECTION_KEYS.has(key)); if (unknownKeys.length > 0) { throw new GSDError(`${prefix}. Unknown field(s): ${unknownKeys.join(', ')}.`, ErrorClassification.Validation); } if (typeof record.heading !== 'string' || record.heading.trim() === '') { throw new GSDError(`${prefix}. heading must be a non-empty string.`, ErrorClassification.Validation); } if (/[\r\n]/.test(record.heading)) { throw new GSDError(`${prefix}. heading must be a single line.`, ErrorClassification.Validation); } if ('enabled' in record && typeof record.enabled !== 'boolean') { throw new GSDError(`${prefix}. enabled must be true or false.`, ErrorClassification.Validation); } for (const field of ['source', 'fallback', 'template']) { if (field in record && typeof record[field] !== 'string') { throw new GSDError(`${prefix}. ${field} must be a string.`, ErrorClassification.Validation); } } const hasContent = ['source', 'fallback', 'template'].some((field) => { return typeof record[field] === 'string' && record[field].trim() !== ''; }); if (!hasContent) { throw new GSDError(`${prefix}. Provide at least one of source, fallback, or template.`, ErrorClassification.Validation); } if (typeof record.source === 'string' && record.source.trim() !== '') { const selectors = record.source.split('||').map((selector) => selector.trim()).filter(Boolean); if (selectors.length === 0 || selectors.some((selector) => !SHIP_PR_BODY_SOURCE_RE.test(selector))) { throw new GSDError( `${prefix}. source must use selectors like "PLAN.md ## Risks", separated with "||".`, ErrorClassification.Validation, ); } } if (typeof record.template === 'string') { const tokens = record.template.matchAll(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g); for (const match of tokens) { if (!SHIP_PR_BODY_TEMPLATE_TOKENS.has(match[1])) { throw new GSDError(`${prefix}. Unsupported template token: {${match[1]}}.`, ErrorClassification.Validation); } } } }); } // ─── isValidConfigKey ───────────────────────────────────────────────────── /** * Check whether a config key path is valid. * * Supports exact matches from VALID_CONFIG_KEYS plus dynamic patterns * like `agent_skills.` and `features.`. * Uses curated CONFIG_KEY_SUGGESTIONS before LCP fallback for typo correction. * * @param keyPath - Dot-notation config key path * @returns Object with valid flag and optional suggestion for typos */ export function isValidConfigKey(keyPath: string): { valid: boolean; suggestion?: string } { if (VALID_CONFIG_KEYS.has(keyPath)) return { valid: true }; if (RUNTIME_STATE_KEYS.has(keyPath)) return { valid: true }; // Dynamic patterns — all sourced from shared config-schema (#2653). // Covers agent_skills.*, review.models.*, features.*, // claude_md_assembly.blocks.*, and model_profile_overrides.*.. if (DYNAMIC_KEY_PATTERNS.some((p) => p.test(keyPath))) return { valid: true }; // D9: Check curated suggestions before LCP fallback if (CONFIG_KEY_SUGGESTIONS[keyPath]) { return { valid: false, suggestion: CONFIG_KEY_SUGGESTIONS[keyPath] }; } // Find closest suggestion using longest common prefix const keys = [...VALID_CONFIG_KEYS]; let bestMatch = ''; let bestScore = 0; for (const candidate of keys) { let shared = 0; const maxLen = Math.min(keyPath.length, candidate.length); for (let i = 0; i < maxLen; i++) { if (keyPath[i] === candidate[i]) shared++; else break; } if (shared > bestScore) { bestScore = shared; bestMatch = candidate; } } return { valid: false, suggestion: bestScore > 2 ? bestMatch : undefined }; } // ─── parseConfigValue ───────────────────────────────────────────────────── /** * Coerce a CLI string value to its native type. * * Ported from config.cjs lines 344-351. * * @param value - String value from CLI * @returns Coerced value: boolean, number, parsed JSON, or original string */ export function parseConfigValue(value: string): unknown { if (value === 'true') return true; if (value === 'false') return false; if (value !== '' && !isNaN(Number(value))) return Number(value); if (typeof value === 'string' && (value.startsWith('[') || value.startsWith('{'))) { try { return JSON.parse(value); } catch { /* keep as string */ } } return value; } // ─── setConfigValue ─────────────────────────────────────────────────────── /** * Set a value at a dot-notation path in a config object. * * Creates nested objects as needed along the path. * * @param obj - Config object to mutate * @param dotPath - Dot-notation key path (e.g., 'workflow.auto_advance') * @param value - Value to set */ function getValueAtPath(obj: Record, dotPath: string): unknown { const keys = dotPath.split('.'); let current: unknown = obj; for (const key of keys) { if (current === undefined || current === null || typeof current !== 'object') { return undefined; } current = (current as Record)[key]; } return current; } function setConfigValue(obj: Record, dotPath: string, value: unknown): void { const keys = dotPath.split('.'); let current: Record = obj; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; if (current[key] === undefined || typeof current[key] !== 'object' || current[key] === null) { current[key] = {}; } current = current[key] as Record; } current[keys[keys.length - 1]] = value; } // ─── configSet ──────────────────────────────────────────────────────────── /** * Write a validated key-value pair to config.json. * * Validates key against VALID_CONFIG_KEYS allowlist, coerces value * from CLI string to native type, and writes config.json. * * @param args - args[0]=key, args[1]=value * @param projectDir - Project root directory * @returns QueryResult matching gsd-tools `config-set` JSON: `{ updated, key, value, previousValue }` * @throws GSDError with Validation if key is invalid or args missing */ export const configSet: QueryHandler = async (args, projectDir, workstream) => { const keyPath = args[0]; const rawValue = args[1]; if (!keyPath) { throw new GSDError('Usage: config-set ', ErrorClassification.Validation); } // #3593: parity with CJS cmdConfigSet — reject `config-set ` invocations // that omit the value. Without this guard parsedValue stays undefined and the // write either silently strips the key (JSON.stringify drops undefined) or // persists a corrupt entry. if (rawValue === undefined) { throw new GSDError('Usage: config-set ', ErrorClassification.Validation); } const validation = isValidConfigKey(keyPath); if (!validation.valid) { const suggestion = validation.suggestion ? `. Did you mean: ${validation.suggestion}?` : ''; throw new GSDError( `Unknown config key: ${keyPath}${suggestion}`, ErrorClassification.Validation, ); } const parsedValue = rawValue !== undefined ? parseConfigValue(rawValue) : rawValue; // D8: Context value validation (match CJS config.cjs:357-359) const VALID_CONTEXT_VALUES = ['dev', 'research', 'review']; if (keyPath === 'context' && !VALID_CONTEXT_VALUES.includes(String(parsedValue))) { throw new GSDError( `Invalid context value '${rawValue}'. Valid values: ${VALID_CONTEXT_VALUES.join(', ')}`, ErrorClassification.Validation, ); } if (keyPath === 'ship.pr_body_sections') { validateShipPrBodySections(parsedValue); } // CJS parity (config.cjs:430-441): boolean-only keys must reject non-boolean // input. Without this, `config-set git.create_tag maybe` silently writes // "maybe" to disk under SDK dispatch even though the CJS path correctly // rejects it. Bug #3086. if (keyPath === 'workflow.post_planning_gaps' && typeof parsedValue !== 'boolean') { throw new GSDError( `Invalid workflow.post_planning_gaps '${rawValue}'. Must be a boolean (true or false).`, ErrorClassification.Validation, ); } if (keyPath === 'git.create_tag' && typeof parsedValue !== 'boolean') { throw new GSDError( `Invalid git.create_tag '${rawValue}'. Must be a boolean (true or false).`, ErrorClassification.Validation, ); } // Codebase drift detector value validation — port of config.cjs:430-437. (#2003) const VALID_DRIFT_ACTIONS = ['warn', 'auto-remap']; if (keyPath === 'workflow.drift_action' && !VALID_DRIFT_ACTIONS.includes(String(parsedValue))) { throw new GSDError( `Invalid workflow.drift_action '${rawValue}'. Valid values: ${VALID_DRIFT_ACTIONS.join(', ')}`, ErrorClassification.Validation, ); } if (keyPath === 'workflow.drift_threshold') { if (typeof parsedValue !== 'number' || !Number.isInteger(parsedValue) || parsedValue < 1) { throw new GSDError( `Invalid workflow.drift_threshold '${rawValue}'. Must be a positive integer.`, ErrorClassification.Validation, ); } } // Human verification checkpoint mode (#3309) — port of config.cjs:457-460. const VALID_HUMAN_VERIFY_MODES = ['mid-flight', 'end-of-phase']; if (keyPath === 'workflow.human_verify_mode' && !VALID_HUMAN_VERIFY_MODES.includes(String(parsedValue))) { throw new GSDError( `Invalid workflow.human_verify_mode '${rawValue}'. Valid values: ${VALID_HUMAN_VERIFY_MODES.join(', ')}`, ErrorClassification.Validation, ); } // Context position enum validation (#2937) — port of config.cjs:463-466. const VALID_CONTEXT_POSITIONS = ['front', 'end']; if (keyPath === 'statusline.context_position' && !VALID_CONTEXT_POSITIONS.includes(String(parsedValue))) { throw new GSDError( `Invalid statusline.context_position '${rawValue}'. Valid values: ${VALID_CONTEXT_POSITIONS.join(', ')}`, ErrorClassification.Validation, ); } // Fallow scope + profile enum validation (#3424) — port of config.cjs:469-477. const VALID_FALLOW_SCOPES = ['phase', 'repo']; if (keyPath === 'code_quality.fallow.scope' && !VALID_FALLOW_SCOPES.includes(String(parsedValue))) { throw new GSDError( `Invalid code_quality.fallow.scope '${rawValue}'. Valid values: ${VALID_FALLOW_SCOPES.join(', ')}`, ErrorClassification.Validation, ); } const VALID_FALLOW_PROFILES = ['minimal', 'standard', 'strict']; if (keyPath === 'code_quality.fallow.profile' && !VALID_FALLOW_PROFILES.includes(String(parsedValue))) { throw new GSDError( `Invalid code_quality.fallow.profile '${rawValue}'. Valid values: ${VALID_FALLOW_PROFILES.join(', ')}`, ErrorClassification.Validation, ); } // review.default_reviewers (#3079) — port of normalizeConfiguredDefaultReviewers // from bin/lib/review-reviewer-selection.cjs. Validates array shape, rejects // empties, requires string slugs matching ^[a-zA-Z0-9_-]+$, and normalizes to // lowercase-unique order. `parsedValue` is rewritten in place so the persisted // value carries the normalized form (matching CJS config.cjs:479-483 behavior). let normalizedValue: unknown = parsedValue; if (keyPath === 'review.default_reviewers') { if (parsedValue === null || parsedValue === undefined) { throw new GSDError( 'review.default_reviewers must be a JSON array of reviewer slugs', ErrorClassification.Validation, ); } if (!Array.isArray(parsedValue)) { throw new GSDError( 'review.default_reviewers must be a JSON array of reviewer slugs', ErrorClassification.Validation, ); } if (parsedValue.length === 0) { throw new GSDError( 'review.default_reviewers cannot be empty', ErrorClassification.Validation, ); } const seen = new Set(); const normalized: string[] = []; for (const item of parsedValue) { if (typeof item !== 'string') { throw new GSDError( 'review.default_reviewers must contain only string slugs', ErrorClassification.Validation, ); } if (!/^[a-zA-Z0-9_-]+$/.test(item)) { throw new GSDError( `invalid reviewer slug in review.default_reviewers: ${item}`, ErrorClassification.Validation, ); } const slug = item.toLowerCase(); if (!seen.has(slug)) { seen.add(slug); normalized.push(slug); } } normalizedValue = normalized; } // D6: Lock protection for read-modify-write (match CJS config.cjs:296) const paths = planningPaths(projectDir, workstream); const lockPath = await acquireStateLock(paths.config); let previousValue: unknown; try { let config: Record = {}; try { const raw = await readFile(paths.config, 'utf-8'); config = JSON.parse(raw) as Record; } catch { // Start with empty config if file doesn't exist or is malformed } previousValue = getValueAtPath(config, keyPath); setConfigValue(config, keyPath, normalizedValue); await atomicWriteConfig(paths.config, config); } finally { await releaseStateLock(lockPath); } // Mask plaintext for keys in SECRET_CONFIG_KEYS to match CJS behavior at // config.cjs:362-370 — without this, `gsd-sdk query config-set brave_search XXX` // would echo the plaintext credential into machine-readable output. (#2997) // The on-disk value is intentionally NOT masked — only the response. const data: Record = { updated: true, key: keyPath, value: maskIfSecret(keyPath, parsedValue), }; if (previousValue !== undefined) { data.previousValue = maskIfSecret(keyPath, previousValue); } return { data }; }; // ─── configSetModelProfile ──────────────────────────────────────────────── /** * Validate and set the model profile in config.json. * * @param args - args[0]=profileName * @param projectDir - Project root directory * @returns QueryResult with { set: true, profile, agents } * @throws GSDError with Validation if profile is invalid */ export const configSetModelProfile: QueryHandler = async (args, projectDir, workstream) => { const profileName = args[0]; if (!profileName) { throw new GSDError( `Usage: config-set-model-profile <${VALID_PROFILES.join('|')}>`, ErrorClassification.Validation, ); } const normalized = profileName.toLowerCase().trim(); if (!VALID_PROFILES.includes(normalized)) { throw new GSDError( `Invalid profile '${profileName}'. Valid profiles: ${VALID_PROFILES.join(', ')}`, ErrorClassification.Validation, ); } // D6: Lock protection for read-modify-write const paths = planningPaths(projectDir, workstream); const lockPath = await acquireStateLock(paths.config); let previousProfile = 'balanced'; try { let config: Record = {}; try { const raw = await readFile(paths.config, 'utf-8'); config = JSON.parse(raw) as Record; } catch { // Start with empty config } const prev = typeof config.model_profile === 'string' ? config.model_profile.toLowerCase().trim() : ''; previousProfile = VALID_PROFILES.includes(prev) ? prev : 'balanced'; config.model_profile = normalized; await atomicWriteConfig(paths.config, config); } finally { await releaseStateLock(lockPath); } const agentToModelMap = getAgentToModelMapForProfile(normalized); return { data: { updated: true, profile: normalized, previousProfile, agentToModelMap, }, }; }; // ─── configNewProject ───────────────────────────────────────────────────── /** * Create config.json with defaults and optional user choices. * * Idempotent: if config.json already exists, returns { created: false }. * Detects API key availability from environment variables. * * @param args - args[0]=optional JSON string of user choices * @param projectDir - Project root directory * @returns QueryResult with { created: true, path } or { created: false, reason } */ export const configNewProject: QueryHandler = async (args, projectDir, workstream) => { const paths = planningPaths(projectDir, workstream); // Idempotent: don't overwrite existing config if (existsSync(paths.config)) { return { data: { created: false, reason: 'already_exists' } }; } // Parse user choices let userChoices: Record = {}; if (args[0] && args[0].trim() !== '') { try { userChoices = JSON.parse(args[0]) as Record; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); throw new GSDError(`Invalid JSON for config-new-project: ${msg}`, ErrorClassification.Validation); } } // Ensure .planning directory exists const planningDir = paths.planning; if (!existsSync(planningDir)) { await mkdir(planningDir, { recursive: true }); } // D11: Load global defaults from ~/.gsd/defaults.json if present const homeDir = homedir(); let globalDefaults: Record = {}; try { const defaultsPath = join(homeDir, '.gsd', 'defaults.json'); const defaultsRaw = await readFile(defaultsPath, 'utf-8'); globalDefaults = JSON.parse(defaultsRaw) as Record; } catch { // No global defaults — continue with hardcoded defaults only } // Detect API key availability (boolean only, never store keys) const hasBraveSearch = !!(process.env.BRAVE_API_KEY || existsSync(join(homeDir, '.gsd', 'brave_api_key'))); const hasFirecrawl = !!(process.env.FIRECRAWL_API_KEY || existsSync(join(homeDir, '.gsd', 'firecrawl_api_key'))); const hasExaSearch = !!(process.env.EXA_API_KEY || existsSync(join(homeDir, '.gsd', 'exa_api_key'))); // Build default config. Source is the canonical Configuration Module manifest // at sdk/shared/config-defaults.manifest.json (CONFIG_DEFAULTS from // sdk/src/configuration/index.ts) — but ONLY a subset is materialized at // init time. Legacy CJS `buildNewProjectConfig` (bin/lib/config.cjs:155-210) // intentionally omits keys whose value is meaningful only when set // explicitly so config-get returns "Key not found" and workflows fall back // to auto-detect (e.g. git.base_branch falls back to origin/HEAD // resolution). Keeping the SDK init shape aligned with CJS preserves that // workflow contract while the manifest remains the schema-wide source of // truth for validation and key existence (per ADR §6). // // Runtime API-key detection overrides the manifest's `false` defaults for // the three search providers — manifest comment explicitly notes this. const manifestDefaults = CONFIG_DEFAULTS as Record; // Strip the metadata-only "_comment" key before it gets persisted. const { _comment: _ignoredComment, ...sanitizedManifest } = manifestDefaults; void _ignoredComment; // Top-level keys present in the manifest but NOT in CJS init output. Each // either has its own resolution path (resolve_model_ids, context_window, // mode) or lives under a non-init heading (planning.*, graphify.* are // opt-in features users configure separately). const TOP_LEVEL_OMITTED_FROM_INIT = new Set([ 'resolve_model_ids', 'context_window', 'mode', 'planning', 'graphify', ]); // Nested git keys omitted by CJS init. `git.base_branch` triggers // origin/HEAD auto-detect when absent — materializing `null` here would // suppress that and break ship-ready preflight (#3079). const GIT_KEYS_OMITTED_FROM_INIT = new Set(['base_branch']); const filteredTopLevel: Record = {}; for (const [k, v] of Object.entries(sanitizedManifest)) { if (TOP_LEVEL_OMITTED_FROM_INIT.has(k)) continue; filteredTopLevel[k] = v; } const manifestGit = (filteredTopLevel.git as Record) || {}; const filteredGit: Record = {}; for (const [k, v] of Object.entries(manifestGit)) { if (GIT_KEYS_OMITTED_FROM_INIT.has(k)) continue; filteredGit[k] = v; } const defaults: Record = { ...filteredTopLevel, git: filteredGit, brave_search: hasBraveSearch, firecrawl: hasFirecrawl, exa_search: hasExaSearch, // CJS `buildNewProjectConfig` includes `features: {}` as a hardcoded // top-level slot; the manifest doesn't yet — keep parity until the // manifest is amended in a separate enhancement. features: {}, }; // Deep merge: hardcoded <- globalDefaults <- userChoices (D11) const config: Record = { ...defaults, ...globalDefaults, ...userChoices, git: { ...(defaults.git as Record), ...((globalDefaults.git as Record) || {}), ...((userChoices.git as Record) || {}), }, workflow: { ...(defaults.workflow as Record), ...((globalDefaults.workflow as Record) || {}), ...((userChoices.workflow as Record) || {}), }, ship: { ...(defaults.ship as Record), ...((globalDefaults.ship as Record) || {}), ...((userChoices.ship as Record) || {}), }, hooks: { ...(defaults.hooks as Record), ...((globalDefaults.hooks as Record) || {}), ...((userChoices.hooks as Record) || {}), }, agent_skills: { ...((defaults.agent_skills as Record) || {}), ...((globalDefaults.agent_skills as Record) || {}), ...((userChoices.agent_skills as Record) || {}), }, features: { ...((defaults.features as Record) || {}), ...((globalDefaults.features as Record) || {}), ...((userChoices.features as Record) || {}), }, }; const ship = config.ship as Record; validateShipPrBodySections(ship.pr_body_sections); await atomicWriteConfig(paths.config, config); // Match CJS `ensureConfigFile` shape: report the relative project-rooted // path so output stays workspace-portable. return { data: { created: true, path: '.planning/config.json' } }; }; // ─── configEnsureSection ────────────────────────────────────────────────── /** * Idempotently ensure a top-level section exists in config.json. * * If the section key doesn't exist, creates it as an empty object. * If it already exists, preserves its contents. * * @param args - args[0]=sectionName * @param projectDir - Project root directory * @returns QueryResult with { ensured: true, section } */ export const configEnsureSection: QueryHandler = async (args, projectDir, workstream) => { const sectionName = args[0]; if (!sectionName) { throw new GSDError('Usage: config-ensure-section
', ErrorClassification.Validation); } const paths = planningPaths(projectDir, workstream); let config: Record = {}; try { const raw = await readFile(paths.config, 'utf-8'); config = JSON.parse(raw) as Record; } catch { // Start with empty config } if (!(sectionName in config)) { config[sectionName] = {}; } await atomicWriteConfig(paths.config, config); return { data: { ensured: true, section: sectionName } }; };