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
4346import chalk from 'chalk'
4447import { 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
4752const require = createRequire ( import . meta. url )
4853const readline = require ( 'readline' )
4954const pkg = require ( '../../package.json' )
5055const 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