-
Notifications
You must be signed in to change notification settings - Fork 8
⚙️ FEATURE-#269: Add dotflow login with device flow and config file #271
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 6de87d4
⚙️ FEATURE-#269: Add dotflow login with device flow and base URL fall…
FernandoCelmer ed23983
⚙️ FEATURE-#269: Add dotflow logout to clear the saved CLI config
FernandoCelmer 6dd2153
⚙️ FEATURE-#269: Export LoginCommand and LogoutCommand from cli commands
FernandoCelmer 6f3e8f3
⚙️ FEATURE-#269: Wire login and logout subcommands in the parser
FernandoCelmer 3eb6124
⚙️ FEATURE-#269: Resolve server credentials via env-over-file precedence
FernandoCelmer e608c91
❤️ TEST-#269: Cover config file roundtrip, perms, and env-over-file r…
FernandoCelmer 483d521
❤️ TEST-#269: Cover login device flow, base URL fallback, and logout
FernandoCelmer 7048722
🪲 BUG-#269: Create CLI config atomically with 0600 perms
FernandoCelmer 3294886
🪲 BUG-#269: Honor RFC 8628 slow_down signal on CLI poll
FernandoCelmer 42dd723
⚙️ FEATURE-#269: Switch CLI config from TOML to JSON
FernandoCelmer c5b63d7
❤️ TEST-#269: Update config file tests for JSON format
FernandoCelmer 345a1f8
❤️ TEST-#269: Align login tests with JSON config format
FernandoCelmer 49ae9d6
⚙️ FEATURE-#269: Isolate ServerDefault to env vars only
FernandoCelmer 64d11a3
✨ STYLE-#269: Blank line after LogCommand class declaration
FernandoCelmer 34b7427
✨ STYLE-#269: Blank line after LoginCommand class declaration
FernandoCelmer 3e96488
✨ STYLE-#269: Blank line after ScheduleCommand class declaration
FernandoCelmer db1304e
⚙️ FEATURE-#269: Use single CLI base URL (drop host/lib fallback)
FernandoCelmer ec24648
❤️ TEST-#269: Remove base URL fallback test
FernandoCelmer 1bb16c3
⚙️ FEATURE-#269: Collapse login base URL to single DEFAULT_BASE_URL
FernandoCelmer 209c47a
🎨 STYLE-#269: Drop blank line after LogCommand class declaration
FernandoCelmer 9646bb0
🎨 STYLE-#269: Drop blank line after ScheduleCommand class declaration
FernandoCelmer 789da8b
🎨 STYLE-#269: One-line module docstring in config_file.py
FernandoCelmer b137101
⚙️ FEATURE-#269: Point ServerDefault endpoints to /cli/workflows
FernandoCelmer 42520e2
⚙️ FEATURE-#269: Point login device/token to /cli/* namespace
FernandoCelmer a879e03
🎨 STYLE-#269: Ruff format on test_config_file.py
FernandoCelmer 787b492
🎨 STYLE-#269: Ruff format on login.py
FernandoCelmer c4edcd7
🪲 BUG-#269: Cap slow_down interval growth at 60 seconds
FernandoCelmer File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
| if response.status_code == 410: | ||
| print(settings.ERROR_ALERT, "Authorization expired.") | ||
| return None | ||
|
|
||
| print(settings.ERROR_ALERT, f"{response.status_code}: {detail}") | ||
| return None | ||
|
|
||
|
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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
|
|
||
|
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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.