free-coding-models 0.5.16 → 0.5.18

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,13 @@
1
+ # Changelog v0.5.17 - 2026-06-08
2
+
3
+ ### Fixed
4
+ - **Docker provider key mapping** — replaced broken `sed` logic with a proper Node.js init script (`scripts/docker-init.mjs`). The script imports `ENV_VARS` from `config.js` (single source of truth) and generates clean entrypoint files. `docker-entrypoint.sh` simplified to a 1‑line Node call. Added `/api/key/:provider/test` POST endpoint to the daemon (was 404), and fixed the key test probe to use the first valid model from the provider instead of an empty string.
5
+ - **Docker compose** — cleaned 9 stale providers, added `GEMINI_API_KEY` and `OPENCODE_ZEN_API_KEY` support.
6
+
7
+ ### Updated
8
+ - **kandown** bumped from 0.4.0 → 0.8.0 (minor version bump, improved task kanban).
9
+ - **vite-plus** bumped to 0.1.24 (dependency fix).
10
+ - **docker/setup-qemu-action** bumped from 3 → 4 in CI (major bump, multi‑arch builds).
11
+
12
+ ### Added (test coverage)
13
+ - Added tests for Docker init script key mapping and `/api/key/:provider/test` endpoint (see `test/test.js`).
@@ -0,0 +1,12 @@
1
+ # Changelog v0.5.18 - 2026-06-09
2
+
3
+ ### Fixed
4
+ - Fixed the mandatory startup updater loop when the active `free-coding-models` binary is installed under one npm prefix but another Node/npm manager shadows `npm` in `PATH`. The updater now detects the npm prefix that owns the currently running package, installs into that exact prefix, and verifies that the active package version changed before relaunching.
5
+ - Improved updater fallback instructions so manual recovery commands include the owning npm binary and `--prefix` target when needed, instead of pointing users at a different global install.
6
+ - Stabilized the router dashboard SSE endpoints so `/api/events` and `/stream/events` flush an initial frame immediately and tests/clients do not hang while waiting for the first broadcast.
7
+
8
+ ### Changed
9
+ - Restored the normal startup update path and live OpenRouter/release-date startup behavior after the temporary local debug bypass used to diagnose the update loop.
10
+
11
+ ### Added
12
+ - Added regression coverage for npm-prefix-aware update command generation so future updater changes do not reintroduce the same cross-prefix loop.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.5.16",
3
+ "version": "0.5.18",
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",
@@ -67,7 +67,7 @@
67
67
  "@tabler/icons-react": "^3.44.0",
68
68
  "@tanstack/react-table": "^8.21.3",
69
69
  "chalk": "^5.6.2",
70
- "kandown": "^0.4.0",
70
+ "kandown": "^0.8.0",
71
71
  "socket.io": "^4.8.3",
72
72
  "socket.io-client": "^4.8.3"
73
73
  },
@@ -80,6 +80,6 @@
80
80
  "react": "^19.2.7",
81
81
  "react-dom": "^19.2.7",
82
82
  "vite": "^8.0.16",
83
- "vite-plus": "^0.1.23"
83
+ "vite-plus": "^0.1.24"
84
84
  }
85
85
  }
@@ -108,6 +108,7 @@ import { syncShellEnv } from './shell-env.js'
108
108
 
109
109
  // 📖 New JSON config path — stores all providers' API keys + enabled state
110
110
  export const CONFIG_PATH = join(homedir(), '.free-coding-models.json')
111
+ export { ENV_VARS }
111
112
 
112
113
  // 📖 Runtime data directory — backups and local snapshots live here.
113
114
  export const DAEMON_DATA_DIR = join(homedir(), '.free-coding-models')
@@ -47,7 +47,7 @@ import {
47
47
  normalizeRouterConfig,
48
48
  saveConfig,
49
49
  } from './config.js'
50
- import { buildChatCompletionPingBody, resolveCloudflareUrl, shouldUseDisabledThinkingForProvider } from './ping.js'
50
+ import { buildChatCompletionPingBody, ping, resolveCloudflareUrl, shouldUseDisabledThinkingForProvider } from './ping.js'
51
51
  import { benchmarkModel, BENCHMARK_TIMEOUT_MS } from './benchmark.js'
52
52
  import { sendUsageTelemetry } from './telemetry.js'
53
53
 
@@ -73,6 +73,7 @@ export function getRouterPortRange() {
73
73
 
74
74
  const __dirname = dirname(fileURLToPath(import.meta.url))
75
75
  const CLI_ENTRY_PATH = join(__dirname, '..', '..', 'bin', 'free-coding-models.js')
76
+ const LOCAL_VERSION = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8')).version
76
77
  const MAX_BODY_BYTES = 10 * 1024 * 1024
77
78
  const MAX_REQUEST_LOG = 200
78
79
  const MAX_SSE_CLIENTS = 10
@@ -2507,6 +2508,8 @@ class RouterRuntime {
2507
2508
  Connection: 'keep-alive',
2508
2509
  'x-request-id': requestId,
2509
2510
  })
2511
+ res.flushHeaders?.()
2512
+ res.write(': connected\n\n')
2510
2513
  res.write(`event: hello\ndata: ${JSON.stringify(this.statusPayload())}\n\n`)
2511
2514
  this.sseClients.add(res)
2512
2515
  req.on('close', () => this.sseClients.delete(res))
@@ -2537,6 +2540,22 @@ class RouterRuntime {
2537
2540
  sendJson(res, 200, getWebModelsPayload(this), { 'x-request-id': requestId })
2538
2541
  return
2539
2542
  }
2543
+ // 📖 Stub endpoints for the web dashboard's hooks (useToolMode, useFavorites,
2544
+ // 📖 useUpdateChecker). These were 404 before — minimal shapes that match
2545
+ // 📖 what the dashboard hooks expect. See PR #108 for context.
2546
+ if (req.method === 'GET' && (url.pathname === '/api/tool-mode')) {
2547
+ sendJson(res, 200, { mode: 'opencode', tools: ['opencode', 'openclaw', 'opencode-desktop', 'opencode-web'] }, { 'x-request-id': requestId })
2548
+ return
2549
+ }
2550
+ if (req.method === 'GET' && (url.pathname === '/api/favorites')) {
2551
+ const cfg = this.config || {}
2552
+ sendJson(res, 200, { favorites: cfg.favorites || [], pinnedAndSticky: Boolean(cfg.settings?.favoritesPinnedAndSticky) }, { 'x-request-id': requestId })
2553
+ return
2554
+ }
2555
+ if (req.method === 'GET' && (url.pathname === '/api/version')) {
2556
+ sendJson(res, 200, { local: LOCAL_VERSION, latest: null, lastReleaseDate: null, error: null }, { 'x-request-id': requestId })
2557
+ return
2558
+ }
2540
2559
  // 📖 /api/router/catalog — lightweight catalog of routeable models for
2541
2560
  // 📖 the Web Router Dashboard's "Add model" picker. Returns one row
2542
2561
  // 📖 per (provider, model) with `key`, label, tier, ctx. We filter to
@@ -2626,6 +2645,8 @@ class RouterRuntime {
2626
2645
  'Connection': 'keep-alive',
2627
2646
  'x-request-id': requestId,
2628
2647
  })
2648
+ res.flushHeaders?.()
2649
+ res.write(': connected\n\n')
2629
2650
  res.write(`data: ${JSON.stringify(getWebStatePayload(this))}\n\n`)
2630
2651
  this.sseClients.add(res)
2631
2652
  req.on('close', () => this.sseClients.delete(res))
@@ -2673,21 +2694,50 @@ class RouterRuntime {
2673
2694
  sendJson(res, result.started ? 202 : 409, result, { 'x-request-id': requestId })
2674
2695
  return
2675
2696
  }
2676
- if (req.method === 'GET' && url.pathname.startsWith('/api/key/')) {
2677
- // 📖 Reveals raw API keys — same-origin only to prevent malicious sites
2678
- // 📖 from exfiltrating provider credentials via XHR/fetch.
2697
+ if (url.pathname.startsWith('/api/key/')) {
2679
2698
  if (!isSameOriginOrLocal(req)) {
2680
2699
  sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
2681
2700
  return
2682
2701
  }
2683
- const providerKey = decodeURIComponent(url.pathname.slice('/api/key/'.length))
2684
- if (!providerKey || !sources[providerKey]) {
2685
- sendError(res, 404, 'Unknown provider', 'invalid_request_error', 'unknown_provider', requestId)
2702
+ const testMatch = url.pathname.match(/^\/api\/key\/([^/]+)\/test$/)
2703
+ if (testMatch && req.method === 'POST') {
2704
+ const providerKey = decodeURIComponent(testMatch[1])
2705
+ if (!sources[providerKey]) {
2706
+ sendError(res, 404, 'Unknown provider', 'invalid_request_error', 'unknown_provider', requestId)
2707
+ return
2708
+ }
2709
+ const apiKey = this.getApiKeyForProvider(providerKey)
2710
+ if (!apiKey) {
2711
+ sendJson(res, 200, { outcome: 'missing_key', detail: `${providerKey} has no saved API key.` }, { 'x-request-id': requestId })
2712
+ return
2713
+ }
2714
+ const providerModels = sources[providerKey]?.models || []
2715
+ const modelId = providerModels[0]?.[0] || ''
2716
+ try {
2717
+ const result = await ping(apiKey, modelId, providerKey, sources[providerKey].url)
2718
+ const code = result?.code
2719
+ if (code === '200') {
2720
+ sendJson(res, 200, { outcome: 'ok', code: 200 }, { 'x-request-id': requestId })
2721
+ } else if (code === '401' || code === '403') {
2722
+ sendJson(res, 200, { outcome: 'auth_error', code: Number(code) || code }, { 'x-request-id': requestId })
2723
+ } else {
2724
+ sendJson(res, 200, { outcome: 'fail', code: code ?? 'ERR', detail: 'Probe did not return a 2xx' }, { 'x-request-id': requestId })
2725
+ }
2726
+ } catch (err) {
2727
+ sendJson(res, 200, { outcome: 'fail', detail: err.message || 'Probe failed' }, { 'x-request-id': requestId })
2728
+ }
2729
+ return
2730
+ }
2731
+ if (req.method === 'GET') {
2732
+ const providerKey = decodeURIComponent(url.pathname.slice('/api/key/'.length))
2733
+ if (!providerKey || !sources[providerKey]) {
2734
+ sendError(res, 404, 'Unknown provider', 'invalid_request_error', 'unknown_provider', requestId)
2735
+ return
2736
+ }
2737
+ const rawKey = this.getApiKeyForProvider(providerKey)
2738
+ sendJson(res, 200, { key: rawKey || null }, { 'x-request-id': requestId })
2686
2739
  return
2687
2740
  }
2688
- const rawKey = this.getApiKeyForProvider(providerKey)
2689
- sendJson(res, 200, { key: rawKey || null }, { 'x-request-id': requestId })
2690
- return
2691
2741
  }
2692
2742
  if (req.method === 'POST' && url.pathname === '/api/settings') {
2693
2743
  // 📖 Writes API keys + provider toggles — same-origin only to block
@@ -2881,7 +2931,7 @@ const PREFERRED_DEFAULT_MODELS = [
2881
2931
  * @param {object} [options] { probeFn: async (entry) => ({ ok, latencyMs, code }) }
2882
2932
  * @returns {{ name: string, models: Array, created: string }}
2883
2933
  */
2884
- export async function buildDefaultRouterSet(config = {}, maxModels = 5, options = {}) {
2934
+ export async function buildDefaultRouterSet(config = {}, maxModels, options = {}) {
2885
2935
  const probeFn = typeof options.probeFn === 'function' ? options.probeFn : null
2886
2936
  const probeTimeoutMs = typeof options.probeTimeoutMs === 'number' ? options.probeTimeoutMs : 1500
2887
2937
  const probeBudget = typeof options.probeBudget === 'number' ? options.probeBudget : 24
@@ -2890,6 +2940,10 @@ export async function buildDefaultRouterSet(config = {}, maxModels = 5, options
2890
2940
  .filter(([, value]) => (Array.isArray(value) ? value.length > 0 : typeof value === 'string' && value.trim()))
2891
2941
  .map(([provider]) => provider))
2892
2942
 
2943
+ // 📖 Scale default set size with configured providers so users with many
2944
+ // 📖 keys get a richer default router set (PR #108 idea, kept from the
2945
+ // 📖 previous sync version).
2946
+ if (maxModels === undefined) maxModels = Math.max(5, keyedProviders.size * 2)
2893
2947
  const entries = []
2894
2948
  for (const [providerKey, source] of Object.entries(sources)) {
2895
2949
  if (!isRouteableProvider(providerKey)) continue
@@ -28,6 +28,7 @@
28
28
  *
29
29
  * @functions
30
30
  * → detectPackageManager() — Detect which PM owns the current installation
31
+ * → resolveCurrentNpmInstallTarget() — Detect the npm prefix that owns the active package
31
32
  * → getInstallArgs(pm, version) — Build correct { bin, args } per package manager
32
33
  * → getManualInstallCmd(pm, version) — Human-readable install command string for error messages
33
34
  * → checkForUpdateDetailed() — Fetch npm latest with explicit error info
@@ -36,7 +37,7 @@
36
37
  * → enforceMandatoryStartupUpdate() — Mandatory startup self-update with two-failure fallback
37
38
  * → runUpdate(latestVersion) — Install new version via detected PM + relaunch
38
39
  * @exports
39
- * detectPackageManager, getInstallArgs, getManualInstallCmd,
40
+ * detectPackageManager, resolveCurrentNpmInstallTarget, getInstallArgs, getManualInstallCmd,
40
41
  * checkForUpdateDetailed, checkForUpdate, isPackageDevMode,
41
42
  * enforceMandatoryStartupUpdate, runUpdate, fetchLastReleaseDate
42
43
  *
@@ -47,15 +48,42 @@ import chalk from 'chalk'
47
48
  import { createRequire } from 'module'
48
49
  import { fileURLToPath } from 'url'
49
50
  import { dirname, join } from 'path'
50
- import { accessSync, constants, existsSync } from 'fs'
51
+ import { accessSync, constants, existsSync, readFileSync } from 'fs'
51
52
 
52
53
  const require = createRequire(import.meta.url)
53
54
  const readline = require('readline')
54
55
  const pkg = require('../../package.json')
55
56
  const LOCAL_VERSION = pkg.version
56
57
  const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
58
+ const PACKAGE_NAME = 'free-coding-models'
57
59
  export const UPDATE_FAILURE_THRESHOLD = 2
58
60
 
61
+ /**
62
+ * 📖 resolveCurrentNpmInstallTarget: detect the npm prefix that owns this exact
63
+ * 📖 running package, not just the first `npm` found in PATH. Users can run FCM
64
+ * 📖 from one global install while another Node manager shadows `npm`; updating
65
+ * 📖 the shadow prefix leaves the active binary stale and creates an update loop.
66
+ * @param {string} [packageRoot]
67
+ * @returns {{ packageRoot: string, prefix: string, bin: string } | null}
68
+ */
69
+ export function resolveCurrentNpmInstallTarget(packageRoot = PACKAGE_ROOT) {
70
+ const normalizedRoot = String(packageRoot || '').replace(/\\/g, '/')
71
+ const suffix = `/lib/node_modules/${PACKAGE_NAME}`
72
+ if (!normalizedRoot.endsWith(suffix)) return null
73
+
74
+ const prefix = packageRoot.slice(0, packageRoot.length - suffix.length)
75
+ if (!prefix) return null
76
+
77
+ const npmBinName = process.platform === 'win32' ? 'npm.cmd' : 'npm'
78
+ const npmBin = join(prefix, 'bin', npmBinName)
79
+
80
+ return {
81
+ packageRoot,
82
+ prefix,
83
+ bin: existsSync(npmBin) ? npmBin : 'npm',
84
+ }
85
+ }
86
+
59
87
  /**
60
88
  * 📖 detectPackageManager: figure out which package manager owns the current installation.
61
89
  * 📖 Checks import.meta.url (package install path), process.argv[1] (script entry),
@@ -141,27 +169,48 @@ export function buildOutdatedWarningMessage(latestVersion, failures = UPDATE_FAI
141
169
  * 📖 Each PM has different syntax for global install — this normalises them.
142
170
  * @param {'npm' | 'bun' | 'pnpm' | 'yarn'} pm
143
171
  * @param {string} version
172
+ * @param {{ prefix?: string, bin?: string }} [options]
144
173
  * @returns {{ bin: string, args: string[] }}
145
174
  */
146
- export function getInstallArgs(pm, version) {
147
- const pkg = `free-coding-models@${version}`
175
+ export function getInstallArgs(pm, version, options = {}) {
176
+ const pkg = `${PACKAGE_NAME}@${version}`
148
177
  switch (pm) {
149
178
  case 'bun': return { bin: 'bun', args: ['add', '-g', pkg] }
150
179
  case 'pnpm': return { bin: 'pnpm', args: ['add', '-g', pkg] }
151
180
  case 'yarn': return { bin: 'yarn', args: ['global', 'add', pkg] }
152
- default: return { bin: 'npm', args: ['i', '-g', pkg, '--prefer-online'] }
181
+ default: {
182
+ const args = ['i', '-g']
183
+ if (options.prefix) args.push('--prefix', options.prefix)
184
+ args.push(pkg, '--prefer-online')
185
+ return { bin: options.bin || 'npm', args }
186
+ }
153
187
  }
154
188
  }
155
189
 
190
+ function shellQuoteArg(arg) {
191
+ const value = String(arg)
192
+ if (/^[A-Za-z0-9_./:@+=,-]+$/.test(value)) return value
193
+ return `'${value.replace(/'/g, "'\\''")}'`
194
+ }
195
+
196
+ function getCurrentInstallOptions(pm = detectPackageManager()) {
197
+ const installTarget = pm === 'npm' ? resolveCurrentNpmInstallTarget() : null
198
+ return pm === 'npm' && installTarget ? {
199
+ prefix: installTarget.prefix,
200
+ bin: installTarget.bin,
201
+ } : {}
202
+ }
203
+
156
204
  /**
157
205
  * 📖 getManualInstallCmd: human-readable command string for error / fallback messages.
158
206
  * @param {'npm' | 'bun' | 'pnpm' | 'yarn'} pm
159
207
  * @param {string} version
208
+ * @param {{ prefix?: string, bin?: string }} [options]
160
209
  * @returns {string}
161
210
  */
162
- export function getManualInstallCmd(pm, version) {
163
- const { bin, args } = getInstallArgs(pm, version)
164
- return `${bin} ${args.join(' ')}`
211
+ export function getManualInstallCmd(pm, version, options = {}) {
212
+ const { bin, args } = getInstallArgs(pm, version, options)
213
+ return [bin, ...args].map(shellQuoteArg).join(' ')
165
214
  }
166
215
 
167
216
  /**
@@ -262,14 +311,16 @@ export async function enforceMandatoryStartupUpdate(config, options = {}) {
262
311
  base.allowedOutdated = true
263
312
  base.warningMessage = buildOutdatedWarningMessage(latestVersion, failures)
264
313
  console.log(chalk.red(` ${base.warningMessage}`))
265
- console.log(chalk.dim(` Manual update: ${getManualInstallCmd(detectPackageManager(), latestVersion)}`))
314
+ const pm = detectPackageManager()
315
+ console.log(chalk.dim(` Manual update: ${getManualInstallCmd(pm, latestVersion, getCurrentInstallOptions(pm))}`))
266
316
  console.log()
267
317
  return base
268
318
  }
269
319
 
270
320
  base.blocked = true
271
321
  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)}`))
322
+ const pm = detectPackageManager()
323
+ console.log(chalk.dim(` Attempt ${failures}/${UPDATE_FAILURE_THRESHOLD}. Manual update: ${getManualInstallCmd(pm, latestVersion, getCurrentInstallOptions(pm))}`))
273
324
  console.log()
274
325
  return base
275
326
  }
@@ -305,9 +356,10 @@ export async function fetchLastReleaseDate() {
305
356
  * 📖 Bun installs to ~/.bun/install/global/ (always user-writable) so sudo is never needed.
306
357
  * 📖 For npm/pnpm/yarn we probe their global root/prefix paths and check writability.
307
358
  * @param {'npm' | 'bun' | 'pnpm' | 'yarn'} pm
359
+ * @param {{ packageRoot: string, prefix: string, bin: string } | null} [installTarget]
308
360
  * @returns {{ needsSudo: boolean, checkedPath: string|null }}
309
361
  */
310
- function detectGlobalInstallPermission(pm) {
362
+ function detectGlobalInstallPermission(pm, installTarget = null) {
311
363
  if (pm === 'bun') {
312
364
  return { needsSudo: false, checkedPath: null }
313
365
  }
@@ -326,13 +378,20 @@ function detectGlobalInstallPermission(pm) {
326
378
  if (dir) candidates.push(dir)
327
379
  } catch {}
328
380
  } else {
381
+ if (installTarget?.prefix) {
382
+ candidates.push(join(installTarget.prefix, 'lib', 'node_modules'))
383
+ candidates.push(installTarget.prefix)
384
+ }
385
+
386
+ const npmBin = installTarget?.bin || 'npm'
387
+
329
388
  try {
330
- const npmRoot = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
389
+ const npmRoot = execFileSync(npmBin, ['root', '-g'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
331
390
  if (npmRoot) candidates.push(npmRoot)
332
391
  } catch {}
333
392
 
334
393
  try {
335
- const npmPrefix = execFileSync('npm', ['prefix', '-g'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
394
+ const npmPrefix = execFileSync(npmBin, ['prefix', '-g'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
336
395
  if (npmPrefix) candidates.push(npmPrefix)
337
396
  } catch {}
338
397
  }
@@ -348,6 +407,28 @@ function detectGlobalInstallPermission(pm) {
348
407
  return { needsSudo: false, checkedPath: candidates[0] || null }
349
408
  }
350
409
 
410
+ function readCurrentPackageVersion() {
411
+ try {
412
+ const data = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8'))
413
+ return typeof data.version === 'string' ? data.version : null
414
+ } catch {
415
+ return null
416
+ }
417
+ }
418
+
419
+ function verifyCurrentInstallUpdated(latestVersion, installTarget) {
420
+ if (!installTarget) return
421
+
422
+ const installedVersion = readCurrentPackageVersion()
423
+ if (installedVersion === latestVersion) return
424
+
425
+ const actual = installedVersion ? `v${installedVersion}` : 'an unknown version'
426
+ throw new Error(
427
+ `Update command completed, but the active install at ${installTarget.packageRoot} still reports ${actual}. ` +
428
+ 'The package manager likely installed into another global prefix.'
429
+ )
430
+ }
431
+
351
432
  /**
352
433
  * 📖 hasSudoCommand: lightweight guard so we don't suggest sudo on systems where it does not exist.
353
434
  * @returns {boolean}
@@ -402,11 +483,16 @@ function relaunchCurrentProcess() {
402
483
  * 📖 installUpdateCommand: run global install using the detected package manager, optionally prefixed with sudo.
403
484
  * @param {string} latestVersion
404
485
  * @param {boolean} useSudo
486
+ * @param {{ packageRoot: string, prefix: string, bin: string } | null} [installTarget]
405
487
  */
406
- function installUpdateCommand(latestVersion, useSudo) {
488
+ function installUpdateCommand(latestVersion, useSudo, installTarget = null) {
407
489
  const { execFileSync } = require('child_process')
408
490
  const pm = detectPackageManager()
409
- const { bin, args } = getInstallArgs(pm, latestVersion)
491
+ const installOptions = pm === 'npm' && installTarget ? {
492
+ prefix: installTarget.prefix,
493
+ bin: installTarget.bin,
494
+ } : {}
495
+ const { bin, args } = getInstallArgs(pm, latestVersion, installOptions)
410
496
 
411
497
  if (useSudo) {
412
498
  execFileSync('sudo', [bin, ...args], { stdio: 'inherit', shell: false })
@@ -433,7 +519,12 @@ export function runUpdate(latestVersion, options = {}) {
433
519
  console.log()
434
520
 
435
521
  const pm = detectPackageManager()
436
- const { needsSudo, checkedPath } = detectGlobalInstallPermission(pm)
522
+ const installTarget = pm === 'npm' ? resolveCurrentNpmInstallTarget() : null
523
+ const installOptions = pm === 'npm' && installTarget ? {
524
+ prefix: installTarget.prefix,
525
+ bin: installTarget.bin,
526
+ } : {}
527
+ const { needsSudo, checkedPath } = detectGlobalInstallPermission(pm, installTarget)
437
528
  const sudoAvailable = process.platform !== 'win32' && hasSudoCommand()
438
529
  let lastError = null
439
530
 
@@ -444,7 +535,8 @@ export function runUpdate(latestVersion, options = {}) {
444
535
  }
445
536
 
446
537
  try {
447
- installUpdateCommand(latestVersion, needsSudo && sudoAvailable)
538
+ installUpdateCommand(latestVersion, needsSudo && sudoAvailable, installTarget)
539
+ verifyCurrentInstallUpdated(latestVersion, installTarget)
448
540
  console.log()
449
541
  console.log(chalk.green(` ✅ Update complete! Version ${latestVersion} installed.`))
450
542
  console.log()
@@ -452,13 +544,14 @@ export function runUpdate(latestVersion, options = {}) {
452
544
  return { ok: true }
453
545
  } catch (err) {
454
546
  lastError = err
455
- const manualCmd = getManualInstallCmd(pm, latestVersion)
547
+ const manualCmd = getManualInstallCmd(pm, latestVersion, installOptions)
456
548
  console.log()
457
549
  if (isPermissionError(err) && !needsSudo && sudoAvailable) {
458
550
  console.log(chalk.yellow(` ⚠ Permission denied during ${pm} global install. Retrying with sudo...`))
459
551
  console.log()
460
552
  try {
461
- installUpdateCommand(latestVersion, true)
553
+ installUpdateCommand(latestVersion, true, installTarget)
554
+ verifyCurrentInstallUpdated(latestVersion, installTarget)
462
555
  console.log()
463
556
  console.log(chalk.green(` ✅ Update complete with sudo! Version ${latestVersion} installed.`))
464
557
  console.log()
@@ -484,5 +577,3 @@ export function runUpdate(latestVersion, options = {}) {
484
577
  if (exitOnFailure) process.exit(1)
485
578
  return { ok: false, error: lastError }
486
579
  }
487
-
488
-