diff --git a/README.md b/README.md index 5a4c6327..ec035803 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,9 @@ codealmanac setup --yes --garden-off codealmanac setup --yes --no-auto-update ``` +Setup exits before writing configuration or agent instructions on non-macOS +systems because the production scheduler currently depends on `launchd`. + To uninstall CodeAlmanac-owned local artifacts: ```bash diff --git a/src/codealmanac/app.py b/src/codealmanac/app.py index ca69e029..0002da70 100644 --- a/src/codealmanac/app.py +++ b/src/codealmanac/app.py @@ -1,3 +1,4 @@ +import platform from collections.abc import Sequence from dataclasses import dataclass @@ -199,6 +200,9 @@ def create_services( adapters: AppAdapters, ) -> Services: repositories = RepositoriesService(RepositoryStore(local_state.database_path)) + automation_unavailable_reason = default_scheduler_unavailable_reason( + adapters.scheduler + ) automation = AutomationService(adapters.scheduler or LaunchdSchedulerAdapter()) config_service = ConfigService( ConfigStore(), @@ -248,6 +252,7 @@ def create_services( or PackageToolUninstaller(package_metadata, package_runner), config_service, runner_probe=harnesses, + automation_unavailable_reason=automation_unavailable_reason, ) runs = RunsService( repositories, @@ -287,6 +292,23 @@ def create_services( ) +def default_scheduler_unavailable_reason( + scheduler: SchedulerAdapter | None, + system_name: str | None = None, +) -> str | None: + """Describe why the production scheduler cannot run on this platform.""" + if scheduler is not None: + return None + detected_system = system_name or platform.system() + if detected_system == "Darwin": + return None + return ( + "setup currently supports macOS only because scheduled automation uses " + f"launchd; detected {detected_system or 'an unknown platform'}. " + "No setup changes were made." + ) + + def create_operation(services: Services) -> OperationRunner: return OperationRunner( services.repositories, diff --git a/src/codealmanac/services/setup/service.py b/src/codealmanac/services/setup/service.py index d5e7a772..9207ee5e 100644 --- a/src/codealmanac/services/setup/service.py +++ b/src/codealmanac/services/setup/service.py @@ -1,3 +1,4 @@ +from codealmanac.core.errors import ValidationFailed from codealmanac.services.automation.requests import RemoveAllAutomationRequest from codealmanac.services.config.models import ( AutomationConfig, @@ -36,6 +37,7 @@ def __init__( package_uninstaller: PackageUninstaller, config: ConfigService, runner_probe: RunnerReadinessProbe | None = None, + automation_unavailable_reason: str | None = None, ): self._instructions = instructions self._automation_remover = automation_remover @@ -43,8 +45,11 @@ def __init__( self._package_uninstaller = package_uninstaller self._config = config self._runner_probe = runner_probe + self._automation_unavailable_reason = automation_unavailable_reason def run(self, request: RunSetupRequest) -> SetupResult: + if self._automation_unavailable_reason is not None: + raise ValidationFailed(self._automation_unavailable_reason) readiness = require_runner(self._runner_probe, request) config_update = self.set_config(request) changes = () diff --git a/tests/test_setup_service.py b/tests/test_setup_service.py index e5228835..8af0842c 100644 --- a/tests/test_setup_service.py +++ b/tests/test_setup_service.py @@ -4,7 +4,8 @@ import pytest from pydantic import ValidationError -from codealmanac.core.errors import ExecutionFailed +from codealmanac.app import default_scheduler_unavailable_reason +from codealmanac.core.errors import ExecutionFailed, ValidationFailed from codealmanac.integrations.setup.instructions import ( CLAUDE_IMPORT_LINE, CODEALMANAC_END, @@ -380,6 +381,30 @@ def test_setup_without_probe_reports_no_readiness(home: Path): assert result.runner_readiness is None +def test_setup_rejects_unsupported_platform_before_writing(home: Path): + automation = FakeSetupAutomationManager(home) + service = setup_service( + home, + automation=automation, + automation_unavailable_reason=default_scheduler_unavailable_reason( + None, "Linux" + ), + ) + + with pytest.raises(ValidationFailed, match="detected Linux"): + service.run(RunSetupRequest(targets=(SetupTarget.CODEX,))) + + assert automation.applied == [] + assert not (home / ".codealmanac/config.toml").exists() + assert not (home / ".codex/AGENTS.md").exists() + + +def test_custom_scheduler_remains_available_on_non_macos(): + scheduler = object() + + assert default_scheduler_unavailable_reason(scheduler, "Linux") is None + + @pytest.fixture def home(tmp_path: Path) -> Path: return tmp_path / "home" @@ -390,6 +415,7 @@ def setup_service( automation: "FakeSetupAutomationManager | None" = None, package_uninstaller: "FakePackageUninstaller | None" = None, runner_probe: "FakeRunnerProbe | None" = None, + automation_unavailable_reason: str | None = None, ) -> SetupService: automation_manager = automation or FakeSetupAutomationManager(home) config = ConfigService( @@ -404,6 +430,7 @@ def setup_service( package_uninstaller or FakePackageUninstaller(skipped_package_result()), config=config, runner_probe=runner_probe, + automation_unavailable_reason=automation_unavailable_reason, )