free-coding-models 0.3.5 → 0.3.9

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/src/config.js CHANGED
@@ -86,12 +86,15 @@
86
86
  *
87
87
  * @functions
88
88
  * → loadConfig() — Read ~/.free-coding-models.json; auto-migrate old plain-text config if needed
89
- * → saveConfig(config) — Write config to ~/.free-coding-models.json with 0o600 permissions
89
+ * → saveConfig(config, options?) — Write config to ~/.free-coding-models.json with atomic replace + merge safeguards
90
90
  * → getApiKey(config, providerKey) — Get effective API key (env var override > config > null)
91
91
  * → addApiKey(config, providerKey, key) — Append a key (string→array); ignores empty/duplicate
92
92
  * → removeApiKey(config, providerKey, index?) — Remove key at index (or last); collapses array-of-1 to string; deletes when empty
93
93
  * → listApiKeys(config, providerKey) — Return all keys for a provider as normalized array
94
94
  * → isProviderEnabled(config, providerKey) — Check if provider is enabled (defaults true)
95
+ * → buildPersistedConfig(incomingConfig, diskConfig, options?) — Merge a live snapshot with the latest disk state safely
96
+ * → replaceConfigContents(targetConfig, nextConfig) — Refresh an in-memory config object from a normalized snapshot
97
+ * → persistApiKeysForProvider(config, providerKey) — Persist one provider's API keys without clobbering the rest of the file
95
98
  * → saveAsProfile(config, name) — Snapshot current apiKeys/providers/favorites/settings into a named profile
96
99
  * → loadProfile(config, name) — Apply a named profile's values onto the live config
97
100
  * → listProfiles(config) — Return array of profile names
@@ -107,13 +110,14 @@
107
110
  * @exports addApiKey, removeApiKey, listApiKeys — multi-key management helpers
108
111
  * @exports saveAsProfile, loadProfile, listProfiles, deleteProfile
109
112
  * @exports getActiveProfileName, setActiveProfile, getProxySettings, setClaudeProxyModelRouting, normalizeEndpointInstalls
113
+ * @exports buildPersistedConfig, replaceConfigContents, persistApiKeysForProvider
110
114
  * @exports CONFIG_PATH — path to the JSON config file
111
115
  *
112
116
  * @see bin/free-coding-models.js — main CLI that uses these functions
113
117
  * @see sources.js — provider keys come from Object.keys(sources)
114
118
  */
115
119
 
116
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
120
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync, renameSync } from 'node:fs'
117
121
  import { randomBytes } from 'node:crypto'
118
122
  import { homedir } from 'node:os'
119
123
  import { join } from 'node:path'
@@ -152,6 +156,317 @@ const ENV_VARS = {
152
156
  iflow: 'IFLOW_API_KEY',
153
157
  }
154
158
 
159
+ function isPlainObject(value) {
160
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
161
+ }
162
+
163
+ function cloneConfigValue(value) {
164
+ return JSON.parse(JSON.stringify(value))
165
+ }
166
+
167
+ function normalizeFavoriteList(favorites) {
168
+ if (!Array.isArray(favorites)) return []
169
+ const normalized = []
170
+ const seen = new Set()
171
+ for (const entry of favorites) {
172
+ if (typeof entry !== 'string') continue
173
+ const trimmed = entry.trim()
174
+ if (!trimmed || seen.has(trimmed)) continue
175
+ seen.add(trimmed)
176
+ normalized.push(trimmed)
177
+ }
178
+ return normalized
179
+ }
180
+
181
+ function normalizeApiKeyValue(value) {
182
+ if (Array.isArray(value)) {
183
+ const normalized = []
184
+ const seen = new Set()
185
+ for (const item of value) {
186
+ if (typeof item !== 'string') continue
187
+ const trimmed = item.trim()
188
+ if (!trimmed || seen.has(trimmed)) continue
189
+ seen.add(trimmed)
190
+ normalized.push(trimmed)
191
+ }
192
+ if (normalized.length === 0) return null
193
+ if (normalized.length === 1) return normalized[0]
194
+ return normalized
195
+ }
196
+
197
+ if (typeof value !== 'string') return null
198
+ const trimmed = value.trim()
199
+ return trimmed || null
200
+ }
201
+
202
+ function normalizeApiKeysSection(apiKeys) {
203
+ if (!isPlainObject(apiKeys)) return {}
204
+ const normalized = {}
205
+ for (const [providerKey, value] of Object.entries(apiKeys)) {
206
+ const normalizedValue = normalizeApiKeyValue(value)
207
+ if (normalizedValue !== null) normalized[providerKey] = normalizedValue
208
+ }
209
+ return normalized
210
+ }
211
+
212
+ function normalizeProvidersSection(providers) {
213
+ if (!isPlainObject(providers)) return {}
214
+ const normalized = {}
215
+ for (const [providerKey, value] of Object.entries(providers)) {
216
+ if (typeof value === 'boolean') {
217
+ normalized[providerKey] = { enabled: value !== false }
218
+ continue
219
+ }
220
+ if (!isPlainObject(value)) continue
221
+ normalized[providerKey] = { ...value, enabled: value.enabled !== false }
222
+ }
223
+ return normalized
224
+ }
225
+
226
+ function normalizeSettingsSection(settings) {
227
+ const safeSettings = isPlainObject(settings) ? { ...settings } : {}
228
+ return {
229
+ ...safeSettings,
230
+ hideUnconfiguredModels: typeof safeSettings.hideUnconfiguredModels === 'boolean' ? safeSettings.hideUnconfiguredModels : true,
231
+ proxy: normalizeProxySettings(safeSettings.proxy),
232
+ disableWidthsWarning: safeSettings.disableWidthsWarning === true,
233
+ }
234
+ }
235
+
236
+ function normalizeTelemetrySection(telemetry) {
237
+ const safeTelemetry = isPlainObject(telemetry) ? { ...telemetry } : {}
238
+ return {
239
+ enabled: typeof safeTelemetry.enabled === 'boolean' ? safeTelemetry.enabled : null,
240
+ consentVersion: typeof safeTelemetry.consentVersion === 'number' ? safeTelemetry.consentVersion : 0,
241
+ anonymousId: typeof safeTelemetry.anonymousId === 'string' && safeTelemetry.anonymousId.trim()
242
+ ? safeTelemetry.anonymousId
243
+ : null,
244
+ }
245
+ }
246
+
247
+ function normalizeProfileSettings(settings) {
248
+ const safeSettings = isPlainObject(settings) ? { ...settings } : {}
249
+ return {
250
+ ..._emptyProfileSettings(),
251
+ ...safeSettings,
252
+ proxy: normalizeProxySettings(safeSettings.proxy),
253
+ disableWidthsWarning: safeSettings.disableWidthsWarning === true,
254
+ }
255
+ }
256
+
257
+ function normalizeProfilesSection(profiles) {
258
+ if (!isPlainObject(profiles)) return {}
259
+ const normalized = {}
260
+ for (const [profileName, profile] of Object.entries(profiles)) {
261
+ if (!isPlainObject(profile)) continue
262
+ normalized[profileName] = {
263
+ apiKeys: normalizeApiKeysSection(profile.apiKeys),
264
+ providers: normalizeProvidersSection(profile.providers),
265
+ favorites: normalizeFavoriteList(profile.favorites),
266
+ settings: normalizeProfileSettings(profile.settings),
267
+ }
268
+ }
269
+ return normalized
270
+ }
271
+
272
+ function normalizeConfigShape(config) {
273
+ const safeConfig = isPlainObject(config) ? config : {}
274
+ return {
275
+ apiKeys: normalizeApiKeysSection(safeConfig.apiKeys),
276
+ providers: normalizeProvidersSection(safeConfig.providers),
277
+ settings: normalizeSettingsSection(safeConfig.settings),
278
+ favorites: normalizeFavoriteList(safeConfig.favorites),
279
+ telemetry: normalizeTelemetrySection(safeConfig.telemetry),
280
+ endpointInstalls: normalizeEndpointInstalls(safeConfig.endpointInstalls),
281
+ activeProfile: typeof safeConfig.activeProfile === 'string' && safeConfig.activeProfile.trim()
282
+ ? safeConfig.activeProfile.trim()
283
+ : null,
284
+ profiles: normalizeProfilesSection(safeConfig.profiles),
285
+ }
286
+ }
287
+
288
+ function readStoredConfigSnapshot() {
289
+ if (!existsSync(CONFIG_PATH)) return _emptyConfig()
290
+
291
+ try {
292
+ const raw = readFileSync(CONFIG_PATH, 'utf8').trim()
293
+ if (!raw) return _emptyConfig()
294
+ return normalizeConfigShape(JSON.parse(raw))
295
+ } catch {
296
+ return _emptyConfig()
297
+ }
298
+ }
299
+
300
+ function mergeOrderedUniqueStrings(primaryEntries, fallbackEntries) {
301
+ const merged = []
302
+ const seen = new Set()
303
+ for (const entry of [...normalizeFavoriteList(primaryEntries), ...normalizeFavoriteList(fallbackEntries)]) {
304
+ if (seen.has(entry)) continue
305
+ seen.add(entry)
306
+ merged.push(entry)
307
+ }
308
+ return merged
309
+ }
310
+
311
+ function mergeEndpointInstalls(diskEndpointInstalls, incomingEndpointInstalls) {
312
+ const merged = new Map()
313
+ for (const entry of normalizeEndpointInstalls(diskEndpointInstalls)) {
314
+ merged.set(`${entry.providerKey}::${entry.toolMode}`, entry)
315
+ }
316
+ for (const entry of normalizeEndpointInstalls(incomingEndpointInstalls)) {
317
+ merged.set(`${entry.providerKey}::${entry.toolMode}`, entry)
318
+ }
319
+ return [...merged.values()]
320
+ }
321
+
322
+ function mergeProfiles(diskProfiles, incomingProfiles, options = {}) {
323
+ const replaceProfileNames = new Set((options.replaceProfileNames || []).filter((name) => typeof name === 'string' && name.length > 0))
324
+ const removedProfileNames = new Set((options.removedProfileNames || []).filter((name) => typeof name === 'string' && name.length > 0))
325
+ const normalizedDiskProfiles = normalizeProfilesSection(diskProfiles)
326
+ const normalizedIncomingProfiles = normalizeProfilesSection(incomingProfiles)
327
+ const mergedProfiles = {}
328
+ const profileNames = new Set([
329
+ ...Object.keys(normalizedDiskProfiles),
330
+ ...Object.keys(normalizedIncomingProfiles),
331
+ ])
332
+
333
+ for (const profileName of profileNames) {
334
+ if (removedProfileNames.has(profileName)) continue
335
+
336
+ const diskProfile = normalizedDiskProfiles[profileName]
337
+ const incomingProfile = normalizedIncomingProfiles[profileName]
338
+ if (!incomingProfile) {
339
+ if (diskProfile) mergedProfiles[profileName] = cloneConfigValue(diskProfile)
340
+ continue
341
+ }
342
+
343
+ if (!diskProfile || replaceProfileNames.has(profileName)) {
344
+ mergedProfiles[profileName] = cloneConfigValue(incomingProfile)
345
+ continue
346
+ }
347
+
348
+ mergedProfiles[profileName] = {
349
+ apiKeys: { ...diskProfile.apiKeys, ...incomingProfile.apiKeys },
350
+ providers: { ...diskProfile.providers, ...incomingProfile.providers },
351
+ favorites: mergeOrderedUniqueStrings(incomingProfile.favorites, diskProfile.favorites),
352
+ settings: normalizeProfileSettings({
353
+ ...diskProfile.settings,
354
+ ...incomingProfile.settings,
355
+ }),
356
+ }
357
+ }
358
+
359
+ return mergedProfiles
360
+ }
361
+
362
+ /**
363
+ * 📖 buildPersistedConfig merges the latest disk snapshot with the in-memory config so
364
+ * 📖 stale writers do not accidentally wipe secrets, favorites, or profiles they did not touch.
365
+ *
366
+ * @param {object} incomingConfig
367
+ * @param {object} [diskConfig=_emptyConfig()]
368
+ * @param {{ replaceApiKeys?: boolean, replaceFavorites?: boolean, replaceEndpointInstalls?: boolean, replaceProfileNames?: string[], removedProfileNames?: string[] }} [options]
369
+ * @returns {object}
370
+ */
371
+ export function buildPersistedConfig(incomingConfig, diskConfig = _emptyConfig(), options = {}) {
372
+ const normalizedIncoming = normalizeConfigShape(incomingConfig)
373
+ const normalizedDisk = normalizeConfigShape(diskConfig)
374
+ const merged = {
375
+ apiKeys: options.replaceApiKeys === true
376
+ ? cloneConfigValue(normalizedIncoming.apiKeys)
377
+ : { ...normalizedDisk.apiKeys, ...normalizedIncoming.apiKeys },
378
+ providers: { ...normalizedDisk.providers, ...normalizedIncoming.providers },
379
+ settings: cloneConfigValue(normalizedIncoming.settings),
380
+ favorites: options.replaceFavorites === true
381
+ ? [...normalizedIncoming.favorites]
382
+ : mergeOrderedUniqueStrings(normalizedIncoming.favorites, normalizedDisk.favorites),
383
+ telemetry: {
384
+ ...normalizedDisk.telemetry,
385
+ ...normalizedIncoming.telemetry,
386
+ },
387
+ // 📖 Managed endpoint installs sometimes need an exact snapshot so stale disk
388
+ // 📖 records do not come back after a fresh install/refresh pass.
389
+ endpointInstalls: options.replaceEndpointInstalls === true
390
+ ? cloneConfigValue(normalizedIncoming.endpointInstalls)
391
+ : mergeEndpointInstalls(normalizedDisk.endpointInstalls, normalizedIncoming.endpointInstalls),
392
+ activeProfile: normalizedIncoming.activeProfile,
393
+ profiles: mergeProfiles(normalizedDisk.profiles, normalizedIncoming.profiles, {
394
+ replaceProfileNames: options.replaceProfileNames,
395
+ removedProfileNames: options.removedProfileNames,
396
+ }),
397
+ }
398
+
399
+ if (merged.activeProfile && !merged.profiles[merged.activeProfile]) {
400
+ merged.activeProfile = null
401
+ }
402
+
403
+ return normalizeConfigShape(merged)
404
+ }
405
+
406
+ /**
407
+ * 📖 replaceConfigContents keeps long-lived in-memory config references fresh after a save.
408
+ * 📖 The TUI stores `state.config` by reference, so we mutate it in-place instead of swapping it.
409
+ *
410
+ * @param {object} targetConfig
411
+ * @param {object} nextConfig
412
+ * @returns {object}
413
+ */
414
+ export function replaceConfigContents(targetConfig, nextConfig) {
415
+ const normalizedNextConfig = cloneConfigValue(normalizeConfigShape(nextConfig))
416
+
417
+ if (!isPlainObject(targetConfig)) return normalizedNextConfig
418
+
419
+ for (const key of Object.keys(targetConfig)) {
420
+ delete targetConfig[key]
421
+ }
422
+ Object.assign(targetConfig, normalizedNextConfig)
423
+ return targetConfig
424
+ }
425
+
426
+ /**
427
+ * 📖 persistApiKeysForProvider writes exactly one provider's key set back to disk using the
428
+ * 📖 latest config snapshot first, so editing one provider cannot erase favorites or other keys.
429
+ *
430
+ * @param {object} config
431
+ * @param {string} providerKey
432
+ * @returns {{ success: boolean, error?: string, backupCreated?: boolean }}
433
+ */
434
+ export function persistApiKeysForProvider(config, providerKey) {
435
+ const latestConfig = readStoredConfigSnapshot()
436
+ const normalizedProviderValue = normalizeApiKeyValue(config?.apiKeys?.[providerKey])
437
+
438
+ latestConfig.activeProfile = typeof config?.activeProfile === 'string' && config.activeProfile.trim()
439
+ ? config.activeProfile.trim()
440
+ : null
441
+
442
+ if (normalizedProviderValue === null) delete latestConfig.apiKeys[providerKey]
443
+ else latestConfig.apiKeys[providerKey] = cloneConfigValue(normalizedProviderValue)
444
+
445
+ if (latestConfig.activeProfile) {
446
+ if (!latestConfig.profiles[latestConfig.activeProfile]) {
447
+ latestConfig.profiles[latestConfig.activeProfile] = config?.profiles?.[latestConfig.activeProfile]
448
+ ? cloneConfigValue(config.profiles[latestConfig.activeProfile])
449
+ : {
450
+ apiKeys: {},
451
+ providers: {},
452
+ favorites: [],
453
+ settings: _emptyProfileSettings(),
454
+ }
455
+ }
456
+
457
+ if (normalizedProviderValue === null) delete latestConfig.profiles[latestConfig.activeProfile].apiKeys[providerKey]
458
+ else latestConfig.profiles[latestConfig.activeProfile].apiKeys[providerKey] = cloneConfigValue(normalizedProviderValue)
459
+ }
460
+
461
+ const saveResult = saveConfig(latestConfig, {
462
+ replaceApiKeys: true,
463
+ replaceProfileNames: latestConfig.activeProfile ? [latestConfig.activeProfile] : [],
464
+ })
465
+
466
+ if (saveResult.success) replaceConfigContents(config, latestConfig)
467
+ return saveResult
468
+ }
469
+
155
470
  /**
156
471
  * 📖 loadConfig: Read the JSON config from disk.
157
472
  *
@@ -182,31 +497,7 @@ export function loadConfig() {
182
497
 
183
498
  try {
184
499
  const raw = readFileSync(CONFIG_PATH, 'utf8').trim()
185
- const parsed = JSON.parse(raw)
186
- // 📖 Ensure the shape is always complete — fill missing or corrupted sections with defaults.
187
- if (!parsed.apiKeys || typeof parsed.apiKeys !== 'object' || Array.isArray(parsed.apiKeys)) parsed.apiKeys = {}
188
- if (!parsed.providers || typeof parsed.providers !== 'object' || Array.isArray(parsed.providers)) parsed.providers = {}
189
- if (!parsed.settings || typeof parsed.settings !== 'object' || Array.isArray(parsed.settings)) parsed.settings = {}
190
- if (typeof parsed.settings.hideUnconfiguredModels !== 'boolean') parsed.settings.hideUnconfiguredModels = true
191
- parsed.settings.proxy = normalizeProxySettings(parsed.settings.proxy)
192
- // 📖 Favorites: list of "providerKey/modelId" pinned rows.
193
- if (!Array.isArray(parsed.favorites)) parsed.favorites = []
194
- parsed.favorites = parsed.favorites.filter((fav) => typeof fav === 'string' && fav.trim().length > 0)
195
- if (!parsed.telemetry || typeof parsed.telemetry !== 'object') parsed.telemetry = { enabled: null, consentVersion: 0, anonymousId: null }
196
- if (typeof parsed.telemetry.enabled !== 'boolean') parsed.telemetry.enabled = null
197
- if (typeof parsed.telemetry.consentVersion !== 'number') parsed.telemetry.consentVersion = 0
198
- if (typeof parsed.telemetry.anonymousId !== 'string' || !parsed.telemetry.anonymousId.trim()) parsed.telemetry.anonymousId = null
199
- parsed.endpointInstalls = normalizeEndpointInstalls(parsed.endpointInstalls)
200
- // 📖 Ensure profiles section exists (added in profile system)
201
- if (!parsed.profiles || typeof parsed.profiles !== 'object') parsed.profiles = {}
202
- for (const profile of Object.values(parsed.profiles)) {
203
- if (!profile || typeof profile !== 'object') continue
204
- profile.settings = profile.settings
205
- ? { ..._emptyProfileSettings(), ...profile.settings, proxy: normalizeProxySettings(profile.settings.proxy) }
206
- : _emptyProfileSettings()
207
- }
208
- if (parsed.activeProfile && typeof parsed.activeProfile !== 'string') parsed.activeProfile = null
209
- return parsed
500
+ return normalizeConfigShape(JSON.parse(raw))
210
501
  } catch {
211
502
  // 📖 Corrupted JSON — return empty config (user will re-enter keys)
212
503
  return _emptyConfig()
@@ -247,21 +538,23 @@ export function loadConfig() {
247
538
  * - Post-write validation to ensure file is valid JSON
248
539
  *
249
540
  * @param {{ apiKeys: Record<string,string>, providers: Record<string,{enabled:boolean}>, favorites?: string[], telemetry?: { enabled?: boolean | null, consentVersion?: number, anonymousId?: string | null } }} config
541
+ * @param {{ replaceApiKeys?: boolean, replaceFavorites?: boolean, replaceEndpointInstalls?: boolean, replaceProfileNames?: string[], removedProfileNames?: string[] }} [options]
250
542
  * @returns {{ success: boolean, error?: string, backupCreated?: boolean }}
251
543
  */
252
- export function saveConfig(config) {
544
+ export function saveConfig(config, options = {}) {
253
545
  // 📖 Create backup of existing config before overwriting
254
546
  const backupCreated = createBackup()
547
+ const tempPath = `${CONFIG_PATH}.tmp-${process.pid}-${Date.now()}`
255
548
 
256
549
  try {
257
- // 📖 Write the new config
258
- const json = JSON.stringify(config, null, 2)
259
- writeFileSync(CONFIG_PATH, json, { mode: 0o600 })
550
+ const persistedConfig = buildPersistedConfig(config, readStoredConfigSnapshot(), options)
551
+ const json = JSON.stringify(persistedConfig, null, 2)
552
+ writeFileSync(tempPath, json, { mode: 0o600 })
553
+ renameSync(tempPath, CONFIG_PATH)
260
554
 
261
555
  // 📖 Verify the write succeeded by reading back and validating
262
556
  try {
263
- const written = readFileSync(CONFIG_PATH, 'utf8')
264
- const parsed = JSON.parse(written)
557
+ const parsed = readStoredConfigSnapshot()
265
558
 
266
559
  // 📖 Basic sanity check - ensure apiKeys object exists
267
560
  if (!parsed || typeof parsed !== 'object') {
@@ -269,11 +562,11 @@ export function saveConfig(config) {
269
562
  }
270
563
 
271
564
  // 📖 Verify critical data wasn't lost - check ALL keys are preserved
272
- if (config.apiKeys && Object.keys(config.apiKeys).length > 0) {
565
+ if (persistedConfig.apiKeys && Object.keys(persistedConfig.apiKeys).length > 0) {
273
566
  if (!parsed.apiKeys) {
274
567
  throw new Error('apiKeys object missing after write')
275
568
  }
276
- const originalKeys = Object.keys(config.apiKeys).sort()
569
+ const originalKeys = Object.keys(persistedConfig.apiKeys).sort()
277
570
  const writtenKeys = Object.keys(parsed.apiKeys).sort()
278
571
  if (originalKeys.length > writtenKeys.length) {
279
572
  const lostKeys = originalKeys.filter(k => !writtenKeys.includes(k))
@@ -287,10 +580,11 @@ export function saveConfig(config) {
287
580
  }
288
581
  }
289
582
 
583
+ replaceConfigContents(config, persistedConfig)
290
584
  return { success: true, backupCreated }
291
585
  } catch (verifyError) {
292
586
  // 📖 Verification failed - this is critical!
293
- const errorMsg = `Config verification failed: ${verifyError.message}`
587
+ let errorMsg = `Config verification failed: ${verifyError.message}`
294
588
 
295
589
  // 📖 Try to restore from backup if we have one
296
590
  if (backupCreated) {
@@ -306,7 +600,8 @@ export function saveConfig(config) {
306
600
  }
307
601
  } catch (writeError) {
308
602
  // 📖 Write failed - explicit error instead of silent failure
309
- const errorMsg = `Failed to write config: ${writeError.message}`
603
+ let errorMsg = `Failed to write config: ${writeError.message}`
604
+ try { unlinkSync(tempPath) } catch { /* ignore temp cleanup failures */ }
310
605
 
311
606
  // 📖 Try to restore from backup if we have one
312
607
  if (backupCreated) {
@@ -587,7 +587,7 @@ export function installProviderEndpoints(config, providerKey, toolMode, options
587
587
 
588
588
  if (options.track !== false) {
589
589
  upsertInstallRecord(config, buildInstallRecord(providerKey, canonicalToolMode, scope, models.map((model) => model.modelId)))
590
- saveConfig(config)
590
+ saveConfig(config, { replaceEndpointInstalls: true })
591
591
  }
592
592
 
593
593
  return {
@@ -636,7 +636,7 @@ export function refreshInstalledEndpoints(config, options = {}) {
636
636
  ...record,
637
637
  lastSyncedAt: new Date().toISOString(),
638
638
  }))
639
- saveConfig(config)
639
+ saveConfig(config, { replaceEndpointInstalls: true })
640
640
  }
641
641
 
642
642
  return { refreshed, failed, errors }
package/src/favorites.js CHANGED
@@ -11,8 +11,8 @@
11
11
  * How it works at runtime:
12
12
  * 1. On startup, `syncFavoriteFlags()` is called once to attach `isFavorite`/`favoriteRank`
13
13
  * metadata to every result row based on the persisted favorites list.
14
- * 2. When the user presses F, `toggleFavoriteModel()` mutates the config array and persists
15
- * immediately via `saveConfig()`.
14
+ * 2. When the user presses F, `toggleFavoriteModel()` reloads the latest config snapshot,
15
+ * applies the toggle there, then persists atomically so stale state cannot wipe favorites.
16
16
  * 3. The renderer reads `r.isFavorite` and `r.favoriteRank` from the row to decide whether
17
17
  * to show the ⭐ prefix and how to sort the row relative to non-favorites.
18
18
  *
@@ -25,11 +25,11 @@
25
25
  * @exports
26
26
  * ensureFavoritesConfig, toFavoriteKey, syncFavoriteFlags, toggleFavoriteModel
27
27
  *
28
- * @see src/config.js — saveConfig used here to persist changes immediately
28
+ * @see src/config.js — load/save helpers keep favorite persistence atomic and merge-safe
29
29
  * @see bin/free-coding-models.js — calls syncFavoriteFlags on startup and toggleFavoriteModel on F key
30
30
  */
31
31
 
32
- import { saveConfig } from './config.js'
32
+ import { loadConfig, saveConfig, replaceConfigContents } from './config.js'
33
33
 
34
34
  /**
35
35
  * 📖 Ensure favorites config shape exists and remains clean.
@@ -84,15 +84,36 @@ export function syncFavoriteFlags(results, config) {
84
84
  * @returns {boolean}
85
85
  */
86
86
  export function toggleFavoriteModel(config, providerKey, modelId) {
87
- ensureFavoritesConfig(config)
87
+ const latestConfig = loadConfig()
88
+ latestConfig.activeProfile = typeof config?.activeProfile === 'string' && config.activeProfile.trim()
89
+ ? config.activeProfile.trim()
90
+ : latestConfig.activeProfile
91
+ ensureFavoritesConfig(latestConfig)
92
+ if (latestConfig.activeProfile && !latestConfig.profiles?.[latestConfig.activeProfile] && config?.profiles?.[latestConfig.activeProfile]) {
93
+ latestConfig.profiles[latestConfig.activeProfile] = JSON.parse(JSON.stringify(config.profiles[latestConfig.activeProfile]))
94
+ }
88
95
  const favoriteKey = toFavoriteKey(providerKey, modelId)
89
- const existingIndex = config.favorites.indexOf(favoriteKey)
96
+ const existingIndex = latestConfig.favorites.indexOf(favoriteKey)
90
97
  if (existingIndex >= 0) {
91
- config.favorites.splice(existingIndex, 1)
92
- saveConfig(config)
98
+ latestConfig.favorites.splice(existingIndex, 1)
99
+ if (latestConfig.activeProfile && latestConfig.profiles?.[latestConfig.activeProfile]) {
100
+ latestConfig.profiles[latestConfig.activeProfile].favorites = [...latestConfig.favorites]
101
+ }
102
+ const saveResult = saveConfig(latestConfig, {
103
+ replaceFavorites: true,
104
+ replaceProfileNames: latestConfig.activeProfile ? [latestConfig.activeProfile] : [],
105
+ })
106
+ if (saveResult.success) replaceConfigContents(config, latestConfig)
93
107
  return false
94
108
  }
95
- config.favorites.push(favoriteKey)
96
- saveConfig(config)
109
+ latestConfig.favorites.push(favoriteKey)
110
+ if (latestConfig.activeProfile && latestConfig.profiles?.[latestConfig.activeProfile]) {
111
+ latestConfig.profiles[latestConfig.activeProfile].favorites = [...latestConfig.favorites]
112
+ }
113
+ const saveResult = saveConfig(latestConfig, {
114
+ replaceFavorites: true,
115
+ replaceProfileNames: latestConfig.activeProfile ? [latestConfig.activeProfile] : [],
116
+ })
117
+ if (saveResult.success) replaceConfigContents(config, latestConfig)
97
118
  return true
98
119
  }