sshplex 1.7.0__tar.gz → 1.8.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 (51) hide show
  1. {sshplex-1.7.0/sshplex.egg-info → sshplex-1.8.0}/PKG-INFO +4 -5
  2. {sshplex-1.7.0 → sshplex-1.8.0}/README.md +1 -1
  3. {sshplex-1.7.0 → sshplex-1.8.0}/pyproject.toml +3 -4
  4. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/__init__.py +1 -1
  5. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/cli.py +1 -1
  6. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/cache.py +29 -23
  7. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/config.py +85 -26
  8. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/onboarding/wizard.py +30 -51
  9. sshplex-1.8.0/sshplex/lib/sot/base.py +108 -0
  10. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/sot/factory.py +122 -84
  11. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/sot/git.py +8 -25
  12. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/sot/netbox.py +17 -2
  13. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/ui/config_editor.py +40 -64
  14. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/ui/host_selector.py +16 -5
  15. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/ui/session_manager.py +48 -48
  16. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/sshplex_connector.py +46 -21
  17. {sshplex-1.7.0 → sshplex-1.8.0/sshplex.egg-info}/PKG-INFO +4 -5
  18. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex.egg-info/SOURCES.txt +1 -0
  19. {sshplex-1.7.0 → sshplex-1.8.0}/tests/test_cache.py +7 -0
  20. {sshplex-1.7.0 → sshplex-1.8.0}/tests/test_config.py +0 -2
  21. sshplex-1.8.0/tests/test_sshplex_connector.py +84 -0
  22. sshplex-1.7.0/sshplex/lib/sot/base.py +0 -57
  23. {sshplex-1.7.0 → sshplex-1.8.0}/LICENSE +0 -0
  24. {sshplex-1.7.0 → sshplex-1.8.0}/MANIFEST.in +0 -0
  25. {sshplex-1.7.0 → sshplex-1.8.0}/setup.cfg +0 -0
  26. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/config-template.yaml +0 -0
  27. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/__init__.py +0 -0
  28. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/commands.py +0 -0
  29. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/logger.py +0 -0
  30. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/multiplexer/__init__.py +0 -0
  31. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/multiplexer/base.py +0 -0
  32. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/multiplexer/iterm2_native.py +0 -0
  33. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/multiplexer/tmux.py +0 -0
  34. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/onboarding/__init__.py +0 -0
  35. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/sot/__init__.py +0 -0
  36. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/sot/ansible.py +0 -0
  37. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/sot/consul.py +0 -0
  38. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/sot/static.py +0 -0
  39. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/ui/__init__.py +0 -0
  40. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/utils/__init__.py +0 -0
  41. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/utils/iterm2.py +0 -0
  42. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/lib/utils/ssh_config.py +0 -0
  43. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex/main.py +0 -0
  44. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex.egg-info/dependency_links.txt +0 -0
  45. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex.egg-info/entry_points.txt +0 -0
  46. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex.egg-info/requires.txt +0 -0
  47. {sshplex-1.7.0 → sshplex-1.8.0}/sshplex.egg-info/top_level.txt +0 -0
  48. {sshplex-1.7.0 → sshplex-1.8.0}/tests/test_commands.py +0 -0
  49. {sshplex-1.7.0 → sshplex-1.8.0}/tests/test_iterm2_session_manager.py +0 -0
  50. {sshplex-1.7.0 → sshplex-1.8.0}/tests/test_main.py +0 -0
  51. {sshplex-1.7.0 → sshplex-1.8.0}/tests/test_ui_config_editor_columns.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sshplex
3
- Version: 1.7.0
3
+ Version: 1.8.0
4
4
  Summary: Multiplex your SSH connections with style
5
5
  Author-email: MJAHED Sabri <contact@sabrimjahed.com>
6
6
  License: MIT
@@ -17,15 +17,14 @@ Classifier: License :: OSI Approved :: MIT License
17
17
  Classifier: Operating System :: POSIX :: Linux
18
18
  Classifier: Operating System :: MacOS
19
19
  Classifier: Programming Language :: Python :: 3
20
- Classifier: Programming Language :: Python :: 3.8
21
- Classifier: Programming Language :: Python :: 3.9
22
20
  Classifier: Programming Language :: Python :: 3.10
23
21
  Classifier: Programming Language :: Python :: 3.11
24
22
  Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
25
24
  Classifier: Topic :: System :: Networking
26
25
  Classifier: Topic :: System :: Systems Administration
27
26
  Classifier: Topic :: Terminals
28
- Requires-Python: >=3.8
27
+ Requires-Python: >=3.10
29
28
  Description-Content-Type: text/markdown
30
29
  License-File: LICENSE
31
30
  Requires-Dist: pynetbox==7.6.1
@@ -90,7 +89,7 @@ sshplex
90
89
 
91
90
  ### Prerequisites
92
91
 
93
- - Python 3.8+
92
+ - Python 3.10+
94
93
  - tmux (Linux/macOS) and/or iTerm2 (macOS)
95
94
  - SSH key configured for target hosts
96
95
 
@@ -37,7 +37,7 @@ sshplex
37
37
 
38
38
  ### Prerequisites
39
39
 
40
- - Python 3.8+
40
+ - Python 3.10+
41
41
  - tmux (Linux/macOS) and/or iTerm2 (macOS)
42
42
  - SSH key configured for target hosts
43
43
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sshplex"
7
- version = "1.7.0"
7
+ version = "1.8.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"
@@ -18,17 +18,16 @@ classifiers = [
18
18
  "Operating System :: POSIX :: Linux",
19
19
  "Operating System :: MacOS",
20
20
  "Programming Language :: Python :: 3",
21
- "Programming Language :: Python :: 3.8",
22
- "Programming Language :: Python :: 3.9",
23
21
  "Programming Language :: Python :: 3.10",
24
22
  "Programming Language :: Python :: 3.11",
25
23
  "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
26
25
  "Topic :: System :: Networking",
27
26
  "Topic :: System :: Systems Administration",
28
27
  "Topic :: Terminals",
29
28
  ]
30
29
  keywords = ["ssh", "tmux", "multiplexer", "netbox", "tui", "terminal"]
31
- requires-python = ">=3.8"
30
+ requires-python = ">=3.10"
32
31
  dependencies = [
33
32
  "pynetbox==7.6.1",
34
33
  "textual==8.0.0",
@@ -1,4 +1,4 @@
1
1
  """SSHplex - SSH Connection Multiplexer"""
2
- __version__ = "1.7.0"
2
+ __version__ = "1.8.0"
3
3
  __author__ = "MJAHED Sabri"
4
4
  __email__ = "contact@sabrimjahed.com"
@@ -143,7 +143,7 @@ def list_providers(config: Any, logger: Any) -> int:
143
143
  branch = provider.branch or "main"
144
144
  inventory_format = provider.inventory_format or "static"
145
145
  print(f" Repo: {provider.repo_url} [{branch}, {inventory_format}]")
146
- source_pattern = provider.source_pattern or f"{provider.path}/{provider.file_glob}"
146
+ source_pattern = provider.source_pattern or "hosts/**/*.y*ml"
147
147
  print(f" Source: {source_pattern}")
148
148
  elif provider.type == "static" and provider.hosts:
149
149
  print(f" Hosts: {len(provider.hosts)} defined")
@@ -1,6 +1,8 @@
1
1
  """SSHplex host cache management for optimized startup performance."""
2
2
 
3
+ import contextlib
3
4
  import os
5
+ import tempfile
4
6
  import threading
5
7
  from datetime import datetime, timedelta
6
8
  from pathlib import Path
@@ -28,9 +30,9 @@ class HostCache:
28
30
  self.logger = get_logger()
29
31
 
30
32
  if cache_dir is None:
31
- cache_dir = os.path.expanduser("~/.cache/sshplex")
33
+ cache_dir = "~/.cache/sshplex"
32
34
 
33
- self.cache_dir = Path(cache_dir)
35
+ self.cache_dir = Path(cache_dir).expanduser()
34
36
  self.cache_ttl = timedelta(hours=cache_ttl_hours)
35
37
  self.cache_file = self.cache_dir / "hosts.yaml"
36
38
  self.metadata_file = self.cache_dir / "cache_metadata.yaml"
@@ -70,19 +72,37 @@ class HostCache:
70
72
  with open(self.metadata_file) as f:
71
73
  metadata = yaml.safe_load(f)
72
74
 
73
- if not metadata or 'timestamp' not in metadata:
75
+ if not isinstance(metadata, dict) or 'timestamp' not in metadata:
74
76
  return False
75
77
 
76
78
  cache_time = datetime.fromisoformat(metadata['timestamp'])
77
79
  return datetime.now() - cache_time < self.cache_ttl
78
80
 
79
- except (yaml.YAMLError, ValueError, KeyError) as e:
81
+ except (yaml.YAMLError, ValueError, KeyError, OSError) as e:
80
82
  self.logger.warning(f"Failed to validate cache: {e}")
81
83
  return False
82
84
  except Exception as e:
83
85
  self.logger.error(f"Unexpected error validating cache: {e}")
84
86
  return False
85
87
 
88
+ def _atomic_write_yaml(self, file_path: Path, payload: Any) -> None:
89
+ """Write YAML payload atomically to avoid partial/corrupt cache writes."""
90
+ fd, temp_path_text = tempfile.mkstemp(
91
+ prefix=f".{file_path.name}.",
92
+ suffix=".tmp",
93
+ dir=str(self.cache_dir),
94
+ text=True,
95
+ )
96
+ temp_path = Path(temp_path_text)
97
+ try:
98
+ with os.fdopen(fd, "w") as handle:
99
+ yaml.safe_dump(payload, handle, default_flow_style=False, sort_keys=True)
100
+ os.replace(temp_path, file_path)
101
+ finally:
102
+ if temp_path.exists():
103
+ with contextlib.suppress(OSError):
104
+ temp_path.unlink()
105
+
86
106
  def save_hosts(self, hosts: List[Host], provider_info: Dict[str, Any]) -> bool:
87
107
  """Save hosts to cache with metadata.
88
108
 
@@ -95,19 +115,10 @@ class HostCache:
95
115
  """
96
116
  with self._lock:
97
117
  try:
98
- # Prepare hosts data for serialization
99
- hosts_data = []
100
- for host in hosts:
101
- host_dict = {
102
- 'name': host.name,
103
- 'ip': host.ip,
104
- 'metadata': host.metadata
105
- }
106
- hosts_data.append(host_dict)
118
+ hosts_data = [host.to_dict() for host in hosts]
107
119
 
108
120
  # Save hosts data
109
- with open(self.cache_file, 'w') as f:
110
- yaml.dump(hosts_data, f, default_flow_style=False, sort_keys=True)
121
+ self._atomic_write_yaml(self.cache_file, hosts_data)
111
122
 
112
123
  # Save metadata
113
124
  cache_metadata = {
@@ -117,8 +128,7 @@ class HostCache:
117
128
  'cache_version': '1.0'
118
129
  }
119
130
 
120
- with open(self.metadata_file, 'w') as f:
121
- yaml.dump(cache_metadata, f, default_flow_style=False, sort_keys=True)
131
+ self._atomic_write_yaml(self.metadata_file, cache_metadata)
122
132
 
123
133
  self.logger.info(f"Successfully cached {len(hosts)} hosts to {self.cache_file}")
124
134
  return True
@@ -155,12 +165,8 @@ class HostCache:
155
165
  if 'name' not in host_dict or 'ip' not in host_dict:
156
166
  self.logger.warning(f"Skipping host with missing required fields: {host_dict}")
157
167
  continue
158
-
159
- host = Host(
160
- name=host_dict['name'],
161
- ip=host_dict['ip'],
162
- **host_dict.get('metadata', {})
163
- )
168
+
169
+ host = Host.from_dict(host_dict)
164
170
  hosts.append(host)
165
171
 
166
172
  self.logger.info(f"Successfully loaded {len(hosts)} hosts from cache")
@@ -5,10 +5,27 @@ from pathlib import Path
5
5
  from typing import Any, Dict, List, Optional
6
6
 
7
7
  import yaml
8
- from pydantic import BaseModel, Field
8
+ from pydantic import BaseModel, Field, field_validator
9
9
 
10
10
  from .. import __version__
11
11
 
12
+ SUPPORTED_SOT_PROVIDER_TYPES = ("static", "netbox", "ansible", "consul", "git")
13
+ SOT_PROVIDER_LABELS = {
14
+ "static": "Static",
15
+ "netbox": "NetBox",
16
+ "ansible": "Ansible",
17
+ "consul": "Consul",
18
+ "git": "Git",
19
+ }
20
+
21
+ SUPPORTED_MUX_BACKENDS = ("tmux", "iterm2-native")
22
+ MUX_BACKEND_LABELS = {
23
+ "tmux": "tmux",
24
+ "iterm2-native": "iTerm2 Native",
25
+ }
26
+
27
+ SUPPORTED_GIT_INVENTORY_FORMATS = ("static", "ansible")
28
+
12
29
 
13
30
  class SSHplexConfig(BaseModel):
14
31
  """SSHplex main configuration."""
@@ -16,15 +33,6 @@ class SSHplexConfig(BaseModel):
16
33
  session_prefix: str = "sshplex"
17
34
 
18
35
 
19
- class NetBoxConfig(BaseModel):
20
- """NetBox connection configuration."""
21
- url: str = Field(..., description="NetBox instance URL")
22
- token: str = Field(..., description="NetBox API token")
23
- verify_ssl: bool = True
24
- timeout: int = 30
25
- default_filters: Dict[str, str] = Field(default_factory=dict)
26
-
27
-
28
36
  class LoggingConfig(BaseModel):
29
37
  """Logging configuration."""
30
38
  enabled: bool = True
@@ -42,7 +50,7 @@ class UIConfig(BaseModel):
42
50
  class Proxy(BaseModel):
43
51
  """ImportProxies configuration with defaults."""
44
52
  name: str = Field("", description="Proxy name")
45
- imports: list = Field([], description="List of imports that will use this proxy")
53
+ imports: list = Field(default_factory=list, description="List of imports that will use this proxy")
46
54
  host: str = Field("", description="Proxy host or ip")
47
55
  username: str = Field("", description="Proxy username")
48
56
  key_path: str = Field("", description="Proxy key")
@@ -109,6 +117,16 @@ class TmuxConfig(BaseModel):
109
117
  description="Split pattern for iTerm2 native: alternate, vertical, horizontal"
110
118
  )
111
119
 
120
+ @field_validator("backend")
121
+ @classmethod
122
+ def validate_backend_value(cls, value: str) -> str:
123
+ normalized = str(value or "").strip().lower()
124
+ if normalized not in SUPPORTED_MUX_BACKENDS:
125
+ raise ValueError(
126
+ f"Invalid backend: {value}. Must be one of: {list(SUPPORTED_MUX_BACKENDS)}"
127
+ )
128
+ return normalized
129
+
112
130
  def validate_backend_config(self) -> bool:
113
131
  """Validate backend configuration.
114
132
 
@@ -118,10 +136,9 @@ class TmuxConfig(BaseModel):
118
136
  import platform
119
137
 
120
138
  # Validate backend option
121
- valid_backends = ["tmux", "iterm2-native"]
122
- if self.backend not in valid_backends:
139
+ if self.backend not in SUPPORTED_MUX_BACKENDS:
123
140
  raise ValueError(
124
- f"Invalid backend: {self.backend}. Must be one of: {valid_backends}"
141
+ f"Invalid backend: {self.backend}. Must be one of: {list(SUPPORTED_MUX_BACKENDS)}"
125
142
  )
126
143
 
127
144
  # Validate iTerm2 native mode on macOS only
@@ -146,11 +163,6 @@ class TmuxConfig(BaseModel):
146
163
  return True
147
164
 
148
165
 
149
- class AnsibleConfig(BaseModel):
150
- """Ansible inventory configuration."""
151
- inventory_paths: List[str] = Field(default_factory=list, description="List of paths to Ansible inventory YAML files")
152
- default_filters: Dict[str, Any] = Field(default_factory=dict)
153
-
154
166
  class ConsulConfig(BaseModel):
155
167
  """Consul-specific configuration with defaults."""
156
168
  host: str = Field("consul.example.com", description="Consul host address")
@@ -164,7 +176,7 @@ class ConsulConfig(BaseModel):
164
176
  class SoTImportConfig(BaseModel):
165
177
  """Individual SoT import configuration."""
166
178
  name: str = Field(..., description="Unique name for this import")
167
- type: str = Field(..., description="Provider type: static, netbox, ansible, consul, git")
179
+ type: str = Field(..., description=f"Provider type: {', '.join(SUPPORTED_SOT_PROVIDER_TYPES)}")
168
180
 
169
181
  # Static provider fields
170
182
  hosts: Optional[List[Dict[str, Any]]] = None
@@ -185,23 +197,72 @@ class SoTImportConfig(BaseModel):
185
197
  # Git provider fields
186
198
  repo_url: Optional[str] = None
187
199
  branch: Optional[str] = "main"
188
- source_pattern: Optional[str] = None
189
- path: Optional[str] = "hosts"
190
- file_glob: Optional[str] = "**/*.y*ml"
200
+ source_pattern: Optional[str] = "hosts/**/*.y*ml"
191
201
  auto_pull: Optional[bool] = True
192
202
  pull_interval_seconds: Optional[int] = 300
193
203
  priority: Optional[int] = 100
194
204
  pull_strategy: Optional[str] = "ff-only"
195
205
  inventory_format: Optional[str] = "static"
196
206
 
207
+ @field_validator("type")
208
+ @classmethod
209
+ def validate_provider_type(cls, value: str) -> str:
210
+ normalized = str(value or "").strip().lower()
211
+ if normalized not in SUPPORTED_SOT_PROVIDER_TYPES:
212
+ raise ValueError(
213
+ f"Unsupported provider type '{value}'. "
214
+ f"Supported values: {list(SUPPORTED_SOT_PROVIDER_TYPES)}"
215
+ )
216
+ return normalized
217
+
218
+ @field_validator("pull_strategy")
219
+ @classmethod
220
+ def validate_pull_strategy(cls, value: Optional[str]) -> Optional[str]:
221
+ if value is None:
222
+ return value
223
+ normalized = str(value).strip().lower()
224
+ if normalized and normalized != "ff-only":
225
+ raise ValueError("git pull_strategy only supports 'ff-only'")
226
+ return normalized or "ff-only"
227
+
228
+ @field_validator("inventory_format")
229
+ @classmethod
230
+ def validate_inventory_format(cls, value: Optional[str]) -> Optional[str]:
231
+ if value is None:
232
+ return value
233
+ normalized = str(value).strip().lower()
234
+ if normalized not in SUPPORTED_GIT_INVENTORY_FORMATS:
235
+ raise ValueError(
236
+ f"Unsupported git inventory format '{value}'. "
237
+ f"Supported values: {list(SUPPORTED_GIT_INVENTORY_FORMATS)}"
238
+ )
239
+ return normalized
240
+
197
241
  class SoTConfig(BaseModel):
198
242
  """Source of Truth configuration."""
199
243
  providers: List[str] = Field(
200
- default_factory=lambda: ["static"],
244
+ default_factory=list,
201
245
  description="List of SoT providers to use: static, netbox, ansible, consul, git",
202
246
  )
203
247
  import_: List[SoTImportConfig] = Field(alias='import', default_factory=list, description="List of import configurations")
204
248
 
249
+ @field_validator("providers")
250
+ @classmethod
251
+ def validate_enabled_providers(cls, value: List[str]) -> List[str]:
252
+ normalized: List[str] = []
253
+ for provider in value or []:
254
+ provider_name = str(provider or "").strip().lower()
255
+ if not provider_name:
256
+ continue
257
+ if provider_name not in SUPPORTED_SOT_PROVIDER_TYPES:
258
+ raise ValueError(
259
+ f"Unsupported provider '{provider_name}'. "
260
+ f"Supported values: {list(SUPPORTED_SOT_PROVIDER_TYPES)}"
261
+ )
262
+ if provider_name not in normalized:
263
+ normalized.append(provider_name)
264
+ return normalized
265
+
205
266
 
206
267
  class CacheConfig(BaseModel):
207
268
  """Host cache configuration."""
@@ -214,8 +275,6 @@ class Config(BaseModel):
214
275
  """Main SSHplex configuration model."""
215
276
  sshplex: SSHplexConfig = Field(default_factory=SSHplexConfig)
216
277
  sot: SoTConfig = Field(default_factory=SoTConfig)
217
- netbox: Optional[NetBoxConfig] = None
218
- ansible_inventory: Optional[AnsibleConfig] = None
219
278
  ssh: SSHConfig = Field(default_factory=SSHConfig)
220
279
  tmux: TmuxConfig = Field(default_factory=TmuxConfig)
221
280
  logging: LoggingConfig = Field(default_factory=LoggingConfig)
@@ -14,7 +14,11 @@ from rich.prompt import Confirm, Prompt
14
14
  from rich.table import Table
15
15
  from rich.text import Text
16
16
 
17
- from ..config import Config
17
+ from ..config import (
18
+ SUPPORTED_MUX_BACKENDS,
19
+ SUPPORTED_SOT_PROVIDER_TYPES,
20
+ Config,
21
+ )
18
22
  from ..logger import get_logger
19
23
 
20
24
 
@@ -181,14 +185,18 @@ class OnboardingWizard:
181
185
  Provider configuration dict or None if cancelled
182
186
  """
183
187
  self.console.print("\n" + "─" * 60)
184
-
188
+
185
189
  # Provider type selection
190
+ provider_descriptions = {
191
+ "static": "Static host list (manual entry)",
192
+ "netbox": "NetBox (infrastructure source of truth)",
193
+ "ansible": "Ansible inventory file",
194
+ "consul": "HashiCorp Consul (service discovery)",
195
+ "git": "Git repository inventory (static or ansible YAML)",
196
+ }
186
197
  provider_types = [
187
- ("static", "Static host list (manual entry)"),
188
- ("netbox", "NetBox (infrastructure source of truth)"),
189
- ("ansible", "Ansible inventory file"),
190
- ("consul", "HashiCorp Consul (service discovery)"),
191
- ("git", "Git repository inventory (static or ansible YAML)"),
198
+ (provider_type, provider_descriptions.get(provider_type, provider_type))
199
+ for provider_type in SUPPORTED_SOT_PROVIDER_TYPES
192
200
  ]
193
201
 
194
202
  self.console.print("\n[bold]Select inventory source type:[/bold]")
@@ -197,20 +205,19 @@ class OnboardingWizard:
197
205
 
198
206
  choice = Prompt.ask("\nChoice", choices=[str(i) for i in range(1, len(provider_types) + 1)], default="1")
199
207
  provider_type = provider_types[int(choice) - 1][0]
200
-
201
- # Configure based on type
202
- if provider_type == "static":
203
- return self._configure_static()
204
- elif provider_type == "netbox":
205
- return self._configure_netbox()
206
- elif provider_type == "ansible":
207
- return self._configure_ansible()
208
- elif provider_type == "consul":
209
- return self._configure_consul()
210
- elif provider_type == "git":
211
- return self._configure_git()
212
-
213
- return None
208
+
209
+ configurators = {
210
+ "static": self._configure_static,
211
+ "netbox": self._configure_netbox,
212
+ "ansible": self._configure_ansible,
213
+ "consul": self._configure_consul,
214
+ "git": self._configure_git,
215
+ }
216
+ handler = configurators.get(provider_type)
217
+ if handler is None:
218
+ self.console.print(f"[red]Unsupported provider type: {provider_type}[/red]")
219
+ return None
220
+ return handler()
214
221
 
215
222
  def _configure_static(self) -> Optional[Dict[str, Any]]:
216
223
  """Configure static host provider."""
@@ -414,16 +421,12 @@ class OnboardingWizard:
414
421
  except ValueError:
415
422
  self.console.print(f"[red]Invalid interval: {interval_input}[/red]")
416
423
 
417
- path, file_glob = self._split_source_pattern_legacy(source_pattern)
418
-
419
424
  config: Dict[str, Any] = {
420
425
  "name": name,
421
426
  "type": "git",
422
427
  "repo_url": repo_url,
423
428
  "branch": branch,
424
429
  "source_pattern": source_pattern,
425
- "path": path,
426
- "file_glob": file_glob,
427
430
  "inventory_format": inventory_format,
428
431
  "priority": priority,
429
432
  "auto_pull": auto_pull,
@@ -451,30 +454,6 @@ class OnboardingWizard:
451
454
 
452
455
  return config
453
456
 
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:])
477
-
478
457
  def _test_netbox_connection(self, config: Dict[str, Any]) -> bool:
479
458
  """Test NetBox connection."""
480
459
  self.logger.info(f"Testing NetBox connection to {config['url']}")
@@ -583,7 +562,7 @@ class OnboardingWizard:
583
562
  default_backend = "tmux" if tmux_installed else "iterm2-native"
584
563
  backend = Prompt.ask(
585
564
  "Backend",
586
- choices=["tmux", "iterm2-native"],
565
+ choices=list(SUPPORTED_MUX_BACKENDS),
587
566
  default=default_backend,
588
567
  )
589
568
  return backend
@@ -684,7 +663,7 @@ class OnboardingWizard:
684
663
  cfg = provider.get("config", {}) or {}
685
664
  details = f"{cfg.get('scheme', 'http')}://{cfg.get('host', 'localhost')}:{cfg.get('port', 8500)}"
686
665
  elif provider_type == "git":
687
- details = str(provider.get("source_pattern", provider.get("path", "")))
666
+ details = str(provider.get("source_pattern", ""))
688
667
 
689
668
  provider_table.add_row(provider_name, provider_type, details)
690
669
 
@@ -0,0 +1,108 @@
1
+ """Base classes for Source of Truth providers."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any, Dict, List, Optional
5
+
6
+
7
+ class Host:
8
+ """Simple host data structure."""
9
+
10
+ _RESERVED_METADATA_KEYS = {"name", "ip", "metadata"}
11
+
12
+ def __init__(
13
+ self,
14
+ name: str,
15
+ ip: str,
16
+ metadata: Optional[Dict[str, Any]] = None,
17
+ **kwargs: Any,
18
+ ) -> None:
19
+ self.name = name
20
+ self.ip = ip
21
+ self.metadata: Dict[str, Any] = {}
22
+
23
+ if metadata:
24
+ self.update_metadata(metadata)
25
+ if kwargs:
26
+ self.update_metadata(kwargs)
27
+
28
+ def update_metadata(self, values: Dict[str, Any]) -> None:
29
+ """Merge metadata values and mirror them as instance attributes."""
30
+ for raw_key, value in values.items():
31
+ key = str(raw_key)
32
+ if key in self._RESERVED_METADATA_KEYS:
33
+ continue
34
+ self.metadata[key] = value
35
+ setattr(self, key, value)
36
+
37
+ def merge_metadata(self, values: Dict[str, Any]) -> None:
38
+ """Compatibility helper to merge metadata values in-place."""
39
+ self.update_metadata(values)
40
+
41
+ def to_dict(self) -> Dict[str, Any]:
42
+ """Serialize host object to cache-friendly dictionary."""
43
+ return {
44
+ "name": self.name,
45
+ "ip": self.ip,
46
+ "metadata": dict(self.metadata),
47
+ }
48
+
49
+ @classmethod
50
+ def from_dict(cls, payload: Dict[str, Any]) -> "Host":
51
+ """Create host object from serialized dictionary payload."""
52
+ metadata = payload.get("metadata", {})
53
+ if not isinstance(metadata, dict):
54
+ metadata = {}
55
+
56
+ extras = {
57
+ key: value
58
+ for key, value in payload.items()
59
+ if key not in cls._RESERVED_METADATA_KEYS
60
+ }
61
+ merged_metadata = dict(metadata)
62
+ merged_metadata.update(extras)
63
+
64
+ return cls(
65
+ name=str(payload["name"]),
66
+ ip=str(payload["ip"]),
67
+ metadata=merged_metadata,
68
+ )
69
+
70
+ def __str__(self) -> str:
71
+ return f"{self.name} ({self.ip})"
72
+
73
+ def __repr__(self) -> str:
74
+ return f"Host(name='{self.name}', ip='{self.ip}')"
75
+
76
+
77
+ class SoTProvider(ABC):
78
+ """Abstract base class for Source of Truth providers."""
79
+
80
+ @abstractmethod
81
+ def connect(self) -> bool:
82
+ """Establish connection to the SoT provider.
83
+
84
+ Returns:
85
+ True if connection successful, False otherwise
86
+ """
87
+ pass
88
+
89
+ @abstractmethod
90
+ def get_hosts(self, filters: Optional[Dict[str, Any]] = None) -> List[Host]:
91
+ """Retrieve hosts from the SoT provider.
92
+
93
+ Args:
94
+ filters: Optional filters to apply
95
+
96
+ Returns:
97
+ List of Host objects
98
+ """
99
+ pass
100
+
101
+ @abstractmethod
102
+ def test_connection(self) -> bool:
103
+ """Test connection to the SoT provider.
104
+
105
+ Returns:
106
+ True if connection is healthy, False otherwise
107
+ """
108
+ pass