uhdeploy 0.1.0__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.
uhdeploy/cli.py ADDED
@@ -0,0 +1,1300 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ =============================================================================
4
+ 🚀 uhdeploy - Universal Deployer CLI
5
+ =============================================================================
6
+ A modern, declarative deployment tool for web applications, Docker containers,
7
+ and static websites to VPS instances. Supports Dockerfile-style step pipelines,
8
+ SSH/SFTP and FTP protocols, interactive configuration discovery, and full-color
9
+ terminal feedback.
10
+ =============================================================================
11
+ """
12
+
13
+ import os
14
+ import sys
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import argparse
19
+ import ftplib
20
+ from pathlib import Path
21
+
22
+ # Ensure UTF-8 output encoding for consoles (especially Windows)
23
+ if sys.platform == "win32":
24
+ import io
25
+ if hasattr(sys.stdout, "buffer"):
26
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
27
+ if hasattr(sys.stderr, "buffer"):
28
+ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
29
+
30
+ # Colorama for cross-platform ANSI colors
31
+ try:
32
+ import colorama
33
+ from colorama import Fore, Back, Style
34
+ colorama.init(autoreset=True)
35
+ except ImportError:
36
+ # Graceful fallback if colorama is missing
37
+ class DummyColor:
38
+ def __getattr__(self, name):
39
+ return ""
40
+ Fore = Back = Style = DummyColor()
41
+
42
+ # PyYAML
43
+ try:
44
+ import yaml
45
+ except ImportError:
46
+ print(f"{Fore.RED}❌ Error: The 'pyyaml' library is required to run uhdeploy.{Style.RESET_ALL}")
47
+ print("Please install it using: pip install pyyaml")
48
+ sys.exit(1)
49
+
50
+ # Paramiko for SSH / SFTP
51
+ try:
52
+ import paramiko
53
+ except ImportError:
54
+ paramiko = None
55
+
56
+
57
+ # =============================================================================
58
+ # 🎨 COLORFUL LOGGING & UI HELPERS
59
+ # =============================================================================
60
+
61
+ def banner():
62
+ lines = [
63
+ f"{Fore.CYAN}╔═══════════════════════════════════════════════════════════════════════════╗",
64
+ f"║ {Fore.CYAN}{Style.BRIGHT}🚀 U H D E P L O Y - U N I V E R S A L D E P L O Y E R {Style.NORMAL}{Fore.CYAN}║",
65
+ f"║ {Fore.YELLOW}Local-to-VPS Automated Deployment (SSH / SFTP / FTP / Docker) {Fore.CYAN}║",
66
+ f"╚═══════════════════════════════════════════════════════════════════════════╝{Style.RESET_ALL}"
67
+ ]
68
+ print("\n" + "\n".join(lines) + "\n")
69
+
70
+ def log_info(msg):
71
+ print(f"{Fore.CYAN}â„šī¸ {msg}{Style.RESET_ALL}")
72
+
73
+ def log_step(service, step_type, msg):
74
+ print(f"\n{Fore.MAGENTA}┌─ [{Fore.WHITE}{Style.BRIGHT}{service}{Style.NORMAL}{Fore.MAGENTA}] [{Fore.YELLOW}{step_type}{Fore.MAGENTA}]{Style.RESET_ALL}")
75
+ print(f"{Fore.MAGENTA}└─â–ļ {Fore.WHITE}{msg}{Style.RESET_ALL}")
76
+
77
+ def log_success(msg):
78
+ print(f"{Fore.GREEN}{Style.BRIGHT}✅ {msg}{Style.RESET_ALL}")
79
+
80
+ def log_warning(msg):
81
+ print(f"{Fore.YELLOW}âš ī¸ {msg}{Style.RESET_ALL}")
82
+
83
+ def log_error(msg):
84
+ print(f"{Fore.RED}{Style.BRIGHT}❌ {msg}{Style.RESET_ALL}", file=sys.stderr)
85
+
86
+ def log_cmd(cmd, kind="LOCAL"):
87
+ color = Fore.CYAN if kind == "LOCAL" else Fore.YELLOW
88
+ print(f"{color}[{kind}] > {Fore.WHITE}{cmd}{Style.RESET_ALL}")
89
+
90
+ def print_separator(char="─", length=75, color=Fore.CYAN):
91
+ print(f"{color}{char * length}{Style.RESET_ALL}")
92
+
93
+
94
+ # =============================================================================
95
+ # 🔍 CONFIGURATION FILE DISCOVERY
96
+ # =============================================================================
97
+
98
+ CONFIG_CANDIDATES = [
99
+ "deploy.yml",
100
+ "deploy.yaml",
101
+ "Deployfile.yml",
102
+ "Deployfile.yaml",
103
+ "Deployfile",
104
+ ".deploy.yml",
105
+ ".deploy.yaml",
106
+ ".env.deploy"
107
+ ]
108
+
109
+ def search_candidate_in_dir(directory):
110
+ if not os.path.isdir(directory):
111
+ return None
112
+ for name in CONFIG_CANDIDATES:
113
+ candidate = os.path.join(directory, name)
114
+ if os.path.isfile(candidate):
115
+ return candidate
116
+ return None
117
+
118
+ def find_config_interactive(cli_file=None, cli_project=None):
119
+ """
120
+ Finds the deployment configuration file following the required flow:
121
+ 1. Check CLI specified --file or --project
122
+ 2. Check current folder
123
+ 3. If not found, show message and ask for project path
124
+ 4. If not found in project path, ask for direct file path
125
+ """
126
+ # 1. Direct CLI file provided
127
+ if cli_file:
128
+ resolved = os.path.abspath(cli_file)
129
+ if os.path.isfile(resolved):
130
+ return resolved, os.path.dirname(resolved)
131
+ log_error(f"Config file specified with --file was not found: '{cli_file}'")
132
+ sys.exit(1)
133
+
134
+ # 2. CLI project directory provided
135
+ if cli_project:
136
+ resolved_proj = os.path.abspath(cli_project)
137
+ candidate = search_candidate_in_dir(resolved_proj)
138
+ if candidate:
139
+ return candidate, resolved_proj
140
+ log_error(f"No deploy config file found in project directory: '{cli_project}'")
141
+ sys.exit(1)
142
+
143
+ # 3. Check current folder
144
+ cwd = os.getcwd()
145
+ candidate = search_candidate_in_dir(cwd)
146
+ if candidate:
147
+ return candidate, cwd
148
+
149
+ # 4. Not found in current directory - Interactive prompt
150
+ log_warning(f"No deploy configuration file ({', '.join(CONFIG_CANDIDATES[:2])}) found in current directory:")
151
+ print(f" {Fore.LIGHTBLACK_EX}{cwd}{Style.RESET_ALL}")
152
+
153
+ while True:
154
+ try:
155
+ prompt = f"\n{Fore.CYAN}👉 Enter project path {Fore.LIGHTBLACK_EX}(or press Enter to specify file directly): {Style.RESET_ALL}"
156
+ proj_input = input(prompt).strip().strip('"').strip("'")
157
+ except (KeyboardInterrupt, EOFError):
158
+ print(f"\n{Fore.YELLOW}Deployment cancelled by user.{Style.RESET_ALL}")
159
+ sys.exit(0)
160
+
161
+ if not proj_input:
162
+ break
163
+
164
+ proj_dir = os.path.abspath(os.path.expanduser(proj_input))
165
+ if not os.path.isdir(proj_dir):
166
+ log_error(f"Project folder not found: '{proj_input}'. Please enter a valid directory.")
167
+ continue
168
+
169
+ candidate = search_candidate_in_dir(proj_dir)
170
+ if candidate:
171
+ log_success(f"Found deployment configuration in '{proj_dir}': {os.path.basename(candidate)}")
172
+ return candidate, proj_dir
173
+ else:
174
+ log_warning(f"No deploy configuration file found inside '{proj_input}'.")
175
+ break
176
+
177
+ # 5. Ask for direct file path
178
+ while True:
179
+ try:
180
+ prompt = f"\n{Fore.CYAN}👉 Enter deploy configuration file path {Fore.LIGHTBLACK_EX}(e.g. ./deploy.yml): {Style.RESET_ALL}"
181
+ file_input = input(prompt).strip().strip('"').strip("'")
182
+ except (KeyboardInterrupt, EOFError):
183
+ print(f"\n{Fore.YELLOW}Deployment cancelled by user.{Style.RESET_ALL}")
184
+ sys.exit(0)
185
+
186
+ if not file_input:
187
+ log_error("No file path entered. Cannot proceed with deployment.")
188
+ sys.exit(1)
189
+
190
+ file_path = os.path.abspath(os.path.expanduser(file_input))
191
+ if os.path.isfile(file_path):
192
+ log_success(f"Using configuration file: {file_path}")
193
+ return file_path, os.path.dirname(file_path)
194
+ else:
195
+ log_error(f"File not found: '{file_input}'. Please verify the path.")
196
+
197
+
198
+ # =============================================================================
199
+ # 📄 ENVIRONMENT VARIABLES & CONFIG PARSING
200
+ # =============================================================================
201
+
202
+ def load_env_file(filepath):
203
+ """Parses standard KEY=VALUE environment files into a dictionary."""
204
+ if not os.path.isfile(filepath):
205
+ return {}
206
+ env_vars = {}
207
+ with open(filepath, "r", encoding="utf-8", errors="replace") as f:
208
+ for line in f:
209
+ line = line.strip()
210
+ if not line or line.startswith("#"):
211
+ continue
212
+ if "=" in line:
213
+ key, val = line.split("=", 1)
214
+ val = val.strip().strip('"').strip("'")
215
+ env_vars[key.strip()] = val
216
+ return env_vars
217
+
218
+ def substitute_env_vars(raw_text, env_dict):
219
+ """
220
+ Substitutes ${VAR} and ${VAR:-default} and $VAR with environment values.
221
+ """
222
+ def replace_bracket(match):
223
+ expr = match.group(1)
224
+ if ":-" in expr:
225
+ var_name, default_val = expr.split(":-", 1)
226
+ else:
227
+ var_name, default_val = expr, ""
228
+ return env_dict.get(var_name, os.environ.get(var_name, default_val))
229
+
230
+ def replace_simple(match):
231
+ var_name = match.group(1)
232
+ return env_dict.get(var_name, os.environ.get(var_name, f"${var_name}"))
233
+
234
+ # Replace ${VAR:-default} and ${VAR}
235
+ result = re.sub(r"\$\{([A-Za-z0-9_]+(?::-.*?|)?)\}", replace_bracket, raw_text)
236
+ # Replace $VAR (only if word boundary)
237
+ result = re.sub(r"\$([A-Za-z0-9_]+)", replace_simple, result)
238
+ return result
239
+
240
+ def parse_deploy_config(config_path, project_root):
241
+ """
242
+ Reads deploy.yml, substitutes env vars, and normalizes service definitions.
243
+ """
244
+ # Load .env files from project_root if available
245
+ env_dict = {}
246
+ default_env = os.path.join(project_root, ".env")
247
+ if os.path.isfile(default_env):
248
+ env_dict.update(load_env_file(default_env))
249
+
250
+ deploy_env = os.path.join(project_root, ".env.deploy")
251
+ if os.path.isfile(deploy_env):
252
+ env_dict.update(load_env_file(deploy_env))
253
+
254
+ with open(config_path, "r", encoding="utf-8", errors="replace") as f:
255
+ raw_content = f.read()
256
+
257
+ # If the file specifies custom env_file:
258
+ custom_env_match = re.search(r"^\s*env_file:\s*(.+)$", raw_content, re.MULTILINE)
259
+ if custom_env_match:
260
+ custom_env_name = custom_env_match.group(1).strip().strip('"').strip("'")
261
+ custom_env_path = os.path.join(project_root, custom_env_name)
262
+ if os.path.isfile(custom_env_path):
263
+ env_dict.update(load_env_file(custom_env_path))
264
+
265
+ # Add runtime helper variables
266
+ env_dict["PROJECT_DIR"] = project_root
267
+
268
+ # Substitute env vars
269
+ resolved_content = substitute_env_vars(raw_content, env_dict)
270
+
271
+ try:
272
+ data = yaml.safe_load(resolved_content) or {}
273
+ except yaml.YAMLError as e:
274
+ log_error(f"YAML Syntax Error in '{config_path}':\n{e}")
275
+ sys.exit(1)
276
+
277
+ # Global / Default server settings
278
+ global_server = data.get("server") or data.get("vps") or data.get("defaults") or {}
279
+
280
+ # Extract services
281
+ # Can be defined under "services:" or directly as top-level keys
282
+ services = {}
283
+ if "services" in data and isinstance(data["services"], dict):
284
+ services = data["services"]
285
+ else:
286
+ reserved_keys = {"server", "vps", "defaults", "env_file", "env", "variables", "vars"}
287
+ for k, v in data.items():
288
+ if k not in reserved_keys and isinstance(v, dict):
289
+ services[k] = v
290
+
291
+ if not services:
292
+ log_error(f"No services found in configuration file '{config_path}'.")
293
+ log_info("Please define at least one service (e.g., frontend, backend). Run uhdeploy --example to see syntax.")
294
+ sys.exit(1)
295
+
296
+ # Normalize services by merging global server info
297
+ normalized = {}
298
+ for name, s_cfg in services.items():
299
+ if not isinstance(s_cfg, dict):
300
+ continue
301
+ cfg = dict(s_cfg)
302
+
303
+ # Merge server details
304
+ srv = dict(global_server)
305
+ srv.update(cfg.get("server") or cfg.get("vps") or cfg.get("target") or {})
306
+ cfg["server"] = srv
307
+
308
+ # Detect docker flag
309
+ # Accept 'docker: false', 'dont_use_docker: true', 'use_docker: false', etc.
310
+ docker_raw = cfg.get("docker")
311
+ if docker_raw is None:
312
+ if cfg.get("dont_use_docker") is True or cfg.get("use_docker") is False:
313
+ cfg["docker"] = False
314
+ else:
315
+ cfg["docker"] = False
316
+ normalized[name] = cfg
317
+
318
+ return normalized, env_dict
319
+
320
+
321
+ # =============================================================================
322
+ # 🧩 DOCKERFILE-STYLE STEP PARSER
323
+ # =============================================================================
324
+
325
+ def parse_step(step_entry):
326
+ """
327
+ Parses a step into a standardized dict:
328
+ - String syntax:
329
+ "RUN npm run build" -> {'action': 'RUN', 'command': 'npm run build'}
330
+ "COPY src dest" -> {'action': 'COPY', 'src': 'src', 'dest': 'dest'}
331
+ "CMD python app.py" -> {'action': 'CMD', 'command': 'python app.py'}
332
+ "npm run build" -> {'action': 'RUN', 'command': 'npm run build'}
333
+ - Dict syntax:
334
+ {'run': '...'} or {'copy': '...'} or {'copy': {'src': '...', 'dest': '...'}}
335
+ """
336
+ if isinstance(step_entry, str):
337
+ trimmed = step_entry.strip()
338
+ parts = trimmed.split(None, 1)
339
+ if not parts:
340
+ return None
341
+ verb = parts[0].upper()
342
+ arg = parts[1] if len(parts) > 1 else ""
343
+
344
+ if verb in ("RUN", "CMD"):
345
+ return {"action": "RUN", "command": arg}
346
+ elif verb == "COPY":
347
+ # Handles "COPY src dest" or "COPY src -> dest"
348
+ clean_arg = arg.replace("->", " ")
349
+ cparts = clean_arg.split()
350
+ if len(cparts) >= 2:
351
+ return {"action": "COPY", "src": cparts[0], "dest": cparts[1]}
352
+ return {"action": "COPY", "src": arg, "dest": ""}
353
+ elif verb == "WORKDIR":
354
+ return {"action": "WORKDIR", "dir": arg}
355
+ elif verb == "ENV":
356
+ if "=" in arg:
357
+ k, v = arg.split("=", 1)
358
+ return {"action": "ENV", "key": k.strip(), "val": v.strip()}
359
+ return {"action": "ENV", "raw": arg}
360
+ else:
361
+ # Default fallback for arbitrary shell command without "RUN" prefix
362
+ return {"action": "RUN", "command": trimmed}
363
+
364
+ elif isinstance(step_entry, dict):
365
+ if "run" in step_entry:
366
+ return {"action": "RUN", "command": str(step_entry["run"])}
367
+ if "cmd" in step_entry:
368
+ return {"action": "RUN", "command": str(step_entry["cmd"])}
369
+ if "copy" in step_entry:
370
+ cval = step_entry["copy"]
371
+ if isinstance(cval, dict):
372
+ return {"action": "COPY", "src": str(cval.get("src", "")), "dest": str(cval.get("dest", ""))}
373
+ elif isinstance(cval, str):
374
+ cparts = cval.replace("->", " ").split()
375
+ if len(cparts) >= 2:
376
+ return {"action": "COPY", "src": cparts[0], "dest": cparts[1]}
377
+ return {"action": "COPY", "src": cval, "dest": ""}
378
+ if "workdir" in step_entry:
379
+ return {"action": "WORKDIR", "dir": str(step_entry["workdir"])}
380
+
381
+ return None
382
+
383
+ def normalize_steps(step_list):
384
+ """Converts a step list or single step into parsed normalized items."""
385
+ if not step_list:
386
+ return []
387
+ if isinstance(step_list, str):
388
+ step_list = [step_list]
389
+ parsed = []
390
+ for item in step_list:
391
+ res = parse_step(item)
392
+ if res:
393
+ parsed.append(res)
394
+ return parsed
395
+
396
+
397
+ # =============================================================================
398
+ # 🔐 SSH & SFTP CLIENT HANDLER
399
+ # =============================================================================
400
+
401
+ class SSHManager:
402
+ def __init__(self, host, user, port=22, key_path=None, passphrase=None, password=None):
403
+ self.host = host
404
+ self.user = user or "root"
405
+ self.port = int(port or 22)
406
+ self.key_path = os.path.expanduser(key_path) if key_path else None
407
+ self.passphrase = passphrase
408
+ self.password = password
409
+ self.client = None
410
+ self._created_dirs = set()
411
+
412
+ def connect(self):
413
+ if not paramiko:
414
+ log_error("Paramiko is required for SSH connections. Please run: pip install paramiko")
415
+ sys.exit(1)
416
+
417
+ # Close existing client cleanly if any
418
+ if self.client:
419
+ try:
420
+ self.client.close()
421
+ except Exception:
422
+ pass
423
+
424
+ log_info(f"Connecting to VPS ({self.user}@{self.host}:{self.port}) via SSH...")
425
+ client = paramiko.SSHClient()
426
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
427
+
428
+ connect_kwargs = {
429
+ "hostname": self.host,
430
+ "port": self.port,
431
+ "username": self.user,
432
+ "timeout": 30,
433
+ "banner_timeout": 60,
434
+ "auth_timeout": 60
435
+ }
436
+
437
+ if self.key_path:
438
+ if not os.path.exists(self.key_path):
439
+ log_error(f"SSH private key file not found: {self.key_path}")
440
+ sys.exit(1)
441
+ connect_kwargs["key_filename"] = self.key_path
442
+ if self.passphrase:
443
+ connect_kwargs["passphrase"] = self.passphrase
444
+ elif self.password:
445
+ connect_kwargs["password"] = self.password
446
+
447
+ try:
448
+ client.connect(**connect_kwargs)
449
+ transport = client.get_transport()
450
+ if transport:
451
+ transport.set_keepalive(15)
452
+ self.client = client
453
+ log_success(f"SSH Connection established to {self.host}")
454
+ return self
455
+ except Exception as e:
456
+ log_error(f"Failed to connect to VPS: {e}")
457
+ sys.exit(1)
458
+
459
+ def close(self):
460
+ if self.client:
461
+ try:
462
+ self.client.close()
463
+ except Exception:
464
+ pass
465
+ self.client = None
466
+ log_info(f"SSH connection to {self.host} closed.")
467
+
468
+ def run_cmd(self, cmd, cwd=None, retries=3):
469
+ full_cmd = f"cd '{cwd}' && {cmd}" if cwd else cmd
470
+ log_cmd(full_cmd, kind="VPS SSH")
471
+
472
+ for attempt in range(1, retries + 1):
473
+ try:
474
+ transport = self.client.get_transport() if self.client else None
475
+ if not transport or not transport.is_active():
476
+ log_warning(f"SSH connection inactive. Reconnecting (Attempt {attempt}/{retries})...")
477
+ self.connect()
478
+
479
+ stdin, stdout, stderr = self.client.exec_command(full_cmd)
480
+ exit_status = stdout.channel.recv_exit_status()
481
+
482
+ out = stdout.read().decode("utf-8", errors="replace")
483
+ err = stderr.read().decode("utf-8", errors="replace")
484
+
485
+ if out.strip():
486
+ for line in out.strip().splitlines():
487
+ print(f" {Fore.LIGHTBLACK_EX}│{Style.RESET_ALL} {line}")
488
+ if err.strip():
489
+ for line in err.strip().splitlines():
490
+ print(f" {Fore.RED}│{Style.RESET_ALL} {line}", file=sys.stderr)
491
+
492
+ if exit_status != 0:
493
+ log_error(f"Remote command failed with exit status {exit_status}")
494
+ sys.exit(1)
495
+ return True
496
+ except Exception as e:
497
+ log_warning(f"SSH execution attempt {attempt} failed: {e}")
498
+ if attempt == retries:
499
+ log_error(f"Remote command failed after {retries} attempts.")
500
+ sys.exit(1)
501
+ import time
502
+ time.sleep(2)
503
+
504
+ def mkdir_p(self, remote_dir):
505
+ """Creates directory hierarchy on remote machine via SSH safely."""
506
+ remote_dir_norm = remote_dir.replace("\\", "/")
507
+ if remote_dir_norm in self._created_dirs:
508
+ return
509
+ try:
510
+ transport = self.client.get_transport() if self.client else None
511
+ if not transport or not transport.is_active():
512
+ self.connect()
513
+
514
+ stdin, stdout, stderr = self.client.exec_command(f"mkdir -p '{remote_dir_norm}'")
515
+ stdout.channel.recv_exit_status()
516
+ stdout.read()
517
+ stderr.read()
518
+ self._created_dirs.add(remote_dir_norm)
519
+ except Exception:
520
+ pass
521
+
522
+ def upload_file(self, local_path, remote_path, retries=5):
523
+ import time
524
+ remote_path = remote_path.replace("\\", "/")
525
+ parent_dir = os.path.dirname(remote_path)
526
+ if parent_dir:
527
+ self.mkdir_p(parent_dir)
528
+
529
+ base_name = os.path.basename(local_path)
530
+
531
+ for attempt in range(1, retries + 1):
532
+ sftp = None
533
+ try:
534
+ transport = self.client.get_transport() if self.client else None
535
+ if not transport or not transport.is_active():
536
+ log_warning(f"SSH connection inactive. Reconnecting (Attempt {attempt}/{retries})...")
537
+ self.connect()
538
+
539
+ sftp = self.client.open_sftp()
540
+
541
+ def progress(transferred, total):
542
+ if total > 0:
543
+ percent = (transferred / total) * 100
544
+ mb_trans = transferred / (1024 * 1024)
545
+ mb_tot = total / (1024 * 1024)
546
+ sys.stdout.write(f"\r {Fore.CYAN}📤 [{base_name}] {mb_trans:.1f} MB / {mb_tot:.1f} MB ({percent:.1f}%){Style.RESET_ALL} ")
547
+ sys.stdout.flush()
548
+
549
+ if attempt == 1:
550
+ print(f" {Fore.LIGHTBLUE_EX}⇗ Uploading file:{Style.RESET_ALL} {local_path} -> {remote_path}")
551
+
552
+ sftp.put(local_path, remote_path, callback=progress, confirm=False)
553
+ sys.stdout.write("\n")
554
+ return True
555
+
556
+ except Exception as e:
557
+ sys.stdout.write("\n")
558
+ log_warning(f"SFTP upload failed on attempt {attempt}/{retries}: {e}")
559
+ if attempt == retries:
560
+ log_error(f"SFTP upload failed permanently after {retries} attempts.")
561
+ raise e
562
+ try:
563
+ if sftp:
564
+ sftp.close()
565
+ except Exception:
566
+ pass
567
+ try:
568
+ if self.client:
569
+ self.client.close()
570
+ except Exception:
571
+ pass
572
+ time.sleep(2)
573
+ self.connect()
574
+
575
+ finally:
576
+ if sftp:
577
+ try:
578
+ sftp.close()
579
+ except Exception:
580
+ pass
581
+
582
+ def upload_directory(self, local_dir, remote_dir):
583
+ remote_dir = remote_dir.replace("\\", "/")
584
+ self.mkdir_p(remote_dir)
585
+
586
+ print(f" {Fore.LIGHTBLUE_EX}⇗ Syncing directory:{Style.RESET_ALL} {local_dir} -> {remote_dir}")
587
+ for root, dirs, files in os.walk(local_dir):
588
+ rel_path = os.path.relpath(root, local_dir)
589
+ target_sub = remote_dir if rel_path == "." else f"{remote_dir}/{rel_path}".replace("\\", "/")
590
+ self.mkdir_p(target_sub)
591
+
592
+ for f in files:
593
+ l_file = os.path.join(root, f)
594
+ r_file = f"{target_sub}/{f}".replace("\\", "/")
595
+ self.upload_file(l_file, r_file)
596
+
597
+
598
+ # =============================================================================
599
+ # 📡 FTP CLIENT HANDLER (Standard Library ftplib)
600
+ # =============================================================================
601
+
602
+ class FTPManager:
603
+ def __init__(self, host, user, port=21, password=None):
604
+ self.host = host
605
+ self.user = user or "anonymous"
606
+ self.port = int(port or 21)
607
+ self.password = password or ""
608
+ self.ftp = None
609
+
610
+ def connect(self):
611
+ log_info(f"Connecting to VPS ({self.user}@{self.host}:{self.port}) via FTP...")
612
+ try:
613
+ ftp = ftplib.FTP()
614
+ ftp.connect(self.host, self.port, timeout=30)
615
+ ftp.login(self.user, self.password)
616
+ self.ftp = ftp
617
+ log_success(f"FTP Connection established to {self.host}")
618
+ return self
619
+ except Exception as e:
620
+ log_error(f"Failed to connect to FTP server: {e}")
621
+ sys.exit(1)
622
+
623
+ def close(self):
624
+ if self.ftp:
625
+ try:
626
+ self.ftp.quit()
627
+ except Exception:
628
+ try:
629
+ self.ftp.close()
630
+ except Exception:
631
+ pass
632
+ log_info(f"FTP connection to {self.host} closed.")
633
+
634
+ def run_cmd(self, cmd, cwd=None):
635
+ log_warning(f"[FTP Limitation] Cannot run remote command: '{cmd}'. FTP protocol does not support shell execution!")
636
+ log_info("Only file transfers are supported over FTP. Use SSH for remote commands/Docker.")
637
+ return False
638
+
639
+ def mkdir_p(self, remote_dir):
640
+ """Recursively ensures remote directories exist on FTP."""
641
+ remote_dir = remote_dir.replace("\\", "/").strip("/")
642
+ parts = remote_dir.split("/")
643
+ current = ""
644
+ for p in parts:
645
+ current += "/" + p
646
+ try:
647
+ self.ftp.cwd(current)
648
+ except ftplib.error_perm:
649
+ try:
650
+ self.ftp.mkd(current)
651
+ except Exception:
652
+ pass
653
+
654
+ def upload_file(self, local_path, remote_path):
655
+ remote_path = remote_path.replace("\\", "/")
656
+ parent_dir = os.path.dirname(remote_path)
657
+ if parent_dir:
658
+ self.mkdir_p(parent_dir)
659
+
660
+ print(f" {Fore.LIGHTBLUE_EX}⇗ [FTP] Uploading:{Style.RESET_ALL} {local_path} -> {remote_path}")
661
+ with open(local_path, "rb") as f:
662
+ self.ftp.storbinary(f"STOR {remote_path}", f)
663
+
664
+ def upload_directory(self, local_dir, remote_dir):
665
+ remote_dir = remote_dir.replace("\\", "/")
666
+ self.mkdir_p(remote_dir)
667
+
668
+ print(f" {Fore.LIGHTBLUE_EX}⇗ [FTP] Syncing directory:{Style.RESET_ALL} {local_dir} -> {remote_dir}")
669
+ for root, dirs, files in os.walk(local_dir):
670
+ rel_path = os.path.relpath(root, local_dir)
671
+ target_sub = remote_dir if rel_path == "." else f"{remote_dir}/{rel_path}".replace("\\", "/")
672
+ self.mkdir_p(target_sub)
673
+
674
+ for f in files:
675
+ l_file = os.path.join(root, f)
676
+ r_file = f"{target_sub}/{f}".replace("\\", "/")
677
+ self.upload_file(l_file, r_file)
678
+
679
+
680
+ # =============================================================================
681
+ # âš™ī¸ LOCAL COMMAND & BUILD RUNNER
682
+ # =============================================================================
683
+
684
+ def run_local_cmd(cmd, cwd=None):
685
+ log_cmd(cmd, kind="LOCAL")
686
+ res = subprocess.run(cmd, shell=True, cwd=cwd)
687
+ if res.returncode != 0:
688
+ log_error(f"Local command failed with exit code {res.returncode}: '{cmd}'")
689
+ sys.exit(1)
690
+
691
+ def run_local_copy(src, dest, cwd=None):
692
+ src_path = os.path.join(cwd, src) if cwd and not os.path.isabs(src) else src
693
+ dest_path = os.path.join(cwd, dest) if cwd and not os.path.isabs(dest) else dest
694
+
695
+ if not os.path.exists(src_path):
696
+ log_error(f"Source file/folder for COPY not found: '{src_path}'")
697
+ sys.exit(1)
698
+
699
+ print(f" {Fore.LIGHTBLUE_EX}📋 [LOCAL COPY]{Style.RESET_ALL} {src} -> {dest}")
700
+ dest_dir = os.path.dirname(dest_path) if not os.path.isdir(dest_path) else dest_path
701
+ if dest_dir:
702
+ os.makedirs(dest_dir, exist_ok=True)
703
+
704
+ if os.path.isdir(src_path):
705
+ shutil.copytree(src_path, dest_path, dirs_exist_ok=True)
706
+ else:
707
+ shutil.copy2(src_path, dest_path)
708
+
709
+
710
+ # =============================================================================
711
+ # 🚀 SERVICE DEPLOYMENT ORCHESTRATOR
712
+ # =============================================================================
713
+
714
+ def deploy_service(name, cfg, project_root, dry_run=False):
715
+ print_separator("═", 75, Fore.CYAN)
716
+ log_step(name, "STARTING", cfg.get("description", f"Deploying service '{name}'"))
717
+
718
+ # 1. Resolve work directory
719
+ rel_work_dir = cfg.get("work_dir") or cfg.get("working_dir") or "."
720
+ work_dir = os.path.abspath(os.path.join(project_root, rel_work_dir))
721
+ log_info(f"Local Working Directory: {Fore.WHITE}{work_dir}")
722
+
723
+ server_cfg = cfg.get("server", {})
724
+ proto = str(server_cfg.get("type", "ssh")).lower().strip()
725
+ host = server_cfg.get("host") or server_cfg.get("ip")
726
+ user = server_cfg.get("user") or server_cfg.get("username", "root")
727
+ port = server_cfg.get("port") or (21 if proto == "ftp" else 22)
728
+ remote_base = (server_cfg.get("remote_dir") or server_cfg.get("path") or f"/opt/{name}").replace("\\", "/")
729
+
730
+ # Check connection requirements
731
+ if not dry_run and not host:
732
+ log_error(f"No VPS host specified for service '{name}'! Set server.host in deploy.yml or VPS_IP in .env.")
733
+ sys.exit(1)
734
+
735
+ log_info(f"Target Server: {Fore.WHITE}{user}@{host}:{port} ({proto.upper()}) | Remote Dir: {remote_base}")
736
+
737
+ # 2. Local Build Phase
738
+ build_steps = normalize_steps(cfg.get("build") or cfg.get("for_build") or cfg.get("build_steps"))
739
+ if build_steps:
740
+ print_separator("─", 50, Fore.MAGENTA)
741
+ log_step(name, "LOCAL BUILD", f"Executing {len(build_steps)} build step(s)...")
742
+ for s in build_steps:
743
+ if dry_run:
744
+ print(f" {Fore.YELLOW}[DRY-RUN BUILD]{Style.RESET_ALL} {s}")
745
+ continue
746
+
747
+ action = s["action"]
748
+ if action in ("RUN", "CMD"):
749
+ run_local_cmd(s["command"], cwd=work_dir)
750
+ elif action == "COPY":
751
+ run_local_copy(s["src"], s["dest"], cwd=work_dir)
752
+ elif action == "WORKDIR":
753
+ work_dir = os.path.abspath(os.path.join(work_dir, s["dir"]))
754
+ log_info(f"Changed local working dir to: {work_dir}")
755
+
756
+ if dry_run:
757
+ log_info(f"[DRY-RUN] Finished simulating build for '{name}'.")
758
+ return
759
+
760
+ # 3. Establish Connection to VPS
761
+ conn = None
762
+ if proto == "ftp":
763
+ conn = FTPManager(
764
+ host=host,
765
+ user=user,
766
+ port=port,
767
+ password=server_cfg.get("password")
768
+ ).connect()
769
+ else: # Default: SSH
770
+ conn = SSHManager(
771
+ host=host,
772
+ user=user,
773
+ port=port,
774
+ key_path=server_cfg.get("key_path"),
775
+ passphrase=server_cfg.get("passphrase"),
776
+ password=server_cfg.get("password")
777
+ ).connect()
778
+
779
+ try:
780
+ # Ensure remote base directory exists
781
+ conn.mkdir_p(remote_base)
782
+
783
+ # Pre-deploy commands / remote preparations
784
+ before_steps = normalize_steps(cfg.get("before_deploy") or cfg.get("pre_deploy") or cfg.get("before_run"))
785
+ if before_steps:
786
+ print_separator("─", 50, Fore.MAGENTA)
787
+ log_step(name, "PRE-DEPLOY", f"Running {len(before_steps)} pre-deploy step(s) on VPS...")
788
+ for s in before_steps:
789
+ action = s["action"]
790
+ if action in ("RUN", "CMD"):
791
+ conn.run_cmd(s["command"], cwd=remote_base)
792
+ elif action == "COPY":
793
+ local_src = os.path.join(project_root, s["src"]) if not os.path.isabs(s["src"]) else s["src"]
794
+ remote_dst = s["dest"].replace("${remote_dir}", remote_base)
795
+ if not remote_dst.startswith("/"):
796
+ remote_dst = f"{remote_base}/{remote_dst}".replace("\\", "/")
797
+ if os.path.isdir(local_src):
798
+ conn.upload_directory(local_src, remote_dst)
799
+ elif os.path.isfile(local_src):
800
+ conn.upload_file(local_src, remote_dst)
801
+
802
+ # 4. Handle Docker OR Non-Docker Deployments
803
+ docker_cfg = cfg.get("docker")
804
+ is_docker = False
805
+ if isinstance(docker_cfg, dict):
806
+ is_docker = docker_cfg.get("enabled", True)
807
+ elif isinstance(docker_cfg, bool):
808
+ is_docker = docker_cfg
809
+
810
+ if is_docker:
811
+ if proto == "ftp":
812
+ log_warning("âš ī¸ Docker deployments require SSH for execution. Over FTP, container commands cannot run.")
813
+
814
+ print_separator("─", 50, Fore.MAGENTA)
815
+ log_step(name, "DOCKER DEPLOY", "Deploying with Docker...")
816
+
817
+ d_dict = docker_cfg if isinstance(docker_cfg, dict) else {}
818
+ compose_rel = d_dict.get("compose_file") or "docker-compose.yml"
819
+ compose_path = os.path.join(project_root, compose_rel)
820
+ service_name = d_dict.get("service_name") or d_dict.get("service") or name
821
+ image_name = d_dict.get("image") or f"{name}:latest"
822
+ context_rel = d_dict.get("context") or rel_work_dir
823
+ context_dir = os.path.join(project_root, context_rel)
824
+ build_mode = d_dict.get("build_mode", "local").lower() # 'local' (tarball) or 'remote'
825
+
826
+ # 4.1 Sync docker-compose.yml to VPS if present
827
+ if os.path.isfile(compose_path):
828
+ log_info(f"Syncing docker-compose file: {compose_rel} -> {remote_base}/docker-compose.yml")
829
+ conn.upload_file(compose_path, f"{remote_base}/docker-compose.yml")
830
+
831
+ # Auto-detect and sync referenced env_file(s) from docker-compose.yml or config
832
+ compose_dir = os.path.dirname(compose_path) or project_root
833
+ env_files_to_sync = set()
834
+
835
+ # Explicit env files from deploy.yml
836
+ explicit_envs = d_dict.get("env_file") or d_dict.get("env_files") or []
837
+ if isinstance(explicit_envs, str):
838
+ explicit_envs = [explicit_envs]
839
+ for ee in explicit_envs:
840
+ env_files_to_sync.add(ee)
841
+
842
+ # Auto-parse from docker-compose.yml
843
+ try:
844
+ with open(compose_path, "r", encoding="utf-8", errors="replace") as cf:
845
+ compose_data = yaml.safe_load(cf) or {}
846
+ if isinstance(compose_data, dict):
847
+ services_dict = compose_data.get("services", {})
848
+ if isinstance(services_dict, dict):
849
+ for _, s_def in services_dict.items():
850
+ if isinstance(s_def, dict) and "env_file" in s_def:
851
+ ef = s_def["env_file"]
852
+ if isinstance(ef, str):
853
+ env_files_to_sync.add(ef)
854
+ elif isinstance(ef, list):
855
+ for item in ef:
856
+ if isinstance(item, str):
857
+ env_files_to_sync.add(item)
858
+ elif isinstance(item, dict) and "path" in item:
859
+ env_files_to_sync.add(item["path"])
860
+ except Exception as e:
861
+ log_warning(f"Could not parse docker-compose.yml for env_files: {e}")
862
+
863
+ # Sync compose dir root .env if it exists and not yet added
864
+ root_env = os.path.join(compose_dir, ".env")
865
+ if os.path.isfile(root_env) and not explicit_envs:
866
+ env_files_to_sync.add(".env")
867
+
868
+ # Upload all discovered env files
869
+ for ef in sorted(env_files_to_sync):
870
+ clean_ef = ef.replace("\\", "/").strip("/")
871
+ # Handle paths that start with ./
872
+ search_rel = clean_ef[2:] if clean_ef.startswith("./") else clean_ef
873
+
874
+ candidate_paths = [
875
+ os.path.join(compose_dir, search_rel),
876
+ os.path.join(project_root, search_rel),
877
+ os.path.join(work_dir, search_rel),
878
+ os.path.join(compose_dir, os.path.basename(search_rel)),
879
+ os.path.join(project_root, os.path.basename(search_rel))
880
+ ]
881
+ found_local = None
882
+ for cp in candidate_paths:
883
+ if os.path.isfile(cp):
884
+ found_local = cp
885
+ break
886
+
887
+ if found_local:
888
+ remote_env_dest = f"{remote_base}/{search_rel}".replace("\\", "/")
889
+ log_info(f"Syncing compose env file: {search_rel} -> {remote_env_dest}")
890
+ conn.upload_file(found_local, remote_env_dest)
891
+ else:
892
+ log_warning(f"Environment file '{ef}' referenced by docker-compose was not found locally.")
893
+
894
+ if build_mode == "local":
895
+ # Build locally, package as tarball, transfer, load into remote Docker
896
+ tar_name = f"{service_name}.tar"
897
+ local_tar_path = os.path.join(project_root, tar_name)
898
+ remote_tar_path = f"{remote_base}/{tar_name}"
899
+
900
+ log_step(name, "DOCKER BUILD", f"Building image locally: {image_name}...")
901
+ dockerfile_arg = f"-f {d_dict['dockerfile']}" if d_dict.get("dockerfile") else ""
902
+ run_local_cmd(f"docker build {dockerfile_arg} -t {image_name} {context_dir}")
903
+
904
+ log_step(name, "DOCKER SAVE", f"Saving image to archive: {tar_name}...")
905
+ run_local_cmd(f"docker save -o {local_tar_path} {image_name}")
906
+
907
+ log_step(name, "SFTP UPLOAD", f"Uploading image archive to VPS...")
908
+ conn.upload_file(local_tar_path, remote_tar_path)
909
+
910
+ if proto == "ssh":
911
+ log_step(name, "DOCKER LOAD", f"Loading image into Docker daemon on VPS...")
912
+ conn.run_cmd(f"docker load -i {remote_tar_path}")
913
+ conn.run_cmd(f"rm -f {remote_tar_path}")
914
+
915
+ # Clean up local archive
916
+ if os.path.exists(local_tar_path):
917
+ os.remove(local_tar_path)
918
+ log_info(f"Cleaned up local archive: {tar_name}")
919
+
920
+ else: # 'remote' build mode
921
+ if proto == "ssh":
922
+ log_step(name, "DOCKER BUILD REMOTE", f"Building service {service_name} directly on VPS...")
923
+ conn.run_cmd(f"docker compose build {service_name}", cwd=remote_base)
924
+
925
+ # Recreate container on VPS
926
+ if proto == "ssh":
927
+ log_step(name, "DOCKER RESTART", f"Starting container(s) on VPS...")
928
+ conn.run_cmd(f"docker compose up -d {service_name}", cwd=remote_base)
929
+
930
+ else:
931
+ # Non-Docker Deployment (Static file / code upload)
932
+ print_separator("─", 50, Fore.MAGENTA)
933
+ log_step(name, "UPLOAD", "Uploading build artifacts to remote server...")
934
+ upload_list = cfg.get("upload") or []
935
+ if isinstance(upload_list, str):
936
+ upload_list = [upload_list]
937
+
938
+ if not upload_list:
939
+ # Default: upload entire working dir or dist folder if present
940
+ candidate_dist = os.path.join(work_dir, "dist")
941
+ if os.path.isdir(candidate_dist):
942
+ upload_list.append(f"dist/ -> {remote_base}")
943
+ else:
944
+ upload_list.append(f". -> {remote_base}")
945
+
946
+ for up in upload_list:
947
+ src, dst = "", ""
948
+ if isinstance(up, dict):
949
+ src, dst = up.get("src", ""), up.get("dest", "")
950
+ elif isinstance(up, str):
951
+ parts = up.replace("->", " ").split()
952
+ if len(parts) >= 2:
953
+ src, dst = parts[0], parts[1]
954
+ else:
955
+ src, dst = parts[0], remote_base
956
+
957
+ dst = dst.replace("${remote_dir}", remote_base)
958
+ if not dst.startswith("/"):
959
+ dst = f"{remote_base}/{dst}".replace("\\", "/")
960
+
961
+ local_full = os.path.join(work_dir, src) if not os.path.isabs(src) else src
962
+ if os.path.isdir(local_full):
963
+ conn.upload_directory(local_full, dst)
964
+ elif os.path.isfile(local_full):
965
+ conn.upload_file(local_full, dst)
966
+ else:
967
+ log_warning(f"File or directory to upload not found: {local_full}")
968
+
969
+ # 5. Remote Post-Deploy / Run in VPS Phase
970
+ remote_steps = normalize_steps(cfg.get("run_in_vps") or cfg.get("after_deploy") or cfg.get("post_deploy"))
971
+ if remote_steps:
972
+ print_separator("─", 50, Fore.MAGENTA)
973
+ log_step(name, "RUN IN VPS", f"Executing {len(remote_steps)} remote command(s)...")
974
+ for s in remote_steps:
975
+ action = s["action"]
976
+ if action in ("RUN", "CMD"):
977
+ conn.run_cmd(s["command"], cwd=remote_base)
978
+ elif action == "COPY":
979
+ local_src = os.path.join(project_root, s["src"]) if not os.path.isabs(s["src"]) else s["src"]
980
+ remote_dst = s["dest"].replace("${remote_dir}", remote_base)
981
+ if not remote_dst.startswith("/"):
982
+ remote_dst = f"{remote_base}/{remote_dst}".replace("\\", "/")
983
+ if os.path.isdir(local_src):
984
+ conn.upload_directory(local_src, remote_dst)
985
+ elif os.path.isfile(local_src):
986
+ conn.upload_file(local_src, remote_dst)
987
+
988
+ log_success(f"Service '{name}' deployed successfully!")
989
+
990
+ finally:
991
+ if conn:
992
+ conn.close()
993
+
994
+
995
+ # =============================================================================
996
+ # 📖 CLI HELP & EXAMPLES
997
+ # =============================================================================
998
+
999
+ EXAMPLE_DEPLOY_YAML = """# =============================================================================
1000
+ # 🚀 Universal Deployer Configuration (deploy.yml)
1001
+ # =============================================================================
1002
+ # Environment variables like ${VPS_IP} or ${SSH_KEY_PATH} are loaded from .env!
1003
+
1004
+ server:
1005
+ type: ssh # "ssh" (SFTP + Shell) or "ftp" (File transfer only)
1006
+ host: ${VPS_IP} # VPS Hostname or IP
1007
+ user: ${VPS_USER:-root} # User (defaults to root)
1008
+ port: 22 # Port (22 for SSH, 21 for FTP)
1009
+ key_path: ${SSH_KEY_PATH} # Path to SSH private key
1010
+ passphrase: ${SSH_PASSPHRASE}# (Optional) Passphrase for private key
1011
+ password: ${VPS_PASSWORD} # (Optional) Password for SSH or FTP
1012
+ remote_dir: /opt/my-app # Remote base directory on VPS
1013
+
1014
+ services:
1015
+ # ---------------------------------------------------------------------------
1016
+ # 🌐 Frontend (React / Next.js / Vue / HTML) - WITHOUT DOCKER
1017
+ # ---------------------------------------------------------------------------
1018
+ frontend:
1019
+ description: "Build frontend locally and sync static files to VPS"
1020
+ docker: false # No docker for frontend
1021
+ work_dir: ./frontend # Working directory
1022
+
1023
+ # Dockerfile-like build instructions:
1024
+ build:
1025
+ - RUN npm install
1026
+ - RUN npm run build
1027
+ - COPY package.json dist/package.json
1028
+
1029
+ # Upload dist output to remote directory
1030
+ upload:
1031
+ - dist/ -> ${remote_dir}/frontend/dist
1032
+
1033
+ # Commands executed on remote VPS (SSH only):
1034
+ run_in_vps:
1035
+ - RUN systemctl reload nginx
1036
+ - RUN echo "Frontend updated!"
1037
+
1038
+ # ---------------------------------------------------------------------------
1039
+ # đŸŗ Backend (FastAPI / Node / Python) - WITH DOCKER
1040
+ # ---------------------------------------------------------------------------
1041
+ backend:
1042
+ description: "Build local Docker image, transfer tar, and restart container"
1043
+ work_dir: ./backend
1044
+
1045
+ docker:
1046
+ enabled: true
1047
+ compose_file: docker-compose.yml
1048
+ # env_file: .env.production # (Optional: auto-detected from docker-compose.yml!)
1049
+ service_name: backend
1050
+ build_mode: local # "local" (tarball upload) or "remote" (build on VPS)
1051
+ image: my-backend:latest
1052
+ context: ./backend
1053
+
1054
+ # Run on VPS before docker starts:
1055
+ before_deploy:
1056
+ - RUN mkdir -p ${remote_dir}/backend/data
1057
+
1058
+ # Run on VPS after deployment:
1059
+ # (Note: 'docker compose up -d <service>' is already executed automatically!)
1060
+ run_in_vps:
1061
+ - RUN docker compose ps
1062
+ - RUN docker compose logs --tail=25 backend
1063
+ """
1064
+
1065
+ def print_help_screen():
1066
+ banner()
1067
+ help_text = f"""{Fore.WHITE}{Style.BRIGHT}USAGE:{Style.RESET_ALL}
1068
+ uhdeploy [OPTIONS] [SERVICE_NAME]
1069
+ {Fore.LIGHTBLACK_EX}(or python -m uhdeploy [OPTIONS] [SERVICE_NAME]){Style.RESET_ALL}
1070
+
1071
+ {Fore.WHITE}{Style.BRIGHT}DESCRIPTION:{Style.RESET_ALL}
1072
+ uhdeploy reads your {Fore.CYAN}deploy.yml{Style.RESET_ALL} (or {Fore.CYAN}deploy.yaml{Style.RESET_ALL}) file and deploys
1073
+ your services to a VPS using SSH/SFTP or FTP. It supports Docker packaging,
1074
+ local build pipelines, and Dockerfile-style step syntax ({Fore.YELLOW}RUN{Style.RESET_ALL}, {Fore.YELLOW}COPY{Style.RESET_ALL}, {Fore.YELLOW}CMD{Style.RESET_ALL}).
1075
+
1076
+ {Fore.WHITE}{Style.BRIGHT}OPTIONS:{Style.RESET_ALL}
1077
+ {Fore.GREEN}-h, --help{Style.RESET_ALL} Show this colorful help message and exit.
1078
+ {Fore.GREEN}-e, --example{Style.RESET_ALL} Display a syntax-highlighted example deploy.yml.
1079
+ {Fore.GREEN}--init{Style.RESET_ALL} Generate a starter {Fore.CYAN}deploy.yml{Style.RESET_ALL} in the current directory.
1080
+ {Fore.GREEN}-f, --file PATH{Style.RESET_ALL} Specify path to deploy configuration file.
1081
+ {Fore.GREEN}-p, --project DIR{Style.RESET_ALL} Specify project directory containing deploy configuration.
1082
+ {Fore.GREEN}-s, --service NAME{Style.RESET_ALL} Name of service to deploy (or {Fore.CYAN}'all'{Style.RESET_ALL} for all services).
1083
+ {Fore.GREEN}-l, --list{Style.RESET_ALL} List all services available in the configuration file.
1084
+ {Fore.GREEN}--dry-run{Style.RESET_ALL} Validate configuration and simulate steps without connecting.
1085
+
1086
+ {Fore.WHITE}{Style.BRIGHT}INTERACTIVE DISCOVERY:{Style.RESET_ALL}
1087
+ If no config file is found in the current folder, uhdeploy prompts you
1088
+ to enter your project path or config file path automatically.
1089
+
1090
+ {Fore.WHITE}{Style.BRIGHT}DOCKERFILE-STYLE STEP COMMANDS:{Style.RESET_ALL}
1091
+ In {Fore.CYAN}build{Style.RESET_ALL}, {Fore.CYAN}before_deploy{Style.RESET_ALL}, or {Fore.CYAN}run_in_vps{Style.RESET_ALL}, you can write steps like:
1092
+ â€ĸ {Fore.YELLOW}RUN <shell command>{Style.RESET_ALL} Executes command (locally or on VPS)
1093
+ â€ĸ {Fore.YELLOW}COPY <src> <dest>{Style.RESET_ALL} Copies local file/folder or uploads to VPS
1094
+ â€ĸ {Fore.YELLOW}WORKDIR <dir>{Style.RESET_ALL} Changes working directory
1095
+ â€ĸ {Fore.YELLOW}CMD <command>{Style.RESET_ALL} Alias for RUN
1096
+
1097
+ {Fore.WHITE}{Style.BRIGHT}SSH VS. FTP CAPABILITIES:{Style.RESET_ALL}
1098
+ â€ĸ {Fore.CYAN}SSH{Style.RESET_ALL}: Full support for file transfers (SFTP), remote terminal commands,
1099
+ Docker container management, and system services.
1100
+ â€ĸ {Fore.CYAN}FTP{Style.RESET_ALL}: File transfer protocol only. Useful for static / PHP hosting.
1101
+ Remote shell commands ({Fore.YELLOW}run_in_vps{Style.RESET_ALL}) are not supported over FTP.
1102
+
1103
+ {Fore.WHITE}{Style.BRIGHT}EXAMPLES OF RUNNING:{Style.RESET_ALL}
1104
+ {Fore.LIGHTBLACK_EX}# Interactive service selection menu:{Style.RESET_ALL}
1105
+ uhdeploy
1106
+
1107
+ {Fore.LIGHTBLACK_EX}# Deploy frontend only:{Style.RESET_ALL}
1108
+ uhdeploy frontend
1109
+
1110
+ {Fore.LIGHTBLACK_EX}# Deploy all services:{Style.RESET_ALL}
1111
+ uhdeploy all
1112
+
1113
+ {Fore.LIGHTBLACK_EX}# Dry-run backend to verify steps:{Style.RESET_ALL}
1114
+ uhdeploy backend --dry-run
1115
+ """
1116
+ print(help_text)
1117
+
1118
+ def print_example_file():
1119
+ banner()
1120
+ print(f"{Fore.CYAN}📄 EXAMPLE DEPLOYMENT FILE (deploy.yml):{Style.RESET_ALL}\n")
1121
+ for line in EXAMPLE_DEPLOY_YAML.splitlines():
1122
+ if line.strip().startswith("#"):
1123
+ print(f"{Fore.LIGHTBLACK_EX}{line}{Style.RESET_ALL}")
1124
+ elif line.strip().endswith(":") and not line.strip().startswith("-"):
1125
+ print(f"{Fore.CYAN}{Style.BRIGHT}{line}{Style.RESET_ALL}")
1126
+ elif line.strip().startswith("- RUN") or line.strip().startswith("- CMD"):
1127
+ print(f"{Fore.YELLOW}{line}{Style.RESET_ALL}")
1128
+ elif line.strip().startswith("- COPY"):
1129
+ print(f"{Fore.LIGHTBLUE_EX}{line}{Style.RESET_ALL}")
1130
+ elif "${" in line:
1131
+ print(f"{Fore.GREEN}{line}{Style.RESET_ALL}")
1132
+ else:
1133
+ print(f"{Fore.WHITE}{line}{Style.RESET_ALL}")
1134
+ print()
1135
+
1136
+ def init_deploy_file():
1137
+ target = os.path.join(os.getcwd(), "deploy.yml")
1138
+ if os.path.exists(target):
1139
+ log_warning(f"'deploy.yml' already exists in {os.getcwd()}.")
1140
+ confirm = input(f"{Fore.CYAN}Overwrite? (y/N): {Style.RESET_ALL}").strip().lower()
1141
+ if confirm != "y":
1142
+ print("Aborted.")
1143
+ return
1144
+ with open(target, "w", encoding="utf-8") as f:
1145
+ f.write(EXAMPLE_DEPLOY_YAML)
1146
+ log_success(f"Generated starter 'deploy.yml' in: {target}")
1147
+
1148
+
1149
+ # =============================================================================
1150
+ # 📋 INTERACTIVE SERVICE SELECTION MENU
1151
+ # =============================================================================
1152
+
1153
+ def select_service_interactive(services):
1154
+ banner()
1155
+ svc_keys = list(services.keys())
1156
+ if not svc_keys:
1157
+ log_error("No services available to deploy.")
1158
+ sys.exit(1)
1159
+
1160
+ print(f"{Fore.WHITE}{Style.BRIGHT}Select component to deploy:{Style.RESET_ALL}\n")
1161
+ for idx, name in enumerate(svc_keys, 1):
1162
+ s_cfg = services[name]
1163
+ is_docker = False
1164
+ docker_val = s_cfg.get("docker")
1165
+ if isinstance(docker_val, dict):
1166
+ is_docker = docker_val.get("enabled", True)
1167
+ elif isinstance(docker_val, bool):
1168
+ is_docker = docker_val
1169
+
1170
+ docker_badge = f"{Fore.CYAN}[Docker]{Style.RESET_ALL}" if is_docker else f"{Fore.LIGHTBLUE_EX}[No Docker]{Style.RESET_ALL}"
1171
+ proto = (s_cfg.get("server", {}).get("type") or "ssh").upper()
1172
+ proto_badge = f"{Fore.YELLOW}[{proto}]{Style.RESET_ALL}"
1173
+ desc = s_cfg.get("description", "")
1174
+ desc_str = f" - {Fore.LIGHTBLACK_EX}{desc}{Style.RESET_ALL}" if desc else ""
1175
+
1176
+ print(f" {Fore.GREEN}{Style.BRIGHT}{idx}){Style.RESET_ALL} {Fore.WHITE}{Style.BRIGHT}{name:<15}{Style.RESET_ALL} {docker_badge} {proto_badge}{desc_str}")
1177
+
1178
+ all_idx = len(svc_keys) + 1
1179
+ print(f" {Fore.GREEN}{Style.BRIGHT}{all_idx}){Style.RESET_ALL} {Fore.WHITE}{Style.BRIGHT}All Components {Style.RESET_ALL} {Fore.LIGHTBLACK_EX}(Deploy all services sequentially){Style.RESET_ALL}")
1180
+ print(f" {Fore.RED}q){Style.RESET_ALL} Quit\n")
1181
+
1182
+ while True:
1183
+ try:
1184
+ choice = input(f"{Fore.CYAN}Enter choice (1-{all_idx} or q): {Style.RESET_ALL}").strip().lower()
1185
+ except (KeyboardInterrupt, EOFError):
1186
+ print("\nOperation cancelled.")
1187
+ sys.exit(0)
1188
+
1189
+ if choice == "q":
1190
+ print(f"{Fore.YELLOW}Goodbye!{Style.RESET_ALL}")
1191
+ sys.exit(0)
1192
+
1193
+ if choice.isdigit():
1194
+ c_int = int(choice)
1195
+ if 1 <= c_int <= len(svc_keys):
1196
+ return [svc_keys[c_int - 1]]
1197
+ elif c_int == all_idx:
1198
+ return svc_keys
1199
+
1200
+ log_error("Invalid selection. Please choose an option from the menu.")
1201
+
1202
+
1203
+ # =============================================================================
1204
+ # 🏁 MAIN ENTRY POINT
1205
+ # =============================================================================
1206
+
1207
+ def main():
1208
+ parser = argparse.ArgumentParser(
1209
+ prog="uhdeploy",
1210
+ description="uhdeploy - Universal Local-to-VPS Deployment CLI",
1211
+ add_help=False
1212
+ )
1213
+ parser.add_argument("service", nargs="?", default=None, help="Service name to deploy (or 'all')")
1214
+ parser.add_argument("-h", "--help", action="store_true", help="Show help screen")
1215
+ parser.add_argument("-e", "--example", action="store_true", help="Show example deploy.yml configuration")
1216
+ parser.add_argument("--init", action="store_true", help="Generate starter deploy.yml in current directory")
1217
+ parser.add_argument("-f", "--file", dest="config_file", default=None, help="Path to deploy configuration file")
1218
+ parser.add_argument("-p", "--project", dest="project_dir", default=None, help="Project directory path")
1219
+ parser.add_argument("-s", "--service", dest="flag_service", default=None, help="Service name to deploy")
1220
+ parser.add_argument("-l", "--list", action="store_true", help="List all configured services")
1221
+ parser.add_argument("--dry-run", action="store_true", help="Dry run deployment without making remote changes")
1222
+
1223
+ args = parser.parse_args()
1224
+
1225
+ # Help flag
1226
+ if args.help:
1227
+ print_help_screen()
1228
+ sys.exit(0)
1229
+
1230
+ # Example flag
1231
+ if args.example:
1232
+ print_example_file()
1233
+ sys.exit(0)
1234
+
1235
+ # Init flag
1236
+ if args.init:
1237
+ banner()
1238
+ init_deploy_file()
1239
+ sys.exit(0)
1240
+
1241
+ # Find configuration file (Interactive flow if not found)
1242
+ config_path, project_root = find_config_interactive(
1243
+ cli_file=args.config_file,
1244
+ cli_project=args.project_dir
1245
+ )
1246
+
1247
+ log_info(f"Loaded configuration: {Fore.WHITE}{config_path}")
1248
+ log_info(f"Project Root: {Fore.WHITE}{project_root}")
1249
+
1250
+ # Parse config and substitute environment variables
1251
+ services, env_vars = parse_deploy_config(config_path, project_root)
1252
+
1253
+ # List services flag
1254
+ if args.list:
1255
+ banner()
1256
+ print(f"{Fore.WHITE}{Style.BRIGHT}Configured Services in {os.path.basename(config_path)}:{Style.RESET_ALL}\n")
1257
+ for s_name, s_data in services.items():
1258
+ s_type = "Docker" if s_data.get("docker") else "No Docker"
1259
+ proto = (s_data.get("server", {}).get("type") or "ssh").upper()
1260
+ print(f" â€ĸ {Fore.GREEN}{s_name:<16}{Style.RESET_ALL} [{s_type}] [{proto}] - {s_data.get('description', '')}")
1261
+ print()
1262
+ sys.exit(0)
1263
+
1264
+ # Determine service to deploy
1265
+ target_service = args.flag_service or args.service
1266
+
1267
+ if target_service:
1268
+ if target_service.lower() == "all":
1269
+ selected_services = list(services.keys())
1270
+ else:
1271
+ if target_service not in services:
1272
+ log_error(f"Service '{target_service}' is not defined in {os.path.basename(config_path)}.")
1273
+ log_info(f"Available services: {', '.join(services.keys())}")
1274
+ sys.exit(1)
1275
+ selected_services = [target_service]
1276
+ else:
1277
+ # If only 1 service defined, use it directly or show interactive menu
1278
+ selected_services = select_service_interactive(services)
1279
+
1280
+ # Execute deployment for chosen services
1281
+ banner()
1282
+ log_info(f"Deploying service(s): {Fore.WHITE}{', '.join(selected_services)}")
1283
+ if args.dry_run:
1284
+ log_warning("DRY-RUN MODE ENABLED: No remote changes or uploads will be performed.")
1285
+
1286
+ for s_name in selected_services:
1287
+ deploy_service(
1288
+ name=s_name,
1289
+ cfg=services[s_name],
1290
+ project_root=project_root,
1291
+ dry_run=args.dry_run
1292
+ )
1293
+
1294
+ print_separator("═", 75, Fore.GREEN)
1295
+ log_success("All selected deployment tasks completed successfully!")
1296
+ print_separator("═", 75, Fore.GREEN)
1297
+
1298
+ if __name__ == "__main__":
1299
+ main()
1300
+