sshm-terminal 1.0.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.
- sshm_terminal-1.0.0/.gitignore +23 -0
- sshm_terminal-1.0.0/LICENSE +21 -0
- sshm_terminal-1.0.0/PKG-INFO +216 -0
- sshm_terminal-1.0.0/README.md +186 -0
- sshm_terminal-1.0.0/homebrew-tap/Formula/sshm.rb +24 -0
- sshm_terminal-1.0.0/homebrew-tap/README.md +23 -0
- sshm_terminal-1.0.0/pyproject.toml +46 -0
- sshm_terminal-1.0.0/sshm/__init__.py +1 -0
- sshm_terminal-1.0.0/sshm/__main__.py +3 -0
- sshm_terminal-1.0.0/sshm/app.py +671 -0
- sshm_terminal-1.0.0/sshm/storage.py +114 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
*.egg
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
|
|
9
|
+
# Virtual environment
|
|
10
|
+
.venv/
|
|
11
|
+
|
|
12
|
+
# Environment variables
|
|
13
|
+
.env
|
|
14
|
+
|
|
15
|
+
# macOS
|
|
16
|
+
.DS_Store
|
|
17
|
+
|
|
18
|
+
# IDE
|
|
19
|
+
.vscode/
|
|
20
|
+
.idea/
|
|
21
|
+
|
|
22
|
+
# Claude Code
|
|
23
|
+
.claude/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rajesh
|
|
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.
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sshm-terminal
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Terminal SSH connection manager with macOS Keychain integration and Matrix-themed TUI
|
|
5
|
+
Project-URL: Homepage, https://github.com/dailydeploy365/sshm
|
|
6
|
+
Project-URL: Repository, https://github.com/dailydeploy365/sshm
|
|
7
|
+
Project-URL: Issues, https://github.com/dailydeploy365/sshm/issues
|
|
8
|
+
Author: Rajesh
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: connection-manager,keychain,macos,ssh,terminal,tui
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: System Administrators
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: MacOS
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: System :: Networking
|
|
25
|
+
Classifier: Topic :: System :: Systems Administration
|
|
26
|
+
Classifier: Topic :: Terminals
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Requires-Dist: textual>=0.80.0
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# SSHM - SSH Connection Manager
|
|
32
|
+
|
|
33
|
+
A terminal-based SSH connection manager for macOS with a Matrix-themed TUI. Store server credentials securely (passwords in macOS Keychain), and connect with a single keypress.
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+

|
|
37
|
+

|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- **Interactive TUI** - Navigate servers with arrow keys, connect with Enter
|
|
42
|
+
- **Secure password storage** - Passwords saved in macOS Keychain (never plaintext)
|
|
43
|
+
- **Auto sudo** - Optionally elevate to root automatically after SSH login
|
|
44
|
+
- **Search/filter** - Quickly find servers by name, host, user, or group
|
|
45
|
+
- **Copy SSH command** - Copy the raw `ssh` command to clipboard
|
|
46
|
+
- **Matrix theme** - Green-on-black hacker aesthetic
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
### Option A: pipx (recommended)
|
|
51
|
+
|
|
52
|
+
One command, no manual venv or alias needed:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pipx install sshm-terminal
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
> Don't have pipx? Install it first: `brew install pipx && pipx ensurepath`
|
|
59
|
+
|
|
60
|
+
### Option B: Homebrew
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
brew tap dailydeploy365/tap https://github.com/dailydeploy365/homebrew-tap
|
|
64
|
+
brew install sshm
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Option C: From source (for development)
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
git clone https://github.com/dailydeploy365/sshm.git
|
|
71
|
+
cd sshm
|
|
72
|
+
python3 -m venv .venv
|
|
73
|
+
source .venv/bin/activate
|
|
74
|
+
pip install -e .
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Then add an alias so `sshm` works from anywhere:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# Add to ~/.zshrc
|
|
81
|
+
alias sshm="/path/to/sshm/.venv/bin/sshm"
|
|
82
|
+
source ~/.zshrc
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Optional: install sshpass for cleaner password auth
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
brew install esolitos/ipa/sshpass
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Without it, the app falls back to macOS built-in `expect` which works fine.
|
|
92
|
+
|
|
93
|
+
## Requirements
|
|
94
|
+
|
|
95
|
+
- macOS (uses `security` CLI for Keychain and `expect` for password automation)
|
|
96
|
+
- Python 3.10+
|
|
97
|
+
|
|
98
|
+
## Usage
|
|
99
|
+
|
|
100
|
+
### Launch the TUI
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
sshm
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Quick list from the terminal
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
sshm list
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### TUI Keybindings
|
|
113
|
+
|
|
114
|
+
| Key | Action |
|
|
115
|
+
|-----------|---------------------------------|
|
|
116
|
+
| `a` | Add a new server |
|
|
117
|
+
| `e` | Edit selected server |
|
|
118
|
+
| `d` | Delete selected server |
|
|
119
|
+
| `Enter` | SSH into selected server |
|
|
120
|
+
| `c` | Copy SSH command to clipboard |
|
|
121
|
+
| `/` | Search / filter servers |
|
|
122
|
+
| `Esc` | Close search, or quit |
|
|
123
|
+
| `q` | Quit |
|
|
124
|
+
|
|
125
|
+
### Add/Edit Form
|
|
126
|
+
|
|
127
|
+
| Key | Action |
|
|
128
|
+
|-----------|------------------|
|
|
129
|
+
| `ctrl+s` | Save |
|
|
130
|
+
| `Esc` | Cancel |
|
|
131
|
+
| `Tab` | Next field |
|
|
132
|
+
|
|
133
|
+
### Delete Confirmation
|
|
134
|
+
|
|
135
|
+
| Key | Action |
|
|
136
|
+
|---------------|-----------|
|
|
137
|
+
| `y` / `Enter` | Confirm |
|
|
138
|
+
| `n` / `Esc` | Cancel |
|
|
139
|
+
|
|
140
|
+
## How-To: Add and connect to a server
|
|
141
|
+
|
|
142
|
+
**Step 1** - Run `sshm`
|
|
143
|
+
|
|
144
|
+
**Step 2** - Press `a` to open the Add Server form
|
|
145
|
+
|
|
146
|
+
**Step 3** - Fill in the fields:
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
Name: Production API
|
|
150
|
+
Host / IP: 192.168.1.50
|
|
151
|
+
Port: 2222
|
|
152
|
+
User: deploy
|
|
153
|
+
Group: production
|
|
154
|
+
Password: ••••••••
|
|
155
|
+
SSH Key: (leave empty if using password)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Check **"Auto sudo"** if you want to automatically get a root shell after login.
|
|
159
|
+
|
|
160
|
+
**Step 4** - Press `ctrl+s` to save
|
|
161
|
+
|
|
162
|
+
**Step 5** - Select the server in the list and press `Enter` to connect
|
|
163
|
+
|
|
164
|
+
That's it. Next time, just `sshm` → arrow to your server → `Enter`.
|
|
165
|
+
|
|
166
|
+
## How-To: Use SSH key instead of password
|
|
167
|
+
|
|
168
|
+
**Step 1** - When adding/editing a server, leave the Password field empty
|
|
169
|
+
|
|
170
|
+
**Step 2** - Fill in the SSH Key Path field:
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
SSH Key Path: ~/.ssh/id_rsa
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**Step 3** - Save with `ctrl+s`
|
|
177
|
+
|
|
178
|
+
The app will use `ssh -i ~/.ssh/id_rsa` when connecting.
|
|
179
|
+
|
|
180
|
+
## How-To: Copy the SSH command
|
|
181
|
+
|
|
182
|
+
Select a server and press `c`. The raw SSH command gets copied to your clipboard:
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
ssh -p 2222 deploy@192.168.1.50
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Useful for scripts, sharing with teammates, or pasting into another terminal.
|
|
189
|
+
|
|
190
|
+
## How it works
|
|
191
|
+
|
|
192
|
+
| What | Where |
|
|
193
|
+
|-------------------|--------------------------------------------|
|
|
194
|
+
| Server metadata | `~/.sshm/servers.json` |
|
|
195
|
+
| Passwords | macOS Keychain (via `security` CLI) |
|
|
196
|
+
| SSH connection | `sshpass` if installed, else `expect` |
|
|
197
|
+
| Auto sudo | `expect` script (SSH login + `sudo -i`) |
|
|
198
|
+
|
|
199
|
+
## CLI Commands
|
|
200
|
+
|
|
201
|
+
```
|
|
202
|
+
sshm Open the TUI manager
|
|
203
|
+
sshm list List saved servers in the terminal
|
|
204
|
+
sshm help Show usage info
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Uninstall
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
# Remove the alias from ~/.zshrc
|
|
211
|
+
# Then:
|
|
212
|
+
rm -rf ~/.sshm # Remove server data
|
|
213
|
+
pip uninstall sshm # Remove the package
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Passwords stored in macOS Keychain can be removed via Keychain Access.app (search for "sshm").
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# SSHM - SSH Connection Manager
|
|
2
|
+
|
|
3
|
+
A terminal-based SSH connection manager for macOS with a Matrix-themed TUI. Store server credentials securely (passwords in macOS Keychain), and connect with a single keypress.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Interactive TUI** - Navigate servers with arrow keys, connect with Enter
|
|
12
|
+
- **Secure password storage** - Passwords saved in macOS Keychain (never plaintext)
|
|
13
|
+
- **Auto sudo** - Optionally elevate to root automatically after SSH login
|
|
14
|
+
- **Search/filter** - Quickly find servers by name, host, user, or group
|
|
15
|
+
- **Copy SSH command** - Copy the raw `ssh` command to clipboard
|
|
16
|
+
- **Matrix theme** - Green-on-black hacker aesthetic
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
### Option A: pipx (recommended)
|
|
21
|
+
|
|
22
|
+
One command, no manual venv or alias needed:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pipx install sshm-terminal
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
> Don't have pipx? Install it first: `brew install pipx && pipx ensurepath`
|
|
29
|
+
|
|
30
|
+
### Option B: Homebrew
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
brew tap dailydeploy365/tap https://github.com/dailydeploy365/homebrew-tap
|
|
34
|
+
brew install sshm
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Option C: From source (for development)
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
git clone https://github.com/dailydeploy365/sshm.git
|
|
41
|
+
cd sshm
|
|
42
|
+
python3 -m venv .venv
|
|
43
|
+
source .venv/bin/activate
|
|
44
|
+
pip install -e .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Then add an alias so `sshm` works from anywhere:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Add to ~/.zshrc
|
|
51
|
+
alias sshm="/path/to/sshm/.venv/bin/sshm"
|
|
52
|
+
source ~/.zshrc
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Optional: install sshpass for cleaner password auth
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
brew install esolitos/ipa/sshpass
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Without it, the app falls back to macOS built-in `expect` which works fine.
|
|
62
|
+
|
|
63
|
+
## Requirements
|
|
64
|
+
|
|
65
|
+
- macOS (uses `security` CLI for Keychain and `expect` for password automation)
|
|
66
|
+
- Python 3.10+
|
|
67
|
+
|
|
68
|
+
## Usage
|
|
69
|
+
|
|
70
|
+
### Launch the TUI
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
sshm
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Quick list from the terminal
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
sshm list
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### TUI Keybindings
|
|
83
|
+
|
|
84
|
+
| Key | Action |
|
|
85
|
+
|-----------|---------------------------------|
|
|
86
|
+
| `a` | Add a new server |
|
|
87
|
+
| `e` | Edit selected server |
|
|
88
|
+
| `d` | Delete selected server |
|
|
89
|
+
| `Enter` | SSH into selected server |
|
|
90
|
+
| `c` | Copy SSH command to clipboard |
|
|
91
|
+
| `/` | Search / filter servers |
|
|
92
|
+
| `Esc` | Close search, or quit |
|
|
93
|
+
| `q` | Quit |
|
|
94
|
+
|
|
95
|
+
### Add/Edit Form
|
|
96
|
+
|
|
97
|
+
| Key | Action |
|
|
98
|
+
|-----------|------------------|
|
|
99
|
+
| `ctrl+s` | Save |
|
|
100
|
+
| `Esc` | Cancel |
|
|
101
|
+
| `Tab` | Next field |
|
|
102
|
+
|
|
103
|
+
### Delete Confirmation
|
|
104
|
+
|
|
105
|
+
| Key | Action |
|
|
106
|
+
|---------------|-----------|
|
|
107
|
+
| `y` / `Enter` | Confirm |
|
|
108
|
+
| `n` / `Esc` | Cancel |
|
|
109
|
+
|
|
110
|
+
## How-To: Add and connect to a server
|
|
111
|
+
|
|
112
|
+
**Step 1** - Run `sshm`
|
|
113
|
+
|
|
114
|
+
**Step 2** - Press `a` to open the Add Server form
|
|
115
|
+
|
|
116
|
+
**Step 3** - Fill in the fields:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
Name: Production API
|
|
120
|
+
Host / IP: 192.168.1.50
|
|
121
|
+
Port: 2222
|
|
122
|
+
User: deploy
|
|
123
|
+
Group: production
|
|
124
|
+
Password: ••••••••
|
|
125
|
+
SSH Key: (leave empty if using password)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Check **"Auto sudo"** if you want to automatically get a root shell after login.
|
|
129
|
+
|
|
130
|
+
**Step 4** - Press `ctrl+s` to save
|
|
131
|
+
|
|
132
|
+
**Step 5** - Select the server in the list and press `Enter` to connect
|
|
133
|
+
|
|
134
|
+
That's it. Next time, just `sshm` → arrow to your server → `Enter`.
|
|
135
|
+
|
|
136
|
+
## How-To: Use SSH key instead of password
|
|
137
|
+
|
|
138
|
+
**Step 1** - When adding/editing a server, leave the Password field empty
|
|
139
|
+
|
|
140
|
+
**Step 2** - Fill in the SSH Key Path field:
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
SSH Key Path: ~/.ssh/id_rsa
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Step 3** - Save with `ctrl+s`
|
|
147
|
+
|
|
148
|
+
The app will use `ssh -i ~/.ssh/id_rsa` when connecting.
|
|
149
|
+
|
|
150
|
+
## How-To: Copy the SSH command
|
|
151
|
+
|
|
152
|
+
Select a server and press `c`. The raw SSH command gets copied to your clipboard:
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
ssh -p 2222 deploy@192.168.1.50
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Useful for scripts, sharing with teammates, or pasting into another terminal.
|
|
159
|
+
|
|
160
|
+
## How it works
|
|
161
|
+
|
|
162
|
+
| What | Where |
|
|
163
|
+
|-------------------|--------------------------------------------|
|
|
164
|
+
| Server metadata | `~/.sshm/servers.json` |
|
|
165
|
+
| Passwords | macOS Keychain (via `security` CLI) |
|
|
166
|
+
| SSH connection | `sshpass` if installed, else `expect` |
|
|
167
|
+
| Auto sudo | `expect` script (SSH login + `sudo -i`) |
|
|
168
|
+
|
|
169
|
+
## CLI Commands
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
sshm Open the TUI manager
|
|
173
|
+
sshm list List saved servers in the terminal
|
|
174
|
+
sshm help Show usage info
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Uninstall
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
# Remove the alias from ~/.zshrc
|
|
181
|
+
# Then:
|
|
182
|
+
rm -rf ~/.sshm # Remove server data
|
|
183
|
+
pip uninstall sshm # Remove the package
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Passwords stored in macOS Keychain can be removed via Keychain Access.app (search for "sshm").
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
class Sshm < Formula
|
|
2
|
+
include Language::Python::Virtualenv
|
|
3
|
+
|
|
4
|
+
desc "Terminal SSH connection manager with Matrix-themed TUI and macOS Keychain integration"
|
|
5
|
+
homepage "https://github.com/rajesh/sshm"
|
|
6
|
+
url "https://pypi.io/packages/source/s/sshm-terminal/sshm_terminal-1.0.0.tar.gz"
|
|
7
|
+
sha256 "2dbe18ce5210eeeb02202b78d3e5bc45c9685787feaeb03717f9a88cef1a9ddb"
|
|
8
|
+
license "MIT"
|
|
9
|
+
|
|
10
|
+
depends_on "python@3.13"
|
|
11
|
+
|
|
12
|
+
resource "textual" do
|
|
13
|
+
url "https://pypi.io/packages/source/t/textual/textual-8.2.4.tar.gz"
|
|
14
|
+
sha256 "PLACEHOLDER"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def install
|
|
18
|
+
virtualenv_install_with_resources
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
test do
|
|
22
|
+
assert_match "Usage", shell_output("#{bin}/sshm help")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# homebrew-tap
|
|
2
|
+
|
|
3
|
+
Homebrew formulae for SSHM - Terminal SSH Connection Manager.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
brew tap rajesh/tap https://github.com/rajesh/homebrew-tap
|
|
9
|
+
brew install sshm
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Update
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
brew upgrade sshm
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Uninstall
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
brew uninstall sshm
|
|
22
|
+
brew untap rajesh/tap
|
|
23
|
+
```
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sshm-terminal"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Terminal SSH connection manager with macOS Keychain integration and Matrix-themed TUI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Rajesh" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["ssh", "terminal", "tui", "connection-manager", "macos", "keychain"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Intended Audience :: System Administrators",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Operating System :: MacOS",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Programming Language :: Python :: 3.14",
|
|
29
|
+
"Topic :: System :: Networking",
|
|
30
|
+
"Topic :: System :: Systems Administration",
|
|
31
|
+
"Topic :: Terminals",
|
|
32
|
+
]
|
|
33
|
+
dependencies = [
|
|
34
|
+
"textual>=0.80.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
sshm = "sshm.app:main"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["sshm"]
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Homepage = "https://github.com/dailydeploy365/sshm"
|
|
45
|
+
Repository = "https://github.com/dailydeploy365/sshm"
|
|
46
|
+
Issues = "https://github.com/dailydeploy365/sshm/issues"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""sshm - Terminal SSH connection manager."""
|
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
"""SSH Manager - Terminal UI application."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
|
|
8
|
+
from textual import on
|
|
9
|
+
from textual.app import App, ComposeResult
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
12
|
+
from textual.screen import ModalScreen
|
|
13
|
+
from textual.widgets import Checkbox, DataTable, Footer, Input, Label, Static
|
|
14
|
+
|
|
15
|
+
from .storage import Server, Storage
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# SSH connection
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
def ssh_connect(server: Server, password: str):
|
|
23
|
+
"""Connect to a server via SSH. Auto-elevates to root when user is not root."""
|
|
24
|
+
base_args = ["-o", "StrictHostKeyChecking=accept-new", "-p", str(server.port)]
|
|
25
|
+
|
|
26
|
+
if server.sudo and password:
|
|
27
|
+
_ssh_with_sudo(server, password, base_args)
|
|
28
|
+
elif server.identity_file:
|
|
29
|
+
path = os.path.expanduser(server.identity_file)
|
|
30
|
+
cmd = ["ssh", "-i", path] + base_args + [f"{server.user}@{server.host}"]
|
|
31
|
+
subprocess.call(cmd)
|
|
32
|
+
elif password:
|
|
33
|
+
if shutil.which("sshpass"):
|
|
34
|
+
env = os.environ.copy()
|
|
35
|
+
env["SSHPASS"] = password
|
|
36
|
+
cmd = ["sshpass", "-e", "ssh"] + base_args + [f"{server.user}@{server.host}"]
|
|
37
|
+
subprocess.call(cmd, env=env)
|
|
38
|
+
else:
|
|
39
|
+
_ssh_via_expect(server, password, base_args)
|
|
40
|
+
else:
|
|
41
|
+
cmd = ["ssh"] + base_args + [f"{server.user}@{server.host}"]
|
|
42
|
+
subprocess.call(cmd)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _write_password_file(password: str) -> str:
|
|
46
|
+
"""Write password to a temp file readable only by owner. Returns path."""
|
|
47
|
+
pw_file = tempfile.NamedTemporaryFile(mode="w", suffix=".pw", delete=False)
|
|
48
|
+
pw_file.write(password)
|
|
49
|
+
pw_file.close()
|
|
50
|
+
os.chmod(pw_file.name, 0o400)
|
|
51
|
+
return pw_file.name
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _run_expect(script: str, pw_path: str):
|
|
55
|
+
"""Run an expect script and clean up the password file."""
|
|
56
|
+
try:
|
|
57
|
+
subprocess.call(["expect", "-c", script])
|
|
58
|
+
finally:
|
|
59
|
+
if os.path.exists(pw_path):
|
|
60
|
+
os.unlink(pw_path)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _ssh_via_expect(server: Server, password: str, base_args: list[str]):
|
|
64
|
+
"""Use macOS built-in expect to automate password entry."""
|
|
65
|
+
args_str = " ".join(base_args)
|
|
66
|
+
pw_path = _write_password_file(password)
|
|
67
|
+
|
|
68
|
+
key_arg = ""
|
|
69
|
+
if server.identity_file:
|
|
70
|
+
key_arg = f"-i {os.path.expanduser(server.identity_file)} "
|
|
71
|
+
|
|
72
|
+
script = f'''
|
|
73
|
+
set timeout 30
|
|
74
|
+
set f [open "{pw_path}" r]
|
|
75
|
+
set pw [read $f]
|
|
76
|
+
close $f
|
|
77
|
+
file delete "{pw_path}"
|
|
78
|
+
spawn ssh {key_arg}{args_str} {server.user}@{server.host}
|
|
79
|
+
expect {{
|
|
80
|
+
-re {{[Pp]assword:}} {{ send "$pw\\r"; interact }}
|
|
81
|
+
-re {{passphrase}} {{ send "$pw\\r"; interact }}
|
|
82
|
+
timeout {{ puts "Connection timed out"; exit 1 }}
|
|
83
|
+
}}
|
|
84
|
+
'''
|
|
85
|
+
_run_expect(script, pw_path)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _ssh_with_sudo(server: Server, password: str, base_args: list[str]):
|
|
89
|
+
"""SSH in and auto-elevate to root via sudo."""
|
|
90
|
+
args_str = " ".join(["-t"] + base_args)
|
|
91
|
+
pw_path = _write_password_file(password)
|
|
92
|
+
|
|
93
|
+
key_arg = ""
|
|
94
|
+
if server.identity_file:
|
|
95
|
+
key_arg = f"-i {os.path.expanduser(server.identity_file)} "
|
|
96
|
+
|
|
97
|
+
script = f'''
|
|
98
|
+
set timeout 30
|
|
99
|
+
set f [open "{pw_path}" r]
|
|
100
|
+
set pw [read $f]
|
|
101
|
+
close $f
|
|
102
|
+
file delete "{pw_path}"
|
|
103
|
+
spawn ssh {key_arg}{args_str} {server.user}@{server.host}
|
|
104
|
+
|
|
105
|
+
# Handle SSH password
|
|
106
|
+
expect {{
|
|
107
|
+
-re {{[Pp]assword:}} {{ send "$pw\\r" }}
|
|
108
|
+
-re {{passphrase}} {{ send "$pw\\r" }}
|
|
109
|
+
timeout {{ puts "SSH timed out"; exit 1 }}
|
|
110
|
+
}}
|
|
111
|
+
|
|
112
|
+
# Drain MOTD output until we see the shell prompt (user@host:...$ )
|
|
113
|
+
expect {{
|
|
114
|
+
-re {{@[^:]+:.*\\$ }} {{ }}
|
|
115
|
+
timeout {{ }}
|
|
116
|
+
}}
|
|
117
|
+
|
|
118
|
+
# Elevate to root
|
|
119
|
+
send "sudo -i\\r"
|
|
120
|
+
|
|
121
|
+
# Handle sudo password then hand over to user
|
|
122
|
+
expect {{
|
|
123
|
+
-re {{[Pp]assword}} {{ send "$pw\\r"; interact }}
|
|
124
|
+
-re {{root@}} {{ interact }}
|
|
125
|
+
-re {{# }} {{ interact }}
|
|
126
|
+
timeout {{ interact }}
|
|
127
|
+
}}
|
|
128
|
+
'''
|
|
129
|
+
_run_expect(script, pw_path)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Matrix color palette
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
C_BG = "#0d0208"
|
|
137
|
+
C_SURFACE = "#0a1a0a"
|
|
138
|
+
C_PANEL = "#0f1a0f"
|
|
139
|
+
C_GREEN_BRIGHT = "#00ff41"
|
|
140
|
+
C_GREEN_MID = "#00cc33"
|
|
141
|
+
C_GREEN_DIM = "#008f11"
|
|
142
|
+
C_GREEN_DARK = "#003b00"
|
|
143
|
+
C_GREEN_MUTED = "#337733"
|
|
144
|
+
C_RED = "#ff3333"
|
|
145
|
+
C_RED_DARK = "#661111"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Screens
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
class ServerForm(ModalScreen[Server | None]):
|
|
153
|
+
"""Modal form for adding or editing a server."""
|
|
154
|
+
|
|
155
|
+
CSS = f"""
|
|
156
|
+
ServerForm {{
|
|
157
|
+
align: center middle;
|
|
158
|
+
background: {C_BG} 80%;
|
|
159
|
+
}}
|
|
160
|
+
#form-box {{
|
|
161
|
+
width: 65;
|
|
162
|
+
max-height: 90%;
|
|
163
|
+
border: solid {C_GREEN_DIM};
|
|
164
|
+
background: {C_BG};
|
|
165
|
+
padding: 1 2;
|
|
166
|
+
}}
|
|
167
|
+
#form-fields {{
|
|
168
|
+
height: 1fr;
|
|
169
|
+
background: {C_BG};
|
|
170
|
+
}}
|
|
171
|
+
#form-title {{
|
|
172
|
+
text-align: center;
|
|
173
|
+
text-style: bold;
|
|
174
|
+
width: 100%;
|
|
175
|
+
color: {C_GREEN_BRIGHT};
|
|
176
|
+
}}
|
|
177
|
+
.field-label {{
|
|
178
|
+
margin-top: 0;
|
|
179
|
+
color: {C_GREEN_DIM};
|
|
180
|
+
}}
|
|
181
|
+
#form-box Input {{
|
|
182
|
+
width: 100%;
|
|
183
|
+
background: {C_SURFACE};
|
|
184
|
+
color: {C_GREEN_BRIGHT};
|
|
185
|
+
border: tall {C_GREEN_DARK};
|
|
186
|
+
}}
|
|
187
|
+
#form-box Input:focus {{
|
|
188
|
+
border: tall {C_GREEN_BRIGHT};
|
|
189
|
+
}}
|
|
190
|
+
#form-box Checkbox {{
|
|
191
|
+
background: transparent;
|
|
192
|
+
color: {C_GREEN_MID};
|
|
193
|
+
padding: 0 2;
|
|
194
|
+
}}
|
|
195
|
+
#form-box Checkbox:focus {{
|
|
196
|
+
color: {C_GREEN_BRIGHT};
|
|
197
|
+
}}
|
|
198
|
+
#form-hints {{
|
|
199
|
+
width: 100%;
|
|
200
|
+
height: 1;
|
|
201
|
+
content-align: center middle;
|
|
202
|
+
color: {C_GREEN_DIM};
|
|
203
|
+
margin-top: 1;
|
|
204
|
+
}}
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
BINDINGS = [
|
|
208
|
+
Binding("ctrl+s", "save", "Save", show=False),
|
|
209
|
+
Binding("escape", "cancel", "Cancel", show=False),
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
def __init__(self, server: Server | None = None, password: str = ""):
|
|
213
|
+
super().__init__()
|
|
214
|
+
self.server = server
|
|
215
|
+
self._password = password
|
|
216
|
+
|
|
217
|
+
def compose(self) -> ComposeResult:
|
|
218
|
+
editing = self.server is not None
|
|
219
|
+
s = self.server
|
|
220
|
+
|
|
221
|
+
with Vertical(id="form-box"):
|
|
222
|
+
with VerticalScroll(id="form-fields"):
|
|
223
|
+
yield Static(
|
|
224
|
+
f"[bold {C_GREEN_BRIGHT}]// {'Edit' if editing else 'New'} Server[/]",
|
|
225
|
+
id="form-title",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
yield Label("Name", classes="field-label")
|
|
229
|
+
yield Input(value=s.name if editing else "", placeholder="My Server", id="inp-name")
|
|
230
|
+
|
|
231
|
+
yield Label("Host / IP", classes="field-label")
|
|
232
|
+
yield Input(value=s.host if editing else "", placeholder="192.168.1.10", id="inp-host")
|
|
233
|
+
|
|
234
|
+
yield Label("Port", classes="field-label")
|
|
235
|
+
yield Input(value=str(s.port) if editing else "22", placeholder="22", id="inp-port")
|
|
236
|
+
|
|
237
|
+
yield Label("User", classes="field-label")
|
|
238
|
+
yield Input(value=s.user if editing else "root", placeholder="root", id="inp-user")
|
|
239
|
+
|
|
240
|
+
yield Label("Group (optional)", classes="field-label")
|
|
241
|
+
yield Input(value=s.group if editing else "", placeholder="production", id="inp-group")
|
|
242
|
+
|
|
243
|
+
yield Label("Password", classes="field-label")
|
|
244
|
+
yield Input(value=self._password, placeholder="Leave empty for key auth", password=True, id="inp-pass")
|
|
245
|
+
|
|
246
|
+
yield Label("SSH Key Path (optional)", classes="field-label")
|
|
247
|
+
yield Input(value=s.identity_file if editing else "", placeholder="~/.ssh/id_rsa", id="inp-key")
|
|
248
|
+
|
|
249
|
+
yield Checkbox("Auto sudo (elevate to root)", s.sudo if editing else False, id="inp-sudo")
|
|
250
|
+
|
|
251
|
+
yield Static(
|
|
252
|
+
f"[bold {C_GREEN_BRIGHT}]ctrl+s[/] save "
|
|
253
|
+
f"[{C_GREEN_DARK}]|[/] "
|
|
254
|
+
f"[bold {C_GREEN_BRIGHT}]esc[/] cancel",
|
|
255
|
+
id="form-hints",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def action_save(self) -> None:
|
|
259
|
+
name = self.query_one("#inp-name", Input).value.strip()
|
|
260
|
+
host = self.query_one("#inp-host", Input).value.strip()
|
|
261
|
+
port_s = self.query_one("#inp-port", Input).value.strip()
|
|
262
|
+
user = self.query_one("#inp-user", Input).value.strip() or "root"
|
|
263
|
+
group = self.query_one("#inp-group", Input).value.strip()
|
|
264
|
+
password = self.query_one("#inp-pass", Input).value
|
|
265
|
+
key = self.query_one("#inp-key", Input).value.strip()
|
|
266
|
+
sudo = self.query_one("#inp-sudo", Checkbox).value
|
|
267
|
+
|
|
268
|
+
if not name or not host:
|
|
269
|
+
self.notify("Name and Host are required.", severity="error")
|
|
270
|
+
return
|
|
271
|
+
try:
|
|
272
|
+
port = int(port_s) if port_s else 22
|
|
273
|
+
except ValueError:
|
|
274
|
+
self.notify("Port must be a number.", severity="error")
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
if self.server:
|
|
278
|
+
self.server.name = name
|
|
279
|
+
self.server.host = host
|
|
280
|
+
self.server.port = port
|
|
281
|
+
self.server.user = user
|
|
282
|
+
self.server.group = group
|
|
283
|
+
self.server.identity_file = key
|
|
284
|
+
self.server.sudo = sudo
|
|
285
|
+
server = self.server
|
|
286
|
+
else:
|
|
287
|
+
server = Server(name=name, host=host, port=port, user=user, group=group, identity_file=key, sudo=sudo)
|
|
288
|
+
|
|
289
|
+
self.app.storage.set_password(server.id, password)
|
|
290
|
+
self.dismiss(server)
|
|
291
|
+
|
|
292
|
+
def action_cancel(self) -> None:
|
|
293
|
+
self.dismiss(None)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class ConfirmDelete(ModalScreen[bool]):
|
|
297
|
+
"""Confirmation dialog for server deletion."""
|
|
298
|
+
|
|
299
|
+
CSS = f"""
|
|
300
|
+
ConfirmDelete {{
|
|
301
|
+
align: center middle;
|
|
302
|
+
background: {C_BG} 80%;
|
|
303
|
+
}}
|
|
304
|
+
#del-box {{
|
|
305
|
+
width: 50;
|
|
306
|
+
height: auto;
|
|
307
|
+
border: solid {C_RED};
|
|
308
|
+
background: {C_BG};
|
|
309
|
+
padding: 1 2;
|
|
310
|
+
}}
|
|
311
|
+
#del-msg {{
|
|
312
|
+
text-align: center;
|
|
313
|
+
width: 100%;
|
|
314
|
+
margin: 1 0;
|
|
315
|
+
color: {C_RED};
|
|
316
|
+
}}
|
|
317
|
+
#del-hints {{
|
|
318
|
+
text-align: center;
|
|
319
|
+
width: 100%;
|
|
320
|
+
margin-top: 1;
|
|
321
|
+
color: {C_GREEN_DIM};
|
|
322
|
+
}}
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
BINDINGS = [
|
|
326
|
+
Binding("y", "confirm", "Yes", show=False),
|
|
327
|
+
Binding("enter", "confirm", "Confirm", show=False),
|
|
328
|
+
Binding("n", "deny", "No", show=False),
|
|
329
|
+
Binding("escape", "deny", "Cancel", show=False),
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
def __init__(self, server_name: str):
|
|
333
|
+
super().__init__()
|
|
334
|
+
self.server_name = server_name
|
|
335
|
+
|
|
336
|
+
def compose(self) -> ComposeResult:
|
|
337
|
+
with Vertical(id="del-box"):
|
|
338
|
+
yield Static(
|
|
339
|
+
f"[bold {C_RED}]// WARNING[/]\n"
|
|
340
|
+
f"[{C_RED}]Delete '[bold]{self.server_name}[/bold]' ?[/]",
|
|
341
|
+
id="del-msg",
|
|
342
|
+
)
|
|
343
|
+
yield Static(
|
|
344
|
+
f"[bold {C_GREEN_BRIGHT}]y[/] / [bold {C_GREEN_BRIGHT}]enter[/] confirm "
|
|
345
|
+
f"[{C_GREEN_DARK}]|[/] "
|
|
346
|
+
f"[bold {C_GREEN_BRIGHT}]n[/] / [bold {C_GREEN_BRIGHT}]esc[/] cancel",
|
|
347
|
+
id="del-hints",
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def action_confirm(self) -> None:
|
|
351
|
+
self.dismiss(True)
|
|
352
|
+
|
|
353
|
+
def action_deny(self) -> None:
|
|
354
|
+
self.dismiss(False)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
# Main App
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
class SSHManagerApp(App):
|
|
362
|
+
"""Terminal SSH connection manager."""
|
|
363
|
+
|
|
364
|
+
TITLE = "SSHM"
|
|
365
|
+
|
|
366
|
+
CSS = f"""
|
|
367
|
+
Screen {{
|
|
368
|
+
background: {C_BG};
|
|
369
|
+
}}
|
|
370
|
+
|
|
371
|
+
/* ---- Banner ---- */
|
|
372
|
+
#banner {{
|
|
373
|
+
dock: top;
|
|
374
|
+
width: 100%;
|
|
375
|
+
height: 3;
|
|
376
|
+
background: {C_SURFACE};
|
|
377
|
+
content-align: center middle;
|
|
378
|
+
border-bottom: solid {C_GREEN_DARK};
|
|
379
|
+
}}
|
|
380
|
+
|
|
381
|
+
/* ---- Search ---- */
|
|
382
|
+
#search-bar {{
|
|
383
|
+
dock: top;
|
|
384
|
+
height: 3;
|
|
385
|
+
display: none;
|
|
386
|
+
padding: 0 1;
|
|
387
|
+
background: {C_SURFACE};
|
|
388
|
+
}}
|
|
389
|
+
#search-bar.visible {{
|
|
390
|
+
display: block;
|
|
391
|
+
}}
|
|
392
|
+
#search-input {{
|
|
393
|
+
background: {C_BG};
|
|
394
|
+
color: {C_GREEN_BRIGHT};
|
|
395
|
+
border: tall {C_GREEN_DARK};
|
|
396
|
+
}}
|
|
397
|
+
#search-input:focus {{
|
|
398
|
+
border: tall {C_GREEN_BRIGHT};
|
|
399
|
+
}}
|
|
400
|
+
|
|
401
|
+
/* ---- Server table ---- */
|
|
402
|
+
DataTable {{
|
|
403
|
+
height: 1fr;
|
|
404
|
+
background: {C_BG};
|
|
405
|
+
color: {C_GREEN_MID};
|
|
406
|
+
}}
|
|
407
|
+
DataTable > .datatable--cursor {{
|
|
408
|
+
background: {C_GREEN_DIM};
|
|
409
|
+
color: {C_BG};
|
|
410
|
+
text-style: bold;
|
|
411
|
+
}}
|
|
412
|
+
DataTable > .datatable--header {{
|
|
413
|
+
background: {C_SURFACE};
|
|
414
|
+
color: {C_GREEN_BRIGHT};
|
|
415
|
+
text-style: bold;
|
|
416
|
+
}}
|
|
417
|
+
DataTable > .datatable--even-row {{
|
|
418
|
+
background: {C_BG};
|
|
419
|
+
}}
|
|
420
|
+
DataTable > .datatable--odd-row {{
|
|
421
|
+
background: {C_PANEL};
|
|
422
|
+
}}
|
|
423
|
+
|
|
424
|
+
/* ---- Empty state ---- */
|
|
425
|
+
#empty-hint {{
|
|
426
|
+
width: 100%;
|
|
427
|
+
height: 100%;
|
|
428
|
+
content-align: center middle;
|
|
429
|
+
color: {C_GREEN_MUTED};
|
|
430
|
+
}}
|
|
431
|
+
#empty-hint.hidden {{
|
|
432
|
+
display: none;
|
|
433
|
+
}}
|
|
434
|
+
|
|
435
|
+
/* ---- Footer ---- */
|
|
436
|
+
Footer {{
|
|
437
|
+
background: {C_SURFACE};
|
|
438
|
+
}}
|
|
439
|
+
Footer > .footer--key {{
|
|
440
|
+
background: {C_GREEN_DARK};
|
|
441
|
+
color: {C_GREEN_BRIGHT};
|
|
442
|
+
}}
|
|
443
|
+
Footer > .footer--description {{
|
|
444
|
+
color: {C_GREEN_DIM};
|
|
445
|
+
}}
|
|
446
|
+
FooterKey > .footer--key {{
|
|
447
|
+
background: {C_GREEN_DARK};
|
|
448
|
+
color: {C_GREEN_BRIGHT};
|
|
449
|
+
}}
|
|
450
|
+
FooterKey > .footer--description {{
|
|
451
|
+
color: {C_GREEN_DIM};
|
|
452
|
+
}}
|
|
453
|
+
"""
|
|
454
|
+
|
|
455
|
+
BINDINGS = [
|
|
456
|
+
Binding("a", "add_server", "Add"),
|
|
457
|
+
Binding("e", "edit_server", "Edit"),
|
|
458
|
+
Binding("d", "delete_server", "Delete"),
|
|
459
|
+
Binding("c", "copy_cmd", "Copy SSH"),
|
|
460
|
+
Binding("slash", "toggle_search", "Search"),
|
|
461
|
+
Binding("escape", "esc_pressed", "Quit", show=False),
|
|
462
|
+
Binding("q", "quit", "Quit"),
|
|
463
|
+
]
|
|
464
|
+
|
|
465
|
+
def __init__(self):
|
|
466
|
+
super().__init__()
|
|
467
|
+
self.storage = Storage()
|
|
468
|
+
self._filter = ""
|
|
469
|
+
self._visible: list[Server] = []
|
|
470
|
+
|
|
471
|
+
def compose(self) -> ComposeResult:
|
|
472
|
+
yield Static(
|
|
473
|
+
f"[bold {C_GREEN_BRIGHT}]>_[/] "
|
|
474
|
+
f"[bold {C_GREEN_BRIGHT}]SSHM[/] "
|
|
475
|
+
f"[{C_GREEN_DARK}]::[/] "
|
|
476
|
+
f"[{C_GREEN_DIM}]SSH Connection Manager[/]",
|
|
477
|
+
id="banner",
|
|
478
|
+
)
|
|
479
|
+
with Vertical(id="search-bar"):
|
|
480
|
+
yield Input(placeholder="// filter servers ...", id="search-input")
|
|
481
|
+
yield DataTable(id="server-table")
|
|
482
|
+
yield Static(
|
|
483
|
+
f"[{C_GREEN_MUTED}]> No servers in the matrix. "
|
|
484
|
+
f"Press [{C_GREEN_BRIGHT}]a[/{C_GREEN_BRIGHT}] to add one.[/]",
|
|
485
|
+
id="empty-hint",
|
|
486
|
+
)
|
|
487
|
+
yield Footer()
|
|
488
|
+
|
|
489
|
+
def on_mount(self) -> None:
|
|
490
|
+
table = self.query_one(DataTable)
|
|
491
|
+
table.add_columns("Name", "Host", "User", "Port", "Group")
|
|
492
|
+
table.cursor_type = "row"
|
|
493
|
+
table.zebra_stripes = True
|
|
494
|
+
self._refresh_table()
|
|
495
|
+
|
|
496
|
+
# --- table helpers ---
|
|
497
|
+
|
|
498
|
+
def _refresh_table(self) -> None:
|
|
499
|
+
table = self.query_one(DataTable)
|
|
500
|
+
table.clear()
|
|
501
|
+
|
|
502
|
+
servers = self.storage.servers
|
|
503
|
+
if self._filter:
|
|
504
|
+
ft = self._filter.lower()
|
|
505
|
+
servers = [
|
|
506
|
+
s for s in servers
|
|
507
|
+
if ft in s.name.lower()
|
|
508
|
+
or ft in s.host.lower()
|
|
509
|
+
or ft in s.user.lower()
|
|
510
|
+
or ft in s.group.lower()
|
|
511
|
+
]
|
|
512
|
+
|
|
513
|
+
self._visible = list(servers)
|
|
514
|
+
for s in servers:
|
|
515
|
+
table.add_row(s.name, s.host, s.user, str(s.port), s.group or "-", key=s.id)
|
|
516
|
+
|
|
517
|
+
hint = self.query_one("#empty-hint")
|
|
518
|
+
if servers:
|
|
519
|
+
hint.add_class("hidden")
|
|
520
|
+
else:
|
|
521
|
+
hint.remove_class("hidden")
|
|
522
|
+
|
|
523
|
+
def _selected_server(self) -> Server | None:
|
|
524
|
+
table = self.query_one(DataTable)
|
|
525
|
+
if table.row_count == 0:
|
|
526
|
+
return None
|
|
527
|
+
idx = table.cursor_coordinate.row
|
|
528
|
+
if 0 <= idx < len(self._visible):
|
|
529
|
+
return self._visible[idx]
|
|
530
|
+
return None
|
|
531
|
+
|
|
532
|
+
# --- actions ---
|
|
533
|
+
|
|
534
|
+
def action_add_server(self) -> None:
|
|
535
|
+
def on_result(server: Server | None) -> None:
|
|
536
|
+
if server:
|
|
537
|
+
self.storage.add_server(server)
|
|
538
|
+
self._refresh_table()
|
|
539
|
+
self.notify(f"Added '{server.name}'")
|
|
540
|
+
|
|
541
|
+
self.push_screen(ServerForm(), callback=on_result)
|
|
542
|
+
|
|
543
|
+
def action_edit_server(self) -> None:
|
|
544
|
+
server = self._selected_server()
|
|
545
|
+
if not server:
|
|
546
|
+
self.notify("No server selected", severity="warning")
|
|
547
|
+
return
|
|
548
|
+
password = self.storage.get_password(server.id)
|
|
549
|
+
|
|
550
|
+
def on_result(updated: Server | None) -> None:
|
|
551
|
+
if updated:
|
|
552
|
+
self.storage.update_server(updated)
|
|
553
|
+
self._refresh_table()
|
|
554
|
+
self.notify(f"Updated '{updated.name}'")
|
|
555
|
+
|
|
556
|
+
self.push_screen(ServerForm(server=server, password=password), callback=on_result)
|
|
557
|
+
|
|
558
|
+
def action_delete_server(self) -> None:
|
|
559
|
+
server = self._selected_server()
|
|
560
|
+
if not server:
|
|
561
|
+
self.notify("No server selected", severity="warning")
|
|
562
|
+
return
|
|
563
|
+
|
|
564
|
+
def on_confirm(confirmed: bool) -> None:
|
|
565
|
+
if confirmed:
|
|
566
|
+
self.storage.delete_server(server.id)
|
|
567
|
+
self._refresh_table()
|
|
568
|
+
self.notify(f"Deleted '{server.name}'")
|
|
569
|
+
|
|
570
|
+
self.push_screen(ConfirmDelete(server.name), callback=on_confirm)
|
|
571
|
+
|
|
572
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
573
|
+
"""Connect on Enter / double-click."""
|
|
574
|
+
server_id = str(event.row_key.value)
|
|
575
|
+
server = self.storage.get_server(server_id)
|
|
576
|
+
if not server:
|
|
577
|
+
return
|
|
578
|
+
password = self.storage.get_password(server.id)
|
|
579
|
+
with self.suspend():
|
|
580
|
+
ssh_connect(server, password)
|
|
581
|
+
|
|
582
|
+
def action_copy_cmd(self) -> None:
|
|
583
|
+
server = self._selected_server()
|
|
584
|
+
if not server:
|
|
585
|
+
self.notify("No server selected", severity="warning")
|
|
586
|
+
return
|
|
587
|
+
parts = ["ssh"]
|
|
588
|
+
if server.identity_file:
|
|
589
|
+
parts += ["-i", server.identity_file]
|
|
590
|
+
parts += ["-p", str(server.port), f"{server.user}@{server.host}"]
|
|
591
|
+
cmd = " ".join(parts)
|
|
592
|
+
subprocess.run(["pbcopy"], input=cmd.encode(), check=False)
|
|
593
|
+
self.notify(f"Copied: {cmd}")
|
|
594
|
+
|
|
595
|
+
# --- search ---
|
|
596
|
+
|
|
597
|
+
def action_toggle_search(self) -> None:
|
|
598
|
+
bar = self.query_one("#search-bar")
|
|
599
|
+
if bar.has_class("visible"):
|
|
600
|
+
self._close_search()
|
|
601
|
+
else:
|
|
602
|
+
bar.add_class("visible")
|
|
603
|
+
self.query_one("#search-input", Input).focus()
|
|
604
|
+
|
|
605
|
+
def action_esc_pressed(self) -> None:
|
|
606
|
+
bar = self.query_one("#search-bar")
|
|
607
|
+
if bar.has_class("visible"):
|
|
608
|
+
self._close_search()
|
|
609
|
+
else:
|
|
610
|
+
self.exit()
|
|
611
|
+
|
|
612
|
+
def _close_search(self) -> None:
|
|
613
|
+
self.query_one("#search-bar").remove_class("visible")
|
|
614
|
+
self.query_one("#search-input", Input).value = ""
|
|
615
|
+
self._filter = ""
|
|
616
|
+
self._refresh_table()
|
|
617
|
+
self.query_one(DataTable).focus()
|
|
618
|
+
|
|
619
|
+
@on(Input.Changed, "#search-input")
|
|
620
|
+
def _on_search(self, event: Input.Changed) -> None:
|
|
621
|
+
self._filter = event.value
|
|
622
|
+
self._refresh_table()
|
|
623
|
+
|
|
624
|
+
@on(Input.Submitted, "#search-input")
|
|
625
|
+
def _on_search_submit(self) -> None:
|
|
626
|
+
self.query_one(DataTable).focus()
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def cmd_list():
|
|
630
|
+
"""Print saved servers to stdout."""
|
|
631
|
+
storage = Storage()
|
|
632
|
+
if not storage.servers:
|
|
633
|
+
print(f"\033[32m> No servers saved. Run `sshm` to add one.\033[0m")
|
|
634
|
+
return
|
|
635
|
+
rows = [(s.name, s.host, s.user, str(s.port), s.group or "-") for s in storage.servers]
|
|
636
|
+
headers = ("NAME", "HOST", "USER", "PORT", "GROUP")
|
|
637
|
+
widths = [max(len(h), *(len(r[i]) for r in rows)) for i, h in enumerate(headers)]
|
|
638
|
+
fmt = " ".join(f"{{:<{w}}}" for w in widths)
|
|
639
|
+
print(f"\033[1;32m{fmt.format(*headers)}\033[0m")
|
|
640
|
+
print(f"\033[32m{fmt.format(*('─' * w for w in widths))}\033[0m")
|
|
641
|
+
for r in rows:
|
|
642
|
+
print(f"\033[32m{fmt.format(*r)}\033[0m")
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def main():
|
|
646
|
+
import sys
|
|
647
|
+
|
|
648
|
+
if len(sys.argv) > 1:
|
|
649
|
+
cmd = sys.argv[1]
|
|
650
|
+
if cmd == "list":
|
|
651
|
+
cmd_list()
|
|
652
|
+
elif cmd == "help":
|
|
653
|
+
print("\033[1;32m> SSHM\033[0m \033[32m:: SSH Connection Manager\033[0m")
|
|
654
|
+
print()
|
|
655
|
+
print("\033[32mUsage: sshm [command]\033[0m")
|
|
656
|
+
print()
|
|
657
|
+
print("\033[32m (none) Open the TUI manager\033[0m")
|
|
658
|
+
print("\033[32m list List saved servers\033[0m")
|
|
659
|
+
print("\033[32m help Show this help\033[0m")
|
|
660
|
+
else:
|
|
661
|
+
print(f"\033[31mUnknown command: {cmd}\033[0m")
|
|
662
|
+
print("\033[32mRun `sshm help` for usage.\033[0m")
|
|
663
|
+
sys.exit(1)
|
|
664
|
+
return
|
|
665
|
+
|
|
666
|
+
app = SSHManagerApp()
|
|
667
|
+
app.run()
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
if __name__ == "__main__":
|
|
671
|
+
main()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Server storage with JSON persistence and macOS Keychain for passwords."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
DATA_DIR = Path.home() / ".sshm"
|
|
10
|
+
SERVERS_FILE = DATA_DIR / "servers.json"
|
|
11
|
+
KEYCHAIN_SERVICE = "sshm"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Server:
|
|
16
|
+
name: str
|
|
17
|
+
host: str
|
|
18
|
+
user: str = "root"
|
|
19
|
+
port: int = 22
|
|
20
|
+
group: str = ""
|
|
21
|
+
identity_file: str = ""
|
|
22
|
+
sudo: bool = False
|
|
23
|
+
id: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict:
|
|
26
|
+
return asdict(self)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_dict(cls, data: dict) -> "Server":
|
|
30
|
+
known = {f for f in cls.__dataclass_fields__}
|
|
31
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Storage:
|
|
35
|
+
def __init__(self):
|
|
36
|
+
DATA_DIR.mkdir(exist_ok=True)
|
|
37
|
+
self.servers: list[Server] = []
|
|
38
|
+
self.load()
|
|
39
|
+
|
|
40
|
+
def load(self):
|
|
41
|
+
if SERVERS_FILE.exists():
|
|
42
|
+
data = json.loads(SERVERS_FILE.read_text())
|
|
43
|
+
self.servers = [Server.from_dict(s) for s in data]
|
|
44
|
+
else:
|
|
45
|
+
self.servers = []
|
|
46
|
+
|
|
47
|
+
def save(self):
|
|
48
|
+
data = [s.to_dict() for s in self.servers]
|
|
49
|
+
SERVERS_FILE.write_text(json.dumps(data, indent=2))
|
|
50
|
+
|
|
51
|
+
def add_server(self, server: Server):
|
|
52
|
+
self.servers.append(server)
|
|
53
|
+
self.save()
|
|
54
|
+
|
|
55
|
+
def update_server(self, server: Server):
|
|
56
|
+
for i, s in enumerate(self.servers):
|
|
57
|
+
if s.id == server.id:
|
|
58
|
+
self.servers[i] = server
|
|
59
|
+
break
|
|
60
|
+
self.save()
|
|
61
|
+
|
|
62
|
+
def delete_server(self, server_id: str):
|
|
63
|
+
self.servers = [s for s in self.servers if s.id != server_id]
|
|
64
|
+
self.save()
|
|
65
|
+
self.delete_password(server_id)
|
|
66
|
+
|
|
67
|
+
def get_server(self, server_id: str) -> Server | None:
|
|
68
|
+
for s in self.servers:
|
|
69
|
+
if s.id == server_id:
|
|
70
|
+
return s
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
# --- macOS Keychain via `security` CLI ---
|
|
74
|
+
|
|
75
|
+
def get_password(self, server_id: str) -> str:
|
|
76
|
+
try:
|
|
77
|
+
result = subprocess.run(
|
|
78
|
+
[
|
|
79
|
+
"security", "find-generic-password",
|
|
80
|
+
"-a", KEYCHAIN_SERVICE,
|
|
81
|
+
"-s", server_id,
|
|
82
|
+
"-w",
|
|
83
|
+
],
|
|
84
|
+
capture_output=True,
|
|
85
|
+
text=True,
|
|
86
|
+
)
|
|
87
|
+
if result.returncode == 0:
|
|
88
|
+
return result.stdout.strip()
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
return ""
|
|
92
|
+
|
|
93
|
+
def set_password(self, server_id: str, password: str):
|
|
94
|
+
self.delete_password(server_id)
|
|
95
|
+
if password:
|
|
96
|
+
subprocess.run(
|
|
97
|
+
[
|
|
98
|
+
"security", "add-generic-password",
|
|
99
|
+
"-a", KEYCHAIN_SERVICE,
|
|
100
|
+
"-s", server_id,
|
|
101
|
+
"-w", password,
|
|
102
|
+
],
|
|
103
|
+
capture_output=True,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def delete_password(self, server_id: str):
|
|
107
|
+
subprocess.run(
|
|
108
|
+
[
|
|
109
|
+
"security", "delete-generic-password",
|
|
110
|
+
"-a", KEYCHAIN_SERVICE,
|
|
111
|
+
"-s", server_id,
|
|
112
|
+
],
|
|
113
|
+
capture_output=True,
|
|
114
|
+
)
|