free-coding-models 0.5.4 → 0.5.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.
Files changed (33) hide show
  1. package/README.md +8 -5
  2. package/bin/free-coding-models.js +29 -8
  3. package/changelog/v0.5.5.md +16 -0
  4. package/changelog/v0.5.6.md +24 -0
  5. package/package.json +4 -4
  6. package/src/core/changelog-loader.js +5 -1
  7. package/src/core/router-daemon.js +11 -0
  8. package/src/core/updater.js +174 -10
  9. package/src/tui/app.js +11 -31
  10. package/src/tui/render-table.js +9 -3
  11. package/src/tui/tui-state.js +6 -0
  12. package/web/dist/assets/index-Blp9QJev.js +39 -0
  13. package/web/dist/assets/{index-BrpHevg4.css → index-Cz_aCLTR.css} +1 -1
  14. package/web/dist/index.html +2 -2
  15. package/web/server.js +158 -2
  16. package/web/src/App.jsx +107 -58
  17. package/web/src/components/changelog/ChangelogView.jsx +135 -0
  18. package/web/src/components/changelog/ChangelogView.module.css +160 -0
  19. package/web/src/components/help/HelpView.jsx +188 -0
  20. package/web/src/components/help/HelpView.module.css +157 -0
  21. package/web/src/components/layout/Header.jsx +8 -3
  22. package/web/src/components/palette/CommandPalette.jsx +228 -74
  23. package/web/src/components/settings/SettingsView.jsx +281 -8
  24. package/web/src/components/settings/SettingsView.module.css +174 -0
  25. package/web/src/components/update/UpdateChip.jsx +104 -0
  26. package/web/src/components/update/UpdateChip.module.css +146 -0
  27. package/web/src/global.css +15 -0
  28. package/web/src/hooks/urlState.constants.js +25 -0
  29. package/web/src/hooks/useChangelog.js +51 -0
  30. package/web/src/hooks/useSocket.js +3 -0
  31. package/web/src/hooks/useUpdateChecker.js +91 -0
  32. package/web/src/hooks/useUrlState.js +122 -62
  33. package/web/dist/assets/index-BoWmUveV.js +0 -39
package/README.md CHANGED
@@ -169,14 +169,17 @@ and every TUI capability that's safe to port ships behind a button or chip.
169
169
  | **Filter bar** | Sticky right below the header (always visible) · Tier / Status / Verdict / Health chip rows · Visibility dropdown (Normal / Configured only / Usable only) · Provider select · custom text filter chip with `X` clear · Reset button (TUI `N`) · ping mode (Speed / Normal / Slow / Forced) · "next ping in Xs" countdown (TUI style, always shown) |
170
170
  | **Stats bar** | Removed in M1 (users found it noisy; the table + chips carry the same info at a glance) |
171
171
  | **Detail panel** | Slide-in from the right on row click · per-row benchmark button (TUI `Ctrl+A`) · favorite toggle (TUI `F`) + up/down reorder (TUI `Shift+↑↓`) · latency trend chart · all stats |
172
- | **Command palette** | `⌘K` / `Ctrl+P` (the only global keyboard shortcut) · fuzzy search across views, theme, ping mode, reset, export |
172
+ | **Command palette** | `⌘K` / `Ctrl+P` (the only global keyboard shortcut) · fuzzy search across views, theme, ping mode, reset, export, **and the full TUI command registry** (every filter / sort / tool / page entry from `src/tui/command-palette.js`) |
173
173
  | **Keyboard** | `Esc` closes any modal · `Cmd+K` toggles the palette — that's it. Everything else is mouse-first. |
174
- | **URL deep-linking** | `?tier=S+&sort=verdict&origin=groq&view=dashboard&q=…` hydrates the dashboard on load. CLI flags become shareable links. |
174
+ | **URL deep-linking** | `?tier=S+&sort=verdict&origin=groq&view=dashboard&q=…` hydrates the dashboard on load **and** every filter / sort / view change is reflected back in the URL (debounced 80ms, `history.replaceState`). CLI flags become shareable links. |
175
175
  | **Favorites** | Shared with the TUI through `~/.free-coding-models.json` — a star in the Web is a star in the TUI. Includes pinned+sticky display mode (TUI `Y`). |
176
+ | **Help modal** | Header overflow menu → "Help" opens a full-screen modal with all the TUI's keyboard shortcuts, filter behavior, and parity notes. Live search bar. |
177
+ | **Changelog modal** | Header overflow menu → "Changelog" or Settings "Open Changelog" link. Two-phase (index of versions + per-version release notes). Deep-linkable to a specific version. |
178
+ | **Update flow** | Header `⬆ vX.Y.Z` chip + popover with "Update now" + "What's new" (jumps to the new version's changelog entry). Polls every 5 min. |
179
+ | **Settings parity** | Full Settings page: theme (auto/dark/light), favorites pinned mode, startup AI Speed Scan, shell env export, legacy proxy cleanup, per-provider **Test** key button (TUI `T` key), update row. All settings persisted to the same `~/.free-coding-models.json` the TUI uses. |
176
180
  | **Theme** | Tri-state `auto / dark / light` cycle (TUI `G`) — auto follows the OS preference. |
177
181
 
178
- Roadmap items (header menu badges show "M2 / M3 / M4" until they land):
179
- - **M2** — Help modal, Changelog viewer, Settings parity (theme toggle, test key, shell-env, legacy cleanup, startup AI scan), Update flow, URL write-back, full command palette backed by the TUI registry.
182
+ Roadmap items (header menu badges show "M3 / M4" until they land):
180
183
  - **M3** — Smart Recommend (3-question wizard → 10s analysis → Top 3), tool mode picker, per-row Launch, missing-tool install prompt, incompatible-fallback modal.
181
184
  - **M4** — Router Dashboard, Token Usage, Install Endpoints wizard, Installed Models manager.
182
185
 
@@ -511,7 +514,7 @@ When a tool mode is active (via `Z`), models incompatible with that tool are hig
511
514
  - **Readable everywhere** — semantic theme palette keeps table rows, overlays, badges, and help screens legible in dark and light terminals
512
515
  - **Global theme switch** — `G` cycles `auto`, `dark`, + `light` live without restarting
513
516
  - **Auto-retry** — timeout models keep getting retried
514
- - **Aggressive update nudging** — fluorescent green banner when an update is available, impossible to miss, Shift+U hotkey, command palette entry, background re-check every 5 min, mid-session updates the banner live without restarting
517
+ - **Mandatory self-update policy** — startup checks npm for a newer FCM and installs it automatically without a prompt. If the install fails twice in a row (offline, proxy, or permissions), FCM still starts but shows a red outdated-version warning until the user retries with `Shift+U` or runs the displayed install command.
515
518
  - **Last release timestamp** — light pink footer shows `Last release: Mar 27, 2026, 09:42 PM` from npm so users know how fresh the data is
516
519
 
517
520
  ---
@@ -11,11 +11,12 @@ if (process.argv.includes('--dev')) {
11
11
 
12
12
  import chalk from 'chalk';
13
13
  import { parseArgs, TIER_LETTER_MAP } from '../src/core/utils.js';
14
- import { loadConfig } from '../src/core/config.js';
14
+ import { loadConfig, saveConfig } from '../src/core/config.js';
15
15
  import { ensureTelemetryConfig } from '../src/core/telemetry.js';
16
16
  import { ensureFavoritesConfig } from '../src/core/favorites.js';
17
17
  import { buildCliHelpText } from '../src/tui/cli-help.js';
18
18
  import { ALT_LEAVE } from '../src/core/constants.js';
19
+ import { enforceMandatoryStartupUpdate, isPackageDevMode } from '../src/core/updater.js';
19
20
  import { runApp } from '../src/tui/app.js';
20
21
 
21
22
  // Global error handlers to ensure terminal is restored if something crashes catastrophically
@@ -53,13 +54,38 @@ async function main() {
53
54
  process.exit(0);
54
55
  }
55
56
 
57
+ // Load JSON config before operational modes so the mandatory update policy can
58
+ // 📖 persist failure counters for TUI, Web Dashboard, Docker daemon, and Desktop sidecar launches.
59
+ const config = loadConfig();
60
+ ensureTelemetryConfig(config);
61
+ ensureFavoritesConfig(config);
62
+
63
+ const isDevMode = isPackageDevMode();
64
+ const shouldEnforceUpdate = !cliArgs.daemonStopMode && !cliArgs.daemonStatusMode;
65
+ const startupUpdate = shouldEnforceUpdate
66
+ ? await enforceMandatoryStartupUpdate(config, {
67
+ saveConfig,
68
+ isDevMode,
69
+ surface: cliArgs.webMode ? 'web dashboard' : cliArgs.daemonMode ? 'router daemon' : 'TUI',
70
+ })
71
+ : { latestVersion: null, allowedOutdated: false, warningMessage: null, failures: 0, checked: false, updated: false, blocked: false };
72
+
73
+ if (startupUpdate.updated) return;
74
+ if (startupUpdate.blocked) process.exit(1);
75
+ if (startupUpdate.allowedOutdated) {
76
+ process.env.FCM_UPDATE_ALLOWED_OUTDATED = '1';
77
+ process.env.FCM_UPDATE_LATEST_VERSION = startupUpdate.latestVersion || '';
78
+ process.env.FCM_UPDATE_WARNING_MESSAGE = startupUpdate.warningMessage || '';
79
+ process.env.FCM_UPDATE_FAILURES = String(startupUpdate.failures || 0);
80
+ }
81
+
56
82
  // 📖 Standalone web dashboard: same full-catalog ping UI as the TUI, served
57
83
  // 📖 locally with Socket.IO/SSE/REST realtime updates.
58
84
  if (cliArgs.webMode) {
59
85
  const { startWebServer } = await import('../web/server.js');
60
86
  const parsedPort = Number.parseInt(process.env.FCM_WEB_PORT || process.env.FCM_PORT || '3333', 10);
61
87
  const port = Number.isFinite(parsedPort) && parsedPort > 0 ? parsedPort : 3333;
62
- await startWebServer(port, { open: true, startPingLoop: true });
88
+ await startWebServer(port, { open: true, startPingLoop: true, updateStatus: startupUpdate });
63
89
  return;
64
90
  }
65
91
 
@@ -102,12 +128,7 @@ async function main() {
102
128
  process.exit(1);
103
129
  }
104
130
 
105
- // Load JSON config
106
- const config = loadConfig();
107
- ensureTelemetryConfig(config);
108
- ensureFavoritesConfig(config);
109
-
110
- await runApp(cliArgs, config);
131
+ await runApp(cliArgs, config, { startupUpdate, isDevMode });
111
132
  }
112
133
 
113
134
  main().catch((err) => {
@@ -0,0 +1,16 @@
1
+ # Changelog v0.5.5 - 2026-06-01
2
+
3
+ ### Changed
4
+ - 🔒 **Mandatory self-update policy**: FCM now checks npm at startup across the TUI, Web Dashboard, Docker/daemon, and Desktop sidecar paths, then installs newer releases automatically without asking the user. If automatic installation fails twice in a row, FCM stops blocking startup and shows a red outdated-version warning so offline/proxy/permission users can still work while clearly seeing that their model catalog may be stale.
5
+ - ⬆️ **deps-dev**: bump `vite` from `8.0.14` → `8.0.16` (#105)
6
+ - ⬆️ **deps-dev**: bump `vite-plus` from `0.1.20` → `0.1.23` (#106)
7
+ - ⬆️ **deps-dev**: bump `@vitejs/plugin-react` from `6.0.1` → `6.0.2` (#107)
8
+
9
+ ### CI
10
+ - ⬆️ bump `docker/setup-buildx-action` from `3` → `4` (#104)
11
+ - ⬆️ bump `docker/login-action` from `3` → `4` (#103)
12
+ - ⬆️ bump `pnpm/action-setup` from `4` → `6` (#102)
13
+
14
+ ### Notes
15
+ - All 6 dependabot PRs merged in a single batch with conflict resolution on `pnpm-lock.yaml`.
16
+ - 440/440 tests passing.
@@ -0,0 +1,24 @@
1
+ # Changelog v0.5.6 - 2026-06-01
2
+
3
+ ### Added
4
+ - **Web Dashboard M2 — Settings parity + full command palette + URL write-back + help / changelog / update** — the local Web Dashboard now mirrors the TUI for the everyday flow on every view it ships today.
5
+ - **Full command palette (TUI registry 1:1)** — `⌘K` / `Ctrl+P` opens a fuzzy-searchable palette that pulls every command from `src/tui/command-palette.js` (filters, sorts, target tools, theme, ping, reset, export, plus Web-only pages). Commands without a Web equivalent route to a friendly "arrives in M3/M4" toast.
6
+ - **Help modal** — Header overflow menu → "Help" opens a full-screen modal with 11 sections of Web behavior + TUI parity notes. Live search bar.
7
+ - **Changelog modal** — Two-phase (index of versions + per-version release notes). Settings "Open Changelog" link and the update chip "What's new" button both deep-link to a specific version.
8
+ - **Update chip + popover** — Header shows `⬆ vX.Y.Z` when a newer npm release is available. Click → "Update now" (spawns the package manager) + "What's new" (jumps to the new version's changelog entry). Polls every 5 minutes.
9
+ - **Full Settings parity** — theme dropdown (auto / dark / light), favorites pinned+sticky toggle, startup AI Speed Scan toggle, shell env export toggle, legacy proxy cleanup button, **per-provider Test key button** (TUI `T` key) with outcome badge, update status row, open Changelog link. All settings persist to the same `~/.free-coding-models.json` the TUI uses.
10
+ - **URL write-back (read + write)** — every filter / sort / view / tool-mode / palette state change is reflected back to the URL via `history.replaceState` (debounced 80ms). Sharing a URL with `?tier=S+&sort=verdict&origin=groq&view=analytics&q=…` opens a pre-filtered dashboard.
11
+
12
+ ### Changed
13
+ - **Web command palette upgraded** from M1's static placeholder to a fully wired palette that imports `buildCommandPaletteEntries` + `filterCommandPaletteEntries` from the TUI engine — both surfaces are now guaranteed to show the same command set.
14
+ - **Header menu** marks `Help` and `Changelog` as live (no more `M2` badge). `Recommend` (M3), `Router` (M4), `Install Endpoints` (M4), and `Installed Models` (M4) still badge.
15
+
16
+ ### Fixed
17
+ - 🐛 **TUI Changelog overlay was silently broken** — `src/core/changelog-loader.js` resolved `changelog/` relative to `src/` instead of the project root. Fix walks up two levels. The TUI's `N` key and the new Web Changelog modal both load 142 versioned changelog files now.
18
+ - 🐛 **`/api/key/:provider/test` and `/api/key/:provider` were colliding** — the general single-segment key match ate the two-segment test path. The test path is now matched at the top of the request handler so the two routes coexist.
19
+ - 🐛 **`/api/settings/feature` only handled booleans** — passing a string (e.g. `theme=dark`) silently fell into the toggle branch and saved `true`. The endpoint now accepts arbitrary `value` payloads (string, number, boolean) and only toggles when `value` is omitted.
20
+
21
+ ### Notes
22
+ - All 451 unit tests pass (was 440 before M2). The new M2 tests cover `buildUrlParams`, the URL state validation allowlists, and import-cleanly smoke tests for the three new React hooks.
23
+ - The TUI source of truth is untouched — every new Web feature uses the same engine modules (`src/core/changelog-loader.js`, `src/core/updater.js`, `src/core/legacy-proxy-cleanup.js`, `src/core/shell-env.js`, `src/tui/command-palette.js`).
24
+ - This release is the Web Dashboard reaching parity with the TUI for the day-to-day table flow. M3 ships the tool-mode picker + Smart Recommend; M4 ships the Router Dashboard + Token Usage.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
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",
@@ -75,10 +75,10 @@
75
75
  "node": ">=18.0.0"
76
76
  },
77
77
  "devDependencies": {
78
- "@vitejs/plugin-react": "^6.0.1",
78
+ "@vitejs/plugin-react": "^6.0.2",
79
79
  "react": "^19.2.6",
80
80
  "react-dom": "^19.2.6",
81
- "vite": "^8.0.13",
82
- "vite-plus": "^0.1.16"
81
+ "vite": "^8.0.16",
82
+ "vite-plus": "^0.1.23"
83
83
  }
84
84
  }
@@ -20,7 +20,11 @@ import { fileURLToPath } from 'url'
20
20
 
21
21
  const __filename = fileURLToPath(import.meta.url)
22
22
  const __dirname = dirname(__filename)
23
- const CHANGELOG_DIR = join(__dirname, '..', 'changelog')
23
+ // 📖 Resolve the project-root changelog directory. The loader lives at
24
+ // 📖 src/core/, so we walk up two levels to reach the project root, then
25
+ // 📖 into changelog/. (A previous version only walked up one level and
26
+ // 📖 ended up pointing at src/changelog/, which never existed.)
27
+ const CHANGELOG_DIR = join(__dirname, '..', '..', 'changelog')
24
28
 
25
29
  /**
26
30
  * 📖 loadChangelog: Read and parse all per-version changelog files
@@ -353,6 +353,16 @@ function getWebModelsPayload(runtime) {
353
353
  return payload
354
354
  }
355
355
 
356
+ function getWebUpdateStatusPayload() {
357
+ if (process.env.FCM_UPDATE_ALLOWED_OUTDATED !== '1') return null
358
+ return {
359
+ latestVersion: process.env.FCM_UPDATE_LATEST_VERSION || null,
360
+ allowedOutdated: true,
361
+ warningMessage: process.env.FCM_UPDATE_WARNING_MESSAGE || null,
362
+ failures: Number.parseInt(process.env.FCM_UPDATE_FAILURES || '0', 10) || 0,
363
+ }
364
+ }
365
+
356
366
  function getWebStatePayload(runtime) {
357
367
  const router = runtime.routerConfig()
358
368
  const probeInterval = router.probeIntervals?.[router.probeMode] || DEFAULT_ROUTER_SETTINGS.probeIntervals.balanced
@@ -366,6 +376,7 @@ function getWebStatePayload(runtime) {
366
376
  globalBenchmarkRunning: runtime.webGlobalBenchmarkRunning || false,
367
377
  globalBenchmarkTotal: runtime.webGlobalBenchmarkTotal || 0,
368
378
  globalBenchmarkCompleted: runtime.webGlobalBenchmarkCompleted || 0,
379
+ updateStatus: getWebUpdateStatusPayload(),
369
380
  models: getWebModelsPayload(runtime),
370
381
  }
371
382
  }
@@ -32,22 +32,29 @@
32
32
  * → getManualInstallCmd(pm, version) — Human-readable install command string for error messages
33
33
  * → checkForUpdateDetailed() — Fetch npm latest with explicit error info
34
34
  * → checkForUpdate() — Startup wrapper, returns version string or null
35
+ * → isPackageDevMode() — Detect git/dev checkouts that must not self-update
36
+ * → enforceMandatoryStartupUpdate() — Mandatory startup self-update with two-failure fallback
35
37
  * → runUpdate(latestVersion) — Install new version via detected PM + relaunch
36
38
  * @exports
37
39
  * detectPackageManager, getInstallArgs, getManualInstallCmd,
38
- * checkForUpdateDetailed, checkForUpdate, runUpdate, fetchLastReleaseDate
40
+ * checkForUpdateDetailed, checkForUpdate, isPackageDevMode,
41
+ * enforceMandatoryStartupUpdate, runUpdate, fetchLastReleaseDate
39
42
  *
40
43
  * @see bin/free-coding-models.js — calls checkForUpdate() at startup and runUpdate() on confirm
41
44
  */
42
45
 
43
46
  import chalk from 'chalk'
44
47
  import { createRequire } from 'module'
45
- import { accessSync, constants } from 'fs'
48
+ import { fileURLToPath } from 'url'
49
+ import { dirname, join } from 'path'
50
+ import { accessSync, constants, existsSync } from 'fs'
46
51
 
47
52
  const require = createRequire(import.meta.url)
48
53
  const readline = require('readline')
49
54
  const pkg = require('../../package.json')
50
55
  const LOCAL_VERSION = pkg.version
56
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
57
+ export const UPDATE_FAILURE_THRESHOLD = 2
51
58
 
52
59
  /**
53
60
  * 📖 detectPackageManager: figure out which package manager owns the current installation.
@@ -65,6 +72,70 @@ export function detectPackageManager() {
65
72
  return 'npm'
66
73
  }
67
74
 
75
+ /**
76
+ * 📖 isPackageDevMode: true for repo checkouts and explicit --dev runs.
77
+ * 📖 Self-updating a git checkout creates noisy loops during local development,
78
+ * 📖 while published npm installs do not ship a .git directory and can update safely.
79
+ * @returns {boolean}
80
+ */
81
+ export function isPackageDevMode() {
82
+ return process.env.FCM_DEV === '1' || existsSync(join(PACKAGE_ROOT, '.git'))
83
+ }
84
+
85
+ /**
86
+ * 📖 getUpdateInstallFailureCount: sanitized persistent failure counter.
87
+ * @param {object} config
88
+ * @returns {number}
89
+ */
90
+ export function getUpdateInstallFailureCount(config) {
91
+ const raw = Number(config?.settings?.updateInstallFailures || 0)
92
+ if (!Number.isFinite(raw) || raw < 0) return 0
93
+ return Math.floor(raw)
94
+ }
95
+
96
+ function ensureUpdateSettings(config) {
97
+ if (!config.settings || typeof config.settings !== 'object') config.settings = {}
98
+ return config.settings
99
+ }
100
+
101
+ function persistUpdateSettings(config, saveConfig) {
102
+ if (typeof saveConfig !== 'function') return
103
+ try { saveConfig(config) } catch {}
104
+ }
105
+
106
+ function resetUpdateInstallFailures(config, saveConfig) {
107
+ const settings = ensureUpdateSettings(config)
108
+ if (!settings.updateInstallFailures && !settings.updateLastFailureAt && !settings.updateLastFailureMessage) return
109
+ settings.updateInstallFailures = 0
110
+ delete settings.updateLastFailureAt
111
+ delete settings.updateLastFailureMessage
112
+ delete settings.updateInstallFailureVersion
113
+ persistUpdateSettings(config, saveConfig)
114
+ }
115
+
116
+ function recordUpdateInstallFailure(config, latestVersion, error, saveConfig) {
117
+ const settings = ensureUpdateSettings(config)
118
+ const nextFailures = Math.min(getUpdateInstallFailureCount(config) + 1, UPDATE_FAILURE_THRESHOLD)
119
+ settings.updateInstallFailures = nextFailures
120
+ settings.updateInstallFailureVersion = latestVersion
121
+ settings.updateLastFailureAt = new Date().toISOString()
122
+ settings.updateLastFailureMessage = error instanceof Error ? error.message : String(error || 'Unknown update error')
123
+ persistUpdateSettings(config, saveConfig)
124
+ return nextFailures
125
+ }
126
+
127
+ /**
128
+ * 📖 buildOutdatedWarningMessage: one-line message shown when mandatory updates
129
+ * 📖 failed twice and FCM must let the UI start instead of trapping the user.
130
+ * @param {string|null} latestVersion
131
+ * @param {number} failures
132
+ * @returns {string}
133
+ */
134
+ export function buildOutdatedWarningMessage(latestVersion, failures = UPDATE_FAILURE_THRESHOLD) {
135
+ const target = latestVersion ? `v${LOCAL_VERSION} → v${latestVersion}` : `v${LOCAL_VERSION}`
136
+ return `⚠️ OUTDATED VERSION (${target}) — automatic update failed ${failures} times. Models and free quotas change often; update as soon as possible for the freshest catalog.`
137
+ }
138
+
68
139
  /**
69
140
  * 📖 getInstallArgs: return the correct binary and argument list for a given PM.
70
141
  * 📖 Each PM has different syntax for global install — this normalises them.
@@ -120,6 +191,89 @@ export async function checkForUpdate() {
120
191
  return latestVersion
121
192
  }
122
193
 
194
+ /**
195
+ * 📖 enforceMandatoryStartupUpdate: startup policy for every user-facing surface.
196
+ * 📖 If npm has a newer release, FCM installs it immediately without asking.
197
+ * 📖 The first failed install blocks startup so the next launch retries. After two
198
+ * 📖 consecutive install failures, startup is allowed with a loud outdated warning
199
+ * 📖 so offline/proxy/permission users are not permanently locked out.
200
+ *
201
+ * @param {object} config
202
+ * @param {{ saveConfig?: Function, isDevMode?: boolean, surface?: string }} [options]
203
+ * @returns {Promise<{ latestVersion: string|null, allowedOutdated: boolean, warningMessage: string|null, failures: number, checked: boolean, updated: boolean, blocked: boolean }>}
204
+ */
205
+ export async function enforceMandatoryStartupUpdate(config, options = {}) {
206
+ const { saveConfig, surface = 'app' } = options
207
+ const devMode = typeof options.isDevMode === 'boolean' ? options.isDevMode : isPackageDevMode()
208
+ const base = {
209
+ latestVersion: null,
210
+ allowedOutdated: false,
211
+ warningMessage: null,
212
+ failures: getUpdateInstallFailureCount(config),
213
+ checked: false,
214
+ updated: false,
215
+ blocked: false,
216
+ }
217
+
218
+ if (devMode) return base
219
+
220
+ const { latestVersion, error } = await checkForUpdateDetailed()
221
+ base.checked = true
222
+
223
+ if (error) {
224
+ const settings = ensureUpdateSettings(config)
225
+ settings.updateCheckFailures = Math.min(Number(settings.updateCheckFailures || 0) + 1, UPDATE_FAILURE_THRESHOLD)
226
+ persistUpdateSettings(config, saveConfig)
227
+ return base
228
+ }
229
+
230
+ const settings = ensureUpdateSettings(config)
231
+ if (settings.updateCheckFailures) {
232
+ settings.updateCheckFailures = 0
233
+ persistUpdateSettings(config, saveConfig)
234
+ }
235
+
236
+ if (!latestVersion) {
237
+ resetUpdateInstallFailures(config, saveConfig)
238
+ return base
239
+ }
240
+
241
+ base.latestVersion = latestVersion
242
+ const failuresBeforeInstall = getUpdateInstallFailureCount(config)
243
+ if (failuresBeforeInstall >= UPDATE_FAILURE_THRESHOLD) {
244
+ base.allowedOutdated = true
245
+ base.failures = failuresBeforeInstall
246
+ base.warningMessage = buildOutdatedWarningMessage(latestVersion, failuresBeforeInstall)
247
+ return base
248
+ }
249
+
250
+ console.log(chalk.dim(` ⬆ New version v${latestVersion} detected for ${surface}; updating automatically...`))
251
+ const updateResult = runUpdate(latestVersion, { exitOnFailure: false })
252
+ if (updateResult?.ok) {
253
+ resetUpdateInstallFailures(config, saveConfig)
254
+ base.updated = true
255
+ return base
256
+ }
257
+
258
+ const failures = recordUpdateInstallFailure(config, latestVersion, updateResult?.error, saveConfig)
259
+ base.failures = failures
260
+
261
+ if (failures >= UPDATE_FAILURE_THRESHOLD) {
262
+ base.allowedOutdated = true
263
+ base.warningMessage = buildOutdatedWarningMessage(latestVersion, failures)
264
+ console.log(chalk.red(` ${base.warningMessage}`))
265
+ console.log(chalk.dim(` Manual update: ${getManualInstallCmd(detectPackageManager(), latestVersion)}`))
266
+ console.log()
267
+ return base
268
+ }
269
+
270
+ base.blocked = true
271
+ console.log(chalk.red(' ✖ Mandatory update failed. FCM will retry on the next launch.'))
272
+ console.log(chalk.dim(` Attempt ${failures}/${UPDATE_FAILURE_THRESHOLD}. Manual update: ${getManualInstallCmd(detectPackageManager(), latestVersion)}`))
273
+ console.log()
274
+ return base
275
+ }
276
+
123
277
  /**
124
278
  * 📖 fetchLastReleaseDate: Get the human-readable publish date of the latest npm release.
125
279
  * 📖 Used in the TUI footer to show users how fresh the package is.
@@ -265,10 +419,15 @@ function installUpdateCommand(latestVersion, useSudo) {
265
419
  /**
266
420
  * 📖 runUpdate: Run npm global install to update to latestVersion.
267
421
  * 📖 Retries with sudo on permission errors.
268
- * 📖 Relaunches the process on success, exits with code 1 on failure.
422
+ * 📖 Relaunches the process on success. Manual update actions keep the historic
423
+ * 📖 behavior and exit on failure; mandatory startup checks pass exitOnFailure=false
424
+ * 📖 so they can persist failure counters and decide whether to let the UI start.
269
425
  * @param {string} latestVersion
426
+ * @param {{ exitOnFailure?: boolean, relaunchOnSuccess?: boolean }} [options]
427
+ * @returns {{ ok: boolean, error?: unknown }}
270
428
  */
271
- export function runUpdate(latestVersion) {
429
+ export function runUpdate(latestVersion, options = {}) {
430
+ const { exitOnFailure = true, relaunchOnSuccess = true } = options
272
431
  console.log()
273
432
  console.log(chalk.bold.cyan(' ⬆ Updating free-coding-models to v' + latestVersion + '...'))
274
433
  console.log()
@@ -276,6 +435,7 @@ export function runUpdate(latestVersion) {
276
435
  const pm = detectPackageManager()
277
436
  const { needsSudo, checkedPath } = detectGlobalInstallPermission(pm)
278
437
  const sudoAvailable = process.platform !== 'win32' && hasSudoCommand()
438
+ let lastError = null
279
439
 
280
440
  if (needsSudo && checkedPath && sudoAvailable) {
281
441
  console.log(chalk.yellow(` ⚠ Global ${pm} path is not writable: ${checkedPath}`))
@@ -288,9 +448,10 @@ export function runUpdate(latestVersion) {
288
448
  console.log()
289
449
  console.log(chalk.green(` ✅ Update complete! Version ${latestVersion} installed.`))
290
450
  console.log()
291
- relaunchCurrentProcess()
292
- return
451
+ if (relaunchOnSuccess) relaunchCurrentProcess()
452
+ return { ok: true }
293
453
  } catch (err) {
454
+ lastError = err
294
455
  const manualCmd = getManualInstallCmd(pm, latestVersion)
295
456
  console.log()
296
457
  if (isPermissionError(err) && !needsSudo && sudoAvailable) {
@@ -301,9 +462,10 @@ export function runUpdate(latestVersion) {
301
462
  console.log()
302
463
  console.log(chalk.green(` ✅ Update complete with sudo! Version ${latestVersion} installed.`))
303
464
  console.log()
304
- relaunchCurrentProcess()
305
- return
306
- } catch {
465
+ if (relaunchOnSuccess) relaunchCurrentProcess()
466
+ return { ok: true }
467
+ } catch (sudoErr) {
468
+ lastError = sudoErr
307
469
  console.log()
308
470
  console.log(chalk.red(' ✖ Update failed even with sudo. Try manually:'))
309
471
  console.log(chalk.dim(` sudo ${manualCmd}`))
@@ -318,7 +480,9 @@ export function runUpdate(latestVersion) {
318
480
  console.log()
319
481
  }
320
482
  }
321
- process.exit(1)
483
+
484
+ if (exitOnFailure) process.exit(1)
485
+ return { ok: false, error: lastError }
322
486
  }
323
487
 
324
488
 
package/src/tui/app.js CHANGED
@@ -91,11 +91,7 @@
91
91
 
92
92
  import chalk from 'chalk'
93
93
  import { createRequire } from 'module'
94
- import { fileURLToPath } from 'url'
95
- import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'fs'
96
94
  import { randomUUID } from 'crypto'
97
- import { homedir } from 'os'
98
- import { join, dirname } from 'path'
99
95
  import { MODELS, sources } from '../../sources.js'
100
96
  import { getAvg, getVerdict, getUptime, getP95, getJitter, getStabilityScore, sortResults, filterByTier, findBestModel, parseArgs, TIER_ORDER, VERDICT_ORDER, TIER_LETTER_MAP, scoreModelForTask, getTopRecommendations, TASK_TYPES, PRIORITY_TYPES, CONTEXT_BUDGETS, formatCtxWindow, labelFromId, formatResultsAsJSON } from '../core/utils.js'
101
97
  import { loadConfig, saveConfig, getApiKey, resolveApiKeys, addApiKey, removeApiKey, isProviderEnabled, persistApiKeysForProvider } from '../core/config.js'
@@ -181,7 +177,7 @@ const LOCAL_VERSION = pkg.version
181
177
  // ─── OpenCode integration ──────────────────────────────────────────────────────
182
178
  // 📖 OpenCode helpers are imported from ../src/opencode.js
183
179
 
184
- export async function runApp(cliArgs, config) {
180
+ export async function runApp(cliArgs, config, startupOptions = {}) {
185
181
 
186
182
  // 📖 Detect user active terminal theme
187
183
  detectActiveTheme(config.settings?.theme || 'auto')
@@ -278,32 +274,12 @@ export async function runApp(cliArgs, config) {
278
274
  },
279
275
  })
280
276
 
281
- // 📖 Auto-update detection: check npm registry for new versions at startup.
282
- // 📖 If a new version is available, show an interactive prompt (Update / Changelogs / Skip).
283
- // 📖 Dev mode (git checkout) skips auto-update to avoid infinite relaunch loops.
284
- let latestVersion = null
285
- const isDevMode = existsSync(join(dirname(fileURLToPath(import.meta.url)), '..', '.git'))
286
- try {
287
- latestVersion = await checkForUpdate()
288
- // 📖 Reset failure counter on successful check
289
- if (config.settings?.updateCheckFailures) {
290
- config.settings.updateCheckFailures = 0
291
- saveConfig(config)
292
- }
293
- } catch (err) {
294
- const failures = (config.settings?.updateCheckFailures || 0) + 1
295
- if (!config.settings) config.settings = {}
296
- config.settings.updateCheckFailures = Math.min(failures, 3)
297
- saveConfig(config)
298
- }
299
-
300
- // 📖 Auto-update: if a new version is available, install it immediately (skip in dev mode)
301
- // 📖 runUpdate() will relaunch the process with the new version after install completes
302
- if (latestVersion && !isDevMode) {
303
- console.log(chalk.dim(` ⬆ New version v${latestVersion} detected, updating...`))
304
- runUpdate(latestVersion)
305
- return // 📖 runUpdate relaunches the process — this line is a safety guard
306
- }
277
+ // 📖 Mandatory startup update is enforced in the bin entry before any user-facing
278
+ // 📖 surface starts. runApp receives the result so the TUI can display the
279
+ // 📖 two-failure fallback warning without repeating npm registry/install work.
280
+ const startupUpdate = startupOptions.startupUpdate || {}
281
+ const latestVersion = startupUpdate.latestVersion || null
282
+ const isDevMode = startupOptions.isDevMode === true
307
283
 
308
284
  // 📖 Dynamic OpenRouter free model discovery — fetch live free models from API
309
285
  // 📖 Replaces static openrouter entries in MODELS with fresh data.
@@ -363,6 +339,8 @@ export async function runApp(cliArgs, config) {
363
339
  sessionId,
364
340
  latestVersion,
365
341
  isDevMode,
342
+ updateWarningMessage: startupUpdate.warningMessage || null,
343
+ updateInstallFailures: startupUpdate.failures || 0,
366
344
  })
367
345
 
368
346
  // 📖 Apply the pre-fetched last release date now that state is initialized
@@ -823,6 +801,7 @@ export async function runApp(cliArgs, config) {
823
801
  settingsUpdateLatestVersion: state.settingsUpdateLatestVersion,
824
802
  startupLatestVersion: state.startupLatestVersion,
825
803
  versionAlertsEnabled: state.versionAlertsEnabled,
804
+ updateWarningMessage: state.updateWarningMessage,
826
805
  favoritesPinnedAndSticky: state.favoritesPinnedAndSticky,
827
806
  customTextFilter: state.customTextFilter,
828
807
  lastReleaseDate: state.lastReleaseDate,
@@ -914,6 +893,7 @@ export async function runApp(cliArgs, config) {
914
893
  settingsUpdateLatestVersion: state.settingsUpdateLatestVersion,
915
894
  startupLatestVersion: state.startupLatestVersion,
916
895
  versionAlertsEnabled: state.versionAlertsEnabled,
896
+ updateWarningMessage: state.updateWarningMessage,
917
897
  favoritesPinnedAndSticky: state.favoritesPinnedAndSticky,
918
898
  customTextFilter: state.customTextFilter,
919
899
  lastReleaseDate: state.lastReleaseDate,
@@ -136,6 +136,7 @@ export const PROVIDER_COLOR = new Proxy({}, {
136
136
  * settingsUpdateLatestVersion: string|null,
137
137
  * startupLatestVersion: string|null,
138
138
  * versionAlertsEnabled: boolean,
139
+ * updateWarningMessage?: string|null,
139
140
  * favoritesPinnedAndSticky: boolean,
140
141
  * customTextFilter: string|null,
141
142
  * lastReleaseDate: string|null,
@@ -174,6 +175,7 @@ export function renderTable({
174
175
  settingsUpdateLatestVersion = null,
175
176
  startupLatestVersion = null,
176
177
  versionAlertsEnabled = true,
178
+ updateWarningMessage = null,
177
179
  favoritesPinnedAndSticky = false,
178
180
  customTextFilter = null,
179
181
  lastReleaseDate = null,
@@ -1073,15 +1075,19 @@ export function renderTable({
1073
1075
  )
1074
1076
 
1075
1077
  if (versionStatus.isOutdated) {
1076
- const updateMsg = ` 🚀⬆️ UPDATE AVAILABLE — v${LOCAL_VERSION} → v${versionStatus.latestVersion} • Click here or press Shift+U to update 🚀⬆️ `
1078
+ const updateMsg = updateWarningMessage
1079
+ ? ` ${updateWarningMessage} • Press Shift+U to retry update `
1080
+ : ` 🚀⬆️ UPDATE AVAILABLE — v${LOCAL_VERSION} → v${versionStatus.latestVersion} • Click here or press Shift+U to update 🚀⬆️ `
1077
1081
  const paddedBanner = terminalCols > 0
1078
1082
  ? updateMsg + ' '.repeat(Math.max(0, terminalCols - displayWidth(updateMsg)))
1079
1083
  : updateMsg
1080
- const fluoGreenBanner = chalk.bgRgb(57, 255, 20).rgb(0, 0, 0).bold(paddedBanner)
1084
+ const updateBanner = updateWarningMessage
1085
+ ? chalk.bgRed.white.bold(paddedBanner)
1086
+ : chalk.bgRgb(57, 255, 20).rgb(0, 0, 0).bold(paddedBanner)
1081
1087
  const updateBannerRow = lines.length + 1
1082
1088
  _lastLayout.updateBannerRow = updateBannerRow
1083
1089
  footerHotkeys.push({ key: 'update-click', row: updateBannerRow, xStart: 1, xEnd: Math.max(terminalCols, displayWidth(updateMsg)) })
1084
- lines.push(fluoGreenBanner)
1090
+ lines.push(updateBanner)
1085
1091
  } else {
1086
1092
  _lastLayout.updateBannerRow = 0
1087
1093
  }
@@ -70,6 +70,8 @@ export function intervalToPingMode(intervalMs) {
70
70
  * sessionId: string,
71
71
  * latestVersion: string|null,
72
72
  * isDevMode: boolean,
73
+ * updateWarningMessage?: string|null,
74
+ * updateInstallFailures?: number,
73
75
  * }} opts
74
76
  * @returns {object} The complete TUI state (mutable)
75
77
  */
@@ -80,6 +82,8 @@ export function createTuiState({
80
82
  sessionId,
81
83
  latestVersion,
82
84
  isDevMode,
85
+ updateWarningMessage = null,
86
+ updateInstallFailures = 0,
83
87
  }) {
84
88
  const now = Date.now()
85
89
 
@@ -110,6 +114,8 @@ export function createTuiState({
110
114
 
111
115
  // 📖 Version tracking — startup auto-check + periodic re-check
112
116
  startupLatestVersion: latestVersion,
117
+ updateWarningMessage,
118
+ updateInstallFailures,
113
119
  lastReleaseDate: null,
114
120
  versionAlertsEnabled: !isDevMode,
115
121