diff --git a/dotflow/cli/commands/__init__.py b/dotflow/cli/commands/__init__.py index 0f66e5e1..964704be 100644 --- a/dotflow/cli/commands/__init__.py +++ b/dotflow/cli/commands/__init__.py @@ -4,6 +4,8 @@ from dotflow.cli.commands.deploy import DeployCommand from dotflow.cli.commands.init import InitCommand from dotflow.cli.commands.log import LogCommand +from dotflow.cli.commands.login import LoginCommand +from dotflow.cli.commands.logout import LogoutCommand from dotflow.cli.commands.schedule import ScheduleCommand from dotflow.cli.commands.start import StartCommand @@ -13,6 +15,8 @@ "DeployCommand", "InitCommand", "LogCommand", + "LoginCommand", + "LogoutCommand", "ScheduleCommand", "StartCommand", ] diff --git a/dotflow/cli/commands/login.py b/dotflow/cli/commands/login.py new file mode 100644 index 00000000..8aeca7f7 --- /dev/null +++ b/dotflow/cli/commands/login.py @@ -0,0 +1,123 @@ +"""Command login module.""" + +from __future__ import annotations + +import os +import time +import webbrowser + +from requests import RequestException, post +from rich import print # type: ignore + +from dotflow.cli.command import Command +from dotflow.core.config_file import save_cloud_config +from dotflow.settings import Settings as settings + +DEFAULT_BASE_URL = "https://www.host.dotflow.io/api/v1" +DEVICE_ENDPOINT = "/cli/device" +TOKEN_ENDPOINT = "/cli/token" +TIMEOUT = 15 +DEFAULT_INTERVAL = 5 +MAX_POLL_INTERVAL = 60 + + +class LoginCommand(Command): + def setup(self): + token = getattr(self.params, "token", None) + base_url = self._resolve_base_url() + + if token: + save_cloud_config(token=token, base_url=base_url) + print(settings.INFO_ALERT, "Token saved.") + return + + handshake = self._start_device(base_url) + if handshake is None: + return + + print(settings.INFO_ALERT, f"Opening {handshake['verification_uri']}") + print(settings.INFO_ALERT, f"Code: {handshake['user_code']}") + + webbrowser.open(handshake["verification_uri"]) + + token = self._poll_token(base_url, handshake) + + if not token: + return + + save_cloud_config(token=token, base_url=base_url) + print(settings.INFO_ALERT, "Authenticated.") + + def _resolve_base_url(self) -> str: + """Flag or env var wins over the default.""" + explicit = getattr(self.params, "base_url", None) or os.environ.get( + "SERVER_BASE_URL" + ) + return explicit.rstrip("/") if explicit else DEFAULT_BASE_URL + + def _start_device(self, base_url: str) -> dict | None: + try: + response = post( + f"{base_url}{DEVICE_ENDPOINT}", + timeout=TIMEOUT, + ) + response.raise_for_status() + return response.json() + except RequestException as error: + print( + settings.ERROR_ALERT, + f"Could not reach Dotflow Cloud: {error}", + ) + return None + + def _poll_token(self, base_url: str, handshake: dict) -> str | None: + interval = handshake.get("interval") or DEFAULT_INTERVAL + deadline = time.monotonic() + (handshake.get("expires_in") or 300) + + while time.monotonic() < deadline: + try: + response = post( + f"{base_url}{TOKEN_ENDPOINT}", + json={"device_code": handshake["device_code"]}, + timeout=TIMEOUT, + ) + except RequestException as error: + print(settings.ERROR_ALERT, f"Network error: {error}") + return None + + if response.status_code == 200: + return response.json().get("api_token") + + detail = self._detail(response) + + if ( + response.status_code == 400 + and detail == "authorization_pending" + ): + time.sleep(interval) + continue + + if response.status_code == 400 and detail in ( + "slow_down", + "slow down", + ): + interval = min(interval + 5, MAX_POLL_INTERVAL) + time.sleep(interval) + continue + + if response.status_code == 410: + print(settings.ERROR_ALERT, "Authorization expired.") + return None + + print(settings.ERROR_ALERT, f"{response.status_code}: {detail}") + return None + + print(settings.ERROR_ALERT, "Timed out waiting for authorization.") + return None + + @staticmethod + def _detail(response) -> str: + try: + return str(response.json().get("detail", response.text)) + except ValueError: + return response.text diff --git a/dotflow/cli/commands/logout.py b/dotflow/cli/commands/logout.py new file mode 100644 index 00000000..d2e92a61 --- /dev/null +++ b/dotflow/cli/commands/logout.py @@ -0,0 +1,15 @@ +"""Command logout module.""" + +from rich import print # type: ignore + +from dotflow.cli.command import Command +from dotflow.core.config_file import clear_cloud_config +from dotflow.settings import Settings as settings + + +class LogoutCommand(Command): + def setup(self): + if clear_cloud_config(): + print(settings.INFO_ALERT, "Signed out.") + else: + print(settings.WARNING_ALERT, "No saved credentials.") diff --git a/dotflow/cli/setup.py b/dotflow/cli/setup.py index a3609091..2ce5c73b 100644 --- a/dotflow/cli/setup.py +++ b/dotflow/cli/setup.py @@ -9,6 +9,8 @@ DeployCommand, InitCommand, LogCommand, + LoginCommand, + LogoutCommand, ScheduleCommand, StartCommand, ) @@ -42,12 +44,37 @@ def __init__(self, parser): self.setup_init() self.setup_logs() + self.setup_login() + self.setup_logout() self.setup_start() self.setup_schedule() self.setup_cloud() self.setup_deploy() self.command() + def setup_login(self): + cmd = self.subparsers.add_parser( + "login", help="Authenticate the CLI via browser" + ) + cmd_group = cmd.add_argument_group("Usage: dotflow login [OPTIONS]") + cmd_group.add_argument( + "--base-url", + default=None, + help="Override the Dotflow Cloud API base URL", + ) + cmd_group.add_argument( + "--token", + default=None, + help="Skip the browser flow and save a pre-obtained API token", + ) + cmd.set_defaults(exec=LoginCommand) + + def setup_logout(self): + cmd = self.subparsers.add_parser( + "logout", help="Remove saved CLI credentials" + ) + cmd.set_defaults(exec=LogoutCommand) + def setup_init(self): self.cmd_init = self.subparsers.add_parser( "init", help="Scaffold a new dotflow project" diff --git a/dotflow/core/config_file.py b/dotflow/core/config_file.py new file mode 100644 index 00000000..b5828ff5 --- /dev/null +++ b/dotflow/core/config_file.py @@ -0,0 +1,95 @@ +"""Persistent CLI config stored at ``~/.dotflow/config.json``.""" + +from __future__ import annotations + +import contextlib +import json +import os +from pathlib import Path + +CONFIG_DIR_NAME = ".dotflow" +CONFIG_FILE_NAME = "config.json" +CLOUD_SECTION = "cloud" + + +def config_path() -> Path: + """Return the absolute path of the CLI config file.""" + return Path.home() / CONFIG_DIR_NAME / CONFIG_FILE_NAME + + +def load_cloud_config() -> dict[str, str]: + """Read the ``cloud`` section from the config file. ``{}`` when absent.""" + path = config_path() + + if not path.exists(): + return {} + + try: + raw = path.read_text(encoding="utf-8") + except OSError: + return {} + + try: + data = json.loads(raw) + except json.JSONDecodeError: + return {} + + section = data.get(CLOUD_SECTION) if isinstance(data, dict) else None + if not isinstance(section, dict): + return {} + + return { + key: str(value) + for key, value in section.items() + if isinstance(value, str) + } + + +def save_cloud_config(token: str, base_url: str) -> Path: + """Persist ``cloud`` with the given token/base_url. Returns the file path.""" + path = config_path() + path.parent.mkdir(parents=True, exist_ok=True) + + payload = { + CLOUD_SECTION: { + "base_url": base_url, + "token": token, + } + } + content = json.dumps(payload, indent=2) + "\n" + + fd = os.open( + str(path), + os.O_WRONLY | os.O_CREAT | os.O_TRUNC, + 0o600, + ) + with contextlib.suppress(OSError): + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(content) + + return path + + +def clear_cloud_config() -> bool: + """Remove the config file. Returns True when something was deleted.""" + path = config_path() + + if not path.exists(): + return False + + try: + path.unlink() + return True + except OSError: + return False + + +def resolve(key: str, env_var: str) -> str | None: + """Resolve a setting with env > file precedence. ``None`` when absent.""" + env_value = os.environ.get(env_var) + + if env_value: + return env_value + + return load_cloud_config().get(key) or None diff --git a/dotflow/providers/server_default.py b/dotflow/providers/server_default.py index 9c8d1f5e..f426735b 100644 --- a/dotflow/providers/server_default.py +++ b/dotflow/providers/server_default.py @@ -1,4 +1,4 @@ -"""ServerDefault — no-op or managed HTTP server provider.""" +"""ServerDefault - managed HTTP server provider.""" from __future__ import annotations @@ -24,24 +24,19 @@ def wrapper(self, *args, **kwargs): class ServerDefault(Server): - """Default Server provider with auto-detected managed mode. - - When ``SERVER_BASE_URL`` and ``SERVER_USER_TOKEN`` env vars are set, - sends workflow and task events to the remote API. Otherwise, all - methods are no-ops. - """ + """Default Server provider with auto-detected managed mode.""" MAX_RESULT_SIZE = 5_000_000 TIMEOUT = 15.0 - ENDPOINT_WORKFLOWS = "/workflows" - ENDPOINT_WORKFLOW = "/workflows/{workflow_id}" - ENDPOINT_TASKS = "/workflows/{workflow_id}/tasks" - ENDPOINT_TASK = "/workflows/{workflow_id}/tasks/{task_id}" + ENDPOINT_WORKFLOWS = "/cli/workflows" + ENDPOINT_WORKFLOW = "/cli/workflows/{workflow_id}" + ENDPOINT_TASKS = "/cli/workflows/{workflow_id}/tasks" + ENDPOINT_TASK = "/cli/workflows/{workflow_id}/tasks/{task_id}" def __init__(self) -> None: - base_url = os.getenv("SERVER_BASE_URL") - user_token = os.getenv("SERVER_USER_TOKEN") + base_url = os.environ.get("SERVER_BASE_URL") or None + user_token = os.environ.get("SERVER_USER_TOKEN") or None self._managed = bool(base_url and user_token) self._base_url = base_url.rstrip("/") if base_url else None @@ -56,25 +51,23 @@ def _headers(self) -> dict: def _post(self, url: str, json: dict) -> None: try: - response = post( + post( url, json=json, headers=self._headers, timeout=self.TIMEOUT, ) - logger.info("POST %s [%s]", url, response.status_code) except RequestException as error: logger.error("POST %s failed: %s", url, error) def _patch(self, url: str, json: dict) -> None: try: - response = patch( + patch( url, json=json, headers=self._headers, timeout=self.TIMEOUT, ) - logger.info("PATCH %s [%s]", url, response.status_code) except RequestException as error: logger.error("PATCH %s failed: %s", url, error) diff --git a/tests/cli/test_login_command.py b/tests/cli/test_login_command.py new file mode 100644 index 00000000..d15f8ac9 --- /dev/null +++ b/tests/cli/test_login_command.py @@ -0,0 +1,166 @@ +"""Tests for LoginCommand and LogoutCommand.""" + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from dotflow.cli.commands.login import LoginCommand +from dotflow.cli.commands.logout import LogoutCommand + + +def _params(**kwargs) -> SimpleNamespace: + defaults = {"base_url": None, "token": None} + defaults.update(kwargs) + return SimpleNamespace(**defaults) + + +def _make_cmd(cls, params: SimpleNamespace): + cmd = cls.__new__(cls) + cmd.params = params + return cmd + + +@pytest.fixture +def tmp_home(tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + yield tmp_path + + +class TestLoginWithExplicitToken: + def test_saves_token_without_network_calls(self, tmp_home): + cmd = _make_cmd( + LoginCommand, + _params(token="dtf_sk_direct", base_url="https://api.local"), + ) + with ( + patch("dotflow.cli.commands.login.post") as mock_post, + patch( + "dotflow.cli.commands.login.webbrowser.open" + ) as mock_browser, + ): + cmd.setup() + mock_post.assert_not_called() + mock_browser.assert_not_called() + + config = tmp_home / ".dotflow" / "config.json" + assert config.exists() + content = config.read_text() + assert '"token": "dtf_sk_direct"' in content + assert '"base_url": "https://api.local"' in content + + +class TestLoginDeviceFlow: + def _responses(self, *items): + for item in items: + r = MagicMock() + r.status_code = item[0] + if len(item) > 1: + r.json.return_value = item[1] + else: + r.json.return_value = {} + yield r + + def test_persists_token_on_successful_poll(self, tmp_home): + handshake = MagicMock() + handshake.status_code = 201 + handshake.json.return_value = { + "device_code": "device-xyz", + "user_code": "ABCD-1234", + "verification_uri": "https://cloud.dotflow.io/cli?code=ABCD-1234", + "interval": 0, + "expires_in": 60, + } + handshake.raise_for_status = MagicMock() + + pending = MagicMock() + pending.status_code = 400 + pending.json.return_value = {"detail": "authorization_pending"} + + ok = MagicMock() + ok.status_code = 200 + ok.json.return_value = {"api_token": "dtf_sk_from_browser"} + + cmd = _make_cmd( + LoginCommand, + _params(base_url="https://api.local"), + ) + with ( + patch( + "dotflow.cli.commands.login.post", + side_effect=[handshake, pending, ok], + ) as mock_post, + patch( + "dotflow.cli.commands.login.webbrowser.open" + ) as mock_browser, + ): + cmd.setup() + + assert mock_post.call_count == 3 + mock_browser.assert_called_once_with( + "https://cloud.dotflow.io/cli?code=ABCD-1234" + ) + config = tmp_home / ".dotflow" / "config.json" + assert '"token": "dtf_sk_from_browser"' in config.read_text() + + def test_aborts_when_authorization_gone(self, tmp_home): + handshake = MagicMock() + handshake.status_code = 201 + handshake.json.return_value = { + "device_code": "d", + "user_code": "A-B", + "verification_uri": "https://x", + "interval": 0, + "expires_in": 60, + } + handshake.raise_for_status = MagicMock() + + gone = MagicMock() + gone.status_code = 410 + gone.json.return_value = {"detail": "Device code expired"} + + cmd = _make_cmd(LoginCommand, _params(base_url="https://api.local")) + with ( + patch( + "dotflow.cli.commands.login.post", + side_effect=[handshake, gone], + ), + patch("dotflow.cli.commands.login.webbrowser.open"), + ): + cmd.setup() + + assert not (tmp_home / ".dotflow" / "config.json").exists() + + def test_explicit_base_url_skips_fallback(self, tmp_home): + from requests import ConnectionError as RequestsConnectionError + + cmd = _make_cmd(LoginCommand, _params(base_url="https://only.local")) + with ( + patch( + "dotflow.cli.commands.login.post", + side_effect=RequestsConnectionError("unreachable"), + ) as mock_post, + patch("dotflow.cli.commands.login.webbrowser.open"), + ): + cmd.setup() + + assert mock_post.call_count == 1 + assert not (tmp_home / ".dotflow" / "config.json").exists() + + +class TestLogout: + def test_deletes_config_file(self, tmp_home): + (tmp_home / ".dotflow").mkdir(parents=True, exist_ok=True) + (tmp_home / ".dotflow" / "config.json").write_text( + '{"cloud": {"token": "x"}}' + ) + + cmd = _make_cmd(LogoutCommand, _params()) + cmd.setup() + + assert not (tmp_home / ".dotflow" / "config.json").exists() + + def test_is_noop_when_no_config(self, tmp_home): + cmd = _make_cmd(LogoutCommand, _params()) + cmd.setup() # no error expected diff --git a/tests/core/test_config_file.py b/tests/core/test_config_file.py new file mode 100644 index 00000000..23d30941 --- /dev/null +++ b/tests/core/test_config_file.py @@ -0,0 +1,106 @@ +"""Tests for the CLI config file helpers and env>file precedence.""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from dotflow.core.config_file import ( + clear_cloud_config, + config_path, + load_cloud_config, + resolve, + save_cloud_config, +) + + +@pytest.fixture +def tmp_home(tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + yield tmp_path + + +class TestSaveAndLoad: + def test_roundtrip(self, tmp_home): + save_cloud_config( + token="dtf_sk_abc", base_url="https://api.example.com" + ) + data = load_cloud_config() + assert data == { + "base_url": "https://api.example.com", + "token": "dtf_sk_abc", + } + + def test_file_is_written_with_0600_perms(self, tmp_home): + save_cloud_config(token="t", base_url="b") + mode = config_path().stat().st_mode & 0o777 + assert mode == 0o600 + + def test_load_returns_empty_when_missing(self, tmp_home): + assert load_cloud_config() == {} + + def test_handles_special_chars_in_values(self, tmp_home): + save_cloud_config(token='tok"en\\x', base_url="url with space") + data = load_cloud_config() + assert data["token"] == 'tok"en\\x' + assert data["base_url"] == "url with space" + + def test_ignores_other_sections(self, tmp_home): + import json + + config_path().parent.mkdir(parents=True, exist_ok=True) + config_path().write_text( + json.dumps( + { + "cloud": { + "token": "abc", + "base_url": "http://x", + }, + "other": {"token": "ignored"}, + } + ) + ) + assert load_cloud_config() == { + "token": "abc", + "base_url": "http://x", + } + + def test_returns_empty_on_invalid_json(self, tmp_home): + config_path().parent.mkdir(parents=True, exist_ok=True) + config_path().write_text("not { valid json") + assert load_cloud_config() == {} + + +class TestClear: + def test_removes_file(self, tmp_home): + save_cloud_config(token="t", base_url="b") + assert clear_cloud_config() is True + assert not config_path().exists() + + def test_returns_false_when_file_missing(self, tmp_home): + assert clear_cloud_config() is False + + +class TestResolvePrecedence: + def test_env_wins_over_file(self, tmp_home): + save_cloud_config(token="file-token", base_url="file-url") + with patch.dict( + "os.environ", + {"SERVER_USER_TOKEN": "env-token"}, + clear=False, + ): + assert resolve("token", "SERVER_USER_TOKEN") == "env-token" + + def test_file_used_when_env_missing(self, tmp_home, monkeypatch): + monkeypatch.delenv("SERVER_USER_TOKEN", raising=False) + save_cloud_config(token="file-token", base_url="file-url") + assert resolve("token", "SERVER_USER_TOKEN") == "file-token" + + def test_returns_none_when_nothing_set(self, tmp_home, monkeypatch): + monkeypatch.delenv("SERVER_USER_TOKEN", raising=False) + assert resolve("token", "SERVER_USER_TOKEN") is None + + def test_empty_env_falls_back_to_file(self, tmp_home, monkeypatch): + monkeypatch.setenv("SERVER_USER_TOKEN", "") + save_cloud_config(token="file-token", base_url="b") + assert resolve("token", "SERVER_USER_TOKEN") == "file-token"