portsmith 0.1.0__tar.gz
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.
- portsmith-0.1.0/.gitignore +21 -0
- portsmith-0.1.0/LICENSE +21 -0
- portsmith-0.1.0/PKG-INFO +141 -0
- portsmith-0.1.0/README.md +94 -0
- portsmith-0.1.0/portman/__init__.py +1 -0
- portsmith-0.1.0/portman/commands/__init__.py +0 -0
- portsmith-0.1.0/portman/commands/kill.py +53 -0
- portsmith-0.1.0/portman/commands/list_ports.py +67 -0
- portsmith-0.1.0/portman/commands/profiles.py +35 -0
- portsmith-0.1.0/portman/commands/restore.py +51 -0
- portsmith-0.1.0/portman/commands/save.py +38 -0
- portsmith-0.1.0/portman/commands/watch.py +81 -0
- portsmith-0.1.0/portman/main.py +18 -0
- portsmith-0.1.0/portman/utils.py +88 -0
- portsmith-0.1.0/pyproject.toml +41 -0
portsmith-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Atharva Kokane
|
|
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.
|
portsmith-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: portsmith
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local port management CLI — list, kill, watch, and snapshot listening ports
|
|
5
|
+
Project-URL: Homepage, https://github.com/atharvashashankk/portman
|
|
6
|
+
Project-URL: Issues, https://github.com/atharvashashankk/portman/issues
|
|
7
|
+
Author-email: Atharva Kokane <atharvashashankkokane@gmail.com>
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 Atharva Kokane
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Keywords: cli,devtools,network,port,process
|
|
31
|
+
Classifier: Development Status :: 3 - Alpha
|
|
32
|
+
Classifier: Environment :: Console
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Operating System :: OS Independent
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Topic :: System :: Networking
|
|
41
|
+
Classifier: Topic :: Utilities
|
|
42
|
+
Requires-Python: >=3.10
|
|
43
|
+
Requires-Dist: psutil>=5.9.0
|
|
44
|
+
Requires-Dist: rich>=13.0.0
|
|
45
|
+
Requires-Dist: typer>=0.9.0
|
|
46
|
+
Description-Content-Type: text/markdown
|
|
47
|
+
|
|
48
|
+
# portman
|
|
49
|
+
|
|
50
|
+
A local port management CLI for macOS and Linux.
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install portman
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Or install from source:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
git clone <repo>
|
|
62
|
+
cd portman
|
|
63
|
+
pip install -e .
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Commands
|
|
67
|
+
|
|
68
|
+
### `portman list`
|
|
69
|
+
|
|
70
|
+
Show all listening ports with PID, process name, port, protocol, and status. System ports (<1024) are highlighted in yellow, user ports in green.
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
portman list
|
|
74
|
+
|
|
75
|
+
# Only show ports opened from this terminal session
|
|
76
|
+
portman list --mine
|
|
77
|
+
|
|
78
|
+
# Machine-readable JSON output
|
|
79
|
+
portman list --json
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
> **`--mine` note:** this flag filters to processes running under your OS user account. It removes system services (svchost, lsass, etc.) but will still show other user-owned background apps that happen to listen on ports (e.g. Chrome, cloud sync clients). It correctly catches dev servers started in any shell or terminal window.
|
|
83
|
+
|
|
84
|
+
### `portman kill <port>`
|
|
85
|
+
|
|
86
|
+
Kill the process listening on a given port. Asks for confirmation unless `--force` is passed.
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
portman kill 8080
|
|
90
|
+
|
|
91
|
+
# Skip confirmation
|
|
92
|
+
portman kill 8080 --force
|
|
93
|
+
portman kill 8080 -f
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### `portman watch`
|
|
97
|
+
|
|
98
|
+
Live-updating view of all active ports, refreshing every 2 seconds. New ports are highlighted green, closed ports red. Exit with `Ctrl+C`.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
portman watch
|
|
102
|
+
|
|
103
|
+
# Only watch ports opened from this terminal session
|
|
104
|
+
portman watch --mine
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### `portman save <name>`
|
|
108
|
+
|
|
109
|
+
Save a snapshot of all currently active user ports (port ≥1024) to `~/.portman/profiles/<name>.json`.
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
portman save myproject
|
|
113
|
+
portman save dev-stack
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### `portman restore <name>`
|
|
117
|
+
|
|
118
|
+
Check a saved profile against currently running ports. Shows which saved processes are still running and which are missing, along with hints for restarting them.
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
portman restore myproject
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### `portman profiles`
|
|
125
|
+
|
|
126
|
+
List all saved profiles with their creation timestamp and port count.
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
portman profiles
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Requirements
|
|
133
|
+
|
|
134
|
+
- Python 3.10+
|
|
135
|
+
- macOS or Linux (Windows is not supported)
|
|
136
|
+
|
|
137
|
+
## Notes
|
|
138
|
+
|
|
139
|
+
- Ports below 1024 are system ports and may require `sudo` to kill.
|
|
140
|
+
- `portman restore` does not restart processes — it only reports their status and suggests what needs to be restarted.
|
|
141
|
+
- Profiles are stored in `~/.portman/profiles/` as JSON files.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# portman
|
|
2
|
+
|
|
3
|
+
A local port management CLI for macOS and Linux.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install portman
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or install from source:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
git clone <repo>
|
|
15
|
+
cd portman
|
|
16
|
+
pip install -e .
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
|
|
21
|
+
### `portman list`
|
|
22
|
+
|
|
23
|
+
Show all listening ports with PID, process name, port, protocol, and status. System ports (<1024) are highlighted in yellow, user ports in green.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
portman list
|
|
27
|
+
|
|
28
|
+
# Only show ports opened from this terminal session
|
|
29
|
+
portman list --mine
|
|
30
|
+
|
|
31
|
+
# Machine-readable JSON output
|
|
32
|
+
portman list --json
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
> **`--mine` note:** this flag filters to processes running under your OS user account. It removes system services (svchost, lsass, etc.) but will still show other user-owned background apps that happen to listen on ports (e.g. Chrome, cloud sync clients). It correctly catches dev servers started in any shell or terminal window.
|
|
36
|
+
|
|
37
|
+
### `portman kill <port>`
|
|
38
|
+
|
|
39
|
+
Kill the process listening on a given port. Asks for confirmation unless `--force` is passed.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
portman kill 8080
|
|
43
|
+
|
|
44
|
+
# Skip confirmation
|
|
45
|
+
portman kill 8080 --force
|
|
46
|
+
portman kill 8080 -f
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### `portman watch`
|
|
50
|
+
|
|
51
|
+
Live-updating view of all active ports, refreshing every 2 seconds. New ports are highlighted green, closed ports red. Exit with `Ctrl+C`.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
portman watch
|
|
55
|
+
|
|
56
|
+
# Only watch ports opened from this terminal session
|
|
57
|
+
portman watch --mine
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### `portman save <name>`
|
|
61
|
+
|
|
62
|
+
Save a snapshot of all currently active user ports (port ≥1024) to `~/.portman/profiles/<name>.json`.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
portman save myproject
|
|
66
|
+
portman save dev-stack
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### `portman restore <name>`
|
|
70
|
+
|
|
71
|
+
Check a saved profile against currently running ports. Shows which saved processes are still running and which are missing, along with hints for restarting them.
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
portman restore myproject
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### `portman profiles`
|
|
78
|
+
|
|
79
|
+
List all saved profiles with their creation timestamp and port count.
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
portman profiles
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Requirements
|
|
86
|
+
|
|
87
|
+
- Python 3.10+
|
|
88
|
+
- macOS or Linux (Windows is not supported)
|
|
89
|
+
|
|
90
|
+
## Notes
|
|
91
|
+
|
|
92
|
+
- Ports below 1024 are system ports and may require `sudo` to kill.
|
|
93
|
+
- `portman restore` does not restart processes — it only reports their status and suggests what needs to be restarted.
|
|
94
|
+
- Profiles are stored in `~/.portman/profiles/` as JSON files.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import psutil
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from portman.utils import check_platform, get_listening_ports
|
|
5
|
+
|
|
6
|
+
console = Console()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def command(
|
|
10
|
+
port: int = typer.Argument(..., help="Port number of the process to kill"),
|
|
11
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt"),
|
|
12
|
+
) -> None:
|
|
13
|
+
"""Kill the process listening on the given port."""
|
|
14
|
+
check_platform()
|
|
15
|
+
ports = get_listening_ports()
|
|
16
|
+
matches = [p for p in ports if p.port == port]
|
|
17
|
+
|
|
18
|
+
if not matches:
|
|
19
|
+
console.print(f"[red]No process found listening on port {port}.[/red]")
|
|
20
|
+
raise typer.Exit(1)
|
|
21
|
+
|
|
22
|
+
target = matches[0]
|
|
23
|
+
|
|
24
|
+
if not target.pid:
|
|
25
|
+
console.print(
|
|
26
|
+
f"[red]Cannot kill process on port {port}: PID unknown. "
|
|
27
|
+
"Try running with sudo.[/red]"
|
|
28
|
+
)
|
|
29
|
+
raise typer.Exit(1)
|
|
30
|
+
|
|
31
|
+
if not force:
|
|
32
|
+
confirmed = typer.confirm(
|
|
33
|
+
f"Kill {target.name} (PID {target.pid}) on port {port}?"
|
|
34
|
+
)
|
|
35
|
+
if not confirmed:
|
|
36
|
+
console.print("[yellow]Aborted.[/yellow]")
|
|
37
|
+
raise typer.Exit()
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
proc = psutil.Process(target.pid)
|
|
41
|
+
proc.terminate()
|
|
42
|
+
console.print(
|
|
43
|
+
f"[green]Killed[/green] [bold]{target.name}[/bold] "
|
|
44
|
+
f"(PID {target.pid}) on port [bold]{port}[/bold]."
|
|
45
|
+
)
|
|
46
|
+
except psutil.NoSuchProcess:
|
|
47
|
+
console.print(f"[yellow]Process {target.pid} was already gone.[/yellow]")
|
|
48
|
+
except psutil.AccessDenied:
|
|
49
|
+
console.print(
|
|
50
|
+
f"[red]Permission denied killing PID {target.pid}. "
|
|
51
|
+
"Try running with sudo.[/red]"
|
|
52
|
+
)
|
|
53
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import typer
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from portman.utils import check_platform, get_listening_ports, get_user_pids
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def command(
|
|
11
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
12
|
+
mine: bool = typer.Option(False, "--mine", help="Only show ports opened from this terminal session"),
|
|
13
|
+
) -> None:
|
|
14
|
+
"""Show all listening ports with PID, process name, port, protocol, and status."""
|
|
15
|
+
check_platform()
|
|
16
|
+
ports = get_listening_ports()
|
|
17
|
+
|
|
18
|
+
if mine:
|
|
19
|
+
user_pids = get_user_pids()
|
|
20
|
+
ports = [p for p in ports if p.pid in user_pids]
|
|
21
|
+
|
|
22
|
+
if json_output:
|
|
23
|
+
data = [
|
|
24
|
+
{
|
|
25
|
+
"pid": p.pid,
|
|
26
|
+
"name": p.name,
|
|
27
|
+
"port": p.port,
|
|
28
|
+
"protocol": p.protocol,
|
|
29
|
+
"status": p.status,
|
|
30
|
+
}
|
|
31
|
+
for p in ports
|
|
32
|
+
]
|
|
33
|
+
typer.echo(json.dumps(data, indent=2))
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
if not ports:
|
|
37
|
+
console.print("[yellow]No listening ports found.[/yellow]")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
table = Table(
|
|
41
|
+
title="Active Listening Ports",
|
|
42
|
+
show_header=True,
|
|
43
|
+
header_style="bold cyan",
|
|
44
|
+
)
|
|
45
|
+
table.add_column("Port", justify="right", style="bold")
|
|
46
|
+
table.add_column("Proto")
|
|
47
|
+
table.add_column("PID", justify="right")
|
|
48
|
+
table.add_column("Process")
|
|
49
|
+
table.add_column("Status")
|
|
50
|
+
table.add_column("Type")
|
|
51
|
+
|
|
52
|
+
for p in ports:
|
|
53
|
+
if p.is_system():
|
|
54
|
+
type_label = "[yellow]system[/yellow]"
|
|
55
|
+
else:
|
|
56
|
+
type_label = "[green]user[/green]"
|
|
57
|
+
|
|
58
|
+
table.add_row(
|
|
59
|
+
str(p.port),
|
|
60
|
+
p.protocol,
|
|
61
|
+
str(p.pid) if p.pid else "[dim]-[/dim]",
|
|
62
|
+
p.name,
|
|
63
|
+
p.status,
|
|
64
|
+
type_label,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
console.print(table)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
from portman.utils import check_platform, get_profiles_dir
|
|
5
|
+
|
|
6
|
+
console = Console()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def command() -> None:
|
|
10
|
+
"""List all saved profiles with their timestamp and port count."""
|
|
11
|
+
check_platform()
|
|
12
|
+
profiles_dir = get_profiles_dir()
|
|
13
|
+
files = sorted(profiles_dir.glob("*.json"))
|
|
14
|
+
|
|
15
|
+
if not files:
|
|
16
|
+
console.print("[yellow]No saved profiles. Use 'portman save <name>' to create one.[/yellow]")
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
table = Table(
|
|
20
|
+
title="Saved Profiles",
|
|
21
|
+
show_header=True,
|
|
22
|
+
header_style="bold cyan",
|
|
23
|
+
)
|
|
24
|
+
table.add_column("Name", style="bold")
|
|
25
|
+
table.add_column("Saved At")
|
|
26
|
+
table.add_column("Ports", justify="right")
|
|
27
|
+
|
|
28
|
+
for f in files:
|
|
29
|
+
try:
|
|
30
|
+
data = json.loads(f.read_text())
|
|
31
|
+
table.add_row(data["name"], data["saved_at"], str(len(data["ports"])))
|
|
32
|
+
except (json.JSONDecodeError, KeyError):
|
|
33
|
+
table.add_row(f.stem, "[red]corrupted[/red]", "[dim]-[/dim]")
|
|
34
|
+
|
|
35
|
+
console.print(table)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import typer
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from portman.utils import check_platform, get_listening_ports, get_profiles_dir
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def command(
|
|
11
|
+
name: str = typer.Argument(..., help="Name of the profile to check"),
|
|
12
|
+
) -> None:
|
|
13
|
+
"""Show which saved ports are still running and which are missing."""
|
|
14
|
+
check_platform()
|
|
15
|
+
path = get_profiles_dir() / f"{name}.json"
|
|
16
|
+
|
|
17
|
+
if not path.exists():
|
|
18
|
+
console.print(f"[red]Profile '{name}' not found.[/red]")
|
|
19
|
+
raise typer.Exit(1)
|
|
20
|
+
|
|
21
|
+
profile = json.loads(path.read_text())
|
|
22
|
+
saved_ports: dict[int, dict] = {p["port"]: p for p in profile["ports"]}
|
|
23
|
+
active_ports: set[int] = {p.port for p in get_listening_ports()}
|
|
24
|
+
|
|
25
|
+
table = Table(
|
|
26
|
+
title=f"Profile: {name} [dim](saved {profile['saved_at']})[/dim]",
|
|
27
|
+
show_header=True,
|
|
28
|
+
header_style="bold cyan",
|
|
29
|
+
)
|
|
30
|
+
table.add_column("Port", justify="right")
|
|
31
|
+
table.add_column("Process")
|
|
32
|
+
table.add_column("Status")
|
|
33
|
+
|
|
34
|
+
missing: list[dict] = []
|
|
35
|
+
for port, info in sorted(saved_ports.items()):
|
|
36
|
+
if port in active_ports:
|
|
37
|
+
table.add_row(str(port), info["name"], "[green]running[/green]")
|
|
38
|
+
else:
|
|
39
|
+
table.add_row(str(port), info["name"], "[red]missing[/red]")
|
|
40
|
+
missing.append(info)
|
|
41
|
+
|
|
42
|
+
console.print(table)
|
|
43
|
+
|
|
44
|
+
if missing:
|
|
45
|
+
console.print(
|
|
46
|
+
"\n[bold yellow]The following processes are no longer running:[/bold yellow]"
|
|
47
|
+
)
|
|
48
|
+
for info in missing:
|
|
49
|
+
console.print(
|
|
50
|
+
f" [dim]# Restart '{info['name']}' and have it listen on port {info['port']}[/dim]"
|
|
51
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from portman.utils import check_platform, get_listening_ports, get_profiles_dir
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def command(
|
|
11
|
+
name: str = typer.Argument(..., help="Name for the profile snapshot"),
|
|
12
|
+
) -> None:
|
|
13
|
+
"""Save a snapshot of currently active user ports (>=1024) to ~/.portman/profiles/<name>.json."""
|
|
14
|
+
check_platform()
|
|
15
|
+
ports = get_listening_ports()
|
|
16
|
+
user_ports = [p for p in ports if not p.is_system()]
|
|
17
|
+
|
|
18
|
+
profile = {
|
|
19
|
+
"name": name,
|
|
20
|
+
"saved_at": datetime.now().isoformat(),
|
|
21
|
+
"ports": [
|
|
22
|
+
{
|
|
23
|
+
"pid": p.pid,
|
|
24
|
+
"name": p.name,
|
|
25
|
+
"port": p.port,
|
|
26
|
+
"protocol": p.protocol,
|
|
27
|
+
"status": p.status,
|
|
28
|
+
}
|
|
29
|
+
for p in user_ports
|
|
30
|
+
],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
path = get_profiles_dir() / f"{name}.json"
|
|
34
|
+
path.write_text(json.dumps(profile, indent=2))
|
|
35
|
+
console.print(
|
|
36
|
+
f"[green]Saved[/green] [bold]{len(user_ports)}[/bold] user port(s) "
|
|
37
|
+
f"to profile [bold]'{name}'[/bold] ({path})"
|
|
38
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import typer
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from rich.live import Live
|
|
6
|
+
from portman.utils import check_platform, get_listening_ports, get_user_pids, PortInfo
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _build_table(
|
|
12
|
+
ports: list[PortInfo],
|
|
13
|
+
new_ports: set[int],
|
|
14
|
+
closed_ports: set[int],
|
|
15
|
+
) -> Table:
|
|
16
|
+
table = Table(
|
|
17
|
+
title="Active Ports [dim](Ctrl+C to exit)[/dim]",
|
|
18
|
+
show_header=True,
|
|
19
|
+
header_style="bold cyan",
|
|
20
|
+
)
|
|
21
|
+
table.add_column("Port", justify="right", style="bold")
|
|
22
|
+
table.add_column("Proto")
|
|
23
|
+
table.add_column("PID", justify="right")
|
|
24
|
+
table.add_column("Process")
|
|
25
|
+
table.add_column("Status")
|
|
26
|
+
table.add_column("Change", justify="center")
|
|
27
|
+
|
|
28
|
+
for p in ports:
|
|
29
|
+
if p.port in new_ports:
|
|
30
|
+
change = "[bold green]NEW[/bold green]"
|
|
31
|
+
port_str = f"[green]{p.port}[/green]"
|
|
32
|
+
else:
|
|
33
|
+
change = ""
|
|
34
|
+
port_str = str(p.port)
|
|
35
|
+
|
|
36
|
+
table.add_row(
|
|
37
|
+
port_str,
|
|
38
|
+
p.protocol,
|
|
39
|
+
str(p.pid) if p.pid else "[dim]-[/dim]",
|
|
40
|
+
p.name,
|
|
41
|
+
p.status,
|
|
42
|
+
change,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
for port in sorted(closed_ports):
|
|
46
|
+
table.add_row(
|
|
47
|
+
f"[red]{port}[/red]",
|
|
48
|
+
"", "", "", "",
|
|
49
|
+
"[bold red]CLOSED[/bold red]",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return table
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def command(
|
|
56
|
+
mine: bool = typer.Option(False, "--mine", help="Only show ports opened from this terminal session"),
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Live-updating view of all active ports. Refreshes every 2 seconds."""
|
|
59
|
+
check_platform()
|
|
60
|
+
|
|
61
|
+
def _filtered_ports():
|
|
62
|
+
ports = get_listening_ports()
|
|
63
|
+
if mine:
|
|
64
|
+
user_pids = get_user_pids()
|
|
65
|
+
ports = [p for p in ports if p.pid in user_pids]
|
|
66
|
+
return ports
|
|
67
|
+
|
|
68
|
+
prev_ports: set[int] = {p.port for p in _filtered_ports()}
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
with Live(console=console, refresh_per_second=2) as live:
|
|
72
|
+
while True:
|
|
73
|
+
ports = _filtered_ports()
|
|
74
|
+
current = {p.port for p in ports}
|
|
75
|
+
new_ports = current - prev_ports
|
|
76
|
+
closed_ports = prev_ports - current
|
|
77
|
+
live.update(_build_table(ports, new_ports, closed_ports))
|
|
78
|
+
prev_ports = current
|
|
79
|
+
time.sleep(2)
|
|
80
|
+
except KeyboardInterrupt:
|
|
81
|
+
console.print("\n[yellow]Stopped watching.[/yellow]")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from portman.commands import list_ports, kill, watch, save, restore, profiles
|
|
3
|
+
|
|
4
|
+
app = typer.Typer(
|
|
5
|
+
name="portman",
|
|
6
|
+
help="portman — local port management CLI",
|
|
7
|
+
no_args_is_help=True,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
app.command("list")(list_ports.command)
|
|
11
|
+
app.command("kill")(kill.command)
|
|
12
|
+
app.command("watch")(watch.command)
|
|
13
|
+
app.command("save")(save.command)
|
|
14
|
+
app.command("restore")(restore.command)
|
|
15
|
+
app.command("profiles")(profiles.command)
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
app()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import socket
|
|
4
|
+
import psutil
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def check_platform() -> None:
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class PortInfo:
|
|
14
|
+
pid: int
|
|
15
|
+
name: str
|
|
16
|
+
port: int
|
|
17
|
+
protocol: str
|
|
18
|
+
status: str
|
|
19
|
+
|
|
20
|
+
def is_system(self) -> bool:
|
|
21
|
+
return self.port < 1024
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_process_name(pid: int) -> str:
|
|
25
|
+
if not pid:
|
|
26
|
+
return "unknown"
|
|
27
|
+
try:
|
|
28
|
+
return psutil.Process(pid).name()
|
|
29
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
30
|
+
return "unknown"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_listening_ports() -> list[PortInfo]:
|
|
34
|
+
seen: dict[tuple[int, str], PortInfo] = {}
|
|
35
|
+
try:
|
|
36
|
+
for conn in psutil.net_connections(kind="inet"):
|
|
37
|
+
if not conn.laddr:
|
|
38
|
+
continue
|
|
39
|
+
if conn.type == socket.SOCK_STREAM and conn.status != "LISTEN":
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
port = conn.laddr.port
|
|
43
|
+
protocol = "TCP" if conn.type == socket.SOCK_STREAM else "UDP"
|
|
44
|
+
key = (port, protocol)
|
|
45
|
+
if key in seen:
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
status = conn.status if conn.type == socket.SOCK_STREAM else "N/A"
|
|
49
|
+
pid = conn.pid or 0
|
|
50
|
+
seen[key] = PortInfo(
|
|
51
|
+
pid=pid,
|
|
52
|
+
name=_get_process_name(pid),
|
|
53
|
+
port=port,
|
|
54
|
+
protocol=protocol,
|
|
55
|
+
status=status,
|
|
56
|
+
)
|
|
57
|
+
except psutil.AccessDenied:
|
|
58
|
+
pass
|
|
59
|
+
return sorted(seen.values(), key=lambda x: x.port)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_current_username() -> str | None:
|
|
63
|
+
try:
|
|
64
|
+
return psutil.Process(os.getpid()).username()
|
|
65
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_user_pids() -> set[int]:
|
|
70
|
+
"""Return PIDs of all processes running as the current user."""
|
|
71
|
+
username = get_current_username()
|
|
72
|
+
if not username:
|
|
73
|
+
return set()
|
|
74
|
+
pids: set[int] = set()
|
|
75
|
+
for proc in psutil.process_iter(["pid", "username"]):
|
|
76
|
+
try:
|
|
77
|
+
if proc.info["username"] == username:
|
|
78
|
+
pids.add(proc.info["pid"])
|
|
79
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
80
|
+
pass
|
|
81
|
+
return pids
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_profiles_dir():
|
|
85
|
+
from pathlib import Path
|
|
86
|
+
d = Path.home() / ".portman" / "profiles"
|
|
87
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
return d
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "portsmith"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Local port management CLI — list, kill, watch, and snapshot listening ports"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
authors = [{ name = "Atharva Kokane", email = "atharvashashankkokane@gmail.com" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
keywords = ["port", "cli", "network", "process", "devtools"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: System :: Networking",
|
|
25
|
+
"Topic :: Utilities",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"typer>=0.9.0",
|
|
29
|
+
"rich>=13.0.0",
|
|
30
|
+
"psutil>=5.9.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/atharvashashankk/portman"
|
|
35
|
+
Issues = "https://github.com/atharvashashankk/portman/issues"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
portman = "portman.main:app"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["portman"]
|