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.
@@ -0,0 +1,210 @@
1
+ /**
2
+ * @file security.js
3
+ * @description Security checks and auto-fix for config file permissions.
4
+ *
5
+ * 📖 Problem: API keys stored in ~/.free-coding-models.json must be protected.
6
+ * If the file has incorrect permissions (e.g., 644 = world-readable), keys can leak.
7
+ *
8
+ * 📖 This module:
9
+ * - Checks config file permissions on startup
10
+ * - Warns user if permissions are too open
11
+ * - Offers auto-fix option with user confirmation
12
+ * - Fixes permissions securely (chmod 600 = user read/write only)
13
+ *
14
+ * 📖 Secure permissions:
15
+ * - 0o600 (octal 600) = user:rw, group:---, world:---
16
+ * - Only the file owner can read or write
17
+ * - This is the standard for files containing secrets (SSH keys, API keys, etc.)
18
+ *
19
+ * 📖 Why this matters:
20
+ * - Shared systems: Other users could read your API keys
21
+ * - Git accidents: File could be committed with wrong permissions
22
+ * - Backup tools: Might copy files with permissions intact
23
+ *
24
+ * @functions
25
+ * → checkConfigSecurity() — Main security check, prompts for auto-fix if needed
26
+ * → getConfigPermissions() — Returns file mode object for config
27
+ * → isConfigSecure() — Boolean check if permissions are correct
28
+ * → fixConfigPermissions() — Applies chmod 600 to config file
29
+ * → promptSecurityFix() — Interactive prompt asking user to fix permissions
30
+ *
31
+ * @exports checkConfigSecurity, isConfigSecure, fixConfigPermissions
32
+ */
33
+
34
+ import fs from 'node:fs'
35
+ import path from 'node:path'
36
+ import os from 'node:os'
37
+ import readline from 'node:readline'
38
+
39
+ // 📖 Config file path — matches the path used in config.js
40
+ function getConfigPath() {
41
+ return path.join(os.homedir(), '.free-coding-models.json')
42
+ }
43
+
44
+ // 📖 Secure file permissions: user read/write only (0o600 = 384 in decimal)
45
+ // 📖 This means: owner can read+write, group and others have no permissions
46
+ const SECURE_MODE = 0o600
47
+
48
+ // 📖 Get file stats including permissions for the config file
49
+ // 📖 Returns null if file doesn't exist
50
+ function getConfigPermissions() {
51
+ const configPath = getConfigPath()
52
+
53
+ try {
54
+ if (!fs.existsSync(configPath)) {
55
+ return null
56
+ }
57
+
58
+ const stats = fs.statSync(configPath)
59
+ return {
60
+ mode: stats.mode,
61
+ isSecure: (stats.mode & 0o777) === SECURE_MODE,
62
+ path: configPath
63
+ }
64
+ } catch (err) {
65
+ return null
66
+ }
67
+ }
68
+
69
+ // 📖 Check if config file has secure permissions
70
+ // 📖 Returns true if file doesn't exist (nothing to secure) or if permissions are correct
71
+ export function isConfigSecure() {
72
+ const perms = getConfigPermissions()
73
+
74
+ // 📖 No file = nothing to secure
75
+ if (!perms) return true
76
+
77
+ return perms.isSecure
78
+ }
79
+
80
+ // 📖 Fix config file permissions to secure mode (chmod 600)
81
+ // 📖 Returns true if successful, false otherwise
82
+ export function fixConfigPermissions() {
83
+ const configPath = getConfigPath()
84
+
85
+ try {
86
+ if (!fs.existsSync(configPath)) {
87
+ return false
88
+ }
89
+
90
+ fs.chmodSync(configPath, SECURE_MODE)
91
+ return true
92
+ } catch (err) {
93
+ return false
94
+ }
95
+ }
96
+
97
+ // 📖 Format permission mode in octal (e.g., 0o644 → "644")
98
+ function formatMode(mode) {
99
+ return (mode & 0o777).toString(8).padStart(3, '0')
100
+ }
101
+
102
+ // 📖 Format permission mode in human-readable rwx format (e.g., 0o644 → "rw-r--r--")
103
+ function formatModeRwx(mode) {
104
+ const perms = []
105
+ const types = ['r', 'w', 'x']
106
+
107
+ for (let i = 6; i >= 0; i -= 3) {
108
+ for (let j = 0; j < 3; j++) {
109
+ if (mode & (1 << (i + j))) {
110
+ perms.push(types[j])
111
+ } else {
112
+ perms.push('-')
113
+ }
114
+ }
115
+ }
116
+
117
+ return [
118
+ perms.slice(0, 3).join(''), // Owner permissions
119
+ perms.slice(3, 6).join(''), // Group permissions
120
+ perms.slice(6, 9).join('') // Others permissions
121
+ ].join(' / ')
122
+ }
123
+
124
+ // 📖 Check security and prompt for auto-fix if needed
125
+ // 📖 Call this on startup before loading config
126
+ // 📖 Returns: { wasSecure: boolean, wasFixed: boolean, error?: string }
127
+ export function checkConfigSecurity() {
128
+ const perms = getConfigPermissions()
129
+
130
+ // 📖 No file yet = nothing to check
131
+ if (!perms) {
132
+ return { wasSecure: true, wasFixed: false }
133
+ }
134
+
135
+ // 📖 Permissions are already secure
136
+ if (perms.isSecure) {
137
+ return { wasSecure: true, wasFixed: false }
138
+ }
139
+
140
+ // 📖 Security issue detected! Show warning and offer fix.
141
+ const currentMode = formatMode(perms.mode)
142
+ const currentRwx = formatModeRwx(perms.mode)
143
+
144
+ console.error('')
145
+ console.error('⚠️ SECURITY WARNING ⚠️')
146
+ console.error('')
147
+ console.error(`Your config file has insecure permissions: ${currentMode} (${currentRwx})`)
148
+ console.error(`File: ${perms.path}`)
149
+ console.error('')
150
+ console.error('This means other users on this system may be able to read your API keys.')
151
+ console.error('')
152
+ console.error('Recommended: Fix permissions to 600 (rw-------) — owner read/write only')
153
+
154
+ return promptSecurityFix()
155
+ }
156
+
157
+ // 📖 Interactive prompt asking user if they want to auto-fix
158
+ // 📖 Returns: { wasSecure: boolean, wasFixed: boolean, error?: string }
159
+ async function promptSecurityFix() {
160
+ const rl = readline.createInterface({
161
+ input: process.stdin,
162
+ output: process.stdout
163
+ })
164
+
165
+ try {
166
+ const answer = await new Promise((resolve) => {
167
+ rl.question('Fix permissions automatically? (Y/n): ', resolve)
168
+ })
169
+
170
+ rl.close()
171
+
172
+ // 📖 Default to yes if user just presses Enter
173
+ if (answer.toLowerCase() === 'y' || answer === '') {
174
+ const success = fixConfigPermissions()
175
+
176
+ if (success) {
177
+ console.error('')
178
+ console.error('✅ Permissions fixed! Your API keys are now secure.')
179
+ console.error('')
180
+ return { wasSecure: false, wasFixed: true }
181
+ } else {
182
+ console.error('')
183
+ console.error('❌ Failed to fix permissions automatically.')
184
+ console.error('')
185
+ console.error('Run this command manually:')
186
+ console.error(` chmod 600 ${getConfigPath()}`)
187
+ console.error('')
188
+ return { wasSecure: false, wasFixed: false, error: 'chmod_failed' }
189
+ }
190
+ } else {
191
+ console.error('')
192
+ console.error('⚠️ Permissions not fixed. Your API keys may be at risk.')
193
+ console.error('')
194
+ console.error('To fix later, run:')
195
+ console.error(` chmod 600 ${getConfigPath()}`)
196
+ console.error('')
197
+ return { wasSecure: false, wasFixed: false, error: 'user_declined' }
198
+ }
199
+ } catch (err) {
200
+ rl.close()
201
+ // 📖 If we can't prompt (e.g., non-interactive TTY), just warn and continue
202
+ console.error('')
203
+ console.error('⚠️ Unable to prompt for permission fix (non-interactive terminal?)')
204
+ console.error('')
205
+ console.error('To fix manually, run:')
206
+ console.error(` chmod 600 ${getConfigPath()}`)
207
+ console.error('')
208
+ return { wasSecure: false, wasFixed: false, error: 'no_tty' }
209
+ }
210
+ }
@@ -32,6 +32,7 @@ import { homedir } from 'os'
32
32
  import { dirname, join } from 'path'
33
33
  import { spawn } from 'child_process'
34
34
  import { sources } from '../sources.js'
35
+ import { PROVIDER_COLOR } from './render-table.js'
35
36
  import { getApiKey, getProxySettings } from './config.js'
36
37
  import { ENV_VAR_NAMES, isWindows } from './provider-metadata.js'
37
38
  import { getToolMeta } from './tool-metadata.js'
@@ -262,7 +263,11 @@ export async function startExternalTool(mode, model, config) {
262
263
  const proxySettings = getProxySettings(config)
263
264
 
264
265
  if (!apiKey && mode !== 'amp') {
265
- console.log(chalk.yellow(` ⚠ No API key configured for ${model.providerKey}.`))
266
+ // 📖 Color provider name the same way as in the main table
267
+ const providerRgb = PROVIDER_COLOR[model.providerKey] ?? [105, 190, 245]
268
+ const providerName = sources[model.providerKey]?.name || model.providerKey
269
+ const coloredProviderName = chalk.bold.rgb(...providerRgb)(providerName)
270
+ console.log(chalk.yellow(` ⚠ No API key configured for ${coloredProviderName}.`))
266
271
  console.log(chalk.dim(' Configure the provider first from the Settings screen (P) or via env vars.'))
267
272
  console.log()
268
273
  return 1
package/src/utils.js CHANGED
@@ -389,13 +389,13 @@ export function findBestModel(results) {
389
389
  // - API key: first positional arg that doesn't start with "--" (e.g., "nvapi-xxx")
390
390
  // - Boolean flags: --best, --fiable, --opencode, --opencode-desktop, --openclaw,
391
391
  // --aider, --crush, --goose, --claude-code, --codex, --gemini, --qwen,
392
- // --openhands, --amp, --pi, --no-telemetry (case-insensitive)
392
+ // --openhands, --amp, --pi, --no-telemetry, --json (case-insensitive)
393
393
  // - Value flag: --tier <letter> (the next non-flag arg is the tier value)
394
394
  //
395
395
  // 📖 Returns:
396
396
  // { apiKey, bestMode, fiableMode, openCodeMode, openCodeDesktopMode, openClawMode,
397
397
  // aiderMode, crushMode, gooseMode, claudeCodeMode, codexMode, geminiMode,
398
- // qwenMode, openHandsMode, ampMode, piMode, noTelemetry, tierFilter }
398
+ // qwenMode, openHandsMode, ampMode, piMode, noTelemetry, jsonMode, tierFilter }
399
399
  //
400
400
  // 📖 Note: apiKey may be null here — the main CLI falls back to env vars and saved config.
401
401
  export function parseArgs(argv) {
@@ -446,6 +446,7 @@ export function parseArgs(argv) {
446
446
  const piMode = flags.includes('--pi')
447
447
  const noTelemetry = flags.includes('--no-telemetry')
448
448
  const cleanProxyMode = flags.includes('--clean-proxy') || flags.includes('--proxy-clean')
449
+ const jsonMode = flags.includes('--json')
449
450
 
450
451
  let tierFilter = tierValueIdx !== -1 ? args[tierValueIdx].toUpperCase() : null
451
452
 
@@ -473,6 +474,7 @@ export function parseArgs(argv) {
473
474
  piMode,
474
475
  noTelemetry,
475
476
  cleanProxyMode,
477
+ jsonMode,
476
478
  tierFilter,
477
479
  profileName,
478
480
  recommendMode
@@ -728,3 +730,66 @@ export function getVersionStatusInfo(updateState, latestVersion) {
728
730
  latestVersion: null,
729
731
  }
730
732
  }
733
+
734
+ /**
735
+ * 📖 formatResultsAsJSON converts model results to clean JSON output for scripting/automation.
736
+ *
737
+ * 📖 This is used by the --json flag to output results in a machine-readable format.
738
+ * 📖 The output is designed to be:
739
+ * - Easy to parse with jq, grep, awk, or any JSON library
740
+ * - Human-readable for debugging
741
+ * - Stable (field names won't change between versions)
742
+ *
743
+ * 📖 Output format:
744
+ * [
745
+ * {
746
+ * "rank": 1,
747
+ * "modelId": "nvidia/deepseek-ai/deepseek-v3.2",
748
+ * "label": "DeepSeek V3.2",
749
+ * "provider": "nvidia",
750
+ * "tier": "S+",
751
+ * "sweScore": "73.1%",
752
+ * "context": "128k",
753
+ * "latestPing": 245,
754
+ * "avgPing": 260,
755
+ * "p95": 312,
756
+ * "jitter": 45,
757
+ * "stability": 87,
758
+ * "uptime": 95.5,
759
+ * "verdict": "Perfect",
760
+ * "status": "up"
761
+ * },
762
+ * ...
763
+ * ]
764
+ *
765
+ * 📖 Note: NaN and Infinity values are converted to null for cleaner JSON.
766
+ *
767
+ * @param {Array} results — Model result objects from the TUI
768
+ * @param {string} sortBy — Current sort column (for rank calculation)
769
+ * @param {number} limit — Maximum number of results to return (0 = all)
770
+ * @returns {string} JSON string of formatted results
771
+ */
772
+ export function formatResultsAsJSON(results, sortBy = 'avg', limit = 0) {
773
+ const formatted = results
774
+ .map((r, idx) => ({
775
+ rank: r.idx || idx + 1,
776
+ modelId: r.modelId || null,
777
+ label: r.label || null,
778
+ provider: r.providerKey || null,
779
+ tier: r.tier || null,
780
+ sweScore: r.sweScore || null,
781
+ context: r.ctx || null,
782
+ latestPing: (r.pings && r.pings.length > 0) ? r.pings[r.pings.length - 1].ms : null,
783
+ avgPing: (Number.isFinite(r.avg)) ? r.avg : null,
784
+ p95: (Number.isFinite(r.p95)) ? r.p95 : null,
785
+ jitter: (Number.isFinite(r.jitter)) ? r.jitter : null,
786
+ stability: (Number.isFinite(r.stability)) ? r.stability : null,
787
+ uptime: (Number.isFinite(r.uptime)) ? r.uptime : null,
788
+ verdict: r.verdict || null,
789
+ status: r.status || null,
790
+ httpCode: r.httpCode || null
791
+ }))
792
+ .slice(0, limit || undefined)
793
+
794
+ return JSON.stringify(formatted, null, 2)
795
+ }