db-sync-tool-kmi 2.11.6__py3-none-any.whl → 3.0.2__py3-none-any.whl
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.
- db_sync_tool/__main__.py +7 -252
- db_sync_tool/cli.py +733 -0
- db_sync_tool/database/process.py +94 -111
- db_sync_tool/database/utility.py +339 -121
- db_sync_tool/info.py +1 -1
- db_sync_tool/recipes/drupal.py +87 -12
- db_sync_tool/recipes/laravel.py +7 -6
- db_sync_tool/recipes/parsing.py +102 -0
- db_sync_tool/recipes/symfony.py +17 -28
- db_sync_tool/recipes/typo3.py +33 -54
- db_sync_tool/recipes/wordpress.py +13 -12
- db_sync_tool/remote/client.py +206 -71
- db_sync_tool/remote/file_transfer.py +303 -0
- db_sync_tool/remote/rsync.py +18 -15
- db_sync_tool/remote/system.py +2 -3
- db_sync_tool/remote/transfer.py +51 -47
- db_sync_tool/remote/utility.py +29 -30
- db_sync_tool/sync.py +52 -28
- db_sync_tool/utility/config.py +367 -0
- db_sync_tool/utility/config_resolver.py +573 -0
- db_sync_tool/utility/console.py +779 -0
- db_sync_tool/utility/exceptions.py +32 -0
- db_sync_tool/utility/helper.py +155 -148
- db_sync_tool/utility/info.py +53 -20
- db_sync_tool/utility/log.py +55 -31
- db_sync_tool/utility/logging_config.py +410 -0
- db_sync_tool/utility/mode.py +85 -150
- db_sync_tool/utility/output.py +122 -51
- db_sync_tool/utility/parser.py +33 -53
- db_sync_tool/utility/pure.py +93 -0
- db_sync_tool/utility/security.py +79 -0
- db_sync_tool/utility/system.py +277 -194
- db_sync_tool/utility/validation.py +2 -9
- db_sync_tool_kmi-3.0.2.dist-info/METADATA +99 -0
- db_sync_tool_kmi-3.0.2.dist-info/RECORD +44 -0
- {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info}/WHEEL +1 -1
- db_sync_tool_kmi-2.11.6.dist-info/METADATA +0 -276
- db_sync_tool_kmi-2.11.6.dist-info/RECORD +0 -34
- {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info}/entry_points.txt +0 -0
- {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info/licenses}/LICENSE +0 -0
- {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Config Resolver Module
|
|
5
|
+
|
|
6
|
+
Provides automatic configuration discovery and interactive host selection.
|
|
7
|
+
|
|
8
|
+
Lookup order:
|
|
9
|
+
1. -f specified? → Load that file
|
|
10
|
+
2. .db-sync-tool/[name].yaml? → Load project config
|
|
11
|
+
3. ~/.db-sync-tool/hosts.yaml + name? → Use host reference
|
|
12
|
+
4. No args + .db-sync-tool/? → Interactive config selection
|
|
13
|
+
5. No args + ~/.db-sync-tool/? → Interactive host selection
|
|
14
|
+
6. Nothing found? → Error (as before)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
from rich.panel import Panel
|
|
25
|
+
from rich.prompt import Confirm, IntPrompt
|
|
26
|
+
|
|
27
|
+
from db_sync_tool.utility.exceptions import ConfigError, NoConfigFoundError
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger('db_sync_tool.config_resolver')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Directory names
|
|
33
|
+
GLOBAL_CONFIG_DIR = '.db-sync-tool'
|
|
34
|
+
PROJECT_CONFIG_DIR = '.db-sync-tool'
|
|
35
|
+
|
|
36
|
+
# File names
|
|
37
|
+
HOSTS_FILE = 'hosts.yaml'
|
|
38
|
+
DEFAULTS_FILE = 'defaults.yaml'
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class HostDefinition:
|
|
43
|
+
"""A single host definition from hosts.yaml."""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
host: str | None = None
|
|
47
|
+
user: str | None = None
|
|
48
|
+
path: str | None = None
|
|
49
|
+
port: int | None = None
|
|
50
|
+
ssh_key: str | None = None
|
|
51
|
+
protect: bool = False
|
|
52
|
+
db: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_dict(cls, name: str, data: dict[str, Any]) -> 'HostDefinition':
|
|
56
|
+
"""Create HostDefinition from dictionary."""
|
|
57
|
+
return cls(
|
|
58
|
+
name=name,
|
|
59
|
+
host=data.get('host'),
|
|
60
|
+
user=data.get('user'),
|
|
61
|
+
path=data.get('path'),
|
|
62
|
+
port=data.get('port'),
|
|
63
|
+
ssh_key=data.get('ssh_key'),
|
|
64
|
+
protect=data.get('protect', False),
|
|
65
|
+
db=data.get('db', {}),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def to_client_config(self) -> dict[str, Any]:
|
|
69
|
+
"""Convert to client configuration dictionary."""
|
|
70
|
+
config: dict[str, Any] = {}
|
|
71
|
+
if self.host:
|
|
72
|
+
config['host'] = self.host
|
|
73
|
+
if self.user:
|
|
74
|
+
config['user'] = self.user
|
|
75
|
+
if self.path:
|
|
76
|
+
config['path'] = self.path
|
|
77
|
+
if self.port:
|
|
78
|
+
config['port'] = self.port
|
|
79
|
+
if self.ssh_key:
|
|
80
|
+
config['ssh_key'] = self.ssh_key
|
|
81
|
+
if self.db:
|
|
82
|
+
config['db'] = self.db
|
|
83
|
+
return config
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def is_remote(self) -> bool:
|
|
87
|
+
"""Check if this host is remote (has SSH host)."""
|
|
88
|
+
return self.host is not None
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def display_name(self) -> str:
|
|
92
|
+
"""Get display name for UI."""
|
|
93
|
+
if self.host:
|
|
94
|
+
return f"{self.name} ({self.host})"
|
|
95
|
+
return f"{self.name} (local)"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class ProjectConfig:
|
|
100
|
+
"""A project-specific sync configuration."""
|
|
101
|
+
|
|
102
|
+
name: str
|
|
103
|
+
file_path: Path
|
|
104
|
+
origin: str | dict[str, Any] | None = None
|
|
105
|
+
target: str | dict[str, Any] | None = None
|
|
106
|
+
config: dict[str, Any] = field(default_factory=dict)
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def from_file(cls, file_path: Path) -> 'ProjectConfig':
|
|
110
|
+
"""Load project config from YAML file."""
|
|
111
|
+
with open(file_path) as f:
|
|
112
|
+
data = yaml.safe_load(f) or {}
|
|
113
|
+
|
|
114
|
+
return cls(
|
|
115
|
+
name=file_path.stem,
|
|
116
|
+
file_path=file_path,
|
|
117
|
+
origin=data.get('origin'),
|
|
118
|
+
target=data.get('target'),
|
|
119
|
+
config=data,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def get_description(self, hosts: dict[str, HostDefinition]) -> str:
|
|
123
|
+
"""Get human-readable description of the sync."""
|
|
124
|
+
origin_name = self._get_endpoint_name(self.origin, hosts)
|
|
125
|
+
target_name = self._get_endpoint_name(self.target, hosts)
|
|
126
|
+
return f"{origin_name} → {target_name}"
|
|
127
|
+
|
|
128
|
+
def _get_endpoint_name(
|
|
129
|
+
self, endpoint: str | dict[str, Any] | None, hosts: dict[str, HostDefinition]
|
|
130
|
+
) -> str:
|
|
131
|
+
"""Get display name for an endpoint."""
|
|
132
|
+
if endpoint is None:
|
|
133
|
+
return "?"
|
|
134
|
+
if isinstance(endpoint, str):
|
|
135
|
+
if endpoint in hosts:
|
|
136
|
+
return hosts[endpoint].display_name
|
|
137
|
+
return endpoint
|
|
138
|
+
if isinstance(endpoint, dict):
|
|
139
|
+
if 'host' in endpoint:
|
|
140
|
+
return endpoint.get('name', endpoint['host'])
|
|
141
|
+
return endpoint.get('name', 'local')
|
|
142
|
+
return str(endpoint)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class ResolvedConfig:
|
|
147
|
+
"""Result of config resolution."""
|
|
148
|
+
|
|
149
|
+
config_file: Path | None = None
|
|
150
|
+
origin_config: dict[str, Any] = field(default_factory=dict)
|
|
151
|
+
target_config: dict[str, Any] = field(default_factory=dict)
|
|
152
|
+
merged_config: dict[str, Any] = field(default_factory=dict)
|
|
153
|
+
source: str = "" # Description of where config came from
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class ConfigResolver:
|
|
157
|
+
"""
|
|
158
|
+
Resolves configuration from multiple sources.
|
|
159
|
+
|
|
160
|
+
Supports:
|
|
161
|
+
- Explicit config file (-f config.yaml)
|
|
162
|
+
- Project configs (.db-sync-tool/*.yaml)
|
|
163
|
+
- Global hosts (~/.db-sync-tool/hosts.yaml)
|
|
164
|
+
- Interactive selection when no args provided
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
def __init__(self, console: Console | None = None):
|
|
168
|
+
"""
|
|
169
|
+
Initialize ConfigResolver.
|
|
170
|
+
|
|
171
|
+
:param console: Rich console for interactive prompts
|
|
172
|
+
"""
|
|
173
|
+
self.console = console or Console()
|
|
174
|
+
self._global_dir: Path | None = None
|
|
175
|
+
self._project_dir: Path | None = None
|
|
176
|
+
self._global_hosts: dict[str, HostDefinition] = {}
|
|
177
|
+
self._global_defaults: dict[str, Any] = {}
|
|
178
|
+
self._project_defaults: dict[str, Any] = {}
|
|
179
|
+
self._project_configs: dict[str, ProjectConfig] = {}
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def global_config_dir(self) -> Path:
|
|
183
|
+
"""Get global config directory (~/.db-sync-tool/)."""
|
|
184
|
+
if self._global_dir is None:
|
|
185
|
+
self._global_dir = Path.home() / GLOBAL_CONFIG_DIR
|
|
186
|
+
return self._global_dir
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def project_config_dir(self) -> Path | None:
|
|
190
|
+
"""Get project config directory (.db-sync-tool/ in cwd or parents)."""
|
|
191
|
+
if self._project_dir is None:
|
|
192
|
+
self._project_dir = self._find_project_config_dir()
|
|
193
|
+
return self._project_dir
|
|
194
|
+
|
|
195
|
+
def _find_project_config_dir(self) -> Path | None:
|
|
196
|
+
"""Search for .db-sync-tool/ directory in cwd and parents."""
|
|
197
|
+
cwd = Path.cwd()
|
|
198
|
+
for parent in [cwd, *cwd.parents]:
|
|
199
|
+
project_dir = parent / PROJECT_CONFIG_DIR
|
|
200
|
+
if project_dir.is_dir():
|
|
201
|
+
return project_dir
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
def load_global_config(self) -> None:
|
|
205
|
+
"""Load global hosts and defaults from ~/.db-sync-tool/."""
|
|
206
|
+
if not self.global_config_dir.is_dir():
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
# Load hosts.yaml
|
|
210
|
+
hosts_file = self.global_config_dir / HOSTS_FILE
|
|
211
|
+
if hosts_file.is_file():
|
|
212
|
+
with open(hosts_file) as f:
|
|
213
|
+
data = yaml.safe_load(f) or {}
|
|
214
|
+
self._global_hosts = {
|
|
215
|
+
name: HostDefinition.from_dict(name, config)
|
|
216
|
+
for name, config in data.items()
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
# Load defaults.yaml
|
|
220
|
+
defaults_file = self.global_config_dir / DEFAULTS_FILE
|
|
221
|
+
if defaults_file.is_file():
|
|
222
|
+
with open(defaults_file) as f:
|
|
223
|
+
self._global_defaults = yaml.safe_load(f) or {}
|
|
224
|
+
|
|
225
|
+
def load_project_config(self) -> None:
|
|
226
|
+
"""Load project configs from .db-sync-tool/."""
|
|
227
|
+
if self.project_config_dir is None:
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
# Load project defaults.yaml
|
|
231
|
+
defaults_file = self.project_config_dir / DEFAULTS_FILE
|
|
232
|
+
if defaults_file.is_file():
|
|
233
|
+
with open(defaults_file) as f:
|
|
234
|
+
self._project_defaults = yaml.safe_load(f) or {}
|
|
235
|
+
|
|
236
|
+
# Load all project config files (*.yaml except defaults.yaml)
|
|
237
|
+
for config_file in self.project_config_dir.glob('*.yaml'):
|
|
238
|
+
if config_file.name == DEFAULTS_FILE:
|
|
239
|
+
continue
|
|
240
|
+
try:
|
|
241
|
+
project_config = ProjectConfig.from_file(config_file)
|
|
242
|
+
self._project_configs[project_config.name] = project_config
|
|
243
|
+
except Exception as e:
|
|
244
|
+
logger.warning(
|
|
245
|
+
f"Failed to load project config '{config_file}': {e}"
|
|
246
|
+
)
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
# Also check *.yml files
|
|
250
|
+
for config_file in self.project_config_dir.glob('*.yml'):
|
|
251
|
+
if config_file.name == 'defaults.yml':
|
|
252
|
+
continue
|
|
253
|
+
try:
|
|
254
|
+
project_config = ProjectConfig.from_file(config_file)
|
|
255
|
+
self._project_configs[project_config.name] = project_config
|
|
256
|
+
except Exception as e:
|
|
257
|
+
logger.warning(
|
|
258
|
+
f"Failed to load project config '{config_file}': {e}"
|
|
259
|
+
)
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
def resolve(
|
|
263
|
+
self,
|
|
264
|
+
config_file: str | None = None,
|
|
265
|
+
origin: str | None = None,
|
|
266
|
+
target: str | None = None,
|
|
267
|
+
interactive: bool = True,
|
|
268
|
+
) -> ResolvedConfig:
|
|
269
|
+
"""
|
|
270
|
+
Resolve configuration from available sources.
|
|
271
|
+
|
|
272
|
+
Lookup order:
|
|
273
|
+
1. Explicit config file (-f)
|
|
274
|
+
2. Project config by name
|
|
275
|
+
3. Host references (origin/target names)
|
|
276
|
+
4. Interactive selection
|
|
277
|
+
|
|
278
|
+
:param config_file: Explicit config file path
|
|
279
|
+
:param origin: Origin host name or None
|
|
280
|
+
:param target: Target host name or None
|
|
281
|
+
:param interactive: Allow interactive prompts
|
|
282
|
+
:return: ResolvedConfig with merged configuration
|
|
283
|
+
"""
|
|
284
|
+
# Load available configs
|
|
285
|
+
self.load_global_config()
|
|
286
|
+
self.load_project_config()
|
|
287
|
+
|
|
288
|
+
# 1. Explicit config file takes priority
|
|
289
|
+
if config_file:
|
|
290
|
+
return self._resolve_explicit_file(Path(config_file))
|
|
291
|
+
|
|
292
|
+
# 2. Check if origin arg matches a project config name
|
|
293
|
+
if origin and not target and origin in self._project_configs:
|
|
294
|
+
return self._resolve_project_config(self._project_configs[origin])
|
|
295
|
+
|
|
296
|
+
# 3. Origin and target specified as host names
|
|
297
|
+
if origin and target:
|
|
298
|
+
return self._resolve_host_references(origin, target)
|
|
299
|
+
|
|
300
|
+
# 4. Interactive selection
|
|
301
|
+
if interactive:
|
|
302
|
+
return self._resolve_interactive()
|
|
303
|
+
|
|
304
|
+
raise NoConfigFoundError(
|
|
305
|
+
'Configuration is missing, use a separate file or provide host parameter'
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def _resolve_explicit_file(self, config_file: Path) -> ResolvedConfig:
|
|
309
|
+
"""Resolve configuration from explicit file."""
|
|
310
|
+
if not config_file.is_file():
|
|
311
|
+
raise ConfigError(f'Configuration file not found: {config_file}')
|
|
312
|
+
|
|
313
|
+
return ResolvedConfig(
|
|
314
|
+
config_file=config_file,
|
|
315
|
+
source=f"explicit file: {config_file}",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def _resolve_project_config(self, project: ProjectConfig) -> ResolvedConfig:
|
|
319
|
+
"""Resolve configuration from project config."""
|
|
320
|
+
# Start with global defaults, then project defaults, then project config
|
|
321
|
+
merged = self._merge_defaults(project.config)
|
|
322
|
+
|
|
323
|
+
# Resolve host references in origin/target
|
|
324
|
+
origin_config = self._resolve_endpoint(project.origin)
|
|
325
|
+
target_config = self._resolve_endpoint(project.target)
|
|
326
|
+
|
|
327
|
+
return ResolvedConfig(
|
|
328
|
+
config_file=project.file_path,
|
|
329
|
+
origin_config=origin_config,
|
|
330
|
+
target_config=target_config,
|
|
331
|
+
merged_config=merged,
|
|
332
|
+
source=f"project config: {project.name}",
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
def _resolve_host_references(self, origin: str, target: str) -> ResolvedConfig:
|
|
336
|
+
"""Resolve configuration from host name references."""
|
|
337
|
+
if origin not in self._global_hosts:
|
|
338
|
+
raise ConfigError(
|
|
339
|
+
f"Host '{origin}' not found in {self.global_config_dir / HOSTS_FILE}"
|
|
340
|
+
)
|
|
341
|
+
if target not in self._global_hosts:
|
|
342
|
+
raise ConfigError(
|
|
343
|
+
f"Host '{target}' not found in {self.global_config_dir / HOSTS_FILE}"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
origin_host = self._global_hosts[origin]
|
|
347
|
+
target_host = self._global_hosts[target]
|
|
348
|
+
|
|
349
|
+
# Check protect flag
|
|
350
|
+
if target_host.protect:
|
|
351
|
+
self._warn_protected_target(target_host)
|
|
352
|
+
|
|
353
|
+
merged = self._merge_defaults({})
|
|
354
|
+
|
|
355
|
+
return ResolvedConfig(
|
|
356
|
+
origin_config=origin_host.to_client_config(),
|
|
357
|
+
target_config=target_host.to_client_config(),
|
|
358
|
+
merged_config=merged,
|
|
359
|
+
source=f"host references: {origin} → {target}",
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def _resolve_endpoint(self, endpoint: str | dict[str, Any] | None) -> dict[str, Any]:
|
|
363
|
+
"""Resolve a single endpoint (origin or target)."""
|
|
364
|
+
if endpoint is None:
|
|
365
|
+
return {}
|
|
366
|
+
if isinstance(endpoint, str):
|
|
367
|
+
# Host reference
|
|
368
|
+
if endpoint in self._global_hosts:
|
|
369
|
+
return self._global_hosts[endpoint].to_client_config()
|
|
370
|
+
raise ConfigError(
|
|
371
|
+
f"Host '{endpoint}' not found in {self.global_config_dir / HOSTS_FILE}"
|
|
372
|
+
)
|
|
373
|
+
if isinstance(endpoint, dict):
|
|
374
|
+
return endpoint
|
|
375
|
+
return {}
|
|
376
|
+
|
|
377
|
+
def _resolve_interactive(self) -> ResolvedConfig:
|
|
378
|
+
"""Interactive config/host selection."""
|
|
379
|
+
# Check for project configs first
|
|
380
|
+
if self._project_configs:
|
|
381
|
+
return self._interactive_project_selection()
|
|
382
|
+
|
|
383
|
+
# Fall back to global hosts
|
|
384
|
+
if self._global_hosts:
|
|
385
|
+
return self._interactive_host_selection()
|
|
386
|
+
|
|
387
|
+
raise NoConfigFoundError(
|
|
388
|
+
"No configuration found. Create .db-sync-tool/ or ~/.db-sync-tool/ "
|
|
389
|
+
"with config files, or use -f to specify a config file."
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def _interactive_project_selection(self) -> ResolvedConfig:
|
|
393
|
+
"""Interactive selection from project configs."""
|
|
394
|
+
self.console.print()
|
|
395
|
+
self.console.print(
|
|
396
|
+
Panel(
|
|
397
|
+
f"Project configs found: [cyan]{self.project_config_dir}[/cyan]",
|
|
398
|
+
title="db-sync-tool",
|
|
399
|
+
border_style="cyan",
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
self.console.print()
|
|
403
|
+
|
|
404
|
+
# List available configs
|
|
405
|
+
configs = list(self._project_configs.values())
|
|
406
|
+
for i, cfg in enumerate(configs, 1):
|
|
407
|
+
desc = cfg.get_description(self._global_hosts)
|
|
408
|
+
self.console.print(f" [bold cyan][{i}][/bold cyan] {cfg.name:12} {desc}")
|
|
409
|
+
|
|
410
|
+
self.console.print()
|
|
411
|
+
|
|
412
|
+
# Get user selection
|
|
413
|
+
choice = IntPrompt.ask(
|
|
414
|
+
"Selection",
|
|
415
|
+
choices=[str(i) for i in range(1, len(configs) + 1)],
|
|
416
|
+
console=self.console,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
selected = configs[choice - 1]
|
|
420
|
+
self.console.print()
|
|
421
|
+
|
|
422
|
+
# Show preview and confirm
|
|
423
|
+
self._show_sync_preview(selected)
|
|
424
|
+
|
|
425
|
+
if not Confirm.ask("Continue?", default=False, console=self.console):
|
|
426
|
+
raise ConfigError("Aborted by user")
|
|
427
|
+
|
|
428
|
+
return self._resolve_project_config(selected)
|
|
429
|
+
|
|
430
|
+
def _interactive_host_selection(self) -> ResolvedConfig:
|
|
431
|
+
"""Interactive selection from global hosts."""
|
|
432
|
+
self.console.print()
|
|
433
|
+
self.console.print(
|
|
434
|
+
Panel(
|
|
435
|
+
f"Global hosts: [cyan]{self.global_config_dir / HOSTS_FILE}[/cyan]",
|
|
436
|
+
title="db-sync-tool",
|
|
437
|
+
border_style="cyan",
|
|
438
|
+
)
|
|
439
|
+
)
|
|
440
|
+
self.console.print()
|
|
441
|
+
|
|
442
|
+
# List available hosts
|
|
443
|
+
hosts = list(self._global_hosts.values())
|
|
444
|
+
for i, host in enumerate(hosts, 1):
|
|
445
|
+
protect_marker = " [red](protected)[/red]" if host.protect else ""
|
|
446
|
+
self.console.print(f" [bold cyan][{i}][/bold cyan] {host.display_name}{protect_marker}")
|
|
447
|
+
|
|
448
|
+
self.console.print()
|
|
449
|
+
|
|
450
|
+
# Get origin selection
|
|
451
|
+
self.console.print("[bold]Origin (source):[/bold]")
|
|
452
|
+
origin_choice = IntPrompt.ask(
|
|
453
|
+
"Selection",
|
|
454
|
+
choices=[str(i) for i in range(1, len(hosts) + 1)],
|
|
455
|
+
console=self.console,
|
|
456
|
+
)
|
|
457
|
+
origin_host = hosts[origin_choice - 1]
|
|
458
|
+
|
|
459
|
+
self.console.print()
|
|
460
|
+
|
|
461
|
+
# Get target selection
|
|
462
|
+
self.console.print("[bold]Target (destination):[/bold]")
|
|
463
|
+
target_choice = IntPrompt.ask(
|
|
464
|
+
"Selection",
|
|
465
|
+
choices=[str(i) for i in range(1, len(hosts) + 1)],
|
|
466
|
+
console=self.console,
|
|
467
|
+
)
|
|
468
|
+
target_host = hosts[target_choice - 1]
|
|
469
|
+
|
|
470
|
+
self.console.print()
|
|
471
|
+
|
|
472
|
+
# Check protect flag
|
|
473
|
+
if target_host.protect:
|
|
474
|
+
self._warn_protected_target(target_host)
|
|
475
|
+
|
|
476
|
+
# Confirm
|
|
477
|
+
self.console.print(
|
|
478
|
+
Panel(
|
|
479
|
+
f"[bold]{origin_host.display_name}[/bold] → [bold]{target_host.display_name}[/bold]",
|
|
480
|
+
title="Sync Preview",
|
|
481
|
+
border_style="yellow",
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
if not Confirm.ask("Continue?", default=False, console=self.console):
|
|
486
|
+
raise ConfigError("Aborted by user")
|
|
487
|
+
|
|
488
|
+
return self._resolve_host_references(origin_host.name, target_host.name)
|
|
489
|
+
|
|
490
|
+
def _show_sync_preview(self, project: ProjectConfig) -> None:
|
|
491
|
+
"""Show sync preview panel."""
|
|
492
|
+
desc = project.get_description(self._global_hosts)
|
|
493
|
+
|
|
494
|
+
# Check if origin is production
|
|
495
|
+
origin_name = (
|
|
496
|
+
project.origin if isinstance(project.origin, str) else None
|
|
497
|
+
)
|
|
498
|
+
warning = ""
|
|
499
|
+
if origin_name and origin_name in self._global_hosts:
|
|
500
|
+
origin_host = self._global_hosts[origin_name]
|
|
501
|
+
if origin_host.protect:
|
|
502
|
+
warning = "\n[yellow bold]Warning: Production system as source![/yellow bold]"
|
|
503
|
+
|
|
504
|
+
self.console.print(
|
|
505
|
+
Panel(
|
|
506
|
+
f"[bold]{desc}[/bold]{warning}",
|
|
507
|
+
title="Sync Preview",
|
|
508
|
+
border_style="yellow",
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
def _warn_protected_target(self, host: HostDefinition) -> None:
|
|
513
|
+
"""Warn about protected target and require confirmation."""
|
|
514
|
+
self.console.print()
|
|
515
|
+
self.console.print(
|
|
516
|
+
Panel(
|
|
517
|
+
f"[bold red]DANGER![/bold red]\n\n"
|
|
518
|
+
f"Target [bold]{host.display_name}[/bold] is marked as [bold red]protected[/bold red].\n"
|
|
519
|
+
f"This is typically a production system.\n\n"
|
|
520
|
+
f"Syncing to this target will [bold]overwrite[/bold] the database!",
|
|
521
|
+
title="Protected Target",
|
|
522
|
+
border_style="red",
|
|
523
|
+
)
|
|
524
|
+
)
|
|
525
|
+
self.console.print()
|
|
526
|
+
|
|
527
|
+
if not Confirm.ask(
|
|
528
|
+
"[bold red]Are you absolutely sure you want to continue?[/bold red]",
|
|
529
|
+
default=False,
|
|
530
|
+
console=self.console,
|
|
531
|
+
):
|
|
532
|
+
raise ConfigError("Aborted: Target is protected")
|
|
533
|
+
|
|
534
|
+
def _merge_defaults(self, config: dict[str, Any]) -> dict[str, Any]:
|
|
535
|
+
"""Merge global and project defaults with config."""
|
|
536
|
+
# Start with global defaults
|
|
537
|
+
merged: dict[str, Any] = dict(self._global_defaults)
|
|
538
|
+
|
|
539
|
+
# Apply project defaults
|
|
540
|
+
self._deep_merge(merged, self._project_defaults)
|
|
541
|
+
|
|
542
|
+
# Apply specific config
|
|
543
|
+
self._deep_merge(merged, config)
|
|
544
|
+
|
|
545
|
+
return merged
|
|
546
|
+
|
|
547
|
+
def _deep_merge(self, base: dict[str, Any], overlay: dict[str, Any]) -> None:
|
|
548
|
+
"""Deep merge overlay into base dict (in-place)."""
|
|
549
|
+
for key, value in overlay.items():
|
|
550
|
+
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
|
551
|
+
self._deep_merge(base[key], value)
|
|
552
|
+
else:
|
|
553
|
+
base[key] = value
|
|
554
|
+
|
|
555
|
+
def has_project_configs(self) -> bool:
|
|
556
|
+
"""Check if project configs are available."""
|
|
557
|
+
self.load_project_config()
|
|
558
|
+
return bool(self._project_configs)
|
|
559
|
+
|
|
560
|
+
def has_global_hosts(self) -> bool:
|
|
561
|
+
"""Check if global hosts are available."""
|
|
562
|
+
self.load_global_config()
|
|
563
|
+
return bool(self._global_hosts)
|
|
564
|
+
|
|
565
|
+
def get_project_config_names(self) -> list[str]:
|
|
566
|
+
"""Get list of available project config names."""
|
|
567
|
+
self.load_project_config()
|
|
568
|
+
return list(self._project_configs.keys())
|
|
569
|
+
|
|
570
|
+
def get_global_host_names(self) -> list[str]:
|
|
571
|
+
"""Get list of available global host names."""
|
|
572
|
+
self.load_global_config()
|
|
573
|
+
return list(self._global_hosts.keys())
|