Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a69bda1
⚙️ FEATURE-#269: Add config_file module with env-over-file token reso…
FernandoCelmer Apr 15, 2026
6de87d4
⚙️ FEATURE-#269: Add dotflow login with device flow and base URL fall…
FernandoCelmer Apr 15, 2026
ed23983
⚙️ FEATURE-#269: Add dotflow logout to clear the saved CLI config
FernandoCelmer Apr 15, 2026
6dd2153
⚙️ FEATURE-#269: Export LoginCommand and LogoutCommand from cli commands
FernandoCelmer Apr 15, 2026
6f3e8f3
⚙️ FEATURE-#269: Wire login and logout subcommands in the parser
FernandoCelmer Apr 15, 2026
3eb6124
⚙️ FEATURE-#269: Resolve server credentials via env-over-file precedence
FernandoCelmer Apr 15, 2026
e608c91
❤️ TEST-#269: Cover config file roundtrip, perms, and env-over-file r…
FernandoCelmer Apr 15, 2026
483d521
❤️ TEST-#269: Cover login device flow, base URL fallback, and logout
FernandoCelmer Apr 15, 2026
7048722
🪲 BUG-#269: Create CLI config atomically with 0600 perms
FernandoCelmer Apr 15, 2026
3294886
🪲 BUG-#269: Honor RFC 8628 slow_down signal on CLI poll
FernandoCelmer Apr 15, 2026
42dd723
⚙️ FEATURE-#269: Switch CLI config from TOML to JSON
FernandoCelmer Apr 15, 2026
c5b63d7
❤️ TEST-#269: Update config file tests for JSON format
FernandoCelmer Apr 15, 2026
345a1f8
❤️ TEST-#269: Align login tests with JSON config format
FernandoCelmer Apr 15, 2026
49ae9d6
⚙️ FEATURE-#269: Isolate ServerDefault to env vars only
FernandoCelmer Apr 15, 2026
64d11a3
✨ STYLE-#269: Blank line after LogCommand class declaration
FernandoCelmer Apr 15, 2026
34b7427
✨ STYLE-#269: Blank line after LoginCommand class declaration
FernandoCelmer Apr 15, 2026
3e96488
✨ STYLE-#269: Blank line after ScheduleCommand class declaration
FernandoCelmer Apr 15, 2026
db1304e
⚙️ FEATURE-#269: Use single CLI base URL (drop host/lib fallback)
FernandoCelmer Apr 15, 2026
ec24648
❤️ TEST-#269: Remove base URL fallback test
FernandoCelmer Apr 15, 2026
1bb16c3
⚙️ FEATURE-#269: Collapse login base URL to single DEFAULT_BASE_URL
FernandoCelmer Apr 15, 2026
209c47a
🎨 STYLE-#269: Drop blank line after LogCommand class declaration
FernandoCelmer Apr 15, 2026
9646bb0
🎨 STYLE-#269: Drop blank line after ScheduleCommand class declaration
FernandoCelmer Apr 15, 2026
789da8b
🎨 STYLE-#269: One-line module docstring in config_file.py
FernandoCelmer Apr 15, 2026
b137101
⚙️ FEATURE-#269: Point ServerDefault endpoints to /cli/workflows
FernandoCelmer Apr 15, 2026
42520e2
⚙️ FEATURE-#269: Point login device/token to /cli/* namespace
FernandoCelmer Apr 15, 2026
a879e03
🎨 STYLE-#269: Ruff format on test_config_file.py
FernandoCelmer Apr 15, 2026
787b492
🎨 STYLE-#269: Ruff format on login.py
FernandoCelmer Apr 15, 2026
c4edcd7
🪲 BUG-#269: Cap slow_down interval growth at 60 seconds
FernandoCelmer Apr 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions dotflow/cli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -13,6 +15,8 @@
"DeployCommand",
"InitCommand",
"LogCommand",
"LoginCommand",
"LogoutCommand",
"ScheduleCommand",
"StartCommand",
]
123 changes: 123 additions & 0 deletions dotflow/cli/commands/login.py
Original file line number Diff line number Diff line change
@@ -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

Comment thread
FernandoCelmer marked this conversation as resolved.
if response.status_code == 410:
print(settings.ERROR_ALERT, "Authorization expired.")
return None

print(settings.ERROR_ALERT, f"{response.status_code}: {detail}")
return None

Comment thread
FernandoCelmer marked this conversation as resolved.
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
15 changes: 15 additions & 0 deletions dotflow/cli/commands/logout.py
Original file line number Diff line number Diff line change
@@ -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.")
27 changes: 27 additions & 0 deletions dotflow/cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
DeployCommand,
InitCommand,
LogCommand,
LoginCommand,
LogoutCommand,
ScheduleCommand,
StartCommand,
)
Expand Down Expand Up @@ -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"
Expand Down
95 changes: 95 additions & 0 deletions dotflow/core/config_file.py
Original file line number Diff line number Diff line change
@@ -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)

Comment thread
FernandoCelmer marked this conversation as resolved.
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
27 changes: 10 additions & 17 deletions dotflow/providers/server_default.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""ServerDefault — no-op or managed HTTP server provider."""
"""ServerDefault - managed HTTP server provider."""

from __future__ import annotations

Expand All @@ -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
Expand All @@ -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)

Expand Down
Loading
Loading