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 CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 port-reclaim contributors
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
- ## Script Integration
35
-
36
- Use it in a package script:
37
-
38
- ```json
39
- {
40
- "scripts": {
41
- "dev": "port-reclaim 3000 && next dev"
42
- }
43
- }
44
- ```
45
-
46
- The same command can be used from Python, Ruby, Go, Make, or shell scripts.
47
-
48
- ## Exit Codes
49
-
50
- | Code | Meaning |
51
- | --- | --- |
52
- | `0` | The port was free or was successfully released. |
53
- | `1` | The port is still in use, the user declined, or an operation failed. |
54
- | `2` | Invalid command-line input. |
55
-
56
- ## Security Notes
57
-
58
- `port-reclaim` only acts on processes listening on the port you provide. It does not scan remote hosts or kill Docker containers directly. Review the process name and working directory before confirming a process from another project.
59
-
60
- ## Development
61
-
62
- ```sh
63
- npm install
64
- npm test
65
- npm run build
66
- ```
67
-
68
- Pull requests are tested on Ubuntu, macOS, and Windows with Node.js 18, 20, and 22.
69
-
70
- ## License
71
-
72
- MIT. See [LICENSE](LICENSE).
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).
@@ -0,0 +1,8 @@
1
+ export interface CliOptions {
2
+ ports: number[];
3
+ help: boolean;
4
+ version: boolean;
5
+ yes: boolean;
6
+ list: boolean;
7
+ }
8
+ export declare function parseArgs(argv: string[]): CliOptions | undefined;
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { type ProcessRunner } from "./process.js";
3
+ export declare function main(argv?: string[], runner?: ProcessRunner): Promise<number>;
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
- function usage() {
8
- console.error("Usage: port-reclaim <PORT>");
9
- process.exit(2);
10
- }
11
- function parsePort(value) {
12
- if (!value || !/^\d+$/.test(value))
13
- usage();
14
- const port = Number(value);
15
- if (port < 1 || port > 65535)
16
- usage();
17
- return port;
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 '${portProcess.name}'${portProcess.cwd ? ` in '${portProcess.cwd}'` : ""}. Use an interactive terminal to confirm termination.`);
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 '${portProcess.name}'${portProcess.cwd ? ` in '${portProcess.cwd}'` : ""}. Kill it? (y/N) `);
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
- export async function main(runner = createProcessRunner()) {
37
- const port = parsePort(process.argv[2]);
38
- const processes = await runner.discover(port);
39
- if (processes.length === 0)
40
- return 0;
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 processes) {
43
- const shouldTerminate = sameDirectory(portProcess.cwd, currentDirectory) || await confirm(portProcess, port);
44
- if (!shouldTerminate)
45
- return 1;
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 0;
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,3 @@
1
+ export { createProcessRunner, parseEtime, parseNetstatPids, parseUnixLsof, parseWindowsPids } from "./process.js";
2
+ export { loadConfig, parsePackageConfig, parseReclaimignore } from "./config.js";
3
+ export { parseArgs } 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;
@@ -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
- async function run(command, args) {
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 windowsPids(port) {
31
- const script = `$ErrorActionPreference = 'SilentlyContinue'; Get-NetTCPConnection -LocalPort ${port} -State Listen | Select-Object -ExpandProperty OwningProcess`;
32
- const output = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script]);
33
- if (output.trim())
34
- return parseWindowsPids(output);
35
- const netstat = await run("netstat.exe", ["-ano", "-p", "TCP"]);
36
- return parseWindowsPids(netstat.split(/\r?\n/).filter((line) => {
37
- const columns = line.trim().split(/\s+/);
38
- return columns[0]?.toUpperCase() === "TCP" && columns[1]?.endsWith(`:${port}`) && columns[3]?.toUpperCase() === "LISTENING";
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 unixPids(port) {
42
- const output = await run("lsof", ["-nP", "-a", "-iTCP:" + port, "-sTCP:LISTEN", "-Fp"]);
43
- return parseUnixLsof(output);
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 processName(pid) {
110
+ async function processInfo(pid) {
46
111
  if (process.platform === "win32") {
47
- const output = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${pid}).ProcessName`]);
48
- return output.trim() || "unknown";
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
- return output.trim() || "unknown";
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 pids = process.platform === "win32" ? await windowsPids(port) : await unixPids(port);
60
- return Promise.all(pids.map(async (pid) => ({ pid, name: await processName(pid), cwd: await processCwd(pid) })));
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
- await run("taskkill.exe", ["/PID", String(pid), "/T"]);
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.1.0",
4
- "description": "Safely reclaim local ports from stale development processes.",
5
- "author": "princegoel0",
6
- "license": "MIT",
7
- "repository": {
8
- "type": "git",
9
- "url": "https://github.com/princegoel0/port-reclaim.git"
10
- },
11
- "bugs": {
12
- "url": "https://github.com/princegoel0/port-reclaim/issues"
13
- },
14
- "homepage": "https://github.com/princegoel0/port-reclaim#readme",
15
- "type": "module",
16
- "files": [
17
- "dist",
18
- "README.md",
19
- "LICENSE"
20
- ],
21
- "bin": {
22
- "port-reclaim": "dist/src/cli.js"
23
- },
24
- "scripts": {
25
- "build": "tsc",
26
- "test": "npm run build && node --test dist/tests/process.test.js",
27
- "prepublishOnly": "npm test"
28
- },
29
- "keywords": [
30
- "cli",
31
- "port",
32
- "process",
33
- "developer-tools",
34
- "cross-platform"
35
- ],
36
- "engines": {
37
- "node": ">=18"
38
- },
39
- "devDependencies": {
40
- "@types/node": "^22.10.2",
41
- "typescript": "^5.7.2"
42
- },
43
- "dependencies": {
44
- "pid-cwd": "^1.2.0"
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
+ }