sshplex 1.6.4__tar.gz → 1.7.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 (49) hide show
  1. {sshplex-1.6.4/sshplex.egg-info → sshplex-1.7.0}/PKG-INFO +4 -1
  2. {sshplex-1.6.4 → sshplex-1.7.0}/README.md +3 -0
  3. {sshplex-1.6.4 → sshplex-1.7.0}/pyproject.toml +1 -1
  4. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/__init__.py +1 -1
  5. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/cli.py +8 -0
  6. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/config-template.yaml +23 -1
  7. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/config.py +17 -2
  8. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/onboarding/wizard.py +252 -2
  9. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/sot/factory.py +85 -0
  10. sshplex-1.7.0/sshplex/lib/sot/git.py +534 -0
  11. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/ui/config_editor.py +650 -87
  12. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/ui/host_selector.py +69 -6
  13. {sshplex-1.6.4 → sshplex-1.7.0/sshplex.egg-info}/PKG-INFO +4 -1
  14. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex.egg-info/SOURCES.txt +3 -1
  15. {sshplex-1.6.4 → sshplex-1.7.0}/tests/test_config.py +23 -0
  16. sshplex-1.7.0/tests/test_ui_config_editor_columns.py +35 -0
  17. {sshplex-1.6.4 → sshplex-1.7.0}/LICENSE +0 -0
  18. {sshplex-1.6.4 → sshplex-1.7.0}/MANIFEST.in +0 -0
  19. {sshplex-1.6.4 → sshplex-1.7.0}/setup.cfg +0 -0
  20. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/__init__.py +0 -0
  21. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/cache.py +0 -0
  22. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/commands.py +0 -0
  23. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/logger.py +0 -0
  24. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/multiplexer/__init__.py +0 -0
  25. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/multiplexer/base.py +0 -0
  26. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/multiplexer/iterm2_native.py +0 -0
  27. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/multiplexer/tmux.py +0 -0
  28. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/onboarding/__init__.py +0 -0
  29. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/sot/__init__.py +0 -0
  30. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/sot/ansible.py +0 -0
  31. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/sot/base.py +0 -0
  32. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/sot/consul.py +0 -0
  33. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/sot/netbox.py +0 -0
  34. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/sot/static.py +0 -0
  35. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/ui/__init__.py +0 -0
  36. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/ui/session_manager.py +0 -0
  37. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/utils/__init__.py +0 -0
  38. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/utils/iterm2.py +0 -0
  39. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/lib/utils/ssh_config.py +0 -0
  40. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/main.py +0 -0
  41. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex/sshplex_connector.py +0 -0
  42. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex.egg-info/dependency_links.txt +0 -0
  43. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex.egg-info/entry_points.txt +0 -0
  44. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex.egg-info/requires.txt +0 -0
  45. {sshplex-1.6.4 → sshplex-1.7.0}/sshplex.egg-info/top_level.txt +0 -0
  46. {sshplex-1.6.4 → sshplex-1.7.0}/tests/test_cache.py +0 -0
  47. {sshplex-1.6.4 → sshplex-1.7.0}/tests/test_commands.py +0 -0
  48. {sshplex-1.6.4 → sshplex-1.7.0}/tests/test_iterm2_session_manager.py +0 -0
  49. {sshplex-1.6.4 → sshplex-1.7.0}/tests/test_main.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sshplex
3
- Version: 1.6.4
3
+ Version: 1.7.0
4
4
  Summary: Multiplex your SSH connections with style
5
5
  Author-email: MJAHED Sabri <contact@sabrimjahed.com>
6
6
  License: MIT
@@ -110,9 +110,12 @@ sshplex
110
110
  | **NetBox** | `netbox` | None (included in base install) | Inventory-driven infrastructure with metadata |
111
111
  | **Ansible** | `ansible` | None | Reusing existing Ansible inventory files |
112
112
  | **Consul** | `consul` | `pip install "sshplex[consul]"` | Service discovery and dynamic node catalogs |
113
+ | **Git** | `git` | `git` binary in PATH | Git-backed inventories with auto-pull (`static` or `ansible` YAML) |
113
114
 
114
115
  Provider activation is controlled by `sot.providers`, and each source is configured as an item in `sot.import`.
115
116
 
117
+ Use multiple `git` imports and tune `priority` for deterministic overrides.
118
+
116
119
 
117
120
  ## Local Demo (Consul + Ansible)
118
121
 
@@ -57,9 +57,12 @@ sshplex
57
57
  | **NetBox** | `netbox` | None (included in base install) | Inventory-driven infrastructure with metadata |
58
58
  | **Ansible** | `ansible` | None | Reusing existing Ansible inventory files |
59
59
  | **Consul** | `consul` | `pip install "sshplex[consul]"` | Service discovery and dynamic node catalogs |
60
+ | **Git** | `git` | `git` binary in PATH | Git-backed inventories with auto-pull (`static` or `ansible` YAML) |
60
61
 
61
62
  Provider activation is controlled by `sot.providers`, and each source is configured as an item in `sot.import`.
62
63
 
64
+ Use multiple `git` imports and tune `priority` for deterministic overrides.
65
+
63
66
 
64
67
  ## Local Demo (Consul + Ansible)
65
68
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sshplex"
7
- version = "1.6.4"
7
+ version = "1.7.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"
@@ -1,4 +1,4 @@
1
1
  """SSHplex - SSH Connection Multiplexer"""
2
- __version__ = "1.6.4"
2
+ __version__ = "1.7.0"
3
3
  __author__ = "MJAHED Sabri"
4
4
  __email__ = "contact@sabrimjahed.com"
@@ -127,6 +127,8 @@ def list_providers(config: Any, logger: Any) -> int:
127
127
  status_icon = "📝"
128
128
  elif provider.type == "consul":
129
129
  status_icon = "🔍"
130
+ elif provider.type == "git":
131
+ status_icon = "🔄"
130
132
 
131
133
  print(f"{i}. {status_icon} {provider.name}")
132
134
  print(f" Type: {provider.type}")
@@ -137,6 +139,12 @@ def list_providers(config: Any, logger: Any) -> int:
137
139
  print(f" Paths: {', '.join(provider.inventory_paths)}")
138
140
  elif provider.type == "consul" and provider.config:
139
141
  print(f" Host: {provider.config.host}:{provider.config.port}")
142
+ elif provider.type == "git" and provider.repo_url:
143
+ branch = provider.branch or "main"
144
+ inventory_format = provider.inventory_format or "static"
145
+ print(f" Repo: {provider.repo_url} [{branch}, {inventory_format}]")
146
+ source_pattern = provider.source_pattern or f"{provider.path}/{provider.file_glob}"
147
+ print(f" Source: {source_pattern}")
140
148
  elif provider.type == "static" and provider.hosts:
141
149
  print(f" Hosts: {len(provider.hosts)} defined")
142
150
 
@@ -4,7 +4,7 @@ sshplex:
4
4
 
5
5
  # Source of Truth configuration
6
6
  sot:
7
- providers: ["static", "netbox", "ansible", "consul"] # Available: static, netbox, ansible, consul
7
+ providers: ["static", "netbox", "ansible", "consul", "git"] # Available: static, netbox, ansible, consul, git
8
8
  import:
9
9
  - name: "production-servers"
10
10
  type: static
@@ -71,6 +71,28 @@ sot:
71
71
  verify: false
72
72
  dc: "lisbon"
73
73
  cert: "" # Path to SSL certificate (optional)
74
+ - name: "personal-git-hosts"
75
+ type: git
76
+ repo_url: "git@github.com:your-user/sshplex-hosts.git"
77
+ branch: "main"
78
+ source_pattern: "hosts/**/*.y*ml"
79
+ inventory_format: "static" # static, ansible
80
+ auto_pull: true
81
+ pull_interval_seconds: 300
82
+ priority: 100
83
+ pull_strategy: "ff-only"
84
+ - name: "git-ansible-inventory"
85
+ type: git
86
+ repo_url: "git@github.com:your-org/ansible-inventory.git"
87
+ branch: "main"
88
+ source_pattern: "inventory/**/*.y*ml"
89
+ inventory_format: "ansible"
90
+ default_filters:
91
+ groups: ["webservers", "databases"]
92
+ auto_pull: true
93
+ pull_interval_seconds: 300
94
+ priority: 50
95
+ pull_strategy: "ff-only"
74
96
 
75
97
  ssh:
76
98
  username: "admin"
@@ -164,7 +164,7 @@ class ConsulConfig(BaseModel):
164
164
  class SoTImportConfig(BaseModel):
165
165
  """Individual SoT import configuration."""
166
166
  name: str = Field(..., description="Unique name for this import")
167
- type: str = Field(..., description="Provider type: static, netbox, ansible, consul")
167
+ type: str = Field(..., description="Provider type: static, netbox, ansible, consul, git")
168
168
 
169
169
  # Static provider fields
170
170
  hosts: Optional[List[Dict[str, Any]]] = None
@@ -182,9 +182,24 @@ class SoTImportConfig(BaseModel):
182
182
  # Consul provider fields
183
183
  config: Optional[ConsulConfig] = None
184
184
 
185
+ # Git provider fields
186
+ repo_url: Optional[str] = None
187
+ branch: Optional[str] = "main"
188
+ source_pattern: Optional[str] = None
189
+ path: Optional[str] = "hosts"
190
+ file_glob: Optional[str] = "**/*.y*ml"
191
+ auto_pull: Optional[bool] = True
192
+ pull_interval_seconds: Optional[int] = 300
193
+ priority: Optional[int] = 100
194
+ pull_strategy: Optional[str] = "ff-only"
195
+ inventory_format: Optional[str] = "static"
196
+
185
197
  class SoTConfig(BaseModel):
186
198
  """Source of Truth configuration."""
187
- providers: List[str] = Field(default_factory=lambda: ["static"], description="List of SoT providers to use: static, netbox, ansible")
199
+ providers: List[str] = Field(
200
+ default_factory=lambda: ["static"],
201
+ description="List of SoT providers to use: static, netbox, ansible, consul, git",
202
+ )
188
203
  import_: List[SoTImportConfig] = Field(alias='import', default_factory=list, description="List of import configurations")
189
204
 
190
205
 
@@ -1,7 +1,9 @@
1
1
  """Interactive onboarding wizard for SSHplex."""
2
2
 
3
3
  import os
4
+ import platform
4
5
  import shutil
6
+ import subprocess
5
7
  from pathlib import Path
6
8
  from typing import Any, Dict, List, Optional
7
9
 
@@ -62,6 +64,12 @@ class OnboardingWizard:
62
64
 
63
65
  # Generate configuration
64
66
  config = self._generate_config()
67
+
68
+ # Review before saving
69
+ self._show_configuration_summary(config)
70
+ if not Confirm.ask("\nSave this configuration?", default=True):
71
+ self.console.print("\n[yellow]Onboarding cancelled. Configuration was not saved.[/yellow]")
72
+ return False
65
73
 
66
74
  # Save configuration
67
75
  if self._save_config(config):
@@ -79,7 +87,7 @@ class OnboardingWizard:
79
87
  welcome_text.append("Let's configure SSHplex for your environment.")
80
88
  welcome_text.append("\nThis wizard will help you:")
81
89
  welcome_text.append("\n • Detect SSH keys and system dependencies")
82
- welcome_text.append("\n • Configure inventory sources (NetBox, Ansible, Consul, etc.)")
90
+ welcome_text.append("\n • Configure inventory sources (Static, NetBox, Ansible, Consul, Git)")
83
91
  welcome_text.append("\n • Test connections before saving")
84
92
  welcome_text.append("\n • Generate a working configuration file")
85
93
 
@@ -90,6 +98,8 @@ class OnboardingWizard:
90
98
  def _detect_environment(self) -> None:
91
99
  """Detect system environment and dependencies."""
92
100
  self.console.print("\n🔍 [bold]Detecting Environment[/bold]\n")
101
+ platform_name = platform.system()
102
+ self.detected_info['platform'] = platform_name
93
103
 
94
104
  # Detect SSH keys
95
105
  ssh_dir = Path.home() / ".ssh"
@@ -106,11 +116,22 @@ class OnboardingWizard:
106
116
  tmux_path = shutil.which("tmux")
107
117
  self.detected_info['tmux_installed'] = tmux_path is not None
108
118
  self.detected_info['tmux_path'] = tmux_path
119
+
120
+ # Detect git
121
+ git_path = shutil.which("git")
122
+ self.detected_info['git_installed'] = git_path is not None
123
+ self.detected_info['git_path'] = git_path
124
+
125
+ # Detect iTerm2 app (macOS only)
126
+ iterm_app_exists = platform_name.lower() == "darwin" and Path("/Applications/iTerm.app").exists()
127
+ self.detected_info['iterm2_installed'] = iterm_app_exists
109
128
 
110
129
  # Display detected info
111
130
  table = Table(show_header=False, box=None)
112
131
  table.add_column("Item", style="cyan")
113
132
  table.add_column("Status")
133
+
134
+ table.add_row("Platform", f"✅ {platform_name}")
114
135
 
115
136
  # SSH keys
116
137
  if ssh_keys:
@@ -124,7 +145,20 @@ class OnboardingWizard:
124
145
  if tmux_path:
125
146
  table.add_row("tmux", f"✅ {tmux_path}")
126
147
  else:
127
- table.add_row("tmux", "Not found (required for SSHplex)")
148
+ table.add_row("tmux", "⚠️ Not found (required for tmux backend)")
149
+
150
+ # git
151
+ if git_path:
152
+ table.add_row("git", f"✅ {git_path}")
153
+ else:
154
+ table.add_row("git", "⚠️ Not found (required for git inventory source)")
155
+
156
+ # iTerm2
157
+ if platform_name.lower() == "darwin":
158
+ if iterm_app_exists:
159
+ table.add_row("iTerm2", "✅ /Applications/iTerm.app")
160
+ else:
161
+ table.add_row("iTerm2", "ℹ️ Not found (optional, only for native iTerm2 backend)")
128
162
 
129
163
  self.console.print(table)
130
164
 
@@ -154,6 +188,7 @@ class OnboardingWizard:
154
188
  ("netbox", "NetBox (infrastructure source of truth)"),
155
189
  ("ansible", "Ansible inventory file"),
156
190
  ("consul", "HashiCorp Consul (service discovery)"),
191
+ ("git", "Git repository inventory (static or ansible YAML)"),
157
192
  ]
158
193
 
159
194
  self.console.print("\n[bold]Select inventory source type:[/bold]")
@@ -172,6 +207,8 @@ class OnboardingWizard:
172
207
  return self._configure_ansible()
173
208
  elif provider_type == "consul":
174
209
  return self._configure_consul()
210
+ elif provider_type == "git":
211
+ return self._configure_git()
175
212
 
176
213
  return None
177
214
 
@@ -333,6 +370,110 @@ class OnboardingWizard:
333
370
  return None
334
371
 
335
372
  return config
373
+
374
+ def _configure_git(self) -> Optional[Dict[str, Any]]:
375
+ """Configure read-only git inventory provider."""
376
+ self.console.print("\n[bold cyan]Git Inventory Configuration[/bold cyan]")
377
+
378
+ if not self.detected_info.get('git_installed'):
379
+ self.console.print("[yellow]⚠️ git was not detected on this machine.[/yellow]")
380
+ if not Confirm.ask("Continue configuring git provider anyway?", default=False):
381
+ return None
382
+
383
+ name = Prompt.ask("Provider name", default="git-hosts")
384
+ repo_url = Prompt.ask("Repository URL", default="git@github.com:org/hosts.git")
385
+ branch = Prompt.ask("Branch", default="main")
386
+ source_pattern = Prompt.ask(
387
+ "Source pattern (path + glob)",
388
+ default="hosts/**/*.y*ml",
389
+ )
390
+ inventory_format = Prompt.ask(
391
+ "Inventory format",
392
+ choices=["static", "ansible"],
393
+ default="static",
394
+ )
395
+
396
+ while True:
397
+ priority_input = Prompt.ask("Priority", default="100")
398
+ try:
399
+ priority = int(priority_input)
400
+ break
401
+ except ValueError:
402
+ self.console.print(f"[red]Invalid priority: {priority_input}[/red]")
403
+
404
+ auto_pull = Confirm.ask("Enable auto pull", default=True)
405
+ pull_interval_seconds = 300
406
+ if auto_pull:
407
+ while True:
408
+ interval_input = Prompt.ask("Auto pull interval (seconds)", default="300")
409
+ try:
410
+ pull_interval_seconds = int(interval_input)
411
+ if pull_interval_seconds >= 0:
412
+ break
413
+ self.console.print("[red]Interval must be >= 0[/red]")
414
+ except ValueError:
415
+ self.console.print(f"[red]Invalid interval: {interval_input}[/red]")
416
+
417
+ path, file_glob = self._split_source_pattern_legacy(source_pattern)
418
+
419
+ config: Dict[str, Any] = {
420
+ "name": name,
421
+ "type": "git",
422
+ "repo_url": repo_url,
423
+ "branch": branch,
424
+ "source_pattern": source_pattern,
425
+ "path": path,
426
+ "file_glob": file_glob,
427
+ "inventory_format": inventory_format,
428
+ "priority": priority,
429
+ "auto_pull": auto_pull,
430
+ "pull_interval_seconds": pull_interval_seconds,
431
+ "pull_strategy": "ff-only",
432
+ }
433
+
434
+ if inventory_format == "ansible":
435
+ groups_str = Prompt.ask(
436
+ "Ansible groups filter (comma-separated, optional)",
437
+ default="",
438
+ )
439
+ if groups_str.strip():
440
+ groups = [group.strip() for group in groups_str.split(",") if group.strip()]
441
+ if groups:
442
+ config["default_filters"] = {"groups": groups}
443
+
444
+ if Confirm.ask("\nTest repository access?", default=True):
445
+ if self._test_git_connection(config):
446
+ self.console.print("✅ Repository access successful!")
447
+ else:
448
+ self.console.print("❌ Could not read remote branch/source. Check URL/auth/branch.")
449
+ if not Confirm.ask("Keep this configuration anyway?", default=False):
450
+ return None
451
+
452
+ return config
453
+
454
+ @staticmethod
455
+ def _split_source_pattern_legacy(source_pattern: str) -> tuple[str, str]:
456
+ """Split source pattern into path/glob compatibility fields."""
457
+ normalized = str(source_pattern or "").strip().lstrip("/")
458
+ if not normalized:
459
+ return "hosts", "**/*.y*ml"
460
+
461
+ if normalized.endswith((".yml", ".yaml")):
462
+ return normalized, "**/*.y*ml"
463
+
464
+ wildcard_chars = {"*", "?", "["}
465
+ parts = normalized.split("/")
466
+ wildcard_index = -1
467
+ for idx, part in enumerate(parts):
468
+ if any(char in part for char in wildcard_chars):
469
+ wildcard_index = idx
470
+ break
471
+
472
+ if wildcard_index == -1:
473
+ return normalized, "**/*.y*ml"
474
+ if wildcard_index == 0:
475
+ return ".", normalized
476
+ return "/".join(parts[:wildcard_index]), "/".join(parts[wildcard_index:])
336
477
 
337
478
  def _test_netbox_connection(self, config: Dict[str, Any]) -> bool:
338
479
  """Test NetBox connection."""
@@ -393,11 +534,67 @@ class OnboardingWizard:
393
534
  except Exception as e:
394
535
  self.logger.error(f"Consul connection failed: {e}")
395
536
  return False
537
+
538
+ def _test_git_connection(self, config: Dict[str, Any]) -> bool:
539
+ """Test git repository access and branch visibility."""
540
+ repo_url = str(config.get("repo_url", "")).strip()
541
+ branch = str(config.get("branch", "main")).strip() or "main"
542
+ self.logger.info(f"Testing git source access: {repo_url}@{branch}")
543
+ self.console.print("\n🔄 Testing repository access...")
544
+
545
+ if not repo_url:
546
+ self.logger.error("Git repository URL is empty")
547
+ return False
548
+
549
+ git_bin = shutil.which("git")
550
+ if not git_bin:
551
+ self.logger.error("git binary not found in PATH")
552
+ return False
553
+
554
+ try:
555
+ result = subprocess.run(
556
+ [git_bin, "ls-remote", "--heads", repo_url, branch],
557
+ capture_output=True,
558
+ text=True,
559
+ timeout=20,
560
+ )
561
+ if result.returncode != 0:
562
+ self.logger.error(f"git ls-remote failed: {result.stderr.strip()}")
563
+ return False
564
+
565
+ if not (result.stdout or "").strip():
566
+ self.logger.warning(
567
+ f"Repository reachable but no matching branch found for '{branch}'"
568
+ )
569
+ return False
570
+
571
+ return True
572
+ except Exception as e:
573
+ self.logger.error(f"Git repository check failed: {e}")
574
+ return False
575
+
576
+ def _select_backend(self) -> str:
577
+ """Choose backend based on detected environment."""
578
+ platform_name = str(self.detected_info.get('platform', platform.system()))
579
+ tmux_installed = bool(self.detected_info.get('tmux_installed', False))
580
+ iterm2_installed = bool(self.detected_info.get('iterm2_installed', False))
581
+
582
+ if platform_name.lower() == "darwin" and iterm2_installed:
583
+ default_backend = "tmux" if tmux_installed else "iterm2-native"
584
+ backend = Prompt.ask(
585
+ "Backend",
586
+ choices=["tmux", "iterm2-native"],
587
+ default=default_backend,
588
+ )
589
+ return backend
590
+
591
+ return "tmux"
396
592
 
397
593
  def _generate_config(self) -> Dict[str, Any]:
398
594
  """Generate configuration dictionary."""
399
595
  # Use detected SSH key as default, fallback to common default if none found
400
596
  default_key = self.detected_info.get('default_ssh_key') or '~/.ssh/id_ed25519'
597
+ backend = self._select_backend()
401
598
 
402
599
  # Validate SSH port input
403
600
  while True:
@@ -431,6 +628,7 @@ class OnboardingWizard:
431
628
  "file": "logs/sshplex.log"
432
629
  },
433
630
  "tmux": {
631
+ "backend": backend,
434
632
  "control_with_iterm2": False
435
633
  },
436
634
  "ui": {
@@ -438,8 +636,60 @@ class OnboardingWizard:
438
636
  "table_columns": ["name", "ip", "cluster", "role", "tags", "description", "provider"]
439
637
  }
440
638
  }
639
+
640
+ if backend == "tmux" and not self.detected_info.get('tmux_installed'):
641
+ self.console.print(
642
+ "\n[yellow]⚠️ tmux backend selected but tmux was not detected. "
643
+ "Install tmux or switch to iTerm2 native backend on macOS.[/yellow]"
644
+ )
441
645
 
442
646
  return config
647
+
648
+ def _show_configuration_summary(self, config: Dict[str, Any]) -> None:
649
+ """Display final config summary before save."""
650
+ self.console.print("\n🧾 [bold]Configuration Summary[/bold]\n")
651
+
652
+ ssh = config.get("ssh", {})
653
+ tmux = config.get("tmux", {})
654
+ imports = list((config.get("sot", {}) or {}).get("import", []) or [])
655
+
656
+ summary = Table(show_header=False, box=None)
657
+ summary.add_column("Item", style="cyan")
658
+ summary.add_column("Value")
659
+ summary.add_row("Config Path", str(self.config_path))
660
+ summary.add_row("Backend", str(tmux.get("backend", "tmux")))
661
+ summary.add_row("SSH Username", str(ssh.get("username", "")))
662
+ summary.add_row("SSH Port", str(ssh.get("port", 22)))
663
+ summary.add_row("SSH Key", str(ssh.get("key_path", "")))
664
+ summary.add_row("Sources", str(len(imports)))
665
+ self.console.print(summary)
666
+
667
+ if imports:
668
+ provider_table = Table(show_header=True, box=None)
669
+ provider_table.add_column("Name", style="bold")
670
+ provider_table.add_column("Type", style="magenta")
671
+ provider_table.add_column("Details", style="dim")
672
+
673
+ for provider in imports:
674
+ provider_name = str(provider.get("name", "unnamed"))
675
+ provider_type = str(provider.get("type", "unknown"))
676
+ details = ""
677
+ if provider_type == "static":
678
+ details = f"hosts: {len(provider.get('hosts', []) or [])}"
679
+ elif provider_type == "ansible":
680
+ details = f"paths: {len(provider.get('inventory_paths', []) or [])}"
681
+ elif provider_type == "netbox":
682
+ details = str(provider.get("url", ""))
683
+ elif provider_type == "consul":
684
+ cfg = provider.get("config", {}) or {}
685
+ details = f"{cfg.get('scheme', 'http')}://{cfg.get('host', 'localhost')}:{cfg.get('port', 8500)}"
686
+ elif provider_type == "git":
687
+ details = str(provider.get("source_pattern", provider.get("path", "")))
688
+
689
+ provider_table.add_row(provider_name, provider_type, details)
690
+
691
+ self.console.print()
692
+ self.console.print(provider_table)
443
693
 
444
694
  def _save_config(self, config: Dict[str, Any]) -> bool:
445
695
  """Save configuration to file."""
@@ -4,12 +4,14 @@ from __future__ import annotations
4
4
 
5
5
  import time
6
6
  from concurrent.futures import ThreadPoolExecutor, as_completed
7
+ from pathlib import Path
7
8
  from typing import Any
8
9
 
9
10
  from ..cache import HostCache
10
11
  from ..logger import get_logger
11
12
  from .ansible import AnsibleProvider
12
13
  from .base import Host, SoTProvider
14
+ from .git import GitProvider
13
15
  from .netbox import NetBoxProvider
14
16
  from .static import StaticProvider
15
17
 
@@ -112,6 +114,8 @@ class SoTFactory:
112
114
  provider = self._create_ansible_provider_from_import(import_config)
113
115
  elif import_type == "consul":
114
116
  provider = self._create_consul_provider(import_config)
117
+ elif import_type == "git":
118
+ provider = self._create_git_provider(import_config)
115
119
  else:
116
120
  self.logger.error(f"Unknown SoT provider type: {import_type}")
117
121
  continue
@@ -171,6 +175,18 @@ class SoTFactory:
171
175
  import_config=import_config
172
176
  )
173
177
 
178
+ def _create_git_provider(self, import_config: Any) -> GitProvider | None:
179
+ """Create Git provider instance from import configuration."""
180
+ repo_url = str(getattr(import_config, "repo_url", "") or "").strip()
181
+ if not repo_url:
182
+ self.logger.error(f"Git provider '{import_config.name}' missing required repo_url")
183
+ return None
184
+
185
+ cache_root = Path(getattr(self.config.cache, "cache_dir", "~/.cache/sshplex")).expanduser()
186
+ git_cache_dir = cache_root / "git"
187
+
188
+ return GitProvider(import_config=import_config, cache_dir=str(git_cache_dir))
189
+
174
190
  def _create_netbox_provider_from_import(self, import_config: Any) -> NetBoxProvider | None:
175
191
  """Create NetBox provider instance from import configuration.
176
192
 
@@ -551,3 +567,72 @@ class SoTFactory:
551
567
  True if cache is valid, False otherwise
552
568
  """
553
569
  return self.cache.is_cache_valid()
570
+
571
+ def sync_git_sources(self, force: bool = False) -> list[dict[str, Any]]:
572
+ """Sync configured git providers and return per-provider statuses."""
573
+ results: list[dict[str, Any]] = []
574
+ providers_to_sync = [provider for provider in self.providers if isinstance(provider, GitProvider)]
575
+
576
+ if not providers_to_sync:
577
+ providers_to_sync = self._initialize_git_providers_for_sync()
578
+
579
+ for provider in providers_to_sync:
580
+ if not isinstance(provider, GitProvider):
581
+ continue
582
+ try:
583
+ status = provider.sync(force=force)
584
+ results.append(status)
585
+ except Exception as e:
586
+ provider_name = getattr(provider, "provider_name", type(provider).__name__)
587
+ self.logger.error(f"Git sync failed for {provider_name}: {e}")
588
+ results.append(
589
+ {
590
+ "provider": provider_name,
591
+ "status": "error",
592
+ "message": str(e),
593
+ "old_commit": None,
594
+ "new_commit": None,
595
+ "changed_files": 0,
596
+ }
597
+ )
598
+ return results
599
+
600
+ def _initialize_git_providers_for_sync(self) -> list[GitProvider]:
601
+ """Initialize git providers directly from config for sync-only operations."""
602
+ initialized: list[GitProvider] = []
603
+ configured_imports = list(getattr(self.config.sot, "import_", []) or [])
604
+ if not configured_imports:
605
+ return initialized
606
+
607
+ enabled_provider_types = {
608
+ str(provider_type).strip()
609
+ for provider_type in (getattr(self.config.sot, "providers", []) or [])
610
+ if str(provider_type).strip()
611
+ }
612
+
613
+ providers_explicitly_set = True
614
+ if hasattr(self.config.sot, "model_fields_set"):
615
+ providers_explicitly_set = "providers" in getattr(self.config.sot, "model_fields_set", set())
616
+
617
+ if not providers_explicitly_set:
618
+ enabled_provider_types = {
619
+ str(getattr(import_config, "type", "")).strip()
620
+ for import_config in configured_imports
621
+ if str(getattr(import_config, "type", "")).strip()
622
+ }
623
+
624
+ for import_config in configured_imports:
625
+ import_type = str(getattr(import_config, "type", "")).strip()
626
+ if import_type != "git":
627
+ continue
628
+
629
+ if enabled_provider_types and import_type not in enabled_provider_types:
630
+ continue
631
+
632
+ provider = self._create_git_provider(import_config)
633
+ if provider is None:
634
+ continue
635
+ if provider.connect():
636
+ initialized.append(provider)
637
+
638
+ return initialized