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.
- package/changelog/v0.5.17.md +13 -0
- package/changelog/v0.5.18.md +12 -0
- package/package.json +3 -3
- package/src/core/config.js +1 -0
- package/src/core/router-daemon.js +65 -11
- package/src/core/updater.js +112 -21
- package/web/dist/assets/{index-vsysCImz.js → index-DQJPlPM4.js} +2 -2
- package/web/dist/index.html +1 -1
|
@@ -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.
|
|
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.
|
|
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.
|
|
83
|
+
"vite-plus": "^0.1.24"
|
|
84
84
|
}
|
|
85
85
|
}
|
package/src/core/config.js
CHANGED
|
@@ -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 (
|
|
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
|
|
2684
|
-
if (
|
|
2685
|
-
|
|
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
|
|
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
|
package/src/core/updater.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|