commands.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. """
  2. Compose module commands and functionality.
  3. Manage Compose configurations and services and template operations.
  4. """
  5. import typer
  6. from pathlib import Path
  7. from rich.console import Console
  8. from rich.table import Table
  9. from typing import List, Optional, Set, Dict, Any
  10. from ...core.command import BaseModule
  11. from ...core.helpers import find_boilerplates
  12. from .variables import ComposeVariables
  13. class ComposeModule(BaseModule):
  14. """Module for managing compose boilerplates."""
  15. compose_filenames = ["compose.yaml", "docker-compose.yaml", "compose.yml", "docker-compose.yml"]
  16. library_path = Path(__file__).parent.parent.parent.parent / "library" / "compose"
  17. def __init__(self):
  18. super().__init__(name="compose", icon="🐳", description="Manage Compose Templates and Configurations")
  19. def get_valid_variables(self) -> Set[str]:
  20. """Get the set of valid variable names for the compose module."""
  21. variables = ComposeVariables()
  22. return set(variables._declared.keys())
  23. def _get_variable_details(self) -> Dict[str, Dict[str, Any]]:
  24. """Get detailed information about variables for display."""
  25. variables = ComposeVariables()
  26. details = {}
  27. for var_name, (set_name, var_meta) in variables._declared.items():
  28. details[var_name] = {
  29. 'set': set_name,
  30. 'type': var_meta.get('type', 'str'),
  31. 'display_name': var_meta.get('display_name', var_name),
  32. 'default': var_meta.get('default'),
  33. 'prompt': var_meta.get('prompt', '')
  34. }
  35. return details
  36. def _add_module_commands(self, app: typer.Typer) -> None:
  37. """Add Module-specific commands to the app."""
  38. @app.command("list", help="List all compose boilerplates")
  39. def list():
  40. """List all compose boilerplates from library/compose directory."""
  41. bps = find_boilerplates(self.library_path, self.compose_filenames)
  42. if not bps:
  43. self.console.print("[yellow]No compose boilerplates found.[/yellow]")
  44. return
  45. table = Table(title="🐳 Available Compose Boilerplates", title_style="bold blue")
  46. table.add_column("Name", style="cyan", no_wrap=True)
  47. table.add_column("Module", style="magenta")
  48. table.add_column("Path", style="green")
  49. table.add_column("Size", justify="right", style="yellow")
  50. table.add_column("Description", style="dim")
  51. for bp in bps:
  52. if bp.size < 1024:
  53. size_str = f"{bp.size} B"
  54. elif bp.size < 1024 * 1024:
  55. size_str = f"{bp.size // 1024} KB"
  56. else:
  57. size_str = f"{bp.size // (1024 * 1024)} MB"
  58. table.add_row(
  59. bp.name,
  60. bp.module,
  61. str(bp.file_path.relative_to(self.library_path)),
  62. size_str,
  63. bp.description[:50] + "..." if len(bp.description) > 50 else bp.description
  64. )
  65. self.console.print(table)
  66. @app.command("show", help="Show details about a compose boilerplate")
  67. def show(name: str, raw: bool = typer.Option(False, "--raw", help="Output only the raw boilerplate content")):
  68. """Show details about a compose boilerplate by name."""
  69. bps = find_boilerplates(self.library_path, self.compose_filenames)
  70. bp = next((b for b in bps if b.name.lower() == name.lower()), None)
  71. if not bp:
  72. self.console.print(f"[red]Boilerplate '{name}' not found.[/red]")
  73. return
  74. if raw:
  75. # Output only the raw boilerplate content
  76. print(bp.content)
  77. return
  78. # Print frontmatter info in a clever way
  79. table = Table(title=f"🐳 Boilerplate: {bp.name}", title_style="bold blue")
  80. table.add_column("Field", style="cyan", no_wrap=True)
  81. table.add_column("Value", style="green")
  82. info = bp.to_dict()
  83. for key, value in info.items():
  84. if isinstance(value, List):
  85. value = ", ".join(str(v) for v in value)
  86. table.add_row(key.title(), str(value))
  87. self.console.print(table)
  88. # Show the content of the boilerplate file in a cleaner form
  89. from rich.panel import Panel
  90. from rich.syntax import Syntax
  91. self.console.print() # Add spacing
  92. # Use syntax highlighting for YAML files
  93. syntax = Syntax(bp.content, "yaml", theme="monokai", line_numbers=True, word_wrap=True)
  94. panel = Panel(syntax, title=f"{bp.file_path.name}", border_style="blue", padding=(1,2))
  95. self.console.print(panel)
  96. @app.command("search", help="Search compose boilerplates")
  97. def search(query: str):
  98. pass
  99. @app.command("generate", help="Generate a compose file from a boilerplate and write to --out")
  100. def generate(
  101. name: str,
  102. out: Optional[Path] = typer.Option(None, "--out", "-o", help="Output path to write rendered boilerplate (prints to stdout when omitted)"),
  103. values_file: Optional[Path] = typer.Option(None, "--values-file", "-f", help="Load values from YAML/JSON file"),
  104. values: Optional[List[str]] = typer.Option(None, "--values", help="Set values (format: key=value)")
  105. ):
  106. """Render a compose boilerplate interactively and write output to --out."""
  107. bps = find_boilerplates(self.library_path, self.compose_filenames)
  108. bp = next((b for b in bps if b.name.lower() == name.lower()), None)
  109. if not bp:
  110. self.console.print(f"[red]Boilerplate '{name}' not found.[/red]")
  111. raise typer.Exit(code=1)
  112. cv = ComposeVariables()
  113. matched_sets, used_vars = cv.determine_variable_sets(bp.content)
  114. # If there are no detected variable sets but there are used vars, we still
  115. # need to prompt for the used variables. Lazy-import jinja2 only when
  116. # rendering is required so module import doesn't fail when Jinja2 is missing.
  117. if not used_vars:
  118. rendered = bp.content
  119. else:
  120. try:
  121. import jinja2
  122. except Exception:
  123. typer.secho("Jinja2 is required to render templates. Install it and retry.", fg=typer.colors.RED)
  124. raise typer.Exit(code=2)
  125. template_defaults = cv.extract_template_defaults(bp.content)
  126. used_subscripts = cv.find_used_subscript_keys(bp.content)
  127. # Load values from file if specified
  128. file_values = {}
  129. if values_file:
  130. if not values_file.exists():
  131. self.console.print(f"[red]Values file '{values_file}' not found.[/red]")
  132. raise typer.Exit(code=1)
  133. try:
  134. import yaml
  135. with open(values_file, 'r', encoding='utf-8') as f:
  136. if values_file.suffix.lower() in ['.yaml', '.yml']:
  137. file_values = yaml.safe_load(f) or {}
  138. elif values_file.suffix.lower() == '.json':
  139. import json
  140. file_values = json.load(f)
  141. else:
  142. self.console.print(f"[red]Unsupported file format '{values_file.suffix}'. Use .yaml, .yml, or .json[/red]")
  143. raise typer.Exit(code=1)
  144. self.console.print(f"[dim]Loaded values from {values_file}[/dim]")
  145. except Exception as e:
  146. self.console.print(f"[red]Failed to load values from {values_file}: {e}[/red]")
  147. raise typer.Exit(code=1)
  148. # Parse command-line values
  149. cli_values = {}
  150. if values:
  151. for value_pair in values:
  152. if '=' not in value_pair:
  153. self.console.print(f"[red]Invalid value format '{value_pair}'. Use key=value format.[/red]")
  154. raise typer.Exit(code=1)
  155. key, val = value_pair.split('=', 1)
  156. # Try to parse as JSON for complex values
  157. try:
  158. import json
  159. cli_values[key] = json.loads(val)
  160. except json.JSONDecodeError:
  161. cli_values[key] = val
  162. except Exception:
  163. cli_values[key] = val
  164. # Override template defaults with configured values
  165. from ...core.config import ConfigManager
  166. config_manager = ConfigManager(self.name)
  167. config_values = config_manager.list_all()
  168. # Merge values in order of precedence: template defaults <- config <- file <- CLI
  169. for key, config_value in config_values.items():
  170. template_defaults[key] = config_value
  171. for key, file_value in file_values.items():
  172. template_defaults[key] = file_value
  173. for key, cli_value in cli_values.items():
  174. template_defaults[key] = cli_value
  175. values_dict = cv.collect_values(used_vars, template_defaults, used_subscripts)
  176. # Enable Jinja2 whitespace control so that block tags like
  177. # {% if %} don't leave an extra newline in the rendered result.
  178. env = jinja2.Environment(loader=jinja2.BaseLoader(), trim_blocks=True, lstrip_blocks=True)
  179. template = env.from_string(bp.content)
  180. rendered = template.render(**values_dict)
  181. # If --out not provided, print to console; else write to file
  182. if out is None:
  183. from rich.panel import Panel
  184. from rich.syntax import Syntax
  185. syntax = Syntax(rendered, "yaml", theme="monokai", line_numbers=False, word_wrap=True)
  186. panel = Panel(syntax, title=f"{bp.name}", border_style="green", padding=(1,2))
  187. self.console.print(panel)
  188. else:
  189. # Ensure parent directory exists
  190. out_parent = out.parent
  191. if not out_parent.exists():
  192. out_parent.mkdir(parents=True, exist_ok=True)
  193. out.write_text(rendered, encoding="utf-8")
  194. self.console.print(f"[green]Rendered boilerplate written to {out}[/green]")