From a69bda1078f8ef9e5b3a4fde75dad1a81a5914bb Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Tue, 14 Apr 2026 21:45:39 -0300 Subject: [PATCH 01/28] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20FEATURE-#269:=20Add?= =?UTF-8?q?=20config=5Ffile=20module=20with=20env-over-file=20token=20reso?= =?UTF-8?q?lution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/core/config_file.py | 121 ++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 dotflow/core/config_file.py diff --git a/dotflow/core/config_file.py b/dotflow/core/config_file.py new file mode 100644 index 00000000..f4d10ad9 --- /dev/null +++ b/dotflow/core/config_file.py @@ -0,0 +1,121 @@ +"""Persistent CLI config stored at ``~/.dotflow/config.toml``. + +Kept minimal on purpose — the only shape we read/write is:: + + [cloud] + base_url = "https://host.dotflow.io/api/v1" + token = "dtf_sk_..." +""" + +from __future__ import annotations + +import contextlib +import os +import re +from pathlib import Path + +CONFIG_DIR_NAME = ".dotflow" +CONFIG_FILE_NAME = "config.toml" +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 ``[cloud]`` from the config file. Returns {} when absent.""" + path = config_path() + + if not path.exists(): + return {} + + try: + return _parse_cloud_section(path.read_text(encoding="utf-8")) + except OSError: + return {} + + +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) + + content = ( + f"[{CLOUD_SECTION}]\n" + f'base_url = "{_escape(base_url)}"\n' + f'token = "{_escape(token)}"\n' + ) + path.write_text(content, encoding="utf-8") + + with contextlib.suppress(OSError): + os.chmod(path, 0o600) + + 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 + + +_SECTION_RE = re.compile(r"^\s*\[(?P[^\]]+)\]\s*$") +_KEY_VALUE_RE = re.compile( + r'^\s*(?P[A-Za-z_][A-Za-z0-9_-]*)\s*=\s*"(?P(?:[^"\\]|\\.)*)"\s*$' +) + + +def _parse_cloud_section(content: str) -> dict[str, str]: + """Parse just ``[cloud]`` key/string-value pairs from a TOML-ish file.""" + current_section: str | None = None + result: dict[str, str] = {} + + for line in content.splitlines(): + stripped = line.strip() + + if not stripped or stripped.startswith("#"): + continue + + section = _SECTION_RE.match(line) + if section: + current_section = section.group("name").strip() + continue + + if current_section != CLOUD_SECTION: + continue + + key_value = _KEY_VALUE_RE.match(line) + if not key_value: + continue + + result[key_value.group("key")] = _unescape(key_value.group("value")) + + return result + + +def _escape(value: str) -> str: + return value.replace("\\", "\\\\").replace('"', '\\"') + + +def _unescape(value: str) -> str: + return value.replace('\\"', '"').replace("\\\\", "\\") From 6de87d47f661d263493030d47e7a8cce30109662 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Tue, 14 Apr 2026 21:45:39 -0300 Subject: [PATCH 02/28] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20FEATURE-#269:=20Add?= =?UTF-8?q?=20dotflow=20login=20with=20device=20flow=20and=20base=20URL=20?= =?UTF-8?q?fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/cli/commands/login.py | 138 ++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 dotflow/cli/commands/login.py diff --git a/dotflow/cli/commands/login.py b/dotflow/cli/commands/login.py new file mode 100644 index 00000000..26d71995 --- /dev/null +++ b/dotflow/cli/commands/login.py @@ -0,0 +1,138 @@ +"""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_URLS = ( + "https://www.host.dotflow.io/api/v1", + "https://www.lib.dotflow.io/api/v1", +) +DEFAULT_BASE_URL = DEFAULT_BASE_URLS[0] +DEVICE_ENDPOINT = "/auth/cli/device" +TOKEN_ENDPOINT = "/auth/cli/token" +TIMEOUT = 15 +DEFAULT_INTERVAL = 5 + + +class LoginCommand(Command): + def setup(self): + token = getattr(self.params, "token", None) + + if token: + base_url = self._explicit_base_url() or DEFAULT_BASE_URL + save_cloud_config(token=token, base_url=base_url) + print(settings.INFO_ALERT, "Token saved.") + return + + handshake_result = self._start_device_with_fallback() + + if handshake_result is None: + return + + base_url, handshake = handshake_result + + 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 _explicit_base_url(self) -> str | None: + """Return the user-provided base URL (flag/env), or ``None``.""" + explicit = getattr(self.params, "base_url", None) or os.environ.get( + "SERVER_BASE_URL" + ) + return explicit.rstrip("/") if explicit else None + + def _candidate_base_urls(self) -> list[str]: + """URLs to try in order. Explicit flag/env always wins and skips fallback.""" + explicit = self._explicit_base_url() + + if explicit: + return [explicit] + return list(DEFAULT_BASE_URLS) + + def _start_device_with_fallback(self) -> tuple[str, dict] | None: + """Try each candidate base URL until one responds; returns (url, payload).""" + last_error: Exception | None = None + + for base_url in self._candidate_base_urls(): + try: + payload = self._start_device(base_url) + return base_url, payload + except RequestException as error: + last_error = error + continue + + print( + settings.ERROR_ALERT, + f"Could not reach Dotflow Cloud: {last_error}", + ) + return None + + def _start_device(self, base_url: str) -> dict: + response = post(f"{base_url}{DEVICE_ENDPOINT}", timeout=TIMEOUT) + response.raise_for_status() + + return response.json() + + 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 == 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 From ed23983b447d92a5ebdac4bd7fbff61704bcd4fe Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Tue, 14 Apr 2026 21:45:39 -0300 Subject: [PATCH 03/28] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20FEATURE-#269:=20Add?= =?UTF-8?q?=20dotflow=20logout=20to=20clear=20the=20saved=20CLI=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/cli/commands/logout.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 dotflow/cli/commands/logout.py 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.") From 6dd2153e75d2a1b146f8ed56ffd9adc7f28f7d4d Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Tue, 14 Apr 2026 21:45:48 -0300 Subject: [PATCH 04/28] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20FEATURE-#269:=20Expo?= =?UTF-8?q?rt=20LoginCommand=20and=20LogoutCommand=20from=20cli=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/cli/commands/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) 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", ] From 6f3e8f379546c0a0c2a0e2a9c848d2756dfc7b42 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Tue, 14 Apr 2026 21:45:48 -0300 Subject: [PATCH 05/28] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20FEATURE-#269:=20Wire?= =?UTF-8?q?=20login=20and=20logout=20subcommands=20in=20the=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/cli/setup.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) 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" From 3eb6124ff3b02d252eb1efc933a1ef0eeb1bb817 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Tue, 14 Apr 2026 21:45:48 -0300 Subject: [PATCH 06/28] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20FEATURE-#269:=20Reso?= =?UTF-8?q?lve=20server=20credentials=20via=20env-over-file=20precedence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/providers/server_default.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dotflow/providers/server_default.py b/dotflow/providers/server_default.py index 9c8d1f5e..2ee1d7af 100644 --- a/dotflow/providers/server_default.py +++ b/dotflow/providers/server_default.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os from functools import wraps from typing import Any @@ -10,6 +9,7 @@ from requests.exceptions import RequestException from dotflow.abc.server import Server +from dotflow.core.config_file import resolve from dotflow.logging import logger @@ -40,8 +40,8 @@ class ServerDefault(Server): ENDPOINT_TASK = "/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 = resolve(key="base_url", env_var="SERVER_BASE_URL") + user_token = resolve(key="token", env_var="SERVER_USER_TOKEN") self._managed = bool(base_url and user_token) self._base_url = base_url.rstrip("/") if base_url else None From e608c917c4ad3ea1a52582ba14131ce8ebde022a Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Tue, 14 Apr 2026 21:45:56 -0300 Subject: [PATCH 07/28] =?UTF-8?q?=E2=9D=A4=EF=B8=8F=20TEST-#269:=20Cover?= =?UTF-8?q?=20config=20file=20roundtrip,=20perms,=20and=20env-over-file=20?= =?UTF-8?q?resolve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/core/test_config_file.py | 89 ++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/core/test_config_file.py diff --git a/tests/core/test_config_file.py b/tests/core/test_config_file.py new file mode 100644 index 00000000..cab8dd62 --- /dev/null +++ b/tests/core/test_config_file.py @@ -0,0 +1,89 @@ +"""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_escapes_double_quote_in_values(self, tmp_home): + save_cloud_config(token='tok"en', base_url='url"with"quote') + data = load_cloud_config() + assert data["token"] == 'tok"en' + assert data["base_url"] == 'url"with"quote' + + def test_ignores_other_sections(self, tmp_home): + config_path().parent.mkdir(parents=True, exist_ok=True) + config_path().write_text( + '[cloud]\ntoken = "abc"\nbase_url = "http://x"\n' + '[other]\ntoken = "ignored"\n' + ) + assert load_cloud_config() == {"token": "abc", "base_url": "http://x"} + + +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" From 483d5218392eca07a9232ca6faf075e8f0843cb6 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Tue, 14 Apr 2026 21:45:56 -0300 Subject: [PATCH 08/28] =?UTF-8?q?=E2=9D=A4=EF=B8=8F=20TEST-#269:=20Cover?= =?UTF-8?q?=20login=20device=20flow,=20base=20URL=20fallback,=20and=20logo?= =?UTF-8?q?ut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/cli/test_login_command.py | 215 ++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 tests/cli/test_login_command.py diff --git a/tests/cli/test_login_command.py b/tests/cli/test_login_command.py new file mode 100644 index 00000000..b05fcdec --- /dev/null +++ b/tests/cli/test_login_command.py @@ -0,0 +1,215 @@ +"""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.toml" + 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.toml" + 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.toml").exists() + + def test_falls_back_to_next_base_url_when_first_fails(self, tmp_home): + from requests import ConnectionError as RequestsConnectionError + + from dotflow.cli.commands.login import DEFAULT_BASE_URLS + + handshake = MagicMock() + handshake.status_code = 201 + handshake.json.return_value = { + "device_code": "d", + "user_code": "AAAA-0000", + "verification_uri": "https://x", + "interval": 0, + "expires_in": 60, + } + handshake.raise_for_status = MagicMock() + + ok = MagicMock() + ok.status_code = 200 + ok.json.return_value = {"api_token": "dtf_sk_second"} + + cmd = _make_cmd(LoginCommand, _params()) + with ( + patch( + "dotflow.cli.commands.login.post", + side_effect=[ + RequestsConnectionError("first down"), + handshake, + ok, + ], + ) as mock_post, + patch("dotflow.cli.commands.login.webbrowser.open"), + ): + cmd.setup() + + assert ( + mock_post.call_args_list[0] + .args[0] + .startswith(DEFAULT_BASE_URLS[0]) + ) + assert ( + mock_post.call_args_list[1] + .args[0] + .startswith(DEFAULT_BASE_URLS[1]) + ) + + config = tmp_home / ".dotflow" / "config.toml" + assert 'token = "dtf_sk_second"' in config.read_text() + assert f'base_url = "{DEFAULT_BASE_URLS[1]}"' in config.read_text() + + 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.toml").exists() + + +class TestLogout: + def test_deletes_config_file(self, tmp_home): + (tmp_home / ".dotflow").mkdir(parents=True, exist_ok=True) + (tmp_home / ".dotflow" / "config.toml").write_text( + '[cloud]\ntoken = "x"\n' + ) + + cmd = _make_cmd(LogoutCommand, _params()) + cmd.setup() + + assert not (tmp_home / ".dotflow" / "config.toml").exists() + + def test_is_noop_when_no_config(self, tmp_home): + cmd = _make_cmd(LogoutCommand, _params()) + cmd.setup() # no error expected From 70487222bc8bd9e64b85dfc2b24eecdf1e45280e Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Tue, 14 Apr 2026 22:25:47 -0300 Subject: [PATCH 09/28] =?UTF-8?q?=F0=9F=AA=B2=20BUG-#269:=20Create=20CLI?= =?UTF-8?q?=20config=20atomically=20with=200600=20perms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/core/config_file.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/dotflow/core/config_file.py b/dotflow/core/config_file.py index f4d10ad9..9f29a240 100644 --- a/dotflow/core/config_file.py +++ b/dotflow/core/config_file.py @@ -9,7 +9,6 @@ from __future__ import annotations -import contextlib import os import re from pathlib import Path @@ -47,10 +46,18 @@ def save_cloud_config(token: str, base_url: str) -> Path: f'base_url = "{_escape(base_url)}"\n' f'token = "{_escape(token)}"\n' ) - path.write_text(content, encoding="utf-8") - with contextlib.suppress(OSError): - os.chmod(path, 0o600) + fd = os.open( + str(path), + os.O_WRONLY | os.O_CREAT | os.O_TRUNC, + 0o600, + ) + try: + os.fchmod(fd, 0o600) + except OSError: + pass + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(content) return path From 32948869055494b1c207ad94cd5a7fb5b6a479dd Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Tue, 14 Apr 2026 22:25:50 -0300 Subject: [PATCH 10/28] =?UTF-8?q?=F0=9F=AA=B2=20BUG-#269:=20Honor=20RFC=20?= =?UTF-8?q?8628=20slow=5Fdown=20signal=20on=20CLI=20poll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/cli/commands/login.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/dotflow/cli/commands/login.py b/dotflow/cli/commands/login.py index 26d71995..1594303e 100644 --- a/dotflow/cli/commands/login.py +++ b/dotflow/cli/commands/login.py @@ -120,6 +120,14 @@ def _poll_token(self, base_url: str, handshake: dict) -> str | None: time.sleep(interval) continue + if ( + response.status_code == 400 + and detail in ("slow_down", "slow down") + ): + interval += 5 + time.sleep(interval) + continue + if response.status_code == 410: print(settings.ERROR_ALERT, "Authorization expired.") return None From 42dd723f8506fdb97b0043010251ff7643476d45 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Wed, 15 Apr 2026 00:29:16 -0300 Subject: [PATCH 11/28] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20FEATURE-#269:=20Swit?= =?UTF-8?q?ch=20CLI=20config=20from=20TOML=20to=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/core/config_file.py | 92 ++++++++++++------------------------- 1 file changed, 30 insertions(+), 62 deletions(-) diff --git a/dotflow/core/config_file.py b/dotflow/core/config_file.py index 9f29a240..47c33d32 100644 --- a/dotflow/core/config_file.py +++ b/dotflow/core/config_file.py @@ -1,20 +1,15 @@ -"""Persistent CLI config stored at ``~/.dotflow/config.toml``. - -Kept minimal on purpose — the only shape we read/write is:: - - [cloud] - base_url = "https://host.dotflow.io/api/v1" - token = "dtf_sk_..." +"""Persistent CLI config stored at ``~/.dotflow/config.json``. """ from __future__ import annotations +import contextlib +import json import os -import re from pathlib import Path CONFIG_DIR_NAME = ".dotflow" -CONFIG_FILE_NAME = "config.toml" +CONFIG_FILE_NAME = "config.json" CLOUD_SECTION = "cloud" @@ -24,38 +19,53 @@ def config_path() -> Path: def load_cloud_config() -> dict[str, str]: - """Read ``[cloud]`` from the config file. Returns {} when absent.""" + """Read the ``cloud`` section from the config file. ``{}`` when absent.""" path = config_path() if not path.exists(): return {} try: - return _parse_cloud_section(path.read_text(encoding="utf-8")) + 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.""" + """Persist ``cloud`` with the given token/base_url. Returns the file path.""" path = config_path() path.parent.mkdir(parents=True, exist_ok=True) - content = ( - f"[{CLOUD_SECTION}]\n" - f'base_url = "{_escape(base_url)}"\n' - f'token = "{_escape(token)}"\n' - ) + 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, ) - try: + with contextlib.suppress(OSError): os.fchmod(fd, 0o600) - except OSError: - pass with os.fdopen(fd, "w", encoding="utf-8") as fh: fh.write(content) @@ -84,45 +94,3 @@ def resolve(key: str, env_var: str) -> str | None: return env_value return load_cloud_config().get(key) or None - - -_SECTION_RE = re.compile(r"^\s*\[(?P[^\]]+)\]\s*$") -_KEY_VALUE_RE = re.compile( - r'^\s*(?P[A-Za-z_][A-Za-z0-9_-]*)\s*=\s*"(?P(?:[^"\\]|\\.)*)"\s*$' -) - - -def _parse_cloud_section(content: str) -> dict[str, str]: - """Parse just ``[cloud]`` key/string-value pairs from a TOML-ish file.""" - current_section: str | None = None - result: dict[str, str] = {} - - for line in content.splitlines(): - stripped = line.strip() - - if not stripped or stripped.startswith("#"): - continue - - section = _SECTION_RE.match(line) - if section: - current_section = section.group("name").strip() - continue - - if current_section != CLOUD_SECTION: - continue - - key_value = _KEY_VALUE_RE.match(line) - if not key_value: - continue - - result[key_value.group("key")] = _unescape(key_value.group("value")) - - return result - - -def _escape(value: str) -> str: - return value.replace("\\", "\\\\").replace('"', '\\"') - - -def _unescape(value: str) -> str: - return value.replace('\\"', '"').replace("\\\\", "\\") From c5b63d71875a1ddb6b150e4905232bdd683ecfac Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Wed, 15 Apr 2026 00:29:19 -0300 Subject: [PATCH 12/28] =?UTF-8?q?=E2=9D=A4=EF=B8=8F=20TEST-#269:=20Update?= =?UTF-8?q?=20config=20file=20tests=20for=20JSON=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/core/test_config_file.py | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/tests/core/test_config_file.py b/tests/core/test_config_file.py index cab8dd62..3ba7127a 100644 --- a/tests/core/test_config_file.py +++ b/tests/core/test_config_file.py @@ -39,19 +39,36 @@ def test_file_is_written_with_0600_perms(self, tmp_home): def test_load_returns_empty_when_missing(self, tmp_home): assert load_cloud_config() == {} - def test_escapes_double_quote_in_values(self, tmp_home): - save_cloud_config(token='tok"en', base_url='url"with"quote') + 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' - assert data["base_url"] == 'url"with"quote' + 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( - '[cloud]\ntoken = "abc"\nbase_url = "http://x"\n' - '[other]\ntoken = "ignored"\n' + json.dumps( + { + "cloud": { + "token": "abc", + "base_url": "http://x", + }, + "other": {"token": "ignored"}, + } + ) ) - assert load_cloud_config() == {"token": "abc", "base_url": "http://x"} + 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: From 345a1f81e1474a2370d61d24b02f639a04b20d29 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Wed, 15 Apr 2026 00:29:23 -0300 Subject: [PATCH 13/28] =?UTF-8?q?=E2=9D=A4=EF=B8=8F=20TEST-#269:=20Align?= =?UTF-8?q?=20login=20tests=20with=20JSON=20config=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/cli/test_login_command.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/tests/cli/test_login_command.py b/tests/cli/test_login_command.py index b05fcdec..e52f344d 100644 --- a/tests/cli/test_login_command.py +++ b/tests/cli/test_login_command.py @@ -44,11 +44,11 @@ def test_saves_token_without_network_calls(self, tmp_home): mock_post.assert_not_called() mock_browser.assert_not_called() - config = tmp_home / ".dotflow" / "config.toml" + 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 + assert '"token": "dtf_sk_direct"' in content + assert '"base_url": "https://api.local"' in content class TestLoginDeviceFlow: @@ -101,8 +101,8 @@ def test_persists_token_on_successful_poll(self, tmp_home): mock_browser.assert_called_once_with( "https://cloud.dotflow.io/cli?code=ABCD-1234" ) - config = tmp_home / ".dotflow" / "config.toml" - assert 'token = "dtf_sk_from_browser"' in config.read_text() + 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() @@ -130,7 +130,7 @@ def test_aborts_when_authorization_gone(self, tmp_home): ): cmd.setup() - assert not (tmp_home / ".dotflow" / "config.toml").exists() + assert not (tmp_home / ".dotflow" / "config.json").exists() def test_falls_back_to_next_base_url_when_first_fails(self, tmp_home): from requests import ConnectionError as RequestsConnectionError @@ -177,9 +177,11 @@ def test_falls_back_to_next_base_url_when_first_fails(self, tmp_home): .startswith(DEFAULT_BASE_URLS[1]) ) - config = tmp_home / ".dotflow" / "config.toml" - assert 'token = "dtf_sk_second"' in config.read_text() - assert f'base_url = "{DEFAULT_BASE_URLS[1]}"' in config.read_text() + config = tmp_home / ".dotflow" / "config.json" + assert '"token": "dtf_sk_second"' in config.read_text() + assert ( + f'"base_url": "{DEFAULT_BASE_URLS[1]}"' in config.read_text() + ) def test_explicit_base_url_skips_fallback(self, tmp_home): from requests import ConnectionError as RequestsConnectionError @@ -195,20 +197,20 @@ def test_explicit_base_url_skips_fallback(self, tmp_home): cmd.setup() assert mock_post.call_count == 1 - assert not (tmp_home / ".dotflow" / "config.toml").exists() + 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.toml").write_text( - '[cloud]\ntoken = "x"\n' + (tmp_home / ".dotflow" / "config.json").write_text( + '{"cloud": {"token": "x"}}' ) cmd = _make_cmd(LogoutCommand, _params()) cmd.setup() - assert not (tmp_home / ".dotflow" / "config.toml").exists() + assert not (tmp_home / ".dotflow" / "config.json").exists() def test_is_noop_when_no_config(self, tmp_home): cmd = _make_cmd(LogoutCommand, _params()) From 49ae9d6b303c0b32fa4f2be474486d60deb0ea86 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Wed, 15 Apr 2026 00:29:26 -0300 Subject: [PATCH 14/28] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20FEATURE-#269:=20Isol?= =?UTF-8?q?ate=20ServerDefault=20to=20env=20vars=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/providers/server_default.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/dotflow/providers/server_default.py b/dotflow/providers/server_default.py index 2ee1d7af..f5d49574 100644 --- a/dotflow/providers/server_default.py +++ b/dotflow/providers/server_default.py @@ -1,7 +1,8 @@ -"""ServerDefault — no-op or managed HTTP server provider.""" +"""ServerDefault - managed HTTP server provider.""" from __future__ import annotations +import os from functools import wraps from typing import Any @@ -9,7 +10,6 @@ from requests.exceptions import RequestException from dotflow.abc.server import Server -from dotflow.core.config_file import resolve from dotflow.logging import logger @@ -25,10 +25,6 @@ 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. """ MAX_RESULT_SIZE = 5_000_000 @@ -40,8 +36,8 @@ class ServerDefault(Server): ENDPOINT_TASK = "/workflows/{workflow_id}/tasks/{task_id}" def __init__(self) -> None: - base_url = resolve(key="base_url", env_var="SERVER_BASE_URL") - user_token = resolve(key="token", env_var="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 +52,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) From 64d11a3d2b243eb4a1e7ea981e0734246c49bfa8 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Wed, 15 Apr 2026 00:29:33 -0300 Subject: [PATCH 15/28] =?UTF-8?q?=E2=9C=A8=20STYLE-#269:=20Blank=20line=20?= =?UTF-8?q?after=20LogCommand=20class=20declaration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/cli/commands/log.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dotflow/cli/commands/log.py b/dotflow/cli/commands/log.py index b447aeba..0c404c0e 100644 --- a/dotflow/cli/commands/log.py +++ b/dotflow/cli/commands/log.py @@ -8,6 +8,7 @@ class LogCommand(Command): + def setup(self): if settings.LOG_PATH.exists(): print(read_file(path=settings.LOG_PATH)) From 34b7427616aca402d53a7c5c97afe34157f8b75a Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Wed, 15 Apr 2026 00:29:38 -0300 Subject: [PATCH 16/28] =?UTF-8?q?=E2=9C=A8=20STYLE-#269:=20Blank=20line=20?= =?UTF-8?q?after=20LoginCommand=20class=20declaration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/cli/commands/login.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dotflow/cli/commands/login.py b/dotflow/cli/commands/login.py index 1594303e..eb8cebaf 100644 --- a/dotflow/cli/commands/login.py +++ b/dotflow/cli/commands/login.py @@ -25,6 +25,7 @@ class LoginCommand(Command): + def setup(self): token = getattr(self.params, "token", None) From 3e964883cacb775d5352ef61bd53dd5bb3938a8e Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Wed, 15 Apr 2026 00:29:43 -0300 Subject: [PATCH 17/28] =?UTF-8?q?=E2=9C=A8=20STYLE-#269:=20Blank=20line=20?= =?UTF-8?q?after=20ScheduleCommand=20class=20declaration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/cli/commands/schedule.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dotflow/cli/commands/schedule.py b/dotflow/cli/commands/schedule.py index 7f41a93d..16f19b20 100644 --- a/dotflow/cli/commands/schedule.py +++ b/dotflow/cli/commands/schedule.py @@ -12,6 +12,7 @@ class ScheduleCommand(Command): + def setup(self): workflow = self._new_workflow() From db1304e10b4f3b043049350ab8368f50439f3e3f Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Wed, 15 Apr 2026 00:38:52 -0300 Subject: [PATCH 18/28] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20FEATURE-#269:=20Use?= =?UTF-8?q?=20single=20CLI=20base=20URL=20(drop=20host/lib=20fallback)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/cli/commands/login.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dotflow/cli/commands/login.py b/dotflow/cli/commands/login.py index eb8cebaf..51516d2f 100644 --- a/dotflow/cli/commands/login.py +++ b/dotflow/cli/commands/login.py @@ -14,8 +14,7 @@ from dotflow.settings import Settings as settings DEFAULT_BASE_URLS = ( - "https://www.host.dotflow.io/api/v1", - "https://www.lib.dotflow.io/api/v1", + "https://www.cli.dotflow.io/api/v1", ) DEFAULT_BASE_URL = DEFAULT_BASE_URLS[0] DEVICE_ENDPOINT = "/auth/cli/device" From ec246483fe0bfad69d8c041e9590274eb4f93e57 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Wed, 15 Apr 2026 00:38:55 -0300 Subject: [PATCH 19/28] =?UTF-8?q?=E2=9D=A4=EF=B8=8F=20TEST-#269:=20Remove?= =?UTF-8?q?=20base=20URL=20fallback=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/cli/test_login_command.py | 51 --------------------------------- 1 file changed, 51 deletions(-) diff --git a/tests/cli/test_login_command.py b/tests/cli/test_login_command.py index e52f344d..d15f8ac9 100644 --- a/tests/cli/test_login_command.py +++ b/tests/cli/test_login_command.py @@ -132,57 +132,6 @@ def test_aborts_when_authorization_gone(self, tmp_home): assert not (tmp_home / ".dotflow" / "config.json").exists() - def test_falls_back_to_next_base_url_when_first_fails(self, tmp_home): - from requests import ConnectionError as RequestsConnectionError - - from dotflow.cli.commands.login import DEFAULT_BASE_URLS - - handshake = MagicMock() - handshake.status_code = 201 - handshake.json.return_value = { - "device_code": "d", - "user_code": "AAAA-0000", - "verification_uri": "https://x", - "interval": 0, - "expires_in": 60, - } - handshake.raise_for_status = MagicMock() - - ok = MagicMock() - ok.status_code = 200 - ok.json.return_value = {"api_token": "dtf_sk_second"} - - cmd = _make_cmd(LoginCommand, _params()) - with ( - patch( - "dotflow.cli.commands.login.post", - side_effect=[ - RequestsConnectionError("first down"), - handshake, - ok, - ], - ) as mock_post, - patch("dotflow.cli.commands.login.webbrowser.open"), - ): - cmd.setup() - - assert ( - mock_post.call_args_list[0] - .args[0] - .startswith(DEFAULT_BASE_URLS[0]) - ) - assert ( - mock_post.call_args_list[1] - .args[0] - .startswith(DEFAULT_BASE_URLS[1]) - ) - - config = tmp_home / ".dotflow" / "config.json" - assert '"token": "dtf_sk_second"' in config.read_text() - assert ( - f'"base_url": "{DEFAULT_BASE_URLS[1]}"' in config.read_text() - ) - def test_explicit_base_url_skips_fallback(self, tmp_home): from requests import ConnectionError as RequestsConnectionError From 1bb16c30dbcecb0a2097edbcf79859d4776bd87a Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Wed, 15 Apr 2026 00:39:44 -0300 Subject: [PATCH 20/28] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20FEATURE-#269:=20Coll?= =?UTF-8?q?apse=20login=20base=20URL=20to=20single=20DEFAULT=5FBASE=5FURL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/cli/commands/login.py | 65 +++++++++++------------------------ 1 file changed, 21 insertions(+), 44 deletions(-) diff --git a/dotflow/cli/commands/login.py b/dotflow/cli/commands/login.py index 51516d2f..f41d82c0 100644 --- a/dotflow/cli/commands/login.py +++ b/dotflow/cli/commands/login.py @@ -13,10 +13,7 @@ from dotflow.core.config_file import save_cloud_config from dotflow.settings import Settings as settings -DEFAULT_BASE_URLS = ( - "https://www.cli.dotflow.io/api/v1", -) -DEFAULT_BASE_URL = DEFAULT_BASE_URLS[0] +DEFAULT_BASE_URL = "https://www.cli.dotflow.io/api/v1" DEVICE_ENDPOINT = "/auth/cli/device" TOKEN_ENDPOINT = "/auth/cli/token" TIMEOUT = 15 @@ -27,20 +24,17 @@ class LoginCommand(Command): def setup(self): token = getattr(self.params, "token", None) + base_url = self._resolve_base_url() if token: - base_url = self._explicit_base_url() or DEFAULT_BASE_URL save_cloud_config(token=token, base_url=base_url) print(settings.INFO_ALERT, "Token saved.") return - handshake_result = self._start_device_with_fallback() - - if handshake_result is None: + handshake = self._start_device(base_url) + if handshake is None: return - base_url, handshake = handshake_result - print(settings.INFO_ALERT, f"Opening {handshake['verification_uri']}") print(settings.INFO_ALERT, f"Code: {handshake['user_code']}") @@ -54,44 +48,27 @@ def setup(self): save_cloud_config(token=token, base_url=base_url) print(settings.INFO_ALERT, "Authenticated.") - def _explicit_base_url(self) -> str | None: - """Return the user-provided base URL (flag/env), or ``None``.""" + 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 None - - def _candidate_base_urls(self) -> list[str]: - """URLs to try in order. Explicit flag/env always wins and skips fallback.""" - explicit = self._explicit_base_url() - - if explicit: - return [explicit] - return list(DEFAULT_BASE_URLS) + return explicit.rstrip("/") if explicit else DEFAULT_BASE_URL - def _start_device_with_fallback(self) -> tuple[str, dict] | None: - """Try each candidate base URL until one responds; returns (url, payload).""" - last_error: Exception | None = None - - for base_url in self._candidate_base_urls(): - try: - payload = self._start_device(base_url) - return base_url, payload - except RequestException as error: - last_error = error - continue - - print( - settings.ERROR_ALERT, - f"Could not reach Dotflow Cloud: {last_error}", - ) - return None - - def _start_device(self, base_url: str) -> dict: - response = post(f"{base_url}{DEVICE_ENDPOINT}", timeout=TIMEOUT) - response.raise_for_status() - - return response.json() + 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 From 209c47a13437bad5c7983093dc7c0678ad2d93da Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Wed, 15 Apr 2026 01:26:35 -0300 Subject: [PATCH 21/28] =?UTF-8?q?=F0=9F=8E=A8=20STYLE-#269:=20Drop=20blank?= =?UTF-8?q?=20line=20after=20LogCommand=20class=20declaration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/cli/commands/log.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dotflow/cli/commands/log.py b/dotflow/cli/commands/log.py index 0c404c0e..b447aeba 100644 --- a/dotflow/cli/commands/log.py +++ b/dotflow/cli/commands/log.py @@ -8,7 +8,6 @@ class LogCommand(Command): - def setup(self): if settings.LOG_PATH.exists(): print(read_file(path=settings.LOG_PATH)) From 9646bb0a784b6d8a41e432e082269528bc412c92 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Wed, 15 Apr 2026 01:26:39 -0300 Subject: [PATCH 22/28] =?UTF-8?q?=F0=9F=8E=A8=20STYLE-#269:=20Drop=20blank?= =?UTF-8?q?=20line=20after=20ScheduleCommand=20class=20declaration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/cli/commands/schedule.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dotflow/cli/commands/schedule.py b/dotflow/cli/commands/schedule.py index 16f19b20..7f41a93d 100644 --- a/dotflow/cli/commands/schedule.py +++ b/dotflow/cli/commands/schedule.py @@ -12,7 +12,6 @@ class ScheduleCommand(Command): - def setup(self): workflow = self._new_workflow() From 789da8bc0e3233152ea0e5795eb68d4aa140f8d2 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Wed, 15 Apr 2026 01:26:44 -0300 Subject: [PATCH 23/28] =?UTF-8?q?=F0=9F=8E=A8=20STYLE-#269:=20One-line=20m?= =?UTF-8?q?odule=20docstring=20in=20config=5Ffile.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/core/config_file.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dotflow/core/config_file.py b/dotflow/core/config_file.py index 47c33d32..b5828ff5 100644 --- a/dotflow/core/config_file.py +++ b/dotflow/core/config_file.py @@ -1,5 +1,4 @@ -"""Persistent CLI config stored at ``~/.dotflow/config.json``. -""" +"""Persistent CLI config stored at ``~/.dotflow/config.json``.""" from __future__ import annotations From b13710144b7b8aa890d3d8a24e4463664b82670a Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Wed, 15 Apr 2026 01:26:49 -0300 Subject: [PATCH 24/28] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20FEATURE-#269:=20Poin?= =?UTF-8?q?t=20ServerDefault=20endpoints=20to=20/cli/workflows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/providers/server_default.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/dotflow/providers/server_default.py b/dotflow/providers/server_default.py index f5d49574..f426735b 100644 --- a/dotflow/providers/server_default.py +++ b/dotflow/providers/server_default.py @@ -24,16 +24,15 @@ def wrapper(self, *args, **kwargs): class ServerDefault(Server): - """Default Server provider with auto-detected managed mode. - """ + """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.environ.get("SERVER_BASE_URL") or None From 42520e22f316cb8b50d3708d68e37d78e9b8fdee Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Wed, 15 Apr 2026 01:26:54 -0300 Subject: [PATCH 25/28] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20FEATURE-#269:=20Poin?= =?UTF-8?q?t=20login=20device/token=20to=20/cli/*=20namespace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/cli/commands/login.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dotflow/cli/commands/login.py b/dotflow/cli/commands/login.py index f41d82c0..fc69aa7a 100644 --- a/dotflow/cli/commands/login.py +++ b/dotflow/cli/commands/login.py @@ -13,9 +13,9 @@ from dotflow.core.config_file import save_cloud_config from dotflow.settings import Settings as settings -DEFAULT_BASE_URL = "https://www.cli.dotflow.io/api/v1" -DEVICE_ENDPOINT = "/auth/cli/device" -TOKEN_ENDPOINT = "/auth/cli/token" +DEFAULT_BASE_URL = "https://www.host.dotflow.io/api/v1" +DEVICE_ENDPOINT = "/cli/device" +TOKEN_ENDPOINT = "/cli/token" TIMEOUT = 15 DEFAULT_INTERVAL = 5 From a879e03e6d53988410696a8128aa1b7de7d03d51 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Wed, 15 Apr 2026 01:27:00 -0300 Subject: [PATCH 26/28] =?UTF-8?q?=F0=9F=8E=A8=20STYLE-#269:=20Ruff=20forma?= =?UTF-8?q?t=20on=20test=5Fconfig=5Ffile.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/core/test_config_file.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/core/test_config_file.py b/tests/core/test_config_file.py index 3ba7127a..23d30941 100644 --- a/tests/core/test_config_file.py +++ b/tests/core/test_config_file.py @@ -40,10 +40,10 @@ 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') + 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' + assert data["base_url"] == "url with space" def test_ignores_other_sections(self, tmp_home): import json From 787b492fe7cd640fe67720dcd27b9638ba7bc547 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Wed, 15 Apr 2026 01:29:45 -0300 Subject: [PATCH 27/28] =?UTF-8?q?=F0=9F=8E=A8=20STYLE-#269:=20Ruff=20forma?= =?UTF-8?q?t=20on=20login.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/cli/commands/login.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/dotflow/cli/commands/login.py b/dotflow/cli/commands/login.py index fc69aa7a..bd7f12ff 100644 --- a/dotflow/cli/commands/login.py +++ b/dotflow/cli/commands/login.py @@ -21,7 +21,6 @@ class LoginCommand(Command): - def setup(self): token = getattr(self.params, "token", None) base_url = self._resolve_base_url() @@ -97,9 +96,9 @@ def _poll_token(self, base_url: str, handshake: dict) -> str | None: time.sleep(interval) continue - if ( - response.status_code == 400 - and detail in ("slow_down", "slow down") + if response.status_code == 400 and detail in ( + "slow_down", + "slow down", ): interval += 5 time.sleep(interval) From c4edcd79d72f8aab1f7384f2941ea1f4d27d4d55 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Wed, 15 Apr 2026 01:37:54 -0300 Subject: [PATCH 28/28] =?UTF-8?q?=F0=9F=AA=B2=20BUG-#269:=20Cap=20slow=5Fd?= =?UTF-8?q?own=20interval=20growth=20at=2060=20seconds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dotflow/cli/commands/login.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotflow/cli/commands/login.py b/dotflow/cli/commands/login.py index bd7f12ff..8aeca7f7 100644 --- a/dotflow/cli/commands/login.py +++ b/dotflow/cli/commands/login.py @@ -18,6 +18,7 @@ TOKEN_ENDPOINT = "/cli/token" TIMEOUT = 15 DEFAULT_INTERVAL = 5 +MAX_POLL_INTERVAL = 60 class LoginCommand(Command): @@ -100,7 +101,7 @@ def _poll_token(self, base_url: str, handshake: dict) -> str | None: "slow_down", "slow down", ): - interval += 5 + interval = min(interval + 5, MAX_POLL_INTERVAL) time.sleep(interval) continue