free-coding-models 0.3.5 → 0.3.6
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/CHANGELOG.md +13 -0
- package/README.md +24 -1
- package/bin/free-coding-models.js +57 -61
- package/package.json +4 -2
- package/src/config.js +332 -37
- package/src/endpoint-installer.js +2 -2
- package/src/favorites.js +31 -10
- package/src/key-handler.js +45 -24
- package/src/testfcm.js +451 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
const json = JSON.stringify(
|
|
259
|
-
writeFileSync(
|
|
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
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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()`
|
|
15
|
-
*
|
|
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 —
|
|
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
|
-
|
|
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 =
|
|
96
|
+
const existingIndex = latestConfig.favorites.indexOf(favoriteKey)
|
|
90
97
|
if (existingIndex >= 0) {
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
}
|