free-coding-models 0.3.40 → 0.3.41
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 +5 -16
- package/README.md +7 -1
- package/package.json +1 -1
- package/src/app.js +27 -0
- package/src/config.js +7 -0
- package/src/key-handler.js +27 -1
- package/src/overlays.js +11 -1
- package/src/shell-env.js +393 -0
- package/src/tool-bootstrap.js +7 -0
- package/src/tool-launchers.js +30 -1
- package/src/tool-metadata.js +3 -0
- package/src/utils.js +2 -0
- package/web/server.js +68 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,21 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
---
|
|
3
3
|
|
|
4
|
-
## [0.3.
|
|
5
|
-
|
|
6
|
-
### Fixed
|
|
7
|
-
- **🔧 CI/CD** — Added `packageManager` field to `package.json` so `pnpm/action-setup@v4` can resolve the pnpm version
|
|
8
|
-
|
|
9
|
-
## [0.3.39] - 2026-04-09
|
|
10
|
-
|
|
11
|
-
### Fixed
|
|
12
|
-
- **🔧 CI/CD** — Switched GitHub Actions from `npm ci` to `pnpm install --frozen-lockfile` (release + security-audit workflows)
|
|
13
|
-
- Removed obsolete `package-lock.json` that was causing `EUSAGE` sync errors in CI
|
|
14
|
-
|
|
15
|
-
## [0.3.38] - 2026-04-09
|
|
4
|
+
## [0.3.41] - 2026-04-09
|
|
16
5
|
|
|
17
6
|
### Added
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
7
|
+
- Added support for Xcode Intelligence (Xcode 26.3+) via the `--xcode` flag and manual configuration flow. The tool provides clear setup instructions and launches Xcode directly.
|
|
8
|
+
- The `xcode` launcher mode has been registered in the tool metadata, enabling Xcode selection through the command palette, `Z` cycle, and CLI arguments.
|
|
9
|
+
- Users can now use Xcode Intelligence with any of the 230+ supported OpenAI-compatible API endpoints.
|
|
10
|
+
- Compatibility logic ensures Xcode mode accurately highlights supported models (compatible with all standard models).
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
<p align="center">
|
|
16
16
|
<strong>Find the fastest free coding model in seconds</strong><br>
|
|
17
|
-
<sub>Ping 238 models across 25 AI Free providers in real-time </sub><br
|
|
17
|
+
<sub>Ping 238 models across 25 AI Free providers in real-time </sub><br> <sub> Install Free API endpoints to your favorite AI coding tool: <br>📦 OpenCode, 🦞 OpenClaw, 💘 Crush, 🪿 Goose, 🛠 Aider, 🐉 Qwen Code, 🤲 OpenHands, ⚡ Amp, 🔮 Hermes, ▶️ Continue, 🧠 Cline, 🛠️ Xcode, π Pi, 🦘 Rovo or ♊ Gemini in one keystroke</sub>
|
|
18
18
|
</p>
|
|
19
19
|
|
|
20
20
|
|
|
@@ -159,6 +159,9 @@ free-coding-models --goose --tier S
|
|
|
159
159
|
# "I want NVIDIA's top models only"
|
|
160
160
|
free-coding-models --origin nvidia --tier S
|
|
161
161
|
|
|
162
|
+
# "I want the local web dashboard"
|
|
163
|
+
free-coding-models --web
|
|
164
|
+
|
|
162
165
|
# "Start with an elite-focused preset, then adjust filters live"
|
|
163
166
|
free-coding-models --premium
|
|
164
167
|
|
|
@@ -169,6 +172,8 @@ free-coding-models --tier S --json | jq -r '.[0].modelId'
|
|
|
169
172
|
free-coding-models --openclaw --origin groq
|
|
170
173
|
```
|
|
171
174
|
|
|
175
|
+
When launching the web dashboard, `free-coding-models` prefers `http://localhost:3333`. If that port is already used by another app, it now auto-picks the next free local port and prints the exact URL to open.
|
|
176
|
+
|
|
172
177
|
### Tool launcher flags
|
|
173
178
|
|
|
174
179
|
| Flag | Launches |
|
|
@@ -185,6 +190,7 @@ free-coding-models --openclaw --origin groq
|
|
|
185
190
|
| `--hermes` | 🔮 Hermes |
|
|
186
191
|
| `--continue` | ▶️ Continue CLI |
|
|
187
192
|
| `--cline` | 🧠 Cline |
|
|
193
|
+
| `--xcode` | 🛠️ Xcode Intelligence |
|
|
188
194
|
| `--pi` | π Pi |
|
|
189
195
|
| `--rovo` | 🦘 Rovo Dev CLI |
|
|
190
196
|
| `--gemini` | ♊ Gemini CLI |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "free-coding-models",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.41",
|
|
4
4
|
"description": "Find the fastest coding LLM models in seconds — ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nvidia",
|
package/src/app.js
CHANGED
|
@@ -114,6 +114,7 @@ import { parseTelemetryEnv, isTelemetryDebugEnabled, telemetryDebug, ensureTelem
|
|
|
114
114
|
import { ensureFavoritesConfig, toFavoriteKey, syncFavoriteFlags, toggleFavoriteModel } from '../src/favorites.js'
|
|
115
115
|
import { checkForUpdateDetailed, checkForUpdate, runUpdate, promptUpdateNotification, fetchLastReleaseDate } from './updater.js'
|
|
116
116
|
import { promptApiKey } from '../src/setup.js'
|
|
117
|
+
import { syncShellEnv, ensureShellRcSource, promptShellEnvMigration, removeShellEnv } from '../src/shell-env.js'
|
|
117
118
|
import { stripAnsi, maskApiKey, displayWidth, padEndDisplay, tintOverlayLines, keepOverlayTargetVisible, sliceOverlayLines, calculateViewport, sortResultsWithPinnedFavorites, adjustScrollOffset } from '../src/render-helpers.js'
|
|
118
119
|
import { renderTable, PROVIDER_COLOR } from '../src/render-table.js'
|
|
119
120
|
import { setOpenCodeModelData, startOpenCode, startOpenCodeDesktop } from '../src/opencode.js'
|
|
@@ -215,6 +216,31 @@ export async function runApp(cliArgs, config) {
|
|
|
215
216
|
console.log()
|
|
216
217
|
process.exit(1)
|
|
217
218
|
}
|
|
219
|
+
// 📖 New users get shell env enabled by default
|
|
220
|
+
if (config.settings.shellEnvEnabled === undefined) {
|
|
221
|
+
config.settings.shellEnvEnabled = true
|
|
222
|
+
saveConfig(config)
|
|
223
|
+
syncShellEnv(config)
|
|
224
|
+
ensureShellRcSource()
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 📖 Shell env migration popup for existing users who haven't been asked yet
|
|
229
|
+
// 📖 Only show when user has keys but shellEnvEnabled is still undefined (never prompted)
|
|
230
|
+
if (hasAnyKey && config.settings.shellEnvEnabled === undefined) {
|
|
231
|
+
const choice = await promptShellEnvMigration(config)
|
|
232
|
+
if (choice === 'enable') {
|
|
233
|
+
if (!config.settings) config.settings = {}
|
|
234
|
+
config.settings.shellEnvEnabled = true
|
|
235
|
+
saveConfig(config)
|
|
236
|
+
syncShellEnv(config)
|
|
237
|
+
ensureShellRcSource()
|
|
238
|
+
} else if (choice === 'never') {
|
|
239
|
+
if (!config.settings) config.settings = {}
|
|
240
|
+
config.settings.shellEnvEnabled = false
|
|
241
|
+
saveConfig(config)
|
|
242
|
+
}
|
|
243
|
+
// 📖 'skip' leaves shellEnvEnabled undefined — will prompt again next launch
|
|
218
244
|
}
|
|
219
245
|
|
|
220
246
|
// 📖 Default mode: use the last persisted launcher choice when valid,
|
|
@@ -236,6 +262,7 @@ export async function runApp(cliArgs, config) {
|
|
|
236
262
|
hermes: cliArgs.hermesMode,
|
|
237
263
|
'continue': cliArgs.continueMode,
|
|
238
264
|
cline: cliArgs.clineMode,
|
|
265
|
+
xcode: cliArgs.xcodeMode,
|
|
239
266
|
pi: cliArgs.piMode,
|
|
240
267
|
rovo: cliArgs.rovoMode,
|
|
241
268
|
gemini: cliArgs.geminiMode,
|
package/src/config.js
CHANGED
|
@@ -102,6 +102,7 @@
|
|
|
102
102
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync, renameSync } from 'node:fs'
|
|
103
103
|
import { homedir } from 'node:os'
|
|
104
104
|
import { join } from 'node:path'
|
|
105
|
+
import { syncShellEnv } from './shell-env.js'
|
|
105
106
|
|
|
106
107
|
// 📖 New JSON config path — stores all providers' API keys + enabled state
|
|
107
108
|
export const CONFIG_PATH = join(homedir(), '.free-coding-models.json')
|
|
@@ -484,6 +485,12 @@ export function saveConfig(config, options = {}) {
|
|
|
484
485
|
}
|
|
485
486
|
|
|
486
487
|
replaceConfigContents(config, persistedConfig)
|
|
488
|
+
|
|
489
|
+
// 📖 Keep shell env file in sync when enabled
|
|
490
|
+
if (persistedConfig.settings?.shellEnvEnabled) {
|
|
491
|
+
try { syncShellEnv(persistedConfig) } catch { /* non-critical */ }
|
|
492
|
+
}
|
|
493
|
+
|
|
487
494
|
return { success: true, backupCreated }
|
|
488
495
|
} catch (verifyError) {
|
|
489
496
|
// 📖 Verification failed - this is critical!
|
package/src/key-handler.js
CHANGED
|
@@ -33,6 +33,7 @@ import { loadConfig, replaceConfigContents } from './config.js'
|
|
|
33
33
|
import { cleanupLegacyProxyArtifacts } from './legacy-proxy-cleanup.js'
|
|
34
34
|
import { getLastLayout, COLUMN_SORT_MAP } from './render-table.js'
|
|
35
35
|
import { cycleThemeSetting, detectActiveTheme } from './theme.js'
|
|
36
|
+
import { syncShellEnv, ensureShellRcSource, removeShellEnv } from './shell-env.js'
|
|
36
37
|
import { buildCommandPaletteTree, flattenCommandTree, filterCommandPaletteEntries } from './command-palette.js'
|
|
37
38
|
import { WIDTH_WARNING_MIN_COLS } from './constants.js'
|
|
38
39
|
import { scanAllToolConfigs, softDeleteModel } from './installed-models-manager.js'
|
|
@@ -553,6 +554,19 @@ export function createKeyHandler(ctx) {
|
|
|
553
554
|
applyThemeSetting(cycleThemeSetting(currentTheme))
|
|
554
555
|
}
|
|
555
556
|
|
|
557
|
+
function toggleShellEnv() {
|
|
558
|
+
if (!state.config.settings) state.config.settings = {}
|
|
559
|
+
const currentlyEnabled = state.config.settings.shellEnvEnabled === true
|
|
560
|
+
state.config.settings.shellEnvEnabled = !currentlyEnabled
|
|
561
|
+
saveConfig(state.config)
|
|
562
|
+
if (!currentlyEnabled) {
|
|
563
|
+
syncShellEnv(state.config)
|
|
564
|
+
ensureShellRcSource()
|
|
565
|
+
} else {
|
|
566
|
+
removeShellEnv()
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
556
570
|
function resetInstallEndpointsOverlay() {
|
|
557
571
|
state.installEndpointsOpen = false
|
|
558
572
|
state.installEndpointsPhase = 'providers'
|
|
@@ -1828,8 +1842,9 @@ export function createKeyHandler(ctx) {
|
|
|
1828
1842
|
const favoritesModeRowIdx = themeRowIdx + 1
|
|
1829
1843
|
const cleanupLegacyProxyRowIdx = favoritesModeRowIdx + 1
|
|
1830
1844
|
const changelogViewRowIdx = cleanupLegacyProxyRowIdx + 1
|
|
1845
|
+
const shellEnvRowIdx = changelogViewRowIdx + 1
|
|
1831
1846
|
// 📖 Profile system removed - API keys now persist permanently across all sessions
|
|
1832
|
-
const maxRowIdx =
|
|
1847
|
+
const maxRowIdx = shellEnvRowIdx
|
|
1833
1848
|
|
|
1834
1849
|
// 📖 Edit/Add-key mode: capture typed characters for the API key
|
|
1835
1850
|
if (state.settingsEditMode || state.settingsAddKeyMode) {
|
|
@@ -1993,6 +2008,12 @@ export function createKeyHandler(ctx) {
|
|
|
1993
2008
|
return
|
|
1994
2009
|
}
|
|
1995
2010
|
|
|
2011
|
+
// 📖 Shell env row: Enter → toggle shell env export
|
|
2012
|
+
if (state.settingsCursor === shellEnvRowIdx) {
|
|
2013
|
+
toggleShellEnv()
|
|
2014
|
+
return
|
|
2015
|
+
}
|
|
2016
|
+
|
|
1996
2017
|
// 📖 Profile system removed - API keys now persist permanently across all sessions
|
|
1997
2018
|
|
|
1998
2019
|
// 📖 Enter edit mode for the selected provider's key
|
|
@@ -2010,6 +2031,11 @@ export function createKeyHandler(ctx) {
|
|
|
2010
2031
|
|| state.settingsCursor === cleanupLegacyProxyRowIdx
|
|
2011
2032
|
|| state.settingsCursor === changelogViewRowIdx
|
|
2012
2033
|
) return
|
|
2034
|
+
// 📖 Shell env toggle
|
|
2035
|
+
if (state.settingsCursor === shellEnvRowIdx) {
|
|
2036
|
+
toggleShellEnv()
|
|
2037
|
+
return
|
|
2038
|
+
}
|
|
2013
2039
|
// 📖 Theme configuration cycle inside settings
|
|
2014
2040
|
if (state.settingsCursor === themeRowIdx) {
|
|
2015
2041
|
cycleGlobalTheme()
|
package/src/overlays.js
CHANGED
|
@@ -120,6 +120,7 @@ export function createOverlayRenderers(state, deps) {
|
|
|
120
120
|
const favoritesModeRowIdx = themeRowIdx + 1
|
|
121
121
|
const cleanupLegacyProxyRowIdx = favoritesModeRowIdx + 1
|
|
122
122
|
const changelogViewRowIdx = cleanupLegacyProxyRowIdx + 1
|
|
123
|
+
const shellEnvRowIdx = changelogViewRowIdx + 1
|
|
123
124
|
const EL = '\x1b[K'
|
|
124
125
|
const lines = []
|
|
125
126
|
const cursorLineByRow = {}
|
|
@@ -272,6 +273,15 @@ export function createOverlayRenderers(state, deps) {
|
|
|
272
273
|
cursorLineByRow[changelogViewRowIdx] = lines.length
|
|
273
274
|
lines.push(state.settingsCursor === changelogViewRowIdx ? themeColors.bgCursorSettingsList(changelogViewRow) : changelogViewRow)
|
|
274
275
|
|
|
276
|
+
// 📖 Shell env toggle — expose API keys as shell environment variables
|
|
277
|
+
const shellEnvEnabled = state.config.settings?.shellEnvEnabled === true
|
|
278
|
+
const shellEnvStatus = shellEnvEnabled
|
|
279
|
+
? themeColors.successBold('✅ Enabled — keys available in shell')
|
|
280
|
+
: themeColors.dim('❌ Disabled')
|
|
281
|
+
const shellEnvRow = `${bullet(state.settingsCursor === shellEnvRowIdx)}${themeColors.textBold('Shell Env Export').padEnd(44)} ${shellEnvStatus}`
|
|
282
|
+
cursorLineByRow[shellEnvRowIdx] = lines.length
|
|
283
|
+
lines.push(state.settingsCursor === shellEnvRowIdx ? themeColors.bgCursorSettingsList(shellEnvRow) : shellEnvRow)
|
|
284
|
+
|
|
275
285
|
// 📖 Profile system removed - API keys now persist permanently across all sessions
|
|
276
286
|
|
|
277
287
|
lines.push('')
|
|
@@ -313,7 +323,7 @@ export function createOverlayRenderers(state, deps) {
|
|
|
313
323
|
// 📖 Mouse support: record layout so click handler can map Y → settingsCursor
|
|
314
324
|
overlayLayout.settingsCursorToLine = { ...cursorLineByRow }
|
|
315
325
|
overlayLayout.settingsScrollOffset = offset
|
|
316
|
-
overlayLayout.settingsMaxRow =
|
|
326
|
+
overlayLayout.settingsMaxRow = shellEnvRowIdx
|
|
317
327
|
|
|
318
328
|
const tintedLines = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols)
|
|
319
329
|
const cleared = tintedLines.map(l => l + EL)
|
package/src/shell-env.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file shell-env.js
|
|
3
|
+
* @description Exposes API keys as shell environment variables via a sourced dotfile.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* Creates `~/.free-coding-models.env` containing `export VAR="value"` lines for every
|
|
7
|
+
* configured provider, and injects a single `[ -f ~/.free-coding-models.env ] && source ...`
|
|
8
|
+
* line into the user's shell rc file (`.zshrc`, `.bashrc`, or fish `config.fish`).
|
|
9
|
+
*
|
|
10
|
+
* The env file is kept in sync automatically: every time `saveConfig()` or
|
|
11
|
+
* `persistApiKeysForProvider()` writes a new config to disk, `syncShellEnv()` is called
|
|
12
|
+
* to regenerate the `.env` if `settings.shellEnvEnabled === true`.
|
|
13
|
+
*
|
|
14
|
+
* Shell detection uses `$SHELL` with a fallback chain: zsh → bash → fish.
|
|
15
|
+
* Each shell's rc path is resolved relative to `$HOME`. Fish uses `set -gx` syntax
|
|
16
|
+
* instead of `export`, which is handled transparently.
|
|
17
|
+
*
|
|
18
|
+
* The env file is written with mode `0600` (owner read/write only) to protect API keys.
|
|
19
|
+
* The source line in the rc file is idempotent — calling `ensureShellRcSource()` multiple
|
|
20
|
+
* times will never add a duplicate line.
|
|
21
|
+
*
|
|
22
|
+
* @functions
|
|
23
|
+
* → `syncShellEnv(config)` — regenerate the .env file from current apiKeys
|
|
24
|
+
* → `ensureShellRcSource()` — add the source line to the detected shell rc (idempotent)
|
|
25
|
+
* → `removeShellEnv()` — delete the .env file and remove the source line from rc
|
|
26
|
+
* → `detectShellInfo()` — return { shell, rcPath } for the current user
|
|
27
|
+
* → `getEnvFilePath()` — return the absolute path to ~/.free-coding-models.env
|
|
28
|
+
* → `buildEnvContent(config, shell)` — build the env file body (pure function, testable)
|
|
29
|
+
* → `buildRcSourceLine(envFilePath, shell)` — build the rc source line for a given shell
|
|
30
|
+
*
|
|
31
|
+
* @exports syncShellEnv, ensureShellRcSource, removeShellEnv, detectShellInfo,
|
|
32
|
+
* getEnvFilePath, buildEnvContent, buildRcSourceLine, ENV_FILE_MARKER
|
|
33
|
+
*
|
|
34
|
+
* @see src/config.js — calls syncShellEnv() after saveConfig()
|
|
35
|
+
* @see src/provider-metadata.js — ENV_VAR_NAMES maps provider → env var name
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs'
|
|
39
|
+
import { homedir } from 'node:os'
|
|
40
|
+
import { join } from 'node:path'
|
|
41
|
+
import * as readline from 'node:readline'
|
|
42
|
+
import chalk from 'chalk'
|
|
43
|
+
import { ENV_VAR_NAMES } from './provider-metadata.js'
|
|
44
|
+
|
|
45
|
+
// 📖 Unique marker used to identify the source line we inject into shell rc files.
|
|
46
|
+
// 📖 This allows idempotent add/remove without relying on exact path matching.
|
|
47
|
+
export const ENV_FILE_MARKER = '# free-coding-models-env'
|
|
48
|
+
|
|
49
|
+
// 📖 The env dotfile path — always next to the JSON config in $HOME.
|
|
50
|
+
const ENV_FILE_NAME = '.free-coding-models.env'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 📖 Returns the absolute path to the env dotfile.
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
export function getEnvFilePath() {
|
|
57
|
+
return join(homedir(), ENV_FILE_NAME)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 📖 Detect the user's shell and return its rc file path.
|
|
62
|
+
*
|
|
63
|
+
* Detection order: $SHELL env var → fallback to zsh → bash → fish.
|
|
64
|
+
* Returns the shell type ('zsh'|'bash'|'fish') and the absolute rc path.
|
|
65
|
+
*
|
|
66
|
+
* @returns {{ shell: 'zsh'|'bash'|'fish', rcPath: string }}
|
|
67
|
+
*/
|
|
68
|
+
export function detectShellInfo() {
|
|
69
|
+
const shellEnv = (process.env.SHELL || '').toLowerCase()
|
|
70
|
+
const home = homedir()
|
|
71
|
+
|
|
72
|
+
if (shellEnv.includes('zsh')) {
|
|
73
|
+
return { shell: 'zsh', rcPath: join(home, '.zshrc') }
|
|
74
|
+
}
|
|
75
|
+
if (shellEnv.includes('bash')) {
|
|
76
|
+
return { shell: 'bash', rcPath: join(home, '.bashrc') }
|
|
77
|
+
}
|
|
78
|
+
if (shellEnv.includes('fish')) {
|
|
79
|
+
return { shell: 'fish', rcPath: join(home, '.config', 'fish', 'config.fish') }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 📖 Fallback: try to detect which rc files exist
|
|
83
|
+
if (existsSync(join(home, '.zshrc'))) {
|
|
84
|
+
return { shell: 'zsh', rcPath: join(home, '.zshrc') }
|
|
85
|
+
}
|
|
86
|
+
if (existsSync(join(home, '.bashrc'))) {
|
|
87
|
+
return { shell: 'bash', rcPath: join(home, '.bashrc') }
|
|
88
|
+
}
|
|
89
|
+
const fishConfig = join(home, '.config', 'fish', 'config.fish')
|
|
90
|
+
if (existsSync(fishConfig)) {
|
|
91
|
+
return { shell: 'fish', rcPath: fishConfig }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 📖 Last resort: assume zsh (most common on macOS/Linux)
|
|
95
|
+
return { shell: 'zsh', rcPath: join(home, '.zshrc') }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 📖 Build the env file content for a given shell type.
|
|
100
|
+
*
|
|
101
|
+
* Pure function — no I/O. Iterates over config.apiKeys and generates
|
|
102
|
+
* the appropriate `export` (bash/zsh) or `set -gx` (fish) lines.
|
|
103
|
+
*
|
|
104
|
+
* @param {{ apiKeys: Record<string, string|string[]> }} config
|
|
105
|
+
* @param {'zsh'|'bash'|'fish'} shell
|
|
106
|
+
* @returns {string} The complete file content to write
|
|
107
|
+
*/
|
|
108
|
+
export function buildEnvContent(config, shell) {
|
|
109
|
+
const apiKeys = config?.apiKeys || {}
|
|
110
|
+
const lines = [
|
|
111
|
+
'#!/bin/env sh',
|
|
112
|
+
`# ${ENV_FILE_MARKER}`,
|
|
113
|
+
'# Auto-generated by free-coding-models — do not edit manually.',
|
|
114
|
+
'# Changes to API keys are synced automatically from the TUI.',
|
|
115
|
+
'',
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
const isFish = shell === 'fish'
|
|
119
|
+
|
|
120
|
+
for (const [providerKey, envName] of Object.entries(ENV_VAR_NAMES)) {
|
|
121
|
+
const rawValue = apiKeys[providerKey]
|
|
122
|
+
if (!rawValue) continue
|
|
123
|
+
|
|
124
|
+
// 📖 Support multi-key arrays — use the first key for shell env
|
|
125
|
+
const value = Array.isArray(rawValue) ? rawValue[0] : rawValue
|
|
126
|
+
if (!value || typeof value !== 'string') continue
|
|
127
|
+
|
|
128
|
+
const safeValue = value.replace(/'/g, "'\\''")
|
|
129
|
+
|
|
130
|
+
if (isFish) {
|
|
131
|
+
lines.push(`set -gx ${envName} '${safeValue}'`)
|
|
132
|
+
} else {
|
|
133
|
+
lines.push(`export ${envName}='${safeValue}'`)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
lines.push('')
|
|
138
|
+
return lines.join('\n')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 📖 Build the rc source line that loads the env file.
|
|
143
|
+
*
|
|
144
|
+
* For bash/zsh: `[ -f ~/.free-coding-models.env ] && . ~/.free-coding-models.env # free-coding-models-env`
|
|
145
|
+
* For fish: `test -f ~/.free-coding-models.env; and source ~/.free-coding-models.env # free-coding-models-env`
|
|
146
|
+
*
|
|
147
|
+
* @param {string} envFilePath — absolute path to the .env file
|
|
148
|
+
* @param {'zsh'|'bash'|'fish'} shell
|
|
149
|
+
* @returns {string} A single line to append to the rc file
|
|
150
|
+
*/
|
|
151
|
+
export function buildRcSourceLine(envFilePath, shell) {
|
|
152
|
+
const home = homedir()
|
|
153
|
+
// 📖 Use ~/ relative path in rc for portability
|
|
154
|
+
const relativePath = envFilePath.startsWith(home)
|
|
155
|
+
? '~/' + envFilePath.slice(home.length + 1)
|
|
156
|
+
: envFilePath
|
|
157
|
+
|
|
158
|
+
if (shell === 'fish') {
|
|
159
|
+
return `test -f ${relativePath}; and source ${relativePath} ${ENV_FILE_MARKER}`
|
|
160
|
+
}
|
|
161
|
+
return `[ -f ${relativePath} ] && . ${relativePath} ${ENV_FILE_MARKER}`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 📖 Regenerate the env file from the current config.
|
|
166
|
+
*
|
|
167
|
+
* Called after every saveConfig() when shellEnvEnabled is true.
|
|
168
|
+
* Writes with mode 0600 for security. Skips if there are no keys.
|
|
169
|
+
*
|
|
170
|
+
* @param {{ apiKeys: Record<string, string|string[]>, settings?: { shellEnvEnabled?: boolean } }} config
|
|
171
|
+
* @returns {{ success: boolean, envPath: string, error?: string }}
|
|
172
|
+
*/
|
|
173
|
+
export function syncShellEnv(config) {
|
|
174
|
+
const { shell } = detectShellInfo()
|
|
175
|
+
const envPath = getEnvFilePath()
|
|
176
|
+
const apiKeys = config?.apiKeys || {}
|
|
177
|
+
const hasKeys = Object.values(apiKeys).some(v => {
|
|
178
|
+
if (Array.isArray(v)) return v.length > 0
|
|
179
|
+
return !!v
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
if (!hasKeys) {
|
|
183
|
+
// 📖 No keys — remove stale env file if it exists
|
|
184
|
+
if (existsSync(envPath)) {
|
|
185
|
+
try { unlinkSync(envPath) } catch { /* best effort */ }
|
|
186
|
+
}
|
|
187
|
+
return { success: true, envPath }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const content = buildEnvContent(config, shell)
|
|
192
|
+
writeFileSync(envPath, content, { mode: 0o600 })
|
|
193
|
+
return { success: true, envPath }
|
|
194
|
+
} catch (err) {
|
|
195
|
+
return { success: false, envPath, error: err.message }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 📖 Add the source line to the user's shell rc file (idempotent).
|
|
201
|
+
*
|
|
202
|
+
* Creates the rc file if it doesn't exist. Skips if the marker is already present.
|
|
203
|
+
*
|
|
204
|
+
* @returns {{ success: boolean, rcPath: string, wasAdded: boolean, error?: string }}
|
|
205
|
+
*/
|
|
206
|
+
export function ensureShellRcSource() {
|
|
207
|
+
const { shell, rcPath } = detectShellInfo()
|
|
208
|
+
const envPath = getEnvFilePath()
|
|
209
|
+
const sourceLine = buildRcSourceLine(envPath, shell)
|
|
210
|
+
|
|
211
|
+
let existingContent = ''
|
|
212
|
+
if (existsSync(rcPath)) {
|
|
213
|
+
try {
|
|
214
|
+
existingContent = readFileSync(rcPath, 'utf8')
|
|
215
|
+
} catch (err) {
|
|
216
|
+
return { success: false, rcPath, wasAdded: false, error: `Cannot read ${rcPath}: ${err.message}` }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 📖 Idempotent: skip if our marker is already in the file
|
|
221
|
+
if (existingContent.includes(ENV_FILE_MARKER)) {
|
|
222
|
+
return { success: true, rcPath, wasAdded: false }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
// 📖 Ensure parent directory exists (e.g. ~/.config/fish/)
|
|
227
|
+
const dir = join(rcPath, '..')
|
|
228
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
229
|
+
|
|
230
|
+
const newContent = existingContent
|
|
231
|
+
? existingContent.trimEnd() + '\n\n' + sourceLine + '\n'
|
|
232
|
+
: sourceLine + '\n'
|
|
233
|
+
writeFileSync(rcPath, newContent)
|
|
234
|
+
return { success: true, rcPath, wasAdded: true }
|
|
235
|
+
} catch (err) {
|
|
236
|
+
return { success: false, rcPath, wasAdded: false, error: `Cannot write ${rcPath}: ${err.message}` }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 📖 Remove the env file and the source line from the shell rc.
|
|
242
|
+
*
|
|
243
|
+
* @returns {{ success: boolean, envRemoved: boolean, rcCleaned: boolean, error?: string }}
|
|
244
|
+
*/
|
|
245
|
+
export function removeShellEnv() {
|
|
246
|
+
const { shell, rcPath } = detectShellInfo()
|
|
247
|
+
const envPath = getEnvFilePath()
|
|
248
|
+
let envRemoved = false
|
|
249
|
+
let rcCleaned = false
|
|
250
|
+
|
|
251
|
+
// 📖 Remove the env file
|
|
252
|
+
if (existsSync(envPath)) {
|
|
253
|
+
try {
|
|
254
|
+
unlinkSync(envPath)
|
|
255
|
+
envRemoved = true
|
|
256
|
+
} catch (err) {
|
|
257
|
+
return { success: false, envRemoved: false, rcCleaned: false, error: `Cannot delete ${envPath}: ${err.message}` }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 📖 Remove the source line from rc
|
|
262
|
+
if (existsSync(rcPath)) {
|
|
263
|
+
try {
|
|
264
|
+
const content = readFileSync(rcPath, 'utf8')
|
|
265
|
+
if (content.includes(ENV_FILE_MARKER)) {
|
|
266
|
+
const cleaned = content
|
|
267
|
+
.split('\n')
|
|
268
|
+
.filter(line => !line.includes(ENV_FILE_MARKER))
|
|
269
|
+
.join('\n')
|
|
270
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
271
|
+
writeFileSync(rcPath, cleaned)
|
|
272
|
+
rcCleaned = true
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
// 📖 Non-critical — env file is already removed
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return { success: true, envRemoved, rcCleaned }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 📖 Pre-TUI popup asking the user whether to enable shell env exposure.
|
|
284
|
+
*
|
|
285
|
+
* Follows the same pattern as `promptUpdateNotification()` in updater.js.
|
|
286
|
+
* Shows three options: Enable (recommended, green), Skip for now, Don't ask again.
|
|
287
|
+
* Runs BEFORE entering the alt-screen buffer so it renders in the normal terminal.
|
|
288
|
+
*
|
|
289
|
+
* @param {{ apiKeys: Record<string, string|string[]>, settings: object }} config
|
|
290
|
+
* @returns {Promise<'enable'|'skip'|'never'>} The user's choice
|
|
291
|
+
*/
|
|
292
|
+
export async function promptShellEnvMigration(config) {
|
|
293
|
+
const { shell, rcPath } = detectShellInfo()
|
|
294
|
+
const rcName = rcPath.split('/').pop()
|
|
295
|
+
const keyCount = Object.keys(config.apiKeys || {}).filter(pk => {
|
|
296
|
+
const v = config.apiKeys[pk]
|
|
297
|
+
return Array.isArray(v) ? v.length > 0 : !!v
|
|
298
|
+
}).length
|
|
299
|
+
|
|
300
|
+
return new Promise((resolve) => {
|
|
301
|
+
let selected = 0
|
|
302
|
+
const options = [
|
|
303
|
+
{
|
|
304
|
+
label: 'Yes, enable (recommended)',
|
|
305
|
+
icon: '✅',
|
|
306
|
+
description: `Export ${keyCount} API key${keyCount > 1 ? 's' : ''} to shell via ${rcName}`,
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
label: 'Skip for now',
|
|
310
|
+
icon: '⏭',
|
|
311
|
+
description: 'You can enable it later in Settings (P)',
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
label: "Don't ask again",
|
|
315
|
+
icon: '🔇',
|
|
316
|
+
description: 'Disable this prompt permanently',
|
|
317
|
+
},
|
|
318
|
+
]
|
|
319
|
+
|
|
320
|
+
const render = () => {
|
|
321
|
+
process.stdout.write('\x1b[2J\x1b[H')
|
|
322
|
+
|
|
323
|
+
const terminalWidth = process.stdout.columns || 80
|
|
324
|
+
const maxWidth = Math.min(terminalWidth - 4, 70)
|
|
325
|
+
const centerPad = ' '.repeat(Math.max(0, Math.floor((terminalWidth - maxWidth) / 2)))
|
|
326
|
+
|
|
327
|
+
console.log()
|
|
328
|
+
console.log(centerPad + chalk.bold.rgb(110, 214, 255)(' 🐚 Shell Environment Setup'))
|
|
329
|
+
console.log()
|
|
330
|
+
console.log(centerPad + chalk.white(' Expose your API keys as environment variables'))
|
|
331
|
+
console.log(centerPad + chalk.white(' so they are available globally in your shell.'))
|
|
332
|
+
console.log()
|
|
333
|
+
console.log(centerPad + chalk.dim(` Detected shell: ${shell} (${rcName})`))
|
|
334
|
+
console.log(centerPad + chalk.dim(` This adds a single source line to ${rcName}`))
|
|
335
|
+
console.log(centerPad + chalk.dim(' and creates ~/.free-coding-models.env (0600)'))
|
|
336
|
+
console.log()
|
|
337
|
+
|
|
338
|
+
for (let i = 0; i < options.length; i++) {
|
|
339
|
+
const isSelected = i === selected
|
|
340
|
+
const bullet = isSelected ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
|
|
341
|
+
let label
|
|
342
|
+
if (i === 0) {
|
|
343
|
+
label = isSelected
|
|
344
|
+
? chalk.bold.rgb(112, 231, 181)(options[i].icon + ' ' + options[i].label + ' ★')
|
|
345
|
+
: chalk.rgb(112, 231, 181)(options[i].icon + ' ' + options[i].label)
|
|
346
|
+
} else {
|
|
347
|
+
label = isSelected
|
|
348
|
+
? chalk.bold.white(options[i].icon + ' ' + options[i].label)
|
|
349
|
+
: chalk.dim(options[i].icon + ' ' + options[i].label)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
console.log(centerPad + bullet + label)
|
|
353
|
+
console.log(centerPad + chalk.dim(' ' + options[i].description))
|
|
354
|
+
console.log()
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
console.log(centerPad + chalk.dim(' ↑↓ Navigate • Enter Select • Ctrl+C Skip'))
|
|
358
|
+
console.log()
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
render()
|
|
362
|
+
|
|
363
|
+
readline.emitKeypressEvents(process.stdin)
|
|
364
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true)
|
|
365
|
+
|
|
366
|
+
const onKey = (_str, key) => {
|
|
367
|
+
if (!key) return
|
|
368
|
+
if (key.ctrl && key.name === 'c') {
|
|
369
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
370
|
+
process.stdin.removeListener('keypress', onKey)
|
|
371
|
+
resolve('skip')
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
if (key.name === 'up' && selected > 0) {
|
|
375
|
+
selected--
|
|
376
|
+
render()
|
|
377
|
+
} else if (key.name === 'down' && selected < options.length - 1) {
|
|
378
|
+
selected++
|
|
379
|
+
render()
|
|
380
|
+
} else if (key.name === 'return') {
|
|
381
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
382
|
+
process.stdin.removeListener('keypress', onKey)
|
|
383
|
+
process.stdin.pause()
|
|
384
|
+
|
|
385
|
+
if (selected === 0) resolve('enable')
|
|
386
|
+
else if (selected === 1) resolve('skip')
|
|
387
|
+
else resolve('never')
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
process.stdin.on('keypress', onKey)
|
|
392
|
+
})
|
|
393
|
+
}
|
package/src/tool-bootstrap.js
CHANGED
|
@@ -248,6 +248,13 @@ export const TOOL_BOOTSTRAP_METADATA = {
|
|
|
248
248
|
},
|
|
249
249
|
},
|
|
250
250
|
},
|
|
251
|
+
xcode: {
|
|
252
|
+
binary: null,
|
|
253
|
+
docsUrl: 'https://developer.apple.com/documentation/Xcode/setting-up-coding-intelligence',
|
|
254
|
+
installUnsupported: {
|
|
255
|
+
default: 'Xcode Intelligence requires manual setup. Go to Xcode > Settings > Intelligence > Add a Chat Provider.',
|
|
256
|
+
},
|
|
257
|
+
},
|
|
251
258
|
hermes: {
|
|
252
259
|
binary: 'hermes',
|
|
253
260
|
docsUrl: 'https://github.com/NousResearch/hermes-agent',
|
package/src/tool-launchers.js
CHANGED
|
@@ -462,7 +462,9 @@ function writeHermesConfig(model, apiKey, baseUrl, paths = getDefaultToolPaths()
|
|
|
462
462
|
const hermesBin = resolveToolBinaryPath('hermes') || 'hermes'
|
|
463
463
|
|
|
464
464
|
// 📖 Use `hermes config set` for each field — robust and dependency-free
|
|
465
|
-
|
|
465
|
+
// 📖 Must use 'model.default' not 'model', otherwise it replaces the entire model: dict with a string
|
|
466
|
+
// 📖 and subsequent model.provider / model.base_url / model.api_key calls silently fail
|
|
467
|
+
spawnSync(hermesBin, ['config', 'set', 'model.default', model.modelId], { stdio: 'ignore' })
|
|
466
468
|
spawnSync(hermesBin, ['config', 'set', 'model.provider', 'custom'], { stdio: 'ignore' })
|
|
467
469
|
if (baseUrl) {
|
|
468
470
|
spawnSync(hermesBin, ['config', 'set', 'model.base_url', baseUrl], { stdio: 'ignore' })
|
|
@@ -716,6 +718,18 @@ export function prepareExternalToolLaunch(mode, model, config, options = {}) {
|
|
|
716
718
|
}
|
|
717
719
|
}
|
|
718
720
|
|
|
721
|
+
if (mode === 'xcode') {
|
|
722
|
+
return {
|
|
723
|
+
command: 'open',
|
|
724
|
+
args: ['-a', 'Xcode'],
|
|
725
|
+
env,
|
|
726
|
+
apiKey,
|
|
727
|
+
baseUrl,
|
|
728
|
+
meta,
|
|
729
|
+
configArtifacts: [],
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
719
733
|
if (mode === 'rovo') {
|
|
720
734
|
const result = writeRovoConfig(model, join(homedir(), '.rovodev', 'config.yml'), paths)
|
|
721
735
|
console.log(chalk.dim(` 📖 Rovo Dev CLI configured with model: ${model.modelId}`))
|
|
@@ -815,6 +829,21 @@ export async function startExternalTool(mode, model, config) {
|
|
|
815
829
|
return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
|
|
816
830
|
}
|
|
817
831
|
|
|
832
|
+
if (mode === 'xcode') {
|
|
833
|
+
const xcodeUrl = baseUrl ? baseUrl.replace(/\/v1$/, '').replace(/\/v1\/chat\/completions$/, '') : ''
|
|
834
|
+
console.log(chalk.bold.cyan('\n 🛠️ Xcode Intelligence Setup Instructions:'))
|
|
835
|
+
console.log(chalk.white(' 1. Open Xcode and go to ') + chalk.bold('Xcode > Settings > Intelligence'))
|
|
836
|
+
console.log(chalk.white(' 2. Click ') + chalk.bold('Add a Chat Provider') + chalk.white(' and select ') + chalk.bold('Internet Hosted'))
|
|
837
|
+
console.log(chalk.white(' 3. Enter the following details:'))
|
|
838
|
+
console.log(chalk.dim(' URL: ') + chalk.green(xcodeUrl))
|
|
839
|
+
console.log(chalk.dim(' API Key: ') + chalk.green(apiKey || '<your_api_key>'))
|
|
840
|
+
console.log(chalk.dim(' API Key Header: ') + chalk.green('Authorization') + chalk.dim(' (or x-api-key)'))
|
|
841
|
+
console.log(chalk.dim(' Description: ') + chalk.green(`FCM - ${sources[model.providerKey]?.name || model.providerKey}`))
|
|
842
|
+
console.log(chalk.white(` 4. Click Add, then select `) + chalk.bold(model.modelId) + chalk.white(` from the list.\n`))
|
|
843
|
+
console.log(chalk.dim(` 📖 Attempting to launch Xcode...`))
|
|
844
|
+
return spawnCommand(launchPlan.command, launchPlan.args, launchPlan.env)
|
|
845
|
+
}
|
|
846
|
+
|
|
818
847
|
if (mode === 'rovo') {
|
|
819
848
|
console.log(chalk.dim(` 📖 Launching Rovo Dev CLI in interactive mode...`))
|
|
820
849
|
return spawnCommand(resolveLaunchCommand(mode, launchPlan.command), launchPlan.args, launchPlan.env)
|
package/src/tool-metadata.js
CHANGED
|
@@ -41,6 +41,7 @@ export const TOOL_METADATA = {
|
|
|
41
41
|
cline: { label: 'Cline', emoji: '🧠', flag: '--cline', color: [100, 220, 180] },
|
|
42
42
|
rovo: { label: 'Rovo Dev CLI', emoji: '🦘', flag: '--rovo', color: [148, 163, 184], cliOnly: true },
|
|
43
43
|
gemini: { label: 'Gemini CLI', emoji: '♊', flag: '--gemini', color: [66, 165, 245], cliOnly: true },
|
|
44
|
+
xcode: { label: 'Xcode Intelligence',emoji: '🛠️', flag: '--xcode', color: [20, 126, 251] },
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
// 📖 Deduplicated emoji order for the "Compatible with" column.
|
|
@@ -61,6 +62,7 @@ export const COMPAT_COLUMN_SLOTS = [
|
|
|
61
62
|
{ emoji: '🧠', toolKeys: ['cline'], color: [100, 220, 180] },
|
|
62
63
|
{ emoji: '🦘', toolKeys: ['rovo'], color: [148, 163, 184] },
|
|
63
64
|
{ emoji: '♊', toolKeys: ['gemini'], color: [66, 165, 245] },
|
|
65
|
+
{ emoji: '🛠️', toolKeys: ['xcode'], color: [20, 126, 251] },
|
|
64
66
|
]
|
|
65
67
|
|
|
66
68
|
export const TOOL_MODE_ORDER = [
|
|
@@ -77,6 +79,7 @@ export const TOOL_MODE_ORDER = [
|
|
|
77
79
|
'hermes',
|
|
78
80
|
'continue',
|
|
79
81
|
'cline',
|
|
82
|
+
'xcode',
|
|
80
83
|
'rovo',
|
|
81
84
|
'gemini',
|
|
82
85
|
]
|
package/src/utils.js
CHANGED
|
@@ -458,6 +458,7 @@ export function parseArgs(argv) {
|
|
|
458
458
|
const hermesMode = flags.includes('--hermes')
|
|
459
459
|
const continueMode = flags.includes('--continue')
|
|
460
460
|
const clineMode = flags.includes('--cline')
|
|
461
|
+
const xcodeMode = flags.includes('--xcode')
|
|
461
462
|
const geminiMode = flags.includes('--gemini')
|
|
462
463
|
const noTelemetry = flags.includes('--no-telemetry')
|
|
463
464
|
const jsonMode = flags.includes('--json')
|
|
@@ -501,6 +502,7 @@ export function parseArgs(argv) {
|
|
|
501
502
|
hermesMode,
|
|
502
503
|
continueMode,
|
|
503
504
|
clineMode,
|
|
505
|
+
xcodeMode,
|
|
504
506
|
rovoMode,
|
|
505
507
|
geminiMode,
|
|
506
508
|
noTelemetry,
|
package/web/server.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* GET /styles.css → Dashboard styles
|
|
12
12
|
* GET /app.js → Dashboard client JS
|
|
13
13
|
* GET /api/models → All model metadata (JSON)
|
|
14
|
+
* GET /api/health → Lightweight dashboard health probe
|
|
14
15
|
* GET /api/config → Current config (sanitized — masked keys)
|
|
15
16
|
* GET /api/key/:prov → Reveal a provider's full API key
|
|
16
17
|
* GET /api/events → SSE stream of live ping results
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
} from '../src/utils.js'
|
|
33
34
|
|
|
34
35
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
36
|
+
const SERVER_SIGNATURE = 'free-coding-models-web'
|
|
35
37
|
|
|
36
38
|
// ─── State ───────────────────────────────────────────────────────────────────
|
|
37
39
|
|
|
@@ -215,6 +217,8 @@ function serveDistFile(res, pathname) {
|
|
|
215
217
|
}
|
|
216
218
|
|
|
217
219
|
function handleRequest(req, res) {
|
|
220
|
+
res.setHeader('X-FCM-Server', SERVER_SIGNATURE)
|
|
221
|
+
|
|
218
222
|
// CORS for local dev
|
|
219
223
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
220
224
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
@@ -265,6 +269,11 @@ function handleRequest(req, res) {
|
|
|
265
269
|
res.end(JSON.stringify(getModelsPayload()))
|
|
266
270
|
break
|
|
267
271
|
|
|
272
|
+
case '/api/health':
|
|
273
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
274
|
+
res.end(JSON.stringify({ ok: true, app: SERVER_SIGNATURE }))
|
|
275
|
+
break
|
|
276
|
+
|
|
268
277
|
case '/api/config':
|
|
269
278
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
270
279
|
res.end(JSON.stringify(getConfigPayload()))
|
|
@@ -335,6 +344,40 @@ function checkPortInUse(port) {
|
|
|
335
344
|
})
|
|
336
345
|
}
|
|
337
346
|
|
|
347
|
+
export async function inspectExistingWebServer(port) {
|
|
348
|
+
const inUse = await checkPortInUse(port)
|
|
349
|
+
if (!inUse) return { inUse: false, isFcm: false }
|
|
350
|
+
|
|
351
|
+
const controller = new AbortController()
|
|
352
|
+
const timeout = setTimeout(() => controller.abort(), 750)
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
// 📖 Probe a tiny health route so we only reuse a port when the running
|
|
356
|
+
// 📖 process is actually the free-coding-models dashboard, not any random app.
|
|
357
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
|
358
|
+
signal: controller.signal,
|
|
359
|
+
headers: { Accept: 'application/json' },
|
|
360
|
+
})
|
|
361
|
+
const payload = await response.json().catch(() => null)
|
|
362
|
+
const signature = response.headers.get('x-fcm-server')
|
|
363
|
+
return {
|
|
364
|
+
inUse: true,
|
|
365
|
+
isFcm: signature === SERVER_SIGNATURE || payload?.app === SERVER_SIGNATURE,
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
return { inUse: true, isFcm: false }
|
|
369
|
+
} finally {
|
|
370
|
+
clearTimeout(timeout)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export async function findAvailablePort(startPort, maxAttempts = 20) {
|
|
375
|
+
for (let port = startPort; port < startPort + maxAttempts; port++) {
|
|
376
|
+
if (!(await checkPortInUse(port))) return port
|
|
377
|
+
}
|
|
378
|
+
throw new Error(`No free local port found between ${startPort} and ${startPort + maxAttempts - 1}`)
|
|
379
|
+
}
|
|
380
|
+
|
|
338
381
|
function openBrowser(url) {
|
|
339
382
|
const cmd = process.platform === 'darwin' ? 'open'
|
|
340
383
|
: process.platform === 'win32' ? 'start'
|
|
@@ -346,11 +389,12 @@ function openBrowser(url) {
|
|
|
346
389
|
|
|
347
390
|
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
348
391
|
|
|
349
|
-
export async function startWebServer(port = 3333, { open = true } = {}) {
|
|
350
|
-
const
|
|
351
|
-
|
|
392
|
+
export async function startWebServer(port = 3333, { open = true, startPingLoop = true } = {}) {
|
|
393
|
+
const portStatus = await inspectExistingWebServer(port)
|
|
394
|
+
|
|
395
|
+
if (portStatus.inUse && portStatus.isFcm) {
|
|
396
|
+
const url = `http://localhost:${port}`
|
|
352
397
|
|
|
353
|
-
if (alreadyRunning) {
|
|
354
398
|
console.log()
|
|
355
399
|
console.log(` ⚡ free-coding-models Web Dashboard already running`)
|
|
356
400
|
console.log(` 🌐 ${url}`)
|
|
@@ -359,9 +403,21 @@ export async function startWebServer(port = 3333, { open = true } = {}) {
|
|
|
359
403
|
return null
|
|
360
404
|
}
|
|
361
405
|
|
|
406
|
+
let resolvedPort = port
|
|
407
|
+
if (portStatus.inUse && !portStatus.isFcm) {
|
|
408
|
+
resolvedPort = await findAvailablePort(port + 1)
|
|
409
|
+
console.log()
|
|
410
|
+
console.log(` ⚠️ Port ${port} is already in use by another local app`)
|
|
411
|
+
console.log(` ↪ Starting free-coding-models Web Dashboard on port ${resolvedPort} instead`)
|
|
412
|
+
console.log()
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const url = `http://localhost:${resolvedPort}`
|
|
416
|
+
|
|
362
417
|
const server = createServer(handleRequest)
|
|
418
|
+
let pingLoopTimer = null
|
|
363
419
|
|
|
364
|
-
server.listen(
|
|
420
|
+
server.listen(resolvedPort, () => {
|
|
365
421
|
console.log()
|
|
366
422
|
console.log(` ⚡ free-coding-models Web Dashboard`)
|
|
367
423
|
console.log(` 🌐 ${url}`)
|
|
@@ -373,10 +429,15 @@ export async function startWebServer(port = 3333, { open = true } = {}) {
|
|
|
373
429
|
})
|
|
374
430
|
|
|
375
431
|
async function schedulePingLoop() {
|
|
432
|
+
if (!server.listening) return
|
|
376
433
|
await pingAllModels()
|
|
377
|
-
setTimeout(schedulePingLoop, 10_000)
|
|
434
|
+
pingLoopTimer = setTimeout(schedulePingLoop, 10_000)
|
|
378
435
|
}
|
|
379
|
-
|
|
436
|
+
|
|
437
|
+
if (startPingLoop) schedulePingLoop()
|
|
438
|
+
server.on('close', () => {
|
|
439
|
+
if (pingLoopTimer) clearTimeout(pingLoopTimer)
|
|
440
|
+
})
|
|
380
441
|
|
|
381
442
|
return server
|
|
382
443
|
}
|