@wwkit/opm 1.0.21 → 1.0.23

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.
@@ -1,117 +1,305 @@
1
1
  /**
2
- * node(Node.js)版本管理器实现(基于 nvm)
2
+ * node(Node.js)版本管理器实现(基于 mise)
3
3
  *
4
- * 通过 nvm CLI 管理多个 Node.js 版本。nvm 是 shell function,需通过
5
- * bash -c 'source $NVM_DIR/nvm.sh && nvm ...' 调用。
4
+ * 通过 mise CLI 管理多个 Node.js 版本。mise 是原生跨平台二进制(Rust),
5
+ * 非 shell function,直接调用即可(无需 bash)。
6
6
  *
7
- * 源查询使用 nvm ls-remote,本地查询使用 nvm ls。
8
- * 镜像通过 NVM_NODEJS_ORG_MIRROR 环境变量传递给 nvm install。
7
+ * 源查询使用 mise ls-remote node,本地查询使用 mise ls node --json(机器可读)。
8
+ * 镜像通过 MISE_NODE_MIRROR_URL 环境变量传递给 mise(node 后端 mirror_url)。
9
+ * proxy 通过 HTTP(S)_PROXY 环境变量传递(RuntimeManager._buildEnv 已处理)。
9
10
  */
10
11
 
11
12
  import fs from 'node:fs'
12
13
  import path from 'node:path'
13
14
  import os from 'node:os'
14
- import { execSync } from 'node:child_process'
15
+ import { execSync, spawnSync } from 'node:child_process'
15
16
  import { RuntimeManager } from './runtime.js'
16
- import { Shell } from '@wwkit/shared'
17
- import { getActiveRegistry } from '../config.js'
17
+ import { downloadToFile, fetchText } from '@wwkit/shared'
18
+ import { getConfig } from '../config.js'
18
19
 
19
- const shell = new Shell()
20
+ /** mise 官方 release 下载 base URL(config mise.registry.active 覆盖) */
21
+ const MISE_DEFAULT_BASE = 'https://github.com/jdx/mise/releases/download'
22
+ /** 单次下载超时(mise 二进制约 20MB,镜像下载较慢,给足余量) */
23
+ const MISE_DOWNLOAD_TIMEOUT = 180000
24
+
25
+ /** 平台名映射(mise release 资产命名:windows / linux / macos) */
26
+ const MISE_PLATFORM = { win32: 'windows', linux: 'linux', darwin: 'macos' }
20
27
 
21
28
  /**
22
- * 获取 nvm 目录
29
+ * 获取 mise 安装目录(~/.local/bin,跨平台与 uv 一致)
23
30
  * @returns {string}
24
31
  */
25
- function getNvmDir() {
26
- return process.env.NVM_DIR || path.join(os.homedir(), '.nvm')
32
+ export function getMiseInstallDir() {
33
+ return path.join(os.homedir(), '.local', 'bin')
27
34
  }
28
35
 
29
36
  /**
30
- * 获取 nvm.sh 路径
37
+ * 获取 mise 二进制路径(优先 ~/.local/bin,否则依赖 PATH)
31
38
  * @returns {string}
32
39
  */
33
- export function getNvmSh() {
34
- return path.join(getNvmDir(), 'nvm.sh')
40
+ export function getMiseBin() {
41
+ const name = process.platform === 'win32' ? 'mise.exe' : 'mise'
42
+ const local = path.join(getMiseInstallDir(), name)
43
+ if (fs.existsSync(local)) return local
44
+ return 'mise'
35
45
  }
36
46
 
37
47
  /**
38
- * 检查 nvm 是否已安装
48
+ * 检查 mise 是否已安装
39
49
  * @returns {boolean}
40
50
  */
41
- export function isNvmInstalled() {
42
- return fs.existsSync(getNvmSh())
51
+ export function isMiseInstalled() {
52
+ try {
53
+ execSync(`${getMiseBin()} --version`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
54
+ return true
55
+ } catch {
56
+ return false
57
+ }
58
+ }
59
+
60
+ /**
61
+ * 获取 mise 最新版本号
62
+ * 优先 npm registry 镜像(快且稳,走配置的 npm 镜像),回退 GitHub API。
63
+ * @param {string} [proxy]
64
+ * @returns {Promise<string>} 版本号(如 2026.9.13);获取失败返回空串
65
+ * @private
66
+ */
67
+ async function _latestMiseVersion(proxy) {
68
+ try {
69
+ const out = execSync('npm view mise version', {
70
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 30000,
71
+ })
72
+ const v = out.trim()
73
+ if (v) return v
74
+ } catch {}
75
+ try {
76
+ const text = await fetchText('https://api.github.com/repos/jdx/mise/releases/latest', { proxy })
77
+ const data = JSON.parse(text)
78
+ return (data.tag_name || '').replace(/^v/, '')
79
+ } catch {}
80
+ return ''
81
+ }
82
+
83
+ /**
84
+ * 构造 mise release 资产 URL:<base>/v<version>/mise-v<version>-<platform>-<arch>[.exe]
85
+ * @param {string} version
86
+ * @param {string} base
87
+ * @returns {string}
88
+ * @private
89
+ */
90
+ function _miseAssetUrl(version, base) {
91
+ const platform = MISE_PLATFORM[process.platform] || process.platform
92
+ const arch = process.arch
93
+ const ext = process.platform === 'win32' ? '.exe' : ''
94
+ return `${base}/v${version}/mise-v${version}-${platform}-${arch}${ext}`
95
+ }
96
+
97
+ /**
98
+ * 生成 mise release 下载 base 尝试顺序:active → 其余预设(去重) → official
99
+ * @returns {string[]}
100
+ * @private
101
+ */
102
+ function _miseBases() {
103
+ const reg = getConfig().mise?.registry || {}
104
+ const ordered = []
105
+ const add = (u) => { if (u && !ordered.includes(u)) ordered.push(u) }
106
+ const active = reg.active ? (reg[reg.active] || reg.active) : ''
107
+ add(active)
108
+ for (const [key, url] of Object.entries(reg)) {
109
+ if (key === 'active') continue
110
+ add(url)
111
+ }
112
+ add(MISE_DEFAULT_BASE)
113
+ return ordered
43
114
  }
44
115
 
45
116
  /**
46
- * 安装 nvm(git clone + 写 shell profile)
117
+ * 安装 mise(下载官方 GitHub release 二进制;npm 分发在 Windows 上不可用,故直接用 release 资产)
118
+ *
119
+ * 镜像:config mise.registry 预设,依次尝试 active → 其余预设 → official,
120
+ * 首个下载成功即停(自动回退到可用镜像,如 ghproxy.net);
121
+ * proxy:downloadToFile 走 opts.proxy。
122
+ * 下载到 ~/.local/bin/mise[.exe],Unix 下 chmod +x。
47
123
  * @param {string} [proxy] - 代理地址
48
- * @returns {{ action: string, profile: string, hint: string }}
124
+ * @returns {Promise<{ action: string, version: string, path: string, base: string, hint: string }>}
49
125
  */
50
- export function installNvm(proxy) {
51
- const mirror = getActiveRegistry('nvm')
52
- const nvmDir = getNvmDir()
53
- const env = { ...process.env }
54
- if (proxy) {
55
- env.http_proxy = proxy
56
- env.https_proxy = proxy
126
+ export async function installMise(proxy) {
127
+ const version = await _latestMiseVersion(proxy)
128
+ if (!version) {
129
+ throw new Error('mise: 无法获取最新版本(npm registry 与 GitHub API 均不可用)')
57
130
  }
58
- execSync(`git clone "${mirror}" "${nvmDir}"`, { stdio: shell.stdioForExec(true), shell: shell.resolveBash(), env })
59
- const lines = [
60
- `export NVM_DIR="${nvmDir}"`,
61
- `[ -s "\\$NVM_DIR/nvm.sh" ] && \\. "\\$NVM_DIR/nvm.sh"`,
62
- ]
63
- const wroteProfile = shell.writeProfile('NVM_DIR', 'nvm', lines)
64
- return {
65
- action: 'installed',
66
- profile: wroteProfile ? shell.getProfile() : '(already configured)',
67
- hint: `Restart shell or run: source "${path.join(nvmDir, 'nvm.sh')}"`,
131
+ const binDir = getMiseInstallDir()
132
+ const binPath = path.join(binDir, process.platform === 'win32' ? 'mise.exe' : 'mise')
133
+ fs.mkdirSync(binDir, { recursive: true })
134
+
135
+ const bases = _miseBases()
136
+ let lastError = null
137
+ for (const base of bases) {
138
+ const url = _miseAssetUrl(version, base)
139
+ try {
140
+ fs.rmSync(binPath, { force: true })
141
+ await downloadToFile(url, binPath, { proxy, timeout: MISE_DOWNLOAD_TIMEOUT })
142
+ if (process.platform !== 'win32') {
143
+ fs.chmodSync(binPath, 0o755)
144
+ }
145
+ return {
146
+ action: 'installed',
147
+ version,
148
+ path: binPath,
149
+ base,
150
+ hint: `mise ${version} installed at ${binPath} (source: ${base}); ensure ${binDir} is on PATH`,
151
+ }
152
+ } catch (err) {
153
+ lastError = err
154
+ fs.rmSync(binPath, { force: true })
155
+ }
156
+ }
157
+ throw new Error(`mise: 所有下载源均失败,最后错误: ${lastError ? lastError.message : 'unknown'}`)
158
+ }
159
+
160
+ /**
161
+ * 读取系统 PATH(HKLM,Windows)
162
+ *
163
+ * 用 spawnSync 数组参数直调 reg(不经 cmd),避免 cmd 对值中 %VAR% 的展开与引号问题。
164
+ * @param {Function} [spawnSyncImpl] - 可注入的 spawnSync(测试用)
165
+ * @returns {string} 系统 PATH 字符串;读取失败返回空串
166
+ */
167
+ export function getSystemPath(spawnSyncImpl = spawnSync) {
168
+ try {
169
+ const r = spawnSyncImpl(
170
+ 'reg', ['query', 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', '/v', 'Path'],
171
+ { encoding: 'utf8' }
172
+ )
173
+ if (r.status !== 0) return ''
174
+ const m = String(r.stdout || '').match(/^\s*Path\s+REG_\w+\s+(.*)$/m)
175
+ return m ? m[1].trim() : ''
176
+ } catch {
177
+ return ''
68
178
  }
69
179
  }
70
180
 
71
181
  /**
72
- * 解析 nvm ls 输出为已安装版本列表
182
+ * 检测系统 PATH(HKLM)中是否含 node.exe 的目录(Windows)
183
+ *
184
+ * Windows 新进程 PATH = 系统 PATH + 用户 PATH,系统 PATH 优先。若系统 PATH 有
185
+ * 旧版 node(如 D:\library\nodejs\node.exe),会抢先于用户 PATH 的 mise shims,
186
+ * 导致新终端中 node 仍是旧版。返回该目录路径;无冲突返回空串。
187
+ * @param {Function} [spawnSyncImpl] - 可注入的 spawnSync(测试用)
188
+ * @returns {string}
189
+ */
190
+ export function detectSystemNodeConflict(spawnSyncImpl = spawnSync) {
191
+ if (process.platform !== 'win32') return ''
192
+ const sysPath = getSystemPath(spawnSyncImpl)
193
+ for (const entry of sysPath.split(';')) {
194
+ const dir = entry.trim()
195
+ if (!dir) continue
196
+ try {
197
+ if (fs.existsSync(path.join(dir, 'node.exe'))) return dir
198
+ } catch {}
199
+ }
200
+ return ''
201
+ }
202
+
203
+ /**
204
+ * 把指定目录前置到系统 PATH(HKLM,Windows,幂等)
205
+ *
206
+ * 已在系统 PATH 则移到最前(不重复);写回保留 REG_EXPAND_SZ 类型。
207
+ * 用 spawnSync 数组参数直调 reg(不经 cmd),避免系统 PATH 中的 %VAR% 被
208
+ * cmd 先行展开(写回会丢失变量引用)及引号破坏命令。
209
+ * 写 HKLM 需管理员权限,失败(Access denied 等)返回 false,由调用方决定提示策略。
210
+ * @param {string[]} dirs - 需前置的目录(按序前置,最后一个在最前)
211
+ * @param {Function} [spawnSyncImpl] - 可注入的 spawnSync(测试用)
212
+ * @returns {boolean} 是否写入成功
213
+ */
214
+ export function prependSystemPathDirs(dirs, spawnSyncImpl = spawnSync) {
215
+ const sysPath = getSystemPath(spawnSyncImpl)
216
+ if (!sysPath) return false
217
+ const key = (e) => String(e || '').replace(/[\\/]+$/, '').toLowerCase()
218
+ let entries = sysPath.split(';').map((e) => e.trim()).filter(Boolean)
219
+ for (const d of dirs) {
220
+ const needle = key(d)
221
+ entries = entries.filter((e) => key(e) !== needle)
222
+ entries.unshift(d)
223
+ }
224
+ try {
225
+ const r = spawnSyncImpl(
226
+ 'reg', ['add', 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment',
227
+ '/v', 'Path', '/t', 'REG_EXPAND_SZ', '/d', entries.join(';'), '/f'],
228
+ { encoding: 'utf8' }
229
+ )
230
+ return r.status === 0
231
+ } catch {
232
+ return false
233
+ }
234
+ }
235
+
236
+ /**
237
+ * 解析 mise ls node --json 输出为已安装版本列表
238
+ * 输出为 JSON 数组,每项含 version / requested_version / install_path / installed / active。
73
239
  * @param {string} out
74
240
  * @returns {{ name: string, version: string, current: boolean }[]}
75
241
  */
76
- export function parseNvmInstalled(out) {
242
+ export function parseMiseInstalled(out) {
243
+ let records
244
+ try {
245
+ records = JSON.parse(out)
246
+ } catch {
247
+ return []
248
+ }
249
+ if (!Array.isArray(records)) return []
77
250
  const items = []
78
- for (const line of out.split('\n')) {
79
- const m = line.match(/^(->)?\s*v(\d+\.\d+\.\d+)\b/)
80
- if (m) {
81
- items.push({ name: `v${m[2]}`, version: m[2], current: !!m[1] })
82
- }
251
+ for (const rec of records) {
252
+ if (!rec || typeof rec.version !== 'string') continue
253
+ if (rec.installed === false) continue
254
+ const version = rec.version.replace(/^v/, '')
255
+ items.push({ name: `v${version}`, version, current: !!rec.active })
83
256
  }
84
257
  return items
85
258
  }
86
259
 
260
+ /**
261
+ * 解析 mise ls-remote node 输出为可用版本列表
262
+ * 输出为文本,每行一个版本(如 v24.21.0)。
263
+ * @param {string} out
264
+ * @returns {string[]}
265
+ */
266
+ export function parseMiseRemote(out) {
267
+ const versions = []
268
+ for (const line of out.split('\n')) {
269
+ const m = line.trim().match(/^v?(\d+\.\d+\.\d+)$/)
270
+ if (m) versions.push(`v${m[1]}`)
271
+ }
272
+ return versions
273
+ }
274
+
87
275
  export class NodeManager extends RuntimeManager {
88
276
  constructor() {
89
277
  super({
90
278
  name: 'node',
91
- mirrorEnv: 'NVM_NODEJS_ORG_MIRROR',
92
- prefix: `source "${getNvmSh()}" && nvm`,
93
- needsBash: true,
94
- isInstalled: () => isNvmInstalled(),
95
- installTool: () => installNvm(),
96
- toolName: 'nvm',
97
- toolMissingMsg: 'nvm not installed, run `opm nvm install` first',
98
- listCmd: ['ls', '--no-colors'],
99
- listRemoteCmd: ['ls-remote', '--no-colors'],
100
- currentCmd: ['current'],
279
+ mirrorEnv: 'MISE_NODE_MIRROR_URL',
280
+ prefix: getMiseBin(),
281
+ needsBash: false,
282
+ isInstalled: () => isMiseInstalled(),
283
+ installTool: () => installMise(),
284
+ toolName: 'mise',
285
+ toolMissingMsg: 'mise not installed, run `opm mise install` first',
286
+ listCmd: ['ls', 'node', '--json'],
287
+ listRemoteCmd: ['ls-remote', 'node'],
288
+ currentCmd: ['current', 'node'],
101
289
  parseCurrent: (out) => {
102
- const c = out.trim()
290
+ const c = out.trim().replace(/^v/, '')
103
291
  return c && c !== 'system' && c !== 'none' ? c : ''
104
292
  },
105
- installCmd: (v) => ['install', v],
106
- uninstallCmd: (n) => ['uninstall', n.startsWith('v') ? n : `v${n}`],
293
+ installCmd: (v) => ['install', `node@${v}`],
294
+ uninstallCmd: (n) => ['uninstall', `node@${n.startsWith('v') ? n.slice(1) : n}`],
107
295
  resultName: (v) => `v${v}`,
108
- upgradeLts: ['install', '--lts'],
109
- useCmd: (v) => ['use', v],
110
- useAction: 'switched',
111
- defaultCmd: (v) => ['alias', 'default', v],
112
- noCacheMsg: 'no cache for nvm-managed node',
113
- installUsage: 'Usage: opm node install <version> (e.g. 18, 20, v24.19.0, --lts)',
114
- notInstalledHint: 'nvm not installed or no node version active, run `opm nvm install` and `opm node install <version>`',
296
+ upgradeLts: ['install', 'node@lts'],
297
+ useCmd: (v) => ['use', '-g', `node@${v}`],
298
+ useAction: 'switched (global)',
299
+ defaultCmd: (v) => ['use', '-g', `node@${v}`],
300
+ noCacheMsg: 'no cache for mise-managed node',
301
+ installUsage: 'Usage: opm node install <version> (e.g. 20, 24, v24.19.0, --lts)',
302
+ notInstalledHint: 'mise not installed or no node version active, run `opm mise install` and `opm node install <version>`',
115
303
  upgradeNoVersionMsg: 'No Node.js version available to upgrade to',
116
304
  info: {
117
305
  description: 'Node.js JavaScript runtime',
@@ -129,37 +317,34 @@ export class NodeManager extends RuntimeManager {
129
317
  */
130
318
  getInstallDir() {
131
319
  try {
132
- const out = this._exec(['which', 'current'], { allowNonZero: true }).trim()
133
- if (!out || out === 'system' || out === 'none') return ''
134
- return path.dirname(path.dirname(out))
320
+ const out = this._exec(['ls', 'node', '--json'], { allowNonZero: true })
321
+ const items = parseMiseInstalled(out)
322
+ const current = items.find((i) => i.current)
323
+ if (current) {
324
+ const rec = JSON.parse(out).find((r) => r.version.replace(/^v/, '') === current.version)
325
+ if (rec && rec.install_path) return rec.install_path
326
+ }
135
327
  } catch {}
136
328
  return ''
137
329
  }
138
330
 
139
331
  /**
140
- * 解析 nvm ls 输出为已安装版本列表
332
+ * 解析 mise ls node --json 输出为已安装版本列表
141
333
  * @param {string} out
142
334
  * @returns {{ name: string, version: string, current: boolean }[]}
143
335
  * @private
144
336
  */
145
337
  _parseInstalled(out) {
146
- return parseNvmInstalled(out)
338
+ return parseMiseInstalled(out)
147
339
  }
148
340
 
149
341
  /**
150
- * 解析 nvm ls-remote 输出为可用版本列表
342
+ * 解析 mise ls-remote node 输出为可用版本列表
151
343
  * @param {string} out
152
344
  * @returns {string[]}
153
345
  * @private
154
346
  */
155
347
  _parseRemote(out) {
156
- const versions = []
157
- for (const line of out.split('\n')) {
158
- const m = line.match(/^\s*(->)?\s*v(\d+\.\d+\.\d+)\b/)
159
- if (m) {
160
- versions.push(`v${m[2]}`)
161
- }
162
- }
163
- return versions
348
+ return parseMiseRemote(out)
164
349
  }
165
350
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 运行时版本管理器基类(node / php / python 共用)
3
3
  *
4
- * 三个运行时均通过底层版本管理工具(nvm / phpenv / uv)管理多版本,
4
+ * 三个运行时均通过底层版本管理工具(mise / phpenv / uv)管理多版本,
5
5
  * 接口一致(registry/search/view/info/versions/list/install/uninstall/upgrade/use/current/default)。
6
6
  * 差异(命令前缀、镜像环境变量、动作命令参数、解析函数)通过 cfg 配置注入;
7
7
  * 解析函数由子类实现。python 的 getVersion/currentVersion/setDefault 等差异较大,
@@ -19,8 +19,8 @@ const shell = new Shell()
19
19
  * @typedef {Object} RuntimeConfig
20
20
  * @property {string} name
21
21
  * @property {string} mirrorEnv - dist 镜像环境变量名
22
- * @property {string} prefix - 工具调用前缀(如 `source ".../nvm.sh" && nvm`、bin 路径)
23
- * @property {boolean} [needsBash=false] - 命令是否需要 bash(nvm source / phpenv 脚本);原生二进制(uv)为 false
22
+ * @property {string} prefix - 工具调用前缀(如 `mise`、`source ".../phpenv"`、bin 路径)
23
+ * @property {boolean} [needsBash=false] - 命令是否需要 bash(phpenv 脚本);mise/uv 原生二进制为 false
24
24
  * @property {() => boolean} isInstalled
25
25
  * @property {string} toolMissingMsg
26
26
  * @property {string[]} listCmd - 列出已安装版本的参数
@@ -243,7 +243,7 @@ export class RuntimeManager extends PackageManager {
243
243
  }
244
244
  if (!this.cfg.isInstalled() && this.cfg.installTool) {
245
245
  console.log(`[opm] ${this.cfg.toolName} not installed, auto-installing...`)
246
- this.cfg.installTool()
246
+ await this.cfg.installTool()
247
247
  }
248
248
  this._execInherit(this.cfg.installCmd(version), { proxy: opts.proxy })
249
249
  return { name: this.cfg.resultName(version), version, action: 'installed' }
package/src/presets.js CHANGED
@@ -14,7 +14,6 @@ export const APT_PRESETS = _getPresets('apt')
14
14
  export const BREW_PRESETS = _getPresets('brew')
15
15
  export const COMPOSER_PRESETS = _getPresets('composer')
16
16
  export const BUN_PRESETS = _getPresets('bun')
17
- export const NVM_PRESETS = _getPresets('nvm')
18
17
  export const NODE_PRESETS = _getPresets('node')
19
18
  export const PHPENV_PRESETS = _getPresets('phpenv')
20
19
  export const PHP_PRESETS = _getPresets('php')