Skip to content

Commit 3428a22

Browse files
committed
Mandatory startup self-update with fallback
Add a mandatory startup self-update policy that auto-installs new releases before any user-facing surface starts. Introduce isPackageDevMode and enforceMandatoryStartupUpdate which persist install/check failure counters in config, allow two consecutive install failures before letting the UI start with a loud outdated warning, and skip auto-update in dev checkouts. Make runUpdate non-fatal (returns status) and add options to control relaunch/exit behavior. Wire the startup update through the bin entry, pass update state into the TUI and web server (render red warning banner in TUI and web UI), update socket hook and UI to surface the status, and add tests/CSS. Also bump package version to 0.5.5 and add changelog.
1 parent 7f35d0c commit 3428a22

12 files changed

Lines changed: 289 additions & 56 deletions

File tree

bin/free-coding-models.js

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@ if (process.argv.includes('--dev')) {
1111

1212
import chalk from 'chalk';
1313
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';
1515
import { ensureTelemetryConfig } from '../src/core/telemetry.js';
1616
import { ensureFavoritesConfig } from '../src/core/favorites.js';
1717
import { buildCliHelpText } from '../src/tui/cli-help.js';
1818
import { ALT_LEAVE } from '../src/core/constants.js';
19+
import { enforceMandatoryStartupUpdate, isPackageDevMode } from '../src/core/updater.js';
1920
import { runApp } from '../src/tui/app.js';
2021

2122
// Global error handlers to ensure terminal is restored if something crashes catastrophically
@@ -53,13 +54,32 @@ async function main() {
5354
process.exit(0);
5455
}
5556

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+
5676
// 📖 Standalone web dashboard: same full-catalog ping UI as the TUI, served
5777
// 📖 locally with Socket.IO/SSE/REST realtime updates.
5878
if (cliArgs.webMode) {
5979
const { startWebServer } = await import('../web/server.js');
6080
const parsedPort = Number.parseInt(process.env.FCM_WEB_PORT || process.env.FCM_PORT || '3333', 10);
6181
const port = Number.isFinite(parsedPort) && parsedPort > 0 ? parsedPort : 3333;
62-
await startWebServer(port, { open: true, startPingLoop: true });
82+
await startWebServer(port, { open: true, startPingLoop: true, updateStatus: startupUpdate });
6383
return;
6484
}
6585

@@ -102,12 +122,7 @@ async function main() {
102122
process.exit(1);
103123
}
104124

105-
// Load JSON config
106-
const config = loadConfig();
107-
ensureTelemetryConfig(config);
108-
ensureFavoritesConfig(config);
109-
110-
await runApp(cliArgs, config);
125+
await runApp(cliArgs, config, { startupUpdate, isDevMode });
111126
}
112127

113128
main().catch((err) => {

changelog/v0.5.5.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Changelog v0.5.5 - 2026-06-01
2+
3+
### Changed
4+
- ⬆️ **deps-dev**: bump `vite` from `8.0.14``8.0.16` (#105)
5+
- ⬆️ **deps-dev**: bump `vite-plus` from `0.1.20``0.1.23` (#106)
6+
- ⬆️ **deps-dev**: bump `@vitejs/plugin-react` from `6.0.1``6.0.2` (#107)
7+
8+
### CI
9+
- ⬆️ bump `docker/setup-buildx-action` from `3``4` (#104)
10+
- ⬆️ bump `docker/login-action` from `3``4` (#103)
11+
- ⬆️ bump `pnpm/action-setup` from `4``6` (#102)
12+
13+
### Notes
14+
- All 6 dependabot PRs merged in a single batch with conflict resolution on `pnpm-lock.yaml`.
15+
- 440/440 tests passing.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "free-coding-models",
3-
"version": "0.5.4",
3+
"version": "0.5.5",
44
"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.",
55
"keywords": [
66
"nvidia",

src/core/updater.js

Lines changed: 174 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,29 @@
3232
* → getManualInstallCmd(pm, version) — Human-readable install command string for error messages
3333
* → checkForUpdateDetailed() — Fetch npm latest with explicit error info
3434
* → 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
3537
* → runUpdate(latestVersion) — Install new version via detected PM + relaunch
3638
* @exports
3739
* detectPackageManager, getInstallArgs, getManualInstallCmd,
38-
* checkForUpdateDetailed, checkForUpdate, runUpdate, fetchLastReleaseDate
40+
* checkForUpdateDetailed, checkForUpdate, isPackageDevMode,
41+
* enforceMandatoryStartupUpdate, runUpdate, fetchLastReleaseDate
3942
*
4043
* @see bin/free-coding-models.js — calls checkForUpdate() at startup and runUpdate() on confirm
4144
*/
4245

4346
import chalk from 'chalk'
4447
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'
4651

4752
const require = createRequire(import.meta.url)
4853
const readline = require('readline')
4954
const pkg = require('../../package.json')
5055
const LOCAL_VERSION = pkg.version
56+
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
57+
export const UPDATE_FAILURE_THRESHOLD = 2
5158

5259
/**
5360
* 📖 detectPackageManager: figure out which package manager owns the current installation.
@@ -65,6 +72,70 @@ export function detectPackageManager() {
6572
return 'npm'
6673
}
6774

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+
68139
/**
69140
* 📖 getInstallArgs: return the correct binary and argument list for a given PM.
70141
* 📖 Each PM has different syntax for global install — this normalises them.
@@ -120,6 +191,89 @@ export async function checkForUpdate() {
120191
return latestVersion
121192
}
122193

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+
123277
/**
124278
* 📖 fetchLastReleaseDate: Get the human-readable publish date of the latest npm release.
125279
* 📖 Used in the TUI footer to show users how fresh the package is.
@@ -265,17 +419,23 @@ function installUpdateCommand(latestVersion, useSudo) {
265419
/**
266420
* 📖 runUpdate: Run npm global install to update to latestVersion.
267421
* 📖 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.
269425
* @param {string} latestVersion
426+
* @param {{ exitOnFailure?: boolean, relaunchOnSuccess?: boolean }} [options]
427+
* @returns {{ ok: boolean, error?: unknown }}
270428
*/
271-
export function runUpdate(latestVersion) {
429+
export function runUpdate(latestVersion, options = {}) {
430+
const { exitOnFailure = true, relaunchOnSuccess = true } = options
272431
console.log()
273432
console.log(chalk.bold.cyan(' ⬆ Updating free-coding-models to v' + latestVersion + '...'))
274433
console.log()
275434

276435
const pm = detectPackageManager()
277436
const { needsSudo, checkedPath } = detectGlobalInstallPermission(pm)
278437
const sudoAvailable = process.platform !== 'win32' && hasSudoCommand()
438+
let lastError = null
279439

280440
if (needsSudo && checkedPath && sudoAvailable) {
281441
console.log(chalk.yellow(` ⚠ Global ${pm} path is not writable: ${checkedPath}`))
@@ -288,9 +448,10 @@ export function runUpdate(latestVersion) {
288448
console.log()
289449
console.log(chalk.green(` ✅ Update complete! Version ${latestVersion} installed.`))
290450
console.log()
291-
relaunchCurrentProcess()
292-
return
451+
if (relaunchOnSuccess) relaunchCurrentProcess()
452+
return { ok: true }
293453
} catch (err) {
454+
lastError = err
294455
const manualCmd = getManualInstallCmd(pm, latestVersion)
295456
console.log()
296457
if (isPermissionError(err) && !needsSudo && sudoAvailable) {
@@ -301,9 +462,10 @@ export function runUpdate(latestVersion) {
301462
console.log()
302463
console.log(chalk.green(` ✅ Update complete with sudo! Version ${latestVersion} installed.`))
303464
console.log()
304-
relaunchCurrentProcess()
305-
return
306-
} catch {
465+
if (relaunchOnSuccess) relaunchCurrentProcess()
466+
return { ok: true }
467+
} catch (sudoErr) {
468+
lastError = sudoErr
307469
console.log()
308470
console.log(chalk.red(' ✖ Update failed even with sudo. Try manually:'))
309471
console.log(chalk.dim(` sudo ${manualCmd}`))
@@ -318,7 +480,9 @@ export function runUpdate(latestVersion) {
318480
console.log()
319481
}
320482
}
321-
process.exit(1)
483+
484+
if (exitOnFailure) process.exit(1)
485+
return { ok: false, error: lastError }
322486
}
323487

324488

0 commit comments

Comments
 (0)