Skip to content

Commit ded9f5f

Browse files
committed
fix: harden cli port handling and add tests
1 parent a36baf2 commit ded9f5f

5 files changed

Lines changed: 368 additions & 77 deletions

File tree

index.js

Lines changed: 7 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,11 @@
11
#!/usr/bin/env node
2-
const { exec } = require('child_process');
2+
const { runCli } = require('./lib/kill-port');
33

4-
// 获取命令行参数
5-
const args = process.argv.slice(2);
6-
7-
if (args.length < 1) {
8-
console.error('Usage: kill-port <port1> [<port2> ...]');
9-
process.exit(1);
10-
}
11-
12-
const ports = args.map(port => parseInt(port, 10)).filter(port => !isNaN(port));
13-
14-
if (ports.length === 0) {
15-
console.error('Invalid port number(s)');
16-
process.exit(1);
17-
}
18-
19-
const findPidCommand = port => process.platform === 'win32'
20-
? `netstat -ano | findstr :${port}`
21-
: `lsof -i:${port} -t`;
22-
23-
const killCommand = pid => process.platform === 'win32'
24-
? `taskkill /PID ${pid} /F`
25-
: `kill -9 ${pid}`;
26-
27-
const findAndKillProcess = (port) => {
28-
return new Promise((resolve, reject) => {
29-
// 查找占用指定端口的进程ID
30-
exec(findPidCommand(port), (err, stdout, stderr) => {
31-
if (err || stderr) {
32-
resolve(`Port ${port} is not in use.`);
33-
return;
34-
}
35-
36-
const pids = process.platform === 'win32'
37-
? stdout
38-
.split('\n')
39-
.filter(line => line.includes('LISTEN'))
40-
.map(line => line.trim().split(/\s+/).pop())
41-
.filter(Boolean)
42-
: stdout.split('\n').map(line => line.trim()).filter(Boolean);
43-
44-
if (pids.length === 0) {
45-
resolve(`No process found on port ${port}`);
46-
return;
47-
}
48-
49-
console.log(`Found processes on port ${port} with PIDs: ${pids.join(', ')}`);
50-
51-
Promise.all(
52-
pids.map(pid =>
53-
new Promise((killResolve, killReject) => {
54-
exec(killCommand(pid), (killErr, killStdout, killStderr) => {
55-
if (killErr || killStderr) {
56-
killReject(`Error killing process ${pid}: ${killErr || killStderr}`);
57-
return;
58-
}
59-
killResolve(`Process ${pid} killed successfully on port ${port}.`);
60-
});
61-
})
62-
)
63-
)
64-
.then(killResults => resolve(killResults.join('\n')))
65-
.catch(killErr => reject(killErr));
66-
});
67-
});
68-
};
69-
70-
71-
// 并行处理所有端口
72-
Promise.all(ports.map(findAndKillProcess))
73-
.then(results => {
74-
results.forEach(result => console.log(result));
4+
runCli(process.argv.slice(2))
5+
.then((exitCode) => {
6+
process.exitCode = exitCode;
757
})
76-
.catch(err => {
77-
console.error(err);
8+
.catch((error) => {
9+
console.error(error.message);
10+
process.exitCode = 1;
7811
});

lib/kill-port.js

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
const { exec } = require('child_process');
2+
3+
const USAGE_MESSAGE = 'Usage: kill-port <port1> [<port2> ...]';
4+
const PORT_PATTERN = /^\d+$/;
5+
const PID_PATTERN = /^\d+$/;
6+
const MIN_PORT = 1;
7+
const MAX_PORT = 65535;
8+
9+
function parseAndValidatePortArgs(args) {
10+
if (args.length === 0) {
11+
return { error: USAGE_MESSAGE };
12+
}
13+
14+
const invalidTokens = args.filter((token) => !isValidPortArgument(token));
15+
16+
if (invalidTokens.length > 0) {
17+
return { error: `Invalid port number(s): ${invalidTokens.join(', ')}` };
18+
}
19+
20+
return {
21+
ports: [...new Set(args.map((token) => Number(token)))],
22+
};
23+
}
24+
25+
function isValidPortArgument(token) {
26+
if (!PORT_PATTERN.test(token)) {
27+
return false;
28+
}
29+
30+
const port = Number(token);
31+
return Number.isInteger(port) && port >= MIN_PORT && port <= MAX_PORT;
32+
}
33+
34+
function createPlatformProcessCommands(platform) {
35+
if (platform === 'win32') {
36+
return {
37+
buildPortLookupCommand: (port) => `netstat -ano | findstr :${port}`,
38+
buildProcessKillCommand: (processId) => `taskkill /PID ${processId} /F`,
39+
extractProcessIds: (stdout) => stdout
40+
.split('\n')
41+
.filter((line) => line.includes('LISTEN'))
42+
.map((line) => line.trim().split(/\s+/).pop())
43+
.filter((processId) => PID_PATTERN.test(processId)),
44+
};
45+
}
46+
47+
return {
48+
buildPortLookupCommand: (port) => `lsof -i:${port} -t`,
49+
buildProcessKillCommand: (processId) => `kill -9 ${processId}`,
50+
extractProcessIds: (stdout) => stdout
51+
.split('\n')
52+
.map((line) => line.trim())
53+
.filter((processId) => PID_PATTERN.test(processId)),
54+
};
55+
}
56+
57+
function executeShellCommand(command, execFn = exec) {
58+
return new Promise((resolve, reject) => {
59+
execFn(command, (error, stdout = '', stderr = '') => {
60+
if (error) {
61+
const commandError = new Error(stderr.trim() || error.message.trim());
62+
commandError.code = error.code;
63+
commandError.command = command;
64+
commandError.stdout = stdout;
65+
commandError.stderr = stderr;
66+
reject(commandError);
67+
return;
68+
}
69+
70+
resolve({ stdout, stderr });
71+
});
72+
});
73+
}
74+
75+
function isLookupCommandNoMatchError(error) {
76+
if (!error || error.code !== 1) {
77+
return false;
78+
}
79+
80+
// `lsof` and `findstr` both use exit code 1 with empty output to signal "no matches".
81+
return error.stdout.trim() === '' && error.stderr.trim() === '';
82+
}
83+
84+
async function killPorts(ports, options = {}) {
85+
const executionContext = createExecutionContext(options);
86+
const tasks = ports.map((port) => killProcessesOnPort(port, executionContext));
87+
return Promise.all(tasks);
88+
}
89+
90+
function createExecutionContext(options) {
91+
const platform = options.platform || process.platform;
92+
93+
return {
94+
execFn: options.execFn || exec,
95+
platform,
96+
platformCommands: options.platformCommands || createPlatformProcessCommands(platform),
97+
};
98+
}
99+
100+
async function killProcessesOnPort(port, executionContext) {
101+
let processIds;
102+
103+
try {
104+
processIds = await findProcessIdsUsingPort(port, executionContext);
105+
} catch (error) {
106+
return {
107+
ok: false,
108+
port,
109+
status: 'inspect_error',
110+
message: `Failed to inspect port ${port}: ${error.message}`,
111+
};
112+
}
113+
114+
if (processIds.length === 0) {
115+
return {
116+
ok: true,
117+
port,
118+
status: 'not_in_use',
119+
message: `Port ${port} is not in use.`,
120+
};
121+
}
122+
123+
const killAttemptResults = await Promise.all(
124+
processIds.map((processId) => killProcessId(processId, port, executionContext))
125+
);
126+
127+
const killedProcessIds = killAttemptResults
128+
.filter((result) => result.ok)
129+
.map((result) => result.pid);
130+
const failedKillAttempts = killAttemptResults.filter((result) => !result.ok);
131+
132+
if (failedKillAttempts.length === 0) {
133+
return {
134+
ok: true,
135+
port,
136+
status: 'killed',
137+
message: formatKilledProcessMessage(port, killedProcessIds),
138+
};
139+
}
140+
141+
if (killedProcessIds.length === 0) {
142+
return {
143+
ok: false,
144+
port,
145+
status: 'kill_error',
146+
message: `Failed to kill processes on port ${port}: ${formatFailedKillAttempts(failedKillAttempts)}`,
147+
};
148+
}
149+
150+
return {
151+
ok: false,
152+
port,
153+
status: 'partial_kill_error',
154+
message: `Port ${port}: killed PIDs ${killedProcessIds.join(', ')}; failed to kill ${formatFailedKillAttempts(failedKillAttempts)}`,
155+
};
156+
}
157+
158+
async function findProcessIdsUsingPort(port, executionContext) {
159+
try {
160+
const { stdout } = await executeShellCommand(
161+
executionContext.platformCommands.buildPortLookupCommand(port),
162+
executionContext.execFn
163+
);
164+
165+
return dedupeValues(executionContext.platformCommands.extractProcessIds(stdout));
166+
} catch (error) {
167+
if (isLookupCommandNoMatchError(error)) {
168+
return [];
169+
}
170+
171+
throw error;
172+
}
173+
}
174+
175+
async function killProcessId(processId, port, executionContext) {
176+
try {
177+
await executeShellCommand(
178+
executionContext.platformCommands.buildProcessKillCommand(processId),
179+
executionContext.execFn
180+
);
181+
return { ok: true, pid: processId };
182+
} catch (error) {
183+
return {
184+
ok: false,
185+
pid: processId,
186+
error: `PID ${processId} (${error.message})`,
187+
port,
188+
};
189+
}
190+
}
191+
192+
function formatKilledProcessMessage(port, processIds) {
193+
if (processIds.length === 1) {
194+
return `Killed process ${processIds[0]} on port ${port}.`;
195+
}
196+
197+
return `Killed processes on port ${port}: ${processIds.join(', ')}.`;
198+
}
199+
200+
function formatFailedKillAttempts(failedKillAttempts) {
201+
return failedKillAttempts.map((failure) => failure.error).join('; ');
202+
}
203+
204+
function dedupeValues(values) {
205+
return [...new Set(values)];
206+
}
207+
208+
async function runCli(args, options = {}) {
209+
const parsedPortArgs = parseAndValidatePortArgs(args);
210+
const writeStdout = options.stdout || console.log;
211+
const writeStderr = options.stderr || console.error;
212+
213+
if (parsedPortArgs.error) {
214+
writeStderr(parsedPortArgs.error);
215+
return 1;
216+
}
217+
218+
const portResults = await killPorts(parsedPortArgs.ports, options);
219+
220+
portResults.forEach((result) => {
221+
if (result.ok) {
222+
writeStdout(result.message);
223+
return;
224+
}
225+
226+
writeStderr(result.message);
227+
});
228+
229+
return portResults.every((result) => result.ok) ? 0 : 1;
230+
}
231+
232+
module.exports = {
233+
USAGE_MESSAGE,
234+
createPlatformProcessCommands,
235+
executeShellCommand,
236+
isLookupCommandNoMatchError,
237+
isValidPortArgument,
238+
killPorts,
239+
parseAndValidatePortArgs,
240+
runCli,
241+
};

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
{
22
"name": "kill-port-process-cli",
3-
"version": "1.0.15",
3+
"version": "1.0.16",
44
"description": "A CLI tool to kill processes by port",
55
"main": "index.js",
66
"files": [
77
"index.js",
8+
"lib",
89
"README.md",
910
"LICENSE"
1011
],
@@ -14,7 +15,7 @@
1415
"k": "index.js"
1516
},
1617
"scripts": {
17-
"test": "echo \"Error: no test specified\" && exit 1"
18+
"test": "node --test"
1819
},
1920
"repository": {
2021
"type": "git",

0 commit comments

Comments
 (0)