Skip to content

Commit 58863e3

Browse files
committed
feat(config): use EditableJson for non-destructive config saving
Use EditableJson for preserving existing properties and key order when updating config values. This prevents overwriting unrelated config properties during partial updates. - Add standalone EditableJson implementation in src/utils/editable-json.mts - Update config.mts to use EditableJson for config file writes - Fix socketAppDataPath usage to include config.json filename - Add resetConfigForTesting() helper for test isolation - Update tests to use Node.js built-in fs functions
1 parent ac9fc49 commit 58863e3

File tree

3 files changed

+443
-9
lines changed

3 files changed

+443
-9
lines changed

src/utils/config.mts

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { logger } from '@socketsecurity/registry/lib/logger'
3131
import { naturalCompare } from '@socketsecurity/registry/lib/sorts'
3232

3333
import { debugConfig } from './debug.mts'
34+
import { getEditableJsonClass } from './editable-json.mts'
3435
import constants, {
3536
CONFIG_KEY_API_BASE_URL,
3637
CONFIG_KEY_API_PROXY,
@@ -98,17 +99,18 @@ function getConfigValues(): LocalConfig {
9899
_cachedConfig = {} as LocalConfig
99100
const { socketAppDataPath } = constants
100101
if (socketAppDataPath) {
101-
const raw = safeReadFileSync(socketAppDataPath)
102+
const configFilePath = path.join(socketAppDataPath, 'config.json')
103+
const raw = safeReadFileSync(configFilePath)
102104
if (raw) {
103105
try {
104106
Object.assign(
105107
_cachedConfig,
106108
JSON.parse(Buffer.from(raw, 'base64').toString()),
107109
)
108-
debugConfig(socketAppDataPath, true)
110+
debugConfig(configFilePath, true)
109111
} catch (e) {
110-
logger.warn(`Failed to parse config at ${socketAppDataPath}`)
111-
debugConfig(socketAppDataPath, false, e)
112+
logger.warn(`Failed to parse config at ${configFilePath}`)
113+
debugConfig(configFilePath, false, e)
112114
}
113115
// Normalize apiKey to apiToken and persist it.
114116
// This is a one time migration per user.
@@ -118,7 +120,7 @@ function getConfigValues(): LocalConfig {
118120
updateConfigValue(CONFIG_KEY_API_TOKEN, token)
119121
}
120122
} else {
121-
mkdirSync(path.dirname(socketAppDataPath), { recursive: true })
123+
mkdirSync(socketAppDataPath, { recursive: true })
122124
}
123125
}
124126
}
@@ -243,6 +245,16 @@ let _cachedConfig: LocalConfig | undefined
243245
// When using --config or SOCKET_CLI_CONFIG, do not persist the config.
244246
let _configFromFlag = false
245247

248+
/**
249+
* Reset config cache for testing purposes.
250+
* This allows tests to start with a fresh config state.
251+
* @internal
252+
*/
253+
export function resetConfigForTesting(): void {
254+
_cachedConfig = undefined
255+
_configFromFlag = false
256+
}
257+
246258
export function overrideCachedConfig(jsonConfig: unknown): CResult<undefined> {
247259
debugFn('notice', 'override: full config (not stored)')
248260

@@ -338,13 +350,44 @@ export function updateConfigValue<Key extends keyof LocalConfig>(
338350

339351
if (!_pendingSave) {
340352
_pendingSave = true
353+
// Capture the current config state to save.
354+
const configToSave = { ...localConfig }
341355
process.nextTick(() => {
342356
_pendingSave = false
343357
const { socketAppDataPath } = constants
344358
if (socketAppDataPath) {
359+
mkdirSync(socketAppDataPath, { recursive: true })
360+
const configFilePath = path.join(socketAppDataPath, 'config.json')
361+
// Read existing file to preserve formatting, then update with new values.
362+
const existingRaw = safeReadFileSync(configFilePath)
363+
const EditableJson = getEditableJsonClass<LocalConfig>()
364+
const editor = new EditableJson()
365+
if (existingRaw !== undefined) {
366+
const rawString = Buffer.isBuffer(existingRaw)
367+
? existingRaw.toString('utf8')
368+
: existingRaw
369+
try {
370+
const decoded = Buffer.from(rawString, 'base64').toString('utf8')
371+
editor.fromJSON(decoded)
372+
} catch {
373+
// If decoding fails, start fresh.
374+
}
375+
} else {
376+
// Initialize empty editor for new file.
377+
editor.create(configFilePath)
378+
}
379+
// Update with the captured config state.
380+
editor.update(configToSave)
381+
// Get content with formatting symbols stripped.
382+
const contentToSave = Object.fromEntries(
383+
Object.entries(editor.content).filter(
384+
([key]) => typeof key === 'string',
385+
),
386+
)
387+
const jsonContent = JSON.stringify(contentToSave)
345388
writeFileSync(
346-
socketAppDataPath,
347-
Buffer.from(JSON.stringify(localConfig)).toString('base64'),
389+
configFilePath,
390+
Buffer.from(jsonContent).toString('base64'),
348391
)
349392
}
350393
})

src/utils/config.test.mts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
1-
import { promises as fs, mkdtempSync } from 'node:fs'
1+
import {
2+
mkdtempSync,
3+
readFileSync,
4+
rmSync,
5+
writeFileSync,
6+
promises as fs,
7+
} from 'node:fs'
28
import os from 'node:os'
39
import path from 'node:path'
410

511
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
612

713
import {
814
findSocketYmlSync,
15+
getConfigValue,
916
overrideCachedConfig,
17+
resetConfigForTesting,
1018
updateConfigValue,
1119
} from './config.mts'
1220
import { testPath } from '../../test/utils.mts'
1321

22+
import type { LocalConfig } from './config.mts'
23+
1424
const fixtureBaseDir = path.join(testPath, 'fixtures/utils/config')
1525

1626
describe('utils/config', () => {
@@ -80,7 +90,7 @@ describe('utils/config', () => {
8090
expect(result.data).toBe(undefined)
8191
} finally {
8292
// Clean up the temporary directory.
83-
await fs.rm(tmpDir, { force: true, recursive: true })
93+
rmSync(tmpDir, { force: true, recursive: true })
8494
}
8595
})
8696
})

0 commit comments

Comments
 (0)