Skip to content

Commit e471d09

Browse files
committed
add password auth, clearer setup prompts
1 parent ceb01c1 commit e471d09

5 files changed

Lines changed: 80 additions & 26 deletions

File tree

‎o2switch_cli/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__all__ = ["__version__"]
22

3-
__version__ = "0.1.1"
3+
__version__ = "0.1.2"

‎o2switch_cli/cli/config_cmd.py‎

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ def init_config(
6565
path: Path | None = typer.Option(None, "--path", help="Write credentials to this file. Defaults to global config."),
6666
cpanel_host: str | None = typer.Option(None, "--cpanel-host", help="cPanel host, for example saule.o2switch.net."),
6767
cpanel_user: str | None = typer.Option(None, "--cpanel-user", help="cPanel username."),
68-
cpanel_token: str | None = typer.Option(None, "--cpanel-token", help="cPanel API token."),
68+
cpanel_token: str | None = typer.Option(None, "--cpanel-token", help="cPanel API token (if using token auth)."),
69+
cpanel_password: str | None = typer.Option(None, "--cpanel-password", help="cPanel password (if using password auth)."),
70+
use_password: bool = typer.Option(False, "--password", "-p", help="Use password instead of API token."),
6971
default_ttl: int | None = typer.Option(None, "--default-ttl", help="Default TTL to write into the env file."),
7072
audit_log_path: str | None = typer.Option(
7173
None,
@@ -78,6 +80,13 @@ def init_config(
7880
force: bool = typer.Option(False, "--force", help="Overwrite an existing env file without confirmation."),
7981
test_api: bool = typer.Option(False, "--test-api/--no-test-api", help="Test cPanel API access after writing."),
8082
) -> None:
83+
"""Configure cPanel credentials.
84+
85+
Examples:
86+
o2switch-cli config setup # Interactive setup
87+
o2switch-cli config setup --password # Use password instead of token
88+
o2switch-cli config setup --cpanel-user xxx # Pre-fill username
89+
"""
8190
def action(app_context):
8291
ui = TerminalUI(app_context.console, app_context.output_format)
8392
current = app_context.settings
@@ -90,9 +99,22 @@ def action(app_context):
9099
elif target.exists() and not force and non_interactive:
91100
raise ValidationAppError("config_init", f"{target} already exists. Use --force to overwrite.", str(target))
92101

102+
# Determine auth method
103+
auth_method = "password" if use_password or cpanel_password else current.auth_method
104+
if not non_interactive and not cpanel_token and not cpanel_password:
105+
auth_choice = questionary.select(
106+
"Authentication method:",
107+
choices=[
108+
{"name": "Password (cPanel login password)", "value": "password"},
109+
{"name": "API Token (generate in cPanel > Security > Manage API Tokens)", "value": "token"},
110+
],
111+
default="password",
112+
).ask()
113+
auth_method = auth_choice or "password"
114+
93115
host = cpanel_host or current.cpanel_host
94116
user = cpanel_user or current.cpanel_user
95-
token = cpanel_token or (current.cpanel_token.get_secret_value() if current.cpanel_token else None)
117+
secret = cpanel_password or cpanel_token or (current.cpanel_token.get_secret_value() if current.cpanel_token else None)
96118
ttl = default_ttl if default_ttl is not None else current.default_ttl
97119
audit_path = (
98120
audit_log_path
@@ -101,36 +123,59 @@ def action(app_context):
101123
)
102124

103125
if not non_interactive:
104-
host = host or questionary.text("cPanel host", default=current.cpanel_host or "").ask()
105-
user = user or questionary.text("cPanel user", default=current.cpanel_user or "").ask()
106-
token = token or questionary.password("cPanel API token").ask()
107-
ttl_text = questionary.text("Default TTL", default=str(ttl)).ask() or str(ttl)
108-
try:
109-
ttl = int(ttl_text)
110-
except ValueError as exc:
111-
raise ValidationAppError("config_init", "Default TTL must be an integer.", str(target)) from exc
112-
audit_path = questionary.text("Audit log path", default=audit_path).ask() or audit_path
126+
user = user or questionary.text("cPanel username").ask()
127+
# o2switch server selection
128+
if not host:
129+
ui.console.print("\n[dim]Your o2switch server name is shown in your cPanel URL:[/]")
130+
ui.console.print("[dim] https://[bold]SERVER[/].o2switch.net:2083[/]\n")
131+
server_name = questionary.text(
132+
"o2switch server name",
133+
instruction="(just the name: saule, if, herse, etc.)"
134+
).ask()
135+
if server_name:
136+
server_name = server_name.strip().lower()
137+
if not server_name.endswith(".o2switch.net"):
138+
host = f"{server_name}.o2switch.net"
139+
else:
140+
host = server_name
141+
else:
142+
host = questionary.text("cPanel server", default=host).ask()
143+
144+
if auth_method == "password":
145+
secret = secret or questionary.password("cPanel password").ask()
146+
else:
147+
ui.console.print("\n[dim]Generate a token in cPanel:[/]")
148+
ui.console.print("[dim] Security > Manage API Tokens > Create[/]\n")
149+
secret = secret or questionary.password("cPanel API token").ask()
113150

114151
host = host.strip() if host else None
115152
user = user.strip() if user else None
116-
token = token.strip() if token else None
153+
secret = secret.strip() if secret else None
117154
audit_path = (
118155
audit_path.strip()
119156
if isinstance(audit_path, str) and audit_path.strip()
120157
else default_audit_log_path()
121158
)
122159

123-
if not host or not user or not token:
160+
if not host or not user or not secret:
161+
missing = []
162+
if not host:
163+
missing.append("host")
164+
if not user:
165+
missing.append("user")
166+
if not secret:
167+
missing.append("password" if auth_method == "password" else "token")
124168
raise ValidationAppError(
125169
"config_init",
126-
"cpanel host, user, and token are required.",
170+
f"Missing required fields: {', '.join(missing)}",
127171
str(target),
128172
)
129173

130174
settings = AppSettings(
131175
cpanel_host=host,
132176
cpanel_user=user,
133-
cpanel_token=token,
177+
cpanel_token=secret,
178+
auth_method=auth_method,
134179
port=current.port,
135180
timeout_seconds=current.timeout_seconds,
136181
default_ttl=ttl,
@@ -141,13 +186,12 @@ def action(app_context):
141186
)
142187
written = write_env_file(target, settings)
143188
ui.print_mapping(
144-
"Setup Written",
189+
"Setup Complete",
145190
{
146-
"path": str(written),
191+
"config_file": str(written),
147192
"cpanel_host": host,
148193
"cpanel_user": user,
149-
"default_ttl": ttl,
150-
"audit_log_path": audit_path,
194+
"auth_method": auth_method,
151195
},
152196
)
153197

‎o2switch_cli/config/settings.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ class AppSettings(BaseSettings):
5050

5151
cpanel_host: str | None = None
5252
cpanel_user: str | None = None
53-
cpanel_token: SecretStr | None = None
53+
cpanel_token: SecretStr | None = None # token or password depending on auth_method
54+
auth_method: Literal["token", "password"] = "token"
5455
port: int = 2083
5556
timeout_seconds: float = 20.0
5657
default_ttl: int = 300
@@ -115,10 +116,11 @@ def _dotenv_value(value: Any) -> str:
115116
def render_env_file(settings: AppSettings) -> str:
116117
token = settings.cpanel_token.get_secret_value() if settings.cpanel_token else None
117118
lines = [
118-
"# Generated by o2switch-cli config init",
119+
"# Generated by o2switch-cli config setup",
119120
f"O2SWITCH_CLI_CPANEL_HOST={_dotenv_value(settings.cpanel_host)}",
120121
f"O2SWITCH_CLI_CPANEL_USER={_dotenv_value(settings.cpanel_user)}",
121122
f"O2SWITCH_CLI_CPANEL_TOKEN={_dotenv_value(token)}",
123+
f"O2SWITCH_CLI_AUTH_METHOD={_dotenv_value(settings.auth_method)}",
122124
f"O2SWITCH_CLI_PORT={_dotenv_value(settings.port)}",
123125
f"O2SWITCH_CLI_TIMEOUT_SECONDS={_dotenv_value(settings.timeout_seconds)}",
124126
f"O2SWITCH_CLI_DEFAULT_TTL={_dotenv_value(settings.default_ttl)}",

‎o2switch_cli/core/auth.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import base64
4+
35
import questionary
46
from pydantic import SecretStr
57

@@ -24,5 +26,10 @@ def ensure_credentials(settings: AppSettings, *, allow_prompt: bool) -> AppSetti
2426
return merged
2527

2628

27-
def auth_header(user: str, token: SecretStr) -> dict[str, str]:
28-
return {"Authorization": f"cpanel {user}:{token.get_secret_value()}"}
29+
def auth_header(user: str, token: SecretStr, *, use_basic: bool = False) -> dict[str, str]:
30+
"""Generate auth header. use_basic=True for password auth."""
31+
secret = token.get_secret_value()
32+
if use_basic:
33+
encoded = base64.b64encode(f"{user}:{secret}".encode()).decode()
34+
return {"Authorization": f"Basic {encoded}"}
35+
return {"Authorization": f"cpanel {user}:{secret}"}

‎o2switch_cli/core/cpanel_client.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ def __init__(self, settings: AppSettings, client: httpx.Client | None = None) ->
1616
if not settings.cpanel_user:
1717
raise AuthAppError("cpanel_client_init", "cpanel_user is required but not configured.")
1818
self._settings = settings
19+
use_basic = settings.auth_method == "password"
1920
headers = {
20-
**auth_header(settings.cpanel_user, settings.cpanel_token), # type: ignore[arg-type]
21-
"User-Agent": "o2switch-cli/0.1.0",
21+
**auth_header(settings.cpanel_user, settings.cpanel_token, use_basic=use_basic), # type: ignore[arg-type]
22+
"User-Agent": "o2switch-cli/0.1.2",
2223
"Accept": "application/json",
2324
}
2425
self._client = client or httpx.Client(

0 commit comments

Comments
 (0)