From e41c5cc6a24d2c554acff9cc2c53ee22d08da92b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 25 Jan 2026 22:58:43 +0000 Subject: [PATCH] feat: add interactive input validation for CLI - Add `get_validated_input` helper for robust interactive prompts. - Validate Profile ID format immediately during input. - Prevent empty API Token input. - Refactor `validate_profile_id` to separate logic from logging. - Remove duplicate `MAX_RESPONSE_SIZE` constant. - Update `palette.md` with UX learnings. --- .Jules/palette.md | 4 +++ main.py | 64 ++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/.Jules/palette.md b/.Jules/palette.md index 532c04f4..52fd209e 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -9,3 +9,7 @@ ## 2024-05-24 - Fail Fast & Friendly **Learning:** In CLI tools involving APIs, cascade failures (hundreds of "Failed to X") caused by basic auth issues (401/403) are overwhelming and confusing. A dedicated "Pre-flight Check" that validates credentials *before* attempting the main workload allows for specific, actionable error messages (e.g. "Check your token at [URL]") instead of generic HTTP errors. **Action:** Implement a `check_api_access()` step at the start of any CLI workflow to validate permissions and provide human-readable guidance on failure. + +## 2024-05-25 - Interactive Input Validation +**Learning:** In CLI interactive modes, validating input immediately (looping until valid) prevents user frustration from waiting for a script to start only to crash later. It turns a "Run -> Error -> Restart" cycle into a "Prompt -> Typo -> Correction -> Success" flow. +**Action:** Always implement `get_validated_input` loops for critical configuration prompts in interactive CLI sessions. diff --git a/main.py b/main.py index c7810580..3650983b 100644 --- a/main.py +++ b/main.py @@ -23,9 +23,10 @@ import concurrent.futures import ipaddress import socket +import getpass from functools import lru_cache from urllib.parse import urlparse -from typing import Dict, List, Optional, Any, Set, Sequence +from typing import Dict, List, Optional, Any, Set, Sequence, Callable import httpx from dotenv import load_dotenv @@ -140,6 +141,25 @@ def _clean_env_kv(value: Optional[str], key: str) -> Optional[str]: return v +def get_validated_input( + prompt: str, + validator: Callable[[str], bool], + error_msg: str, + is_password: bool = False +) -> str: + """Prompts for input until the validator returns True.""" + while True: + if is_password: + value = getpass.getpass(prompt).strip() + else: + value = input(prompt).strip() + + if validator(value): + return value + + print(f"{Colors.FAIL}❌ {error_msg}{Colors.ENDC}") + + TOKEN = _clean_env_kv(os.getenv("TOKEN"), "TOKEN") # Default folder sources @@ -189,7 +209,6 @@ def _api_client() -> httpx.Client: ) _gh = httpx.Client(timeout=30) -MAX_RESPONSE_SIZE = 10 * 1024 * 1024 # 10 MB limit for external resources # --------------------------------------------------------------------------- # # 3. Helpers @@ -247,12 +266,19 @@ def validate_folder_url(url: str) -> bool: return True -def validate_profile_id(profile_id: str) -> bool: +def is_valid_profile_id_format(profile_id: str) -> bool: if not re.match(r"^[a-zA-Z0-9_-]+$", profile_id): - log.error("Invalid profile ID format (contains unsafe characters)") return False if len(profile_id) > 64: - log.error("Invalid profile ID length (max 64 chars)") + return False + return True + +def validate_profile_id(profile_id: str) -> bool: + if not is_valid_profile_id_format(profile_id): + if not re.match(r"^[a-zA-Z0-9_-]+$", profile_id): + log.error("Invalid profile ID format (contains unsafe characters)") + elif len(profile_id) > 64: + log.error("Invalid profile ID length (max 64 chars)") return False return True @@ -881,17 +907,31 @@ def main(): if not profile_ids: print(f"{Colors.CYAN}ℹ Profile ID is missing.{Colors.ENDC}") print(f"{Colors.CYAN} You can find this in the URL of your profile in the Control D Dashboard.{Colors.ENDC}") - p_input = input(f"{Colors.BOLD}Enter Control D Profile ID:{Colors.ENDC} ").strip() - if p_input: - profile_ids = [p.strip() for p in p_input.split(",") if p.strip()] + + def validate_profile_input(value: str) -> bool: + if not value: + return False + ids = [p.strip() for p in value.split(",") if p.strip()] + return bool(ids) and all(is_valid_profile_id_format(pid) for pid in ids) + + p_input = get_validated_input( + f"{Colors.BOLD}Enter Control D Profile ID:{Colors.ENDC} ", + validate_profile_input, + "Invalid ID(s). Must be alphanumeric (including - and _), max 64 chars. Comma-separate for multiple." + ) + profile_ids = [p.strip() for p in p_input.split(",") if p.strip()] if not TOKEN: print(f"{Colors.CYAN}ℹ API Token is missing.{Colors.ENDC}") print(f"{Colors.CYAN} You can generate one at: https://controld.com/account/manage-account{Colors.ENDC}") - import getpass - t_input = getpass.getpass(f"{Colors.BOLD}Enter Control D API Token:{Colors.ENDC} ").strip() - if t_input: - TOKEN = t_input + + t_input = get_validated_input( + f"{Colors.BOLD}Enter Control D API Token:{Colors.ENDC} ", + lambda x: bool(x), + "Token cannot be empty.", + is_password=True + ) + TOKEN = t_input if not profile_ids and not args.dry_run: log.error("PROFILE missing and --dry-run not set. Provide --profiles or set PROFILE env.")