sshplex 1.3.1__tar.gz → 1.4.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.
- {sshplex-1.3.1/sshplex.egg-info → sshplex-1.4.0}/PKG-INFO +14 -3
- {sshplex-1.3.1 → sshplex-1.4.0}/README.md +13 -2
- {sshplex-1.3.1 → sshplex-1.4.0}/pyproject.toml +1 -1
- sshplex-1.4.0/sshplex/lib/onboarding/__init__.py +5 -0
- sshplex-1.4.0/sshplex/lib/onboarding/wizard.py +482 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/sot/factory.py +23 -2
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/main.py +23 -1
- {sshplex-1.3.1 → sshplex-1.4.0/sshplex.egg-info}/PKG-INFO +14 -3
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex.egg-info/SOURCES.txt +2 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/LICENSE +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/MANIFEST.in +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/setup.cfg +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/__init__.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/cli.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/config-template.yaml +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/__init__.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/cache.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/config.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/logger.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/multiplexer/__init__.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/multiplexer/base.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/multiplexer/tmux.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/sot/__init__.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/sot/ansible.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/sot/base.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/sot/consul.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/sot/netbox.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/sot/static.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/ui/__init__.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/ui/config_editor.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/ui/host_selector.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/ui/session_manager.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/sshplex_connector.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex.egg-info/dependency_links.txt +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex.egg-info/entry_points.txt +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex.egg-info/requires.txt +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/sshplex.egg-info/top_level.txt +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/tests/test_cache.py +0 -0
- {sshplex-1.3.1 → sshplex-1.4.0}/tests/test_config.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sshplex
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Multiplex your SSH connections with style
|
|
5
5
|
Author-email: MJAHED Sabri <contact@sabrimjahed.com>
|
|
6
6
|
License: MIT
|
|
@@ -121,7 +121,10 @@ pip install -e ".[dev]"
|
|
|
121
121
|
## Quick Start
|
|
122
122
|
|
|
123
123
|
```bash
|
|
124
|
-
#
|
|
124
|
+
# First-time setup - interactive onboarding wizard
|
|
125
|
+
sshplex --onboarding
|
|
126
|
+
|
|
127
|
+
# Launch TUI
|
|
125
128
|
sshplex
|
|
126
129
|
|
|
127
130
|
# Debug mode - test provider connectivity
|
|
@@ -134,7 +137,15 @@ sshplex --show-config
|
|
|
134
137
|
sshplex --clear-cache
|
|
135
138
|
```
|
|
136
139
|
|
|
137
|
-
|
|
140
|
+
### First-Time Setup
|
|
141
|
+
|
|
142
|
+
Run `sshplex --onboarding` for an interactive setup wizard that will:
|
|
143
|
+
- Auto-detect your SSH keys and system dependencies
|
|
144
|
+
- Guide you through configuring inventory sources (NetBox, Ansible, Consul, or static hosts)
|
|
145
|
+
- Test connections before saving
|
|
146
|
+
- Generate a working configuration file
|
|
147
|
+
|
|
148
|
+
On first run without `--onboarding`, SSHplex creates a default config at `~/.config/sshplex/sshplex.yaml`. Edit it with your provider details, or use the built-in config editor (`e` key in TUI).
|
|
138
149
|
|
|
139
150
|
## What's New (Quality Upgrade)
|
|
140
151
|
|
|
@@ -70,7 +70,10 @@ pip install -e ".[dev]"
|
|
|
70
70
|
## Quick Start
|
|
71
71
|
|
|
72
72
|
```bash
|
|
73
|
-
#
|
|
73
|
+
# First-time setup - interactive onboarding wizard
|
|
74
|
+
sshplex --onboarding
|
|
75
|
+
|
|
76
|
+
# Launch TUI
|
|
74
77
|
sshplex
|
|
75
78
|
|
|
76
79
|
# Debug mode - test provider connectivity
|
|
@@ -83,7 +86,15 @@ sshplex --show-config
|
|
|
83
86
|
sshplex --clear-cache
|
|
84
87
|
```
|
|
85
88
|
|
|
86
|
-
|
|
89
|
+
### First-Time Setup
|
|
90
|
+
|
|
91
|
+
Run `sshplex --onboarding` for an interactive setup wizard that will:
|
|
92
|
+
- Auto-detect your SSH keys and system dependencies
|
|
93
|
+
- Guide you through configuring inventory sources (NetBox, Ansible, Consul, or static hosts)
|
|
94
|
+
- Test connections before saving
|
|
95
|
+
- Generate a working configuration file
|
|
96
|
+
|
|
97
|
+
On first run without `--onboarding`, SSHplex creates a default config at `~/.config/sshplex/sshplex.yaml`. Edit it with your provider details, or use the built-in config editor (`e` key in TUI).
|
|
87
98
|
|
|
88
99
|
## What's New (Quality Upgrade)
|
|
89
100
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sshplex"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.4.0"
|
|
8
8
|
description = "Multiplex your SSH connections with style"
|
|
9
9
|
authors = [{name = "MJAHED Sabri", email = "contact@sabrimjahed.com"}]
|
|
10
10
|
readme = "README.md"
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""Interactive onboarding wizard for SSHplex."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.prompt import Confirm, Prompt
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from ..config import Config
|
|
16
|
+
from ..logger import get_logger
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OnboardingWizard:
|
|
20
|
+
"""Interactive first-run setup wizard for SSHplex."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config_path: Optional[Path] = None):
|
|
23
|
+
"""Initialize the onboarding wizard.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
config_path: Optional custom config path
|
|
27
|
+
"""
|
|
28
|
+
self.console = Console()
|
|
29
|
+
self.logger = get_logger()
|
|
30
|
+
self.config_path = config_path or Path.home() / ".config" / "sshplex" / "sshplex.yaml"
|
|
31
|
+
self.detected_info: Dict[str, Any] = {}
|
|
32
|
+
self.providers: List[Dict[str, Any]] = []
|
|
33
|
+
|
|
34
|
+
def run(self) -> bool:
|
|
35
|
+
"""Run the onboarding wizard.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
True if onboarding completed successfully, False otherwise
|
|
39
|
+
"""
|
|
40
|
+
self.logger.info("Starting onboarding wizard")
|
|
41
|
+
|
|
42
|
+
# Show welcome screen
|
|
43
|
+
self._show_welcome()
|
|
44
|
+
|
|
45
|
+
# Detect system environment
|
|
46
|
+
self._detect_environment()
|
|
47
|
+
|
|
48
|
+
# Check if config already exists
|
|
49
|
+
if self.config_path.exists() and not Confirm.ask(
|
|
50
|
+
"\n⚠️ Configuration file already exists. Overwrite?", default=False
|
|
51
|
+
):
|
|
52
|
+
self.console.print("\n[yellow]Onboarding cancelled. Existing configuration preserved.[/yellow]")
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
# Collect provider configurations
|
|
56
|
+
self._collect_providers()
|
|
57
|
+
|
|
58
|
+
if not self.providers:
|
|
59
|
+
self.console.print("\n[red]❌ At least one inventory source is required to use SSHplex.[/red]")
|
|
60
|
+
self.console.print("[yellow]Please run the wizard again and configure at least one provider.[/yellow]")
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
# Generate configuration
|
|
64
|
+
config = self._generate_config()
|
|
65
|
+
|
|
66
|
+
# Save configuration
|
|
67
|
+
if self._save_config(config):
|
|
68
|
+
self._show_success()
|
|
69
|
+
return True
|
|
70
|
+
else:
|
|
71
|
+
self.console.print("\n[red]❌ Failed to save configuration[/red]")
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
def _show_welcome(self) -> None:
|
|
75
|
+
"""Display welcome screen."""
|
|
76
|
+
welcome_text = Text()
|
|
77
|
+
welcome_text.append("SSHplex Setup Wizard", style="bold blue")
|
|
78
|
+
welcome_text.append("\n\n")
|
|
79
|
+
welcome_text.append("Let's configure SSHplex for your environment.")
|
|
80
|
+
welcome_text.append("\nThis wizard will help you:")
|
|
81
|
+
welcome_text.append("\n • Detect SSH keys and system dependencies")
|
|
82
|
+
welcome_text.append("\n • Configure inventory sources (NetBox, Ansible, Consul, etc.)")
|
|
83
|
+
welcome_text.append("\n • Test connections before saving")
|
|
84
|
+
welcome_text.append("\n • Generate a working configuration file")
|
|
85
|
+
|
|
86
|
+
panel = Panel(welcome_text, border_style="blue", padding=(1, 2))
|
|
87
|
+
self.console.print(panel)
|
|
88
|
+
self.console.print()
|
|
89
|
+
|
|
90
|
+
def _detect_environment(self) -> None:
|
|
91
|
+
"""Detect system environment and dependencies."""
|
|
92
|
+
self.console.print("\n🔍 [bold]Detecting Environment[/bold]\n")
|
|
93
|
+
|
|
94
|
+
# Detect SSH keys
|
|
95
|
+
ssh_dir = Path.home() / ".ssh"
|
|
96
|
+
ssh_keys = []
|
|
97
|
+
if ssh_dir.exists():
|
|
98
|
+
for key_file in ssh_dir.glob("id_*"):
|
|
99
|
+
if key_file.is_file() and key_file.suffix != ".pub":
|
|
100
|
+
ssh_keys.append(str(key_file))
|
|
101
|
+
|
|
102
|
+
self.detected_info['ssh_keys'] = ssh_keys
|
|
103
|
+
self.detected_info['default_ssh_key'] = ssh_keys[0] if ssh_keys else None
|
|
104
|
+
|
|
105
|
+
# Detect tmux
|
|
106
|
+
tmux_path = shutil.which("tmux")
|
|
107
|
+
self.detected_info['tmux_installed'] = tmux_path is not None
|
|
108
|
+
self.detected_info['tmux_path'] = tmux_path
|
|
109
|
+
|
|
110
|
+
# Display detected info
|
|
111
|
+
table = Table(show_header=False, box=None)
|
|
112
|
+
table.add_column("Item", style="cyan")
|
|
113
|
+
table.add_column("Status")
|
|
114
|
+
|
|
115
|
+
# SSH keys
|
|
116
|
+
if ssh_keys:
|
|
117
|
+
table.add_row("SSH Keys", f"✅ Found {len(ssh_keys)} key(s)")
|
|
118
|
+
for key in ssh_keys:
|
|
119
|
+
table.add_row("", f" • {key}")
|
|
120
|
+
else:
|
|
121
|
+
table.add_row("SSH Keys", "⚠️ No SSH keys found in ~/.ssh/")
|
|
122
|
+
|
|
123
|
+
# tmux
|
|
124
|
+
if tmux_path:
|
|
125
|
+
table.add_row("tmux", f"✅ {tmux_path}")
|
|
126
|
+
else:
|
|
127
|
+
table.add_row("tmux", "❌ Not found (required for SSHplex)")
|
|
128
|
+
|
|
129
|
+
self.console.print(table)
|
|
130
|
+
|
|
131
|
+
def _collect_providers(self) -> None:
|
|
132
|
+
"""Collect provider configurations from user."""
|
|
133
|
+
self.console.print("\n📡 [bold]Configure Inventory Sources[/bold]\n")
|
|
134
|
+
|
|
135
|
+
add_more = True
|
|
136
|
+
while add_more:
|
|
137
|
+
provider = self._configure_provider()
|
|
138
|
+
if provider:
|
|
139
|
+
self.providers.append(provider)
|
|
140
|
+
|
|
141
|
+
add_more = Confirm.ask("\nAdd another inventory source?", default=False)
|
|
142
|
+
|
|
143
|
+
def _configure_provider(self) -> Optional[Dict[str, Any]]:
|
|
144
|
+
"""Configure a single provider.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Provider configuration dict or None if cancelled
|
|
148
|
+
"""
|
|
149
|
+
self.console.print("\n" + "─" * 60)
|
|
150
|
+
|
|
151
|
+
# Provider type selection
|
|
152
|
+
provider_types = [
|
|
153
|
+
("static", "Static host list (manual entry)"),
|
|
154
|
+
("netbox", "NetBox (infrastructure source of truth)"),
|
|
155
|
+
("ansible", "Ansible inventory file"),
|
|
156
|
+
("consul", "HashiCorp Consul (service discovery)"),
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
self.console.print("\n[bold]Select inventory source type:[/bold]")
|
|
160
|
+
for i, (_, desc) in enumerate(provider_types, 1):
|
|
161
|
+
self.console.print(f" {i}. {desc}")
|
|
162
|
+
|
|
163
|
+
choice = Prompt.ask("\nChoice", choices=[str(i) for i in range(1, len(provider_types) + 1)], default="1")
|
|
164
|
+
provider_type = provider_types[int(choice) - 1][0]
|
|
165
|
+
|
|
166
|
+
# Configure based on type
|
|
167
|
+
if provider_type == "static":
|
|
168
|
+
return self._configure_static()
|
|
169
|
+
elif provider_type == "netbox":
|
|
170
|
+
return self._configure_netbox()
|
|
171
|
+
elif provider_type == "ansible":
|
|
172
|
+
return self._configure_ansible()
|
|
173
|
+
elif provider_type == "consul":
|
|
174
|
+
return self._configure_consul()
|
|
175
|
+
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
def _configure_static(self) -> Optional[Dict[str, Any]]:
|
|
179
|
+
"""Configure static host provider."""
|
|
180
|
+
self.console.print("\n[bold cyan]Static Host List Configuration[/bold cyan]")
|
|
181
|
+
|
|
182
|
+
name = Prompt.ask("Provider name", default="my-hosts")
|
|
183
|
+
hosts: List[Dict[str, str]] = []
|
|
184
|
+
|
|
185
|
+
self.console.print("\n[bold]Add hosts[/bold] (leave name empty to finish)")
|
|
186
|
+
|
|
187
|
+
while True:
|
|
188
|
+
host_name = Prompt.ask(f"\nHost {len(hosts) + 1} name", default="")
|
|
189
|
+
if not host_name:
|
|
190
|
+
break
|
|
191
|
+
|
|
192
|
+
host_ip = Prompt.ask("IP address")
|
|
193
|
+
description = Prompt.ask("Description (optional)", default="")
|
|
194
|
+
|
|
195
|
+
host = {
|
|
196
|
+
"name": host_name,
|
|
197
|
+
"ip": host_ip,
|
|
198
|
+
}
|
|
199
|
+
if description:
|
|
200
|
+
host["description"] = description
|
|
201
|
+
|
|
202
|
+
hosts.append(host)
|
|
203
|
+
|
|
204
|
+
if not hosts:
|
|
205
|
+
self.console.print("[yellow]No hosts added. Skipping...[/yellow]")
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
config = {
|
|
209
|
+
"name": name,
|
|
210
|
+
"type": "static",
|
|
211
|
+
"hosts": hosts
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
# Test is not applicable for static hosts
|
|
215
|
+
self.console.print(f"\n✅ Configured {len(hosts)} static hosts")
|
|
216
|
+
return config
|
|
217
|
+
|
|
218
|
+
def _configure_netbox(self) -> Optional[Dict[str, Any]]:
|
|
219
|
+
"""Configure NetBox provider."""
|
|
220
|
+
self.console.print("\n[bold cyan]NetBox Configuration[/bold cyan]")
|
|
221
|
+
|
|
222
|
+
name = Prompt.ask("Provider name", default="netbox")
|
|
223
|
+
url = Prompt.ask("NetBox URL", default="https://netbox.example.com")
|
|
224
|
+
token = Prompt.ask("API Token", password=True)
|
|
225
|
+
verify_ssl = Confirm.ask("Verify SSL certificate", default=True)
|
|
226
|
+
|
|
227
|
+
config = {
|
|
228
|
+
"name": name,
|
|
229
|
+
"type": "netbox",
|
|
230
|
+
"url": url,
|
|
231
|
+
"token": token,
|
|
232
|
+
"verify_ssl": verify_ssl
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# Test connection
|
|
236
|
+
if Confirm.ask("\nTest connection?", default=True):
|
|
237
|
+
if self._test_netbox_connection(config):
|
|
238
|
+
self.console.print("✅ Connection successful!")
|
|
239
|
+
else:
|
|
240
|
+
self.console.print("❌ Connection failed. Check your credentials and URL.")
|
|
241
|
+
if not Confirm.ask("Keep this configuration anyway?", default=False):
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
return config
|
|
245
|
+
|
|
246
|
+
def _configure_ansible(self) -> Optional[Dict[str, Any]]:
|
|
247
|
+
"""Configure Ansible inventory provider."""
|
|
248
|
+
self.console.print("\n[bold cyan]Ansible Inventory Configuration[/bold cyan]")
|
|
249
|
+
|
|
250
|
+
name = Prompt.ask("Provider name", default="ansible")
|
|
251
|
+
|
|
252
|
+
inventory_paths: List[str] = []
|
|
253
|
+
self.console.print("\n[bold]Add inventory file paths[/bold] (leave empty to finish)")
|
|
254
|
+
|
|
255
|
+
while True:
|
|
256
|
+
path = Prompt.ask(f"Inventory path {len(inventory_paths) + 1}", default="")
|
|
257
|
+
if not path:
|
|
258
|
+
break
|
|
259
|
+
|
|
260
|
+
# Check if path exists
|
|
261
|
+
if not Path(path).exists():
|
|
262
|
+
self.console.print(f"[yellow]⚠️ Path does not exist: {path}[/yellow]")
|
|
263
|
+
if not Confirm.ask("Add anyway?", default=False):
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
inventory_paths.append(path)
|
|
267
|
+
|
|
268
|
+
if not inventory_paths:
|
|
269
|
+
self.console.print("[yellow]No inventory paths added. Skipping...[/yellow]")
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
config = {
|
|
273
|
+
"name": name,
|
|
274
|
+
"type": "ansible",
|
|
275
|
+
"inventory_paths": inventory_paths
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
# Test connection
|
|
279
|
+
if Confirm.ask("\nTest connection?", default=True):
|
|
280
|
+
if self._test_ansible_connection(config):
|
|
281
|
+
self.console.print("✅ Inventory loaded successfully!")
|
|
282
|
+
else:
|
|
283
|
+
self.console.print("❌ Failed to load inventory. Check paths and format.")
|
|
284
|
+
if not Confirm.ask("Keep this configuration anyway?", default=False):
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
return config
|
|
288
|
+
|
|
289
|
+
def _configure_consul(self) -> Optional[Dict[str, Any]]:
|
|
290
|
+
"""Configure Consul provider."""
|
|
291
|
+
self.console.print("\n[bold cyan]Consul Configuration[/bold cyan]")
|
|
292
|
+
|
|
293
|
+
name = Prompt.ask("Provider name", default="consul")
|
|
294
|
+
host = Prompt.ask("Consul host", default="localhost")
|
|
295
|
+
# Validate Consul port input
|
|
296
|
+
while True:
|
|
297
|
+
port_input = Prompt.ask("Consul port", default="8500")
|
|
298
|
+
try:
|
|
299
|
+
port = int(port_input)
|
|
300
|
+
if 1 <= port <= 65535:
|
|
301
|
+
break
|
|
302
|
+
else:
|
|
303
|
+
self.console.print("[red]Port must be between 1 and 65535[/red]")
|
|
304
|
+
except ValueError:
|
|
305
|
+
self.console.print(f"[red]Invalid port number: {port_input}[/red]")
|
|
306
|
+
scheme = Prompt.ask("Scheme (http/https)", default="http")
|
|
307
|
+
token = Prompt.ask("ACL Token (optional)", password=True, default="")
|
|
308
|
+
dc = Prompt.ask("Datacenter (optional)", default="")
|
|
309
|
+
|
|
310
|
+
consul_config: Dict[str, Any] = {
|
|
311
|
+
"host": host,
|
|
312
|
+
"port": port,
|
|
313
|
+
"scheme": scheme,
|
|
314
|
+
"token": token, # Always include token, even if empty
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if dc:
|
|
318
|
+
consul_config["dc"] = dc
|
|
319
|
+
|
|
320
|
+
config = {
|
|
321
|
+
"name": name,
|
|
322
|
+
"type": "consul",
|
|
323
|
+
"config": consul_config
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
# Test connection
|
|
327
|
+
if Confirm.ask("\nTest connection?", default=True):
|
|
328
|
+
if self._test_consul_connection(config):
|
|
329
|
+
self.console.print("✅ Connection successful!")
|
|
330
|
+
else:
|
|
331
|
+
self.console.print("❌ Connection failed. Check your configuration.")
|
|
332
|
+
if not Confirm.ask("Keep this configuration anyway?", default=False):
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
return config
|
|
336
|
+
|
|
337
|
+
def _test_netbox_connection(self, config: Dict[str, Any]) -> bool:
|
|
338
|
+
"""Test NetBox connection."""
|
|
339
|
+
self.logger.info(f"Testing NetBox connection to {config['url']}")
|
|
340
|
+
self.console.print("\n🔄 Testing connection...")
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
import pynetbox
|
|
344
|
+
nb = pynetbox.api(config['url'], token=config['token'])
|
|
345
|
+
if config.get('verify_ssl') is False:
|
|
346
|
+
nb.http_session.verify = False
|
|
347
|
+
|
|
348
|
+
# Test by getting version
|
|
349
|
+
version = nb.version
|
|
350
|
+
self.logger.info(f"NetBox connection successful, version: {version}")
|
|
351
|
+
return True
|
|
352
|
+
except Exception as e:
|
|
353
|
+
self.logger.error(f"NetBox connection failed: {e}")
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
def _test_ansible_connection(self, config: Dict[str, Any]) -> bool:
|
|
357
|
+
"""Test Ansible inventory loading."""
|
|
358
|
+
self.logger.info(f"Testing Ansible inventory from {config['inventory_paths']}")
|
|
359
|
+
self.console.print("\n🔄 Loading inventory...")
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
# Try to load the inventory
|
|
363
|
+
from ..sot.ansible import AnsibleProvider
|
|
364
|
+
provider = AnsibleProvider(
|
|
365
|
+
inventory_paths=config['inventory_paths']
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# Connect to load the inventory files
|
|
369
|
+
if not provider.connect():
|
|
370
|
+
self.logger.error("Ansible provider failed to connect/load inventory")
|
|
371
|
+
return False
|
|
372
|
+
|
|
373
|
+
hosts = provider.get_hosts()
|
|
374
|
+
self.logger.info(f"Ansible inventory loaded, found {len(hosts)} hosts")
|
|
375
|
+
return True
|
|
376
|
+
except Exception as e:
|
|
377
|
+
self.logger.error(f"Ansible inventory loading failed: {e}")
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
def _test_consul_connection(self, config: Dict[str, Any]) -> bool:
|
|
381
|
+
"""Test Consul connection."""
|
|
382
|
+
self.logger.info(f"Testing Consul connection to {config['config']['host']}:{config['config']['port']}")
|
|
383
|
+
self.console.print("\n🔄 Testing connection...")
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
import consul
|
|
387
|
+
client = consul.Consul(**config['config'])
|
|
388
|
+
|
|
389
|
+
# Test by getting leader
|
|
390
|
+
leader = client.status.leader()
|
|
391
|
+
self.logger.info(f"Consul connection successful, leader: {leader}")
|
|
392
|
+
return True
|
|
393
|
+
except Exception as e:
|
|
394
|
+
self.logger.error(f"Consul connection failed: {e}")
|
|
395
|
+
return False
|
|
396
|
+
|
|
397
|
+
def _generate_config(self) -> Dict[str, Any]:
|
|
398
|
+
"""Generate configuration dictionary."""
|
|
399
|
+
# Use detected SSH key as default, fallback to common default if none found
|
|
400
|
+
default_key = self.detected_info.get('default_ssh_key') or '~/.ssh/id_ed25519'
|
|
401
|
+
|
|
402
|
+
# Validate SSH port input
|
|
403
|
+
while True:
|
|
404
|
+
port_input = Prompt.ask("Default SSH port", default="22")
|
|
405
|
+
try:
|
|
406
|
+
ssh_port = int(port_input)
|
|
407
|
+
if 1 <= ssh_port <= 65535:
|
|
408
|
+
break
|
|
409
|
+
else:
|
|
410
|
+
self.console.print("[red]Port must be between 1 and 65535[/red]")
|
|
411
|
+
except ValueError:
|
|
412
|
+
self.console.print(f"[red]Invalid port number: {port_input}[/red]")
|
|
413
|
+
|
|
414
|
+
config = {
|
|
415
|
+
"ssh": {
|
|
416
|
+
"username": Prompt.ask("\nDefault SSH username", default=os.environ.get('USER', 'admin')),
|
|
417
|
+
"key_path": Prompt.ask("Default SSH key path", default=default_key),
|
|
418
|
+
"port": ssh_port,
|
|
419
|
+
},
|
|
420
|
+
"sot": {
|
|
421
|
+
"import": self.providers
|
|
422
|
+
},
|
|
423
|
+
"cache": {
|
|
424
|
+
"enabled": True,
|
|
425
|
+
"cache_dir": "~/.cache/sshplex",
|
|
426
|
+
"ttl_hours": 24
|
|
427
|
+
},
|
|
428
|
+
"logging": {
|
|
429
|
+
"enabled": True,
|
|
430
|
+
"level": "INFO",
|
|
431
|
+
"file": "logs/sshplex.log"
|
|
432
|
+
},
|
|
433
|
+
"tmux": {
|
|
434
|
+
"control_with_iterm2": False
|
|
435
|
+
},
|
|
436
|
+
"ui": {
|
|
437
|
+
"show_log_panel": False,
|
|
438
|
+
"table_columns": ["name", "ip", "cluster", "role", "tags", "description", "provider"]
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return config
|
|
443
|
+
|
|
444
|
+
def _save_config(self, config: Dict[str, Any]) -> bool:
|
|
445
|
+
"""Save configuration to file."""
|
|
446
|
+
try:
|
|
447
|
+
# Ensure directory exists
|
|
448
|
+
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
449
|
+
|
|
450
|
+
# Validate configuration against full Config model
|
|
451
|
+
Config(**config) # Validates and raises ValidationError if invalid
|
|
452
|
+
|
|
453
|
+
# Save to YAML
|
|
454
|
+
import yaml
|
|
455
|
+
with open(self.config_path, 'w') as f:
|
|
456
|
+
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
457
|
+
|
|
458
|
+
self.logger.info(f"Configuration saved to {self.config_path}")
|
|
459
|
+
return True
|
|
460
|
+
except ValidationError as e:
|
|
461
|
+
self.logger.error(f"Configuration validation failed: {e}")
|
|
462
|
+
self.console.print("\n[red]❌ Configuration validation failed:[/red]")
|
|
463
|
+
self.console.print(str(e))
|
|
464
|
+
return False
|
|
465
|
+
except Exception as e:
|
|
466
|
+
self.logger.error(f"Failed to save configuration: {e}")
|
|
467
|
+
return False
|
|
468
|
+
|
|
469
|
+
def _show_success(self) -> None:
|
|
470
|
+
"""Display success message."""
|
|
471
|
+
success_text = Text()
|
|
472
|
+
success_text.append("✅ Configuration Complete!", style="bold green")
|
|
473
|
+
success_text.append(f"\n\nConfiguration saved to: {self.config_path}")
|
|
474
|
+
success_text.append("\n\nYou're ready to use SSHplex!")
|
|
475
|
+
success_text.append("\n\nNext steps:")
|
|
476
|
+
success_text.append("\n • Run [bold]sshplex[/bold] to launch the TUI")
|
|
477
|
+
success_text.append("\n • Press [bold]?[/bold] or [bold]h[/bold] in the TUI for keyboard shortcuts")
|
|
478
|
+
success_text.append("\n • Edit configuration anytime with [bold]e[/bold] key in TUI")
|
|
479
|
+
|
|
480
|
+
panel = Panel(success_text, border_style="green", padding=(1, 2))
|
|
481
|
+
self.console.print("\n")
|
|
482
|
+
self.console.print(panel)
|
|
@@ -446,10 +446,31 @@ class SoTFactory:
|
|
|
446
446
|
|
|
447
447
|
for provider in self.providers:
|
|
448
448
|
provider_name = type(provider).__name__
|
|
449
|
+
provider_config_name = getattr(provider, 'provider_name', provider_name)
|
|
449
450
|
try:
|
|
450
|
-
|
|
451
|
+
self.logger.info(f"Testing connection to {provider_config_name} ({provider_name})...")
|
|
452
|
+
start_time = None
|
|
453
|
+
try:
|
|
454
|
+
import time
|
|
455
|
+
start_time = time.time()
|
|
456
|
+
except ImportError:
|
|
457
|
+
pass
|
|
458
|
+
|
|
459
|
+
success = provider.test_connection()
|
|
460
|
+
|
|
461
|
+
if start_time:
|
|
462
|
+
elapsed = time.time() - start_time
|
|
463
|
+
self.logger.info(f"Connection test to {provider_config_name} completed in {elapsed:.2f}s")
|
|
464
|
+
|
|
465
|
+
if success:
|
|
466
|
+
self.logger.info(f"✅ {provider_config_name}: Connection successful")
|
|
467
|
+
else:
|
|
468
|
+
self.logger.error(f"❌ {provider_config_name}: Connection failed")
|
|
469
|
+
|
|
470
|
+
results[provider_name] = success
|
|
451
471
|
except Exception as e:
|
|
452
|
-
self.logger.error(f"Connection test failed for {
|
|
472
|
+
self.logger.error(f"❌ Connection test failed for {provider_config_name}: {e}")
|
|
473
|
+
self.logger.exception(f"Full exception details for {provider_config_name}:")
|
|
453
474
|
results[provider_name] = False
|
|
454
475
|
|
|
455
476
|
return results
|
|
@@ -5,11 +5,12 @@ import argparse
|
|
|
5
5
|
import shutil
|
|
6
6
|
import sys
|
|
7
7
|
from datetime import datetime
|
|
8
|
-
from typing import Any
|
|
8
|
+
from typing import Any, Optional
|
|
9
9
|
|
|
10
10
|
from . import __version__
|
|
11
11
|
from .lib.config import get_config_info, load_config
|
|
12
12
|
from .lib.logger import get_logger, setup_logging
|
|
13
|
+
from .lib.onboarding import OnboardingWizard
|
|
13
14
|
from .lib.sot.factory import SoTFactory
|
|
14
15
|
from .lib.ui.host_selector import HostSelector
|
|
15
16
|
|
|
@@ -30,6 +31,22 @@ def check_system_dependencies() -> bool:
|
|
|
30
31
|
return True
|
|
31
32
|
|
|
32
33
|
|
|
34
|
+
def run_onboarding(config_path: Optional[str] = None) -> int:
|
|
35
|
+
"""Run the interactive onboarding wizard."""
|
|
36
|
+
try:
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
path = Path(config_path) if config_path else None
|
|
39
|
+
wizard = OnboardingWizard(config_path=path)
|
|
40
|
+
success = wizard.run()
|
|
41
|
+
return 0 if success else 1
|
|
42
|
+
except KeyboardInterrupt:
|
|
43
|
+
print("\n\nOnboarding cancelled by user")
|
|
44
|
+
return 130
|
|
45
|
+
except Exception as e:
|
|
46
|
+
print(f"❌ Onboarding failed: {e}")
|
|
47
|
+
return 1
|
|
48
|
+
|
|
49
|
+
|
|
33
50
|
def main() -> int:
|
|
34
51
|
"""Main entry point for SSHplex TUI Application."""
|
|
35
52
|
|
|
@@ -49,6 +66,7 @@ Examples:
|
|
|
49
66
|
parser.add_argument('--config', type=str, default=None, help='Path to the configuration file (default: ~/.config/sshplex/sshplex.yaml)')
|
|
50
67
|
parser.add_argument('--version', action='version', version=f'SSHplex {__version__}')
|
|
51
68
|
parser.add_argument('--debug', action='store_true', help='Run in debug mode (CLI only, no TUI)')
|
|
69
|
+
parser.add_argument('--onboarding', action='store_true', help='Run the interactive setup wizard')
|
|
52
70
|
parser.add_argument('--clear-cache', action='store_true', help='Clear the host cache before starting')
|
|
53
71
|
parser.add_argument('--show-config', action='store_true', help='Show configuration paths and exit')
|
|
54
72
|
parser.add_argument('--verbose', '-v', action='store_true', help='Enable verbose output')
|
|
@@ -58,6 +76,10 @@ Examples:
|
|
|
58
76
|
if args.show_config:
|
|
59
77
|
return show_config_info()
|
|
60
78
|
|
|
79
|
+
# Handle onboarding wizard
|
|
80
|
+
if args.onboarding:
|
|
81
|
+
return run_onboarding(args.config)
|
|
82
|
+
|
|
61
83
|
# Check system dependencies (skip for debug/cache operations)
|
|
62
84
|
if not args.debug and not args.clear_cache and not check_system_dependencies():
|
|
63
85
|
return 1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sshplex
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Multiplex your SSH connections with style
|
|
5
5
|
Author-email: MJAHED Sabri <contact@sabrimjahed.com>
|
|
6
6
|
License: MIT
|
|
@@ -121,7 +121,10 @@ pip install -e ".[dev]"
|
|
|
121
121
|
## Quick Start
|
|
122
122
|
|
|
123
123
|
```bash
|
|
124
|
-
#
|
|
124
|
+
# First-time setup - interactive onboarding wizard
|
|
125
|
+
sshplex --onboarding
|
|
126
|
+
|
|
127
|
+
# Launch TUI
|
|
125
128
|
sshplex
|
|
126
129
|
|
|
127
130
|
# Debug mode - test provider connectivity
|
|
@@ -134,7 +137,15 @@ sshplex --show-config
|
|
|
134
137
|
sshplex --clear-cache
|
|
135
138
|
```
|
|
136
139
|
|
|
137
|
-
|
|
140
|
+
### First-Time Setup
|
|
141
|
+
|
|
142
|
+
Run `sshplex --onboarding` for an interactive setup wizard that will:
|
|
143
|
+
- Auto-detect your SSH keys and system dependencies
|
|
144
|
+
- Guide you through configuring inventory sources (NetBox, Ansible, Consul, or static hosts)
|
|
145
|
+
- Test connections before saving
|
|
146
|
+
- Generate a working configuration file
|
|
147
|
+
|
|
148
|
+
On first run without `--onboarding`, SSHplex creates a default config at `~/.config/sshplex/sshplex.yaml`. Edit it with your provider details, or use the built-in config editor (`e` key in TUI).
|
|
138
149
|
|
|
139
150
|
## What's New (Quality Upgrade)
|
|
140
151
|
|
|
@@ -20,6 +20,8 @@ sshplex/lib/logger.py
|
|
|
20
20
|
sshplex/lib/multiplexer/__init__.py
|
|
21
21
|
sshplex/lib/multiplexer/base.py
|
|
22
22
|
sshplex/lib/multiplexer/tmux.py
|
|
23
|
+
sshplex/lib/onboarding/__init__.py
|
|
24
|
+
sshplex/lib/onboarding/wizard.py
|
|
23
25
|
sshplex/lib/sot/__init__.py
|
|
24
26
|
sshplex/lib/sot/ansible.py
|
|
25
27
|
sshplex/lib/sot/base.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|