diff --git a/app/src/confgen/cli.py b/app/src/confgen/cli.py new file mode 100644 index 0000000..fb54917 --- /dev/null +++ b/app/src/confgen/cli.py @@ -0,0 +1,295 @@ +"""Command-line interface for confgen.""" + +from pathlib import Path +from typing import Optional + +import click +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from .core import ConfgenCore +from .editor import InteractiveEditor +from .validator import SchemaValidator + +console = Console() + + +@click.group() +@click.option( + "--config-dir", + type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path), + help="Directory containing confgen.yaml config", +) +@click.option( + "--templates-dir", + type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path), + help="Directory containing template files", +) +@click.option( + "--output-dir", + type=click.Path(file_okay=False, dir_okay=True, path_type=Path), + help="Directory for generated configs", +) +@click.option( + "--environment", + "-e", + default="dev", + help="Default environment (dev/staging/prod)", +) +@click.version_option(version="0.1.0") +@click.pass_context +def main( + ctx: click.Context, + config_dir: Optional[Path], + templates_dir: Optional[Path], + output_dir: Optional[Path], + environment: str, +) -> None: + """Generate, validate, and manage environment-specific configuration files.""" + ctx.ensure_object(dict) + ctx.obj["config_dir"] = config_dir or Path.cwd() + ctx.obj["templates_dir"] = templates_dir or Path.cwd() / "templates" + ctx.obj["output_dir"] = output_dir or Path.cwd() / "output" + ctx.obj["environment"] = environment + ctx.obj["core"] = ConfgenCore( + config_dir=ctx.obj["config_dir"], + templates_dir=ctx.obj["templates_dir"], + output_dir=ctx.obj["output_dir"], + environment=environment, + ) + + +@main.command() +@click.argument("template_name", type=str) +@click.option( + "--environment", + "-e", + help="Environment name (overrides default)", +) +@click.option( + "--output", + "-o", + type=click.Path(path_type=Path), + help="Output file path", +) +@click.option( + "--format", + "-f", + type=click.Choice(["json", "yaml", "toml"]), + help="Output format", +) +@click.option( + "--validate/--no-validate", + default=True, + help="Validate against schema", +) +@click.option( + "--diff", + is_flag=True, + help="Show diff before applying", +) +@click.option( + "--edit", + is_flag=True, + help="Open interactive editor", +) +@click.pass_context +def generate( + ctx: click.Context, + template_name: str, + environment: Optional[str], + output: Optional[Path], + format: Optional[str], + validate: bool, + diff: bool, + edit: bool, +) -> None: + """Generate a configuration file from a template.""" + core = ctx.obj["core"] + env = environment or ctx.obj["environment"] + + try: + if edit: + editor = InteractiveEditor(core, template_name, env) + result = editor.run() + if result: + console.print(Panel("[green]Configuration saved successfully![/green]")) + return + + current_config = None + if output and output.exists(): + current_config = output.read_text() + + generated = core.generate_config(template_name, env, format) + + if diff and current_config: + from .diff import ConfigDiff + + diff_viewer = ConfigDiff() + diff_viewer.show_diff(current_config, generated.content) + if not click.confirm("Apply these changes?"): + console.print("[yellow]Operation cancelled.[/yellow]") + return + + if output: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(generated.content) + console.print(f"[green]Generated: {output}[/green]") + else: + console.print(generated.content) + + if validate and generated.schema_validated: + console.print("[green]Schema validation passed.[/green]") + elif validate and not generated.schema_validated: + console.print("[yellow]Schema validation skipped (no schema defined).[/yellow]") + + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + raise click.Exit(1) + + +@main.command() +@click.option("--templates", is_flag=True, help="List available templates") +@click.option("--environments", is_flag=True, help="List available environments") +@click.option("--env", type=str, help="Show variables for specific environment") +@click.pass_context +def list(ctx: click.Context, templates: bool, environments: bool, env: Optional[str]) -> None: + """List available templates and environments.""" + core = ctx.obj["core"] + + if templates or not environments: + table = Table(title="Templates") + table.add_column("Name") + table.add_column("Path") + table.add_column("Format") + + for name, tmpl in core.config.templates.items(): + table.add_row(name, str(tmpl.path), tmpl.format or "auto") + + console.print(table) + + if environments or env: + table = Table(title="Environments") + table.add_column("Name") + table.add_column("Variables") + + for name, env_config in core.config.environments.items(): + if env is None or name == env: + var_count = len(env_config.variables) + table.add_row(name, str(var_count) + " variables") + + console.print(table) + + if env: + console.print(f"\n[bold]Variables for {env}:[/bold]") + env_config = core.config.environments.get(env) + if env_config: + for key, value in env_config.variables.items(): + if "SECRET" in key or "PASSWORD" in key or "API_KEY" in key: + console.print(f" {key}: *****") + else: + console.print(f" {key}: {value}") + + +@main.command() +@click.argument("config_file", type=click.Path(exists=True, path_type=Path)) +@click.option( + "--schema", + "-s", + type=click.Path(exists=True, path_type=Path), + help="Schema file path", +) +@click.option( + "--template", + "-t", + type=str, + help="Template name to use its schema", +) +@click.pass_context +def validate( + ctx: click.Context, + config_file: Path, + schema: Optional[Path], + template: Optional[str], +) -> None: + """Validate a configuration file against a schema.""" + core = ctx.obj["core"] + + if template and not schema: + tmpl = core.config.templates.get(template) + if tmpl and tmpl.schema: + schema = Path(tmpl.schema) + + if not schema: + console.print("[red]No schema specified. Use --schema or --template.[/red]") + raise click.Exit(1) + + validator = SchemaValidator(schema) + content = config_file.read_text() + + if config_file.suffix == ".json": + import json + + config_data = json.loads(content) + elif config_file.suffix in (".yaml", ".yml"): + import yaml + + config_data = yaml.safe_load(content) + elif config_file.suffix == ".toml": + import toml + + config_data = toml.loads(content) + else: + console.print("[red]Unsupported config format.[/red]") + raise click.Exit(1) + + is_valid, errors = validator.validate(config_data) + + if is_valid: + console.print("[green]Validation passed![/green]") + else: + console.print("[red]Validation failed:[/red]") + for error in errors: + console.print(f" - {error}") + raise click.Exit(1) + + +@main.command() +@click.argument("template_name", type=str) +@click.option( + "--environment", + "-e", + help="Environment name", +) +@click.option( + "--output", + "-o", + type=click.Path(path_type=Path), + help="Output file path", +) +@click.pass_context +def edit( + ctx: click.Context, + template_name: str, + environment: Optional[str], + output: Optional[Path], +) -> None: + """Open interactive editor for editing config values.""" + core = ctx.obj["core"] + env = environment or ctx.obj["environment"] + + try: + editor = InteractiveEditor(core, template_name, env) + result = editor.run() + + if result and output: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(result) + console.print(f"[green]Saved to: {output}[/green]") + elif result: + console.print(result) + + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + raise click.Exit(1)