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.
Files changed (39) hide show
  1. {sshplex-1.3.1/sshplex.egg-info → sshplex-1.4.0}/PKG-INFO +14 -3
  2. {sshplex-1.3.1 → sshplex-1.4.0}/README.md +13 -2
  3. {sshplex-1.3.1 → sshplex-1.4.0}/pyproject.toml +1 -1
  4. sshplex-1.4.0/sshplex/lib/onboarding/__init__.py +5 -0
  5. sshplex-1.4.0/sshplex/lib/onboarding/wizard.py +482 -0
  6. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/sot/factory.py +23 -2
  7. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/main.py +23 -1
  8. {sshplex-1.3.1 → sshplex-1.4.0/sshplex.egg-info}/PKG-INFO +14 -3
  9. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex.egg-info/SOURCES.txt +2 -0
  10. {sshplex-1.3.1 → sshplex-1.4.0}/LICENSE +0 -0
  11. {sshplex-1.3.1 → sshplex-1.4.0}/MANIFEST.in +0 -0
  12. {sshplex-1.3.1 → sshplex-1.4.0}/setup.cfg +0 -0
  13. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/__init__.py +0 -0
  14. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/cli.py +0 -0
  15. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/config-template.yaml +0 -0
  16. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/__init__.py +0 -0
  17. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/cache.py +0 -0
  18. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/config.py +0 -0
  19. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/logger.py +0 -0
  20. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/multiplexer/__init__.py +0 -0
  21. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/multiplexer/base.py +0 -0
  22. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/multiplexer/tmux.py +0 -0
  23. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/sot/__init__.py +0 -0
  24. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/sot/ansible.py +0 -0
  25. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/sot/base.py +0 -0
  26. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/sot/consul.py +0 -0
  27. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/sot/netbox.py +0 -0
  28. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/sot/static.py +0 -0
  29. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/ui/__init__.py +0 -0
  30. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/ui/config_editor.py +0 -0
  31. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/ui/host_selector.py +0 -0
  32. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/lib/ui/session_manager.py +0 -0
  33. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex/sshplex_connector.py +0 -0
  34. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex.egg-info/dependency_links.txt +0 -0
  35. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex.egg-info/entry_points.txt +0 -0
  36. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex.egg-info/requires.txt +0 -0
  37. {sshplex-1.3.1 → sshplex-1.4.0}/sshplex.egg-info/top_level.txt +0 -0
  38. {sshplex-1.3.1 → sshplex-1.4.0}/tests/test_cache.py +0 -0
  39. {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.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
- # Launch TUI (creates default config on first run)
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
- On first run, SSHplex creates a config at `~/.config/sshplex/sshplex.yaml`. Edit it with your provider details, then run `sshplex` again.
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
- # Launch TUI (creates default config on first run)
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
- On first run, SSHplex creates a config at `~/.config/sshplex/sshplex.yaml`. Edit it with your provider details, then run `sshplex` again.
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.3.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,5 @@
1
+ """Onboarding wizard for first-time SSHplex setup."""
2
+
3
+ from .wizard import OnboardingWizard
4
+
5
+ __all__ = ['OnboardingWizard']
@@ -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
- results[provider_name] = provider.test_connection()
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 {provider_name}: {e}")
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.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
- # Launch TUI (creates default config on first run)
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
- On first run, SSHplex creates a config at `~/.config/sshplex/sshplex.yaml`. Edit it with your provider details, then run `sshplex` again.
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