free-coding-models 0.2.4 → 0.2.8

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
@@ -102,7 +102,7 @@
102
102
  * → getProxySettings(config) — Return normalized proxy settings from config
103
103
  * → normalizeEndpointInstalls(endpointInstalls) — Keep tracked endpoint installs stable across app versions
104
104
  *
105
- * @exports loadConfig, saveConfig, getApiKey, isProviderEnabled
105
+ * @exports loadConfig, saveConfig, validateConfigFile, getApiKey, isProviderEnabled
106
106
  * @exports addApiKey, removeApiKey, listApiKeys — multi-key management helpers
107
107
  * @exports saveAsProfile, loadProfile, listProfiles, deleteProfile
108
108
  * @exports getActiveProfileName, setActiveProfile, getProxySettings, normalizeEndpointInstalls
@@ -112,9 +112,9 @@
112
112
  * @see sources.js — provider keys come from Object.keys(sources)
113
113
  */
114
114
 
115
- import { readFileSync, writeFileSync, existsSync } from 'fs'
116
- import { homedir } from 'os'
117
- import { join } from 'path'
115
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
116
+ import { homedir } from 'node:os'
117
+ import { join } from 'node:path'
118
118
 
119
119
  // 📖 New JSON config path — stores all providers' API keys + enabled state
120
120
  export const CONFIG_PATH = join(homedir(), '.free-coding-models.json')
@@ -155,14 +155,26 @@ const ENV_VARS = {
155
155
  * 2. If missing, check if ~/.free-coding-models (old plain-text) exists → migrate
156
156
  * 3. If neither, return an empty default config
157
157
  *
158
- * 📖 The migration reads the old file as a plain nvidia API key and writes
159
- * a proper JSON config. The old file is NOT deleted (safety first).
158
+ * 📖 Now includes automatic validation and repair from backups if config is corrupted.
160
159
  *
161
160
  * @returns {{ apiKeys: Record<string,string>, providers: Record<string,{enabled:boolean}>, favorites: string[], telemetry: { enabled: boolean | null, consentVersion: number, anonymousId: string | null } }}
162
161
  */
163
162
  export function loadConfig() {
164
163
  // 📖 Try new JSON config first
165
164
  if (existsSync(CONFIG_PATH)) {
165
+ // 📖 Validate the config file first, try auto-repair if corrupted
166
+ const validation = validateConfigFile({ autoRepair: true })
167
+
168
+ if (!validation.valid && !validation.repaired) {
169
+ // 📖 Config is corrupted and repair failed - warn user but continue with empty config
170
+ console.error(`⚠️ Warning: Config file is corrupted and could not be repaired: ${validation.error}`)
171
+ console.error('⚠️ Starting with fresh config. Your backups are in ~/.free-coding-models.backups/')
172
+ }
173
+
174
+ if (validation.repaired) {
175
+ console.log('✅ Config file was corrupted but has been restored from backup.')
176
+ }
177
+
166
178
  try {
167
179
  const raw = readFileSync(CONFIG_PATH, 'utf8').trim()
168
180
  const parsed = JSON.parse(raw)
@@ -204,7 +216,10 @@ export function loadConfig() {
204
216
  const config = _emptyConfig()
205
217
  config.apiKeys.nvidia = oldKey
206
218
  // 📖 Auto-save migrated config so next launch is fast
207
- saveConfig(config)
219
+ const result = saveConfig(config)
220
+ if (!result.success) {
221
+ console.error(`⚠️ Warning: Failed to save migrated config: ${result.error}`)
222
+ }
208
223
  return config
209
224
  }
210
225
  } catch {
@@ -220,14 +235,229 @@ export function loadConfig() {
220
235
  *
221
236
  * 📖 Uses mode 0o600 so the file is only readable by the owning user (API keys!).
222
237
  * 📖 Pretty-prints JSON for human readability.
238
+ * 📖 Now includes:
239
+ * - Automatic backup before overwriting (keeps last 5 versions)
240
+ * - Verification that write succeeded
241
+ * - Explicit error handling (no silent failures)
242
+ * - Post-write validation to ensure file is valid JSON
223
243
  *
224
244
  * @param {{ apiKeys: Record<string,string>, providers: Record<string,{enabled:boolean}>, favorites?: string[], telemetry?: { enabled?: boolean | null, consentVersion?: number, anonymousId?: string | null } }} config
245
+ * @returns {{ success: boolean, error?: string, backupCreated?: boolean }}
225
246
  */
226
247
  export function saveConfig(config) {
248
+ // 📖 Create backup of existing config before overwriting
249
+ const backupCreated = createBackup()
250
+
251
+ try {
252
+ // 📖 Write the new config
253
+ const json = JSON.stringify(config, null, 2)
254
+ writeFileSync(CONFIG_PATH, json, { mode: 0o600 })
255
+
256
+ // 📖 Verify the write succeeded by reading back and validating
257
+ try {
258
+ const written = readFileSync(CONFIG_PATH, 'utf8')
259
+ const parsed = JSON.parse(written)
260
+
261
+ // 📖 Basic sanity check - ensure apiKeys object exists
262
+ if (!parsed || typeof parsed !== 'object') {
263
+ throw new Error('Written config is not a valid object')
264
+ }
265
+
266
+ // 📖 Verify critical data wasn't lost - check ALL keys are preserved
267
+ if (config.apiKeys && Object.keys(config.apiKeys).length > 0) {
268
+ if (!parsed.apiKeys) {
269
+ throw new Error('apiKeys object missing after write')
270
+ }
271
+ const originalKeys = Object.keys(config.apiKeys).sort()
272
+ const writtenKeys = Object.keys(parsed.apiKeys).sort()
273
+ if (originalKeys.length > writtenKeys.length) {
274
+ const lostKeys = originalKeys.filter(k => !writtenKeys.includes(k))
275
+ throw new Error(`API keys lost during write: ${lostKeys.join(', ')}`)
276
+ }
277
+ // 📖 Also verify each key's value is not empty
278
+ for (const key of originalKeys) {
279
+ if (!parsed.apiKeys[key] || parsed.apiKeys[key].length === 0) {
280
+ throw new Error(`API key for ${key} is empty after write`)
281
+ }
282
+ }
283
+ }
284
+
285
+ return { success: true, backupCreated }
286
+ } catch (verifyError) {
287
+ // 📖 Verification failed - this is critical!
288
+ const errorMsg = `Config verification failed: ${verifyError.message}`
289
+
290
+ // 📖 Try to restore from backup if we have one
291
+ if (backupCreated) {
292
+ try {
293
+ restoreFromBackup()
294
+ errorMsg += ' (Restored from backup)'
295
+ } catch (restoreError) {
296
+ errorMsg += ` (Backup restoration failed: ${restoreError.message})`
297
+ }
298
+ }
299
+
300
+ return { success: false, error: errorMsg, backupCreated }
301
+ }
302
+ } catch (writeError) {
303
+ // 📖 Write failed - explicit error instead of silent failure
304
+ const errorMsg = `Failed to write config: ${writeError.message}`
305
+
306
+ // 📖 Try to restore from backup if we have one
307
+ if (backupCreated) {
308
+ try {
309
+ restoreFromBackup()
310
+ errorMsg += ' (Restored from backup)'
311
+ } catch (restoreError) {
312
+ errorMsg += ` (Backup restoration failed: ${restoreError.message})`
313
+ }
314
+ }
315
+
316
+ return { success: false, error: errorMsg, backupCreated }
317
+ }
318
+ }
319
+
320
+ /**
321
+ * 📖 createBackup: Creates a timestamped backup of the current config file.
322
+ * 📖 Keeps only the 5 most recent backups to avoid disk space issues.
323
+ * 📖 Backup files are stored in ~/.free-coding-models.backups/
324
+ *
325
+ * @returns {boolean} true if backup was created, false otherwise
326
+ */
327
+ function createBackup() {
328
+ try {
329
+ if (!existsSync(CONFIG_PATH)) {
330
+ return false // No file to backup
331
+ }
332
+
333
+ // 📖 Create backup directory if it doesn't exist
334
+ const backupDir = join(homedir(), '.free-coding-models.backups')
335
+ if (!existsSync(backupDir)) {
336
+ mkdirSync(backupDir, { mode: 0o700, recursive: true })
337
+ }
338
+
339
+ // 📖 Create timestamped backup
340
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, -5) + 'Z'
341
+ const backupPath = join(backupDir, `config.${timestamp}.json`)
342
+ const backupContent = readFileSync(CONFIG_PATH, 'utf8')
343
+ writeFileSync(backupPath, backupContent, { mode: 0o600 })
344
+
345
+ // 📖 Clean up old backups (keep only 5 most recent)
346
+ const backups = readdirSync(backupDir)
347
+ .filter(f => f.startsWith('config.') && f.endsWith('.json'))
348
+ .map(f => ({
349
+ name: f,
350
+ path: join(backupDir, f),
351
+ time: statSync(join(backupDir, f)).mtime.getTime()
352
+ }))
353
+ .sort((a, b) => b.time - a.time)
354
+
355
+ // 📖 Delete old backups beyond the 5 most recent
356
+ if (backups.length > 5) {
357
+ for (const oldBackup of backups.slice(5)) {
358
+ try {
359
+ unlinkSync(oldBackup.path)
360
+ } catch {
361
+ // Ignore cleanup errors
362
+ }
363
+ }
364
+ }
365
+
366
+ return true
367
+ } catch (error) {
368
+ // 📖 Log but don't fail if backup creation fails
369
+ console.error(`Warning: Backup creation failed: ${error.message}`)
370
+ return false
371
+ }
372
+ }
373
+
374
+ /**
375
+ * 📖 restoreFromBackup: Restores the most recent backup.
376
+ * 📖 Used when config write or verification fails.
377
+ *
378
+ * @throws {Error} if no backup exists or restoration fails
379
+ */
380
+ function restoreFromBackup() {
381
+ const backupDir = join(homedir(), '.free-coding-models.backups')
382
+
383
+ if (!existsSync(backupDir)) {
384
+ throw new Error('No backup directory found')
385
+ }
386
+
387
+ // 📖 Find the most recent backup
388
+ const backups = readdirSync(backupDir)
389
+ .filter(f => f.startsWith('config.') && f.endsWith('.json'))
390
+ .map(f => ({
391
+ name: f,
392
+ path: join(backupDir, f),
393
+ time: statSync(join(backupDir, f)).mtime.getTime()
394
+ }))
395
+ .sort((a, b) => b.time - a.time)
396
+
397
+ if (backups.length === 0) {
398
+ throw new Error('No backups available')
399
+ }
400
+
401
+ const latestBackup = backups[0]
402
+ const backupContent = readFileSync(latestBackup.path, 'utf8')
403
+
404
+ // 📖 Verify backup is valid JSON before restoring
405
+ JSON.parse(backupContent)
406
+
407
+ // 📖 Restore the backup
408
+ writeFileSync(CONFIG_PATH, backupContent, { mode: 0o600 })
409
+ }
410
+
411
+ /**
412
+ * 📖 validateConfigFile: Checks if the config file is valid JSON.
413
+ * 📖 Returns validation result and can auto-repair from backups if needed.
414
+ *
415
+ * @param {{ autoRepair?: boolean }} options - If true, attempts to repair using backups
416
+ * @returns {{ valid: boolean, error?: string, repaired?: boolean }}
417
+ */
418
+ export function validateConfigFile(options = {}) {
419
+ const { autoRepair = false } = options
420
+
227
421
  try {
228
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 })
229
- } catch {
230
- // 📖 Silently fail — the app is still usable, keys just won't persist
422
+ if (!existsSync(CONFIG_PATH)) {
423
+ return { valid: true } // No config file is valid (will be created)
424
+ }
425
+
426
+ const content = readFileSync(CONFIG_PATH, 'utf8')
427
+
428
+ // 📖 Check if file is empty
429
+ if (!content.trim()) {
430
+ throw new Error('Config file is empty')
431
+ }
432
+
433
+ // 📖 Try to parse JSON
434
+ const parsed = JSON.parse(content)
435
+
436
+ // 📖 Basic structure validation
437
+ if (!parsed || typeof parsed !== 'object') {
438
+ throw new Error('Config is not a valid object')
439
+ }
440
+
441
+ // 📖 Check for critical corruption (apiKeys should be an object if it exists)
442
+ if (parsed.apiKeys !== null && parsed.apiKeys !== undefined && typeof parsed.apiKeys !== 'object') {
443
+ throw new Error('apiKeys field is corrupted')
444
+ }
445
+
446
+ return { valid: true }
447
+ } catch (error) {
448
+ const errorMsg = `Config validation failed: ${error.message}`
449
+
450
+ // 📖 Attempt auto-repair from backup if requested
451
+ if (autoRepair) {
452
+ try {
453
+ restoreFromBackup()
454
+ return { valid: false, error: errorMsg, repaired: true }
455
+ } catch (repairError) {
456
+ return { valid: false, error: `${errorMsg} (Repair failed: ${repairError.message})`, repaired: false }
457
+ }
458
+ }
459
+
460
+ return { valid: false, error: errorMsg, repaired: false }
231
461
  }
232
462
  }
233
463
 
@@ -524,7 +754,13 @@ export function loadProfile(config, name) {
524
754
  const nextSettings = profile.settings ? { ..._emptyProfileSettings(), ...profile.settings, proxy: normalizeProxySettings(profile.settings.proxy) } : _emptyProfileSettings()
525
755
 
526
756
  // 📖 Deep-copy the profile data into the live config (don't share references)
527
- config.apiKeys = JSON.parse(JSON.stringify(profile.apiKeys || {}))
757
+ // 📖 IMPORTANT: MERGE apiKeys instead of replacing to preserve keys not in profile
758
+ // 📖 Profile keys take priority over existing keys (allows profile-specific overrides)
759
+ const profileApiKeys = profile.apiKeys || {}
760
+ const mergedApiKeys = { ...config.apiKeys || {}, ...profileApiKeys }
761
+ config.apiKeys = JSON.parse(JSON.stringify(mergedApiKeys))
762
+
763
+ // 📖 For providers, favorites: replace with profile values (these are profile-specific settings)
528
764
  config.providers = JSON.parse(JSON.stringify(profile.providers || {}))
529
765
  config.favorites = [...(profile.favorites || [])]
530
766
  config.settings = nextSettings
package/src/constants.js CHANGED
@@ -74,10 +74,10 @@ export const TIER_CYCLE = [null, 'S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
74
74
 
75
75
  // 📖 Overlay background chalk functions — each overlay panel has a distinct tint
76
76
  // 📖 so users can tell Settings, Help, Recommend, and Log panels apart at a glance.
77
- export const SETTINGS_OVERLAY_BG = chalk.bgRgb(14, 20, 30)
78
- export const HELP_OVERLAY_BG = chalk.bgRgb(24, 16, 32)
79
- export const RECOMMEND_OVERLAY_BG = chalk.bgRgb(10, 25, 15) // 📖 Green tint for Smart Recommend
80
- export const LOG_OVERLAY_BG = chalk.bgRgb(10, 20, 26) // 📖 Dark blue-green tint for Log page
77
+ export const SETTINGS_OVERLAY_BG = chalk.bgRgb(0, 0, 0)
78
+ export const HELP_OVERLAY_BG = chalk.bgRgb(0, 0, 0)
79
+ export const RECOMMEND_OVERLAY_BG = chalk.bgRgb(0, 0, 0) // 📖 Green tint for Smart Recommend
80
+ export const LOG_OVERLAY_BG = chalk.bgRgb(0, 0, 0) // 📖 Dark blue-green tint for Log page
81
81
 
82
82
  // 📖 OVERLAY_PANEL_WIDTH: fixed character width of all overlay panels so background
83
83
  // 📖 tint fills the panel consistently regardless of content length.
@@ -750,6 +750,12 @@ export function createKeyHandler(ctx) {
750
750
  state.logVisible = false
751
751
  return
752
752
  }
753
+ // 📖 A key: toggle between showing all logs and limited to 500
754
+ if (key.name === 'a') {
755
+ state.logShowAll = !state.logShowAll
756
+ state.logScrollOffset = 0
757
+ return
758
+ }
753
759
  if (key.name === 'up') { state.logScrollOffset = Math.max(0, state.logScrollOffset - 1); return }
754
760
  if (key.name === 'down') { state.logScrollOffset += 1; return }
755
761
  if (key.name === 'pageup') { state.logScrollOffset = Math.max(0, state.logScrollOffset - pageStep); return }
package/src/openclaw.js CHANGED
@@ -23,6 +23,8 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from
23
23
  import { homedir } from 'os'
24
24
  import { join } from 'path'
25
25
  import { patchOpenClawModelsJson } from '../patch-openclaw-models.js'
26
+ import { sources } from '../sources.js'
27
+ import { PROVIDER_COLOR } from './render-table.js'
26
28
 
27
29
  // 📖 OpenClaw config: ~/.openclaw/openclaw.json (JSON format, may be JSON5 in newer versions)
28
30
  const OPENCLAW_CONFIG = join(homedir(), '.openclaw', 'openclaw.json')
@@ -84,7 +86,10 @@ export async function startOpenClaw(model, apiKey) {
84
86
  api: 'openai-completions',
85
87
  models: [],
86
88
  }
87
- console.log(chalk.dim(' ➕ Added nvidia provider block to OpenClaw config (models.providers.nvidia)'))
89
+ // 📖 Color provider name the same way as in the main table
90
+ const providerRgb = PROVIDER_COLOR['nvidia'] ?? [105, 190, 245]
91
+ const coloredProviderName = chalk.bold.rgb(...providerRgb)('nvidia')
92
+ console.log(chalk.dim(` ➕ Added ${coloredProviderName} provider block to OpenClaw config (models.providers.nvidia)`))
88
93
  }
89
94
  // 📖 Ensure models array exists even if the provider block was created by an older version
90
95
  if (!Array.isArray(config.models.providers.nvidia.models)) {
package/src/opencode.js CHANGED
@@ -38,6 +38,7 @@ import { homedir } from 'os'
38
38
  import { join } from 'path'
39
39
  import { copyFileSync, existsSync } from 'fs'
40
40
  import { sources } from '../sources.js'
41
+ import { PROVIDER_COLOR } from './render-table.js'
41
42
  import { resolveCloudflareUrl } from './ping.js'
42
43
  import { ProxyServer } from './proxy-server.js'
43
44
  import { loadOpenCodeConfig, saveOpenCodeConfig, syncToOpenCode } from './opencode-sync.js'
@@ -268,7 +269,10 @@ export async function startOpenCode(model, fcmConfig) {
268
269
  },
269
270
  models: {}
270
271
  }
271
- console.log(chalk.green(' + Auto-configured NVIDIA NIM provider in OpenCode'))
272
+ // 📖 Color provider name the same way as in the main table
273
+ const providerRgb = PROVIDER_COLOR['nvidia'] ?? [105, 190, 245]
274
+ const coloredNimName = chalk.bold.rgb(...providerRgb)('NVIDIA NIM')
275
+ console.log(chalk.green(` + Auto-configured ${coloredNimName} provider in OpenCode`))
272
276
  }
273
277
 
274
278
  console.log(chalk.green(` Setting ${chalk.bold(model.label)} as default...`))
@@ -780,7 +784,10 @@ export async function startOpenCodeDesktop(model, fcmConfig) {
780
784
  },
781
785
  models: {}
782
786
  }
783
- console.log(chalk.green(' + Auto-configured NVIDIA NIM provider in OpenCode'))
787
+ // 📖 Color provider name the same way as in the main table
788
+ const providerRgb = PROVIDER_COLOR['nvidia'] ?? [105, 190, 245]
789
+ const coloredNimName = chalk.bold.rgb(...providerRgb)('NVIDIA NIM')
790
+ console.log(chalk.green(` + Auto-configured ${coloredNimName} provider in OpenCode`))
784
791
  }
785
792
 
786
793
  console.log(chalk.green(` Setting ${chalk.bold(model.label)} as default for OpenCode Desktop...`))