clash-kit 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/README.md +39 -23
- package/bin/index.js +31 -7
- package/index.js +90 -9
- package/lib/commands/proxy.js +36 -3
- package/lib/commands/start.js +1 -1
- package/lib/port.js +108 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,10 +44,13 @@ ck init
|
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
46
|
# 启动 Clash 代理服务
|
|
47
|
-
ck start
|
|
47
|
+
ck start # 或者 ck on
|
|
48
48
|
|
|
49
49
|
# 启动并自动开启系统代理
|
|
50
50
|
ck start -s
|
|
51
|
+
|
|
52
|
+
# 关闭服务并关闭系统代理
|
|
53
|
+
ck stop # 或者 ck off
|
|
51
54
|
```
|
|
52
55
|
|
|
53
56
|
### 4. 添加订阅
|
|
@@ -56,18 +59,24 @@ ck start -s
|
|
|
56
59
|
# 交互式管理订阅(添加、切换、删除等)【推荐使用这种方式来管理订阅】
|
|
57
60
|
ck sub
|
|
58
61
|
|
|
62
|
+
# 列出所有订阅
|
|
63
|
+
ck sub -l
|
|
64
|
+
|
|
59
65
|
# 手动添加订阅
|
|
60
66
|
ck sub -a "https://example.com/subscribe?token=xxx" -n "abcName"
|
|
61
67
|
```
|
|
62
68
|
|
|
63
|
-
### 4.
|
|
69
|
+
### 4. 节点切换(自动测速)
|
|
64
70
|
|
|
65
|
-
|
|
66
|
-
# 测速
|
|
67
|
-
ck test
|
|
71
|
+
进入交互式界面,自动对当前节点组进行并发测速,并展示带有即时延迟数据的节点列表供选择。
|
|
68
72
|
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
```bash
|
|
74
|
+
# 切换节点 (支持别名: node, proxy, switch)
|
|
75
|
+
ck use
|
|
76
|
+
# 或者
|
|
77
|
+
ck switch
|
|
78
|
+
# 或者
|
|
79
|
+
ck node
|
|
71
80
|
```
|
|
72
81
|
|
|
73
82
|
### 5. 更多功能
|
|
@@ -76,27 +85,34 @@ ck proxy
|
|
|
76
85
|
# 查看状态
|
|
77
86
|
ck status
|
|
78
87
|
|
|
88
|
+
# 节点并发测速 (仅测速不切换,支持别名: test, ls, t)
|
|
89
|
+
ck list
|
|
90
|
+
|
|
91
|
+
# 设置系统代理
|
|
92
|
+
ck sys on
|
|
93
|
+
ck sys off
|
|
94
|
+
|
|
79
95
|
# 开启 TUN 模式 (需要 sudo 权限)
|
|
80
|
-
|
|
96
|
+
ck tun on # 开启
|
|
97
|
+
ck tun off # 关闭
|
|
81
98
|
```
|
|
82
99
|
|
|
83
100
|
## 命令详解
|
|
84
101
|
|
|
85
|
-
| 命令 (别名)
|
|
86
|
-
|
|
|
87
|
-
| `ck init`
|
|
88
|
-
| `ck start` (`on`)
|
|
89
|
-
| `ck stop` (`off`)
|
|
90
|
-
| `ck status` (`
|
|
91
|
-
| `ck
|
|
92
|
-
| `ck
|
|
93
|
-
| `ck sub
|
|
94
|
-
| `ck sub -l`
|
|
95
|
-
| `ck
|
|
96
|
-
| `ck
|
|
97
|
-
| `ck
|
|
98
|
-
| `ck
|
|
99
|
-
|
|
102
|
+
| 命令 (别名) | 说明 | 示例 |
|
|
103
|
+
| ----------------------------- | -------------------------- | --------------------------------------- |
|
|
104
|
+
| `ck init` | 初始化内核及权限 | `ck init` |
|
|
105
|
+
| `ck start` (`on`) | 启动 Clash 服务 | `ck on` `ck on -s` (启动并设置系统代理) |
|
|
106
|
+
| `ck stop` (`off`) | 停止服务并关闭代理 | `ck off` |
|
|
107
|
+
| `ck status` (`info`, `view`) | 查看运行状态及当前节点延迟 | `ck status` |
|
|
108
|
+
| `ck sysproxy` (`sys`) | 设置系统代理 | `ck sys on` / `ck sys off` |
|
|
109
|
+
| `ck tun` | 设置 TUN 模式 (需要 sudo) | `ck tun on` |
|
|
110
|
+
| `ck sub` | 管理订阅(交互式)【推荐】 | `ck sub` |
|
|
111
|
+
| `ck sub -l` | 列出所有订阅 | `ck sub -l` |
|
|
112
|
+
| `ck sub -a <url>` | 添加订阅 | `ck sub -a "http..." -n "pro"` |
|
|
113
|
+
| `ck sub -u <name>` | 切换订阅 | `ck sub -u "pro"` |
|
|
114
|
+
| `ck use` (`node`, `switch`) | 切换节点 (自动测速) | `ck use` / `ck node` |
|
|
115
|
+
| `ck list` (`ls`, `test`, `t`) | 节点测速列表 (不切换) | `ck list` / `ck test` |
|
|
100
116
|
|
|
101
117
|
## License
|
|
102
118
|
|
package/bin/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { Command } from 'commander'
|
|
4
|
+
import { createRequire } from 'module'
|
|
4
5
|
import { init } from '../lib/commands/init.js'
|
|
5
6
|
import { start } from '../lib/commands/start.js'
|
|
6
7
|
import { stop } from '../lib/commands/stop.js'
|
|
@@ -11,9 +12,12 @@ import { manageSub } from '../lib/commands/sub.js'
|
|
|
11
12
|
import { proxy } from '../lib/commands/proxy.js'
|
|
12
13
|
import { test } from '../lib/commands/test.js'
|
|
13
14
|
|
|
15
|
+
const require = createRequire(import.meta.url)
|
|
16
|
+
const pkg = require('../package.json')
|
|
17
|
+
|
|
14
18
|
const program = new Command()
|
|
15
19
|
|
|
16
|
-
program.name('clash').alias('ck').description('Clash CLI 管理工具 (Alias: ck)').version(
|
|
20
|
+
program.name('clash').alias('ck').description('Clash CLI 管理工具 (Alias: ck)').version(pkg.version, '-v, --version')
|
|
17
21
|
|
|
18
22
|
// 初始化 clash 内核
|
|
19
23
|
program
|
|
@@ -23,10 +27,15 @@ program
|
|
|
23
27
|
.action(init)
|
|
24
28
|
|
|
25
29
|
// 启动 clash 服务
|
|
26
|
-
program
|
|
30
|
+
program
|
|
31
|
+
.command('start')
|
|
32
|
+
.alias('on')
|
|
33
|
+
.description('启动 Clash 服务')
|
|
34
|
+
.option('-s, --sysproxy', '启动后自动开启系统代理')
|
|
35
|
+
.action(start)
|
|
27
36
|
|
|
28
37
|
// 停止 clash 服务
|
|
29
|
-
program.command('stop').description('停止 Clash 服务').action(stop)
|
|
38
|
+
program.command('stop').alias('off').description('停止 Clash 服务').action(stop)
|
|
30
39
|
|
|
31
40
|
// 设置系统代理
|
|
32
41
|
program
|
|
@@ -40,7 +49,7 @@ program
|
|
|
40
49
|
program.command('tun').description('设置 TUN 模式 (可能需要提权)').argument('[action]', 'on 或 off').action(setTun)
|
|
41
50
|
|
|
42
51
|
// 查看 clash 状态
|
|
43
|
-
program.command('status').alias('st').description('查看 Clash 运行状态').action(status)
|
|
52
|
+
program.command('status').alias('st').alias('view').alias('info').description('查看 Clash 运行状态').action(status)
|
|
44
53
|
|
|
45
54
|
// 管理订阅
|
|
46
55
|
program
|
|
@@ -53,9 +62,24 @@ program
|
|
|
53
62
|
.action(manageSub)
|
|
54
63
|
|
|
55
64
|
// 切换节点
|
|
56
|
-
program
|
|
65
|
+
program
|
|
66
|
+
.command('use')
|
|
67
|
+
.aliases(['node', 'proxy', 'switch'])
|
|
68
|
+
.description('切换节点 (别名: node, proxy, switch)')
|
|
69
|
+
.action(proxy)
|
|
70
|
+
|
|
71
|
+
// 列出所有节点,并测速
|
|
72
|
+
program
|
|
73
|
+
.command('list')
|
|
74
|
+
.alias('ls')
|
|
75
|
+
.alias('test')
|
|
76
|
+
.alias('t')
|
|
77
|
+
.description('节点测速 (别名: list, ls, test, t) ')
|
|
78
|
+
.action(test)
|
|
57
79
|
|
|
58
|
-
//
|
|
59
|
-
|
|
80
|
+
// Support -V for version
|
|
81
|
+
if (process.argv.includes('-V')) {
|
|
82
|
+
process.argv[process.argv.indexOf('-V')] = '-v'
|
|
83
|
+
}
|
|
60
84
|
|
|
61
85
|
program.parse(process.argv)
|
package/index.js
CHANGED
|
@@ -2,27 +2,75 @@ import { spawn, execSync } from 'child_process'
|
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import fs from 'fs'
|
|
4
4
|
import chalk from 'chalk'
|
|
5
|
+
import axios from 'axios'
|
|
6
|
+
import ora from 'ora'
|
|
7
|
+
import YAML from 'yaml'
|
|
5
8
|
import { fileURLToPath } from 'url'
|
|
6
9
|
import { getApiBase, getProxyPort } from './lib/api.js'
|
|
7
10
|
import * as sysproxy from './lib/sysproxy.js'
|
|
8
11
|
import * as tun from './lib/tun.js'
|
|
12
|
+
import { isPortOpen, extractPort, getPortOccupier } from './lib/port.js'
|
|
9
13
|
|
|
10
14
|
const __filename = fileURLToPath(import.meta.url)
|
|
11
15
|
const __dirname = path.dirname(__filename)
|
|
12
16
|
|
|
13
|
-
// ----------------
|
|
17
|
+
// ---------------- 配置项 ----------------
|
|
14
18
|
const CLASH_BIN_PATH = path.join(__dirname, 'clash-kit') // 解压后的二进制文件路径
|
|
15
19
|
const CLASH_CONFIG_PATH = path.join(__dirname, 'config.yaml') // 配置文件路径
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
async function checkPorts() {
|
|
22
|
+
try {
|
|
23
|
+
if (fs.existsSync(CLASH_CONFIG_PATH)) {
|
|
24
|
+
const configContent = fs.readFileSync(CLASH_CONFIG_PATH, 'utf8')
|
|
25
|
+
const config = YAML.parse(configContent)
|
|
26
|
+
|
|
27
|
+
const checks = [
|
|
28
|
+
{ key: 'mixed-port', name: 'Mixed Port' },
|
|
29
|
+
{ key: 'port', name: 'HTTP Port' },
|
|
30
|
+
{ key: 'socks-port', name: 'SOCKS Port' },
|
|
31
|
+
{ key: 'external-controller', name: 'External Controller' },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
for (const check of checks) {
|
|
35
|
+
const val = config[check.key]
|
|
36
|
+
const port = extractPort(val)
|
|
37
|
+
if (port) {
|
|
38
|
+
const isOpen = await isPortOpen(port)
|
|
39
|
+
if (!isOpen) {
|
|
40
|
+
const occupier = getPortOccupier(port)
|
|
41
|
+
const occupierInfo = occupier ? ` (被 ${occupier} 占用)` : ''
|
|
42
|
+
|
|
43
|
+
console.error(chalk.red(`\n启动失败: 端口 ${port} (${check.name}) 已被占用${occupierInfo}`))
|
|
44
|
+
console.error(chalk.yellow(`请检查是否有其他代理软件正在运行,或修改 config.yaml 中的 ${check.key} \n`))
|
|
45
|
+
|
|
46
|
+
if (!occupierInfo) {
|
|
47
|
+
console.error(`占用进程未知,可能是权限不足或系统进程`)
|
|
48
|
+
console.error(chalk.yellow(`提示: 可尝试使用 'sudo lsof -i :${port}' 手动查看端口占用情况`))
|
|
49
|
+
}
|
|
50
|
+
process.exit(1)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch (e) {
|
|
56
|
+
console.error(chalk.yellow('警告: 端口检查预检失败,将尝试直接启动内核:', e.message))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------- 启动 Clash.Meta 进程 ----------------
|
|
61
|
+
async function startClash() {
|
|
19
62
|
// 尝试停止已存在的进程
|
|
20
63
|
try {
|
|
21
64
|
execSync('pkill -f clash-kit')
|
|
65
|
+
// 稍微等待端口释放,避免 restart 时偶发端口占用报错
|
|
66
|
+
await new Promise(resolve => setTimeout(resolve, 500))
|
|
22
67
|
} catch (e) {
|
|
23
68
|
// 忽略错误,说明没有运行中的进程
|
|
24
69
|
}
|
|
25
70
|
|
|
71
|
+
// 检查端口占用 (核心策略:报错/启动失败)
|
|
72
|
+
await checkPorts()
|
|
73
|
+
|
|
26
74
|
const logPath = path.join(__dirname, 'clash.log')
|
|
27
75
|
const logFd = fs.openSync(logPath, 'a')
|
|
28
76
|
|
|
@@ -50,7 +98,7 @@ function startClash() {
|
|
|
50
98
|
return clashProcess
|
|
51
99
|
}
|
|
52
100
|
|
|
53
|
-
//
|
|
101
|
+
// 清理函数
|
|
54
102
|
async function cleanup() {
|
|
55
103
|
try {
|
|
56
104
|
// 关闭系统代理
|
|
@@ -75,7 +123,7 @@ async function cleanup() {
|
|
|
75
123
|
}
|
|
76
124
|
}
|
|
77
125
|
|
|
78
|
-
//
|
|
126
|
+
// 注册进程退出处理
|
|
79
127
|
function setupExitHandlers() {
|
|
80
128
|
// 处理正常退出 (Ctrl+C)
|
|
81
129
|
process.on('SIGINT', async () => {
|
|
@@ -99,8 +147,21 @@ function setupExitHandlers() {
|
|
|
99
147
|
})
|
|
100
148
|
}
|
|
101
149
|
|
|
102
|
-
//
|
|
103
|
-
|
|
150
|
+
// 检查服务健康状态
|
|
151
|
+
async function checkServiceHealth(apiBase, maxRetries = 20) {
|
|
152
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
153
|
+
try {
|
|
154
|
+
await axios.get(apiBase, { timeout: 1000 })
|
|
155
|
+
return true
|
|
156
|
+
} catch (e) {
|
|
157
|
+
if (e.response) return true // 端口已通 (即使是 401 也可以)
|
|
158
|
+
await new Promise(r => setTimeout(r, 200)) // 200ms * 20 = 4s
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return false
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function main() {
|
|
104
165
|
// 检查 clash-kit 二进制文件是否存在
|
|
105
166
|
if (!fs.existsSync(CLASH_BIN_PATH)) {
|
|
106
167
|
return console.error(chalk.red('\n找不到 Clash.Meta 内核文件,请先运行 clash init 命令初始化内核!\n'))
|
|
@@ -113,10 +174,30 @@ export function main() {
|
|
|
113
174
|
// 设置退出处理
|
|
114
175
|
setupExitHandlers()
|
|
115
176
|
|
|
116
|
-
const clashProcess = startClash()
|
|
177
|
+
const clashProcess = await startClash()
|
|
178
|
+
|
|
179
|
+
const spinner = ora('正在等待服务启动...').start()
|
|
180
|
+
const started = await checkServiceHealth(getApiBase())
|
|
181
|
+
|
|
182
|
+
if (!started) {
|
|
183
|
+
spinner.fail(chalk.red('启动失败'))
|
|
184
|
+
const logPath = path.join(__dirname, 'clash.log')
|
|
185
|
+
if (fs.existsSync(logPath)) {
|
|
186
|
+
console.log(chalk.yellow('\n------- clash.log (Last 20 lines) -------'))
|
|
187
|
+
const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n')
|
|
188
|
+
console.log(lines.slice(-20).join('\n'))
|
|
189
|
+
console.log(chalk.yellow('-----------------------------------------\n'))
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
process.kill(clashProcess.pid)
|
|
193
|
+
} catch (e) {}
|
|
194
|
+
process.exit(1)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
spinner.succeed(chalk.green('启动成功'))
|
|
198
|
+
|
|
117
199
|
const { http, socks } = getProxyPort()
|
|
118
200
|
|
|
119
|
-
console.log(chalk.green('\n代理服务已在后台启动✅'))
|
|
120
201
|
if (clashProcess.pid) {
|
|
121
202
|
console.log(`进程名称:${chalk.yellow('clash-kit')} PID: ${chalk.yellow(clashProcess.pid)}`)
|
|
122
203
|
}
|
package/lib/commands/proxy.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { select } from '@inquirer/prompts'
|
|
2
2
|
import ora from 'ora'
|
|
3
|
+
import chalk from 'chalk'
|
|
3
4
|
import * as api from '../api.js'
|
|
4
5
|
|
|
5
6
|
export async function proxy() {
|
|
6
7
|
let spinner = ora('正在获取最新代理列表...').start()
|
|
7
8
|
try {
|
|
8
|
-
|
|
9
|
+
let proxies = await api.getProxies()
|
|
9
10
|
spinner.stop()
|
|
10
11
|
|
|
11
12
|
// 通常我们只关心 Proxy 组或者 Selector 类型的组
|
|
@@ -24,10 +25,42 @@ export async function proxy() {
|
|
|
24
25
|
|
|
25
26
|
const group = proxies[groupName]
|
|
26
27
|
|
|
28
|
+
// 自动对组内所有节点进行测速
|
|
29
|
+
spinner = ora(`测速后选择合适的节点,正在对 [${groupName}] 进行测速...`).start()
|
|
30
|
+
await Promise.all(group.all.map(n => api.getProxyDelay(n).catch(() => {})))
|
|
31
|
+
|
|
32
|
+
// 测速完成后,刷新数据以获取最新状态
|
|
33
|
+
proxies = await api.getProxies()
|
|
34
|
+
spinner.stop()
|
|
35
|
+
|
|
36
|
+
const updatedGroup = proxies[groupName]
|
|
37
|
+
|
|
27
38
|
// 选择节点
|
|
28
39
|
const proxyName = await select({
|
|
29
|
-
message: `[${groupName}] 当前: ${
|
|
30
|
-
|
|
40
|
+
message: `[${groupName}] 当前: ${updatedGroup.now}, 请选择节点:`,
|
|
41
|
+
pageSize: 15,
|
|
42
|
+
choices: updatedGroup.all.map(n => {
|
|
43
|
+
const node = proxies[n]
|
|
44
|
+
const lastHistory = node?.history && node.history.length ? node.history[node.history.length - 1] : null
|
|
45
|
+
let delayInfo = ''
|
|
46
|
+
|
|
47
|
+
if (lastHistory && lastHistory.delay > 0) {
|
|
48
|
+
const delay = lastHistory.delay
|
|
49
|
+
if (delay < 800) {
|
|
50
|
+
delayInfo = chalk.green(` ${delay}ms`)
|
|
51
|
+
} else if (delay < 1500) {
|
|
52
|
+
delayInfo = chalk.yellow(` ${delay}ms`)
|
|
53
|
+
} else {
|
|
54
|
+
delayInfo = chalk.red(` ${delay}ms`)
|
|
55
|
+
}
|
|
56
|
+
} else if (lastHistory && lastHistory.delay === 0) {
|
|
57
|
+
delayInfo = chalk.red(' [超时]')
|
|
58
|
+
} else {
|
|
59
|
+
delayInfo = chalk.gray(' [未测速]')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { name: `${n}${delayInfo}`, value: n }
|
|
63
|
+
}),
|
|
31
64
|
})
|
|
32
65
|
|
|
33
66
|
spinner = ora(`正在切换到 ${proxyName}...`).start()
|
package/lib/commands/start.js
CHANGED
|
@@ -2,7 +2,7 @@ import * as sysproxy from '../sysproxy.js'
|
|
|
2
2
|
import { main as startClashService } from '../../index.js'
|
|
3
3
|
|
|
4
4
|
export async function start(options) {
|
|
5
|
-
startClashService()
|
|
5
|
+
await startClashService()
|
|
6
6
|
|
|
7
7
|
if (options.sysproxy) {
|
|
8
8
|
console.log('正在等待 Clash API 就绪以设置系统代理...')
|
package/lib/port.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import net from 'net'
|
|
2
|
+
import { execSync } from 'child_process'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 检查端口是否被占用 (true = 空闲, false = 被占用)
|
|
7
|
+
* @param {number} port
|
|
8
|
+
* @returns {Promise<boolean>}
|
|
9
|
+
*/
|
|
10
|
+
export function isPortOpen(port) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const server = net.createServer()
|
|
13
|
+
server.once('error', (err) => {
|
|
14
|
+
resolve(false) // 端口被占用
|
|
15
|
+
})
|
|
16
|
+
server.once('listening', () => {
|
|
17
|
+
server.close()
|
|
18
|
+
resolve(true) // 端口可用
|
|
19
|
+
})
|
|
20
|
+
server.listen(port, '127.0.0.1')
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 获取占用端口的进程信息
|
|
26
|
+
* @param {number} port
|
|
27
|
+
* @returns {string|null} 进程名称(PID),例如 "mihomo (PID: 1234)"
|
|
28
|
+
*/
|
|
29
|
+
export function getPortOccupier(port) {
|
|
30
|
+
try {
|
|
31
|
+
const platform = os.platform()
|
|
32
|
+
if (platform === 'win32') {
|
|
33
|
+
// Windows 实现
|
|
34
|
+
try {
|
|
35
|
+
const output = execSync(`netstat -ano | findstr :${port}`).toString()
|
|
36
|
+
const lines = output.trim().split('\n')
|
|
37
|
+
if(lines.length > 0) {
|
|
38
|
+
const parts = lines[0].trim().split(/\s+/)
|
|
39
|
+
const pid = parts[parts.length - 1]
|
|
40
|
+
return `PID: ${pid}`
|
|
41
|
+
}
|
|
42
|
+
} catch (e) { return null }
|
|
43
|
+
} else {
|
|
44
|
+
// macOS / Linux
|
|
45
|
+
// lsof output format: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
|
46
|
+
const output = execSync(`lsof -i :${port} -sTCP:LISTEN -P -n`).toString().trim()
|
|
47
|
+
const lines = output.split('\n')
|
|
48
|
+
if (lines.length > 1) {
|
|
49
|
+
const parts = lines[1].trim().split(/\s+/)
|
|
50
|
+
return `${parts[0]} (PID: ${parts[1]})`
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} catch (e) {
|
|
54
|
+
// lsof 找不到时会返回非 0 退出码,抛出错误
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 寻找可用端口
|
|
62
|
+
* @param {number} startPort
|
|
63
|
+
* @returns {Promise<number>}
|
|
64
|
+
*/
|
|
65
|
+
export function findAvailablePort(startPort) {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const server = net.createServer()
|
|
68
|
+
server.on('error', (err) => {
|
|
69
|
+
if (startPort <= 65535) {
|
|
70
|
+
// 核心逻辑:如果报错,递归尝试 +1 的端口
|
|
71
|
+
resolve(findAvailablePort(startPort + 1))
|
|
72
|
+
} else {
|
|
73
|
+
reject(err)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
server.on('listening', () => {
|
|
77
|
+
server.close(() => {
|
|
78
|
+
resolve(startPort)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
server.listen(startPort, '127.0.0.1')
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 从配置值中提取端口号
|
|
87
|
+
* @param {string|number} val - 例如 9090, ":9090", "127.0.0.1:9090"
|
|
88
|
+
* @returns {number|null}
|
|
89
|
+
*/
|
|
90
|
+
export function extractPort(val) {
|
|
91
|
+
if (!val) return null
|
|
92
|
+
if (typeof val === 'number') return val
|
|
93
|
+
if (typeof val === 'string') {
|
|
94
|
+
// 移除空白
|
|
95
|
+
val = val.trim()
|
|
96
|
+
// 如果是纯数字字符串
|
|
97
|
+
if (/^\d+$/.test(val)) return parseInt(val, 10)
|
|
98
|
+
|
|
99
|
+
// 如果包含冒号,取最后一部分
|
|
100
|
+
if (val.includes(':')) {
|
|
101
|
+
const parts = val.split(':')
|
|
102
|
+
const lastPart = parts[parts.length - 1]
|
|
103
|
+
const port = parseInt(lastPart, 10)
|
|
104
|
+
return isNaN(port) ? null : port
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return null
|
|
108
|
+
}
|