offmyport 1.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/CHANGELOG.md +38 -0
- package/LICENSE.md +21 -0
- package/README.md +168 -0
- package/package.json +51 -0
- package/src/index.ts +452 -0
- package/src/platform/index.ts +16 -0
- package/src/platform/types.ts +42 -0
- package/src/platform/unix.ts +341 -0
- package/src/platform/windows.ts +146 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [1.2.0] - 2025-12-31
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- GitHub Actions workflows for CI and automated releases
|
|
10
|
+
- Comprehensive unit tests for CLI argument and port parsing
|
|
11
|
+
- Development tooling with vitest test runner
|
|
12
|
+
- Claude Code release command for streamlined releases
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Refactored CLI to use meow for argument parsing
|
|
17
|
+
- Improved cross-platform support with dedicated Unix and Windows adapters
|
|
18
|
+
|
|
19
|
+
## [1.1.0] - 2025-12-31
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Internal refactoring and code organization
|
|
24
|
+
|
|
25
|
+
## [1.0.0] - 2025-12-31
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- Interactive TUI for selecting and killing processes by port
|
|
30
|
+
- Filter by single port, multiple ports (comma-separated), or port ranges
|
|
31
|
+
- Choose between SIGTERM (gentle) or SIGKILL (force) termination
|
|
32
|
+
- `--kill` flag for non-interactive batch killing
|
|
33
|
+
- `--force` flag to skip confirmation prompts
|
|
34
|
+
- `--json` flag for JSON output (scripting/piping)
|
|
35
|
+
- Press `q` to quit during selection
|
|
36
|
+
- Graceful cancellation with Ctrl+C or ESC
|
|
37
|
+
- Cross-platform support (macOS, Linux, Windows)
|
|
38
|
+
- Extended process metadata in JSON output (CPU, memory, path, cwd)
|
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Helge Sverre
|
|
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
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# offmyport 🔪
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/offmyport)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://bun.sh)
|
|
6
|
+
|
|
7
|
+
Interactive CLI tool to find and kill processes by port. No more memorizing `lsof -i :8080` or `netstat` flags.
|
|
8
|
+
|
|
9
|
+
## Table of Contents
|
|
10
|
+
|
|
11
|
+
- [Installation](#installation)
|
|
12
|
+
- [Usage](#usage)
|
|
13
|
+
- [Features](#features)
|
|
14
|
+
- [CLI Reference](#cli-reference)
|
|
15
|
+
- [Requirements](#requirements)
|
|
16
|
+
- [License](#license)
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Using bun (recommended)
|
|
22
|
+
bun install -g offmyport
|
|
23
|
+
|
|
24
|
+
# Using npm
|
|
25
|
+
npm install -g offmyport
|
|
26
|
+
|
|
27
|
+
# Run without installing
|
|
28
|
+
npx offmyport
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Install from Source
|
|
32
|
+
|
|
33
|
+
```shell
|
|
34
|
+
git clone https://github.com/HelgeSverre/offmyport.git
|
|
35
|
+
cd offmyport
|
|
36
|
+
|
|
37
|
+
# Install dependencies and make it accessible globally
|
|
38
|
+
bun install
|
|
39
|
+
bun link
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```shell
|
|
46
|
+
# List all listening ports, pick one to kill
|
|
47
|
+
offmyport
|
|
48
|
+
|
|
49
|
+
# Filter to a specific port
|
|
50
|
+
offmyport 8080
|
|
51
|
+
|
|
52
|
+
# Filter multiple ports (comma-separated)
|
|
53
|
+
offmyport 80,443,8080
|
|
54
|
+
|
|
55
|
+
# Filter port ranges (inclusive)
|
|
56
|
+
offmyport 3000-3005
|
|
57
|
+
|
|
58
|
+
# Mix and match
|
|
59
|
+
offmyport 80,443,3000-3005
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Non-Interactive Mode
|
|
63
|
+
|
|
64
|
+
```shell
|
|
65
|
+
# Kill all processes on port(s) with confirmation prompt
|
|
66
|
+
offmyport 3000 --kill
|
|
67
|
+
|
|
68
|
+
# Skip confirmation (use with caution)
|
|
69
|
+
offmyport 3000 --kill --force
|
|
70
|
+
|
|
71
|
+
# Shorthand flags
|
|
72
|
+
offmyport 3000 -k -f
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### JSON Output
|
|
76
|
+
|
|
77
|
+
```shell
|
|
78
|
+
# Output process info as JSON (no TUI)
|
|
79
|
+
offmyport --json
|
|
80
|
+
|
|
81
|
+
# Filter and output as JSON
|
|
82
|
+
offmyport 3000-3005 --json
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Features
|
|
86
|
+
|
|
87
|
+
- Lists all TCP listening ports with process info
|
|
88
|
+
- Interactive selection with arrow keys
|
|
89
|
+
- Choose between SIGTERM (gentle) or SIGKILL (force)
|
|
90
|
+
- Filter by single port, multiple ports, or port ranges
|
|
91
|
+
- Non-interactive mode with `--kill` for scripting
|
|
92
|
+
- JSON output with `--json` for scripting and piping
|
|
93
|
+
- Press `q` to quit during selection
|
|
94
|
+
- Graceful cancellation with Ctrl+C or ESC
|
|
95
|
+
|
|
96
|
+
## Example
|
|
97
|
+
|
|
98
|
+
### Interactive Mode
|
|
99
|
+
|
|
100
|
+
```shell
|
|
101
|
+
$ offmyport 3000
|
|
102
|
+
|
|
103
|
+
Found 1 listening process (q to quit)
|
|
104
|
+
|
|
105
|
+
? Select a process to kill:
|
|
106
|
+
❯ Port 3000 │ node │ PID 12345 │ user
|
|
107
|
+
|
|
108
|
+
? Kill node (PID 12345) with:
|
|
109
|
+
❯ SIGTERM (gentle - allows cleanup)
|
|
110
|
+
SIGKILL (force - immediate)
|
|
111
|
+
|
|
112
|
+
Sent SIGTERM to PID 12345 (node on port 3000)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Kill Mode
|
|
116
|
+
|
|
117
|
+
```shell
|
|
118
|
+
$ offmyport 3000-3005 --kill
|
|
119
|
+
|
|
120
|
+
Processes to kill (3):
|
|
121
|
+
|
|
122
|
+
Port 3000 │ node │ PID 12345 │ user
|
|
123
|
+
Port 3001 │ python │ PID 12346 │ user
|
|
124
|
+
Port 3002 │ ruby │ PID 12347 │ user
|
|
125
|
+
|
|
126
|
+
? Kill 3 processes? (y/N)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### JSON Output
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
$ offmyport 3000 --json
|
|
133
|
+
[
|
|
134
|
+
{
|
|
135
|
+
"pid": 12345,
|
|
136
|
+
"name": "node",
|
|
137
|
+
"port": 3000,
|
|
138
|
+
"protocol": "TCP",
|
|
139
|
+
"user": "helge",
|
|
140
|
+
"cpuPercent": 0.5,
|
|
141
|
+
"memoryBytes": 46137344,
|
|
142
|
+
"startTime": "2025-12-31T10:30:15.000Z",
|
|
143
|
+
"path": "/usr/local/bin/node server.js",
|
|
144
|
+
"cwd": "/Users/helge/projects/myapp"
|
|
145
|
+
}
|
|
146
|
+
]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## CLI Reference
|
|
150
|
+
|
|
151
|
+
| Flag | Shorthand | Description |
|
|
152
|
+
| ----------- | --------- | ---------------------------------------------- |
|
|
153
|
+
| `--json` | | Output as JSON (no TUI, for scripting) |
|
|
154
|
+
| `--kill` | `-k` | Non-interactive mode, kills matching processes |
|
|
155
|
+
| `--force` | `-f` | Skip confirmation prompt (use with `--kill`) |
|
|
156
|
+
| `--version` | `-v` | Show version number |
|
|
157
|
+
| `--help` | `-h` | Show help |
|
|
158
|
+
|
|
159
|
+
## Requirements
|
|
160
|
+
|
|
161
|
+
- **macOS**: Works out of the box (uses `lsof`)
|
|
162
|
+
- **Linux**: Uses `lsof` or falls back to `ss` (from iproute2)
|
|
163
|
+
- **Windows**: Requires PowerShell 5.0+ (uses `Get-NetTCPConnection`)
|
|
164
|
+
- Node.js 18+ or Bun
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details.
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "offmyport",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Interactive CLI tool to find and kill processes by port.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"setup": "bun link offmyport",
|
|
8
|
+
"test": "vitest run",
|
|
9
|
+
"test:watch": "vitest",
|
|
10
|
+
"format": "bunx prettier --write ."
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"offmyport": "src/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"!src/**/*.test.ts",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE.md",
|
|
20
|
+
"CHANGELOG.md"
|
|
21
|
+
],
|
|
22
|
+
"keywords": [
|
|
23
|
+
"cli",
|
|
24
|
+
"port",
|
|
25
|
+
"process",
|
|
26
|
+
"cross-platform",
|
|
27
|
+
"tui"
|
|
28
|
+
],
|
|
29
|
+
"author": "Helge Sverre <helge.sverre@gmail.com>",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/HelgeSverre/offmyport.git"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/HelgeSverre/offmyport#readme",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/HelgeSverre/offmyport/issues"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"bun": ">=1.0.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/bun": "^1.3.5",
|
|
44
|
+
"@types/node": "^24.10.4",
|
|
45
|
+
"vitest": "^4.0.16"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@inquirer/prompts": "^8.1.0",
|
|
49
|
+
"meow": "^14.0.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { select, confirm } from "@inquirer/prompts";
|
|
4
|
+
import { ExitPromptError } from "@inquirer/core";
|
|
5
|
+
import * as readline from "readline";
|
|
6
|
+
import meow from "meow";
|
|
7
|
+
import { getAdapter, type ProcessInfo } from "./platform/index.js";
|
|
8
|
+
|
|
9
|
+
export interface CliFlags {
|
|
10
|
+
ports: string | null;
|
|
11
|
+
kill: boolean;
|
|
12
|
+
force: boolean;
|
|
13
|
+
murder: boolean; // 🔪 undocumented: uses SIGKILL instead of SIGTERM
|
|
14
|
+
json: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const helpText = `
|
|
18
|
+
Usage
|
|
19
|
+
$ offmyport [ports] [options]
|
|
20
|
+
|
|
21
|
+
Options
|
|
22
|
+
--kill, -k Kill matching processes (with confirmation)
|
|
23
|
+
--force, -f Skip confirmation prompt (use with --kill)
|
|
24
|
+
--json Output process info as JSON (no TUI)
|
|
25
|
+
--version Show version number
|
|
26
|
+
--help Show this help
|
|
27
|
+
|
|
28
|
+
Examples
|
|
29
|
+
$ offmyport List all listening ports
|
|
30
|
+
$ offmyport 3000 Filter to port 3000
|
|
31
|
+
$ offmyport 80,443 Filter multiple ports
|
|
32
|
+
$ offmyport 3000-3005 Filter port range
|
|
33
|
+
$ offmyport 3000 --kill Kill process on port 3000
|
|
34
|
+
$ offmyport 3000 -k -f Kill without confirmation
|
|
35
|
+
$ offmyport --json Output all ports as JSON
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse CLI arguments using meow.
|
|
40
|
+
* Exported for testing compatibility.
|
|
41
|
+
*/
|
|
42
|
+
export function parseArgs(argv: string[]): CliFlags {
|
|
43
|
+
const cli = meow(helpText, {
|
|
44
|
+
importMeta: import.meta,
|
|
45
|
+
argv: argv.slice(2), // skip 'bun' and script path
|
|
46
|
+
flags: {
|
|
47
|
+
kill: {
|
|
48
|
+
type: "boolean",
|
|
49
|
+
shortFlag: "k",
|
|
50
|
+
default: false,
|
|
51
|
+
},
|
|
52
|
+
force: {
|
|
53
|
+
type: "boolean",
|
|
54
|
+
shortFlag: "f",
|
|
55
|
+
default: false,
|
|
56
|
+
},
|
|
57
|
+
murder: {
|
|
58
|
+
type: "boolean",
|
|
59
|
+
default: false,
|
|
60
|
+
},
|
|
61
|
+
json: {
|
|
62
|
+
type: "boolean",
|
|
63
|
+
default: false,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
autoHelp: false, // Handle manually to avoid auto-exit during tests
|
|
67
|
+
autoVersion: false, // Handle manually to avoid auto-exit during tests
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Handle --help and --version manually when running as CLI
|
|
71
|
+
if (import.meta.main) {
|
|
72
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
73
|
+
cli.showHelp(0);
|
|
74
|
+
}
|
|
75
|
+
if (argv.includes("--version") || argv.includes("-v")) {
|
|
76
|
+
cli.showVersion();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const murder = cli.flags.murder;
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
ports: cli.input[0] ?? null,
|
|
84
|
+
kill: cli.flags.kill || murder, // --murder implies --kill
|
|
85
|
+
force: cli.flags.force || murder, // --murder implies --force
|
|
86
|
+
murder,
|
|
87
|
+
json: cli.flags.json,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Calculate page size as half the terminal height (min 5, max 20)
|
|
92
|
+
function getPageSize(): number {
|
|
93
|
+
const rows = process.stdout.rows || 24;
|
|
94
|
+
return Math.min(20, Math.max(5, Math.floor(rows / 2)));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Setup keyboard listener for 'q' to quit.
|
|
99
|
+
* Returns cleanup function and AbortController for cancelling prompts.
|
|
100
|
+
*/
|
|
101
|
+
function setupQuitHandler(): {
|
|
102
|
+
cleanup: () => void;
|
|
103
|
+
controller: AbortController;
|
|
104
|
+
} {
|
|
105
|
+
const controller = new AbortController();
|
|
106
|
+
|
|
107
|
+
// Save original raw mode state
|
|
108
|
+
const wasRaw = process.stdin.isRaw;
|
|
109
|
+
|
|
110
|
+
if (process.stdin.isTTY) {
|
|
111
|
+
readline.emitKeypressEvents(process.stdin);
|
|
112
|
+
|
|
113
|
+
const onKeypress = (char: string, key: readline.Key) => {
|
|
114
|
+
if (char === "q" || char === "Q") {
|
|
115
|
+
controller.abort();
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
process.stdin.on("keypress", onKeypress);
|
|
120
|
+
|
|
121
|
+
const cleanup = () => {
|
|
122
|
+
process.stdin.removeListener("keypress", onKeypress);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return { cleanup, controller };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { cleanup: () => {}, controller };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// JSON output format (--json flag)
|
|
132
|
+
// Uses common denominator fields across macOS, Linux, and Windows
|
|
133
|
+
export interface ProcessJsonOutput {
|
|
134
|
+
pid: number;
|
|
135
|
+
name: string;
|
|
136
|
+
port: number;
|
|
137
|
+
protocol: string;
|
|
138
|
+
user: string;
|
|
139
|
+
cpuPercent: number | null; // Can be null if unavailable (Windows edge cases)
|
|
140
|
+
memoryBytes: number | null; // Working set (Win) or RSS (Unix), null if unavailable
|
|
141
|
+
startTime: string | null; // ISO 8601 format, null if unavailable
|
|
142
|
+
path: string | null; // Full executable path, null if unavailable (Win 32-bit accessing 64-bit)
|
|
143
|
+
cwd: string | null; // Current working directory of the process
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Parse port specification supporting:
|
|
148
|
+
* - Single port: "80" → [80]
|
|
149
|
+
* - Comma-separated: "80,8080,3000" → [80, 8080, 3000]
|
|
150
|
+
* - Ranges (inclusive): "3000-3005" → [3000, 3001, 3002, 3003, 3004, 3005]
|
|
151
|
+
* - Mixed: "80,8080,3000-3005" → [80, 8080, 3000, 3001, 3002, 3003, 3004, 3005]
|
|
152
|
+
*/
|
|
153
|
+
export function parsePorts(input: string): number[] {
|
|
154
|
+
const ports: number[] = [];
|
|
155
|
+
const segments = input
|
|
156
|
+
.split(",")
|
|
157
|
+
.map((s) => s.trim())
|
|
158
|
+
.filter(Boolean);
|
|
159
|
+
|
|
160
|
+
for (const segment of segments) {
|
|
161
|
+
if (segment.includes("-")) {
|
|
162
|
+
const rangeParts = segment.split("-").map((s) => s.trim());
|
|
163
|
+
const startStr = rangeParts[0] ?? "";
|
|
164
|
+
const endStr = rangeParts[1] ?? "";
|
|
165
|
+
const start = parseInt(startStr, 10);
|
|
166
|
+
const end = parseInt(endStr, 10);
|
|
167
|
+
|
|
168
|
+
if (isNaN(start) || isNaN(end)) {
|
|
169
|
+
console.error(`Invalid port range: ${segment}`);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (start > end) {
|
|
174
|
+
console.error(`Invalid port range (start > end): ${segment}`);
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (start < 1 || end > 65535) {
|
|
179
|
+
console.error(`Port out of range (1-65535): ${segment}`);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (let p = start; p <= end; p++) {
|
|
184
|
+
ports.push(p);
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
const port = parseInt(segment, 10);
|
|
188
|
+
|
|
189
|
+
if (isNaN(port)) {
|
|
190
|
+
console.error(`Invalid port number: ${segment}`);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (port < 1 || port > 65535) {
|
|
195
|
+
console.error(`Port out of range (1-65535): ${port}`);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
ports.push(port);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Deduplicate and sort
|
|
204
|
+
return [...new Set(ports)].sort((a, b) => a - b);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Platform adapter instance (lazy initialized)
|
|
208
|
+
let adapter: ReturnType<typeof getAdapter> | null = null;
|
|
209
|
+
|
|
210
|
+
function getPlatformAdapter() {
|
|
211
|
+
if (!adapter) {
|
|
212
|
+
adapter = getAdapter();
|
|
213
|
+
}
|
|
214
|
+
return adapter;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Convert ProcessInfo to ProcessJsonOutput with extended metadata.
|
|
219
|
+
*/
|
|
220
|
+
function toJsonOutput(p: ProcessInfo): ProcessJsonOutput {
|
|
221
|
+
const platform = getPlatformAdapter();
|
|
222
|
+
const meta = platform.getProcessMetadata(p.pid);
|
|
223
|
+
return {
|
|
224
|
+
pid: p.pid,
|
|
225
|
+
name: p.command,
|
|
226
|
+
port: p.port,
|
|
227
|
+
protocol: p.protocol,
|
|
228
|
+
user: p.user,
|
|
229
|
+
cpuPercent: meta.cpuPercent,
|
|
230
|
+
memoryBytes: meta.memoryBytes,
|
|
231
|
+
startTime: meta.startTime,
|
|
232
|
+
path: meta.path,
|
|
233
|
+
cwd: meta.cwd,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function main() {
|
|
238
|
+
const flags = parseArgs(Bun.argv);
|
|
239
|
+
const filterPorts = flags.ports ? parsePorts(flags.ports) : null;
|
|
240
|
+
const platform = getPlatformAdapter();
|
|
241
|
+
|
|
242
|
+
let processes = platform.getListeningProcesses();
|
|
243
|
+
|
|
244
|
+
if (filterPorts && filterPorts.length > 0) {
|
|
245
|
+
const portSet = new Set(filterPorts);
|
|
246
|
+
processes = processes.filter((p) => portSet.has(p.port));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Sort by port
|
|
250
|
+
processes.sort((a, b) => a.port - b.port);
|
|
251
|
+
|
|
252
|
+
// --json mode: output JSON and exit (no TUI)
|
|
253
|
+
if (flags.json) {
|
|
254
|
+
const output = processes.map(toJsonOutput);
|
|
255
|
+
console.log(JSON.stringify(output, null, 2));
|
|
256
|
+
process.exit(0);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (processes.length === 0) {
|
|
260
|
+
if (filterPorts && filterPorts.length > 0) {
|
|
261
|
+
const portDisplay =
|
|
262
|
+
filterPorts.length === 1
|
|
263
|
+
? `port ${filterPorts[0]}`
|
|
264
|
+
: `ports ${filterPorts.join(", ")}`;
|
|
265
|
+
console.log(`No process found listening on ${portDisplay}`);
|
|
266
|
+
} else {
|
|
267
|
+
console.log("No listening TCP processes found");
|
|
268
|
+
}
|
|
269
|
+
process.exit(0);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// --kill mode: kill all matching processes without interactive selection
|
|
273
|
+
if (flags.kill) {
|
|
274
|
+
await handleKillMode(processes, flags.force, flags.murder, flags.ports);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Interactive mode
|
|
279
|
+
console.log(
|
|
280
|
+
`\nFound ${processes.length} listening process${processes.length > 1 ? "es" : ""} \x1b[2m(q to quit)\x1b[0m\n`,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const pageSize = getPageSize();
|
|
284
|
+
const { cleanup, controller } = setupQuitHandler();
|
|
285
|
+
|
|
286
|
+
let selectedPid: number;
|
|
287
|
+
let selectedProcess: ProcessInfo;
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
// Show the list and let user pick
|
|
291
|
+
selectedPid = await select(
|
|
292
|
+
{
|
|
293
|
+
message: "Select a process to kill:",
|
|
294
|
+
pageSize,
|
|
295
|
+
choices: processes.map((p) => ({
|
|
296
|
+
name: `Port ${p.port.toString().padStart(5)} │ ${p.command.padEnd(15)} │ PID ${p.pid} │ ${p.user}`,
|
|
297
|
+
value: p.pid,
|
|
298
|
+
})),
|
|
299
|
+
},
|
|
300
|
+
{ signal: controller.signal },
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
selectedProcess = processes.find((p) => p.pid === selectedPid)!;
|
|
304
|
+
|
|
305
|
+
// Ask for kill method
|
|
306
|
+
const signal = await select(
|
|
307
|
+
{
|
|
308
|
+
message: `Kill ${selectedProcess.command} (PID ${selectedPid}) with:`,
|
|
309
|
+
pageSize: 2,
|
|
310
|
+
choices: [
|
|
311
|
+
{
|
|
312
|
+
name: "SIGTERM (gentle - allows cleanup)",
|
|
313
|
+
value: "SIGTERM" as const,
|
|
314
|
+
},
|
|
315
|
+
{ name: "SIGKILL (force - immediate)", value: "SIGKILL" as const },
|
|
316
|
+
],
|
|
317
|
+
},
|
|
318
|
+
{ signal: controller.signal },
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
cleanup();
|
|
322
|
+
|
|
323
|
+
platform.killProcess(selectedPid, signal);
|
|
324
|
+
console.log(
|
|
325
|
+
`\nSent ${signal} to PID ${selectedPid} (${selectedProcess.command} on port ${selectedProcess.port})`,
|
|
326
|
+
);
|
|
327
|
+
} catch (err: any) {
|
|
328
|
+
cleanup();
|
|
329
|
+
|
|
330
|
+
const isAbortError =
|
|
331
|
+
err instanceof ExitPromptError ||
|
|
332
|
+
err.name === "AbortError" ||
|
|
333
|
+
err.code === "ABORT_ERR" ||
|
|
334
|
+
(typeof err.message === "string" &&
|
|
335
|
+
err.message.toLowerCase().includes("abort"));
|
|
336
|
+
|
|
337
|
+
if (isAbortError) {
|
|
338
|
+
// User pressed Ctrl+C, ESC, or 'q'
|
|
339
|
+
console.log("\nCancelled");
|
|
340
|
+
process.exit(0);
|
|
341
|
+
}
|
|
342
|
+
if (err.code === "EPERM") {
|
|
343
|
+
console.error(
|
|
344
|
+
`\nPermission denied. Try: sudo offmyport ${flags.ports || ""}`,
|
|
345
|
+
);
|
|
346
|
+
} else if (err.code === "ESRCH") {
|
|
347
|
+
console.error(`\nProcess no longer exists`);
|
|
348
|
+
} else {
|
|
349
|
+
console.error(`\nFailed to kill process: ${err.message}`);
|
|
350
|
+
}
|
|
351
|
+
process.exit(1);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Handle --kill mode: kill all matching processes with optional confirmation.
|
|
357
|
+
*/
|
|
358
|
+
async function handleKillMode(
|
|
359
|
+
processes: ProcessInfo[],
|
|
360
|
+
force: boolean,
|
|
361
|
+
murder: boolean,
|
|
362
|
+
portArg: string | null,
|
|
363
|
+
): Promise<void> {
|
|
364
|
+
const signal: "SIGTERM" | "SIGKILL" = murder ? "SIGKILL" : "SIGTERM";
|
|
365
|
+
const platform = getPlatformAdapter();
|
|
366
|
+
|
|
367
|
+
// Display what will be killed
|
|
368
|
+
console.log(`\nProcesses to kill (${processes.length}):\n`);
|
|
369
|
+
for (const p of processes) {
|
|
370
|
+
console.log(
|
|
371
|
+
` Port ${p.port.toString().padStart(5)} │ ${p.command.padEnd(15)} │ PID ${p.pid} │ ${p.user}`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
console.log();
|
|
375
|
+
|
|
376
|
+
// Require confirmation unless --force is set
|
|
377
|
+
if (!force) {
|
|
378
|
+
try {
|
|
379
|
+
const confirmed = await confirm({
|
|
380
|
+
message: `Kill ${processes.length} process${processes.length > 1 ? "es" : ""}?`,
|
|
381
|
+
default: false,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
if (!confirmed) {
|
|
385
|
+
console.log("Cancelled");
|
|
386
|
+
process.exit(0);
|
|
387
|
+
}
|
|
388
|
+
} catch (err: any) {
|
|
389
|
+
const isAbortError =
|
|
390
|
+
err instanceof ExitPromptError ||
|
|
391
|
+
err.name === "AbortError" ||
|
|
392
|
+
err.code === "ABORT_ERR" ||
|
|
393
|
+
(typeof err.message === "string" &&
|
|
394
|
+
err.message.toLowerCase().includes("abort"));
|
|
395
|
+
|
|
396
|
+
if (isAbortError) {
|
|
397
|
+
console.log("\nCancelled");
|
|
398
|
+
process.exit(0);
|
|
399
|
+
}
|
|
400
|
+
throw err;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Kill all processes
|
|
405
|
+
let killed = 0;
|
|
406
|
+
let failed = 0;
|
|
407
|
+
|
|
408
|
+
for (const p of processes) {
|
|
409
|
+
try {
|
|
410
|
+
platform.killProcess(p.pid, signal);
|
|
411
|
+
if (murder) {
|
|
412
|
+
console.log(
|
|
413
|
+
`Process ${p.command} \x1b[91mELIMINATED\x1b[0m! 👁️👅👁️ 🔪`,
|
|
414
|
+
);
|
|
415
|
+
} else {
|
|
416
|
+
console.log(`Killed PID ${p.pid} (${p.command} on port ${p.port})`);
|
|
417
|
+
}
|
|
418
|
+
killed++;
|
|
419
|
+
} catch (err: any) {
|
|
420
|
+
if (err.code === "EPERM") {
|
|
421
|
+
console.error(
|
|
422
|
+
`Permission denied for PID ${p.pid}. Try: sudo offmyport ${portArg || ""} --kill`,
|
|
423
|
+
);
|
|
424
|
+
} else if (err.code === "ESRCH") {
|
|
425
|
+
console.error(`PID ${p.pid} no longer exists`);
|
|
426
|
+
} else {
|
|
427
|
+
console.error(`Failed to kill PID ${p.pid}: ${err.message}`);
|
|
428
|
+
}
|
|
429
|
+
failed++;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
console.log(
|
|
434
|
+
`\nKilled ${killed} process${killed !== 1 ? "es" : ""}${failed > 0 ? `, ${failed} failed` : ""}`,
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
if (failed > 0) {
|
|
438
|
+
process.exit(1);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Only run when executed directly (not when imported for testing)
|
|
443
|
+
if (import.meta.main) {
|
|
444
|
+
main().catch((err) => {
|
|
445
|
+
if (err instanceof ExitPromptError) {
|
|
446
|
+
console.log("\nCancelled");
|
|
447
|
+
process.exit(0);
|
|
448
|
+
}
|
|
449
|
+
console.error(err);
|
|
450
|
+
process.exit(1);
|
|
451
|
+
});
|
|
452
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PlatformAdapter } from "./types.js";
|
|
2
|
+
import { UnixAdapter } from "./unix.js";
|
|
3
|
+
import { WindowsAdapter } from "./windows.js";
|
|
4
|
+
|
|
5
|
+
export type { PlatformAdapter, ProcessInfo, ProcessMetadata } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Get the platform-specific adapter for the current OS.
|
|
9
|
+
*/
|
|
10
|
+
export function getAdapter(): PlatformAdapter {
|
|
11
|
+
if (process.platform === "win32") {
|
|
12
|
+
return new WindowsAdapter();
|
|
13
|
+
}
|
|
14
|
+
// macOS (darwin) and Linux both use Unix adapter
|
|
15
|
+
return new UnixAdapter();
|
|
16
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process information from port listing.
|
|
3
|
+
*/
|
|
4
|
+
export interface ProcessInfo {
|
|
5
|
+
command: string;
|
|
6
|
+
pid: number;
|
|
7
|
+
user: string;
|
|
8
|
+
port: number;
|
|
9
|
+
protocol: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extended process metadata for JSON output.
|
|
14
|
+
*/
|
|
15
|
+
export interface ProcessMetadata {
|
|
16
|
+
cpuPercent: number | null;
|
|
17
|
+
memoryBytes: number | null;
|
|
18
|
+
startTime: string | null;
|
|
19
|
+
path: string | null;
|
|
20
|
+
cwd: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Platform-specific adapter interface.
|
|
25
|
+
* Implementations exist for Unix (macOS/Linux) and Windows.
|
|
26
|
+
*/
|
|
27
|
+
export interface PlatformAdapter {
|
|
28
|
+
/**
|
|
29
|
+
* Get all processes listening on TCP ports.
|
|
30
|
+
*/
|
|
31
|
+
getListeningProcesses(): ProcessInfo[];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get extended metadata for a process.
|
|
35
|
+
*/
|
|
36
|
+
getProcessMetadata(pid: number): ProcessMetadata;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Kill a process with the specified signal.
|
|
40
|
+
*/
|
|
41
|
+
killProcess(pid: number, signal: "SIGTERM" | "SIGKILL"): void;
|
|
42
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import type { PlatformAdapter, ProcessInfo, ProcessMetadata } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Unix (macOS/Linux) platform adapter.
|
|
5
|
+
* Uses lsof with ss fallback for port discovery.
|
|
6
|
+
*/
|
|
7
|
+
export class UnixAdapter implements PlatformAdapter {
|
|
8
|
+
/**
|
|
9
|
+
* Get all processes listening on TCP ports.
|
|
10
|
+
* Tries lsof first, falls back to ss on Linux.
|
|
11
|
+
*/
|
|
12
|
+
getListeningProcesses(): ProcessInfo[] {
|
|
13
|
+
// Try lsof first (available on macOS, usually on Linux)
|
|
14
|
+
const lsofResult = this.tryLsof();
|
|
15
|
+
if (lsofResult !== null) {
|
|
16
|
+
return lsofResult;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Fallback to ss (Linux when lsof unavailable)
|
|
20
|
+
const ssResult = this.trySs();
|
|
21
|
+
if (ssResult !== null) {
|
|
22
|
+
return ssResult;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
console.error("Neither lsof nor ss available. Install lsof or iproute2.");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Try to get listening processes using lsof.
|
|
31
|
+
* Returns null if lsof is not available.
|
|
32
|
+
*/
|
|
33
|
+
private tryLsof(): ProcessInfo[] | null {
|
|
34
|
+
try {
|
|
35
|
+
const proc = Bun.spawnSync(["lsof", "-iTCP", "-sTCP:LISTEN", "-P", "-n"]);
|
|
36
|
+
|
|
37
|
+
if (proc.exitCode !== 0) {
|
|
38
|
+
// Check if lsof is not found vs other errors
|
|
39
|
+
const stderr = proc.stderr.toString();
|
|
40
|
+
if (stderr.includes("not found") || proc.exitCode === 127) {
|
|
41
|
+
return null; // lsof not available, try fallback
|
|
42
|
+
}
|
|
43
|
+
// lsof exists but failed (e.g., no listening processes)
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return this.parseLsofOutput(proc.stdout.toString());
|
|
48
|
+
} catch (err: unknown) {
|
|
49
|
+
// Bun throws ENOENT when executable not found
|
|
50
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse lsof output into ProcessInfo array.
|
|
59
|
+
*/
|
|
60
|
+
private parseLsofOutput(output: string): ProcessInfo[] {
|
|
61
|
+
const lines = output.split("\n").slice(1); // skip header
|
|
62
|
+
const processes: ProcessInfo[] = [];
|
|
63
|
+
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
if (!line.trim()) continue;
|
|
66
|
+
|
|
67
|
+
const parts = line.split(/\s+/);
|
|
68
|
+
// COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
|
69
|
+
// node 123 user 45u IPv4 0x123 0t0 TCP 127.0.0.1:3000 (LISTEN)
|
|
70
|
+
|
|
71
|
+
const command = parts[0];
|
|
72
|
+
const pidStr = parts[1];
|
|
73
|
+
const user = parts[2];
|
|
74
|
+
const name = parts[8] ?? "";
|
|
75
|
+
|
|
76
|
+
if (!command || !pidStr || !user) continue;
|
|
77
|
+
|
|
78
|
+
const pid = parseInt(pidStr, 10);
|
|
79
|
+
if (isNaN(pid)) continue;
|
|
80
|
+
|
|
81
|
+
// Extract port from name like "127.0.0.1:3000" or "*:8080"
|
|
82
|
+
const portMatch = name.match(/:(\d+)$/);
|
|
83
|
+
if (!portMatch?.[1]) continue;
|
|
84
|
+
|
|
85
|
+
const port = parseInt(portMatch[1], 10);
|
|
86
|
+
|
|
87
|
+
processes.push({
|
|
88
|
+
command,
|
|
89
|
+
pid,
|
|
90
|
+
user,
|
|
91
|
+
port,
|
|
92
|
+
protocol: "TCP",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Deduplicate by pid+port (same process may have multiple file descriptors)
|
|
97
|
+
const seen = new Set<string>();
|
|
98
|
+
return processes.filter((p) => {
|
|
99
|
+
const key = `${p.pid}:${p.port}`;
|
|
100
|
+
if (seen.has(key)) return false;
|
|
101
|
+
seen.add(key);
|
|
102
|
+
return true;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Try to get listening processes using ss (Linux fallback).
|
|
108
|
+
* Returns null if ss is not available.
|
|
109
|
+
*/
|
|
110
|
+
private trySs(): ProcessInfo[] | null {
|
|
111
|
+
try {
|
|
112
|
+
// ss -tulnp shows TCP/UDP listening with process info
|
|
113
|
+
const proc = Bun.spawnSync(["ss", "-tulnp"]);
|
|
114
|
+
|
|
115
|
+
if (proc.exitCode !== 0) {
|
|
116
|
+
const stderr = proc.stderr.toString();
|
|
117
|
+
if (stderr.includes("not found") || proc.exitCode === 127) {
|
|
118
|
+
return null; // ss not available
|
|
119
|
+
}
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return this.parseSsOutput(proc.stdout.toString());
|
|
124
|
+
} catch (err: unknown) {
|
|
125
|
+
// Bun throws ENOENT when executable not found
|
|
126
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Parse ss output into ProcessInfo array.
|
|
135
|
+
* Example line: LISTEN 0 128 *:3000 *:* users:(("node",pid=1234,fd=20))
|
|
136
|
+
*/
|
|
137
|
+
private parseSsOutput(output: string): ProcessInfo[] {
|
|
138
|
+
const lines = output.split("\n").slice(1); // skip header
|
|
139
|
+
const processes: ProcessInfo[] = [];
|
|
140
|
+
|
|
141
|
+
for (const line of lines) {
|
|
142
|
+
if (!line.trim()) continue;
|
|
143
|
+
|
|
144
|
+
// Only process LISTEN state
|
|
145
|
+
if (!line.startsWith("LISTEN")) continue;
|
|
146
|
+
|
|
147
|
+
// Extract port from local address (4th column)
|
|
148
|
+
// Format: *:3000 or 0.0.0.0:3000 or [::]:3000
|
|
149
|
+
const parts = line.split(/\s+/);
|
|
150
|
+
const localAddr = parts[3] ?? "";
|
|
151
|
+
const portMatch = localAddr.match(/:(\d+)$/);
|
|
152
|
+
if (!portMatch?.[1]) continue;
|
|
153
|
+
const port = parseInt(portMatch[1], 10);
|
|
154
|
+
|
|
155
|
+
// Extract process info from users:((... ))
|
|
156
|
+
// Format: users:(("node",pid=1234,fd=20))
|
|
157
|
+
const usersMatch = line.match(/users:\(\("([^"]+)",pid=(\d+)/);
|
|
158
|
+
if (!usersMatch) continue;
|
|
159
|
+
|
|
160
|
+
const command = usersMatch[1] ?? "unknown";
|
|
161
|
+
const pid = parseInt(usersMatch[2] ?? "0", 10);
|
|
162
|
+
if (isNaN(pid) || pid === 0) continue;
|
|
163
|
+
|
|
164
|
+
// ss doesn't show user, get it from /proc
|
|
165
|
+
const user = this.getProcessUser(pid) ?? "unknown";
|
|
166
|
+
|
|
167
|
+
processes.push({
|
|
168
|
+
command,
|
|
169
|
+
pid,
|
|
170
|
+
user,
|
|
171
|
+
port,
|
|
172
|
+
protocol: "TCP",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Deduplicate
|
|
177
|
+
const seen = new Set<string>();
|
|
178
|
+
return processes.filter((p) => {
|
|
179
|
+
const key = `${p.pid}:${p.port}`;
|
|
180
|
+
if (seen.has(key)) return false;
|
|
181
|
+
seen.add(key);
|
|
182
|
+
return true;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Get the user owning a process from /proc on Linux.
|
|
188
|
+
*/
|
|
189
|
+
private getProcessUser(pid: number): string | null {
|
|
190
|
+
try {
|
|
191
|
+
const proc = Bun.spawnSync(["stat", "-c", "%U", `/proc/${pid}`]);
|
|
192
|
+
if (proc.exitCode === 0) {
|
|
193
|
+
return proc.stdout.toString().trim();
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
// Ignore errors
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Get extended metadata for a process.
|
|
203
|
+
*/
|
|
204
|
+
getProcessMetadata(pid: number): ProcessMetadata {
|
|
205
|
+
const cwd = this.getProcessCwd(pid);
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
// ps -p PID -o %cpu=,%mem=,rss=,lstart=,args=
|
|
209
|
+
const proc = Bun.spawnSync([
|
|
210
|
+
"ps",
|
|
211
|
+
"-p",
|
|
212
|
+
String(pid),
|
|
213
|
+
"-o",
|
|
214
|
+
"%cpu=,%mem=,rss=,lstart=,args=",
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
if (proc.exitCode !== 0) {
|
|
218
|
+
return {
|
|
219
|
+
cpuPercent: null,
|
|
220
|
+
memoryBytes: null,
|
|
221
|
+
startTime: null,
|
|
222
|
+
path: null,
|
|
223
|
+
cwd,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const output = proc.stdout.toString().trim();
|
|
228
|
+
if (!output) {
|
|
229
|
+
return {
|
|
230
|
+
cpuPercent: null,
|
|
231
|
+
memoryBytes: null,
|
|
232
|
+
startTime: null,
|
|
233
|
+
path: null,
|
|
234
|
+
cwd,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Output format varies by locale:
|
|
239
|
+
// "0.0 0.1 12345 Mon Jan 1 12:00:00 2025 /usr/bin/node server.js"
|
|
240
|
+
// "0,0 0,1 12345 ons 31 des 06:04:50 2025 /usr/bin/node server.js"
|
|
241
|
+
// Note: lstart uses multiple spaces before args
|
|
242
|
+
|
|
243
|
+
// Parse numbers first (handle both . and , as decimal separator)
|
|
244
|
+
const parts = output.trim().split(/\s+/);
|
|
245
|
+
const cpuStr = (parts[0] ?? "").replace(",", ".");
|
|
246
|
+
const rssStr = parts[2] ?? "";
|
|
247
|
+
|
|
248
|
+
const cpuParsed = parseFloat(cpuStr);
|
|
249
|
+
const cpuPercent = isNaN(cpuParsed) ? null : cpuParsed;
|
|
250
|
+
const rssKb = parseInt(rssStr, 10) || null;
|
|
251
|
+
|
|
252
|
+
// Find the path - it's after the year (4 digits) and multiple spaces
|
|
253
|
+
// lstart format: "Day Mon DD HH:MM:SS YYYY" then spaces then args
|
|
254
|
+
const yearMatch = output.match(/\d{4}\s{2,}(.+)$/);
|
|
255
|
+
const path = yearMatch?.[1]?.trim() ?? null;
|
|
256
|
+
|
|
257
|
+
// Extract start time - between RSS and path
|
|
258
|
+
// parts[3..7] is typically: Day Mon DD HH:MM:SS YYYY
|
|
259
|
+
let startTime: string | null = null;
|
|
260
|
+
if (parts.length >= 8) {
|
|
261
|
+
const dateStr = parts.slice(3, 8).join(" ");
|
|
262
|
+
try {
|
|
263
|
+
const date = new Date(dateStr);
|
|
264
|
+
if (!isNaN(date.getTime())) {
|
|
265
|
+
startTime = date.toISOString();
|
|
266
|
+
} else {
|
|
267
|
+
// Keep raw string for non-English locales
|
|
268
|
+
startTime = dateStr;
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
startTime = dateStr;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
cpuPercent,
|
|
277
|
+
memoryBytes: rssKb ? rssKb * 1024 : null,
|
|
278
|
+
startTime,
|
|
279
|
+
path,
|
|
280
|
+
cwd,
|
|
281
|
+
};
|
|
282
|
+
} catch {
|
|
283
|
+
return {
|
|
284
|
+
cpuPercent: null,
|
|
285
|
+
memoryBytes: null,
|
|
286
|
+
startTime: null,
|
|
287
|
+
path: null,
|
|
288
|
+
cwd,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Get the current working directory of a process.
|
|
295
|
+
* Tries lsof first, falls back to /proc on Linux.
|
|
296
|
+
*/
|
|
297
|
+
private getProcessCwd(pid: number): string | null {
|
|
298
|
+
// Try lsof first
|
|
299
|
+
try {
|
|
300
|
+
const proc = Bun.spawnSync([
|
|
301
|
+
"lsof",
|
|
302
|
+
"-a",
|
|
303
|
+
"-p",
|
|
304
|
+
String(pid),
|
|
305
|
+
"-d",
|
|
306
|
+
"cwd",
|
|
307
|
+
"-Fn",
|
|
308
|
+
]);
|
|
309
|
+
if (proc.exitCode === 0) {
|
|
310
|
+
const output = proc.stdout.toString();
|
|
311
|
+
// Output format: "p12345\nn/path/to/cwd\n"
|
|
312
|
+
const match = output.match(/^n(.+)$/m);
|
|
313
|
+
if (match?.[1]) {
|
|
314
|
+
return match[1];
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
// Ignore, try fallback
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Fallback: read /proc/PID/cwd symlink (Linux only)
|
|
322
|
+
try {
|
|
323
|
+
const proc = Bun.spawnSync(["readlink", `/proc/${pid}/cwd`]);
|
|
324
|
+
if (proc.exitCode === 0) {
|
|
325
|
+
const cwd = proc.stdout.toString().trim();
|
|
326
|
+
if (cwd) return cwd;
|
|
327
|
+
}
|
|
328
|
+
} catch {
|
|
329
|
+
// Ignore
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Kill a process with the specified signal.
|
|
337
|
+
*/
|
|
338
|
+
killProcess(pid: number, signal: "SIGTERM" | "SIGKILL"): void {
|
|
339
|
+
process.kill(pid, signal);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { PlatformAdapter, ProcessInfo, ProcessMetadata } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Windows platform adapter.
|
|
5
|
+
* Uses PowerShell for port discovery and process metadata.
|
|
6
|
+
*/
|
|
7
|
+
export class WindowsAdapter implements PlatformAdapter {
|
|
8
|
+
/**
|
|
9
|
+
* Get all processes listening on TCP ports using PowerShell.
|
|
10
|
+
*/
|
|
11
|
+
getListeningProcesses(): ProcessInfo[] {
|
|
12
|
+
// PowerShell script to get listening ports with process info
|
|
13
|
+
const script = `
|
|
14
|
+
Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue |
|
|
15
|
+
ForEach-Object {
|
|
16
|
+
$proc = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue
|
|
17
|
+
[PSCustomObject]@{
|
|
18
|
+
Port = $_.LocalPort
|
|
19
|
+
PID = $_.OwningProcess
|
|
20
|
+
Name = if ($proc) { $proc.ProcessName } else { "unknown" }
|
|
21
|
+
User = try { (Get-Process -Id $_.OwningProcess -IncludeUserName -ErrorAction SilentlyContinue).UserName } catch { "unknown" }
|
|
22
|
+
}
|
|
23
|
+
} | ConvertTo-Json -Compress
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
const proc = Bun.spawnSync([
|
|
27
|
+
"powershell",
|
|
28
|
+
"-NoProfile",
|
|
29
|
+
"-Command",
|
|
30
|
+
script,
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
if (proc.exitCode !== 0) {
|
|
34
|
+
console.error("Failed to query listening ports via PowerShell.");
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const output = proc.stdout.toString().trim();
|
|
39
|
+
if (!output || output === "null") {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const data = JSON.parse(output);
|
|
45
|
+
// PowerShell returns single object (not array) when only one result
|
|
46
|
+
const items = Array.isArray(data) ? data : [data];
|
|
47
|
+
|
|
48
|
+
return items.map(
|
|
49
|
+
(item: { Port: number; PID: number; Name: string; User: string }) => ({
|
|
50
|
+
port: item.Port,
|
|
51
|
+
pid: item.PID,
|
|
52
|
+
command: item.Name,
|
|
53
|
+
user: item.User ?? "unknown",
|
|
54
|
+
protocol: "TCP",
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
} catch {
|
|
58
|
+
console.error("Failed to parse PowerShell output.");
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get extended metadata for a process using PowerShell.
|
|
65
|
+
*/
|
|
66
|
+
getProcessMetadata(pid: number): ProcessMetadata {
|
|
67
|
+
const script = `
|
|
68
|
+
$p = Get-Process -Id ${pid} -ErrorAction SilentlyContinue
|
|
69
|
+
if ($p) {
|
|
70
|
+
$wmi = Get-WmiObject Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction SilentlyContinue
|
|
71
|
+
[PSCustomObject]@{
|
|
72
|
+
CPU = $p.CPU
|
|
73
|
+
Memory = $p.WorkingSet64
|
|
74
|
+
StartTime = if ($p.StartTime) { $p.StartTime.ToString("o") } else { $null }
|
|
75
|
+
Path = $p.Path
|
|
76
|
+
Cwd = if ($wmi) { Split-Path -Parent $wmi.ExecutablePath -ErrorAction SilentlyContinue } else { $null }
|
|
77
|
+
} | ConvertTo-Json -Compress
|
|
78
|
+
} else {
|
|
79
|
+
"{}"
|
|
80
|
+
}
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const proc = Bun.spawnSync([
|
|
85
|
+
"powershell",
|
|
86
|
+
"-NoProfile",
|
|
87
|
+
"-Command",
|
|
88
|
+
script,
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
if (proc.exitCode !== 0) {
|
|
92
|
+
return this.emptyMetadata();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const output = proc.stdout.toString().trim();
|
|
96
|
+
if (!output || output === "{}" || output === "null") {
|
|
97
|
+
return this.emptyMetadata();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const data = JSON.parse(output);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
cpuPercent: typeof data.CPU === "number" ? data.CPU : null,
|
|
104
|
+
memoryBytes: typeof data.Memory === "number" ? data.Memory : null,
|
|
105
|
+
startTime: data.StartTime ?? null,
|
|
106
|
+
path: data.Path ?? null,
|
|
107
|
+
cwd: data.Cwd ?? null,
|
|
108
|
+
};
|
|
109
|
+
} catch {
|
|
110
|
+
return this.emptyMetadata();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Return empty metadata object.
|
|
116
|
+
*/
|
|
117
|
+
private emptyMetadata(): ProcessMetadata {
|
|
118
|
+
return {
|
|
119
|
+
cpuPercent: null,
|
|
120
|
+
memoryBytes: null,
|
|
121
|
+
startTime: null,
|
|
122
|
+
path: null,
|
|
123
|
+
cwd: null,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Kill a process with the specified signal.
|
|
129
|
+
* Uses process.kill which works on Windows for SIGTERM/SIGKILL.
|
|
130
|
+
*/
|
|
131
|
+
killProcess(pid: number, signal: "SIGTERM" | "SIGKILL"): void {
|
|
132
|
+
try {
|
|
133
|
+
process.kill(pid, signal);
|
|
134
|
+
} catch (err: unknown) {
|
|
135
|
+
// Fallback to taskkill if process.kill fails
|
|
136
|
+
const force = signal === "SIGKILL" ? "/F" : "";
|
|
137
|
+
const args = ["/PID", String(pid)];
|
|
138
|
+
if (force) args.push(force);
|
|
139
|
+
|
|
140
|
+
const proc = Bun.spawnSync(["taskkill", ...args]);
|
|
141
|
+
if (proc.exitCode !== 0) {
|
|
142
|
+
throw err; // Re-throw original error
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|