commands.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  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. # Core BaseModule integration
  22. @property
  23. def template_paths(self) -> List[str]:
  24. # Prefer compose.yaml as default per project rules
  25. return self.compose_filenames
  26. @property
  27. def library_path(self) -> Path:
  28. return self._library_path
  29. @property
  30. def variable_handler_class(self):
  31. return ComposeVariables
  32. def _get_variable_details(self) -> Dict[str, Dict[str, Any]]:
  33. """Get detailed information about variables for display."""
  34. variables = ComposeVariables()
  35. details = {}
  36. for var_name, (set_name, var_meta) in variables._declared.items():
  37. details[var_name] = {
  38. 'set': set_name,
  39. 'type': var_meta.get('type', 'str'),
  40. 'display_name': var_meta.get('display_name', var_name),
  41. 'default': var_meta.get('default'),
  42. 'prompt': var_meta.get('prompt', '')
  43. }
  44. return details
  45. def _add_custom_commands(self, app: typer.Typer) -> None:
  46. """Add compose-specific commands to the app."""
  47. @app.command("list", help="List all compose boilerplates")
  48. def list():
  49. """List all compose boilerplates from library/compose directory."""
  50. bps = find_boilerplates(self.library_path, self.compose_filenames)
  51. if not bps:
  52. self.console.print("[yellow]No compose boilerplates found.[/yellow]")
  53. return
  54. table = Table(title="🐳 Available Compose Boilerplates", title_style="bold blue")
  55. table.add_column("Name", style="cyan", no_wrap=True)
  56. table.add_column("Module", style="magenta")
  57. table.add_column("Path", style="green")
  58. table.add_column("Size", justify="right", style="yellow")
  59. table.add_column("Description", style="dim")
  60. for bp in bps:
  61. if bp.size < 1024:
  62. size_str = f"{bp.size} B"
  63. elif bp.size < 1024 * 1024:
  64. size_str = f"{bp.size // 1024} KB"
  65. else:
  66. size_str = f"{bp.size // (1024 * 1024)} MB"
  67. table.add_row(
  68. bp.name,
  69. bp.module,
  70. str(bp.file_path.relative_to(self.library_path)),
  71. size_str,
  72. bp.description[:50] + "..." if len(bp.description) > 50 else bp.description
  73. )
  74. self.console.print(table)
  75. @app.command("show", help="Show details about a compose boilerplate")
  76. def show(name: str, raw: bool = typer.Option(False, "--raw", help="Output only the raw boilerplate content")):
  77. """Show details about a compose boilerplate by name."""
  78. bps = find_boilerplates(self.library_path, self.compose_filenames)
  79. # Match by directory name (parent folder of the compose file) instead of frontmatter 'name'
  80. bp = next((b for b in bps if b.file_path.parent.name.lower() == name.lower()), None)
  81. if not bp:
  82. self.console.print(f"[red]Boilerplate '{name}' not found.[/red]")
  83. return
  84. if raw:
  85. # Output only the raw boilerplate content
  86. print(bp.content)
  87. return
  88. # Print frontmatter info in a clean, readable format
  89. from rich.text import Text
  90. from rich.console import Group
  91. info = bp.to_dict()
  92. # Create a clean header
  93. header = Text()
  94. header.append("🐳 Boilerplate: ", style="bold")
  95. header.append(f"{info['name']}", style="bold blue")
  96. header.append(f" ({info['version']})", style="magenta")
  97. header.append("\n", style="bold")
  98. header.append(f"{info['description']}", style="dim white")
  99. # Create metadata section with clean formatting
  100. metadata = Text()
  101. metadata.append("\nDetails:\n", style="bold cyan")
  102. metadata.append("─" * 40 + "\n", style="dim cyan")
  103. # Format each field with consistent styling
  104. fields = [
  105. ("Tags", ", ".join(info['tags']), "cyan"),
  106. ("Author", info['author'], "dim white"),
  107. ("Date", info['date'], "dim white"),
  108. ("Size", info['size'], "dim white"),
  109. ("Path", info['path'], "dim white")
  110. ]
  111. for label, value, color in fields:
  112. metadata.append(f"{label}: ")
  113. metadata.append(f"{value}\n", style=color)
  114. # Handle files list if present
  115. if info['files'] and len(info['files']) > 0:
  116. metadata.append(" Files: ")
  117. files_str = ", ".join(info['files'][:3]) # Show first 3
  118. if len(info['files']) > 3:
  119. files_str += f" ... and {len(info['files']) - 3} more"
  120. metadata.append(f"{files_str}\n", style="green")
  121. # Display everything as a group
  122. display_group = Group(header, metadata)
  123. self.console.print(display_group)
  124. # Show the content of the boilerplate file in a cleaner form
  125. from rich.panel import Panel
  126. from rich.syntax import Syntax
  127. # Detect if content contains Jinja2 templating
  128. has_jinja = bool(re.search(r'\{\{.*\}\}|\{\%.*\%\}|\{\#.*\#\}', bp.content))
  129. # Use appropriate lexer based on content
  130. # Use yaml+jinja for combined YAML and Jinja2 highlighting when Jinja2 is present
  131. lexer = "yaml+jinja" if has_jinja else "yaml"
  132. syntax = Syntax(bp.content, lexer, theme="monokai", line_numbers=True, word_wrap=True)
  133. panel = Panel(syntax, title=f"{bp.file_path.name}", border_style="blue", padding=(1,2))
  134. self.console.print(panel)
  135. @app.command("search", help="Search compose boilerplates")
  136. def search(query: str):
  137. pass
  138. @app.command("generate", help="Generate a compose file from a boilerplate and write to --out")
  139. def generate(
  140. name: str,
  141. out: Optional[Path] = typer.Option(None, "--out", "-o", help="Output path to write rendered boilerplate (prints to stdout when omitted)"),
  142. values_file: Optional[Path] = typer.Option(None, "--values-file", "-f", help="Load values from YAML/JSON file"),
  143. cli_values: Optional[List[str]] = typer.Option(None, "--values", help="Set values (format: key=value)")
  144. ):
  145. """Render a compose boilerplate interactively and write output to --out."""
  146. from ...core import template, values as values_mod, render
  147. from ...core.config import ConfigManager
  148. # Find and validate boilerplate
  149. bps = find_boilerplates(self.library_path, self.compose_filenames)
  150. bp = next((b for b in bps if b.file_path.parent.name.lower() == name.lower()), None)
  151. if not bp:
  152. self.console.print(f"[red]Boilerplate '{name}' not found.[/red]")
  153. raise typer.Exit(code=1)
  154. # Clean template content and find variables
  155. cv = ComposeVariables()
  156. cleaned_content = template.clean_template_content(bp.content)
  157. matched_sets, used_vars = cv.determine_variable_sets(cleaned_content)
  158. # If no variables used, return original content
  159. if not used_vars:
  160. rendered = bp.content
  161. else:
  162. # Validate template syntax
  163. is_valid, error = template.validate_template(cleaned_content, bp.file_path)
  164. if not is_valid:
  165. self.console.print(f"[red]{error}[/red]")
  166. raise typer.Exit(code=2)
  167. # Extract defaults and variable metadata
  168. template_defaults = cv.extract_template_defaults(cleaned_content)
  169. try:
  170. meta_overrides = cv.extract_variable_meta_overrides(bp.content)
  171. # Merge overrides into declared metadata
  172. for var_name, overrides in meta_overrides.items():
  173. if var_name in cv._declared and isinstance(overrides, dict):
  174. existing = cv._declared[var_name][1]
  175. existing.update(overrides)
  176. except Exception:
  177. meta_overrides = {}
  178. # Get subscript keys and load values from all sources
  179. used_subscripts = cv.find_used_subscript_keys(bp.content)
  180. config_manager = ConfigManager(self.name)
  181. try:
  182. merged_values = values_mod.load_and_merge_values(
  183. values_file=values_file,
  184. cli_values=cli_values,
  185. config_values=config_manager.list_all(),
  186. defaults=template_defaults
  187. )
  188. except Exception as e:
  189. self.console.print(f"[red]{str(e)}[/red]")
  190. raise typer.Exit(code=1)
  191. # Collect final values and render template
  192. values_dict = cv.collect_values(used_vars, merged_values, used_subscripts)
  193. success, rendered, error = template.render_template(
  194. cleaned_content,
  195. values_dict
  196. )
  197. if not success:
  198. self.console.print(f"[red]{error}[/red]")
  199. raise typer.Exit(code=2)
  200. # Output the rendered content
  201. output_handler = render.RenderOutput(self.console)
  202. output_handler.output_rendered_content(
  203. rendered,
  204. out,
  205. "yaml",
  206. bp.name
  207. )