commands.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. """
  2. Compose module commands and functionality.
  3. Manage Compose configurations and services and template operations.
  4. """
  5. import re
  6. import typer
  7. from pathlib import Path
  8. from rich.console import Console
  9. from rich.table import Table
  10. from rich.syntax import Syntax
  11. from typing import List, Optional, Set, Dict, Any
  12. from ...core.command import BaseModule
  13. from ...core.helpers import find_boilerplates
  14. from .variables import ComposeVariables
  15. class ComposeModule(BaseModule):
  16. """Module for managing compose boilerplates."""
  17. compose_filenames = ["compose.yaml", "docker-compose.yaml", "compose.yml", "docker-compose.yml"]
  18. library_path = Path(__file__).parent.parent.parent.parent / "library" / "compose"
  19. def __init__(self):
  20. super().__init__(name="compose", icon="🐳", description="Manage Compose Templates and Configurations")
  21. def get_valid_variables(self) -> Set[str]:
  22. """Get the set of valid variable names for the compose module."""
  23. variables = ComposeVariables()
  24. return set(variables._declared.keys())
  25. def _get_variable_details(self) -> Dict[str, Dict[str, Any]]:
  26. """Get detailed information about variables for display."""
  27. variables = ComposeVariables()
  28. details = {}
  29. for var_name, (set_name, var_meta) in variables._declared.items():
  30. details[var_name] = {
  31. 'set': set_name,
  32. 'type': var_meta.get('type', 'str'),
  33. 'display_name': var_meta.get('display_name', var_name),
  34. 'default': var_meta.get('default'),
  35. 'prompt': var_meta.get('prompt', '')
  36. }
  37. return details
  38. def _add_module_commands(self, app: typer.Typer) -> None:
  39. """Add Module-specific commands to the app."""
  40. @app.command("list", help="List all compose boilerplates")
  41. def list():
  42. """List all compose boilerplates from library/compose directory."""
  43. bps = find_boilerplates(self.library_path, self.compose_filenames)
  44. if not bps:
  45. self.console.print("[yellow]No compose boilerplates found.[/yellow]")
  46. return
  47. table = Table(title="🐳 Available Compose Boilerplates", title_style="bold blue")
  48. table.add_column("Name", style="cyan", no_wrap=True)
  49. table.add_column("Module", style="magenta")
  50. table.add_column("Path", style="green")
  51. table.add_column("Size", justify="right", style="yellow")
  52. table.add_column("Description", style="dim")
  53. for bp in bps:
  54. if bp.size < 1024:
  55. size_str = f"{bp.size} B"
  56. elif bp.size < 1024 * 1024:
  57. size_str = f"{bp.size // 1024} KB"
  58. else:
  59. size_str = f"{bp.size // (1024 * 1024)} MB"
  60. table.add_row(
  61. bp.name,
  62. bp.module,
  63. str(bp.file_path.relative_to(self.library_path)),
  64. size_str,
  65. bp.description[:50] + "..." if len(bp.description) > 50 else bp.description
  66. )
  67. self.console.print(table)
  68. @app.command("show", help="Show details about a compose boilerplate")
  69. def show(name: str, raw: bool = typer.Option(False, "--raw", help="Output only the raw boilerplate content")):
  70. """Show details about a compose boilerplate by name."""
  71. bps = find_boilerplates(self.library_path, self.compose_filenames)
  72. # Match by directory name (parent folder of the compose file) instead of frontmatter 'name'
  73. bp = next((b for b in bps if b.file_path.parent.name.lower() == name.lower()), None)
  74. if not bp:
  75. self.console.print(f"[red]Boilerplate '{name}' not found.[/red]")
  76. return
  77. if raw:
  78. # Output only the raw boilerplate content
  79. print(bp.content)
  80. return
  81. # Print frontmatter info in a clean, readable format
  82. from rich.text import Text
  83. from rich.console import Group
  84. info = bp.to_dict()
  85. # Create a clean header
  86. header = Text()
  87. header.append("🐳 Boilerplate: ", style="bold")
  88. header.append(f"{info['name']}", style="bold blue")
  89. header.append(f" ({info['version']})", style="magenta")
  90. header.append("\n", style="bold")
  91. header.append(f"{info['description']}", style="dim white")
  92. # Create metadata section with clean formatting
  93. metadata = Text()
  94. metadata.append("\nDetails:\n", style="bold cyan")
  95. metadata.append("─" * 40 + "\n", style="dim cyan")
  96. # Format each field with consistent styling
  97. fields = [
  98. ("Tags", ", ".join(info['tags']), "cyan"),
  99. ("Author", info['author'], "dim white"),
  100. ("Date", info['date'], "dim white"),
  101. ("Size", info['size'], "dim white"),
  102. ("Path", info['path'], "dim white")
  103. ]
  104. for label, value, color in fields:
  105. metadata.append(f"{label}: ")
  106. metadata.append(f"{value}\n", style=color)
  107. # Handle files list if present
  108. if info['files'] and len(info['files']) > 0:
  109. metadata.append(" Files: ")
  110. files_str = ", ".join(info['files'][:3]) # Show first 3
  111. if len(info['files']) > 3:
  112. files_str += f" ... and {len(info['files']) - 3} more"
  113. metadata.append(f"{files_str}\n", style="green")
  114. # Display everything as a group
  115. display_group = Group(header, metadata)
  116. self.console.print(display_group)
  117. # Show the content of the boilerplate file in a cleaner form
  118. from rich.panel import Panel
  119. from rich.syntax import Syntax
  120. # Detect if content contains Jinja2 templating
  121. has_jinja = bool(re.search(r'\{\{.*\}\}|\{\%.*\%\}|\{\#.*\#\}', bp.content))
  122. # Use appropriate lexer based on content
  123. # Use yaml+jinja for combined YAML and Jinja2 highlighting when Jinja2 is present
  124. lexer = "yaml+jinja" if has_jinja else "yaml"
  125. syntax = Syntax(bp.content, lexer, theme="monokai", line_numbers=True, word_wrap=True)
  126. panel = Panel(syntax, title=f"{bp.file_path.name}", border_style="blue", padding=(1,2))
  127. self.console.print(panel)
  128. @app.command("search", help="Search compose boilerplates")
  129. def search(query: str):
  130. pass
  131. @app.command("generate", help="Generate a compose file from a boilerplate and write to --out")
  132. def generate(
  133. name: str,
  134. out: Optional[Path] = typer.Option(None, "--out", "-o", help="Output path to write rendered boilerplate (prints to stdout when omitted)"),
  135. values_file: Optional[Path] = typer.Option(None, "--values-file", "-f", help="Load values from YAML/JSON file"),
  136. values: Optional[List[str]] = typer.Option(None, "--values", help="Set values (format: key=value)")
  137. ):
  138. """Render a compose boilerplate interactively and write output to --out."""
  139. bps = find_boilerplates(self.library_path, self.compose_filenames)
  140. # Match by directory name (parent folder of the compose file) instead of frontmatter 'name'
  141. bp = next((b for b in bps if b.file_path.parent.name.lower() == name.lower()), None)
  142. if not bp:
  143. self.console.print(f"[red]Boilerplate '{name}' not found.[/red]")
  144. raise typer.Exit(code=1)
  145. cv = ComposeVariables()
  146. # Remove any in-template `{% variables %} ... {% endvariables %}` block
  147. # before asking Jinja2 to parse/render the template. This block is
  148. # used only to provide metadata overrides and is not valid Jinja2
  149. # syntax for the default parser (unknown tag -> TemplateSyntaxError).
  150. import re
  151. cleaned_content = re.sub(r"\{%\s*variables\s*%\}(.+?)\{%\s*endvariables\s*%\}\n?", "", bp.content, flags=re.S)
  152. matched_sets, used_vars = cv.determine_variable_sets(cleaned_content)
  153. # If there are no detected variable sets but there are used vars, we still
  154. # need to prompt for the used variables. Lazy-import jinja2 only when
  155. # rendering is required so module import doesn't fail when Jinja2 is missing.
  156. if not used_vars:
  157. rendered = bp.content
  158. else:
  159. try:
  160. import jinja2
  161. except Exception:
  162. typer.secho("Jinja2 is required to render templates. Install it and retry.", fg=typer.colors.RED)
  163. raise typer.Exit(code=2)
  164. # Use the cleaned content for defaults and rendering, but extract
  165. # overrides from the original content (which may contain the
  166. # variables block).
  167. template_defaults = cv.extract_template_defaults(cleaned_content)
  168. # Validate Jinja2 template syntax before proceeding. Parsing the
  169. # template will surface syntax errors (unclosed blocks, invalid
  170. # tags, etc.) early and allow us to abort with a helpful message.
  171. try:
  172. env_for_validation = jinja2.Environment(loader=jinja2.BaseLoader())
  173. env_for_validation.parse(cleaned_content)
  174. except jinja2.exceptions.TemplateSyntaxError as e:
  175. # Show file path (if available) and error details, then exit.
  176. self.console.print(f"[red]Template syntax error in '{bp.file_path}': {e.message} (line {e.lineno})[/red]")
  177. raise typer.Exit(code=2)
  178. except Exception as e:
  179. # Generic parse failure
  180. self.console.print(f"[red]Failed to parse template '{bp.file_path}': {e}[/red]")
  181. raise typer.Exit(code=2)
  182. # Extract variable metadata overrides from a {% variables %} block
  183. try:
  184. meta_overrides = cv.extract_variable_meta_overrides(bp.content)
  185. # Merge overrides into declared metadata so PromptHandler will pick them up
  186. for var_name, overrides in meta_overrides.items():
  187. if var_name in cv._declared and isinstance(overrides, dict):
  188. existing = cv._declared[var_name][1]
  189. # shallow merge
  190. existing.update(overrides)
  191. except Exception:
  192. meta_overrides = {}
  193. used_subscripts = cv.find_used_subscript_keys(bp.content)
  194. # Load values from file if specified
  195. file_values = {}
  196. if values_file:
  197. if not values_file.exists():
  198. self.console.print(f"[red]Values file '{values_file}' not found.[/red]")
  199. raise typer.Exit(code=1)
  200. try:
  201. import yaml
  202. with open(values_file, 'r', encoding='utf-8') as f:
  203. if values_file.suffix.lower() in ['.yaml', '.yml']:
  204. file_values = yaml.safe_load(f) or {}
  205. elif values_file.suffix.lower() == '.json':
  206. import json
  207. file_values = json.load(f)
  208. else:
  209. self.console.print(f"[red]Unsupported file format '{values_file.suffix}'. Use .yaml, .yml, or .json[/red]")
  210. raise typer.Exit(code=1)
  211. self.console.print(f"[dim]Loaded values from {values_file}[/dim]")
  212. except Exception as e:
  213. self.console.print(f"[red]Failed to load values from {values_file}: {e}[/red]")
  214. raise typer.Exit(code=1)
  215. # Parse command-line values
  216. cli_values = {}
  217. if values:
  218. for value_pair in values:
  219. if '=' not in value_pair:
  220. self.console.print(f"[red]Invalid value format '{value_pair}'. Use key=value format.[/red]")
  221. raise typer.Exit(code=1)
  222. key, val = value_pair.split('=', 1)
  223. # Try to parse as JSON for complex values
  224. try:
  225. import json
  226. cli_values[key] = json.loads(val)
  227. except json.JSONDecodeError:
  228. cli_values[key] = val
  229. except Exception:
  230. cli_values[key] = val
  231. # Override template defaults with configured values
  232. from ...core.config import ConfigManager
  233. config_manager = ConfigManager(self.name)
  234. config_values = config_manager.list_all()
  235. # Merge values in order of precedence: template defaults <- config <- file <- CLI
  236. for key, config_value in config_values.items():
  237. template_defaults[key] = config_value
  238. for key, file_value in file_values.items():
  239. template_defaults[key] = file_value
  240. for key, cli_value in cli_values.items():
  241. template_defaults[key] = cli_value
  242. values_dict = cv.collect_values(used_vars, template_defaults, used_subscripts)
  243. # Enable Jinja2 whitespace control so that block tags like
  244. # {% if %} don't leave an extra newline in the rendered result.
  245. env = jinja2.Environment(loader=jinja2.BaseLoader(), trim_blocks=True, lstrip_blocks=True)
  246. try:
  247. template = env.from_string(cleaned_content)
  248. except jinja2.exceptions.TemplateSyntaxError as e:
  249. self.console.print(f"[red]Template syntax error in '{bp.file_path}': {e.message} (line {e.lineno})[/red]")
  250. raise typer.Exit(code=2)
  251. except Exception as e:
  252. self.console.print(f"[red]Failed to compile template '{bp.file_path}': {e}[/red]")
  253. raise typer.Exit(code=2)
  254. try:
  255. rendered = template.render(**values_dict)
  256. except jinja2.exceptions.TemplateError as e:
  257. # Catch runtime/template errors (undefined variables, etc.)
  258. self.console.print(f"[red]Template rendering error for '{bp.file_path}': {e}[/red]")
  259. raise typer.Exit(code=2)
  260. except Exception as e:
  261. self.console.print(f"[red]Unexpected error while rendering '{bp.file_path}': {e}[/red]")
  262. raise typer.Exit(code=2)
  263. # If --out not provided, print to console; else write to file
  264. if out is None:
  265. # Print a subtle rule and a small header, then the highlighted YAML
  266. self.console.print(f"\n\nGenerated Boilerplate for [bold cyan]{bp.name}[/bold cyan]\n")
  267. syntax = Syntax(rendered, "yaml", theme="monokai", line_numbers=False, word_wrap=True)
  268. self.console.print(syntax)
  269. else:
  270. # Ensure parent directory exists
  271. out_parent = out.parent
  272. if not out_parent.exists():
  273. out_parent.mkdir(parents=True, exist_ok=True)
  274. out.write_text(rendered, encoding="utf-8")
  275. self.console.print(f"[green]Rendered boilerplate written to {out}[/green]")