@wwkit/shared 1.0.27 → 1.0.28

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.
Files changed (3) hide show
  1. package/package.json +5 -3
  2. package/src/release.js +146 -69
  3. package/src/shell.js +240 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wwkit/shared",
3
- "version": "1.0.27",
3
+ "version": "1.0.28",
4
4
  "author": "bluesliu <langcai163@163.com>",
5
5
  "description": "Shared utilities for @wwkit packages — config loader, platform detection",
6
6
  "type": "module",
@@ -33,8 +33,10 @@
33
33
  "jest": "^29.7.0"
34
34
  },
35
35
  "scripts": {
36
- "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --no-cache tests/unit",
37
- "test:all": "node --experimental-vm-modules node_modules/jest/bin/jest.js --no-cache tests/unit",
36
+ "ut": "node ../../scripts/run-tests.js unit",
37
+ "it": "node ../../scripts/run-tests.js integration",
38
+ "e2e": "node ../../scripts/run-tests.js e2e",
39
+ "test": "pnpm run ut",
38
40
  "release": "node bin/release-pkg.js"
39
41
  }
40
42
  }
package/src/release.js CHANGED
@@ -2,48 +2,35 @@ import { execSync } from 'node:child_process'
2
2
  import { readFileSync } from 'node:fs'
3
3
  import { resolve } from 'node:path'
4
4
 
5
- // ── helpers ──────────────────────────────────────────────────
6
-
7
- export function bumpPatch(version) {
8
- const parts = version.split('.').map(Number)
9
- parts[2]++
10
- return parts.join('.')
11
- }
12
-
13
- function runQuiet(cmd, opts = {}) {
14
- return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], ...opts }).trim()
15
- }
16
-
17
- function run(cmd, { dryRun = false, ...execOpts } = {}) {
18
- if (dryRun) { console.log(` $ ${cmd}`); return }
19
- execSync(cmd, { stdio: 'inherit', ...execOpts })
20
- }
21
-
22
- export function readPackage(pkgDir) {
23
- const pkgPath = resolve(pkgDir, 'package.json')
24
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
25
- return { pkg, pkgPath, name: pkg.name, version: pkg.version }
5
+ // ════════════════════════════ 核心 API ════════════════════════════
6
+
7
+ // 发布过程错误。stage 标记失败阶段(validate|bump|publish|git);
8
+ // published=true 表示新版本已上 npm——此时调用方【不得】rollbackVersion,
9
+ // 而应保留版本号并按 repair 命令手动补齐 git 记录(否则下次发布会撞 E403 重复版本)。
10
+ export class ReleaseError extends Error {
11
+ constructor(message, { stage, published = false, version = null, repair = [] } = {}) {
12
+ super(message)
13
+ this.name = 'ReleaseError'
14
+ this.stage = stage
15
+ this.published = published
16
+ this.version = version
17
+ this.repair = repair
18
+ }
26
19
  }
27
20
 
28
- // ── preflight ────────────────────────────────────────────────
29
-
30
- // 解析实际发布用的 registry:优先 package.json 的 publishConfig.registry,
31
- // 否则用该目录下 npm 配置的 registry。.npmrc 可能指向镜像,但发布走 publishConfig。
32
- function resolvePublishRegistry(pkgDir) {
33
- try {
34
- const { pkg } = readPackage(pkgDir)
35
- if (pkg.publishConfig?.registry) return pkg.publishConfig.registry
36
- } catch {}
37
- try {
38
- return runQuiet('npm config get registry', { cwd: pkgDir })
39
- } catch {
40
- return null
21
+ // patch bump,仅支持稳定版 x.y.z;预发布/异常格式直接抛错(fail-fast)。
22
+ export function bumpPatch(version) {
23
+ if (!/^\d+\.\d+\.\d+$/.test(String(version))) {
24
+ throw new Error(`版本号格式不支持自动 bump(需 x.y.z): ${version}`)
41
25
  }
26
+ const [major, minor, patch] = version.split('.').map(Number)
27
+ return `${major}.${minor}.${patch + 1}`
42
28
  }
43
29
 
44
30
  // 发布前检查: git 工作区干净(不干净仅告警)+ npm 已登录(未登录抛出 Error)。
45
- // pkgDir 给定时,针对该包 publishConfig.registry 检查登录(而非 .npmrc 里可能指向镜像的 registry)。
46
- export function preflight({ dryRun = false, pkgDir = null } = {}) {
31
+ // pkgDirs 给定时,按各包 publishConfig.registry【去重】逐一检查登录——
32
+ // 发布实际走 publishConfig(如 npmjs.org),而非 .npmrc 里可能指向镜像的默认 registry。
33
+ export function preflight({ pkgDirs = [] } = {}) {
47
34
  const gitStatus = runQuiet('git status --porcelain')
48
35
  if (gitStatus) {
49
36
  console.warn('⚠ Git 工作区不干净(仅告警,继续发布)')
@@ -51,62 +38,112 @@ export function preflight({ dryRun = false, pkgDir = null } = {}) {
51
38
  } else {
52
39
  console.log(' ✓ Git 工作区干净')
53
40
  }
54
- const registry = pkgDir ? resolvePublishRegistry(pkgDir) : null
55
- const whoamiCmd = registry ? `npm whoami --registry=${registry}` : 'npm whoami'
56
- try {
57
- const who = runQuiet(whoamiCmd)
58
- console.log(` ✓ npm 已登录: ${who}${registry ? ` (${registry})` : ''}`)
59
- } catch {
60
- console.error('✗ 未登录 npm,请先执行 npm login')
61
- if (registry) console.error(` (检查的 registry: ${registry})`)
62
- throw new Error('npm-not-logged-in')
41
+ const registries = [...new Set(pkgDirs.map(resolvePublishRegistry).filter(Boolean))]
42
+ const targets = registries.length > 0 ? registries : [null]
43
+ for (const registry of targets) {
44
+ const whoamiCmd = registry ? `npm whoami --registry=${registry}` : 'npm whoami'
45
+ try {
46
+ const who = runQuiet(whoamiCmd)
47
+ console.log(` ✓ npm 已登录: ${who}${registry ? ` (${registry})` : ''}`)
48
+ } catch {
49
+ console.error('✗ 未登录 npm,请先执行 npm login')
50
+ if (registry) console.error(` (检查的 registry: ${registry})`)
51
+ throw new Error('npm-not-logged-in')
52
+ }
63
53
  }
64
54
  }
65
55
 
66
- // ── single-package release ───────────────────────────────────
56
+ // 发布门禁: 依次在各包目录跑其标准入口 `pnpm run ut`,任一失败返回 false(调用方须中止发布)。
57
+ // dryRun 只打印计划命令,不执行。
58
+ export function runUnitTests(pkgDirs, { dryRun = false } = {}) {
59
+ console.log('\n━━━ 单元测试(发布门禁) ━━━')
60
+ for (const pkgDir of pkgDirs) {
61
+ const { name } = readPackage(pkgDir)
62
+ if (dryRun) {
63
+ console.log(` $ pnpm run ut # ${name}`)
64
+ continue
65
+ }
66
+ console.log(`▶ ${name}: pnpm run ut`)
67
+ try {
68
+ execSync('pnpm run ut', { cwd: pkgDir, stdio: 'inherit' })
69
+ } catch {
70
+ console.error(`\n✗ ${name} 单元测试未通过,发布已阻止(修复测试后重试)`)
71
+ return false
72
+ }
73
+ }
74
+ if (!dryRun) console.log(' ✓ 全部包单元测试通过')
75
+ return true
76
+ }
67
77
 
68
78
  // 单包发布: bump patch → publish → commit + scoped tag(不推送)。
69
- // 成功返回 { name, oldVersion, version, tag };失败抛出(调用方可 rollbackVersion)。
79
+ // 成功返回 { name, oldVersion, version, tag };失败抛出 ReleaseError(含阶段与 published 标记)。
70
80
  export function releasePackage({ pkgDir, dryRun = false } = {}) {
71
81
  const { pkgPath, name, version: oldVer } = readPackage(pkgDir)
72
- const newVer = bumpPatch(oldVer)
73
- if (!name || !oldVer) throw new Error(`无法从 ${pkgPath} 读取 name/version`)
82
+ if (!name) throw new ReleaseError(`无法从 ${pkgPath} 读取 name`, { stage: 'validate' })
83
+ let newVer
84
+ try {
85
+ newVer = bumpPatch(oldVer)
86
+ } catch (e) {
87
+ throw new ReleaseError(e.message, { stage: 'validate' })
88
+ }
74
89
 
75
90
  console.log(`━━━ 发布 ${name}@${newVer}(原 ${oldVer})━━━`)
76
91
 
77
92
  // 1. bump version (no git tag — scoped tag created manually below)
78
- run('pnpm version patch --no-git-tag-version --no-git-checks', { cwd: pkgDir, dryRun })
93
+ try {
94
+ run('pnpm version patch --no-git-tag-version --no-git-checks', { cwd: pkgDir, dryRun })
95
+ } catch {
96
+ throw new ReleaseError(`${name} 版本号 bump 失败`, { stage: 'bump' })
97
+ }
98
+ // 以 bump 后 package.json 的【实际版本】为准(dry-run 未实际 bump,用预测值)
99
+ const actualVer = dryRun ? newVer : readPackage(pkgDir).version
100
+ const tag = `${name}@${actualVer}`
79
101
 
80
102
  // 2. publish (prepublishOnly hook runs tests automatically)
81
- run('pnpm publish --no-git-checks', { cwd: pkgDir, dryRun })
103
+ try {
104
+ run('pnpm publish --no-git-checks', { cwd: pkgDir, dryRun })
105
+ } catch {
106
+ throw new ReleaseError(`${name}@${actualVer} 发布失败`, { stage: 'publish', version: actualVer })
107
+ }
82
108
 
83
- // 3. commit + scoped tag
84
- if (!dryRun) {
85
- execSync(`git add "${pkgPath}"`, { stdio: 'inherit' })
86
- execSync(`git commit -m "release ${name}@${newVer}"`, { stdio: 'inherit' })
87
- execSync(`git tag ${name}@${newVer}`, { stdio: 'inherit' })
88
- } else {
89
- console.log(` $ git add + commit "release ${name}@${newVer}" + tag ${name}@${newVer}`)
109
+ // 3. commit + scoped tag —— 自此版本已在 npm 上,失败时不得再回滚版本号。
110
+ // --only 仅提交该 package.json,避免把用户预先 staged 的无关改动裹进 release commit。
111
+ const repair = [
112
+ `git commit --only -m "release ${tag}" -- "${pkgPath}"`,
113
+ `git tag ${tag}`,
114
+ ]
115
+ try {
116
+ if (!dryRun) {
117
+ execSync(`git commit --only -m "release ${tag}" -- "${pkgPath}"`, { stdio: 'inherit' })
118
+ execSync(`git tag ${tag}`, { stdio: 'inherit' })
119
+ } else {
120
+ console.log(` $ ${repair.join(' && ')}`)
121
+ }
122
+ } catch {
123
+ throw new ReleaseError(`${name}@${actualVer} 已发布但 git 记录失败`, {
124
+ stage: 'git', published: true, version: actualVer, repair,
125
+ })
90
126
  }
91
127
 
92
- return { name, oldVersion: oldVer, version: newVer, tag: `${name}@${newVer}` }
128
+ return { name, oldVersion: oldVer, version: actualVer, tag }
93
129
  }
94
130
 
95
- // 回滚 package.json 版本号
131
+ // 回滚 package.json 版本号。仅适用于【未发布】的失败包;
132
+ // 已发布(ReleaseError.published)的包调用本函数会造成 npm 与仓库版本撕裂。
96
133
  export function rollbackVersion(pkgDir, { dryRun = false } = {}) {
97
134
  if (dryRun) return
98
135
  try {
99
136
  execSync('git checkout -- package.json', { cwd: pkgDir, stdio: 'pipe' })
100
137
  console.log(' 已回滚版本号')
101
138
  } catch {
102
- // version already committed? unlikely in this flow
139
+ console.warn(' ⚠ 版本号回滚失败,请手动检查该包 package.json')
103
140
  }
104
141
  }
105
142
 
106
- // ── push ─────────────────────────────────────────────────────
107
-
108
- // 推送到远端同名分支 + 指定 tag(s)。返回 true 成功,false 跳过(需手动推)。
109
- export function pushToRemote(tags, { dryRun = false } = {}) {
143
+ // 推送到远端同名分支 + 指定 tag(s),并追加同步到 alsoBranches(如 develop,HEAD:<branch>)。
144
+ // 返回 true 成功/已打印计划,false 失败或跳过(需手动推)。
145
+ // 推送异常被捕获并打印手动命令,不向上抛裸栈。
146
+ export function pushToRemote(tags, { dryRun = false, alsoBranches = [] } = {}) {
110
147
  console.log('\n━━━ 推送到远端 ━━━')
111
148
 
112
149
  let remote
@@ -129,10 +166,50 @@ export function pushToRemote(tags, { dryRun = false } = {}) {
129
166
  return false
130
167
  }
131
168
 
132
- run(`git push ${remote} ${branch}`, { dryRun })
133
- if (tags.length > 0) {
134
- run(`git push ${remote} ${tags.join(' ')}`, { dryRun })
169
+ const cmds = [`git push ${remote} ${branch}`]
170
+ if (tags.length > 0) cmds.push(`git push ${remote} ${tags.join(' ')}`)
171
+ // 额外分支同步(如 develop):推当前 HEAD 到远端目标分支,跳过与当前分支同名的
172
+ for (const b of alsoBranches) {
173
+ if (b && b !== branch) cmds.push(`git push ${remote} HEAD:${b}`)
174
+ }
175
+ try {
176
+ for (const cmd of cmds) run(cmd, { dryRun })
177
+ } catch {
178
+ console.error(' ✗ 推送失败,请手动执行:')
179
+ for (const cmd of cmds) console.error(` ${cmd}`)
180
+ return false
135
181
  }
136
182
  if (!dryRun) console.log(' ✓ 推送完成')
137
183
  return true
138
- }
184
+ }
185
+
186
+ // ════════════════════════════ 辅助方法 ════════════════════════════
187
+
188
+ export function readPackage(pkgDir) {
189
+ const pkgPath = resolve(pkgDir, 'package.json')
190
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
191
+ return { pkg, pkgPath, name: pkg.name, version: pkg.version }
192
+ }
193
+
194
+ function runQuiet(cmd, opts = {}) {
195
+ return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], ...opts }).trim()
196
+ }
197
+
198
+ function run(cmd, { dryRun = false, ...execOpts } = {}) {
199
+ if (dryRun) { console.log(` $ ${cmd}`); return }
200
+ execSync(cmd, { stdio: 'inherit', ...execOpts })
201
+ }
202
+
203
+ // 解析实际发布用的 registry:优先 package.json 的 publishConfig.registry,
204
+ // 否则用该目录下 npm 配置的 registry。.npmrc 可能指向镜像,但发布走 publishConfig。
205
+ function resolvePublishRegistry(pkgDir) {
206
+ try {
207
+ const { pkg } = readPackage(pkgDir)
208
+ if (pkg.publishConfig?.registry) return pkg.publishConfig.registry
209
+ } catch {}
210
+ try {
211
+ return runQuiet('npm config get registry', { cwd: pkgDir })
212
+ } catch {
213
+ return null
214
+ }
215
+ }
package/src/shell.js CHANGED
@@ -48,7 +48,9 @@ export class Shell {
48
48
  * 解析可用的 bash 路径
49
49
  *
50
50
  * Unix: 优先 /bin/bash(原行为),回退 /usr/bin/bash,再回退 PATH 中的 bash
51
- * Windows: 在 PATH 中查找 bash(Git Bash / WSL 提供);找不到则抛错
51
+ * Windows: 优先 Git Bash(常见安装路径 + 由 `where git` 推导 Git 根),
52
+ * 再回退 PATH 中的 bash(排除 WSL 转发器 System32\bash.exe / WindowsApps\bash.exe);
53
+ * 找不到则抛错
52
54
  *
53
55
  * @returns {string} bash 可执行文件路径
54
56
  * @throws {Error} 找不到 bash 时抛出(含平台相关提示)
@@ -63,10 +65,26 @@ export class Shell {
63
65
  }
64
66
  }
65
67
 
68
+ if (this.isWindows) {
69
+ const gitBash = this._findGitBash()
70
+ if (gitBash) return gitBash
71
+ }
72
+
66
73
  try {
67
74
  const cmd = this.isWindows ? 'where bash' : 'command -v bash'
68
75
  const out = execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
69
- if (out) return out.split(/\r?\n/)[0]
76
+ if (out) {
77
+ const lines = out.split(/\r?\n/)
78
+ if (this.isWindows) {
79
+ // 排除 WSL 转发器(System32\bash.exe / WindowsApps\bash.exe)——它们不是真实 bash
80
+ const real = lines.find((l) => {
81
+ const lower = l.toLowerCase()
82
+ return !lower.includes('system32\\bash') && !lower.includes('windowsapps\\bash')
83
+ })
84
+ if (real) return real
85
+ }
86
+ return lines[0]
87
+ }
70
88
  } catch {}
71
89
 
72
90
  throw new Error(
@@ -75,6 +93,43 @@ export class Shell {
75
93
  )
76
94
  }
77
95
 
96
+ /**
97
+ * 在 Windows 上定位 Git Bash(真实 bash,非 WSL 转发器)
98
+ *
99
+ * 依次尝试:常见 Git 安装路径(ProgramFiles / ProgramFiles(x86) / LOCALAPPDATA\Programs\Git)
100
+ * → 由 `where git` 定位 git.exe 推导 Git 根(<root>\cmd\git.exe → <root>\bin\bash.exe)。
101
+ * @returns {string} Git Bash 路径;未找到返回空串
102
+ * @private
103
+ */
104
+ _findGitBash() {
105
+ const candidates = []
106
+ const pf = this.env.ProgramFiles
107
+ const pf86 = this.env['ProgramFiles(x86)']
108
+ const la = this.env.LOCALAPPDATA
109
+ if (pf) candidates.push(path.join(pf, 'Git', 'bin', 'bash.exe'))
110
+ if (pf86) candidates.push(path.join(pf86, 'Git', 'bin', 'bash.exe'))
111
+ if (la) candidates.push(path.join(la, 'Programs', 'Git', 'bin', 'bash.exe'))
112
+ for (const c of candidates) {
113
+ try {
114
+ fs.accessSync(c, fs.constants.X_OK)
115
+ return c
116
+ } catch {}
117
+ }
118
+ try {
119
+ const out = execSync('where git', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
120
+ const gitExe = out.split(/\r?\n/)[0]
121
+ if (gitExe && /\\cmd\\git\.exe$/i.test(gitExe)) {
122
+ const root = path.dirname(path.dirname(gitExe))
123
+ const bash = path.join(root, 'bin', 'bash.exe')
124
+ try {
125
+ fs.accessSync(bash, fs.constants.X_OK)
126
+ return bash
127
+ } catch {}
128
+ }
129
+ } catch {}
130
+ return ''
131
+ }
132
+
78
133
  /**
79
134
  * 返回 execSync / spawnSync 的 shell 选项
80
135
  *
@@ -189,6 +244,17 @@ export class Shell {
189
244
  return true
190
245
  }
191
246
 
247
+ /**
248
+ * 统一的 spawnSync 封装(供 Windows 注册表/setx 操作与命令检测使用)
249
+ * @param {string} cmd - 可执行文件
250
+ * @param {string[]} [args=[]] - 参数
251
+ * @returns {Object} spawnSync 结果对象
252
+ * @private
253
+ */
254
+ _spawnSync(cmd, args = []) {
255
+ return spawnSync(cmd, args, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
256
+ }
257
+
192
258
  /**
193
259
  * 持久化设置系统环境变量(跨平台)
194
260
  *
@@ -204,10 +270,7 @@ export class Shell {
204
270
  setEnvVar(name, value) {
205
271
  if (this.isWindows) {
206
272
  try {
207
- const result = spawnSync('setx', [name, String(value)], {
208
- encoding: 'utf8',
209
- stdio: ['pipe', 'pipe', 'pipe'],
210
- })
273
+ const result = this._spawnSync('setx', [name, String(value)])
211
274
  return result.status === 0
212
275
  } catch {
213
276
  return false
@@ -302,6 +365,176 @@ const marker = `# >>> wwkit ${name} >>>`
302
365
  }).join('\n')
303
366
  }
304
367
 
368
+ /**
369
+ * 归一化路径用于 PATH 条目比较(大小写不敏感、统一分隔符、去尾斜杠)
370
+ * @param {string} p - 原始路径/条目
371
+ * @returns {string}
372
+ * @private
373
+ */
374
+ _absPathKey(p) {
375
+ return String(p).trim().replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
376
+ }
377
+
378
+ /**
379
+ * 判断路径列表(分号/冒号分隔)是否包含指定目录
380
+ * @param {string} pathList - 分隔符连接的路径列表
381
+ * @param {string} dir - 要查找的目录
382
+ * @param {string} sep - 分隔符(Windows ';',Unix ':')
383
+ * @returns {boolean}
384
+ * @private
385
+ */
386
+ _pathListIncludes(pathList, dir, sep = ';') {
387
+ if (!pathList) return false
388
+ const needle = this._absPathKey(dir)
389
+ if (!needle) return false
390
+ return String(pathList)
391
+ .split(sep)
392
+ .some((p) => this._absPathKey(p) === needle)
393
+ }
394
+
395
+ /**
396
+ * 判断目录是否已包含在当前进程 PATH(或传入的用户 PATH 字符串)中
397
+ *
398
+ * 进程 PATH 按平台分隔(Windows ';',Unix ':');持久化用户 PATH 字符串
399
+ * (Windows 注册表读取值)恒为分号分隔。
400
+ * @param {string} dir - 目录绝对路径
401
+ * @param {string} [userPath=''] - 可选:持久化用户 PATH 字符串(Windows 注册表读取值)
402
+ * @returns {boolean}
403
+ */
404
+ dirInPath(dir, userPath = '') {
405
+ const needle = this._absPathKey(dir)
406
+ if (!needle) return false
407
+ const procSep = this.isWindows ? ';' : ':'
408
+ if (this._pathListIncludes(this.env.PATH, dir, procSep)) return true
409
+ return this._pathListIncludes(userPath, dir, ';')
410
+ }
411
+
412
+ /**
413
+ * 读取持久化用户 PATH(Windows 注册表 HKCU\Environment\Path)
414
+ *
415
+ * Unix 无用户级 PATH 注册表,返回空串(PATH 由 shell profile 管理)。
416
+ * @returns {string}
417
+ */
418
+ getUserPath() {
419
+ if (!this.isWindows) return ''
420
+ try {
421
+ const result = this._spawnSync('reg', ['query', 'HKCU\\Environment', '/v', 'Path'])
422
+ if (result.status !== 0) return ''
423
+ const lines = result.stdout.split(/\r?\n/)
424
+ for (const line of lines) {
425
+ const m = line.match(/^\s*Path\s+REG_\w+\s+(.*)$/)
426
+ if (m) return m[1].trim()
427
+ }
428
+ return ''
429
+ } catch {
430
+ return ''
431
+ }
432
+ }
433
+
434
+ /**
435
+ * 写回持久化用户 PATH(Windows 注册表,保留 REG_EXPAND_SZ 类型)
436
+ *
437
+ * 不用 setx 的原因:setx 会把 REG_EXPAND_SZ 降级为 REG_SZ(%VAR% 字面量失去展开)
438
+ * 且值超过 1024 字符会被静默截断;reg add 可原样保留类型与完整值。
439
+ * @param {string} value - 完整 PATH 值(分号分隔)
440
+ * @returns {boolean} 是否写入成功
441
+ * @private
442
+ */
443
+ _setUserPath(value) {
444
+ try {
445
+ const result = this._spawnSync(
446
+ 'reg', ['add', 'HKCU\\Environment', '/v', 'Path', '/t', 'REG_EXPAND_SZ', '/d', value, '/f']
447
+ )
448
+ return result.status === 0
449
+ } catch {
450
+ return false
451
+ }
452
+ }
453
+
454
+ /**
455
+ * 追加目录到持久化用户 PATH(Windows 注册表,保留 REG_EXPAND_SZ 类型)
456
+ * @param {string} dir - 要追加的目录绝对路径
457
+ * @returns {boolean} 是否写入成功
458
+ * @private
459
+ */
460
+ _appendUserPath(dir) {
461
+ const current = this.getUserPath()
462
+ const sep = current && !current.endsWith(';') ? ';' : ''
463
+ return this._setUserPath(current + sep + dir)
464
+ }
465
+
466
+ /**
467
+ * 确保目录已进入 Windows 持久化用户 PATH(幂等),并同步当前进程 PATH
468
+ *
469
+ * 解决场景:npm 全局安装(如 ww init 插件)装到 shell 会话专属前缀(如 nvm
470
+ * 的 bin/)时,该目录不在系统/用户 PATH,其他 shell 中命令不可用。此方法
471
+ * 把目录追加进 HKCU\Environment\Path(REG_EXPAND_SZ),重启终端后全局生效。
472
+ *
473
+ * Unix:不干预(PATH 由 nvm/profile 管理,npm 全局 bin 通常已在其中),返回 false。
474
+ *
475
+ * @param {string} dir - 目录绝对路径
476
+ * @returns {boolean} 是否新写入(已在则 false)
477
+ */
478
+ ensurePathEntry(dir) {
479
+ const needle = this._absPathKey(dir)
480
+ if (!needle || !this.isWindows) return false
481
+ if (this._pathListIncludes(this.getUserPath(), dir)) return false
482
+ const ok = this._appendUserPath(dir)
483
+ if (ok && !this._pathListIncludes(this.env.PATH, dir)) {
484
+ const sep = this.env.PATH && !this.env.PATH.endsWith(';') ? ';' : ''
485
+ this.env.PATH = (this.env.PATH || '') + sep + dir
486
+ }
487
+ return ok
488
+ }
489
+
490
+ /**
491
+ * 把目录前置到当前进程 PATH(process.env.PATH,幂等,不写注册表)
492
+ *
493
+ * 进程从旧 PATH 启动时不含注册表最新变更;需要让本进程后续的子进程立即用
494
+ * 新目录中的命令(如 mise shims 的 node)时调用。已在则移到最前(不重复)。
495
+ * @param {string|string[]} dirs - 目录(数组按序前置,最后一个在最前)
496
+ */
497
+ prependProcPath(dirs) {
498
+ const list = (Array.isArray(dirs) ? dirs : [dirs]).filter(Boolean)
499
+ if (!list.length) return
500
+ const sep = this.isWindows ? ';' : ':'
501
+ const key = (e) => this._absPathKey(e)
502
+ let entries = String(this.env.PATH || '').split(sep).filter(Boolean)
503
+ for (const d of list) {
504
+ const needle = key(d)
505
+ if (!needle) continue
506
+ entries = entries.filter((e) => key(e) !== needle)
507
+ entries.unshift(d)
508
+ }
509
+ this.env.PATH = entries.join(sep)
510
+ }
511
+
512
+ /**
513
+ * 确保目录已前置到 Windows 持久化用户 PATH 最前(幂等),并同步当前进程 PATH
514
+ *
515
+ * 用于需要**优先于系统 PATH** 的场景:如 mise shims 目录——系统 PATH 里可能已有
516
+ * 旧版 node(如 D:\library\nodejs),只有把 mise shims 前置才能让新 shell 优先用
517
+ * mise 管理的 node。若目录已在用户 PATH 任意位置,会移到最前(幂等更新)。
518
+ *
519
+ * Unix:不干预,返回 false。
520
+ *
521
+ * @param {string} dir - 目录绝对路径
522
+ * @returns {boolean} 是否新写入/更新(已在最前则 false)
523
+ */
524
+ prependPathEntry(dir) {
525
+ const needle = this._absPathKey(dir)
526
+ if (!needle || !this.isWindows) return false
527
+ const current = this.getUserPath()
528
+ const entries = String(current || '').split(';').map((e) => e.trim()).filter(Boolean)
529
+ const key = (e) => this._absPathKey(e)
530
+ const filtered = entries.filter((e) => key(e) !== needle)
531
+ const newValue = [dir, ...filtered].join(';')
532
+ if (newValue === current) return false
533
+ const ok = this._setUserPath(newValue)
534
+ if (ok) this.prependProcPath(dir)
535
+ return ok
536
+ }
537
+
305
538
  /**
306
539
  * 检测系统命令是否可用
307
540
  * Windows: where <cmd>;Unix: which <cmd>
@@ -312,7 +545,7 @@ const marker = `# >>> wwkit ${name} >>>`
312
545
  isCommandAvailable(cmd) {
313
546
  const checker = this.isWindows ? 'where' : 'which'
314
547
  try {
315
- const result = spawnSync(checker, [cmd], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
548
+ const result = this._spawnSync(checker, [cmd])
316
549
  return result.status === 0
317
550
  } catch {
318
551
  return false