@wwkit/shared 1.0.26 → 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.
- package/package.json +5 -3
- package/src/release.js +146 -69
- 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.
|
|
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
|
-
"
|
|
37
|
-
"
|
|
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
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
//
|
|
46
|
-
|
|
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
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
//
|
|
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 }
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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:
|
|
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
|
-
|
|
139
|
+
console.warn(' ⚠ 版本号回滚失败,请手动检查该包 package.json')
|
|
103
140
|
}
|
|
104
141
|
}
|
|
105
142
|
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
//
|
|
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
|
-
|
|
133
|
-
if (tags.length > 0) {
|
|
134
|
-
|
|
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:
|
|
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)
|
|
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 =
|
|
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 =
|
|
548
|
+
const result = this._spawnSync(checker, [cmd])
|
|
316
549
|
return result.status === 0
|
|
317
550
|
} catch {
|
|
318
551
|
return false
|