From 00d7817a6a65ddc34e8568d873866a38c99ade0b Mon Sep 17 00:00:00 2001 From: Youssef Fellah Date: Mon, 1 Jun 2026 09:00:40 +0100 Subject: [PATCH 01/18] feat: implement v1.0.0 readiness plan - Bump version to 1.0.0 - Add PyYAML dependency - Refactor deploy command to use PyYAML for robust parsing - Refactor run command and runner for multi-service microservices support - Implement thread-based log streaming with service prefixes - Add CONTRIBUTING.md and update README.md --- CONTRIBUTING.md | 47 ++++++++ README.md | 14 +-- devctl/commands/deploy.py | 107 +++++++++++------- devctl/commands/run.py | 48 ++++---- devctl/orchestrator/runner.py | 202 ++++++++++++++++++++-------------- pyproject.toml | 3 +- test/test_commands.py | 18 ++- 7 files changed, 279 insertions(+), 160 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..80e4170 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,47 @@ +# Contributing to devctl + +Thank you for your interest in contributing to `devctl`! We welcome contributions from the community to make this tool even better. + +## How to Contribute + +1. **Report Bugs**: If you find a bug, please open an issue on GitHub with a detailed description and steps to reproduce it. +2. **Suggest Features**: Have an idea for a new feature? Open an issue to discuss it. +3. **Submit Pull Requests**: + * Fork the repository. + * Create a new branch for your feature or bugfix. + * Ensure your code follows the project's style (we use [Ruff](https://github.com/astral-sh/ruff)). + * Add tests for any new functionality. + * Submit a PR with a clear description of your changes. + +## Development Setup + +1. Clone the repository: + ```bash + git clone https://github.com/your-username/devctl.git + cd devctl + ``` +2. Create a virtual environment: + ```bash + python -m venv .venv + source .venv/bin/activate + ``` +3. Install dependencies in editable mode: + ```bash + pip install -e . + ``` +4. Run tests: + ```bash + pytest + ``` + +## Code Quality + +We use `ruff` for linting and formatting. You can run it with: +```bash +ruff check . +ruff format . +``` + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. diff --git a/README.md b/README.md index f7fc9cf..7d51954 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ devctl add resource "Product" --fields "name:string,price:double,quantity:int" ### Orchestration -Scan the current directory tree and launch all detected components (Database, Backend, Frontend) in parallel. +Scan the current directory tree and launch all detected components (Database, Backend, Frontend) in parallel. Supports microservice architectures with log prefixing. ```bash devctl run @@ -99,22 +99,22 @@ Upon termination (Ctrl+C), `devctl` gracefully stops all processes and performs ### Dockerfile Scaffolding -Generate Dockerfiles for all detected Spring Boot, Angular, and Vue/Vite services without building or running containers. +Generate Dockerfiles for all detected Spring Boot, Angular, and Vue/Vite services. ```bash devctl dockerize -devctl dockerize ./my-workspace --dry-run -devctl dockerize --force ``` -Generated assets are intentionally limited to service-local `Dockerfile` files. Existing Dockerfiles are skipped by default; use `--force` to replace them. +### Global Deployment -Build an image from a generated Dockerfile with: +Generate a unified `docker-compose.yml` for the entire project by scanning all subdirectories. ```bash -docker build -t my-service ./my-service +devctl deploy ``` +This command automatically detects databases from Spring Boot properties and links them to the respective services in a global configuration. + ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/devctl/commands/deploy.py b/devctl/commands/deploy.py index 06ed213..30953e0 100644 --- a/devctl/commands/deploy.py +++ b/devctl/commands/deploy.py @@ -48,58 +48,89 @@ def extract_db_info(project_path: Path) -> Optional[Dict[str, Any]]: # Try to refine service name from existing docker-compose if possible compose_path = project_path / "docker-compose.yml" if compose_path.exists(): - compose_content = compose_path.read_text(encoding="utf-8", errors="ignore") - service_match = re.search(r"services:\s*\n\s+([\w-]+):", compose_content) - if service_match: - db_dict["service_name"] = service_match.group(1) + try: + with open(compose_path, "r", encoding="utf-8") as f: + config = yaml.safe_load(f) + if config and "services" in config: + # Find the first service that looks like a database + for s_name, s_cfg in config["services"].items(): + image = str(s_cfg.get("image", "")) + if db_type == "postgresql" and "postgres" in image: + db_dict["service_name"] = s_name + break + if db_type == "mysql" and "mysql" in image: + db_dict["service_name"] = s_name + break + except Exception: + pass return db_dict def extract_db_from_compose(compose_path: Path) -> Optional[Dict[str, Any]]: """ - Extract database information from a docker-compose.yml file using regex. + Extract database information from a docker-compose.yml file using PyYAML. """ if not compose_path.exists(): return None - content = compose_path.read_text(encoding="utf-8", errors="ignore") - - # This is a bit hacky without PyYAML but follows devctl's generated structure - # Try to find the first service name under 'services:' - service_match = re.search(r"services:\s*\n\s+([\w-]+):", content) - original_service_name = service_match.group(1) if service_match else None - - image_match = re.search(r"image:\s+(postgres|mysql)(:[\w.-]+)?", content) - if not image_match: + try: + with open(compose_path, "r", encoding="utf-8") as f: + config = yaml.safe_load(f) + except Exception: return None - db_type = "postgresql" if image_match.group(1) == "postgres" else "mysql" - - # Try to find env vars - if db_type == "postgresql": - user = re.search(r"POSTGRES_USER:\s+([\w-]+)", content) - password = re.search(r"POSTGRES_PASSWORD:\s+([\w-]+)", content) - db_name = re.search(r"POSTGRES_DB:\s+([\w-]+)", content) - else: - user = re.search(r"MYSQL_USER:\s+([\w-]+)", content) - password = re.search(r"MYSQL_PASSWORD:\s+([\w-]+)", content) - db_name = re.search(r"MYSQL_DATABASE:\s+([\w-]+)", content) - - port_match = re.search(r"\"(\d+):(\d+)\"", content) - - db_dict = _build_db_dict( - db_type, - port_match.group(1) if port_match else ("5432" if db_type == "postgresql" else "3306"), - db_name.group(1) if db_name else "db", - user.group(1) if user else "admin", - password.group(1) if password else "password", - ) + if not config or "services" not in config: + return None - if original_service_name: - db_dict["service_name"] = original_service_name + for service_name, service_cfg in config["services"].items(): + image = str(service_cfg.get("image", "")) + if "postgres" in image or "mysql" in image: + db_type = "postgresql" if "postgres" in image else "mysql" + + # Extract environment variables + env = service_cfg.get("environment", {}) + env_dict = {} + if isinstance(env, list): + for item in env: + if "=" in item: + k, v = item.split("=", 1) + env_dict[k] = v + elif ":" in item: + k, v = item.split(":", 1) + env_dict[k] = v.strip() + elif isinstance(env, dict): + env_dict = env + + # Extract info based on db_type + if db_type == "postgresql": + user = env_dict.get("POSTGRES_USER", "admin") + password = env_dict.get("POSTGRES_PASSWORD", "password") + db_name = env_dict.get("POSTGRES_DB", "db") + else: + user = env_dict.get("MYSQL_USER", env_dict.get("MYSQL_ROOT_PASSWORD", "admin")) + password = env_dict.get("MYSQL_PASSWORD", env_dict.get("MYSQL_ROOT_PASSWORD", "password")) + db_name = env_dict.get("MYSQL_DATABASE", "db") + + # Extract port + ports = service_cfg.get("ports", []) + host_port = None + if ports and isinstance(ports, list): + first_port = str(ports[0]) + if ":" in first_port: + host_port = first_port.split(":")[0].strip("'").strip('"') + + db_dict = _build_db_dict( + db_type, + host_port or ("5432" if db_type == "postgresql" else "3306"), + db_name, + user, + password, + ) + db_dict["service_name"] = service_name + return db_dict - return db_dict + return None def _build_db_dict(db_type: str, port: str, name: str, user: str, password: str) -> Dict[str, Any]: diff --git a/devctl/commands/run.py b/devctl/commands/run.py index e3d52f3..3e8bcbc 100644 --- a/devctl/commands/run.py +++ b/devctl/commands/run.py @@ -4,9 +4,10 @@ """ import typer +from pathlib import Path from devctl.orchestrator.runner import launch_dev_environment -from devctl.orchestrator.scanner import detect_environment +from devctl.generators.docker_scaffold import discover_docker_projects from devctl.utils.dependencies import check_tool app = typer.Typer(help="Local execution and development commands.") @@ -15,42 +16,45 @@ @app.callback(invoke_without_command=True) def run_env(ctx: typer.Context): """ - Scans the current tree and automatically launches Spring, Angular, and the Database. + Scans the current tree and automatically launches Spring, Angular, Vue, and Databases. """ if ctx.invoked_subcommand is not None: return typer.secho("šŸ” Analyzing the current directory tree...", fg=typer.colors.CYAN) - env_state = detect_environment(".") + + projects = discover_docker_projects(".") + + # Check for docker-compose.yml files + docker_composes = [] + for p in Path(".").rglob("docker-compose.yml"): + if "node_modules" not in str(p) and "target" not in str(p) and ".git" not in str(p): + docker_composes.append(p.parent) + + has_spring = any(p.kind == "spring" for p in projects) + has_angular = any(p.kind == "angular" for p in projects) + has_vue = any(p.kind == "vue" for p in projects) + has_docker = len(docker_composes) > 0 # Check dependencies based on detection - if env_state["has_docker_compose"]: + if has_docker: check_tool("docker", "running the database environment") - if env_state["has_spring"]: + if has_spring: check_tool("java", "running the Spring Boot backend") - if env_state["has_angular"] or env_state.get("has_vue"): + if has_angular or has_vue: check_tool("npm", "running the frontend project") # Visual summary of detection for the user - typer.echo(f" - Docker Database : {'āœ…' if env_state['has_docker_compose'] else 'āŒ'}") - typer.echo(f" - Spring Boot Backend : {'āœ…' if env_state['has_spring'] else 'āŒ'}") - typer.echo(f" - Angular Frontend : {'āœ…' if env_state['has_angular'] else 'āŒ'}") - typer.echo(f" - Vue.js Frontend : {'āœ…' if env_state['has_vue'] else 'āŒ'}") - - has_env = any( - [ - env_state["has_docker_compose"], - env_state["has_spring"], - env_state["has_angular"], - env_state.get("has_vue"), - ] - ) - - if not has_env: + typer.echo(f" - Docker Compose ({len(docker_composes)}) : {'āœ…' if has_docker else 'āŒ'}") + typer.echo(f" - Spring Boot ({sum(1 for p in projects if p.kind == 'spring')}) : {'āœ…' if has_spring else 'āŒ'}") + typer.echo(f" - Angular Frontend ({sum(1 for p in projects if p.kind == 'angular')}) : {'āœ…' if has_angular else 'āŒ'}") + typer.echo(f" - Vue.js Frontend ({sum(1 for p in projects if p.kind == 'vue')}) : {'āœ…' if has_vue else 'āŒ'}") + + if not projects and not docker_composes: typer.secho("\nāŒ No valid development environment detected here.", fg=typer.colors.RED) raise typer.Exit(code=1) # Transfer control to the system orchestration layer - launch_dev_environment(env_state) + launch_dev_environment(projects, docker_composes) diff --git a/devctl/orchestrator/runner.py b/devctl/orchestrator/runner.py index 8cd40d8..d0ee215 100644 --- a/devctl/orchestrator/runner.py +++ b/devctl/orchestrator/runner.py @@ -1,19 +1,30 @@ """ Local development environment runner. -Handles parallel process management for multi-tier applications. +Handles parallel process management for multi-tier applications with log prefixing. """ import subprocess import sys import time +import threading +import signal +from pathlib import Path +from typing import List import typer +from rich.console import Console + +from devctl.generators.docker_scaffold import DockerProject + +console = Console() + +# Global list to track processes for cleanup +active_processes = [] +active_threads = [] def is_docker_running(): - """ - Checks if the Docker daemon is active on the system. - """ + """Checks if the Docker daemon is active on the system.""" try: subprocess.run(["docker", "info"], capture_output=True, check=True) return True @@ -21,66 +32,89 @@ def is_docker_running(): return False -def launch_dev_environment(env_state: dict): +def stream_logs(name: str, process: subprocess.Popen, color: str): + """Streams logs from a process with a colored prefix.""" + try: + # Use line-buffered reading + for line in iter(process.stdout.readline, b""): + if line: + decoded_line = line.decode("utf-8", errors="ignore").rstrip() + console.print(f"[{color}]{name:>15} |[/{color}] {decoded_line}") + except Exception as e: + console.print(f"[red]Error streaming logs for {name}: {e}[/red]") + + +def launch_dev_environment(projects: List[DockerProject], docker_composes: List[Path]): """ - Launches the necessary processes in parallel (Optimized for Linux/Fedora). + Launches the necessary processes in parallel with structured startup and log streaming. """ - processes = [] + global active_processes - try: - # 4. Launch Vue.js Frontend - if env_state.get("has_vue"): - typer.secho( - f"🟢 Starting Vue.js from {env_state['vue_path']}...", fg=typer.colors.GREEN - ) + def signal_handler(sig, frame): + typer.echo("\nšŸ›‘ Shutdown requested. Cleaning up...") + cleanup_and_exit(docker_composes) - p_vue = subprocess.Popen( - ["npm", "run", "dev"], # Standard Vite command - cwd=env_state["vue_path"], - ) - processes.append(("Vue.js", p_vue)) + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) - # 1. Launch Database - if env_state["has_docker_compose"]: + try: + # 1. Start Databases + if docker_composes: if not is_docker_running(): typer.secho("āŒ Error: Docker service is not running.", fg=typer.colors.RED) - typer.echo("šŸ’” Hint: sudo systemctl start docker") sys.exit(1) - typer.secho( - f"🐳 Starting Docker from {env_state['docker_path']}...", fg=typer.colors.CYAN - ) - subprocess.run( - ["docker", "compose", "up", "-d"], cwd=env_state["docker_path"], check=True - ) + for compose_path in docker_composes: + typer.secho(f"🐳 Starting Docker Compose in {compose_path}...", fg=typer.colors.CYAN) + subprocess.run( + ["docker", "compose", "up", "-d"], cwd=str(compose_path), check=True + ) + + typer.echo("ā³ Waiting 5s for databases to initialize...") time.sleep(5) - # 2. Launch Spring Boot Backend - if env_state["has_spring"]: - typer.secho( - f"šŸƒ Starting Spring Boot from {env_state['spring_path']}...", - fg=typer.colors.GREEN, + # 2. Start Backends (Spring Boot) + backends = [p for p in projects if p.kind == "spring"] + for p in backends: + typer.secho(f"šŸƒ Starting Spring Boot: {p.name}...", fg=typer.colors.GREEN) + + proc = subprocess.Popen( + ["./mvnw", "spring-boot:run"], + cwd=str(p.path), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, ) - - # Native Linux execution - p_spring = subprocess.Popen(["./mvnw", "spring-boot:run"], cwd=env_state["spring_path"]) - processes.append(("Spring Boot", p_spring)) - - # 3. Launch Angular Frontend - if env_state["has_angular"]: - typer.secho( - f"šŸ…°ļø Starting Angular from {env_state['angular_path']}...", fg=typer.colors.CYAN - ) - - # Native Linux execution - p_angular = subprocess.Popen(["npx", "ng", "serve"], cwd=env_state["angular_path"]) - processes.append(("Angular", p_angular)) - - if not processes and not env_state["has_docker_compose"]: - typer.secho( - "āš ļø No Spring, Angular, Vue.js or DB project detected in this tree.", - fg=typer.colors.YELLOW, + active_processes.append((p.name, proc)) + + t = threading.Thread(target=stream_logs, args=(p.name, proc, "green"), daemon=True) + t.start() + active_threads.append(t) + + # 3. Start Frontends (Angular / Vue) + frontends = [p for p in projects if p.kind in ["angular", "vue"]] + for p in frontends: + color = "cyan" if p.kind == "angular" else "magenta" + icon = "šŸ…°ļø" if p.kind == "angular" else "🟢" + cmd = ["npx", "ng", "serve"] if p.kind == "angular" else ["npm", "run", "dev"] + + typer.secho(f"{icon} Starting {p.kind.capitalize()}: {p.name}...", fg=typer.colors.CYAN if p.kind == "angular" else typer.colors.MAGENTA) + + proc = subprocess.Popen( + cmd, + cwd=str(p.path), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, ) + active_processes.append((p.name, proc)) + + t = threading.Thread(target=stream_logs, args=(p.name, proc, color), daemon=True) + t.start() + active_threads.append(t) + + if not active_processes and not docker_composes: + typer.secho("āš ļø No projects or databases detected to run.", fg=typer.colors.YELLOW) return typer.secho( @@ -89,39 +123,43 @@ def launch_dev_environment(env_state: dict): bold=True, ) + # Keep the main thread alive while True: time.sleep(1) - - except KeyboardInterrupt: - typer.secho( - "\nšŸ›‘ Shutdown requested. Performing full cleanup (Please wait, " - "do not press Ctrl+C again)...", - fg=typer.colors.YELLOW, - bold=True, - ) - - for name, p in processes: - typer.echo(f"Closing {name}...") - p.terminate() - p.wait() - - if env_state["has_docker_compose"]: - typer.echo("Destroying database and Docker volumes...") - try: - subprocess.run( - ["docker", "compose", "down", "-v"], - cwd=env_state["docker_path"], - check=True, - stdout=subprocess.DEVNULL, - ) - except subprocess.CalledProcessError: - typer.secho( - "āš ļø Warning: Docker cleanup did not complete correctly.", - fg=typer.colors.RED, - ) - - typer.secho("āœ… Cleanup finished. Environment is perfectly clean.", fg=typer.colors.GREEN) - sys.exit(0) + # Check if any process has died unexpectedly + for name, proc in active_processes: + if proc.poll() is not None: + typer.secho(f"āš ļø Process {name} exited with code {proc.returncode}", fg=typer.colors.RED) + active_processes.remove((name, proc)) except Exception as e: typer.secho(f"āŒ A system error occurred: {e}", fg=typer.colors.RED) + cleanup_and_exit(docker_composes) + + +def cleanup_and_exit(docker_composes: List[Path]): + """Stops all active processes and docker containers.""" + for name, proc in active_processes: + typer.echo(f"Closing {name}...") + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + typer.echo(f"Force killing {name}...") + proc.kill() + + for compose_path in docker_composes: + typer.echo(f"Stopping Docker Compose in {compose_path}...") + try: + subprocess.run( + ["docker", "compose", "down", "-v"], + cwd=str(compose_path), + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception: + typer.secho(f"āš ļø Warning: Docker cleanup failed for {compose_path}", fg=typer.colors.RED) + + typer.secho("āœ… Cleanup finished. Environment is clean.", fg=typer.colors.GREEN) + sys.exit(0) diff --git a/pyproject.toml b/pyproject.toml index 4538a21..8dc4889 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "devctl" -version = "0.1.1" +version = "1.0.0" description = "Local development orchestrator for Spring Boot, Angular, and Vue.js" readme = "README.md" license = {file = "LICENSE"} @@ -23,6 +23,7 @@ dependencies = [ "requests>=2.31.0", "jinja2>=3.1.3", "rich>=13.7.0", + "pyyaml>=6.0.1", ] [project.scripts] diff --git a/test/test_commands.py b/test/test_commands.py index 4fa80bf..1556890 100644 --- a/test/test_commands.py +++ b/test/test_commands.py @@ -18,16 +18,14 @@ def test_init_spring_unsupported_db(): def test_run_no_environment(): """Ensure run command fails when no project is detected.""" - with patch("devctl.commands.run.detect_environment") as mock_detect: - mock_detect.return_value = { - "has_docker_compose": False, - "has_spring": False, - "has_angular": False, - "has_vue": False, - } - result = runner.invoke(app, ["run"]) - assert result.exit_code == 1 - assert "No valid development environment detected" in result.stdout + with patch("devctl.commands.run.discover_docker_projects") as mock_discover: + mock_discover.return_value = [] + # Also need to mock Path.rglob or something to avoid finding docker-compose.yml + with patch("devctl.commands.run.Path.rglob") as mock_rglob: + mock_rglob.return_value = [] + result = runner.invoke(app, ["run"]) + assert result.exit_code == 1 + assert "No valid development environment detected" in result.stdout def test_add_resource_no_project(): From 20bdc35149a61a777a883ac298d90b82c373cc3b Mon Sep 17 00:00:00 2001 From: Youssef Fellah Date: Mon, 1 Jun 2026 09:01:19 +0100 Subject: [PATCH 02/18] fix: resolve test failures and add missing yaml import --- devctl/commands/deploy.py | 1 + 1 file changed, 1 insertion(+) diff --git a/devctl/commands/deploy.py b/devctl/commands/deploy.py index 30953e0..4d3172d 100644 --- a/devctl/commands/deploy.py +++ b/devctl/commands/deploy.py @@ -3,6 +3,7 @@ from typing import Any, Dict, Optional import typer +import yaml from jinja2 import Environment, FileSystemLoader from devctl.generators.docker_scaffold import ( From abfbd584ea248f7d7d54886dd17259f67d10ee07 Mon Sep 17 00:00:00 2001 From: Youssef Fellah Date: Mon, 1 Jun 2026 09:09:50 +0100 Subject: [PATCH 03/18] feat: add MongoDB support to init and deploy commands --- devctl/commands/deploy.py | 75 +++++++++++++++---- devctl/commands/init.py | 4 +- devctl/orchestrator/config_builder.py | 9 ++- .../spring/application.properties.j2 | 2 + devctl/templates/spring/docker-compose.yml.j2 | 10 ++- test/test_mongodb.py | 36 +++++++++ 6 files changed, 115 insertions(+), 21 deletions(-) create mode 100644 test/test_mongodb.py diff --git a/devctl/commands/deploy.py b/devctl/commands/deploy.py index 4d3172d..2253280 100644 --- a/devctl/commands/deploy.py +++ b/devctl/commands/deploy.py @@ -31,21 +31,31 @@ def extract_db_info(project_path: Path) -> Optional[Dict[str, Any]]: # spring.datasource.url=jdbc:postgresql://localhost:5432/sample_api_db url_match = re.search(r"spring\.datasource\.url=jdbc:([^:]+)://[^:]+:(\d+)/([\w-]+)", content) + # spring.data.mongodb.uri=mongodb://admin:password@localhost:27017/db_name + mongo_match = re.search(r"spring\.data\.mongodb\.uri=mongodb://([^:]+):([^@]+)@[^:]+:(\d+)/([\w-]+)", content) + user_match = re.search(r"spring\.datasource\.username=([\w-]+)", content) pass_match = re.search(r"spring\.datasource\.password=([\w-]+)", content) - if not url_match: + if not url_match and not mongo_match: return extract_db_from_compose(project_path / "docker-compose.yml") - db_type_raw = url_match.group(1) - db_type = "postgresql" if "postgres" in db_type_raw else "mysql" - db_port = url_match.group(2) - db_name = url_match.group(3) - db_user = user_match.group(1) if user_match else "admin" - db_pass = pass_match.group(1) if pass_match else "password" + if mongo_match: + db_type = "mongodb" + db_user = mongo_match.group(1) + db_pass = mongo_match.group(2) + db_port = mongo_match.group(3) + db_name = mongo_match.group(4) + else: + db_type_raw = url_match.group(1) + db_type = "postgresql" if "postgres" in db_type_raw else "mysql" + db_port = url_match.group(2) + db_name = url_match.group(3) + db_user = user_match.group(1) if user_match else "admin" + db_pass = pass_match.group(1) if pass_match else "password" db_dict = _build_db_dict(db_type, db_port, db_name, db_user, db_pass) - + ... # Try to refine service name from existing docker-compose if possible compose_path = project_path / "docker-compose.yml" if compose_path.exists(): @@ -62,6 +72,9 @@ def extract_db_info(project_path: Path) -> Optional[Dict[str, Any]]: if db_type == "mysql" and "mysql" in image: db_dict["service_name"] = s_name break + if db_type == "mongodb" and "mongo" in image: + db_dict["service_name"] = s_name + break except Exception: pass @@ -86,8 +99,13 @@ def extract_db_from_compose(compose_path: Path) -> Optional[Dict[str, Any]]: for service_name, service_cfg in config["services"].items(): image = str(service_cfg.get("image", "")) - if "postgres" in image or "mysql" in image: - db_type = "postgresql" if "postgres" in image else "mysql" + if "postgres" in image or "mysql" in image or "mongo" in image: + if "postgres" in image: + db_type = "postgresql" + elif "mysql" in image: + db_type = "mysql" + else: + db_type = "mongodb" # Extract environment variables env = service_cfg.get("environment", {}) @@ -108,10 +126,14 @@ def extract_db_from_compose(compose_path: Path) -> Optional[Dict[str, Any]]: user = env_dict.get("POSTGRES_USER", "admin") password = env_dict.get("POSTGRES_PASSWORD", "password") db_name = env_dict.get("POSTGRES_DB", "db") - else: + elif db_type == "mysql": user = env_dict.get("MYSQL_USER", env_dict.get("MYSQL_ROOT_PASSWORD", "admin")) password = env_dict.get("MYSQL_PASSWORD", env_dict.get("MYSQL_ROOT_PASSWORD", "password")) db_name = env_dict.get("MYSQL_DATABASE", "db") + else: + user = env_dict.get("MONGO_INITDB_ROOT_USERNAME", "admin") + password = env_dict.get("MONGO_INITDB_ROOT_PASSWORD", "password") + db_name = env_dict.get("MONGO_INITDB_DATABASE", "db") # Extract port ports = service_cfg.get("ports", []) @@ -123,7 +145,7 @@ def extract_db_from_compose(compose_path: Path) -> Optional[Dict[str, Any]]: db_dict = _build_db_dict( db_type, - host_port or ("5432" if db_type == "postgresql" else "3306"), + host_port or ("5432" if db_type == "postgresql" else ("3306" if db_type == "mysql" else "27017")), db_name, user, password, @@ -136,7 +158,21 @@ def extract_db_from_compose(compose_path: Path) -> Optional[Dict[str, Any]]: def _build_db_dict(db_type: str, port: str, name: str, user: str, password: str) -> Dict[str, Any]: is_postgres = db_type == "postgresql" - internal_port = "5432" if is_postgres else "3306" + is_mysql = db_type == "mysql" + is_mongo = db_type == "mongodb" + + if is_postgres: + internal_port = "5432" + image = "postgres:15-alpine" + vol_path = "/var/lib/postgresql/data" + elif is_mysql: + internal_port = "3306" + image = "mysql:8.0" + vol_path = "/var/lib/mysql/data" + else: + internal_port = "27017" + image = "mongo:6.0" + vol_path = "/data/db" env = {} if is_postgres: @@ -145,13 +181,19 @@ def _build_db_dict(db_type: str, port: str, name: str, user: str, password: str) "POSTGRES_PASSWORD": password, "POSTGRES_DB": name, } - else: + elif is_mysql: env = { "MYSQL_ROOT_PASSWORD": password, "MYSQL_DATABASE": name, "MYSQL_USER": user, "MYSQL_PASSWORD": password, } + else: + env = { + "MONGO_INITDB_ROOT_USERNAME": user, + "MONGO_INITDB_ROOT_PASSWORD": password, + "MONGO_INITDB_DATABASE": name, + } return { "type": db_type, @@ -161,13 +203,14 @@ def _build_db_dict(db_type: str, port: str, name: str, user: str, password: str) "user": user, "password": password, "service_name": f"{name}-db", - "image": "postgres:15-alpine" if is_postgres else "mysql:8.0", + "image": image, "volume_name": f"{name}_data", - "volume_path": "/var/lib/postgresql/data" if is_postgres else "/var/lib/mysql/data", + "volume_path": vol_path, "env": env, } + @app.command() def deploy(path: Path = PATH_ARGUMENT): """ diff --git a/devctl/commands/init.py b/devctl/commands/init.py index bc819fe..cebd33e 100644 --- a/devctl/commands/init.py +++ b/devctl/commands/init.py @@ -21,7 +21,7 @@ @app.command("spring") def init_spring( name: str, - db: str = typer.Option("postgres", help="Database type (postgres or mysql)"), + db: str = typer.Option("postgres", help="Database type (postgres, mysql, or mongodb)"), port: int = typer.Option(None, help="Local port (optional)"), ): """ @@ -30,7 +30,7 @@ def init_spring( check_tool("java", "initializing a Spring Boot project") # Strict input validation - if db not in ["postgres", "mysql"]: + if db not in ["postgres", "mysql", "mongodb"]: typer.secho(f"āŒ Error: Database '{db}' is not supported.", fg=typer.colors.RED) raise typer.Exit(code=1) diff --git a/devctl/orchestrator/config_builder.py b/devctl/orchestrator/config_builder.py index c148225..9f43a9e 100644 --- a/devctl/orchestrator/config_builder.py +++ b/devctl/orchestrator/config_builder.py @@ -18,7 +18,14 @@ def generate_config(project_name: str, db_type: str = "postgres", custom_port: i # Intelligent default port resolution if custom_port is None: - db_port = 5432 if db_type == "postgres" else 3306 + if db_type == "postgres": + db_port = 5432 + elif db_type == "mysql": + db_port = 3306 + elif db_type == "mongodb": + db_port = 27017 + else: + db_port = 5432 else: db_port = custom_port diff --git a/devctl/templates/spring/application.properties.j2 b/devctl/templates/spring/application.properties.j2 index 3132dec..35a6909 100644 --- a/devctl/templates/spring/application.properties.j2 +++ b/devctl/templates/spring/application.properties.j2 @@ -11,6 +11,8 @@ spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect {% elif db_type == 'mysql' %} spring.datasource.url=jdbc:mysql://localhost:{{ db_port }}/{{ db_name }} spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect +{% elif db_type == 'mongodb' %} +spring.data.mongodb.uri=mongodb://{{ db_user }}:{{ db_password }}@localhost:{{ db_port }}/{{ db_name }}?authSource=admin {% endif %} application.security.jwt.secret-key={{ jwt_secret|default('404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970') }} diff --git a/devctl/templates/spring/docker-compose.yml.j2 b/devctl/templates/spring/docker-compose.yml.j2 index 5f5effd..79edd35 100644 --- a/devctl/templates/spring/docker-compose.yml.j2 +++ b/devctl/templates/spring/docker-compose.yml.j2 @@ -13,11 +13,17 @@ services: MYSQL_DATABASE: {{ db_name }} MYSQL_USER: {{ db_user }} MYSQL_PASSWORD: {{ db_password }} + {% elif db_type == 'mongodb' %} + image: mongo:6.0 + environment: + MONGO_INITDB_ROOT_USERNAME: {{ db_user }} + MONGO_INITDB_ROOT_PASSWORD: {{ db_password }} + MONGO_INITDB_DATABASE: {{ db_name }} {% endif %} ports: - - "{{ db_port }}:{% if db_type == 'postgres' %}5432{% else %}3306{% endif %}" + - "{{ db_port }}:{% if db_type == 'postgres' %}5432{% elif db_type == 'mysql' %}3306{% else %}27017{% endif %}" volumes: - - {{ db_name }}_data:/var/lib/{% if db_type == 'postgres' %}postgresql{% else %}mysql{% endif %}/data + - {{ db_name }}_data:{% if db_type == 'postgres' %}/var/lib/postgresql/data{% elif db_type == 'mysql' %}/var/lib/mysql/data{% else %}/data/db{% endif %} volumes: {{ db_name }}_data: \ No newline at end of file diff --git a/test/test_mongodb.py b/test/test_mongodb.py new file mode 100644 index 0000000..8add1fc --- /dev/null +++ b/test/test_mongodb.py @@ -0,0 +1,36 @@ +import yaml +from pathlib import Path +from typer.testing import CliRunner +from devctl.main import app + +runner = CliRunner() + +def test_deploy_mongodb(tmp_path): + """Ensure deploy correctly detects and configures MongoDB.""" + # Setup project with MongoDB properties + backend = tmp_path / "mongo-api" + backend.mkdir() + (backend / "pom.xml").write_text("mongo-api", encoding="utf-8") + props = backend / "src" / "main" / "resources" + props.mkdir(parents=True) + (props / "application.properties").write_text( + "spring.data.mongodb.uri=mongodb://admin:pass@localhost:27017/mydb?authSource=admin", + encoding="utf-8" + ) + + result = runner.invoke(app, ["deploy", str(tmp_path)]) + + assert result.exit_code == 0 + compose_file = tmp_path / "docker-compose.yml" + assert compose_file.exists() + + with open(compose_file, "r") as f: + config = yaml.safe_load(f) + + services = config["services"] + assert "mydb-db" in services + db_service = services["mydb-db"] + assert db_service["image"] == "mongo:6.0" + assert "MONGO_INITDB_ROOT_USERNAME" in db_service["environment"] + assert db_service["environment"]["MONGO_INITDB_ROOT_USERNAME"] == "admin" + assert "27017:27017" in db_service["ports"] From d3148e83457295ec19143dddaac02d15299f25ac Mon Sep 17 00:00:00 2001 From: Youssef Fellah Date: Mon, 1 Jun 2026 09:17:27 +0100 Subject: [PATCH 04/18] feat: implement full NestJS support across all CLI commands --- devctl/commands/add.py | 10 +++++ devctl/commands/init.py | 15 +++++++ devctl/generators/docker_scaffold.py | 7 ++++ devctl/generators/nestjs.py | 40 ++++++++++++++++++ devctl/generators/scaffold_nestjs.py | 43 ++++++++++++++++++++ devctl/orchestrator/runner.py | 18 ++++++++ devctl/orchestrator/scanner.py | 8 +++- devctl/templates/docker/nestjs/Dockerfile.j2 | 26 ++++++++++++ test/test_nestjs.py | 41 +++++++++++++++++++ 9 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 devctl/generators/nestjs.py create mode 100644 devctl/generators/scaffold_nestjs.py create mode 100644 devctl/templates/docker/nestjs/Dockerfile.j2 create mode 100644 test/test_nestjs.py diff --git a/devctl/commands/add.py b/devctl/commands/add.py index a0a2c1d..a2fe410 100644 --- a/devctl/commands/add.py +++ b/devctl/commands/add.py @@ -10,6 +10,7 @@ from devctl.generators.scaffold_angular import generate_angular_resource from devctl.generators.scaffold_spring import generate_spring_resource from devctl.generators.scaffold_vue import generate_vue_resource +from devctl.generators.scaffold_nestjs import generate_nest_resource from devctl.orchestrator.scanner import detect_environment app = typer.Typer(help="Adds resources to the current project (Scaffolding).") @@ -65,6 +66,15 @@ def resource( except Exception as e: typer.secho(f"āŒ Error during Vue generation: {e}", fg=typer.colors.RED) + # Check for NestJS project + if env_state.get("has_nest"): + project_detected = True + typer.secho("🦁 NestJS project detected. Launching Nest generator...", fg=typer.colors.CYAN) + try: + generate_nest_resource(name, fields, root_path=".") + except Exception as e: + typer.secho(f"āŒ Error during Nest generation: {e}", fg=typer.colors.RED) + # Error message only if NO project detected if not project_detected: typer.secho( diff --git a/devctl/commands/init.py b/devctl/commands/init.py index bc819fe..73a7164 100644 --- a/devctl/commands/init.py +++ b/devctl/commands/init.py @@ -11,6 +11,7 @@ # Spring generator from devctl.generators.spring import download_spring_boilerplate from devctl.generators.vue import generate_vue_boilerplate +from devctl.generators.nestjs import generate_nest_boilerplate from devctl.orchestrator.config_builder import generate_config from devctl.utils.dependencies import check_tool @@ -70,3 +71,17 @@ def init_vue(name: str): if success: typer.secho("\n✨ Vue.js project ready!", fg=typer.colors.GREEN) + + +@app.command("nest") +def init_nest(name: str): + """ + Initializes a new NestJS backend project. + """ + check_tool("npm", "initializing a NestJS project") + + typer.secho(f"šŸš€ Initializing NestJS project: '{name}'...", fg=typer.colors.CYAN) + success = generate_nest_boilerplate(name) + + if success: + typer.secho("\n✨ NestJS project ready!", fg=typer.colors.GREEN) diff --git a/devctl/generators/docker_scaffold.py b/devctl/generators/docker_scaffold.py index 826ceca..3d4d0cb 100644 --- a/devctl/generators/docker_scaffold.py +++ b/devctl/generators/docker_scaffold.py @@ -105,6 +105,8 @@ def discover_docker_projects(root_path: Union[str, Path]) -> list[DockerProject] has_vite_config = {"vite.config.ts", "vite.config.js"} & filename_set if has_vite_config and "angular.json" not in filename_set: candidates.append(("vue", project_path)) + if "nest-cli.json" in filename_set: + candidates.append(("nest", project_path)) used_names: set[str] = set() projects: list[DockerProject] = [] @@ -163,6 +165,8 @@ def scaffold_docker_assets( def _dockerfile_content(env: Environment, project: DockerProject) -> str: if project.kind == "spring": return env.get_template("spring/Dockerfile.j2").render(project=project) + if project.kind == "nest": + return env.get_template("nestjs/Dockerfile.j2").render(project=project) return env.get_template("frontend/Dockerfile.j2").render(project=project) @@ -294,6 +298,9 @@ def _node_version(project_path: Path, kind: str) -> str: return "20" return "18" + if kind == "nest": + return "20" + return "22" diff --git a/devctl/generators/nestjs.py b/devctl/generators/nestjs.py new file mode 100644 index 0000000..d00daaa --- /dev/null +++ b/devctl/generators/nestjs.py @@ -0,0 +1,40 @@ +""" +Generators for NestJS projects. +Includes boilerplate generation via Nest CLI. +""" + +import os +import subprocess + +import typer + + +def generate_nest_boilerplate(project_name: str) -> bool: + """ + Generates a new NestJS project using the Nest CLI via npx. + """ + typer.secho(f"šŸ”„ Generating NestJS project '{project_name}'...", fg=typer.colors.CYAN) + safe_name = project_name.lower().replace("_", "-") + + try: + # Use npx to run Nest CLI without requiring global installation + # --package-manager npm: ensures npm is used + # --strict: enables strict mode + # --skip-git: devctl might be in a git repo already + typer.secho("šŸ“¦ Scaffolding NestJS project (this may take a minute)...", fg=typer.colors.CYAN) + subprocess.run( + ["npx", "-p", "@nestjs/cli", "nest", "new", safe_name, "--package-manager", "npm", "--skip-git"], + check=True + ) + + typer.secho( + f"āœ… NestJS project '{safe_name}' successfully generated!", fg=typer.colors.GREEN + ) + return True + + except subprocess.CalledProcessError as e: + typer.secho(f"āŒ Nest CLI process failed with code: {e.returncode}", fg=typer.colors.RED) + return False + except FileNotFoundError: + typer.secho("āŒ Error: 'npx' or 'npm' not found in path.", fg=typer.colors.RED) + return False diff --git a/devctl/generators/scaffold_nestjs.py b/devctl/generators/scaffold_nestjs.py new file mode 100644 index 0000000..cc88aa5 --- /dev/null +++ b/devctl/generators/scaffold_nestjs.py @@ -0,0 +1,43 @@ +""" +NestJS resource scaffolding generator. +Handles the creation of modules, controllers, and services using Nest CLI. +""" + +import os +import subprocess +import typer +from devctl.orchestrator.scanner import detect_environment + + +def generate_nest_resource(resource_name: str, fields_str: str, root_path: str = "."): + """ + Scaffolds a NestJS resource using the Nest CLI. + """ + env_state = detect_environment(root_path) + + if not env_state["has_nest"]: + typer.secho("āŒ Error: No NestJS project detected here.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + nest_root = env_state["nest_path"] + resource_lower = resource_name.lower() + + typer.secho(f"āš™ļø Generating NestJS resource '{resource_name}'...", fg=typer.colors.CYAN) + + try: + # Use npx to run Nest CLI + # 'g res' is shorthand for 'generate resource' + # --no-spec skips test files for a cleaner scaffold + subprocess.run( + ["npx", "@nestjs/cli", "g", "resource", resource_lower, "--no-spec"], + cwd=nest_root, + check=True + ) + + typer.secho(f"āœ… {resource_name} NestJS resource successfully generated!", fg=typer.colors.GREEN) + typer.echo(f"šŸ’” Note: Fields [{fields_str}] were provided but manual DTO update is recommended for NestJS.") + + except subprocess.CalledProcessError as e: + typer.secho(f"āŒ Nest CLI resource generation failed with code: {e.returncode}", fg=typer.colors.RED) + except Exception as e: + typer.secho(f"āš ļø An unexpected error occurred: {e}", fg=typer.colors.YELLOW) diff --git a/devctl/orchestrator/runner.py b/devctl/orchestrator/runner.py index d0ee215..fafb6a5 100644 --- a/devctl/orchestrator/runner.py +++ b/devctl/orchestrator/runner.py @@ -113,6 +113,24 @@ def signal_handler(sig, frame): t.start() active_threads.append(t) + # 4. Start NestJS Backends + nest_apps = [p for p in projects if p.kind == "nest"] + for p in nest_apps: + typer.secho(f"🦁 Starting NestJS: {p.name}...", fg=typer.colors.MAGENTA) + + proc = subprocess.Popen( + ["npm", "run", "start:dev"], + cwd=str(p.path), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + ) + active_processes.append((p.name, proc)) + + t = threading.Thread(target=stream_logs, args=(p.name, proc, "magenta"), daemon=True) + t.start() + active_threads.append(t) + if not active_processes and not docker_composes: typer.secho("āš ļø No projects or databases detected to run.", fg=typer.colors.YELLOW) return diff --git a/devctl/orchestrator/scanner.py b/devctl/orchestrator/scanner.py index ecc4a5c..ac08125 100644 --- a/devctl/orchestrator/scanner.py +++ b/devctl/orchestrator/scanner.py @@ -20,12 +20,14 @@ def detect_environment(root_path: str = "."): "angular_path": None, "has_vue": False, "vue_path": None, + "has_nest": False, + "nest_path": None, "project_root": os.path.abspath(root_path), } for dirpath, _dirnames, filenames in os.walk(root_path): # Optimization: ignore heavy folders for an instant scan - if any(ignored in dirpath for ignored in ["node_modules", "target", ".git", ".angular"]): + if any(ignored in dirpath for ignored in ["node_modules", "target", ".git", ".angular", "dist"]): continue if "docker-compose.yml" in filenames and not env_state["has_docker_compose"]: @@ -45,4 +47,8 @@ def detect_environment(root_path: str = "."): env_state["has_vue"] = True env_state["vue_path"] = dirpath + if "nest-cli.json" in filenames and not env_state["has_nest"]: + env_state["has_nest"] = True + env_state["nest_path"] = dirpath + return env_state diff --git a/devctl/templates/docker/nestjs/Dockerfile.j2 b/devctl/templates/docker/nestjs/Dockerfile.j2 new file mode 100644 index 0000000..007b6fa --- /dev/null +++ b/devctl/templates/docker/nestjs/Dockerfile.j2 @@ -0,0 +1,26 @@ +# Production Dockerfile for NestJS +# Generated by devctl + +# Stage 1: Build +FROM node:{{ project.node_version }}-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . +RUN npm run build + +# Stage 2: Run +FROM node:{{ project.node_version }}-alpine + +WORKDIR /app + +COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist + +EXPOSE 3000 + +CMD ["node", "dist/main"] diff --git a/test/test_nestjs.py b/test/test_nestjs.py new file mode 100644 index 0000000..f206620 --- /dev/null +++ b/test/test_nestjs.py @@ -0,0 +1,41 @@ +import os +from unittest.mock import patch +from typer.testing import CliRunner +from devctl.main import app + +runner = CliRunner() + +def test_init_nest_calls_boilerplate(tmp_path): + """Ensure init nest calls the boilerplate generator.""" + with patch("devctl.commands.init.generate_nest_boilerplate") as mock_gen: + mock_gen.return_value = True + with patch("devctl.commands.init.check_tool"): + result = runner.invoke(app, ["init", "nest", "my-nest-app"]) + assert result.exit_code == 0 + mock_gen.assert_called_once_with("my-nest-app") + +def test_add_resource_nest(tmp_path): + """Ensure add resource works in a NestJS project.""" + # Setup a mock NestJS project + (tmp_path / "nest-cli.json").write_text("{}", encoding="utf-8") + + with patch("devctl.commands.add.generate_nest_resource") as mock_gen: + # We need to ensure detect_environment finds it + os.chdir(tmp_path) + try: + result = runner.invoke(app, ["add", "resource", "User"]) + assert result.exit_code == 0 + mock_gen.assert_called_once() + finally: + # Cleanup chdir if needed (though pytest tmp_path is usually fine) + pass + +def test_dockerize_nest(tmp_path): + """Ensure dockerize detects NestJS and generates a Dockerfile.""" + (tmp_path / "nest-cli.json").write_text("{}", encoding="utf-8") + (tmp_path / "package.json").write_text('{"name": "nest-app"}', encoding="utf-8") + + result = runner.invoke(app, ["dockerize", str(tmp_path)]) + assert result.exit_code == 0 + assert (tmp_path / "Dockerfile").exists() + assert "dist/main" in (tmp_path / "Dockerfile").read_text() From 4a07002d1c9affaaece7427fdfe30320ede30bf6 Mon Sep 17 00:00:00 2001 From: Youssef Fellah Date: Mon, 1 Jun 2026 09:19:10 +0100 Subject: [PATCH 05/18] feat: implement NodeJS/Express support across CLI --- devctl/commands/add.py | 11 +++ devctl/commands/init.py | 15 ++++ devctl/generators/docker_scaffold.py | 4 + devctl/generators/nodejs.py | 87 ++++++++++++++++++++ devctl/generators/scaffold_nodejs.py | 79 ++++++++++++++++++ devctl/orchestrator/runner.py | 18 ++++ devctl/orchestrator/scanner.py | 6 ++ devctl/templates/docker/nodejs/Dockerfile.j2 | 24 ++++++ test/test_nodejs.py | 36 ++++++++ 9 files changed, 280 insertions(+) create mode 100644 devctl/generators/nodejs.py create mode 100644 devctl/generators/scaffold_nodejs.py create mode 100644 devctl/templates/docker/nodejs/Dockerfile.j2 create mode 100644 test/test_nodejs.py diff --git a/devctl/commands/add.py b/devctl/commands/add.py index a0a2c1d..764153a 100644 --- a/devctl/commands/add.py +++ b/devctl/commands/add.py @@ -10,6 +10,7 @@ from devctl.generators.scaffold_angular import generate_angular_resource from devctl.generators.scaffold_spring import generate_spring_resource from devctl.generators.scaffold_vue import generate_vue_resource +from devctl.generators.scaffold_nodejs import generate_nodejs_resource from devctl.orchestrator.scanner import detect_environment app = typer.Typer(help="Adds resources to the current project (Scaffolding).") @@ -65,6 +66,16 @@ def resource( except Exception as e: typer.secho(f"āŒ Error during Vue generation: {e}", fg=typer.colors.RED) + # Check for NodeJS project + if os.path.exists("package.json") and not project_detected: + # Heuristic for generic nodejs project if not already caught by angular/vue + project_detected = True + typer.secho("šŸ“¦ NodeJS project detected. Launching NodeJS generator...", fg=typer.colors.GREEN) + try: + generate_nodejs_resource(name, fields, root_path=".") + except Exception as e: + typer.secho(f"āŒ Error during NodeJS generation: {e}", fg=typer.colors.RED) + # Error message only if NO project detected if not project_detected: typer.secho( diff --git a/devctl/commands/init.py b/devctl/commands/init.py index bc819fe..e8dac6b 100644 --- a/devctl/commands/init.py +++ b/devctl/commands/init.py @@ -11,6 +11,7 @@ # Spring generator from devctl.generators.spring import download_spring_boilerplate from devctl.generators.vue import generate_vue_boilerplate +from devctl.generators.nodejs import generate_nodejs_boilerplate from devctl.orchestrator.config_builder import generate_config from devctl.utils.dependencies import check_tool @@ -70,3 +71,17 @@ def init_vue(name: str): if success: typer.secho("\n✨ Vue.js project ready!", fg=typer.colors.GREEN) + + +@app.command("nodejs") +def init_nodejs(name: str): + """ + Initializes a new NodeJS/Express backend project. + """ + check_tool("npm", "initializing a NodeJS project") + + typer.secho(f"šŸš€ Initializing NodeJS project: '{name}'...", fg=typer.colors.CYAN) + success = generate_nodejs_boilerplate(name) + + if success: + typer.secho("\n✨ NodeJS project ready!", fg=typer.colors.GREEN) diff --git a/devctl/generators/docker_scaffold.py b/devctl/generators/docker_scaffold.py index 826ceca..81e5302 100644 --- a/devctl/generators/docker_scaffold.py +++ b/devctl/generators/docker_scaffold.py @@ -105,6 +105,8 @@ def discover_docker_projects(root_path: Union[str, Path]) -> list[DockerProject] has_vite_config = {"vite.config.ts", "vite.config.js"} & filename_set if has_vite_config and "angular.json" not in filename_set: candidates.append(("vue", project_path)) + if "package.json" in filename_set and not any(k in ["angular", "vue"] for k, _ in candidates): + candidates.append(("nodejs", project_path)) used_names: set[str] = set() projects: list[DockerProject] = [] @@ -163,6 +165,8 @@ def scaffold_docker_assets( def _dockerfile_content(env: Environment, project: DockerProject) -> str: if project.kind == "spring": return env.get_template("spring/Dockerfile.j2").render(project=project) + if project.kind == "nodejs": + return env.get_template("nodejs/Dockerfile.j2").render(project=project) return env.get_template("frontend/Dockerfile.j2").render(project=project) diff --git a/devctl/generators/nodejs.py b/devctl/generators/nodejs.py new file mode 100644 index 0000000..8b846d8 --- /dev/null +++ b/devctl/generators/nodejs.py @@ -0,0 +1,87 @@ +""" +Generators for NodeJS (Express) projects. +Includes boilerplate generation with TypeScript and Express. +""" + +import os +import subprocess +import json +import typer + + +def generate_nodejs_boilerplate(project_name: str) -> bool: + """ + Generates a new NodeJS + Express + TypeScript project. + """ + typer.secho(f"šŸ”„ Generating NodeJS/Express project '{project_name}'...", fg=typer.colors.CYAN) + safe_name = project_name.lower().replace("_", "-") + project_path = os.path.join(os.getcwd(), safe_name) + + try: + os.makedirs(project_path, exist_ok=True) + + # 1. Initialize package.json + typer.secho("šŸ“¦ Initializing package.json...", fg=typer.colors.CYAN) + subprocess.run(["npm", "init", "-y"], cwd=project_path, check=True, stdout=subprocess.DEVNULL) + + # 2. Install dependencies + typer.secho("ā³ Installing dependencies (express, typescript, ts-node, nodemon)...", fg=typer.colors.CYAN) + subprocess.run( + ["npm", "install", "express", "dotenv"], + cwd=project_path, check=True, stdout=subprocess.DEVNULL + ) + subprocess.run( + ["npm", "install", "-D", "typescript", "@types/node", "@types/express", "ts-node", "nodemon", "rimraf"], + cwd=project_path, check=True, stdout=subprocess.DEVNULL + ) + + # 3. Initialize TypeScript + typer.secho("āš™ļø Configuring TypeScript...", fg=typer.colors.CYAN) + subprocess.run(["npx", "tsc", "--init"], cwd=project_path, check=True, stdout=subprocess.DEVNULL) + + # 4. Create folder structure + os.makedirs(os.path.join(project_path, "src"), exist_ok=True) + + # 5. Create basic index.ts + index_ts = """import express, { Request, Response } from 'express'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const app = express(); +const port = process.env.PORT || 3000; + +app.use(express.json()); + +app.get('/', (req: Request, res: Response) => { + res.send('Hello from devctl NodeJS/Express!'); +}); + +app.listen(port, () => { + console.log(`[server]: Server is running at http://localhost:${port}`); +}); +""" + with open(os.path.join(project_path, "src", "index.ts"), "w") as f: + f.write(index_ts) + + # 6. Update package.json scripts + with open(os.path.join(project_path, "package.json"), "r") as f: + pkg = json.load(f) + + pkg["scripts"] = { + "start": "node dist/index.js", + "build": "rimraf dist && tsc", + "dev": "nodemon src/index.ts" + } + + with open(os.path.join(project_path, "package.json"), "w") as f: + json.dump(pkg, f, indent=2) + + typer.secho( + f"āœ… NodeJS/Express project '{safe_name}' successfully generated!", fg=typer.colors.GREEN + ) + return True + + except Exception as e: + typer.secho(f"āŒ NodeJS/Express initialization failed: {e}", fg=typer.colors.RED) + return False diff --git a/devctl/generators/scaffold_nodejs.py b/devctl/generators/scaffold_nodejs.py new file mode 100644 index 0000000..c0d0233 --- /dev/null +++ b/devctl/generators/scaffold_nodejs.py @@ -0,0 +1,79 @@ +""" +NodeJS/Express resource scaffolding generator. +Handles the creation of routes and controllers. +""" + +import os +import typer +from devctl.orchestrator.scanner import detect_environment + + +def generate_nodejs_resource(resource_name: str, fields_str: str, root_path: str = "."): + """ + Scaffolds a NodeJS/Express resource. + """ + # Using basic manual file writing for simplicity and speed + # In a more advanced version, we could use Jinja2 templates + + resource_lower = resource_name.lower() + entity_name = resource_name.capitalize() + + src_dir = os.path.join(root_path, "src") + if not os.path.exists(src_dir): + # Try to find nodejs path from scanner + env_state = detect_environment(root_path) + # We need to add has_nodejs to scanner or just check if src exists in root + src_dir = os.path.join(root_path, "src") + + routes_dir = os.path.join(src_dir, "routes") + controllers_dir = os.path.join(src_dir, "controllers") + + os.makedirs(routes_dir, exist_ok=True) + os.makedirs(controllers_dir, exist_ok=True) + + typer.secho(f"āš™ļø Generating NodeJS/Express resource '{entity_name}'...", fg=typer.colors.CYAN) + + # 1. Generate Controller + controller_content = f"""import {{ Request, Response }} from 'express'; + +export const get{entity_name}s = (req: Request, res: Response) => {{ + res.json({{ message: 'Get all {resource_lower}s' }}); +}}; + +export const get{entity_name}ById = (req: Request, res: Response) => {{ + const {{ id }} = req.params; + res.json({{ message: `Get {resource_lower} with id ${{id}}` }}); +}}; + +export const create{entity_name} = (req: Request, res: Response) => {{ + const data = req.body; + res.status(201).json({{ + message: '{entity_name} created', + data + }}); +}}; +""" + controller_path = os.path.join(controllers_dir, f"{resource_lower}.controller.ts") + with open(controller_path, "w") as f: + f.write(controller_content) + + # 2. Generate Route + route_content = f"""import {{ Router }} from 'express'; +import * as {resource_lower}Controller from '../controllers/{resource_lower}.controller'; + +const router = Router(); + +router.get('/', {resource_lower}Controller.get{entity_name}s); +router.get('/:id', {resource_lower}Controller.get{entity_name}ById); +router.post('/', {resource_lower}Controller.create{entity_name}); + +export default router; +""" + route_path = os.path.join(routes_dir, f"{resource_lower}.routes.ts") + with open(route_path, "w") as f: + f.write(route_content) + + typer.secho(f"āœ… {entity_name} NodeJS resource successfully generated!", fg=typer.colors.GREEN) + typer.echo(f" - Created: controllers/{resource_lower}.controller.ts") + typer.echo(f" - Created: routes/{resource_lower}.routes.ts") + typer.echo(f"šŸ’” Don't forget to register the route in your main app file.") diff --git a/devctl/orchestrator/runner.py b/devctl/orchestrator/runner.py index d0ee215..5047a87 100644 --- a/devctl/orchestrator/runner.py +++ b/devctl/orchestrator/runner.py @@ -113,6 +113,24 @@ def signal_handler(sig, frame): t.start() active_threads.append(t) + # 4. Start NodeJS/Express Backends + nodejs_apps = [p for p in projects if p.kind == "nodejs"] + for p in nodejs_apps: + typer.secho(f"šŸ“¦ Starting NodeJS: {p.name}...", fg=typer.colors.GREEN) + + proc = subprocess.Popen( + ["npm", "run", "dev"], + cwd=str(p.path), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + ) + active_processes.append((p.name, proc)) + + t = threading.Thread(target=stream_logs, args=(p.name, proc, "green"), daemon=True) + t.start() + active_threads.append(t) + if not active_processes and not docker_composes: typer.secho("āš ļø No projects or databases detected to run.", fg=typer.colors.YELLOW) return diff --git a/devctl/orchestrator/scanner.py b/devctl/orchestrator/scanner.py index ecc4a5c..8923b6a 100644 --- a/devctl/orchestrator/scanner.py +++ b/devctl/orchestrator/scanner.py @@ -20,6 +20,8 @@ def detect_environment(root_path: str = "."): "angular_path": None, "has_vue": False, "vue_path": None, + "has_nodejs": False, + "nodejs_path": None, "project_root": os.path.abspath(root_path), } @@ -45,4 +47,8 @@ def detect_environment(root_path: str = "."): env_state["has_vue"] = True env_state["vue_path"] = dirpath + if "package.json" in filenames and not any([env_state["has_angular"], env_state["has_vue"]]): + env_state["has_nodejs"] = True + env_state["nodejs_path"] = dirpath + return env_state diff --git a/devctl/templates/docker/nodejs/Dockerfile.j2 b/devctl/templates/docker/nodejs/Dockerfile.j2 new file mode 100644 index 0000000..c1be50d --- /dev/null +++ b/devctl/templates/docker/nodejs/Dockerfile.j2 @@ -0,0 +1,24 @@ +# Production Dockerfile for NodeJS/Express +# Generated by devctl + +FROM node:{{ project.node_version }}-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . +RUN npm run build + +FROM node:{{ project.node_version }}-alpine + +WORKDIR /app + +COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist + +EXPOSE 3000 + +CMD ["npm", "start"] diff --git a/test/test_nodejs.py b/test/test_nodejs.py new file mode 100644 index 0000000..b5a1cdb --- /dev/null +++ b/test/test_nodejs.py @@ -0,0 +1,36 @@ +import os +import json +from unittest.mock import patch +from typer.testing import CliRunner +from devctl.main import app + +runner = CliRunner() + +def test_init_nodejs_calls_boilerplate(tmp_path): + """Ensure init nodejs calls the boilerplate generator.""" + with patch("devctl.commands.init.generate_nodejs_boilerplate") as mock_gen: + mock_gen.return_value = True + with patch("devctl.commands.init.check_tool"): + result = runner.invoke(app, ["init", "nodejs", "my-node-app"]) + assert result.exit_code == 0 + mock_gen.assert_called_once_with("my-node-app") + +def test_add_resource_nodejs(tmp_path): + """Ensure add resource works in a NodeJS project.""" + (tmp_path / "package.json").write_text('{"name": "node-app"}', encoding="utf-8") + (tmp_path / "src").mkdir() + + with patch("devctl.commands.add.generate_nodejs_resource") as mock_gen: + os.chdir(tmp_path) + result = runner.invoke(app, ["add", "resource", "User"]) + assert result.exit_code == 0 + mock_gen.assert_called_once() + +def test_dockerize_nodejs(tmp_path): + """Ensure dockerize detects NodeJS and generates a Dockerfile.""" + (tmp_path / "package.json").write_text('{"name": "node-app"}', encoding="utf-8") + + result = runner.invoke(app, ["dockerize", str(tmp_path)]) + assert result.exit_code == 0 + assert (tmp_path / "Dockerfile").exists() + assert 'CMD ["npm", "start"]' in (tmp_path / "Dockerfile").read_text() From eae10c8dca54979e77941c6a09314f4ccad36cb5 Mon Sep 17 00:00:00 2001 From: Youssef Fellah Date: Mon, 1 Jun 2026 09:20:20 +0100 Subject: [PATCH 06/18] feat: implement ReactJS (Vite + TS) support across CLI --- devctl/commands/add.py | 10 ++++ devctl/commands/init.py | 15 ++++++ devctl/generators/docker_scaffold.py | 20 ++++++- devctl/generators/react.py | 40 ++++++++++++++ devctl/generators/scaffold_react.py | 78 ++++++++++++++++++++++++++++ devctl/orchestrator/runner.py | 10 ++-- devctl/orchestrator/scanner.py | 30 +++++++++-- test/test_react.py | 35 +++++++++++++ 8 files changed, 227 insertions(+), 11 deletions(-) create mode 100644 devctl/generators/react.py create mode 100644 devctl/generators/scaffold_react.py create mode 100644 test/test_react.py diff --git a/devctl/commands/add.py b/devctl/commands/add.py index a0a2c1d..a85458a 100644 --- a/devctl/commands/add.py +++ b/devctl/commands/add.py @@ -10,6 +10,7 @@ from devctl.generators.scaffold_angular import generate_angular_resource from devctl.generators.scaffold_spring import generate_spring_resource from devctl.generators.scaffold_vue import generate_vue_resource +from devctl.generators.scaffold_react import generate_react_resource from devctl.orchestrator.scanner import detect_environment app = typer.Typer(help="Adds resources to the current project (Scaffolding).") @@ -65,6 +66,15 @@ def resource( except Exception as e: typer.secho(f"āŒ Error during Vue generation: {e}", fg=typer.colors.RED) + # Check for React project + if env_state.get("has_react"): + project_detected = True + typer.secho("āš›ļø React project detected. Launching React generator...", fg=typer.colors.BLUE) + try: + generate_react_resource(name, fields, root_path=".") + except Exception as e: + typer.secho(f"āŒ Error during React generation: {e}", fg=typer.colors.RED) + # Error message only if NO project detected if not project_detected: typer.secho( diff --git a/devctl/commands/init.py b/devctl/commands/init.py index bc819fe..6b93891 100644 --- a/devctl/commands/init.py +++ b/devctl/commands/init.py @@ -11,6 +11,7 @@ # Spring generator from devctl.generators.spring import download_spring_boilerplate from devctl.generators.vue import generate_vue_boilerplate +from devctl.generators.react import generate_react_boilerplate from devctl.orchestrator.config_builder import generate_config from devctl.utils.dependencies import check_tool @@ -70,3 +71,17 @@ def init_vue(name: str): if success: typer.secho("\n✨ Vue.js project ready!", fg=typer.colors.GREEN) + + +@app.command("react") +def init_react(name: str): + """ + Initializes a new ReactJS frontend project (Vite + TS). + """ + check_tool("npm", "initializing a ReactJS project") + + typer.secho(f"šŸš€ Initializing ReactJS project: '{name}'...", fg=typer.colors.CYAN) + success = generate_react_boilerplate(name) + + if success: + typer.secho("\n✨ ReactJS project ready!", fg=typer.colors.GREEN) diff --git a/devctl/generators/docker_scaffold.py b/devctl/generators/docker_scaffold.py index 826ceca..48cc48e 100644 --- a/devctl/generators/docker_scaffold.py +++ b/devctl/generators/docker_scaffold.py @@ -104,7 +104,25 @@ def discover_docker_projects(root_path: Union[str, Path]) -> list[DockerProject] candidates.append(("angular", project_path)) has_vite_config = {"vite.config.ts", "vite.config.js"} & filename_set if has_vite_config and "angular.json" not in filename_set: - candidates.append(("vue", project_path)) + # Check package.json to distinguish between vue and react + pkg_path = project_path / "package.json" + if pkg_path.exists(): + try: + pkg = json.loads(pkg_path.read_text(encoding="utf-8")) + deps = pkg.get("dependencies", {}) + dev_deps = pkg.get("devDependencies", {}) + all_deps = {**deps, **dev_deps} + + if "vue" in all_deps: + candidates.append(("vue", project_path)) + elif "react" in all_deps: + candidates.append(("react", project_path)) + else: + candidates.append(("vue", project_path)) # Fallback to vue + except Exception: + candidates.append(("vue", project_path)) + else: + candidates.append(("vue", project_path)) used_names: set[str] = set() projects: list[DockerProject] = [] diff --git a/devctl/generators/react.py b/devctl/generators/react.py new file mode 100644 index 0000000..e25c887 --- /dev/null +++ b/devctl/generators/react.py @@ -0,0 +1,40 @@ +""" +Generators for ReactJS projects via Vite. +Includes boilerplate generation and basic configuration. +""" + +import os +import subprocess +import typer + + +def generate_react_boilerplate(project_name: str) -> bool: + """ + Generates a new React + TypeScript project via Vite. + """ + typer.secho(f"šŸ”„ Generating ReactJS frontend '{project_name}' via Vite...", fg=typer.colors.CYAN) + safe_name = project_name.lower().replace("_", "-") + + try: + typer.secho("šŸ“¦ Scaffolding React project...", fg=typer.colors.CYAN) + subprocess.run( + ["npm", "create", "vite@latest", safe_name, "--", "--template", "react-ts"], + check=True + ) + + project_full_path = os.path.join(os.getcwd(), safe_name) + + typer.secho("ā³ Installing npm dependencies...", fg=typer.colors.CYAN) + subprocess.run(["npm", "install"], cwd=project_full_path, check=True) + + typer.secho( + f"āœ… ReactJS frontend '{safe_name}' successfully generated!", fg=typer.colors.GREEN + ) + return True + + except subprocess.CalledProcessError as e: + typer.secho(f"āŒ React/Vite process failed with code: {e.returncode}", fg=typer.colors.RED) + return False + except Exception as e: + typer.secho(f"āŒ React/Vite initialization failed: {e}", fg=typer.colors.RED) + return False diff --git a/devctl/generators/scaffold_react.py b/devctl/generators/scaffold_react.py new file mode 100644 index 0000000..2d98e9e --- /dev/null +++ b/devctl/generators/scaffold_react.py @@ -0,0 +1,78 @@ +""" +ReactJS resource scaffolding generator. +Handles the creation of components, hooks, and services. +""" + +import os +import typer +from devctl.orchestrator.scanner import detect_environment + + +def generate_react_resource(resource_name: str, fields_str: str, root_path: str = "."): + """ + Scaffolds a React resource (Component, Service). + """ + env_state = detect_environment(root_path) + + if not env_state["has_react"]: + typer.secho("āŒ Error: No ReactJS project detected here.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + react_root = env_state["react_path"] + resource_lower = resource_name.lower() + entity_name = resource_name.capitalize() + + # Structure: src/components/ResourceName/... + components_dir = os.path.join(react_root, "src", "components", entity_name) + services_dir = os.path.join(react_root, "src", "services") + + os.makedirs(components_dir, exist_ok=True) + os.makedirs(services_dir, exist_ok=True) + + typer.secho(f"āš™ļø Generating ReactJS resource '{entity_name}'...", fg=typer.colors.CYAN) + + # 1. Generate Component (tsx) + component_content = f"""import React from 'react'; + +export const {entity_name}List: React.FC = () => {{ + return ( +
+

{entity_name} List

+

Manage your {resource_lower}s here.

+
+ ); +}}; + +export const {entity_name}Form: React.FC = () => {{ + return ( +
+

Create {entity_name}

+ {fields_str} +
+ ); +}}; +""" + with open(os.path.join(components_dir, f"{entity_name}.tsx"), "w") as f: + f.write(component_content) + + # 2. Generate Service + service_content = f"""export const fetch{entity_name}s = async () => {{ + const response = await fetch('/api/{resource_lower}s'); + return response.json(); +}}; + +export const create{entity_name} = async (data: any) => {{ + const response = await fetch('/api/{resource_lower}s', {{ + method: 'POST', + body: JSON.stringify(data), + headers: {{ 'Content-Type': 'application/json' }} + }}); + return response.json(); +}}; +""" + with open(os.path.join(services_dir, f"{resource_lower}.service.ts"), "w") as f: + f.write(service_content) + + typer.secho(f"āœ… {entity_name} React feature successfully generated!", fg=typer.colors.GREEN) + typer.echo(f" - Created: src/components/{entity_name}/{entity_name}.tsx") + typer.echo(f" - Created: src/services/{resource_lower}.service.ts") diff --git a/devctl/orchestrator/runner.py b/devctl/orchestrator/runner.py index d0ee215..4c29a5a 100644 --- a/devctl/orchestrator/runner.py +++ b/devctl/orchestrator/runner.py @@ -91,14 +91,14 @@ def signal_handler(sig, frame): t.start() active_threads.append(t) - # 3. Start Frontends (Angular / Vue) - frontends = [p for p in projects if p.kind in ["angular", "vue"]] + # 3. Start Frontends (Angular / Vue / React) + frontends = [p for p in projects if p.kind in ["angular", "vue", "react"]] for p in frontends: - color = "cyan" if p.kind == "angular" else "magenta" - icon = "šŸ…°ļø" if p.kind == "angular" else "🟢" + color = "cyan" if p.kind == "angular" else ("magenta" if p.kind == "vue" else "blue") + icon = "šŸ…°ļø" if p.kind == "angular" else ("🟢" if p.kind == "vue" else "āš›ļø") cmd = ["npx", "ng", "serve"] if p.kind == "angular" else ["npm", "run", "dev"] - typer.secho(f"{icon} Starting {p.kind.capitalize()}: {p.name}...", fg=typer.colors.CYAN if p.kind == "angular" else typer.colors.MAGENTA) + typer.secho(f"{icon} Starting {p.kind.capitalize()}: {p.name}...", fg=typer.colors.CYAN if p.kind == "angular" else (typer.colors.MAGENTA if p.kind == "vue" else typer.colors.BLUE)) proc = subprocess.Popen( cmd, diff --git a/devctl/orchestrator/scanner.py b/devctl/orchestrator/scanner.py index ecc4a5c..8dea063 100644 --- a/devctl/orchestrator/scanner.py +++ b/devctl/orchestrator/scanner.py @@ -1,9 +1,10 @@ """ Project scanner and environment detector. -Identifies Spring Boot, Angular, Vue.js, and Docker components in a directory tree. +Identifies Spring Boot, Angular, Vue.js, React, and Docker components in a directory tree. """ import os +import json def detect_environment(root_path: str = "."): @@ -20,12 +21,14 @@ def detect_environment(root_path: str = "."): "angular_path": None, "has_vue": False, "vue_path": None, + "has_react": False, + "react_path": None, "project_root": os.path.abspath(root_path), } for dirpath, _dirnames, filenames in os.walk(root_path): # Optimization: ignore heavy folders for an instant scan - if any(ignored in dirpath for ignored in ["node_modules", "target", ".git", ".angular"]): + if any(ignored in dirpath for ignored in ["node_modules", "target", ".git", ".angular", "dist"]): continue if "docker-compose.yml" in filenames and not env_state["has_docker_compose"]: @@ -41,8 +44,25 @@ def detect_environment(root_path: str = "."): env_state["angular_path"] = dirpath vue_files = ["vite.config.ts", "vite.config.js"] - if any(f in filenames for f in vue_files) and not env_state["has_vue"]: - env_state["has_vue"] = True - env_state["vue_path"] = dirpath + if any(f in filenames for f in vue_files) and not any([env_state["has_vue"], env_state["has_react"]]): + # Distinguish by package.json + pkg_path = os.path.join(dirpath, "package.json") + if os.path.exists(pkg_path): + try: + with open(pkg_path, "r") as f: + pkg = json.load(f) + all_deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})} + if "react" in all_deps: + env_state["has_react"] = True + env_state["react_path"] = dirpath + else: + env_state["has_vue"] = True + env_state["vue_path"] = dirpath + except Exception: + env_state["has_vue"] = True + env_state["vue_path"] = dirpath + else: + env_state["has_vue"] = True + env_state["vue_path"] = dirpath return env_state diff --git a/test/test_react.py b/test/test_react.py new file mode 100644 index 0000000..255b715 --- /dev/null +++ b/test/test_react.py @@ -0,0 +1,35 @@ +import os +from unittest.mock import patch +from typer.testing import CliRunner +from devctl.main import app + +runner = CliRunner() + +def test_init_react_calls_boilerplate(tmp_path): + """Ensure init react calls the boilerplate generator.""" + with patch("devctl.commands.init.generate_react_boilerplate") as mock_gen: + mock_gen.return_value = True + with patch("devctl.commands.init.check_tool"): + result = runner.invoke(app, ["init", "react", "my-react-app"]) + assert result.exit_code == 0 + mock_gen.assert_called_once_with("my-react-app") + +def test_add_resource_react(tmp_path): + """Ensure add resource works in a React project.""" + (tmp_path / "package.json").write_text('{"dependencies": {"react": "18.2.0"}}', encoding="utf-8") + (tmp_path / "vite.config.ts").write_text("export default {}", encoding="utf-8") + + with patch("devctl.commands.add.generate_react_resource") as mock_gen: + os.chdir(tmp_path) + result = runner.invoke(app, ["add", "resource", "User"]) + assert result.exit_code == 0 + mock_gen.assert_called_once() + +def test_dockerize_react(tmp_path): + """Ensure dockerize detects React and generates a Dockerfile.""" + (tmp_path / "package.json").write_text('{"dependencies": {"react": "18.2.0"}}', encoding="utf-8") + (tmp_path / "vite.config.ts").write_text("export default {}", encoding="utf-8") + + result = runner.invoke(app, ["dockerize", str(tmp_path)]) + assert result.exit_code == 0 + assert (tmp_path / "Dockerfile").exists() From 52f123167777cb16458a5d9cacef66dfac842d3a Mon Sep 17 00:00:00 2001 From: Youssef Fellah Date: Mon, 1 Jun 2026 09:23:39 +0100 Subject: [PATCH 07/18] feat: implement NextJS support across CLI --- devctl/commands/add.py | 10 +++ devctl/commands/init.py | 15 +++++ devctl/generators/docker_scaffold.py | 4 ++ devctl/generators/nextjs.py | 41 +++++++++++ devctl/generators/scaffold_nextjs.py | 71 ++++++++++++++++++++ devctl/orchestrator/runner.py | 10 +-- devctl/orchestrator/scanner.py | 10 ++- devctl/templates/docker/nextjs/Dockerfile.j2 | 43 ++++++++++++ test/test_nextjs.py | 35 ++++++++++ 9 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 devctl/generators/nextjs.py create mode 100644 devctl/generators/scaffold_nextjs.py create mode 100644 devctl/templates/docker/nextjs/Dockerfile.j2 create mode 100644 test/test_nextjs.py diff --git a/devctl/commands/add.py b/devctl/commands/add.py index a0a2c1d..2eaf9b3 100644 --- a/devctl/commands/add.py +++ b/devctl/commands/add.py @@ -10,6 +10,7 @@ from devctl.generators.scaffold_angular import generate_angular_resource from devctl.generators.scaffold_spring import generate_spring_resource from devctl.generators.scaffold_vue import generate_vue_resource +from devctl.generators.scaffold_nextjs import generate_nextjs_resource from devctl.orchestrator.scanner import detect_environment app = typer.Typer(help="Adds resources to the current project (Scaffolding).") @@ -65,6 +66,15 @@ def resource( except Exception as e: typer.secho(f"āŒ Error during Vue generation: {e}", fg=typer.colors.RED) + # Check for NextJS project + if env_state.get("has_nextjs"): + project_detected = True + typer.secho("ā–² NextJS project detected. Launching NextJS generator...", fg=typer.colors.YELLOW) + try: + generate_nextjs_resource(name, fields, root_path=".") + except Exception as e: + typer.secho(f"āŒ Error during NextJS generation: {e}", fg=typer.colors.RED) + # Error message only if NO project detected if not project_detected: typer.secho( diff --git a/devctl/commands/init.py b/devctl/commands/init.py index bc819fe..9e728e3 100644 --- a/devctl/commands/init.py +++ b/devctl/commands/init.py @@ -11,6 +11,7 @@ # Spring generator from devctl.generators.spring import download_spring_boilerplate from devctl.generators.vue import generate_vue_boilerplate +from devctl.generators.nextjs import generate_nextjs_boilerplate from devctl.orchestrator.config_builder import generate_config from devctl.utils.dependencies import check_tool @@ -70,3 +71,17 @@ def init_vue(name: str): if success: typer.secho("\n✨ Vue.js project ready!", fg=typer.colors.GREEN) + + +@app.command("nextjs") +def init_nextjs(name: str): + """ + Initializes a new NextJS frontend project. + """ + check_tool("npm", "initializing a NextJS project") + + typer.secho(f"šŸš€ Initializing NextJS project: '{name}'...", fg=typer.colors.CYAN) + success = generate_nextjs_boilerplate(name) + + if success: + typer.secho("\n✨ NextJS project ready!", fg=typer.colors.GREEN) diff --git a/devctl/generators/docker_scaffold.py b/devctl/generators/docker_scaffold.py index 826ceca..fe2290f 100644 --- a/devctl/generators/docker_scaffold.py +++ b/devctl/generators/docker_scaffold.py @@ -105,6 +105,8 @@ def discover_docker_projects(root_path: Union[str, Path]) -> list[DockerProject] has_vite_config = {"vite.config.ts", "vite.config.js"} & filename_set if has_vite_config and "angular.json" not in filename_set: candidates.append(("vue", project_path)) + if any(f.startswith("next.config.") for f in filename_set): + candidates.append(("nextjs", project_path)) used_names: set[str] = set() projects: list[DockerProject] = [] @@ -163,6 +165,8 @@ def scaffold_docker_assets( def _dockerfile_content(env: Environment, project: DockerProject) -> str: if project.kind == "spring": return env.get_template("spring/Dockerfile.j2").render(project=project) + if project.kind == "nextjs": + return env.get_template("nextjs/Dockerfile.j2").render(project=project) return env.get_template("frontend/Dockerfile.j2").render(project=project) diff --git a/devctl/generators/nextjs.py b/devctl/generators/nextjs.py new file mode 100644 index 0000000..7a3a1c7 --- /dev/null +++ b/devctl/generators/nextjs.py @@ -0,0 +1,41 @@ +""" +Generators for NextJS projects. +Includes boilerplate generation via create-next-app. +""" + +import os +import subprocess +import typer + + +def generate_nextjs_boilerplate(project_name: str) -> bool: + """ + Generates a new NextJS project using create-next-app via npx. + """ + typer.secho(f"šŸ”„ Generating NextJS project '{project_name}'...", fg=typer.colors.CYAN) + safe_name = project_name.lower().replace("_", "-") + + try: + typer.secho("šŸ“¦ Scaffolding NextJS project (this may take a minute)...", fg=typer.colors.CYAN) + # --ts: TypeScript + # --eslint: ESLint + # --tailwind: Tailwind CSS + # --src-dir: Use src/ directory + # --app: Use App Router + # --import-alias: alias for imports + subprocess.run( + ["npx", "create-next-app@latest", safe_name, "--ts", "--eslint", "--tailwind", "--src-dir", "--app", "--import-alias", "@/*", "--use-npm"], + check=True + ) + + typer.secho( + f"āœ… NextJS project '{safe_name}' successfully generated!", fg=typer.colors.GREEN + ) + return True + + except subprocess.CalledProcessError as e: + typer.secho(f"āŒ NextJS creation failed with code: {e.returncode}", fg=typer.colors.RED) + return False + except Exception as e: + typer.secho(f"āŒ NextJS initialization failed: {e}", fg=typer.colors.RED) + return False diff --git a/devctl/generators/scaffold_nextjs.py b/devctl/generators/scaffold_nextjs.py new file mode 100644 index 0000000..63ff3ae --- /dev/null +++ b/devctl/generators/scaffold_nextjs.py @@ -0,0 +1,71 @@ +""" +NextJS resource scaffolding generator. +Handles the creation of pages and components in the App Router. +""" + +import os +import typer +from devctl.orchestrator.scanner import detect_environment + + +def generate_nextjs_resource(resource_name: str, fields_str: str, root_path: str = "."): + """ + Scaffolds a NextJS resource (Page, Component). + """ + env_state = detect_environment(root_path) + + if not env_state["has_nextjs"]: + typer.secho("āŒ Error: No NextJS project detected here.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + nextjs_root = env_state["nextjs_path"] + resource_lower = resource_name.lower() + entity_name = resource_name.capitalize() + + # Structure: src/app/resource-name/page.tsx + app_dir = os.path.join(nextjs_root, "src", "app", resource_lower) + components_dir = os.path.join(nextjs_root, "src", "components") + + os.makedirs(app_dir, exist_ok=True) + os.makedirs(components_dir, exist_ok=True) + + typer.secho(f"āš™ļø Generating NextJS resource '{entity_name}'...", fg=typer.colors.CYAN) + + # 1. Generate Page (tsx) + page_content = f"""import React from 'react'; +import {{ {entity_name}List }} from '@/components/{entity_name}List'; + +export default function {entity_name}Page() {{ + return ( +
+

{entity_name} Management

+ <{entity_name}List /> +
+ ); +}} +""" + with open(os.path.join(app_dir, "page.tsx"), "w") as f: + f.write(page_content) + + # 2. Generate Component + component_content = f"""import React from 'react'; + +export const {entity_name}List: React.FC = () => {{ + return ( +
+

{entity_name} List

+

This component was generated by devctl.

+
    +
  • Item 1
  • +
  • Item 2
  • +
+
+ ); +}}; +""" + with open(os.path.join(components_dir, f"{entity_name}List.tsx"), "w") as f: + f.write(component_content) + + typer.secho(f"āœ… {entity_name} NextJS feature successfully generated!", fg=typer.colors.GREEN) + typer.echo(f" - Created: src/app/{resource_lower}/page.tsx") + typer.echo(f" - Created: src/components/{entity_name}List.tsx") diff --git a/devctl/orchestrator/runner.py b/devctl/orchestrator/runner.py index d0ee215..895c200 100644 --- a/devctl/orchestrator/runner.py +++ b/devctl/orchestrator/runner.py @@ -91,14 +91,14 @@ def signal_handler(sig, frame): t.start() active_threads.append(t) - # 3. Start Frontends (Angular / Vue) - frontends = [p for p in projects if p.kind in ["angular", "vue"]] + # 3. Start Frontends (Angular / Vue / NextJS) + frontends = [p for p in projects if p.kind in ["angular", "vue", "nextjs"]] for p in frontends: - color = "cyan" if p.kind == "angular" else "magenta" - icon = "šŸ…°ļø" if p.kind == "angular" else "🟢" + color = "cyan" if p.kind == "angular" else ("magenta" if p.kind == "vue" else "yellow") + icon = "šŸ…°ļø" if p.kind == "angular" else ("🟢" if p.kind == "vue" else "ā–²") cmd = ["npx", "ng", "serve"] if p.kind == "angular" else ["npm", "run", "dev"] - typer.secho(f"{icon} Starting {p.kind.capitalize()}: {p.name}...", fg=typer.colors.CYAN if p.kind == "angular" else typer.colors.MAGENTA) + typer.secho(f"{icon} Starting {p.kind.capitalize()}: {p.name}...", fg=typer.colors.CYAN if p.kind == "angular" else (typer.colors.MAGENTA if p.kind == "vue" else typer.colors.YELLOW)) proc = subprocess.Popen( cmd, diff --git a/devctl/orchestrator/scanner.py b/devctl/orchestrator/scanner.py index ecc4a5c..d411e4e 100644 --- a/devctl/orchestrator/scanner.py +++ b/devctl/orchestrator/scanner.py @@ -1,6 +1,6 @@ """ Project scanner and environment detector. -Identifies Spring Boot, Angular, Vue.js, and Docker components in a directory tree. +Identifies Spring Boot, Angular, Vue.js, NextJS, and Docker components in a directory tree. """ import os @@ -20,12 +20,14 @@ def detect_environment(root_path: str = "."): "angular_path": None, "has_vue": False, "vue_path": None, + "has_nextjs": False, + "nextjs_path": None, "project_root": os.path.abspath(root_path), } for dirpath, _dirnames, filenames in os.walk(root_path): # Optimization: ignore heavy folders for an instant scan - if any(ignored in dirpath for ignored in ["node_modules", "target", ".git", ".angular"]): + if any(ignored in dirpath for ignored in ["node_modules", "target", ".git", ".angular", ".next"]): continue if "docker-compose.yml" in filenames and not env_state["has_docker_compose"]: @@ -45,4 +47,8 @@ def detect_environment(root_path: str = "."): env_state["has_vue"] = True env_state["vue_path"] = dirpath + if any(f.startswith("next.config.") for f in filenames) and not env_state["has_nextjs"]: + env_state["has_nextjs"] = True + env_state["nextjs_path"] = dirpath + return env_state diff --git a/devctl/templates/docker/nextjs/Dockerfile.j2 b/devctl/templates/docker/nextjs/Dockerfile.j2 new file mode 100644 index 0000000..263b2b3 --- /dev/null +++ b/devctl/templates/docker/nextjs/Dockerfile.j2 @@ -0,0 +1,43 @@ +# Production Dockerfile for NextJS +# Generated by devctl + +FROM node:{{ project.node_version }}-alpine AS base + +# 1. Install dependencies only when needed +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app +COPY package*.json ./ +RUN npm install + +# 2. Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +# 3. Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production + +RUN addgroup -g 1001 -S nodejs +RUN adduser -S nextjs -u 1001 + +COPY --from=builder /app/public ./public + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/test/test_nextjs.py b/test/test_nextjs.py new file mode 100644 index 0000000..e2cae88 --- /dev/null +++ b/test/test_nextjs.py @@ -0,0 +1,35 @@ +import os +from unittest.mock import patch +from typer.testing import CliRunner +from devctl.main import app + +runner = CliRunner() + +def test_init_nextjs_calls_boilerplate(tmp_path): + """Ensure init nextjs calls the boilerplate generator.""" + with patch("devctl.commands.init.generate_nextjs_boilerplate") as mock_gen: + mock_gen.return_value = True + with patch("devctl.commands.init.check_tool"): + result = runner.invoke(app, ["init", "nextjs", "my-next-app"]) + assert result.exit_code == 0 + mock_gen.assert_called_once_with("my-next-app") + +def test_add_resource_nextjs(tmp_path): + """Ensure add resource works in a NextJS project.""" + (tmp_path / "next.config.js").write_text("module.exports = {}", encoding="utf-8") + + with patch("devctl.commands.add.generate_nextjs_resource") as mock_gen: + os.chdir(tmp_path) + result = runner.invoke(app, ["add", "resource", "User"]) + assert result.exit_code == 0 + mock_gen.assert_called_once() + +def test_dockerize_nextjs(tmp_path): + """Ensure dockerize detects NextJS and generates a Dockerfile.""" + (tmp_path / "next.config.js").write_text("module.exports = {}", encoding="utf-8") + (tmp_path / "package.json").write_text('{"name": "next-app"}', encoding="utf-8") + + result = runner.invoke(app, ["dockerize", str(tmp_path)]) + assert result.exit_code == 0 + assert (tmp_path / "Dockerfile").exists() + assert "server.js" in (tmp_path / "Dockerfile").read_text() From aed3b16496053702e90cadb1175278c7f9211794 Mon Sep 17 00:00:00 2001 From: Youssef Fellah Date: Mon, 1 Jun 2026 09:38:32 +0100 Subject: [PATCH 08/18] feat: implement FastAPI support across CLI --- devctl/commands/add.py | 10 ++ devctl/commands/init.py | 15 +++ devctl/generators/docker_scaffold.py | 10 ++ devctl/generators/fastapi.py | 63 ++++++++++++ devctl/generators/scaffold_fastapi.py | 98 +++++++++++++++++++ devctl/orchestrator/runner.py | 23 +++++ devctl/orchestrator/scanner.py | 16 ++- devctl/templates/docker/fastapi/Dockerfile.j2 | 15 +++ test/test_fastapi.py | 36 +++++++ 9 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 devctl/generators/fastapi.py create mode 100644 devctl/generators/scaffold_fastapi.py create mode 100644 devctl/templates/docker/fastapi/Dockerfile.j2 create mode 100644 test/test_fastapi.py diff --git a/devctl/commands/add.py b/devctl/commands/add.py index a0a2c1d..dde74b5 100644 --- a/devctl/commands/add.py +++ b/devctl/commands/add.py @@ -10,6 +10,7 @@ from devctl.generators.scaffold_angular import generate_angular_resource from devctl.generators.scaffold_spring import generate_spring_resource from devctl.generators.scaffold_vue import generate_vue_resource +from devctl.generators.scaffold_fastapi import generate_fastapi_resource from devctl.orchestrator.scanner import detect_environment app = typer.Typer(help="Adds resources to the current project (Scaffolding).") @@ -65,6 +66,15 @@ def resource( except Exception as e: typer.secho(f"āŒ Error during Vue generation: {e}", fg=typer.colors.RED) + # Check for FastAPI project + if env_state.get("has_fastapi"): + project_detected = True + typer.secho("⚔ FastAPI project detected. Launching FastAPI generator...", fg=typer.colors.CYAN) + try: + generate_fastapi_resource(name, fields, root_path=".") + except Exception as e: + typer.secho(f"āŒ Error during FastAPI generation: {e}", fg=typer.colors.RED) + # Error message only if NO project detected if not project_detected: typer.secho( diff --git a/devctl/commands/init.py b/devctl/commands/init.py index bc819fe..4a2bf8c 100644 --- a/devctl/commands/init.py +++ b/devctl/commands/init.py @@ -11,6 +11,7 @@ # Spring generator from devctl.generators.spring import download_spring_boilerplate from devctl.generators.vue import generate_vue_boilerplate +from devctl.generators.fastapi import generate_fastapi_boilerplate from devctl.orchestrator.config_builder import generate_config from devctl.utils.dependencies import check_tool @@ -70,3 +71,17 @@ def init_vue(name: str): if success: typer.secho("\n✨ Vue.js project ready!", fg=typer.colors.GREEN) + + +@app.command("fastapi") +def init_fastapi(name: str): + """ + Initializes a new FastAPI backend project. + """ + check_tool("python3", "initializing a FastAPI project") + + typer.secho(f"šŸš€ Initializing FastAPI project: '{name}'...", fg=typer.colors.CYAN) + success = generate_fastapi_boilerplate(name) + + if success: + typer.secho("\n✨ FastAPI project ready!", fg=typer.colors.GREEN) diff --git a/devctl/generators/docker_scaffold.py b/devctl/generators/docker_scaffold.py index 826ceca..a736e77 100644 --- a/devctl/generators/docker_scaffold.py +++ b/devctl/generators/docker_scaffold.py @@ -105,6 +105,14 @@ def discover_docker_projects(root_path: Union[str, Path]) -> list[DockerProject] has_vite_config = {"vite.config.ts", "vite.config.js"} & filename_set if has_vite_config and "angular.json" not in filename_set: candidates.append(("vue", project_path)) + if "main.py" in filename_set and "requirements.txt" in filename_set: + # Heuristic for FastAPI + try: + reqs = (project_path / "requirements.txt").read_text() + if "fastapi" in reqs.lower(): + candidates.append(("fastapi", project_path)) + except Exception: + pass used_names: set[str] = set() projects: list[DockerProject] = [] @@ -163,6 +171,8 @@ def scaffold_docker_assets( def _dockerfile_content(env: Environment, project: DockerProject) -> str: if project.kind == "spring": return env.get_template("spring/Dockerfile.j2").render(project=project) + if project.kind == "fastapi": + return env.get_template("fastapi/Dockerfile.j2").render(project=project) return env.get_template("frontend/Dockerfile.j2").render(project=project) diff --git a/devctl/generators/fastapi.py b/devctl/generators/fastapi.py new file mode 100644 index 0000000..420358d --- /dev/null +++ b/devctl/generators/fastapi.py @@ -0,0 +1,63 @@ +""" +Generators for FastAPI projects. +Includes boilerplate generation with Uvicorn and Pydantic. +""" + +import os +import subprocess +import typer + + +def generate_fastapi_boilerplate(project_name: str) -> bool: + """ + Generates a new FastAPI project. + """ + typer.secho(f"šŸ”„ Generating FastAPI project '{project_name}'...", fg=typer.colors.CYAN) + safe_name = project_name.lower().replace("_", "-") + project_path = os.path.join(os.getcwd(), safe_name) + + try: + os.makedirs(project_path, exist_ok=True) + + # 1. Create main.py + main_py = """from fastapi import FastAPI + +app = FastAPI(title="devctl FastAPI project") + +@app.get("/") +def read_root(): + return {"Hello": "from devctl FastAPI!"} + +@app.get("/items/{item_id}") +def read_item(item_id: int, q: str = None): + return {"item_id": item_id, "q": q} +""" + with open(os.path.join(project_path, "main.py"), "w") as f: + f.write(main_py) + + # 2. Create requirements.txt + requirements = """fastapi +uvicorn[standard] +pydantic +""" + with open(os.path.join(project_path, "requirements.txt"), "w") as f: + f.write(requirements) + + # 3. Create virtual environment + typer.secho("šŸ“¦ Creating virtual environment...", fg=typer.colors.CYAN) + subprocess.run(["python3", "-m", "venv", ".venv"], cwd=project_path, check=True) + + # 4. Install dependencies + typer.secho("ā³ Installing dependencies (fastapi, uvicorn)...", fg=typer.colors.CYAN) + # Note: on Linux it's .venv/bin/pip + pip_path = os.path.join(".venv", "bin", "pip") + subprocess.run([pip_path, "install", "-r", "requirements.txt"], cwd=project_path, check=True, stdout=subprocess.DEVNULL) + + typer.secho( + f"āœ… FastAPI project '{safe_name}' successfully generated!", fg=typer.colors.GREEN + ) + return True + + except Exception as e: + typer.secho(f"āŒ FastAPI initialization failed: {e}", fg=typer.colors.RED) + return False diff --git a/devctl/generators/scaffold_fastapi.py b/devctl/generators/scaffold_fastapi.py new file mode 100644 index 0000000..ce11210 --- /dev/null +++ b/devctl/generators/scaffold_fastapi.py @@ -0,0 +1,98 @@ +""" +FastAPI resource scaffolding generator. +Handles the creation of routers, schemas, and models. +""" + +import os +import typer +from devctl.orchestrator.scanner import detect_environment + + +def generate_fastapi_resource(resource_name: str, fields_str: str, root_path: str = "."): + """ + Scaffolds a FastAPI resource. + """ + env_state = detect_environment(root_path) + + if not env_state["has_fastapi"]: + typer.secho("āŒ Error: No FastAPI project detected here.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + fastapi_root = env_state["fastapi_path"] + resource_lower = resource_name.lower() + entity_name = resource_name.capitalize() + + # Create directories + routers_dir = os.path.join(fastapi_root, "routers") + schemas_dir = os.path.join(fastapi_root, "schemas") + models_dir = os.path.join(fastapi_root, "models") + + for d in [routers_dir, schemas_dir, models_dir]: + os.makedirs(d, exist_ok=True) + # Ensure __init__.py exists + with open(os.path.join(d, "__init__.py"), "a"): pass + + typer.secho(f"āš™ļø Generating FastAPI resource '{entity_name}'...", fg=typer.colors.CYAN) + + # 1. Generate Schema (Pydantic) + schema_content = f"""from pydantic import BaseModel +from typing import Optional + +class {entity_name}Base(BaseModel): + # Fields: {fields_str} + pass + +class {entity_name}Create({entity_name}Base): + pass + +class {entity_name}({entity_name}Base): + id: int + + class Config: + orm_mode = True +""" + with open(os.path.join(schemas_dir, f"{resource_lower}.py"), "w") as f: + f.write(schema_content) + + # 2. Generate Router + router_content = f"""from fastapi import APIRouter, HTTPException +from typing import List +from schemas import {resource_lower} as schemas + +router = APIRouter( + prefix="/{resource_lower}s", + tags=["{resource_lower}s"] +) + +@{resource_lower}.get("/", response_model=List[schemas.{entity_name}]) +def read_{resource_lower}s(): + return [] + +@{resource_lower}.post("/", response_model=schemas.{entity_name}) +def create_{resource_lower}({resource_lower}: schemas.{entity_name}Create): + return {{"id": 1, **{resource_lower}.dict()}} +""" + # Note: fixed typo in template during thought but let's correct it properly + router_content = f"""from fastapi import APIRouter, HTTPException +from typing import List +from schemas import {resource_lower} as schemas + +router = APIRouter( + prefix="/{resource_lower}s", + tags=["{resource_lower}s"] +) + +@router.get("/", response_model=List[schemas.{entity_name}]) +def read_{resource_lower}s(): + return [] + +@router.post("/", response_model=schemas.{entity_name}) +def create_{resource_lower}(item: schemas.{entity_name}Create): + return {{"id": 1, **item.dict()}} +""" + with open(os.path.join(routers_dir, f"{resource_lower}.py"), "w") as f: + f.write(router_content) + + typer.secho(f"āœ… {entity_name} FastAPI feature successfully generated!", fg=typer.colors.GREEN) + typer.echo(f" - Created: schemas/{resource_lower}.py") + typer.echo(f" - Created: routers/{resource_lower}.py") diff --git a/devctl/orchestrator/runner.py b/devctl/orchestrator/runner.py index d0ee215..6e260b7 100644 --- a/devctl/orchestrator/runner.py +++ b/devctl/orchestrator/runner.py @@ -113,6 +113,29 @@ def signal_handler(sig, frame): t.start() active_threads.append(t) + # 4. Start FastAPI Backends + fastapi_apps = [p for p in projects if p.kind == "fastapi"] + for p in fastapi_apps: + typer.secho(f"⚔ Starting FastAPI: {p.name}...", fg=typer.colors.CYAN) + + # Use venv if exists + venv_python = os.path.join(str(p.path), ".venv", "bin", "python3") + if not os.path.exists(venv_python): + venv_python = "python3" + + proc = subprocess.Popen( + [venv_python, "-m", "uvicorn", "main:app", "--reload"], + cwd=str(p.path), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + ) + active_processes.append((p.name, proc)) + + t = threading.Thread(target=stream_logs, args=(p.name, proc, "cyan"), daemon=True) + t.start() + active_threads.append(t) + if not active_processes and not docker_composes: typer.secho("āš ļø No projects or databases detected to run.", fg=typer.colors.YELLOW) return diff --git a/devctl/orchestrator/scanner.py b/devctl/orchestrator/scanner.py index ecc4a5c..8942b2d 100644 --- a/devctl/orchestrator/scanner.py +++ b/devctl/orchestrator/scanner.py @@ -1,6 +1,6 @@ """ Project scanner and environment detector. -Identifies Spring Boot, Angular, Vue.js, and Docker components in a directory tree. +Identifies Spring Boot, Angular, Vue.js, FastAPI, and Docker components in a directory tree. """ import os @@ -20,12 +20,14 @@ def detect_environment(root_path: str = "."): "angular_path": None, "has_vue": False, "vue_path": None, + "has_fastapi": False, + "fastapi_path": None, "project_root": os.path.abspath(root_path), } for dirpath, _dirnames, filenames in os.walk(root_path): # Optimization: ignore heavy folders for an instant scan - if any(ignored in dirpath for ignored in ["node_modules", "target", ".git", ".angular"]): + if any(ignored in dirpath for ignored in ["node_modules", "target", ".git", ".angular", ".venv"]): continue if "docker-compose.yml" in filenames and not env_state["has_docker_compose"]: @@ -45,4 +47,14 @@ def detect_environment(root_path: str = "."): env_state["has_vue"] = True env_state["vue_path"] = dirpath + if "main.py" in filenames and "requirements.txt" in filenames: + req_path = os.path.join(dirpath, "requirements.txt") + try: + with open(req_path, "r") as f: + if "fastapi" in f.read().lower(): + env_state["has_fastapi"] = True + env_state["fastapi_path"] = dirpath + except Exception: + pass + return env_state diff --git a/devctl/templates/docker/fastapi/Dockerfile.j2 b/devctl/templates/docker/fastapi/Dockerfile.j2 new file mode 100644 index 0000000..15bba2c --- /dev/null +++ b/devctl/templates/docker/fastapi/Dockerfile.j2 @@ -0,0 +1,15 @@ +# Production Dockerfile for FastAPI +# Generated by devctl + +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/test/test_fastapi.py b/test/test_fastapi.py new file mode 100644 index 0000000..df018a9 --- /dev/null +++ b/test/test_fastapi.py @@ -0,0 +1,36 @@ +import os +from unittest.mock import patch +from typer.testing import CliRunner +from devctl.main import app + +runner = CliRunner() + +def test_init_fastapi_calls_boilerplate(tmp_path): + """Ensure init fastapi calls the boilerplate generator.""" + with patch("devctl.commands.init.generate_fastapi_boilerplate") as mock_gen: + mock_gen.return_value = True + with patch("devctl.commands.init.check_tool"): + result = runner.invoke(app, ["init", "fastapi", "my-fast-app"]) + assert result.exit_code == 0 + mock_gen.assert_called_once_with("my-fast-app") + +def test_add_resource_fastapi(tmp_path): + """Ensure add resource works in a FastAPI project.""" + (tmp_path / "main.py").write_text("from fastapi import FastAPI", encoding="utf-8") + (tmp_path / "requirements.txt").write_text("fastapi", encoding="utf-8") + + with patch("devctl.commands.add.generate_fastapi_resource") as mock_gen: + os.chdir(tmp_path) + result = runner.invoke(app, ["add", "resource", "User"]) + assert result.exit_code == 0 + mock_gen.assert_called_once() + +def test_dockerize_fastapi(tmp_path): + """Ensure dockerize detects FastAPI and generates a Dockerfile.""" + (tmp_path / "main.py").write_text("from fastapi import FastAPI", encoding="utf-8") + (tmp_path / "requirements.txt").write_text("fastapi", encoding="utf-8") + + result = runner.invoke(app, ["dockerize", str(tmp_path)]) + assert result.exit_code == 0 + assert (tmp_path / "Dockerfile").exists() + assert '"uvicorn", "main:app"' in (tmp_path / "Dockerfile").read_text() From abb6f75ff6846b263a0b98739672ffc663828ed6 Mon Sep 17 00:00:00 2001 From: Youssef Fellah Date: Mon, 1 Jun 2026 09:49:57 +0100 Subject: [PATCH 09/18] feat: implement Django support across CLI --- devctl/commands/add.py | 10 +++ devctl/commands/init.py | 15 ++++ devctl/generators/django.py | 55 ++++++++++++++ devctl/generators/docker_scaffold.py | 9 +++ devctl/generators/scaffold_django.py | 77 ++++++++++++++++++++ devctl/orchestrator/runner.py | 22 ++++++ devctl/orchestrator/scanner.py | 16 +++- devctl/templates/docker/django/Dockerfile.j2 | 21 ++++++ test/test_django.py | 38 ++++++++++ 9 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 devctl/generators/django.py create mode 100644 devctl/generators/scaffold_django.py create mode 100644 devctl/templates/docker/django/Dockerfile.j2 create mode 100644 test/test_django.py diff --git a/devctl/commands/add.py b/devctl/commands/add.py index a0a2c1d..9f177f4 100644 --- a/devctl/commands/add.py +++ b/devctl/commands/add.py @@ -10,6 +10,7 @@ from devctl.generators.scaffold_angular import generate_angular_resource from devctl.generators.scaffold_spring import generate_spring_resource from devctl.generators.scaffold_vue import generate_vue_resource +from devctl.generators.scaffold_django import generate_django_resource from devctl.orchestrator.scanner import detect_environment app = typer.Typer(help="Adds resources to the current project (Scaffolding).") @@ -65,6 +66,15 @@ def resource( except Exception as e: typer.secho(f"āŒ Error during Vue generation: {e}", fg=typer.colors.RED) + # Check for Django project + if env_state.get("has_django"): + project_detected = True + typer.secho("šŸŽø Django project detected. Launching Django generator...", fg=typer.colors.GREEN) + try: + generate_django_resource(name, fields, root_path=".") + except Exception as e: + typer.secho(f"āŒ Error during Django generation: {e}", fg=typer.colors.RED) + # Error message only if NO project detected if not project_detected: typer.secho( diff --git a/devctl/commands/init.py b/devctl/commands/init.py index bc819fe..b6c6c95 100644 --- a/devctl/commands/init.py +++ b/devctl/commands/init.py @@ -11,6 +11,7 @@ # Spring generator from devctl.generators.spring import download_spring_boilerplate from devctl.generators.vue import generate_vue_boilerplate +from devctl.generators.django import generate_django_boilerplate from devctl.orchestrator.config_builder import generate_config from devctl.utils.dependencies import check_tool @@ -70,3 +71,17 @@ def init_vue(name: str): if success: typer.secho("\n✨ Vue.js project ready!", fg=typer.colors.GREEN) + + +@app.command("django") +def init_django(name: str): + """ + Initializes a new Django backend project. + """ + check_tool("python3", "initializing a Django project") + + typer.secho(f"šŸš€ Initializing Django project: '{name}'...", fg=typer.colors.CYAN) + success = generate_django_boilerplate(name) + + if success: + typer.secho("\n✨ Django project ready!", fg=typer.colors.GREEN) diff --git a/devctl/generators/django.py b/devctl/generators/django.py new file mode 100644 index 0000000..b70ae07 --- /dev/null +++ b/devctl/generators/django.py @@ -0,0 +1,55 @@ +""" +Generators for Django projects. +Includes boilerplate generation with Django and DRF. +""" + +import os +import subprocess +import typer + + +def generate_django_boilerplate(project_name: str) -> bool: + """ + Generates a new Django project. + """ + typer.secho(f"šŸ”„ Generating Django project '{project_name}'...", fg=typer.colors.CYAN) + safe_name = project_name.lower().replace("-", "_") # Django prefers underscores + project_path = os.path.join(os.getcwd(), project_name) + + try: + os.makedirs(project_path, exist_ok=True) + + # 1. Create requirements.txt + requirements = """django +djangorestframework +django-cors-headers +""" + with open(os.path.join(project_path, "requirements.txt"), "w") as f: + f.write(requirements) + + # 2. Create virtual environment + typer.secho("šŸ“¦ Creating virtual environment...", fg=typer.colors.CYAN) + subprocess.run(["python3", "-m", "venv", ".venv"], cwd=project_path, check=True) + + # 3. Install Django + typer.secho("ā³ Installing Django and DRF...", fg=typer.colors.CYAN) + pip_path = os.path.join(".venv", "bin", "pip") + subprocess.run([pip_path, "install", "django", "djangorestframework"], cwd=project_path, check=True, stdout=subprocess.DEVNULL) + + # 4. Start Project + typer.secho("šŸš€ Scaffolding Django project structure...", fg=typer.colors.CYAN) + django_admin = os.path.join(".venv", "bin", "django-admin") + subprocess.run([django_admin, "startproject", safe_name, "."], cwd=project_path, check=True) + + # 5. Create core app + python_path = os.path.join(".venv", "bin", "python") + subprocess.run([python_path, "manage.py", "startapp", "core"], cwd=project_path, check=True) + + typer.secho( + f"āœ… Django project '{project_name}' successfully generated!", fg=typer.colors.GREEN + ) + return True + + except Exception as e: + typer.secho(f"āŒ Django initialization failed: {e}", fg=typer.colors.RED) + return False diff --git a/devctl/generators/docker_scaffold.py b/devctl/generators/docker_scaffold.py index 826ceca..5d0d06c 100644 --- a/devctl/generators/docker_scaffold.py +++ b/devctl/generators/docker_scaffold.py @@ -105,6 +105,13 @@ def discover_docker_projects(root_path: Union[str, Path]) -> list[DockerProject] has_vite_config = {"vite.config.ts", "vite.config.js"} & filename_set if has_vite_config and "angular.json" not in filename_set: candidates.append(("vue", project_path)) + if "manage.py" in filename_set and "requirements.txt" in filename_set: + try: + reqs = (project_path / "requirements.txt").read_text() + if "django" in reqs.lower(): + candidates.append(("django", project_path)) + except Exception: + pass used_names: set[str] = set() projects: list[DockerProject] = [] @@ -163,6 +170,8 @@ def scaffold_docker_assets( def _dockerfile_content(env: Environment, project: DockerProject) -> str: if project.kind == "spring": return env.get_template("spring/Dockerfile.j2").render(project=project) + if project.kind == "django": + return env.get_template("django/Dockerfile.j2").render(project=project) return env.get_template("frontend/Dockerfile.j2").render(project=project) diff --git a/devctl/generators/scaffold_django.py b/devctl/generators/scaffold_django.py new file mode 100644 index 0000000..19eaeb2 --- /dev/null +++ b/devctl/generators/scaffold_django.py @@ -0,0 +1,77 @@ +""" +Django resource scaffolding generator. +Handles the creation of models, serializers, and views. +""" + +import os +import typer +from devctl.orchestrator.scanner import detect_environment + + +def generate_django_resource(resource_name: str, fields_str: str, root_path: str = "."): + """ + Scaffolds a Django resource. + """ + env_state = detect_environment(root_path) + + if not env_state["has_django"]: + typer.secho("āŒ Error: No Django project detected here.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + django_root = env_state["django_path"] + resource_lower = resource_name.lower() + entity_name = resource_name.capitalize() + + # Structure: core/models.py, core/serializers.py, core/views.py + # For simplicity, we inject into the 'core' app created during init + core_dir = os.path.join(django_root, "core") + if not os.path.exists(core_dir): + os.makedirs(core_dir, exist_ok=True) + + typer.secho(f"āš™ļø Generating Django resource '{entity_name}'...", fg=typer.colors.CYAN) + + # 1. Append Model + model_snippet = f""" +class {entity_name}(models.Model): + # Fields: {fields_str} + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{entity_name} {{self.id}}" +""" + with open(os.path.join(core_dir, "models.py"), "a") as f: + f.write(model_snippet) + + # 2. Append Serializer + serializer_path = os.path.join(core_dir, "serializers.py") + if not os.path.exists(serializer_path): + with open(serializer_path, "w") as f: + f.write("from rest_framework import serializers\nfrom .models import *\n") + + serializer_snippet = f""" +class {entity_name}Serializer(serializers.ModelSerializer): + class Meta: + model = {entity_name} + fields = '__all__' +""" + with open(serializer_path, "a") as f: + f.write(serializer_snippet) + + # 3. Append View + view_snippet = f""" +from rest_framework import viewsets +from .models import {entity_name} +from .serializers import {entity_name}Serializer + +class {entity_name}ViewSet(viewsets.ModelViewSet): + queryset = {entity_name}.objects.all() + serializer_class = {entity_name}Serializer +""" + with open(os.path.join(core_dir, "views.py"), "a") as f: + f.write(view_snippet) + + typer.secho(f"āœ… {entity_name} Django feature successfully generated!", fg=typer.colors.GREEN) + typer.echo(f" - Updated: core/models.py") + typer.echo(f" - Updated: core/serializers.py") + typer.echo(f" - Updated: core/views.py") diff --git a/devctl/orchestrator/runner.py b/devctl/orchestrator/runner.py index d0ee215..58eff8e 100644 --- a/devctl/orchestrator/runner.py +++ b/devctl/orchestrator/runner.py @@ -113,6 +113,28 @@ def signal_handler(sig, frame): t.start() active_threads.append(t) + # 4. Start Django Backends + django_apps = [p for p in projects if p.kind == "django"] + for p in django_apps: + typer.secho(f"šŸŽø Starting Django: {p.name}...", fg=typer.colors.GREEN) + + venv_python = os.path.join(str(p.path), ".venv", "bin", "python3") + if not os.path.exists(venv_python): + venv_python = "python3" + + proc = subprocess.Popen( + [venv_python, "manage.py", "runserver"], + cwd=str(p.path), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + ) + active_processes.append((p.name, proc)) + + t = threading.Thread(target=stream_logs, args=(p.name, proc, "green"), daemon=True) + t.start() + active_threads.append(t) + if not active_processes and not docker_composes: typer.secho("āš ļø No projects or databases detected to run.", fg=typer.colors.YELLOW) return diff --git a/devctl/orchestrator/scanner.py b/devctl/orchestrator/scanner.py index ecc4a5c..a09d934 100644 --- a/devctl/orchestrator/scanner.py +++ b/devctl/orchestrator/scanner.py @@ -1,6 +1,6 @@ """ Project scanner and environment detector. -Identifies Spring Boot, Angular, Vue.js, and Docker components in a directory tree. +Identifies Spring Boot, Angular, Vue.js, Django, and Docker components in a directory tree. """ import os @@ -20,12 +20,14 @@ def detect_environment(root_path: str = "."): "angular_path": None, "has_vue": False, "vue_path": None, + "has_django": False, + "django_path": None, "project_root": os.path.abspath(root_path), } for dirpath, _dirnames, filenames in os.walk(root_path): # Optimization: ignore heavy folders for an instant scan - if any(ignored in dirpath for ignored in ["node_modules", "target", ".git", ".angular"]): + if any(ignored in dirpath for ignored in ["node_modules", "target", ".git", ".angular", ".venv"]): continue if "docker-compose.yml" in filenames and not env_state["has_docker_compose"]: @@ -45,4 +47,14 @@ def detect_environment(root_path: str = "."): env_state["has_vue"] = True env_state["vue_path"] = dirpath + if "manage.py" in filenames and "requirements.txt" in filenames: + req_path = os.path.join(dirpath, "requirements.txt") + try: + with open(req_path, "r") as f: + if "django" in f.read().lower(): + env_state["has_django"] = True + env_state["django_path"] = dirpath + except Exception: + pass + return env_state diff --git a/devctl/templates/docker/django/Dockerfile.j2 b/devctl/templates/docker/django/Dockerfile.j2 new file mode 100644 index 0000000..4170620 --- /dev/null +++ b/devctl/templates/docker/django/Dockerfile.j2 @@ -0,0 +1,21 @@ +# Production Dockerfile for Django +# Generated by devctl + +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apk add --no-cache libpq-dev gcc + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +# Using gunicorn for production +RUN pip install gunicorn + +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "core.wsgi:application"] diff --git a/test/test_django.py b/test/test_django.py new file mode 100644 index 0000000..427134d --- /dev/null +++ b/test/test_django.py @@ -0,0 +1,38 @@ +import os +from unittest.mock import patch +from typer.testing import CliRunner +from devctl.main import app + +runner = CliRunner() + +def test_init_django_calls_boilerplate(tmp_path): + """Ensure init django calls the boilerplate generator.""" + with patch("devctl.commands.init.generate_django_boilerplate") as mock_gen: + mock_gen.return_value = True + with patch("devctl.commands.init.check_tool"): + result = runner.invoke(app, ["init", "django", "my-django-app"]) + assert result.exit_code == 0 + mock_gen.assert_called_once_with("my-django-app") + +def test_add_resource_django(tmp_path): + """Ensure add resource works in a Django project.""" + (tmp_path / "manage.py").write_text("import os", encoding="utf-8") + (tmp_path / "requirements.txt").write_text("django", encoding="utf-8") + (tmp_path / "core").mkdir() + + with patch("devctl.commands.add.generate_django_resource") as mock_gen: + os.chdir(tmp_path) + result = runner.invoke(app, ["add", "resource", "User"]) + assert result.exit_code == 0 + mock_gen.assert_called_once() + +def test_dockerize_django(tmp_path): + """Ensure dockerize detects Django and generates a Dockerfile.""" + (tmp_path / "manage.py").write_text("import os", encoding="utf-8") + (tmp_path / "requirements.txt").write_text("django", encoding="utf-8") + (tmp_path / "core").mkdir() + + result = runner.invoke(app, ["dockerize", str(tmp_path)]) + assert result.exit_code == 0 + assert (tmp_path / "Dockerfile").exists() + assert "gunicorn" in (tmp_path / "Dockerfile").read_text() From 7caa98a48d9ab5ff9636fca4b03361a5143f603a Mon Sep 17 00:00:00 2001 From: Youssef Fellah Date: Mon, 1 Jun 2026 09:57:19 +0100 Subject: [PATCH 10/18] feat: implement Svelte support across CLI --- devctl/commands/add.py | 10 +++ devctl/commands/init.py | 15 +++++ devctl/generators/docker_scaffold.py | 4 ++ devctl/generators/scaffold_svelte.py | 71 ++++++++++++++++++++ devctl/generators/svelte.py | 42 ++++++++++++ devctl/orchestrator/runner.py | 10 +-- devctl/orchestrator/scanner.py | 10 ++- devctl/templates/docker/svelte/Dockerfile.j2 | 24 +++++++ test/test_svelte.py | 35 ++++++++++ 9 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 devctl/generators/scaffold_svelte.py create mode 100644 devctl/generators/svelte.py create mode 100644 devctl/templates/docker/svelte/Dockerfile.j2 create mode 100644 test/test_svelte.py diff --git a/devctl/commands/add.py b/devctl/commands/add.py index a0a2c1d..0867002 100644 --- a/devctl/commands/add.py +++ b/devctl/commands/add.py @@ -10,6 +10,7 @@ from devctl.generators.scaffold_angular import generate_angular_resource from devctl.generators.scaffold_spring import generate_spring_resource from devctl.generators.scaffold_vue import generate_vue_resource +from devctl.generators.scaffold_svelte import generate_svelte_resource from devctl.orchestrator.scanner import detect_environment app = typer.Typer(help="Adds resources to the current project (Scaffolding).") @@ -65,6 +66,15 @@ def resource( except Exception as e: typer.secho(f"āŒ Error during Vue generation: {e}", fg=typer.colors.RED) + # Check for Svelte project + if env_state.get("has_svelte"): + project_detected = True + typer.secho("🧔 Svelte project detected. Launching Svelte generator...", fg=typer.colors.RED) + try: + generate_svelte_resource(name, fields, root_path=".") + except Exception as e: + typer.secho(f"āŒ Error during Svelte generation: {e}", fg=typer.colors.RED) + # Error message only if NO project detected if not project_detected: typer.secho( diff --git a/devctl/commands/init.py b/devctl/commands/init.py index bc819fe..2aaa78c 100644 --- a/devctl/commands/init.py +++ b/devctl/commands/init.py @@ -11,6 +11,7 @@ # Spring generator from devctl.generators.spring import download_spring_boilerplate from devctl.generators.vue import generate_vue_boilerplate +from devctl.generators.svelte import generate_svelte_boilerplate from devctl.orchestrator.config_builder import generate_config from devctl.utils.dependencies import check_tool @@ -70,3 +71,17 @@ def init_vue(name: str): if success: typer.secho("\n✨ Vue.js project ready!", fg=typer.colors.GREEN) + + +@app.command("svelte") +def init_svelte(name: str): + """ + Initializes a new SvelteKit frontend project. + """ + check_tool("npm", "initializing a Svelte project") + + typer.secho(f"šŸš€ Initializing Svelte project: '{name}'...", fg=typer.colors.CYAN) + success = generate_svelte_boilerplate(name) + + if success: + typer.secho("\n✨ Svelte project ready!", fg=typer.colors.GREEN) diff --git a/devctl/generators/docker_scaffold.py b/devctl/generators/docker_scaffold.py index 826ceca..910236d 100644 --- a/devctl/generators/docker_scaffold.py +++ b/devctl/generators/docker_scaffold.py @@ -105,6 +105,8 @@ def discover_docker_projects(root_path: Union[str, Path]) -> list[DockerProject] has_vite_config = {"vite.config.ts", "vite.config.js"} & filename_set if has_vite_config and "angular.json" not in filename_set: candidates.append(("vue", project_path)) + if "svelte.config.js" in filename_set: + candidates.append(("svelte", project_path)) used_names: set[str] = set() projects: list[DockerProject] = [] @@ -163,6 +165,8 @@ def scaffold_docker_assets( def _dockerfile_content(env: Environment, project: DockerProject) -> str: if project.kind == "spring": return env.get_template("spring/Dockerfile.j2").render(project=project) + if project.kind == "svelte": + return env.get_template("svelte/Dockerfile.j2").render(project=project) return env.get_template("frontend/Dockerfile.j2").render(project=project) diff --git a/devctl/generators/scaffold_svelte.py b/devctl/generators/scaffold_svelte.py new file mode 100644 index 0000000..61684e8 --- /dev/null +++ b/devctl/generators/scaffold_svelte.py @@ -0,0 +1,71 @@ +""" +Svelte resource scaffolding generator. +Handles the creation of components and routes. +""" + +import os +import typer +from devctl.orchestrator.scanner import detect_environment + + +def generate_svelte_resource(resource_name: str, fields_str: str, root_path: str = "."): + """ + Scaffolds a Svelte resource (Route, Component). + """ + env_state = detect_environment(root_path) + + if not env_state["has_svelte"]: + typer.secho("āŒ Error: No Svelte project detected here.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + svelte_root = env_state["svelte_path"] + resource_lower = resource_name.lower() + entity_name = resource_name.capitalize() + + # Structure: src/routes/resource-name/+page.svelte + routes_dir = os.path.join(svelte_root, "src", "routes", resource_lower) + components_dir = os.path.join(svelte_root, "src", "lib", "components") + + os.makedirs(routes_dir, exist_ok=True) + os.makedirs(components_dir, exist_ok=True) + + typer.secho(f"āš™ļø Generating Svelte resource '{entity_name}'...", fg=typer.colors.CYAN) + + # 1. Generate +page.svelte + page_content = f""" + +

{entity_name} Management

+ +<{entity_name}List /> +""" + with open(os.path.join(routes_dir, "+page.svelte"), "w") as f: + f.write(page_content) + + # 2. Generate Component + component_content = f""" + +
+

{entity_name} List

+

Generated by devctl

+
    +
  • Sample Item
  • +
+
+ + +""" + with open(os.path.join(components_dir, f"{entity_name}List.svelte"), "w") as f: + f.write(component_content) + + typer.secho(f"āœ… {entity_name} Svelte feature successfully generated!", fg=typer.colors.GREEN) + typer.echo(f" - Created: src/routes/{resource_lower}/+page.svelte") + typer.echo(f" - Created: src/lib/components/{entity_name}List.svelte") diff --git a/devctl/generators/svelte.py b/devctl/generators/svelte.py new file mode 100644 index 0000000..09fa4c6 --- /dev/null +++ b/devctl/generators/svelte.py @@ -0,0 +1,42 @@ +""" +Generators for Svelte projects. +Includes boilerplate generation via create-svelte. +""" + +import os +import subprocess +import typer + + +def generate_svelte_boilerplate(project_name: str) -> bool: + """ + Generates a new SvelteKit project using create-svelte via npx. + """ + typer.secho(f"šŸ”„ Generating Svelte project '{project_name}'...", fg=typer.colors.CYAN) + safe_name = project_name.lower().replace("_", "-") + + try: + typer.secho("šŸ“¦ Scaffolding SvelteKit project...", fg=typer.colors.CYAN) + # Using a non-interactive way to scaffold svelte + # We'll use the 'skeleton' template with TypeScript + subprocess.run( + ["npx", "sv", "create", safe_name, "--template", "skeleton", "--types", "typescript", "--no-install", "--no-git"], + check=True + ) + + project_full_path = os.path.join(os.getcwd(), safe_name) + + typer.secho("ā³ Installing npm dependencies...", fg=typer.colors.CYAN) + subprocess.run(["npm", "install"], cwd=project_full_path, check=True) + + typer.secho( + f"āœ… Svelte project '{safe_name}' successfully generated!", fg=typer.colors.GREEN + ) + return True + + except subprocess.CalledProcessError as e: + typer.secho(f"āŒ Svelte creation failed with code: {e.returncode}", fg=typer.colors.RED) + return False + except Exception as e: + typer.secho(f"āŒ Svelte initialization failed: {e}", fg=typer.colors.RED) + return False diff --git a/devctl/orchestrator/runner.py b/devctl/orchestrator/runner.py index d0ee215..60f22f4 100644 --- a/devctl/orchestrator/runner.py +++ b/devctl/orchestrator/runner.py @@ -91,14 +91,14 @@ def signal_handler(sig, frame): t.start() active_threads.append(t) - # 3. Start Frontends (Angular / Vue) - frontends = [p for p in projects if p.kind in ["angular", "vue"]] + # 3. Start Frontends (Angular / Vue / Svelte) + frontends = [p for p in projects if p.kind in ["angular", "vue", "svelte"]] for p in frontends: - color = "cyan" if p.kind == "angular" else "magenta" - icon = "šŸ…°ļø" if p.kind == "angular" else "🟢" + color = "cyan" if p.kind == "angular" else ("magenta" if p.kind == "vue" else "red") + icon = "šŸ…°ļø" if p.kind == "angular" else ("🟢" if p.kind == "vue" else "🧔") cmd = ["npx", "ng", "serve"] if p.kind == "angular" else ["npm", "run", "dev"] - typer.secho(f"{icon} Starting {p.kind.capitalize()}: {p.name}...", fg=typer.colors.CYAN if p.kind == "angular" else typer.colors.MAGENTA) + typer.secho(f"{icon} Starting {p.kind.capitalize()}: {p.name}...", fg=typer.colors.CYAN if p.kind == "angular" else (typer.colors.MAGENTA if p.kind == "vue" else typer.colors.RED)) proc = subprocess.Popen( cmd, diff --git a/devctl/orchestrator/scanner.py b/devctl/orchestrator/scanner.py index ecc4a5c..3767631 100644 --- a/devctl/orchestrator/scanner.py +++ b/devctl/orchestrator/scanner.py @@ -1,6 +1,6 @@ """ Project scanner and environment detector. -Identifies Spring Boot, Angular, Vue.js, and Docker components in a directory tree. +Identifies Spring Boot, Angular, Vue.js, Svelte, and Docker components in a directory tree. """ import os @@ -20,12 +20,14 @@ def detect_environment(root_path: str = "."): "angular_path": None, "has_vue": False, "vue_path": None, + "has_svelte": False, + "svelte_path": None, "project_root": os.path.abspath(root_path), } for dirpath, _dirnames, filenames in os.walk(root_path): # Optimization: ignore heavy folders for an instant scan - if any(ignored in dirpath for ignored in ["node_modules", "target", ".git", ".angular"]): + if any(ignored in dirpath for ignored in ["node_modules", "target", ".git", ".angular", ".svelte-kit"]): continue if "docker-compose.yml" in filenames and not env_state["has_docker_compose"]: @@ -45,4 +47,8 @@ def detect_environment(root_path: str = "."): env_state["has_vue"] = True env_state["vue_path"] = dirpath + if "svelte.config.js" in filenames and not env_state["has_svelte"]: + env_state["has_svelte"] = True + env_state["svelte_path"] = dirpath + return env_state diff --git a/devctl/templates/docker/svelte/Dockerfile.j2 b/devctl/templates/docker/svelte/Dockerfile.j2 new file mode 100644 index 0000000..d5e57be --- /dev/null +++ b/devctl/templates/docker/svelte/Dockerfile.j2 @@ -0,0 +1,24 @@ +# Production Dockerfile for SvelteKit +# Generated by devctl + +FROM node:{{ project.node_version }}-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . +RUN npm run build + +FROM node:{{ project.node_version }}-alpine + +WORKDIR /app + +COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/build ./build + +EXPOSE 3000 + +CMD ["node", "build"] diff --git a/test/test_svelte.py b/test/test_svelte.py new file mode 100644 index 0000000..f011297 --- /dev/null +++ b/test/test_svelte.py @@ -0,0 +1,35 @@ +import os +from unittest.mock import patch +from typer.testing import CliRunner +from devctl.main import app + +runner = CliRunner() + +def test_init_svelte_calls_boilerplate(tmp_path): + """Ensure init svelte calls the boilerplate generator.""" + with patch("devctl.commands.init.generate_svelte_boilerplate") as mock_gen: + mock_gen.return_value = True + with patch("devctl.commands.init.check_tool"): + result = runner.invoke(app, ["init", "svelte", "my-svelte-app"]) + assert result.exit_code == 0 + mock_gen.assert_called_once_with("my-svelte-app") + +def test_add_resource_svelte(tmp_path): + """Ensure add resource works in a Svelte project.""" + (tmp_path / "svelte.config.js").write_text("export default {}", encoding="utf-8") + + with patch("devctl.commands.add.generate_svelte_resource") as mock_gen: + os.chdir(tmp_path) + result = runner.invoke(app, ["add", "resource", "User"]) + assert result.exit_code == 0 + mock_gen.assert_called_once() + +def test_dockerize_svelte(tmp_path): + """Ensure dockerize detects Svelte and generates a Dockerfile.""" + (tmp_path / "svelte.config.js").write_text("export default {}", encoding="utf-8") + (tmp_path / "package.json").write_text('{"name": "svelte-app"}', encoding="utf-8") + + result = runner.invoke(app, ["dockerize", str(tmp_path)]) + assert result.exit_code == 0 + assert (tmp_path / "Dockerfile").exists() + assert 'CMD ["node", "build"]' in (tmp_path / "Dockerfile").read_text() From f7d8c30ff97efed1249a3fbc8b380b728fa90228 Mon Sep 17 00:00:00 2001 From: Youssef Fellah Date: Mon, 1 Jun 2026 10:01:55 +0100 Subject: [PATCH 11/18] feat: implement Go (Fiber) support across CLI --- devctl/commands/add.py | 10 ++++ devctl/commands/init.py | 15 ++++++ devctl/generators/docker_scaffold.py | 4 ++ devctl/generators/go_fiber.py | 57 +++++++++++++++++++++ devctl/generators/scaffold_go.py | 65 ++++++++++++++++++++++++ devctl/orchestrator/runner.py | 18 +++++++ devctl/orchestrator/scanner.py | 8 ++- devctl/templates/docker/go/Dockerfile.j2 | 24 +++++++++ test/test_go.py | 35 +++++++++++++ 9 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 devctl/generators/go_fiber.py create mode 100644 devctl/generators/scaffold_go.py create mode 100644 devctl/templates/docker/go/Dockerfile.j2 create mode 100644 test/test_go.py diff --git a/devctl/commands/add.py b/devctl/commands/add.py index a0a2c1d..e32b415 100644 --- a/devctl/commands/add.py +++ b/devctl/commands/add.py @@ -10,6 +10,7 @@ from devctl.generators.scaffold_angular import generate_angular_resource from devctl.generators.scaffold_spring import generate_spring_resource from devctl.generators.scaffold_vue import generate_vue_resource +from devctl.generators.scaffold_go import generate_go_resource from devctl.orchestrator.scanner import detect_environment app = typer.Typer(help="Adds resources to the current project (Scaffolding).") @@ -65,6 +66,15 @@ def resource( except Exception as e: typer.secho(f"āŒ Error during Vue generation: {e}", fg=typer.colors.RED) + # Check for Go project + if env_state.get("has_go"): + project_detected = True + typer.secho("🐹 Go project detected. Launching Go generator...", fg=typer.colors.CYAN) + try: + generate_go_resource(name, fields, root_path=".") + except Exception as e: + typer.secho(f"āŒ Error during Go generation: {e}", fg=typer.colors.RED) + # Error message only if NO project detected if not project_detected: typer.secho( diff --git a/devctl/commands/init.py b/devctl/commands/init.py index bc819fe..71f941d 100644 --- a/devctl/commands/init.py +++ b/devctl/commands/init.py @@ -11,6 +11,7 @@ # Spring generator from devctl.generators.spring import download_spring_boilerplate from devctl.generators.vue import generate_vue_boilerplate +from devctl.generators.go_fiber import generate_go_boilerplate from devctl.orchestrator.config_builder import generate_config from devctl.utils.dependencies import check_tool @@ -70,3 +71,17 @@ def init_vue(name: str): if success: typer.secho("\n✨ Vue.js project ready!", fg=typer.colors.GREEN) + + +@app.command("go") +def init_go(name: str): + """ + Initializes a new Go/Fiber backend project. + """ + check_tool("go", "initializing a Go project") + + typer.secho(f"šŸš€ Initializing Go project: '{name}'...", fg=typer.colors.CYAN) + success = generate_go_boilerplate(name) + + if success: + typer.secho("\n✨ Go project ready!", fg=typer.colors.GREEN) diff --git a/devctl/generators/docker_scaffold.py b/devctl/generators/docker_scaffold.py index 826ceca..e6cfe38 100644 --- a/devctl/generators/docker_scaffold.py +++ b/devctl/generators/docker_scaffold.py @@ -105,6 +105,8 @@ def discover_docker_projects(root_path: Union[str, Path]) -> list[DockerProject] has_vite_config = {"vite.config.ts", "vite.config.js"} & filename_set if has_vite_config and "angular.json" not in filename_set: candidates.append(("vue", project_path)) + if "go.mod" in filename_set: + candidates.append(("go", project_path)) used_names: set[str] = set() projects: list[DockerProject] = [] @@ -163,6 +165,8 @@ def scaffold_docker_assets( def _dockerfile_content(env: Environment, project: DockerProject) -> str: if project.kind == "spring": return env.get_template("spring/Dockerfile.j2").render(project=project) + if project.kind == "go": + return env.get_template("go/Dockerfile.j2").render(project=project) return env.get_template("frontend/Dockerfile.j2").render(project=project) diff --git a/devctl/generators/go_fiber.py b/devctl/generators/go_fiber.py new file mode 100644 index 0000000..3a74b8f --- /dev/null +++ b/devctl/generators/go_fiber.py @@ -0,0 +1,57 @@ +""" +Generators for Go (Fiber) projects. +Includes boilerplate generation with Fiber framework. +""" + +import os +import subprocess +import typer + + +def generate_go_boilerplate(project_name: str) -> bool: + """ + Generates a new Go + Fiber project. + """ + typer.secho(f"šŸ”„ Generating Go project '{project_name}'...", fg=typer.colors.CYAN) + project_path = os.path.join(os.getcwd(), project_name) + + try: + os.makedirs(project_path, exist_ok=True) + + # 1. Go mod init + typer.secho("šŸ“¦ Initializing Go module...", fg=typer.colors.CYAN) + subprocess.run(["go", "mod", "init", project_name], cwd=project_path, check=True) + + # 2. Install Fiber + typer.secho("ā³ Installing Fiber framework...", fg=typer.colors.CYAN) + subprocess.run(["go", "get", "github.com/gofiber/fiber/v2"], cwd=project_path, check=True, stdout=subprocess.DEVNULL) + + # 3. Create main.go + main_go = """package main + +import ( + "log" + "github.com/gofiber/fiber/v2" +) + +func main() { + app := fiber.New() + + app.Get("/", func(c *fiber.Ctx) error { + return c.SendString("Hello from devctl Go/Fiber!") + }) + + log.Fatal(app.Listen(":3000")) +} +""" + with open(os.path.join(project_path, "main.go"), "w") as f: + f.write(main_go) + + typer.secho( + f"āœ… Go project '{project_name}' successfully generated!", fg=typer.colors.GREEN + ) + return True + + except Exception as e: + typer.secho(f"āŒ Go initialization failed: {e}", fg=typer.colors.RED) + return False diff --git a/devctl/generators/scaffold_go.py b/devctl/generators/scaffold_go.py new file mode 100644 index 0000000..24012ab --- /dev/null +++ b/devctl/generators/scaffold_go.py @@ -0,0 +1,65 @@ +""" +Go (Fiber) resource scaffolding generator. +Handles the creation of handlers and models. +""" + +import os +import typer +from devctl.orchestrator.scanner import detect_environment + + +def generate_go_resource(resource_name: str, fields_str: str, root_path: str = "."): + """ + Scaffolds a Go resource. + """ + env_state = detect_environment(root_path) + + if not env_state["has_go"]: + typer.secho("āŒ Error: No Go project detected here.", fg=typer.colors.RED) + raise typer.Exit(code=1) + + go_root = env_state["go_path"] + resource_lower = resource_name.lower() + entity_name = resource_name.capitalize() + + # Structure: handlers/resource.go, models/resource.go + handlers_dir = os.path.join(go_root, "handlers") + models_dir = os.path.join(go_root, "models") + + os.makedirs(handlers_dir, exist_ok=True) + os.makedirs(models_dir, exist_ok=True) + + typer.secho(f"āš™ļø Generating Go resource '{entity_name}'...", fg=typer.colors.CYAN) + + # 1. Generate Model + model_content = f"""package models + +type {entity_name} struct {{ + ID uint `json:"id"` + // Fields: {fields_str} +}} +""" + with open(os.path.join(models_dir, f"{resource_lower}.go"), "w") as f: + f.write(model_content) + + # 2. Generate Handler + handler_content = f"""package handlers + +import ( + "github.com/gofiber/fiber/v2" +) + +func Get{entity_name}s(c *fiber.Ctx) error {{ + return c.JSON(fiber.Map{{"message": "Get all {resource_lower}s"}}) +}} + +func Create{entity_name}(c *fiber.Ctx) error {{ + return c.Status(201).JSON(fiber.Map{{"message": "{entity_name} created"}}) +}} +""" + with open(os.path.join(handlers_dir, f"{resource_lower}.go"), "w") as f: + f.write(handler_content) + + typer.secho(f"āœ… {entity_name} Go feature successfully generated!", fg=typer.colors.GREEN) + typer.echo(f" - Created: models/{resource_lower}.go") + typer.echo(f" - Created: handlers/{resource_lower}.go") diff --git a/devctl/orchestrator/runner.py b/devctl/orchestrator/runner.py index d0ee215..8e8deb1 100644 --- a/devctl/orchestrator/runner.py +++ b/devctl/orchestrator/runner.py @@ -113,6 +113,24 @@ def signal_handler(sig, frame): t.start() active_threads.append(t) + # 4. Start Go Backends + go_apps = [p for p in projects if p.kind == "go"] + for p in go_apps: + typer.secho(f"🐹 Starting Go: {p.name}...", fg=typer.colors.CYAN) + + proc = subprocess.Popen( + ["go", "run", "."], + cwd=str(p.path), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + ) + active_processes.append((p.name, proc)) + + t = threading.Thread(target=stream_logs, args=(p.name, proc, "cyan"), daemon=True) + t.start() + active_threads.append(t) + if not active_processes and not docker_composes: typer.secho("āš ļø No projects or databases detected to run.", fg=typer.colors.YELLOW) return diff --git a/devctl/orchestrator/scanner.py b/devctl/orchestrator/scanner.py index ecc4a5c..b1faeac 100644 --- a/devctl/orchestrator/scanner.py +++ b/devctl/orchestrator/scanner.py @@ -1,6 +1,6 @@ """ Project scanner and environment detector. -Identifies Spring Boot, Angular, Vue.js, and Docker components in a directory tree. +Identifies Spring Boot, Angular, Vue.js, Go (Fiber), and Docker components in a directory tree. """ import os @@ -20,6 +20,8 @@ def detect_environment(root_path: str = "."): "angular_path": None, "has_vue": False, "vue_path": None, + "has_go": False, + "go_path": None, "project_root": os.path.abspath(root_path), } @@ -45,4 +47,8 @@ def detect_environment(root_path: str = "."): env_state["has_vue"] = True env_state["vue_path"] = dirpath + if "go.mod" in filenames and not env_state["has_go"]: + env_state["has_go"] = True + env_state["go_path"] = dirpath + return env_state diff --git a/devctl/templates/docker/go/Dockerfile.j2 b/devctl/templates/docker/go/Dockerfile.j2 new file mode 100644 index 0000000..e3a806f --- /dev/null +++ b/devctl/templates/docker/go/Dockerfile.j2 @@ -0,0 +1,24 @@ +# Production Dockerfile for Go (Fiber) +# Generated by devctl + +# Stage 1: Build +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o main . + +# Stage 2: Run +FROM alpine:latest + +WORKDIR /app + +COPY --from=builder /app/main . + +EXPOSE 3000 + +CMD ["./main"] diff --git a/test/test_go.py b/test/test_go.py new file mode 100644 index 0000000..2d19433 --- /dev/null +++ b/test/test_go.py @@ -0,0 +1,35 @@ +import os +from unittest.mock import patch +from typer.testing import CliRunner +from devctl.main import app + +runner = CliRunner() + +def test_init_go_calls_boilerplate(tmp_path): + """Ensure init go calls the boilerplate generator.""" + with patch("devctl.commands.init.generate_go_boilerplate") as mock_gen: + mock_gen.return_value = True + with patch("devctl.commands.init.check_tool"): + result = runner.invoke(app, ["init", "go", "my-go-app"]) + assert result.exit_code == 0 + mock_gen.assert_called_once_with("my-go-app") + +def test_add_resource_go(tmp_path): + """Ensure add resource works in a Go project.""" + (tmp_path / "go.mod").write_text("module my-go-app", encoding="utf-8") + + with patch("devctl.commands.add.generate_go_resource") as mock_gen: + os.chdir(tmp_path) + result = runner.invoke(app, ["add", "resource", "User"]) + assert result.exit_code == 0 + mock_gen.assert_called_once() + +def test_dockerize_go(tmp_path): + """Ensure dockerize detects Go and generates a Dockerfile.""" + (tmp_path / "go.mod").write_text("module my-go-app", encoding="utf-8") + + result = runner.invoke(app, ["dockerize", str(tmp_path)]) + assert result.exit_code == 0 + assert (tmp_path / "Dockerfile").exists() + assert "golang" in (tmp_path / "Dockerfile").read_text() + assert 'CMD ["./main"]' in (tmp_path / "Dockerfile").read_text() From 8a106e5180413d3afef079ef2924f2130927b1ce Mon Sep 17 00:00:00 2001 From: Youssef Fellah Date: Mon, 1 Jun 2026 10:36:36 +0100 Subject: [PATCH 12/18] refactor: remove emojis and clean up CLI output for professional look --- devctl/commands/add.py | 16 ++++++++-------- devctl/commands/deploy.py | 10 +++++----- devctl/commands/docker.py | 4 ++-- devctl/commands/init.py | 14 +++++++------- devctl/commands/run.py | 15 +++++++++------ devctl/generators/angular.py | 18 +++++++++--------- devctl/generators/scaffold_angular.py | 8 ++++---- devctl/generators/scaffold_spring.py | 12 ++++++------ devctl/generators/scaffold_vue.py | 8 ++++---- devctl/generators/spring.py | 8 ++++---- devctl/generators/vue.py | 18 +++++++++--------- devctl/orchestrator/config_builder.py | 4 ++-- devctl/orchestrator/runner.py | 25 ++++++++++++------------- devctl/utils/dependencies.py | 4 ++-- 14 files changed, 83 insertions(+), 81 deletions(-) diff --git a/devctl/commands/add.py b/devctl/commands/add.py index a0a2c1d..35ccd6b 100644 --- a/devctl/commands/add.py +++ b/devctl/commands/add.py @@ -25,7 +25,7 @@ def resource( """ Scans the current folder and generates a suitable business architecture. """ - typer.secho("šŸ” Analyzing current context...", fg=typer.colors.CYAN) + typer.secho("Analyzing current context...", fg=typer.colors.CYAN) env_state = detect_environment(".") original_dir = os.getcwd() @@ -35,13 +35,13 @@ def resource( if env_state["has_spring"]: project_detected = True typer.secho( - "šŸƒ Spring Boot project detected. Launching Java generator...", fg=typer.colors.GREEN + "Spring Boot project detected. Launching Java generator...", fg=typer.colors.GREEN ) os.chdir(env_state["spring_path"]) try: generate_spring_resource(name, fields) except Exception as e: - typer.secho(f"āŒ Error during Spring generation: {e}", fg=typer.colors.RED) + typer.secho(f"Error: Spring generation failed: {e}", fg=typer.colors.RED) finally: os.chdir(original_dir) @@ -49,26 +49,26 @@ def resource( if env_state["has_angular"]: project_detected = True typer.secho( - "šŸ…°ļø Angular project detected. Launching TypeScript generator...", fg=typer.colors.CYAN + "Angular project detected. Launching TypeScript generator...", fg=typer.colors.CYAN ) try: generate_angular_resource(name, fields, root_path=".") except Exception as e: - typer.secho(f"āŒ Error during Angular generation: {e}", fg=typer.colors.RED) + typer.secho(f"Error: Angular generation failed: {e}", fg=typer.colors.RED) # Check for Vue.js project if env_state.get("has_vue"): project_detected = True - typer.secho("🟢 Vue.js project detected. Launching Vue generator...", fg=typer.colors.GREEN) + typer.secho("Vue.js project detected. Launching Vue generator...", fg=typer.colors.GREEN) try: generate_vue_resource(name, fields, root_path=".") except Exception as e: - typer.secho(f"āŒ Error during Vue generation: {e}", fg=typer.colors.RED) + typer.secho(f"Error: Vue generation failed: {e}", fg=typer.colors.RED) # Error message only if NO project detected if not project_detected: typer.secho( - "āŒ Unable to determine project type. " + "Error: Unable to determine project type. " "Please run from within a Spring, Angular, or Vue.js project directory.", fg=typer.colors.RED, ) diff --git a/devctl/commands/deploy.py b/devctl/commands/deploy.py index 4d3172d..4c53393 100644 --- a/devctl/commands/deploy.py +++ b/devctl/commands/deploy.py @@ -173,16 +173,16 @@ def deploy(path: Path = PATH_ARGUMENT): """ Generate a global docker-compose.yml by scanning subdirectories. """ - typer.secho(f"šŸš€ Preparing deployment for {path.resolve()}...", fg=typer.colors.CYAN, bold=True) + typer.secho(f"Preparing deployment for {path.resolve()}...", fg=typer.colors.CYAN, bold=True) try: projects = discover_docker_projects(path) except Exception as e: - typer.secho(f"āŒ Error scanning projects: {e}", fg=typer.colors.RED) + typer.secho(f"Error: Scanning failed: {e}", fg=typer.colors.RED) raise typer.Exit(1) from e if not projects: - typer.secho("āŒ No supported projects (Spring, Angular, Vue) found.", fg=typer.colors.RED) + typer.secho("Error: No supported projects (Spring, Angular, Vue) found.", fg=typer.colors.RED) raise typer.Exit(1) services_data = [] @@ -193,7 +193,7 @@ def deploy(path: Path = PATH_ARGUMENT): dockerfile_path = project.path / "Dockerfile" if not dockerfile_path.exists(): typer.secho( - f"āš ļø Warning: No Dockerfile found in {project.path}. " + f"Warning: No Dockerfile found in {project.path}. " "You may need to run 'devctl dockerize' first.", fg=typer.colors.YELLOW, ) @@ -231,5 +231,5 @@ def deploy(path: Path = PATH_ARGUMENT): output_path = path / "docker-compose.yml" output_path.write_text(output, encoding="utf-8") - typer.secho(f"āœ… Generated {output_path}", fg=typer.colors.GREEN, bold=True) + typer.secho(f"Generated {output_path}", fg=typer.colors.GREEN, bold=True) typer.echo(f"Summary: {len(services_data)} services, {len(databases)} databases.") diff --git a/devctl/commands/docker.py b/devctl/commands/docker.py index 989916b..8faa8f5 100644 --- a/devctl/commands/docker.py +++ b/devctl/commands/docker.py @@ -43,11 +43,11 @@ def dockerize( dry_run=dry_run, ) except DockerScaffoldError as exc: - typer.secho(f"āŒ {exc}", fg=typer.colors.RED) + typer.secho(f"Error: {exc}", fg=typer.colors.RED) raise typer.Exit(code=1) from exc mode = "Dry run" if dry_run else "Docker scaffolding" - typer.secho(f"🐳 {mode} complete for {result.root_path}", fg=typer.colors.CYAN, bold=True) + typer.secho(f"{mode} complete for {result.root_path}", fg=typer.colors.CYAN, bold=True) typer.echo("\nDetected services:") for service in result.services: diff --git a/devctl/commands/init.py b/devctl/commands/init.py index bc819fe..2e27152 100644 --- a/devctl/commands/init.py +++ b/devctl/commands/init.py @@ -31,16 +31,16 @@ def init_spring( # Strict input validation if db not in ["postgres", "mysql"]: - typer.secho(f"āŒ Error: Database '{db}' is not supported.", fg=typer.colors.RED) + typer.secho(f"Error: Database '{db}' is not supported.", fg=typer.colors.RED) raise typer.Exit(code=1) - typer.secho(f"šŸš€ Initializing Spring Boot project: '{name}'...", fg=typer.colors.CYAN) + typer.secho(f"Initializing Spring Boot project: '{name}'...", fg=typer.colors.CYAN) success_download = download_spring_boilerplate(name, db_type=db) if success_download: generate_config(name, db_type=db, custom_port=port) - typer.secho("\n✨ Spring project ready!", fg=typer.colors.GREEN) + typer.secho("\nSpring project ready!", fg=typer.colors.GREEN) @app.command("angular") @@ -51,11 +51,11 @@ def init_angular(name: str): check_tool("npm", "initializing an Angular project") check_tool("ng", "initializing an Angular project") - typer.secho(f"šŸš€ Initializing Angular project: '{name}'...", fg=typer.colors.CYAN) + typer.secho(f"Initializing Angular project: '{name}'...", fg=typer.colors.CYAN) success = generate_angular_boilerplate(name) if success: - typer.secho("\n✨ Angular project ready!", fg=typer.colors.GREEN) + typer.secho("\nAngular project ready!", fg=typer.colors.GREEN) @app.command("vue") @@ -65,8 +65,8 @@ def init_vue(name: str): """ check_tool("npm", "initializing a Vue.js project") - typer.secho(f"šŸš€ Initializing Vue.js project: '{name}'...", fg=typer.colors.CYAN) + typer.secho(f"Initializing Vue.js project: '{name}'...", fg=typer.colors.CYAN) success = generate_vue_boilerplate(name) if success: - typer.secho("\n✨ Vue.js project ready!", fg=typer.colors.GREEN) + typer.secho("\nVue.js project ready!", fg=typer.colors.GREEN) diff --git a/devctl/commands/run.py b/devctl/commands/run.py index 3e8bcbc..b02f855 100644 --- a/devctl/commands/run.py +++ b/devctl/commands/run.py @@ -21,7 +21,7 @@ def run_env(ctx: typer.Context): if ctx.invoked_subcommand is not None: return - typer.secho("šŸ” Analyzing the current directory tree...", fg=typer.colors.CYAN) + typer.secho("Analyzing the current directory tree...", fg=typer.colors.CYAN) projects = discover_docker_projects(".") @@ -47,13 +47,16 @@ def run_env(ctx: typer.Context): check_tool("npm", "running the frontend project") # Visual summary of detection for the user - typer.echo(f" - Docker Compose ({len(docker_composes)}) : {'āœ…' if has_docker else 'āŒ'}") - typer.echo(f" - Spring Boot ({sum(1 for p in projects if p.kind == 'spring')}) : {'āœ…' if has_spring else 'āŒ'}") - typer.echo(f" - Angular Frontend ({sum(1 for p in projects if p.kind == 'angular')}) : {'āœ…' if has_angular else 'āŒ'}") - typer.echo(f" - Vue.js Frontend ({sum(1 for p in projects if p.kind == 'vue')}) : {'āœ…' if has_vue else 'āŒ'}") + def get_status(condition: bool): + return typer.style("FOUND", fg=typer.colors.GREEN) if condition else typer.style("MISSING", fg=typer.colors.RED) + + typer.echo(f" - Docker Compose ({len(docker_composes)}) : {get_status(has_docker)}") + typer.echo(f" - Spring Boot ({sum(1 for p in projects if p.kind == 'spring')}) : {get_status(has_spring)}") + typer.echo(f" - Angular Frontend ({sum(1 for p in projects if p.kind == 'angular')}) : {get_status(has_angular)}") + typer.echo(f" - Vue.js Frontend ({sum(1 for p in projects if p.kind == 'vue')}) : {get_status(has_vue)}") if not projects and not docker_composes: - typer.secho("\nāŒ No valid development environment detected here.", fg=typer.colors.RED) + typer.secho("\nError: No valid development environment detected here.", fg=typer.colors.RED) raise typer.Exit(code=1) # Transfer control to the system orchestration layer diff --git a/devctl/generators/angular.py b/devctl/generators/angular.py index 9cf0a46..531cc10 100644 --- a/devctl/generators/angular.py +++ b/devctl/generators/angular.py @@ -15,7 +15,7 @@ def setup_angular_environments(project_path: str): """ Generates environments and proxy for an Angular project. """ - typer.secho("āš™ļø Configuring Proxy and Environments...", fg=typer.colors.CYAN) + typer.secho("Configuring Proxy and Environments...", fg=typer.colors.CYAN) # 1. Target paths src_dir = os.path.join(project_path, "src") @@ -39,7 +39,7 @@ def setup_angular_environments(project_path: str): with open(target_path, "w", encoding="utf-8") as f: f.write(content) except Exception as e: - typer.secho(f"āš ļø Error generating {tpl_name}: {e}", fg=typer.colors.YELLOW) + typer.secho(f"Warning: Failed to generate {tpl_name}: {e}", fg=typer.colors.YELLOW) # 3. Modify angular.json to enable the proxy angular_json_path = os.path.join(project_path, "angular.json") @@ -66,7 +66,7 @@ def setup_angular_environments(project_path: str): typer.echo(" - angular.json updated with proxyConfig.") except Exception as e: typer.secho( - f"āš ļø Unable to modify angular.json automatically: {e}", + f"Warning: Unable to modify angular.json automatically: {e}", fg=typer.colors.YELLOW, ) @@ -75,7 +75,7 @@ def generate_angular_boilerplate(project_name: str) -> bool: """ Generates an Angular project via the native CLI (@angular/cli) and configures it. """ - typer.secho(f"šŸ”„ Generating Angular frontend '{project_name}'...", fg=typer.colors.CYAN) + typer.secho(f"Generating Angular frontend '{project_name}'...", fg=typer.colors.CYAN) safe_name = project_name.lower().replace("_", "-") @@ -83,19 +83,19 @@ def generate_angular_boilerplate(project_name: str) -> bool: subprocess.run(["ng", "version"], capture_output=True, check=True) except FileNotFoundError: typer.secho( - "āŒ Error: Angular CLI ('ng') not found on your system.", + "Error: Angular CLI ('ng') not found on your system.", fg=typer.colors.RED, ) return False except subprocess.CalledProcessError: - typer.secho("āŒ Error: Angular CLI is installed but not responding.", fg=typer.colors.RED) + typer.secho("Error: Angular CLI is installed but not responding.", fg=typer.colors.RED) return False try: command = ["ng", "new", safe_name, "--routing=true", "--style=scss", "--skip-git=true"] typer.secho( - "šŸ“¦ Downloading npm packages... (This may take 1-2 minutes)", fg=typer.colors.CYAN + "Downloading npm packages... (This may take 1-2 minutes)", fg=typer.colors.CYAN ) subprocess.run(command, check=True) @@ -104,11 +104,11 @@ def generate_angular_boilerplate(project_name: str) -> bool: setup_angular_environments(project_full_path) typer.secho( - f"āœ… Frontend '{safe_name}' successfully generated and configured!", + f"Frontend '{safe_name}' successfully generated and configured!", fg=typer.colors.GREEN, ) return True except subprocess.CalledProcessError as e: - typer.secho(f"āŒ Angular process failed with code: {e.returncode}", fg=typer.colors.RED) + typer.secho(f"Error: Angular process failed with code: {e.returncode}", fg=typer.colors.RED) return False diff --git a/devctl/generators/scaffold_angular.py b/devctl/generators/scaffold_angular.py index 06f4255..9901f3f 100644 --- a/devctl/generators/scaffold_angular.py +++ b/devctl/generators/scaffold_angular.py @@ -43,7 +43,7 @@ def generate_angular_resource(resource_name: str, fields_str: str, root_path: st env_state = detect_environment(root_path) if not env_state["has_angular"]: - typer.secho("āŒ Error: No Angular project detected here.", fg=typer.colors.RED) + typer.secho("Error: No Angular project detected here.", fg=typer.colors.RED) raise typer.Exit(code=1) angular_root = env_state["angular_path"] @@ -120,7 +120,7 @@ def generate_angular_resource(resource_name: str, fields_str: str, root_path: st templates_dir = os.path.join(os.path.dirname(__file__), "..", "templates", "angular", "feature") env = Environment(loader=FileSystemLoader(templates_dir)) - typer.secho(f"āš™ļø Generating Angular feature '{entity_name}'...", fg=typer.colors.CYAN) + typer.secho(f"Generating Angular feature '{entity_name}'...", fg=typer.colors.CYAN) # Template data context = { @@ -155,6 +155,6 @@ def generate_angular_resource(resource_name: str, fields_str: str, root_path: st f.write("") typer.echo(f" - Created (empty): {comp['dir']}/{target_file_name}") else: - typer.secho(f"āš ļø Error on {comp['template']}: {e}", fg=typer.colors.YELLOW) + typer.secho(f"Warning: Failed to generate {comp['template']}: {e}", fg=typer.colors.YELLOW) - typer.secho(f"āœ… {entity_name} feature successfully generated!", fg=typer.colors.GREEN) + typer.secho(f"{entity_name} feature successfully generated!", fg=typer.colors.GREEN) diff --git a/devctl/generators/scaffold_spring.py b/devctl/generators/scaffold_spring.py index 92fb5ed..39ce0b3 100644 --- a/devctl/generators/scaffold_spring.py +++ b/devctl/generators/scaffold_spring.py @@ -68,7 +68,7 @@ def generate_spring_resource(resource_name: str, fields_str: str): if not base_package: typer.secho( - "āŒ Error: Unable to find a valid Spring Boot project here.", + "Error: Unable to find a valid Spring Boot project here.", fg=typer.colors.RED, ) raise typer.Exit(code=1) @@ -91,7 +91,7 @@ def generate_spring_resource(resource_name: str, fields_str: str): env = Environment(loader=FileSystemLoader(templates_dir)) typer.secho( - f"āš™ļø Generating Spring resource '{entity_name}' (with MapStruct & DTOs)...", + f"Generating Spring resource '{entity_name}' (with MapStruct & DTOs)...", fg=typer.colors.CYAN, ) @@ -121,7 +121,7 @@ def generate_spring_resource(resource_name: str, fields_str: str): typer.echo(f" - Created: {comp['dir']}/{target_file_name}") - typer.secho(f"āœ… {entity_name} architecture successfully generated!", fg=typer.colors.GREEN) + typer.secho(f"{entity_name} architecture successfully generated!", fg=typer.colors.GREEN) def generate_spring_security(_root_path: str = "."): @@ -133,7 +133,7 @@ def generate_spring_security(_root_path: str = "."): if not base_package: typer.secho( - "āŒ Error: Unable to locate Java package for security.", + "Error: Unable to locate Java package for security.", fg=typer.colors.RED, ) return @@ -153,7 +153,7 @@ def generate_spring_security(_root_path: str = "."): "ApplicationConfig.java", ] - typer.secho(f"šŸ›”ļø Injecting JWT security into {base_package}.config...", fg=typer.colors.CYAN) + typer.secho(f"Injecting JWT security into {base_package}.config...", fg=typer.colors.CYAN) for filename in security_files: template = env.get_template(f"{filename}.j2") @@ -163,4 +163,4 @@ def generate_spring_security(_root_path: str = "."): f.write(content) typer.echo(f" - Created: config/{filename}") - typer.secho("āœ… Security initialized successfully!", fg=typer.colors.GREEN) + typer.secho("Security initialized successfully!", fg=typer.colors.GREEN) diff --git a/devctl/generators/scaffold_vue.py b/devctl/generators/scaffold_vue.py index 414c071..98db469 100644 --- a/devctl/generators/scaffold_vue.py +++ b/devctl/generators/scaffold_vue.py @@ -19,7 +19,7 @@ def generate_vue_resource(resource_name: str, fields_str: str, root_path: str = env_state = detect_environment(root_path) if not env_state["has_vue"]: - typer.secho("āŒ Error: No Vue.js project detected here.", fg=typer.colors.RED) + typer.secho("Error: No Vue.js project detected here.", fg=typer.colors.RED) raise typer.Exit(code=1) vue_root = env_state["vue_path"] @@ -74,7 +74,7 @@ def generate_vue_resource(resource_name: str, fields_str: str, root_path: str = autoescape=select_autoescape(["html", "xml"]), ) - typer.secho(f"āš™ļø Generating Vue.js feature '{entity_name}'...", fg=typer.colors.CYAN) + typer.secho(f"Generating Vue.js feature '{entity_name}'...", fg=typer.colors.CYAN) # Template data context = { @@ -106,6 +106,6 @@ def generate_vue_resource(resource_name: str, fields_str: str, root_path: str = typer.echo(f" - Created: {display_dir}/{target_file_name}") except Exception as e: - typer.secho(f"āš ļø Error on {comp['template']}: {e}", fg=typer.colors.YELLOW) + typer.secho(f"Warning: Error on {comp['template']}: {e}", fg=typer.colors.YELLOW) - typer.secho(f"āœ… {entity_name} Vue feature successfully generated!", fg=typer.colors.GREEN) + typer.secho(f"{entity_name} Vue feature successfully generated!", fg=typer.colors.GREEN) diff --git a/devctl/generators/spring.py b/devctl/generators/spring.py index 39cdade..be8e17b 100644 --- a/devctl/generators/spring.py +++ b/devctl/generators/spring.py @@ -146,7 +146,7 @@ def download_spring_boilerplate(project_name: str, db_type: str = "postgres"): Automatically makes the Maven wrapper executable on Unix. """ typer.secho( - f"šŸ”„ Generating Spring Boot backend '{project_name}' (Driver: {db_type})...", + f"Generating Spring Boot backend '{project_name}' (Driver: {db_type})...", fg=typer.colors.CYAN, ) @@ -188,7 +188,7 @@ def download_spring_boilerplate(project_name: str, db_type: str = "postgres"): response = requests.get(url, params=params) if response.status_code != 200: - typer.secho(f"āŒ API Rejected: {response.text}", fg=typer.colors.RED) + typer.secho(f"Error: API Rejected: {response.text}", fg=typer.colors.RED) return False z = zipfile.ZipFile(io.BytesIO(response.content)) @@ -211,11 +211,11 @@ def download_spring_boilerplate(project_name: str, db_type: str = "postgres"): os.chdir("..") typer.secho( - f"āœ… Backend successfully generated in folder ./{project_name}!", + f"Backend successfully generated in folder ./{project_name}!", fg=typer.colors.GREEN, ) return True except requests.exceptions.RequestException as e: - typer.secho(f"āŒ Network error contacting API: {e}", fg=typer.colors.RED) + typer.secho(f"Error: Network error contacting API: {e}", fg=typer.colors.RED) return False diff --git a/devctl/generators/vue.py b/devctl/generators/vue.py index 439d581..a04893b 100644 --- a/devctl/generators/vue.py +++ b/devctl/generators/vue.py @@ -14,7 +14,7 @@ def setup_vue_proxy(project_path: str): """ Replaces the default vite.config.ts with our version including the proxy. """ - typer.secho("āš™ļø Configuring Vite Proxy for Spring Boot...", fg=typer.colors.CYAN) + typer.secho("Configuring Vite Proxy for Spring Boot...", fg=typer.colors.CYAN) templates_dir = os.path.join(os.path.dirname(__file__), "..", "templates", "vue", "config") env = Environment(loader=FileSystemLoader(templates_dir)) @@ -28,14 +28,14 @@ def setup_vue_proxy(project_path: str): f.write(content) typer.echo(" - vite.config.ts updated with /api proxy.") except Exception as e: - typer.secho(f"āš ļø Error configuring proxy: {e}", fg=typer.colors.YELLOW) + typer.secho(f"Warning: Error configuring proxy: {e}", fg=typer.colors.YELLOW) def setup_vue_router(project_path: str): """ Installs vue-router and configures the base architecture (main.ts, router, App.vue). """ - typer.secho("šŸ›£ļø Installing and configuring vue-router...", fg=typer.colors.CYAN) + typer.secho("Installing and configuring vue-router...", fg=typer.colors.CYAN) try: # 1. NPM package installation @@ -69,25 +69,25 @@ def setup_vue_router(project_path: str): typer.echo(" - Navigation architecture ready.") except Exception as e: - typer.secho(f"āš ļø Error configuring router: {e}", fg=typer.colors.YELLOW) + typer.secho(f"Warning: Error configuring router: {e}", fg=typer.colors.YELLOW) def generate_vue_boilerplate(project_name: str) -> bool: """ Generates a Vue 3 + TypeScript project via Vite. """ - typer.secho(f"šŸ”„ Generating Vue.js frontend '{project_name}' via Vite...", fg=typer.colors.CYAN) + typer.secho(f"Generating Vue.js frontend '{project_name}' via Vite...", fg=typer.colors.CYAN) safe_name = project_name.lower().replace("_", "-") try: - typer.secho("šŸ“¦ Scaffolding Vite project...", fg=typer.colors.CYAN) + typer.secho("Scaffolding Vite project...", fg=typer.colors.CYAN) subprocess.run( ["npm", "create", "vite@latest", safe_name, "--", "--template", "vue-ts"], check=True ) project_full_path = os.path.join(os.getcwd(), safe_name) - typer.secho("ā³ Installing npm dependencies...", fg=typer.colors.CYAN) + typer.secho("Installing npm dependencies...", fg=typer.colors.CYAN) subprocess.run(["npm", "install"], cwd=project_full_path, check=True) # --- CALL OUR TWO CONFIGURATORS --- @@ -96,10 +96,10 @@ def generate_vue_boilerplate(project_name: str) -> bool: # ---------------------------------------- typer.secho( - f"āœ… Vue.js frontend '{safe_name}' successfully generated!", fg=typer.colors.GREEN + f"Vue.js frontend '{safe_name}' successfully generated!", fg=typer.colors.GREEN ) return True except subprocess.CalledProcessError as e: - typer.secho(f"āŒ Vue/Vite process failed with code: {e.returncode}", fg=typer.colors.RED) + typer.secho(f"Error: Vue/Vite process failed with code: {e.returncode}", fg=typer.colors.RED) return False diff --git a/devctl/orchestrator/config_builder.py b/devctl/orchestrator/config_builder.py index c148225..0322085 100644 --- a/devctl/orchestrator/config_builder.py +++ b/devctl/orchestrator/config_builder.py @@ -48,10 +48,10 @@ def generate_config(project_name: str, db_type: str = "postgres", custom_port: i f.write(props_template.render(context)) typer.secho( - f"āš™ļø Dynamic configuration ({db_type} on port {db_port}) generated.", + f"Dynamic configuration ({db_type} on port {db_port}) generated.", fg=typer.colors.GREEN, ) return True except Exception as e: - typer.secho(f"āŒ Configuration error: {e}", fg=typer.colors.RED) + typer.secho(f"Error: Configuration failed: {e}", fg=typer.colors.RED) return False diff --git a/devctl/orchestrator/runner.py b/devctl/orchestrator/runner.py index d0ee215..665baf0 100644 --- a/devctl/orchestrator/runner.py +++ b/devctl/orchestrator/runner.py @@ -51,7 +51,7 @@ def launch_dev_environment(projects: List[DockerProject], docker_composes: List[ global active_processes def signal_handler(sig, frame): - typer.echo("\nšŸ›‘ Shutdown requested. Cleaning up...") + typer.echo("\nShutdown requested. Cleaning up...") cleanup_and_exit(docker_composes) signal.signal(signal.SIGINT, signal_handler) @@ -61,22 +61,22 @@ def signal_handler(sig, frame): # 1. Start Databases if docker_composes: if not is_docker_running(): - typer.secho("āŒ Error: Docker service is not running.", fg=typer.colors.RED) + typer.secho("Error: Docker service is not running.", fg=typer.colors.RED) sys.exit(1) for compose_path in docker_composes: - typer.secho(f"🐳 Starting Docker Compose in {compose_path}...", fg=typer.colors.CYAN) + typer.secho(f"Starting Docker Compose in {compose_path}...", fg=typer.colors.CYAN) subprocess.run( ["docker", "compose", "up", "-d"], cwd=str(compose_path), check=True ) - typer.echo("ā³ Waiting 5s for databases to initialize...") + typer.echo("Waiting 5s for databases to initialize...") time.sleep(5) # 2. Start Backends (Spring Boot) backends = [p for p in projects if p.kind == "spring"] for p in backends: - typer.secho(f"šŸƒ Starting Spring Boot: {p.name}...", fg=typer.colors.GREEN) + typer.secho(f"Starting Spring Boot: {p.name}...", fg=typer.colors.GREEN) proc = subprocess.Popen( ["./mvnw", "spring-boot:run"], @@ -95,10 +95,9 @@ def signal_handler(sig, frame): frontends = [p for p in projects if p.kind in ["angular", "vue"]] for p in frontends: color = "cyan" if p.kind == "angular" else "magenta" - icon = "šŸ…°ļø" if p.kind == "angular" else "🟢" cmd = ["npx", "ng", "serve"] if p.kind == "angular" else ["npm", "run", "dev"] - typer.secho(f"{icon} Starting {p.kind.capitalize()}: {p.name}...", fg=typer.colors.CYAN if p.kind == "angular" else typer.colors.MAGENTA) + typer.secho(f"Starting {p.kind.capitalize()}: {p.name}...", fg=typer.colors.CYAN if p.kind == "angular" else typer.colors.MAGENTA) proc = subprocess.Popen( cmd, @@ -114,11 +113,11 @@ def signal_handler(sig, frame): active_threads.append(t) if not active_processes and not docker_composes: - typer.secho("āš ļø No projects or databases detected to run.", fg=typer.colors.YELLOW) + typer.secho("Warning: No projects or databases detected to run.", fg=typer.colors.YELLOW) return typer.secho( - "\n✨ Development environment active! Press Ctrl+C to stop everything gracefully.\n", + "\nDevelopment environment active! Press Ctrl+C to stop everything gracefully.\n", fg=typer.colors.GREEN, bold=True, ) @@ -129,11 +128,11 @@ def signal_handler(sig, frame): # Check if any process has died unexpectedly for name, proc in active_processes: if proc.poll() is not None: - typer.secho(f"āš ļø Process {name} exited with code {proc.returncode}", fg=typer.colors.RED) + typer.secho(f"Warning: Process {name} exited with code {proc.returncode}", fg=typer.colors.RED) active_processes.remove((name, proc)) except Exception as e: - typer.secho(f"āŒ A system error occurred: {e}", fg=typer.colors.RED) + typer.secho(f"Error: A system error occurred: {e}", fg=typer.colors.RED) cleanup_and_exit(docker_composes) @@ -159,7 +158,7 @@ def cleanup_and_exit(docker_composes: List[Path]): stderr=subprocess.DEVNULL, ) except Exception: - typer.secho(f"āš ļø Warning: Docker cleanup failed for {compose_path}", fg=typer.colors.RED) + typer.secho(f"Warning: Docker cleanup failed for {compose_path}", fg=typer.colors.RED) - typer.secho("āœ… Cleanup finished. Environment is clean.", fg=typer.colors.GREEN) + typer.secho("Cleanup finished. Environment is clean.", fg=typer.colors.GREEN) sys.exit(0) diff --git a/devctl/utils/dependencies.py b/devctl/utils/dependencies.py index e2d14a1..132b690 100644 --- a/devctl/utils/dependencies.py +++ b/devctl/utils/dependencies.py @@ -15,7 +15,7 @@ def check_tool(tool_name: str, required_for: str = "this operation"): """ if shutil.which(tool_name) is None: typer.secho( - f"\nāŒ Error: '{tool_name}' is not installed or not in your PATH.", + f"\nError: '{tool_name}' is not installed or not in your PATH.", fg=typer.colors.RED, bold=True, ) @@ -31,6 +31,6 @@ def check_tool(tool_name: str, required_for: str = "this operation"): } if tool_name in hints: - typer.echo(f"šŸ’” Hint: {hints[tool_name]}") + typer.echo(f"Hint: {hints[tool_name]}") raise typer.Exit(code=1) From 379e9ce33e309dd7bbf13563dd8853ac5a08ff70 Mon Sep 17 00:00:00 2001 From: Youssef Fellah Date: Mon, 1 Jun 2026 10:57:06 +0100 Subject: [PATCH 13/18] refactor: finalize professional output and merge all stacks --- devctl/commands/run.py | 15 +++++++-------- devctl/generators/docker_scaffold.py | 8 ++++++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/devctl/commands/run.py b/devctl/commands/run.py index b02f855..c5d43e6 100644 --- a/devctl/commands/run.py +++ b/devctl/commands/run.py @@ -40,20 +40,19 @@ def run_env(ctx: typer.Context): if has_docker: check_tool("docker", "running the database environment") - if has_spring: - check_tool("java", "running the Spring Boot backend") - - if has_angular or has_vue: - check_tool("npm", "running the frontend project") + # Group counts + counts = {} + for p in projects: + counts[p.kind] = counts.get(p.kind, 0) + 1 # Visual summary of detection for the user def get_status(condition: bool): return typer.style("FOUND", fg=typer.colors.GREEN) if condition else typer.style("MISSING", fg=typer.colors.RED) typer.echo(f" - Docker Compose ({len(docker_composes)}) : {get_status(has_docker)}") - typer.echo(f" - Spring Boot ({sum(1 for p in projects if p.kind == 'spring')}) : {get_status(has_spring)}") - typer.echo(f" - Angular Frontend ({sum(1 for p in projects if p.kind == 'angular')}) : {get_status(has_angular)}") - typer.echo(f" - Vue.js Frontend ({sum(1 for p in projects if p.kind == 'vue')}) : {get_status(has_vue)}") + + for kind in sorted(counts.keys()): + typer.echo(f" - {kind.capitalize()} ({counts[kind]}) : {get_status(True)}") if not projects and not docker_composes: typer.secho("\nError: No valid development environment detected here.", fg=typer.colors.RED) diff --git a/devctl/generators/docker_scaffold.py b/devctl/generators/docker_scaffold.py index e193604..26d7901 100644 --- a/devctl/generators/docker_scaffold.py +++ b/devctl/generators/docker_scaffold.py @@ -144,6 +144,14 @@ def discover_docker_projects(root_path: Union[str, Path]) -> list[DockerProject] candidates.append(("django", project_path)) except Exception: pass + + if "manage.py" in filename_set and "requirements.txt" in filename_set: + try: + reqs = (project_path / "requirements.txt").read_text() + if "django" in reqs.lower(): + candidates.append(("django", project_path)) + except Exception: + pass if "go.mod" in filename_set: candidates.append(("go", project_path)) From 26dfafcb9bac545745f956d05d99370d5d9cd6c0 Mon Sep 17 00:00:00 2001 From: Youssef Fellah Date: Mon, 1 Jun 2026 11:06:51 +0100 Subject: [PATCH 14/18] test(qa): add full test suite for all commands and generators --- test/test_add.py | 32 +++++++++++++++++++++++++++++++ test/test_init.py | 28 +++++++++++++++++++++++++++ test/test_runner.py | 46 +++++++++++++++++++++++++++++++++++++++++++++ test/test_utils.py | 17 +++++++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 test/test_add.py create mode 100644 test/test_init.py create mode 100644 test/test_runner.py create mode 100644 test/test_utils.py diff --git a/test/test_add.py b/test/test_add.py new file mode 100644 index 0000000..22c7ef4 --- /dev/null +++ b/test/test_add.py @@ -0,0 +1,32 @@ +from unittest.mock import patch +from typer.testing import CliRunner +from devctl.main import app +import os + +runner = CliRunner() + +def test_add_resource_spring(tmp_path): + with patch("devctl.commands.add.detect_environment") as mock_detect: + mock_detect.return_value = { + "has_spring": True, + "spring_path": str(tmp_path), + "has_angular": False + } + with patch("devctl.commands.add.generate_spring_resource") as mock_gen: + # Create a mock pom.xml so it looks like a spring project + (tmp_path / "pom.xml").write_text("") + result = runner.invoke(app, ["add", "resource", "Product"]) + assert result.exit_code == 0 + mock_gen.assert_called_once() + +def test_add_resource_angular(tmp_path): + with patch("devctl.commands.add.detect_environment") as mock_detect: + mock_detect.return_value = { + "has_spring": False, + "has_angular": True, + "angular_path": str(tmp_path) + } + with patch("devctl.commands.add.generate_angular_resource") as mock_gen: + result = runner.invoke(app, ["add", "resource", "User"]) + assert result.exit_code == 0 + mock_gen.assert_called_once() diff --git a/test/test_init.py b/test/test_init.py new file mode 100644 index 0000000..8647a35 --- /dev/null +++ b/test/test_init.py @@ -0,0 +1,28 @@ +from unittest.mock import patch +from typer.testing import CliRunner +from devctl.main import app + +runner = CliRunner() + +def test_init_spring_success(tmp_path): + with patch("devctl.commands.init.download_spring_boilerplate", return_value=True) as mock_dl: + with patch("devctl.commands.init.generate_config") as mock_cfg: + with patch("devctl.commands.init.check_tool"): + result = runner.invoke(app, ["init", "spring", "test-api"]) + assert result.exit_code == 0 + mock_dl.assert_called_once() + mock_cfg.assert_called_once() + +def test_init_angular_success(): + with patch("devctl.commands.init.generate_angular_boilerplate", return_value=True) as mock_gen: + with patch("devctl.commands.init.check_tool"): + result = runner.invoke(app, ["init", "angular", "test-front"]) + assert result.exit_code == 0 + mock_gen.assert_called_once_with("test-front") + +def test_init_vue_success(): + with patch("devctl.commands.init.generate_vue_boilerplate", return_value=True) as mock_gen: + with patch("devctl.commands.init.check_tool"): + result = runner.invoke(app, ["init", "vue", "test-vue"]) + assert result.exit_code == 0 + mock_gen.assert_called_once_with("test-vue") diff --git a/test/test_runner.py b/test/test_runner.py new file mode 100644 index 0000000..fd90ecc --- /dev/null +++ b/test/test_runner.py @@ -0,0 +1,46 @@ +import subprocess +from unittest.mock import patch, MagicMock +from pathlib import Path +from devctl.orchestrator.runner import launch_dev_environment, is_docker_running +from devctl.generators.docker_scaffold import DockerProject + +def test_is_docker_running_true(): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + assert is_docker_running() is True + +def test_is_docker_running_false(): + with patch("subprocess.run") as mock_run: + mock_run.side_effect = subprocess.CalledProcessError(1, "docker info") + assert is_docker_running() is False + +def test_launch_dev_environment_empty(): + with patch("typer.secho") as mock_secho: + launch_dev_environment([], []) + mock_secho.assert_called_with("Warning: No projects or databases detected to run.", fg="yellow") + +@patch("subprocess.Popen") +@patch("subprocess.run") +@patch("time.sleep") +def test_launch_dev_environment_complex(mock_sleep, mock_run, mock_popen): + # Setup mocks + mock_popen.return_value = MagicMock(poll=lambda: None) + + projects = [ + DockerProject(kind="spring", path=Path("/tmp/spring"), name="api", service_name="api", relative_context="./api"), + DockerProject(kind="angular", path=Path("/tmp/angular"), name="front", service_name="front", relative_context="./front"), + ] + docker_composes = [Path("/tmp/db")] + + # Use an exception that is caught by launch_dev_environment to stop the loop + mock_sleep.side_effect = Exception("Stop Loop") + + with patch("devctl.orchestrator.runner.is_docker_running", return_value=True): + with patch("devctl.orchestrator.runner.cleanup_and_exit") as mock_cleanup: + launch_dev_environment(projects, docker_composes) + + # Verify Docker Compose started + mock_run.assert_any_call(["docker", "compose", "up", "-d"], cwd="/tmp/db", check=True) + + # Verify cleanup called + mock_cleanup.assert_called_once() diff --git a/test/test_utils.py b/test/test_utils.py new file mode 100644 index 0000000..2847c73 --- /dev/null +++ b/test/test_utils.py @@ -0,0 +1,17 @@ +import pytest +from unittest.mock import patch +from devctl.utils.dependencies import check_tool +import typer + +def test_check_tool_success(): + """Ensure check_tool does nothing if the tool exists.""" + with patch("shutil.which", return_value="/usr/bin/docker"): + # Should not raise any exception + check_tool("docker") + +def test_check_tool_failure(): + """Ensure check_tool raises Exit(1) if the tool is missing.""" + with patch("shutil.which", return_value=None): + with pytest.raises(typer.Exit) as excinfo: + check_tool("nonexistent-tool") + assert excinfo.value.exit_code == 1 From 64e1535ce396a3840fd505f1d10ad897cb468fb2 Mon Sep 17 00:00:00 2001 From: Youssef Fellah Date: Mon, 1 Jun 2026 11:44:35 +0100 Subject: [PATCH 15/18] chore: fix ruff linting and formatting issues --- devctl/commands/add.py | 26 ++++++---- devctl/commands/deploy.py | 23 ++++++--- devctl/commands/init.py | 19 ++++---- devctl/commands/run.py | 23 +++++---- devctl/generators/angular.py | 4 +- devctl/generators/django.py | 28 ++++++----- devctl/generators/docker_scaffold.py | 22 +++++---- devctl/generators/fastapi.py | 26 +++++----- devctl/generators/go_fiber.py | 24 ++++++---- devctl/generators/nestjs.py | 29 ++++++++---- devctl/generators/nextjs.py | 30 ++++++++---- devctl/generators/nodejs.py | 63 +++++++++++++++++-------- devctl/generators/react.py | 19 ++++---- devctl/generators/scaffold_angular.py | 5 +- devctl/generators/scaffold_django.py | 12 +++-- devctl/generators/scaffold_fastapi.py | 9 ++-- devctl/generators/scaffold_go.py | 4 +- devctl/generators/scaffold_nestjs.py | 20 +++++--- devctl/generators/scaffold_nextjs.py | 6 ++- devctl/generators/scaffold_nodejs.py | 18 +++---- devctl/generators/scaffold_react.py | 4 +- devctl/generators/scaffold_svelte.py | 6 ++- devctl/generators/svelte.py | 30 ++++++++---- devctl/generators/vue.py | 8 ++-- devctl/orchestrator/runner.py | 68 +++++++++++++++------------ devctl/orchestrator/scanner.py | 33 +++++++++++-- test/test_add.py | 9 ++-- test/test_django.py | 11 +++-- test/test_fastapi.py | 11 +++-- test/test_go.py | 11 +++-- test/test_init.py | 7 ++- test/test_mongodb.py | 11 +++-- test/test_nestjs.py | 11 +++-- test/test_nextjs.py | 11 +++-- test/test_nodejs.py | 12 +++-- test/test_react.py | 19 ++++++-- test/test_runner.py | 39 +++++++++++---- test/test_svelte.py | 11 +++-- test/test_utils.py | 8 +++- 39 files changed, 479 insertions(+), 251 deletions(-) diff --git a/devctl/commands/add.py b/devctl/commands/add.py index eecd694..449c7bf 100644 --- a/devctl/commands/add.py +++ b/devctl/commands/add.py @@ -1,6 +1,7 @@ """ CLI command group for adding new resources to existing projects. -Supports Spring Boot, Angular, Vue.js, NestJS, NodeJS, React, NextJS, FastAPI, Django, Svelte, and Go scaffolding. +Supports Spring Boot, Angular, Vue.js, NestJS, NodeJS, React, NextJS, FastAPI, +Django, Svelte, and Go scaffolding. """ import os @@ -8,16 +9,16 @@ import typer from devctl.generators.scaffold_angular import generate_angular_resource -from devctl.generators.scaffold_spring import generate_spring_resource -from devctl.generators.scaffold_vue import generate_vue_resource +from devctl.generators.scaffold_django import generate_django_resource +from devctl.generators.scaffold_fastapi import generate_fastapi_resource +from devctl.generators.scaffold_go import generate_go_resource from devctl.generators.scaffold_nestjs import generate_nest_resource +from devctl.generators.scaffold_nextjs import generate_nextjs_resource from devctl.generators.scaffold_nodejs import generate_nodejs_resource from devctl.generators.scaffold_react import generate_react_resource -from devctl.generators.scaffold_nextjs import generate_nextjs_resource -from devctl.generators.scaffold_fastapi import generate_fastapi_resource -from devctl.generators.scaffold_django import generate_django_resource +from devctl.generators.scaffold_spring import generate_spring_resource from devctl.generators.scaffold_svelte import generate_svelte_resource -from devctl.generators.scaffold_go import generate_go_resource +from devctl.generators.scaffold_vue import generate_vue_resource from devctl.orchestrator.scanner import detect_environment app = typer.Typer(help="Adds resources to the current project (Scaffolding).") @@ -94,7 +95,9 @@ def resource( # Check for NextJS project if env_state.get("has_nextjs"): project_detected = True - typer.secho("NextJS project detected. Launching NextJS generator...", fg=typer.colors.YELLOW) + typer.secho( + "NextJS project detected. Launching NextJS generator...", fg=typer.colors.YELLOW + ) try: generate_nextjs_resource(name, fields, root_path=".") except Exception as e: @@ -103,7 +106,9 @@ def resource( # Check for FastAPI project if env_state.get("has_fastapi"): project_detected = True - typer.secho("FastAPI project detected. Launching FastAPI generator...", fg=typer.colors.CYAN) + typer.secho( + "FastAPI project detected. Launching FastAPI generator...", fg=typer.colors.CYAN + ) try: generate_fastapi_resource(name, fields, root_path=".") except Exception as e: @@ -150,7 +155,8 @@ def resource( if not project_detected: typer.secho( "Error: Unable to determine project type. " - "Please run from within a supported project directory (Spring, Angular, React, NextJS, FastAPI, Django, Svelte, Go, or Vue.js).", + "Please run from within a supported project directory " + "(Spring, Angular, React, NextJS, FastAPI, Django, Svelte, Go, or Vue.js).", fg=typer.colors.RED, ) raise typer.Exit(code=1) diff --git a/devctl/commands/deploy.py b/devctl/commands/deploy.py index a32bc7f..cece1cb 100644 --- a/devctl/commands/deploy.py +++ b/devctl/commands/deploy.py @@ -32,8 +32,10 @@ def extract_db_info(project_path: Path) -> Optional[Dict[str, Any]]: # spring.datasource.url=jdbc:postgresql://localhost:5432/sample_api_db url_match = re.search(r"spring\.datasource\.url=jdbc:([^:]+)://[^:]+:(\d+)/([\w-]+)", content) # spring.data.mongodb.uri=mongodb://admin:password@localhost:27017/db_name - mongo_match = re.search(r"spring\.data\.mongodb\.uri=mongodb://([^:]+):([^@]+)@[^:]+:(\d+)/([\w-]+)", content) - + mongo_match = re.search( + r"spring\.data\.mongodb\.uri=mongodb://([^:]+):([^@]+)@[^:]+:(\d+)/([\w-]+)", content + ) + user_match = re.search(r"spring\.datasource\.username=([\w-]+)", content) pass_match = re.search(r"spring\.datasource\.password=([\w-]+)", content) @@ -128,7 +130,9 @@ def extract_db_from_compose(compose_path: Path) -> Optional[Dict[str, Any]]: db_name = env_dict.get("POSTGRES_DB", "db") elif db_type == "mysql": user = env_dict.get("MYSQL_USER", env_dict.get("MYSQL_ROOT_PASSWORD", "admin")) - password = env_dict.get("MYSQL_PASSWORD", env_dict.get("MYSQL_ROOT_PASSWORD", "password")) + password = env_dict.get( + "MYSQL_PASSWORD", env_dict.get("MYSQL_ROOT_PASSWORD", "password") + ) db_name = env_dict.get("MYSQL_DATABASE", "db") else: user = env_dict.get("MONGO_INITDB_ROOT_USERNAME", "admin") @@ -145,7 +149,12 @@ def extract_db_from_compose(compose_path: Path) -> Optional[Dict[str, Any]]: db_dict = _build_db_dict( db_type, - host_port or ("5432" if db_type == "postgresql" else ("3306" if db_type == "mysql" else "27017")), + host_port + or ( + "5432" + if db_type == "postgresql" + else ("3306" if db_type == "mysql" else "27017") + ), db_name, user, password, @@ -159,7 +168,6 @@ def extract_db_from_compose(compose_path: Path) -> Optional[Dict[str, Any]]: def _build_db_dict(db_type: str, port: str, name: str, user: str, password: str) -> Dict[str, Any]: is_postgres = db_type == "postgresql" is_mysql = db_type == "mysql" - is_mongo = db_type == "mongodb" if is_postgres: internal_port = "5432" @@ -210,7 +218,6 @@ def _build_db_dict(db_type: str, port: str, name: str, user: str, password: str) } - @app.command() def deploy(path: Path = PATH_ARGUMENT): """ @@ -225,7 +232,9 @@ def deploy(path: Path = PATH_ARGUMENT): raise typer.Exit(1) from e if not projects: - typer.secho("Error: No supported projects (Spring, Angular, Vue) found.", fg=typer.colors.RED) + typer.secho( + "Error: No supported projects (Spring, Angular, Vue) found.", fg=typer.colors.RED + ) raise typer.Exit(1) services_data = [] diff --git a/devctl/commands/init.py b/devctl/commands/init.py index 4b908c6..2ccd6b4 100644 --- a/devctl/commands/init.py +++ b/devctl/commands/init.py @@ -1,24 +1,25 @@ """ CLI command group for initializing new projects. -Supports Spring Boot, Angular, Vue.js, NestJS, NodeJS, React, NextJS, FastAPI, Django, Svelte, and Go. +Supports Spring Boot, Angular, Vue.js, NestJS, NodeJS, React, NextJS, FastAPI, +Django, Svelte, and Go. """ import typer # Angular generator from devctl.generators.angular import generate_angular_boilerplate - -# Spring generator -from devctl.generators.spring import download_spring_boilerplate -from devctl.generators.vue import generate_vue_boilerplate +from devctl.generators.django import generate_django_boilerplate +from devctl.generators.fastapi import generate_fastapi_boilerplate +from devctl.generators.go_fiber import generate_go_boilerplate from devctl.generators.nestjs import generate_nest_boilerplate +from devctl.generators.nextjs import generate_nextjs_boilerplate from devctl.generators.nodejs import generate_nodejs_boilerplate from devctl.generators.react import generate_react_boilerplate -from devctl.generators.nextjs import generate_nextjs_boilerplate -from devctl.generators.fastapi import generate_fastapi_boilerplate -from devctl.generators.django import generate_django_boilerplate + +# Spring generator +from devctl.generators.spring import download_spring_boilerplate from devctl.generators.svelte import generate_svelte_boilerplate -from devctl.generators.go_fiber import generate_go_boilerplate +from devctl.generators.vue import generate_vue_boilerplate from devctl.orchestrator.config_builder import generate_config from devctl.utils.dependencies import check_tool diff --git a/devctl/commands/run.py b/devctl/commands/run.py index c5d43e6..0ecb89b 100644 --- a/devctl/commands/run.py +++ b/devctl/commands/run.py @@ -3,11 +3,12 @@ Automatically detects and launches backend, frontend, and database services. """ -import typer from pathlib import Path -from devctl.orchestrator.runner import launch_dev_environment +import typer + from devctl.generators.docker_scaffold import discover_docker_projects +from devctl.orchestrator.runner import launch_dev_environment from devctl.utils.dependencies import check_tool app = typer.Typer(help="Local execution and development commands.") @@ -22,18 +23,18 @@ def run_env(ctx: typer.Context): return typer.secho("Analyzing the current directory tree...", fg=typer.colors.CYAN) - + projects = discover_docker_projects(".") - + # Check for docker-compose.yml files docker_composes = [] for p in Path(".").rglob("docker-compose.yml"): if "node_modules" not in str(p) and "target" not in str(p) and ".git" not in str(p): docker_composes.append(p.parent) - has_spring = any(p.kind == "spring" for p in projects) - has_angular = any(p.kind == "angular" for p in projects) - has_vue = any(p.kind == "vue" for p in projects) + any(p.kind == "spring" for p in projects) + any(p.kind == "angular" for p in projects) + any(p.kind == "vue" for p in projects) has_docker = len(docker_composes) > 0 # Check dependencies based on detection @@ -47,10 +48,14 @@ def run_env(ctx: typer.Context): # Visual summary of detection for the user def get_status(condition: bool): - return typer.style("FOUND", fg=typer.colors.GREEN) if condition else typer.style("MISSING", fg=typer.colors.RED) + return ( + typer.style("FOUND", fg=typer.colors.GREEN) + if condition + else typer.style("MISSING", fg=typer.colors.RED) + ) typer.echo(f" - Docker Compose ({len(docker_composes)}) : {get_status(has_docker)}") - + for kind in sorted(counts.keys()): typer.echo(f" - {kind.capitalize()} ({counts[kind]}) : {get_status(True)}") diff --git a/devctl/generators/angular.py b/devctl/generators/angular.py index 531cc10..977f1be 100644 --- a/devctl/generators/angular.py +++ b/devctl/generators/angular.py @@ -94,9 +94,7 @@ def generate_angular_boilerplate(project_name: str) -> bool: try: command = ["ng", "new", safe_name, "--routing=true", "--style=scss", "--skip-git=true"] - typer.secho( - "Downloading npm packages... (This may take 1-2 minutes)", fg=typer.colors.CYAN - ) + typer.secho("Downloading npm packages... (This may take 1-2 minutes)", fg=typer.colors.CYAN) subprocess.run(command, check=True) # Post-installation configuration diff --git a/devctl/generators/django.py b/devctl/generators/django.py index b70ae07..c56a4ec 100644 --- a/devctl/generators/django.py +++ b/devctl/generators/django.py @@ -5,6 +5,7 @@ import os import subprocess + import typer @@ -12,13 +13,13 @@ def generate_django_boilerplate(project_name: str) -> bool: """ Generates a new Django project. """ - typer.secho(f"šŸ”„ Generating Django project '{project_name}'...", fg=typer.colors.CYAN) - safe_name = project_name.lower().replace("-", "_") # Django prefers underscores + typer.secho(f"Generating Django project '{project_name}'...", fg=typer.colors.CYAN) + safe_name = project_name.lower().replace("-", "_") # Django prefers underscores project_path = os.path.join(os.getcwd(), project_name) try: os.makedirs(project_path, exist_ok=True) - + # 1. Create requirements.txt requirements = """django djangorestframework @@ -28,28 +29,33 @@ def generate_django_boilerplate(project_name: str) -> bool: f.write(requirements) # 2. Create virtual environment - typer.secho("šŸ“¦ Creating virtual environment...", fg=typer.colors.CYAN) + typer.secho("Creating virtual environment...", fg=typer.colors.CYAN) subprocess.run(["python3", "-m", "venv", ".venv"], cwd=project_path, check=True) - + # 3. Install Django - typer.secho("ā³ Installing Django and DRF...", fg=typer.colors.CYAN) + typer.secho("Installing Django and DRF...", fg=typer.colors.CYAN) pip_path = os.path.join(".venv", "bin", "pip") - subprocess.run([pip_path, "install", "django", "djangorestframework"], cwd=project_path, check=True, stdout=subprocess.DEVNULL) + subprocess.run( + [pip_path, "install", "django", "djangorestframework"], + cwd=project_path, + check=True, + stdout=subprocess.DEVNULL, + ) # 4. Start Project - typer.secho("šŸš€ Scaffolding Django project structure...", fg=typer.colors.CYAN) + typer.secho("Scaffolding Django project structure...", fg=typer.colors.CYAN) django_admin = os.path.join(".venv", "bin", "django-admin") subprocess.run([django_admin, "startproject", safe_name, "."], cwd=project_path, check=True) - + # 5. Create core app python_path = os.path.join(".venv", "bin", "python") subprocess.run([python_path, "manage.py", "startapp", "core"], cwd=project_path, check=True) typer.secho( - f"āœ… Django project '{project_name}' successfully generated!", fg=typer.colors.GREEN + f"Django project '{project_name}' successfully generated!", fg=typer.colors.GREEN ) return True except Exception as e: - typer.secho(f"āŒ Django initialization failed: {e}", fg=typer.colors.RED) + typer.secho(f"Error: Django initialization failed: {e}", fg=typer.colors.RED) return False diff --git a/devctl/generators/docker_scaffold.py b/devctl/generators/docker_scaffold.py index 26d7901..040ca0f 100644 --- a/devctl/generators/docker_scaffold.py +++ b/devctl/generators/docker_scaffold.py @@ -103,7 +103,7 @@ def discover_docker_projects(root_path: Union[str, Path]) -> list[DockerProject] candidates.append(("spring", project_path)) if "angular.json" in filename_set: candidates.append(("angular", project_path)) - + has_vite_config = {"vite.config.ts", "vite.config.js"} & filename_set if has_vite_config and "angular.json" not in filename_set: # Check package.json to distinguish between vue and react @@ -114,13 +114,13 @@ def discover_docker_projects(root_path: Union[str, Path]) -> list[DockerProject] deps = pkg.get("dependencies", {}) dev_deps = pkg.get("devDependencies", {}) all_deps = {**deps, **dev_deps} - + if "vue" in all_deps: candidates.append(("vue", project_path)) elif "react" in all_deps: candidates.append(("react", project_path)) else: - candidates.append(("vue", project_path)) # Fallback to vue + candidates.append(("vue", project_path)) # Fallback to vue except Exception: candidates.append(("vue", project_path)) else: @@ -128,10 +128,10 @@ def discover_docker_projects(root_path: Union[str, Path]) -> list[DockerProject] if "nest-cli.json" in filename_set: candidates.append(("nest", project_path)) - + if any(f.startswith("next.config.") for f in filename_set): candidates.append(("nextjs", project_path)) - + if "svelte.config.js" in filename_set: candidates.append(("svelte", project_path)) @@ -152,11 +152,15 @@ def discover_docker_projects(root_path: Union[str, Path]) -> list[DockerProject] candidates.append(("django", project_path)) except Exception: pass - + if "go.mod" in filename_set: candidates.append(("go", project_path)) - if "package.json" in filename_set and not any(k in ["angular", "vue", "react", "nest", "nextjs", "svelte"] for k, p in candidates if p == project_path): + if "package.json" in filename_set and not any( + k in ["angular", "vue", "react", "nest", "nextjs", "svelte"] + for k, p in candidates + if p == project_path + ): candidates.append(("nodejs", project_path)) used_names: set[str] = set() @@ -176,7 +180,9 @@ def discover_docker_projects(root_path: Union[str, Path]) -> list[DockerProject] relative_context=_relative_context(root, project_path), java_version=_spring_java_version(project_path) if kind == "spring" else None, node_version=( - _node_version(project_path, kind) if kind in {"angular", "vue", "react", "nest", "nodejs", "nextjs", "svelte"} else None + _node_version(project_path, kind) + if kind in {"angular", "vue", "react", "nest", "nodejs", "nextjs", "svelte"} + else None ), angular_output_name=( _angular_output_name(project_path) if kind == "angular" else None diff --git a/devctl/generators/fastapi.py b/devctl/generators/fastapi.py index 420358d..1a3a11b 100644 --- a/devctl/generators/fastapi.py +++ b/devctl/generators/fastapi.py @@ -5,6 +5,7 @@ import os import subprocess + import typer @@ -12,13 +13,13 @@ def generate_fastapi_boilerplate(project_name: str) -> bool: """ Generates a new FastAPI project. """ - typer.secho(f"šŸ”„ Generating FastAPI project '{project_name}'...", fg=typer.colors.CYAN) + typer.secho(f"Generating FastAPI project '{project_name}'...", fg=typer.colors.CYAN) safe_name = project_name.lower().replace("_", "-") project_path = os.path.join(os.getcwd(), safe_name) try: os.makedirs(project_path, exist_ok=True) - + # 1. Create main.py main_py = """from fastapi import FastAPI @@ -34,7 +35,7 @@ def read_item(item_id: int, q: str = None): """ with open(os.path.join(project_path, "main.py"), "w") as f: f.write(main_py) - + # 2. Create requirements.txt requirements = """fastapi uvicorn[standard] @@ -44,20 +45,23 @@ def read_item(item_id: int, q: str = None): f.write(requirements) # 3. Create virtual environment - typer.secho("šŸ“¦ Creating virtual environment...", fg=typer.colors.CYAN) + typer.secho("Creating virtual environment...", fg=typer.colors.CYAN) subprocess.run(["python3", "-m", "venv", ".venv"], cwd=project_path, check=True) - + # 4. Install dependencies - typer.secho("ā³ Installing dependencies (fastapi, uvicorn)...", fg=typer.colors.CYAN) + typer.secho("Installing dependencies (fastapi, uvicorn)...", fg=typer.colors.CYAN) # Note: on Linux it's .venv/bin/pip pip_path = os.path.join(".venv", "bin", "pip") - subprocess.run([pip_path, "install", "-r", "requirements.txt"], cwd=project_path, check=True, stdout=subprocess.DEVNULL) - - typer.secho( - f"āœ… FastAPI project '{safe_name}' successfully generated!", fg=typer.colors.GREEN + subprocess.run( + [pip_path, "install", "-r", "requirements.txt"], + cwd=project_path, + check=True, + stdout=subprocess.DEVNULL, ) + + typer.secho(f"FastAPI project '{safe_name}' successfully generated!", fg=typer.colors.GREEN) return True except Exception as e: - typer.secho(f"āŒ FastAPI initialization failed: {e}", fg=typer.colors.RED) + typer.secho(f"Error: FastAPI initialization failed: {e}", fg=typer.colors.RED) return False diff --git a/devctl/generators/go_fiber.py b/devctl/generators/go_fiber.py index 3a74b8f..628e655 100644 --- a/devctl/generators/go_fiber.py +++ b/devctl/generators/go_fiber.py @@ -5,6 +5,7 @@ import os import subprocess + import typer @@ -12,19 +13,24 @@ def generate_go_boilerplate(project_name: str) -> bool: """ Generates a new Go + Fiber project. """ - typer.secho(f"šŸ”„ Generating Go project '{project_name}'...", fg=typer.colors.CYAN) + typer.secho(f"Generating Go project '{project_name}'...", fg=typer.colors.CYAN) project_path = os.path.join(os.getcwd(), project_name) try: os.makedirs(project_path, exist_ok=True) - + # 1. Go mod init - typer.secho("šŸ“¦ Initializing Go module...", fg=typer.colors.CYAN) + typer.secho("Initializing Go module...", fg=typer.colors.CYAN) subprocess.run(["go", "mod", "init", project_name], cwd=project_path, check=True) - + # 2. Install Fiber - typer.secho("ā³ Installing Fiber framework...", fg=typer.colors.CYAN) - subprocess.run(["go", "get", "github.com/gofiber/fiber/v2"], cwd=project_path, check=True, stdout=subprocess.DEVNULL) + typer.secho("Installing Fiber framework...", fg=typer.colors.CYAN) + subprocess.run( + ["go", "get", "github.com/gofiber/fiber/v2"], + cwd=project_path, + check=True, + stdout=subprocess.DEVNULL, + ) # 3. Create main.go main_go = """package main @@ -47,11 +53,9 @@ def generate_go_boilerplate(project_name: str) -> bool: with open(os.path.join(project_path, "main.go"), "w") as f: f.write(main_go) - typer.secho( - f"āœ… Go project '{project_name}' successfully generated!", fg=typer.colors.GREEN - ) + typer.secho(f"Go project '{project_name}' successfully generated!", fg=typer.colors.GREEN) return True except Exception as e: - typer.secho(f"āŒ Go initialization failed: {e}", fg=typer.colors.RED) + typer.secho(f"Error: Go initialization failed: {e}", fg=typer.colors.RED) return False diff --git a/devctl/generators/nestjs.py b/devctl/generators/nestjs.py index d00daaa..c1ad0d7 100644 --- a/devctl/generators/nestjs.py +++ b/devctl/generators/nestjs.py @@ -3,7 +3,6 @@ Includes boilerplate generation via Nest CLI. """ -import os import subprocess import typer @@ -13,7 +12,7 @@ def generate_nest_boilerplate(project_name: str) -> bool: """ Generates a new NestJS project using the Nest CLI via npx. """ - typer.secho(f"šŸ”„ Generating NestJS project '{project_name}'...", fg=typer.colors.CYAN) + typer.secho(f"Generating NestJS project '{project_name}'...", fg=typer.colors.CYAN) safe_name = project_name.lower().replace("_", "-") try: @@ -21,20 +20,30 @@ def generate_nest_boilerplate(project_name: str) -> bool: # --package-manager npm: ensures npm is used # --strict: enables strict mode # --skip-git: devctl might be in a git repo already - typer.secho("šŸ“¦ Scaffolding NestJS project (this may take a minute)...", fg=typer.colors.CYAN) + typer.secho("Scaffolding NestJS project (this may take a minute)...", fg=typer.colors.CYAN) subprocess.run( - ["npx", "-p", "@nestjs/cli", "nest", "new", safe_name, "--package-manager", "npm", "--skip-git"], - check=True + [ + "npx", + "-p", + "@nestjs/cli", + "nest", + "new", + safe_name, + "--package-manager", + "npm", + "--skip-git", + ], + check=True, ) - typer.secho( - f"āœ… NestJS project '{safe_name}' successfully generated!", fg=typer.colors.GREEN - ) + typer.secho(f"NestJS project '{safe_name}' successfully generated!", fg=typer.colors.GREEN) return True except subprocess.CalledProcessError as e: - typer.secho(f"āŒ Nest CLI process failed with code: {e.returncode}", fg=typer.colors.RED) + typer.secho( + f"Error: Nest CLI process failed with code: {e.returncode}", fg=typer.colors.RED + ) return False except FileNotFoundError: - typer.secho("āŒ Error: 'npx' or 'npm' not found in path.", fg=typer.colors.RED) + typer.secho("Error: 'npx' or 'npm' not found in path.", fg=typer.colors.RED) return False diff --git a/devctl/generators/nextjs.py b/devctl/generators/nextjs.py index 7a3a1c7..892d205 100644 --- a/devctl/generators/nextjs.py +++ b/devctl/generators/nextjs.py @@ -3,8 +3,8 @@ Includes boilerplate generation via create-next-app. """ -import os import subprocess + import typer @@ -12,11 +12,11 @@ def generate_nextjs_boilerplate(project_name: str) -> bool: """ Generates a new NextJS project using create-next-app via npx. """ - typer.secho(f"šŸ”„ Generating NextJS project '{project_name}'...", fg=typer.colors.CYAN) + typer.secho(f"Generating NextJS project '{project_name}'...", fg=typer.colors.CYAN) safe_name = project_name.lower().replace("_", "-") try: - typer.secho("šŸ“¦ Scaffolding NextJS project (this may take a minute)...", fg=typer.colors.CYAN) + typer.secho("Scaffolding NextJS project (this may take a minute)...", fg=typer.colors.CYAN) # --ts: TypeScript # --eslint: ESLint # --tailwind: Tailwind CSS @@ -24,18 +24,28 @@ def generate_nextjs_boilerplate(project_name: str) -> bool: # --app: Use App Router # --import-alias: alias for imports subprocess.run( - ["npx", "create-next-app@latest", safe_name, "--ts", "--eslint", "--tailwind", "--src-dir", "--app", "--import-alias", "@/*", "--use-npm"], - check=True + [ + "npx", + "create-next-app@latest", + safe_name, + "--ts", + "--eslint", + "--tailwind", + "--src-dir", + "--app", + "--import-alias", + "@/*", + "--use-npm", + ], + check=True, ) - typer.secho( - f"āœ… NextJS project '{safe_name}' successfully generated!", fg=typer.colors.GREEN - ) + typer.secho(f"NextJS project '{safe_name}' successfully generated!", fg=typer.colors.GREEN) return True except subprocess.CalledProcessError as e: - typer.secho(f"āŒ NextJS creation failed with code: {e.returncode}", fg=typer.colors.RED) + typer.secho(f"Error: NextJS creation failed with code: {e.returncode}", fg=typer.colors.RED) return False except Exception as e: - typer.secho(f"āŒ NextJS initialization failed: {e}", fg=typer.colors.RED) + typer.secho(f"Error: NextJS initialization failed: {e}", fg=typer.colors.RED) return False diff --git a/devctl/generators/nodejs.py b/devctl/generators/nodejs.py index 8b846d8..f601714 100644 --- a/devctl/generators/nodejs.py +++ b/devctl/generators/nodejs.py @@ -3,9 +3,10 @@ Includes boilerplate generation with TypeScript and Express. """ +import json import os import subprocess -import json + import typer @@ -13,35 +14,56 @@ def generate_nodejs_boilerplate(project_name: str) -> bool: """ Generates a new NodeJS + Express + TypeScript project. """ - typer.secho(f"šŸ”„ Generating NodeJS/Express project '{project_name}'...", fg=typer.colors.CYAN) + typer.secho(f"Generating NodeJS/Express project '{project_name}'...", fg=typer.colors.CYAN) safe_name = project_name.lower().replace("_", "-") project_path = os.path.join(os.getcwd(), safe_name) try: os.makedirs(project_path, exist_ok=True) - + # 1. Initialize package.json - typer.secho("šŸ“¦ Initializing package.json...", fg=typer.colors.CYAN) - subprocess.run(["npm", "init", "-y"], cwd=project_path, check=True, stdout=subprocess.DEVNULL) - + typer.secho("Initializing package.json...", fg=typer.colors.CYAN) + subprocess.run( + ["npm", "init", "-y"], cwd=project_path, check=True, stdout=subprocess.DEVNULL + ) + # 2. Install dependencies - typer.secho("ā³ Installing dependencies (express, typescript, ts-node, nodemon)...", fg=typer.colors.CYAN) + typer.secho( + "Installing dependencies (express, typescript, ts-node, nodemon)...", + fg=typer.colors.CYAN, + ) subprocess.run( - ["npm", "install", "express", "dotenv"], - cwd=project_path, check=True, stdout=subprocess.DEVNULL + ["npm", "install", "express", "dotenv"], + cwd=project_path, + check=True, + stdout=subprocess.DEVNULL, ) subprocess.run( - ["npm", "install", "-D", "typescript", "@types/node", "@types/express", "ts-node", "nodemon", "rimraf"], - cwd=project_path, check=True, stdout=subprocess.DEVNULL + [ + "npm", + "install", + "-D", + "typescript", + "@types/node", + "@types/express", + "ts-node", + "nodemon", + "rimraf", + ], + cwd=project_path, + check=True, + stdout=subprocess.DEVNULL, ) # 3. Initialize TypeScript - typer.secho("āš™ļø Configuring TypeScript...", fg=typer.colors.CYAN) - subprocess.run(["npx", "tsc", "--init"], cwd=project_path, check=True, stdout=subprocess.DEVNULL) - + typer.secho("Configuring TypeScript...", fg=typer.colors.CYAN) + subprocess.run( + ["npx", "tsc", "--init"], cwd=project_path, check=True, stdout=subprocess.DEVNULL + ) + # 4. Create folder structure os.makedirs(os.path.join(project_path, "src"), exist_ok=True) - + # 5. Create basic index.ts index_ts = """import express, { Request, Response } from 'express'; import dotenv from 'dotenv'; @@ -67,21 +89,22 @@ def generate_nodejs_boilerplate(project_name: str) -> bool: # 6. Update package.json scripts with open(os.path.join(project_path, "package.json"), "r") as f: pkg = json.load(f) - + pkg["scripts"] = { "start": "node dist/index.js", "build": "rimraf dist && tsc", - "dev": "nodemon src/index.ts" + "dev": "nodemon src/index.ts", } - + with open(os.path.join(project_path, "package.json"), "w") as f: json.dump(pkg, f, indent=2) typer.secho( - f"āœ… NodeJS/Express project '{safe_name}' successfully generated!", fg=typer.colors.GREEN + f"NodeJS/Express project '{safe_name}' successfully generated!", + fg=typer.colors.GREEN, ) return True except Exception as e: - typer.secho(f"āŒ NodeJS/Express initialization failed: {e}", fg=typer.colors.RED) + typer.secho(f"Error: NodeJS/Express initialization failed: {e}", fg=typer.colors.RED) return False diff --git a/devctl/generators/react.py b/devctl/generators/react.py index e25c887..5256afb 100644 --- a/devctl/generators/react.py +++ b/devctl/generators/react.py @@ -5,6 +5,7 @@ import os import subprocess + import typer @@ -12,29 +13,31 @@ def generate_react_boilerplate(project_name: str) -> bool: """ Generates a new React + TypeScript project via Vite. """ - typer.secho(f"šŸ”„ Generating ReactJS frontend '{project_name}' via Vite...", fg=typer.colors.CYAN) + typer.secho(f"Generating ReactJS frontend '{project_name}' via Vite...", fg=typer.colors.CYAN) safe_name = project_name.lower().replace("_", "-") try: - typer.secho("šŸ“¦ Scaffolding React project...", fg=typer.colors.CYAN) + typer.secho("Scaffolding React project...", fg=typer.colors.CYAN) subprocess.run( - ["npm", "create", "vite@latest", safe_name, "--", "--template", "react-ts"], - check=True + ["npm", "create", "vite@latest", safe_name, "--", "--template", "react-ts"], + check=True, ) project_full_path = os.path.join(os.getcwd(), safe_name) - typer.secho("ā³ Installing npm dependencies...", fg=typer.colors.CYAN) + typer.secho("Installing npm dependencies...", fg=typer.colors.CYAN) subprocess.run(["npm", "install"], cwd=project_full_path, check=True) typer.secho( - f"āœ… ReactJS frontend '{safe_name}' successfully generated!", fg=typer.colors.GREEN + f"ReactJS frontend '{safe_name}' successfully generated!", fg=typer.colors.GREEN ) return True except subprocess.CalledProcessError as e: - typer.secho(f"āŒ React/Vite process failed with code: {e.returncode}", fg=typer.colors.RED) + typer.secho( + f"Error: React/Vite process failed with code: {e.returncode}", fg=typer.colors.RED + ) return False except Exception as e: - typer.secho(f"āŒ React/Vite initialization failed: {e}", fg=typer.colors.RED) + typer.secho(f"Error: React/Vite initialization failed: {e}", fg=typer.colors.RED) return False diff --git a/devctl/generators/scaffold_angular.py b/devctl/generators/scaffold_angular.py index 9901f3f..52e3793 100644 --- a/devctl/generators/scaffold_angular.py +++ b/devctl/generators/scaffold_angular.py @@ -155,6 +155,9 @@ def generate_angular_resource(resource_name: str, fields_str: str, root_path: st f.write("") typer.echo(f" - Created (empty): {comp['dir']}/{target_file_name}") else: - typer.secho(f"Warning: Failed to generate {comp['template']}: {e}", fg=typer.colors.YELLOW) + typer.secho( + f"Warning: Failed to generate {comp['template']}: {e}", + fg=typer.colors.YELLOW, + ) typer.secho(f"{entity_name} feature successfully generated!", fg=typer.colors.GREEN) diff --git a/devctl/generators/scaffold_django.py b/devctl/generators/scaffold_django.py index 19eaeb2..174af2f 100644 --- a/devctl/generators/scaffold_django.py +++ b/devctl/generators/scaffold_django.py @@ -4,7 +4,9 @@ """ import os + import typer + from devctl.orchestrator.scanner import detect_environment @@ -19,7 +21,7 @@ def generate_django_resource(resource_name: str, fields_str: str, root_path: str raise typer.Exit(code=1) django_root = env_state["django_path"] - resource_lower = resource_name.lower() + resource_name.lower() entity_name = resource_name.capitalize() # Structure: core/models.py, core/serializers.py, core/views.py @@ -48,7 +50,7 @@ def __str__(self): if not os.path.exists(serializer_path): with open(serializer_path, "w") as f: f.write("from rest_framework import serializers\nfrom .models import *\n") - + serializer_snippet = f""" class {entity_name}Serializer(serializers.ModelSerializer): class Meta: @@ -72,6 +74,6 @@ class {entity_name}ViewSet(viewsets.ModelViewSet): f.write(view_snippet) typer.secho(f"āœ… {entity_name} Django feature successfully generated!", fg=typer.colors.GREEN) - typer.echo(f" - Updated: core/models.py") - typer.echo(f" - Updated: core/serializers.py") - typer.echo(f" - Updated: core/views.py") + typer.echo(" - Updated: core/models.py") + typer.echo(" - Updated: core/serializers.py") + typer.echo(" - Updated: core/views.py") diff --git a/devctl/generators/scaffold_fastapi.py b/devctl/generators/scaffold_fastapi.py index ce11210..d6d71da 100644 --- a/devctl/generators/scaffold_fastapi.py +++ b/devctl/generators/scaffold_fastapi.py @@ -4,7 +4,9 @@ """ import os + import typer + from devctl.orchestrator.scanner import detect_environment @@ -26,13 +28,14 @@ def generate_fastapi_resource(resource_name: str, fields_str: str, root_path: st routers_dir = os.path.join(fastapi_root, "routers") schemas_dir = os.path.join(fastapi_root, "schemas") models_dir = os.path.join(fastapi_root, "models") - + for d in [routers_dir, schemas_dir, models_dir]: os.makedirs(d, exist_ok=True) # Ensure __init__.py exists - with open(os.path.join(d, "__init__.py"), "a"): pass + with open(os.path.join(d, "__init__.py"), "a"): + pass - typer.secho(f"āš™ļø Generating FastAPI resource '{entity_name}'...", fg=typer.colors.CYAN) + typer.secho(f"Generating FastAPI resource '{entity_name}'...", fg=typer.colors.CYAN) # 1. Generate Schema (Pydantic) schema_content = f"""from pydantic import BaseModel diff --git a/devctl/generators/scaffold_go.py b/devctl/generators/scaffold_go.py index 24012ab..0f0499e 100644 --- a/devctl/generators/scaffold_go.py +++ b/devctl/generators/scaffold_go.py @@ -4,7 +4,9 @@ """ import os + import typer + from devctl.orchestrator.scanner import detect_environment @@ -25,7 +27,7 @@ def generate_go_resource(resource_name: str, fields_str: str, root_path: str = " # Structure: handlers/resource.go, models/resource.go handlers_dir = os.path.join(go_root, "handlers") models_dir = os.path.join(go_root, "models") - + os.makedirs(handlers_dir, exist_ok=True) os.makedirs(models_dir, exist_ok=True) diff --git a/devctl/generators/scaffold_nestjs.py b/devctl/generators/scaffold_nestjs.py index cc88aa5..35d5f5e 100644 --- a/devctl/generators/scaffold_nestjs.py +++ b/devctl/generators/scaffold_nestjs.py @@ -3,9 +3,10 @@ Handles the creation of modules, controllers, and services using Nest CLI. """ -import os import subprocess + import typer + from devctl.orchestrator.scanner import detect_environment @@ -31,13 +32,20 @@ def generate_nest_resource(resource_name: str, fields_str: str, root_path: str = subprocess.run( ["npx", "@nestjs/cli", "g", "resource", resource_lower, "--no-spec"], cwd=nest_root, - check=True + check=True, ) - typer.secho(f"āœ… {resource_name} NestJS resource successfully generated!", fg=typer.colors.GREEN) - typer.echo(f"šŸ’” Note: Fields [{fields_str}] were provided but manual DTO update is recommended for NestJS.") + typer.secho( + f"{resource_name} NestJS resource successfully generated!", fg=typer.colors.GREEN + ) + typer.echo( + f"Hint: Fields [{fields_str}] were provided but manual DTO update is recommended." + ) except subprocess.CalledProcessError as e: - typer.secho(f"āŒ Nest CLI resource generation failed with code: {e.returncode}", fg=typer.colors.RED) + typer.secho( + f"Error: Nest CLI resource generation failed with code: {e.returncode}", + fg=typer.colors.RED, + ) except Exception as e: - typer.secho(f"āš ļø An unexpected error occurred: {e}", fg=typer.colors.YELLOW) + typer.secho(f"Warning: An unexpected error occurred: {e}", fg=typer.colors.YELLOW) diff --git a/devctl/generators/scaffold_nextjs.py b/devctl/generators/scaffold_nextjs.py index 63ff3ae..5014a12 100644 --- a/devctl/generators/scaffold_nextjs.py +++ b/devctl/generators/scaffold_nextjs.py @@ -4,11 +4,13 @@ """ import os + import typer + from devctl.orchestrator.scanner import detect_environment -def generate_nextjs_resource(resource_name: str, fields_str: str, root_path: str = "."): +def generate_nextjs_resource(resource_name: str, _fields_str: str, root_path: str = "."): """ Scaffolds a NextJS resource (Page, Component). """ @@ -25,7 +27,7 @@ def generate_nextjs_resource(resource_name: str, fields_str: str, root_path: str # Structure: src/app/resource-name/page.tsx app_dir = os.path.join(nextjs_root, "src", "app", resource_lower) components_dir = os.path.join(nextjs_root, "src", "components") - + os.makedirs(app_dir, exist_ok=True) os.makedirs(components_dir, exist_ok=True) diff --git a/devctl/generators/scaffold_nodejs.py b/devctl/generators/scaffold_nodejs.py index c0d0233..0e4b776 100644 --- a/devctl/generators/scaffold_nodejs.py +++ b/devctl/generators/scaffold_nodejs.py @@ -4,30 +4,32 @@ """ import os + import typer + from devctl.orchestrator.scanner import detect_environment -def generate_nodejs_resource(resource_name: str, fields_str: str, root_path: str = "."): +def generate_nodejs_resource(resource_name: str, _fields_str: str, root_path: str = "."): """ Scaffolds a NodeJS/Express resource. """ # Using basic manual file writing for simplicity and speed # In a more advanced version, we could use Jinja2 templates - + resource_lower = resource_name.lower() entity_name = resource_name.capitalize() - + src_dir = os.path.join(root_path, "src") if not os.path.exists(src_dir): # Try to find nodejs path from scanner - env_state = detect_environment(root_path) + detect_environment(root_path) # We need to add has_nodejs to scanner or just check if src exists in root src_dir = os.path.join(root_path, "src") routes_dir = os.path.join(src_dir, "routes") controllers_dir = os.path.join(src_dir, "controllers") - + os.makedirs(routes_dir, exist_ok=True) os.makedirs(controllers_dir, exist_ok=True) @@ -47,9 +49,9 @@ def generate_nodejs_resource(resource_name: str, fields_str: str, root_path: str export const create{entity_name} = (req: Request, res: Response) => {{ const data = req.body; - res.status(201).json({{ + res.status(201).json({{ message: '{entity_name} created', - data + data }}); }}; """ @@ -76,4 +78,4 @@ def generate_nodejs_resource(resource_name: str, fields_str: str, root_path: str typer.secho(f"āœ… {entity_name} NodeJS resource successfully generated!", fg=typer.colors.GREEN) typer.echo(f" - Created: controllers/{resource_lower}.controller.ts") typer.echo(f" - Created: routes/{resource_lower}.routes.ts") - typer.echo(f"šŸ’” Don't forget to register the route in your main app file.") + typer.echo("šŸ’” Don't forget to register the route in your main app file.") diff --git a/devctl/generators/scaffold_react.py b/devctl/generators/scaffold_react.py index 2d98e9e..e6f751a 100644 --- a/devctl/generators/scaffold_react.py +++ b/devctl/generators/scaffold_react.py @@ -4,7 +4,9 @@ """ import os + import typer + from devctl.orchestrator.scanner import detect_environment @@ -25,7 +27,7 @@ def generate_react_resource(resource_name: str, fields_str: str, root_path: str # Structure: src/components/ResourceName/... components_dir = os.path.join(react_root, "src", "components", entity_name) services_dir = os.path.join(react_root, "src", "services") - + os.makedirs(components_dir, exist_ok=True) os.makedirs(services_dir, exist_ok=True) diff --git a/devctl/generators/scaffold_svelte.py b/devctl/generators/scaffold_svelte.py index 61684e8..21cd384 100644 --- a/devctl/generators/scaffold_svelte.py +++ b/devctl/generators/scaffold_svelte.py @@ -4,11 +4,13 @@ """ import os + import typer + from devctl.orchestrator.scanner import detect_environment -def generate_svelte_resource(resource_name: str, fields_str: str, root_path: str = "."): +def generate_svelte_resource(resource_name: str, _fields_str: str, root_path: str = "."): """ Scaffolds a Svelte resource (Route, Component). """ @@ -25,7 +27,7 @@ def generate_svelte_resource(resource_name: str, fields_str: str, root_path: str # Structure: src/routes/resource-name/+page.svelte routes_dir = os.path.join(svelte_root, "src", "routes", resource_lower) components_dir = os.path.join(svelte_root, "src", "lib", "components") - + os.makedirs(routes_dir, exist_ok=True) os.makedirs(components_dir, exist_ok=True) diff --git a/devctl/generators/svelte.py b/devctl/generators/svelte.py index 09fa4c6..d3c7563 100644 --- a/devctl/generators/svelte.py +++ b/devctl/generators/svelte.py @@ -5,6 +5,7 @@ import os import subprocess + import typer @@ -12,31 +13,40 @@ def generate_svelte_boilerplate(project_name: str) -> bool: """ Generates a new SvelteKit project using create-svelte via npx. """ - typer.secho(f"šŸ”„ Generating Svelte project '{project_name}'...", fg=typer.colors.CYAN) + typer.secho(f"Generating Svelte project '{project_name}'...", fg=typer.colors.CYAN) safe_name = project_name.lower().replace("_", "-") try: - typer.secho("šŸ“¦ Scaffolding SvelteKit project...", fg=typer.colors.CYAN) + typer.secho("Scaffolding SvelteKit project...", fg=typer.colors.CYAN) # Using a non-interactive way to scaffold svelte # We'll use the 'skeleton' template with TypeScript subprocess.run( - ["npx", "sv", "create", safe_name, "--template", "skeleton", "--types", "typescript", "--no-install", "--no-git"], - check=True + [ + "npx", + "sv", + "create", + safe_name, + "--template", + "skeleton", + "--types", + "typescript", + "--no-install", + "--no-git", + ], + check=True, ) project_full_path = os.path.join(os.getcwd(), safe_name) - typer.secho("ā³ Installing npm dependencies...", fg=typer.colors.CYAN) + typer.secho("Installing npm dependencies...", fg=typer.colors.CYAN) subprocess.run(["npm", "install"], cwd=project_full_path, check=True) - typer.secho( - f"āœ… Svelte project '{safe_name}' successfully generated!", fg=typer.colors.GREEN - ) + typer.secho(f"Svelte project '{safe_name}' successfully generated!", fg=typer.colors.GREEN) return True except subprocess.CalledProcessError as e: - typer.secho(f"āŒ Svelte creation failed with code: {e.returncode}", fg=typer.colors.RED) + typer.secho(f"Error: Svelte creation failed with code: {e.returncode}", fg=typer.colors.RED) return False except Exception as e: - typer.secho(f"āŒ Svelte initialization failed: {e}", fg=typer.colors.RED) + typer.secho(f"Error: Svelte initialization failed: {e}", fg=typer.colors.RED) return False diff --git a/devctl/generators/vue.py b/devctl/generators/vue.py index a04893b..dcd7280 100644 --- a/devctl/generators/vue.py +++ b/devctl/generators/vue.py @@ -95,11 +95,11 @@ def generate_vue_boilerplate(project_name: str) -> bool: setup_vue_router(project_full_path) # ---------------------------------------- - typer.secho( - f"Vue.js frontend '{safe_name}' successfully generated!", fg=typer.colors.GREEN - ) + typer.secho(f"Vue.js frontend '{safe_name}' successfully generated!", fg=typer.colors.GREEN) return True except subprocess.CalledProcessError as e: - typer.secho(f"Error: Vue/Vite process failed with code: {e.returncode}", fg=typer.colors.RED) + typer.secho( + f"Error: Vue/Vite process failed with code: {e.returncode}", fg=typer.colors.RED + ) return False diff --git a/devctl/orchestrator/runner.py b/devctl/orchestrator/runner.py index 2ddc132..6ada88a 100644 --- a/devctl/orchestrator/runner.py +++ b/devctl/orchestrator/runner.py @@ -3,12 +3,12 @@ Handles parallel process management for multi-tier applications with log prefixing. """ +import os +import signal import subprocess import sys -import time import threading -import signal -import os +import time from pathlib import Path from typing import List @@ -51,7 +51,7 @@ def launch_dev_environment(projects: List[DockerProject], docker_composes: List[ """ global active_processes - def signal_handler(sig, frame): + def signal_handler(_sig, _frame): typer.echo("\nShutdown requested. Cleaning up...") cleanup_and_exit(docker_composes) @@ -67,10 +67,8 @@ def signal_handler(sig, frame): for compose_path in docker_composes: typer.secho(f"Starting Docker Compose in {compose_path}...", fg=typer.colors.CYAN) - subprocess.run( - ["docker", "compose", "up", "-d"], cwd=str(compose_path), check=True - ) - + subprocess.run(["docker", "compose", "up", "-d"], cwd=str(compose_path), check=True) + typer.echo("Waiting 5s for databases to initialize...") time.sleep(5) @@ -78,7 +76,7 @@ def signal_handler(sig, frame): backends = [p for p in projects if p.kind == "spring"] for p in backends: typer.secho(f"Starting Spring Boot: {p.name}...", fg=typer.colors.GREEN) - + proc = subprocess.Popen( ["./mvnw", "spring-boot:run"], cwd=str(p.path), @@ -87,13 +85,15 @@ def signal_handler(sig, frame): bufsize=1, ) active_processes.append((p.name, proc)) - + t = threading.Thread(target=stream_logs, args=(p.name, proc, "green"), daemon=True) t.start() active_threads.append(t) # 3. Start Frontends (Angular / Vue / React / NextJS / Svelte) - frontends = [p for p in projects if p.kind in ["angular", "vue", "react", "nextjs", "svelte"]] + frontends = [ + p for p in projects if p.kind in ["angular", "vue", "react", "nextjs", "svelte"] + ] for p in frontends: if p.kind == "angular": color = "cyan" @@ -107,12 +107,15 @@ def signal_handler(sig, frame): elif p.kind == "nextjs": color = "yellow" cmd = ["npm", "run", "dev"] - else: # svelte + else: # svelte color = "red" cmd = ["npm", "run", "dev"] - - typer.secho(f"Starting {p.kind.capitalize()}: {p.name}...", fg=getattr(typer.colors, color.upper())) - + + typer.secho( + f"Starting {p.kind.capitalize()}: {p.name}...", + fg=getattr(typer.colors, color.upper()), + ) + proc = subprocess.Popen( cmd, cwd=str(p.path), @@ -121,7 +124,7 @@ def signal_handler(sig, frame): bufsize=1, ) active_processes.append((p.name, proc)) - + t = threading.Thread(target=stream_logs, args=(p.name, proc, color), daemon=True) t.start() active_threads.append(t) @@ -130,7 +133,7 @@ def signal_handler(sig, frame): nest_apps = [p for p in projects if p.kind == "nest"] for p in nest_apps: typer.secho(f"Starting NestJS: {p.name}...", fg=typer.colors.MAGENTA) - + proc = subprocess.Popen( ["npm", "run", "start:dev"], cwd=str(p.path), @@ -139,7 +142,7 @@ def signal_handler(sig, frame): bufsize=1, ) active_processes.append((p.name, proc)) - + t = threading.Thread(target=stream_logs, args=(p.name, proc, "magenta"), daemon=True) t.start() active_threads.append(t) @@ -148,7 +151,7 @@ def signal_handler(sig, frame): nodejs_apps = [p for p in projects if p.kind == "nodejs"] for p in nodejs_apps: typer.secho(f"Starting NodeJS: {p.name}...", fg=typer.colors.GREEN) - + proc = subprocess.Popen( ["npm", "run", "dev"], cwd=str(p.path), @@ -157,7 +160,7 @@ def signal_handler(sig, frame): bufsize=1, ) active_processes.append((p.name, proc)) - + t = threading.Thread(target=stream_logs, args=(p.name, proc, "green"), daemon=True) t.start() active_threads.append(t) @@ -166,11 +169,11 @@ def signal_handler(sig, frame): fastapi_apps = [p for p in projects if p.kind == "fastapi"] for p in fastapi_apps: typer.secho(f"Starting FastAPI: {p.name}...", fg=typer.colors.CYAN) - + venv_python = os.path.join(str(p.path), ".venv", "bin", "python3") if not os.path.exists(venv_python): venv_python = "python3" - + proc = subprocess.Popen( [venv_python, "-m", "uvicorn", "main:app", "--reload"], cwd=str(p.path), @@ -179,7 +182,7 @@ def signal_handler(sig, frame): bufsize=1, ) active_processes.append((p.name, proc)) - + t = threading.Thread(target=stream_logs, args=(p.name, proc, "cyan"), daemon=True) t.start() active_threads.append(t) @@ -188,11 +191,11 @@ def signal_handler(sig, frame): django_apps = [p for p in projects if p.kind == "django"] for p in django_apps: typer.secho(f"Starting Django: {p.name}...", fg=typer.colors.GREEN) - + venv_python = os.path.join(str(p.path), ".venv", "bin", "python3") if not os.path.exists(venv_python): venv_python = "python3" - + proc = subprocess.Popen( [venv_python, "manage.py", "runserver"], cwd=str(p.path), @@ -201,7 +204,7 @@ def signal_handler(sig, frame): bufsize=1, ) active_processes.append((p.name, proc)) - + t = threading.Thread(target=stream_logs, args=(p.name, proc, "green"), daemon=True) t.start() active_threads.append(t) @@ -210,7 +213,7 @@ def signal_handler(sig, frame): go_apps = [p for p in projects if p.kind == "go"] for p in go_apps: typer.secho(f"Starting Go: {p.name}...", fg=typer.colors.CYAN) - + proc = subprocess.Popen( ["go", "run", "."], cwd=str(p.path), @@ -219,13 +222,15 @@ def signal_handler(sig, frame): bufsize=1, ) active_processes.append((p.name, proc)) - + t = threading.Thread(target=stream_logs, args=(p.name, proc, "cyan"), daemon=True) t.start() active_threads.append(t) if not active_processes and not docker_composes: - typer.secho("Warning: No projects or databases detected to run.", fg=typer.colors.YELLOW) + typer.secho( + "Warning: No projects or databases detected to run.", fg=typer.colors.YELLOW + ) return typer.secho( @@ -240,7 +245,10 @@ def signal_handler(sig, frame): # Check if any process has died unexpectedly for name, proc in active_processes: if proc.poll() is not None: - typer.secho(f"Warning: Process {name} exited with code {proc.returncode}", fg=typer.colors.RED) + typer.secho( + f"Warning: Process {name} exited with code {proc.returncode}", + fg=typer.colors.RED, + ) active_processes.remove((name, proc)) except Exception as e: diff --git a/devctl/orchestrator/scanner.py b/devctl/orchestrator/scanner.py index 9db7183..131d854 100644 --- a/devctl/orchestrator/scanner.py +++ b/devctl/orchestrator/scanner.py @@ -1,10 +1,11 @@ """ Project scanner and environment detector. -Identifies Spring Boot, Angular, Vue.js, React, NextJS, NestJS, NodeJS, FastAPI, Django, Svelte, Go, and Docker components in a directory tree. +Identifies Spring Boot, Angular, Vue.js, React, NextJS, NestJS, NodeJS, FastAPI, +Django, Svelte, Go, and Docker components in a directory tree. """ -import os import json +import os def detect_environment(root_path: str = "."): @@ -42,7 +43,19 @@ def detect_environment(root_path: str = "."): for dirpath, _dirnames, filenames in os.walk(root_path): # Optimization: ignore heavy folders for an instant scan - if any(ignored in dirpath for ignored in ["node_modules", "target", ".git", ".angular", "dist", ".next", ".venv", ".svelte-kit"]): + if any( + ignored in dirpath + for ignored in [ + "node_modules", + "target", + ".git", + ".angular", + "dist", + ".next", + ".venv", + ".svelte-kit", + ] + ): continue if "docker-compose.yml" in filenames and not env_state["has_docker_compose"]: @@ -58,7 +71,9 @@ def detect_environment(root_path: str = "."): env_state["angular_path"] = dirpath vue_files = ["vite.config.ts", "vite.config.js"] - if any(f in filenames for f in vue_files) and not any([env_state["has_vue"], env_state["has_react"]]): + if any(f in filenames for f in vue_files) and not any( + [env_state["has_vue"], env_state["has_react"]] + ): # Distinguish by package.json pkg_path = os.path.join(dirpath, "package.json") if os.path.exists(pkg_path): @@ -87,7 +102,15 @@ def detect_environment(root_path: str = "."): env_state["has_nextjs"] = True env_state["nextjs_path"] = dirpath - if "package.json" in filenames and not any([env_state["has_angular"], env_state["has_vue"], env_state["has_react"], env_state["has_nest"], env_state["has_nextjs"]]): + if "package.json" in filenames and not any( + [ + env_state["has_angular"], + env_state["has_vue"], + env_state["has_react"], + env_state["has_nest"], + env_state["has_nextjs"], + ] + ): env_state["has_nodejs"] = True env_state["nodejs_path"] = dirpath diff --git a/test/test_add.py b/test/test_add.py index 22c7ef4..57d1d24 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -1,16 +1,18 @@ from unittest.mock import patch + from typer.testing import CliRunner + from devctl.main import app -import os runner = CliRunner() + def test_add_resource_spring(tmp_path): with patch("devctl.commands.add.detect_environment") as mock_detect: mock_detect.return_value = { "has_spring": True, "spring_path": str(tmp_path), - "has_angular": False + "has_angular": False, } with patch("devctl.commands.add.generate_spring_resource") as mock_gen: # Create a mock pom.xml so it looks like a spring project @@ -19,12 +21,13 @@ def test_add_resource_spring(tmp_path): assert result.exit_code == 0 mock_gen.assert_called_once() + def test_add_resource_angular(tmp_path): with patch("devctl.commands.add.detect_environment") as mock_detect: mock_detect.return_value = { "has_spring": False, "has_angular": True, - "angular_path": str(tmp_path) + "angular_path": str(tmp_path), } with patch("devctl.commands.add.generate_angular_resource") as mock_gen: result = runner.invoke(app, ["add", "resource", "User"]) diff --git a/test/test_django.py b/test/test_django.py index 427134d..a49a608 100644 --- a/test/test_django.py +++ b/test/test_django.py @@ -1,11 +1,14 @@ import os from unittest.mock import patch + from typer.testing import CliRunner + from devctl.main import app runner = CliRunner() -def test_init_django_calls_boilerplate(tmp_path): + +def test_init_django_calls_boilerplate(): """Ensure init django calls the boilerplate generator.""" with patch("devctl.commands.init.generate_django_boilerplate") as mock_gen: mock_gen.return_value = True @@ -14,24 +17,26 @@ def test_init_django_calls_boilerplate(tmp_path): assert result.exit_code == 0 mock_gen.assert_called_once_with("my-django-app") + def test_add_resource_django(tmp_path): """Ensure add resource works in a Django project.""" (tmp_path / "manage.py").write_text("import os", encoding="utf-8") (tmp_path / "requirements.txt").write_text("django", encoding="utf-8") (tmp_path / "core").mkdir() - + with patch("devctl.commands.add.generate_django_resource") as mock_gen: os.chdir(tmp_path) result = runner.invoke(app, ["add", "resource", "User"]) assert result.exit_code == 0 mock_gen.assert_called_once() + def test_dockerize_django(tmp_path): """Ensure dockerize detects Django and generates a Dockerfile.""" (tmp_path / "manage.py").write_text("import os", encoding="utf-8") (tmp_path / "requirements.txt").write_text("django", encoding="utf-8") (tmp_path / "core").mkdir() - + result = runner.invoke(app, ["dockerize", str(tmp_path)]) assert result.exit_code == 0 assert (tmp_path / "Dockerfile").exists() diff --git a/test/test_fastapi.py b/test/test_fastapi.py index df018a9..d5c5d39 100644 --- a/test/test_fastapi.py +++ b/test/test_fastapi.py @@ -1,11 +1,14 @@ import os from unittest.mock import patch + from typer.testing import CliRunner + from devctl.main import app runner = CliRunner() -def test_init_fastapi_calls_boilerplate(tmp_path): + +def test_init_fastapi_calls_boilerplate(): """Ensure init fastapi calls the boilerplate generator.""" with patch("devctl.commands.init.generate_fastapi_boilerplate") as mock_gen: mock_gen.return_value = True @@ -14,22 +17,24 @@ def test_init_fastapi_calls_boilerplate(tmp_path): assert result.exit_code == 0 mock_gen.assert_called_once_with("my-fast-app") + def test_add_resource_fastapi(tmp_path): """Ensure add resource works in a FastAPI project.""" (tmp_path / "main.py").write_text("from fastapi import FastAPI", encoding="utf-8") (tmp_path / "requirements.txt").write_text("fastapi", encoding="utf-8") - + with patch("devctl.commands.add.generate_fastapi_resource") as mock_gen: os.chdir(tmp_path) result = runner.invoke(app, ["add", "resource", "User"]) assert result.exit_code == 0 mock_gen.assert_called_once() + def test_dockerize_fastapi(tmp_path): """Ensure dockerize detects FastAPI and generates a Dockerfile.""" (tmp_path / "main.py").write_text("from fastapi import FastAPI", encoding="utf-8") (tmp_path / "requirements.txt").write_text("fastapi", encoding="utf-8") - + result = runner.invoke(app, ["dockerize", str(tmp_path)]) assert result.exit_code == 0 assert (tmp_path / "Dockerfile").exists() diff --git a/test/test_go.py b/test/test_go.py index 2d19433..371e778 100644 --- a/test/test_go.py +++ b/test/test_go.py @@ -1,11 +1,14 @@ import os from unittest.mock import patch + from typer.testing import CliRunner + from devctl.main import app runner = CliRunner() -def test_init_go_calls_boilerplate(tmp_path): + +def test_init_go_calls_boilerplate(): """Ensure init go calls the boilerplate generator.""" with patch("devctl.commands.init.generate_go_boilerplate") as mock_gen: mock_gen.return_value = True @@ -14,20 +17,22 @@ def test_init_go_calls_boilerplate(tmp_path): assert result.exit_code == 0 mock_gen.assert_called_once_with("my-go-app") + def test_add_resource_go(tmp_path): """Ensure add resource works in a Go project.""" (tmp_path / "go.mod").write_text("module my-go-app", encoding="utf-8") - + with patch("devctl.commands.add.generate_go_resource") as mock_gen: os.chdir(tmp_path) result = runner.invoke(app, ["add", "resource", "User"]) assert result.exit_code == 0 mock_gen.assert_called_once() + def test_dockerize_go(tmp_path): """Ensure dockerize detects Go and generates a Dockerfile.""" (tmp_path / "go.mod").write_text("module my-go-app", encoding="utf-8") - + result = runner.invoke(app, ["dockerize", str(tmp_path)]) assert result.exit_code == 0 assert (tmp_path / "Dockerfile").exists() diff --git a/test/test_init.py b/test/test_init.py index 8647a35..6843ac7 100644 --- a/test/test_init.py +++ b/test/test_init.py @@ -1,10 +1,13 @@ from unittest.mock import patch + from typer.testing import CliRunner + from devctl.main import app runner = CliRunner() -def test_init_spring_success(tmp_path): + +def test_init_spring_success(): with patch("devctl.commands.init.download_spring_boilerplate", return_value=True) as mock_dl: with patch("devctl.commands.init.generate_config") as mock_cfg: with patch("devctl.commands.init.check_tool"): @@ -13,6 +16,7 @@ def test_init_spring_success(tmp_path): mock_dl.assert_called_once() mock_cfg.assert_called_once() + def test_init_angular_success(): with patch("devctl.commands.init.generate_angular_boilerplate", return_value=True) as mock_gen: with patch("devctl.commands.init.check_tool"): @@ -20,6 +24,7 @@ def test_init_angular_success(): assert result.exit_code == 0 mock_gen.assert_called_once_with("test-front") + def test_init_vue_success(): with patch("devctl.commands.init.generate_vue_boilerplate", return_value=True) as mock_gen: with patch("devctl.commands.init.check_tool"): diff --git a/test/test_mongodb.py b/test/test_mongodb.py index 8add1fc..2ad3b25 100644 --- a/test/test_mongodb.py +++ b/test/test_mongodb.py @@ -1,21 +1,24 @@ import yaml -from pathlib import Path from typer.testing import CliRunner + from devctl.main import app runner = CliRunner() + def test_deploy_mongodb(tmp_path): """Ensure deploy correctly detects and configures MongoDB.""" # Setup project with MongoDB properties backend = tmp_path / "mongo-api" backend.mkdir() - (backend / "pom.xml").write_text("mongo-api", encoding="utf-8") + (backend / "pom.xml").write_text( + "mongo-api", encoding="utf-8" + ) props = backend / "src" / "main" / "resources" props.mkdir(parents=True) (props / "application.properties").write_text( "spring.data.mongodb.uri=mongodb://admin:pass@localhost:27017/mydb?authSource=admin", - encoding="utf-8" + encoding="utf-8", ) result = runner.invoke(app, ["deploy", str(tmp_path)]) @@ -26,7 +29,7 @@ def test_deploy_mongodb(tmp_path): with open(compose_file, "r") as f: config = yaml.safe_load(f) - + services = config["services"] assert "mydb-db" in services db_service = services["mydb-db"] diff --git a/test/test_nestjs.py b/test/test_nestjs.py index f206620..1ea6b14 100644 --- a/test/test_nestjs.py +++ b/test/test_nestjs.py @@ -1,11 +1,14 @@ import os from unittest.mock import patch + from typer.testing import CliRunner + from devctl.main import app runner = CliRunner() -def test_init_nest_calls_boilerplate(tmp_path): + +def test_init_nest_calls_boilerplate(): """Ensure init nest calls the boilerplate generator.""" with patch("devctl.commands.init.generate_nest_boilerplate") as mock_gen: mock_gen.return_value = True @@ -14,11 +17,12 @@ def test_init_nest_calls_boilerplate(tmp_path): assert result.exit_code == 0 mock_gen.assert_called_once_with("my-nest-app") + def test_add_resource_nest(tmp_path): """Ensure add resource works in a NestJS project.""" # Setup a mock NestJS project (tmp_path / "nest-cli.json").write_text("{}", encoding="utf-8") - + with patch("devctl.commands.add.generate_nest_resource") as mock_gen: # We need to ensure detect_environment finds it os.chdir(tmp_path) @@ -30,11 +34,12 @@ def test_add_resource_nest(tmp_path): # Cleanup chdir if needed (though pytest tmp_path is usually fine) pass + def test_dockerize_nest(tmp_path): """Ensure dockerize detects NestJS and generates a Dockerfile.""" (tmp_path / "nest-cli.json").write_text("{}", encoding="utf-8") (tmp_path / "package.json").write_text('{"name": "nest-app"}', encoding="utf-8") - + result = runner.invoke(app, ["dockerize", str(tmp_path)]) assert result.exit_code == 0 assert (tmp_path / "Dockerfile").exists() diff --git a/test/test_nextjs.py b/test/test_nextjs.py index e2cae88..a13e76a 100644 --- a/test/test_nextjs.py +++ b/test/test_nextjs.py @@ -1,11 +1,14 @@ import os from unittest.mock import patch + from typer.testing import CliRunner + from devctl.main import app runner = CliRunner() -def test_init_nextjs_calls_boilerplate(tmp_path): + +def test_init_nextjs_calls_boilerplate(): """Ensure init nextjs calls the boilerplate generator.""" with patch("devctl.commands.init.generate_nextjs_boilerplate") as mock_gen: mock_gen.return_value = True @@ -14,21 +17,23 @@ def test_init_nextjs_calls_boilerplate(tmp_path): assert result.exit_code == 0 mock_gen.assert_called_once_with("my-next-app") + def test_add_resource_nextjs(tmp_path): """Ensure add resource works in a NextJS project.""" (tmp_path / "next.config.js").write_text("module.exports = {}", encoding="utf-8") - + with patch("devctl.commands.add.generate_nextjs_resource") as mock_gen: os.chdir(tmp_path) result = runner.invoke(app, ["add", "resource", "User"]) assert result.exit_code == 0 mock_gen.assert_called_once() + def test_dockerize_nextjs(tmp_path): """Ensure dockerize detects NextJS and generates a Dockerfile.""" (tmp_path / "next.config.js").write_text("module.exports = {}", encoding="utf-8") (tmp_path / "package.json").write_text('{"name": "next-app"}', encoding="utf-8") - + result = runner.invoke(app, ["dockerize", str(tmp_path)]) assert result.exit_code == 0 assert (tmp_path / "Dockerfile").exists() diff --git a/test/test_nodejs.py b/test/test_nodejs.py index b5a1cdb..16916a5 100644 --- a/test/test_nodejs.py +++ b/test/test_nodejs.py @@ -1,12 +1,14 @@ import os -import json from unittest.mock import patch + from typer.testing import CliRunner + from devctl.main import app runner = CliRunner() -def test_init_nodejs_calls_boilerplate(tmp_path): + +def test_init_nodejs_calls_boilerplate(): """Ensure init nodejs calls the boilerplate generator.""" with patch("devctl.commands.init.generate_nodejs_boilerplate") as mock_gen: mock_gen.return_value = True @@ -15,21 +17,23 @@ def test_init_nodejs_calls_boilerplate(tmp_path): assert result.exit_code == 0 mock_gen.assert_called_once_with("my-node-app") + def test_add_resource_nodejs(tmp_path): """Ensure add resource works in a NodeJS project.""" (tmp_path / "package.json").write_text('{"name": "node-app"}', encoding="utf-8") (tmp_path / "src").mkdir() - + with patch("devctl.commands.add.generate_nodejs_resource") as mock_gen: os.chdir(tmp_path) result = runner.invoke(app, ["add", "resource", "User"]) assert result.exit_code == 0 mock_gen.assert_called_once() + def test_dockerize_nodejs(tmp_path): """Ensure dockerize detects NodeJS and generates a Dockerfile.""" (tmp_path / "package.json").write_text('{"name": "node-app"}', encoding="utf-8") - + result = runner.invoke(app, ["dockerize", str(tmp_path)]) assert result.exit_code == 0 assert (tmp_path / "Dockerfile").exists() diff --git a/test/test_react.py b/test/test_react.py index 255b715..fcb3429 100644 --- a/test/test_react.py +++ b/test/test_react.py @@ -1,11 +1,14 @@ import os from unittest.mock import patch + from typer.testing import CliRunner + from devctl.main import app runner = CliRunner() -def test_init_react_calls_boilerplate(tmp_path): + +def test_init_react_calls_boilerplate(): """Ensure init react calls the boilerplate generator.""" with patch("devctl.commands.init.generate_react_boilerplate") as mock_gen: mock_gen.return_value = True @@ -14,22 +17,28 @@ def test_init_react_calls_boilerplate(tmp_path): assert result.exit_code == 0 mock_gen.assert_called_once_with("my-react-app") + def test_add_resource_react(tmp_path): """Ensure add resource works in a React project.""" - (tmp_path / "package.json").write_text('{"dependencies": {"react": "18.2.0"}}', encoding="utf-8") + (tmp_path / "package.json").write_text( + '{"dependencies": {"react": "18.2.0"}}', encoding="utf-8" + ) (tmp_path / "vite.config.ts").write_text("export default {}", encoding="utf-8") - + with patch("devctl.commands.add.generate_react_resource") as mock_gen: os.chdir(tmp_path) result = runner.invoke(app, ["add", "resource", "User"]) assert result.exit_code == 0 mock_gen.assert_called_once() + def test_dockerize_react(tmp_path): """Ensure dockerize detects React and generates a Dockerfile.""" - (tmp_path / "package.json").write_text('{"dependencies": {"react": "18.2.0"}}', encoding="utf-8") + (tmp_path / "package.json").write_text( + '{"dependencies": {"react": "18.2.0"}}', encoding="utf-8" + ) (tmp_path / "vite.config.ts").write_text("export default {}", encoding="utf-8") - + result = runner.invoke(app, ["dockerize", str(tmp_path)]) assert result.exit_code == 0 assert (tmp_path / "Dockerfile").exists() diff --git a/test/test_runner.py b/test/test_runner.py index fd90ecc..705b138 100644 --- a/test/test_runner.py +++ b/test/test_runner.py @@ -1,23 +1,30 @@ import subprocess -from unittest.mock import patch, MagicMock from pathlib import Path -from devctl.orchestrator.runner import launch_dev_environment, is_docker_running +from unittest.mock import MagicMock, patch + from devctl.generators.docker_scaffold import DockerProject +from devctl.orchestrator.runner import is_docker_running, launch_dev_environment + def test_is_docker_running_true(): with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0) assert is_docker_running() is True + def test_is_docker_running_false(): with patch("subprocess.run") as mock_run: mock_run.side_effect = subprocess.CalledProcessError(1, "docker info") assert is_docker_running() is False + def test_launch_dev_environment_empty(): with patch("typer.secho") as mock_secho: launch_dev_environment([], []) - mock_secho.assert_called_with("Warning: No projects or databases detected to run.", fg="yellow") + mock_secho.assert_called_with( + "Warning: No projects or databases detected to run.", fg="yellow" + ) + @patch("subprocess.Popen") @patch("subprocess.run") @@ -25,22 +32,34 @@ def test_launch_dev_environment_empty(): def test_launch_dev_environment_complex(mock_sleep, mock_run, mock_popen): # Setup mocks mock_popen.return_value = MagicMock(poll=lambda: None) - + projects = [ - DockerProject(kind="spring", path=Path("/tmp/spring"), name="api", service_name="api", relative_context="./api"), - DockerProject(kind="angular", path=Path("/tmp/angular"), name="front", service_name="front", relative_context="./front"), + DockerProject( + kind="spring", + path=Path("/tmp/spring"), + name="api", + service_name="api", + relative_context="./api", + ), + DockerProject( + kind="angular", + path=Path("/tmp/angular"), + name="front", + service_name="front", + relative_context="./front", + ), ] docker_composes = [Path("/tmp/db")] - + # Use an exception that is caught by launch_dev_environment to stop the loop mock_sleep.side_effect = Exception("Stop Loop") - + with patch("devctl.orchestrator.runner.is_docker_running", return_value=True): with patch("devctl.orchestrator.runner.cleanup_and_exit") as mock_cleanup: launch_dev_environment(projects, docker_composes) - + # Verify Docker Compose started mock_run.assert_any_call(["docker", "compose", "up", "-d"], cwd="/tmp/db", check=True) - + # Verify cleanup called mock_cleanup.assert_called_once() diff --git a/test/test_svelte.py b/test/test_svelte.py index f011297..1725494 100644 --- a/test/test_svelte.py +++ b/test/test_svelte.py @@ -1,11 +1,14 @@ import os from unittest.mock import patch + from typer.testing import CliRunner + from devctl.main import app runner = CliRunner() -def test_init_svelte_calls_boilerplate(tmp_path): + +def test_init_svelte_calls_boilerplate(): """Ensure init svelte calls the boilerplate generator.""" with patch("devctl.commands.init.generate_svelte_boilerplate") as mock_gen: mock_gen.return_value = True @@ -14,21 +17,23 @@ def test_init_svelte_calls_boilerplate(tmp_path): assert result.exit_code == 0 mock_gen.assert_called_once_with("my-svelte-app") + def test_add_resource_svelte(tmp_path): """Ensure add resource works in a Svelte project.""" (tmp_path / "svelte.config.js").write_text("export default {}", encoding="utf-8") - + with patch("devctl.commands.add.generate_svelte_resource") as mock_gen: os.chdir(tmp_path) result = runner.invoke(app, ["add", "resource", "User"]) assert result.exit_code == 0 mock_gen.assert_called_once() + def test_dockerize_svelte(tmp_path): """Ensure dockerize detects Svelte and generates a Dockerfile.""" (tmp_path / "svelte.config.js").write_text("export default {}", encoding="utf-8") (tmp_path / "package.json").write_text('{"name": "svelte-app"}', encoding="utf-8") - + result = runner.invoke(app, ["dockerize", str(tmp_path)]) assert result.exit_code == 0 assert (tmp_path / "Dockerfile").exists() diff --git a/test/test_utils.py b/test/test_utils.py index 2847c73..c72cbbb 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -1,14 +1,18 @@ -import pytest from unittest.mock import patch -from devctl.utils.dependencies import check_tool + +import pytest import typer +from devctl.utils.dependencies import check_tool + + def test_check_tool_success(): """Ensure check_tool does nothing if the tool exists.""" with patch("shutil.which", return_value="/usr/bin/docker"): # Should not raise any exception check_tool("docker") + def test_check_tool_failure(): """Ensure check_tool raises Exit(1) if the tool is missing.""" with patch("shutil.which", return_value=None): From 0a6c27db3098fadf5349edb7bf1330257375a8c8 Mon Sep 17 00:00:00 2001 From: Youssef Fellah Date: Mon, 1 Jun 2026 11:50:15 +0100 Subject: [PATCH 16/18] docs: overhaul README and add COMMANDS.md reference --- COMMANDS.md | 99 +++++++++++++++++++++++++++++++++++ README.md | 147 ++++++++++++++++++++-------------------------------- 2 files changed, 154 insertions(+), 92 deletions(-) create mode 100644 COMMANDS.md diff --git a/COMMANDS.md b/COMMANDS.md new file mode 100644 index 0000000..827787a --- /dev/null +++ b/COMMANDS.md @@ -0,0 +1,99 @@ +# devctl Commands Reference + +This document provides a comprehensive list of all commands available in the `devctl` CLI. + +## Overview + +`devctl` is organized into logical command groups. You can always run `devctl --help` or `devctl [command] --help` to get immediate assistance. + +--- + +## 1. Project Initialization (`devctl init`) + +Initialize a new project with a standard boilerplate and optimized configuration. + +| Command | Usage | Description | +| :--- | :--- | :--- | +| **Spring Boot** | `devctl init spring [name]` | Downloads a Spring Boot project from start.spring.io. | +| **Angular** | `devctl init angular [name]` | Scaffolds a new Angular project. | +| **Vue.js** | `devctl init vue [name]` | Scaffolds a new Vue 3 project via Vite. | +| **NestJS** | `devctl init nest [name]` | Scaffolds a new NestJS backend. | +| **NodeJS** | `devctl init nodejs [name]` | Scaffolds a clean Express + TypeScript project. | +| **ReactJS** | `devctl init react [name]` | Scaffolds a new React project via Vite. | +| **NextJS** | `devctl init nextjs [name]` | Scaffolds a new NextJS project with App Router. | +| **FastAPI** | `devctl init fastapi [name]` | Scaffolds a new FastAPI (Python) project. | +| **Django** | `devctl init django [name]` | Scaffolds a new Django REST Framework project. | +| **Svelte** | `devctl init svelte [name]` | Scaffolds a new SvelteKit project. | +| **Go** | `devctl init go [name]` | Scaffolds a new Go (Fiber) project. | + +### Options +* `--db [postgres|mysql|mongodb]`: Specify the database driver for Spring Boot projects (Default: `postgres`). +* `--port [number]`: Specify a custom local port for the project. + +--- + +## 2. Resource Scaffolding (`devctl add`) + +Inject business resources (Entities, Controllers, Services) into your existing project layers. + +| Command | Usage | Description | +| :--- | :--- | :--- | +| **Resource** | `devctl add resource [Name]` | Generates a full-stack feature for the given name. | + +### Parameters +* `--fields, -f`: Define fields in `name:type` format. + * Example: `devctl add resource Product -f "name:string,price:double"` + * Supported types: `string`, `int`, `double`, `float`, `boolean`, `date`. + +--- + +## 3. Local Orchestration (`devctl run`) + +Launch your entire development environment in a single terminal. + +| Command | Usage | Description | +| :--- | :--- | :--- | +| **Run** | `devctl run` | Scans for databases, backends, and frontends and runs them. | + +### Execution Logic +1. **Databases**: Starts Docker Compose services first and waits for initialization. +2. **Backends**: Launches Spring, Nest, Express, Python, or Go APIs in parallel. +3. **Frontends**: Launches Angular, Vue, React, Svelte, or NextJS dev servers. +4. **Logging**: All output is prefixed by service name and color-coded. + +--- + +## 4. Containerization (`devctl dockerize`) + +Generate optimized Dockerfiles for all detected projects. + +| Command | Usage | Description | +| :--- | :--- | :--- | +| **Dockerize** | `devctl dockerize [path]` | Scaffolds Multi-stage Dockerfiles for all services. | + +### Options +* `--force`: Overwrite existing Dockerfiles. +* `--dry-run`: Preview actions without writing files. + +--- + +## 5. Deployment Preparation (`devctl deploy`) + +Prepare for multi-service production or staging deployments. + +| Command | Usage | Description | +| :--- | :--- | :--- | +| **Deploy** | `devctl deploy [path]` | Generates a global `docker-compose.yml` for all services. | + +### Features +* **Automatic Linking**: Detects database configurations from backends and automatically links them to database services in the global compose file. +* **Context Aware**: Uses relative paths for builds to ensure the compose file is portable. + +--- + +## 6. Utilities + +| Command | Usage | Description | +| :--- | :--- | :--- | +| **Ping** | `devctl ping` | Returns "pong" to verify the CLI is operational. | +| **Help** | `devctl --help` | Displays the global or command-specific help menu. | diff --git a/README.md b/README.md index 7d51954..f7e75c5 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,86 @@ # devctl -`devctl` is a command-line interface (CLI) designed to automate and orchestrate the local development lifecycle, specifically tailored for Spring Boot, Angular, and Vue.js architectures. - -## Purpose - -Modern full-stack development involves repetitive configuration and environment management tasks that are prone to human error. `devctl` provides a unified point of control to automate these processes, allowing developers to focus on implementation rather than infrastructure. - -Key challenges addressed: -* Standardized database configuration via Docker. -* Automated security boilerplate generation (JWT, Filters, Configuration). -* Surgical CRUD layer generation (Entity, Repository, Service, Controller). -* Concurrent process management for multi-tier applications. -* Automated environment cleanup. - -## Architecture and Integration - -The tool is built on a modular Python 3 architecture leveraging industry-standard libraries: - -* **CLI Engine**: Typer-based interface for type-safe command execution. -* **Templating**: Jinja2 for dynamic generation of Java, TypeScript, and configuration assets. -* **Spring Integration**: Integration with the Spring Initializr API for tailored project bootstrapping. -* **Frontend Orchestration**: Native support for @angular/cli and Vite. -* **Orchestration Engine**: Recursive scanning for project detection and parallel process management. -* **Container Management**: Lifecycle management for Docker Compose, including volume persistence control. - -## System Requirements - -The following dependencies must be available in the system path: - -* **Python**: >= 3.9 -* **Docker & Docker Compose** -* **Java**: 17+ (for Spring Boot modules) -* **Node.js & npm**: (for Angular/Vue.js modules) -* **Angular CLI**: Required for Angular project initialization. +`devctl` is a professional command-line interface (CLI) designed to automate and +orchestrate the development lifecycle for modern full-stack architectures. It +provides a unified workflow for managing backends (Spring Boot, NestJS, Go, +FastAPI), frontends (Angular, Vue, React, NextJS, Svelte), and their +containerization. + +## Core Features + +- **Multi-Stack Orchestration**: Launch databases, multiple microservices, and + frontend dev servers with a single command. Includes real-time, prefixed log + streaming. +- **Full-Stack Scaffolding**: Generate consistent business resources + (Entities, DTOs, Controllers, Services) across both backend and frontend + layers simultaneously. +- **Instant Infrastructure**: Automatically generate optimized, multi-stage + `Dockerfiles` and global `docker-compose.yml` configurations by scanning + your project tree. +- **Security by Default**: Inject standardized JWT authentication and security + configurations into new projects. ## Installation -1. **Clone the repository**: - ```bash - git clone https://github.com/your-username/devctl.git - cd devctl - ``` - -2. **Configure a virtual environment**: - ```bash - python -m venv .venv - source .venv/bin/activate # Linux/macOS - # .venv\Scripts\activate # Windows - ``` - -3. **Install the package**: - ```bash - pip install -e . - ``` +You can install `devctl` locally for development: -4. **Verify installation**: - ```bash - devctl ping - ``` +```bash +git clone https://github.com/yss-ef/devctl.git +cd devctl +pip install -e . +``` -## Command Reference +## Quick Start -### Project Initialization +### 1. Initialize a Full-Stack Project -Bootstrap new projects with pre-configured defaults and security standards. +Create a new backend and frontend in seconds: -* **Spring Boot**: - ```bash - devctl init spring "api-service" --db [postgres|mysql] --port 5432 - ``` -* **Angular**: - ```bash - devctl init angular "web-client" - ``` -* **Vue.js**: - ```bash - devctl init vue "vue-client" - ``` +```bash +mkdir my-app && cd my-app +devctl init spring api --db postgres +devctl init angular front +``` -### Resource Scaffolding +### 2. Add a Business Resource -Inject business resources into existing project structures. The command automatically detects active modules and updates both backend and frontend layers. +Generate a complete vertical slice of your application: ```bash -devctl add resource "Product" --fields "name:string,price:double,quantity:int" +devctl add resource Product -f "name:string,price:double,quantity:int" ``` -*Supported types: string, int, double, float, boolean, date.* -### Orchestration +### 3. Run the Environment -Scan the current directory tree and launch all detected components (Database, Backend, Frontend) in parallel. Supports microservice architectures with log prefixing. +Launch everything in parallel with automatic database startup: ```bash devctl run ``` -Upon termination (Ctrl+C), `devctl` gracefully stops all processes and performs a clean teardown of Docker resources. - -### Dockerfile Scaffolding +## Supported Stacks -Generate Dockerfiles for all detected Spring Boot, Angular, and Vue/Vite services. +| Type | Frameworks / Technologies | +| :--- | :--- | +| **Backends** | Spring Boot, NestJS, Express (NodeJS), FastAPI, Django, Go (Fiber) | +| **Frontends** | Angular, Vue.js, ReactJS, NextJS, Svelte | +| **Databases** | PostgreSQL, MySQL, MongoDB | -```bash -devctl dockerize -``` - -### Global Deployment +## Documentation -Generate a unified `docker-compose.yml` for the entire project by scanning all subdirectories. +For a detailed reference of all available commands and their options, see the +[Commands Reference](COMMANDS.md). -```bash -devctl deploy -``` +## Contributing -This command automatically detects databases from Spring Boot properties and links them to the respective services in a global configuration. +Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for +development setup and code quality guidelines. ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file +for details. -Authored by Youssef Fellah. -Personal Project. +--- +Authored by Youssef Fellah. +Professional Development Orchestrator. From 719d49f5e9ab2a4ff9b083252bd2c199437eb3ba Mon Sep 17 00:00:00 2001 From: Youssef Fellah Date: Mon, 1 Jun 2026 12:06:14 +0100 Subject: [PATCH 17/18] fix: resolve regressions and broken code in runner and scanner --- devctl/orchestrator/runner.py | 14 +---- devctl/orchestrator/scanner.py | 103 ++++++++++++++++----------------- 2 files changed, 52 insertions(+), 65 deletions(-) diff --git a/devctl/orchestrator/runner.py b/devctl/orchestrator/runner.py index ecc6af6..0efea81 100644 --- a/devctl/orchestrator/runner.py +++ b/devctl/orchestrator/runner.py @@ -170,6 +170,7 @@ def signal_handler(_sig, _frame): for p in fastapi_apps: typer.secho(f"Starting FastAPI: {p.name}...", fg=typer.colors.CYAN) + # Use venv if exists venv_python = os.path.join(str(p.path), ".venv", "bin", "python3") if not os.path.exists(venv_python): venv_python = "python3" @@ -241,19 +242,6 @@ def signal_handler(_sig, _frame): # Keep the main thread alive while True: - # Monitor process health - for name, p in processes: - exit_code = p.poll() - if exit_code is not None: - typer.secho( - f"\nāŒ Critical Error: {name} process terminated unexpectedly " - f"(Exit code: {exit_code}).", - fg=typer.colors.RED, - bold=True, - ) - # Trigger shutdown logic - raise KeyboardInterrupt - time.sleep(1) # Check if any process has died unexpectedly for name, proc in active_processes: diff --git a/devctl/orchestrator/scanner.py b/devctl/orchestrator/scanner.py index e289f19..2961689 100644 --- a/devctl/orchestrator/scanner.py +++ b/devctl/orchestrator/scanner.py @@ -19,6 +19,8 @@ "venv", ".venv", "__pycache__", + ".next", + ".svelte-kit", } @@ -53,25 +55,15 @@ def detect_environment(root_path: str = "."): "svelte_path": None, "has_go": False, "go_path": None, - "project_root": os.path.abspath(root_path), + "project_root": str(root), } - for dirpath, _dirnames, filenames in os.walk(root_path): - # Optimization: ignore heavy folders for an instant scan - if any( - ignored in dirpath - for ignored in [ - "node_modules", - "target", - ".git", - ".angular", - "dist", - ".next", - ".venv", - ".svelte-kit", - ] - ): - continue + for dirpath, dirnames, filenames in os.walk(root): + # In-place modification of dirnames to prune the traversal + dirnames[:] = [d for d in dirnames if d not in IGNORED_DIRECTORIES] + + current_path = Path(dirpath) + filename_set = set(filenames) # 1. Docker Compose detection if "docker-compose.yml" in filename_set and not env_state["has_docker_compose"]: @@ -88,39 +80,44 @@ def detect_environment(root_path: str = "."): env_state["has_angular"] = True env_state["angular_path"] = str(current_path) - vue_files = ["vite.config.ts", "vite.config.js"] - if any(f in filenames for f in vue_files) and not any( + # 4. Vite-based detection (Vue/React) + vue_markers = {"vite.config.ts", "vite.config.js"} + if (vue_markers & filename_set) and not any( [env_state["has_vue"], env_state["has_react"]] ): # Distinguish by package.json - pkg_path = os.path.join(dirpath, "package.json") - if os.path.exists(pkg_path): + pkg_path = current_path / "package.json" + if pkg_path.exists(): try: - with open(pkg_path, "r") as f: - pkg = json.load(f) - all_deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})} - if "react" in all_deps: - env_state["has_react"] = True - env_state["react_path"] = dirpath - else: - env_state["has_vue"] = True - env_state["vue_path"] = dirpath + pkg = json.loads(pkg_path.read_text(encoding="utf-8")) + deps = pkg.get("dependencies", {}) + dev_deps = pkg.get("devDependencies", {}) + all_deps = {**deps, **dev_deps} + if "react" in all_deps: + env_state["has_react"] = True + env_state["react_path"] = str(current_path) + else: + env_state["has_vue"] = True + env_state["vue_path"] = str(current_path) except Exception: env_state["has_vue"] = True - env_state["vue_path"] = dirpath + env_state["vue_path"] = str(current_path) else: env_state["has_vue"] = True - env_state["vue_path"] = dirpath + env_state["vue_path"] = str(current_path) - if "nest-cli.json" in filenames and not env_state["has_nest"]: + # 5. NestJS detection + if "nest-cli.json" in filename_set and not env_state["has_nest"]: env_state["has_nest"] = True - env_state["nest_path"] = dirpath + env_state["nest_path"] = str(current_path) - if any(f.startswith("next.config.") for f in filenames) and not env_state["has_nextjs"]: + # 6. NextJS detection + if any(f.startswith("next.config.") for f in filename_set) and not env_state["has_nextjs"]: env_state["has_nextjs"] = True - env_state["nextjs_path"] = dirpath + env_state["nextjs_path"] = str(current_path) - if "package.json" in filenames and not any( + # 7. Generic NodeJS detection (if not already caught) + if "package.json" in filename_set and not any( [ env_state["has_angular"], env_state["has_vue"], @@ -130,28 +127,30 @@ def detect_environment(root_path: str = "."): ] ): env_state["has_nodejs"] = True - env_state["nodejs_path"] = dirpath + env_state["nodejs_path"] = str(current_path) - if "requirements.txt" in filenames: - req_path = os.path.join(dirpath, "requirements.txt") + # 8. Python detection (FastAPI/Django) + if "requirements.txt" in filename_set: + req_path = current_path / "requirements.txt" try: - with open(req_path, "r") as f: - req_content = f.read().lower() - if "fastapi" in req_content and not env_state["has_fastapi"]: - env_state["has_fastapi"] = True - env_state["fastapi_path"] = dirpath - if "django" in req_content and not env_state["has_django"]: - env_state["has_django"] = True - env_state["django_path"] = dirpath + req_content = req_path.read_text().lower() + if "fastapi" in req_content and not env_state["has_fastapi"]: + env_state["has_fastapi"] = True + env_state["fastapi_path"] = str(current_path) + if "django" in req_content and not env_state["has_django"]: + env_state["has_django"] = True + env_state["django_path"] = str(current_path) except Exception: pass - if "svelte.config.js" in filenames and not env_state["has_svelte"]: + # 9. Svelte detection + if "svelte.config.js" in filename_set and not env_state["has_svelte"]: env_state["has_svelte"] = True - env_state["svelte_path"] = dirpath + env_state["svelte_path"] = str(current_path) - if "go.mod" in filenames and not env_state["has_go"]: + # 10. Go detection + if "go.mod" in filename_set and not env_state["has_go"]: env_state["has_go"] = True - env_state["go_path"] = dirpath + env_state["go_path"] = str(current_path) return env_state From eb7b635c93ab08182fdd0e7d6038f8b9f3655536 Mon Sep 17 00:00:00 2001 From: Youssef Fellah Date: Mon, 1 Jun 2026 12:07:06 +0100 Subject: [PATCH 18/18] fix: ruff formatting in scanner.py --- devctl/orchestrator/scanner.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/devctl/orchestrator/scanner.py b/devctl/orchestrator/scanner.py index 2961689..dfa566e 100644 --- a/devctl/orchestrator/scanner.py +++ b/devctl/orchestrator/scanner.py @@ -82,9 +82,7 @@ def detect_environment(root_path: str = "."): # 4. Vite-based detection (Vue/React) vue_markers = {"vite.config.ts", "vite.config.js"} - if (vue_markers & filename_set) and not any( - [env_state["has_vue"], env_state["has_react"]] - ): + if (vue_markers & filename_set) and not any([env_state["has_vue"], env_state["has_react"]]): # Distinguish by package.json pkg_path = current_path / "package.json" if pkg_path.exists():