@wwkit/shared 1.0.4 → 1.0.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wwkit/shared",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "author": "bluesliu <langcai163@163.com>",
5
5
  "description": "Shared utilities for @wwkit packages — config loader, platform detection",
6
6
  "type": "module",
@@ -2,9 +2,76 @@ import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import os from 'node:os'
4
4
  import process from 'node:process'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { createRequire } from 'node:module'
5
7
  import JSON5 from 'json5'
6
8
 
7
- const ENV_PATTERN = /\{env:(\w+)\}/
9
+ const ENV_PATTERN = /\{env:(\w+)\}/g
10
+ const PKG_PATTERN = /\{pkg:([^}]+)\}/g
11
+
12
+ const _require = createRequire(import.meta.url)
13
+ const _selfDir = path.dirname(fileURLToPath(import.meta.url))
14
+
15
+ /**
16
+ * 向上查找 pnpm 工作区根目录
17
+ * @param {string} startDir
18
+ * @returns {string|null}
19
+ */
20
+ function findWorkspaceRoot(startDir) {
21
+ let dir = startDir
22
+ while (dir !== path.dirname(dir)) {
23
+ if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) return dir
24
+ const packagesDir = path.join(dir, 'packages')
25
+ if (fs.existsSync(packagesDir) && fs.existsSync(path.join(dir, 'package.json'))) {
26
+ try {
27
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
28
+ if (pkg.private && pkg.workspaces) return dir
29
+ } catch {}
30
+ }
31
+ dir = path.dirname(dir)
32
+ }
33
+ return null
34
+ }
35
+
36
+ /**
37
+ * 解析 npm 包的安装目录路径
38
+ * 策略:1) require.resolve(已发布/已安装) 2) 工作区 packages/ 回退
39
+ * @param {string} pkgName - 包名,如 @wwkit/harness
40
+ * @returns {string} 目录路径,找不到时返回空串
41
+ */
42
+ function resolvePkgPath(pkgName) {
43
+ try {
44
+ const pkgJsonPath = _require.resolve(`${pkgName}/package.json`)
45
+ return path.dirname(pkgJsonPath)
46
+ } catch {}
47
+ try {
48
+ const entry = _require.resolve(pkgName)
49
+ let dir = path.dirname(entry)
50
+ while (dir !== path.dirname(dir)) {
51
+ const pkgJson = path.join(dir, 'package.json')
52
+ if (fs.existsSync(pkgJson)) {
53
+ try {
54
+ const pkg = JSON.parse(fs.readFileSync(pkgJson, 'utf8'))
55
+ if (pkg.name === pkgName) return dir
56
+ } catch {}
57
+ }
58
+ dir = path.dirname(dir)
59
+ }
60
+ } catch {}
61
+ const wsRoot = findWorkspaceRoot(_selfDir)
62
+ if (wsRoot) {
63
+ const seg = pkgName.includes('/') ? pkgName.split('/').pop() : pkgName
64
+ const candidate = path.join(wsRoot, 'packages', seg)
65
+ const pkgJson = path.join(candidate, 'package.json')
66
+ if (fs.existsSync(pkgJson)) {
67
+ try {
68
+ const pkg = JSON.parse(fs.readFileSync(pkgJson, 'utf8'))
69
+ if (pkg.name === pkgName) return candidate
70
+ } catch {}
71
+ }
72
+ }
73
+ return ''
74
+ }
8
75
 
9
76
  /**
10
77
  * 递归合并对象,override 优先,嵌套对象深合并(数组/标量直接覆盖)。
@@ -40,6 +107,24 @@ export function resolveEnvVars(value, env = process.env) {
40
107
  return value
41
108
  }
42
109
 
110
+ /**
111
+ * 递归解析字符串中的 {pkg:NAME} 占位符,替换为包安装目录(找不到则为空串)。
112
+ */
113
+ export function resolvePkgVars(value) {
114
+ if (typeof value === 'string') {
115
+ return value.replace(PKG_PATTERN, (_, name) => resolvePkgPath(name.trim()))
116
+ }
117
+ if (Array.isArray(value)) {
118
+ return value.map((v) => resolvePkgVars(v))
119
+ }
120
+ if (value && typeof value === 'object') {
121
+ const out = {}
122
+ for (const [k, v] of Object.entries(value)) out[k] = resolvePkgVars(v)
123
+ return out
124
+ }
125
+ return value
126
+ }
127
+
43
128
  /**
44
129
  * 多层配置加载器:内置 config.json5 + 用户 config.json5 + 环境变量注入内容(优先级从低到高)。
45
130
  *
@@ -88,7 +173,7 @@ export function createConfigLoader({ builtinConfigPath, userConfigDir, envConten
88
173
  if (envContentVar) {
89
174
  merged = deepMerge(merged, loadContent(process.env[envContentVar]))
90
175
  }
91
- return resolveEnvVars(merged)
176
+ return resolvePkgVars(resolveEnvVars(merged))
92
177
  }
93
178
 
94
179
  function copyBuiltinConfig() {
package/src/index.js CHANGED
@@ -1 +1,2 @@
1
- export { createConfigLoader, getXdgConfigDir, deepMerge, resolveEnvVars } from './config-loader.js'
1
+ export { createConfigLoader, getXdgConfigDir, deepMerge, resolveEnvVars, resolvePkgVars } from './config-loader.js'
2
+ export { Shell } from './shell.js'
package/src/release.js CHANGED
@@ -41,16 +41,16 @@ function resolvePublishRegistry(pkgDir) {
41
41
  }
42
42
  }
43
43
 
44
- // 发布前检查: git 工作区干净 + npm 已登录。失败抛出 Error。
44
+ // 发布前检查: git 工作区干净(不干净仅告警)+ npm 已登录(未登录抛出 Error)。
45
45
  // pkgDir 给定时,针对该包 publishConfig.registry 检查登录(而非 .npmrc 里可能指向镜像的 registry)。
46
46
  export function preflight({ dryRun = false, pkgDir = null } = {}) {
47
47
  const gitStatus = runQuiet('git status --porcelain')
48
48
  if (gitStatus) {
49
- console.error('✗ Git 工作区不干净,请先提交或 stash')
50
- console.error(gitStatus)
51
- throw new Error('git-not-clean')
49
+ console.warn('⚠ Git 工作区不干净(仅告警,继续发布)')
50
+ console.warn(gitStatus)
51
+ } else {
52
+ console.log(' ✓ Git 工作区干净')
52
53
  }
53
- console.log(' ✓ Git 工作区干净')
54
54
  const registry = pkgDir ? resolvePublishRegistry(pkgDir) : null
55
55
  const whoamiCmd = registry ? `npm whoami --registry=${registry}` : 'npm whoami'
56
56
  try {
@@ -75,7 +75,7 @@ export function releasePackage({ pkgDir, dryRun = false } = {}) {
75
75
  console.log(`━━━ 发布 ${name}@${newVer}(原 ${oldVer})━━━`)
76
76
 
77
77
  // 1. bump version (no git tag — scoped tag created manually below)
78
- run('pnpm version patch --no-git-tag-version', { cwd: pkgDir, dryRun })
78
+ run('pnpm version patch --no-git-tag-version --no-git-checks', { cwd: pkgDir, dryRun })
79
79
 
80
80
  // 2. publish (prepublishOnly hook runs tests automatically)
81
81
  run('pnpm publish --no-git-checks', { cwd: pkgDir, dryRun })
package/src/shell.js ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * 跨平台 shell / 系统环境处理
3
+ *
4
+ * 封装 Windows / macOS / Linux 的 shell 与系统环境差异,供 @wwkit 各包复用:
5
+ * - 平台检测(isWindows / isMac / isLinux)
6
+ * - bash 解析(nvm / phpenv 等 bash 工具需要;Windows 下找 Git Bash / WSL)
7
+ * - execSync / spawnSync 的 shell 选项选择
8
+ * - shell profile 文件路径检测与写入(.zshrc / .bashrc / .profile)
9
+ *
10
+ * 用法:
11
+ * import { Shell } from '@wwkit/shared'
12
+ * const shell = new Shell()
13
+ * execSync(cmd, { shell: shell.shellForExec(true) })
14
+ * shell.writeProfile('NVM_DIR', 'nvm', ['export NVM_DIR=...'])
15
+ */
16
+
17
+ import fs from 'node:fs'
18
+ import os from 'node:os'
19
+ import path from 'node:path'
20
+ import { execSync, spawnSync } from 'node:child_process'
21
+
22
+ export class Shell {
23
+ /**
24
+ * @param {{ platform?: string, env?: Record<string,string> }} [opts]
25
+ * platform / env 注入用于测试;默认取 process.platform / process.env
26
+ */
27
+ constructor({ platform = process.platform, env = process.env } = {}) {
28
+ this.platform = platform
29
+ this.env = env
30
+ }
31
+
32
+ /** 是否为 Windows */
33
+ get isWindows() {
34
+ return this.platform === 'win32'
35
+ }
36
+
37
+ /** 是否为 macOS */
38
+ get isMac() {
39
+ return this.platform === 'darwin'
40
+ }
41
+
42
+ /** 是否为 Linux */
43
+ get isLinux() {
44
+ return this.platform === 'linux'
45
+ }
46
+
47
+ /**
48
+ * 解析可用的 bash 路径
49
+ *
50
+ * Unix: 优先 /bin/bash(原行为),回退 /usr/bin/bash,再回退 PATH 中的 bash
51
+ * Windows: 在 PATH 中查找 bash(Git Bash / WSL 提供);找不到则抛错
52
+ *
53
+ * @returns {string} bash 可执行文件路径
54
+ * @throws {Error} 找不到 bash 时抛出(含平台相关提示)
55
+ */
56
+ resolveBash() {
57
+ if (!this.isWindows) {
58
+ for (const c of ['/bin/bash', '/usr/bin/bash']) {
59
+ try {
60
+ fs.accessSync(c, fs.constants.X_OK)
61
+ return c
62
+ } catch {}
63
+ }
64
+ }
65
+
66
+ try {
67
+ const cmd = this.isWindows ? 'where bash' : 'command -v bash'
68
+ const out = execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
69
+ if (out) return out.split(/\r?\n/)[0]
70
+ } catch {}
71
+
72
+ throw new Error(
73
+ 'bash not found: nvm/phpenv require bash. ' +
74
+ (this.isWindows ? 'Install Git Bash or run inside WSL.' : 'Install bash.')
75
+ )
76
+ }
77
+
78
+ /**
79
+ * 返回 execSync / spawnSync 的 shell 选项
80
+ *
81
+ * needsBash=true → 解析并返回 bash 路径(nvm/phpenv 等 bash 工具)
82
+ * needsBash=false → 返回 true(使用系统默认 shell:Unix /bin/sh,Windows cmd.exe)
83
+ *
84
+ * @param {boolean} needsBash - 命令是否需要 bash
85
+ * @returns {string|boolean}
86
+ */
87
+ shellForExec(needsBash) {
88
+ return needsBash ? this.resolveBash() : true
89
+ }
90
+
91
+ /**
92
+ * 检测当前 shell 的 profile 文件路径
93
+ *
94
+ * Unix: $SHELL 含 zsh → ~/.zshrc;含 bash → ~/.bashrc;否则 ~/.profile
95
+ * Windows: 无 $SHELL,默认 ~/.bashrc(Git Bash 约定)
96
+ *
97
+ * @returns {string}
98
+ */
99
+ getProfile() {
100
+ const shell = this.env.SHELL || ''
101
+ if (shell.includes('zsh')) {
102
+ return path.join(os.homedir(), '.zshrc')
103
+ }
104
+ if (shell.includes('bash')) {
105
+ return path.join(os.homedir(), '.bashrc')
106
+ }
107
+ if (this.isWindows) {
108
+ return path.join(os.homedir(), '.bashrc')
109
+ }
110
+ return path.join(os.homedir(), '.profile')
111
+ }
112
+
113
+ /**
114
+ * 将 shell 初始化行写入 profile(按 marker 去重)
115
+ *
116
+ * @param {string} marker - 若 profile 已含该标记则跳过
117
+ * @param {string} comment - 写入块注释
118
+ * @param {string[]} lines - 要追加的行
119
+ * @returns {boolean} 是否写入了新行
120
+ */
121
+ writeProfile(marker, comment, lines) {
122
+ const profilePath = this.getProfile()
123
+ let existing = ''
124
+ try {
125
+ existing = fs.readFileSync(profilePath, 'utf8')
126
+ } catch {}
127
+ if (existing.includes(marker)) {
128
+ return false
129
+ }
130
+ const append = `\n# ${comment}\n${lines.join('\n')}\n`
131
+ fs.appendFileSync(profilePath, append, 'utf8')
132
+ return true
133
+ }
134
+
135
+ /**
136
+ * 检测系统命令是否可用
137
+ * Windows: where <cmd>;Unix: which <cmd>
138
+ *
139
+ * @param {string} cmd - 命令名
140
+ * @returns {boolean}
141
+ */
142
+ isCommandAvailable(cmd) {
143
+ const checker = this.isWindows ? 'where' : 'which'
144
+ try {
145
+ const result = spawnSync(checker, [cmd], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
146
+ return result.status === 0
147
+ } catch {
148
+ return false
149
+ }
150
+ }
151
+
152
+ /**
153
+ * 按优先级检测可用的网络探测工具
154
+ * 顺序: ping > curl > wget
155
+ *
156
+ * @returns {string|null} 'ping' | 'curl' | 'wget' | null
157
+ */
158
+ getNetProbeTool() {
159
+ for (const cmd of ['ping', 'curl', 'wget']) {
160
+ if (this.isCommandAvailable(cmd)) return cmd
161
+ }
162
+ return null
163
+ }
164
+ }