coding-tool-x 3.3.5 → 3.3.7

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 (27) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/web/assets/{Analytics-B6CWdkhx.js → Analytics-IW6eAy9u.js} +1 -1
  3. package/dist/web/assets/{ConfigTemplates-BW6LEgd8.js → ConfigTemplates-BPtkTMSc.js} +1 -1
  4. package/dist/web/assets/{Home-B2B2gS2-.js → Home-obifg_9E.js} +1 -1
  5. package/dist/web/assets/{PluginManager-Bqc7ldY-.js → PluginManager-BGx9MSDV.js} +1 -1
  6. package/dist/web/assets/{ProjectList-BFdZZm_8.js → ProjectList-BCn-mrCx.js} +1 -1
  7. package/dist/web/assets/{SessionList-B_Tp37kM.js → SessionList-CzLfebJQ.js} +1 -1
  8. package/dist/web/assets/{SkillManager-ul2rcS3o.js → SkillManager-CXz2vBQx.js} +1 -1
  9. package/dist/web/assets/{WorkspaceManager-Dp5Jvdtu.js → WorkspaceManager-CHtgMfKc.js} +1 -1
  10. package/dist/web/assets/index-C7LPdVsN.js +2 -0
  11. package/dist/web/assets/{index-DxRneGyu.css → index-eEmjZKWP.css} +1 -1
  12. package/dist/web/index.html +2 -2
  13. package/package.json +1 -1
  14. package/src/commands/daemon.js +44 -6
  15. package/src/commands/update.js +21 -6
  16. package/src/config/default.js +1 -1
  17. package/src/config/model-metadata.js +2 -2
  18. package/src/config/model-metadata.json +7 -2
  19. package/src/server/api/config-export.js +21 -2
  20. package/src/server/api/mcp.js +26 -4
  21. package/src/server/index.js +25 -2
  22. package/src/server/services/config-export-service.js +639 -138
  23. package/src/server/services/mcp-client.js +162 -18
  24. package/src/server/services/mcp-service.js +130 -15
  25. package/src/server/services/model-detector.js +1 -0
  26. package/src/utils/port-helper.js +87 -2
  27. package/dist/web/assets/index-CSBDZxYn.js +0 -2
@@ -14,6 +14,8 @@
14
14
  */
15
15
 
16
16
  const { spawn } = require('child_process');
17
+ const fs = require('fs');
18
+ const path = require('path');
17
19
  const http = require('http');
18
20
  const https = require('https');
19
21
  const { EventEmitter } = require('events');
@@ -22,6 +24,148 @@ const DEFAULT_TIMEOUT = 10000; // 10 seconds
22
24
  const JSONRPC_VERSION = '2.0';
23
25
  const MCP_PROTOCOL_VERSION = '2024-11-05';
24
26
 
27
+ function getPathEnvKey(envObj = {}) {
28
+ return Object.keys(envObj).find(key => key.toLowerCase() === 'path') || 'PATH';
29
+ }
30
+
31
+ function stripWrappingQuotes(value) {
32
+ const text = String(value || '').trim();
33
+ if (!text) return '';
34
+ if (
35
+ (text.startsWith('"') && text.endsWith('"')) ||
36
+ (text.startsWith("'") && text.endsWith("'"))
37
+ ) {
38
+ return text.slice(1, -1);
39
+ }
40
+ return text;
41
+ }
42
+
43
+ function mergeSpawnEnv(extraEnv = {}) {
44
+ const mergedEnv = { ...process.env, ...extraEnv };
45
+ const processPathKey = getPathEnvKey(process.env);
46
+ const extraPathKey = getPathEnvKey(extraEnv);
47
+ const mergedPathKey = getPathEnvKey(mergedEnv);
48
+
49
+ const extraPath = extraEnv && typeof extraEnv[extraPathKey] === 'string'
50
+ ? extraEnv[extraPathKey]
51
+ : '';
52
+ const processPath = process.env && typeof process.env[processPathKey] === 'string'
53
+ ? process.env[processPathKey]
54
+ : '';
55
+
56
+ if (extraPath && processPath) {
57
+ mergedEnv[mergedPathKey] = `${extraPath}${path.delimiter}${processPath}`;
58
+ }
59
+
60
+ return mergedEnv;
61
+ }
62
+
63
+ function resolveWindowsSpawnCommand(command, env, cwd) {
64
+ if (process.platform !== 'win32') {
65
+ return stripWrappingQuotes(command);
66
+ }
67
+
68
+ const normalizedCommand = stripWrappingQuotes(command);
69
+ if (!normalizedCommand) {
70
+ return normalizedCommand;
71
+ }
72
+
73
+ const hasPathSegment = /[\\/]/.test(normalizedCommand) || /^[a-zA-Z]:/.test(normalizedCommand);
74
+ const hasExtension = path.extname(normalizedCommand).length > 0;
75
+ const extensions = hasExtension ? [''] : ['.cmd', '.exe', '.bat', '.com'];
76
+ const resolveCandidate = (basePath) => {
77
+ for (const ext of extensions) {
78
+ const candidate = ext ? `${basePath}${ext}` : basePath;
79
+ if (fs.existsSync(candidate)) {
80
+ return candidate;
81
+ }
82
+ }
83
+ return null;
84
+ };
85
+
86
+ if (hasPathSegment) {
87
+ const absoluteBasePath = path.isAbsolute(normalizedCommand)
88
+ ? normalizedCommand
89
+ : path.resolve(cwd || process.cwd(), normalizedCommand);
90
+ return resolveCandidate(absoluteBasePath) || normalizedCommand;
91
+ }
92
+
93
+ const pathKey = getPathEnvKey(env || process.env);
94
+ const pathValue = env && typeof env[pathKey] === 'string' ? env[pathKey] : '';
95
+ if (!pathValue) {
96
+ return normalizedCommand;
97
+ }
98
+
99
+ const searchPaths = pathValue.split(path.delimiter).filter(Boolean);
100
+ for (const searchPath of searchPaths) {
101
+ const found = resolveCandidate(path.join(searchPath.trim(), normalizedCommand));
102
+ if (found) {
103
+ return found;
104
+ }
105
+ }
106
+
107
+ return normalizedCommand;
108
+ }
109
+
110
+ function getCommandInstallHint(command) {
111
+ const normalized = path.basename(stripWrappingQuotes(command)).toLowerCase()
112
+
113
+ if (['node', 'node.exe', 'npm', 'npm.cmd', 'npx', 'npx.cmd'].includes(normalized)) {
114
+ return '请先安装 Node.js,并确认 `node` / `npm` / `npx` 已加入 PATH。'
115
+ }
116
+
117
+ if (['uv', 'uv.exe', 'uvx', 'uvx.exe'].includes(normalized)) {
118
+ return '请先安装 `uv`,并确认 `uv` / `uvx` 可以在终端直接执行。'
119
+ }
120
+
121
+ if (['python', 'python.exe', 'python3', 'py'].includes(normalized)) {
122
+ return '请先安装 Python,并确认对应命令已加入 PATH。'
123
+ }
124
+
125
+ if (process.platform === 'win32' && ['netstat', 'taskkill'].includes(normalized)) {
126
+ return '请确认 Windows 自带命令可用,并检查 `C:\\Windows\\System32` 是否在 PATH 中。'
127
+ }
128
+
129
+ return `请确认已安装 "${command}",或改用可执行文件的绝对路径。`
130
+ }
131
+
132
+ function createMissingCommandHint(command, resolvedCommand, env = {}) {
133
+ const pathKey = getPathEnvKey(env)
134
+ const pathValue = typeof env[pathKey] === 'string' ? env[pathKey] : ''
135
+ const pathPreview = pathValue
136
+ ? pathValue.split(path.delimiter).slice(0, 5).join(path.delimiter)
137
+ : ''
138
+ const commandHint = resolvedCommand === command
139
+ ? command
140
+ : `${command} (resolved: ${resolvedCommand})`
141
+
142
+ const details = [
143
+ getCommandInstallHint(command),
144
+ process.platform === 'win32'
145
+ ? 'Windows 可优先尝试 `.cmd` / `.exe` 文件,必要时直接填写绝对路径。'
146
+ : `可尝试填写绝对路径(例如 \`/usr/bin/node\` 或 \`$(which ${command})\`)。`
147
+ ]
148
+
149
+ if (pathPreview) {
150
+ details.push(`当前 PATH 前 5 项: ${pathPreview}`)
151
+ } else {
152
+ details.push('当前 PATH 为空,请检查环境变量配置。')
153
+ }
154
+
155
+ return {
156
+ type: 'missing-command',
157
+ command,
158
+ resolvedCommand,
159
+ title: `命令 "${commandHint}" 未找到`,
160
+ details
161
+ }
162
+ }
163
+
164
+ function buildMissingCommandMessage(command, resolvedCommand, env = {}) {
165
+ const hint = createMissingCommandHint(command, resolvedCommand, env)
166
+ return [hint.title, ...hint.details].join('\n')
167
+ }
168
+
25
169
  // ============================================================================
26
170
  // McpClient
27
171
  // ============================================================================
@@ -237,22 +381,19 @@ class McpClient extends EventEmitter {
237
381
  reject(new McpClientError(`Connection timeout after ${this._timeout}ms`));
238
382
  }, this._timeout);
239
383
 
240
- try {
241
- // 确保 PATH 不被覆盖,优先使用用户提供的 env,但保留 PATH
242
- const mergedEnv = { ...process.env, ...env };
243
- // 如果用户提供的 env 中有 PATH,将其追加到系统 PATH 前面
244
- if (env && env.PATH && process.env.PATH) {
245
- mergedEnv.PATH = `${env.PATH}:${process.env.PATH}`;
246
- }
384
+ const finalCwd = cwd || process.cwd();
385
+ const mergedEnv = mergeSpawnEnv(env || {});
386
+ const resolvedCommand = resolveWindowsSpawnCommand(command, mergedEnv, finalCwd);
247
387
 
248
- this._child = spawn(command, args, {
388
+ try {
389
+ this._child = spawn(resolvedCommand, args, {
249
390
  env: mergedEnv,
250
391
  stdio: ['pipe', 'pipe', 'pipe'],
251
- cwd: cwd || process.cwd()
392
+ cwd: finalCwd
252
393
  });
253
394
  } catch (err) {
254
395
  clearTimeout(timer);
255
- throw new McpClientError(`Failed to spawn "${command}": ${err.message}`);
396
+ throw new McpClientError(`Failed to spawn "${resolvedCommand}": ${err.message}`);
256
397
  }
257
398
 
258
399
  // Once we get the spawn event (or first stdout), consider connected
@@ -274,14 +415,11 @@ class McpClient extends EventEmitter {
274
415
 
275
416
  this._child.on('error', (err) => {
276
417
  if (err.code === 'ENOENT') {
277
- const pathHint = mergedEnv.PATH
278
- ? `\n Current PATH: ${mergedEnv.PATH.split(':').slice(0, 5).join(':')}\n (showing first 5 entries)`
279
- : '\n PATH is not set!';
418
+ const hint = createMissingCommandHint(command, resolvedCommand, mergedEnv)
280
419
  settle(new McpClientError(
281
- `Command "${command}" not found. Please check:\n` +
282
- ` 1. Is "${command}" installed?\n` +
283
- ` 2. Try using absolute path (e.g., /usr/bin/node or $(which ${command}))\n` +
284
- ` 3. Check your PATH environment variable${pathHint}`
420
+ buildMissingCommandMessage(command, resolvedCommand, mergedEnv),
421
+ undefined,
422
+ { hint }
285
423
  ));
286
424
  } else {
287
425
  settle(new McpClientError(`Failed to start process: ${err.message}`));
@@ -786,5 +924,11 @@ async function createClient(serverSpec, options = {}) {
786
924
  module.exports = {
787
925
  McpClient,
788
926
  McpClientError,
789
- createClient
927
+ createClient,
928
+ buildMissingCommandMessage,
929
+ createMissingCommandHint,
930
+ _test: {
931
+ createMissingCommandHint,
932
+ buildMissingCommandMessage
933
+ }
790
934
  };
@@ -11,7 +11,7 @@ const toml = require('@iarna/toml');
11
11
  const { spawn } = require('child_process');
12
12
  const http = require('http');
13
13
  const https = require('https');
14
- const { McpClient } = require('./mcp-client');
14
+ const { McpClient, buildMissingCommandMessage, createMissingCommandHint } = require('./mcp-client');
15
15
  const { NATIVE_PATHS } = require('../../config/paths');
16
16
  const { resolvePreferredHomeDir } = require('../../utils/home-dir');
17
17
 
@@ -418,6 +418,102 @@ function writeOpenCodeConfig(filePath, data) {
418
418
  fs.renameSync(tempPath, filePath);
419
419
  }
420
420
 
421
+ function getPathEnvKey(envObj = {}) {
422
+ return Object.keys(envObj).find(key => key.toLowerCase() === 'path') || 'PATH';
423
+ }
424
+
425
+ function stripWrappingQuotes(value) {
426
+ const text = String(value || '').trim();
427
+ if (!text) return '';
428
+ if (
429
+ (text.startsWith('"') && text.endsWith('"')) ||
430
+ (text.startsWith("'") && text.endsWith("'"))
431
+ ) {
432
+ return text.slice(1, -1);
433
+ }
434
+ return text;
435
+ }
436
+
437
+ function mergeSpawnEnv(extraEnv = {}) {
438
+ const mergedEnv = { ...process.env, ...extraEnv };
439
+ const processPathKey = getPathEnvKey(process.env);
440
+ const extraPathKey = getPathEnvKey(extraEnv);
441
+ const mergedPathKey = getPathEnvKey(mergedEnv);
442
+
443
+ const extraPath = extraEnv && typeof extraEnv[extraPathKey] === 'string'
444
+ ? extraEnv[extraPathKey]
445
+ : '';
446
+ const processPath = process.env && typeof process.env[processPathKey] === 'string'
447
+ ? process.env[processPathKey]
448
+ : '';
449
+
450
+ if (extraPath && processPath) {
451
+ mergedEnv[mergedPathKey] = `${extraPath}${path.delimiter}${processPath}`;
452
+ }
453
+
454
+ return mergedEnv;
455
+ }
456
+
457
+ function resolveWindowsSpawnCommand(command, env, cwd) {
458
+ if (process.platform !== 'win32') {
459
+ return stripWrappingQuotes(command);
460
+ }
461
+
462
+ const normalizedCommand = stripWrappingQuotes(command);
463
+ if (!normalizedCommand) {
464
+ return normalizedCommand;
465
+ }
466
+
467
+ const hasPathSegment = /[\\/]/.test(normalizedCommand) || /^[a-zA-Z]:/.test(normalizedCommand);
468
+ const hasExtension = path.extname(normalizedCommand).length > 0;
469
+ const extensions = hasExtension ? [''] : ['.cmd', '.exe', '.bat', '.com'];
470
+ const resolveCandidate = (basePath) => {
471
+ for (const ext of extensions) {
472
+ const candidate = ext ? `${basePath}${ext}` : basePath;
473
+ if (fs.existsSync(candidate)) {
474
+ return candidate;
475
+ }
476
+ }
477
+ return null;
478
+ };
479
+
480
+ if (hasPathSegment) {
481
+ const absoluteBasePath = path.isAbsolute(normalizedCommand)
482
+ ? normalizedCommand
483
+ : path.resolve(cwd || process.cwd(), normalizedCommand);
484
+ return resolveCandidate(absoluteBasePath) || normalizedCommand;
485
+ }
486
+
487
+ const pathKey = getPathEnvKey(env || process.env);
488
+ const pathValue = env && typeof env[pathKey] === 'string' ? env[pathKey] : '';
489
+ if (!pathValue) {
490
+ return normalizedCommand;
491
+ }
492
+
493
+ const searchPaths = pathValue.split(path.delimiter).filter(Boolean);
494
+ for (const searchPath of searchPaths) {
495
+ const found = resolveCandidate(path.join(searchPath.trim(), normalizedCommand));
496
+ if (found) {
497
+ return found;
498
+ }
499
+ }
500
+
501
+ return normalizedCommand;
502
+ }
503
+
504
+ function extractMcpHint(error) {
505
+ return error?.data?.hint || error?.hint || null;
506
+ }
507
+
508
+ function buildMcpFailureResult(error, fallbackMessage, duration) {
509
+ const hint = extractMcpHint(error);
510
+ return {
511
+ message: hint?.title || fallbackMessage || error?.message || '操作失败',
512
+ hint,
513
+ duration
514
+ };
515
+ }
516
+
421
517
  // ============================================================================
422
518
  // MCP 数据管理
423
519
  // ============================================================================
@@ -1218,10 +1314,12 @@ async function testServer(serverId) {
1218
1314
  return { success: false, message: `不支持的服务器类型: ${type}` };
1219
1315
  }
1220
1316
  } catch (err) {
1317
+ const failure = buildMcpFailureResult(err, err.message, Date.now() - startTime);
1221
1318
  return {
1222
1319
  success: false,
1223
- message: err.message,
1224
- duration: Date.now() - startTime
1320
+ message: failure.message,
1321
+ hint: failure.hint,
1322
+ duration: failure.duration
1225
1323
  };
1226
1324
  }
1227
1325
  }
@@ -1237,6 +1335,9 @@ async function testStdioServer(spec) {
1237
1335
  // 检查命令是否存在
1238
1336
  const command = spec.command;
1239
1337
  const args = spec.args || [];
1338
+ const cwd = spec.cwd || process.cwd();
1339
+ const mergedEnv = mergeSpawnEnv(spec.env || {});
1340
+ const resolvedCommand = resolveWindowsSpawnCommand(command, mergedEnv, cwd);
1240
1341
 
1241
1342
  let child;
1242
1343
  let resolved = false;
@@ -1260,10 +1361,10 @@ async function testStdioServer(spec) {
1260
1361
  };
1261
1362
 
1262
1363
  try {
1263
- child = spawn(command, args, {
1264
- env: { ...process.env, ...spec.env },
1364
+ child = spawn(resolvedCommand, args, {
1365
+ env: mergedEnv,
1265
1366
  stdio: ['pipe', 'pipe', 'pipe'],
1266
- cwd: spec.cwd || process.cwd()
1367
+ cwd
1267
1368
  });
1268
1369
 
1269
1370
  child.stdout.on('data', (data) => {
@@ -1284,9 +1385,11 @@ async function testStdioServer(spec) {
1284
1385
 
1285
1386
  child.on('error', (err) => {
1286
1387
  if (err.code === 'ENOENT') {
1388
+ const hint = createMissingCommandHint(command, resolvedCommand, mergedEnv);
1287
1389
  done({
1288
1390
  success: false,
1289
- message: `命令 "${command}" 未找到,请确保已安装`,
1391
+ message: buildMissingCommandMessage(command, resolvedCommand, mergedEnv),
1392
+ hint,
1290
1393
  duration: Date.now() - startTime
1291
1394
  });
1292
1395
  } else {
@@ -1336,10 +1439,12 @@ async function testStdioServer(spec) {
1336
1439
  }, timeout);
1337
1440
 
1338
1441
  } catch (err) {
1442
+ const failure = buildMcpFailureResult(err, `测试失败: ${err.message}`, Date.now() - startTime);
1339
1443
  done({
1340
1444
  success: false,
1341
- message: `测试失败: ${err.message}`,
1342
- duration: Date.now() - startTime
1445
+ message: failure.message,
1446
+ hint: failure.hint,
1447
+ duration: failure.duration
1343
1448
  });
1344
1449
  }
1345
1450
  });
@@ -1484,11 +1589,14 @@ async function getServerTools(serverId) {
1484
1589
  mcpClientPool.delete(serverId);
1485
1590
  }
1486
1591
 
1592
+ const failure = buildMcpFailureResult(err, err.message, Date.now() - startTime);
1487
1593
  return {
1488
1594
  tools: [],
1489
- duration: Date.now() - startTime,
1595
+ duration: failure.duration,
1490
1596
  status: 'error',
1491
- error: err.message
1597
+ error: failure.message,
1598
+ message: failure.message,
1599
+ hint: failure.hint
1492
1600
  };
1493
1601
  }
1494
1602
  }
@@ -1590,14 +1698,17 @@ async function callServerTool(serverId, toolName, arguments = {}) {
1590
1698
  mcpClientPool.delete(serverId);
1591
1699
  }
1592
1700
 
1701
+ const failure = buildMcpFailureResult(err, err.message, Date.now() - startTime);
1593
1702
  return {
1594
1703
  result: {
1595
- error: err.message,
1704
+ error: failure.message,
1596
1705
  code: err.code,
1597
1706
  data: err.data
1598
1707
  },
1599
- duration: Date.now() - startTime,
1600
- isError: true
1708
+ duration: failure.duration,
1709
+ isError: true,
1710
+ message: failure.message,
1711
+ hint: failure.hint
1601
1712
  };
1602
1713
  }
1603
1714
  }
@@ -1777,5 +1888,9 @@ module.exports = {
1777
1888
  callServerTool,
1778
1889
  updateServerStatus,
1779
1890
  updateServerOrder,
1780
- exportServers
1891
+ exportServers,
1892
+ _test: {
1893
+ extractMcpHint,
1894
+ buildMcpFailureResult
1895
+ }
1781
1896
  };
@@ -23,6 +23,7 @@ const MODEL_PRIORITY = {
23
23
  'claude-haiku-4-5-20251001'
24
24
  ],
25
25
  codex: [
26
+ 'gpt-5.4',
26
27
  'gpt-5.2-codex',
27
28
  'gpt-5.1-codex-max',
28
29
  'gpt-5.1-codex',
@@ -2,10 +2,74 @@ const { execSync } = require('child_process');
2
2
  const net = require('net');
3
3
  const { isWindowsLikePlatform } = require('./home-dir');
4
4
 
5
+ let lastPortToolIssue = null;
6
+
5
7
  function isWindowsLikeRuntime(platform = process.platform, env = process.env) {
6
8
  return isWindowsLikePlatform(platform, env);
7
9
  }
8
10
 
11
+ function clearPortToolIssue() {
12
+ lastPortToolIssue = null;
13
+ }
14
+
15
+ function rememberPortToolIssue(issue) {
16
+ if (!lastPortToolIssue) {
17
+ lastPortToolIssue = issue;
18
+ }
19
+ return lastPortToolIssue;
20
+ }
21
+
22
+ function getPortToolIssue() {
23
+ return lastPortToolIssue;
24
+ }
25
+
26
+ function isMissingCommandError(error) {
27
+ const message = [
28
+ error && typeof error.message === 'string' ? error.message : '',
29
+ error && typeof error.stderr === 'string'
30
+ ? error.stderr
31
+ : Buffer.isBuffer(error?.stderr)
32
+ ? error.stderr.toString('utf-8')
33
+ : ''
34
+ ].join('\n');
35
+
36
+ return error?.code === 'ENOENT' || /not found|not recognized as an internal or external command/i.test(message);
37
+ }
38
+
39
+ function createPortToolIssue(command, phase, isWindows) {
40
+ if (isWindows) {
41
+ return {
42
+ command,
43
+ phase,
44
+ platform: 'windows',
45
+ summary: `未找到系统命令 ${command},无法${phase === 'kill' ? '关闭' : '检测'}占用端口的进程。`,
46
+ hints: [
47
+ '请确认已安装或启用 Windows 自带网络工具。',
48
+ '请将 `C:\\Windows\\System32` 加入 PATH 后重试。'
49
+ ]
50
+ };
51
+ }
52
+
53
+ return {
54
+ command,
55
+ phase,
56
+ platform: 'unix',
57
+ summary: `未找到 ${command},无法${phase === 'kill' ? '关闭' : '检测'}占用端口的进程。`,
58
+ hints: [
59
+ '请安装 `lsof`,或安装提供 `fuser` 的系统工具后重试。',
60
+ '如果暂时不想安装,也可以改用其他端口。'
61
+ ]
62
+ };
63
+ }
64
+
65
+ function formatPortToolIssue(issue = lastPortToolIssue) {
66
+ if (!issue) {
67
+ return [];
68
+ }
69
+
70
+ return [issue.summary, ...issue.hints];
71
+ }
72
+
9
73
  function parsePidsFromNetstatOutput(output, port) {
10
74
  const target = `:${port}`;
11
75
  const pids = new Set();
@@ -69,6 +133,7 @@ function isPortInUse(port, host = '127.0.0.1') {
69
133
  * 查找占用端口的进程PID(跨平台)
70
134
  */
71
135
  function findProcessByPort(port) {
136
+ clearPortToolIssue();
72
137
  const isWindows = isWindowsLikeRuntime();
73
138
  if (isWindows) {
74
139
  try {
@@ -76,20 +141,28 @@ function findProcessByPort(port) {
76
141
  const result = execSync('netstat -ano', { encoding: 'utf-8' });
77
142
  return parsePidsFromNetstatOutput(result, port);
78
143
  } catch (e) {
144
+ if (isMissingCommandError(e)) {
145
+ rememberPortToolIssue(createPortToolIssue('netstat', 'lookup', true));
146
+ }
79
147
  return [];
80
148
  }
81
149
  }
82
150
 
151
+ let lsofMissing = false;
83
152
  try {
84
153
  // macOS/Linux 使用 lsof
85
154
  const result = execSync(`lsof -ti :${port}`, { encoding: 'utf-8' }).trim();
86
155
  return result.split('\n').filter(pid => pid);
87
156
  } catch (err) {
157
+ lsofMissing = isMissingCommandError(err);
88
158
  // 如果 lsof 失败,尝试使用 fuser(某些 Linux 系统)
89
159
  try {
90
160
  const result = execSync(`fuser ${port}/tcp 2>/dev/null`, { encoding: 'utf-8' }).trim();
91
161
  return result.split(/\s+/).filter(pid => pid);
92
162
  } catch (e) {
163
+ if (lsofMissing && isMissingCommandError(e)) {
164
+ rememberPortToolIssue(createPortToolIssue('lsof / fuser', 'lookup', false));
165
+ }
93
166
  return [];
94
167
  }
95
168
  }
@@ -106,6 +179,7 @@ function killProcessByPort(port) {
106
179
  }
107
180
 
108
181
  const isWindows = isWindowsLikeRuntime();
182
+ let killedAny = false;
109
183
  pids.forEach(pid => {
110
184
  try {
111
185
  if (isWindows) {
@@ -113,12 +187,16 @@ function killProcessByPort(port) {
113
187
  } else {
114
188
  execSync(`kill -9 ${pid}`, { stdio: 'ignore' });
115
189
  }
190
+ killedAny = true;
116
191
  } catch (err) {
192
+ if (isWindows && isMissingCommandError(err)) {
193
+ rememberPortToolIssue(createPortToolIssue('taskkill', 'kill', true));
194
+ }
117
195
  // 忽略单个进程杀掉失败的错误
118
196
  }
119
197
  });
120
198
 
121
- return true;
199
+ return killedAny;
122
200
  } catch (err) {
123
201
  return false;
124
202
  }
@@ -146,6 +224,13 @@ module.exports = {
146
224
  findProcessByPort,
147
225
  killProcessByPort,
148
226
  waitForPortRelease,
227
+ getPortToolIssue,
228
+ formatPortToolIssue,
149
229
  isWindowsLikeRuntime,
150
- parsePidsFromNetstatOutput
230
+ parsePidsFromNetstatOutput,
231
+ _test: {
232
+ isMissingCommandError,
233
+ createPortToolIssue,
234
+ formatPortToolIssue
235
+ }
151
236
  };