port-reclaim 0.1.0 → 0.2.0
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/LICENSE +21 -21
- package/README.md +134 -72
- package/dist/src/args.d.ts +8 -0
- package/dist/src/args.js +35 -0
- package/dist/src/cli.d.ts +3 -0
- package/dist/src/cli.js +113 -23
- package/dist/src/config.d.ts +7 -0
- package/dist/src/config.js +55 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +3 -0
- package/dist/src/process.d.ts +22 -0
- package/dist/src/process.js +107 -22
- package/dist/tests/args.test.d.ts +1 -0
- package/dist/tests/args.test.js +25 -0
- package/dist/tests/cli.test.d.ts +1 -0
- package/dist/tests/cli.test.js +144 -0
- package/dist/tests/config.test.d.ts +1 -0
- package/dist/tests/config.test.js +37 -0
- package/dist/tests/process.test.d.ts +1 -0
- package/dist/tests/process.test.js +27 -1
- package/package.json +54 -46
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Prince Goel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,72 +1,134 @@
|
|
|
1
|
-
# port-reclaim
|
|
2
|
-
|
|
3
|
-
A zero-configuration, cross-platform CLI for safely reclaiming ports held by stale local development processes.
|
|
4
|
-
|
|
5
|
-
## Install
|
|
6
|
-
|
|
7
|
-
Run it once without installing:
|
|
8
|
-
|
|
9
|
-
```sh
|
|
10
|
-
npx port-reclaim 3000
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
Or install it globally:
|
|
14
|
-
|
|
15
|
-
```sh
|
|
16
|
-
npm install --global port-reclaim
|
|
17
|
-
port-reclaim 3000
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
Requires Node.js 18 or newer. Supports macOS, Linux, Windows, WSL, PowerShell, and CMD.
|
|
21
|
-
|
|
22
|
-
## How It Works
|
|
23
|
-
|
|
24
|
-
```sh
|
|
25
|
-
port-reclaim <PORT>
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
If the port is free, the command exits successfully. If the process working directory matches the current project, the process is terminated automatically. Processes from another directory, system services, and processes whose working directory cannot be read require explicit confirmation.
|
|
29
|
-
|
|
30
|
-
The command uses a graceful termination signal first on Unix-like systems and falls back to a forceful signal if the process remains alive. Windows uses `taskkill`.
|
|
31
|
-
|
|
32
|
-
If confirmation is required in a non-interactive environment, the command declines safely and exits with status `1
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
1
|
+
# port-reclaim
|
|
2
|
+
|
|
3
|
+
A zero-configuration, cross-platform CLI for safely reclaiming ports held by stale local development processes.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Run it once without installing:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx port-reclaim 3000
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or install it globally:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm install --global port-reclaim
|
|
17
|
+
port-reclaim 3000
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Requires Node.js 18 or newer. Supports macOS, Linux, Windows, WSL, PowerShell, and CMD.
|
|
21
|
+
|
|
22
|
+
## How It Works
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
port-reclaim <PORT> [PORT ...]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
If the port is free, the command exits successfully. If the process working directory matches the current project, the process is terminated automatically. Processes from another directory, system services, and processes whose working directory cannot be read require explicit confirmation. Confirmation prompts show the process name, working directory, and uptime.
|
|
29
|
+
|
|
30
|
+
The command uses a graceful termination signal first on Unix-like systems and falls back to a forceful signal if the process remains alive. Windows uses `taskkill /F`.
|
|
31
|
+
|
|
32
|
+
If confirmation is required in a non-interactive environment, the command declines safely and exits with status `1` (rerun with `--yes` to override).
|
|
33
|
+
|
|
34
|
+
Ports held by Docker processes (for example `docker-proxy` or Docker Desktop) are never killed. The tool explains that the port looks like Docker and suggests stopping the container instead.
|
|
35
|
+
|
|
36
|
+
Both TCP and UDP listeners are discovered.
|
|
37
|
+
|
|
38
|
+
### Options
|
|
39
|
+
|
|
40
|
+
| Flag | Meaning |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `-l`, `--list` | Report what is using each port without killing anything. |
|
|
43
|
+
| `-y`, `--yes` | Terminate processes from other directories without prompting. |
|
|
44
|
+
| `-h`, `--help` | Show the help message. |
|
|
45
|
+
| `-v`, `--version` | Show the installed version. |
|
|
46
|
+
|
|
47
|
+
## Configuration
|
|
48
|
+
|
|
49
|
+
Ports can be declared once so scripts can run plain `port-reclaim` with no arguments. Add a `port-reclaim` key to `package.json`:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"port-reclaim": {
|
|
54
|
+
"ports": [3000, 5173],
|
|
55
|
+
"ignore": [5432]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Ports in `ignore` are skipped with a notice. A `.reclaimignore` file in the project root protects ports the same way — one port per line, `#` starts a comment:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
# databases
|
|
64
|
+
5432
|
|
65
|
+
6379
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Script Integration
|
|
69
|
+
|
|
70
|
+
Use it in a package script:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"scripts": {
|
|
75
|
+
"dev": "port-reclaim 3000 && next dev"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Or rely on configured ports:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"scripts": {
|
|
85
|
+
"dev": "port-reclaim && next dev"
|
|
86
|
+
},
|
|
87
|
+
"port-reclaim": { "ports": [3000] }
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The same command can be used from Python, Ruby, Go, Make, or shell scripts.
|
|
92
|
+
|
|
93
|
+
## Exit Codes
|
|
94
|
+
|
|
95
|
+
| Code | Meaning |
|
|
96
|
+
| --- | --- |
|
|
97
|
+
| `0` | The port was free, listed with `--list`, or successfully released. |
|
|
98
|
+
| `1` | The port is still in use, the user declined, or an operation failed. |
|
|
99
|
+
| `2` | Invalid command-line input. |
|
|
100
|
+
|
|
101
|
+
## Programmatic Use
|
|
102
|
+
|
|
103
|
+
The process runner is exported for use in other tools:
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
import { createProcessRunner } from "port-reclaim";
|
|
107
|
+
|
|
108
|
+
const runner = createProcessRunner();
|
|
109
|
+
const found = await runner.discover(3000);
|
|
110
|
+
for (const process of found) {
|
|
111
|
+
console.log(process.name, process.cwd, process.ageMs);
|
|
112
|
+
}
|
|
113
|
+
if (found.length > 0) {
|
|
114
|
+
await runner.terminate(found[0].pid);
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Security Notes
|
|
119
|
+
|
|
120
|
+
`port-reclaim` only acts on processes listening on the port you provide. It does not scan remote hosts, and it refuses to kill Docker processes — stop those containers yourself. Ports protected by `.reclaimignore` or the `ignore` config are always skipped. Review the process name and working directory before confirming a process from another project. UDP matches are heuristic: a UDP socket on a port can belong to a client as well as a server.
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
npm install
|
|
126
|
+
npm test
|
|
127
|
+
npm run build
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Pull requests are tested on Ubuntu, macOS, and Windows with Node.js 18, 20, and 22.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT. See [LICENSE](LICENSE).
|
package/dist/src/args.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const HELP_FLAGS = new Set(["--help", "-h"]);
|
|
2
|
+
const VERSION_FLAGS = new Set(["--version", "-v"]);
|
|
3
|
+
const YES_FLAGS = new Set(["--yes", "-y", "--force", "-f"]);
|
|
4
|
+
const LIST_FLAGS = new Set(["--list", "-l"]);
|
|
5
|
+
function parsePort(value) {
|
|
6
|
+
if (!/^\d+$/.test(value))
|
|
7
|
+
return undefined;
|
|
8
|
+
const port = Number(value);
|
|
9
|
+
return port >= 1 && port <= 65535 ? port : undefined;
|
|
10
|
+
}
|
|
11
|
+
export function parseArgs(argv) {
|
|
12
|
+
const options = { ports: [], help: false, version: false, yes: false, list: false };
|
|
13
|
+
for (const arg of argv) {
|
|
14
|
+
if (HELP_FLAGS.has(arg)) {
|
|
15
|
+
options.help = true;
|
|
16
|
+
}
|
|
17
|
+
else if (VERSION_FLAGS.has(arg)) {
|
|
18
|
+
options.version = true;
|
|
19
|
+
}
|
|
20
|
+
else if (YES_FLAGS.has(arg)) {
|
|
21
|
+
options.yes = true;
|
|
22
|
+
}
|
|
23
|
+
else if (LIST_FLAGS.has(arg)) {
|
|
24
|
+
options.list = true;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
const port = parsePort(arg);
|
|
28
|
+
if (port === undefined)
|
|
29
|
+
return undefined;
|
|
30
|
+
if (!options.ports.includes(port))
|
|
31
|
+
options.ports.push(port);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return options;
|
|
35
|
+
}
|
package/dist/src/cli.js
CHANGED
|
@@ -2,19 +2,26 @@
|
|
|
2
2
|
import readline from "node:readline/promises";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
5
6
|
import { pathToFileURL } from "node:url";
|
|
6
7
|
import { createProcessRunner } from "./process.js";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
8
|
+
import { parseArgs } from "./args.js";
|
|
9
|
+
import { loadConfig } from "./config.js";
|
|
10
|
+
const USAGE = `Usage: port-reclaim [options] <PORT ...>
|
|
11
|
+
|
|
12
|
+
Safely reclaim local ports held by stale development processes.
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
-l, --list Report what is using each port without killing anything.
|
|
16
|
+
-y, --yes Terminate processes from other directories without prompting.
|
|
17
|
+
-h, --help Show this help message.
|
|
18
|
+
-v, --version Show the installed version.
|
|
19
|
+
|
|
20
|
+
Ports may also come from the "port-reclaim" key in package.json:
|
|
21
|
+
"port-reclaim": { "ports": [3000, 5173], "ignore": [5432] }
|
|
22
|
+
Ports listed in .reclaimignore (one per line, '#' starts a comment) are never touched.`;
|
|
23
|
+
function printUsage() {
|
|
24
|
+
console.error(USAGE);
|
|
18
25
|
}
|
|
19
26
|
function sameDirectory(left, right) {
|
|
20
27
|
if (!left)
|
|
@@ -23,30 +30,113 @@ function sameDirectory(left, right) {
|
|
|
23
30
|
const normalizedRight = path.normalize(path.resolve(right));
|
|
24
31
|
return process.platform === "win32" ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() : normalizedLeft === normalizedRight;
|
|
25
32
|
}
|
|
33
|
+
function formatAge(ageMs) {
|
|
34
|
+
const totalSeconds = Math.floor(ageMs / 1000);
|
|
35
|
+
const days = Math.floor(totalSeconds / 86400);
|
|
36
|
+
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
|
37
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
38
|
+
if (days > 0)
|
|
39
|
+
return `${days}d ${hours}h`;
|
|
40
|
+
if (hours > 0)
|
|
41
|
+
return `${hours}h ${minutes}m`;
|
|
42
|
+
if (minutes > 0)
|
|
43
|
+
return `${minutes}m ${totalSeconds % 60}s`;
|
|
44
|
+
return `${totalSeconds}s`;
|
|
45
|
+
}
|
|
46
|
+
function describe(portProcess) {
|
|
47
|
+
const details = [`PID ${portProcess.pid}`];
|
|
48
|
+
if (portProcess.cwd)
|
|
49
|
+
details.push(`'${portProcess.cwd}'`);
|
|
50
|
+
if (portProcess.ageMs !== undefined)
|
|
51
|
+
details.push(`up ${formatAge(portProcess.ageMs)}`);
|
|
52
|
+
if (portProcess.protocol === "udp")
|
|
53
|
+
details.push("udp");
|
|
54
|
+
return `'${portProcess.name}' (${details.join(", ")})`;
|
|
55
|
+
}
|
|
26
56
|
async function confirm(portProcess, port) {
|
|
27
57
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
28
|
-
console.error(`Port ${port} is used by
|
|
58
|
+
console.error(`Port ${port} is used by ${describe(portProcess)}. Use an interactive terminal to confirm termination, or rerun with --yes.`);
|
|
29
59
|
return false;
|
|
30
60
|
}
|
|
31
61
|
const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
32
|
-
const answer = await prompt.question(`Port ${port} is used by
|
|
62
|
+
const answer = await prompt.question(`Port ${port} is used by ${describe(portProcess)}. Kill it? (y/N) `);
|
|
33
63
|
prompt.close();
|
|
34
64
|
return /^y(es)?$/i.test(answer.trim());
|
|
35
65
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
66
|
+
async function version() {
|
|
67
|
+
try {
|
|
68
|
+
const pkg = JSON.parse(await readFile(new URL("../../package.json", import.meta.url), "utf8"));
|
|
69
|
+
return pkg.version ?? "unknown";
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return "unknown";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function reclaimPort(port, runner, options) {
|
|
76
|
+
const portProcesses = await runner.discover(port);
|
|
77
|
+
if (portProcesses.length === 0) {
|
|
78
|
+
if (options.list)
|
|
79
|
+
console.log(`Port ${port} is free.`);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
let free = true;
|
|
41
83
|
const currentDirectory = process.cwd();
|
|
42
|
-
for (const portProcess of
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
84
|
+
for (const portProcess of portProcesses) {
|
|
85
|
+
if (options.list) {
|
|
86
|
+
const origin = sameDirectory(portProcess.cwd, currentDirectory)
|
|
87
|
+
? "matches the current directory"
|
|
88
|
+
: portProcess.cwd ? `'${portProcess.cwd}'` : "an unreadable working directory";
|
|
89
|
+
const docker = portProcess.docker ? " [docker]" : "";
|
|
90
|
+
console.log(`Port ${port} is used by ${describe(portProcess)}${docker} — ${origin}.`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (portProcess.docker) {
|
|
94
|
+
console.error(`Port ${port} is used by ${describe(portProcess)}, which appears to be a Docker process. Refusing to kill it — stop the container instead (for example 'docker stop <container>').`);
|
|
95
|
+
free = false;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const shouldTerminate = sameDirectory(portProcess.cwd, currentDirectory) || options.yes || await confirm(portProcess, port);
|
|
99
|
+
if (!shouldTerminate) {
|
|
100
|
+
free = false;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
46
103
|
await runner.terminate(portProcess.pid);
|
|
47
104
|
console.log(`Released port ${port} from ${portProcess.name} (PID ${portProcess.pid}).`);
|
|
48
105
|
}
|
|
49
|
-
return
|
|
106
|
+
return free;
|
|
107
|
+
}
|
|
108
|
+
export async function main(argv = process.argv.slice(2), runner = createProcessRunner()) {
|
|
109
|
+
const options = parseArgs(argv);
|
|
110
|
+
if (!options) {
|
|
111
|
+
printUsage();
|
|
112
|
+
return 2;
|
|
113
|
+
}
|
|
114
|
+
if (options.help) {
|
|
115
|
+
console.log(USAGE);
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
if (options.version) {
|
|
119
|
+
console.log(await version());
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
const config = await loadConfig(process.cwd());
|
|
123
|
+
const ignored = new Set(config.ignore);
|
|
124
|
+
const ports = options.ports.length > 0 ? options.ports : config.ports;
|
|
125
|
+
if (ports.length === 0) {
|
|
126
|
+
printUsage();
|
|
127
|
+
return 2;
|
|
128
|
+
}
|
|
129
|
+
let exitCode = 0;
|
|
130
|
+
for (const port of ports) {
|
|
131
|
+
if (ignored.has(port)) {
|
|
132
|
+
console.log(`Port ${port} is protected by .reclaimignore or config — skipped.`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const free = await reclaimPort(port, runner, options);
|
|
136
|
+
if (!free)
|
|
137
|
+
exitCode = 1;
|
|
138
|
+
}
|
|
139
|
+
return exitCode;
|
|
50
140
|
}
|
|
51
141
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
52
142
|
main().then((code) => process.exit(code)).catch((error) => {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface ReclaimConfig {
|
|
2
|
+
ports: number[];
|
|
3
|
+
ignore: number[];
|
|
4
|
+
}
|
|
5
|
+
export declare function parseReclaimignore(content: string): number[];
|
|
6
|
+
export declare function parsePackageConfig(json: unknown): ReclaimConfig;
|
|
7
|
+
export declare function loadConfig(cwd: string): Promise<ReclaimConfig>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
function toPortList(value) {
|
|
4
|
+
if (!Array.isArray(value))
|
|
5
|
+
return [];
|
|
6
|
+
const ports = [];
|
|
7
|
+
for (const entry of value) {
|
|
8
|
+
const port = typeof entry === "number" ? entry : typeof entry === "string" && /^\d+$/.test(entry) ? Number(entry) : NaN;
|
|
9
|
+
if (Number.isInteger(port) && port >= 1 && port <= 65535 && !ports.includes(port))
|
|
10
|
+
ports.push(port);
|
|
11
|
+
}
|
|
12
|
+
return ports;
|
|
13
|
+
}
|
|
14
|
+
export function parseReclaimignore(content) {
|
|
15
|
+
const ports = [];
|
|
16
|
+
for (const line of content.split(/\r?\n/)) {
|
|
17
|
+
const trimmed = line.trim();
|
|
18
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
19
|
+
continue;
|
|
20
|
+
const port = Number(trimmed);
|
|
21
|
+
if (Number.isInteger(port) && port >= 1 && port <= 65535 && !ports.includes(port))
|
|
22
|
+
ports.push(port);
|
|
23
|
+
}
|
|
24
|
+
return ports;
|
|
25
|
+
}
|
|
26
|
+
export function parsePackageConfig(json) {
|
|
27
|
+
if (typeof json !== "object" || json === null)
|
|
28
|
+
return { ports: [], ignore: [] };
|
|
29
|
+
const key = json["port-reclaim"];
|
|
30
|
+
if (typeof key !== "object" || key === null)
|
|
31
|
+
return { ports: [], ignore: [] };
|
|
32
|
+
const config = key;
|
|
33
|
+
return { ports: toPortList(config.ports), ignore: toPortList(config.ignore) };
|
|
34
|
+
}
|
|
35
|
+
export async function loadConfig(cwd) {
|
|
36
|
+
let ports = [];
|
|
37
|
+
let ignore = [];
|
|
38
|
+
try {
|
|
39
|
+
const pkg = JSON.parse(await readFile(path.join(cwd, "package.json"), "utf8"));
|
|
40
|
+
const config = parsePackageConfig(pkg);
|
|
41
|
+
ports = config.ports;
|
|
42
|
+
ignore = config.ignore;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// No readable package.json or no usable config key.
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const extra = parseReclaimignore(await readFile(path.join(cwd, ".reclaimignore"), "utf8"));
|
|
49
|
+
ignore = [...new Set([...ignore, ...extra])];
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// No .reclaimignore file.
|
|
53
|
+
}
|
|
54
|
+
return { ports, ignore };
|
|
55
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createProcessRunner, parseEtime, parseNetstatPids, parseUnixLsof, parseWindowsPids } from "./process.js";
|
|
2
|
+
export type { NetstatPid, PortProcess, ProcessRunner, Protocol } from "./process.js";
|
|
3
|
+
export { loadConfig, parsePackageConfig, parseReclaimignore } from "./config.js";
|
|
4
|
+
export type { ReclaimConfig } from "./config.js";
|
|
5
|
+
export { parseArgs } from "./args.js";
|
|
6
|
+
export type { CliOptions } from "./args.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type Protocol = "tcp" | "udp";
|
|
2
|
+
export interface PortProcess {
|
|
3
|
+
pid: number;
|
|
4
|
+
name: string;
|
|
5
|
+
cwd?: string;
|
|
6
|
+
protocol?: Protocol;
|
|
7
|
+
ageMs?: number;
|
|
8
|
+
docker?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface ProcessRunner {
|
|
11
|
+
discover(port: number): Promise<PortProcess[]>;
|
|
12
|
+
terminate(pid: number): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export declare function parseWindowsPids(output: string): number[];
|
|
15
|
+
export declare function parseUnixLsof(output: string): number[];
|
|
16
|
+
export declare function parseEtime(value: string): number | undefined;
|
|
17
|
+
export interface NetstatPid {
|
|
18
|
+
pid: number;
|
|
19
|
+
protocol: Protocol;
|
|
20
|
+
}
|
|
21
|
+
export declare function parseNetstatPids(output: string, port: number): NetstatPid[];
|
|
22
|
+
export declare function createProcessRunner(): ProcessRunner;
|
package/dist/src/process.js
CHANGED
|
@@ -3,6 +3,10 @@ import { promisify } from "node:util";
|
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import pidCwd from "pid-cwd";
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
|
+
const DOCKER_PROCESS = /docker|vpnkit/i;
|
|
7
|
+
function isDockerProcess(name) {
|
|
8
|
+
return DOCKER_PROCESS.test(name);
|
|
9
|
+
}
|
|
6
10
|
function parsePidList(output) {
|
|
7
11
|
return [...new Set(output.split(/\r?\n/).map((line) => Number(line.trim())).filter((pid) => Number.isInteger(pid) && pid > 0))];
|
|
8
12
|
}
|
|
@@ -12,7 +16,49 @@ export function parseWindowsPids(output) {
|
|
|
12
16
|
export function parseUnixLsof(output) {
|
|
13
17
|
return [...new Set([...output.matchAll(/^p(\d+)$/gm)].map((match) => Number(match[1])))];
|
|
14
18
|
}
|
|
15
|
-
|
|
19
|
+
export function parseEtime(value) {
|
|
20
|
+
const trimmed = value.trim();
|
|
21
|
+
if (!trimmed)
|
|
22
|
+
return undefined;
|
|
23
|
+
const dayParts = trimmed.split("-");
|
|
24
|
+
const days = dayParts.length === 2 ? Number(dayParts[0]) : 0;
|
|
25
|
+
const units = (dayParts.length === 2 ? dayParts[1] : dayParts[0]).split(":");
|
|
26
|
+
if (dayParts.length > 2 || (units.length !== 2 && units.length !== 3) || units.some((unit) => !/^\d+$/.test(unit)) || !Number.isInteger(days)) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
const numbers = units.map(Number);
|
|
30
|
+
let hours = 0;
|
|
31
|
+
let minutes = 0;
|
|
32
|
+
let seconds = 0;
|
|
33
|
+
if (numbers.length === 3) {
|
|
34
|
+
[hours, minutes, seconds] = numbers;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
[minutes, seconds] = numbers;
|
|
38
|
+
}
|
|
39
|
+
return (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
|
|
40
|
+
}
|
|
41
|
+
export function parseNetstatPids(output, port) {
|
|
42
|
+
const results = [];
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
for (const line of output.split(/\r?\n/)) {
|
|
45
|
+
const columns = line.trim().split(/\s+/);
|
|
46
|
+
const protocol = columns[0]?.toUpperCase();
|
|
47
|
+
if (protocol !== "TCP" && protocol !== "UDP")
|
|
48
|
+
continue;
|
|
49
|
+
if (columns[1]?.split(":").at(-1) !== String(port))
|
|
50
|
+
continue;
|
|
51
|
+
if (protocol === "TCP" && columns[3]?.toUpperCase() !== "LISTENING")
|
|
52
|
+
continue;
|
|
53
|
+
const pid = Number(columns.at(-1));
|
|
54
|
+
if (Number.isInteger(pid) && pid > 0 && !seen.has(pid)) {
|
|
55
|
+
seen.add(pid);
|
|
56
|
+
results.push({ pid, protocol: protocol.toLowerCase() });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return results;
|
|
60
|
+
}
|
|
61
|
+
async function run(command, args, checked = false) {
|
|
16
62
|
try {
|
|
17
63
|
const result = await execFileAsync(command, args, { windowsHide: true, maxBuffer: 1024 * 1024 });
|
|
18
64
|
return result.stdout;
|
|
@@ -24,31 +70,59 @@ async function run(command, args) {
|
|
|
24
70
|
if (processError.code === "ENOENT") {
|
|
25
71
|
throw new Error(`Required system command '${command}' was not found.`);
|
|
26
72
|
}
|
|
73
|
+
if (checked) {
|
|
74
|
+
const detail = processError.stderr?.trim() || processError.message;
|
|
75
|
+
throw new Error(`Command '${command}' failed: ${detail}`);
|
|
76
|
+
}
|
|
27
77
|
return "";
|
|
28
78
|
}
|
|
29
79
|
}
|
|
30
|
-
async function
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}).map((line) => line.trim().split(/\s+/).at(-1)).join("\n"));
|
|
80
|
+
async function unixPortProcesses(port) {
|
|
81
|
+
const merged = new Map();
|
|
82
|
+
const udp = await run("lsof", ["-nP", "-iUDP:" + port, "-Fp"]);
|
|
83
|
+
for (const pid of parseUnixLsof(udp))
|
|
84
|
+
merged.set(pid, "udp");
|
|
85
|
+
const tcp = await run("lsof", ["-nP", "-a", "-iTCP:" + port, "-sTCP:LISTEN", "-Fp"]);
|
|
86
|
+
for (const pid of parseUnixLsof(tcp))
|
|
87
|
+
merged.set(pid, "tcp");
|
|
88
|
+
return merged;
|
|
40
89
|
}
|
|
41
|
-
async function
|
|
42
|
-
const
|
|
43
|
-
|
|
90
|
+
async function windowsPortProcesses(port) {
|
|
91
|
+
const merged = new Map();
|
|
92
|
+
const tcpScript = `$ErrorActionPreference = 'SilentlyContinue'; Get-NetTCPConnection -LocalPort ${port} -State Listen | Select-Object -ExpandProperty OwningProcess`;
|
|
93
|
+
for (const pid of parseWindowsPids(await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", tcpScript]))) {
|
|
94
|
+
merged.set(pid, "tcp");
|
|
95
|
+
}
|
|
96
|
+
const udpScript = `$ErrorActionPreference = 'SilentlyContinue'; Get-NetUDPConnection -LocalPort ${port} | Select-Object -ExpandProperty OwningProcess`;
|
|
97
|
+
for (const pid of parseWindowsPids(await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", udpScript]))) {
|
|
98
|
+
if (!merged.has(pid))
|
|
99
|
+
merged.set(pid, "udp");
|
|
100
|
+
}
|
|
101
|
+
if (merged.size > 0)
|
|
102
|
+
return merged;
|
|
103
|
+
const netstat = await run("netstat.exe", ["-ano"]);
|
|
104
|
+
for (const entry of parseNetstatPids(netstat, port)) {
|
|
105
|
+
if (!merged.has(entry.pid))
|
|
106
|
+
merged.set(entry.pid, entry.protocol);
|
|
107
|
+
}
|
|
108
|
+
return merged;
|
|
44
109
|
}
|
|
45
|
-
async function
|
|
110
|
+
async function processInfo(pid) {
|
|
46
111
|
if (process.platform === "win32") {
|
|
47
|
-
const
|
|
48
|
-
|
|
112
|
+
const script = `$ErrorActionPreference = 'SilentlyContinue'; $p = Get-Process -Id ${pid}; if ($p) { $p.ProcessName; if ($p.StartTime) { [DateTime]::UtcNow.Subtract($p.StartTime).TotalMilliseconds } }`;
|
|
113
|
+
const output = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script]);
|
|
114
|
+
const [name, age] = output.split(/\r?\n/);
|
|
115
|
+
const ageMs = Number(age?.trim());
|
|
116
|
+
return { name: name?.trim() || "unknown", ageMs: Number.isFinite(ageMs) && ageMs > 0 ? ageMs : undefined };
|
|
49
117
|
}
|
|
50
|
-
const output = await run("ps", ["-p", String(pid), "-o", "comm="]);
|
|
51
|
-
|
|
118
|
+
const output = await run("ps", ["-p", String(pid), "-o", "comm=", "-o", "etime="]);
|
|
119
|
+
const trimmed = output.trim();
|
|
120
|
+
if (!trimmed)
|
|
121
|
+
return { name: "unknown" };
|
|
122
|
+
const match = /^(.*?)\s+(\S+)$/.exec(trimmed);
|
|
123
|
+
if (!match)
|
|
124
|
+
return { name: trimmed };
|
|
125
|
+
return { name: match[1].trim(), ageMs: parseEtime(match[2]) };
|
|
52
126
|
}
|
|
53
127
|
async function processCwd(pid) {
|
|
54
128
|
return (await pidCwd(pid)) ?? undefined;
|
|
@@ -56,12 +130,23 @@ async function processCwd(pid) {
|
|
|
56
130
|
export function createProcessRunner() {
|
|
57
131
|
return {
|
|
58
132
|
async discover(port) {
|
|
59
|
-
const
|
|
60
|
-
return Promise.all(
|
|
133
|
+
const entries = process.platform === "win32" ? await windowsPortProcesses(port) : await unixPortProcesses(port);
|
|
134
|
+
return Promise.all([...entries].map(async ([pid, protocol]) => {
|
|
135
|
+
const info = await processInfo(pid);
|
|
136
|
+
return {
|
|
137
|
+
pid,
|
|
138
|
+
name: info.name,
|
|
139
|
+
protocol,
|
|
140
|
+
cwd: await processCwd(pid),
|
|
141
|
+
ageMs: info.ageMs,
|
|
142
|
+
docker: isDockerProcess(info.name),
|
|
143
|
+
};
|
|
144
|
+
}));
|
|
61
145
|
},
|
|
62
146
|
async terminate(pid) {
|
|
63
147
|
if (process.platform === "win32") {
|
|
64
|
-
|
|
148
|
+
// Without /F, taskkill cannot end console processes such as node dev servers.
|
|
149
|
+
await run("taskkill.exe", ["/F", "/PID", String(pid), "/T"], true);
|
|
65
150
|
return;
|
|
66
151
|
}
|
|
67
152
|
process.kill(pid, "SIGTERM");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { parseArgs } from "../src/args.js";
|
|
4
|
+
test("parses multiple ports and deduplicates", () => {
|
|
5
|
+
assert.deepEqual(parseArgs(["3000", "5173", "3000"]), {
|
|
6
|
+
ports: [3000, 5173],
|
|
7
|
+
help: false,
|
|
8
|
+
version: false,
|
|
9
|
+
yes: false,
|
|
10
|
+
list: false,
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
test("parses flags", () => {
|
|
14
|
+
assert.deepEqual(parseArgs(["-l", "--yes"]), { ports: [], help: false, version: false, yes: true, list: true });
|
|
15
|
+
assert.deepEqual(parseArgs(["--list", "-y", "-f"]), { ports: [], help: false, version: false, yes: true, list: true });
|
|
16
|
+
assert.deepEqual(parseArgs(["--help"]), { ports: [], help: true, version: false, yes: false, list: false });
|
|
17
|
+
assert.deepEqual(parseArgs(["-v"]), { ports: [], help: false, version: true, yes: false, list: false });
|
|
18
|
+
});
|
|
19
|
+
test("rejects invalid input", () => {
|
|
20
|
+
assert.equal(parseArgs(["abc"]), undefined);
|
|
21
|
+
assert.equal(parseArgs(["0"]), undefined);
|
|
22
|
+
assert.equal(parseArgs(["65536"]), undefined);
|
|
23
|
+
assert.equal(parseArgs(["3000", "--nope"]), undefined);
|
|
24
|
+
assert.equal(parseArgs(["-1"]), undefined);
|
|
25
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { main } from "../src/cli.js";
|
|
8
|
+
function fakeRunner(byPort) {
|
|
9
|
+
const calls = { discovered: [], terminated: [] };
|
|
10
|
+
return {
|
|
11
|
+
calls,
|
|
12
|
+
runner: {
|
|
13
|
+
async discover(port) {
|
|
14
|
+
calls.discovered.push(port);
|
|
15
|
+
return byPort[port] ?? [];
|
|
16
|
+
},
|
|
17
|
+
async terminate(pid) {
|
|
18
|
+
calls.terminated.push(pid);
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function capture() {
|
|
24
|
+
const log = [];
|
|
25
|
+
const error = [];
|
|
26
|
+
const originalLog = console.log;
|
|
27
|
+
const originalError = console.error;
|
|
28
|
+
console.log = (...args) => log.push(args.join(" "));
|
|
29
|
+
console.error = (...args) => error.push(args.join(" "));
|
|
30
|
+
return {
|
|
31
|
+
log,
|
|
32
|
+
error,
|
|
33
|
+
restore: () => {
|
|
34
|
+
console.log = originalLog;
|
|
35
|
+
console.error = originalError;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
test("exits 0 when all ports are free", async () => {
|
|
40
|
+
const { runner, calls } = fakeRunner({});
|
|
41
|
+
const output = capture();
|
|
42
|
+
try {
|
|
43
|
+
assert.equal(await main(["3000", "5173"], runner), 0);
|
|
44
|
+
assert.deepEqual(calls.discovered, [3000, 5173]);
|
|
45
|
+
assert.deepEqual(calls.terminated, []);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
output.restore();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
test("terminates processes from the current directory without prompting", async () => {
|
|
52
|
+
const { runner, calls } = fakeRunner({ 3000: [{ pid: 42, name: "node", cwd: process.cwd() }] });
|
|
53
|
+
const output = capture();
|
|
54
|
+
try {
|
|
55
|
+
assert.equal(await main(["3000"], runner), 0);
|
|
56
|
+
assert.deepEqual(calls.terminated, [42]);
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
output.restore();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
test("declines foreign processes when not interactive", async () => {
|
|
63
|
+
const { runner, calls } = fakeRunner({ 3000: [{ pid: 7, name: "node", cwd: "/somewhere/else" }] });
|
|
64
|
+
const output = capture();
|
|
65
|
+
try {
|
|
66
|
+
assert.equal(await main(["3000"], runner), 1);
|
|
67
|
+
assert.deepEqual(calls.terminated, []);
|
|
68
|
+
assert.match(output.error.join("\n"), /interactive terminal/);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
output.restore();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
test("--yes terminates foreign processes across multiple ports", async () => {
|
|
75
|
+
const { runner, calls } = fakeRunner({
|
|
76
|
+
3000: [{ pid: 7, name: "node", cwd: "/somewhere/else" }],
|
|
77
|
+
5173: [{ pid: 8, name: "node", cwd: "/elsewhere" }],
|
|
78
|
+
});
|
|
79
|
+
const output = capture();
|
|
80
|
+
try {
|
|
81
|
+
assert.equal(await main(["3000", "5173", "--yes"], runner), 0);
|
|
82
|
+
assert.deepEqual(calls.terminated, [7, 8]);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
output.restore();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
test("--list reports without terminating", async () => {
|
|
89
|
+
const { runner, calls } = fakeRunner({
|
|
90
|
+
3000: [{ pid: 7, name: "node", cwd: "/somewhere/else", ageMs: 93784000, protocol: "tcp" }],
|
|
91
|
+
});
|
|
92
|
+
const output = capture();
|
|
93
|
+
try {
|
|
94
|
+
assert.equal(await main(["--list", "3000"], runner), 0);
|
|
95
|
+
assert.deepEqual(calls.terminated, []);
|
|
96
|
+
assert.match(output.log.join("\n"), /PID 7/);
|
|
97
|
+
assert.match(output.log.join("\n"), /up 1d 2h/);
|
|
98
|
+
assert.match(output.log.join("\n"), /\/somewhere\/else/);
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
output.restore();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
test("refuses to kill Docker processes even with --yes", async () => {
|
|
105
|
+
const { runner, calls } = fakeRunner({ 3000: [{ pid: 9, name: "docker-proxy", docker: true }] });
|
|
106
|
+
const output = capture();
|
|
107
|
+
try {
|
|
108
|
+
assert.equal(await main(["3000", "--yes"], runner), 1);
|
|
109
|
+
assert.deepEqual(calls.terminated, []);
|
|
110
|
+
assert.match(output.error.join("\n"), /Docker/);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
output.restore();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
test("skips ports protected by .reclaimignore in the current directory", async () => {
|
|
117
|
+
const dir = await mkdtemp(path.join(tmpdir(), "port-reclaim-"));
|
|
118
|
+
const originalCwd = process.cwd();
|
|
119
|
+
const { runner, calls } = fakeRunner({ 5432: [{ pid: 1, name: "postgres", cwd: "/var/lib/postgresql" }] });
|
|
120
|
+
const output = capture();
|
|
121
|
+
try {
|
|
122
|
+
await writeFile(path.join(dir, ".reclaimignore"), "5432\n");
|
|
123
|
+
process.chdir(dir);
|
|
124
|
+
assert.equal(await main(["5432"], runner), 0);
|
|
125
|
+
assert.deepEqual(calls.discovered, []);
|
|
126
|
+
assert.match(output.log.join("\n"), /protected/);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
process.chdir(originalCwd);
|
|
130
|
+
await rm(dir, { recursive: true, force: true });
|
|
131
|
+
output.restore();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
test("invalid input exits 2", async () => {
|
|
135
|
+
const { runner } = fakeRunner({});
|
|
136
|
+
const output = capture();
|
|
137
|
+
try {
|
|
138
|
+
assert.equal(await main(["nope"], runner), 2);
|
|
139
|
+
assert.match(output.error.join("\n"), /Usage: port-reclaim/);
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
output.restore();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { loadConfig, parsePackageConfig, parseReclaimignore } from "../src/config.js";
|
|
7
|
+
test("parses .reclaimignore with comments and invalid lines", () => {
|
|
8
|
+
const content = "# databases\n5432\n\n 6379 \nnot-a-port\n70000\n5432\n";
|
|
9
|
+
assert.deepEqual(parseReclaimignore(content), [5432, 6379]);
|
|
10
|
+
});
|
|
11
|
+
test("parses the package.json config key", () => {
|
|
12
|
+
assert.deepEqual(parsePackageConfig({ "port-reclaim": { ports: [3000, "5173"], ignore: [5432] } }), { ports: [3000, 5173], ignore: [5432] });
|
|
13
|
+
assert.deepEqual(parsePackageConfig({}), { ports: [], ignore: [] });
|
|
14
|
+
assert.deepEqual(parsePackageConfig({ "port-reclaim": "nope" }), { ports: [], ignore: [] });
|
|
15
|
+
assert.deepEqual(parsePackageConfig(null), { ports: [], ignore: [] });
|
|
16
|
+
});
|
|
17
|
+
test("loadConfig merges package.json key and .reclaimignore", async () => {
|
|
18
|
+
const dir = await mkdtemp(path.join(tmpdir(), "port-reclaim-"));
|
|
19
|
+
try {
|
|
20
|
+
await writeFile(path.join(dir, "package.json"), JSON.stringify({ name: "demo", "port-reclaim": { ports: [3000, "5173"], ignore: [5432] } }));
|
|
21
|
+
await writeFile(path.join(dir, ".reclaimignore"), "# db\n5432\n6379\n");
|
|
22
|
+
const config = await loadConfig(dir);
|
|
23
|
+
assert.deepEqual(config, { ports: [3000, 5173], ignore: [5432, 6379] });
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
await rm(dir, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
test("loadConfig returns an empty config when nothing is configured", async () => {
|
|
30
|
+
const dir = await mkdtemp(path.join(tmpdir(), "port-reclaim-"));
|
|
31
|
+
try {
|
|
32
|
+
assert.deepEqual(await loadConfig(dir), { ports: [], ignore: [] });
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await rm(dir, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,9 +1,35 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { parseUnixLsof, parseWindowsPids } from "../src/process.js";
|
|
3
|
+
import { parseEtime, parseNetstatPids, parseUnixLsof, parseWindowsPids } from "../src/process.js";
|
|
4
4
|
test("parses unique Windows PIDs", () => {
|
|
5
5
|
assert.deepEqual(parseWindowsPids("1234\r\n5678\r\n1234\r\n"), [1234, 5678]);
|
|
6
6
|
});
|
|
7
7
|
test("parses lsof PID records", () => {
|
|
8
8
|
assert.deepEqual(parseUnixLsof("p1234\ncnode\np5678\np1234\n"), [1234, 5678]);
|
|
9
9
|
});
|
|
10
|
+
test("parses ps etime durations", () => {
|
|
11
|
+
assert.equal(parseEtime("45"), undefined);
|
|
12
|
+
assert.equal(parseEtime("34:56"), 2096000);
|
|
13
|
+
assert.equal(parseEtime("12:34:56"), 45296000);
|
|
14
|
+
assert.equal(parseEtime("1-02:03:04"), 93784000);
|
|
15
|
+
assert.equal(parseEtime(""), undefined);
|
|
16
|
+
assert.equal(parseEtime("abc"), undefined);
|
|
17
|
+
assert.equal(parseEtime("1:2:3:4"), undefined);
|
|
18
|
+
});
|
|
19
|
+
test("parses netstat TCP listeners and UDP sockets for an exact port", () => {
|
|
20
|
+
const output = [
|
|
21
|
+
"Active Connections",
|
|
22
|
+
"",
|
|
23
|
+
" Proto Local Address Foreign Address State PID",
|
|
24
|
+
" TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1111",
|
|
25
|
+
" TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 2222",
|
|
26
|
+
" TCP 0.0.0.0:13000 0.0.0.0:0 LISTENING 3333",
|
|
27
|
+
" TCP 127.0.0.1:3000 127.0.0.1:51234 ESTABLISHED 4444",
|
|
28
|
+
" UDP 0.0.0.0:3000 *:* 5555",
|
|
29
|
+
" UDP [::]:5353 *:* 6666",
|
|
30
|
+
].join("\r\n");
|
|
31
|
+
assert.deepEqual(parseNetstatPids(output, 3000), [
|
|
32
|
+
{ pid: 2222, protocol: "tcp" },
|
|
33
|
+
{ pid: 5555, protocol: "udp" },
|
|
34
|
+
]);
|
|
35
|
+
});
|
package/package.json
CHANGED
|
@@ -1,46 +1,54 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "port-reclaim",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Safely reclaim local ports from stale development processes.",
|
|
5
|
-
"author": "princegoel0",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
"cli"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "port-reclaim",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Safely reclaim local ports from stale development processes.",
|
|
5
|
+
"author": "princegoel0",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/src/index.js",
|
|
9
|
+
"types": "./dist/src/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/src/index.d.ts",
|
|
13
|
+
"default": "./dist/src/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/princegoel0/port-reclaim.git"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/princegoel0/port-reclaim/issues"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/princegoel0/port-reclaim#readme",
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"bin": {
|
|
30
|
+
"port-reclaim": "dist/src/cli.js"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc",
|
|
34
|
+
"test": "npm run build && node --test dist/tests/process.test.js dist/tests/args.test.js dist/tests/config.test.js dist/tests/cli.test.js",
|
|
35
|
+
"prepublishOnly": "npm test"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"cli",
|
|
39
|
+
"port",
|
|
40
|
+
"process",
|
|
41
|
+
"developer-tools",
|
|
42
|
+
"cross-platform"
|
|
43
|
+
],
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^22.10.2",
|
|
49
|
+
"typescript": "^5.7.2"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"pid-cwd": "^1.2.0"
|
|
53
|
+
}
|
|
54
|
+
}
|