mirror of
https://github.com/ChristianLempa/boilerplates.git
synced 2026-08-29 10:34:11 -05:00
204 lines
7.5 KiB
Python
204 lines
7.5 KiB
Python
"""Helpers for generation destinations and remote uploads."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shlex
|
|
import subprocess
|
|
import tempfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from ..input import InputManager
|
|
|
|
|
|
@dataclass
|
|
class GenerationDestination:
|
|
"""Resolved generation target."""
|
|
|
|
mode: str
|
|
local_output_dir: Path | None = None
|
|
remote_host: str | None = None
|
|
remote_path: str | None = None
|
|
|
|
@property
|
|
def is_remote(self) -> bool:
|
|
return self.mode == "remote"
|
|
|
|
|
|
def normalize_output_path(path_value: str) -> Path:
|
|
"""Normalize paths that look absolute but were provided without a leading slash."""
|
|
output_dir = Path(path_value)
|
|
if not output_dir.is_absolute() and str(output_dir).startswith(("Users/", "home/", "usr/", "opt/", "var/", "tmp/")):
|
|
output_dir = Path("/") / output_dir
|
|
return output_dir
|
|
|
|
|
|
def resolve_cli_destination(
|
|
output: str | None,
|
|
remote: str | None,
|
|
remote_path: str | None,
|
|
slug: str,
|
|
) -> GenerationDestination | None:
|
|
"""Resolve generation destination from explicit CLI flags only."""
|
|
if output and remote:
|
|
raise ValueError("Use either --output for a local directory or --remote for a remote server, not both")
|
|
if remote_path and not remote:
|
|
raise ValueError("--remote-path requires --remote")
|
|
|
|
if remote:
|
|
return GenerationDestination(
|
|
mode="remote",
|
|
remote_host=remote,
|
|
remote_path=remote_path or f"~/{slug}",
|
|
)
|
|
|
|
if output:
|
|
return GenerationDestination(mode="local", local_output_dir=normalize_output_path(output))
|
|
|
|
return None
|
|
|
|
|
|
def prompt_generation_destination(slug: str) -> GenerationDestination:
|
|
"""Prompt for local or remote generation target."""
|
|
input_mgr = InputManager()
|
|
destination_mode = input_mgr.numbered_choice("Store generated template in", ["local", "remote"], default="local")
|
|
|
|
if destination_mode == "local":
|
|
local_default = str(Path.cwd() / slug)
|
|
local_output = input_mgr.text("Local output directory", default=local_default).strip() or local_default
|
|
return GenerationDestination(mode="local", local_output_dir=normalize_output_path(local_output))
|
|
|
|
remote_host = input_mgr.text("Remote server host or IP address", default=None).strip()
|
|
if not remote_host:
|
|
raise ValueError("Remote server host or IP address cannot be empty")
|
|
|
|
remote_default = f"~/{slug}"
|
|
remote_path = input_mgr.text("Remote target directory", default=remote_default).strip() or remote_default
|
|
return GenerationDestination(mode="remote", remote_host=remote_host, remote_path=remote_path)
|
|
|
|
|
|
def format_remote_destination(host: str, remote_path: str) -> str:
|
|
"""Format host/path for user-facing messages."""
|
|
return f"{host}:{remote_path}"
|
|
|
|
|
|
def build_remote_shell_path(remote_path: str, trailing_slash: bool = False) -> str:
|
|
"""Build a shell-safe remote path expression for ssh/scp commands."""
|
|
normalized = remote_path.rstrip("/")
|
|
suffix = "/" if trailing_slash else ""
|
|
|
|
if normalized in {"~", ""}:
|
|
return f'"$HOME"{suffix}'
|
|
|
|
if normalized.startswith("~/"):
|
|
relative = normalized[2:]
|
|
quoted_relative = shlex.quote(f"{relative}{suffix}")
|
|
return f'"$HOME"/{quoted_relative}'
|
|
|
|
return shlex.quote(f"{normalized}{suffix}")
|
|
|
|
|
|
def build_scp_remote_target(remote_host: str, remote_path: str, trailing_slash: bool = False) -> str:
|
|
"""Build an scp destination target for an already-resolved remote path."""
|
|
normalized = remote_path.rstrip("/")
|
|
suffix = "/" if trailing_slash else ""
|
|
|
|
quoted_path = shlex.quote(f"{normalized}{suffix}")
|
|
return f"{remote_host}:{quoted_path}"
|
|
|
|
|
|
def resolve_remote_home_directory(remote_host: str) -> str:
|
|
"""Resolve the remote user's home directory over SSH."""
|
|
home_result = subprocess.run(
|
|
["ssh", remote_host, "printf '%s' \"$HOME\""],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if home_result.returncode != 0:
|
|
error_output = home_result.stderr.strip() or home_result.stdout.strip() or "SSH home resolution failed"
|
|
raise RuntimeError(f"Failed to resolve remote home directory on '{remote_host}': {error_output}")
|
|
|
|
remote_home = home_result.stdout.strip()
|
|
if not remote_home:
|
|
raise RuntimeError(f"Failed to resolve remote home directory on '{remote_host}': empty response")
|
|
|
|
return remote_home
|
|
|
|
|
|
def resolve_remote_absolute_path(remote_host: str, remote_path: str, trailing_slash: bool = False) -> str:
|
|
"""Resolve ~-prefixed remote paths to absolute remote filesystem paths."""
|
|
normalized = remote_path.rstrip("/")
|
|
suffix = "/" if trailing_slash else ""
|
|
|
|
if normalized not in {"~", ""} and not normalized.startswith("~/"):
|
|
return f"{normalized}{suffix}"
|
|
|
|
remote_home = resolve_remote_home_directory(remote_host)
|
|
|
|
return f"{remote_home}{suffix}" if normalized in {"~", ""} else f"{remote_home}/{normalized[2:]}{suffix}"
|
|
|
|
|
|
def resolve_remote_upload_target(remote_host: str, remote_path: str, trailing_slash: bool = False) -> str:
|
|
"""Resolve remote paths to absolute scp targets."""
|
|
absolute_path = resolve_remote_absolute_path(remote_host, remote_path, trailing_slash=trailing_slash)
|
|
return build_scp_remote_target(remote_host, absolute_path, trailing_slash=trailing_slash)
|
|
|
|
|
|
def _write_staging_files(staging_dir: Path, rendered_files: dict[str, str]) -> None:
|
|
"""Write rendered files to a local staging directory."""
|
|
staging_dir.mkdir(parents=True, exist_ok=True)
|
|
for file_path, content in rendered_files.items():
|
|
full_path = staging_dir / file_path
|
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
full_path.write_text(content, encoding="utf-8")
|
|
|
|
|
|
def write_rendered_files_remote(
|
|
remote_host: str,
|
|
remote_path: str,
|
|
rendered_files: dict[str, str],
|
|
) -> None:
|
|
"""Upload rendered files to a remote host over SSH."""
|
|
with tempfile.TemporaryDirectory(prefix="boilerplates-remote-") as staging_root:
|
|
staging_dir = Path(staging_root)
|
|
_write_staging_files(staging_dir, rendered_files)
|
|
|
|
remote_mkdir_path = build_remote_shell_path(remote_path)
|
|
remote_copy_target = resolve_remote_upload_target(remote_host, remote_path, trailing_slash=True)
|
|
|
|
mkdir_result = subprocess.run(
|
|
["ssh", remote_host, f"mkdir -p -- {remote_mkdir_path}"],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if mkdir_result.returncode != 0:
|
|
error_output = mkdir_result.stderr.strip() or mkdir_result.stdout.strip() or "SSH mkdir failed"
|
|
raise RuntimeError(f"Failed to prepare remote directory '{remote_path}' on '{remote_host}': {error_output}")
|
|
|
|
upload_result = subprocess.run(
|
|
["scp", "-r", f"{staging_dir}/.", remote_copy_target],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if upload_result.returncode != 0:
|
|
error_output = upload_result.stderr.strip() or upload_result.stdout.strip() or "SCP upload failed"
|
|
raise RuntimeError(f"Failed to upload files to '{remote_host}:{remote_path}': {error_output}")
|
|
|
|
|
|
__all__ = [
|
|
"GenerationDestination",
|
|
"build_remote_shell_path",
|
|
"build_scp_remote_target",
|
|
"format_remote_destination",
|
|
"normalize_output_path",
|
|
"prompt_generation_destination",
|
|
"resolve_cli_destination",
|
|
"resolve_remote_absolute_path",
|
|
"resolve_remote_home_directory",
|
|
"resolve_remote_upload_target",
|
|
"write_rendered_files_remote",
|
|
]
|