""" UnitForge CLI - Command line interface for systemd unit file management. This module provides a comprehensive command-line interface for creating, validating, and managing systemd unit files. """ import sys from pathlib import Path from typing import Any, Dict import click # Add the backend directory to the path so we can import our modules # This needs to be done before importing our app modules _backend_path = str(Path(__file__).parent.parent) if _backend_path not in sys.path: sys.path.insert(0, _backend_path) # Import our modules after path setup from app.core.templates import template_registry # noqa: E402 from app.core.unit_file import SystemdUnitFile, UnitType, create_unit_file # noqa: E402 @click.group() @click.version_option(version="1.0.0") def cli(): """ UnitForge - Create, validate, and manage systemd unit files. A comprehensive tool for working with systemd unit files from the command line. """ pass @cli.command() @click.argument("file_path", type=click.Path(exists=True)) @click.option("--verbose", "-v", is_flag=True, help="Show detailed validation output") @click.option( "--warnings", "-w", is_flag=True, help="Show warnings in addition to errors" ) def validate(file_path: str, verbose: bool, warnings: bool): """Validate a systemd unit file.""" try: unit_file = SystemdUnitFile(file_path=file_path) validation_errors = unit_file.validate() errors = [e for e in validation_errors if e.severity == "error"] warning_list = [e for e in validation_errors if e.severity != "error"] if not errors and not warning_list: click.echo(click.style("✓ Unit file is valid", fg="green")) return if errors: click.echo(click.style(f"✗ Found {len(errors)} error(s):", fg="red")) for error in errors: location = f"[{error.section}" if error.key: location += f".{error.key}" location += "]" click.echo(f" {location} {error.message}") if warnings and warning_list: click.echo( click.style(f"⚠ Found {len(warning_list)} warning(s):", fg="yellow") ) for warning in warning_list: location = f"[{warning.section}" if warning.key: location += f".{warning.key}" location += "]" click.echo(f" {location} {warning.message}") if verbose: click.echo("\nUnit file info:") info = unit_file.get_info() if info.description: click.echo(f" Description: {info.description}") if info.unit_type: click.echo(f" Type: {info.unit_type.value}") if info.requires: click.echo(f" Requires: {', '.join(info.requires)}") if info.wants: click.echo(f" Wants: {', '.join(info.wants)}") sys.exit(1 if errors else 0) except Exception as e: click.echo(click.style(f"Error: {str(e)}", fg="red")) sys.exit(1) @cli.command() @click.option( "--type", "unit_type", type=click.Choice(["service", "timer", "socket", "mount", "target"]), required=True, help="Type of unit file to create", ) @click.option("--name", required=True, help="Name of the unit") @click.option("--description", help="Description of the unit") @click.option("--exec-start", help="Command to execute (for services)") @click.option("--user", help="User to run the service as") @click.option("--group", help="Group to run the service as") @click.option("--working-directory", help="Working directory for the service") @click.option( "--restart", type=click.Choice(["no", "always", "on-failure", "on-success"]), default="on-failure", help="Restart policy", ) @click.option( "--wanted-by", multiple=True, help="Target to be wanted by (can be used multiple times)", ) @click.option("--output", "-o", help="Output file path") @click.option( "--validate-output", is_flag=True, help="Validate the generated unit file" ) def create( unit_type: str, name: str, description: str, exec_start: str, user: str, group: str, working_directory: str, restart: str, wanted_by: tuple, output: str, validate_output: bool, ): """Create a new systemd unit file.""" try: # Convert string to enum unit_type_enum = UnitType(unit_type) # Build parameters kwargs: Dict[str, Any] = { "description": description or f"{name} {unit_type}", } if exec_start: kwargs["exec_start"] = exec_start if user: kwargs["user"] = user if group: kwargs["group"] = group if working_directory: kwargs["working_directory"] = working_directory if restart: kwargs["restart"] = restart if wanted_by: kwargs["wanted_by"] = [str(item) for item in wanted_by] # Create the unit file unit_file = create_unit_file(unit_type_enum, **kwargs) content = unit_file.to_string() # Determine output path if output: output_path = output else: output_path = f"{name}.{unit_type}" # Write to file with open(output_path, "w") as f: f.write(content) click.echo(click.style(f"✓ Created unit file: {output_path}", fg="green")) # Validate if requested if validate_output: validation_errors = unit_file.validate() errors = [e for e in validation_errors if e.severity == "error"] if errors: click.echo( click.style( f"⚠ Generated unit file has {len(errors)} validation error(s)", fg="yellow", ) ) for error in errors: click.echo(f" [{error.section}.{error.key}] {error.message}") else: click.echo(click.style("✓ Generated unit file is valid", fg="green")) except Exception as e: click.echo(click.style(f"Error: {str(e)}", fg="red")) sys.exit(1) @cli.group() def template(): """Work with unit file templates.""" pass @template.command("list") @click.option("--category", help="Filter by category") @click.option("--type", "unit_type", help="Filter by unit type") @click.option("--search", help="Search templates by name, description, or tags") def list_templates(category: str, unit_type: str, search: str): """List available templates.""" templates = template_registry.list_templates() if category: templates = [t for t in templates if t.category == category] if unit_type: try: unit_type_enum = UnitType(unit_type.lower()) templates = [t for t in templates if t.unit_type == unit_type_enum] except ValueError: click.echo(click.style(f"Invalid unit type: {unit_type}", fg="red")) return if search: templates = template_registry.search(search) if not templates: click.echo("No templates found matching the criteria.") return # Group by category categories = {} for template in templates: if template.category not in categories: categories[template.category] = [] categories[template.category].append(template) for cat, cat_templates in categories.items(): click.echo(click.style(f"\n{cat}:", fg="blue", bold=True)) for template in cat_templates: click.echo(f" {template.name} ({template.unit_type.value})") click.echo(f" {template.description}") if template.tags: click.echo(f" Tags: {', '.join(template.tags)}") @template.command("show") @click.argument("template_name") def show_template(template_name: str): """Show details of a specific template.""" template = template_registry.get_template(template_name) if not template: click.echo(click.style(f"Template '{template_name}' not found", fg="red")) return click.echo(click.style(f"Template: {template.name}", fg="blue", bold=True)) click.echo(f"Description: {template.description}") click.echo(f"Type: {template.unit_type.value}") click.echo(f"Category: {template.category}") if template.tags: click.echo(f"Tags: {', '.join(template.tags)}") click.echo("\nParameters:") for param in template.parameters: required_text = "required" if param.required else "optional" click.echo(f" {param.name} ({param.parameter_type}, {required_text})") click.echo(f" {param.description}") if param.default is not None: click.echo(f" Default: {param.default}") if param.choices: click.echo(f" Choices: {', '.join(param.choices)}") if param.example: click.echo(f" Example: {param.example}") @template.command("generate") @click.argument("template_name") @click.option( "--param", "-p", multiple=True, help="Template parameter in key=value format" ) @click.option("--output", "-o", help="Output file path") @click.option( "--validate-output", is_flag=True, help="Validate the generated unit file" ) @click.option("--interactive", "-i", is_flag=True, help="Interactive parameter input") def generate_template( template_name: str, param: tuple, output: str, validate_output: bool, interactive: bool, ): """Generate a unit file from a template.""" template = template_registry.get_template(template_name) if not template: click.echo(click.style(f"Template '{template_name}' not found", fg="red")) return # Parse parameters parameters = {} for p in param: if "=" not in p: click.echo( click.style(f"Invalid parameter format: {p}. Use key=value", fg="red") ) return key, value = p.split("=", 1) parameters[key] = value # Interactive mode if interactive: click.echo(f"Generating unit file from template: {template.name}") click.echo(f"Description: {template.description}\n") for template_param in template.parameters: if template_param.name in parameters: continue # Skip if already provided via --param prompt_text = f"{template_param.name}" if template_param.description: prompt_text += f" ({template_param.description})" if template_param.example: prompt_text += f" [example: {template_param.example}]" if template_param.required: value = click.prompt(prompt_text) else: default = template_param.default or "" value = click.prompt(prompt_text, default=default, show_default=True) if value: # Only add non-empty values # Type conversion if template_param.parameter_type == "boolean": value = value.lower() in ("true", "yes", "1", "on") elif template_param.parameter_type == "integer": try: value = int(value) except ValueError: click.echo( click.style(f"Invalid integer value: {value}", fg="red") ) return parameters[template_param.name] = value # Check required parameters missing_params = [] for template_param in template.parameters: if template_param.required and template_param.name not in parameters: missing_params.append(template_param.name) if missing_params: click.echo( click.style( f"Missing required parameters: {', '.join(missing_params)}", fg="red" ) ) click.echo("Use --interactive mode or provide them with --param key=value") return try: # Generate the unit file unit_file = template.generate(parameters) content = unit_file.to_string() # Determine output path if output: output_path = output else: name = parameters.get("name", "generated") output_path = f"{name}.{template.unit_type.value}" # Write to file with open(output_path, "w") as f: f.write(content) click.echo(click.style(f"✓ Generated unit file: {output_path}", fg="green")) # Validate if requested if validate_output: validation_errors = unit_file.validate() errors = [e for e in validation_errors if e.severity == "error"] if errors: click.echo( click.style( f"⚠ Generated unit file has {len(errors)} validation error(s)", fg="yellow", ) ) for error in errors: location = f"[{error.section}" if error.key: location += f".{error.key}" location += "]" click.echo(f" {location} {error.message}") else: click.echo(click.style("✓ Generated unit file is valid", fg="green")) except Exception as e: click.echo(click.style(f"Error generating unit file: {str(e)}", fg="red")) sys.exit(1) @cli.command() @click.argument("file_path", type=click.Path(exists=True)) def info(file_path: str): """Show information about a unit file.""" try: unit_file = SystemdUnitFile(file_path=file_path) info = unit_file.get_info() click.echo(click.style(f"Unit File: {file_path}", fg="blue", bold=True)) if info.name: click.echo(f"Name: {info.name}") if info.unit_type: click.echo(f"Type: {info.unit_type.value}") if info.description: click.echo(f"Description: {info.description}") if info.documentation: click.echo(f"Documentation: {', '.join(info.documentation)}") if info.requires: click.echo(f"Requires: {', '.join(info.requires)}") if info.wants: click.echo(f"Wants: {', '.join(info.wants)}") if info.conflicts: click.echo(f"Conflicts: {', '.join(info.conflicts)}") # Show sections and key counts sections = unit_file.get_sections() if sections: click.echo("\nSections:") for section in sections: keys = unit_file.get_keys(section) click.echo(f" {section}: {len(keys)} keys") except Exception as e: click.echo(click.style(f"Error: {str(e)}", fg="red")) sys.exit(1) @cli.command() @click.argument("source_file", type=click.Path(exists=True)) @click.argument("dest_file", type=click.Path()) @click.option( "--set", "set_values", multiple=True, help="Set values in format section.key=value" ) @click.option( "--remove", "remove_keys", multiple=True, help="Remove keys in format section.key" ) @click.option("--validate-output", is_flag=True, help="Validate the modified unit file") def edit( source_file: str, dest_file: str, set_values: tuple, remove_keys: tuple, validate_output: bool, ): """Edit a unit file by setting or removing values.""" try: unit_file = SystemdUnitFile(file_path=source_file) # Apply modifications for set_value in set_values: if "=" not in set_value: click.echo( click.style( f"Invalid set format: {set_value}. Use section.key=value", fg="red", ) ) return key_part, value = set_value.split("=", 1) if "." not in key_part: click.echo( click.style( f"Invalid key format: {key_part}. Use section.key", fg="red" ) ) return section, key = key_part.split(".", 1) unit_file.set_value(section, key, value) click.echo(f"Set {section}.{key} = {value}") for remove_key in remove_keys: if "." not in remove_key: click.echo( click.style( f"Invalid key format: {remove_key}. Use section.key", fg="red" ) ) return section, key = remove_key.split(".", 1) if unit_file.remove_key(section, key): click.echo(f"Removed {section}.{key}") else: click.echo(click.style(f"Key {section}.{key} not found", fg="yellow")) # Write modified file content = unit_file.to_string() with open(dest_file, "w") as f: f.write(content) click.echo(click.style(f"✓ Modified unit file saved: {dest_file}", fg="green")) # Validate if requested if validate_output: validation_errors = unit_file.validate() errors = [e for e in validation_errors if e.severity == "error"] if errors: click.echo( click.style( f"⚠ Modified unit file has {len(errors)} validation error(s)", fg="yellow", ) ) for error in errors: location = f"[{error.section}" if error.key: location += f".{error.key}" location += "]" click.echo(f" {location} {error.message}") else: click.echo(click.style("✓ Modified unit file is valid", fg="green")) except Exception as e: click.echo(click.style(f"Error: {str(e)}", fg="red")) sys.exit(1) if __name__ == "__main__": cli()