basant307/AI_Governance_Project
045
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import * as fs from 'node:fs';8import { parse, stringify } from 'comment-json';9import { writeStderrLine } from './stdioHelpers.js';10import { writeWithBackupSync } from './writeWithBackup.js';11 12/**13 * Updates a JSON file while preserving comments and formatting.14 *15 * In merge mode (default), updates are deep-merged into the existing file,16 * preserving keys not mentioned in the updates object.17 * A replacePath can be provided for a single updated subtree that should be18 * replaced exactly instead of deep-merged.19 *20 * In sync mode (sync=true), the file is synchronized to match the updates21 * object exactly — keys present in the original but not in updates are22 * removed, preventing zombie keys after migrations.23 *24 * Uses writeWithBackupSync internally for atomic temp-file + rename writes,25 * preventing file corruption if the process crashes mid-write.26 *27 * @returns true if the file was successfully written, false if the write28 * was refused (e.g. the result would not be valid JSON or file not parseable).29 */30export function updateSettingsFilePreservingFormat(31 filePath: string,32 updates: Record<string, unknown>,33 sync = false,34 replacePath: readonly string[] = [],35): boolean {36 if (!fs.existsSync(filePath)) {37 const content = stringify(updates, null, 2);38 writeWithBackupSync(filePath, content);39 return true;40 }41 42 const originalContent = fs.readFileSync(filePath, 'utf-8');43 44 let parsed: Record<string, unknown>;45 try {46 parsed = parse(originalContent) as Record<string, unknown>;47 } catch (_error) {48 writeStderrLine('Error parsing settings file.');49 writeStderrLine(50 `Settings file may be corrupted: ${_error instanceof Error ? _error.message : String(_error)}`,51 );52 return false;53 }54 55 // In sync mode, applyUpdates recursively removes keys not present in the56 // migrated object, preventing zombie keys at every nesting level.57 // In merge mode, only the specified updates are applied.58 const updatedStructure = applyUpdates(parsed, updates, sync, replacePath);59 60 const updatedContent = stringify(updatedStructure, null, 2);61 62 // Validate that the output is parseable before writing to disk.63 // This prevents corrupted settings files that would block startup.64 try {65 parse(updatedContent);66 } catch (validationError) {67 writeStderrLine(68 'Error: Refusing to write settings file — the result would not be valid JSON.',69 );70 writeStderrLine(71 validationError instanceof Error72 ? validationError.message73 : String(validationError),74 );75 return false;76 }77 78 writeWithBackupSync(filePath, updatedContent);79 return true;80}81 82export function applyUpdates(83 current: Record<string, unknown>,84 updates: Record<string, unknown>,85 sync = false,86 replacePath: readonly string[] = [],87 currentPath: readonly string[] = [],88): Record<string, unknown> {89 const result = current;90 91 if (sync) {92 // Sync mode: remove keys from current that are not present in updates,93 // then recursively apply updates. This prevents nested zombie keys94 // from persisting after migrations that restructure nested objects.95 const keysToRemove = Object.keys(result).filter((key) => !(key in updates));96 for (const key of keysToRemove) {97 delete result[key];98 }99 }100 101 for (const key of Object.getOwnPropertyNames(updates)) {102 if (key === '__proto__' || key === 'constructor' || key === 'prototype') {103 continue;104 }105 106 const value = updates[key];107 const nextPath = [...currentPath, key];108 const valueIsObject =109 typeof value === 'object' &&110 value !== null &&111 !Array.isArray(value) &&112 Object.keys(value).length > 0;113 if (pathsEqual(nextPath, replacePath)) {114 result[key] = valueIsObject115 ? applyUpdates({}, value as Record<string, unknown>)116 : value;117 continue;118 }119 120 if (121 valueIsObject &&122 (typeof result[key] !== 'object' ||123 result[key] === null ||124 Array.isArray(result[key]))125 ) {126 result[key] = applyUpdates(127 {},128 value as Record<string, unknown>,129 sync,130 replacePath,131 nextPath,132 );133 } else if (134 valueIsObject &&135 typeof result[key] === 'object' &&136 result[key] !== null &&137 !Array.isArray(result[key])138 ) {139 result[key] = applyUpdates(140 result[key] as Record<string, unknown>,141 value as Record<string, unknown>,142 sync,143 replacePath,144 nextPath,145 );146 } else {147 result[key] = value;148 }149 }150 151 return result;152}153 154function pathsEqual(155 left: readonly string[],156 right: readonly string[],157): boolean {158 return (159 left.length === right.length &&160 left.every((segment, index) => segment === right[index])161 );162}163 