| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203 |
- """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",
- ]
|