|
| 1 | +"""Init command: First-time setup wizard for UnityAuth CLI. |
| 2 | +
|
| 3 | +Guides users through initial configuration and authentication. |
| 4 | +""" |
| 5 | + |
| 6 | +import sys |
| 7 | + |
| 8 | +import click |
| 9 | +import requests |
| 10 | + |
| 11 | +from unityauth_cli.cli import CLIContext, console, error, info, pass_context, success, warning |
| 12 | +from unityauth_cli.config import Configuration |
| 13 | +from unityauth_cli.utils.validation import validate_url, validate_email |
| 14 | + |
| 15 | + |
| 16 | +@click.command() |
| 17 | +@click.option( |
| 18 | + '--api-url', |
| 19 | + help='UnityAuth API endpoint URL (skip prompt)', |
| 20 | +) |
| 21 | +@click.option( |
| 22 | + '--skip-login', |
| 23 | + is_flag=True, |
| 24 | + help='Skip the login step after configuration', |
| 25 | +) |
| 26 | +@click.option( |
| 27 | + '--allow-http', |
| 28 | + is_flag=True, |
| 29 | + help='Allow insecure HTTP URLs (not recommended)', |
| 30 | +) |
| 31 | +@pass_context |
| 32 | +def init(ctx: CLIContext, api_url: str | None, skip_login: bool, allow_http: bool) -> None: |
| 33 | + """Initialize UnityAuth CLI with first-time setup wizard. |
| 34 | +
|
| 35 | + Guides you through configuring the API endpoint and optionally |
| 36 | + authenticating with your credentials. |
| 37 | +
|
| 38 | + \b |
| 39 | + Examples: |
| 40 | + unityauth init # Interactive setup |
| 41 | + unityauth init --api-url https://auth.example.com # Skip URL prompt |
| 42 | + unityauth init --skip-login # Configure only, no login |
| 43 | + """ |
| 44 | + console.print() |
| 45 | + console.print("[bold cyan]Welcome to UnityAuth CLI![/bold cyan]") |
| 46 | + console.print("Let's get you set up.\n") |
| 47 | + |
| 48 | + # Step 1: Get API URL |
| 49 | + if not api_url: |
| 50 | + if not sys.stdin.isatty(): |
| 51 | + error( |
| 52 | + "API URL required in non-interactive mode", |
| 53 | + "Use --api-url option to specify the UnityAuth API endpoint" |
| 54 | + ) |
| 55 | + sys.exit(4) |
| 56 | + |
| 57 | + # Show current value if configured |
| 58 | + current_url = ctx.config.get('api_url') if ctx.config else None |
| 59 | + if current_url: |
| 60 | + info(f"Current API URL: {current_url}") |
| 61 | + |
| 62 | + api_url = click.prompt( |
| 63 | + 'API URL', |
| 64 | + default=current_url or 'https://auth.example.com', |
| 65 | + type=str |
| 66 | + ) |
| 67 | + |
| 68 | + # Validate URL format |
| 69 | + api_url = api_url.strip().rstrip('/') |
| 70 | + |
| 71 | + if not validate_url(api_url, require_https=not allow_http): |
| 72 | + if not allow_http and api_url.startswith('http://'): |
| 73 | + error( |
| 74 | + "HTTP URLs are not allowed by default (insecure)", |
| 75 | + "Use HTTPS or add --allow-http flag if this is intentional" |
| 76 | + ) |
| 77 | + else: |
| 78 | + error( |
| 79 | + "Invalid URL format", |
| 80 | + "URL must start with https:// (e.g., https://auth.example.com)" |
| 81 | + ) |
| 82 | + sys.exit(4) |
| 83 | + |
| 84 | + # Step 2: Test connection |
| 85 | + info(f"Testing connection to {api_url}...") |
| 86 | + |
| 87 | + try: |
| 88 | + # Try to reach the /keys endpoint (public, no auth required) |
| 89 | + response = requests.get(f"{api_url}/keys", timeout=10) |
| 90 | + |
| 91 | + if response.ok: |
| 92 | + success("Connection successful") |
| 93 | + if ctx.verbose: |
| 94 | + info(f"Server responded with status {response.status_code}") |
| 95 | + else: |
| 96 | + # Server responded but with error |
| 97 | + warning(f"Server responded with status {response.status_code}") |
| 98 | + if not click.confirm("Continue anyway?", default=False): |
| 99 | + info("Setup cancelled") |
| 100 | + sys.exit(0) |
| 101 | + |
| 102 | + except requests.ConnectionError: |
| 103 | + error( |
| 104 | + f"Could not connect to {api_url}", |
| 105 | + "Check that the URL is correct and the server is running" |
| 106 | + ) |
| 107 | + if not click.confirm("Save configuration anyway?", default=False): |
| 108 | + info("Setup cancelled") |
| 109 | + sys.exit(0) |
| 110 | + except requests.Timeout: |
| 111 | + warning("Connection timed out") |
| 112 | + if not click.confirm("Save configuration anyway?", default=False): |
| 113 | + info("Setup cancelled") |
| 114 | + sys.exit(0) |
| 115 | + except requests.RequestException as e: |
| 116 | + warning(f"Connection test failed: {e}") |
| 117 | + if not click.confirm("Save configuration anyway?", default=False): |
| 118 | + info("Setup cancelled") |
| 119 | + sys.exit(0) |
| 120 | + |
| 121 | + # Step 3: Save configuration |
| 122 | + try: |
| 123 | + config = ctx.config or Configuration() |
| 124 | + config.set('api_url', api_url) |
| 125 | + config.save() |
| 126 | + success(f"Configuration saved to {config.config_path}") |
| 127 | + except Exception as e: |
| 128 | + error(f"Failed to save configuration: {e}") |
| 129 | + sys.exit(4) |
| 130 | + |
| 131 | + # Step 4: Optionally login |
| 132 | + if not skip_login: |
| 133 | + console.print() |
| 134 | + if sys.stdin.isatty() and click.confirm("Would you like to log in now?", default=True): |
| 135 | + console.print() |
| 136 | + _do_login(api_url, config, ctx.verbose) |
| 137 | + else: |
| 138 | + info("Skipping login. Run 'unityauth login' when ready.") |
| 139 | + |
| 140 | + # Step 5: Show next steps |
| 141 | + console.print() |
| 142 | + console.print("[bold green]Setup complete![/bold green]") |
| 143 | + console.print() |
| 144 | + console.print("Next steps:") |
| 145 | + console.print(" [dim]$[/dim] unityauth tenant list [dim]# List your tenants[/dim]") |
| 146 | + console.print(" [dim]$[/dim] unityauth user list --tenant-id 1 [dim]# List users in tenant 1[/dim]") |
| 147 | + console.print(" [dim]$[/dim] unityauth role list [dim]# List available roles[/dim]") |
| 148 | + console.print(" [dim]$[/dim] unityauth --help [dim]# See all commands[/dim]") |
| 149 | + console.print() |
| 150 | + |
| 151 | + |
| 152 | +def _do_login(api_url: str, config: Configuration, verbose: bool) -> None: |
| 153 | + """Perform login as part of init wizard. |
| 154 | +
|
| 155 | + Args: |
| 156 | + api_url: API endpoint URL |
| 157 | + config: Configuration instance |
| 158 | + verbose: Whether to show verbose output |
| 159 | + """ |
| 160 | + from unityauth_cli import auth |
| 161 | + from unityauth_cli.client import UnityAuthAPIClient |
| 162 | + |
| 163 | + # Get email |
| 164 | + email = click.prompt('Email', type=str) |
| 165 | + |
| 166 | + if not validate_email(email): |
| 167 | + error("Invalid email format") |
| 168 | + return |
| 169 | + |
| 170 | + # Get password |
| 171 | + password = click.prompt('Password', hide_input=True, type=str) |
| 172 | + |
| 173 | + # Attempt login |
| 174 | + if verbose: |
| 175 | + info(f"Authenticating with {api_url}...") |
| 176 | + |
| 177 | + try: |
| 178 | + timeout = config.get('timeout', 30) |
| 179 | + client = UnityAuthAPIClient(api_url, timeout=timeout) |
| 180 | + response = client.post('/api/login', data={ |
| 181 | + 'username': email, |
| 182 | + 'password': password |
| 183 | + }) |
| 184 | + |
| 185 | + # Extract token from response |
| 186 | + token = response.get('access_token') or response.get('accessToken') |
| 187 | + if not token: |
| 188 | + error("Login failed: No token in response") |
| 189 | + return |
| 190 | + |
| 191 | + # Store token in keyring |
| 192 | + auth.store_token(api_url, token) |
| 193 | + success(f"Logged in as {email}") |
| 194 | + |
| 195 | + except Exception as e: |
| 196 | + error(f"Login failed: {e}") |
| 197 | + info("You can try again later with 'unityauth login'") |
0 commit comments