From ec3c6fa0dab8c38292882c0aca0012e25d9811ec Mon Sep 17 00:00:00 2001 From: Matthew Powelson Date: Thu, 14 Aug 2025 12:11:18 -0400 Subject: [PATCH 1/9] Add project config --- arm_cli/config.py | 95 ++++++++++++++++++++++++++- arm_cli/self/self.py | 78 ++++++++++++++++++++-- arm_cli/system/system.py | 12 +++- pyproject.toml | 1 + resources/default_project_config.json | 18 +++++ 5 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 resources/default_project_config.json diff --git a/arm_cli/config.py b/arm_cli/config.py index 65f70a9..18e54d0 100644 --- a/arm_cli/config.py +++ b/arm_cli/config.py @@ -1,10 +1,24 @@ import json +import shutil from pathlib import Path +from typing import Dict, Optional import appdirs from pydantic import BaseModel +class ProjectConfig(BaseModel): + """Configuration schema for individual projects.""" + + name: str + description: Optional[str] = None + docker_compose_file: Optional[str] = None + data_directory: Optional[str] = None + resources: Optional[Dict[str, str]] = None + skills: Optional[Dict[str, str]] = None + monitoring: Optional[Dict[str, str]] = None + + class Config(BaseModel): """Configuration schema for the CLI.""" @@ -23,6 +37,65 @@ def get_config_file() -> Path: return get_config_dir() / "config.json" +def get_default_project_config_path() -> Path: + """Get the path to the default project configuration in the repository.""" + # First try to find it relative to the current file (development) + current_file = Path(__file__) + dev_path = current_file.parent.parent / "resources" / "default_project_config.json" + + if dev_path.exists(): + return dev_path + + # If not found in development, try to find it in the installed package + try: + import arm_cli + + package_dir = Path(arm_cli.__file__).parent + # When installed, the resources directory is at the root level of the package + installed_path = package_dir.parent / "resources" / "default_project_config.json" + + if installed_path.exists(): + return installed_path + except ImportError: + pass + + # Fallback: try to find it in the current working directory + cwd_path = Path.cwd() / "resources" / "default_project_config.json" + if cwd_path.exists(): + return cwd_path + + raise FileNotFoundError("Could not find default_project_config.json in any expected location") + + +def copy_default_project_config() -> Path: + """Copy the default project config from repository to user config directory.""" + config_dir = get_config_dir() + default_config_path = get_default_project_config_path() + user_config_path = config_dir / "default_project_config.json" + + if not default_config_path.exists(): + raise FileNotFoundError(f"Default project config not found at {default_config_path}") + + shutil.copy2(default_config_path, user_config_path) + return user_config_path + + +def load_project_config(project_path: str) -> ProjectConfig: + """Load a project configuration from file.""" + config_path = Path(project_path) + + # If it's a relative path, make it relative to the config directory + if not config_path.is_absolute(): + config_path = get_config_dir() / config_path + + if not config_path.exists(): + raise FileNotFoundError(f"Project config not found at {config_path}") + + with open(config_path, "r") as f: + data = json.load(f) + return ProjectConfig(**data) + + def load_config() -> Config: """Load configuration from file, creating default if it doesn't exist.""" config_file = get_config_file() @@ -39,7 +112,7 @@ def load_config() -> Config: return Config(**data) except (json.JSONDecodeError, KeyError, TypeError) as e: # If config is corrupted, create a new one - print(f"Warning: Config file corrupted, creating new default config: " f"{e}") + print(f"Warning: Config file corrupted, creating new default config: {e}") default_config = Config() save_config(default_config) return default_config @@ -54,3 +127,23 @@ def save_config(config: Config) -> None: with open(config_file, "w") as f: json.dump(config.model_dump(), f, indent=2) + + +def get_active_project_config(config: Config) -> Optional[ProjectConfig]: + """Get the active project configuration.""" + if not config.active_project: + # No active project set, copy default and set it + try: + default_path = copy_default_project_config() + config.active_project = str(default_path) + save_config(config) + return load_project_config(config.active_project) + except Exception as e: + print(f"Error setting up default project config: {e}") + return None + + try: + return load_project_config(config.active_project) + except FileNotFoundError: + print(f"Active project config not found at {config.active_project}") + return None diff --git a/arm_cli/self/self.py b/arm_cli/self/self.py index 5d97954..44790af 100644 --- a/arm_cli/self/self.py +++ b/arm_cli/self/self.py @@ -1,11 +1,17 @@ import os import subprocess import sys +from pathlib import Path import click from click.core import ParameterSource -from arm_cli.config import save_config +from arm_cli.config import ( + get_active_project_config, + get_config_dir, + load_project_config, + save_config, +) @click.group() @@ -60,16 +66,78 @@ def update(ctx, source, force): @self.command() -@click.option("--project", help="Set the active project") +@click.option("--project", help="Set the active project config file") +@click.option("--list-projects", is_flag=True, help="List all available project config files") +@click.option("--show-project", is_flag=True, help="Show current project configuration") @click.pass_context -def config(ctx, project): +def config(ctx, project, list_projects, show_project): """Manage CLI configuration""" config = ctx.obj["config"] + if list_projects: + config_dir = get_config_dir() + print("Available project config files:") + for config_file in config_dir.glob("*.json"): + if config_file.name != "config.json": # Skip the main config file + active_marker = " (active)" if str(config_file) == config.active_project else "" + print(f" {config_file.name}{active_marker}") + + # Try to load and show description + try: + project_config = load_project_config(str(config_file)) + if project_config.description: + print(f" Description: {project_config.description}") + except Exception: + print(f" (Could not load config)") + return + + if show_project: + project_config = get_active_project_config(config) + if project_config: + print(f"Active project: {project_config.name}") + print(f"Config file: {config.active_project}") + print(f"Description: {project_config.description or 'No description'}") + print(f"Docker compose file: {project_config.docker_compose_file or 'Not specified'}") + print(f"Data directory: {project_config.data_directory or 'Not specified'}") + + if project_config.resources: + print("Resources:") + for resource_name, resource_path in project_config.resources.items(): + print(f" {resource_name}: {resource_path}") + + if project_config.skills: + print("Skills:") + for skill_name, skill_path in project_config.skills.items(): + print(f" {skill_name}: {skill_path}") + + if project_config.monitoring: + print("Monitoring:") + for service_name, service_url in project_config.monitoring.items(): + print(f" {service_name}: {service_url}") + else: + print("No active project configuration found.") + return + if project is not None: - config.active_project = project + # Check if the project file exists + project_path = Path(project) + if not project_path.is_absolute(): + project_path = get_config_dir() / project_path + + if not project_path.exists(): + print(f"Error: Project config file '{project}' not found.") + print("Available project config files:") + config_dir = get_config_dir() + for config_file in config_dir.glob("*.json"): + if config_file.name != "config.json": + print(f" {config_file.name}") + return + + config.active_project = str(project_path) save_config(config) print(f"Active project set to: {project}") else: - print(f"Active project: {config.active_project}") + print(f"Active project: {config.active_project or 'None (will use default)'}") print("Use --project to set a new active project") + print("Use --list-projects to see all available project config files") + print("Use --show-project to see current project configuration") diff --git a/arm_cli/system/system.py b/arm_cli/system/system.py index ff8f272..0079016 100644 --- a/arm_cli/system/system.py +++ b/arm_cli/system/system.py @@ -1,5 +1,6 @@ import click +from arm_cli.config import get_active_project_config from arm_cli.system.setup_utils import ( setup_data_directories, setup_docker_group, @@ -18,9 +19,18 @@ def system(): @click.option("-f", "--force", is_flag=True, help="Skip confirmation prompts") @click.pass_context def setup(ctx, force): - config = ctx.obj["config"] # noqa: F841 - config available for future use + config = ctx.obj["config"] """Generic setup (will be refined later)""" + # Load project configuration + project_config = get_active_project_config(config) + if project_config: + print(f"Setting up system for project: {project_config.name}") + if project_config.description: + print(f"Description: {project_config.description}") + else: + print("No active project configuration found. Using default settings.") + setup_xhost(force=force) setup_shell(force=force) diff --git a/pyproject.toml b/pyproject.toml index bd64f08..761dff0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ include = ["arm_cli*"] [tool.setuptools.package-data] "arm_cli.system.shell_scripts" = ["*.sh", "*.zsh", "*.fish"] +"arm_cli" = ["../resources/*.json"] [tool.setuptools_scm] write_to = "arm_cli/_version.py" diff --git a/resources/default_project_config.json b/resources/default_project_config.json new file mode 100644 index 0000000..c172e82 --- /dev/null +++ b/resources/default_project_config.json @@ -0,0 +1,18 @@ +{ + "name": "default", + "description": "Default ARM project configuration", + "docker_compose_file": "docker-compose.yml", + "data_directory": "./data", + "resources": { + "camera": "orbbec_resource", + "audio": "sound_device_resource" + }, + "skills": { + "image_capture": "image_capture_skill", + "audio_capture": "audio_capture_skill" + }, + "monitoring": { + "grafana": "grafana:3000", + "influxdb": "influxdb:8086" + } +} From a6aa18c953fe97849c69c88e1abe451196bd33e0 Mon Sep 17 00:00:00 2001 From: Matthew Powelson Date: Thu, 14 Aug 2025 12:25:18 -0400 Subject: [PATCH 2/9] Add project module --- arm_cli/cli.py | 2 + arm_cli/config.py | 7 +- arm_cli/projects/__init__.py | 1 + arm_cli/projects/projects.py | 86 ++++++++++++++++ resources/default_project_config.json | 15 +-- tests/unit/projects/test_projects.py | 140 ++++++++++++++++++++++++++ 6 files changed, 235 insertions(+), 16 deletions(-) create mode 100644 arm_cli/projects/__init__.py create mode 100644 arm_cli/projects/projects.py create mode 100644 tests/unit/projects/test_projects.py diff --git a/arm_cli/cli.py b/arm_cli/cli.py index 2da5b6a..04c1511 100644 --- a/arm_cli/cli.py +++ b/arm_cli/cli.py @@ -8,6 +8,7 @@ from arm_cli import __version__ from arm_cli.config import load_config from arm_cli.container.container import container +from arm_cli.projects.projects import projects from arm_cli.self.self import self from arm_cli.system.system import system @@ -24,6 +25,7 @@ def cli(ctx): # Add command groups cli.add_command(container) +cli.add_command(projects) cli.add_command(self) cli.add_command(system) diff --git a/arm_cli/config.py b/arm_cli/config.py index 18e54d0..da6a0b8 100644 --- a/arm_cli/config.py +++ b/arm_cli/config.py @@ -12,11 +12,9 @@ class ProjectConfig(BaseModel): name: str description: Optional[str] = None + project_directory: Optional[str] = None docker_compose_file: Optional[str] = None data_directory: Optional[str] = None - resources: Optional[Dict[str, str]] = None - skills: Optional[Dict[str, str]] = None - monitoring: Optional[Dict[str, str]] = None class Config(BaseModel): @@ -147,3 +145,6 @@ def get_active_project_config(config: Config) -> Optional[ProjectConfig]: except FileNotFoundError: print(f"Active project config not found at {config.active_project}") return None + except IsADirectoryError: + print(f"Active project config path is a directory: {config.active_project}") + return None diff --git a/arm_cli/projects/__init__.py b/arm_cli/projects/__init__.py new file mode 100644 index 0000000..828ebc6 --- /dev/null +++ b/arm_cli/projects/__init__.py @@ -0,0 +1 @@ +# Projects module for ARM CLI diff --git a/arm_cli/projects/projects.py b/arm_cli/projects/projects.py new file mode 100644 index 0000000..b2b92d4 --- /dev/null +++ b/arm_cli/projects/projects.py @@ -0,0 +1,86 @@ +import os +import subprocess +import sys +from pathlib import Path + +import click + +from arm_cli.config import get_active_project_config + + +@click.group() +def projects(): + """Manage ARM projects""" + pass + + +@projects.command() +@click.pass_context +def cd(ctx): + """Change to the active project directory""" + config = ctx.obj["config"] + + # Get the active project configuration + project_config = get_active_project_config(config) + if not project_config: + print( + "No active project configured. Use 'arm-cli self config --project ' to set one." + ) + sys.exit(1) + + # Get the project directory + project_dir = project_config.project_directory + if not project_dir: + print("No project directory configured in the active project.") + sys.exit(1) + + # Expand the path (handle ~ for home directory) + project_path = Path(project_dir).expanduser().resolve() + + if not project_path.exists(): + print(f"Project directory does not exist: {project_path}") + sys.exit(1) + + if not project_path.is_dir(): + print(f"Project directory is not a directory: {project_path}") + sys.exit(1) + + # Change to the project directory + try: + os.chdir(project_path) + print(f"Changed to project directory: {project_path}") + + # If we're in a shell, we can't actually change the parent process directory + # So we'll print the command that the user should run + if os.getenv("SHELL"): + print(f"\nTo change to this directory in your shell, run:") + print(f"cd {project_path}") + else: + print(f"Current working directory: {os.getcwd()}") + + except OSError as e: + print(f"Error changing to project directory: {e}") + sys.exit(1) + + +@projects.command() +@click.pass_context +def info(ctx): + """Show information about the active project""" + config = ctx.obj["config"] + + # Get the active project configuration + project_config = get_active_project_config(config) + if not project_config: + print("No active project configured.") + return + + print(f"Active Project: {project_config.name}") + if project_config.description: + print(f"Description: {project_config.description}") + if project_config.project_directory: + print(f"Project Directory: {project_config.project_directory}") + if project_config.docker_compose_file: + print(f"Docker Compose File: {project_config.docker_compose_file}") + if project_config.data_directory: + print(f"Data Directory: {project_config.data_directory}") diff --git a/resources/default_project_config.json b/resources/default_project_config.json index c172e82..ce1cc4c 100644 --- a/resources/default_project_config.json +++ b/resources/default_project_config.json @@ -2,17 +2,6 @@ "name": "default", "description": "Default ARM project configuration", "docker_compose_file": "docker-compose.yml", - "data_directory": "./data", - "resources": { - "camera": "orbbec_resource", - "audio": "sound_device_resource" - }, - "skills": { - "image_capture": "image_capture_skill", - "audio_capture": "audio_capture_skill" - }, - "monitoring": { - "grafana": "grafana:3000", - "influxdb": "influxdb:8086" - } + "data_directory": "/DATA", + "project_directory": "~" } diff --git a/tests/unit/projects/test_projects.py b/tests/unit/projects/test_projects.py new file mode 100644 index 0000000..f6882b9 --- /dev/null +++ b/tests/unit/projects/test_projects.py @@ -0,0 +1,140 @@ +import json +import tempfile +from pathlib import Path +from unittest.mock import patch + +import click +import pytest +from click.testing import CliRunner + +from arm_cli.config import Config, ProjectConfig +from arm_cli.projects.projects import projects + + +@pytest.fixture +def runner(): + """Fixture to provide a CliRunner instance.""" + return CliRunner() + + +@pytest.fixture +def temp_project_config(): + """Create a temporary project configuration.""" + config_data = { + "name": "test-project", + "description": "Test project for unit tests", + "project_directory": "/tmp/test-project", + "docker_compose_file": "docker-compose.yml", + "data_directory": "/DATA", + } + return config_data + + +@pytest.fixture +def mock_config(temp_project_config): + """Create a mock config with an active project.""" + config = Config(active_project="/tmp/test-project-config.json") + return config + + +def test_projects_cd_no_active_project(runner): + """Test that cd command fails when no active project is configured.""" + with patch("arm_cli.projects.projects.get_active_project_config") as mock_get_config: + mock_get_config.return_value = None + + result = runner.invoke(projects, ["cd"], obj={"config": Config()}) + + assert result.exit_code == 1 + assert "No active project configured" in result.output + + +def test_projects_cd_no_project_directory(runner, mock_config): + """Test that cd command fails when project has no directory configured.""" + project_config = ProjectConfig(name="test", project_directory=None) + + with patch("arm_cli.projects.projects.get_active_project_config") as mock_get_config: + mock_get_config.return_value = project_config + + result = runner.invoke(projects, ["cd"], obj={"config": mock_config}) + + assert result.exit_code == 1 + assert "No project directory configured" in result.output + + +def test_projects_cd_directory_does_not_exist(runner, mock_config): + """Test that cd command fails when project directory doesn't exist.""" + project_config = ProjectConfig(name="test", project_directory="/nonexistent/path") + + with patch("arm_cli.projects.projects.get_active_project_config") as mock_get_config: + mock_get_config.return_value = project_config + + result = runner.invoke(projects, ["cd"], obj={"config": mock_config}) + + assert result.exit_code == 1 + assert "Project directory does not exist" in result.output + + +def test_projects_cd_success(runner, mock_config): + """Test that cd command succeeds with valid project directory.""" + with tempfile.TemporaryDirectory() as temp_dir: + project_config = ProjectConfig(name="test", project_directory=temp_dir) + + with patch("arm_cli.projects.projects.get_active_project_config") as mock_get_config: + mock_get_config.return_value = project_config + + result = runner.invoke(projects, ["cd"], obj={"config": mock_config}) + + assert result.exit_code == 0 + assert "Changed to project directory" in result.output + assert temp_dir in result.output + + +def test_projects_info_no_active_project(runner): + """Test that info command handles no active project gracefully.""" + with patch("arm_cli.projects.projects.get_active_project_config") as mock_get_config: + mock_get_config.return_value = None + + result = runner.invoke(projects, ["info"], obj={"config": Config()}) + + assert result.exit_code == 0 + assert "No active project configured" in result.output + + +def test_projects_info_with_project(runner, mock_config, temp_project_config): + """Test that info command displays project information correctly.""" + project_config = ProjectConfig(**temp_project_config) + + with patch("arm_cli.projects.projects.get_active_project_config") as mock_get_config: + mock_get_config.return_value = project_config + + result = runner.invoke(projects, ["info"], obj={"config": mock_config}) + + assert result.exit_code == 0 + assert "Active Project: test-project" in result.output + assert "Description: Test project for unit tests" in result.output + assert "Project Directory: /tmp/test-project" in result.output + assert "Docker Compose File: docker-compose.yml" in result.output + assert "Data Directory: /DATA" in result.output + + +def test_projects_info_minimal_project(runner, mock_config): + """Test that info command works with minimal project configuration.""" + project_config = ProjectConfig(name="minimal-project") + + with patch("arm_cli.projects.projects.get_active_project_config") as mock_get_config: + mock_get_config.return_value = project_config + + result = runner.invoke(projects, ["info"], obj={"config": mock_config}) + + assert result.exit_code == 0 + assert "Active Project: minimal-project" in result.output + + +def test_projects_help(runner): + """Test that projects command shows help.""" + result = runner.invoke(projects, ["--help"]) + + assert result.exit_code == 0 + assert "Manage ARM projects" in result.output + assert "cd" in result.output + assert "info" in result.output From ab0a9b3d14fc9a9dc5b3a539ad6f26dee7c045b2 Mon Sep 17 00:00:00 2001 From: Matthew Powelson Date: Thu, 14 Aug 2025 12:30:52 -0400 Subject: [PATCH 3/9] Add unit test that configs load --- tests/test_config.py | 75 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/tests/test_config.py b/tests/test_config.py index 983eb3b..dc6d1d5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,7 +3,15 @@ from pathlib import Path from unittest.mock import patch -from arm_cli.config import Config, get_config_dir, get_config_file, load_config, save_config +from arm_cli.config import ( + Config, + ProjectConfig, + get_config_dir, + get_config_file, + load_config, + load_project_config, + save_config, +) class TestConfig: @@ -133,3 +141,68 @@ def test_load_config_missing_fields(self): # Should use default values for missing fields assert config.active_project == "" + + +class TestProjectConfig: + def test_project_config_default_values(self): + """Test that ProjectConfig has correct default values.""" + config = ProjectConfig(name="test-project") + assert config.name == "test-project" + assert config.description is None + assert config.project_directory is None + assert config.docker_compose_file is None + assert config.data_directory is None + + def test_project_config_with_values(self): + """Test that ProjectConfig can be created with custom values.""" + config = ProjectConfig( + name="test-project", + description="Test project", + project_directory="/tmp/project", + docker_compose_file="docker-compose.yml", + data_directory="/DATA", + ) + assert config.name == "test-project" + assert config.description == "Test project" + assert config.project_directory == "/tmp/project" + assert config.docker_compose_file == "docker-compose.yml" + assert config.data_directory == "/DATA" + + def test_project_config_model_dump(self): + """Test that ProjectConfig can be serialized to dict.""" + config = ProjectConfig( + name="test-project", description="Test project", project_directory="/tmp/project" + ) + data = config.model_dump() + expected = { + "name": "test-project", + "description": "Test project", + "project_directory": "/tmp/project", + "docker_compose_file": None, + "data_directory": None, + } + assert data == expected + + def test_load_default_project_config(self): + """Test that the actual default project config JSON can be loaded without Pydantic errors.""" + from arm_cli.config import get_default_project_config_path + + # Load the actual default config file + config_path = get_default_project_config_path() + + # Load the JSON data + with open(config_path, "r") as f: + data = json.load(f) + + # Create a temporary model with extra="forbid" to catch any schema mismatches + # If someone modifies the JSON and forgets to update the Pydantic model, this will fail + from pydantic import ConfigDict + + class StrictProjectConfig(ProjectConfig): + model_config = ConfigDict(extra="forbid") + + project_config = StrictProjectConfig.model_validate(data) + + # Just verify it loaded successfully (the JSON is the source of truth for values) + assert isinstance(project_config, ProjectConfig) + assert project_config.name is not None From 28cb41b8e00ddbbbfc223c952a4d241135c3bb63 Mon Sep 17 00:00:00 2001 From: Matthew Powelson Date: Thu, 14 Aug 2025 13:02:45 -0400 Subject: [PATCH 4/9] Add project init/remove --- arm_cli/config.py | 70 +++++++++- arm_cli/projects/projects.py | 254 ++++++++++++++++++++++++++++++++++- 2 files changed, 321 insertions(+), 3 deletions(-) diff --git a/arm_cli/config.py b/arm_cli/config.py index da6a0b8..4325dbf 100644 --- a/arm_cli/config.py +++ b/arm_cli/config.py @@ -1,7 +1,7 @@ import json import shutil from pathlib import Path -from typing import Dict, Optional +from typing import Dict, List, Optional import appdirs from pydantic import BaseModel @@ -17,10 +17,18 @@ class ProjectConfig(BaseModel): data_directory: Optional[str] = None +class AvailableProject(BaseModel): + """Schema for available project entry.""" + + name: str + path: str + + class Config(BaseModel): """Configuration schema for the CLI.""" active_project: str = "" + available_projects: List[AvailableProject] = [] def get_config_dir() -> Path: @@ -94,6 +102,66 @@ def load_project_config(project_path: str) -> ProjectConfig: return ProjectConfig(**data) +def add_project_to_list(config: Config, project_path: str, project_name: str) -> None: + """Add a project to the available projects list and set as active.""" + # Remove existing entry if it exists + config.available_projects = [p for p in config.available_projects if p.path != project_path] + + # Add new entry + project_entry = AvailableProject(name=project_name, path=project_path) + config.available_projects.append(project_entry) + + # Set as active project + config.active_project = project_path + + +def get_available_projects(config: Config) -> List[AvailableProject]: + """Get the list of available projects.""" + return config.available_projects + + +def activate_project(config: Config, project_identifier: str) -> Optional[ProjectConfig]: + """Activate a project by path or name.""" + # First try to find by exact path + for project in config.available_projects: + if project.path == project_identifier: + config.active_project = project.path + save_config(config) + return load_project_config(project.path) + + # Try to find by name + for project in config.available_projects: + if project.name.lower() == project_identifier.lower(): + config.active_project = project.path + save_config(config) + return load_project_config(project.path) + + return None + + +def remove_project_from_list(config: Config, project_identifier: str) -> bool: + """Remove a project from the available projects list by path or name.""" + # First try to find by exact path + for project in config.available_projects: + if project.path == project_identifier: + # If this is the active project, clear the active project + if config.active_project == project.path: + config.active_project = "" + config.available_projects.remove(project) + return True + + # Try to find by name + for project in config.available_projects: + if project.name.lower() == project_identifier.lower(): + # If this is the active project, clear the active project + if config.active_project == project.path: + config.active_project = "" + config.available_projects.remove(project) + return True + + return False + + def load_config() -> Config: """Load configuration from file, creating default if it doesn't exist.""" config_file = get_config_file() diff --git a/arm_cli/projects/projects.py b/arm_cli/projects/projects.py index b2b92d4..c3dba63 100644 --- a/arm_cli/projects/projects.py +++ b/arm_cli/projects/projects.py @@ -2,10 +2,21 @@ import subprocess import sys from pathlib import Path +from typing import Optional import click +import inquirer -from arm_cli.config import get_active_project_config +from arm_cli.config import ( + activate_project, + add_project_to_list, + copy_default_project_config, + get_active_project_config, + get_available_projects, + load_project_config, + remove_project_from_list, + save_config, +) @click.group() @@ -14,6 +25,138 @@ def projects(): pass +@projects.command() +@click.argument("project_path", type=click.Path(exists=True, file_okay=False, dir_okay=True)) +@click.option("--name", help="Name for the project (defaults to directory name)") +@click.pass_context +def init(ctx, project_path: str, name: Optional[str] = None): + """Initialize a new project from an existing directory""" + config = ctx.obj["config"] + + project_path_obj = Path(project_path).resolve() + + if name is None: + name = project_path_obj.name + + # Check if project config already exists + project_config_file = project_path_obj / "project_config.json" + + if project_config_file.exists(): + print(f"Project config already exists at {project_config_file}") + print("Loading existing project configuration...") + project_config = load_project_config(str(project_config_file)) + else: + # Copy default config and customize it + try: + default_config_path = copy_default_project_config() + with open(default_config_path, "r") as f: + import json + + default_data = json.load(f) + + # Update with project-specific information + default_data["name"] = name + default_data["project_directory"] = str(project_path_obj) + + # Save the new project config + with open(project_config_file, "w") as f: + json.dump(default_data, f, indent=2) + + project_config = load_project_config(str(project_config_file)) + print(f"Created new project configuration at {project_config_file}") + + except Exception as e: + print(f"Error creating project configuration: {e}") + sys.exit(1) + + # Add to available projects and set as active + add_project_to_list(config, str(project_config_file), project_config.name) + save_config(config) + + print(f"Project '{project_config.name}' initialized and set as active") + print(f"Project directory: {project_config.project_directory}") + + +@projects.command() +@click.argument("project", required=False) +@click.pass_context +def activate(ctx, project: Optional[str] = None): + """Activate a project from available projects""" + config = ctx.obj["config"] + + # If no project specified, show interactive list + if project is None: + available_projects = get_available_projects(config) + + if not available_projects: + print("No projects available. Use 'arm-cli projects init ' to add a project.") + return + + # Create choices for inquirer + choices = [] + for proj in available_projects: + active_indicator = " *" if proj.path == config.active_project else "" + choices.append(f"{proj.name}{active_indicator}") + + # Create the question + questions = [ + inquirer.List( + "project", message="Select a project to activate", choices=choices, carousel=True + ) + ] + + try: + answers = inquirer.prompt(questions) + if answers is None: + print("Cancelled.") + return + + # Extract project name (remove the active indicator if present) + selected_choice = answers["project"] + project = selected_choice.replace(" *", "") + + except KeyboardInterrupt: + print("\nCancelled.") + return + + # Try to activate the project + # At this point, project is guaranteed to be a string + assert project is not None # type guard + project_config = activate_project(config, project) + + if project_config: + print(f"Activated project: {project_config.name}") + print(f"Project directory: {project_config.project_directory}") + else: + print(f"Project '{project}' not found in available projects") + print("\nAvailable projects:") + available_projects = get_available_projects(config) + if available_projects: + for i, proj in enumerate(available_projects, 1): + print(f" {i}. {proj.name} ({proj.path})") + else: + print(" No projects available. Use 'arm-cli projects init ' to add a project.") + + +@projects.command() +@click.pass_context +def list(ctx): + """List all available projects""" + config = ctx.obj["config"] + available_projects = get_available_projects(config) + + if not available_projects: + print("No projects available. Use 'arm-cli projects init ' to add a project.") + return + + print("Available Projects:") + for i, project in enumerate(available_projects, 1): + active_indicator = " *" if project.path == config.active_project else "" + print(f" {i}. {project.name}{active_indicator}") + print(f" Path: {project.path}") + print() + + @projects.command() @click.pass_context def cd(ctx): @@ -24,7 +167,7 @@ def cd(ctx): project_config = get_active_project_config(config) if not project_config: print( - "No active project configured. Use 'arm-cli self config --project ' to set one." + "No active project configured. Use 'arm-cli projects init ' to initialize a project." ) sys.exit(1) @@ -84,3 +227,110 @@ def info(ctx): print(f"Docker Compose File: {project_config.docker_compose_file}") if project_config.data_directory: print(f"Data Directory: {project_config.data_directory}") + + +@projects.command() +@click.argument("project", required=False) +@click.pass_context +def remove(ctx, project: Optional[str] = None): + """Remove a project from available projects""" + config = ctx.obj["config"] + + # If no project specified, show interactive list + if project is None: + available_projects = get_available_projects(config) + + if not available_projects: + print("No projects available to remove.") + return + + # Create choices for inquirer + choices = [] + for proj in available_projects: + active_indicator = " *" if proj.path == config.active_project else "" + choices.append(f"{proj.name}{active_indicator}") + + # Create the question + questions = [ + inquirer.List( + "project", message="Select a project to remove", choices=choices, carousel=True + ) + ] + + try: + answers = inquirer.prompt(questions) + if answers is None: + print("Cancelled.") + return + + # Extract project name (remove the active indicator if present) + selected_choice = answers["project"] + project = selected_choice.replace(" *", "") + + except KeyboardInterrupt: + print("\nCancelled.") + return + + # Try to remove the project + # At this point, project is guaranteed to be a string + assert project is not None # type guard + + # Check if this is the active project + available_projects = get_available_projects(config) + is_active = False + for proj in available_projects: + if proj.name.lower() == project.lower(): + is_active = proj.path == config.active_project + break + + # Confirm removal, especially for active project + if is_active: + confirm_questions = [ + inquirer.Confirm( + "confirm", + message=f"Are you sure you want to remove the active project '{project}'? This will clear the active project.", + default=False, + ) + ] + + try: + confirm_answers = inquirer.prompt(confirm_questions) + if confirm_answers is None or not confirm_answers["confirm"]: + print("Removal cancelled.") + return + except KeyboardInterrupt: + print("\nCancelled.") + return + else: + confirm_questions = [ + inquirer.Confirm( + "confirm", + message=f"Are you sure you want to remove project '{project}'?", + default=False, + ) + ] + + try: + confirm_answers = inquirer.prompt(confirm_questions) + if confirm_answers is None or not confirm_answers["confirm"]: + print("Removal cancelled.") + return + except KeyboardInterrupt: + print("\nCancelled.") + return + + # Remove the project + if remove_project_from_list(config, project): + save_config(config) + print(f"Removed project: {project}") + if is_active: + print("Active project has been cleared.") + else: + print(f"Project '{project}' not found in available projects") + print("\nAvailable projects:") + available_projects = get_available_projects(config) + if available_projects: + for i, proj in enumerate(available_projects, 1): + print(f" {i}. {proj.name} ({proj.path})") + else: + print(" No projects available.") From f6d161aecfda2523b0f532be0509496b0f3ad9ba Mon Sep 17 00:00:00 2001 From: Matthew Powelson Date: Thu, 14 Aug 2025 13:08:49 -0400 Subject: [PATCH 5/9] Remove projects cd --- arm_cli/projects/projects.py | 49 ------------------------------------ 1 file changed, 49 deletions(-) diff --git a/arm_cli/projects/projects.py b/arm_cli/projects/projects.py index c3dba63..1bc0740 100644 --- a/arm_cli/projects/projects.py +++ b/arm_cli/projects/projects.py @@ -157,55 +157,6 @@ def list(ctx): print() -@projects.command() -@click.pass_context -def cd(ctx): - """Change to the active project directory""" - config = ctx.obj["config"] - - # Get the active project configuration - project_config = get_active_project_config(config) - if not project_config: - print( - "No active project configured. Use 'arm-cli projects init ' to initialize a project." - ) - sys.exit(1) - - # Get the project directory - project_dir = project_config.project_directory - if not project_dir: - print("No project directory configured in the active project.") - sys.exit(1) - - # Expand the path (handle ~ for home directory) - project_path = Path(project_dir).expanduser().resolve() - - if not project_path.exists(): - print(f"Project directory does not exist: {project_path}") - sys.exit(1) - - if not project_path.is_dir(): - print(f"Project directory is not a directory: {project_path}") - sys.exit(1) - - # Change to the project directory - try: - os.chdir(project_path) - print(f"Changed to project directory: {project_path}") - - # If we're in a shell, we can't actually change the parent process directory - # So we'll print the command that the user should run - if os.getenv("SHELL"): - print(f"\nTo change to this directory in your shell, run:") - print(f"cd {project_path}") - else: - print(f"Current working directory: {os.getcwd()}") - - except OSError as e: - print(f"Error changing to project directory: {e}") - sys.exit(1) - - @projects.command() @click.pass_context def info(ctx): From a610702e6a75133c6c4bdfc3889cfbec5819ca37 Mon Sep 17 00:00:00 2001 From: Matthew Powelson Date: Thu, 14 Aug 2025 13:14:06 -0400 Subject: [PATCH 6/9] Split up project management commands --- arm_cli/projects/activate.py | 67 +++++++++ arm_cli/projects/info.py | 26 ++++ arm_cli/projects/init.py | 64 ++++++++ arm_cli/projects/list.py | 22 +++ arm_cli/projects/projects.py | 284 +---------------------------------- arm_cli/projects/remove.py | 113 ++++++++++++++ 6 files changed, 299 insertions(+), 277 deletions(-) create mode 100644 arm_cli/projects/activate.py create mode 100644 arm_cli/projects/info.py create mode 100644 arm_cli/projects/init.py create mode 100644 arm_cli/projects/list.py create mode 100644 arm_cli/projects/remove.py diff --git a/arm_cli/projects/activate.py b/arm_cli/projects/activate.py new file mode 100644 index 0000000..56bdf9e --- /dev/null +++ b/arm_cli/projects/activate.py @@ -0,0 +1,67 @@ +from typing import Optional + +import click +import inquirer + +from arm_cli.config import activate_project, get_available_projects + + +@click.command() +@click.argument("project", required=False) +@click.pass_context +def activate(ctx, project: Optional[str] = None): + """Activate a project from available projects""" + config = ctx.obj["config"] + + # If no project specified, show interactive list + if project is None: + available_projects = get_available_projects(config) + + if not available_projects: + print("No projects available. Use 'arm-cli projects init ' to add a project.") + return + + # Create choices for inquirer + choices = [] + for proj in available_projects: + active_indicator = " *" if proj.path == config.active_project else "" + choices.append(f"{proj.name}{active_indicator}") + + # Create the question + questions = [ + inquirer.List( + "project", message="Select a project to activate", choices=choices, carousel=True + ) + ] + + try: + answers = inquirer.prompt(questions) + if answers is None: + print("Cancelled.") + return + + # Extract project name (remove the active indicator if present) + selected_choice = answers["project"] + project = selected_choice.replace(" *", "") + + except KeyboardInterrupt: + print("\nCancelled.") + return + + # Try to activate the project + # At this point, project is guaranteed to be a string + assert project is not None # type guard + project_config = activate_project(config, project) + + if project_config: + print(f"Activated project: {project_config.name}") + print(f"Project directory: {project_config.project_directory}") + else: + print(f"Project '{project}' not found in available projects") + print("\nAvailable projects:") + available_projects = get_available_projects(config) + if available_projects: + for i, proj in enumerate(available_projects, 1): + print(f" {i}. {proj.name} ({proj.path})") + else: + print(" No projects available. Use 'arm-cli projects init ' to add a project.") diff --git a/arm_cli/projects/info.py b/arm_cli/projects/info.py new file mode 100644 index 0000000..cedaf19 --- /dev/null +++ b/arm_cli/projects/info.py @@ -0,0 +1,26 @@ +import click + +from arm_cli.config import get_active_project_config + + +@click.command() +@click.pass_context +def info(ctx): + """Show information about the active project""" + config = ctx.obj["config"] + + # Get the active project configuration + project_config = get_active_project_config(config) + if not project_config: + print("No active project configured.") + return + + print(f"Active Project: {project_config.name}") + if project_config.description: + print(f"Description: {project_config.description}") + if project_config.project_directory: + print(f"Project Directory: {project_config.project_directory}") + if project_config.docker_compose_file: + print(f"Docker Compose File: {project_config.docker_compose_file}") + if project_config.data_directory: + print(f"Data Directory: {project_config.data_directory}") diff --git a/arm_cli/projects/init.py b/arm_cli/projects/init.py new file mode 100644 index 0000000..1ed45ed --- /dev/null +++ b/arm_cli/projects/init.py @@ -0,0 +1,64 @@ +import sys +from pathlib import Path +from typing import Optional + +import click + +from arm_cli.config import ( + add_project_to_list, + copy_default_project_config, + load_project_config, + save_config, +) + + +@click.command() +@click.argument("project_path", type=click.Path(exists=True, file_okay=False, dir_okay=True)) +@click.option("--name", help="Name for the project (defaults to directory name)") +@click.pass_context +def init(ctx, project_path: str, name: Optional[str] = None): + """Initialize a new project from an existing directory""" + config = ctx.obj["config"] + + project_path_obj = Path(project_path).resolve() + + if name is None: + name = project_path_obj.name + + # Check if project config already exists + project_config_file = project_path_obj / "project_config.json" + + if project_config_file.exists(): + print(f"Project config already exists at {project_config_file}") + print("Loading existing project configuration...") + project_config = load_project_config(str(project_config_file)) + else: + # Copy default config and customize it + try: + default_config_path = copy_default_project_config() + with open(default_config_path, "r") as f: + import json + + default_data = json.load(f) + + # Update with project-specific information + default_data["name"] = name + default_data["project_directory"] = str(project_path_obj) + + # Save the new project config + with open(project_config_file, "w") as f: + json.dump(default_data, f, indent=2) + + project_config = load_project_config(str(project_config_file)) + print(f"Created new project configuration at {project_config_file}") + + except Exception as e: + print(f"Error creating project configuration: {e}") + sys.exit(1) + + # Add to available projects and set as active + add_project_to_list(config, str(project_config_file), project_config.name) + save_config(config) + + print(f"Project '{project_config.name}' initialized and set as active") + print(f"Project directory: {project_config.project_directory}") diff --git a/arm_cli/projects/list.py b/arm_cli/projects/list.py new file mode 100644 index 0000000..f4f350c --- /dev/null +++ b/arm_cli/projects/list.py @@ -0,0 +1,22 @@ +import click + +from arm_cli.config import get_available_projects + + +@click.command() +@click.pass_context +def list(ctx): + """List all available projects""" + config = ctx.obj["config"] + available_projects = get_available_projects(config) + + if not available_projects: + print("No projects available. Use 'arm-cli projects init ' to add a project.") + return + + print("Available Projects:") + for i, project in enumerate(available_projects, 1): + active_indicator = " *" if project.path == config.active_project else "" + print(f" {i}. {project.name}{active_indicator}") + print(f" Path: {project.path}") + print() diff --git a/arm_cli/projects/projects.py b/arm_cli/projects/projects.py index 1bc0740..2b697d7 100644 --- a/arm_cli/projects/projects.py +++ b/arm_cli/projects/projects.py @@ -1,22 +1,6 @@ -import os -import subprocess -import sys -from pathlib import Path -from typing import Optional - import click -import inquirer -from arm_cli.config import ( - activate_project, - add_project_to_list, - copy_default_project_config, - get_active_project_config, - get_available_projects, - load_project_config, - remove_project_from_list, - save_config, -) +from arm_cli.projects import activate, info, init, list, remove @click.group() @@ -25,263 +9,9 @@ def projects(): pass -@projects.command() -@click.argument("project_path", type=click.Path(exists=True, file_okay=False, dir_okay=True)) -@click.option("--name", help="Name for the project (defaults to directory name)") -@click.pass_context -def init(ctx, project_path: str, name: Optional[str] = None): - """Initialize a new project from an existing directory""" - config = ctx.obj["config"] - - project_path_obj = Path(project_path).resolve() - - if name is None: - name = project_path_obj.name - - # Check if project config already exists - project_config_file = project_path_obj / "project_config.json" - - if project_config_file.exists(): - print(f"Project config already exists at {project_config_file}") - print("Loading existing project configuration...") - project_config = load_project_config(str(project_config_file)) - else: - # Copy default config and customize it - try: - default_config_path = copy_default_project_config() - with open(default_config_path, "r") as f: - import json - - default_data = json.load(f) - - # Update with project-specific information - default_data["name"] = name - default_data["project_directory"] = str(project_path_obj) - - # Save the new project config - with open(project_config_file, "w") as f: - json.dump(default_data, f, indent=2) - - project_config = load_project_config(str(project_config_file)) - print(f"Created new project configuration at {project_config_file}") - - except Exception as e: - print(f"Error creating project configuration: {e}") - sys.exit(1) - - # Add to available projects and set as active - add_project_to_list(config, str(project_config_file), project_config.name) - save_config(config) - - print(f"Project '{project_config.name}' initialized and set as active") - print(f"Project directory: {project_config.project_directory}") - - -@projects.command() -@click.argument("project", required=False) -@click.pass_context -def activate(ctx, project: Optional[str] = None): - """Activate a project from available projects""" - config = ctx.obj["config"] - - # If no project specified, show interactive list - if project is None: - available_projects = get_available_projects(config) - - if not available_projects: - print("No projects available. Use 'arm-cli projects init ' to add a project.") - return - - # Create choices for inquirer - choices = [] - for proj in available_projects: - active_indicator = " *" if proj.path == config.active_project else "" - choices.append(f"{proj.name}{active_indicator}") - - # Create the question - questions = [ - inquirer.List( - "project", message="Select a project to activate", choices=choices, carousel=True - ) - ] - - try: - answers = inquirer.prompt(questions) - if answers is None: - print("Cancelled.") - return - - # Extract project name (remove the active indicator if present) - selected_choice = answers["project"] - project = selected_choice.replace(" *", "") - - except KeyboardInterrupt: - print("\nCancelled.") - return - - # Try to activate the project - # At this point, project is guaranteed to be a string - assert project is not None # type guard - project_config = activate_project(config, project) - - if project_config: - print(f"Activated project: {project_config.name}") - print(f"Project directory: {project_config.project_directory}") - else: - print(f"Project '{project}' not found in available projects") - print("\nAvailable projects:") - available_projects = get_available_projects(config) - if available_projects: - for i, proj in enumerate(available_projects, 1): - print(f" {i}. {proj.name} ({proj.path})") - else: - print(" No projects available. Use 'arm-cli projects init ' to add a project.") - - -@projects.command() -@click.pass_context -def list(ctx): - """List all available projects""" - config = ctx.obj["config"] - available_projects = get_available_projects(config) - - if not available_projects: - print("No projects available. Use 'arm-cli projects init ' to add a project.") - return - - print("Available Projects:") - for i, project in enumerate(available_projects, 1): - active_indicator = " *" if project.path == config.active_project else "" - print(f" {i}. {project.name}{active_indicator}") - print(f" Path: {project.path}") - print() - - -@projects.command() -@click.pass_context -def info(ctx): - """Show information about the active project""" - config = ctx.obj["config"] - - # Get the active project configuration - project_config = get_active_project_config(config) - if not project_config: - print("No active project configured.") - return - - print(f"Active Project: {project_config.name}") - if project_config.description: - print(f"Description: {project_config.description}") - if project_config.project_directory: - print(f"Project Directory: {project_config.project_directory}") - if project_config.docker_compose_file: - print(f"Docker Compose File: {project_config.docker_compose_file}") - if project_config.data_directory: - print(f"Data Directory: {project_config.data_directory}") - - -@projects.command() -@click.argument("project", required=False) -@click.pass_context -def remove(ctx, project: Optional[str] = None): - """Remove a project from available projects""" - config = ctx.obj["config"] - - # If no project specified, show interactive list - if project is None: - available_projects = get_available_projects(config) - - if not available_projects: - print("No projects available to remove.") - return - - # Create choices for inquirer - choices = [] - for proj in available_projects: - active_indicator = " *" if proj.path == config.active_project else "" - choices.append(f"{proj.name}{active_indicator}") - - # Create the question - questions = [ - inquirer.List( - "project", message="Select a project to remove", choices=choices, carousel=True - ) - ] - - try: - answers = inquirer.prompt(questions) - if answers is None: - print("Cancelled.") - return - - # Extract project name (remove the active indicator if present) - selected_choice = answers["project"] - project = selected_choice.replace(" *", "") - - except KeyboardInterrupt: - print("\nCancelled.") - return - - # Try to remove the project - # At this point, project is guaranteed to be a string - assert project is not None # type guard - - # Check if this is the active project - available_projects = get_available_projects(config) - is_active = False - for proj in available_projects: - if proj.name.lower() == project.lower(): - is_active = proj.path == config.active_project - break - - # Confirm removal, especially for active project - if is_active: - confirm_questions = [ - inquirer.Confirm( - "confirm", - message=f"Are you sure you want to remove the active project '{project}'? This will clear the active project.", - default=False, - ) - ] - - try: - confirm_answers = inquirer.prompt(confirm_questions) - if confirm_answers is None or not confirm_answers["confirm"]: - print("Removal cancelled.") - return - except KeyboardInterrupt: - print("\nCancelled.") - return - else: - confirm_questions = [ - inquirer.Confirm( - "confirm", - message=f"Are you sure you want to remove project '{project}'?", - default=False, - ) - ] - - try: - confirm_answers = inquirer.prompt(confirm_questions) - if confirm_answers is None or not confirm_answers["confirm"]: - print("Removal cancelled.") - return - except KeyboardInterrupt: - print("\nCancelled.") - return - - # Remove the project - if remove_project_from_list(config, project): - save_config(config) - print(f"Removed project: {project}") - if is_active: - print("Active project has been cleared.") - else: - print(f"Project '{project}' not found in available projects") - print("\nAvailable projects:") - available_projects = get_available_projects(config) - if available_projects: - for i, proj in enumerate(available_projects, 1): - print(f" {i}. {proj.name} ({proj.path})") - else: - print(" No projects available.") +# Register all project commands +projects.add_command(init.init) +projects.add_command(activate.activate) +projects.add_command(list.list) +projects.add_command(info.info) +projects.add_command(remove.remove) diff --git a/arm_cli/projects/remove.py b/arm_cli/projects/remove.py new file mode 100644 index 0000000..1ff7d65 --- /dev/null +++ b/arm_cli/projects/remove.py @@ -0,0 +1,113 @@ +from typing import Optional + +import click +import inquirer + +from arm_cli.config import get_available_projects, remove_project_from_list, save_config + + +@click.command() +@click.argument("project", required=False) +@click.pass_context +def remove(ctx, project: Optional[str] = None): + """Remove a project from available projects""" + config = ctx.obj["config"] + + # If no project specified, show interactive list + if project is None: + available_projects = get_available_projects(config) + + if not available_projects: + print("No projects available to remove.") + return + + # Create choices for inquirer + choices = [] + for proj in available_projects: + active_indicator = " *" if proj.path == config.active_project else "" + choices.append(f"{proj.name}{active_indicator}") + + # Create the question + questions = [ + inquirer.List( + "project", message="Select a project to remove", choices=choices, carousel=True + ) + ] + + try: + answers = inquirer.prompt(questions) + if answers is None: + print("Cancelled.") + return + + # Extract project name (remove the active indicator if present) + selected_choice = answers["project"] + project = selected_choice.replace(" *", "") + + except KeyboardInterrupt: + print("\nCancelled.") + return + + # Try to remove the project + # At this point, project is guaranteed to be a string + assert project is not None # type guard + + # Check if this is the active project + available_projects = get_available_projects(config) + is_active = False + for proj in available_projects: + if proj.name.lower() == project.lower(): + is_active = proj.path == config.active_project + break + + # Confirm removal, especially for active project + if is_active: + confirm_questions = [ + inquirer.Confirm( + "confirm", + message=f"Are you sure you want to remove the active project '{project}'? This will clear the active project.", + default=False, + ) + ] + + try: + confirm_answers = inquirer.prompt(confirm_questions) + if confirm_answers is None or not confirm_answers["confirm"]: + print("Removal cancelled.") + return + except KeyboardInterrupt: + print("\nCancelled.") + return + else: + confirm_questions = [ + inquirer.Confirm( + "confirm", + message=f"Are you sure you want to remove project '{project}'?", + default=False, + ) + ] + + try: + confirm_answers = inquirer.prompt(confirm_questions) + if confirm_answers is None or not confirm_answers["confirm"]: + print("Removal cancelled.") + return + except KeyboardInterrupt: + print("\nCancelled.") + return + + # Remove the project + if remove_project_from_list(config, project): + save_config(config) + print(f"Removed project: {project}") + if is_active: + print("Active project has been cleared.") + else: + print(f"Project '{project}' not found in available projects") + print("\nAvailable projects:") + available_projects = get_available_projects(config) + if available_projects: + for i, proj in enumerate(available_projects, 1): + print(f" {i}. {proj.name} ({proj.path})") + else: + print(" No projects available.") From 1ad361519772f068adf8f1e6dbe534137b2b86f4 Mon Sep 17 00:00:00 2001 From: Matthew Powelson Date: Thu, 14 Aug 2025 13:36:10 -0400 Subject: [PATCH 7/9] Add cdp alias --- arm_cli/projects/__init__.py | 2 + arm_cli/projects/activate.py | 11 +- arm_cli/projects/info.py | 55 ++++-- arm_cli/projects/init.py | 16 +- arm_cli/projects/list.py | 8 +- arm_cli/projects/projects.py | 24 ++- arm_cli/projects/remove.py | 11 +- arm_cli/system/shell_scripts/shell_addins.sh | 3 + tests/unit/projects/test_projects.py | 172 +++++++++++++------ 9 files changed, 217 insertions(+), 85 deletions(-) diff --git a/arm_cli/projects/__init__.py b/arm_cli/projects/__init__.py index 828ebc6..6568b84 100644 --- a/arm_cli/projects/__init__.py +++ b/arm_cli/projects/__init__.py @@ -1 +1,3 @@ # Projects module for ARM CLI + +from . import activate, info, init, list, remove diff --git a/arm_cli/projects/activate.py b/arm_cli/projects/activate.py index 56bdf9e..d7817c3 100644 --- a/arm_cli/projects/activate.py +++ b/arm_cli/projects/activate.py @@ -6,10 +6,7 @@ from arm_cli.config import activate_project, get_available_projects -@click.command() -@click.argument("project", required=False) -@click.pass_context -def activate(ctx, project: Optional[str] = None): +def _activate(ctx, project: Optional[str] = None): """Activate a project from available projects""" config = ctx.obj["config"] @@ -65,3 +62,9 @@ def activate(ctx, project: Optional[str] = None): print(f" {i}. {proj.name} ({proj.path})") else: print(" No projects available. Use 'arm-cli projects init ' to add a project.") + + +# Create the command object +activate = click.command(name="activate")( + click.argument("project", required=False)(click.pass_context(_activate)) +) diff --git a/arm_cli/projects/info.py b/arm_cli/projects/info.py index cedaf19..96f1877 100644 --- a/arm_cli/projects/info.py +++ b/arm_cli/projects/info.py @@ -3,9 +3,7 @@ from arm_cli.config import get_active_project_config -@click.command() -@click.pass_context -def info(ctx): +def _info(ctx, field): """Show information about the active project""" config = ctx.obj["config"] @@ -15,12 +13,45 @@ def info(ctx): print("No active project configured.") return - print(f"Active Project: {project_config.name}") - if project_config.description: - print(f"Description: {project_config.description}") - if project_config.project_directory: - print(f"Project Directory: {project_config.project_directory}") - if project_config.docker_compose_file: - print(f"Docker Compose File: {project_config.docker_compose_file}") - if project_config.data_directory: - print(f"Data Directory: {project_config.data_directory}") + # If --field is specified, extract and print only that field + if field: + # Convert field name to attribute name (e.g., "project_directory" -> project_directory) + field = field.lower().replace(" ", "_") + + # Get all public attributes (not starting with _) + available_attrs = [ + attr + for attr in dir(project_config) + if not attr.startswith("_") and not callable(getattr(project_config, attr)) + ] + + if field in available_attrs: + value = getattr(project_config, field) + if value: + print(value) + else: + print("", end="") # Print empty string for empty values + else: + print(f"Unknown field: {field}", file=click.get_text_stream("stderr")) + print( + f"Available fields: {', '.join(available_attrs)}", + file=click.get_text_stream("stderr"), + ) + return + else: + # Print all fields as before + print(f"Active Project: {project_config.name}") + if project_config.description: + print(f"Description: {project_config.description}") + if project_config.project_directory: + print(f"Project Directory: {project_config.project_directory}") + if project_config.docker_compose_file: + print(f"Docker Compose File: {project_config.docker_compose_file}") + if project_config.data_directory: + print(f"Data Directory: {project_config.data_directory}") + + +# Create the command object +info = click.command(name="info")( + click.option("--field", help="Extract a specific field value")(click.pass_context(_info)) +) diff --git a/arm_cli/projects/init.py b/arm_cli/projects/init.py index 1ed45ed..842c4b1 100644 --- a/arm_cli/projects/init.py +++ b/arm_cli/projects/init.py @@ -12,11 +12,7 @@ ) -@click.command() -@click.argument("project_path", type=click.Path(exists=True, file_okay=False, dir_okay=True)) -@click.option("--name", help="Name for the project (defaults to directory name)") -@click.pass_context -def init(ctx, project_path: str, name: Optional[str] = None): +def _init(ctx, project_path: str, name: Optional[str] = None): """Initialize a new project from an existing directory""" config = ctx.obj["config"] @@ -62,3 +58,13 @@ def init(ctx, project_path: str, name: Optional[str] = None): print(f"Project '{project_config.name}' initialized and set as active") print(f"Project directory: {project_config.project_directory}") + + +# Create the command object +init = click.command(name="init")( + click.argument("project_path", type=click.Path(exists=True, file_okay=False, dir_okay=True))( + click.option("--name", help="Name for the project (defaults to directory name)")( + click.pass_context(_init) + ) + ) +) diff --git a/arm_cli/projects/list.py b/arm_cli/projects/list.py index f4f350c..5aea6af 100644 --- a/arm_cli/projects/list.py +++ b/arm_cli/projects/list.py @@ -3,9 +3,7 @@ from arm_cli.config import get_available_projects -@click.command() -@click.pass_context -def list(ctx): +def _list(ctx): """List all available projects""" config = ctx.obj["config"] available_projects = get_available_projects(config) @@ -20,3 +18,7 @@ def list(ctx): print(f" {i}. {project.name}{active_indicator}") print(f" Path: {project.path}") print() + + +# Create the command object +list = click.command(name="ls")(click.pass_context(_list)) diff --git a/arm_cli/projects/projects.py b/arm_cli/projects/projects.py index 2b697d7..dc0370b 100644 --- a/arm_cli/projects/projects.py +++ b/arm_cli/projects/projects.py @@ -1,6 +1,18 @@ import click -from arm_cli.projects import activate, info, init, list, remove +# Import the modules and access the command objects +import arm_cli.projects.activate +import arm_cli.projects.info +import arm_cli.projects.init +import arm_cli.projects.list +import arm_cli.projects.remove + +# Get the command objects +activate = arm_cli.projects.activate.activate +info = arm_cli.projects.info.info +init = arm_cli.projects.init.init +ls_cmd = arm_cli.projects.list.list +remove = arm_cli.projects.remove.remove @click.group() @@ -10,8 +22,8 @@ def projects(): # Register all project commands -projects.add_command(init.init) -projects.add_command(activate.activate) -projects.add_command(list.list) -projects.add_command(info.info) -projects.add_command(remove.remove) +projects.add_command(init) +projects.add_command(activate) +projects.add_command(ls_cmd) +projects.add_command(info) +projects.add_command(remove) diff --git a/arm_cli/projects/remove.py b/arm_cli/projects/remove.py index 1ff7d65..02350ad 100644 --- a/arm_cli/projects/remove.py +++ b/arm_cli/projects/remove.py @@ -6,10 +6,7 @@ from arm_cli.config import get_available_projects, remove_project_from_list, save_config -@click.command() -@click.argument("project", required=False) -@click.pass_context -def remove(ctx, project: Optional[str] = None): +def _remove(ctx, project: Optional[str] = None): """Remove a project from available projects""" config = ctx.obj["config"] @@ -111,3 +108,9 @@ def remove(ctx, project: Optional[str] = None): print(f" {i}. {proj.name} ({proj.path})") else: print(" No projects available.") + + +# Create the command object +remove = click.command(name="remove")( + click.argument("project", required=False)(click.pass_context(_remove)) +) diff --git a/arm_cli/system/shell_scripts/shell_addins.sh b/arm_cli/system/shell_scripts/shell_addins.sh index 100fa25..484b75f 100644 --- a/arm_cli/system/shell_scripts/shell_addins.sh +++ b/arm_cli/system/shell_scripts/shell_addins.sh @@ -36,6 +36,9 @@ setup_alias() { if [[ $- == *i* ]]; then # Only define alias in interactive shells alias "$alias_name"="$cli_path" complete -o default -F _arm_cli_completion "$alias_name" 2>/dev/null || true + + # Add cdp alias to change to project directory + alias cdp='cd "$(arm-cli projects info --field "project_directory")"' fi fi } diff --git a/tests/unit/projects/test_projects.py b/tests/unit/projects/test_projects.py index f6882b9..c2d79eb 100644 --- a/tests/unit/projects/test_projects.py +++ b/tests/unit/projects/test_projects.py @@ -37,97 +37,164 @@ def mock_config(temp_project_config): return config -def test_projects_cd_no_active_project(runner): - """Test that cd command fails when no active project is configured.""" - with patch("arm_cli.projects.projects.get_active_project_config") as mock_get_config: +def test_projects_info_no_active_project(runner): + """Test that info command handles no active project gracefully.""" + with patch("arm_cli.projects.info.get_active_project_config") as mock_get_config: mock_get_config.return_value = None - result = runner.invoke(projects, ["cd"], obj={"config": Config()}) + result = runner.invoke(projects, ["info"], obj={"config": Config()}) - assert result.exit_code == 1 + assert result.exit_code == 0 assert "No active project configured" in result.output -def test_projects_cd_no_project_directory(runner, mock_config): - """Test that cd command fails when project has no directory configured.""" - project_config = ProjectConfig(name="test", project_directory=None) +def test_projects_info_with_project(runner, mock_config, temp_project_config): + """Test that info command displays project information correctly.""" + project_config = ProjectConfig(**temp_project_config) - with patch("arm_cli.projects.projects.get_active_project_config") as mock_get_config: + with patch("arm_cli.projects.info.get_active_project_config") as mock_get_config: mock_get_config.return_value = project_config - result = runner.invoke(projects, ["cd"], obj={"config": mock_config}) + result = runner.invoke(projects, ["info"], obj={"config": mock_config}) - assert result.exit_code == 1 - assert "No project directory configured" in result.output + assert result.exit_code == 0 + assert "Active Project: test-project" in result.output + assert "Description: Test project for unit tests" in result.output + assert "Project Directory: /tmp/test-project" in result.output + assert "Docker Compose File: docker-compose.yml" in result.output + assert "Data Directory: /DATA" in result.output -def test_projects_cd_directory_does_not_exist(runner, mock_config): - """Test that cd command fails when project directory doesn't exist.""" - project_config = ProjectConfig(name="test", project_directory="/nonexistent/path") +def test_projects_info_minimal_project(runner, mock_config): + """Test that info command works with minimal project configuration.""" + project_config = ProjectConfig(name="minimal-project") - with patch("arm_cli.projects.projects.get_active_project_config") as mock_get_config: + with patch("arm_cli.projects.info.get_active_project_config") as mock_get_config: mock_get_config.return_value = project_config - result = runner.invoke(projects, ["cd"], obj={"config": mock_config}) + result = runner.invoke(projects, ["info"], obj={"config": mock_config}) + + assert result.exit_code == 0 + assert "Active Project: minimal-project" in result.output - assert result.exit_code == 1 - assert "Project directory does not exist" in result.output +def test_projects_info_field_option(runner, mock_config, temp_project_config): + """Test that info command with --field extracts specific field values.""" + project_config = ProjectConfig(**temp_project_config) -def test_projects_cd_success(runner, mock_config): - """Test that cd command succeeds with valid project directory.""" - with tempfile.TemporaryDirectory() as temp_dir: - project_config = ProjectConfig(name="test", project_directory=temp_dir) + with patch("arm_cli.projects.info.get_active_project_config") as mock_get_config: + mock_get_config.return_value = project_config - with patch("arm_cli.projects.projects.get_active_project_config") as mock_get_config: - mock_get_config.return_value = project_config + # Test each field + result = runner.invoke(projects, ["info", "--field", "name"], obj={"config": mock_config}) + assert result.exit_code == 0 + assert result.output.strip() == "test-project" - result = runner.invoke(projects, ["cd"], obj={"config": mock_config}) + result = runner.invoke( + projects, ["info", "--field", "description"], obj={"config": mock_config} + ) + assert result.exit_code == 0 + assert result.output.strip() == "Test project for unit tests" - assert result.exit_code == 0 - assert "Changed to project directory" in result.output - assert temp_dir in result.output + result = runner.invoke( + projects, ["info", "--field", "project_directory"], obj={"config": mock_config} + ) + assert result.exit_code == 0 + assert result.output.strip() == "/tmp/test-project" + result = runner.invoke( + projects, ["info", "--field", "docker_compose_file"], obj={"config": mock_config} + ) + assert result.exit_code == 0 + assert result.output.strip() == "docker-compose.yml" -def test_projects_info_no_active_project(runner): - """Test that info command handles no active project gracefully.""" - with patch("arm_cli.projects.projects.get_active_project_config") as mock_get_config: - mock_get_config.return_value = None + result = runner.invoke( + projects, ["info", "--field", "data_directory"], obj={"config": mock_config} + ) + assert result.exit_code == 0 + assert result.output.strip() == "/DATA" - result = runner.invoke(projects, ["info"], obj={"config": Config()}) +def test_projects_info_field_with_spaces(runner, mock_config, temp_project_config): + """Test that info command handles field names with spaces correctly.""" + project_config = ProjectConfig(**temp_project_config) + + with patch("arm_cli.projects.info.get_active_project_config") as mock_get_config: + mock_get_config.return_value = project_config + + # Test field names with spaces (should be converted to underscores) + result = runner.invoke( + projects, ["info", "--field", "Project Directory"], obj={"config": mock_config} + ) assert result.exit_code == 0 - assert "No active project configured" in result.output + assert result.output.strip() == "/tmp/test-project" + result = runner.invoke( + projects, ["info", "--field", "Docker Compose File"], obj={"config": mock_config} + ) + assert result.exit_code == 0 + assert result.output.strip() == "docker-compose.yml" -def test_projects_info_with_project(runner, mock_config, temp_project_config): - """Test that info command displays project information correctly.""" + +def test_projects_info_field_case_insensitive(runner, mock_config, temp_project_config): + """Test that info command handles case-insensitive field names.""" project_config = ProjectConfig(**temp_project_config) - with patch("arm_cli.projects.projects.get_active_project_config") as mock_get_config: + with patch("arm_cli.projects.info.get_active_project_config") as mock_get_config: mock_get_config.return_value = project_config - result = runner.invoke(projects, ["info"], obj={"config": mock_config}) + # Test different case variations + result = runner.invoke( + projects, ["info", "--field", "PROJECT_DIRECTORY"], obj={"config": mock_config} + ) + assert result.exit_code == 0 + assert result.output.strip() == "/tmp/test-project" + result = runner.invoke( + projects, ["info", "--field", "Project_Directory"], obj={"config": mock_config} + ) assert result.exit_code == 0 - assert "Active Project: test-project" in result.output - assert "Description: Test project for unit tests" in result.output - assert "Project Directory: /tmp/test-project" in result.output - assert "Docker Compose File: docker-compose.yml" in result.output - assert "Data Directory: /DATA" in result.output + assert result.output.strip() == "/tmp/test-project" -def test_projects_info_minimal_project(runner, mock_config): - """Test that info command works with minimal project configuration.""" - project_config = ProjectConfig(name="minimal-project") +def test_projects_info_field_empty_value(runner, mock_config): + """Test that info command handles empty field values correctly.""" + project_config = ProjectConfig(name="test", description=None, project_directory=None) - with patch("arm_cli.projects.projects.get_active_project_config") as mock_get_config: + with patch("arm_cli.projects.info.get_active_project_config") as mock_get_config: mock_get_config.return_value = project_config - result = runner.invoke(projects, ["info"], obj={"config": mock_config}) + result = runner.invoke( + projects, ["info", "--field", "description"], obj={"config": mock_config} + ) + assert result.exit_code == 0 + assert result.output.strip() == "" # Empty string for None values + + +def test_projects_info_field_unknown_field(runner, mock_config, temp_project_config): + """Test that info command handles unknown field names gracefully.""" + project_config = ProjectConfig(**temp_project_config) + + with patch("arm_cli.projects.info.get_active_project_config") as mock_get_config: + mock_get_config.return_value = project_config + + result = runner.invoke( + projects, ["info", "--field", "nonexistent_field"], obj={"config": mock_config} + ) + assert result.exit_code == 0 # Click doesn't exit with error code for stderr output + assert "Unknown field: nonexistent_field" in result.output + assert "Available fields:" in result.output + + +def test_projects_info_field_no_active_project(runner): + """Test that info command with --field handles no active project gracefully.""" + with patch("arm_cli.projects.info.get_active_project_config") as mock_get_config: + mock_get_config.return_value = None + + result = runner.invoke(projects, ["info", "--field", "name"], obj={"config": Config()}) assert result.exit_code == 0 - assert "Active Project: minimal-project" in result.output + assert "No active project configured" in result.output def test_projects_help(runner): @@ -136,5 +203,8 @@ def test_projects_help(runner): assert result.exit_code == 0 assert "Manage ARM projects" in result.output - assert "cd" in result.output + assert "activate" in result.output assert "info" in result.output + assert "init" in result.output + assert "list" in result.output + assert "remove" in result.output From a08c9ce6b69712169fb7d88f49e15dd60948336c Mon Sep 17 00:00:00 2001 From: Matthew Powelson Date: Thu, 14 Aug 2025 13:45:33 -0400 Subject: [PATCH 8/9] Fix cdp when using ~ --- arm_cli/config.py | 33 ++++++++++++++++++-- arm_cli/projects/activate.py | 25 +++++++++------ arm_cli/projects/list.py | 4 +-- arm_cli/projects/remove.py | 16 +++++----- arm_cli/system/shell_scripts/shell_addins.sh | 2 +- 5 files changed, 58 insertions(+), 22 deletions(-) diff --git a/arm_cli/config.py b/arm_cli/config.py index 4325dbf..f51c39a 100644 --- a/arm_cli/config.py +++ b/arm_cli/config.py @@ -117,6 +117,15 @@ def add_project_to_list(config: Config, project_path: str, project_name: str) -> def get_available_projects(config: Config) -> List[AvailableProject]: """Get the list of available projects.""" + # If no projects are available, ensure the default project is added + if not config.available_projects: + try: + default_path = copy_default_project_config() + default_project_config = load_project_config(str(default_path)) + add_project_to_list(config, str(default_path), default_project_config.name) + except Exception as e: + print(f"Error setting up default project: {e}") + return config.available_projects @@ -202,8 +211,12 @@ def get_active_project_config(config: Config) -> Optional[ProjectConfig]: try: default_path = copy_default_project_config() config.active_project = str(default_path) - save_config(config) - return load_project_config(config.active_project) + + # Also add the default project to available projects if not already there + default_project_config = load_project_config(str(default_path)) + add_project_to_list(config, str(default_path), default_project_config.name) + + return default_project_config except Exception as e: print(f"Error setting up default project config: {e}") return None @@ -216,3 +229,19 @@ def get_active_project_config(config: Config) -> Optional[ProjectConfig]: except IsADirectoryError: print(f"Active project config path is a directory: {config.active_project}") return None + + +def print_no_projects_message() -> None: + """Print the standard message when no projects are available.""" + print("No projects available. Use 'arm-cli projects init ' to add a project.") + + +def print_available_projects(config: Config) -> None: + """Print the list of available projects.""" + available_projects = get_available_projects(config) + if available_projects: + print("Available projects:") + for i, proj in enumerate(available_projects, 1): + print(f" {i}. {proj.name} ({proj.path})") + else: + print_no_projects_message() diff --git a/arm_cli/projects/activate.py b/arm_cli/projects/activate.py index d7817c3..242ca91 100644 --- a/arm_cli/projects/activate.py +++ b/arm_cli/projects/activate.py @@ -3,7 +3,13 @@ import click import inquirer -from arm_cli.config import activate_project, get_available_projects +from arm_cli.config import ( + activate_project, + get_active_project_config, + get_available_projects, + print_available_projects, + print_no_projects_message, +) def _activate(ctx, project: Optional[str] = None): @@ -15,7 +21,14 @@ def _activate(ctx, project: Optional[str] = None): available_projects = get_available_projects(config) if not available_projects: - print("No projects available. Use 'arm-cli projects init ' to add a project.") + print("No projects available. Setting up default project...") + project_config = get_active_project_config(config) + if project_config: + print(f"Activated default project: {project_config.name}") + print(f"Project directory: {project_config.project_directory}") + else: + print("Failed to set up default project.") + print_no_projects_message() return # Create choices for inquirer @@ -55,13 +68,7 @@ def _activate(ctx, project: Optional[str] = None): print(f"Project directory: {project_config.project_directory}") else: print(f"Project '{project}' not found in available projects") - print("\nAvailable projects:") - available_projects = get_available_projects(config) - if available_projects: - for i, proj in enumerate(available_projects, 1): - print(f" {i}. {proj.name} ({proj.path})") - else: - print(" No projects available. Use 'arm-cli projects init ' to add a project.") + print_available_projects(config) # Create the command object diff --git a/arm_cli/projects/list.py b/arm_cli/projects/list.py index 5aea6af..286fbb3 100644 --- a/arm_cli/projects/list.py +++ b/arm_cli/projects/list.py @@ -1,6 +1,6 @@ import click -from arm_cli.config import get_available_projects +from arm_cli.config import get_available_projects, print_no_projects_message def _list(ctx): @@ -9,7 +9,7 @@ def _list(ctx): available_projects = get_available_projects(config) if not available_projects: - print("No projects available. Use 'arm-cli projects init ' to add a project.") + print_no_projects_message() return print("Available Projects:") diff --git a/arm_cli/projects/remove.py b/arm_cli/projects/remove.py index 02350ad..d8d289f 100644 --- a/arm_cli/projects/remove.py +++ b/arm_cli/projects/remove.py @@ -3,7 +3,13 @@ import click import inquirer -from arm_cli.config import get_available_projects, remove_project_from_list, save_config +from arm_cli.config import ( + get_available_projects, + print_available_projects, + print_no_projects_message, + remove_project_from_list, + save_config, +) def _remove(ctx, project: Optional[str] = None): @@ -101,13 +107,7 @@ def _remove(ctx, project: Optional[str] = None): print("Active project has been cleared.") else: print(f"Project '{project}' not found in available projects") - print("\nAvailable projects:") - available_projects = get_available_projects(config) - if available_projects: - for i, proj in enumerate(available_projects, 1): - print(f" {i}. {proj.name} ({proj.path})") - else: - print(" No projects available.") + print_available_projects(config) # Create the command object diff --git a/arm_cli/system/shell_scripts/shell_addins.sh b/arm_cli/system/shell_scripts/shell_addins.sh index 484b75f..da032cc 100644 --- a/arm_cli/system/shell_scripts/shell_addins.sh +++ b/arm_cli/system/shell_scripts/shell_addins.sh @@ -38,7 +38,7 @@ setup_alias() { complete -o default -F _arm_cli_completion "$alias_name" 2>/dev/null || true # Add cdp alias to change to project directory - alias cdp='cd "$(arm-cli projects info --field "project_directory")"' + alias cdp='cd "$(arm-cli projects info --field "project_directory" | sed "s|^~|$HOME|")"' fi fi } From fbbaef796fbd9ba3a9aead72c91ae7a58541b0b8 Mon Sep 17 00:00:00 2001 From: Matthew Powelson Date: Thu, 14 Aug 2025 13:53:46 -0400 Subject: [PATCH 9/9] Stop hardcoding /DATA and fix unit tests --- arm_cli/container/container.py | 2 +- arm_cli/system/setup_utils.py | 18 +++++++++++++----- arm_cli/system/system.py | 6 +++++- feature_roadmap.md | 4 ++-- tests/test_config.py | 8 ++++---- tests/unit/projects/test_projects.py | 2 +- 6 files changed, 26 insertions(+), 14 deletions(-) diff --git a/arm_cli/container/container.py b/arm_cli/container/container.py index c1180cf..eac6fde 100644 --- a/arm_cli/container/container.py +++ b/arm_cli/container/container.py @@ -8,7 +8,7 @@ @click.group() def container(): """Basic tools for managing Docker containers. For more extensive tooling, - try lazydocker""" + try lazydocker (brew install jesseduffield/lazydocker/lazydocker)""" pass diff --git a/arm_cli/system/setup_utils.py b/arm_cli/system/setup_utils.py index 6663bfd..3c3be35 100644 --- a/arm_cli/system/setup_utils.py +++ b/arm_cli/system/setup_utils.py @@ -94,9 +94,13 @@ def check_sudo_privileges(): return False -def check_data_directories_setup(): +def check_data_directories_setup(data_directory="/DATA"): """Check if data directories are already properly set up""" - data_dirs = ["/DATA/influxdb2", "/DATA/images", "/DATA/node_exporter"] + data_dirs = [ + os.path.join(data_directory, "influxdb2"), + os.path.join(data_directory, "images"), + os.path.join(data_directory, "node_exporter"), + ] current_uid = os.getuid() current_gid = os.getgid() @@ -122,11 +126,11 @@ def check_data_directories_setup(): return True -def setup_data_directories(force=False): +def setup_data_directories(force=False, data_directory="/DATA"): """Setup data directories for the ARM system""" try: # Check if directories are already properly set up - if check_data_directories_setup(): + if check_data_directories_setup(data_directory): print("Data directories are already properly set up.") return True @@ -140,7 +144,11 @@ def setup_data_directories(force=False): # Ask user for confirmation print("This will create the following directories:") - data_dirs = ["/DATA/influxdb2", "/DATA/images", "/DATA/node_exporter"] + data_dirs = [ + os.path.join(data_directory, "influxdb2"), + os.path.join(data_directory, "images"), + os.path.join(data_directory, "node_exporter"), + ] for directory in data_dirs: print(f" - {directory}") print("And set appropriate ownership and permissions.") diff --git a/arm_cli/system/system.py b/arm_cli/system/system.py index 0079016..8c72822 100644 --- a/arm_cli/system/system.py +++ b/arm_cli/system/system.py @@ -41,7 +41,11 @@ def setup(ctx, force): print("You can run this setup again later with: arm-cli system setup") # Setup data directories (may require sudo) - if not setup_data_directories(force=force): + data_directory = "/DATA" # Default fallback + if project_config and project_config.data_directory: + data_directory = project_config.data_directory + + if not setup_data_directories(force=force, data_directory=data_directory): print("Data directory setup was not completed.") print("You can run this setup again later with: arm-cli system setup") diff --git a/feature_roadmap.md b/feature_roadmap.md index 15a1172..8af6b7f 100644 --- a/feature_roadmap.md +++ b/feature_roadmap.md @@ -1,6 +1,6 @@ Probably mostly in order -1) Global config - store active session info -2) Project config - store path for `cdc`, data dirs, etc +1) Fully flesh out the project configs and how that plays into system setup +2) Add `arm-cli system install-tools` for easy configuration of new machines 3) Add additional tab completes 4) Additional integration tests on project setup 5) Deployment tools - possibly using ansible or maybe save that for later diff --git a/tests/test_config.py b/tests/test_config.py index dc6d1d5..a204c6a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -29,7 +29,7 @@ def test_config_model_dump(self): """Test that Config can be serialized to dict.""" config = Config(active_project="test-project") data = config.model_dump() - assert data == {"active_project": "test-project"} + assert data == {"active_project": "test-project", "available_projects": []} class TestConfigFunctions: @@ -66,7 +66,7 @@ def test_save_config(self): with open(config_file, "r") as f: data = json.load(f) - assert data == {"active_project": "test-project"} + assert data == {"active_project": "test-project", "available_projects": []} def test_load_config_new_file(self): """Test that new config file is created when it doesn't exist.""" @@ -87,7 +87,7 @@ def test_load_config_new_file(self): # Verify file contents with open(config_file, "r") as f: data = json.load(f) - assert data == {"active_project": ""} + assert data == {"active_project": "", "available_projects": []} def test_load_config_existing_file(self): """Test that existing config file is loaded correctly.""" @@ -124,7 +124,7 @@ def test_load_config_corrupted_file(self): # Verify file was overwritten with valid JSON with open(config_file, "r") as f: data = json.load(f) - assert data == {"active_project": ""} + assert data == {"active_project": "", "available_projects": []} def test_load_config_missing_fields(self): """Test that config with missing fields is handled gracefully.""" diff --git a/tests/unit/projects/test_projects.py b/tests/unit/projects/test_projects.py index c2d79eb..6c2d6ac 100644 --- a/tests/unit/projects/test_projects.py +++ b/tests/unit/projects/test_projects.py @@ -206,5 +206,5 @@ def test_projects_help(runner): assert "activate" in result.output assert "info" in result.output assert "init" in result.output - assert "list" in result.output + assert "ls" in result.output assert "remove" in result.output