free-coding-models 0.1.82 → 0.1.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @file tier-colors.js
3
+ * @description Chalk colour functions for each tier level, extracted from bin/free-coding-models.js.
4
+ *
5
+ * @details
6
+ * The tier system maps model quality tiers (S+, S, A+, A, A-, B+, B, C) to a
7
+ * green → yellow → orange → red gradient. Keeping these colour definitions in their
8
+ * own module allows the renderer, overlays, and any future CLI tools to share a
9
+ * single, consistent visual language without depending on the whole TUI entry point.
10
+ *
11
+ * The gradient is deliberately designed so that the higher the tier the more
12
+ * "neon" and attention-grabbing the colour, while lower tiers fade toward dark red.
13
+ * `chalk.rgb()` is used for fine-grained control — terminal 256-colour and truecolour
14
+ * modes both support this; on terminals that don't, chalk gracefully degrades.
15
+ *
16
+ * @exports
17
+ * TIER_COLOR — object mapping tier string → chalk colouring function
18
+ *
19
+ * @see src/constants.js — TIER_CYCLE ordering that drives the T-key filter
20
+ * @see bin/free-coding-models.js — renderTable() uses TIER_COLOR per row
21
+ */
22
+
23
+ import chalk from 'chalk'
24
+
25
+ // 📖 Tier colors: green gradient (best) → yellow → orange → red (worst).
26
+ // 📖 Uses chalk.rgb() for fine-grained color control across 8 tier levels.
27
+ // 📖 Each entry is a function (t) => styled string so it can be applied to any text.
28
+ export const TIER_COLOR = {
29
+ 'S+': t => chalk.bold.rgb(0, 255, 80)(t), // 🟢 bright neon green — elite
30
+ 'S': t => chalk.bold.rgb(80, 220, 0)(t), // 🟢 green — excellent
31
+ 'A+': t => chalk.bold.rgb(170, 210, 0)(t), // 🟡 yellow-green — great
32
+ 'A': t => chalk.bold.rgb(240, 190, 0)(t), // 🟡 yellow — good
33
+ 'A-': t => chalk.bold.rgb(255, 130, 0)(t), // 🟠 amber — decent
34
+ 'B+': t => chalk.bold.rgb(255, 70, 0)(t), // 🟠 orange-red — average
35
+ 'B': t => chalk.bold.rgb(210, 20, 0)(t), // 🔴 red — below avg
36
+ 'C': t => chalk.bold.rgb(140, 0, 0)(t), // 🔴 dark red — lightweight
37
+ }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * @file lib/token-stats.js
3
+ * @description Persistent token usage tracking for the multi-account proxy.
4
+ *
5
+ * Records per-account and per-model token usage, hourly/daily aggregates,
6
+ * an in-memory ring buffer of the 100 most-recent requests, and an
7
+ * append-only JSONL log file for detailed history.
8
+ *
9
+ * Quota snapshots are intentionally stored at two granularities:
10
+ * - byProviderModel: precise UI data for a specific Origin + model pair
11
+ * - byProvider: fallback when a provider exposes account-level remaining quota
12
+ *
13
+ * We still keep the legacy byModel aggregate for backward compatibility and
14
+ * debugging, but the TUI no longer trusts it for display because the same
15
+ * model ID can exist under multiple Origins with different limits.
16
+ *
17
+ * Storage locations:
18
+ * ~/.free-coding-models/token-stats.json — aggregated stats (auto-saved every 10 records)
19
+ * ~/.free-coding-models/request-log.jsonl — timestamped per-request log (pruned after 30 days)
20
+ *
21
+ * @exports TokenStats
22
+ */
23
+
24
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync } from 'node:fs'
25
+ import { join } from 'node:path'
26
+ import { homedir } from 'node:os'
27
+
28
+ const DEFAULT_DATA_DIR = join(homedir(), '.free-coding-models')
29
+ const MAX_RING_BUFFER = 100
30
+ const RETENTION_DAYS = 30
31
+
32
+ function timestampToMillis(value) {
33
+ if (typeof value === 'number' && Number.isFinite(value)) return value
34
+ if (typeof value === 'string') {
35
+ const numeric = Number(value)
36
+ if (Number.isFinite(numeric)) return numeric
37
+ const parsed = Date.parse(value)
38
+ if (!Number.isNaN(parsed)) return parsed
39
+ }
40
+ return null
41
+ }
42
+
43
+ export class TokenStats {
44
+ /**
45
+ * @param {{ dataDir?: string }} [opts]
46
+ * dataDir — override the default ~/.free-coding-models directory (used in tests)
47
+ */
48
+ constructor({ dataDir } = {}) {
49
+ this._dataDir = dataDir || DEFAULT_DATA_DIR
50
+ this._statsFile = join(this._dataDir, 'token-stats.json')
51
+ this._logFile = join(this._dataDir, 'request-log.jsonl')
52
+ this._stats = {
53
+ byAccount: {},
54
+ byModel: {},
55
+ hourly: {},
56
+ daily: {},
57
+ quotaSnapshots: { byAccount: {}, byModel: {}, byProvider: {}, byProviderModel: {} },
58
+ }
59
+ this._ringBuffer = []
60
+ this._recordsSinceLastSave = 0
61
+ this._load()
62
+ setImmediate(() => this._pruneOldLogs())
63
+ }
64
+
65
+ _load() {
66
+ try {
67
+ mkdirSync(this._dataDir, { recursive: true })
68
+ if (existsSync(this._statsFile)) {
69
+ const loaded = JSON.parse(readFileSync(this._statsFile, 'utf8'))
70
+ this._stats = loaded
71
+ }
72
+ } catch { /* start fresh */ }
73
+ // Ensure quotaSnapshots always exists (backward compat for old files)
74
+ if (!this._stats.quotaSnapshots || typeof this._stats.quotaSnapshots !== 'object') {
75
+ this._stats.quotaSnapshots = { byAccount: {}, byModel: {}, byProvider: {}, byProviderModel: {} }
76
+ }
77
+ if (!this._stats.quotaSnapshots.byAccount) this._stats.quotaSnapshots.byAccount = {}
78
+ if (!this._stats.quotaSnapshots.byModel) this._stats.quotaSnapshots.byModel = {}
79
+ if (!this._stats.quotaSnapshots.byProvider) this._stats.quotaSnapshots.byProvider = {}
80
+ if (!this._stats.quotaSnapshots.byProviderModel) this._stats.quotaSnapshots.byProviderModel = {}
81
+ }
82
+
83
+ _pruneOldLogs() {
84
+ try {
85
+ if (!existsSync(this._logFile)) return
86
+ const cutoff = Date.now() - RETENTION_DAYS * 86400000
87
+ const lines = readFileSync(this._logFile, 'utf8').split('\n').filter(Boolean)
88
+ const kept = lines.filter(line => {
89
+ try {
90
+ const millis = timestampToMillis(JSON.parse(line).timestamp)
91
+ return millis !== null && millis >= cutoff
92
+ } catch {
93
+ return false
94
+ }
95
+ })
96
+ writeFileSync(this._logFile, kept.join('\n') + (kept.length ? '\n' : ''))
97
+ } catch { /* ignore */ }
98
+ }
99
+
100
+ /**
101
+ * Record a single request's token usage.
102
+ *
103
+ * @param {{ accountId: string, modelId: string, providerKey?: string, statusCode?: number|string, requestType?: string, promptTokens?: number, completionTokens?: number, latencyMs?: number, success?: boolean }} entry
104
+ */
105
+ record(entry) {
106
+ const {
107
+ accountId,
108
+ modelId,
109
+ providerKey = 'unknown',
110
+ statusCode = 200,
111
+ requestType = 'chat.completions',
112
+ promptTokens = 0,
113
+ completionTokens = 0,
114
+ latencyMs = 0,
115
+ success = true,
116
+ } = entry
117
+ const totalTokens = promptTokens + completionTokens
118
+ const now = new Date()
119
+ const hourKey = now.toISOString().slice(0, 13)
120
+ const dayKey = now.toISOString().slice(0, 10)
121
+
122
+ // By account
123
+ const acct = this._stats.byAccount[accountId] ||= { requests: 0, tokens: 0, errors: 0 }
124
+ acct.requests++
125
+ acct.tokens += totalTokens
126
+ if (!success) acct.errors++
127
+
128
+ // By model
129
+ const model = this._stats.byModel[modelId] ||= { requests: 0, tokens: 0 }
130
+ model.requests++
131
+ model.tokens += totalTokens
132
+
133
+ // Hourly
134
+ this._stats.hourly[hourKey] ||= { requests: 0, tokens: 0 }
135
+ this._stats.hourly[hourKey].requests++
136
+ this._stats.hourly[hourKey].tokens += totalTokens
137
+
138
+ // Daily
139
+ this._stats.daily[dayKey] ||= { requests: 0, tokens: 0 }
140
+ this._stats.daily[dayKey].requests++
141
+ this._stats.daily[dayKey].tokens += totalTokens
142
+
143
+ // Ring buffer (newest at end)
144
+ this._ringBuffer.push({ ...entry, timestamp: now.toISOString() })
145
+ if (this._ringBuffer.length > MAX_RING_BUFFER) this._ringBuffer.shift()
146
+
147
+ // JSONL log
148
+ try {
149
+ const logEntry = {
150
+ timestamp: now.toISOString(),
151
+ accountId,
152
+ modelId,
153
+ providerKey,
154
+ statusCode,
155
+ requestType,
156
+ promptTokens,
157
+ completionTokens,
158
+ latencyMs,
159
+ success,
160
+ }
161
+ appendFileSync(this._logFile, JSON.stringify(logEntry) + '\n')
162
+ } catch { /* ignore */ }
163
+
164
+ // Auto-save every 10 records
165
+ this._recordsSinceLastSave++
166
+ if (this._recordsSinceLastSave >= 10) this.save()
167
+ }
168
+
169
+ save() {
170
+ try {
171
+ mkdirSync(this._dataDir, { recursive: true })
172
+ writeFileSync(this._statsFile, JSON.stringify(this._stats, null, 2))
173
+ this._recordsSinceLastSave = 0
174
+ } catch { /* ignore */ }
175
+ }
176
+
177
+ /**
178
+ * Persist a quota snapshot for a single account.
179
+ * Also recomputes the per-model aggregate quota if modelId is provided.
180
+ * Tracks latest provider-level quota snapshot when providerKey is provided.
181
+ *
182
+ * Quota snapshots are lightweight (not per-request) and are written to
183
+ * token-stats.json immediately so the TUI can read them without waiting
184
+ * for the next 10-record auto-save cycle.
185
+ *
186
+ * @param {string} accountId
187
+ * @param {{ quotaPercent: number, providerKey?: string, modelId?: string, updatedAt?: string }} opts
188
+ */
189
+ updateQuotaSnapshot(accountId, { quotaPercent, providerKey, modelId, updatedAt } = {}) {
190
+ const previousSnap = this._stats.quotaSnapshots.byAccount[accountId]
191
+ const snap = {
192
+ quotaPercent,
193
+ updatedAt: updatedAt || new Date().toISOString(),
194
+ }
195
+ if (providerKey !== undefined) snap.providerKey = providerKey
196
+ if (modelId !== undefined) snap.modelId = modelId
197
+
198
+ this._stats.quotaSnapshots.byAccount[accountId] = snap
199
+
200
+ // 📖 Recompute the old aggregate buckets first when an account switches model/provider.
201
+ if (previousSnap?.modelId !== undefined) {
202
+ this._recomputeModelQuota(previousSnap.modelId)
203
+ }
204
+ if (previousSnap?.providerKey !== undefined && previousSnap?.modelId !== undefined) {
205
+ this._recomputeProviderModelQuota(previousSnap.providerKey, previousSnap.modelId)
206
+ }
207
+
208
+ if (modelId !== undefined) {
209
+ this._recomputeModelQuota(modelId)
210
+ }
211
+ if (providerKey !== undefined && modelId !== undefined) {
212
+ this._recomputeProviderModelQuota(providerKey, modelId)
213
+ }
214
+
215
+ if (providerKey !== undefined) {
216
+ this._stats.quotaSnapshots.byProvider[providerKey] = {
217
+ quotaPercent,
218
+ updatedAt: snap.updatedAt,
219
+ }
220
+ }
221
+
222
+ // Persist immediately (quota data must be fresh for TUI reads)
223
+ this.save()
224
+ }
225
+
226
+ /**
227
+ * Build a stable key for provider-scoped model quota snapshots.
228
+ *
229
+ * @param {string} providerKey
230
+ * @param {string} modelId
231
+ * @returns {string}
232
+ */
233
+ _providerModelKey(providerKey, modelId) {
234
+ return `${providerKey}::${modelId}`
235
+ }
236
+
237
+ /**
238
+ * Recompute the per-model quota snapshot by averaging all account snapshots
239
+ * that share the given modelId.
240
+ *
241
+ * @param {string} modelId
242
+ */
243
+ _recomputeModelQuota(modelId) {
244
+ const accountSnaps = Object.values(this._stats.quotaSnapshots.byAccount)
245
+ .filter(s => s.modelId === modelId)
246
+
247
+ if (accountSnaps.length === 0) {
248
+ delete this._stats.quotaSnapshots.byModel[modelId]
249
+ return
250
+ }
251
+
252
+ const avgPercent = Math.round(
253
+ accountSnaps.reduce((sum, s) => sum + s.quotaPercent, 0) / accountSnaps.length
254
+ )
255
+ const latestUpdatedAt = accountSnaps.reduce(
256
+ (latest, s) => (s.updatedAt > latest ? s.updatedAt : latest),
257
+ accountSnaps[0].updatedAt
258
+ )
259
+
260
+ this._stats.quotaSnapshots.byModel[modelId] = {
261
+ quotaPercent: avgPercent,
262
+ updatedAt: latestUpdatedAt,
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Recompute the provider-scoped quota snapshot for one Origin + model pair.
268
+ * This is the canonical source used by the TUI Usage column.
269
+ *
270
+ * @param {string} providerKey
271
+ * @param {string} modelId
272
+ */
273
+ _recomputeProviderModelQuota(providerKey, modelId) {
274
+ const accountSnaps = Object.values(this._stats.quotaSnapshots.byAccount)
275
+ .filter((s) => s.providerKey === providerKey && s.modelId === modelId)
276
+
277
+ const key = this._providerModelKey(providerKey, modelId)
278
+ if (accountSnaps.length === 0) {
279
+ delete this._stats.quotaSnapshots.byProviderModel[key]
280
+ return
281
+ }
282
+
283
+ const avgPercent = Math.round(
284
+ accountSnaps.reduce((sum, s) => sum + s.quotaPercent, 0) / accountSnaps.length
285
+ )
286
+ const latestUpdatedAt = accountSnaps.reduce(
287
+ (latest, s) => (s.updatedAt > latest ? s.updatedAt : latest),
288
+ accountSnaps[0].updatedAt
289
+ )
290
+
291
+ this._stats.quotaSnapshots.byProviderModel[key] = {
292
+ quotaPercent: avgPercent,
293
+ updatedAt: latestUpdatedAt,
294
+ providerKey,
295
+ modelId,
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Return a summary snapshot including the 10 most-recent requests.
301
+ *
302
+ * @returns {{ byAccount: object, byModel: object, hourly: object, daily: object, recentRequests: object[] }}
303
+ */
304
+ getSummary() {
305
+ return {
306
+ ...this._stats,
307
+ recentRequests: this._ringBuffer.slice(-10),
308
+ }
309
+ }
310
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @file token-usage-reader.js
3
+ * @description Reads historical token usage from request-log.jsonl and aggregates it by exact provider + model pair.
4
+ *
5
+ * @details
6
+ * The TUI already shows live latency and quota state, but that does not tell
7
+ * you how much you've actually consumed on a given Origin. This module reads
8
+ * the persistent JSONL request log once at startup and builds a compact
9
+ * `provider::model -> totalTokens` map for table display.
10
+ *
11
+ * Why this exists:
12
+ * - `token-stats.json` keeps convenience aggregates, but not the exact
13
+ * provider+model sum needed for the new table column.
14
+ * - `request-log.jsonl` is the source of truth because every proxied request
15
+ * records prompt and completion token counts with provider context.
16
+ * - Startup-only parsing keeps runtime overhead negligible during TUI redraws.
17
+ *
18
+ * @functions
19
+ * → `buildProviderModelTokenKey` — creates a stable aggregation key
20
+ * → `loadTokenUsageByProviderModel` — reads request-log.jsonl and returns total tokens by provider+model
21
+ * → `formatTokenTotalCompact` — renders totals as integer K / M strings for narrow columns
22
+ *
23
+ * @exports buildProviderModelTokenKey, loadTokenUsageByProviderModel, formatTokenTotalCompact
24
+ *
25
+ * @see src/log-reader.js
26
+ * @see src/render-table.js
27
+ */
28
+
29
+ import { loadRecentLogs } from './log-reader.js'
30
+
31
+ // 📖 buildProviderModelTokenKey keeps provider-scoped totals isolated even when
32
+ // 📖 multiple Origins expose the same model ID.
33
+ export function buildProviderModelTokenKey(providerKey, modelId) {
34
+ return `${providerKey}::${modelId}`
35
+ }
36
+
37
+ // 📖 loadTokenUsageByProviderModel reads the full bounded log history available
38
+ // 📖 through log-reader and sums tokens per exact provider+model pair.
39
+ export function loadTokenUsageByProviderModel({ logFile, limit = 50_000 } = {}) {
40
+ const rows = loadRecentLogs({ logFile, limit })
41
+ const totals = {}
42
+
43
+ for (const row of rows) {
44
+ const providerKey = typeof row.provider === 'string' ? row.provider : 'unknown'
45
+ const modelId = typeof row.model === 'string' ? row.model : 'unknown'
46
+ const tokens = Number(row.tokens) || 0
47
+ if (tokens <= 0) continue
48
+
49
+ const key = buildProviderModelTokenKey(providerKey, modelId)
50
+ totals[key] = (totals[key] || 0) + tokens
51
+ }
52
+
53
+ return totals
54
+ }
55
+
56
+ // 📖 formatTokenTotalCompact keeps the new column narrow and scannable:
57
+ // 📖 0-999 => raw integer, 1k-999k => Nk, 1m+ => NM, no decimals.
58
+ export function formatTokenTotalCompact(totalTokens) {
59
+ const safeTotal = Number(totalTokens) || 0
60
+ if (safeTotal >= 1_000_000) return `${Math.floor(safeTotal / 1_000_000)}M`
61
+ if (safeTotal >= 1_000) return `${Math.floor(safeTotal / 1_000)}k`
62
+ return String(Math.floor(safeTotal))
63
+ }
package/src/updater.js ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * @file updater.js
3
+ * @description Update detection and installation helpers, extracted from bin/free-coding-models.js.
4
+ *
5
+ * @details
6
+ * This module handles all npm version-check and auto-update logic:
7
+ *
8
+ * - `checkForUpdateDetailed()` — hits the npm registry to compare the published version
9
+ * against the locally installed one. Returns `{ latestVersion, error }` so callers
10
+ * can surface meaningful status text in the Settings overlay.
11
+ *
12
+ * - `checkForUpdate()` — thin backward-compatible wrapper used at startup for the
13
+ * auto-update guard. Returns `latestVersion` (string) or `null`.
14
+ *
15
+ * - `runUpdate(latestVersion)` — runs `npm i -g free-coding-models@<version> --prefer-online`,
16
+ * retrying with `sudo` on EACCES/EPERM. On success, relaunches the process with the
17
+ * same argv. On failure, prints manual instructions and exits with code 1.
18
+ * Uses `require('child_process').execSync` inline because ESM dynamic import is async
19
+ * but `execSync` must block to give `stdio: 'inherit'` feedback in the terminal.
20
+ *
21
+ * - `promptUpdateNotification(latestVersion)` — renders a small centered interactive menu
22
+ * that lets the user choose: Update Now / Read Changelogs / Continue without update.
23
+ * Uses raw mode readline keypress events (same pattern as the main TUI).
24
+ * This function is called BEFORE the alt-screen is entered, so it writes to the
25
+ * normal terminal buffer.
26
+ *
27
+ * ⚙️ Notes:
28
+ * - `LOCAL_VERSION` is resolved from package.json via `createRequire` so this module
29
+ * can be imported independently from the bin entry point.
30
+ * - The auto-update flow in `main()` skips update if `isDevMode` is detected (presence of
31
+ * a `.git` directory next to the package root) to avoid an infinite update loop in dev.
32
+ *
33
+ * @functions
34
+ * → checkForUpdateDetailed() — Fetch npm latest with explicit error info
35
+ * → checkForUpdate() — Startup wrapper, returns version string or null
36
+ * → runUpdate(latestVersion) — Install new version via npm global + relaunch
37
+ * → promptUpdateNotification(version) — Interactive pre-TUI update menu
38
+ *
39
+ * @exports
40
+ * checkForUpdateDetailed, checkForUpdate, runUpdate, promptUpdateNotification
41
+ *
42
+ * @see bin/free-coding-models.js — calls checkForUpdate() at startup and runUpdate() on confirm
43
+ */
44
+
45
+ import chalk from 'chalk'
46
+ import { createRequire } from 'module'
47
+
48
+ const require = createRequire(import.meta.url)
49
+ const readline = require('readline')
50
+ const pkg = require('../package.json')
51
+ const LOCAL_VERSION = pkg.version
52
+
53
+ /**
54
+ * 📖 checkForUpdateDetailed: Fetch npm latest version with explicit error details.
55
+ * 📖 Used by settings manual-check flow to display meaningful status in the UI.
56
+ * @returns {Promise<{ latestVersion: string|null, error: string|null }>}
57
+ */
58
+ export async function checkForUpdateDetailed() {
59
+ try {
60
+ const res = await fetch('https://registry.npmjs.org/free-coding-models/latest', { signal: AbortSignal.timeout(5000) })
61
+ if (!res.ok) return { latestVersion: null, error: `HTTP ${res.status}` }
62
+ const data = await res.json()
63
+ if (data.version && data.version !== LOCAL_VERSION) return { latestVersion: data.version, error: null }
64
+ return { latestVersion: null, error: null }
65
+ } catch (error) {
66
+ const message = error instanceof Error ? error.message : 'Unknown error'
67
+ return { latestVersion: null, error: message }
68
+ }
69
+ }
70
+
71
+ /**
72
+ * 📖 checkForUpdate: Backward-compatible wrapper for startup update prompt.
73
+ * @returns {Promise<string|null>}
74
+ */
75
+ export async function checkForUpdate() {
76
+ const { latestVersion } = await checkForUpdateDetailed()
77
+ return latestVersion
78
+ }
79
+
80
+ /**
81
+ * 📖 runUpdate: Run npm global install to update to latestVersion.
82
+ * 📖 Retries with sudo on permission errors.
83
+ * 📖 Relaunches the process on success, exits with code 1 on failure.
84
+ * @param {string} latestVersion
85
+ */
86
+ export function runUpdate(latestVersion) {
87
+ const { execSync } = require('child_process')
88
+ console.log()
89
+ console.log(chalk.bold.cyan(' ⬆ Updating free-coding-models to v' + latestVersion + '...'))
90
+ console.log()
91
+
92
+ try {
93
+ // 📖 Force install from npm registry (ignore local cache)
94
+ // 📖 Use --prefer-online to ensure we get the latest published version
95
+ execSync(`npm i -g free-coding-models@${latestVersion} --prefer-online`, { stdio: 'inherit' })
96
+ console.log()
97
+ console.log(chalk.green(' ✅ Update complete! Version ' + latestVersion + ' installed.'))
98
+ console.log()
99
+ console.log(chalk.dim(' 🔄 Restarting with new version...'))
100
+ console.log()
101
+
102
+ // 📖 Relaunch automatically with the same arguments
103
+ const args = process.argv.slice(2)
104
+ execSync(`node ${process.argv[1]} ${args.join(' ')}`, { stdio: 'inherit' })
105
+ process.exit(0)
106
+ } catch (err) {
107
+ console.log()
108
+ // 📖 Check if error is permission-related (EACCES or EPERM)
109
+ const isPermissionError = err.code === 'EACCES' || err.code === 'EPERM' ||
110
+ (err.stderr && (err.stderr.includes('EACCES') || err.stderr.includes('permission') ||
111
+ err.stderr.includes('EACCES'))) ||
112
+ (err.message && (err.message.includes('EACCES') || err.message.includes('permission')))
113
+
114
+ if (isPermissionError) {
115
+ console.log(chalk.yellow(' ⚠️ Permission denied. Retrying with sudo...'))
116
+ console.log()
117
+ try {
118
+ execSync(`sudo npm i -g free-coding-models@${latestVersion} --prefer-online`, { stdio: 'inherit' })
119
+ console.log()
120
+ console.log(chalk.green(' ✅ Update complete with sudo! Version ' + latestVersion + ' installed.'))
121
+ console.log()
122
+ console.log(chalk.dim(' 🔄 Restarting with new version...'))
123
+ console.log()
124
+
125
+ // 📖 Relaunch automatically with the same arguments
126
+ const args = process.argv.slice(2)
127
+ execSync(`node ${process.argv[1]} ${args.join(' ')}`, { stdio: 'inherit' })
128
+ process.exit(0)
129
+ } catch (sudoErr) {
130
+ console.log()
131
+ console.log(chalk.red(' ✖ Update failed even with sudo. Try manually:'))
132
+ console.log(chalk.dim(' sudo npm i -g free-coding-models@' + latestVersion))
133
+ console.log()
134
+ }
135
+ } else {
136
+ console.log(chalk.red(' ✖ Update failed. Try manually: npm i -g free-coding-models@' + latestVersion))
137
+ console.log()
138
+ }
139
+ }
140
+ process.exit(1)
141
+ }
142
+
143
+ /**
144
+ * 📖 promptUpdateNotification: Show a centered interactive menu when a new version is available.
145
+ * 📖 Returns 'update', 'changelogs', or null (continue without update).
146
+ * 📖 Called BEFORE entering the alt-screen so it renders in the normal terminal buffer.
147
+ * @param {string|null} latestVersion
148
+ * @returns {Promise<'update'|'changelogs'|null>}
149
+ */
150
+ export async function promptUpdateNotification(latestVersion) {
151
+ if (!latestVersion) return null
152
+
153
+ return new Promise((resolve) => {
154
+ let selected = 0
155
+ const options = [
156
+ {
157
+ label: 'Update now',
158
+ icon: '⬆',
159
+ description: `Update free-coding-models to v${latestVersion}`,
160
+ },
161
+ {
162
+ label: 'Read Changelogs',
163
+ icon: '📋',
164
+ description: 'Open GitHub changelog',
165
+ },
166
+ {
167
+ label: 'Continue without update',
168
+ icon: '▶',
169
+ description: 'Use current version',
170
+ },
171
+ ]
172
+
173
+ // 📖 Centered render function
174
+ const render = () => {
175
+ process.stdout.write('\x1b[2J\x1b[H') // clear screen + cursor home
176
+
177
+ // 📖 Calculate centering
178
+ const terminalWidth = process.stdout.columns || 80
179
+ const maxWidth = Math.min(terminalWidth - 4, 70)
180
+ const centerPad = ' '.repeat(Math.max(0, Math.floor((terminalWidth - maxWidth) / 2)))
181
+
182
+ console.log()
183
+ console.log(centerPad + chalk.bold.red(' ⚠ UPDATE AVAILABLE'))
184
+ console.log(centerPad + chalk.red(` Version ${latestVersion} is ready to install`))
185
+ console.log()
186
+ console.log(centerPad + chalk.bold(' ⚡ Free Coding Models') + chalk.dim(` v${LOCAL_VERSION}`))
187
+ console.log()
188
+
189
+ for (let i = 0; i < options.length; i++) {
190
+ const isSelected = i === selected
191
+ const bullet = isSelected ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
192
+ const label = isSelected
193
+ ? chalk.bold.white(options[i].icon + ' ' + options[i].label)
194
+ : chalk.dim(options[i].icon + ' ' + options[i].label)
195
+
196
+ console.log(centerPad + bullet + label)
197
+ console.log(centerPad + chalk.dim(' ' + options[i].description))
198
+ console.log()
199
+ }
200
+
201
+ console.log(centerPad + chalk.dim(' ↑↓ Navigate • Enter Select • Ctrl+C Continue'))
202
+ console.log()
203
+ }
204
+
205
+ render()
206
+
207
+ readline.emitKeypressEvents(process.stdin)
208
+ if (process.stdin.isTTY) process.stdin.setRawMode(true)
209
+
210
+ const onKey = (_str, key) => {
211
+ if (!key) return
212
+ if (key.ctrl && key.name === 'c') {
213
+ if (process.stdin.isTTY) process.stdin.setRawMode(false)
214
+ process.stdin.removeListener('keypress', onKey)
215
+ resolve(null) // Continue without update
216
+ return
217
+ }
218
+ if (key.name === 'up' && selected > 0) {
219
+ selected--
220
+ render()
221
+ } else if (key.name === 'down' && selected < options.length - 1) {
222
+ selected++
223
+ render()
224
+ } else if (key.name === 'return') {
225
+ if (process.stdin.isTTY) process.stdin.setRawMode(false)
226
+ process.stdin.removeListener('keypress', onKey)
227
+ process.stdin.pause()
228
+
229
+ if (selected === 0) resolve('update')
230
+ else if (selected === 1) resolve('changelogs')
231
+ else resolve(null) // Continue without update
232
+ }
233
+ }
234
+
235
+ process.stdin.on('keypress', onKey)
236
+ })
237
+ }